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
+116 -10
View File
@@ -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