refactor(webui): use shared organization explorer tree
This commit is contained in:
@@ -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",
|
||||
|
||||
24
webui/scripts/test-organizations-tree-structure.mjs
Normal file
24
webui/scripts/test-organizations-tree-structure.mjs
Normal 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");
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user