Files
pansy/web/src/components/gardens/GardenDialog.tsx
T
steveandClaude Fable 5 157e04ed24
Build image / build-and-push (push) Successful in 26s
Skip no-op saves in the edit dialogs; clear a stale model-spec error
A Save that changed nothing still sent a PATCH, which bumped the row's
version and landed an "Edited garden settings" step in History that undid
nothing — the drift is gone since the last commit, but the write was still
there. Both dialogs now close without a request when every field matches
the loaded row.

In Settings, a rejected model spec's reason stayed under the field after
the field was blanked back to the saved value; committing an unchanged
value now clears it.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-22 22:22:22 -04:00

209 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, type FormEvent } from 'react'
import { Alert } from '@/components/ui/Alert'
import { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog'
import { TextAreaField, TextField } from '@/components/ui/Field'
import { Seg } from '@/components/ui/Seg'
import { Toggle } from '@/components/ui/Toggle'
import { errorMessage } from '@/lib/api'
import { conflictGarden, useCreateGarden, useUpdateGarden, type Garden } from '@/lib/gardens'
import {
cmFromFtIn,
convertDimensionField,
dimensionField,
dimensionInputMode,
dimensionUnitLabel,
editDimensionField,
formatCm,
isValidDimensionCm,
MIN_GARDEN_GRID_CM,
type LengthField,
type UnitPref,
} from '@/lib/units'
// A new plot: the design's 20 × 12, spoken in feet; the garden grid defaults
// to a foot (the server's 1 m for a metric garden).
const DEFAULT_W_FT = 20
const DEFAULT_H_FT = 12
const DEFAULT_GRID_CM = 100
const unitOptions = [
{ value: 'imperial' as const, label: 'ft' },
{ value: 'metric' as const, label: 'm' },
]
function entryHint(unit: UnitPref): string {
return unit === 'imperial' ? `Sizes read as feet and inches — 8' 6", 8', or 8.5 for feet.` : 'Sizes are in meters, e.g. 2.5.'
}
/**
* "A new garden" (no garden) or edit (garden given). Dimensions are typed in the
* chosen unit and stored as centimeters: each field is a LengthField, so
* switching units re-shows the same centimeters and a Save sends exactly what
* was loaded unless the person typed over it (re-parsing the display string is
* how 900 cm once became 899.922). A 409 rebases the form onto the server's
* fresh row.
*/
export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: () => void }) {
const isEdit = !!garden
const create = useCreateGarden()
const update = useUpdateGarden()
const pending = create.isPending || update.isPending
const initialUnit: UnitPref = garden?.unitPref ?? 'imperial'
const [name, setName] = useState(garden?.name ?? '')
const [unit, setUnit] = useState<UnitPref>(initialUnit)
const [width, setWidth] = useState<LengthField>(() => dimensionField(garden?.widthCm ?? cmFromFtIn(DEFAULT_W_FT), initialUnit))
const [height, setHeight] = useState<LengthField>(() => dimensionField(garden?.heightCm ?? cmFromFtIn(DEFAULT_H_FT), initialUnit))
const [gridSize, setGridSize] = useState<LengthField>(() =>
dimensionField(garden?.gridSizeCm ?? (initialUnit === 'imperial' ? cmFromFtIn(1) : DEFAULT_GRID_CM), initialUnit),
)
const [snapToGrid, setSnapToGrid] = useState(garden?.snapToGrid ?? false)
const [notes, setNotes] = useState(garden?.notes ?? '')
const [more, setMore] = useState(isEdit && (!!garden.notes || garden.snapToGrid))
const [version, setVersion] = useState(garden?.version ?? 0)
const [conflict, setConflict] = useState<string | null>(null)
const [formError, setFormError] = useState<string | null>(null)
function changeUnit(next: UnitPref) {
setWidth((f) => convertDimensionField(f, next))
setHeight((f) => convertDimensionField(f, next))
setGridSize((f) => convertDimensionField(f, next))
setUnit(next)
}
const gridSizeCm = gridSize.cm
const gridTooFine = gridSizeCm !== null && gridSizeCm > 0 && gridSizeCm < MIN_GARDEN_GRID_CM
async function onSubmit(e: FormEvent) {
e.preventDefault()
setFormError(null)
setConflict(null)
if (!name.trim()) {
setFormError('Give the garden a name.')
return
}
const widthCm = width.cm
const heightCm = height.cm
if (widthCm === null || heightCm === null) {
setFormError(entryHint(unit))
return
}
if (!isValidDimensionCm(widthCm) || !isValidDimensionCm(heightCm)) {
setFormError('Width and depth must be between 1 cm and 100 m.')
return
}
if (gridSizeCm === null || !isValidDimensionCm(gridSizeCm)) {
setFormError('The grid must be between 1 cm and 100 m.')
return
}
const input = { name: name.trim(), widthCm, heightCm, unitPref: unit, notes: notes.trim(), gridSizeCm, snapToGrid }
// Nothing changed: close without a request. A PATCH that writes the same
// row still bumps the version and lands an "Edited garden settings" step
// in History that undoes nothing.
if (isEdit && (Object.keys(input) as (keyof typeof input)[]).every((k) => input[k] === garden[k])) {
onClose()
return
}
try {
if (isEdit) await update.mutateAsync({ id: garden.id, ...input, version })
else await create.mutateAsync(input)
onClose()
} catch (err) {
const current = conflictGarden(err)
if (current) {
setVersion(current.version)
setName(current.name)
setUnit(current.unitPref)
setWidth(dimensionField(current.widthCm, current.unitPref))
setHeight(dimensionField(current.heightCm, current.unitPref))
setGridSize(dimensionField(current.gridSizeCm, current.unitPref))
setSnapToGrid(current.snapToGrid)
setNotes(current.notes)
setConflict('This garden changed elsewhere. The latest values are shown — look them over and save again.')
return
}
setFormError(errorMessage(err, isEdit ? 'Could not save the garden.' : 'Could not create the garden.'))
}
}
const u = dimensionUnitLabel(unit)
const inputMode = dimensionInputMode(unit)
return (
<Dialog title={isEdit ? `Edit ${garden.name}` : 'A new garden'} onClose={onClose} busy={pending}>
<form onSubmit={onSubmit} className="flex flex-col gap-3.5">
{conflict && <Alert tone="info">{conflict}</Alert>}
<TextField label="Name" name="name" required autoFocus placeholder="Back forty" value={name} onChange={(e) => setName(e.target.value)} />
<div className="flex gap-2.5">
<TextField
label={`Width (${u})`}
name="width"
type="text"
inputMode={inputMode}
required
value={width.text}
onChange={(e) => setWidth(editDimensionField(e.target.value, unit))}
wrapperClassName="flex-1"
/>
<TextField
label={`Depth (${u})`}
name="height"
type="text"
inputMode={inputMode}
required
value={height.text}
onChange={(e) => setHeight(editDimensionField(e.target.value, unit))}
wrapperClassName="flex-1"
/>
<div className="field">
<label>Units</label>
<Seg options={unitOptions} value={unit} onChange={changeUnit} label="Units" />
</div>
</div>
<div className="text-xs text-ink-mute">Stored in centimeters under the hood {unit === 'imperial' ? 'feet are' : 'meters are'} just how you talk.</div>
{!more ? (
<button type="button" className="btn btn-ghost self-start text-[13px]" onClick={() => setMore(true)}>
Grid &amp; notes
</button>
) : (
<div className="flex flex-col gap-3.5 rounded-md border border-divider bg-bg p-3.5">
<div className="flex items-end gap-2.5">
<TextField
label={`Garden grid (${u})`}
name="gridSize"
type="text"
inputMode={inputMode}
value={gridSize.text}
onChange={(e) => setGridSize(editDimensionField(e.target.value, unit))}
wrapperClassName="flex-1"
hint={
gridTooFine && gridSizeCm !== null
? `${formatCm(gridSizeCm, unit)} is very fine for a whole-garden grid — plant spacing lives on each bed.`
: undefined
}
/>
<div className="field">
<label>Snap objects</label>
<Toggle on={snapToGrid} onChange={setSnapToGrid} label="Snap objects to the garden grid" />
</div>
</div>
<TextAreaField label="Notes" name="notes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
</div>
)}
{formError && <Alert>{formError}</Alert>}
<div className="flex justify-end gap-2">
<Button type="button" onClick={onClose} disabled={pending}>
Never mind
</Button>
<Button type="submit" variant="primary" disabled={pending}>
{pending ? 'Saving…' : isEdit ? 'Save' : 'Break ground'}
</Button>
</div>
</form>
</Dialog>
)
}