Field editor: palette, select/move/resize/rotate, inspector, optimistic sync (#11) (#30)
Build image / build-and-push (push) Successful in 13s
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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user