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]>
208 lines
7.8 KiB
TypeScript
208 lines
7.8 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 { Field, SelectField, TextAreaField, TextField } from '@/components/ui/Field'
|
|
import { errorMessage } from '@/lib/api'
|
|
import {
|
|
CATEGORY_LABELS,
|
|
PLANT_CATEGORIES,
|
|
conflictPlant,
|
|
useCreatePlant,
|
|
useUpdatePlant,
|
|
type Plant,
|
|
type PlantCategory,
|
|
type PlantInput,
|
|
} from '@/lib/plants'
|
|
import { safeExternalUrl } from '@/lib/seedLots'
|
|
import { editSpacingField, spacingField, spacingUnitLabel, type LengthField, type UnitPref } from '@/lib/units'
|
|
import { ColorSwatches, CURATED_SWATCHES, expandHex } from './ColorSwatches'
|
|
|
|
// Markers are monograms now, but the API still carries an icon per plant; a
|
|
// plant made here gets the neutral sprout so nothing downstream sees an empty one.
|
|
const DEFAULT_ICON = '🌱'
|
|
const categoryOptions = PLANT_CATEGORIES.map((c) => ({ value: c, label: CATEGORY_LABELS[c] }))
|
|
|
|
/**
|
|
* "A new plant" — or edit (`plant`), or a fresh create prefilled from another
|
|
* (`template`, the Duplicate action; the only way to customize a built-in).
|
|
* Spacing is typed in the page's unit and stored in centimeters — as a
|
|
* LengthField, so a Save that didn't touch it sends the centimeters that were
|
|
* loaded rather than re-parsing "17.7 in" into 44.958. A 409 rebases onto the
|
|
* server's current row.
|
|
*/
|
|
export function PlantDialog({
|
|
plant,
|
|
template,
|
|
unit,
|
|
onClose,
|
|
}: {
|
|
plant?: Plant
|
|
template?: Plant
|
|
unit: UnitPref
|
|
onClose: () => void
|
|
}) {
|
|
const isEdit = !!plant
|
|
const source = plant ?? template
|
|
const create = useCreatePlant()
|
|
const update = useUpdatePlant()
|
|
const pending = create.isPending || update.isPending
|
|
|
|
const [name, setName] = useState(source ? (isEdit ? source.name : `${source.name} (copy)`) : '')
|
|
const [category, setCategory] = useState<PlantCategory>(source?.category ?? 'vegetable')
|
|
const [spacing, setSpacing] = useState<LengthField>(() => spacingField(source?.spacingCm ?? 30, unit))
|
|
const [color, setColor] = useState(expandHex(source?.color ?? CURATED_SWATCHES[0]))
|
|
const [days, setDays] = useState(source?.daysToMaturity != null ? String(source.daysToMaturity) : '')
|
|
const [vendor, setVendor] = useState(source?.vendor ?? '')
|
|
const [sourceUrl, setSourceUrl] = useState(source?.sourceUrl ?? '')
|
|
const [notes, setNotes] = useState(source?.notes ?? '')
|
|
const [more, setMore] = useState(!!(source?.vendor || source?.sourceUrl || source?.notes))
|
|
const [version, setVersion] = useState(plant?.version ?? 0)
|
|
const [conflict, setConflict] = useState<string | null>(null)
|
|
const [formError, setFormError] = useState<string | null>(null)
|
|
const unitLabel = spacingUnitLabel(unit)
|
|
|
|
async function onSubmit(e: FormEvent) {
|
|
e.preventDefault()
|
|
setFormError(null)
|
|
setConflict(null)
|
|
if (!name.trim()) {
|
|
setFormError('Give the plant a name.')
|
|
return
|
|
}
|
|
const spacingCm = spacing.cm
|
|
if (spacingCm === null || spacingCm < 1) {
|
|
setFormError(`Spacing must be at least 1 ${unitLabel}.`)
|
|
return
|
|
}
|
|
let daysToMaturity: number | null = null
|
|
if (days.trim()) {
|
|
const d = Number(days)
|
|
if (!Number.isInteger(d) || d < 1) {
|
|
setFormError('Days to maturity is a whole number of days, or blank.')
|
|
return
|
|
}
|
|
daysToMaturity = d
|
|
}
|
|
if (sourceUrl.trim() && !safeExternalUrl(sourceUrl.trim())) {
|
|
setFormError('The source link needs to be a full http:// or https:// address.')
|
|
return
|
|
}
|
|
const input: PlantInput = {
|
|
name: name.trim(),
|
|
category,
|
|
spacingCm,
|
|
color,
|
|
icon: source?.icon || DEFAULT_ICON,
|
|
daysToMaturity,
|
|
sourceUrl: sourceUrl.trim(),
|
|
vendor: vendor.trim(),
|
|
notes: notes.trim(),
|
|
}
|
|
// Nothing changed: close without a request, so a look-and-Save doesn't bump
|
|
// the version for every garden that shares the plant.
|
|
if (isEdit && (Object.keys(input) as (keyof PlantInput)[]).every((k) => input[k] === (k === 'color' ? expandHex(plant.color) : plant[k]))) {
|
|
onClose()
|
|
return
|
|
}
|
|
try {
|
|
if (isEdit) await update.mutateAsync({ id: plant.id, ...input, version })
|
|
else await create.mutateAsync(input)
|
|
onClose()
|
|
} catch (err) {
|
|
const current = conflictPlant(err)
|
|
if (current) {
|
|
setVersion(current.version)
|
|
setName(current.name)
|
|
setCategory(current.category)
|
|
setSpacing(spacingField(current.spacingCm, unit))
|
|
setColor(expandHex(current.color))
|
|
setDays(current.daysToMaturity != null ? String(current.daysToMaturity) : '')
|
|
setVendor(current.vendor)
|
|
setSourceUrl(current.sourceUrl)
|
|
setNotes(current.notes)
|
|
setConflict('This plant changed elsewhere. The latest values are shown — look them over and save again.')
|
|
return
|
|
}
|
|
setFormError(errorMessage(err, isEdit ? 'Could not save the plant.' : 'Could not add the plant.'))
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dialog title={isEdit ? `Edit ${plant.name}` : 'A new plant'} 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="Delicata squash" value={name} onChange={(e) => setName(e.target.value)} />
|
|
<div className="flex gap-2.5">
|
|
<SelectField
|
|
label="Category"
|
|
name="category"
|
|
value={category}
|
|
onChange={(e) => setCategory(e.target.value as PlantCategory)}
|
|
options={categoryOptions}
|
|
wrapperClassName="flex-1"
|
|
/>
|
|
<TextField
|
|
label={`Spacing (${unitLabel})`}
|
|
name="spacing"
|
|
type="number"
|
|
inputMode="decimal"
|
|
step="any"
|
|
min="1"
|
|
required
|
|
value={spacing.text}
|
|
onChange={(e) => setSpacing(editSpacingField(e.target.value, unit))}
|
|
wrapperClassName="flex-1"
|
|
/>
|
|
</div>
|
|
<Field label="Marker color">
|
|
<ColorSwatches value={color} onChange={setColor} />
|
|
</Field>
|
|
<TextField
|
|
label="Days to maturity (optional)"
|
|
name="days"
|
|
type="number"
|
|
inputMode="numeric"
|
|
step="1"
|
|
min="1"
|
|
value={days}
|
|
onChange={(e) => setDays(e.target.value)}
|
|
/>
|
|
|
|
{!more ? (
|
|
<button type="button" className="btn btn-ghost self-start text-[13px]" onClick={() => setMore(true)}>
|
|
Vendor, source link & notes…
|
|
</button>
|
|
) : (
|
|
<div className="flex flex-col gap-3.5 rounded-md border border-divider bg-bg p-3.5">
|
|
<div className="flex gap-2.5">
|
|
<TextField label="Vendor" name="vendor" placeholder="Johnny's" value={vendor} onChange={(e) => setVendor(e.target.value)} wrapperClassName="flex-1" />
|
|
<TextField
|
|
label="Source link"
|
|
name="sourceUrl"
|
|
type="url"
|
|
inputMode="url"
|
|
placeholder="https://…"
|
|
value={sourceUrl}
|
|
onChange={(e) => setSourceUrl(e.target.value)}
|
|
wrapperClassName="flex-1"
|
|
/>
|
|
</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' : 'Add it'}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</Dialog>
|
|
)
|
|
}
|