Validate project device drop targets

This commit is contained in:
2026-07-22 20:07:09 +02:00
parent b2f5ce77a5
commit fd62bf2e15
6 changed files with 231 additions and 30 deletions
+5
View File
@@ -57,6 +57,9 @@ Intent is separated by drag source type:
- project device drag: - project device drag:
- drop to section/placeholder -> create new circuit with linked row - drop to section/placeholder -> create new circuit with linked row
- drop to existing circuit row -> append row to that circuit - drop to existing circuit row -> append row to that circuit
- lighting devices are accepted only by the lighting section
- other devices are accepted only by the section matching their phase type
- invalid targets show rejection feedback and do not create data
- 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)
@@ -68,6 +71,8 @@ Intent is separated by drag source type:
- multi-circuit move: - multi-circuit move:
- supported for same-section selected circuit blocks - supported for same-section selected circuit blocks
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.
## Filtering and Sorting ## Filtering and Sorting
- Per-column filtering works on normalized displayed values. - Per-column filtering works on normalized displayed values.
@@ -8,4 +8,5 @@
- 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.
+2 -2
View File
@@ -11,8 +11,8 @@
"build:api": "tsc -p tsconfig.json", "build:api": "tsc -p tsconfig.json",
"build:web": "next build", "build:web": "next build",
"start": "node dist/server/index.js", "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-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", "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",
"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-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", "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",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:backup": "node scripts/db-backup.js", "db:backup": "node scripts/db-backup.js",
@@ -0,0 +1,29 @@
export interface ProjectDevicePlacementSource {
category?: string | null;
phaseType: "single_phase" | "three_phase";
}
export interface ProjectDevicePlacementSection {
key: string;
}
export type DefaultCircuitSectionKey = "lighting" | "single_phase" | "three_phase";
// Lighting classification takes precedence over the electrical phase because
// lighting devices use their dedicated numbering section by default.
export function inferProjectDeviceSectionKey(
device: ProjectDevicePlacementSource
): DefaultCircuitSectionKey {
const category = (device.category ?? "").trim().toLowerCase();
if (category.includes("light") || category.includes("beleuchtung")) {
return "lighting";
}
return device.phaseType;
}
export function isProjectDevicePlacementValid(
device: ProjectDevicePlacementSource,
section: ProjectDevicePlacementSection
): boolean {
return section.key === inferProjectDeviceSectionKey(device);
}
+154 -26
View File
@@ -1,6 +1,10 @@
"use client"; "use client";
import { DragEvent, KeyboardEvent, useEffect, useMemo, useRef, useState } from "react"; import { DragEvent, KeyboardEvent, useEffect, useMemo, useRef, useState } from "react";
import {
inferProjectDeviceSectionKey,
isProjectDevicePlacementValid,
} from "../../domain/services/project-device-placement.service";
import { import {
createCircuit, createCircuit,
createCircuitDeviceRow, createCircuitDeviceRow,
@@ -126,8 +130,8 @@ interface CircuitSnapshot {
} }
type ProjectDeviceDropIntent = type ProjectDeviceDropIntent =
| { kind: "new-circuit"; sectionId: string } | { kind: "new-circuit"; sectionId: string; valid: boolean }
| { kind: "add-to-circuit"; circuitId: string; sectionId: string }; | { 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 }
@@ -1706,6 +1710,21 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return projectDevices.find((device) => device.id === selectedProjectDeviceId) ?? null; return projectDevices.find((device) => device.id === selectedProjectDeviceId) ?? null;
} }
function isProjectDeviceTargetValid(device: ProjectDeviceDto, sectionId: string) {
const section = data?.sections.find((entry) => entry.id === sectionId);
return Boolean(section && isProjectDevicePlacementValid(device, section));
}
function getProjectDeviceTargetError(device: ProjectDeviceDto, sectionId: string) {
const target = data?.sections.find((entry) => entry.id === sectionId);
const expectedKey = inferProjectDeviceSectionKey(device);
const expected = data?.sections.find((entry) => entry.key === expectedKey);
if (!target) {
return "Invalid target section.";
}
return `${device.displayName || device.name} belongs in ${expected?.displayName ?? expectedKey}, not ${target.displayName}.`;
}
async function handleAddProjectDeviceAsNewCircuit() { async function handleAddProjectDeviceAsNewCircuit() {
const device = resolveSelectedProjectDevice(); const device = resolveSelectedProjectDevice();
if (!device) { if (!device) {
@@ -1716,6 +1735,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
setError("Please select a target section."); setError("Please select a target section.");
return; return;
} }
if (!isProjectDeviceTargetValid(device, targetSectionId)) {
setError(getProjectDeviceTargetError(device, targetSectionId));
return;
}
let createdCircuitId: string | null = null; let createdCircuitId: string | null = null;
let createdRowId: string | null = null; let createdRowId: string | null = null;
await runCommand({ await runCommand({
@@ -1764,6 +1787,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
setError("Please select a target circuit."); setError("Please select a target circuit.");
return; return;
} }
const sectionId = findCircuitSectionId(circuitId);
if (!sectionId || !isProjectDeviceTargetValid(device, sectionId)) {
setError(getProjectDeviceTargetError(device, sectionId ?? ""));
return;
}
let createdRowId: string | null = null; let createdRowId: string | null = null;
await runCommand({ await runCommand({
@@ -2076,6 +2104,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
setError("Invalid project device drop source."); setError("Invalid project device drop source.");
return; return;
} }
if (!intent.valid || !isProjectDeviceTargetValid(device, intent.sectionId)) {
setError(getProjectDeviceTargetError(device, intent.sectionId));
return;
}
// Project-device drop logic is isolated from row/circuit move logic because // Project-device drop logic is isolated from row/circuit move logic because
// it can create new rows/circuits instead of moving existing ones. // it can create new rows/circuits instead of moving existing ones.
if (intent.kind === "new-circuit") { if (intent.kind === "new-circuit") {
@@ -2580,6 +2612,25 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const activeDraggedCircuitIds = const activeDraggedCircuitIds =
draggingCircuitIds.length > 0 ? draggingCircuitIds : draggingCircuitId ? [draggingCircuitId] : []; draggingCircuitIds.length > 0 ? draggingCircuitIds : draggingCircuitId ? [draggingCircuitId] : [];
const draggingCircuitCount = activeDraggedCircuitIds.length; const draggingCircuitCount = activeDraggedCircuitIds.length;
const selectedProjectDevice = resolveSelectedProjectDevice();
const suggestedSection = selectedProjectDevice
? data.sections.find((section) => section.key === inferProjectDeviceSectionKey(selectedProjectDevice))
: null;
const selectedRowCircuitId = selectedCell ? findRow(selectedCell.rowKey)?.circuit?.id ?? null : null;
const resolvedSidebarTargetCircuitId = targetCircuitId ?? selectedRowCircuitId;
const resolvedSidebarTargetSectionId = resolvedSidebarTargetCircuitId
? findCircuitSectionId(resolvedSidebarTargetCircuitId)
: null;
const canAddToSelectedSection = Boolean(
selectedProjectDevice &&
targetSectionId &&
isProjectDeviceTargetValid(selectedProjectDevice, targetSectionId)
);
const canAddToSelectedCircuit = Boolean(
selectedProjectDevice &&
resolvedSidebarTargetSectionId &&
isProjectDeviceTargetValid(selectedProjectDevice, resolvedSidebarTargetSectionId)
);
return ( return (
<div className="tree-editor-shell"> <div className="tree-editor-shell">
@@ -2722,14 +2773,24 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
onChange={(event) => setTargetSectionId(event.target.value || null)} onChange={(event) => setTargetSectionId(event.target.value || null)}
> >
<option value="">Select section...</option> <option value="">Select section...</option>
{data.sections.map((section) => ( {data.sections.map((section) => {
<option key={section.id} value={section.id}> const valid = !selectedProjectDevice || isProjectDevicePlacementValid(selectedProjectDevice, section);
{section.displayName} return (
<option key={section.id} value={section.id} disabled={!valid}>
{section.displayName}{valid ? "" : " (not valid)"}
</option> </option>
))} );
})}
</select> </select>
</label> </label>
<button type="button" onClick={() => void handleAddProjectDeviceAsNewCircuit()}> {selectedProjectDevice && suggestedSection ? (
<p className="notice muted">Suggested section: {suggestedSection.displayName}</p>
) : null}
<button
type="button"
disabled={!canAddToSelectedSection}
onClick={() => void handleAddProjectDeviceAsNewCircuit()}
>
Add as new circuit Add as new circuit
</button> </button>
<label> <label>
@@ -2739,14 +2800,21 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
onChange={(event) => setTargetCircuitId(event.target.value || null)} onChange={(event) => setTargetCircuitId(event.target.value || null)}
> >
<option value="">Use selected row circuit...</option> <option value="">Use selected row circuit...</option>
{circuitOptions.map((option) => ( {circuitOptions.map((option) => {
<option key={option.id} value={option.id}> const valid = !selectedProjectDevice || isProjectDeviceTargetValid(selectedProjectDevice, option.sectionId);
{option.label} return (
<option key={option.id} value={option.id} disabled={!valid}>
{option.label}{valid ? "" : " (not valid)"}
</option> </option>
))} );
})}
</select> </select>
</label> </label>
<button type="button" onClick={() => void handleAddProjectDeviceToCircuit()}> <button
type="button"
disabled={!canAddToSelectedCircuit}
onClick={() => void handleAddProjectDeviceToCircuit()}
>
Add to selected circuit Add to selected circuit
</button> </button>
</div> </div>
@@ -2849,7 +2917,13 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
<tr <tr
key={row.rowKey} key={row.rowKey}
className={`section-row ${ className={`section-row ${
dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id ? "drop-target-active" : "" dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id && dropIntent.valid
? "drop-target-active"
: ""
} ${
dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id && !dropIntent.valid
? "drop-target-invalid"
: ""
}`} }`}
onDragOver={(event) => { onDragOver={(event) => {
if (draggingCircuitCount > 0) { if (draggingCircuitCount > 0) {
@@ -2865,9 +2939,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
if (!draggingProjectDeviceId) { if (!draggingProjectDeviceId) {
return; return;
} }
const device = projectDevices.find((entry) => entry.id === draggingProjectDeviceId);
const valid = Boolean(device && isProjectDevicePlacementValid(device, section));
event.preventDefault(); event.preventDefault();
event.dataTransfer.dropEffect = "copy"; event.dataTransfer.dropEffect = valid ? "copy" : "none";
setDropIntent({ kind: "new-circuit", sectionId: section.id }); setDropIntent({ kind: "new-circuit", sectionId: section.id, valid });
}} }}
onDragLeave={() => { onDragLeave={() => {
if (dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id) { if (dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id) {
@@ -2879,7 +2955,12 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}} }}
onDrop={(event) => { onDrop={(event) => {
if (draggingProjectDeviceId) { if (draggingProjectDeviceId) {
void handleDropWithIntent(event, { kind: "new-circuit", sectionId: section.id }); const device = projectDevices.find((entry) => entry.id === draggingProjectDeviceId);
void handleDropWithIntent(event, {
kind: "new-circuit",
sectionId: section.id,
valid: Boolean(device && isProjectDevicePlacementValid(device, section)),
});
return; return;
} }
if (draggingCircuitCount > 0) { if (draggingCircuitCount > 0) {
@@ -2910,7 +2991,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
</div> </div>
</div> </div>
{dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id ? ( {dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id ? (
<span className="drop-hint">drop here to create new circuit</span> <span className="drop-hint">
{dropIntent.valid ? "drop here to create new circuit" : "device does not match this section"}
</span>
) : null} ) : null}
</td> </td>
</tr> </tr>
@@ -2932,11 +3015,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
: "" : ""
} ${ } ${
row.rowType === "placeholder" && row.rowType === "placeholder" &&
((dropIntent?.kind === "new-circuit" && dropIntent.sectionId === row.sectionId) || ((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) ||
(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" &&
dropIntent?.kind === "new-circuit" &&
dropIntent.sectionId === row.sectionId &&
!dropIntent.valid
? "drop-target-invalid"
: ""
} ${ } ${
row.rowType === "placeholder" && row.rowType === "placeholder" &&
circuitReorderIntent?.kind === "section-end" && circuitReorderIntent?.kind === "section-end" &&
@@ -2944,6 +3034,20 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
!circuitReorderIntent.valid !circuitReorderIntent.valid
? "drop-target-invalid" ? "drop-target-invalid"
: "" : ""
} ${
row.circuit &&
dropIntent?.kind === "add-to-circuit" &&
dropIntent.circuitId === row.circuit.id &&
dropIntent.valid
? "drop-target-active"
: ""
} ${
row.circuit &&
dropIntent?.kind === "add-to-circuit" &&
dropIntent.circuitId === row.circuit.id &&
!dropIntent.valid
? "drop-target-invalid"
: ""
} ${ } ${
row.circuit && row.circuit &&
circuitReorderIntent && circuitReorderIntent &&
@@ -3014,16 +3118,24 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return; return;
} }
if (draggingProjectDeviceId) { if (draggingProjectDeviceId) {
const device = projectDevices.find((entry) => entry.id === draggingProjectDeviceId);
const section = data.sections.find((entry) => entry.id === row.sectionId);
const valid = Boolean(device && section && isProjectDevicePlacementValid(device, section));
if (row.rowType === "placeholder") { if (row.rowType === "placeholder") {
event.preventDefault(); event.preventDefault();
event.dataTransfer.dropEffect = "copy"; event.dataTransfer.dropEffect = valid ? "copy" : "none";
setDropIntent({ kind: "new-circuit", sectionId: row.sectionId }); setDropIntent({ kind: "new-circuit", sectionId: row.sectionId, valid });
return; return;
} }
if (row.circuit && row.rowType !== "deviceRow") { if (row.circuit && row.rowType !== "deviceRow") {
event.preventDefault(); event.preventDefault();
event.dataTransfer.dropEffect = "copy"; event.dataTransfer.dropEffect = valid ? "copy" : "none";
setDropIntent({ kind: "add-to-circuit", circuitId: row.circuit.id, sectionId: row.sectionId }); setDropIntent({
kind: "add-to-circuit",
circuitId: row.circuit.id,
sectionId: row.sectionId,
valid,
});
} }
return; return;
} }
@@ -3119,8 +3231,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return; return;
} }
if (draggingProjectDeviceId) { if (draggingProjectDeviceId) {
const device = projectDevices.find((entry) => entry.id === draggingProjectDeviceId);
const section = data.sections.find((entry) => entry.id === row.sectionId);
const valid = Boolean(device && section && isProjectDevicePlacementValid(device, section));
if (row.rowType === "placeholder") { if (row.rowType === "placeholder") {
void handleDropWithIntent(event, { kind: "new-circuit", sectionId: row.sectionId }); void handleDropWithIntent(event, { kind: "new-circuit", sectionId: row.sectionId, valid });
return; return;
} }
if (row.circuit && row.rowType !== "deviceRow") { if (row.circuit && row.rowType !== "deviceRow") {
@@ -3128,6 +3243,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
kind: "add-to-circuit", kind: "add-to-circuit",
circuitId: row.circuit.id, circuitId: row.circuit.id,
sectionId: row.sectionId, sectionId: row.sectionId,
valid,
}); });
} }
return; return;
@@ -3287,10 +3403,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
})} })}
<td <td
className={`action-cell ${ className={`action-cell ${
(dropIntent?.kind === "add-to-circuit" && dropIntent.circuitId === row.circuit?.id) || (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)
? "drop-target-active" ? "drop-target-active"
: "" : ""
} ${
dropIntent?.kind === "add-to-circuit" &&
dropIntent.circuitId === row.circuit?.id &&
!dropIntent.valid
? "drop-target-invalid"
: ""
}`} }`}
> >
{row.circuit && row.rowType !== "deviceRow" ? ( {row.circuit && row.rowType !== "deviceRow" ? (
@@ -3317,10 +3441,14 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
</button> </button>
) : null} ) : null}
{dropIntent?.kind === "new-circuit" && row.rowType === "placeholder" && dropIntent.sectionId === row.sectionId ? ( {dropIntent?.kind === "new-circuit" && row.rowType === "placeholder" && dropIntent.sectionId === row.sectionId ? (
<span className="drop-hint">new circuit in this section</span> <span className="drop-hint">
{dropIntent.valid ? "new circuit in this section" : "device does not match this section"}
</span>
) : null} ) : null}
{dropIntent?.kind === "add-to-circuit" && dropIntent.circuitId === row.circuit?.id ? ( {dropIntent?.kind === "add-to-circuit" && dropIntent.circuitId === row.circuit?.id ? (
<span className="drop-hint">add to this circuit</span> <span className="drop-hint">
{dropIntent.valid ? "add to this circuit" : "device does not match this circuit section"}
</span>
) : null} ) : null}
{deviceMoveIntent?.kind === "move-to-new-circuit" && {deviceMoveIntent?.kind === "move-to-new-circuit" &&
row.rowType === "placeholder" && row.rowType === "placeholder" &&
@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
inferProjectDeviceSectionKey,
isProjectDevicePlacementValid,
} from "../src/domain/services/project-device-placement.service.js";
describe("project device placement", () => {
it("routes lighting categories to the lighting section before phase classification", () => {
assert.equal(
inferProjectDeviceSectionKey({ category: "Lighting", phaseType: "three_phase" }),
"lighting"
);
assert.equal(
inferProjectDeviceSectionKey({ category: "Beleuchtung", phaseType: "single_phase" }),
"lighting"
);
});
it("routes non-lighting devices by phase type", () => {
assert.equal(
inferProjectDeviceSectionKey({ category: "Socket", phaseType: "single_phase" }),
"single_phase"
);
assert.equal(
inferProjectDeviceSectionKey({ category: "Motor", phaseType: "three_phase" }),
"three_phase"
);
});
it("accepts only the inferred default section", () => {
const device = { category: "Motor", phaseType: "three_phase" as const };
assert.equal(isProjectDevicePlacementValid(device, { key: "three_phase" }), true);
assert.equal(isProjectDevicePlacementValid(device, { key: "single_phase" }), false);
assert.equal(isProjectDevicePlacementValid(device, { key: "unassigned" }), false);
});
});