Build image / build-and-push (push) Successful in 26s
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]>
209 lines
8.4 KiB
TypeScript
209 lines
8.4 KiB
TypeScript
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 & 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>
|
||
)
|
||
}
|