diff --git a/docs/circuit-list-editor-interactions.md b/docs/circuit-list-editor-interactions.md index dfb5226..a9c8e6b 100644 --- a/docs/circuit-list-editor-interactions.md +++ b/docs/circuit-list-editor-interactions.md @@ -73,6 +73,8 @@ Intent is separated by drag source type: - device row drag: - drop to existing circuit -> move row(s) into that circuit - drop to placeholder -> create new target circuit and move row(s) + - crossing a section boundary requires explicit confirmation + - phase type, category, linked project device and local row values remain unchanged - circuit drag (BMK handle): - reorder circuits inside same section only - cross-section reorder is rejected @@ -83,6 +85,8 @@ Intent is separated by drag source type: The sidebar insertion controls use the same project-device placement rules as drag-and-drop. Invalid section and circuit options are disabled after selecting a project device. +Cross-section device moves show confirmation-required feedback before drop. The confirmation names source and target sections and warns that the unchanged device classification may need manual review. Cancelling leaves every row and circuit unchanged. Moving never renumbers existing circuits. + ## Filtering and Sorting - Per-column filtering works on normalized displayed values. diff --git a/docs/circuit-list-editor-known-limitations.md b/docs/circuit-list-editor-known-limitations.md index 82a7a5d..70f289b 100644 --- a/docs/circuit-list-editor-known-limitations.md +++ b/docs/circuit-list-editor-known-limitations.md @@ -8,5 +8,4 @@ - Bulk device-row move flow is command-based but not fully transaction-hardened end-to-end across all affected circuits. - Sorting is view-only until users explicitly apply sorted order. - Cross-section circuit drag-reorder is intentionally blocked. -- Existing device rows can currently be moved across sections without the planned classification confirmation dialog. - Legacy consumer and circuit-first paths coexist; migration is transitional and still requires operational discipline. diff --git a/src/app/globals.css b/src/app/globals.css index 999705f..8a71bb2 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -423,6 +423,11 @@ body { background: #fff7ed !important; } +.tree-grid .drop-target-confirm { + box-shadow: inset 0 0 0 2px #d97706; + background: #fffbeb !important; +} + .tree-grid tr.circuit-insert-before td, .tree-grid tr.circuit-insert-after td { position: relative; diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx index 2629e89..e55a940 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -12,6 +12,7 @@ import { import { findDuplicateEquipmentIdentifierCircuitIds, hasEquipmentIdentifierConflict, + requiresCrossSectionMoveConfirmation, resolveGridDeleteIntent, } from "../utils/circuit-grid-safety"; import { @@ -143,8 +144,8 @@ type ProjectDeviceDropIntent = | { kind: "add-to-circuit"; circuitId: string; sectionId: string; valid: boolean }; type DeviceRowMoveDropIntent = - | { kind: "move-to-circuit"; circuitId: string; sectionId: string } - | { kind: "move-to-new-circuit"; sectionId: string }; + | { kind: "move-to-circuit"; circuitId: string; sectionId: string; requiresConfirmation: boolean } + | { kind: "move-to-new-circuit"; sectionId: string; requiresConfirmation: boolean }; type CircuitReorderDropIntent = | { kind: "before-circuit"; sectionId: string; targetCircuitId: string; valid: boolean } @@ -2140,6 +2141,36 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str return null; } + function getDeviceRowSourceSectionIds(rowIds: string[]) { + const sectionIds = new Set(); + for (const rowId of rowIds) { + const circuitId = findDeviceRowCircuitId(rowId); + const sectionId = circuitId ? findCircuitSectionId(circuitId) : null; + if (sectionId) { + sectionIds.add(sectionId); + } + } + return [...sectionIds]; + } + + function confirmCrossSectionDeviceMove(rowIds: string[], targetSectionId: string) { + const sourceSectionIds = getDeviceRowSourceSectionIds(rowIds); + if (!requiresCrossSectionMoveConfirmation(sourceSectionIds, targetSectionId)) { + return true; + } + const sourceNames = sourceSectionIds + .map((sectionId) => data?.sections.find((section) => section.id === sectionId)?.displayName ?? sectionId) + .join(", "); + const targetName = + data?.sections.find((section) => section.id === targetSectionId)?.displayName ?? targetSectionId; + return confirm( + `Move ${rowIds.length} device row(s) from ${sourceNames} to ${targetName}?\n\n` + + "This crosses a section boundary. Phase type, category, project-device links and local values remain unchanged. " + + "The target section's technical and numbering classification may therefore need manual review.\n\n" + + "Existing circuit identifiers will not be renumbered." + ); + } + // Applies same-section circuit reorder as explicit id ordering. // No implicit renumbering is performed here. async function applyCircuitReorder(intent: CircuitReorderDropIntent, sourceCircuitIds: string[]) { @@ -2274,12 +2305,16 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str return; } + if (intent.kind === "move-to-circuit" && rowIds.length === 1 && intent.circuitId === sourceCircuitId) { + return; + } + if (!confirmCrossSectionDeviceMove(rowIds, intent.sectionId)) { + return; + } + // Device-row drag supports bulk selection, but target intent is always explicit: // move into existing circuit or create new circuit in a target section. if (intent.kind === "move-to-circuit") { - if (rowIds.length === 1 && intent.circuitId === sourceCircuitId) { - return; - } const groupedBySource = new Map(); for (const rowId of rowIds) { const source = sourceByRowId.get(rowId)!; @@ -2748,7 +2783,13 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str // Renumbering is intentionally blocked while sort/filter overlays are active so users // cannot renumber against a transformed view that has not been persisted yet. const hasActiveSortOrFilter = isSortedView || hasActiveFilters; - const draggingDeviceCount = draggingDeviceRowIds.length > 0 ? draggingDeviceRowIds.length : draggingDeviceRowId ? 1 : 0; + const activeDraggedDeviceRowIds = + draggingDeviceRowIds.length > 0 + ? draggingDeviceRowIds + : draggingDeviceRowId + ? [draggingDeviceRowId] + : []; + const draggingDeviceCount = activeDraggedDeviceRowIds.length; const activeDraggedCircuitIds = draggingCircuitIds.length > 0 ? draggingCircuitIds : draggingCircuitId ? [draggingCircuitId] : []; const draggingCircuitCount = activeDraggedCircuitIds.length; @@ -3167,10 +3208,26 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str } ${ row.rowType === "placeholder" && ((dropIntent?.kind === "new-circuit" && dropIntent.sectionId === row.sectionId && dropIntent.valid) || - (deviceMoveIntent?.kind === "move-to-new-circuit" && deviceMoveIntent.sectionId === row.sectionId) || + (deviceMoveIntent?.kind === "move-to-new-circuit" && + deviceMoveIntent.sectionId === row.sectionId && + !deviceMoveIntent.requiresConfirmation) || (circuitReorderIntent?.kind === "section-end" && circuitReorderIntent.sectionId === row.sectionId)) ? "drop-target-active" : "" + } ${ + row.rowType === "placeholder" && + deviceMoveIntent?.kind === "move-to-new-circuit" && + deviceMoveIntent.sectionId === row.sectionId && + deviceMoveIntent.requiresConfirmation + ? "drop-target-confirm" + : "" + } ${ + row.circuit && + deviceMoveIntent?.kind === "move-to-circuit" && + deviceMoveIntent.circuitId === row.circuit.id && + deviceMoveIntent.requiresConfirmation + ? "drop-target-confirm" + : "" } ${ row.rowType === "placeholder" && dropIntent?.kind === "new-circuit" && @@ -3291,20 +3348,33 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str return; } if (draggingDeviceCount > 0) { + const requiresConfirmation = requiresCrossSectionMoveConfirmation( + getDeviceRowSourceSectionIds(activeDraggedDeviceRowIds), + row.sectionId + ); if (row.rowType === "placeholder") { event.preventDefault(); event.dataTransfer.dropEffect = "move"; - setDeviceMoveIntent({ kind: "move-to-new-circuit", sectionId: row.sectionId }); + setDeviceMoveIntent({ + kind: "move-to-new-circuit", + sectionId: row.sectionId, + requiresConfirmation, + }); return; } if (row.circuit && row.rowType !== "deviceRow") { - const sourceCircuitIds = (draggingDeviceRowIds.length > 0 ? draggingDeviceRowIds : draggingDeviceRowId ? [draggingDeviceRowId] : []) + const sourceCircuitIds = activeDraggedDeviceRowIds .map((id) => findDeviceRowCircuitId(id)) .filter((id): id is string => Boolean(id)); if (sourceCircuitIds.some((sourceCircuitId) => sourceCircuitId !== row.circuit!.id)) { event.preventDefault(); event.dataTransfer.dropEffect = "move"; - setDeviceMoveIntent({ kind: "move-to-circuit", circuitId: row.circuit.id, sectionId: row.sectionId }); + setDeviceMoveIntent({ + kind: "move-to-circuit", + circuitId: row.circuit.id, + sectionId: row.sectionId, + requiresConfirmation, + }); } } } @@ -3400,8 +3470,16 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str return; } if (draggingDeviceCount > 0) { + const requiresConfirmation = requiresCrossSectionMoveConfirmation( + getDeviceRowSourceSectionIds(activeDraggedDeviceRowIds), + row.sectionId + ); if (row.rowType === "placeholder") { - void handleDeviceRowDropWithIntent(event, { kind: "move-to-new-circuit", sectionId: row.sectionId }); + void handleDeviceRowDropWithIntent(event, { + kind: "move-to-new-circuit", + sectionId: row.sectionId, + requiresConfirmation, + }); return; } if (row.circuit && row.rowType !== "deviceRow") { @@ -3409,6 +3487,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str kind: "move-to-circuit", circuitId: row.circuit.id, sectionId: row.sectionId, + requiresConfirmation, }); } } @@ -3570,7 +3649,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str (dropIntent?.kind === "add-to-circuit" && dropIntent.circuitId === row.circuit?.id && dropIntent.valid) || - (deviceMoveIntent?.kind === "move-to-circuit" && deviceMoveIntent.circuitId === row.circuit?.id) + (deviceMoveIntent?.kind === "move-to-circuit" && + deviceMoveIntent.circuitId === row.circuit?.id && + !deviceMoveIntent.requiresConfirmation) ? "drop-target-active" : "" } ${ @@ -3579,6 +3660,12 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str !dropIntent.valid ? "drop-target-invalid" : "" + } ${ + deviceMoveIntent?.kind === "move-to-circuit" && + deviceMoveIntent.circuitId === row.circuit?.id && + deviceMoveIntent.requiresConfirmation + ? "drop-target-confirm" + : "" }`} > {row.circuit && row.rowType !== "deviceRow" ? ( @@ -3617,10 +3704,14 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str {deviceMoveIntent?.kind === "move-to-new-circuit" && row.rowType === "placeholder" && deviceMoveIntent.sectionId === row.sectionId ? ( - {`move ${draggingDeviceCount || 1} device${draggingDeviceCount === 1 ? "" : "s"} to new circuit`} + + {`move ${draggingDeviceCount || 1} device${draggingDeviceCount === 1 ? "" : "s"} to new circuit${deviceMoveIntent.requiresConfirmation ? " (confirmation required)" : ""}`} + ) : null} {deviceMoveIntent?.kind === "move-to-circuit" && deviceMoveIntent.circuitId === row.circuit?.id ? ( - {`move ${draggingDeviceCount || 1} device${draggingDeviceCount === 1 ? "" : "s"} to this circuit`} + + {`move ${draggingDeviceCount || 1} device${draggingDeviceCount === 1 ? "" : "s"} to this circuit${deviceMoveIntent.requiresConfirmation ? " (confirmation required)" : ""}`} + ) : null} {circuitReorderIntent?.kind === "section-end" && row.rowType === "placeholder" && diff --git a/src/frontend/utils/circuit-grid-safety.ts b/src/frontend/utils/circuit-grid-safety.ts index 1c3d5c9..b9d8930 100644 --- a/src/frontend/utils/circuit-grid-safety.ts +++ b/src/frontend/utils/circuit-grid-safety.ts @@ -72,3 +72,10 @@ export function findDuplicateEquipmentIdentifierCircuitIds( } return [...idsByIdentifier.values()].filter((ids) => ids.length > 1).flat(); } + +export function requiresCrossSectionMoveConfirmation( + sourceSectionIds: string[], + targetSectionId: string +): boolean { + return sourceSectionIds.some((sectionId) => sectionId !== targetSectionId); +} diff --git a/tests/circuit-grid-safety.test.ts b/tests/circuit-grid-safety.test.ts index 150a34d..eaffc8a 100644 --- a/tests/circuit-grid-safety.test.ts +++ b/tests/circuit-grid-safety.test.ts @@ -3,6 +3,7 @@ import { describe, it } from "node:test"; import { findDuplicateEquipmentIdentifierCircuitIds, hasEquipmentIdentifierConflict, + requiresCrossSectionMoveConfirmation, resolveGridDeleteIntent, } from "../src/frontend/utils/circuit-grid-safety.js"; @@ -76,4 +77,11 @@ describe("circuit grid safety", () => { ["a", "b"] ); }); + + it("requires confirmation when any moved device crosses a section boundary", () => { + assert.equal(requiresCrossSectionMoveConfirmation(["single"], "single"), false); + assert.equal(requiresCrossSectionMoveConfirmation(["single", "single"], "single"), false); + assert.equal(requiresCrossSectionMoveConfirmation(["single"], "three"), true); + assert.equal(requiresCrossSectionMoveConfirmation(["single", "three"], "three"), true); + }); });