refactor(webui): use shared organization explorer tree

This commit is contained in:
2026-07-21 13:59:57 +02:00
parent df91c70491
commit 28f799e426
4 changed files with 59 additions and 74 deletions

View File

@@ -6,6 +6,9 @@
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"test:organizations-tree": "node scripts/test-organizations-tree-structure.mjs"
},
"exports": {
".": {
"types": "./src/index.ts",

View File

@@ -0,0 +1,24 @@
import { readFileSync } from "node:fs";
function assert(condition, message) {
if (!condition) throw new Error(message);
}
const source = readFileSync(
new URL("../src/features/organizations/OrganizationsPage.tsx", import.meta.url),
"utf8"
);
const styles = readFileSync(
new URL("../src/styles/organizations.css", import.meta.url),
"utf8"
);
assert(source.includes("ExplorerTree,"), "Organizations imports the central ExplorerTree");
assert(source.includes("<ExplorerTree"), "the organization hierarchy uses ExplorerTree");
assert(source.includes("collapsible={false}"), "the always-expanded hierarchy uses the non-collapsible contract");
assert(source.includes("renderNodeActions="), "per-unit controls use the sibling node-action slot");
assert(source.includes("<IconButton"), "per-unit controls use the central icon-only action primitive");
assert(!source.includes("renderUnitTreeNodes"), "the custom recursive renderer was removed");
assert(!source.includes("organizations-tree-row"), "the custom tree row was removed");
assert(!source.includes("organizations-tree-node"), "the custom tree node was removed");
assert(!styles.includes("organizations-tree-"), "Organizations no longer owns shared tree styling");

View File

@@ -8,7 +8,9 @@ import {
DataGrid,
Dialog,
DismissibleAlert,
ExplorerTree,
FormField,
IconButton,
LoadingFrame,
ModuleSubnav,
PageTitle,
@@ -1000,41 +1002,42 @@ export default function OrganizationsPage({
);
}
function renderUnitTreeNodes(parentId = "", depth = 0, seen: ReadonlySet<string> = new Set()): JSX.Element[] {
return (unitsByParentId.get(parentId) ?? []).flatMap((unit) => {
if (seen.has(unit.id)) return [];
const nextSeen = new Set(seen);
nextSeen.add(unit.id);
return [
<div className="organizations-tree-row" key={unit.id}>
<button
type="button"
className={`organizations-tree-node ${selectedUnitId === unit.id ? "active" : ""}`.trim()}
style={{ paddingLeft: `${10 + depth * 18}px` }}
onClick={() => setSelectedUnitId(unit.id)}
>
<strong>{unit.name}</strong>
<span>{unitTypeById.get(unit.unit_type_id || "")?.name ?? "i18n:govoplan-organizations.no_unit_type.73e799f5"}</span>
</button>
<Button type="button" variant="ghost" disabled={!canWriteUnits || busy} onClick={() => addSubunit(unit)} title="i18n:govoplan-organizations.add_subunit.8256a8f7">
<Plus size={16} aria-hidden="true" /> i18n:govoplan-organizations.add_subunit.8256a8f7
</Button>
</div>,
...renderUnitTreeNodes(unit.id, depth + 1, nextSeen)
];
});
}
function renderUnitsSection() {
return (
<div className="organizations-table-stack">
<Card title="i18n:govoplan-organizations.organization_tree.e5bfb195" actions={<AdminIconButton label="i18n:govoplan-organizations.add_root_unit.1ef8a9f5" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteUnits || busy} onClick={() => openUnitCreate()} />}>
<div className="organizations-tree-toolbar">
<div className="explorer-tree-toolbar">
{selectedUnitId && <Button type="button" variant="ghost" disabled={busy} onClick={() => setSelectedUnitId("")}>i18n:govoplan-organizations.none.334c4a4c</Button>}
</div>
<div className="organizations-tree-list">
{model.units.length ? renderUnitTreeNodes() : <p className="muted small-note">i18n:govoplan-organizations.no_units_found.eea2dd0c</p>}
</div>
{model.units.length ? (
<ExplorerTree
nodes={unitsByParentId.get("") ?? []}
getNodeId={(unit) => unit.id}
getNodeLabel={(unit) => unit.name}
getNodeChildren={(unit) => unitsByParentId.get(unit.id) ?? []}
activeId={selectedUnitId}
collapsible={false}
depth={0}
className="explorer-tree-scroll-region"
getNodeWrapStyle={(_unit, context) => ({ paddingLeft: `${Math.min(context.depth * 18, 72)}px` })}
onOpen={(unit) => setSelectedUnitId(unit.id)}
renderNodeContent={(unit) => (
<span className="explorer-tree-node-content">
<strong>{unit.name}</strong>
<small>{unitTypeById.get(unit.unit_type_id || "")?.name ?? "i18n:govoplan-organizations.no_unit_type.73e799f5"}</small>
</span>
)}
renderNodeActions={(unit) => (
<IconButton
label="i18n:govoplan-organizations.add_subunit.8256a8f7"
icon={<Plus size={16} aria-hidden="true" />}
variant="ghost"
disabled={!canWriteUnits || busy}
onClick={() => addSubunit(unit)}
/>
)}
/>
) : <p className="muted small-note">i18n:govoplan-organizations.no_units_found.eea2dd0c</p>}
</Card>
<Card title="i18n:govoplan-organizations.units.e14d0d92" actions={<AdminIconButton label="i18n:govoplan-organizations.add_unit.8fa12fb1" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteUnits || busy} onClick={() => openUnitCreate()} />}>
<DataGrid id="organizations-units" rows={model.units} columns={unitColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_units_found.eea2dd0c" initialFit="container" />

View File

@@ -50,51 +50,6 @@
padding-top: 2px;
}
.organizations-tree-toolbar {
display: flex;
justify-content: flex-start;
margin-bottom: 12px;
}
.organizations-tree-list {
display: grid;
gap: 4px;
max-height: 640px;
overflow: auto;
}
.organizations-tree-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 6px;
}
.organizations-tree-node {
display: grid;
gap: 2px;
width: 100%;
min-width: 0;
padding: 8px 10px;
border: 1px solid transparent;
border-radius: 7px;
background: transparent;
color: var(--text);
text-align: left;
cursor: pointer;
}
.organizations-tree-node:hover,
.organizations-tree-node.active {
border-color: var(--line);
background: var(--surface-muted);
}
.organizations-tree-node span {
color: var(--muted);
font-size: 12px;
}
.organizations-field-note {
margin: 8px 0 0;
color: var(--muted);