Extract circuit grid projection
This commit is contained in:
@@ -8,6 +8,7 @@ The circuit-list editor is a circuit-first planning workspace for distribution-b
|
|||||||
|
|
||||||
- `circuit-tree-editor.tsx` owns React state, API commands, undo/redo and drag-and-drop orchestration.
|
- `circuit-tree-editor.tsx` owns React state, API commands, undo/redo and drag-and-drop orchestration.
|
||||||
- `circuit-grid-model.ts` owns column metadata, circuit/device cell ownership, value projection, formatting, numeric parsing and block sort values.
|
- `circuit-grid-model.ts` owns column metadata, circuit/device cell ownership, value projection, formatting, numeric parsing and block sort values.
|
||||||
|
- `circuit-grid-projection.ts` owns block-preserving filtering/sorting and the normalized visible row projection.
|
||||||
- `circuit-grid-insertion.ts` resolves insertion intent and insertion sort positions.
|
- `circuit-grid-insertion.ts` resolves insertion intent and insertion sort positions.
|
||||||
- `circuit-grid-safety.ts` resolves delete intent, BMK conflicts and cross-section move confirmation requirements.
|
- `circuit-grid-safety.ts` resolves delete intent, BMK conflicts and cross-section move confirmation requirements.
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -11,8 +11,8 @@
|
|||||||
"build:api": "tsc -p tsconfig.json",
|
"build:api": "tsc -p tsconfig.json",
|
||||||
"build:web": "next build",
|
"build:web": "next build",
|
||||||
"start": "node dist/server/index.js",
|
"start": "node dist/server/index.js",
|
||||||
"test": "tsx --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts",
|
"test": "tsx --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts",
|
||||||
"test:watch": "tsx --watch --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts",
|
"test:watch": "tsx --watch --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:backup": "node scripts/db-backup.js",
|
"db:backup": "node scripts/db-backup.js",
|
||||||
|
|||||||
@@ -17,16 +17,11 @@ import {
|
|||||||
} from "../utils/circuit-grid-safety";
|
} from "../utils/circuit-grid-safety";
|
||||||
import {
|
import {
|
||||||
allColumns,
|
allColumns,
|
||||||
circuitOnlyColumns,
|
|
||||||
compareSortValues,
|
|
||||||
defaultVisibleColumnKeys,
|
defaultVisibleColumnKeys,
|
||||||
deviceFieldKeys,
|
deviceFieldKeys,
|
||||||
formatValue,
|
formatValue,
|
||||||
getBlockSortValue,
|
|
||||||
getCellKind,
|
|
||||||
getCircuitValue,
|
getCircuitValue,
|
||||||
getDeviceValue,
|
getDeviceValue,
|
||||||
normalizeFilterValue,
|
|
||||||
parseNumeric,
|
parseNumeric,
|
||||||
} from "../utils/circuit-grid-model";
|
} from "../utils/circuit-grid-model";
|
||||||
import type {
|
import type {
|
||||||
@@ -35,6 +30,12 @@ import type {
|
|||||||
ColumnDef,
|
ColumnDef,
|
||||||
RowType,
|
RowType,
|
||||||
} from "../utils/circuit-grid-model";
|
} from "../utils/circuit-grid-model";
|
||||||
|
import {
|
||||||
|
buildVisibleGridRows,
|
||||||
|
filterAndSortCircuitSections,
|
||||||
|
getDistinctFilterValues,
|
||||||
|
} from "../utils/circuit-grid-projection";
|
||||||
|
import type { VisibleGridRow } from "../utils/circuit-grid-projection";
|
||||||
import {
|
import {
|
||||||
createCircuit,
|
createCircuit,
|
||||||
createCircuitDeviceRow,
|
createCircuitDeviceRow,
|
||||||
@@ -82,22 +83,6 @@ interface SelectionIntent {
|
|||||||
deviceId?: string;
|
deviceId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VisibleGridCell {
|
|
||||||
cellKey: CellKey;
|
|
||||||
editable: boolean;
|
|
||||||
kind: CellKind;
|
|
||||||
value: string | number | boolean | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VisibleGridRow {
|
|
||||||
rowKey: string;
|
|
||||||
rowType: RowType;
|
|
||||||
sectionId: string;
|
|
||||||
circuit?: CircuitTreeCircuitDto;
|
|
||||||
device?: CircuitTreeDeviceRowDto;
|
|
||||||
cells: VisibleGridCell[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HistoryCommand {
|
interface HistoryCommand {
|
||||||
label: string;
|
label: string;
|
||||||
redo: () => Promise<SelectionIntent | null | void>;
|
redo: () => Promise<SelectionIntent | null | void>;
|
||||||
@@ -332,129 +317,19 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
const distinctValuesByColumn = useMemo(() => {
|
const distinctValuesByColumn = useMemo(() => {
|
||||||
// Filter options are generated only for visible columns to match current header UI.
|
// Filter options are generated only for visible columns to match current header UI.
|
||||||
// Hidden columns still exist in row model, but are intentionally not filterable from header.
|
// Hidden columns still exist in row model, but are intentionally not filterable from header.
|
||||||
const result = {} as Record<CellKey, string[]>;
|
return getDistinctFilterValues(data?.sections ?? [], visibleColumns);
|
||||||
for (const column of visibleColumns) {
|
|
||||||
const set = new Set<string>();
|
|
||||||
for (const section of data?.sections ?? []) {
|
|
||||||
for (const circuit of section.circuits) {
|
|
||||||
set.add(normalizeFilterValue(getCircuitValue(circuit, column.key)));
|
|
||||||
for (const row of circuit.deviceRows) {
|
|
||||||
set.add(normalizeFilterValue(getDeviceValue(row, column.key)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result[column.key] = [...set].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}, [data, visibleColumns]);
|
}, [data, visibleColumns]);
|
||||||
|
|
||||||
const filteredSortedSections = useMemo(() => {
|
const filteredSortedSections = useMemo(() => {
|
||||||
// Filtering and sorting are frontend view state. Backend sort order remains unchanged
|
// Filtering and sorting are frontend view state. Backend sort order remains unchanged
|
||||||
// until user explicitly applies sorted order.
|
// until user explicitly applies sorted order.
|
||||||
if (!data) {
|
return filterAndSortCircuitSections(data?.sections ?? [], columnFilters, sortState);
|
||||||
return [] as CircuitTreeResponseDto["sections"];
|
}, [data, columnFilters, sortState]);
|
||||||
}
|
|
||||||
const matchesColumnFilter = (
|
|
||||||
circuit: CircuitTreeCircuitDto,
|
|
||||||
row: CircuitTreeDeviceRowDto | null,
|
|
||||||
key: CellKey,
|
|
||||||
selected: string[]
|
|
||||||
) => {
|
|
||||||
if (!selected.length) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const values = new Set<string>();
|
|
||||||
values.add(normalizeFilterValue(getCircuitValue(circuit, key)));
|
|
||||||
if (row) {
|
|
||||||
values.add(normalizeFilterValue(getDeviceValue(row, key)));
|
|
||||||
} else {
|
|
||||||
for (const device of circuit.deviceRows) {
|
|
||||||
values.add(normalizeFilterValue(getDeviceValue(device, key)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return selected.some((entry) => values.has(entry));
|
|
||||||
};
|
|
||||||
|
|
||||||
const sections = data.sections
|
|
||||||
.map((section) => {
|
|
||||||
let circuits = section.circuits
|
|
||||||
.map((circuit) => {
|
|
||||||
const selectedFilters = Object.entries(columnFilters).filter(([, values]) => (values?.length ?? 0) > 0);
|
|
||||||
const circuitMatchesAll = selectedFilters.every(([key, values]) =>
|
|
||||||
matchesColumnFilter(circuit, null, key as CellKey, values ?? [])
|
|
||||||
);
|
|
||||||
if (!circuitMatchesAll) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasActiveFilters || circuit.deviceRows.length <= 1) {
|
|
||||||
return circuit;
|
|
||||||
}
|
|
||||||
|
|
||||||
const matchingRows = circuit.deviceRows.filter((row) =>
|
|
||||||
selectedFilters.every(([key, values]) =>
|
|
||||||
matchesColumnFilter(circuit, row, key as CellKey, values ?? [])
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (matchingRows.length > 0) {
|
|
||||||
return { ...circuit, deviceRows: matchingRows };
|
|
||||||
}
|
|
||||||
if (selectedFilters.some(([key]) => circuitOnlyColumns.has(key as CellKey))) {
|
|
||||||
return circuit;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})
|
|
||||||
.filter(Boolean) as CircuitTreeCircuitDto[];
|
|
||||||
|
|
||||||
if (sortState) {
|
|
||||||
// Sort on circuit blocks so multi-device circuits keep row grouping integrity.
|
|
||||||
circuits = [...circuits].sort((a, b) => {
|
|
||||||
const cmp = compareSortValues(getBlockSortValue(a, sortState.key), getBlockSortValue(b, sortState.key));
|
|
||||||
return sortState.direction === "asc" ? cmp : -cmp;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { ...section, circuits };
|
|
||||||
})
|
|
||||||
.filter((section) => section.circuits.length > 0);
|
|
||||||
|
|
||||||
return sections;
|
|
||||||
}, [data, columnFilters, hasActiveFilters, sortState]);
|
|
||||||
|
|
||||||
// visibleRows is the single normalized grid used by render + navigation + editability.
|
// visibleRows is the single normalized grid used by render + navigation + editability.
|
||||||
// This avoids selected/rendered/editable state drifting apart after filters/sorts/reloads.
|
// This avoids selected/rendered/editable state drifting apart after filters/sorts/reloads.
|
||||||
const visibleRows = useMemo(() => {
|
const visibleRows = useMemo(() => {
|
||||||
if (!filteredSortedSections.length) {
|
return buildVisibleGridRows(filteredSortedSections);
|
||||||
return [] as VisibleGridRow[];
|
|
||||||
}
|
|
||||||
const rows: VisibleGridRow[] = [];
|
|
||||||
for (const section of filteredSortedSections) {
|
|
||||||
rows.push({
|
|
||||||
rowKey: `section:${section.id}`,
|
|
||||||
rowType: "section",
|
|
||||||
sectionId: section.id,
|
|
||||||
// Section headers are structural markers only: visible, but never editable/selectable.
|
|
||||||
cells: allColumns.map((col) => ({ cellKey: col.key, editable: false, kind: "readonly", value: undefined })),
|
|
||||||
});
|
|
||||||
for (const circuit of section.circuits) {
|
|
||||||
if (circuit.deviceRows.length === 0) {
|
|
||||||
rows.push(makeRow("reserveCircuit", section.id, circuit));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (circuit.deviceRows.length === 1) {
|
|
||||||
rows.push(makeRow("circuitCompact", section.id, circuit, circuit.deviceRows[0]));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
rows.push(makeRow("circuitSummary", section.id, circuit));
|
|
||||||
for (const device of circuit.deviceRows) {
|
|
||||||
rows.push(makeRow("deviceRow", section.id, circuit, device));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Placeholder row is virtual (-frei-) but intentionally part of editable grid because
|
|
||||||
// editing/dropping here is the fast path to create a real circuit in this section.
|
|
||||||
rows.push(makeRow("placeholder", section.id));
|
|
||||||
}
|
|
||||||
return rows;
|
|
||||||
}, [filteredSortedSections]);
|
}, [filteredSortedSections]);
|
||||||
|
|
||||||
const searchableProjectDevices = useMemo(() => {
|
const searchableProjectDevices = useMemo(() => {
|
||||||
@@ -490,40 +365,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
[allCircuits]
|
[allCircuits]
|
||||||
);
|
);
|
||||||
|
|
||||||
function makeRow(
|
|
||||||
rowType: RowType,
|
|
||||||
sectionId: string,
|
|
||||||
circuit?: CircuitTreeCircuitDto,
|
|
||||||
device?: CircuitTreeDeviceRowDto
|
|
||||||
): VisibleGridRow {
|
|
||||||
// Row keys are UI identities, not persisted identities. They are rebuilt on each tree refresh.
|
|
||||||
const rowKey =
|
|
||||||
rowType === "placeholder"
|
|
||||||
? `placeholder:${sectionId}`
|
|
||||||
: rowType === "deviceRow" && device
|
|
||||||
? `device:${device.id}`
|
|
||||||
: `${rowType}:${circuit?.id ?? sectionId}`;
|
|
||||||
const cells = allColumns.map((col) => {
|
|
||||||
const kind = getCellKind(rowType, col.key);
|
|
||||||
// Hidden columns remain in the normalized row data; only visibility controls rendering.
|
|
||||||
const editable = kind === "circuitField" || kind === "deviceField";
|
|
||||||
let value: string | number | boolean | undefined;
|
|
||||||
if (rowType === "placeholder") {
|
|
||||||
value = col.key === "equipmentIdentifier" ? "-frei-" : undefined;
|
|
||||||
} else if (kind === "deviceField" && device) {
|
|
||||||
value = getDeviceValue(device, col.key);
|
|
||||||
} else if (kind === "circuitField" && circuit) {
|
|
||||||
value = getCircuitValue(circuit, col.key);
|
|
||||||
} else if (col.key === "circuitTotalPower" && circuit) {
|
|
||||||
value = circuit.circuitTotalPower;
|
|
||||||
} else if (col.key === "rowTotalPower" && device) {
|
|
||||||
value = device.rowTotalPower;
|
|
||||||
}
|
|
||||||
return { cellKey: col.key, editable, kind, value };
|
|
||||||
});
|
|
||||||
return { rowKey, rowType, sectionId, circuit, device, cells };
|
|
||||||
}
|
|
||||||
|
|
||||||
const editableCells = useMemo(
|
const editableCells = useMemo(
|
||||||
() =>
|
() =>
|
||||||
visibleRows.flatMap((row) =>
|
visibleRows.flatMap((row) =>
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import type {
|
||||||
|
CircuitTreeCircuitDto,
|
||||||
|
CircuitTreeDeviceRowDto,
|
||||||
|
CircuitTreeSectionDto,
|
||||||
|
} from "../types.js";
|
||||||
|
import {
|
||||||
|
allColumns,
|
||||||
|
circuitOnlyColumns,
|
||||||
|
compareSortValues,
|
||||||
|
deviceFieldKeys,
|
||||||
|
getBlockSortValue,
|
||||||
|
getCellKind,
|
||||||
|
getCircuitValue,
|
||||||
|
getDeviceValue,
|
||||||
|
normalizeFilterValue,
|
||||||
|
} from "./circuit-grid-model";
|
||||||
|
import type {
|
||||||
|
CellKey,
|
||||||
|
CellKind,
|
||||||
|
ColumnDef,
|
||||||
|
GridValue,
|
||||||
|
RowType,
|
||||||
|
} from "./circuit-grid-model";
|
||||||
|
|
||||||
|
export type ColumnFilters = Partial<Record<CellKey, string[]>>;
|
||||||
|
|
||||||
|
export interface GridSortState {
|
||||||
|
key: CellKey;
|
||||||
|
direction: "asc" | "desc";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VisibleGridCell {
|
||||||
|
cellKey: CellKey;
|
||||||
|
editable: boolean;
|
||||||
|
kind: CellKind;
|
||||||
|
value: GridValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VisibleGridRow {
|
||||||
|
rowKey: string;
|
||||||
|
rowType: RowType;
|
||||||
|
sectionId: string;
|
||||||
|
circuit?: CircuitTreeCircuitDto;
|
||||||
|
device?: CircuitTreeDeviceRowDto;
|
||||||
|
cells: VisibleGridCell[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCircuitBlockFilterValues(circuit: CircuitTreeCircuitDto, key: CellKey): Set<string> {
|
||||||
|
const values = new Set<string>();
|
||||||
|
if (circuitOnlyColumns.has(key) || key === "displayName" || key === "remark") {
|
||||||
|
values.add(normalizeFilterValue(getCircuitValue(circuit, key)));
|
||||||
|
}
|
||||||
|
if (!circuitOnlyColumns.has(key)) {
|
||||||
|
for (const device of circuit.deviceRows) {
|
||||||
|
values.add(normalizeFilterValue(getDeviceValue(device, key)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (values.size === 0 && deviceFieldKeys.has(key)) {
|
||||||
|
values.add(normalizeFilterValue(undefined));
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDistinctFilterValues(
|
||||||
|
sections: readonly CircuitTreeSectionDto[],
|
||||||
|
columns: readonly ColumnDef[]
|
||||||
|
): Record<CellKey, string[]> {
|
||||||
|
const result = {} as Record<CellKey, string[]>;
|
||||||
|
for (const column of columns) {
|
||||||
|
const values = new Set<string>();
|
||||||
|
for (const section of sections) {
|
||||||
|
for (const circuit of section.circuits) {
|
||||||
|
for (const value of getCircuitBlockFilterValues(circuit, column.key)) {
|
||||||
|
values.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result[column.key] = [...values].sort((left, right) =>
|
||||||
|
left.localeCompare(right, undefined, { numeric: true })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterAndSortCircuitSections(
|
||||||
|
sections: readonly CircuitTreeSectionDto[],
|
||||||
|
columnFilters: ColumnFilters,
|
||||||
|
sortState: GridSortState | null
|
||||||
|
): CircuitTreeSectionDto[] {
|
||||||
|
const activeFilters = Object.entries(columnFilters).filter(([, values]) => (values?.length ?? 0) > 0) as Array<
|
||||||
|
[CellKey, string[]]
|
||||||
|
>;
|
||||||
|
|
||||||
|
return sections
|
||||||
|
.map((section) => {
|
||||||
|
let circuits = section.circuits.filter((circuit) =>
|
||||||
|
activeFilters.every(([key, selectedValues]) => {
|
||||||
|
const blockValues = getCircuitBlockFilterValues(circuit, key);
|
||||||
|
return selectedValues.some((selectedValue) => blockValues.has(selectedValue));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sortState) {
|
||||||
|
// Circuit blocks are sorted as units; their device row membership/order is untouched.
|
||||||
|
circuits = [...circuits].sort((left, right) => {
|
||||||
|
const comparison = compareSortValues(
|
||||||
|
getBlockSortValue(left, sortState.key),
|
||||||
|
getBlockSortValue(right, sortState.key)
|
||||||
|
);
|
||||||
|
return sortState.direction === "asc" ? comparison : -comparison;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...section, circuits };
|
||||||
|
})
|
||||||
|
.filter((section) => section.circuits.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeVisibleGridRow(
|
||||||
|
rowType: RowType,
|
||||||
|
sectionId: string,
|
||||||
|
circuit?: CircuitTreeCircuitDto,
|
||||||
|
device?: CircuitTreeDeviceRowDto
|
||||||
|
): VisibleGridRow {
|
||||||
|
const rowKey =
|
||||||
|
rowType === "placeholder"
|
||||||
|
? `placeholder:${sectionId}`
|
||||||
|
: rowType === "deviceRow" && device
|
||||||
|
? `device:${device.id}`
|
||||||
|
: `${rowType}:${circuit?.id ?? sectionId}`;
|
||||||
|
|
||||||
|
const cells = allColumns.map((column): VisibleGridCell => {
|
||||||
|
const kind = getCellKind(rowType, column.key);
|
||||||
|
const editable = kind === "circuitField" || kind === "deviceField";
|
||||||
|
let value: GridValue;
|
||||||
|
|
||||||
|
if (rowType === "placeholder") {
|
||||||
|
value = column.key === "equipmentIdentifier" ? "-frei-" : undefined;
|
||||||
|
} else if (kind === "deviceField" && device) {
|
||||||
|
value = getDeviceValue(device, column.key);
|
||||||
|
} else if (kind === "circuitField" && circuit) {
|
||||||
|
value = getCircuitValue(circuit, column.key);
|
||||||
|
} else if (column.key === "circuitTotalPower" && circuit) {
|
||||||
|
value = circuit.circuitTotalPower;
|
||||||
|
} else if (column.key === "rowTotalPower" && device) {
|
||||||
|
value = device.rowTotalPower;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { cellKey: column.key, editable, kind, value };
|
||||||
|
});
|
||||||
|
|
||||||
|
return { rowKey, rowType, sectionId, circuit, device, cells };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildVisibleGridRows(sections: readonly CircuitTreeSectionDto[]): VisibleGridRow[] {
|
||||||
|
const rows: VisibleGridRow[] = [];
|
||||||
|
for (const section of sections) {
|
||||||
|
rows.push({
|
||||||
|
rowKey: `section:${section.id}`,
|
||||||
|
rowType: "section",
|
||||||
|
sectionId: section.id,
|
||||||
|
cells: allColumns.map((column) => ({
|
||||||
|
cellKey: column.key,
|
||||||
|
editable: false,
|
||||||
|
kind: "readonly",
|
||||||
|
value: undefined,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const circuit of section.circuits) {
|
||||||
|
if (circuit.deviceRows.length === 0) {
|
||||||
|
rows.push(makeVisibleGridRow("reserveCircuit", section.id, circuit));
|
||||||
|
} else if (circuit.deviceRows.length === 1) {
|
||||||
|
rows.push(makeVisibleGridRow("circuitCompact", section.id, circuit, circuit.deviceRows[0]));
|
||||||
|
} else {
|
||||||
|
rows.push(makeVisibleGridRow("circuitSummary", section.id, circuit));
|
||||||
|
for (const device of circuit.deviceRows) {
|
||||||
|
rows.push(makeVisibleGridRow("deviceRow", section.id, circuit, device));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push(makeVisibleGridRow("placeholder", section.id));
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import {
|
||||||
|
buildVisibleGridRows,
|
||||||
|
filterAndSortCircuitSections,
|
||||||
|
getDistinctFilterValues,
|
||||||
|
} from "../src/frontend/utils/circuit-grid-projection.js";
|
||||||
|
import type {
|
||||||
|
CircuitTreeCircuitDto,
|
||||||
|
CircuitTreeDeviceRowDto,
|
||||||
|
CircuitTreeSectionDto,
|
||||||
|
} from "../src/frontend/types.js";
|
||||||
|
|
||||||
|
function makeDevice(
|
||||||
|
id: string,
|
||||||
|
displayName: string,
|
||||||
|
roomNumberSnapshot: string,
|
||||||
|
sortOrder: number
|
||||||
|
): CircuitTreeDeviceRowDto {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
sortOrder,
|
||||||
|
name: displayName,
|
||||||
|
displayName,
|
||||||
|
roomNumberSnapshot,
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 0.1,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
rowTotalPower: 0.1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCircuit(
|
||||||
|
id: string,
|
||||||
|
equipmentIdentifier: string,
|
||||||
|
displayName: string,
|
||||||
|
deviceRows: CircuitTreeDeviceRowDto[],
|
||||||
|
sortOrder: number
|
||||||
|
): CircuitTreeCircuitDto {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
circuitListId: "list-1",
|
||||||
|
sectionId: "section-1",
|
||||||
|
equipmentIdentifier,
|
||||||
|
displayName,
|
||||||
|
sortOrder,
|
||||||
|
isReserve: deviceRows.length === 0,
|
||||||
|
circuitTotalPower: deviceRows.reduce((total, row) => total + row.rowTotalPower, 0),
|
||||||
|
deviceRows,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const multiDeviceCircuit = makeCircuit(
|
||||||
|
"circuit-multi",
|
||||||
|
"-1F2",
|
||||||
|
"Office lighting",
|
||||||
|
[makeDevice("device-office", "Office light", "1.01", 10), makeDevice("device-store", "Store light", "1.02", 20)],
|
||||||
|
20
|
||||||
|
);
|
||||||
|
const compactCircuit = makeCircuit(
|
||||||
|
"circuit-compact",
|
||||||
|
"-1F1",
|
||||||
|
"Corridor lighting",
|
||||||
|
[makeDevice("device-corridor", "Corridor light", "1.03", 10)],
|
||||||
|
10
|
||||||
|
);
|
||||||
|
const reserveCircuit = makeCircuit("circuit-reserve", "-1F3", "Reserve", [], 30);
|
||||||
|
|
||||||
|
const sections: CircuitTreeSectionDto[] = [
|
||||||
|
{
|
||||||
|
id: "section-1",
|
||||||
|
key: "lighting",
|
||||||
|
displayName: "Lighting",
|
||||||
|
prefix: "-1F",
|
||||||
|
sortOrder: 10,
|
||||||
|
circuits: [compactCircuit, multiDeviceCircuit, reserveCircuit],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("circuit grid projection", () => {
|
||||||
|
it("keeps every device row in a circuit block when one device matches a filter", () => {
|
||||||
|
const result = filterAndSortCircuitSections(sections, { roomNumberSnapshot: ["1.01"] }, null);
|
||||||
|
|
||||||
|
assert.deepEqual(result.map((section) => section.circuits.map((circuit) => circuit.id)), [["circuit-multi"]]);
|
||||||
|
assert.deepEqual(result[0].circuits[0].deviceRows.map((row) => row.id), ["device-office", "device-store"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("combines column filters across the complete circuit block", () => {
|
||||||
|
const result = filterAndSortCircuitSections(
|
||||||
|
sections,
|
||||||
|
{ equipmentIdentifier: ["-1F2"], roomNumberSnapshot: ["1.02"] },
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(result[0].circuits.map((circuit) => circuit.id), ["circuit-multi"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats missing device values as missing without matching populated circuits", () => {
|
||||||
|
const result = filterAndSortCircuitSections(sections, { roomNumberSnapshot: ["-"] }, null);
|
||||||
|
|
||||||
|
assert.deepEqual(result[0].circuits.map((circuit) => circuit.id), ["circuit-reserve"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts complete circuit blocks without changing device row order", () => {
|
||||||
|
const result = filterAndSortCircuitSections(sections, {}, { key: "equipmentIdentifier", direction: "desc" });
|
||||||
|
|
||||||
|
assert.deepEqual(result[0].circuits.map((circuit) => circuit.id), [
|
||||||
|
"circuit-reserve",
|
||||||
|
"circuit-multi",
|
||||||
|
"circuit-compact",
|
||||||
|
]);
|
||||||
|
assert.deepEqual(result[0].circuits[1].deviceRows.map((row) => row.id), ["device-office", "device-store"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds the compact, grouped, reserve and placeholder row shapes", () => {
|
||||||
|
const rows = buildVisibleGridRows(sections);
|
||||||
|
|
||||||
|
assert.deepEqual(rows.map((row) => row.rowType), [
|
||||||
|
"section",
|
||||||
|
"circuitCompact",
|
||||||
|
"circuitSummary",
|
||||||
|
"deviceRow",
|
||||||
|
"deviceRow",
|
||||||
|
"reserveCircuit",
|
||||||
|
"placeholder",
|
||||||
|
]);
|
||||||
|
assert.equal(rows.at(-1)?.rowKey, "placeholder:section-1");
|
||||||
|
assert.equal(
|
||||||
|
rows.at(-1)?.cells.find((cell) => cell.cellKey === "equipmentIdentifier")?.value,
|
||||||
|
"-frei-"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collects filter options from both circuit and device values", () => {
|
||||||
|
const values = getDistinctFilterValues(sections, [
|
||||||
|
{ key: "displayName", label: "Display name" },
|
||||||
|
{ key: "roomNumberSnapshot", label: "Room number" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.ok(values.displayName.includes("Office lighting"));
|
||||||
|
assert.ok(values.displayName.includes("Store light"));
|
||||||
|
assert.ok(values.roomNumberSnapshot.includes("1.03"));
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user