feat: add automatic mobile board layout

This commit is contained in:
ajnart committed 2026-08-14 11:32:09 +02:00
1 parent 08588da0d1
commit d63653142d
15 files changed
+1498 -323

No files matched your search

+579 -191
View File
@@ -2,13 +2,15 @@ import { TRPCError } from "@trpc/server";
import superjson from "superjson";
import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { constructBoardPermissions } from "@homarr/auth/shared";
import { createId } from "@homarr/common";
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";
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,
@@ -38,6 +40,7 @@ import {
getRootSectionLane,
getPermissionsWithChildren,
getPermissionsWithParents,
normalizeBoardLayoutRoles,
rootSectionOffsets,
widgetDefaultSizes,
widgetKinds,
@@ -52,6 +55,7 @@ import {
boardCreateSchema,
boardDuplicateSchema,
boardRenameSchema,
boardResetLayoutSchema,
boardSaveLayoutsSchema,
boardSavePartialSettingsSchema,
boardSavePermissionsSchema,
@@ -69,7 +73,6 @@ import {
throwIfCustomWidgetBoardDuplicationForbidden,
throwIfCustomWidgetPlacementChangeForbidden,
} from "./board/custom-widget-placement-access";
import { generateResponsiveGridFor } from "./board/grid-algorithm";
import { validateTimetableOptionsChangeAsync } from "./widgets/timetable";
export const boardRouter = createTRPCRouter({
@@ -322,15 +325,28 @@ export const boardRouter = createTRPCRouter({
yOffset: 0,
boardId,
});
createBoardCollection.layouts.push({
id: createId(),
name: "Base",
columnCount: input.columnCount,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
breakpoint: 0,
boardId,
});
createBoardCollection.layouts.push(
{
id: createId(),
name: "Mobile",
columnCount: 3,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
breakpoint: 0,
role: "mobile",
boardId,
},
{
id: createId(),
name: "Base",
columnCount: input.columnCount,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
breakpoint: 768,
role: "base",
boardId,
},
);
await createBoardCollection.insertAllAsync(ctx.db);
@@ -387,8 +403,65 @@ export const boardRouter = createTRPCRouter({
const newBoardId = createId();
const layoutsMap = new Map<string, string>(boardLayouts.map((layout) => [layout.id, createId()]));
const layoutsToInsert = boardLayouts.map((layout) => ({
const generatedMobileLayoutId = createId();
const generatedMobilePositions =
boardLayouts.length === 1 && boardLayouts[0]
? getUpdatedBoardLayout(board, {
previous: {
layoutId: boardLayouts[0].id,
columnCount: boardLayouts[0].columnCount,
leftGutterColumnCount: boardLayouts[0].leftGutterColumnCount,
rightGutterColumnCount: boardLayouts[0].rightGutterColumnCount,
},
current: {
layoutId: generatedMobileLayoutId,
columnCount: 3,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
},
})
: null;
const normalizedBoardLayouts =
boardLayouts.length === 0
? [
{
id: generatedMobileLayoutId,
name: "Mobile",
columnCount: 3,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
breakpoint: 0,
role: "mobile" as const,
boardId: board.id,
},
{
id: createId(),
name: "Base",
columnCount: 10,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
breakpoint: 768,
role: "base" as const,
boardId: board.id,
},
]
: boardLayouts.length === 1 && boardLayouts[0]
? [
{
id: generatedMobileLayoutId,
name: "Mobile",
columnCount: 3,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
breakpoint: 0,
role: "mobile" as const,
boardId: board.id,
},
{ ...boardLayouts[0], breakpoint: 768, role: "base" as const },
]
: normalizeBoardLayoutRoles(boardLayouts);
const layoutsMap = new Map<string, string>(normalizedBoardLayouts.map((layout) => [layout.id, createId()]));
const layoutsToInsert = normalizedBoardLayouts.map((layout) => ({
...layout,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
id: layoutsMap.get(layout.id)!,
@@ -405,9 +478,24 @@ export const boardRouter = createTRPCRouter({
}),
);
const sectionLayoutsToInsert: InferInsertModel<typeof sectionLayouts>[] = boardSections.flatMap((section) =>
section.layouts.map(
(layoutSection): InferInsertModel<typeof sectionLayouts> => ({
const sectionLayoutsToInsert: InferInsertModel<typeof sectionLayouts>[] = boardSections
.flatMap((section) =>
section.layouts.map(
(layoutSection): InferInsertModel<typeof sectionLayouts> => ({
...layoutSection,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
layoutId: layoutsMap.get(layoutSection.layoutId)!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
sectionId: sectionMap.get(layoutSection.sectionId)!,
parentSectionId: layoutSection.parentSectionId
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
sectionMap.get(layoutSection.parentSectionId)!
: layoutSection.parentSectionId,
}),
),
)
.concat(
(generatedMobilePositions?.sectionLayouts ?? []).map((layoutSection) => ({
...layoutSection,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
layoutId: layoutsMap.get(layoutSection.layoutId)!,
@@ -416,10 +504,9 @@ export const boardRouter = createTRPCRouter({
parentSectionId: layoutSection.parentSectionId
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
sectionMap.get(layoutSection.parentSectionId)!
: layoutSection.parentSectionId,
}),
),
);
: null,
})),
);
const sectionCollapseStatesToInsert: InferInsertModel<typeof sectionCollapseStates>[] = boardSections.flatMap(
(section) =>
section.collapseStates.map(
@@ -441,9 +528,22 @@ export const boardRouter = createTRPCRouter({
}),
);
const itemLayoutsToInsert: InferInsertModel<typeof itemLayouts>[] = boardItems.flatMap((item) =>
item.layouts.map(
(layoutSection): InferInsertModel<typeof itemLayouts> => ({
const itemLayoutsToInsert: InferInsertModel<typeof itemLayouts>[] = boardItems
.flatMap((item) =>
item.layouts.map(
(layoutSection): InferInsertModel<typeof itemLayouts> => ({
...layoutSection,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
sectionId: sectionMap.get(layoutSection.sectionId)!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
itemId: itemMap.get(layoutSection.itemId)!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
layoutId: layoutsMap.get(layoutSection.layoutId)!,
}),
),
)
.concat(
(generatedMobilePositions?.itemSectionLayouts ?? []).map((layoutSection) => ({
...layoutSection,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
sectionId: sectionMap.get(layoutSection.sectionId)!,
@@ -451,9 +551,8 @@ export const boardRouter = createTRPCRouter({
itemId: itemMap.get(layoutSection.itemId)!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
layoutId: layoutsMap.get(layoutSection.layoutId)!,
}),
),
);
})),
);
// Creates a list with all integration ids the user has access to
const hasAccessForAll = ctx.session.user.permissions.includes("integration-use-all");
@@ -694,153 +793,338 @@ export const boardRouter = createTRPCRouter({
return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);
}),
saveLayouts: protectedProcedure.input(boardSaveLayoutsSchema).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
saveLayouts: protectedProcedure
.input(boardSaveLayoutsSchema)
.output(boardSaveLayoutsSchema.shape.layouts)
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
const requiredGutterRoots = (["left", "right"] as const).filter((lane) =>
input.layouts.some((layout) => getBoardLaneColumnCount(layout, lane) > 0),
);
await ensureGutterRootSectionsAsync(ctx.db, input.id, requiredGutterRoots);
const requiredGutterRoots = (["left", "right"] as const).filter((lane) =>
input.layouts.some((layout) => getBoardLaneColumnCount(layout, lane) > 0),
);
await ensureGutterRootSectionsAsync(ctx.db, input.id, requiredGutterRoots);
const board = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
const board = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
const existingLayoutsById = new Map(board.layouts.map((layout) => [layout.id, layout]));
const addedLayouts = filterAddedItems(input.layouts, board.layouts);
const removedLayouts = filterRemovedItems(input.layouts, board.layouts);
const addedLayouts = filterAddedItems(input.layouts, board.layouts);
if (addedLayouts.some((layout) => layout.role !== "custom")) {
throw new TRPCError({ code: "BAD_REQUEST", message: "New layouts must use the custom role" });
}
if (removedLayouts.some((layout) => layout.role !== "custom")) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Mobile and Base layouts cannot be removed" });
}
if (
input.layouts.some((layout) => {
const existingLayout = existingLayoutsById.get(layout.id);
return existingLayout && existingLayout.role !== layout.role;
})
) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Layout roles cannot be changed" });
}
const layoutsToInsert: InferInsertModel<typeof layouts>[] = [];
const itemSectionLayoutsToInsert: InferInsertModel<typeof itemLayouts>[] = [];
const sectionLayoutsToInsert: InferInsertModel<typeof sectionLayouts>[] = [];
const baseLayout = board.layouts.find((layout) => layout.role === "base");
if (!baseLayout) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Board must have a Base layout" });
}
for (const addedLayout of addedLayouts) {
const layoutId = createId();
const layoutsToInsert: InferInsertModel<typeof layouts>[] = [];
const itemSectionLayoutsToInsert: InferInsertModel<typeof itemLayouts>[] = [];
const sectionLayoutsToInsert: InferInsertModel<typeof sectionLayouts>[] = [];
const savedLayoutIds = new Map<string, string>();
const requestedBaseLayout = input.layouts.find((layout) => layout.id === baseLayout.id);
const resizedBaseLayout =
requestedBaseLayout &&
(requestedBaseLayout.columnCount !== baseLayout.columnCount ||
requestedBaseLayout.leftGutterColumnCount !== baseLayout.leftGutterColumnCount ||
requestedBaseLayout.rightGutterColumnCount !== baseLayout.rightGutterColumnCount)
? getUpdatedBoardLayout(board, {
previous: {
layoutId: baseLayout.id,
columnCount: baseLayout.columnCount,
leftGutterColumnCount: baseLayout.leftGutterColumnCount,
rightGutterColumnCount: baseLayout.rightGutterColumnCount,
},
current: {
layoutId: baseLayout.id,
columnCount: requestedBaseLayout.columnCount,
leftGutterColumnCount: requestedBaseLayout.leftGutterColumnCount,
rightGutterColumnCount: requestedBaseLayout.rightGutterColumnCount,
},
})
: null;
const baseSourceElements = resizedBaseLayout ? getElementsForProjectedLayout(resizedBaseLayout) : undefined;
const baseSourceGeometry = {
layoutId: baseLayout.id,
columnCount: requestedBaseLayout?.columnCount ?? baseLayout.columnCount,
leftGutterColumnCount: requestedBaseLayout?.leftGutterColumnCount ?? baseLayout.leftGutterColumnCount,
rightGutterColumnCount: requestedBaseLayout?.rightGutterColumnCount ?? baseLayout.rightGutterColumnCount,
};
const sortedLayouts = board.layouts.toSorted((layoutA, layoutB) => layoutA.columnCount - layoutB.columnCount);
// Fallback to biggest if none exists with columnCount bigger than addedLayout.columnCount
const layoutToClone =
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
sortedLayouts.find((layout) => layout.columnCount >= addedLayout.columnCount) ?? sortedLayouts.at(-1)!;
const updatedBoardLayout = getUpdatedBoardLayout(board, {
previous: {
layoutId: layoutToClone.id,
columnCount: layoutToClone.columnCount,
leftGutterColumnCount: layoutToClone.leftGutterColumnCount,
rightGutterColumnCount: layoutToClone.rightGutterColumnCount,
},
current: {
layoutId,
for (const addedLayout of addedLayouts) {
const layoutId = createId();
savedLayoutIds.set(addedLayout.id, layoutId);
layoutsToInsert.push({
id: layoutId,
name: addedLayout.name,
columnCount: addedLayout.columnCount,
leftGutterColumnCount: addedLayout.leftGutterColumnCount,
rightGutterColumnCount: addedLayout.rightGutterColumnCount,
breakpoint: addedLayout.breakpoint,
role: "custom",
boardId: board.id,
});
const projectedLayout = getUpdatedBoardLayout(board, {
previous: {
...baseSourceGeometry,
elements: baseSourceElements,
},
current: {
layoutId,
columnCount: addedLayout.columnCount,
leftGutterColumnCount: addedLayout.leftGutterColumnCount,
rightGutterColumnCount: addedLayout.rightGutterColumnCount,
},
});
itemSectionLayoutsToInsert.push(...projectedLayout.itemSectionLayouts);
sectionLayoutsToInsert.push(...projectedLayout.sectionLayouts);
}
const itemSectionLayoutsToUpdate: InferInsertModel<typeof itemLayouts>[] = [];
const sectionLayoutsToUpdate: InferInsertModel<typeof sectionLayouts>[] = [];
const layoutsToUpdate = filterUpdatedItems(input.layouts, board.layouts);
for (const updatedLayout of layoutsToUpdate) {
const dbLayout = existingLayoutsById.get(updatedLayout.id);
if (
!dbLayout ||
(dbLayout.columnCount === updatedLayout.columnCount &&
dbLayout.leftGutterColumnCount === updatedLayout.leftGutterColumnCount &&
dbLayout.rightGutterColumnCount === updatedLayout.rightGutterColumnCount)
)
continue;
const projectedLayout =
updatedLayout.role === "base" && resizedBaseLayout
? resizedBaseLayout
: getUpdatedBoardLayout(board, {
previous: {
layoutId: dbLayout.id,
columnCount: dbLayout.columnCount,
leftGutterColumnCount: dbLayout.leftGutterColumnCount,
rightGutterColumnCount: dbLayout.rightGutterColumnCount,
},
current: {
layoutId: dbLayout.id,
columnCount: updatedLayout.columnCount,
leftGutterColumnCount: updatedLayout.leftGutterColumnCount,
rightGutterColumnCount: updatedLayout.rightGutterColumnCount,
},
});
itemSectionLayoutsToUpdate.push(...projectedLayout.itemSectionLayouts);
sectionLayoutsToUpdate.push(...projectedLayout.sectionLayouts);
}
const removedLayoutIds = removedLayouts.map((layout) => layout.id);
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
if (layoutsToInsert.length > 0) await transaction.insert(schema.layouts).values(layoutsToInsert);
if (itemSectionLayoutsToInsert.length > 0) {
await transaction.insert(schema.itemLayouts).values(itemSectionLayoutsToInsert);
}
if (sectionLayoutsToInsert.length > 0) {
await transaction.insert(schema.sectionLayouts).values(sectionLayoutsToInsert);
}
for (const itemLayout of itemSectionLayoutsToUpdate) {
await transaction
.update(schema.itemLayouts)
.set({
height: itemLayout.height,
width: itemLayout.width,
xOffset: itemLayout.xOffset,
yOffset: itemLayout.yOffset,
sectionId: itemLayout.sectionId,
})
.where(
and(
eq(schema.itemLayouts.itemId, itemLayout.itemId),
eq(schema.itemLayouts.layoutId, itemLayout.layoutId),
),
);
}
for (const sectionLayout of sectionLayoutsToUpdate) {
await transaction
.update(schema.sectionLayouts)
.set({
height: sectionLayout.height,
width: sectionLayout.width,
xOffset: sectionLayout.xOffset,
yOffset: sectionLayout.yOffset,
parentSectionId: sectionLayout.parentSectionId,
})
.where(
and(
eq(schema.sectionLayouts.sectionId, sectionLayout.sectionId),
eq(schema.sectionLayouts.layoutId, sectionLayout.layoutId),
),
);
}
for (const layout of layoutsToUpdate) {
await transaction
.update(schema.layouts)
.set({
name: layout.name,
columnCount: layout.columnCount,
leftGutterColumnCount: layout.leftGutterColumnCount,
rightGutterColumnCount: layout.rightGutterColumnCount,
breakpoint: layout.breakpoint,
})
.where(eq(schema.layouts.id, layout.id));
}
if (removedLayoutIds.length > 0) {
await transaction.delete(schema.layouts).where(inArray(schema.layouts.id, removedLayoutIds));
}
});
},
handleSync(db) {
db.transaction((transaction) => {
if (layoutsToInsert.length > 0) transaction.insert(layouts).values(layoutsToInsert).run();
if (itemSectionLayoutsToInsert.length > 0) {
transaction.insert(itemLayouts).values(itemSectionLayoutsToInsert).run();
}
if (sectionLayoutsToInsert.length > 0) {
transaction.insert(sectionLayouts).values(sectionLayoutsToInsert).run();
}
for (const itemLayout of itemSectionLayoutsToUpdate) {
transaction
.update(itemLayouts)
.set({
height: itemLayout.height,
width: itemLayout.width,
xOffset: itemLayout.xOffset,
yOffset: itemLayout.yOffset,
sectionId: itemLayout.sectionId,
})
.where(and(eq(itemLayouts.itemId, itemLayout.itemId), eq(itemLayouts.layoutId, itemLayout.layoutId)))
.run();
}
for (const sectionLayout of sectionLayoutsToUpdate) {
transaction
.update(sectionLayouts)
.set({
height: sectionLayout.height,
width: sectionLayout.width,
xOffset: sectionLayout.xOffset,
yOffset: sectionLayout.yOffset,
parentSectionId: sectionLayout.parentSectionId,
})
.where(
and(
eq(sectionLayouts.sectionId, sectionLayout.sectionId),
eq(sectionLayouts.layoutId, sectionLayout.layoutId),
),
)
.run();
}
for (const layout of layoutsToUpdate) {
transaction
.update(layouts)
.set({
name: layout.name,
columnCount: layout.columnCount,
leftGutterColumnCount: layout.leftGutterColumnCount,
rightGutterColumnCount: layout.rightGutterColumnCount,
breakpoint: layout.breakpoint,
})
.where(eq(layouts.id, layout.id))
.run();
}
if (removedLayoutIds.length > 0)
transaction.delete(layouts).where(inArray(layouts.id, removedLayoutIds)).run();
});
},
});
layoutsToInsert.push({
id: layoutId,
name: addedLayout.name,
columnCount: addedLayout.columnCount,
leftGutterColumnCount: addedLayout.leftGutterColumnCount,
rightGutterColumnCount: addedLayout.rightGutterColumnCount,
breakpoint: addedLayout.breakpoint,
boardId: board.id,
});
return input.layouts
.map((layout) => ({
...layout,
id: savedLayoutIds.get(layout.id) ?? layout.id,
role: existingLayoutsById.get(layout.id)?.role ?? "custom",
}))
.toSorted((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint);
}),
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)",
},
})
.input(boardResetLayoutSchema)
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
itemSectionLayoutsToInsert.push(...updatedBoardLayout.itemSectionLayouts);
sectionLayoutsToInsert.push(...updatedBoardLayout.sectionLayouts);
}
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 (layoutsToInsert.length > 0) {
await ctx.db.insert(layouts).values(layoutsToInsert);
}
if (itemSectionLayoutsToInsert.length > 0) {
await ctx.db.insert(itemLayouts).values(itemSectionLayoutsToInsert);
}
if (sectionLayoutsToInsert.length > 0) {
await ctx.db.insert(sectionLayouts).values(sectionLayoutsToInsert);
}
const updatedLayouts = filterUpdatedItems(input.layouts, board.layouts);
for (const updatedLayout of updatedLayouts) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const dbLayout = board.layouts.find((layout) => layout.id === updatedLayout.id)!;
let updatedBoardLayout: ReturnType<typeof getUpdatedBoardLayout> | undefined;
if (
dbLayout.columnCount !== updatedLayout.columnCount ||
dbLayout.leftGutterColumnCount !== updatedLayout.leftGutterColumnCount ||
dbLayout.rightGutterColumnCount !== updatedLayout.rightGutterColumnCount
) {
updatedBoardLayout = getUpdatedBoardLayout(board, {
previous: {
layoutId: dbLayout.id,
columnCount: dbLayout.columnCount,
leftGutterColumnCount: dbLayout.leftGutterColumnCount,
rightGutterColumnCount: dbLayout.rightGutterColumnCount,
},
current: {
layoutId: dbLayout.id,
columnCount: updatedLayout.columnCount,
leftGutterColumnCount: updatedLayout.leftGutterColumnCount,
rightGutterColumnCount: updatedLayout.rightGutterColumnCount,
},
});
for (const itemSectionLayout of updatedBoardLayout.itemSectionLayouts) {
await ctx.db
.update(itemLayouts)
.set({
height: itemSectionLayout.height,
width: itemSectionLayout.width,
xOffset: itemSectionLayout.xOffset,
yOffset: itemSectionLayout.yOffset,
sectionId: itemSectionLayout.sectionId,
})
.where(
and(
eq(itemLayouts.itemId, itemSectionLayout.itemId),
eq(itemLayouts.layoutId, itemSectionLayout.layoutId),
),
);
}
for (const sectionLayout of updatedBoardLayout.sectionLayouts) {
await ctx.db
.update(sectionLayouts)
.set({
height: sectionLayout.height,
width: sectionLayout.width,
xOffset: sectionLayout.xOffset,
yOffset: sectionLayout.yOffset,
parentSectionId: sectionLayout.parentSectionId,
})
.where(
and(
eq(sectionLayouts.sectionId, sectionLayout.sectionId),
eq(sectionLayouts.layoutId, sectionLayout.layoutId),
),
);
}
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" });
}
await ctx.db
.update(layouts)
.set({
name: updatedLayout.name,
columnCount: updatedLayout.columnCount,
leftGutterColumnCount: updatedLayout.leftGutterColumnCount,
rightGutterColumnCount: updatedLayout.rightGutterColumnCount,
breakpoint: updatedLayout.breakpoint,
})
.where(eq(layouts.id, updatedLayout.id));
}
const projectedLayout = getUpdatedBoardLayout(board, {
previous: {
layoutId: baseLayout.id,
columnCount: baseLayout.columnCount,
leftGutterColumnCount: baseLayout.leftGutterColumnCount,
rightGutterColumnCount: baseLayout.rightGutterColumnCount,
},
current: {
layoutId: targetLayout.id,
columnCount: targetLayout.columnCount,
leftGutterColumnCount: targetLayout.leftGutterColumnCount,
rightGutterColumnCount: targetLayout.rightGutterColumnCount,
},
});
const removedLayouts = filterRemovedItems(input.layouts, board.layouts);
const removedLayoutIds = removedLayouts.map((layout) => layout.id);
if (removedLayoutIds.length > 0) {
await ctx.db.delete(layouts).where(inArray(layouts.id, removedLayoutIds));
}
}),
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 },
@@ -1556,13 +1840,14 @@ export const boardRouter = createTRPCRouter({
}
const defaultSize = widgetDefaultSizes[input.kind as WidgetKind] ?? { width: 1, height: 1 };
const sizeForLayout = { ...defaultSize, width: Math.min(layout.columnCount, defaultSize.width) };
const fitsAt = (x: number, y: number) => {
if (x + defaultSize.width > layout.columnCount) return false;
for (let dy = 0; dy < defaultSize.height; dy++) {
if (x + sizeForLayout.width > layout.columnCount) return false;
for (let dy = 0; dy < sizeForLayout.height; dy++) {
const row = occupied[y + dy];
if (!row) continue;
for (let dx = 0; dx < defaultSize.width; dx++) {
for (let dx = 0; dx < sizeForLayout.width; dx++) {
if (row[x + dx]) return false;
}
}
@@ -1580,8 +1865,8 @@ export const boardRouter = createTRPCRouter({
layoutId: layout.id,
xOffset: x,
yOffset: y,
width: defaultSize.width,
height: defaultSize.height,
width: sizeForLayout.width,
height: sizeForLayout.height,
});
placed = true;
}
@@ -1691,14 +1976,43 @@ const noBoardWithSimilarNameAsync = async (db: Database, name: string, ignoredId
}
};
interface BoardForLayoutProjection {
id: string;
items: Array<{
id: string;
layouts: Array<{
layoutId: string;
sectionId: string;
width: number;
height: number;
xOffset: number;
yOffset: number;
}>;
}>;
sections: Array<{
id: string;
kind: string;
xOffset?: number | null;
yOffset?: number | null;
layouts?: Array<{
layoutId: string;
parentSectionId: string | null;
width: number;
height: number;
xOffset: number;
yOffset: number;
}>;
}>;
}
const getUpdatedBoardLayout = (
board: Awaited<ReturnType<typeof getFullBoardWithWhereAsync>>,
board: BoardForLayoutProjection,
options: {
previous: BoardLayoutGeometry;
previous: BoardLayoutGeometry & { elements?: GridAlgorithmItem[] };
current: BoardLayoutGeometry;
},
) => {
const elements = getElementsForLayout(board, options.previous.layoutId);
const elements = options.previous.elements ?? getElementsForLayout(board, options.previous.layoutId);
const emptyRoots = board.sections
.filter((section) => section.kind === "empty")
.toSorted((first, second) => (first.yOffset ?? 0) - (second.yOffset ?? 0) || first.id.localeCompare(second.id));
@@ -1734,7 +2048,12 @@ const getUpdatedBoardLayout = (
const root = rootByLane.get(lane);
const width = getBoardLaneColumnCount(options.current, lane);
if (!root || width === 0) return [];
const sourceLanes = boardLanes.filter((sourceLane) => targetLaneBySourceLane.get(sourceLane) === lane);
const sourceLanes = boardLanes.filter(
(sourceLane) =>
rootByLane.has(sourceLane) &&
getBoardLaneColumnCount(options.previous, sourceLane) > 0 &&
targetLaneBySourceLane.get(sourceLane) === lane,
);
const previousWidth =
sourceLanes.length === 1
? getBoardLaneColumnCount(options.previous, sourceLanes[0] ?? "main")
@@ -1793,6 +2112,35 @@ const getUpdatedBoardLayout = (
};
};
const getElementsForProjectedLayout = (
projectedLayout: ReturnType<typeof getUpdatedBoardLayout>,
): 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,
},
]
: [],
),
];
interface BoardLayoutGeometry {
layoutId: string;
columnCount: number;
@@ -1873,43 +2221,54 @@ const ensureGutterRootSectionsAsync = async (db: Database, boardId: string, lane
});
};
const getElementsForLayout = (board: Awaited<ReturnType<typeof getFullBoardWithWhereAsync>>, layoutId: string) => {
const getElementsForLayout = (board: BoardForLayoutProjection, layoutId: string) => {
const sectionElements = board.sections
.filter((section) => section.kind === "container")
.map((section) => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const clonedLayout = section.layouts.find((sectionLayout) => sectionLayout.layoutId === layoutId)!;
.flatMap((section) => {
const clonedLayout = section.layouts?.find((sectionLayout) => sectionLayout.layoutId === layoutId);
if (!clonedLayout?.parentSectionId) return [];
return {
id: section.id,
type: "section" as const,
return [
{
id: section.id,
type: "section" as const,
height: clonedLayout.height,
width: clonedLayout.width,
xOffset: clonedLayout.xOffset,
yOffset: clonedLayout.yOffset,
sectionId: clonedLayout.parentSectionId,
},
];
});
const itemElements = board.items.flatMap((item) => {
const clonedLayout = item.layouts.find((itemLayout) => itemLayout.layoutId === layoutId);
if (!clonedLayout) return [];
return [
{
id: item.id,
type: "item" as const,
height: clonedLayout.height,
width: clonedLayout.width,
xOffset: clonedLayout.xOffset,
yOffset: clonedLayout.yOffset,
sectionId: clonedLayout.parentSectionId,
};
});
const itemElements = board.items.map((item) => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const clonedLayout = item.layouts.find((itemLayout) => itemLayout.layoutId === layoutId)!;
return {
id: item.id,
type: "item" as const,
height: clonedLayout.height,
width: clonedLayout.width,
xOffset: clonedLayout.xOffset,
yOffset: clonedLayout.yOffset,
sectionId: clonedLayout.sectionId,
};
sectionId: clonedLayout.sectionId,
},
];
});
return [...itemElements, ...sectionElements];
};
const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, userId: string | null) => {
const protectedLayoutRepairPromises = new Map<string, Promise<void>>();
const getFullBoardWithWhereAsync = async (
db: Database,
where: SQL<unknown>,
userId: string | null,
repairProtectedLayouts = true,
) => {
const groupPermissionWhere = userId
? inArray(
boardGroupPermissions.groupId,
@@ -1965,6 +2324,18 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, 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 {
@@ -2010,6 +2381,23 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, 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 = <T extends WidgetKind>(kind: T) =>
z.object({
kind: z.literal(kind),
+212 -24
View File
@@ -4,7 +4,7 @@ import { describe, expect, it, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import type { Database, InferInsertModel } from "@homarr/db";
import { and, eq, not } from "@homarr/db";
import { and, eq } from "@homarr/db";
import {
boardGroupPermissions,
boards,
@@ -325,10 +325,13 @@ describe("createBoard should create a new board", () => {
expect(firstSection?.xOffset).toBe(0);
expect(firstSection?.yOffset).toBe(0);
expect(dbBoard?.layouts.length).toBe(1);
const firstLayout = dbBoard?.layouts.at(0);
expect(firstLayout?.columnCount).toBe(24);
expect(firstLayout?.breakpoint).toBe(0);
expect(dbBoard?.layouts.length).toBe(2);
expect(dbBoard?.layouts).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: "Mobile", role: "mobile", columnCount: 3, breakpoint: 0 }),
expect.objectContaining({ name: "Base", role: "base", columnCount: 24, breakpoint: 768 }),
]),
);
});
test("should throw error when user has no board-create permission", async () => {
@@ -344,6 +347,36 @@ describe("createBoard should create a new board", () => {
});
});
describe("duplicateBoard should preserve protected layout invariants", () => {
test("normalizes a legacy single-layout board while preserving Base positions", async () => {
const db = createDb();
const session = {
...defaultSession,
user: {
...defaultSession.user,
permissions: ["board-create"] satisfies GroupPermissionKey[],
},
};
const caller = boardRouter.createCaller({ db, deviceType: undefined, session });
const { boardId } = await createFullBoardAsync(db, "source");
const result = await caller.duplicateBoard({ id: boardId, name: "copy" });
const copiedBoard = await db.query.boards.findFirst({
where: eq(boards.id, result.boardId),
with: { layouts: true, items: { with: { layouts: true } } },
});
expect(copiedBoard?.layouts).toHaveLength(2);
expect(copiedBoard?.layouts).toEqual(
expect.arrayContaining([
expect.objectContaining({ role: "mobile", columnCount: 3, breakpoint: 0 }),
expect.objectContaining({ role: "base", columnCount: 10, breakpoint: 768 }),
]),
);
expect(copiedBoard?.items.at(0)?.layouts).toHaveLength(2);
});
});
describe("rename board should rename board", () => {
test("should rename board", async () => {
// Arrange
@@ -569,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");
});
@@ -1330,12 +1370,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 () => {
@@ -1522,7 +1562,17 @@ const createExistingLayout = (id: string) => ({
columnCount: 10,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
breakpoint: 768,
role: "base" as const,
});
const createMobileLayout = (id: string) => ({
id,
name: "Mobile",
columnCount: 3,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
breakpoint: 0,
role: "mobile" as const,
});
const createNewLayout = (columnCount: number) => ({
id: createId(),
@@ -1530,8 +1580,23 @@ const createNewLayout = (columnCount: number) => ({
columnCount,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
breakpoint: 1400,
breakpoint: 480,
role: "custom" as const,
});
const addMobileLayoutAsync = async (db: Database, boardId: string) => {
const mobileLayoutId = createId();
await db.insert(layouts).values({
id: mobileLayoutId,
name: "Mobile",
columnCount: 3,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
breakpoint: 0,
role: "mobile",
boardId,
});
return mobileLayoutId;
};
describe("saveLayouts should save layout changes", () => {
test("should add layout when not present in database", async () => {
// Arrange
@@ -1539,23 +1604,26 @@ describe("saveLayouts should save layout changes", () => {
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
const newLayout = createNewLayout(12);
// Act
await caller.saveLayouts({
const savedLayouts = await caller.saveLayouts({
id: boardId,
layouts: [createExistingLayout(layoutId), newLayout],
layouts: [createMobileLayout(mobileLayoutId), createExistingLayout(layoutId), newLayout],
});
// Assert
const layout = await db.query.layouts.findFirst({
where: not(eq(layouts.id, layoutId)),
where: eq(layouts.role, "custom"),
});
const definedLayout = expectToBeDefined(layout);
expect(definedLayout.name).toBe(newLayout.name);
expect(definedLayout.columnCount).toBe(newLayout.columnCount);
expect(definedLayout.breakpoint).toBe(newLayout.breakpoint);
expect(definedLayout.role).toBe("custom");
expect(savedLayouts.find((candidate) => candidate.role === "custom")?.id).toBe(definedLayout.id);
});
test("should add items and containers generated from grid-algorithm when new layout is added", async () => {
// Arrange
@@ -1563,6 +1631,7 @@ describe("saveLayouts should save layout changes", () => {
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 assignments = await createItemsAndSectionsAsync(db, {
boardId,
layoutId,
@@ -1573,12 +1642,12 @@ describe("saveLayouts should save layout changes", () => {
// Act
await caller.saveLayouts({
id: boardId,
layouts: [createExistingLayout(layoutId), newLayout],
layouts: [createMobileLayout(mobileLayoutId), createExistingLayout(layoutId), newLayout],
});
// Assert
const layout = await db.query.layouts.findFirst({
where: not(eq(layouts.id, layoutId)),
where: eq(layouts.role, "custom"),
});
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await expectLayoutForRootLayoutAsync(db, sectionId, layout!.id, {
@@ -1594,6 +1663,7 @@ describe("saveLayouts should save layout changes", () => {
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
const updatedLayout = createExistingLayout(layoutId);
updatedLayout.breakpoint = 1400;
updatedLayout.name = "Updated layout";
@@ -1601,7 +1671,7 @@ describe("saveLayouts should save layout changes", () => {
// Act
await caller.saveLayouts({
id: boardId,
layouts: [updatedLayout],
layouts: [createMobileLayout(mobileLayoutId), updatedLayout],
});
// Assert
@@ -1620,6 +1690,7 @@ describe("saveLayouts should save layout changes", () => {
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 assignments = await createItemsAndSectionsAsync(db, {
boardId,
layoutId,
@@ -1631,7 +1702,7 @@ describe("saveLayouts should save layout changes", () => {
// Act
await caller.saveLayouts({
id: boardId,
layouts: [updatedLayout],
layouts: [createMobileLayout(mobileLayoutId), updatedLayout],
});
// Assert
@@ -1645,6 +1716,7 @@ describe("saveLayouts should save layout changes", () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "gutters");
const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
await db
.update(itemLayouts)
@@ -1656,6 +1728,7 @@ describe("saveLayouts should save layout changes", () => {
await caller.saveLayouts({
id: boardId,
layouts: [
createMobileLayout(mobileLayoutId),
{
...createExistingLayout(layoutId),
leftGutterColumnCount: 2,
@@ -1684,7 +1757,7 @@ describe("saveLayouts should save layout changes", () => {
});
await caller.saveLayouts({
id: boardId,
layouts: [createExistingLayout(layoutId)],
layouts: [createMobileLayout(mobileLayoutId), createExistingLayout(layoutId)],
});
const returnedItem = expectToBeDefined(
@@ -1698,9 +1771,11 @@ describe("saveLayouts should save layout changes", () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId } = await createFullBoardAsync(db, "concurrent-gutters");
const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
const input = {
id: boardId,
layouts: [
createMobileLayout(mobileLayoutId),
{
...createExistingLayout(layoutId),
leftGutterColumnCount: 2,
@@ -1715,22 +1790,73 @@ describe("saveLayouts should save layout changes", () => {
});
expect(leftRoots).toHaveLength(1);
});
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();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
const customLayoutId = createId();
await db.insert(layouts).values({
id: customLayoutId,
name: "Custom",
columnCount: 6,
breakpoint: 480,
role: "custom",
boardId,
});
// Act
await caller.saveLayouts({
id: boardId,
layouts: [createNewLayout(12)],
layouts: [createMobileLayout(mobileLayoutId), createExistingLayout(layoutId)],
});
// Assert
const layout = await db.query.layouts.findFirst({
where: eq(layouts.id, layoutId),
where: eq(layouts.id, customLayoutId),
});
expect(layout).toBeUndefined();
});
@@ -1745,12 +1871,73 @@ describe("saveLayouts should save layout changes", () => {
const actAsync = async () =>
await caller.saveLayouts({
id: createId(),
layouts: [createExistingLayout(layoutId)],
layouts: [createMobileLayout(createId()), createExistingLayout(layoutId)],
});
// Assert
await expect(actAsync()).rejects.toThrowError("Board not found");
});
test("should reject changing protected layout roles", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
const actAsync = async () =>
await caller.saveLayouts({
id: boardId,
layouts: [
{ ...createExistingLayout(layoutId), role: "mobile", breakpoint: 0 },
{ ...createMobileLayout(mobileLayoutId), role: "base", breakpoint: 768 },
],
});
await expect(actAsync()).rejects.toThrowError("Layout roles cannot be changed");
});
});
describe("resetLayout should rebuild a layout from Base", () => {
test("should replace target item positions and preserve layout metadata", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, sectionId, itemId } = await createFullBoardAsync(db, "default");
await addMobileLayoutAsync(db, boardId);
const customLayoutId = createId();
await db.insert(layouts).values({
id: customLayoutId,
name: "Tablet",
columnCount: 6,
breakpoint: 480,
role: "custom",
boardId,
});
await db.insert(itemLayouts).values({
itemId,
sectionId,
layoutId: customLayoutId,
width: 2,
height: 2,
xOffset: 3,
yOffset: 4,
});
await caller.resetLayout({ boardId, layoutId: customLayoutId });
const resetPosition = await db.query.itemLayouts.findFirst({
where: and(eq(itemLayouts.itemId, itemId), eq(itemLayouts.layoutId, customLayoutId)),
});
expect(resetPosition).toMatchObject({ width: 1, height: 1, xOffset: 0, yOffset: 0, sectionId });
const customLayout = await db.query.layouts.findFirst({ where: eq(layouts.id, customLayoutId) });
expect(customLayout).toMatchObject({ name: "Tablet", columnCount: 6, breakpoint: 480, role: "custom" });
});
test("should reject resetting Base", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
await expect(caller.resetLayout({ boardId, layoutId })).rejects.toThrowError("Base layout cannot be reset");
});
});
describe("Custom Widget placement permissions", () => {
@@ -1848,7 +2035,8 @@ const createFullBoardAsync = async (db: Database, name: string) => {
id: layoutId,
name: "Base",
columnCount: 10,
breakpoint: 0,
breakpoint: 768,
role: "base",
boardId,
});