Plops editor: focus mode, place/move/resize, semantic zoom (#15)
Build image / build-and-push (push) Successful in 14s
Build image / build-and-push (push) Successful in 14s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #34.
This commit is contained in:
+146
-2
@@ -7,6 +7,8 @@ import { queryOptions, useMutation, useQuery, useQueryClient, type QueryClient }
|
||||
import { z } from 'zod'
|
||||
import { ApiError, api } from './api'
|
||||
import { gardenSchema } from './gardens'
|
||||
import { plantSchema } from './plants'
|
||||
import { serverPlantingSchema, type ServerPlanting } from './plantings'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import type { EditorObject, ObjectShapeKind } from '@/editor/types'
|
||||
|
||||
@@ -35,8 +37,8 @@ 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
|
||||
plantings: z.array(serverPlantingSchema),
|
||||
plants: z.array(plantSchema),
|
||||
})
|
||||
export type FullGarden = z.infer<typeof fullGardenSchema>
|
||||
|
||||
@@ -181,12 +183,154 @@ export function useDeleteObject(gardenId: number) {
|
||||
})
|
||||
}
|
||||
|
||||
// --- planting (plop) mutations ---------------------------------------------
|
||||
// Plops are part of the FullGarden cache, so these patch the same query as the
|
||||
// object mutations, with the same optimistic + 409-rollback contract.
|
||||
|
||||
export interface PlantingCreate {
|
||||
objectId: number
|
||||
plantId: number
|
||||
xCm: number
|
||||
yCm: number
|
||||
radiusCm: number
|
||||
count?: number | null
|
||||
label?: string | null
|
||||
}
|
||||
|
||||
export function useCreatePlanting(gardenId: number) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async ({ objectId, ...body }: PlantingCreate): Promise<ServerPlanting> =>
|
||||
serverPlantingSchema.parse(await api.post(`/objects/${objectId}/plantings`, body)),
|
||||
onSuccess: (created) => {
|
||||
patchFullCache(qc, gardenId, (full) => ({ ...full, plantings: [...full.plantings, created] }))
|
||||
},
|
||||
onError: (err) => toast.error(objectErrorMessage(err, 'Could not place that plant.')),
|
||||
})
|
||||
}
|
||||
|
||||
/** Fields the plop editor patches. version is required for the optimistic guard. */
|
||||
export interface PlantingPatch {
|
||||
id: number
|
||||
version: number
|
||||
plantId?: number
|
||||
xCm?: number
|
||||
yCm?: number
|
||||
radiusCm?: number
|
||||
count?: number | null
|
||||
label?: string | null
|
||||
plantedAt?: string | null
|
||||
removedAt?: string | null
|
||||
}
|
||||
|
||||
export function useUpdatePlanting(gardenId: number) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, ...body }: PlantingPatch): Promise<ServerPlanting> =>
|
||||
serverPlantingSchema.parse(await api.patch(`/plantings/${id}`, body)),
|
||||
onMutate: async (patch) => {
|
||||
await qc.cancelQueries({ queryKey: fullKey(gardenId) })
|
||||
const prev = qc.getQueryData<FullGarden>(fullKey(gardenId))
|
||||
patchFullCache(qc, gardenId, (full) => ({
|
||||
...full,
|
||||
plantings: full.plantings.map((p) => (p.id === patch.id ? applyPlantingPatch(p, patch) : p)),
|
||||
}))
|
||||
return { prev }
|
||||
},
|
||||
onSuccess: (updated) => {
|
||||
patchFullCache(qc, gardenId, (full) => ({
|
||||
...full,
|
||||
// A soft-remove (removedAt set) drops the plop from the active list;
|
||||
// otherwise write the authoritative row (carries the fresh derivedCount).
|
||||
plantings: updated.removedAt
|
||||
? full.plantings.filter((p) => p.id !== updated.id)
|
||||
: full.plantings.map((p) => (p.id === updated.id ? updated : p)),
|
||||
}))
|
||||
},
|
||||
onError: (err, _patch, ctx) => {
|
||||
const current = conflictPlanting(err)
|
||||
if (current) {
|
||||
patchFullCache(qc, gardenId, (full) => ({
|
||||
...full,
|
||||
plantings: full.plantings.map((p) => (p.id === current.id ? current : p)),
|
||||
}))
|
||||
toast.error('That plant 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.'))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Soft-remove ("Remove"): PATCH removed_at to today; the row is kept but leaves
|
||||
* the active list. Distinct from a hard delete. */
|
||||
export function useRemovePlanting(gardenId: number) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, version }: { id: number; version: number }): Promise<ServerPlanting> => {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
return serverPlantingSchema.parse(await api.patch(`/plantings/${id}`, { removedAt: today, version }))
|
||||
},
|
||||
onMutate: async ({ id }) => {
|
||||
await qc.cancelQueries({ queryKey: fullKey(gardenId) })
|
||||
const prev = qc.getQueryData<FullGarden>(fullKey(gardenId))
|
||||
patchFullCache(qc, gardenId, (full) => ({ ...full, plantings: full.plantings.filter((p) => p.id !== id) }))
|
||||
return { prev }
|
||||
},
|
||||
onError: (err, _vars, ctx) => {
|
||||
if (ctx?.prev) qc.setQueryData(fullKey(gardenId), ctx.prev)
|
||||
// On a 409, adopt the server's current row so the plop reappears with a
|
||||
// fresh version — otherwise a retry keeps 409-ing on the stale version.
|
||||
const current = conflictPlanting(err)
|
||||
if (current) {
|
||||
patchFullCache(qc, gardenId, (full) => ({
|
||||
...full,
|
||||
plantings: full.plantings.map((p) => (p.id === current.id ? current : p)),
|
||||
}))
|
||||
toast.error('That plant was updated elsewhere — try removing it again.')
|
||||
} else {
|
||||
toast.error(objectErrorMessage(err, 'Could not remove that plant.'))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// --- helpers ---------------------------------------------------------------
|
||||
|
||||
function patchFullCache(qc: QueryClient, gardenId: number, fn: (full: FullGarden) => FullGarden) {
|
||||
qc.setQueryData<FullGarden>(fullKey(gardenId), (full) => (full ? fn(full) : full))
|
||||
}
|
||||
|
||||
/** Apply a plop patch onto a cached row for the optimistic pass. version is
|
||||
* bumped to mirror the server's post-commit increment (as applyServerPatch does
|
||||
* for objects), so a fast follow-up edit doesn't self-conflict. derivedCount is
|
||||
* left as-is here; the editor recomputes it live from the plant's spacing and
|
||||
* onSuccess writes the authoritative value. */
|
||||
function applyPlantingPatch(p: ServerPlanting, patch: PlantingPatch): ServerPlanting {
|
||||
return {
|
||||
...p,
|
||||
version: p.version + 1,
|
||||
...(patch.plantId !== undefined ? { plantId: patch.plantId } : {}),
|
||||
...(patch.xCm !== undefined ? { xCm: patch.xCm } : {}),
|
||||
...(patch.yCm !== undefined ? { yCm: patch.yCm } : {}),
|
||||
...(patch.radiusCm !== undefined ? { radiusCm: patch.radiusCm } : {}),
|
||||
...(patch.count !== undefined ? { count: patch.count } : {}),
|
||||
...(patch.label !== undefined ? { label: patch.label } : {}),
|
||||
...(patch.plantedAt !== undefined ? { plantedAt: patch.plantedAt } : {}),
|
||||
...(patch.removedAt !== undefined ? { removedAt: patch.removedAt } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** The current server plop from a 409 conflict body, or null. */
|
||||
function conflictPlanting(err: unknown): ServerPlanting | null {
|
||||
if (err instanceof ApiError && err.isConflict && err.body && typeof err.body === 'object') {
|
||||
const parsed = serverPlantingSchema.safeParse((err.body as { current?: unknown }).current)
|
||||
if (parsed.success) return parsed.data
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 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
|
||||
|
||||
Reference in New Issue
Block a user