Build image / build-and-push (push) Successful in 6s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
243 lines
8.8 KiB
TypeScript
243 lines
8.8 KiB
TypeScript
import { useState, type FormEvent } from 'react'
|
|
import { Modal } from '@/components/ui/Modal'
|
|
import { Alert } from '@/components/ui/Alert'
|
|
import { Button } from '@/components/ui/Button'
|
|
import { Select } from '@/components/ui/Select'
|
|
import { TextArea } from '@/components/ui/TextArea'
|
|
import { TextField } from '@/components/ui/TextField'
|
|
import { errorMessage } from '@/lib/api'
|
|
import { conflictGarden, useCreateGarden, useUpdateGarden, type Garden } from '@/lib/gardens'
|
|
import {
|
|
dimensionUnitLabel,
|
|
formatCm,
|
|
dimensionInputMode,
|
|
formatDimensionInput,
|
|
isValidDimensionCm,
|
|
MIN_GARDEN_GRID_CM,
|
|
parseDimension,
|
|
type UnitPref,
|
|
} from '@/lib/units'
|
|
|
|
const DEFAULT_METERS = 10 // matches the server's 10 m default
|
|
const DEFAULT_GRID_CM = 100 // matches the server's 1 m grid default
|
|
|
|
// What to say when a field can't be read. Naming the accepted forms beats
|
|
// "invalid input", which leaves the person guessing which field and which part.
|
|
function entryHint(unit: UnitPref): string {
|
|
return unit === 'imperial'
|
|
? `Enter sizes as feet and inches — 8' 6", 8', or 8.5 for feet.`
|
|
: 'Enter sizes in meters, e.g. 2.5.'
|
|
}
|
|
|
|
const unitOptions = [
|
|
{ value: 'metric', label: 'Metric (m)' },
|
|
{ value: 'imperial', label: 'Imperial (ft)' },
|
|
]
|
|
|
|
function dimString(cm: number | undefined, unit: UnitPref): string {
|
|
return cm === undefined ? String(DEFAULT_METERS) : formatDimensionInput(cm, unit)
|
|
}
|
|
|
|
/**
|
|
* Create (no garden) or edit (garden given) form. Dimensions are entered in the
|
|
* selected unit and converted to centimeters for the API; switching units
|
|
* converts the current values so the physical size is preserved. A 409 rebases
|
|
* the form onto the server's fresh row.
|
|
*/
|
|
export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose: () => void }) {
|
|
const isEdit = !!garden
|
|
const create = useCreateGarden()
|
|
const update = useUpdateGarden()
|
|
const pending = create.isPending || update.isPending
|
|
|
|
const [name, setName] = useState(garden?.name ?? '')
|
|
const [unit, setUnit] = useState<UnitPref>(garden?.unitPref ?? 'metric')
|
|
const [width, setWidth] = useState(() => dimString(garden?.widthCm, garden?.unitPref ?? 'metric'))
|
|
const [height, setHeight] = useState(() => dimString(garden?.heightCm, garden?.unitPref ?? 'metric'))
|
|
const [gridSize, setGridSize] = useState(() =>
|
|
formatDimensionInput(garden?.gridSizeCm ?? DEFAULT_GRID_CM, garden?.unitPref ?? 'metric'),
|
|
)
|
|
const [snapToGrid, setSnapToGrid] = useState(garden?.snapToGrid ?? false)
|
|
const [notes, setNotes] = useState(garden?.notes ?? '')
|
|
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) {
|
|
// Re-render each field in the new unit, preserving the physical size. An
|
|
// unparseable field is left as typed rather than blanked.
|
|
const convert = (s: string) => {
|
|
const cm = parseDimension(s, unit)
|
|
return cm === null ? s : formatDimensionInput(cm, next)
|
|
}
|
|
setWidth(convert(width))
|
|
setHeight(convert(height))
|
|
// The garden grid is a layout concern, so it lives at the same scale as the
|
|
// garden's own dimensions — same helpers, same unit label. (The *bed* grid in
|
|
// the object inspector is a plant-spacing concern and stays at cm/in.)
|
|
setGridSize(convert(gridSize))
|
|
setUnit(next)
|
|
}
|
|
|
|
// Converted once per render and used by both the submit handler and the
|
|
// too-fine hint, so the two can never disagree about what was entered.
|
|
const gridSizeCm = parseDimension(gridSize, unit)
|
|
// Soft floor: hint, don't refuse. A garden-scale grid this fine is usually
|
|
// someone reaching for plant spacing, which lives on the bed instead — but it
|
|
// is a legitimate choice for a very small garden, so the save still goes through.
|
|
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('Enter a name for the garden.')
|
|
return
|
|
}
|
|
// Validate the converted centimeter values against the same bounds the
|
|
// server enforces, so sub-cm or over-100m sizes fail here with a clear
|
|
// message instead of a generic server error.
|
|
const widthCm = parseDimension(width, unit)
|
|
const heightCm = parseDimension(height, unit)
|
|
if (widthCm === null || heightCm === null) {
|
|
setFormError(entryHint(unit))
|
|
return
|
|
}
|
|
if (!isValidDimensionCm(widthCm) || !isValidDimensionCm(heightCm)) {
|
|
setFormError('Width and height must be between 1 cm and 100 m.')
|
|
return
|
|
}
|
|
if (gridSizeCm === null) {
|
|
setFormError(entryHint(unit))
|
|
return
|
|
}
|
|
if (!isValidDimensionCm(gridSizeCm)) {
|
|
setFormError('Grid size must be between 1 cm and 100 m.')
|
|
return
|
|
}
|
|
|
|
const input = {
|
|
name: name.trim(),
|
|
widthCm,
|
|
heightCm,
|
|
unitPref: unit,
|
|
notes: notes.trim(),
|
|
gridSizeCm,
|
|
snapToGrid,
|
|
}
|
|
|
|
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) {
|
|
// Someone else changed this garden: rebase the form onto the fresh row so
|
|
// a re-save applies against the current version.
|
|
setVersion(current.version)
|
|
setName(current.name)
|
|
setUnit(current.unitPref)
|
|
setWidth(dimString(current.widthCm, current.unitPref))
|
|
setHeight(dimString(current.heightCm, current.unitPref))
|
|
setGridSize(formatDimensionInput(current.gridSizeCm, current.unitPref))
|
|
setSnapToGrid(current.snapToGrid)
|
|
setNotes(current.notes)
|
|
setConflict('This garden changed elsewhere. The latest values are shown — review and save again.')
|
|
return
|
|
}
|
|
setFormError(errorMessage(err, isEdit ? 'Could not save changes.' : 'Could not create the garden.'))
|
|
}
|
|
}
|
|
|
|
const unitLabel = dimensionUnitLabel(unit)
|
|
const inputMode = dimensionInputMode(unit)
|
|
|
|
return (
|
|
<Modal title={isEdit ? 'Edit garden' : 'New garden'} onClose={onClose} busy={pending}>
|
|
<form onSubmit={onSubmit} className="flex flex-col gap-3">
|
|
{conflict && <Alert tone="info">{conflict}</Alert>}
|
|
|
|
<TextField label="Name" name="name" required value={name} onChange={(e) => setName(e.target.value)} />
|
|
|
|
<Select
|
|
label="Units"
|
|
name="unitPref"
|
|
value={unit}
|
|
onChange={(e) => changeUnit(e.target.value as UnitPref)}
|
|
options={unitOptions}
|
|
/>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<TextField
|
|
label={`Width (${unitLabel})`}
|
|
name="width"
|
|
type="text"
|
|
inputMode={inputMode}
|
|
required
|
|
value={width}
|
|
onChange={(e) => setWidth(e.target.value)}
|
|
/>
|
|
<TextField
|
|
label={`Height (${unitLabel})`}
|
|
name="height"
|
|
type="text"
|
|
inputMode={inputMode}
|
|
required
|
|
value={height}
|
|
onChange={(e) => setHeight(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="flex items-end gap-3">
|
|
<div className="flex-1">
|
|
<TextField
|
|
label={`Garden grid (${unitLabel})`}
|
|
name="gridSize"
|
|
type="text"
|
|
inputMode={inputMode}
|
|
value={gridSize}
|
|
onChange={(e) => setGridSize(e.target.value)}
|
|
/>
|
|
</div>
|
|
<label className="flex h-9 items-center gap-2 whitespace-nowrap text-sm text-fg">
|
|
<input
|
|
type="checkbox"
|
|
checked={snapToGrid}
|
|
onChange={(e) => setSnapToGrid(e.target.checked)}
|
|
className="h-4 w-4 rounded border-border"
|
|
/>
|
|
Snap objects
|
|
</label>
|
|
</div>
|
|
{gridTooFine && (
|
|
<p className="mt-1 text-xs text-muted">
|
|
{formatCm(gridSizeCm, unit)} is very fine for a whole-garden grid. Plant spacing lives on each bed
|
|
(Bed grid in the inspector), not here.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<TextArea label="Notes" name="notes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
|
|
|
{formError && <Alert>{formError}</Alert>}
|
|
|
|
<div className="mt-1 flex justify-end gap-2">
|
|
<Button type="button" variant="ghost" onClick={onClose} disabled={pending}>
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit" disabled={pending}>
|
|
{pending ? 'Saving…' : isEdit ? 'Save' : 'Create garden'}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
)
|
|
}
|