Confirm cross-section device moves

This commit is contained in:
2026-07-22 20:31:55 +02:00
parent f8802fecdb
commit b3a7ff79da
6 changed files with 129 additions and 15 deletions
+4
View File
@@ -73,6 +73,8 @@ Intent is separated by drag source type:
- device row drag: - device row drag:
- drop to existing circuit -> move row(s) into that circuit - drop to existing circuit -> move row(s) into that circuit
- drop to placeholder -> create new target circuit and move row(s) - 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): - circuit drag (BMK handle):
- reorder circuits inside same section only - reorder circuits inside same section only
- cross-section reorder is rejected - 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. 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 ## Filtering and Sorting
- Per-column filtering works on normalized displayed values. - Per-column filtering works on normalized displayed values.
@@ -8,5 +8,4 @@
- Bulk device-row move flow is command-based but not fully transaction-hardened end-to-end across all affected circuits. - 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. - Sorting is view-only until users explicitly apply sorted order.
- Cross-section circuit drag-reorder is intentionally blocked. - 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. - Legacy consumer and circuit-first paths coexist; migration is transitional and still requires operational discipline.
+5
View File
@@ -423,6 +423,11 @@ body {
background: #fff7ed !important; 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-before td,
.tree-grid tr.circuit-insert-after td { .tree-grid tr.circuit-insert-after td {
position: relative; position: relative;
+105 -14
View File
@@ -12,6 +12,7 @@ import {
import { import {
findDuplicateEquipmentIdentifierCircuitIds, findDuplicateEquipmentIdentifierCircuitIds,
hasEquipmentIdentifierConflict, hasEquipmentIdentifierConflict,
requiresCrossSectionMoveConfirmation,
resolveGridDeleteIntent, resolveGridDeleteIntent,
} from "../utils/circuit-grid-safety"; } from "../utils/circuit-grid-safety";
import { import {
@@ -143,8 +144,8 @@ type ProjectDeviceDropIntent =
| { kind: "add-to-circuit"; circuitId: string; sectionId: string; valid: boolean }; | { kind: "add-to-circuit"; circuitId: string; sectionId: string; valid: boolean };
type DeviceRowMoveDropIntent = type DeviceRowMoveDropIntent =
| { kind: "move-to-circuit"; circuitId: string; sectionId: string } | { kind: "move-to-circuit"; circuitId: string; sectionId: string; requiresConfirmation: boolean }
| { kind: "move-to-new-circuit"; sectionId: string }; | { kind: "move-to-new-circuit"; sectionId: string; requiresConfirmation: boolean };
type CircuitReorderDropIntent = type CircuitReorderDropIntent =
| { kind: "before-circuit"; sectionId: string; targetCircuitId: string; valid: boolean } | { kind: "before-circuit"; sectionId: string; targetCircuitId: string; valid: boolean }
@@ -2140,6 +2141,36 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return null; return null;
} }
function getDeviceRowSourceSectionIds(rowIds: string[]) {
const sectionIds = new Set<string>();
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. // Applies same-section circuit reorder as explicit id ordering.
// No implicit renumbering is performed here. // No implicit renumbering is performed here.
async function applyCircuitReorder(intent: CircuitReorderDropIntent, sourceCircuitIds: string[]) { async function applyCircuitReorder(intent: CircuitReorderDropIntent, sourceCircuitIds: string[]) {
@@ -2274,12 +2305,16 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return; 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: // Device-row drag supports bulk selection, but target intent is always explicit:
// move into existing circuit or create new circuit in a target section. // move into existing circuit or create new circuit in a target section.
if (intent.kind === "move-to-circuit") { if (intent.kind === "move-to-circuit") {
if (rowIds.length === 1 && intent.circuitId === sourceCircuitId) {
return;
}
const groupedBySource = new Map<string, string[]>(); const groupedBySource = new Map<string, string[]>();
for (const rowId of rowIds) { for (const rowId of rowIds) {
const source = sourceByRowId.get(rowId)!; 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 // Renumbering is intentionally blocked while sort/filter overlays are active so users
// cannot renumber against a transformed view that has not been persisted yet. // cannot renumber against a transformed view that has not been persisted yet.
const hasActiveSortOrFilter = isSortedView || hasActiveFilters; 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 = const activeDraggedCircuitIds =
draggingCircuitIds.length > 0 ? draggingCircuitIds : draggingCircuitId ? [draggingCircuitId] : []; draggingCircuitIds.length > 0 ? draggingCircuitIds : draggingCircuitId ? [draggingCircuitId] : [];
const draggingCircuitCount = activeDraggedCircuitIds.length; const draggingCircuitCount = activeDraggedCircuitIds.length;
@@ -3167,10 +3208,26 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
} ${ } ${
row.rowType === "placeholder" && row.rowType === "placeholder" &&
((dropIntent?.kind === "new-circuit" && dropIntent.sectionId === row.sectionId && dropIntent.valid) || ((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)) (circuitReorderIntent?.kind === "section-end" && circuitReorderIntent.sectionId === row.sectionId))
? "drop-target-active" ? "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" && row.rowType === "placeholder" &&
dropIntent?.kind === "new-circuit" && dropIntent?.kind === "new-circuit" &&
@@ -3291,20 +3348,33 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return; return;
} }
if (draggingDeviceCount > 0) { if (draggingDeviceCount > 0) {
const requiresConfirmation = requiresCrossSectionMoveConfirmation(
getDeviceRowSourceSectionIds(activeDraggedDeviceRowIds),
row.sectionId
);
if (row.rowType === "placeholder") { if (row.rowType === "placeholder") {
event.preventDefault(); event.preventDefault();
event.dataTransfer.dropEffect = "move"; event.dataTransfer.dropEffect = "move";
setDeviceMoveIntent({ kind: "move-to-new-circuit", sectionId: row.sectionId }); setDeviceMoveIntent({
kind: "move-to-new-circuit",
sectionId: row.sectionId,
requiresConfirmation,
});
return; return;
} }
if (row.circuit && row.rowType !== "deviceRow") { if (row.circuit && row.rowType !== "deviceRow") {
const sourceCircuitIds = (draggingDeviceRowIds.length > 0 ? draggingDeviceRowIds : draggingDeviceRowId ? [draggingDeviceRowId] : []) const sourceCircuitIds = activeDraggedDeviceRowIds
.map((id) => findDeviceRowCircuitId(id)) .map((id) => findDeviceRowCircuitId(id))
.filter((id): id is string => Boolean(id)); .filter((id): id is string => Boolean(id));
if (sourceCircuitIds.some((sourceCircuitId) => sourceCircuitId !== row.circuit!.id)) { if (sourceCircuitIds.some((sourceCircuitId) => sourceCircuitId !== row.circuit!.id)) {
event.preventDefault(); event.preventDefault();
event.dataTransfer.dropEffect = "move"; 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; return;
} }
if (draggingDeviceCount > 0) { if (draggingDeviceCount > 0) {
const requiresConfirmation = requiresCrossSectionMoveConfirmation(
getDeviceRowSourceSectionIds(activeDraggedDeviceRowIds),
row.sectionId
);
if (row.rowType === "placeholder") { 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; return;
} }
if (row.circuit && row.rowType !== "deviceRow") { if (row.circuit && row.rowType !== "deviceRow") {
@@ -3409,6 +3487,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
kind: "move-to-circuit", kind: "move-to-circuit",
circuitId: row.circuit.id, circuitId: row.circuit.id,
sectionId: row.sectionId, sectionId: row.sectionId,
requiresConfirmation,
}); });
} }
} }
@@ -3570,7 +3649,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
(dropIntent?.kind === "add-to-circuit" && (dropIntent?.kind === "add-to-circuit" &&
dropIntent.circuitId === row.circuit?.id && dropIntent.circuitId === row.circuit?.id &&
dropIntent.valid) || 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" ? "drop-target-active"
: "" : ""
} ${ } ${
@@ -3579,6 +3660,12 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
!dropIntent.valid !dropIntent.valid
? "drop-target-invalid" ? "drop-target-invalid"
: "" : ""
} ${
deviceMoveIntent?.kind === "move-to-circuit" &&
deviceMoveIntent.circuitId === row.circuit?.id &&
deviceMoveIntent.requiresConfirmation
? "drop-target-confirm"
: ""
}`} }`}
> >
{row.circuit && row.rowType !== "deviceRow" ? ( {row.circuit && row.rowType !== "deviceRow" ? (
@@ -3617,10 +3704,14 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
{deviceMoveIntent?.kind === "move-to-new-circuit" && {deviceMoveIntent?.kind === "move-to-new-circuit" &&
row.rowType === "placeholder" && row.rowType === "placeholder" &&
deviceMoveIntent.sectionId === row.sectionId ? ( deviceMoveIntent.sectionId === row.sectionId ? (
<span className="drop-hint">{`move ${draggingDeviceCount || 1} device${draggingDeviceCount === 1 ? "" : "s"} to new circuit`}</span> <span className="drop-hint">
{`move ${draggingDeviceCount || 1} device${draggingDeviceCount === 1 ? "" : "s"} to new circuit${deviceMoveIntent.requiresConfirmation ? " (confirmation required)" : ""}`}
</span>
) : null} ) : null}
{deviceMoveIntent?.kind === "move-to-circuit" && deviceMoveIntent.circuitId === row.circuit?.id ? ( {deviceMoveIntent?.kind === "move-to-circuit" && deviceMoveIntent.circuitId === row.circuit?.id ? (
<span className="drop-hint">{`move ${draggingDeviceCount || 1} device${draggingDeviceCount === 1 ? "" : "s"} to this circuit`}</span> <span className="drop-hint">
{`move ${draggingDeviceCount || 1} device${draggingDeviceCount === 1 ? "" : "s"} to this circuit${deviceMoveIntent.requiresConfirmation ? " (confirmation required)" : ""}`}
</span>
) : null} ) : null}
{circuitReorderIntent?.kind === "section-end" && {circuitReorderIntent?.kind === "section-end" &&
row.rowType === "placeholder" && row.rowType === "placeholder" &&
@@ -72,3 +72,10 @@ export function findDuplicateEquipmentIdentifierCircuitIds(
} }
return [...idsByIdentifier.values()].filter((ids) => ids.length > 1).flat(); return [...idsByIdentifier.values()].filter((ids) => ids.length > 1).flat();
} }
export function requiresCrossSectionMoveConfirmation(
sourceSectionIds: string[],
targetSectionId: string
): boolean {
return sourceSectionIds.some((sectionId) => sectionId !== targetSectionId);
}
+8
View File
@@ -3,6 +3,7 @@ import { describe, it } from "node:test";
import { import {
findDuplicateEquipmentIdentifierCircuitIds, findDuplicateEquipmentIdentifierCircuitIds,
hasEquipmentIdentifierConflict, hasEquipmentIdentifierConflict,
requiresCrossSectionMoveConfirmation,
resolveGridDeleteIntent, resolveGridDeleteIntent,
} from "../src/frontend/utils/circuit-grid-safety.js"; } from "../src/frontend/utils/circuit-grid-safety.js";
@@ -76,4 +77,11 @@ describe("circuit grid safety", () => {
["a", "b"] ["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);
});
}); });