Harden grid deletion and BMK validation
This commit is contained in:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user