Harden grid deletion and BMK validation
This commit is contained in:
@@ -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:
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 <div className="notice info">Loading circuit tree editor...</div>;
|
||||
}
|
||||
if (error) {
|
||||
if (error && !data) {
|
||||
return <div className="notice error">{error}</div>;
|
||||
}
|
||||
if (!data || data.sections.length === 0) {
|
||||
@@ -2657,6 +2733,12 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
</div>
|
||||
) : null}
|
||||
</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>
|
||||
);
|
||||
@@ -2773,6 +2855,17 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
{isSortedView && hasActiveFilters ? (
|
||||
<div className="notice muted">Apply sorted order is disabled while filters are active.</div>
|
||||
) : 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}
|
||||
<div className="tree-editor-layout">
|
||||
<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;
|
||||
const isEditing =
|
||||
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 (
|
||||
<td
|
||||
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")
|
||||
? "device-drag-handle"
|
||||
: ""
|
||||
@@ -3426,6 +3530,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={editingCell.draft}
|
||||
aria-invalid={hasDraftIdentifierConflict}
|
||||
title={hasDraftIdentifierConflict ? "Equipment identifier already exists." : undefined}
|
||||
onChange={(event) =>
|
||||
setEditingCell((current) =>
|
||||
current ? { ...current, draft: event.target.value } : current
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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"]
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user