Add buildings above floors and a building structure page
Projects with several buildings could not be structured: floors and rooms hung directly off the project. Floors can now belong to a building (migration 0009: table buildings, nullable floors.building_id). Rooms and distribution boards keep their floor; their building follows from it. Existing floors stay without a building. Buildings use the location-structure command store with project-building.insert/.update/.delete, inverses and persistent Undo/Redo; deleting a building with assigned floors is rejected. Floor commands carry buildingId since command version 2. Version 1 floor commands from existing history predate buildings and are upgraded with buildingId = null before they are executed and persisted, so forward and inverse keep one version. Project snapshot schema 7 stores buildings and floor.buildingId; versions 1-6 are upgraded without buildings. Restore and portable transfer include buildings, and the transfer remaps building ids. Buildings, floors and rooms move from the project overview to their own page /projects/:id/struktur with three columns (Gebäude -> Etagen -> Räume) that filter each other, and a sidebar entry. Floor selects in the board and room dialogs are grouped by building. Unchanged dialogs close without a command. Details in docs/building-structure.md; see issue #6. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
7692699ee2
commit
cd89c4a499
42 changed files with 5883 additions and 373 deletions
|
|
@ -8,10 +8,22 @@ import {
|
|||
} from "../src/domain/models/project-state-snapshot.model.js";
|
||||
import { createNamedProjectSnapshotSchema } from "../src/shared/validation/project-snapshot.schemas.js";
|
||||
|
||||
/** Removes the fields introduced by snapshot schema version 6. */
|
||||
function withoutVersionSixFields<
|
||||
T extends { project: object; distributionBoards: object[] },
|
||||
/** Removes the fields introduced by snapshot schema version 7. */
|
||||
function withoutVersionSevenFields<
|
||||
T extends { floors: object[] },
|
||||
>(snapshot: T): T {
|
||||
delete (snapshot as Record<string, unknown>).buildings;
|
||||
for (const floor of snapshot.floors) {
|
||||
delete (floor as Record<string, unknown>).buildingId;
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** Removes the fields introduced by snapshot schema versions 6 and 7. */
|
||||
function withoutVersionSixFields<
|
||||
T extends { project: object; distributionBoards: object[]; floors: object[] },
|
||||
>(snapshot: T): T {
|
||||
withoutVersionSevenFields(snapshot);
|
||||
const project = snapshot.project as Record<string, unknown>;
|
||||
for (const field of ["siteAddress", "workStage", "projectLead", "projectEditor"]) {
|
||||
delete project[field];
|
||||
|
|
@ -66,7 +78,19 @@ function minimalSnapshot() {
|
|||
distributionBoardComponentProtectionDevices: [],
|
||||
circuits: [],
|
||||
projectDevices: [],
|
||||
floors: [],
|
||||
buildings: [] as Array<{
|
||||
id: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
sortOrder: number;
|
||||
}>,
|
||||
floors: [] as Array<{
|
||||
id: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
sortOrder: number;
|
||||
buildingId: string | null;
|
||||
}>,
|
||||
rooms: [],
|
||||
};
|
||||
}
|
||||
|
|
@ -306,7 +330,10 @@ describe("project state snapshot model", () => {
|
|||
delete (outdated.project as Record<string, unknown>).description;
|
||||
assert.throws(() => parseProjectStateSnapshot(outdated));
|
||||
assert.throws(() =>
|
||||
parseProjectStateSnapshot({ ...minimalSnapshot(), schemaVersion: 7 })
|
||||
parseProjectStateSnapshot({
|
||||
...minimalSnapshot(),
|
||||
schemaVersion: projectStateSnapshotSchemaVersion + 1,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -355,12 +382,99 @@ describe("project state snapshot model", () => {
|
|||
projectId: "project-2",
|
||||
name: "EG",
|
||||
sortOrder: 10,
|
||||
buildingId: null,
|
||||
});
|
||||
assert.throws(
|
||||
() => parseProjectStateSnapshot(wrongOwner),
|
||||
/floor belongs to a different project/
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps buildings and validates floor building references", () => {
|
||||
const snapshot = minimalSnapshot();
|
||||
snapshot.buildings.push({
|
||||
id: "building-1",
|
||||
projectId: "project-1",
|
||||
name: "Haus A",
|
||||
sortOrder: 0,
|
||||
});
|
||||
snapshot.floors.push(
|
||||
{
|
||||
id: "floor-1",
|
||||
projectId: "project-1",
|
||||
name: "EG",
|
||||
sortOrder: 0,
|
||||
buildingId: "building-1",
|
||||
},
|
||||
{
|
||||
id: "floor-2",
|
||||
projectId: "project-1",
|
||||
name: "Keller",
|
||||
sortOrder: 1,
|
||||
buildingId: null,
|
||||
}
|
||||
);
|
||||
const parsed = deserializeProjectStateSnapshot(
|
||||
serializeProjectStateSnapshot(parseProjectStateSnapshot(snapshot))
|
||||
);
|
||||
assert.deepEqual(parsed.buildings, snapshot.buildings);
|
||||
assert.deepEqual(
|
||||
parsed.floors.map((floor) => floor.buildingId),
|
||||
["building-1", null]
|
||||
);
|
||||
|
||||
const unknownBuilding = structuredClone(snapshot);
|
||||
unknownBuilding.floors[0]!.buildingId = "building-x";
|
||||
assert.throws(
|
||||
() => parseProjectStateSnapshot(unknownBuilding),
|
||||
/floor building/
|
||||
);
|
||||
|
||||
const foreignBuilding = structuredClone(snapshot);
|
||||
foreignBuilding.buildings[0]!.projectId = "project-2";
|
||||
assert.throws(
|
||||
() => parseProjectStateSnapshot(foreignBuilding),
|
||||
/building belongs to a different project/
|
||||
);
|
||||
|
||||
const floorWithoutBuildingField = structuredClone(snapshot);
|
||||
delete (floorWithoutBuildingField.floors[1] as Record<string, unknown>)
|
||||
.buildingId;
|
||||
assert.throws(() => parseProjectStateSnapshot(floorWithoutBuildingField));
|
||||
});
|
||||
|
||||
it("upgrades snapshot version 6 without buildings", () => {
|
||||
const snapshot = minimalSnapshot();
|
||||
snapshot.floors.push({
|
||||
id: "floor-1",
|
||||
projectId: "project-1",
|
||||
name: "EG",
|
||||
sortOrder: 0,
|
||||
buildingId: null,
|
||||
});
|
||||
const previous = withoutVersionSevenFields(structuredClone(snapshot));
|
||||
previous.schemaVersion = 6 as typeof previous.schemaVersion;
|
||||
|
||||
const upgraded = parseProjectStateSnapshot(previous);
|
||||
|
||||
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
|
||||
assert.deepEqual(upgraded.buildings, []);
|
||||
assert.deepEqual(upgraded.floors, [
|
||||
{
|
||||
id: "floor-1",
|
||||
projectId: "project-1",
|
||||
name: "EG",
|
||||
sortOrder: 0,
|
||||
buildingId: null,
|
||||
},
|
||||
]);
|
||||
// Version 6 cover values survive the upgrade.
|
||||
assert.equal(upgraded.project.workStage, "LPH 5");
|
||||
|
||||
const mislabeled = structuredClone(snapshot);
|
||||
mislabeled.schemaVersion = 6 as typeof mislabeled.schemaVersion;
|
||||
assert.throws(() => parseProjectStateSnapshot(mislabeled));
|
||||
});
|
||||
});
|
||||
|
||||
describe("named project snapshot request", () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue