Undo Redo working

This commit is contained in:
2026-05-05 09:55:08 +02:00
parent 75435475fc
commit 9b9e67bf0c
9 changed files with 938 additions and 213 deletions
+17
View File
@@ -46,6 +46,23 @@ body {
gap: 0.75rem; gap: 0.75rem;
} }
.editor-toolbar {
display: flex;
gap: 0.5rem;
}
.editor-toolbar button {
border: 1px solid #c4cddc;
background: #fff;
padding: 0.28rem 0.6rem;
border-radius: 4px;
font-size: 0.82rem;
}
.editor-toolbar button:disabled {
opacity: 0.45;
}
.tree-grid-wrap { .tree-grid-wrap {
overflow: auto; overflow: auto;
border: 1px solid #d9dee8; border: 1px solid #d9dee8;
+41 -1
View File
@@ -1,5 +1,5 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import { and, asc, eq, ne } from "drizzle-orm"; import { and, asc, eq, inArray, ne } from "drizzle-orm";
import { db } from "../client.js"; import { db } from "../client.js";
import { circuits } from "../schema/circuits.js"; import { circuits } from "../schema/circuits.js";
@@ -132,4 +132,44 @@ export class CircuitRepository {
.where(eq(circuits.sectionId, sectionId)) .where(eq(circuits.sectionId, sectionId))
.orderBy(asc(circuits.sortOrder), asc(circuits.equipmentIdentifier)); .orderBy(asc(circuits.sortOrder), asc(circuits.equipmentIdentifier));
} }
async updateEquipmentIdentifiersSafely(
circuitListId: string,
updates: Array<{ id: string; equipmentIdentifier: string }>,
tempNamespace: string
) {
if (updates.length === 0) {
return;
}
db.transaction((tx) => {
const ids = updates.map((entry) => entry.id);
const existing = tx
.select({ id: circuits.id })
.from(circuits)
.where(and(eq(circuits.circuitListId, circuitListId), inArray(circuits.id, ids)))
.all();
if (existing.length !== ids.length) {
throw new Error("One or more circuit ids are invalid for circuit list.");
}
const stamp = Date.now();
for (let index = 0; index < updates.length; index += 1) {
const entry = updates[index];
const tempIdentifier = `__tmp_renumber_${tempNamespace}_${stamp}_${index}`;
tx
.update(circuits)
.set({ equipmentIdentifier: tempIdentifier })
.where(and(eq(circuits.circuitListId, circuitListId), eq(circuits.id, entry.id)))
.run();
}
for (const entry of updates) {
tx
.update(circuits)
.set({ equipmentIdentifier: entry.equipmentIdentifier })
.where(and(eq(circuits.circuitListId, circuitListId), eq(circuits.id, entry.id)))
.run();
}
});
}
} }
+35 -18
View File
@@ -8,6 +8,7 @@ import type {
CreateCircuitInput, CreateCircuitInput,
MoveCircuitDeviceRowInput, MoveCircuitDeviceRowInput,
ReorderSectionCircuitsInput, ReorderSectionCircuitsInput,
UpdateSectionEquipmentIdentifiersInput,
UpdateCircuitDeviceRowInput, UpdateCircuitDeviceRowInput,
UpdateCircuitInput, UpdateCircuitInput,
} from "../../shared/validation/circuit.schemas.js"; } from "../../shared/validation/circuit.schemas.js";
@@ -379,6 +380,7 @@ export class CircuitWriteService {
); );
const otherIdentifiers = new Set(otherCircuits.map((circuit) => circuit.equipmentIdentifier)); const otherIdentifiers = new Set(otherCircuits.map((circuit) => circuit.equipmentIdentifier));
const finalAssignments: Array<{ id: string; equipmentIdentifier: string }> = [];
let index = 1; let index = 1;
for (const circuit of sectionCircuits) { for (const circuit of sectionCircuits) {
let candidate = `${section.prefix}${index}`; let candidate = `${section.prefix}${index}`;
@@ -386,30 +388,45 @@ export class CircuitWriteService {
index += 1; index += 1;
candidate = `${section.prefix}${index}`; candidate = `${section.prefix}${index}`;
} }
await this.circuitRepository.update(circuit.id, { finalAssignments.push({ id: circuit.id, equipmentIdentifier: candidate });
sectionId: circuit.sectionId,
equipmentIdentifier: candidate,
displayName: circuit.displayName ?? undefined,
sortOrder: circuit.sortOrder,
protectionType: circuit.protectionType ?? undefined,
protectionRatedCurrent: circuit.protectionRatedCurrent ?? undefined,
protectionCharacteristic: circuit.protectionCharacteristic ?? undefined,
cableType: circuit.cableType ?? undefined,
cableCrossSection: circuit.cableCrossSection ?? undefined,
cableLength: circuit.cableLength ?? undefined,
rcdAssignment: circuit.rcdAssignment ?? undefined,
terminalDesignation: circuit.terminalDesignation ?? undefined,
voltage: circuit.voltage ?? undefined,
status: circuit.status ?? undefined,
isReserve: Boolean(circuit.isReserve),
remark: circuit.remark ?? undefined,
});
index += 1; index += 1;
} }
await this.circuitRepository.updateEquipmentIdentifiersSafely(
section.circuitListId,
finalAssignments,
sectionId
);
return this.circuitRepository.listBySection(sectionId); return this.circuitRepository.listBySection(sectionId);
} }
async updateSectionEquipmentIdentifiers(
sectionId: string,
input: UpdateSectionEquipmentIdentifiersInput
) {
const section = await this.circuitSectionRepository.findById(sectionId);
if (!section) {
throw new Error("Invalid section id.");
}
const sectionCircuits = await this.circuitRepository.listBySection(sectionId);
const sectionIds = new Set(sectionCircuits.map((circuit) => circuit.id));
if (input.identifiers.length !== sectionCircuits.length) {
throw new Error("identifiers must include all circuits in the section.");
}
for (const entry of input.identifiers) {
if (!sectionIds.has(entry.circuitId)) {
throw new Error("Circuit id does not belong to section.");
}
}
await this.circuitRepository.updateEquipmentIdentifiersSafely(
section.circuitListId,
input.identifiers.map((entry) => ({ id: entry.circuitId, equipmentIdentifier: entry.equipmentIdentifier })),
sectionId
);
return this.circuitRepository.listBySection(sectionId);
}
async reorderCircuitsInSection(sectionId: string, input: ReorderSectionCircuitsInput) { async reorderCircuitsInSection(sectionId: string, input: ReorderSectionCircuitsInput) {
const section = await this.circuitSectionRepository.findById(sectionId); const section = await this.circuitSectionRepository.findById(sectionId);
if (!section) { if (!section) {
+582 -141
View File
@@ -12,6 +12,7 @@ import {
moveCircuitDeviceRowById, moveCircuitDeviceRowById,
reorderSectionCircuits, reorderSectionCircuits,
renumberCircuitSection, renumberCircuitSection,
updateSectionEquipmentIdentifiers,
updateCircuitById, updateCircuitById,
updateCircuitDeviceRowById, updateCircuitDeviceRowById,
} from "../utils/api"; } from "../utils/api";
@@ -76,6 +77,33 @@ interface VisibleGridRow {
cells: VisibleGridCell[]; cells: VisibleGridCell[];
} }
interface HistoryCommand {
label: string;
redo: () => Promise<SelectionIntent | null | void>;
undo: () => Promise<SelectionIntent | null | void>;
}
interface CircuitSnapshot {
id: string;
sectionId: string;
equipmentIdentifier: string;
displayName?: string;
sortOrder: number;
protectionType?: string;
protectionRatedCurrent?: number;
protectionCharacteristic?: string;
cableType?: string;
cableCrossSection?: string;
cableLength?: number;
rcdAssignment?: string;
terminalDesignation?: string;
voltage?: number;
status?: string;
isReserve: boolean;
remark?: string;
deviceRows: CircuitTreeDeviceRowDto[];
}
type ProjectDeviceDropIntent = type ProjectDeviceDropIntent =
| { kind: "new-circuit"; sectionId: string } | { kind: "new-circuit"; sectionId: string }
| { kind: "add-to-circuit"; circuitId: string; sectionId: string }; | { kind: "add-to-circuit"; circuitId: string; sectionId: string };
@@ -282,6 +310,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const [deviceMoveIntent, setDeviceMoveIntent] = useState<DeviceRowMoveDropIntent | null>(null); const [deviceMoveIntent, setDeviceMoveIntent] = useState<DeviceRowMoveDropIntent | null>(null);
const [draggingCircuitId, setDraggingCircuitId] = useState<string | null>(null); const [draggingCircuitId, setDraggingCircuitId] = useState<string | null>(null);
const [circuitReorderIntent, setCircuitReorderIntent] = useState<CircuitReorderDropIntent | null>(null); const [circuitReorderIntent, setCircuitReorderIntent] = useState<CircuitReorderDropIntent | null>(null);
const [undoStack, setUndoStack] = useState<HistoryCommand[]>([]);
const [redoStack, setRedoStack] = useState<HistoryCommand[]>([]);
const [historyBusy, setHistoryBusy] = useState(false);
const [pendingFocus, setPendingFocus] = useState<SelectedCell | null>(null); const [pendingFocus, setPendingFocus] = useState<SelectedCell | null>(null);
const pendingSelectionAfterReload = useRef<SelectionIntent | null>(null); const pendingSelectionAfterReload = useRef<SelectionIntent | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
@@ -511,6 +542,69 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return editableCells[0] ?? null; return editableCells[0] ?? null;
} }
async function reloadWithIntent(intent?: SelectionIntent | null) {
if (intent) {
pendingSelectionAfterReload.current = intent;
}
await loadTree({ showLoading: false });
if (!intent) {
requestAnimationFrame(() => containerRef.current?.focus());
}
}
async function runCommand(command: HistoryCommand) {
try {
setError(null);
setIsSaving(true);
const intent = await command.redo();
await reloadWithIntent(intent ?? null);
setUndoStack((current) => [...current, command]);
setRedoStack([]);
} catch (err) {
setError(normalizeUiError(err));
} finally {
setIsSaving(false);
}
}
async function applyHistory(command: HistoryCommand, mode: "undo" | "redo") {
try {
setError(null);
setHistoryBusy(true);
setIsSaving(true);
const intent = mode === "undo" ? await command.undo() : await command.redo();
await reloadWithIntent(intent ?? null);
if (mode === "undo") {
setUndoStack((current) => current.slice(0, -1));
setRedoStack((current) => [...current, command]);
} else {
setRedoStack((current) => current.slice(0, -1));
setUndoStack((current) => [...current, command]);
}
} catch (err) {
setError(normalizeUiError(err));
} finally {
setIsSaving(false);
setHistoryBusy(false);
}
}
async function handleUndo() {
if (historyBusy || isSaving || undoStack.length === 0) {
return;
}
const command = undoStack[undoStack.length - 1];
await applyHistory(command, "undo");
}
async function handleRedo() {
if (historyBusy || isSaving || redoStack.length === 0) {
return;
}
const command = redoStack[redoStack.length - 1];
await applyHistory(command, "redo");
}
useEffect(() => { useEffect(() => {
const intent = pendingSelectionAfterReload.current; const intent = pendingSelectionAfterReload.current;
if (!intent || !data) { if (!intent || !data) {
@@ -693,6 +787,16 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return { rowKey: `reserveCircuit:${createdCircuit.id}`, cellKey: key }; return { rowKey: `reserveCircuit:${createdCircuit.id}`, cellKey: key };
} }
function getCurrentDraftForCell(row: VisibleGridRow, cellKey: CellKey, kind: CellKind) {
if (kind === "deviceField" && row.device) {
return String(getDeviceValue(row.device, cellKey) ?? "");
}
if (kind === "circuitField" && row.circuit) {
return String(getCircuitValue(row.circuit, cellKey) ?? "");
}
return "";
}
async function commitEdit(direction: SaveDirection = "stay") { async function commitEdit(direction: SaveDirection = "stay") {
if (!editingCell) { if (!editingCell) {
return; return;
@@ -704,20 +808,93 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return; return;
} }
try {
setIsSaving(true);
setError(null);
let targetSelection: SelectedCell = { rowKey: editingCell.rowKey, cellKey: editingCell.cellKey }; let targetSelection: SelectedCell = { rowKey: editingCell.rowKey, cellKey: editingCell.cellKey };
const nextSelectionIntent = () => {
let selected = targetSelection;
if (direction !== "stay") {
const idx = editableCells.findIndex(
(entry) => entry.rowKey === selected.rowKey && entry.cellKey === selected.cellKey
);
if (idx >= 0) {
const targetIdx =
direction === "next" ? Math.min(editableCells.length - 1, idx + 1) : Math.max(0, idx - 1);
selected = editableCells[targetIdx];
}
}
return buildSelectionIntent(selected);
};
if (row.rowType === "placeholder") { if (row.rowType === "placeholder") {
targetSelection = await createFromPlaceholder(row.sectionId, editingCell.cellKey, editingCell.draft); let createdCircuitId: string | null = null;
} else if (cell.kind === "circuitField" && row.circuit) { const command: HistoryCommand = {
await patchCircuit(row.circuit.id, editingCell.cellKey, editingCell.draft); label: "Create from placeholder",
} else if (cell.kind === "deviceField") { redo: async () => {
if (row.device) { const selection = await createFromPlaceholder(row.sectionId, editingCell.cellKey, editingCell.draft);
await patchDeviceRow(row.device.id, editingCell.cellKey, editingCell.draft); targetSelection = selection;
} else if (row.rowType === "reserveCircuit" && row.circuit) { const createdRow = selection.rowKey.startsWith("circuitCompact:")
const created = (await createCircuitDeviceRow(row.circuit.id, { ? selection.rowKey.replace("circuitCompact:", "")
: selection.rowKey.replace("reserveCircuit:", "");
createdCircuitId = createdRow;
return nextSelectionIntent();
},
undo: async () => {
if (!createdCircuitId) {
return null;
}
await deleteCircuitById(createdCircuitId);
return buildSelectionIntent({ rowKey: `placeholder:${row.sectionId}`, cellKey: editingCell.cellKey });
},
};
setEditingCell(null);
await runCommand(command);
return;
}
if (cell.kind === "circuitField" && row.circuit) {
const previous = getCurrentDraftForCell(row, editingCell.cellKey, cell.kind);
const next = editingCell.draft;
const command: HistoryCommand = {
label: "Edit circuit field",
redo: async () => {
await patchCircuit(row.circuit!.id, editingCell.cellKey, next);
return nextSelectionIntent();
},
undo: async () => {
await patchCircuit(row.circuit!.id, editingCell.cellKey, previous);
return buildSelectionIntent({ rowKey: row.rowKey, cellKey: editingCell.cellKey });
},
};
setEditingCell(null);
await runCommand(command);
return;
}
if (cell.kind === "deviceField" && row.device) {
const previous = getCurrentDraftForCell(row, editingCell.cellKey, cell.kind);
const next = editingCell.draft;
const command: HistoryCommand = {
label: "Edit device field",
redo: async () => {
await patchDeviceRow(row.device!.id, editingCell.cellKey, next);
return nextSelectionIntent();
},
undo: async () => {
await patchDeviceRow(row.device!.id, editingCell.cellKey, previous);
return buildSelectionIntent({ rowKey: `device:${row.device!.id}`, cellKey: editingCell.cellKey });
},
};
setEditingCell(null);
await runCommand(command);
return;
}
if (cell.kind === "deviceField" && row.rowType === "reserveCircuit" && row.circuit) {
const next = editingCell.draft;
let createdRowId: string | null = null;
const command: HistoryCommand = {
label: "Create reserve device row",
redo: async () => {
const created = (await createCircuitDeviceRow(row.circuit!.id, {
name: "Reserve load", name: "Reserve load",
displayName: "Reserve load", displayName: "Reserve load",
phaseType: "single_phase", phaseType: "single_phase",
@@ -726,37 +903,21 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
simultaneityFactor: 1, simultaneityFactor: 1,
cosPhi: 1, cosPhi: 1,
})) as { id: string }; })) as { id: string };
await patchDeviceRow(created.id, editingCell.cellKey, editingCell.draft); createdRowId = created.id;
targetSelection = { rowKey: `circuitCompact:${row.circuit.id}`, cellKey: editingCell.cellKey }; await patchDeviceRow(created.id, editingCell.cellKey, next);
targetSelection = { rowKey: `circuitCompact:${row.circuit!.id}`, cellKey: editingCell.cellKey };
return nextSelectionIntent();
},
undo: async () => {
if (!createdRowId) {
return null;
} }
} await deleteCircuitDeviceRowById(createdRowId);
return buildSelectionIntent({ rowKey: `reserveCircuit:${row.circuit!.id}`, cellKey: editingCell.cellKey });
if (direction !== "stay") { },
const idx = editableCells.findIndex( };
(entry) => entry.rowKey === targetSelection.rowKey && entry.cellKey === targetSelection.cellKey
);
if (idx >= 0) {
const targetIdx =
direction === "next"
? Math.min(editableCells.length - 1, idx + 1)
: Math.max(0, idx - 1);
targetSelection = editableCells[targetIdx];
}
}
const intent = buildSelectionIntent(targetSelection);
setEditingCell(null); setEditingCell(null);
if (intent) { await runCommand(command);
pendingSelectionAfterReload.current = intent;
}
await loadTree({ showLoading: false });
if (!intent) {
requestAnimationFrame(() => containerRef.current?.focus());
}
} catch (err) {
setError(normalizeUiError(err));
} finally {
setIsSaving(false);
} }
} }
@@ -770,36 +931,53 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
} }
async function handleAddReserveCircuit(sectionId: string) { async function handleAddReserveCircuit(sectionId: string) {
try {
setError(null);
setIsSaving(true);
const section = data?.sections.find((entry) => entry.id === sectionId); const section = data?.sections.find((entry) => entry.id === sectionId);
if (!section) { if (!section) {
return; return;
} }
let createdCircuitId: string | null = null;
await runCommand({
label: "Add circuit",
redo: async () => {
const next = await getNextCircuitIdentifier(sectionId); const next = await getNextCircuitIdentifier(sectionId);
const sortOrder = const sortOrder =
section.circuits.length > 0 ? Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) + 10 : 10; section.circuits.length > 0 ? Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) + 10 : 10;
await createCircuit(projectId, circuitListId, { const created = (await createCircuit(projectId, circuitListId, {
sectionId, sectionId,
equipmentIdentifier: next.nextIdentifier, equipmentIdentifier: next.nextIdentifier,
displayName: "Reserve", displayName: "Reserve",
sortOrder, sortOrder,
isReserve: true, isReserve: true,
}); })) as CircuitTreeCircuitDto;
await loadTree({ showLoading: false }); createdCircuitId = created.id;
setActiveSectionId(sectionId); setActiveSectionId(sectionId);
} catch (err) { return {
setError(normalizeUiError(err)); rowKey: `reserveCircuit:${created.id}`,
} finally { cellKey: "equipmentIdentifier",
setIsSaving(false); rowType: "reserveCircuit",
sectionId,
circuitId: created.id,
};
},
undo: async () => {
if (createdCircuitId) {
await deleteCircuitById(createdCircuitId);
} }
return {
rowKey: `placeholder:${sectionId}`,
cellKey: "equipmentIdentifier",
rowType: "placeholder",
sectionId,
};
},
});
} }
async function handleAddManualDevice(circuit: CircuitTreeCircuitDto, sectionId: string) { async function handleAddManualDevice(circuit: CircuitTreeCircuitDto, sectionId: string) {
try { let createdRowId: string | null = null;
setError(null); await runCommand({
setIsSaving(true); label: "Add manual device",
redo: async () => {
const created = (await createCircuitDeviceRow(circuit.id, { const created = (await createCircuitDeviceRow(circuit.id, {
name: "Manual device", name: "Manual device",
displayName: "Manual device", displayName: "Manual device",
@@ -809,17 +987,30 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
simultaneityFactor: 1, simultaneityFactor: 1,
cosPhi: 1, cosPhi: 1,
})) as { id: string }; })) as { id: string };
await loadTree({ showLoading: false }); createdRowId = created.id;
setActiveSectionId(sectionId); setActiveSectionId(sectionId);
setPendingFocus({ return {
rowKey: circuit.deviceRows.length === 0 ? `circuitCompact:${circuit.id}` : `device:${created.id}`, rowKey: `device:${created.id}`,
cellKey: "displayName", cellKey: "displayName",
}); rowType: "deviceRow",
} catch (err) { sectionId,
setError(normalizeUiError(err)); circuitId: circuit.id,
} finally { deviceId: created.id,
setIsSaving(false); };
},
undo: async () => {
if (createdRowId) {
await deleteCircuitDeviceRowById(createdRowId);
} }
return {
rowKey: `reserveCircuit:${circuit.id}`,
cellKey: "displayName",
rowType: "reserveCircuit",
sectionId,
circuitId: circuit.id,
};
},
});
} }
function resolvePhaseType(device: ProjectDeviceDto): string { function resolvePhaseType(device: ProjectDeviceDto): string {
@@ -846,15 +1037,35 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
setError("Please select a target section."); setError("Please select a target section.");
return; return;
} }
try { let createdCircuitId: string | null = null;
setError(null); let createdRowId: string | null = null;
setIsSaving(true); await runCommand({
await insertProjectDeviceAsNewCircuit(device, targetSectionId); label: "Add project device as new circuit",
} catch (err) { redo: async () => {
setError(normalizeUiError(err)); const created = await insertProjectDeviceAsNewCircuit(device, targetSectionId);
} finally { createdCircuitId = created.circuitId;
setIsSaving(false); createdRowId = created.rowId;
return {
rowKey: `device:${created.rowId}`,
cellKey: "displayName",
rowType: "deviceRow",
sectionId: targetSectionId,
circuitId: created.circuitId,
deviceId: created.rowId,
};
},
undo: async () => {
if (createdCircuitId) {
await deleteCircuitById(createdCircuitId);
} }
return {
rowKey: `placeholder:${targetSectionId}`,
cellKey: "displayName",
rowType: "placeholder",
sectionId: targetSectionId,
};
},
});
} }
async function handleAddProjectDeviceToCircuit() { async function handleAddProjectDeviceToCircuit() {
@@ -875,15 +1086,34 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return; return;
} }
try { let createdRowId: string | null = null;
setError(null); await runCommand({
setIsSaving(true); label: "Add project device to circuit",
await insertProjectDeviceToCircuit(device, circuitId); redo: async () => {
} catch (err) { const created = await insertProjectDeviceToCircuit(device, circuitId);
setError(normalizeUiError(err)); createdRowId = created.rowId;
} finally { return {
setIsSaving(false); rowKey: `device:${created.rowId}`,
cellKey: "displayName",
rowType: "deviceRow",
sectionId: findCircuitSectionId(circuitId) ?? "",
circuitId,
deviceId: created.rowId,
};
},
undo: async () => {
if (createdRowId) {
await deleteCircuitDeviceRowById(createdRowId);
} }
return {
rowKey: `circuitSummary:${circuitId}`,
cellKey: "displayName",
rowType: "circuitSummary",
sectionId: findCircuitSectionId(circuitId) ?? "",
circuitId,
};
},
});
} }
async function insertProjectDeviceAsNewCircuit(device: ProjectDeviceDto, sectionId: string) { async function insertProjectDeviceAsNewCircuit(device: ProjectDeviceDto, sectionId: string) {
@@ -914,10 +1144,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
category: device.category ?? undefined, category: device.category ?? undefined,
})) as { id: string }; })) as { id: string };
await loadTree({ showLoading: false });
setActiveSectionId(sectionId); setActiveSectionId(sectionId);
setTargetCircuitId(createdCircuit.id); setTargetCircuitId(createdCircuit.id);
setPendingFocus({ rowKey: `device:${createdRow.id}`, cellKey: "displayName" }); return { circuitId: createdCircuit.id, rowId: createdRow.id };
} }
async function insertProjectDeviceToCircuit(device: ProjectDeviceDto, circuitId: string) { async function insertProjectDeviceToCircuit(device: ProjectDeviceDto, circuitId: string) {
@@ -932,8 +1161,84 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
cosPhi: device.powerFactor ?? undefined, cosPhi: device.powerFactor ?? undefined,
category: device.category ?? undefined, category: device.category ?? undefined,
})) as { id: string }; })) as { id: string };
await loadTree({ showLoading: false }); return { rowId: createdRow.id };
setPendingFocus({ rowKey: `device:${createdRow.id}`, cellKey: "displayName" }); }
function getCircuitSnapshot(circuitId: string): CircuitSnapshot | null {
for (const section of data?.sections ?? []) {
const found = section.circuits.find((entry) => entry.id === circuitId);
if (!found) {
continue;
}
return {
id: found.id,
sectionId: found.sectionId,
equipmentIdentifier: found.equipmentIdentifier,
displayName: found.displayName ?? undefined,
sortOrder: found.sortOrder,
protectionType: found.protectionType ?? undefined,
protectionRatedCurrent: found.protectionRatedCurrent ?? undefined,
protectionCharacteristic: found.protectionCharacteristic ?? undefined,
cableType: found.cableType ?? undefined,
cableCrossSection: found.cableCrossSection ?? undefined,
cableLength: found.cableLength ?? undefined,
rcdAssignment: found.rcdAssignment ?? undefined,
terminalDesignation: found.terminalDesignation ?? undefined,
voltage: found.voltage ?? undefined,
status: found.status ?? undefined,
isReserve: found.isReserve,
remark: found.remark ?? undefined,
deviceRows: found.deviceRows.map((row) => ({ ...row })),
};
}
return null;
}
async function recreateCircuit(snapshot: CircuitSnapshot) {
const createdCircuit = (await createCircuit(projectId, circuitListId, {
sectionId: snapshot.sectionId,
equipmentIdentifier: snapshot.equipmentIdentifier,
displayName: snapshot.displayName,
sortOrder: snapshot.sortOrder,
protectionType: snapshot.protectionType,
protectionRatedCurrent: snapshot.protectionRatedCurrent,
protectionCharacteristic: snapshot.protectionCharacteristic,
cableType: snapshot.cableType,
cableCrossSection: snapshot.cableCrossSection,
cableLength: snapshot.cableLength,
rcdAssignment: snapshot.rcdAssignment,
terminalDesignation: snapshot.terminalDesignation,
voltage: snapshot.voltage,
status: snapshot.status,
isReserve: snapshot.isReserve,
remark: snapshot.remark,
})) as CircuitTreeCircuitDto;
const createdRowIds: string[] = [];
for (const row of snapshot.deviceRows) {
const created = (await createCircuitDeviceRow(createdCircuit.id, {
linkedProjectDeviceId: row.linkedProjectDeviceId,
name: row.name,
displayName: row.displayName,
phaseType: row.phaseType,
connectionKind: row.connectionKind,
costGroup: row.costGroup,
category: row.category,
level: row.level,
roomId: row.roomId,
roomNumberSnapshot: row.roomNumberSnapshot,
roomNameSnapshot: row.roomNameSnapshot,
quantity: row.quantity,
powerPerUnit: row.powerPerUnit,
simultaneityFactor: row.simultaneityFactor,
cosPhi: row.cosPhi,
remark: row.remark,
overriddenFields: row.overriddenFields,
sortOrder: row.sortOrder,
})) as { id: string };
createdRowIds.push(created.id);
}
return { circuitId: createdCircuit.id, rowIds: createdRowIds };
} }
function parseDraggedProjectDeviceId(event: DragEvent<HTMLElement>) { function parseDraggedProjectDeviceId(event: DragEvent<HTMLElement>) {
@@ -1005,19 +1310,53 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
setError("Invalid project device drop source."); setError("Invalid project device drop source.");
return; return;
} }
try {
setError(null);
setIsSaving(true);
if (intent.kind === "new-circuit") { if (intent.kind === "new-circuit") {
await insertProjectDeviceAsNewCircuit(device, intent.sectionId); let createdCircuitId: string | null = null;
} else { await runCommand({
await insertProjectDeviceToCircuit(device, intent.circuitId); label: "Drop project device to new circuit",
redo: async () => {
const created = await insertProjectDeviceAsNewCircuit(device, intent.sectionId);
createdCircuitId = created.circuitId;
return {
rowKey: `device:${created.rowId}`,
cellKey: "displayName",
rowType: "deviceRow",
sectionId: intent.sectionId,
circuitId: created.circuitId,
deviceId: created.rowId,
};
},
undo: async () => {
if (createdCircuitId) {
await deleteCircuitById(createdCircuitId);
} }
} catch (err) { return null;
setError(normalizeUiError(err)); },
} finally { });
setIsSaving(false); return;
} }
let createdRowId: string | null = null;
await runCommand({
label: "Drop project device to circuit",
redo: async () => {
const created = await insertProjectDeviceToCircuit(device, intent.circuitId);
createdRowId = created.rowId;
return {
rowKey: `device:${created.rowId}`,
cellKey: "displayName",
rowType: "deviceRow",
sectionId: intent.sectionId,
circuitId: intent.circuitId,
deviceId: created.rowId,
};
},
undo: async () => {
if (createdRowId) {
await deleteCircuitDeviceRowById(createdRowId);
}
return null;
},
});
} }
async function handleDeviceRowDropWithIntent(event: DragEvent<HTMLElement>, intent: DeviceRowMoveDropIntent) { async function handleDeviceRowDropWithIntent(event: DragEvent<HTMLElement>, intent: DeviceRowMoveDropIntent) {
@@ -1037,28 +1376,44 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return; return;
} }
try {
setError(null);
setIsSaving(true);
if (intent.kind === "move-to-circuit") { if (intent.kind === "move-to-circuit") {
if (intent.circuitId === sourceCircuitId) { if (intent.circuitId === sourceCircuitId) {
return; return;
} }
await runCommand({
label: "Move device row",
redo: async () => {
await moveCircuitDeviceRowById(rowId, { targetCircuitId: intent.circuitId }); await moveCircuitDeviceRowById(rowId, { targetCircuitId: intent.circuitId });
} else { return null;
await moveCircuitDeviceRowById(rowId, { },
undo: async () => {
await moveCircuitDeviceRowById(rowId, { targetCircuitId: sourceCircuitId });
return null;
},
});
return;
}
let createdCircuitId: string | null = null;
await runCommand({
label: "Move device row to new circuit",
redo: async () => {
const moved = (await moveCircuitDeviceRowById(rowId, {
targetSectionId: intent.sectionId, targetSectionId: intent.sectionId,
createNewCircuit: true, createNewCircuit: true,
})) as { circuitId?: string };
createdCircuitId = moved.circuitId ?? null;
return null;
},
undo: async () => {
await moveCircuitDeviceRowById(rowId, { targetCircuitId: sourceCircuitId });
if (createdCircuitId) {
await deleteCircuitById(createdCircuitId);
}
return null;
},
}); });
} }
await loadTree({ showLoading: false });
setPendingFocus({ rowKey: `device:${rowId}`, cellKey: "displayName" });
} catch (err) {
setError(normalizeUiError(err));
} finally {
setIsSaving(false);
}
}
async function handleCircuitReorderDrop(event: DragEvent<HTMLElement>, intent: CircuitReorderDropIntent) { async function handleCircuitReorderDrop(event: DragEvent<HTMLElement>, intent: CircuitReorderDropIntent) {
event.preventDefault(); event.preventDefault();
@@ -1074,71 +1429,135 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
setError("Cross-section circuit move is not allowed in this phase."); setError("Cross-section circuit move is not allowed in this phase.");
return; return;
} }
try { const section = data?.sections.find((entry) => entry.id === intent.sectionId);
setError(null); if (!section) {
setIsSaving(true); setError("Invalid section id.");
return;
}
const beforeOrder = section.circuits.map((circuit) => circuit.id);
await runCommand({
label: "Reorder circuits",
redo: async () => {
await applyCircuitReorder(intent, sourceCircuitId); await applyCircuitReorder(intent, sourceCircuitId);
pendingSelectionAfterReload.current = { return {
rowKey: `circuitSummary:${sourceCircuitId}`, rowKey: `circuitSummary:${sourceCircuitId}`,
cellKey: "equipmentIdentifier", cellKey: "equipmentIdentifier",
rowType: "circuitSummary", rowType: "circuitSummary",
sectionId: intent.sectionId, sectionId: intent.sectionId,
circuitId: sourceCircuitId, circuitId: sourceCircuitId,
}; };
await loadTree({ showLoading: false }); },
} catch (err) { undo: async () => {
setError(normalizeUiError(err)); await reorderSectionCircuits(intent.sectionId, beforeOrder);
} finally { return {
setIsSaving(false); rowKey: `circuitSummary:${sourceCircuitId}`,
} cellKey: "equipmentIdentifier",
rowType: "circuitSummary",
sectionId: intent.sectionId,
circuitId: sourceCircuitId,
};
},
});
} }
async function handleDeleteDevice(rowId: string) { async function handleDeleteDevice(rowId: string) {
if (!confirm("Delete this device row?")) { if (!confirm("Delete this device row?")) {
return; return;
} }
try { const sourceCircuitId = findDeviceRowCircuitId(rowId);
setError(null); const sourceCircuit = sourceCircuitId ? getCircuitSnapshot(sourceCircuitId) : null;
setIsSaving(true); const rowSnapshot = sourceCircuit?.deviceRows.find((row) => row.id === rowId);
await deleteCircuitDeviceRowById(rowId); if (!sourceCircuitId || !rowSnapshot) {
await loadTree({ showLoading: false }); setError("Invalid device row id.");
} catch (err) { return;
setError(normalizeUiError(err));
} finally {
setIsSaving(false);
} }
let recreatedRowId: string | null = null;
await runCommand({
label: "Delete device row",
redo: async () => {
await deleteCircuitDeviceRowById(recreatedRowId ?? rowId);
return null;
},
undo: async () => {
const created = (await createCircuitDeviceRow(sourceCircuitId, {
linkedProjectDeviceId: rowSnapshot.linkedProjectDeviceId,
name: rowSnapshot.name,
displayName: rowSnapshot.displayName,
phaseType: rowSnapshot.phaseType,
connectionKind: rowSnapshot.connectionKind,
costGroup: rowSnapshot.costGroup,
category: rowSnapshot.category,
level: rowSnapshot.level,
roomId: rowSnapshot.roomId,
roomNumberSnapshot: rowSnapshot.roomNumberSnapshot,
roomNameSnapshot: rowSnapshot.roomNameSnapshot,
quantity: rowSnapshot.quantity,
powerPerUnit: rowSnapshot.powerPerUnit,
simultaneityFactor: rowSnapshot.simultaneityFactor,
cosPhi: rowSnapshot.cosPhi,
remark: rowSnapshot.remark,
overriddenFields: rowSnapshot.overriddenFields,
sortOrder: rowSnapshot.sortOrder,
})) as { id: string };
recreatedRowId = created.id;
return null;
},
});
} }
async function handleDeleteCircuit(circuitId: string) { async function handleDeleteCircuit(circuitId: string) {
if (!confirm("Delete this circuit and all assigned device rows?")) { if (!confirm("Delete this circuit and all assigned device rows?")) {
return; return;
} }
try { const snapshot = getCircuitSnapshot(circuitId);
setError(null); if (!snapshot) {
setIsSaving(true); setError("Invalid circuit id.");
await deleteCircuitById(circuitId); return;
await loadTree({ showLoading: false });
} catch (err) {
setError(normalizeUiError(err));
} finally {
setIsSaving(false);
} }
let recreatedCircuitId: string | null = null;
await runCommand({
label: "Delete circuit",
redo: async () => {
await deleteCircuitById(recreatedCircuitId ?? circuitId);
return null;
},
undo: async () => {
const recreated = await recreateCircuit(snapshot);
recreatedCircuitId = recreated.circuitId;
return null;
},
});
} }
async function handleRenumberSection(sectionId: string) { async function handleRenumberSection(sectionId: string) {
if (!confirm("Renumber this section? Only circuits in this section will change.")) { if (!confirm("Renumber this section? Only circuits in this section will change.")) {
return; return;
} }
try { const section = data?.sections.find((entry) => entry.id === sectionId);
setError(null); if (!section) {
setIsSaving(true); return;
await renumberCircuitSection(sectionId);
await loadTree({ showLoading: false });
} catch (err) {
setError(normalizeUiError(err));
} finally {
setIsSaving(false);
} }
const before = section.circuits.map((circuit) => ({
id: circuit.id,
equipmentIdentifier: circuit.equipmentIdentifier,
}));
await runCommand({
label: "Renumber section",
redo: async () => {
await renumberCircuitSection(sectionId);
return null;
},
undo: async () => {
await updateSectionEquipmentIdentifiers(
sectionId,
before.map((entry) => ({
circuitId: entry.id,
equipmentIdentifier: entry.equipmentIdentifier,
}))
);
return null;
},
});
} }
function handleContainerFocus() { function handleContainerFocus() {
@@ -1162,6 +1581,20 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
} }
return; return;
} }
if (event.ctrlKey && event.key.toLowerCase() === "z") {
event.preventDefault();
if (event.shiftKey) {
void handleRedo();
} else {
void handleUndo();
}
return;
}
if (event.ctrlKey && event.key.toLowerCase() === "y") {
event.preventDefault();
void handleRedo();
return;
}
const isCtrlPlus = const isCtrlPlus =
event.ctrlKey && event.ctrlKey &&
(event.key === "+" || (event.shiftKey && event.key === "=") || event.code === "NumpadAdd"); (event.key === "+" || (event.shiftKey && event.key === "=") || event.code === "NumpadAdd");
@@ -1228,6 +1661,14 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return ( return (
<div className="tree-editor-shell"> <div className="tree-editor-shell">
<div className="editor-toolbar">
<button type="button" onClick={() => void handleUndo()} disabled={undoStack.length === 0 || historyBusy || isSaving}>
Undo
</button>
<button type="button" onClick={() => void handleRedo()} disabled={redoStack.length === 0 || historyBusy || isSaving}>
Redo
</button>
</div>
{isSaving ? <div className="notice info">Saving...</div> : null} {isSaving ? <div className="notice info">Saving...</div> : null}
<div className="tree-editor-layout"> <div className="tree-editor-layout">
<aside className="project-device-sidebar"> <aside className="project-device-sidebar">
+10
View File
@@ -155,6 +155,16 @@ export function reorderSectionCircuits(sectionId: string, orderedCircuitIds: str
}); });
} }
export function updateSectionEquipmentIdentifiers(
sectionId: string,
identifiers: Array<{ circuitId: string; equipmentIdentifier: string }>
) {
return request(`/api/circuit-sections/${sectionId}/equipment-identifiers`, {
method: "PATCH",
body: JSON.stringify({ identifiers }),
});
}
export function listFloors(projectId: string) { export function listFloors(projectId: string) {
return request<FloorDto[]>(`/api/projects/${projectId}/floors`); return request<FloorDto[]>(`/api/projects/${projectId}/floors`);
} }
@@ -1,6 +1,9 @@
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { CircuitWriteService } from "../../domain/services/circuit-write.service.js"; import { CircuitWriteService } from "../../domain/services/circuit-write.service.js";
import { reorderSectionCircuitsSchema } from "../../shared/validation/circuit.schemas.js"; import {
reorderSectionCircuitsSchema,
updateSectionEquipmentIdentifiersSchema,
} from "../../shared/validation/circuit.schemas.js";
const circuitWriteService = new CircuitWriteService(); const circuitWriteService = new CircuitWriteService();
@@ -36,3 +39,22 @@ export async function reorderSectionCircuits(req: Request, res: Response) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to reorder circuits." }); return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to reorder circuits." });
} }
} }
export async function updateSectionEquipmentIdentifiers(req: Request, res: Response) {
const { sectionId } = req.params;
if (typeof sectionId !== "string") {
return res.status(400).json({ error: "Invalid sectionId" });
}
const parsed = updateSectionEquipmentIdentifiersSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
const updatedCircuits = await circuitWriteService.updateSectionEquipmentIdentifiers(sectionId, parsed.data);
return res.json({ sectionId, circuits: updatedCircuits });
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to update section identifiers." });
}
}
+6 -1
View File
@@ -1,7 +1,12 @@
import { Router } from "express"; import { Router } from "express";
import { renumberCircuitSection, reorderSectionCircuits } from "../controllers/circuit-section.controller.js"; import {
renumberCircuitSection,
reorderSectionCircuits,
updateSectionEquipmentIdentifiers,
} from "../controllers/circuit-section.controller.js";
export const circuitSectionRouter = Router(); export const circuitSectionRouter = Router();
circuitSectionRouter.post("/circuit-sections/:sectionId/renumber", renumberCircuitSection); circuitSectionRouter.post("/circuit-sections/:sectionId/renumber", renumberCircuitSection);
circuitSectionRouter.patch("/circuit-sections/:sectionId/circuits/reorder", reorderSectionCircuits); circuitSectionRouter.patch("/circuit-sections/:sectionId/circuits/reorder", reorderSectionCircuits);
circuitSectionRouter.patch("/circuit-sections/:sectionId/equipment-identifiers", updateSectionEquipmentIdentifiers);
+12
View File
@@ -66,9 +66,21 @@ export const reorderSectionCircuitsSchema = z.object({
orderedCircuitIds: z.array(z.string().min(1)).min(1), orderedCircuitIds: z.array(z.string().min(1)).min(1),
}); });
export const updateSectionEquipmentIdentifiersSchema = z.object({
identifiers: z
.array(
z.object({
circuitId: z.string().min(1),
equipmentIdentifier: z.string().min(1),
})
)
.min(1),
});
export type CreateCircuitInput = z.infer<typeof createCircuitSchema>; export type CreateCircuitInput = z.infer<typeof createCircuitSchema>;
export type UpdateCircuitInput = z.infer<typeof updateCircuitSchema>; export type UpdateCircuitInput = z.infer<typeof updateCircuitSchema>;
export type CreateCircuitDeviceRowInput = z.infer<typeof createCircuitDeviceRowSchema>; export type CreateCircuitDeviceRowInput = z.infer<typeof createCircuitDeviceRowSchema>;
export type UpdateCircuitDeviceRowInput = z.infer<typeof updateCircuitDeviceRowSchema>; export type UpdateCircuitDeviceRowInput = z.infer<typeof updateCircuitDeviceRowSchema>;
export type MoveCircuitDeviceRowInput = z.infer<typeof moveCircuitDeviceRowSchema>; export type MoveCircuitDeviceRowInput = z.infer<typeof moveCircuitDeviceRowSchema>;
export type ReorderSectionCircuitsInput = z.infer<typeof reorderSectionCircuitsSchema>; export type ReorderSectionCircuitsInput = z.infer<typeof reorderSectionCircuitsSchema>;
export type UpdateSectionEquipmentIdentifiersInput = z.infer<typeof updateSectionEquipmentIdentifiersSchema>;
+168 -7
View File
@@ -1,6 +1,8 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import { CircuitWriteService } from "../src/domain/services/circuit-write.service.js"; import { CircuitWriteService } from "../src/domain/services/circuit-write.service.js";
import { CircuitRepository } from "../src/db/repositories/circuit.repository.js";
import { db } from "../src/db/client.js";
describe("circuit write service rules", () => { describe("circuit write service rules", () => {
it("rejects duplicate equipment identifiers in same circuit list", async () => { it("rejects duplicate equipment identifiers in same circuit list", async () => {
@@ -146,8 +148,8 @@ describe("circuit write service rules", () => {
assert.equal(reserveFlag, false); assert.equal(reserveFlag, false);
}); });
it("renumber affects only circuits in selected section and keeps row order untouched", async () => { it("renumber uses safe bulk identifier update for swapped identifiers", async () => {
const updatedIds: string[] = []; let safeUpdatePayload: Array<{ id: string; equipmentIdentifier: string }> = [];
const service = new CircuitWriteService({ const service = new CircuitWriteService({
circuitSectionRepository: { circuitSectionRepository: {
async findById() { async findById() {
@@ -157,24 +159,94 @@ describe("circuit write service rules", () => {
circuitRepository: { circuitRepository: {
async listBySection() { async listBySection() {
return [ return [
{ id: "c1", sectionId: "s1", equipmentIdentifier: "-2F7", sortOrder: 10, isReserve: 0 }, { id: "cB", sectionId: "s1", equipmentIdentifier: "-2F2", sortOrder: 10, isReserve: 0 },
{ id: "c2", sectionId: "s1", equipmentIdentifier: "-2F9", sortOrder: 20, isReserve: 1 }, { id: "cA", sectionId: "s1", equipmentIdentifier: "-2F1", sortOrder: 20, isReserve: 1 },
] as never[]; ] as never[];
}, },
async listByCircuitList() { async listByCircuitList() {
return [{ id: "x1", sectionId: "s2", equipmentIdentifier: "-3F1" }] as never[]; return [{ id: "x1", sectionId: "s2", equipmentIdentifier: "-3F1" }] as never[];
}, },
async update(circuitId: string) { async updateEquipmentIdentifiersSafely(_listId: string, updates: Array<{ id: string; equipmentIdentifier: string }>) {
updatedIds.push(circuitId); safeUpdatePayload = updates;
}, },
} as never, } as never,
}); });
const result = await service.renumberSection("s1"); const result = await service.renumberSection("s1");
assert.deepEqual(updatedIds, ["c1", "c2"]); assert.deepEqual(safeUpdatePayload, [
{ id: "cB", equipmentIdentifier: "-2F1" },
{ id: "cA", equipmentIdentifier: "-2F2" },
]);
assert.equal(result.length, 2); assert.equal(result.length, 2);
}); });
it("renumber shifts forward/backward and respects other sections", async () => {
let safeUpdatePayload: Array<{ id: string; equipmentIdentifier: string }> = [];
const service = new CircuitWriteService({
circuitSectionRepository: {
async findById() {
return { id: "s1", circuitListId: "l1", prefix: "-1F" } as never;
},
} as never,
circuitRepository: {
async listBySection() {
return [
{ id: "c2", sectionId: "s1", equipmentIdentifier: "-1F5", sortOrder: 10, isReserve: 0 },
{ id: "c1", sectionId: "s1", equipmentIdentifier: "-1F1", sortOrder: 20, isReserve: 0 },
{ id: "c3", sectionId: "s1", equipmentIdentifier: "-1F9", sortOrder: 30, isReserve: 0 },
] as never[];
},
async listByCircuitList() {
return [{ id: "o1", sectionId: "s2", equipmentIdentifier: "-1F2" }] as never[];
},
async updateEquipmentIdentifiersSafely(_listId: string, updates: Array<{ id: string; equipmentIdentifier: string }>) {
safeUpdatePayload = updates;
},
} as never,
});
await service.renumberSection("s1");
assert.deepEqual(safeUpdatePayload, [
{ id: "c2", equipmentIdentifier: "-1F1" },
{ id: "c1", equipmentIdentifier: "-1F3" },
{ id: "c3", equipmentIdentifier: "-1F4" },
]);
});
it("renumber handles gaps and keeps device rows untouched by identifier-only update path", async () => {
let safeCalled = 0;
const service = new CircuitWriteService({
circuitSectionRepository: {
async findById() {
return { id: "s1", circuitListId: "l1", prefix: "-1F" } as never;
},
} as never,
circuitRepository: {
async listBySection() {
return [
{ id: "c1", sectionId: "s1", equipmentIdentifier: "-1F1", sortOrder: 10, isReserve: 0 },
{ id: "c2", sectionId: "s1", equipmentIdentifier: "-1F5", sortOrder: 20, isReserve: 0 },
{ id: "c3", sectionId: "s1", equipmentIdentifier: "-1F9", sortOrder: 30, isReserve: 0 },
] as never[];
},
async listByCircuitList() {
return [] as never[];
},
async updateEquipmentIdentifiersSafely() {
safeCalled += 1;
},
} as never,
deviceRowRepository: {
async update() {
throw new Error("device rows must not be touched");
},
} as never,
});
await service.renumberSection("s1");
assert.equal(safeCalled, 1);
});
it("moving a device row to another circuit preserves row and toggles reserve flags", async () => { it("moving a device row to another circuit preserves row and toggles reserve flags", async () => {
const updatedReserve: Array<{ id: string; isReserve: boolean }> = []; const updatedReserve: Array<{ id: string; isReserve: boolean }> = [];
const movedCalls: Array<{ rowId: string; targetCircuitId: string; sortOrder: number }> = []; const movedCalls: Array<{ rowId: string; targetCircuitId: string; sortOrder: number }> = [];
@@ -330,4 +402,93 @@ describe("circuit write service rules", () => {
{ id: "c2", sortOrder: 30, equipmentIdentifier: "-2F9" }, { id: "c2", sortOrder: 30, equipmentIdentifier: "-2F9" },
]); ]);
}); });
it("safe section identifier bulk update validates full section set (undo safety)", async () => {
let safeCalled = false;
const service = new CircuitWriteService({
circuitSectionRepository: {
async findById() {
return { id: "s1", circuitListId: "l1", prefix: "-1F" } as never;
},
} as never,
circuitRepository: {
async listBySection() {
return [
{ id: "c1", sectionId: "s1", equipmentIdentifier: "-1F1", sortOrder: 10, isReserve: 0 },
{ id: "c2", sectionId: "s1", equipmentIdentifier: "-1F2", sortOrder: 20, isReserve: 0 },
] as never[];
},
async updateEquipmentIdentifiersSafely() {
safeCalled = true;
},
} as never,
});
await service.updateSectionEquipmentIdentifiers("s1", {
identifiers: [
{ circuitId: "c1", equipmentIdentifier: "-1F2" },
{ circuitId: "c2", equipmentIdentifier: "-1F1" },
],
});
assert.equal(safeCalled, true);
});
it("safe identifier bulk update uses synchronous transaction callback", async () => {
const repository = new CircuitRepository();
const originalTransaction = (db as unknown as { transaction: unknown }).transaction;
let callbackReturnedPromise = false;
(db as unknown as { transaction: (cb: (tx: unknown) => unknown) => void }).transaction = (cb) => {
const fakeTx = {
select() {
return {
from() {
return {
where() {
return {
all() {
return [{ id: "c1" }, { id: "c2" }];
},
};
},
};
},
};
},
update() {
return {
set() {
return {
where() {
return {
run() {
return;
},
};
},
};
},
};
},
};
const result = cb(fakeTx);
callbackReturnedPromise = Boolean(result && typeof (result as Promise<unknown>).then === "function");
if (callbackReturnedPromise) {
throw new Error("Transaction function cannot return a promise");
}
};
try {
await repository.updateEquipmentIdentifiersSafely(
"l1",
[
{ id: "c1", equipmentIdentifier: "-1F1" },
{ id: "c2", equipmentIdentifier: "-1F2" },
],
"s1"
);
assert.equal(callbackReturnedPromise, false);
} finally {
(db as unknown as { transaction: unknown }).transaction = originalTransaction;
}
});
}); });