Harden grid deletion and BMK validation

This commit is contained in:
2026-07-22 20:27:01 +02:00
parent ea61772e62
commit f8802fecdb
6 changed files with 300 additions and 12 deletions
+7
View File
@@ -38,9 +38,16 @@ Selection, keyboard movement, and editability checks run against this normalized
- arrow keys: move selected cell in grid when not editing - arrow keys: move selected cell in grid when not editing
- type-to-edit: printable keys open edit mode and replace current display value with typed character - type-to-edit: printable keys open edit mode and replace current display value with typed character
- `Ctrl+Plus` / `Ctrl+Shift+Plus`: insert below the selected circuit or device context - `Ctrl+Plus` / `Ctrl+Shift+Plus`: insert below the selected circuit or device context
- `Delete`: delete the selected circuit or device context after confirmation
For insertion, a circuit-level cell creates a reserve circuit directly after the selected circuit. A device-level cell creates a manual device row directly after the selected device. On the virtual `-frei-` row, or without a valid row selection, the shortcut creates a circuit at the end of the active section. Insertions use command history and never renumber existing equipment identifiers. For insertion, a circuit-level cell creates a reserve circuit directly after the selected circuit. A device-level cell creates a manual device row directly after the selected device. On the virtual `-frei-` row, or without a valid row selection, the shortcut creates a circuit at the end of the active section. Insertions use command history and never renumber existing equipment identifiers.
For deletion, a circuit-level cell targets the complete circuit and a device-level cell targets only the device row. Deleting the last device requires an explicit choice between keeping the empty circuit as reserve, deleting the complete circuit, or cancelling. Delete commands are undoable and never renumber remaining circuits.
## Equipment Identifier Validation
Equipment identifiers are compared case-insensitively after trimming whitespace. A conflicting edit is highlighted before submission and cannot be committed. Existing conflicts are highlighted in the grid. Validation errors remain dismissible above the editor instead of replacing the complete workspace.
## `-frei-` Placeholder Behavior ## `-frei-` Placeholder Behavior
Each section has a trailing placeholder row showing `-frei-` in BMK column: Each section has a trailing placeholder row showing `-frei-` in BMK column:
+2 -2
View File
@@ -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", "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",
"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", "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",
"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",
+22
View File
@@ -382,6 +382,16 @@ body {
outline-offset: -2px; outline-offset: -2px;
} }
.tree-grid .cell-invalid {
outline: 2px solid #c2410c;
outline-offset: -2px;
background: #fff7ed !important;
}
.tree-grid .cell-invalid input {
border-color: #c2410c;
}
.tree-grid input { .tree-grid input {
width: 100%; width: 100%;
min-width: 5rem; min-width: 5rem;
@@ -486,6 +496,18 @@ body {
border-color: #f5b5b5; border-color: #f5b5b5;
} }
.notice.warning {
background: #fff7ed;
border-color: #fdba74;
}
.editor-error-notice {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.notice.muted { .notice.muted {
background: #f6f6f6; background: #f6f6f6;
border-color: #e4e4e4; border-color: #e4e4e4;
+116 -10
View File
@@ -9,6 +9,11 @@ import {
getInsertionSortOrder, getInsertionSortOrder,
resolveGridInsertionIntent, resolveGridInsertionIntent,
} from "../utils/circuit-grid-insertion"; } from "../utils/circuit-grid-insertion";
import {
findDuplicateEquipmentIdentifierCircuitIds,
hasEquipmentIdentifierConflict,
resolveGridDeleteIntent,
} from "../utils/circuit-grid-safety";
import { import {
createCircuit, createCircuit,
createCircuitDeviceRow, createCircuitDeviceRow,
@@ -788,6 +793,15 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
); );
}, [data]); }, [data]);
const allCircuits = useMemo(
() => data?.sections.flatMap((section) => section.circuits) ?? [],
[data]
);
const duplicateEquipmentIdentifierCircuitIds = useMemo(
() => new Set(findDuplicateEquipmentIdentifierCircuitIds(allCircuits)),
[allCircuits]
);
function makeRow( function makeRow(
rowType: RowType, rowType: RowType,
sectionId: string, sectionId: string,
@@ -1495,6 +1509,14 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
setEditingCell(null); setEditingCell(null);
return; return;
} }
if (
editingCell.cellKey === "equipmentIdentifier" &&
row.circuit &&
hasEquipmentIdentifierConflict(allCircuits, row.circuit.id, editingCell.draft)
) {
setError("Dieses Betriebsmittelkennzeichen ist bereits vorhanden. Die Änderung wurde nicht gespeichert.");
return;
}
let targetSelection: SelectedCell = { rowKey: editingCell.rowKey, cellKey: editingCell.cellKey }; let targetSelection: SelectedCell = { rowKey: editingCell.rowKey, cellKey: editingCell.cellKey };
const nextSelectionIntent = () => { const nextSelectionIntent = () => {
@@ -2377,16 +2399,32 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
// Delete undo recreates the row from captured snapshot because delete is destructive. // Delete undo recreates the row from captured snapshot because delete is destructive.
async function handleDeleteDevice(rowId: string) { async function handleDeleteDevice(rowId: string) {
if (!confirm("Delete this device row?")) {
return;
}
const sourceCircuitId = findDeviceRowCircuitId(rowId); const sourceCircuitId = findDeviceRowCircuitId(rowId);
const sourceCircuit = sourceCircuitId ? getCircuitSnapshot(sourceCircuitId) : null; const sourceCircuit = sourceCircuitId ? getCircuitSnapshot(sourceCircuitId) : null;
const rowSnapshot = sourceCircuit?.deviceRows.find((row) => row.id === rowId); const rowSnapshot = sourceCircuit?.deviceRows.find((row) => row.id === rowId);
if (!sourceCircuitId || !rowSnapshot) { if (!sourceCircuitId || !sourceCircuit || !rowSnapshot) {
setError("Invalid device row id."); setError("Invalid device row id.");
return; return;
} }
if (sourceCircuit.deviceRows.length === 1) {
const keepAsReserve = confirm(
`"${rowSnapshot.displayName}" is the last device in ${sourceCircuit.equipmentIdentifier}.\n\n` +
"OK: delete the device and keep the empty circuit as reserve.\n" +
"Cancel: choose whether to delete the whole circuit instead."
);
if (!keepAsReserve) {
const deleteCircuit = confirm(
`Delete the complete circuit ${sourceCircuit.equipmentIdentifier}?\n\n` +
"OK: delete circuit and device.\nCancel: do not delete anything."
);
if (deleteCircuit) {
await handleDeleteCircuit(sourceCircuitId, false);
}
return;
}
} else if (!confirm(`Delete device row "${rowSnapshot.displayName}"?`)) {
return;
}
let recreatedRowId: string | null = null; let recreatedRowId: string | null = null;
await runCommand({ await runCommand({
label: "Delete device row", label: "Delete device row",
@@ -2423,15 +2461,21 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
} }
// Deletes a circuit and all child rows; undo recreates from captured snapshot. // Deletes a circuit and all child rows; undo recreates from captured snapshot.
async function handleDeleteCircuit(circuitId: string) { async function handleDeleteCircuit(circuitId: string, askForConfirmation = true) {
if (!confirm("Delete this circuit and all assigned device rows?")) {
return;
}
const snapshot = getCircuitSnapshot(circuitId); const snapshot = getCircuitSnapshot(circuitId);
if (!snapshot) { if (!snapshot) {
setError("Invalid circuit id."); setError("Invalid circuit id.");
return; return;
} }
if (
askForConfirmation &&
!confirm(
`Delete circuit ${snapshot.equipmentIdentifier} and ${snapshot.deviceRows.length} assigned device row(s)?\n\n` +
"Existing circuit identifiers will not be renumbered."
)
) {
return;
}
let recreatedCircuitId: string | null = null; let recreatedCircuitId: string | null = null;
await runCommand({ await runCommand({
label: "Delete circuit", label: "Delete circuit",
@@ -2447,6 +2491,29 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}); });
} }
async function handleDeleteSelection() {
const row = selectedCell ? findRow(selectedCell.rowKey) : null;
const cell = row?.cells.find((entry) => entry.cellKey === selectedCell?.cellKey);
const intent = resolveGridDeleteIntent(
row && cell && row.rowType !== "section"
? {
rowType: row.rowType,
cellKind: cell.kind,
circuitId: row.circuit?.id,
deviceId: row.device?.id,
}
: null
);
if (!intent) {
return;
}
if (intent.kind === "device") {
await handleDeleteDevice(intent.deviceId);
return;
}
await handleDeleteCircuit(intent.circuitId);
}
// Explicit renumber action. Undo restores previous BMKs via safe section identifier update. // Explicit renumber action. Undo restores previous BMKs via safe section identifier update.
async function handleRenumberSection(sectionId: string) { async function handleRenumberSection(sectionId: string) {
if (!confirm("Renumber this section? Only circuits in this section will change.")) { if (!confirm("Renumber this section? Only circuits in this section will change.")) {
@@ -2506,6 +2573,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
} }
return; return;
} }
const eventTarget = event.target as HTMLElement;
if (eventTarget.closest("button, input, select, textarea")) {
return;
}
if (event.key === "Escape") { if (event.key === "Escape") {
if (selectedRowKeys.length > 0) { if (selectedRowKeys.length > 0) {
event.preventDefault(); event.preventDefault();
@@ -2536,6 +2607,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
void handleInsertBelowSelection(); void handleInsertBelowSelection();
return; return;
} }
if (event.key === "Delete" && selectedCell) {
event.preventDefault();
void handleDeleteSelection();
return;
}
if (event.key === "Tab" && selectedCell) { if (event.key === "Tab" && selectedCell) {
event.preventDefault(); event.preventDefault();
const idx = editableCells.findIndex( const idx = editableCells.findIndex(
@@ -2583,7 +2659,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
if (isLoading && !data) { if (isLoading && !data) {
return <div className="notice info">Loading circuit tree editor...</div>; return <div className="notice info">Loading circuit tree editor...</div>;
} }
if (error) { if (error && !data) {
return <div className="notice error">{error}</div>; return <div className="notice error">{error}</div>;
} }
if (!data || data.sections.length === 0) { if (!data || data.sections.length === 0) {
@@ -2657,6 +2733,12 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
</div> </div>
) : null} ) : null}
</div> </div>
{error ? (
<div className="notice error editor-error-notice" role="alert">
<span>{error}</span>
<button type="button" onClick={() => setError(null)}>Dismiss</button>
</div>
) : null}
<div className="notice muted">No matching circuits for active filters.</div> <div className="notice muted">No matching circuits for active filters.</div>
</div> </div>
); );
@@ -2773,6 +2855,17 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
{isSortedView && hasActiveFilters ? ( {isSortedView && hasActiveFilters ? (
<div className="notice muted">Apply sorted order is disabled while filters are active.</div> <div className="notice muted">Apply sorted order is disabled while filters are active.</div>
) : null} ) : null}
{error ? (
<div className="notice error editor-error-notice" role="alert">
<span>{error}</span>
<button type="button" onClick={() => setError(null)}>Dismiss</button>
</div>
) : null}
{duplicateEquipmentIdentifierCircuitIds.size > 0 ? (
<div className="notice warning" role="alert">
Duplicate equipment identifiers are highlighted. Renumbering remains an explicit action.
</div>
) : null}
{isSaving ? <div className="notice info">Saving...</div> : null} {isSaving ? <div className="notice info">Saving...</div> : null}
<div className="tree-editor-layout"> <div className="tree-editor-layout">
<aside className="project-device-sidebar"> <aside className="project-device-sidebar">
@@ -3327,10 +3420,21 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
selectedCell?.rowKey === row.rowKey && selectedCell.cellKey === column.key; selectedCell?.rowKey === row.rowKey && selectedCell.cellKey === column.key;
const isEditing = const isEditing =
editingCell?.rowKey === row.rowKey && editingCell.cellKey === column.key; editingCell?.rowKey === row.rowKey && editingCell.cellKey === column.key;
const hasDraftIdentifierConflict = Boolean(
isEditing &&
column.key === "equipmentIdentifier" &&
row.circuit &&
hasEquipmentIdentifierConflict(allCircuits, row.circuit.id, editingCell?.draft ?? "")
);
const hasIdentifierConflict = Boolean(
column.key === "equipmentIdentifier" &&
row.circuit &&
(duplicateEquipmentIdentifierCircuitIds.has(row.circuit.id) || hasDraftIdentifierConflict)
);
return ( return (
<td <td
key={column.key} key={column.key}
className={`${column.numeric ? "num" : ""} ${cell.editable ? "cell-editable" : ""} ${isSelected ? "cell-selected" : ""} ${ className={`${column.numeric ? "num" : ""} ${cell.editable ? "cell-editable" : ""} ${isSelected ? "cell-selected" : ""} ${hasIdentifierConflict ? "cell-invalid" : ""} ${
Boolean(row.device) && column.key === "displayName" && (row.rowType === "deviceRow" || row.rowType === "circuitCompact") Boolean(row.device) && column.key === "displayName" && (row.rowType === "deviceRow" || row.rowType === "circuitCompact")
? "device-drag-handle" ? "device-drag-handle"
: "" : ""
@@ -3426,6 +3530,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
<input <input
ref={inputRef} ref={inputRef}
value={editingCell.draft} value={editingCell.draft}
aria-invalid={hasDraftIdentifierConflict}
title={hasDraftIdentifierConflict ? "Equipment identifier already exists." : undefined}
onChange={(event) => onChange={(event) =>
setEditingCell((current) => setEditingCell((current) =>
current ? { ...current, draft: event.target.value } : current current ? { ...current, draft: event.target.value } : current
+74
View File
@@ -0,0 +1,74 @@
import type {
GridInsertionCellKind,
GridInsertionRowType,
} from "./circuit-grid-insertion.js";
export interface GridDeleteSelection {
rowType: GridInsertionRowType;
cellKind: GridInsertionCellKind;
circuitId?: string;
deviceId?: string;
}
export type GridDeleteIntent =
| { kind: "circuit"; circuitId: string }
| { kind: "device"; deviceId: string };
export interface CircuitIdentifierRecord {
id: string;
equipmentIdentifier: string;
}
export function resolveGridDeleteIntent(selection: GridDeleteSelection | null): GridDeleteIntent | null {
if (!selection || selection.rowType === "placeholder") {
return null;
}
const targetsDevice =
selection.rowType === "deviceRow" ||
(selection.rowType === "circuitCompact" && selection.cellKind === "deviceField");
if (targetsDevice && selection.deviceId) {
return { kind: "device", deviceId: selection.deviceId };
}
if (selection.circuitId) {
return { kind: "circuit", circuitId: selection.circuitId };
}
return null;
}
export function normalizeEquipmentIdentifier(value: string): string {
return value.trim().toUpperCase();
}
export function hasEquipmentIdentifierConflict(
circuits: CircuitIdentifierRecord[],
currentCircuitId: string,
candidate: string
): boolean {
const normalizedCandidate = normalizeEquipmentIdentifier(candidate);
if (!normalizedCandidate) {
return false;
}
return circuits.some(
(circuit) =>
circuit.id !== currentCircuitId &&
normalizeEquipmentIdentifier(circuit.equipmentIdentifier) === normalizedCandidate
);
}
export function findDuplicateEquipmentIdentifierCircuitIds(
circuits: CircuitIdentifierRecord[]
): string[] {
const idsByIdentifier = new Map<string, string[]>();
for (const circuit of circuits) {
const normalized = normalizeEquipmentIdentifier(circuit.equipmentIdentifier);
if (!normalized) {
continue;
}
const ids = idsByIdentifier.get(normalized) ?? [];
ids.push(circuit.id);
idsByIdentifier.set(normalized, ids);
}
return [...idsByIdentifier.values()].filter((ids) => ids.length > 1).flat();
}
+79
View File
@@ -0,0 +1,79 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
findDuplicateEquipmentIdentifierCircuitIds,
hasEquipmentIdentifierConflict,
resolveGridDeleteIntent,
} from "../src/frontend/utils/circuit-grid-safety.js";
describe("circuit grid safety", () => {
it("deletes only the device for device-level selections", () => {
assert.deepEqual(
resolveGridDeleteIntent({
rowType: "circuitCompact",
cellKind: "deviceField",
circuitId: "circuit-1",
deviceId: "device-1",
}),
{ kind: "device", deviceId: "device-1" }
);
assert.deepEqual(
resolveGridDeleteIntent({
rowType: "deviceRow",
cellKind: "deviceField",
circuitId: "circuit-1",
deviceId: "device-2",
}),
{ kind: "device", deviceId: "device-2" }
);
});
it("deletes the whole circuit for circuit-level selections", () => {
assert.deepEqual(
resolveGridDeleteIntent({
rowType: "circuitCompact",
cellKind: "circuitField",
circuitId: "circuit-1",
deviceId: "device-1",
}),
{ kind: "circuit", circuitId: "circuit-1" }
);
assert.deepEqual(
resolveGridDeleteIntent({
rowType: "reserveCircuit",
cellKind: "deviceField",
circuitId: "circuit-2",
}),
{ kind: "circuit", circuitId: "circuit-2" }
);
});
it("does nothing on the free placeholder", () => {
assert.equal(
resolveGridDeleteIntent({ rowType: "placeholder", cellKind: "deviceField" }),
null
);
});
it("detects normalized BMK conflicts while excluding the edited circuit", () => {
const circuits = [
{ id: "a", equipmentIdentifier: "-2F1" },
{ id: "b", equipmentIdentifier: "-2F2" },
];
assert.equal(hasEquipmentIdentifierConflict(circuits, "b", " -2f1 "), true);
assert.equal(hasEquipmentIdentifierConflict(circuits, "a", "-2f1"), false);
assert.equal(hasEquipmentIdentifierConflict(circuits, "b", "-2F3"), false);
});
it("returns every circuit participating in a normalized duplicate", () => {
assert.deepEqual(
findDuplicateEquipmentIdentifierCircuitIds([
{ id: "a", equipmentIdentifier: "-2F1" },
{ id: "b", equipmentIdentifier: " -2f1 " },
{ id: "c", equipmentIdentifier: "-2F2" },
]).sort(),
["a", "b"]
);
});
});