The overview now uses the same icon buttons as the circuit list editor: row actions are icon-only IconButtons (Pencil, Link2, Library, Trash2), card-header and panel actions show icon plus text, and the circuit-list link uses the new IconLink. The version history uses Undo2/Redo2, a chevron toggle and a RotateCcw restore action. Page-header buttons stay Bootstrap buttons with a lucide icon so the upcoming "Seitenansicht" button of feature/print-view merges without conflicts and matches them. The Verteilungen and Projektgeräte cards get a PM-style filter bar: search plus MultiSelectDropdown (checkbox list with "Alle", closes on outside click and Escape) and "Zurücksetzen" while a filter is active. Only the bar collapses; the state is kept per card in localStorage and a collapsed active filter shows "Filter aktiv". The header row shows "x von y", and an empty-state row explains when filters hide everything. The filter logic is a pure module (overview-filters.ts) with node tests; selections that are no longer offered (deleted floor) are ignored. IconButton now imports React explicitly like the other components that are rendered in node tests with the classic JSX runtime. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
248 lines
7.2 KiB
TypeScript
248 lines
7.2 KiB
TypeScript
import assert from "node:assert/strict";
|
||
import { describe, it } from "node:test";
|
||
import {
|
||
buildCostGroupFilterOptions,
|
||
buildFloorFilterOptions,
|
||
buildSupplyTypeFilterOptions,
|
||
emptyDistributionBoardFilters,
|
||
emptyProjectDeviceFilters,
|
||
filterDistributionBoards,
|
||
filterProjectDevices,
|
||
isDistributionBoardFilterActive,
|
||
isProjectDeviceFilterActive,
|
||
retainAvailableFilterValues,
|
||
toggleFilterValue,
|
||
withoutCostGroupFilterId,
|
||
withoutFloorFilterId,
|
||
withoutSupplyTypeFilterId,
|
||
} from "../src/frontend/utils/overview-filters.js";
|
||
import type {
|
||
DistributionBoardDto,
|
||
FloorDto,
|
||
ProjectDeviceDto,
|
||
} from "../src/frontend/types.js";
|
||
|
||
type TestBoard = Pick<
|
||
DistributionBoardDto,
|
||
"id" | "name" | "floorId" | "supplyType"
|
||
>;
|
||
|
||
function board(
|
||
id: string,
|
||
name: string,
|
||
floorId: string | null,
|
||
supplyType: DistributionBoardDto["supplyType"]
|
||
): TestBoard {
|
||
return { id, name, floorId, supplyType };
|
||
}
|
||
|
||
function device(
|
||
id: string,
|
||
overrides: Partial<ProjectDeviceDto>
|
||
): ProjectDeviceDto {
|
||
return {
|
||
id,
|
||
projectId: "project-1",
|
||
name: `device-${id}`,
|
||
displayName: `Gerät ${id}`,
|
||
phaseType: "single_phase",
|
||
connectionKind: null,
|
||
costGroup: null,
|
||
category: "single_phase",
|
||
quantity: 1,
|
||
powerPerUnit: 1,
|
||
simultaneityFactor: 1,
|
||
totalPower: 1,
|
||
cosPhi: null,
|
||
remark: null,
|
||
voltageV: null,
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
const floors: FloorDto[] = [
|
||
{ id: "floor-og", projectId: "project-1", name: "1. OG", sortOrder: 2 },
|
||
{ id: "floor-eg", projectId: "project-1", name: "EG", sortOrder: 1 },
|
||
];
|
||
const floorNameById = new Map(floors.map((floor) => [floor.id, floor.name]));
|
||
|
||
const boards = [
|
||
board("b1", "UV-EG-01", "floor-eg", "AV"),
|
||
board("b2", "UV-OG-01", "floor-og", "SV"),
|
||
board("b3", "HV", null, "AV"),
|
||
board("b4", "SiBe-Zentrale", null, null),
|
||
];
|
||
|
||
describe("overview filters: distribution boards", () => {
|
||
it("returns every board without an active filter", () => {
|
||
assert.equal(isDistributionBoardFilterActive(emptyDistributionBoardFilters), false);
|
||
assert.deepEqual(
|
||
filterDistributionBoards(boards, emptyDistributionBoardFilters, floorNameById),
|
||
boards
|
||
);
|
||
});
|
||
|
||
it("searches board and floor names case-insensitively and ignores blank queries", () => {
|
||
const byFloorName = filterDistributionBoards(
|
||
boards,
|
||
{ ...emptyDistributionBoardFilters, query: " og " },
|
||
floorNameById
|
||
);
|
||
assert.deepEqual(byFloorName.map((entry) => entry.id), ["b2"]);
|
||
const byBoardName = filterDistributionBoards(
|
||
boards,
|
||
{ ...emptyDistributionBoardFilters, query: "sibe" },
|
||
floorNameById
|
||
);
|
||
assert.deepEqual(byBoardName.map((entry) => entry.id), ["b4"]);
|
||
assert.equal(
|
||
isDistributionBoardFilterActive({ ...emptyDistributionBoardFilters, query: " " }),
|
||
false
|
||
);
|
||
});
|
||
|
||
it("filters floors with the dedicated option for boards without floor", () => {
|
||
const result = filterDistributionBoards(
|
||
boards,
|
||
{ ...emptyDistributionBoardFilters, floorIds: ["floor-eg", withoutFloorFilterId] },
|
||
floorNameById
|
||
);
|
||
assert.deepEqual(result.map((entry) => entry.id), ["b1", "b3", "b4"]);
|
||
});
|
||
|
||
it("combines supply type with the other criteria", () => {
|
||
const filters = {
|
||
query: "",
|
||
floorIds: [withoutFloorFilterId],
|
||
supplyTypes: ["AV"],
|
||
};
|
||
assert.equal(isDistributionBoardFilterActive(filters), true);
|
||
assert.deepEqual(
|
||
filterDistributionBoards(boards, filters, floorNameById).map((entry) => entry.id),
|
||
["b3"]
|
||
);
|
||
assert.deepEqual(
|
||
filterDistributionBoards(
|
||
boards,
|
||
{ ...emptyDistributionBoardFilters, supplyTypes: [withoutSupplyTypeFilterId] },
|
||
floorNameById
|
||
).map((entry) => entry.id),
|
||
["b4"]
|
||
);
|
||
});
|
||
|
||
it("offers floors in configured order followed by 'Ohne Etage'", () => {
|
||
assert.deepEqual(buildFloorFilterOptions(floors), [
|
||
{ id: "floor-eg", label: "EG" },
|
||
{ id: "floor-og", label: "1. OG" },
|
||
{ id: withoutFloorFilterId, label: "Ohne Etage" },
|
||
]);
|
||
});
|
||
|
||
it("offers enabled and used supply types in catalog order", () => {
|
||
const options = buildSupplyTypeFilterOptions(
|
||
["AV", "SV", "EV", "USV"],
|
||
["USV", "AV"],
|
||
boards,
|
||
{ AV: "AV – Allgemein", SV: "SV – Sicherheit" }
|
||
);
|
||
assert.deepEqual(options, [
|
||
{ id: "AV", label: "AV – Allgemein" },
|
||
{ id: "SV", label: "SV – Sicherheit" },
|
||
{ id: "USV", label: "USV" },
|
||
{ id: withoutSupplyTypeFilterId, label: "Nicht festgelegt" },
|
||
]);
|
||
});
|
||
});
|
||
|
||
describe("overview filters: project devices", () => {
|
||
const devices = [
|
||
device("d1", {
|
||
displayName: "Steckdose Büro",
|
||
costGroup: "444",
|
||
connectionKind: "Steckdose",
|
||
}),
|
||
device("d2", {
|
||
displayName: "Durchlauferhitzer",
|
||
phaseType: "three_phase",
|
||
category: "three_phase",
|
||
costGroup: " 445 ",
|
||
}),
|
||
device("d3", {
|
||
displayName: "Downlight",
|
||
category: "lighting",
|
||
costGroup: " ",
|
||
}),
|
||
device("d4", { displayName: "Kaffeemaschine", costGroup: null }),
|
||
];
|
||
|
||
it("searches names, connection kind, cost group and the German category label", () => {
|
||
assert.deepEqual(
|
||
filterProjectDevices(devices, { ...emptyProjectDeviceFilters, query: "STECK" }).map(
|
||
(entry) => entry.id
|
||
),
|
||
["d1"]
|
||
);
|
||
assert.deepEqual(
|
||
filterProjectDevices(
|
||
devices,
|
||
{ ...emptyProjectDeviceFilters, query: "beleucht" },
|
||
{ lighting: "Beleuchtung" }
|
||
).map((entry) => entry.id),
|
||
["d3"]
|
||
);
|
||
});
|
||
|
||
it("filters category, phase type and cost group together", () => {
|
||
const filters = {
|
||
query: "",
|
||
categories: ["single_phase", "three_phase"],
|
||
phaseTypes: ["three_phase"],
|
||
costGroups: ["445"],
|
||
};
|
||
assert.equal(isProjectDeviceFilterActive(filters), true);
|
||
assert.deepEqual(
|
||
filterProjectDevices(devices, filters).map((entry) => entry.id),
|
||
["d2"]
|
||
);
|
||
});
|
||
|
||
it("treats blank cost groups as 'Ohne Kostengruppe'", () => {
|
||
assert.deepEqual(
|
||
filterProjectDevices(devices, {
|
||
...emptyProjectDeviceFilters,
|
||
costGroups: [withoutCostGroupFilterId],
|
||
}).map((entry) => entry.id),
|
||
["d3", "d4"]
|
||
);
|
||
assert.deepEqual(buildCostGroupFilterOptions(devices), [
|
||
{ id: "444", label: "444" },
|
||
{ id: "445", label: "445" },
|
||
{ id: withoutCostGroupFilterId, label: "Ohne Kostengruppe" },
|
||
]);
|
||
});
|
||
|
||
it("returns an empty list when the filters hide every device", () => {
|
||
assert.deepEqual(
|
||
filterProjectDevices(devices, { ...emptyProjectDeviceFilters, query: "gibt es nicht" }),
|
||
[]
|
||
);
|
||
assert.equal(isProjectDeviceFilterActive(emptyProjectDeviceFilters), false);
|
||
});
|
||
});
|
||
|
||
describe("overview filters: selection helpers", () => {
|
||
it("toggles values without mutating the selection", () => {
|
||
const selected = ["a"];
|
||
assert.deepEqual(toggleFilterValue(selected, "b"), ["a", "b"]);
|
||
assert.deepEqual(toggleFilterValue(selected, "a"), []);
|
||
assert.deepEqual(selected, ["a"]);
|
||
});
|
||
|
||
it("drops selected values that are no longer offered", () => {
|
||
assert.deepEqual(
|
||
retainAvailableFilterValues(["floor-eg", "deleted-floor"], buildFloorFilterOptions(floors)),
|
||
["floor-eg"]
|
||
);
|
||
});
|
||
});
|