From 2657ff4384075ca5578de0841b79e7305491f1a9 Mon Sep 17 00:00:00 2001 From: ajnart Date: Sat, 8 Aug 2026 18:44:32 +0200 Subject: [PATCH] fix: repair restored board layouts --- packages/api/src/router/board.ts | 39 +++++++++++++++++++++- packages/api/src/router/test/board.spec.ts | 19 +++++++---- packages/db/migrations/seed.ts | 3 +- packages/db/package.json | 1 + 4 files changed, 54 insertions(+), 8 deletions(-) diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index 806facc1a..7dc9e800e 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -10,6 +10,7 @@ import { createLogger } from "@homarr/core/infrastructure/logs"; import type { Database, InferInsertModel, InferSelectModel, SQL } from "@homarr/db"; import { and, asc, eq, handleTransactionsAsync, inArray, isNull, like, not, or, sql } from "@homarr/db"; import { createDbInsertCollectionWithoutTransaction } from "@homarr/db/collection"; +import { seedProtectedBoardLayoutsAsync } from "@homarr/db/migrations/seed"; import { getServerSettingByKeyAsync } from "@homarr/db/queries"; import { boardGroupPermissions, @@ -2037,7 +2038,14 @@ const getElementsForLayout = (board: BoardForLayoutProjection, layoutId: string) return [...itemElements, ...sectionElements]; }; -const getFullBoardWithWhereAsync = async (db: Database, where: SQL, userId: string | null) => { +const protectedLayoutRepairPromises = new Map>(); + +const getFullBoardWithWhereAsync = async ( + db: Database, + where: SQL, + userId: string | null, + repairProtectedLayouts = true, +) => { const groupPermissionWhere = userId ? inArray( boardGroupPermissions.groupId, @@ -2093,6 +2101,18 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL, use }); } + if (repairProtectedLayouts && boardLayoutsNeedRepair(board.layouts)) { + let repairPromise = protectedLayoutRepairPromises.get(board.id); + if (!repairPromise) { + repairPromise = seedProtectedBoardLayoutsAsync(db, board.id).finally(() => { + protectedLayoutRepairPromises.delete(board.id); + }); + protectedLayoutRepairPromises.set(board.id, repairPromise); + } + await repairPromise; + return getFullBoardWithWhereAsync(db, where, userId, false); + } + const { sections, items, layouts, ...otherBoardProperties } = board; return { @@ -2138,6 +2158,23 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL, use }; }; +export const boardLayoutsNeedRepair = ( + boardLayouts: Array<{ id: string; breakpoint: number; role: "mobile" | "base" | "custom" }>, +) => { + const mobileLayouts = boardLayouts.filter((layout) => layout.role === "mobile"); + const baseLayouts = boardLayouts.filter((layout) => layout.role === "base"); + const baseLayout = baseLayouts.at(0); + + return ( + mobileLayouts.length !== 1 || + baseLayouts.length !== 1 || + mobileLayouts.at(0)?.breakpoint !== 0 || + !baseLayout || + boardLayouts.some((layout) => layout.id !== baseLayout.id && layout.breakpoint >= baseLayout.breakpoint) || + new Set(boardLayouts.map((layout) => layout.breakpoint)).size !== boardLayouts.length + ); +}; + const forKind = (kind: T) => z.object({ kind: z.literal(kind), diff --git a/packages/api/src/router/test/board.spec.ts b/packages/api/src/router/test/board.spec.ts index 0d3cbe0f1..e4005d4c0 100644 --- a/packages/api/src/router/test/board.spec.ts +++ b/packages/api/src/router/test/board.spec.ts @@ -602,6 +602,13 @@ describe("getBoardByName should return board by name", () => { name, ...fullBoardProps, }); + expect(result.layouts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ role: "mobile", breakpoint: 0, columnCount: 3 }), + expect.objectContaining({ id: fullBoardProps.layoutId, role: "base", breakpoint: 768 }), + ]), + ); + expect(result.items.at(0)?.layouts).toHaveLength(2); expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "view"); }); @@ -1171,12 +1178,12 @@ describe("saveBoard should save full board", () => { expect(firstItem.id).toBe(itemId); expect(firstItem.kind).toBe("clock"); expect(SuperJSON.parse<{ is24HourFormat: boolean }>(firstItem.options).is24HourFormat).toBe(false); - const firstLayout = expectToBeDefined(firstItem.layouts[0]); - expect(firstLayout.sectionId).toBe(sectionId); - expect(firstLayout.height).toBe(3); - expect(firstLayout.width).toBe(2); - expect(firstLayout.xOffset).toBe(7); - expect(firstLayout.yOffset).toBe(5); + const updatedLayout = expectToBeDefined(firstItem.layouts.find((layout) => layout.layoutId === layoutId)); + expect(updatedLayout.sectionId).toBe(sectionId); + expect(updatedLayout.height).toBe(3); + expect(updatedLayout.width).toBe(2); + expect(updatedLayout.xOffset).toBe(7); + expect(updatedLayout.yOffset).toBe(5); expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify"); }); it("should fail when board not found", async () => { diff --git a/packages/db/migrations/seed.ts b/packages/db/migrations/seed.ts index 9cc5aeed0..2828a1d3e 100644 --- a/packages/db/migrations/seed.ts +++ b/packages/db/migrations/seed.ts @@ -161,8 +161,9 @@ export const seedDataAsync = async (db: Database) => { await seedProtectedBoardLayoutsAsync(db); }; -export const seedProtectedBoardLayoutsAsync = async (db: Database) => { +export const seedProtectedBoardLayoutsAsync = async (db: Database, boardId?: string) => { const dbBoards = await db.query.boards.findMany({ + where: boardId ? eq(boards.id, boardId) : undefined, with: { layouts: true, items: { with: { layouts: true } }, diff --git a/packages/db/package.json b/packages/db/package.json index f8bdfacc5..4bf900906 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -9,6 +9,7 @@ "exports": { ".": "./index.ts", "./collection": "./collection.ts", + "./migrations/seed": "./migrations/seed.ts", "./proxy-reader": "./proxy-reader.ts", "./schema": "./schema/index.ts", "./test": "./test/index.ts",