Prevent stale cell update overwrites
This commit is contained in:
@@ -11,6 +11,8 @@ Inline cells are static text by default. A cell enters edit mode by:
|
|||||||
|
|
||||||
`Enter` confirms changes. `Escape` cancels current edit draft. `Tab` and `Shift+Tab` confirm and move to next/previous editable cell.
|
`Enter` confirms changes. `Escape` cancels current edit draft. `Tab` and `Shift+Tab` confirm and move to next/previous editable cell.
|
||||||
|
|
||||||
|
Committed cell edits are persisted as partial database updates. Fields that were not part of the edit are not written back from an older read and therefore cannot overwrite a concurrent change to another field.
|
||||||
|
|
||||||
## `selectedCell` vs `editingCell`
|
## `selectedCell` vs `editingCell`
|
||||||
|
|
||||||
- `selectedCell` tracks spreadsheet navigation focus.
|
- `selectedCell` tracks spreadsheet navigation focus.
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ export interface CircuitDeviceRowUpdateInput {
|
|||||||
overriddenFields?: string;
|
overriddenFields?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CircuitDeviceRowPatchInput = Partial<CircuitDeviceRowUpdateInput> & {
|
||||||
|
sortOrder?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export interface CircuitDeviceRowCreateInput extends CircuitDeviceRowUpdateInput {
|
export interface CircuitDeviceRowCreateInput extends CircuitDeviceRowUpdateInput {
|
||||||
circuitId: string;
|
circuitId: string;
|
||||||
linkedProjectDeviceId?: string;
|
linkedProjectDeviceId?: string;
|
||||||
@@ -78,6 +82,39 @@ function toUpdateValues(input: CircuitDeviceRowUpdateInput) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toPatchValues(input: CircuitDeviceRowPatchInput) {
|
||||||
|
const values: Partial<typeof circuitDeviceRows.$inferInsert> = {};
|
||||||
|
const has = (field: keyof CircuitDeviceRowPatchInput) =>
|
||||||
|
Object.prototype.hasOwnProperty.call(input, field);
|
||||||
|
|
||||||
|
if (has("linkedProjectDeviceId")) {
|
||||||
|
values.linkedProjectDeviceId = input.linkedProjectDeviceId ?? null;
|
||||||
|
}
|
||||||
|
if (input.name !== undefined) values.name = input.name;
|
||||||
|
if (input.displayName !== undefined) values.displayName = input.displayName;
|
||||||
|
if (has("phaseType")) values.phaseType = input.phaseType ?? null;
|
||||||
|
if (has("connectionKind")) values.connectionKind = input.connectionKind ?? null;
|
||||||
|
if (has("costGroup")) values.costGroup = input.costGroup ?? null;
|
||||||
|
if (has("category")) values.category = input.category ?? null;
|
||||||
|
if (has("level")) values.level = input.level ?? null;
|
||||||
|
if (has("roomId")) values.roomId = input.roomId ?? null;
|
||||||
|
if (has("roomNumberSnapshot")) {
|
||||||
|
values.roomNumberSnapshot = input.roomNumberSnapshot ?? null;
|
||||||
|
}
|
||||||
|
if (has("roomNameSnapshot")) values.roomNameSnapshot = input.roomNameSnapshot ?? null;
|
||||||
|
if (input.quantity !== undefined) values.quantity = input.quantity;
|
||||||
|
if (input.powerPerUnit !== undefined) values.powerPerUnit = input.powerPerUnit;
|
||||||
|
if (input.simultaneityFactor !== undefined) {
|
||||||
|
values.simultaneityFactor = input.simultaneityFactor;
|
||||||
|
}
|
||||||
|
if (has("cosPhi")) values.cosPhi = input.cosPhi ?? null;
|
||||||
|
if (has("remark")) values.remark = input.remark ?? null;
|
||||||
|
if (has("overriddenFields")) values.overriddenFields = input.overriddenFields ?? null;
|
||||||
|
if (input.sortOrder !== undefined) values.sortOrder = input.sortOrder;
|
||||||
|
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
function toCreateValues(id: string, input: CircuitDeviceRowCreateInput) {
|
function toCreateValues(id: string, input: CircuitDeviceRowCreateInput) {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -256,6 +293,14 @@ export class CircuitDeviceRowRepository {
|
|||||||
.where(eq(circuitDeviceRows.id, rowId));
|
.where(eq(circuitDeviceRows.id, rowId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateFields(rowId: string, input: CircuitDeviceRowPatchInput) {
|
||||||
|
const values = toPatchValues(input);
|
||||||
|
if (Object.keys(values).length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await db.update(circuitDeviceRows).set(values).where(eq(circuitDeviceRows.id, rowId));
|
||||||
|
}
|
||||||
|
|
||||||
updateLinkedRowsTransactional(
|
updateLinkedRowsTransactional(
|
||||||
projectDeviceId: string,
|
projectDeviceId: string,
|
||||||
changes: Array<{ rowId: string; input: CircuitDeviceRowUpdateInput }>
|
changes: Array<{ rowId: string; input: CircuitDeviceRowUpdateInput }>
|
||||||
|
|||||||
@@ -24,6 +24,26 @@ export interface CircuitCreatePersistenceInput {
|
|||||||
isReserve?: boolean;
|
isReserve?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CircuitPatchPersistenceInput {
|
||||||
|
sectionId?: string;
|
||||||
|
equipmentIdentifier?: string;
|
||||||
|
displayName?: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
protectionType?: string;
|
||||||
|
protectionRatedCurrent?: number;
|
||||||
|
protectionCharacteristic?: string;
|
||||||
|
cableType?: string;
|
||||||
|
cableCrossSection?: string;
|
||||||
|
cableLength?: number;
|
||||||
|
voltage?: number;
|
||||||
|
controlRequirement?: string;
|
||||||
|
remark?: string;
|
||||||
|
rcdAssignment?: string;
|
||||||
|
terminalDesignation?: string;
|
||||||
|
status?: string;
|
||||||
|
isReserve?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export function toCircuitCreateValues(id: string, input: CircuitCreatePersistenceInput) {
|
export function toCircuitCreateValues(id: string, input: CircuitCreatePersistenceInput) {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -48,6 +68,42 @@ export function toCircuitCreateValues(id: string, input: CircuitCreatePersistenc
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toCircuitPatchValues(input: CircuitPatchPersistenceInput) {
|
||||||
|
const values: Partial<typeof circuits.$inferInsert> = {};
|
||||||
|
const has = (field: keyof CircuitPatchPersistenceInput) =>
|
||||||
|
Object.prototype.hasOwnProperty.call(input, field);
|
||||||
|
|
||||||
|
if (input.sectionId !== undefined) values.sectionId = input.sectionId;
|
||||||
|
if (input.equipmentIdentifier !== undefined) {
|
||||||
|
values.equipmentIdentifier = input.equipmentIdentifier;
|
||||||
|
}
|
||||||
|
if (has("displayName")) values.displayName = input.displayName ?? null;
|
||||||
|
if (input.sortOrder !== undefined) values.sortOrder = input.sortOrder;
|
||||||
|
if (has("protectionType")) values.protectionType = input.protectionType ?? null;
|
||||||
|
if (has("protectionRatedCurrent")) {
|
||||||
|
values.protectionRatedCurrent = input.protectionRatedCurrent ?? null;
|
||||||
|
}
|
||||||
|
if (has("protectionCharacteristic")) {
|
||||||
|
values.protectionCharacteristic = input.protectionCharacteristic ?? null;
|
||||||
|
}
|
||||||
|
if (has("cableType")) values.cableType = input.cableType ?? null;
|
||||||
|
if (has("cableCrossSection")) values.cableCrossSection = input.cableCrossSection ?? null;
|
||||||
|
if (has("cableLength")) values.cableLength = input.cableLength ?? null;
|
||||||
|
if (has("rcdAssignment")) values.rcdAssignment = input.rcdAssignment ?? null;
|
||||||
|
if (has("terminalDesignation")) {
|
||||||
|
values.terminalDesignation = input.terminalDesignation ?? null;
|
||||||
|
}
|
||||||
|
if (has("voltage")) values.voltage = input.voltage ?? null;
|
||||||
|
if (has("controlRequirement")) {
|
||||||
|
values.controlRequirement = input.controlRequirement ?? null;
|
||||||
|
}
|
||||||
|
if (has("status")) values.status = input.status ?? null;
|
||||||
|
if (input.isReserve !== undefined) values.isReserve = input.isReserve ? 1 : 0;
|
||||||
|
if (has("remark")) values.remark = input.remark ?? null;
|
||||||
|
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
export class CircuitRepository {
|
export class CircuitRepository {
|
||||||
async findById(circuitId: string) {
|
async findById(circuitId: string) {
|
||||||
const [row] = await db.select().from(circuits).where(eq(circuits.id, circuitId)).limit(1);
|
const [row] = await db.select().from(circuits).where(eq(circuits.id, circuitId)).limit(1);
|
||||||
@@ -114,6 +170,14 @@ export class CircuitRepository {
|
|||||||
.where(eq(circuits.id, circuitId));
|
.where(eq(circuits.id, circuitId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateFields(circuitId: string, input: CircuitPatchPersistenceInput) {
|
||||||
|
const values = toCircuitPatchValues(input);
|
||||||
|
if (Object.keys(values).length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await db.update(circuits).set(values).where(eq(circuits.id, circuitId));
|
||||||
|
}
|
||||||
|
|
||||||
async delete(circuitId: string) {
|
async delete(circuitId: string) {
|
||||||
await db.delete(circuits).where(eq(circuits.id, circuitId));
|
await db.delete(circuits).where(eq(circuits.id, circuitId));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,30 +182,10 @@ export class CircuitWriteService {
|
|||||||
|
|
||||||
const sectionId = input.sectionId ?? current.sectionId;
|
const sectionId = input.sectionId ?? current.sectionId;
|
||||||
const equipmentIdentifier = input.equipmentIdentifier ?? current.equipmentIdentifier;
|
const equipmentIdentifier = input.equipmentIdentifier ?? current.equipmentIdentifier;
|
||||||
const sortOrder = input.sortOrder ?? current.sortOrder;
|
|
||||||
await this.assertSectionInList(sectionId, current.circuitListId);
|
await this.assertSectionInList(sectionId, current.circuitListId);
|
||||||
await this.assertUniqueEquipmentIdentifier(current.circuitListId, equipmentIdentifier, circuitId);
|
await this.assertUniqueEquipmentIdentifier(current.circuitListId, equipmentIdentifier, circuitId);
|
||||||
|
|
||||||
await this.circuitRepository.update(circuitId, {
|
await this.circuitRepository.updateFields(circuitId, input);
|
||||||
sectionId,
|
|
||||||
equipmentIdentifier,
|
|
||||||
displayName: input.displayName ?? current.displayName ?? undefined,
|
|
||||||
sortOrder,
|
|
||||||
protectionType: input.protectionType ?? current.protectionType ?? undefined,
|
|
||||||
protectionRatedCurrent: input.protectionRatedCurrent ?? current.protectionRatedCurrent ?? undefined,
|
|
||||||
protectionCharacteristic:
|
|
||||||
input.protectionCharacteristic ?? current.protectionCharacteristic ?? undefined,
|
|
||||||
cableType: input.cableType ?? current.cableType ?? undefined,
|
|
||||||
cableCrossSection: input.cableCrossSection ?? current.cableCrossSection ?? undefined,
|
|
||||||
cableLength: input.cableLength ?? current.cableLength ?? undefined,
|
|
||||||
rcdAssignment: input.rcdAssignment ?? current.rcdAssignment ?? undefined,
|
|
||||||
terminalDesignation: input.terminalDesignation ?? current.terminalDesignation ?? undefined,
|
|
||||||
voltage: input.voltage ?? current.voltage ?? undefined,
|
|
||||||
controlRequirement: input.controlRequirement ?? current.controlRequirement ?? undefined,
|
|
||||||
status: input.status ?? current.status ?? undefined,
|
|
||||||
isReserve: input.isReserve ?? Boolean(current.isReserve),
|
|
||||||
remark: input.remark ?? current.remark ?? undefined,
|
|
||||||
});
|
|
||||||
return this.circuitRepository.findById(circuitId);
|
return this.circuitRepository.findById(circuitId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,7 +235,7 @@ export class CircuitWriteService {
|
|||||||
throw new Error("Invalid device row id.");
|
throw new Error("Invalid device row id.");
|
||||||
}
|
}
|
||||||
await this.assertValidLinkedProjectDevice(current.circuitId, input.linkedProjectDeviceId);
|
await this.assertValidLinkedProjectDevice(current.circuitId, input.linkedProjectDeviceId);
|
||||||
let overriddenFields = input.overriddenFields ?? current.overriddenFields ?? undefined;
|
let overriddenFields = input.overriddenFields;
|
||||||
if (current.linkedProjectDeviceId && input.overriddenFields === undefined) {
|
if (current.linkedProjectDeviceId && input.overriddenFields === undefined) {
|
||||||
const overrides = new Set(parseOverriddenFields(current.overriddenFields));
|
const overrides = new Set(parseOverriddenFields(current.overriddenFields));
|
||||||
const inputValues = input as Record<string, unknown>;
|
const inputValues = input as Record<string, unknown>;
|
||||||
@@ -270,24 +250,9 @@ export class CircuitWriteService {
|
|||||||
}
|
}
|
||||||
overriddenFields = serializeOverriddenFields(overrides);
|
overriddenFields = serializeOverriddenFields(overrides);
|
||||||
}
|
}
|
||||||
await this.deviceRowRepository.update(rowId, {
|
await this.deviceRowRepository.updateFields(rowId, {
|
||||||
linkedProjectDeviceId: input.linkedProjectDeviceId ?? current.linkedProjectDeviceId ?? undefined,
|
...input,
|
||||||
name: input.name ?? current.name,
|
...(overriddenFields !== undefined ? { overriddenFields } : {}),
|
||||||
displayName: input.displayName ?? current.displayName,
|
|
||||||
phaseType: input.phaseType ?? current.phaseType ?? undefined,
|
|
||||||
connectionKind: input.connectionKind ?? current.connectionKind ?? undefined,
|
|
||||||
costGroup: input.costGroup ?? current.costGroup ?? undefined,
|
|
||||||
category: input.category ?? current.category ?? undefined,
|
|
||||||
level: input.level ?? current.level ?? undefined,
|
|
||||||
roomId: input.roomId ?? current.roomId ?? undefined,
|
|
||||||
roomNumberSnapshot: input.roomNumberSnapshot ?? current.roomNumberSnapshot ?? undefined,
|
|
||||||
roomNameSnapshot: input.roomNameSnapshot ?? current.roomNameSnapshot ?? undefined,
|
|
||||||
quantity: input.quantity ?? current.quantity,
|
|
||||||
powerPerUnit: input.powerPerUnit ?? current.powerPerUnit,
|
|
||||||
simultaneityFactor: input.simultaneityFactor ?? current.simultaneityFactor,
|
|
||||||
cosPhi: input.cosPhi ?? current.cosPhi ?? undefined,
|
|
||||||
remark: input.remark ?? current.remark ?? undefined,
|
|
||||||
overriddenFields,
|
|
||||||
});
|
});
|
||||||
return this.deviceRowRepository.findById(rowId);
|
return this.deviceRowRepository.findById(rowId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ describe("circuit write service rules", () => {
|
|||||||
async existsByEquipmentIdentifier() {
|
async existsByEquipmentIdentifier() {
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
async update(_id: string, payload: Record<string, unknown>) {
|
async updateFields(_id: string, payload: Record<string, unknown>) {
|
||||||
updatePayload = payload;
|
updatePayload = payload;
|
||||||
},
|
},
|
||||||
} as never,
|
} as never,
|
||||||
@@ -52,8 +52,10 @@ describe("circuit write service rules", () => {
|
|||||||
|
|
||||||
await service.updateCircuit("c1", { voltage: 400, controlRequirement: "DALI" });
|
await service.updateCircuit("c1", { voltage: 400, controlRequirement: "DALI" });
|
||||||
|
|
||||||
assert.equal(updatePayload.voltage, 400);
|
assert.deepEqual(updatePayload, {
|
||||||
assert.equal(updatePayload.controlRequirement, "DALI");
|
voltage: 400,
|
||||||
|
controlRequirement: "DALI",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects duplicate equipment identifiers in same circuit list", async () => {
|
it("rejects duplicate equipment identifiers in same circuit list", async () => {
|
||||||
@@ -1147,7 +1149,7 @@ describe("circuit write service rules", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("tracks local edits on linked device rows as overridden fields", async () => {
|
it("tracks local edits on linked device rows as overridden fields", async () => {
|
||||||
let savedOverrides: string | undefined;
|
let updatePayload: Record<string, unknown> = {};
|
||||||
const current = {
|
const current = {
|
||||||
id: "r1",
|
id: "r1",
|
||||||
circuitId: "c1",
|
circuitId: "c1",
|
||||||
@@ -1165,14 +1167,17 @@ describe("circuit write service rules", () => {
|
|||||||
async findById() {
|
async findById() {
|
||||||
return current as never;
|
return current as never;
|
||||||
},
|
},
|
||||||
async update(_rowId: string, input: { overriddenFields?: string }) {
|
async updateFields(_rowId: string, input: Record<string, unknown>) {
|
||||||
savedOverrides = input.overriddenFields;
|
updatePayload = input;
|
||||||
},
|
},
|
||||||
} as never,
|
} as never,
|
||||||
});
|
});
|
||||||
|
|
||||||
await service.updateDeviceRow("r1", { quantity: 2 });
|
await service.updateDeviceRow("r1", { quantity: 2 });
|
||||||
|
|
||||||
assert.deepEqual(JSON.parse(savedOverrides ?? "[]"), ["quantity"]);
|
assert.deepEqual(updatePayload, {
|
||||||
|
quantity: 2,
|
||||||
|
overriddenFields: "[\"quantity\"]",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user