diff --git a/web/src/Classification.tsx b/web/src/Classification.tsx index d2f6e32..cfa0260 100644 --- a/web/src/Classification.tsx +++ b/web/src/Classification.tsx @@ -695,6 +695,11 @@ function CorrectionEditor({ mutate: Mutate; onChange: (value: CorrectionValue) => void; }) { + // Async creates resolve against the freshest correction, not the snapshot + // captured when the create row was clicked: a chip removed during the + // server round trip must survive the create landing. + const latest = useRef(value); + latest.current = value; const addable = data.tags .filter((t) => !value.tag_ids.includes(t.id)) .map((t) => ({ value: t.id, label: t.name })); @@ -739,7 +744,9 @@ function CorrectionEditor({ mutate={mutate} value={value.category_id} disabled={disabled} - onChange={(category_id) => onChange({ ...value, category_id })} + onChange={(category_id) => + onChange({ ...latest.current, category_id }) + } /> @@ -783,14 +790,13 @@ function CorrectionEditor({ { key: "tag", label: `Create tag "${text}"`, - run: async () => + run: async () => { + const id = await createTag(mutate, data, text); onChange({ - ...value, - tag_ids: [ - ...value.tag_ids, - await createTag(mutate, data, text), - ], - }), + ...latest.current, + tag_ids: [...latest.current.tag_ids, id], + }); + }, }, ] } diff --git a/web/src/Transactions.tsx b/web/src/Transactions.tsx index f1218dc..765108d 100644 --- a/web/src/Transactions.tsx +++ b/web/src/Transactions.tsx @@ -460,7 +460,9 @@ function TransactionEditor({ required mutate={mutate} value={value.category_id || ""} - onChange={(category_id) => setValue({ ...value, category_id })} + onChange={(category_id) => + setValue((v) => ({ ...v, category_id })) + } /> )} @@ -473,7 +475,7 @@ function TransactionEditor({ setValue({ ...value, tag_ids })} + onChange={(tag_ids) => setValue((v) => ({ ...v, tag_ids }))} mutate={mutate} />
diff --git a/web/src/ui.tsx b/web/src/ui.tsx index 29488be..9e02156 100644 --- a/web/src/ui.tsx +++ b/web/src/ui.tsx @@ -179,6 +179,9 @@ export function Combobox({ setOpen(false); } catch (err) { setCreateError(err instanceof Error ? err.message : String(err)); + // A blur may have closed the list mid-flight; a failure must never + // land invisibly. + setOpen(true); } finally { setCreating(false); } @@ -208,7 +211,11 @@ export function Combobox({ setActive(-1); setOpen(true); }} - onBlur={() => setOpen(false)} + onBlur={() => { + // A blur during an in-flight create keeps the list mounted so the + // outcome (or the error row) stays visible. + if (!creating) setOpen(false); + }} onKeyDown={(e) => { if (e.key === "Escape") setOpen(false); if ((e.key === "ArrowDown" || e.key === "ArrowUp") && open && total) { @@ -250,6 +257,7 @@ export function Combobox({ } role="option" aria-selected={o.value === value} + disabled={creating} onMouseDown={(e) => e.preventDefault()} onClick={() => pick(o.value)} > @@ -565,6 +573,8 @@ export async function createTag( data: Dataset, name: string, ): Promise { + if (name.length > 200) + throw new Error("Tag names are limited to 200 characters."); const next = await mutate( "/api/tags", { tag: { id: "", name, hint: "" } }, @@ -582,6 +592,8 @@ export async function createCategory( data: Dataset, category: { name: string; parent_id: string; kind: string }, ): Promise { + if (category.name.length > 200) + throw new Error("Category names are limited to 200 characters."); const next = await mutate( "/api/categories", { category: { id: "", hint: "", ...category } }, @@ -610,6 +622,11 @@ export function TagPicker({ const [draft, setDraft] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); + // The async add resolves against the freshest selection, not the one + // captured at click time: a checkbox toggled during the server round trip + // must survive the create landing. + const latest = useRef(value); + latest.current = value; const add = async () => { const name = draft.trim(); if (!name || busy || !mutate) return; @@ -626,7 +643,7 @@ export function TagPicker({ setError(""); try { const id = await createTag(mutate, data, name); - onChange([...value, id]); + onChange([...latest.current, id]); setDraft(""); } catch (err) { setError(err instanceof Error ? err.message : String(err)); @@ -756,10 +773,7 @@ export function CategoryCombobox({ return data.categories.some((c) => pathOf(c.id) === full); }; const create = (text: string): ComboCreate[] => { - // Text aiming at the empty option ("No parent…") is a selection, not a - // new category name. - if (!mutate || emptyLabel?.toLowerCase().includes(text.toLowerCase())) - return []; + if (!mutate) return []; const segments = text .split("/") .map((s) => s.trim())