Plant catalog UI: /plants page + PlantPicker (#13)
Build image / build-and-push (push) Successful in 15s
Build image / build-and-push (push) Successful in 15s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #32.
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
// Plant catalog data layer: zod-validated shapes for /api/v1/plants plus the
|
||||
// react-query hooks the /plants page and PlantPicker use. Built-ins (ownerId
|
||||
// null) are seeded and read-only; a user's own rows are editable. "Cloning" a
|
||||
// built-in is just a create of a copy (no special endpoint).
|
||||
|
||||
import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { z } from 'zod'
|
||||
import { ApiError, api } from './api'
|
||||
|
||||
export const PLANT_CATEGORIES = ['vegetable', 'herb', 'flower', 'fruit', 'tree_shrub', 'cover'] as const
|
||||
export type PlantCategory = (typeof PLANT_CATEGORIES)[number]
|
||||
|
||||
export const CATEGORY_LABELS: Record<PlantCategory, string> = {
|
||||
vegetable: 'Vegetable',
|
||||
herb: 'Herb',
|
||||
flower: 'Flower',
|
||||
fruit: 'Fruit',
|
||||
tree_shrub: 'Tree / shrub',
|
||||
cover: 'Cover crop',
|
||||
}
|
||||
|
||||
const plantCategorySchema = z.enum(PLANT_CATEGORIES)
|
||||
|
||||
export const plantSchema = z.object({
|
||||
id: z.number(),
|
||||
// Absent (built-in) or a user id. The API omits ownerId for built-ins.
|
||||
ownerId: z.number().nullable().optional(),
|
||||
name: z.string(),
|
||||
category: plantCategorySchema,
|
||||
spacingCm: z.number(),
|
||||
color: z.string(),
|
||||
icon: z.string(),
|
||||
daysToMaturity: z.number().nullable().optional(),
|
||||
notes: z.string(),
|
||||
version: z.number(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
})
|
||||
export type Plant = z.infer<typeof plantSchema>
|
||||
|
||||
/** A built-in (seeded, read-only) plant has no owner. */
|
||||
export function isBuiltin(p: Plant): boolean {
|
||||
return p.ownerId == null
|
||||
}
|
||||
|
||||
/** Category filter value: a specific category, or 'all'. Shared by the catalog
|
||||
* page and the PlantPicker so their chip rows and filtering stay in sync. */
|
||||
export type CategoryFilter = PlantCategory | 'all'
|
||||
|
||||
/** Filter a plant list by a name query (case-insensitive substring) and
|
||||
* category. The single source of truth for both the /plants grid and the
|
||||
* PlantPicker list. */
|
||||
export function filterPlants(plants: Plant[], query: string, category: CategoryFilter): Plant[] {
|
||||
const q = query.trim().toLowerCase()
|
||||
return plants.filter(
|
||||
(p) => (category === 'all' || p.category === category) && (q === '' || p.name.toLowerCase().includes(q)),
|
||||
)
|
||||
}
|
||||
|
||||
const plantsKey = ['plants'] as const
|
||||
|
||||
export const plantsQueryOptions = queryOptions({
|
||||
queryKey: plantsKey,
|
||||
queryFn: async (): Promise<Plant[]> => z.array(plantSchema).parse(await api.get('/plants')),
|
||||
})
|
||||
|
||||
export function usePlants() {
|
||||
return useQuery(plantsQueryOptions)
|
||||
}
|
||||
|
||||
export interface PlantInput {
|
||||
name: string
|
||||
category: PlantCategory
|
||||
spacingCm: number
|
||||
color: string
|
||||
icon: string
|
||||
daysToMaturity: number | null
|
||||
notes: string
|
||||
}
|
||||
|
||||
export function useCreatePlant() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (input: PlantInput): Promise<Plant> => plantSchema.parse(await api.post('/plants', input)),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: plantsKey }),
|
||||
})
|
||||
}
|
||||
|
||||
export interface PlantUpdate extends PlantInput {
|
||||
id: number
|
||||
version: number
|
||||
}
|
||||
|
||||
export function useUpdatePlant() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
// id/version travel with the mutation variables so a single hook serves any row.
|
||||
mutationFn: async ({ id, ...body }: PlantUpdate): Promise<Plant> =>
|
||||
plantSchema.parse(await api.patch(`/plants/${id}`, body)),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: plantsKey }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeletePlant() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => api.delete(`/plants/${id}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: plantsKey }),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* If err is a 409 version conflict, return the fresh server row it carries
|
||||
* (under `current`), so a form can rebase onto it; otherwise null.
|
||||
*/
|
||||
export function conflictPlant(err: unknown): Plant | null {
|
||||
if (err instanceof ApiError && err.isConflict && err.body && typeof err.body === 'object') {
|
||||
const parsed = plantSchema.safeParse((err.body as { current?: unknown }).current)
|
||||
if (parsed.success) return parsed.data
|
||||
}
|
||||
return null
|
||||
}
|
||||
Reference in New Issue
Block a user