From f8802fecdba751eb8d718ca292cdf44fd8bc50fe Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Wed, 22 Jul 2026 20:27:01 +0200 Subject: [PATCH] Harden grid deletion and BMK validation --- docs/circuit-list-editor-interactions.md | 7 + package.json | 4 +- src/app/globals.css | 22 +++ .../components/circuit-tree-editor.tsx | 126 ++++++++++++++++-- src/frontend/utils/circuit-grid-safety.ts | 74 ++++++++++ tests/circuit-grid-safety.test.ts | 79 +++++++++++ 6 files changed, 300 insertions(+), 12 deletions(-) create mode 100644 src/frontend/utils/circuit-grid-safety.ts create mode 100644 tests/circuit-grid-safety.test.ts diff --git a/docs/circuit-list-editor-interactions.md b/docs/circuit-list-editor-interactions.md index 7b1aabc..dfb5226 100644 --- a/docs/circuit-list-editor-interactions.md +++ b/docs/circuit-list-editor-interactions.md @@ -38,9 +38,16 @@ Selection, keyboard movement, and editability checks run against this normalized - 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 - `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 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 Each section has a trailing placeholder row showing `-frei-` in BMK column: diff --git a/package.json b/package.json index da706fa..0b08f30 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,8 @@ "build:api": "tsc -p tsconfig.json", "build:web": "next build", "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: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": "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 tests/circuit-grid-safety.test.ts", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:backup": "node scripts/db-backup.js", diff --git a/src/app/globals.css b/src/app/globals.css index c1bfb11..999705f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -382,6 +382,16 @@ body { 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 { width: 100%; min-width: 5rem; @@ -486,6 +496,18 @@ body { 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 { background: #f6f6f6; border-color: #e4e4e4; diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx index 9b27eda..2629e89 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -9,6 +9,11 @@ import { getInsertionSortOrder, resolveGridInsertionIntent, } from "../utils/circuit-grid-insertion"; +import { + findDuplicateEquipmentIdentifierCircuitIds, + hasEquipmentIdentifierConflict, + resolveGridDeleteIntent, +} from "../utils/circuit-grid-safety"; import { createCircuit, createCircuitDeviceRow, @@ -788,6 +793,15 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str ); }, [data]); + const allCircuits = useMemo( + () => data?.sections.flatMap((section) => section.circuits) ?? [], + [data] + ); + const duplicateEquipmentIdentifierCircuitIds = useMemo( + () => new Set(findDuplicateEquipmentIdentifierCircuitIds(allCircuits)), + [allCircuits] + ); + function makeRow( rowType: RowType, sectionId: string, @@ -1495,6 +1509,14 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str setEditingCell(null); 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 }; 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. async function handleDeleteDevice(rowId: string) { - if (!confirm("Delete this device row?")) { - return; - } const sourceCircuitId = findDeviceRowCircuitId(rowId); const sourceCircuit = sourceCircuitId ? getCircuitSnapshot(sourceCircuitId) : null; const rowSnapshot = sourceCircuit?.deviceRows.find((row) => row.id === rowId); - if (!sourceCircuitId || !rowSnapshot) { + if (!sourceCircuitId || !sourceCircuit || !rowSnapshot) { setError("Invalid device row id."); 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; await runCommand({ 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. - async function handleDeleteCircuit(circuitId: string) { - if (!confirm("Delete this circuit and all assigned device rows?")) { - return; - } + async function handleDeleteCircuit(circuitId: string, askForConfirmation = true) { const snapshot = getCircuitSnapshot(circuitId); if (!snapshot) { setError("Invalid circuit id."); 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; await runCommand({ 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. async function handleRenumberSection(sectionId: string) { 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; } + const eventTarget = event.target as HTMLElement; + if (eventTarget.closest("button, input, select, textarea")) { + return; + } if (event.key === "Escape") { if (selectedRowKeys.length > 0) { event.preventDefault(); @@ -2536,6 +2607,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str void handleInsertBelowSelection(); return; } + if (event.key === "Delete" && selectedCell) { + event.preventDefault(); + void handleDeleteSelection(); + return; + } if (event.key === "Tab" && selectedCell) { event.preventDefault(); const idx = editableCells.findIndex( @@ -2583,7 +2659,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str if (isLoading && !data) { return
Loading circuit tree editor...
; } - if (error) { + if (error && !data) { return
{error}
; } if (!data || data.sections.length === 0) { @@ -2657,6 +2733,12 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str ) : null} + {error ? ( +
+ {error} + +
+ ) : null}
No matching circuits for active filters.
); @@ -2773,6 +2855,17 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str {isSortedView && hasActiveFilters ? (
Apply sorted order is disabled while filters are active.
) : null} + {error ? ( +
+ {error} + +
+ ) : null} + {duplicateEquipmentIdentifierCircuitIds.size > 0 ? ( +
+ Duplicate equipment identifiers are highlighted. Renumbering remains an explicit action. +
+ ) : null} {isSaving ?
Saving...
: null}