diff --git a/docs/circuit-list-editor-interactions.md b/docs/circuit-list-editor-interactions.md index 8b799db..7b1aabc 100644 --- a/docs/circuit-list-editor-interactions.md +++ b/docs/circuit-list-editor-interactions.md @@ -37,6 +37,9 @@ Selection, keyboard movement, and editability checks run against this normalized - `Tab` / `Shift+Tab`: move between editable cells (commits active edit) - arrow keys: move selected cell in grid when not editing - type-to-edit: printable keys open edit mode and replace current display value with typed character +- `Ctrl+Plus` / `Ctrl+Shift+Plus`: insert below the selected circuit or device context + +For insertion, a circuit-level cell creates a reserve circuit directly after the selected circuit. A device-level cell creates a manual device row directly after the selected device. On the virtual `-frei-` row, or without a valid row selection, the shortcut creates a circuit at the end of the active section. Insertions use command history and never renumber existing equipment identifiers. ## `-frei-` Placeholder Behavior diff --git a/package.json b/package.json index 0fa23fd..da706fa 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,8 @@ "build:api": "tsc -p tsconfig.json", "build:web": "next build", "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-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-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": "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 tests/circuit-grid-insertion.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 tests/circuit-grid-insertion.test.ts", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:backup": "node scripts/db-backup.js", diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx index 6f23b6b..9b27eda 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -5,6 +5,10 @@ import { inferProjectDeviceSectionKey, isProjectDevicePlacementValid, } from "../../domain/services/project-device-placement.service"; +import { + getInsertionSortOrder, + resolveGridInsertionIntent, +} from "../utils/circuit-grid-insertion"; import { createCircuit, createCircuitDeviceRow, @@ -1616,7 +1620,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str requestAnimationFrame(() => containerRef.current?.focus()); } - async function handleAddReserveCircuit(sectionId: string) { + async function handleAddReserveCircuit(sectionId: string, afterCircuitId?: string) { const section = data?.sections.find((entry) => entry.id === sectionId); if (!section) { return; @@ -1626,8 +1630,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str label: "Add circuit", redo: async () => { const next = await getNextCircuitIdentifier(sectionId); - const sortOrder = - section.circuits.length > 0 ? Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) + 10 : 10; + const sortOrder = getInsertionSortOrder(section.circuits, afterCircuitId); const created = (await createCircuit(projectId, circuitListId, { sectionId, equipmentIdentifier: next.nextIdentifier, @@ -1659,7 +1662,13 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str }); } - async function handleAddManualDevice(circuit: CircuitTreeCircuitDto, sectionId: string) { + async function handleAddManualDevice( + circuit: CircuitTreeCircuitDto, + sectionId: string, + afterDeviceRowId?: string + ) { + const originalDeviceCount = circuit.deviceRows.length; + const section = data?.sections.find((entry) => entry.id === sectionId); let createdRowId: string | null = null; await runCommand({ label: "Add manual device", @@ -1667,18 +1676,19 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str const created = (await createCircuitDeviceRow(circuit.id, { name: "Manual device", displayName: "Manual device", - phaseType: "single_phase", + phaseType: section?.key === "three_phase" ? "three_phase" : "single_phase", quantity: 1, powerPerUnit: 0, simultaneityFactor: 1, cosPhi: 1, + sortOrder: getInsertionSortOrder(circuit.deviceRows, afterDeviceRowId), })) as { id: string }; createdRowId = created.id; setActiveSectionId(sectionId); return { - rowKey: `device:${created.id}`, + rowKey: originalDeviceCount === 0 ? `circuitCompact:${circuit.id}` : `device:${created.id}`, cellKey: "displayName", - rowType: "deviceRow", + rowType: originalDeviceCount === 0 ? "circuitCompact" : "deviceRow", sectionId, circuitId: circuit.id, deviceId: created.id, @@ -1688,17 +1698,68 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str if (createdRowId) { await deleteCircuitDeviceRowById(createdRowId); } + const rowKey = + originalDeviceCount === 0 + ? `reserveCircuit:${circuit.id}` + : originalDeviceCount === 1 + ? `circuitCompact:${circuit.id}` + : afterDeviceRowId + ? `device:${afterDeviceRowId}` + : `circuitSummary:${circuit.id}`; return { - rowKey: `reserveCircuit:${circuit.id}`, + rowKey, cellKey: "displayName", - rowType: "reserveCircuit", + rowType: + originalDeviceCount === 0 + ? "reserveCircuit" + : originalDeviceCount === 1 + ? "circuitCompact" + : afterDeviceRowId + ? "deviceRow" + : "circuitSummary", sectionId, circuitId: circuit.id, + deviceId: afterDeviceRowId, }; }, }); } + async function handleInsertBelowSelection() { + const row = selectedCell ? findRow(selectedCell.rowKey) : null; + const cell = row?.cells.find((entry) => entry.cellKey === selectedCell?.cellKey); + const selection = + row && cell && row.rowType !== "section" + ? { + rowType: row.rowType, + cellKind: cell.kind, + sectionId: row.sectionId, + circuitId: row.circuit?.id, + deviceId: row.device?.id, + } + : null; + const intent = resolveGridInsertionIntent( + selection, + activeSectionId ?? data?.sections[0]?.id + ); + if (!intent) { + setError("No section is available for insertion."); + return; + } + if (intent.kind === "circuit") { + await handleAddReserveCircuit(intent.sectionId, intent.afterCircuitId); + return; + } + const circuit = data?.sections + .flatMap((sectionEntry) => sectionEntry.circuits) + .find((entry) => entry.id === intent.circuitId); + if (!circuit) { + setError("Invalid circuit id."); + return; + } + await handleAddManualDevice(circuit, intent.sectionId, intent.afterDeviceRowId); + } + function resolvePhaseType(device: ProjectDeviceDto): string { return device.phaseType; } @@ -2472,10 +2533,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str (event.key === "+" || (event.shiftKey && event.key === "=") || event.code === "NumpadAdd"); if (isCtrlPlus) { event.preventDefault(); - const sectionId = activeSectionId ?? data?.sections[0]?.id; - if (sectionId) { - void handleAddReserveCircuit(sectionId); - } + void handleInsertBelowSelection(); return; } if (event.key === "Tab" && selectedCell) { @@ -3486,7 +3544,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str -
TODO Phase: Ctrl+Plus currently adds circuit at end of active section.
++ Ctrl+Plus inserts below the selected circuit or device context. Use Undo to revert the insertion. +
); } diff --git a/src/frontend/utils/circuit-grid-insertion.ts b/src/frontend/utils/circuit-grid-insertion.ts new file mode 100644 index 0000000..ccb73ad --- /dev/null +++ b/src/frontend/utils/circuit-grid-insertion.ts @@ -0,0 +1,79 @@ +export type GridInsertionRowType = + | "circuitCompact" + | "circuitSummary" + | "deviceRow" + | "reserveCircuit" + | "placeholder"; + +export type GridInsertionCellKind = "circuitField" | "deviceField" | "readonly" | "computed"; + +export interface GridInsertionSelection { + rowType: GridInsertionRowType; + cellKind: GridInsertionCellKind; + sectionId: string; + circuitId?: string; + deviceId?: string; +} + +export type GridInsertionIntent = + | { kind: "circuit"; sectionId: string; afterCircuitId?: string } + | { kind: "device"; sectionId: string; circuitId: string; afterDeviceRowId?: string }; + +export function resolveGridInsertionIntent( + selection: GridInsertionSelection | null, + fallbackSectionId?: string +): GridInsertionIntent | null { + if (!selection) { + return fallbackSectionId ? { kind: "circuit", sectionId: fallbackSectionId } : null; + } + + if (selection.rowType === "placeholder") { + return { kind: "circuit", sectionId: selection.sectionId }; + } + + const targetsDevice = + selection.rowType === "deviceRow" || + ((selection.rowType === "circuitCompact" || selection.rowType === "reserveCircuit") && + selection.cellKind === "deviceField"); + + if (targetsDevice && selection.circuitId) { + return { + kind: "device", + sectionId: selection.sectionId, + circuitId: selection.circuitId, + afterDeviceRowId: selection.deviceId, + }; + } + + if (selection.circuitId) { + return { + kind: "circuit", + sectionId: selection.sectionId, + afterCircuitId: selection.circuitId, + }; + } + + return { kind: "circuit", sectionId: selection.sectionId }; +} + +export function getInsertionSortOrder( + orderedEntries: Array<{ id: string; sortOrder: number }>, + afterId?: string +): number { + if (orderedEntries.length === 0) { + return 10; + } + if (!afterId) { + return Math.max(...orderedEntries.map((entry) => entry.sortOrder)) + 10; + } + + const entries = [...orderedEntries].sort((left, right) => left.sortOrder - right.sortOrder); + const index = entries.findIndex((entry) => entry.id === afterId); + if (index < 0) { + return entries[entries.length - 1].sortOrder + 10; + } + + const current = entries[index].sortOrder; + const next = entries[index + 1]?.sortOrder; + return next === undefined ? current + 10 : current + (next - current) / 2; +} diff --git a/tests/circuit-grid-insertion.test.ts b/tests/circuit-grid-insertion.test.ts new file mode 100644 index 0000000..d4cf108 --- /dev/null +++ b/tests/circuit-grid-insertion.test.ts @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + getInsertionSortOrder, + resolveGridInsertionIntent, +} from "../src/frontend/utils/circuit-grid-insertion.js"; + +describe("circuit grid insertion", () => { + it("inserts a circuit after circuit-level selections", () => { + assert.deepEqual( + resolveGridInsertionIntent( + { + rowType: "circuitCompact", + cellKind: "circuitField", + sectionId: "section-1", + circuitId: "circuit-1", + deviceId: "row-1", + }, + "fallback" + ), + { kind: "circuit", sectionId: "section-1", afterCircuitId: "circuit-1" } + ); + }); + + it("inserts a device after device-level selections in compact and expanded circuits", () => { + assert.deepEqual( + resolveGridInsertionIntent({ + rowType: "circuitCompact", + cellKind: "deviceField", + sectionId: "section-1", + circuitId: "circuit-1", + deviceId: "row-1", + }), + { + kind: "device", + sectionId: "section-1", + circuitId: "circuit-1", + afterDeviceRowId: "row-1", + } + ); + assert.deepEqual( + resolveGridInsertionIntent({ + rowType: "deviceRow", + cellKind: "deviceField", + sectionId: "section-1", + circuitId: "circuit-1", + deviceId: "row-2", + }), + { + kind: "device", + sectionId: "section-1", + circuitId: "circuit-1", + afterDeviceRowId: "row-2", + } + ); + }); + + it("uses the active section for placeholders and missing selections", () => { + assert.deepEqual( + resolveGridInsertionIntent({ + rowType: "placeholder", + cellKind: "deviceField", + sectionId: "section-1", + }), + { kind: "circuit", sectionId: "section-1" } + ); + assert.deepEqual(resolveGridInsertionIntent(null, "section-2"), { + kind: "circuit", + sectionId: "section-2", + }); + }); + + it("creates stable sort values between neighbours or at the end", () => { + const entries = [ + { id: "a", sortOrder: 10 }, + { id: "b", sortOrder: 20 }, + { id: "c", sortOrder: 30 }, + ]; + + assert.equal(getInsertionSortOrder(entries, "a"), 15); + assert.equal(getInsertionSortOrder(entries, "c"), 40); + assert.equal(getInsertionSortOrder(entries), 40); + assert.equal(getInsertionSortOrder([], "missing"), 10); + }); +});