feat(categories): type-ahead combobox for the parent and keyword category fields #428
5 changed files with 123 additions and 11 deletions
|
|
@ -33,11 +33,11 @@ export default function AddKeywordDialog({
|
||||||
}: AddKeywordDialogProps) {
|
}: AddKeywordDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [keyword, setKeyword] = useState(initialKeyword);
|
const [keyword, setKeyword] = useState(initialKeyword);
|
||||||
// Held at null until the list is in: the pre-filled category is only kept
|
// Held at null until the list is in, which keeps the apply button disabled
|
||||||
// when it is among the categories the dialog offers (`reconcileInitialCategory`).
|
// meanwhile: the pre-filled category is only kept when it is among the
|
||||||
|
// categories the dialog offers (`reconcileInitialCategory`).
|
||||||
const [categoryId, setCategoryId] = useState<number | null>(null);
|
const [categoryId, setCategoryId] = useState<number | null>(null);
|
||||||
const [categories, setCategories] = useState<Category[]>([]);
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
const [categoriesLoaded, setCategoriesLoaded] = useState(false);
|
|
||||||
const [checked, setChecked] = useState<Set<number>>(new Set());
|
const [checked, setChecked] = useState<Set<number>>(new Set());
|
||||||
const [applyToHidden, setApplyToHidden] = useState(false);
|
const [applyToHidden, setApplyToHidden] = useState(false);
|
||||||
const [preview, setPreview] = useState<PreviewState>({ kind: "idle" });
|
const [preview, setPreview] = useState<PreviewState>({ kind: "idle" });
|
||||||
|
|
@ -55,13 +55,11 @@ export default function AddKeywordDialog({
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setCategories(rows);
|
setCategories(rows);
|
||||||
setCategoryId(reconcileInitialCategory(initialCategoryId, rows));
|
setCategoryId(reconcileInitialCategory(initialCategoryId, rows));
|
||||||
setCategoriesLoaded(true);
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setCategories([]);
|
setCategories([]);
|
||||||
setCategoryId(null);
|
setCategoryId(null);
|
||||||
setCategoriesLoaded(true);
|
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
|
@ -101,7 +99,6 @@ export default function AddKeywordDialog({
|
||||||
|
|
||||||
const canApply =
|
const canApply =
|
||||||
validation.ok &&
|
validation.ok &&
|
||||||
categoriesLoaded &&
|
|
||||||
categoryId !== null &&
|
categoryId !== null &&
|
||||||
preview.kind === "ready" &&
|
preview.kind === "ready" &&
|
||||||
!isApplying &&
|
!isApplying &&
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
// CategoryDetailPanel — the edited category's id reaches the parent options (#423).
|
||||||
|
// No jsdom: react-dom/server renders in vitest's node environment.
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (k: string, o?: Record<string, unknown>) =>
|
||||||
|
o ? `${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}]` : k,
|
||||||
|
i18n: { language: "fr" },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./parentOptions", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("./parentOptions")>();
|
||||||
|
return { ...actual, buildParentOptions: vi.fn(actual.buildParentOptions) };
|
||||||
|
});
|
||||||
|
|
||||||
|
import CategoryDetailPanel from "./CategoryDetailPanel";
|
||||||
|
import { buildParentOptions } from "./parentOptions";
|
||||||
|
import type { CategoryFormData, CategoryTreeNode } from "../../shared/types";
|
||||||
|
|
||||||
|
const node = (id: number, parent_id: number | null, name: string): CategoryTreeNode => ({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
parent_id,
|
||||||
|
color: null,
|
||||||
|
icon: null,
|
||||||
|
type: "expense",
|
||||||
|
is_active: true,
|
||||||
|
is_inputable: true,
|
||||||
|
sort_order: id,
|
||||||
|
keyword_count: 0,
|
||||||
|
i18n_key: null,
|
||||||
|
children: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const HOUSING = node(1, null, "Logement");
|
||||||
|
const RENT = node(10, 1, "Loyer");
|
||||||
|
const CATEGORIES = [HOUSING, RENT];
|
||||||
|
|
||||||
|
const formOf = (c: CategoryTreeNode): CategoryFormData => ({
|
||||||
|
name: c.name,
|
||||||
|
type: c.type,
|
||||||
|
color: "#4A90A4",
|
||||||
|
parent_id: c.parent_id,
|
||||||
|
is_inputable: c.is_inputable,
|
||||||
|
sort_order: c.sort_order,
|
||||||
|
});
|
||||||
|
|
||||||
|
const markup = (selected: CategoryTreeNode | null, editing: CategoryFormData | null, isCreating: boolean) =>
|
||||||
|
renderToStaticMarkup(
|
||||||
|
<CategoryDetailPanel
|
||||||
|
categories={CATEGORIES}
|
||||||
|
selectedCategory={selected}
|
||||||
|
keywords={[]}
|
||||||
|
editingCategory={editing}
|
||||||
|
isCreating={isCreating}
|
||||||
|
isSaving={false}
|
||||||
|
onStartEditing={vi.fn()}
|
||||||
|
onCancelEditing={vi.fn()}
|
||||||
|
onSave={vi.fn()}
|
||||||
|
onDelete={vi.fn(async () => ({ blocked: false, count: 0 }))}
|
||||||
|
onAddKeyword={vi.fn()}
|
||||||
|
onUpdateKeyword={vi.fn()}
|
||||||
|
onRemoveKeyword={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("CategoryDetailPanel — editingId wiring (#423)", () => {
|
||||||
|
it("passes the selected category's id when editing", () => {
|
||||||
|
vi.mocked(buildParentOptions).mockClear();
|
||||||
|
const html = markup(RENT, formOf(RENT), false);
|
||||||
|
expect(html).toContain('role="combobox"');
|
||||||
|
expect(buildParentOptions).toHaveBeenCalledWith(CATEGORIES, { editingId: 10, currentParentId: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes no id when creating", () => {
|
||||||
|
vi.mocked(buildParentOptions).mockClear();
|
||||||
|
const html = markup(null, formOf(node(0, null, "")), true);
|
||||||
|
expect(html).toContain('role="combobox"');
|
||||||
|
expect(buildParentOptions).toHaveBeenCalledWith(CATEGORIES, { editingId: null, currentParentId: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -12,7 +12,14 @@ vi.mock("react-i18next", () => ({
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Pass-through spy on the pure module, to pin what the form hands it.
|
||||||
|
vi.mock("./parentOptions", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("./parentOptions")>();
|
||||||
|
return { ...actual, buildParentOptions: vi.fn(actual.buildParentOptions) };
|
||||||
|
});
|
||||||
|
|
||||||
import CategoryForm from "./CategoryForm";
|
import CategoryForm from "./CategoryForm";
|
||||||
|
import { buildParentOptions } from "./parentOptions";
|
||||||
import type { CategoryFormData, CategoryTreeNode } from "../../shared/types";
|
import type { CategoryFormData, CategoryTreeNode } from "../../shared/types";
|
||||||
|
|
||||||
const node = (
|
const node = (
|
||||||
|
|
@ -92,4 +99,16 @@ describe("CategoryForm — parent field (#423)", () => {
|
||||||
it("shows a user-created parent by its name", () => {
|
it("shows a user-created parent by its name", () => {
|
||||||
expect(comboboxInput(markup(form(10), 42))).toContain('value="Loyer"');
|
expect(comboboxInput(markup(form(10), 42))).toContain('value="Loyer"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("hands the edited id and the stored parent to buildParentOptions", () => {
|
||||||
|
vi.mocked(buildParentOptions).mockClear();
|
||||||
|
markup(form(1), 10);
|
||||||
|
expect(buildParentOptions).toHaveBeenCalledWith(CATEGORIES, { editingId: 10, currentParentId: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hands no edited id when creating", () => {
|
||||||
|
vi.mocked(buildParentOptions).mockClear();
|
||||||
|
markup(form(null));
|
||||||
|
expect(buildParentOptions).toHaveBeenCalledWith(CATEGORIES, { editingId: null, currentParentId: null });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -96,4 +96,11 @@ describe("buildParentOptions — editing a category", () => {
|
||||||
expect(offered).not.toContain(3);
|
expect(offered).not.toContain(3);
|
||||||
expect(offered.every((id) => id === 1 || id === 2)).toBe(true);
|
expect(offered.every((id) => id === 1 || id === 2)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("terminates when the edited category is itself a member of a cycle", () => {
|
||||||
|
// 1 <-> 2: the height walk under 1 meets 1 again and stops (height 1, not
|
||||||
|
// unbounded). Only roots remain, so the root outside the cycle is offered.
|
||||||
|
const cyclic = [node(1, 2), node(2, 1), node(3, null)];
|
||||||
|
expect(ids(buildParentOptions(cyclic, { editingId: 1 }))).toEqual([3]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,8 @@ function depthsOf(categories: CategoryTreeNode[]): Map<number, number> {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Height of the sub-tree under `rootId`: 0 for a leaf, 1 with children, 2 with
|
* Height of the sub-tree under `rootId`: 0 for a leaf, 1 with children, 2 with
|
||||||
* grandchildren. Walks `parent_id` links; a cycle is not walked twice.
|
* grandchildren. Walks `parent_id` links; a cycle is not walked twice, and the
|
||||||
|
* walk can never take more steps than there are categories.
|
||||||
*/
|
*/
|
||||||
function subtreeHeight(categories: CategoryTreeNode[], rootId: number): number {
|
function subtreeHeight(categories: CategoryTreeNode[], rootId: number): number {
|
||||||
const childrenOf = new Map<number, number[]>();
|
const childrenOf = new Map<number, number[]>();
|
||||||
|
|
@ -49,7 +50,7 @@ function subtreeHeight(categories: CategoryTreeNode[], rootId: number): number {
|
||||||
let height = 0;
|
let height = 0;
|
||||||
const seen = new Set<number>([rootId]);
|
const seen = new Set<number>([rootId]);
|
||||||
let frontier = [rootId];
|
let frontier = [rootId];
|
||||||
while (frontier.length > 0) {
|
while (frontier.length > 0 && height < categories.length) {
|
||||||
const next: number[] = [];
|
const next: number[] = [];
|
||||||
for (const id of frontier) {
|
for (const id of frontier) {
|
||||||
for (const child of childrenOf.get(id) ?? []) {
|
for (const child of childrenOf.get(id) ?? []) {
|
||||||
|
|
@ -79,6 +80,10 @@ function subtreeHeight(categories: CategoryTreeNode[], rootId: number): number {
|
||||||
* deeper than any depth still allowed.
|
* deeper than any depth still allowed.
|
||||||
* - The parent currently set on the form is always kept when it exists.
|
* - The parent currently set on the form is always kept when it exists.
|
||||||
*
|
*
|
||||||
|
* This list is the only guard: `updateCategory` writes any `parent_id` it is
|
||||||
|
* given, without a depth or cycle check (#393 will add one). The form therefore
|
||||||
|
* never offers a new parent that the rules above reject.
|
||||||
|
*
|
||||||
* Ordering is left to the caller (`CategoryCombobox` sorts hierarchically).
|
* Ordering is left to the caller (`CategoryCombobox` sorts hierarchically).
|
||||||
*/
|
*/
|
||||||
export function buildParentOptions(
|
export function buildParentOptions(
|
||||||
|
|
@ -95,9 +100,9 @@ export function buildParentOptions(
|
||||||
|
|
||||||
return categories.filter((c) => {
|
return categories.filter((c) => {
|
||||||
if (c.id === editingId) return false;
|
if (c.id === editingId) return false;
|
||||||
// Kept even when the depth rule or the descent rule would drop it: the
|
// Kept even when the depth rule would drop it: the field must show what is
|
||||||
// field must show what is stored. Saving an invalid parent is refused by
|
// stored. Keeping it changes nothing on save (it is already the parent);
|
||||||
// the service-side validation of #393, not here.
|
// repairing such data is the job of #393.
|
||||||
if (c.id === currentParentId) return true;
|
if (c.id === currentParentId) return true;
|
||||||
return (depths.get(c.id) ?? 0) <= maxDepth;
|
return (depths.get(c.id) ?? 0) <= maxDepth;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue