From a33eb83655bb594ba37b3fca47964f4923e6e581 Mon Sep 17 00:00:00 2001 From: ajnart Date: Sat, 8 Aug 2026 16:19:26 +0200 Subject: [PATCH] fix: address responsive layout review --- .../app/[locale]/boards/(content)/_client.tsx | 4 +- .../boards/[name]/settings/_layout.tsx | 12 +- packages/api/src/router/board.ts | 161 ++++++++++++------ packages/api/src/router/test/board.spec.ts | 41 +++++ packages/translation/src/lang/en.json | 3 +- 5 files changed, 166 insertions(+), 55 deletions(-) diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx index dd8c6057b..afedcc7fa 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx @@ -3,6 +3,7 @@ import { Box, Paper, Stack, Text } from "@mantine/core"; import { useCurrentLayout, useLayoutOverride, useRequiredBoard } from "@homarr/boards/context"; +import { useScopedI18n } from "@homarr/translation/client"; import { getRepresentativeLayoutWidth } from "../_layout-utils"; import { BoardCategorySection } from "~/components/board/sections/category-section"; @@ -11,6 +12,7 @@ import { BoardBackgroundVideo } from "~/components/layout/background"; export const ClientBoard = () => { const board = useRequiredBoard(); + const tPreview = useScopedI18n("board.setting.section.layout.preview"); const currentLayoutId = useCurrentLayout(); const layoutOverrideId = useLayoutOverride(); @@ -42,7 +44,7 @@ export const ClientBoard = () => { return ( - {currentLayout.name} · {representativeWidth}px + {tPreview("editorWidthLabel", { layoutName: currentLayout.name, width: representativeWidth })} (null); const [resettingLayoutId, setResettingLayoutId] = useState(null); - const { mutateAsync: resetLayout } = clientApi.board.resetLayout.useMutation(); + const { mutateAsync: resetLayout } = clientApi.board.resetLayout.useMutation({ + onError() { + showErrorNotification({ + title: t("common.notification.update.error"), + message: t("common.notification.update.error"), + }); + }, + }); const appIds = useMemo( () => Array.from( @@ -86,6 +94,8 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync await resetLayout({ boardId: board.id, layoutId: canonicalLayout.id }); await utils.board.getBoardByName.invalidate({ name: board.name }); router.refresh(); + } catch { + // The mutation callback displays the error notification. } finally { setResettingLayoutId(null); } diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index e446a74bd..806facc1a 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -4,6 +4,7 @@ import { z } from "zod/v4"; import { constructBoardPermissions } from "@homarr/auth/shared"; import { createId, generateResponsiveGridFor } from "@homarr/common"; +import type { GridAlgorithmItem } from "@homarr/common"; import type { DeviceType } from "@homarr/common/server"; import { createLogger } from "@homarr/core/infrastructure/logs"; import type { Database, InferInsertModel, InferSelectModel, SQL } from "@homarr/db"; @@ -796,6 +797,16 @@ export const boardRouter = createTRPCRouter({ const itemSectionLayoutsToInsert: InferInsertModel[] = []; const sectionLayoutsToInsert: InferInsertModel[] = []; const savedLayoutIds = new Map(); + const requestedBaseLayout = input.layouts.find((layout) => layout.id === baseLayout.id); + const resizedBaseLayout = + requestedBaseLayout && requestedBaseLayout.columnCount !== baseLayout.columnCount + ? getUpdatedBoardLayout(board, { + previous: { layoutId: baseLayout.id, columnCount: baseLayout.columnCount }, + current: { layoutId: baseLayout.id, columnCount: requestedBaseLayout.columnCount }, + }) + : null; + const baseSourceElements = resizedBaseLayout ? getElementsForProjectedLayout(resizedBaseLayout) : undefined; + const baseSourceColumnCount = requestedBaseLayout?.columnCount ?? baseLayout.columnCount; for (const addedLayout of addedLayouts) { const layoutId = createId(); @@ -810,7 +821,11 @@ export const boardRouter = createTRPCRouter({ }); const projectedLayout = getUpdatedBoardLayout(board, { - previous: { layoutId: baseLayout.id, columnCount: baseLayout.columnCount }, + previous: { + layoutId: baseLayout.id, + columnCount: baseSourceColumnCount, + elements: baseSourceElements, + }, current: { layoutId, columnCount: addedLayout.columnCount }, }); itemSectionLayoutsToInsert.push(...projectedLayout.itemSectionLayouts); @@ -825,10 +840,13 @@ export const boardRouter = createTRPCRouter({ const dbLayout = existingLayoutsById.get(updatedLayout.id); if (!dbLayout || dbLayout.columnCount === updatedLayout.columnCount) continue; - const projectedLayout = getUpdatedBoardLayout(board, { - previous: { layoutId: dbLayout.id, columnCount: dbLayout.columnCount }, - current: { layoutId: dbLayout.id, columnCount: updatedLayout.columnCount }, - }); + const projectedLayout = + updatedLayout.role === "base" && resizedBaseLayout + ? resizedBaseLayout + : getUpdatedBoardLayout(board, { + previous: { layoutId: dbLayout.id, columnCount: dbLayout.columnCount }, + current: { layoutId: dbLayout.id, columnCount: updatedLayout.columnCount }, + }); itemSectionLayoutsToUpdate.push(...projectedLayout.itemSectionLayouts); sectionLayoutsToUpdate.push(...projectedLayout.sectionLayouts); } @@ -953,53 +971,62 @@ export const boardRouter = createTRPCRouter({ })) .toSorted((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint); }), - resetLayout: protectedProcedure.input(boardResetLayoutSchema).mutation(async ({ ctx, input }) => { - await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify"); - - const board = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.boardId), ctx.session.user.id); - const targetLayout = board.layouts.find((layout) => layout.id === input.layoutId); - const baseLayout = board.layouts.find((layout) => layout.role === "base"); - - if (!targetLayout) throw new TRPCError({ code: "NOT_FOUND", message: "Layout not found" }); - if (!baseLayout) { - throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Board must have a Base layout" }); - } - if (targetLayout.role === "base") { - throw new TRPCError({ code: "BAD_REQUEST", message: "The Base layout cannot be reset" }); - } - - const projectedLayout = getUpdatedBoardLayout(board, { - previous: { layoutId: baseLayout.id, columnCount: baseLayout.columnCount }, - current: { layoutId: targetLayout.id, columnCount: targetLayout.columnCount }, - }); - - await handleTransactionsAsync(ctx.db, { - async handleAsync(db, schema) { - await db.transaction(async (transaction) => { - await transaction.delete(schema.itemLayouts).where(eq(schema.itemLayouts.layoutId, targetLayout.id)); - await transaction.delete(schema.sectionLayouts).where(eq(schema.sectionLayouts.layoutId, targetLayout.id)); - if (projectedLayout.itemSectionLayouts.length > 0) { - await transaction.insert(schema.itemLayouts).values(projectedLayout.itemSectionLayouts); - } - if (projectedLayout.sectionLayouts.length > 0) { - await transaction.insert(schema.sectionLayouts).values(projectedLayout.sectionLayouts); - } - }); + resetLayout: protectedProcedure + .meta({ + mcp: { + enabled: true, + description: + "Reset a board's Mobile or custom layout from its Base layout while preserving the target layout settings. Requires modify permission. REQUIRED: boardId (board ID), layoutId (non-Base layout ID)", }, - handleSync(db) { - db.transaction((transaction) => { - transaction.delete(itemLayouts).where(eq(itemLayouts.layoutId, targetLayout.id)).run(); - transaction.delete(sectionLayouts).where(eq(sectionLayouts.layoutId, targetLayout.id)).run(); - if (projectedLayout.itemSectionLayouts.length > 0) { - transaction.insert(itemLayouts).values(projectedLayout.itemSectionLayouts).run(); - } - if (projectedLayout.sectionLayouts.length > 0) { - transaction.insert(sectionLayouts).values(projectedLayout.sectionLayouts).run(); - } - }); - }, - }); - }), + }) + .input(boardResetLayoutSchema) + .mutation(async ({ ctx, input }) => { + await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify"); + + const board = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.boardId), ctx.session.user.id); + const targetLayout = board.layouts.find((layout) => layout.id === input.layoutId); + const baseLayout = board.layouts.find((layout) => layout.role === "base"); + + if (!targetLayout) throw new TRPCError({ code: "NOT_FOUND", message: "Layout not found" }); + if (!baseLayout) { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Board must have a Base layout" }); + } + if (targetLayout.role === "base") { + throw new TRPCError({ code: "BAD_REQUEST", message: "The Base layout cannot be reset" }); + } + + const projectedLayout = getUpdatedBoardLayout(board, { + previous: { layoutId: baseLayout.id, columnCount: baseLayout.columnCount }, + current: { layoutId: targetLayout.id, columnCount: targetLayout.columnCount }, + }); + + await handleTransactionsAsync(ctx.db, { + async handleAsync(db, schema) { + await db.transaction(async (transaction) => { + await transaction.delete(schema.itemLayouts).where(eq(schema.itemLayouts.layoutId, targetLayout.id)); + await transaction.delete(schema.sectionLayouts).where(eq(schema.sectionLayouts.layoutId, targetLayout.id)); + if (projectedLayout.itemSectionLayouts.length > 0) { + await transaction.insert(schema.itemLayouts).values(projectedLayout.itemSectionLayouts); + } + if (projectedLayout.sectionLayouts.length > 0) { + await transaction.insert(schema.sectionLayouts).values(projectedLayout.sectionLayouts); + } + }); + }, + handleSync(db) { + db.transaction((transaction) => { + transaction.delete(itemLayouts).where(eq(itemLayouts.layoutId, targetLayout.id)).run(); + transaction.delete(sectionLayouts).where(eq(sectionLayouts.layoutId, targetLayout.id)).run(); + if (projectedLayout.itemSectionLayouts.length > 0) { + transaction.insert(itemLayouts).values(projectedLayout.itemSectionLayouts).run(); + } + if (projectedLayout.sectionLayouts.length > 0) { + transaction.insert(sectionLayouts).values(projectedLayout.sectionLayouts).run(); + } + }); + }, + }); + }), savePartialBoardSettings: protectedProcedure .meta({ openapi: { method: "PATCH", path: "/api/boards/{id}/settings", tags: ["boards"], protect: true }, @@ -1865,6 +1892,7 @@ const getUpdatedBoardLayout = ( previous: { layoutId: string; columnCount: number; + elements?: GridAlgorithmItem[]; }; current: { layoutId: string; @@ -1875,7 +1903,7 @@ const getUpdatedBoardLayout = ( const itemSectionLayoutsCollection: InferInsertModel[] = []; const sectionLayoutsCollection: InferInsertModel[] = []; - const elements = getElementsForLayout(board, options.previous.layoutId); + const elements = options.previous.elements ?? getElementsForLayout(board, options.previous.layoutId); const rootSections = board.sections.filter((section) => section.kind !== "dynamic"); for (const rootSection of rootSections) { @@ -1940,6 +1968,35 @@ const getUpdatedBoardLayout = ( }; }; +const getElementsForProjectedLayout = ( + projectedLayout: ReturnType, +): GridAlgorithmItem[] => [ + ...projectedLayout.itemSectionLayouts.map((layout) => ({ + id: layout.itemId, + type: "item" as const, + height: layout.height, + width: layout.width, + xOffset: layout.xOffset, + yOffset: layout.yOffset, + sectionId: layout.sectionId, + })), + ...projectedLayout.sectionLayouts.flatMap((layout) => + layout.parentSectionId + ? [ + { + id: layout.sectionId, + type: "section" as const, + height: layout.height, + width: layout.width, + xOffset: layout.xOffset, + yOffset: layout.yOffset, + sectionId: layout.parentSectionId, + }, + ] + : [], + ), +]; + const getElementsForLayout = (board: BoardForLayoutProjection, layoutId: string) => { const sectionElements = board.sections .filter((section) => section.kind === "dynamic") diff --git a/packages/api/src/router/test/board.spec.ts b/packages/api/src/router/test/board.spec.ts index 013fc1182..0d3cbe0f1 100644 --- a/packages/api/src/router/test/board.spec.ts +++ b/packages/api/src/router/test/board.spec.ts @@ -1505,6 +1505,47 @@ describe("saveLayouts should save layout changes", () => { }); await expectLayoutForDynamicSectionAsync(db, assignments.inRoot.f, layoutId, assignments.inDynamicSection); }); + test("should project a new custom layout from the resized Base layout", async () => { + const db = createDb(); + const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession }); + const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "default"); + const mobileLayoutId = await addMobileLayoutAsync(db, boardId); + const secondItemId = createId(); + + await db.update(itemLayouts).set({ width: 4, xOffset: 0 }).where(eq(itemLayouts.itemId, itemId)); + await db.insert(items).values({ + id: secondItemId, + kind: "clock", + boardId, + options: SuperJSON.stringify({ is24HourFormat: true }), + }); + await db.insert(itemLayouts).values({ + itemId: secondItemId, + layoutId, + sectionId, + width: 4, + height: 1, + xOffset: 6, + yOffset: 0, + }); + + const resizedBase = { ...createExistingLayout(layoutId), columnCount: 6 }; + const savedLayouts = await caller.saveLayouts({ + id: boardId, + layouts: [createMobileLayout(mobileLayoutId), createNewLayout(8), resizedBase], + }); + const customLayoutId = expectToBeDefined(savedLayouts.find((layout) => layout.role === "custom")).id; + + const basePosition = await db.query.itemLayouts.findFirst({ + where: and(eq(itemLayouts.itemId, secondItemId), eq(itemLayouts.layoutId, layoutId)), + }); + const customPosition = await db.query.itemLayouts.findFirst({ + where: and(eq(itemLayouts.itemId, secondItemId), eq(itemLayouts.layoutId, customLayoutId)), + }); + + expect(basePosition).toMatchObject({ xOffset: 0, yOffset: 1, width: 4, height: 1, sectionId }); + expect(customPosition).toMatchObject({ xOffset: 0, yOffset: 1, width: 4, height: 1, sectionId }); + }); test("should remove layout when not present in input", async () => { // Arrange const db = createDb(); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index ab35fe197..200c43de2 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -4427,7 +4427,8 @@ "preview": { "columns": "columns", "empty": "This layout is empty", - "dynamicSection": "Dynamic section" + "dynamicSection": "Dynamic section", + "editorWidthLabel": "{layoutName} · {width}px" }, "reset": { "action": "Reset from Base",