Field editor: palette, select/move/resize/rotate, inspector, optimistic sync (#11) (#30)
Build image / build-and-push (push) Successful in 13s

Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #30.
This commit is contained in:
2026-07-19 01:01:38 +00:00
committed by steve
parent 2119f1a376
commit b79bfcf7a9
13 changed files with 989 additions and 74 deletions
+222
View File
@@ -0,0 +1,222 @@
// Garden objects data layer: the /full editor payload plus optimistic
// create/update/delete mutations. Updates apply to the cache immediately and
// PATCH with the row version; a 409 rolls back to the server's current row and
// toasts (see DESIGN § Sync).
import { queryOptions, useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'
import { z } from 'zod'
import { ApiError, api } from './api'
import { gardenSchema } from './gardens'
import { toast } from '@/components/ui/toast'
import type { EditorObject, ObjectShapeKind } from '@/editor/types'
const shapeSchema = z.enum(['rect', 'circle', 'polygon'])
export const serverObjectSchema = z.object({
id: z.number(),
gardenId: z.number(),
kind: z.string(),
name: z.string(),
shape: shapeSchema,
xCm: z.number(),
yCm: z.number(),
widthCm: z.number(),
heightCm: z.number(),
rotationDeg: z.number(),
zIndex: z.number(),
plantable: z.boolean(),
color: z.string().nullable().optional(),
props: z.string().nullable().optional(),
notes: z.string(),
version: z.number(),
})
export type ServerObject = z.infer<typeof serverObjectSchema>
export const fullGardenSchema = z.object({
garden: gardenSchema,
objects: z.array(serverObjectSchema),
plantings: z.array(z.unknown()), // typed in #14
plants: z.array(z.unknown()), // typed in #12
})
export type FullGarden = z.infer<typeof fullGardenSchema>
/** Map a server object to the canvas's EditorObject (polygon never reaches the
* v1 editor; coerce it to rect defensively). */
export function toEditorObject(o: ServerObject): EditorObject {
const shape: ObjectShapeKind = o.shape === 'circle' ? 'circle' : 'rect'
return {
id: o.id,
kind: o.kind,
name: o.name,
shape,
xCm: o.xCm,
yCm: o.yCm,
widthCm: o.widthCm,
heightCm: o.heightCm,
rotationDeg: o.rotationDeg,
zIndex: o.zIndex,
color: o.color ?? null,
notes: o.notes,
plantable: o.plantable,
version: o.version,
}
}
const fullKey = (gardenId: number) => ['garden-full', gardenId] as const
export function gardenFullQueryOptions(gardenId: number) {
return queryOptions({
queryKey: fullKey(gardenId),
queryFn: async (): Promise<FullGarden> => fullGardenSchema.parse(await api.get(`/gardens/${gardenId}/full`)),
})
}
export function useGardenFull(gardenId: number) {
return useQuery(gardenFullQueryOptions(gardenId))
}
// --- mutations -------------------------------------------------------------
export interface ObjectCreate {
kind: string
name?: string
shape?: ObjectShapeKind
xCm: number
yCm: number
widthCm: number
heightCm: number
rotationDeg?: number
zIndex?: number
plantable?: boolean
color?: string | null
}
export function useCreateObject(gardenId: number) {
const qc = useQueryClient()
return useMutation({
mutationFn: async (input: ObjectCreate): Promise<ServerObject> =>
serverObjectSchema.parse(await api.post(`/gardens/${gardenId}/objects`, input)),
onSuccess: (created) => {
// Splice the created object into the cache (avoids a full refetch flash).
patchFullCache(qc, gardenId, (full) => ({ ...full, objects: [...full.objects, created] }))
},
onError: (err) => toast.error(objectErrorMessage(err, 'Could not add that object.')),
})
}
/** Fields the editor patches. version is required for the optimistic guard. */
export interface ObjectPatch {
id: number
version: number
name?: string
xCm?: number
yCm?: number
widthCm?: number
heightCm?: number
rotationDeg?: number
zIndex?: number
plantable?: boolean
color?: string | null
notes?: string
}
export function useUpdateObject(gardenId: number) {
const qc = useQueryClient()
return useMutation({
mutationFn: async ({ id, ...body }: ObjectPatch): Promise<ServerObject> =>
serverObjectSchema.parse(await api.patch(`/objects/${id}`, body)),
// Apply the change to the cache up front so the object doesn't snap back
// while the request is in flight. Snapshot for rollback.
onMutate: async (patch) => {
await qc.cancelQueries({ queryKey: fullKey(gardenId) })
const prev = qc.getQueryData<FullGarden>(fullKey(gardenId))
patchFullCache(qc, gardenId, (full) => ({
...full,
objects: full.objects.map((o) => (o.id === patch.id ? applyServerPatch(o, patch) : o)),
}))
return { prev }
},
onSuccess: (updated) => {
patchFullCache(qc, gardenId, (full) => ({
...full,
objects: full.objects.map((o) => (o.id === updated.id ? updated : o)),
}))
},
onError: (err, _patch, ctx) => {
const current = conflictObject(err)
if (current) {
// Someone else changed it: adopt the server's row and tell the user.
patchFullCache(qc, gardenId, (full) => ({
...full,
objects: full.objects.map((o) => (o.id === current.id ? current : o)),
}))
toast.error('That object was updated elsewhere — your change was rolled back.')
} else if (ctx?.prev) {
qc.setQueryData(fullKey(gardenId), ctx.prev)
toast.error(objectErrorMessage(err, 'Could not save that change.'))
}
},
// No onSettled refetch: onSuccess already writes the authoritative server
// row and onError reconciles. Invalidating here would fire a full-garden GET
// after every PATCH — a request storm during drags, and a refetch race that
// can clobber a fast follow-up edit.
})
}
export function useDeleteObject(gardenId: number) {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: number) => api.delete(`/objects/${id}`),
onMutate: async (id) => {
await qc.cancelQueries({ queryKey: fullKey(gardenId) })
const prev = qc.getQueryData<FullGarden>(fullKey(gardenId))
patchFullCache(qc, gardenId, (full) => ({ ...full, objects: full.objects.filter((o) => o.id !== id) }))
return { prev }
},
onError: (err, _id, ctx) => {
if (ctx?.prev) qc.setQueryData(fullKey(gardenId), ctx.prev)
toast.error(objectErrorMessage(err, 'Could not delete that object.'))
},
onSettled: () => qc.invalidateQueries({ queryKey: fullKey(gardenId) }),
})
}
// --- helpers ---------------------------------------------------------------
function patchFullCache(qc: QueryClient, gardenId: number, fn: (full: FullGarden) => FullGarden) {
qc.setQueryData<FullGarden>(fullKey(gardenId), (full) => (full ? fn(full) : full))
}
/** Apply a patch onto a cached server object for the optimistic pass. The
* version is bumped to mirror the server's post-commit increment, so a second
* edit started before the first PATCH resolves reads the next version instead
* of re-sending the stale one and self-conflicting (a spurious 409). */
function applyServerPatch(o: ServerObject, patch: ObjectPatch): ServerObject {
return {
...o,
version: o.version + 1,
...(patch.name !== undefined ? { name: patch.name } : {}),
...(patch.xCm !== undefined ? { xCm: patch.xCm } : {}),
...(patch.yCm !== undefined ? { yCm: patch.yCm } : {}),
...(patch.widthCm !== undefined ? { widthCm: patch.widthCm } : {}),
...(patch.heightCm !== undefined ? { heightCm: patch.heightCm } : {}),
...(patch.rotationDeg !== undefined ? { rotationDeg: patch.rotationDeg } : {}),
...(patch.zIndex !== undefined ? { zIndex: patch.zIndex } : {}),
...(patch.plantable !== undefined ? { plantable: patch.plantable } : {}),
...(patch.color !== undefined ? { color: patch.color } : {}),
...(patch.notes !== undefined ? { notes: patch.notes } : {}),
}
}
/** The current server row from a 409 conflict body, or null. */
function conflictObject(err: unknown): ServerObject | null {
if (err instanceof ApiError && err.isConflict && err.body && typeof err.body === 'object') {
const parsed = serverObjectSchema.safeParse((err.body as { current?: unknown }).current)
if (parsed.success) return parsed.data
}
return null
}
function objectErrorMessage(err: unknown, fallback: string): string {
return err instanceof ApiError ? err.message : fallback
}