Simpl-Resultat/src/components/shared/CategoryCombobox.tsx
le king fu 871768593d
All checks were successful
PR Check / rust (push) Successful in 22m7s
PR Check / frontend (push) Successful in 2m19s
PR Check / rust (pull_request) Successful in 21m37s
PR Check / frontend (pull_request) Successful in 2m14s
fix(reports): render category combobox in hierarchical DFS order (#126)
The by-category report combobox (`/reports/category`) was showing its full
category list with scrambled indentation — parents from one sub-tree
interleaved with children from another. Root cause: `getAllCategoriesWithCounts`
returns rows via `ORDER BY sort_order, name`, which is a *global* sort; two
different roots with sort_order=1 would be followed by their respective
children in the same bucket, mixing depths together.

Add a pure `sortHierarchical(categories, resolveName)` helper in
`CategoryCombobox.tsx` that rebuilds the display order as a DFS walk of the
tree: each parent is emitted immediately followed by its descendants, with
siblings within a group sorted by `sort_order` then localized display name.
Orphans (parent filtered out or missing) are appended at the end so nothing
disappears. The helper runs client-side inside the combobox's `useMemo`, so
the fix is scoped to this component and doesn't affect other consumers of
`getAllCategoriesWithCounts`. Filtering on the input query remains unchanged.

Covered by 7 unit tests on the helper (empty list, single root, the exact
bug-reproducing scrambled case, sort_order + name tiebreak, 3-level
hierarchy, orphans, idempotence).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 20:58:53 -04:00

322 lines
11 KiB
TypeScript

import { useState, useRef, useEffect, useCallback, useId, useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { Category } from "../../shared/types";
interface CategoryComboboxProps {
categories: Category[];
value: number | null;
onChange: (id: number | null) => void;
placeholder?: string;
compact?: boolean;
ariaLabel?: string;
/** Extra options shown before the category list (e.g. "All categories", "Uncategorized") */
extraOptions?: Array<{ value: string; label: string }>;
/** Called when an extra option is selected */
onExtraSelect?: (value: string) => void;
/** Currently active extra option value (for display) */
activeExtra?: string | null;
}
// Strip accents + lowercase for accent-insensitive matching
function normalize(s: string): string {
return s.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
}
// Compute depth of each category based on parent_id chain
function computeDepths(categories: Category[]): Map<number, number> {
const byId = new Map<number, Category>();
for (const c of categories) byId.set(c.id, c);
const depths = new Map<number, number>();
function depthOf(id: number, seen: Set<number>): number {
if (depths.has(id)) return depths.get(id)!;
if (seen.has(id)) return 0;
seen.add(id);
const cat = byId.get(id);
if (!cat || cat.parent_id == null) {
depths.set(id, 0);
return 0;
}
const d = depthOf(cat.parent_id, seen) + 1;
depths.set(id, d);
return d;
}
for (const c of categories) depthOf(c.id, new Set());
return depths;
}
/**
* Order a flat list of categories in hierarchical DFS order: each root is
* emitted immediately followed by its descendants (depth-first, parent before
* children). Siblings within a group are ordered by `sort_order` ascending,
* then by `resolveName(cat)` for stable tiebreaking.
*
* A plain `ORDER BY sort_order, name` in SQL mixes parents and children from
* different sub-trees that happen to share the same `sort_order`, producing
* the scrambled indentation we saw in the by-category report combobox.
* Doing the DFS client-side keeps rendering correct regardless of query shape.
*
* Orphans (category whose parent is missing or inactive / filtered out) are
* emitted at the end, each treated as a pseudo-root, so nothing disappears.
*/
export function sortHierarchical(
categories: Category[],
resolveName: (cat: Category) => string,
): Category[] {
if (categories.length === 0) return [];
const ids = new Set<number>();
for (const c of categories) ids.add(c.id);
// Group by parent bucket: root (`null`) or parent id.
const childrenByParent = new Map<number | null, Category[]>();
const orphans: Category[] = [];
for (const c of categories) {
if (c.parent_id == null) {
const bucket = childrenByParent.get(null) ?? [];
bucket.push(c);
childrenByParent.set(null, bucket);
} else if (ids.has(c.parent_id)) {
const bucket = childrenByParent.get(c.parent_id) ?? [];
bucket.push(c);
childrenByParent.set(c.parent_id, bucket);
} else {
orphans.push(c);
}
}
const compare = (a: Category, b: Category) => {
if (a.sort_order !== b.sort_order) return a.sort_order - b.sort_order;
return resolveName(a).localeCompare(resolveName(b));
};
for (const bucket of childrenByParent.values()) bucket.sort(compare);
orphans.sort(compare);
const out: Category[] = [];
const visited = new Set<number>();
const visit = (cat: Category) => {
if (visited.has(cat.id)) return; // defensive against cycles
visited.add(cat.id);
out.push(cat);
const kids = childrenByParent.get(cat.id);
if (kids) for (const child of kids) visit(child);
};
const roots = childrenByParent.get(null) ?? [];
for (const root of roots) visit(root);
// Append orphans last, still treated as pseudo-roots so their own children
// (if any were pulled in) follow them.
for (const orphan of orphans) visit(orphan);
return out;
}
export default function CategoryCombobox({
categories,
value,
onChange,
placeholder = "",
compact = false,
ariaLabel,
extraOptions,
onExtraSelect,
activeExtra,
}: CategoryComboboxProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [highlightIndex, setHighlightIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const baseId = useId();
const listboxId = `${baseId}-listbox`;
const optionId = (i: number) => `${baseId}-option-${i}`;
const depths = useMemo(() => computeDepths(categories), [categories]);
// Resolve the display name for a category: seed rows carry an i18n_key that
// we translate with name as defaultValue; user-created rows just use name.
const displayName = useCallback(
(c: Category) => (c.i18n_key ? t(c.i18n_key, { defaultValue: c.name }) : c.name),
[t]
);
// Re-order the (potentially sort_order-globally-sorted) input into proper
// hierarchical DFS order so parents always precede their children and
// siblings stay grouped under the same ancestor.
const orderedCategories = useMemo(
() => sortHierarchical(categories, displayName),
[categories, displayName],
);
const selectedCategory = orderedCategories.find((c) => c.id === value);
const displayLabel =
activeExtra != null
? extraOptions?.find((o) => o.value === activeExtra)?.label ?? ""
: selectedCategory
? displayName(selectedCategory)
: "";
const normalizedQuery = normalize(query);
const filtered = query
? orderedCategories.filter((c) => normalize(displayName(c)).includes(normalizedQuery))
: orderedCategories;
const filteredExtras = extraOptions
? query
? extraOptions.filter((o) => normalize(o.label).includes(normalizedQuery))
: extraOptions
: [];
const totalItems = filteredExtras.length + filtered.length;
useEffect(() => {
if (open && listRef.current) {
const el = listRef.current.children[highlightIndex] as HTMLElement | undefined;
el?.scrollIntoView({ block: "nearest" });
}
}, [highlightIndex, open]);
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
setQuery("");
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open]);
const selectItem = useCallback(
(index: number) => {
if (index < filteredExtras.length) {
onExtraSelect?.(filteredExtras[index].value);
} else {
const cat = filtered[index - filteredExtras.length];
if (cat) onChange(cat.id);
}
setOpen(false);
setQuery("");
inputRef.current?.blur();
},
[filteredExtras, filtered, onChange, onExtraSelect]
);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!open) {
if (e.key === "ArrowDown" || e.key === "Enter") {
e.preventDefault();
setOpen(true);
setHighlightIndex(0);
}
return;
}
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setHighlightIndex((i) => (i + 1) % totalItems);
break;
case "ArrowUp":
e.preventDefault();
setHighlightIndex((i) => (i - 1 + totalItems) % totalItems);
break;
case "Enter":
e.preventDefault();
if (totalItems > 0) selectItem(highlightIndex);
break;
case "Escape":
e.preventDefault();
setOpen(false);
setQuery("");
inputRef.current?.blur();
break;
}
};
const py = compact ? "py-1" : "py-2";
const px = compact ? "px-2" : "px-3";
const activeId = open && totalItems > 0 ? optionId(highlightIndex) : undefined;
return (
<div ref={containerRef} className="relative">
<input
ref={inputRef}
type="text"
role="combobox"
aria-label={ariaLabel}
aria-expanded={open}
aria-controls={listboxId}
aria-autocomplete="list"
aria-activedescendant={activeId}
value={open ? query : displayLabel}
placeholder={placeholder || displayLabel}
onChange={(e) => {
setQuery(e.target.value);
setHighlightIndex(0);
if (!open) setOpen(true);
}}
onFocus={() => {
setOpen(true);
setQuery("");
setHighlightIndex(0);
}}
onKeyDown={handleKeyDown}
className={`w-full ${px} ${py} text-sm rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] placeholder:text-[var(--muted-foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]`}
/>
{open && totalItems > 0 && (
<ul
ref={listRef}
id={listboxId}
role="listbox"
className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border border-[var(--border)] bg-[var(--card)] shadow-lg"
>
{filteredExtras.map((opt, i) => (
<li
key={`extra-${opt.value}`}
id={optionId(i)}
role="option"
aria-selected={i === highlightIndex}
onMouseDown={(e) => e.preventDefault()}
onClick={() => selectItem(i)}
onMouseEnter={() => setHighlightIndex(i)}
className={`${px} ${py} text-sm cursor-pointer ${
i === highlightIndex
? "bg-[var(--primary)] text-white"
: "text-[var(--foreground)] hover:bg-[var(--muted)]"
}`}
>
{opt.label}
</li>
))}
{filtered.map((cat, i) => {
const idx = filteredExtras.length + i;
const depth = depths.get(cat.id) ?? 0;
const indent = depth > 0 ? " ".repeat(depth) : "";
return (
<li
key={cat.id}
id={optionId(idx)}
role="option"
aria-selected={idx === highlightIndex}
onMouseDown={(e) => e.preventDefault()}
onClick={() => selectItem(idx)}
onMouseEnter={() => setHighlightIndex(idx)}
className={`${px} ${py} text-sm cursor-pointer ${
idx === highlightIndex
? "bg-[var(--primary)] text-white"
: "text-[var(--foreground)] hover:bg-[var(--muted)]"
}`}
style={depth > 0 ? { paddingLeft: `calc(${compact ? "0.5rem" : "0.75rem"} + ${depth * 1}rem)` } : undefined}
>
<span className="whitespace-pre">{indent}</span>
{displayName(cat)}
</li>
);
})}
</ul>
)}
</div>
);
}