Replace the two hand-rolled Set-of-ids collapse state machines in the category trees with the shared useCollapsibleGroups hook (a strict superset after #288), keeping each tree's distinct recursive render and CategoryTree's drag-and-drop untouched. - CategoryTree (Categories page): storageKey null + defaultExpanded true, so every parent opens with no seeding; drops the local Set + collectExpandable. - CategoryTaxonomyTree + guide page: storageKey null + defaultExpanded false (collapsed by default); exports a shared TAXONOMY_COLLAPSE_ACCESSORS. - Fix the guide's button bug: allExpanded = expanded.size > 0 flipped to "Collapse all" after opening a single node; now uses the hook's correct allExpanded (every group must be open). - Also migrate StepDiscover (4th consumer of CategoryTaxonomyTree, same button bug) onto the hook for a green build and consistency. Both trees pass a flattened node list to the bulk ops; behaviour preserved: Categories opens expanded, the guide/wizard open collapsed. Resolves #290 Generated autonomously by /autopilot run of 2026-07-15 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
186 lines
6.8 KiB
TypeScript
186 lines
6.8 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Link } from "react-router-dom";
|
|
import { ArrowLeft, Search, Printer, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
|
|
import { useCategoryTaxonomy } from "../hooks/useCategoryTaxonomy";
|
|
import CategoryTaxonomyTree, {
|
|
TAXONOMY_COLLAPSE_ACCESSORS,
|
|
} from "../components/categories/CategoryTaxonomyTree";
|
|
import type { TaxonomyNode } from "../services/categoryTaxonomyService";
|
|
import { useCollapsibleGroups } from "../hooks/useCollapsibleGroups";
|
|
|
|
function countNodes(nodes: TaxonomyNode[]): {
|
|
roots: number;
|
|
subcategories: number;
|
|
leaves: number;
|
|
} {
|
|
let roots = 0;
|
|
let subcategories = 0;
|
|
let leaves = 0;
|
|
for (const root of nodes) {
|
|
roots += 1;
|
|
for (const child of root.children) {
|
|
if (child.children.length === 0) {
|
|
// direct leaf under a root (rare but possible)
|
|
leaves += 1;
|
|
} else {
|
|
subcategories += 1;
|
|
for (const leaf of child.children) {
|
|
if (leaf.children.length === 0) leaves += 1;
|
|
else subcategories += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return { roots, subcategories, leaves };
|
|
}
|
|
|
|
// Flattens the taxonomy to a single array so the hook's bulk ops
|
|
// (expandAll/collapseAll/allExpanded/groupCount) can walk every parent at any depth.
|
|
function flattenNodes(nodes: TaxonomyNode[]): TaxonomyNode[] {
|
|
const flat: TaxonomyNode[] = [];
|
|
const walk = (n: TaxonomyNode) => {
|
|
flat.push(n);
|
|
n.children.forEach(walk);
|
|
};
|
|
nodes.forEach(walk);
|
|
return flat;
|
|
}
|
|
|
|
export default function CategoriesStandardGuidePage() {
|
|
const { t } = useTranslation();
|
|
const { taxonomy } = useCategoryTaxonomy();
|
|
const [search, setSearch] = useState("");
|
|
|
|
// State machine only (issue #290): in-memory (storageKey null), collapsed by
|
|
// default so the guide opens on roots — the previous local Set behaviour.
|
|
const groups = useCollapsibleGroups<TaxonomyNode>(null, TAXONOMY_COLLAPSE_ACCESSORS, {
|
|
defaultExpanded: false,
|
|
});
|
|
|
|
const counts = useMemo(() => countNodes(taxonomy.roots), [taxonomy.roots]);
|
|
const total = counts.roots + counts.subcategories + counts.leaves;
|
|
|
|
// Flattened nodes for the bulk ops (expand/collapse all, allExpanded).
|
|
const flatNodes = useMemo(() => flattenNodes(taxonomy.roots), [taxonomy.roots]);
|
|
|
|
const handleExpandAll = () => groups.expandAll(flatNodes);
|
|
const handleCollapseAll = () => groups.collapseAll(flatNodes);
|
|
|
|
const handlePrint = () => {
|
|
// window.print() opens the browser print dialog; @media print rules strip chrome.
|
|
window.print();
|
|
};
|
|
|
|
// Correct "all expanded" test (issue #290): every collapsible group must be open,
|
|
// not merely "at least one node open" (the old expanded.size > 0 bug, which
|
|
// flipped the button to "Collapse all" after a single node was expanded).
|
|
const allExpanded = groups.allExpanded(flatNodes);
|
|
|
|
return (
|
|
<div className="p-6 max-w-4xl mx-auto space-y-6">
|
|
{/* Back link (hidden in print) */}
|
|
<div className="print:hidden">
|
|
<Link
|
|
to="/settings/data"
|
|
className="inline-flex items-center gap-2 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
|
|
>
|
|
<ArrowLeft size={16} />
|
|
{t("categoriesSeed.guidePage.backToSettings")}
|
|
</Link>
|
|
</div>
|
|
|
|
{/* Header */}
|
|
<header className="space-y-1">
|
|
<h1 className="text-2xl font-bold">
|
|
{t("categoriesSeed.guidePage.title")}
|
|
</h1>
|
|
<p className="text-sm text-[var(--muted-foreground)]">
|
|
{t("categoriesSeed.guidePage.subtitle")}
|
|
</p>
|
|
</header>
|
|
|
|
{/* Intro card */}
|
|
<section className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-5 space-y-2">
|
|
<h2 className="text-lg font-semibold">
|
|
{t("categoriesSeed.guidePage.intro.title")}
|
|
</h2>
|
|
<p className="text-sm text-[var(--muted-foreground)]">
|
|
{t("categoriesSeed.guidePage.intro.body")}
|
|
</p>
|
|
</section>
|
|
|
|
{/* Counter + toolbar */}
|
|
<section className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-5 space-y-4">
|
|
<p
|
|
className="text-sm text-[var(--muted-foreground)]"
|
|
aria-live="polite"
|
|
>
|
|
{t("categoriesSeed.guidePage.counter", {
|
|
roots: counts.roots,
|
|
subcategories: counts.subcategories,
|
|
leaves: counts.leaves,
|
|
total,
|
|
})}
|
|
</p>
|
|
|
|
{/* Toolbar: search + actions (hidden in print) */}
|
|
<div className="print:hidden flex flex-col sm:flex-row gap-3">
|
|
<div className="relative flex-1">
|
|
<Search
|
|
size={16}
|
|
className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--muted-foreground)] pointer-events-none"
|
|
aria-hidden="true"
|
|
/>
|
|
<input
|
|
type="search"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder={t("categoriesSeed.guidePage.searchPlaceholder")}
|
|
aria-label={t("categoriesSeed.guidePage.searchPlaceholder")}
|
|
className="w-full pl-9 pr-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--background)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/30"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={allExpanded ? handleCollapseAll : handleExpandAll}
|
|
className="inline-flex items-center gap-2 px-3 py-2 text-sm rounded-lg border border-[var(--border)] hover:bg-[var(--muted)] transition-colors"
|
|
>
|
|
{allExpanded ? (
|
|
<>
|
|
<ChevronsDownUp size={16} />
|
|
{t("categoriesSeed.guidePage.collapseAll")}
|
|
</>
|
|
) : (
|
|
<>
|
|
<ChevronsUpDown size={16} />
|
|
{t("categoriesSeed.guidePage.expandAll")}
|
|
</>
|
|
)}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handlePrint}
|
|
className="inline-flex items-center gap-2 px-3 py-2 text-sm rounded-lg bg-[var(--primary)] text-white hover:opacity-90 transition-opacity"
|
|
title={t("categoriesSeed.guidePage.printHint")}
|
|
>
|
|
<Printer size={16} />
|
|
{t("categoriesSeed.guidePage.print")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Tree */}
|
|
<section className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-3 taxonomy-tree-print">
|
|
<CategoryTaxonomyTree
|
|
nodes={taxonomy.roots}
|
|
isCollapsed={groups.isCollapsed}
|
|
onToggle={groups.toggle}
|
|
searchQuery={search}
|
|
/>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|