Create categories and tags in place from every assignment picker
Category and tag inputs across the transaction editor, Analyse corrections, and merchant defaults now mint missing entries without a detour through the registry pages. A bare name lands under the kind's root, "Parent / Name" targets that parent, and typing an existing name selects it instead of duplicating. Enter only creates when nothing matches, server rejections surface inline in the dropdown, and assignment pickers offer leaf categories only — the shape the server validates. Mutations now return the accepted state so callers can select the id the server just minted, and the revision-keyed remounts on Transactions and the registry pages are gone: they closed the open modal and threw away pending edits the moment any in-modal creation committed.
This commit is contained in:
+265
-21
@@ -7,8 +7,9 @@ import {
|
||||
CalendarDays,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import type { Dataset, Filter, VerifiedModel } from "./api";
|
||||
import type { Category, Dataset, Filter, State, VerifiedModel } from "./api";
|
||||
import {
|
||||
categoryPath,
|
||||
DEFAULT_MONTHS,
|
||||
@@ -103,6 +104,14 @@ export interface ComboOption {
|
||||
label: string;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
// ComboCreate is one "create it now" row a Combobox offers when the typed
|
||||
// text matches nothing: running it is expected to persist the new entity and
|
||||
// select it through the caller's own onChange.
|
||||
export interface ComboCreate {
|
||||
key: string;
|
||||
label: string;
|
||||
run: () => Promise<void> | void;
|
||||
}
|
||||
// Combobox is a free-text input that autocompletes against a fixed option
|
||||
// list: typing filters by label, Enter takes the exact or only match, and
|
||||
// picking an option reports its value. The caller keeps working with stable
|
||||
@@ -116,6 +125,7 @@ export function Combobox({
|
||||
required = false,
|
||||
adornment,
|
||||
emptyText = "No matches.",
|
||||
create,
|
||||
}: {
|
||||
options: ComboOption[];
|
||||
value: string;
|
||||
@@ -125,7 +135,10 @@ export function Combobox({
|
||||
required?: boolean;
|
||||
adornment?: ReactNode;
|
||||
emptyText?: string;
|
||||
create?: (text: string) => ComboCreate[];
|
||||
}) {
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const filter = query.trim().toLowerCase();
|
||||
@@ -137,10 +150,25 @@ export function Combobox({
|
||||
? [exact, ...matches.filter((o) => o !== exact).slice(0, 59)]
|
||||
: matches.slice(0, 60);
|
||||
const selected = options.find((o) => o.value === value);
|
||||
const creations =
|
||||
create && filter && !exact && !disabled ? create(query.trim()) : [];
|
||||
const pick = (v: string) => {
|
||||
onChange(v);
|
||||
setOpen(false);
|
||||
};
|
||||
const runCreate = async (c: ComboCreate) => {
|
||||
if (creating) return;
|
||||
setCreating(true);
|
||||
setCreateError("");
|
||||
try {
|
||||
await c.run();
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
setCreateError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="combo">
|
||||
<input
|
||||
@@ -157,6 +185,7 @@ export function Combobox({
|
||||
}}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setCreateError("");
|
||||
setOpen(true);
|
||||
}}
|
||||
onBlur={() => setOpen(false)}
|
||||
@@ -166,6 +195,10 @@ export function Combobox({
|
||||
e.preventDefault();
|
||||
const hit = exact ?? (shown.length === 1 ? shown[0] : undefined);
|
||||
if (hit) pick(hit.value);
|
||||
// Enter creates only when nothing matches at all: with matches
|
||||
// still listed, minting from a half-typed name is too easy.
|
||||
else if (!shown.length && creations.length === 1)
|
||||
void runCreate(creations[0]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -189,7 +222,24 @@ export function Combobox({
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{shown.length === 0 && <li className="combo-empty">{emptyText}</li>}
|
||||
{creations.map((c) => (
|
||||
<li key={c.key}>
|
||||
<button
|
||||
type="button"
|
||||
className="combo-option create"
|
||||
disabled={creating}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => void runCreate(c)}
|
||||
>
|
||||
<Plus size={14} />
|
||||
<span>{creating ? "Creating…" : c.label}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{createError && <li className="combo-empty error">{createError}</li>}
|
||||
{shown.length === 0 && creations.length === 0 && !createError && (
|
||||
<li className="combo-empty">{emptyText}</li>
|
||||
)}
|
||||
{matches.length > shown.length && (
|
||||
<li className="combo-empty">
|
||||
{matches.length - shown.length} more — keep typing to narrow down.
|
||||
@@ -461,38 +511,135 @@ export function Empty({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// createTag persists a new tag and returns its server-minted id, found by
|
||||
// diffing the returned state against the dataset the caller rendered with.
|
||||
export async function createTag(
|
||||
mutate: Mutate,
|
||||
data: Dataset,
|
||||
name: string,
|
||||
): Promise<string> {
|
||||
const next = await mutate(
|
||||
"/api/tags",
|
||||
{ tag: { id: "", name, hint: "" } },
|
||||
`Tag "${name}" created`,
|
||||
);
|
||||
const created = next.data.tags.find(
|
||||
(t) => !data.tags.some((o) => o.id === t.id),
|
||||
);
|
||||
if (!created)
|
||||
throw new Error(`The server did not return the new tag "${name}".`);
|
||||
return created.id;
|
||||
}
|
||||
export async function createCategory(
|
||||
mutate: Mutate,
|
||||
data: Dataset,
|
||||
category: { name: string; parent_id: string; kind: string },
|
||||
): Promise<string> {
|
||||
const next = await mutate(
|
||||
"/api/categories",
|
||||
{ category: { id: "", hint: "", ...category } },
|
||||
`Category "${category.name}" created`,
|
||||
);
|
||||
const created = next.data.categories.find(
|
||||
(c) => !data.categories.some((o) => o.id === c.id),
|
||||
);
|
||||
if (!created)
|
||||
throw new Error(
|
||||
`The server did not return the new category "${category.name}".`,
|
||||
);
|
||||
return created.id;
|
||||
}
|
||||
export function TagPicker({
|
||||
data,
|
||||
value,
|
||||
onChange,
|
||||
mutate,
|
||||
}: {
|
||||
data: Dataset;
|
||||
value: string[];
|
||||
onChange: (ids: string[]) => void;
|
||||
mutate?: Mutate;
|
||||
}) {
|
||||
const [draft, setDraft] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const add = async () => {
|
||||
const name = draft.trim();
|
||||
if (!name || busy || !mutate) return;
|
||||
// An existing tag of the same name is checked instead of duplicated.
|
||||
const existing = data.tags.find(
|
||||
(t) => t.name.toLowerCase() === name.toLowerCase(),
|
||||
);
|
||||
if (existing) {
|
||||
if (!value.includes(existing.id)) onChange([...value, existing.id]);
|
||||
setDraft("");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const id = await createTag(mutate, data, name);
|
||||
onChange([...value, id]);
|
||||
setDraft("");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<fieldset className="tag-picker">
|
||||
<legend>Tags</legend>
|
||||
{data.tags.length ? (
|
||||
data.tags.map((tag) => (
|
||||
<label className="check-chip" key={tag.id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value.includes(tag.id)}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
e.target.checked
|
||||
? [...value, tag.id]
|
||||
: value.filter((id) => id !== tag.id),
|
||||
)
|
||||
}
|
||||
/>
|
||||
{tag.name}
|
||||
</label>
|
||||
))
|
||||
) : (
|
||||
{data.tags.map((tag) => (
|
||||
<label className="check-chip" key={tag.id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value.includes(tag.id)}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
e.target.checked
|
||||
? [...value, tag.id]
|
||||
: value.filter((id) => id !== tag.id),
|
||||
)
|
||||
}
|
||||
/>
|
||||
{tag.name}
|
||||
</label>
|
||||
))}
|
||||
{!data.tags.length && !mutate && (
|
||||
<small>No tags yet. Create them in Tags.</small>
|
||||
)}
|
||||
{mutate && (
|
||||
<span className="tag-add">
|
||||
<input
|
||||
value={draft}
|
||||
maxLength={200}
|
||||
placeholder="New tag"
|
||||
aria-label="New tag name"
|
||||
disabled={busy}
|
||||
onChange={(e) => {
|
||||
setDraft(e.target.value);
|
||||
setError("");
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void add();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
aria-label="Create tag"
|
||||
disabled={busy || !draft.trim()}
|
||||
onClick={() => void add()}
|
||||
>
|
||||
<Plus size={15} />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{error && <small className="tag-add-error">{error}</small>}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -517,6 +664,101 @@ export function CategoryOptions({
|
||||
</>
|
||||
);
|
||||
}
|
||||
// CategoryCombobox is the one category picker: options are full paths, and
|
||||
// with a mutate handle an unmatched name can be created in place. A bare name
|
||||
// lands under the kind's root; "Parent / Name" creates under that parent.
|
||||
// leavesOnly matches the server rule that assigned categories must be leaves;
|
||||
// a freshly created category is always a leaf.
|
||||
export function CategoryCombobox({
|
||||
data,
|
||||
value,
|
||||
onChange,
|
||||
mutate,
|
||||
kind,
|
||||
leavesOnly = false,
|
||||
exclude = [],
|
||||
emptyLabel,
|
||||
required = false,
|
||||
disabled = false,
|
||||
placeholder = "Search categories",
|
||||
}: {
|
||||
data: Dataset;
|
||||
value: string;
|
||||
onChange: (id: string) => void;
|
||||
mutate?: Mutate;
|
||||
kind?: string;
|
||||
leavesOnly?: boolean;
|
||||
exclude?: string[];
|
||||
emptyLabel?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const parents = new Set(
|
||||
data.categories.map((c) => c.parent_id).filter(Boolean),
|
||||
);
|
||||
const eligible = (c: Category) =>
|
||||
(!kind || c.kind === kind) && !exclude.includes(c.id);
|
||||
const options: ComboOption[] = data.categories
|
||||
.filter((c) => eligible(c) && (!leavesOnly || !parents.has(c.id)))
|
||||
.map((c) => ({ value: c.id, label: categoryPath(data, c.id) }));
|
||||
if (emptyLabel) options.unshift({ value: "", label: emptyLabel });
|
||||
const pathOf = (id: string) => categoryPath(data, id).toLowerCase();
|
||||
const taken = (parentID: string, name: string) => {
|
||||
const full = `${parentID ? pathOf(parentID) + " / " : ""}${name.toLowerCase()}`;
|
||||
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 [];
|
||||
const segments = text
|
||||
.split("/")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (!segments.length) return [];
|
||||
const name = segments[segments.length - 1];
|
||||
const row = (parent: Category): ComboCreate => ({
|
||||
key: parent.id,
|
||||
label: `Create "${name}" in ${categoryPath(data, parent.id)}`,
|
||||
run: async () =>
|
||||
onChange(
|
||||
await createCategory(mutate, data, {
|
||||
name,
|
||||
parent_id: parent.id,
|
||||
kind: parent.kind,
|
||||
}),
|
||||
),
|
||||
});
|
||||
if (segments.length > 1) {
|
||||
const prefix = segments.slice(0, -1).join(" / ").toLowerCase();
|
||||
const parent = data.categories.find(
|
||||
(c) => eligible(c) && pathOf(c.id) === prefix,
|
||||
);
|
||||
return parent && !taken(parent.id, name) ? [row(parent)] : [];
|
||||
}
|
||||
return data.categories
|
||||
.filter((c) => !c.parent_id && eligible(c) && !taken(c.id, name))
|
||||
.map(row);
|
||||
};
|
||||
return (
|
||||
<Combobox
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
emptyText={
|
||||
mutate
|
||||
? "No matching category. Type a name to create it."
|
||||
: "No matching category."
|
||||
}
|
||||
create={create}
|
||||
/>
|
||||
);
|
||||
}
|
||||
export function Filters({
|
||||
data,
|
||||
value,
|
||||
@@ -680,8 +922,10 @@ export function FormActions({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Mutate posts a revisioned change and returns the accepted state, so a
|
||||
// caller can find ids the server just minted.
|
||||
export type Mutate = (
|
||||
path: string,
|
||||
body: Record<string, unknown>,
|
||||
message?: string,
|
||||
) => Promise<void>;
|
||||
) => Promise<State>;
|
||||
|
||||
Reference in New Issue
Block a user