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:
+6
-1
@@ -13,7 +13,12 @@ from Accounts and confirm the reviewed mapping. The application starts empty
|
|||||||
except for expense/income fallback categories. Create your category tree, tags,
|
except for expense/income fallback categories. Create your category tree, tags,
|
||||||
and merchants in the UI. Enable a merchant's default rule explicitly only when
|
and merchants in the UI. Enable a merchant's default rule explicitly only when
|
||||||
its category/tags are reliable; leave it disabled for ambiguous merchants such
|
its category/tags are reliable; leave it disabled for ambiguous merchants such
|
||||||
as Amazon.
|
as Amazon. Category and tag pickers create in place: type an unknown name in
|
||||||
|
a category picker and choose "Create … in …" (a bare name lands under the
|
||||||
|
kind's root; "Parent / Name" targets that parent), or type a new tag next to
|
||||||
|
the tag checkboxes. Assignment pickers offer leaf categories only, matching
|
||||||
|
what the server accepts; a name that already exists is selected, never
|
||||||
|
duplicated.
|
||||||
|
|
||||||
Tests: go test ./...
|
Tests: go test ./...
|
||||||
The Go build embeds web/dist, so build React first. CGO and a C++ linker are
|
The Go build embeds web/dist, so build React first. CGO and a C++ linker are
|
||||||
|
|||||||
@@ -439,6 +439,8 @@ The classifier learns from you in three ways. Manually linking a merchant record
|
|||||||
|
|
||||||
**AI classification → Analyse** runs in the background: the page shows how many transactions have been analysed, proposed changes, and every per-transaction failure as it happens, with a **Stop** button that abandons the run without writing anything. You can navigate away and return; the run keeps building and the page re-attaches to it. A run that has produced no successful result and fails **three times in a row with the same error** stops early and reports that error — a wrong key or an unsupported model surfaces within seconds instead of repeating across the whole range.
|
**AI classification → Analyse** runs in the background: the page shows how many transactions have been analysed, proposed changes, and every per-transaction failure as it happens, with a **Stop** button that abandons the run without writing anything. You can navigate away and return; the run keeps building and the page re-attaches to it. A run that has produced no successful result and fails **three times in a row with the same error** stops early and reports that error — a wrong key or an unsupported model surfaces within seconds instead of repeating across the whole range.
|
||||||
|
|
||||||
|
Wherever a category or tag is assigned — the transaction editor, an **Analyse** correction, or a merchant's defaults — the picker creates missing entries in place. Type a name and choose **Create "…" in …**: a bare name lands under the kind's root, and **Parent / Name** creates under that parent. New tags are typed next to the tag checkboxes. Assignment pickers offer leaf categories only, matching what the server accepts, and an existing name is selected rather than duplicated. Creating during an **Analyse** review keeps the preview applicable as long as the transactions themselves are unchanged.
|
||||||
|
|
||||||
## Data, backups, and recovery
|
## Data, backups, and recovery
|
||||||
|
|
||||||
Back up the **entire canonical finance directory**, including registry files, journals, `config.toml` when present, and operational/recovery state, plus any separately stored environment-managed secrets. `state/openrouter.json` and `state/enablebanking.json` contain UI-managed credentials: protect backups accordingly, including the matching banking session state. Stop the service for a consistent filesystem backup. DuckDB under `cache/` can be excluded and rebuilt.
|
Back up the **entire canonical finance directory**, including registry files, journals, `config.toml` when present, and operational/recovery state, plus any separately stored environment-managed secrets. `state/openrouter.json` and `state/enablebanking.json` contain UI-managed credentials: protect backups accordingly, including the matching banking session state. Stop the service for a consistent filesystem backup. DuckDB under `cache/` can be excluded and rebuilt.
|
||||||
|
|||||||
+40
-19
@@ -16,7 +16,9 @@ import type {
|
|||||||
} from "./api";
|
} from "./api";
|
||||||
import { categoryPath, money, request } from "./api";
|
import { categoryPath, money, request } from "./api";
|
||||||
import {
|
import {
|
||||||
|
CategoryCombobox,
|
||||||
Combobox,
|
Combobox,
|
||||||
|
createTag,
|
||||||
DateField,
|
DateField,
|
||||||
Empty,
|
Empty,
|
||||||
ErrorMessage,
|
ErrorMessage,
|
||||||
@@ -24,12 +26,15 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
ModelOptions,
|
ModelOptions,
|
||||||
} from "./ui";
|
} from "./ui";
|
||||||
|
import type { Mutate } from "./ui";
|
||||||
export function Classification({
|
export function Classification({
|
||||||
state,
|
state,
|
||||||
acceptState,
|
acceptState,
|
||||||
|
mutate,
|
||||||
}: {
|
}: {
|
||||||
state: State;
|
state: State;
|
||||||
acceptState: (state: State, message?: string) => void;
|
acceptState: (state: State, message?: string) => void;
|
||||||
|
mutate: Mutate;
|
||||||
}) {
|
}) {
|
||||||
const dates = state.data.transactions.map((t) => t.facts.booking_date).sort();
|
const dates = state.data.transactions.map((t) => t.facts.booking_date).sort();
|
||||||
const [from, setFrom] = useState(dates[0] || "");
|
const [from, setFrom] = useState(dates[0] || "");
|
||||||
@@ -482,6 +487,7 @@ export function Classification({
|
|||||||
value={effective(change)}
|
value={effective(change)}
|
||||||
edited={change.id in edits}
|
edited={change.id in edits}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
|
mutate={mutate}
|
||||||
onChange={(value) => correct(change, value)}
|
onChange={(value) => correct(change, value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -667,14 +673,18 @@ interface CorrectionValue {
|
|||||||
}
|
}
|
||||||
// CorrectionEditor is the "Proposed" side of a review row, editable in place.
|
// CorrectionEditor is the "Proposed" side of a review row, editable in place.
|
||||||
// Category and tags are free-text inputs that autocomplete against the
|
// Category and tags are free-text inputs that autocomplete against the
|
||||||
// existing taxonomy; the category list is limited to leaves of the change's
|
// existing taxonomy and can create a missing entry in place; the category
|
||||||
// kind because that is what validation will accept.
|
// list is limited to leaves of the change's kind because that is what
|
||||||
|
// validation will accept. Creating mid-review bumps the journal revision,
|
||||||
|
// which the apply path tolerates as long as the transactions themselves are
|
||||||
|
// untouched.
|
||||||
function CorrectionEditor({
|
function CorrectionEditor({
|
||||||
data,
|
data,
|
||||||
change,
|
change,
|
||||||
value,
|
value,
|
||||||
edited,
|
edited,
|
||||||
disabled,
|
disabled,
|
||||||
|
mutate,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
data: Dataset;
|
data: Dataset;
|
||||||
@@ -682,15 +692,9 @@ function CorrectionEditor({
|
|||||||
value: CorrectionValue;
|
value: CorrectionValue;
|
||||||
edited: boolean;
|
edited: boolean;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
|
mutate: Mutate;
|
||||||
onChange: (value: CorrectionValue) => void;
|
onChange: (value: CorrectionValue) => void;
|
||||||
}) {
|
}) {
|
||||||
const categories = data.categories
|
|
||||||
.filter(
|
|
||||||
(c) =>
|
|
||||||
c.kind === change.after.kind &&
|
|
||||||
!data.categories.some((child) => child.parent_id === c.id),
|
|
||||||
)
|
|
||||||
.map((c) => ({ value: c.id, label: categoryPath(data, c.id) }));
|
|
||||||
const addable = data.tags
|
const addable = data.tags
|
||||||
.filter((t) => !value.tag_ids.includes(t.id))
|
.filter((t) => !value.tag_ids.includes(t.id))
|
||||||
.map((t) => ({ value: t.id, label: t.name }));
|
.map((t) => ({ value: t.id, label: t.name }));
|
||||||
@@ -728,13 +732,14 @@ function CorrectionEditor({
|
|||||||
<div>
|
<div>
|
||||||
<dt>Category</dt>
|
<dt>Category</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<Combobox
|
<CategoryCombobox
|
||||||
options={categories}
|
data={data}
|
||||||
|
kind={change.after.kind}
|
||||||
|
leavesOnly
|
||||||
|
mutate={mutate}
|
||||||
value={value.category_id}
|
value={value.category_id}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={(category_id) => onChange({ ...value, category_id })}
|
onChange={(category_id) => onChange({ ...value, category_id })}
|
||||||
placeholder="Search categories"
|
|
||||||
emptyText="No matching category. Create it in Categories first."
|
|
||||||
/>
|
/>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
@@ -763,16 +768,32 @@ function CorrectionEditor({
|
|||||||
<Combobox
|
<Combobox
|
||||||
options={addable}
|
options={addable}
|
||||||
value=""
|
value=""
|
||||||
disabled={disabled || !addable.length}
|
disabled={disabled}
|
||||||
onChange={(id) =>
|
onChange={(id) =>
|
||||||
onChange({ ...value, tag_ids: [...value.tag_ids, id] })
|
onChange({ ...value, tag_ids: [...value.tag_ids, id] })
|
||||||
}
|
}
|
||||||
placeholder={
|
placeholder={data.tags.length ? "Add tag" : "Add or create tag"}
|
||||||
data.tags.length
|
emptyText="No matching tag. Type a name to create it."
|
||||||
? "Add tag"
|
create={(text) =>
|
||||||
: "No tags yet — create them in Tags"
|
data.tags.some(
|
||||||
|
(t) => t.name.toLowerCase() === text.toLowerCase(),
|
||||||
|
)
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
key: "tag",
|
||||||
|
label: `Create tag "${text}"`,
|
||||||
|
run: async () =>
|
||||||
|
onChange({
|
||||||
|
...value,
|
||||||
|
tag_ids: [
|
||||||
|
...value.tag_ids,
|
||||||
|
await createTag(mutate, data, text),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]
|
||||||
}
|
}
|
||||||
emptyText="No matching tag. Create it in Tags first."
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</dd>
|
</dd>
|
||||||
|
|||||||
+19
-15
@@ -21,7 +21,7 @@ import type {
|
|||||||
} from "./api";
|
} from "./api";
|
||||||
import { categoryPath, request } from "./api";
|
import { categoryPath, request } from "./api";
|
||||||
import {
|
import {
|
||||||
CategoryOptions,
|
CategoryCombobox,
|
||||||
Empty,
|
Empty,
|
||||||
ErrorMessage,
|
ErrorMessage,
|
||||||
Field,
|
Field,
|
||||||
@@ -550,17 +550,15 @@ function RegistryEditor({
|
|||||||
</select>
|
</select>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Parent category">
|
<Field label="Parent category">
|
||||||
<select
|
<CategoryCombobox
|
||||||
value={parent}
|
|
||||||
onChange={(e) => setParent(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">No parent (root)</option>
|
|
||||||
<CategoryOptions
|
|
||||||
data={data}
|
data={data}
|
||||||
kind={kind}
|
kind={kind}
|
||||||
exclude={[...descendants]}
|
exclude={[...descendants]}
|
||||||
|
emptyLabel="No parent (root)"
|
||||||
|
mutate={mutate}
|
||||||
|
value={parent}
|
||||||
|
onChange={setParent}
|
||||||
/>
|
/>
|
||||||
</select>
|
|
||||||
</Field>
|
</Field>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
Changing the parent moves this category and its entire subtree.
|
Changing the parent moves this category and its entire subtree.
|
||||||
@@ -590,15 +588,21 @@ function RegistryEditor({
|
|||||||
Use these defaults when this merchant is recognized
|
Use these defaults when this merchant is recognized
|
||||||
</label>
|
</label>
|
||||||
<Field label="Default category">
|
<Field label="Default category">
|
||||||
<select
|
<CategoryCombobox
|
||||||
|
data={data}
|
||||||
|
leavesOnly
|
||||||
|
emptyLabel="No default category"
|
||||||
|
mutate={mutate}
|
||||||
value={category}
|
value={category}
|
||||||
onChange={(e) => setCategory(e.target.value)}
|
onChange={setCategory}
|
||||||
>
|
/>
|
||||||
<option value="">No default category</option>
|
|
||||||
<CategoryOptions data={data} />
|
|
||||||
</select>
|
|
||||||
</Field>
|
</Field>
|
||||||
<TagPicker data={data} value={tags} onChange={setTags} />
|
<TagPicker
|
||||||
|
data={data}
|
||||||
|
value={tags}
|
||||||
|
onChange={setTags}
|
||||||
|
mutate={mutate}
|
||||||
|
/>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
Defaults are only used when explicitly enabled. Editing defaults
|
Defaults are only used when explicitly enabled. Editing defaults
|
||||||
does not rewrite existing transactions.
|
does not rewrite existing transactions.
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import type {
|
|||||||
} from "./api";
|
} from "./api";
|
||||||
import { categoryPath, money } from "./api";
|
import { categoryPath, money } from "./api";
|
||||||
import {
|
import {
|
||||||
CategoryOptions,
|
CategoryCombobox,
|
||||||
Empty,
|
Empty,
|
||||||
ErrorMessage,
|
ErrorMessage,
|
||||||
Field,
|
Field,
|
||||||
@@ -453,16 +453,15 @@ function TransactionEditor({
|
|||||||
</div>
|
</div>
|
||||||
{value.kind !== "transfer" && value.kind !== "investment" && (
|
{value.kind !== "transfer" && value.kind !== "investment" && (
|
||||||
<Field label="Category">
|
<Field label="Category">
|
||||||
<select
|
<CategoryCombobox
|
||||||
|
data={data}
|
||||||
|
kind={value.kind}
|
||||||
|
leavesOnly
|
||||||
required
|
required
|
||||||
|
mutate={mutate}
|
||||||
value={value.category_id || ""}
|
value={value.category_id || ""}
|
||||||
onChange={(e) =>
|
onChange={(category_id) => setValue({ ...value, category_id })}
|
||||||
setValue({ ...value, category_id: e.target.value })
|
/>
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="">Choose category</option>
|
|
||||||
<CategoryOptions data={data} kind={value.kind} />
|
|
||||||
</select>
|
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
<TransferLink
|
<TransferLink
|
||||||
@@ -475,6 +474,7 @@ function TransactionEditor({
|
|||||||
data={data}
|
data={data}
|
||||||
value={value.tag_ids}
|
value={value.tag_ids}
|
||||||
onChange={(tag_ids) => setValue({ ...value, tag_ids })}
|
onChange={(tag_ids) => setValue({ ...value, tag_ids })}
|
||||||
|
mutate={mutate}
|
||||||
/>
|
/>
|
||||||
<details open>
|
<details open>
|
||||||
<summary>
|
<summary>
|
||||||
|
|||||||
+10
-20
@@ -136,13 +136,12 @@ function App() {
|
|||||||
"/api/rebuild",
|
"/api/rebuild",
|
||||||
].includes(path);
|
].includes(path);
|
||||||
try {
|
try {
|
||||||
acceptState(
|
const next = await request<State>(
|
||||||
await request<State>(
|
|
||||||
path,
|
path,
|
||||||
revisionless ? body : { revision: state.revision, ...body },
|
revisionless ? body : { revision: state.revision, ...body },
|
||||||
),
|
|
||||||
message,
|
|
||||||
);
|
);
|
||||||
|
acceptState(next, message);
|
||||||
|
return next;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof APIError && err.status === 409) setConflict(true);
|
if (err instanceof APIError && err.status === 409) setConflict(true);
|
||||||
throw err;
|
throw err;
|
||||||
@@ -352,7 +351,6 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
{page === "transactions" && (
|
{page === "transactions" && (
|
||||||
<Transactions
|
<Transactions
|
||||||
key={state.revision}
|
|
||||||
data={state.data}
|
data={state.data}
|
||||||
filter={filter}
|
filter={filter}
|
||||||
setFilter={setFilter}
|
setFilter={setFilter}
|
||||||
@@ -361,7 +359,6 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
{page === "categories" && (
|
{page === "categories" && (
|
||||||
<Registry
|
<Registry
|
||||||
key={`categories-${state.revision}`}
|
|
||||||
entity="category"
|
entity="category"
|
||||||
data={state.data}
|
data={state.data}
|
||||||
mutate={mutate}
|
mutate={mutate}
|
||||||
@@ -371,24 +368,13 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{page === "tags" && (
|
{page === "tags" && (
|
||||||
<Registry
|
<Registry entity="tag" data={state.data} mutate={mutate} />
|
||||||
key={`tags-${state.revision}`}
|
|
||||||
entity="tag"
|
|
||||||
data={state.data}
|
|
||||||
mutate={mutate}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
{page === "merchants" && (
|
{page === "merchants" && (
|
||||||
<Registry
|
<Registry entity="merchant" data={state.data} mutate={mutate} />
|
||||||
key={`merchants-${state.revision}`}
|
|
||||||
entity="merchant"
|
|
||||||
data={state.data}
|
|
||||||
mutate={mutate}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
{page === "instruments" && (
|
{page === "instruments" && (
|
||||||
<Registry
|
<Registry
|
||||||
key={`instruments-${state.revision}`}
|
|
||||||
entity="instrument"
|
entity="instrument"
|
||||||
data={state.data}
|
data={state.data}
|
||||||
mutate={mutate}
|
mutate={mutate}
|
||||||
@@ -409,7 +395,11 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{page === "classification" && (
|
{page === "classification" && (
|
||||||
<Classification state={state} acceptState={acceptState} />
|
<Classification
|
||||||
|
state={state}
|
||||||
|
acceptState={acceptState}
|
||||||
|
mutate={mutate}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{page === "settings" && (
|
{page === "settings" && (
|
||||||
<Settings
|
<Settings
|
||||||
|
|||||||
@@ -966,6 +966,23 @@ tbody tr:hover {
|
|||||||
color: #546779;
|
color: #546779;
|
||||||
padding: 0 5px;
|
padding: 0 5px;
|
||||||
}
|
}
|
||||||
|
/* Inline tag creation inside the picker: a small input plus one button, so a
|
||||||
|
missing tag never forces a detour through the Tags page. */
|
||||||
|
.tag-add {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
.tag-add input {
|
||||||
|
width: 140px;
|
||||||
|
padding: 6px 9px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.tag-add-error {
|
||||||
|
flex-basis: 100%;
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
.check-chip {
|
.check-chip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2075,6 +2092,17 @@ footer span:first-child {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
.combo-option.create {
|
||||||
|
color: var(--emerald);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.combo-option.create svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
.combo-empty.error {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
/* The proposed side of a review row is editable in place: compact combobox
|
/* The proposed side of a review row is editable in place: compact combobox
|
||||||
inputs so a correction fits the diff card, removable chips for tags. */
|
inputs so a correction fits the diff card, removable chips for tags. */
|
||||||
.diff-value .combo > input {
|
.diff-value .combo > input {
|
||||||
|
|||||||
+251
-7
@@ -7,8 +7,9 @@ import {
|
|||||||
CalendarDays,
|
CalendarDays,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
Plus,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Dataset, Filter, VerifiedModel } from "./api";
|
import type { Category, Dataset, Filter, State, VerifiedModel } from "./api";
|
||||||
import {
|
import {
|
||||||
categoryPath,
|
categoryPath,
|
||||||
DEFAULT_MONTHS,
|
DEFAULT_MONTHS,
|
||||||
@@ -103,6 +104,14 @@ export interface ComboOption {
|
|||||||
label: string;
|
label: string;
|
||||||
icon?: ReactNode;
|
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
|
// 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
|
// 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
|
// picking an option reports its value. The caller keeps working with stable
|
||||||
@@ -116,6 +125,7 @@ export function Combobox({
|
|||||||
required = false,
|
required = false,
|
||||||
adornment,
|
adornment,
|
||||||
emptyText = "No matches.",
|
emptyText = "No matches.",
|
||||||
|
create,
|
||||||
}: {
|
}: {
|
||||||
options: ComboOption[];
|
options: ComboOption[];
|
||||||
value: string;
|
value: string;
|
||||||
@@ -125,7 +135,10 @@ export function Combobox({
|
|||||||
required?: boolean;
|
required?: boolean;
|
||||||
adornment?: ReactNode;
|
adornment?: ReactNode;
|
||||||
emptyText?: string;
|
emptyText?: string;
|
||||||
|
create?: (text: string) => ComboCreate[];
|
||||||
}) {
|
}) {
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [createError, setCreateError] = useState("");
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const filter = query.trim().toLowerCase();
|
const filter = query.trim().toLowerCase();
|
||||||
@@ -137,10 +150,25 @@ export function Combobox({
|
|||||||
? [exact, ...matches.filter((o) => o !== exact).slice(0, 59)]
|
? [exact, ...matches.filter((o) => o !== exact).slice(0, 59)]
|
||||||
: matches.slice(0, 60);
|
: matches.slice(0, 60);
|
||||||
const selected = options.find((o) => o.value === value);
|
const selected = options.find((o) => o.value === value);
|
||||||
|
const creations =
|
||||||
|
create && filter && !exact && !disabled ? create(query.trim()) : [];
|
||||||
const pick = (v: string) => {
|
const pick = (v: string) => {
|
||||||
onChange(v);
|
onChange(v);
|
||||||
setOpen(false);
|
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 (
|
return (
|
||||||
<div className="combo">
|
<div className="combo">
|
||||||
<input
|
<input
|
||||||
@@ -157,6 +185,7 @@ export function Combobox({
|
|||||||
}}
|
}}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setQuery(e.target.value);
|
setQuery(e.target.value);
|
||||||
|
setCreateError("");
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
}}
|
}}
|
||||||
onBlur={() => setOpen(false)}
|
onBlur={() => setOpen(false)}
|
||||||
@@ -166,6 +195,10 @@ export function Combobox({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const hit = exact ?? (shown.length === 1 ? shown[0] : undefined);
|
const hit = exact ?? (shown.length === 1 ? shown[0] : undefined);
|
||||||
if (hit) pick(hit.value);
|
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>
|
</button>
|
||||||
</li>
|
</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 && (
|
{matches.length > shown.length && (
|
||||||
<li className="combo-empty">
|
<li className="combo-empty">
|
||||||
{matches.length - shown.length} more — keep typing to narrow down.
|
{matches.length - shown.length} more — keep typing to narrow down.
|
||||||
@@ -461,20 +511,86 @@ export function Empty({
|
|||||||
</div>
|
</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({
|
export function TagPicker({
|
||||||
data,
|
data,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
mutate,
|
||||||
}: {
|
}: {
|
||||||
data: Dataset;
|
data: Dataset;
|
||||||
value: string[];
|
value: string[];
|
||||||
onChange: (ids: string[]) => void;
|
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 (
|
return (
|
||||||
<fieldset className="tag-picker">
|
<fieldset className="tag-picker">
|
||||||
<legend>Tags</legend>
|
<legend>Tags</legend>
|
||||||
{data.tags.length ? (
|
{data.tags.map((tag) => (
|
||||||
data.tags.map((tag) => (
|
|
||||||
<label className="check-chip" key={tag.id}>
|
<label className="check-chip" key={tag.id}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -489,10 +605,41 @@ export function TagPicker({
|
|||||||
/>
|
/>
|
||||||
{tag.name}
|
{tag.name}
|
||||||
</label>
|
</label>
|
||||||
))
|
))}
|
||||||
) : (
|
{!data.tags.length && !mutate && (
|
||||||
<small>No tags yet. Create them in Tags.</small>
|
<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>
|
</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({
|
export function Filters({
|
||||||
data,
|
data,
|
||||||
value,
|
value,
|
||||||
@@ -680,8 +922,10 @@ export function FormActions({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Mutate posts a revisioned change and returns the accepted state, so a
|
||||||
|
// caller can find ids the server just minted.
|
||||||
export type Mutate = (
|
export type Mutate = (
|
||||||
path: string,
|
path: string,
|
||||||
body: Record<string, unknown>,
|
body: Record<string, unknown>,
|
||||||
message?: string,
|
message?: string,
|
||||||
) => Promise<void>;
|
) => Promise<State>;
|
||||||
|
|||||||
Reference in New Issue
Block a user