feat: protect responsive board layouts
This commit is contained in:
1 parent
fa9444323b
commit
6728228387
46 files changed
+8933
-477
No files matched your search
+397
-173
@@ -2,10 +2,10 @@ 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 { 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";
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
everyoneGroup,
|
||||
getPermissionsWithChildren,
|
||||
getPermissionsWithParents,
|
||||
normalizeBoardLayoutRoles,
|
||||
widgetDefaultSizes,
|
||||
widgetKinds,
|
||||
} from "@homarr/definitions";
|
||||
@@ -48,6 +49,7 @@ import {
|
||||
boardCreateSchema,
|
||||
boardDuplicateSchema,
|
||||
boardRenameSchema,
|
||||
boardResetLayoutSchema,
|
||||
boardSaveLayoutsSchema,
|
||||
boardSavePartialSettingsSchema,
|
||||
boardSavePermissionsSchema,
|
||||
@@ -61,7 +63,6 @@ import { sectionSchema, sharedItemSchema } from "@homarr/validation/shared";
|
||||
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "./board/board-access";
|
||||
import { generateResponsiveGridFor } from "./board/grid-algorithm";
|
||||
|
||||
export const boardRouter = createTRPCRouter({
|
||||
exists: permissionRequiredProcedure
|
||||
@@ -313,13 +314,24 @@ export const boardRouter = createTRPCRouter({
|
||||
yOffset: 0,
|
||||
boardId,
|
||||
});
|
||||
createBoardCollection.layouts.push({
|
||||
id: createId(),
|
||||
name: "Base",
|
||||
columnCount: input.columnCount,
|
||||
breakpoint: 0,
|
||||
boardId,
|
||||
});
|
||||
createBoardCollection.layouts.push(
|
||||
{
|
||||
id: createId(),
|
||||
name: "Mobile",
|
||||
columnCount: 3,
|
||||
breakpoint: 0,
|
||||
role: "mobile",
|
||||
boardId,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
name: "Base",
|
||||
columnCount: input.columnCount,
|
||||
breakpoint: 768,
|
||||
role: "base",
|
||||
boardId,
|
||||
},
|
||||
);
|
||||
|
||||
await createBoardCollection.insertAllAsync(ctx.db);
|
||||
|
||||
@@ -375,8 +387,49 @@ 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 },
|
||||
current: { layoutId: generatedMobileLayoutId, columnCount: 3 },
|
||||
})
|
||||
: null;
|
||||
const normalizedBoardLayouts =
|
||||
boardLayouts.length === 0
|
||||
? [
|
||||
{
|
||||
id: generatedMobileLayoutId,
|
||||
name: "Mobile",
|
||||
columnCount: 3,
|
||||
breakpoint: 0,
|
||||
role: "mobile" as const,
|
||||
boardId: board.id,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
name: "Base",
|
||||
columnCount: 10,
|
||||
breakpoint: 768,
|
||||
role: "base" as const,
|
||||
boardId: board.id,
|
||||
},
|
||||
]
|
||||
: boardLayouts.length === 1 && boardLayouts[0]
|
||||
? [
|
||||
{
|
||||
id: generatedMobileLayoutId,
|
||||
name: "Mobile",
|
||||
columnCount: 3,
|
||||
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)!,
|
||||
@@ -393,9 +446,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)!,
|
||||
@@ -404,10 +472,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(
|
||||
@@ -429,9 +496,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)!,
|
||||
@@ -439,9 +519,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");
|
||||
@@ -682,130 +761,244 @@ 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 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);
|
||||
|
||||
const layoutsToInsert: InferInsertModel<typeof layouts>[] = [];
|
||||
const itemSectionLayoutsToInsert: InferInsertModel<typeof itemLayouts>[] = [];
|
||||
const sectionLayoutsToInsert: InferInsertModel<typeof sectionLayouts>[] = [];
|
||||
|
||||
for (const addedLayout of addedLayouts) {
|
||||
const layoutId = createId();
|
||||
|
||||
layoutsToInsert.push({
|
||||
id: layoutId,
|
||||
name: addedLayout.name,
|
||||
columnCount: addedLayout.columnCount,
|
||||
breakpoint: addedLayout.breakpoint,
|
||||
boardId: board.id,
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
current: {
|
||||
layoutId,
|
||||
columnCount: addedLayout.columnCount,
|
||||
},
|
||||
});
|
||||
|
||||
itemSectionLayoutsToInsert.push(...updatedBoardLayout.itemSectionLayouts);
|
||||
sectionLayoutsToInsert.push(...updatedBoardLayout.sectionLayouts);
|
||||
}
|
||||
|
||||
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)!;
|
||||
|
||||
if (dbLayout.columnCount !== updatedLayout.columnCount) {
|
||||
const updatedBoardLayout = getUpdatedBoardLayout(board, {
|
||||
previous: {
|
||||
layoutId: dbLayout.id,
|
||||
columnCount: dbLayout.columnCount,
|
||||
},
|
||||
current: {
|
||||
layoutId: dbLayout.id,
|
||||
columnCount: updatedLayout.columnCount,
|
||||
},
|
||||
});
|
||||
|
||||
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 (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" });
|
||||
}
|
||||
|
||||
await ctx.db
|
||||
.update(layouts)
|
||||
.set({
|
||||
name: updatedLayout.name,
|
||||
columnCount: updatedLayout.columnCount,
|
||||
breakpoint: updatedLayout.breakpoint,
|
||||
})
|
||||
.where(eq(layouts.id, updatedLayout.id));
|
||||
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" });
|
||||
}
|
||||
|
||||
const layoutsToInsert: InferInsertModel<typeof layouts>[] = [];
|
||||
const itemSectionLayoutsToInsert: InferInsertModel<typeof itemLayouts>[] = [];
|
||||
const sectionLayoutsToInsert: InferInsertModel<typeof sectionLayouts>[] = [];
|
||||
const savedLayoutIds = new Map<string, string>();
|
||||
|
||||
for (const addedLayout of addedLayouts) {
|
||||
const layoutId = createId();
|
||||
savedLayoutIds.set(addedLayout.id, layoutId);
|
||||
layoutsToInsert.push({
|
||||
id: layoutId,
|
||||
name: addedLayout.name,
|
||||
columnCount: addedLayout.columnCount,
|
||||
breakpoint: addedLayout.breakpoint,
|
||||
role: "custom",
|
||||
boardId: board.id,
|
||||
});
|
||||
|
||||
const projectedLayout = getUpdatedBoardLayout(board, {
|
||||
previous: { layoutId: baseLayout.id, columnCount: baseLayout.columnCount },
|
||||
current: { layoutId, columnCount: addedLayout.columnCount },
|
||||
});
|
||||
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) continue;
|
||||
|
||||
const projectedLayout = getUpdatedBoardLayout(board, {
|
||||
previous: { layoutId: dbLayout.id, columnCount: dbLayout.columnCount },
|
||||
current: { layoutId: dbLayout.id, columnCount: updatedLayout.columnCount },
|
||||
});
|
||||
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, 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, breakpoint: layout.breakpoint })
|
||||
.where(eq(layouts.id, layout.id))
|
||||
.run();
|
||||
}
|
||||
if (removedLayoutIds.length > 0)
|
||||
transaction.delete(layouts).where(inArray(layouts.id, removedLayoutIds)).run();
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
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.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 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));
|
||||
}
|
||||
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({
|
||||
@@ -1504,13 +1697,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;
|
||||
}
|
||||
}
|
||||
@@ -1528,8 +1722,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;
|
||||
}
|
||||
@@ -1639,8 +1833,34 @@ const noBoardWithSimilarNameAsync = async (db: Database, name: string, ignoredId
|
||||
}
|
||||
};
|
||||
|
||||
interface BoardForLayoutProjection {
|
||||
items: Array<{
|
||||
id: string;
|
||||
layouts: Array<{
|
||||
layoutId: string;
|
||||
sectionId: string;
|
||||
width: number;
|
||||
height: number;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
}>;
|
||||
}>;
|
||||
sections: Array<{
|
||||
id: string;
|
||||
kind: string;
|
||||
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: {
|
||||
layoutId: string;
|
||||
@@ -1720,37 +1940,41 @@ const getUpdatedBoardLayout = (
|
||||
};
|
||||
};
|
||||
|
||||
const getElementsForLayout = (board: Awaited<ReturnType<typeof getFullBoardWithWhereAsync>>, layoutId: string) => {
|
||||
const getElementsForLayout = (board: BoardForLayoutProjection, layoutId: string) => {
|
||||
const sectionElements = board.sections
|
||||
.filter((section) => section.kind === "dynamic")
|
||||
.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];
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
export interface GridAlgorithmItem {
|
||||
id: string;
|
||||
type: "item" | "section";
|
||||
width: number;
|
||||
height: number;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
sectionId: string;
|
||||
}
|
||||
|
||||
interface GridAlgorithmInput {
|
||||
items: GridAlgorithmItem[];
|
||||
width: number;
|
||||
previousWidth: number;
|
||||
sectionId: string;
|
||||
}
|
||||
|
||||
interface GridAlgorithmOutput {
|
||||
height: number;
|
||||
items: GridAlgorithmItem[];
|
||||
}
|
||||
|
||||
export const generateResponsiveGridFor = ({
|
||||
items,
|
||||
previousWidth,
|
||||
width,
|
||||
sectionId,
|
||||
}: GridAlgorithmInput): GridAlgorithmOutput => {
|
||||
const itemsOfCurrentSection = items
|
||||
.filter((item) => item.sectionId === sectionId)
|
||||
.sort((itemA, itemB) =>
|
||||
itemA.yOffset === itemB.yOffset ? itemA.xOffset - itemB.xOffset : itemA.yOffset - itemB.yOffset,
|
||||
);
|
||||
const normalizedItems = normalizeItems(itemsOfCurrentSection, width);
|
||||
|
||||
if (itemsOfCurrentSection.length === 0) {
|
||||
return {
|
||||
height: 0,
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
|
||||
const newItems: GridAlgorithmItem[] = [];
|
||||
|
||||
// Fix height of dynamic sections
|
||||
const dynamicSectionHeightMap = new Map<string, number>();
|
||||
const dynamicSectionsOfCurrentSection = normalizedItems.filter((item) => item.type === "section");
|
||||
for (const dynamicSection of dynamicSectionsOfCurrentSection) {
|
||||
const result = generateResponsiveGridFor({
|
||||
items,
|
||||
previousWidth: dynamicSection.previousWidth,
|
||||
width: dynamicSection.width,
|
||||
sectionId: dynamicSection.id,
|
||||
});
|
||||
newItems.push(...result.items);
|
||||
dynamicSectionHeightMap.set(dynamicSection.id, result.height);
|
||||
}
|
||||
|
||||
// Return same positions for items in the current section
|
||||
if (width >= previousWidth) {
|
||||
return {
|
||||
height: Math.max(...itemsOfCurrentSection.map((item) => item.yOffset + item.height)),
|
||||
items: newItems.concat(normalizedItems),
|
||||
};
|
||||
}
|
||||
|
||||
const occupied2d: boolean[][] = [];
|
||||
|
||||
for (const item of normalizedItems) {
|
||||
const itemWithHeight = {
|
||||
...item,
|
||||
height: item.type === "section" ? Math.max(dynamicSectionHeightMap.get(item.id) ?? 1, item.height) : item.height,
|
||||
};
|
||||
const position = nextFreeSpot(occupied2d, itemWithHeight, width);
|
||||
if (!position) throw new Error("No free spot available");
|
||||
|
||||
addItemToOccupied(occupied2d, itemWithHeight, position, width);
|
||||
newItems.push({
|
||||
...itemWithHeight,
|
||||
xOffset: position.x,
|
||||
yOffset: position.y,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
height: occupied2d.length,
|
||||
items: newItems,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Reduces the width of the items to fit the new column count.
|
||||
* @param items items to normalize
|
||||
* @param columnCount new column count
|
||||
*/
|
||||
const normalizeItems = (items: GridAlgorithmItem[], columnCount: number) => {
|
||||
return items.map((item) => ({ ...item, previousWidth: item.width, width: Math.min(columnCount, item.width) }));
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds the item to the occupied spots.
|
||||
* @param occupied2d array of occupied spots
|
||||
* @param item item to place
|
||||
* @param position position to place the item
|
||||
*/
|
||||
const addItemToOccupied = (
|
||||
occupied2d: boolean[][],
|
||||
item: GridAlgorithmItem,
|
||||
position: { x: number; y: number },
|
||||
columnCount: number,
|
||||
) => {
|
||||
for (let yOffset = 0; yOffset < item.height; yOffset++) {
|
||||
let row = occupied2d[position.y + yOffset];
|
||||
if (!row) {
|
||||
addRow(occupied2d, columnCount);
|
||||
// After adding it, it must exist
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
row = occupied2d[position.y + yOffset]!;
|
||||
}
|
||||
|
||||
for (let xOffset = 0; xOffset < item.width; xOffset++) {
|
||||
row[position.x + xOffset] = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds a new row to the grid.
|
||||
* @param occupied2d array of occupied spots
|
||||
* @param columnCount column count of section
|
||||
*/
|
||||
const addRow = (occupied2d: boolean[][], columnCount: number) => {
|
||||
occupied2d.push(new Array<boolean>(columnCount).fill(false));
|
||||
};
|
||||
|
||||
/**
|
||||
* Searches for the next free spot in the grid.
|
||||
* @param occupied2d array of occupied spots
|
||||
* @param item item to place
|
||||
* @param columnCount column count of section
|
||||
* @returns the position of the next free spot or null if no spot is available
|
||||
*/
|
||||
const nextFreeSpot = (occupied2d: boolean[][], item: GridAlgorithmItem, columnCount: number) => {
|
||||
for (let offsetY = 0; offsetY < 99999; offsetY++) {
|
||||
for (let offsetX = 0; offsetX < columnCount; offsetX++) {
|
||||
if (hasHorizontalSpace(columnCount, item, offsetX) && isFree(occupied2d, item, { x: offsetX, y: offsetY })) {
|
||||
return { x: offsetX, y: offsetY };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the item fits into the grid horizontally.
|
||||
* @param columnCount available width
|
||||
* @param item item to place
|
||||
* @param offsetX current x position
|
||||
* @returns true if the item fits horizontally
|
||||
*/
|
||||
const hasHorizontalSpace = (columnCount: number, item: GridAlgorithmItem, offsetX: number) => {
|
||||
return offsetX + item.width <= columnCount;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the spot is free.
|
||||
* @param occupied2d array of occupied spots
|
||||
* @param item item to place
|
||||
* @param position position to check
|
||||
* @returns true if the spot is free
|
||||
*/
|
||||
const isFree = (occupied2d: boolean[][], item: GridAlgorithmItem, position: { x: number; y: number }) => {
|
||||
for (let yOffset = 0; yOffset < item.height; yOffset++) {
|
||||
const row = occupied2d[position.y + yOffset];
|
||||
if (!row) return true; // Empty row is free
|
||||
|
||||
for (let xOffset = 0; xOffset < item.width; xOffset++) {
|
||||
if (row[position.x + xOffset]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { GridAlgorithmItem } from "../grid-algorithm";
|
||||
import { generateResponsiveGridFor } from "../grid-algorithm";
|
||||
import { generateResponsiveGridFor } from "@homarr/common";
|
||||
import type { GridAlgorithmItem } from "@homarr/common";
|
||||
|
||||
const ROOT_SECTION_ID = "section";
|
||||
|
||||
|
||||
@@ -294,9 +294,7 @@ export const onboardRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
const section = board.sections.find((boardSection) => boardSection.kind === "empty");
|
||||
const layout = board.layouts[0];
|
||||
|
||||
if (section && layout) {
|
||||
if (section && board.layouts.length > 0) {
|
||||
const existingItems = await db.query.items.findMany({
|
||||
where: eq(items.boardId, board.id),
|
||||
});
|
||||
@@ -310,12 +308,12 @@ export const onboardRouter = createTRPCRouter({
|
||||
|
||||
await placeAllWidgetsAsync(
|
||||
db,
|
||||
{
|
||||
board.layouts.map((layout) => ({
|
||||
boardId: board.id,
|
||||
sectionId: section.id,
|
||||
layoutId: layout.id,
|
||||
columnCount: layout.columnCount,
|
||||
},
|
||||
})),
|
||||
allIntegrations,
|
||||
allApps,
|
||||
);
|
||||
|
||||
@@ -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
|
||||
@@ -1328,14 +1361,35 @@ const createExistingLayout = (id: string) => ({
|
||||
id,
|
||||
name: "Base",
|
||||
columnCount: 10,
|
||||
breakpoint: 768,
|
||||
role: "base" as const,
|
||||
});
|
||||
const createMobileLayout = (id: string) => ({
|
||||
id,
|
||||
name: "Mobile",
|
||||
columnCount: 3,
|
||||
breakpoint: 0,
|
||||
role: "mobile" as const,
|
||||
});
|
||||
const createNewLayout = (columnCount: number) => ({
|
||||
id: createId(),
|
||||
name: "New layout",
|
||||
columnCount,
|
||||
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,
|
||||
breakpoint: 0,
|
||||
role: "mobile",
|
||||
boardId,
|
||||
});
|
||||
return mobileLayoutId;
|
||||
};
|
||||
describe("saveLayouts should save layout changes", () => {
|
||||
test("should add layout when not present in database", async () => {
|
||||
// Arrange
|
||||
@@ -1343,23 +1397,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 dynamic sections generated from grid-algorithm when new layout is added", async () => {
|
||||
// Arrange
|
||||
@@ -1367,6 +1424,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,
|
||||
@@ -1377,12 +1435,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, {
|
||||
@@ -1398,6 +1456,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";
|
||||
@@ -1405,7 +1464,7 @@ describe("saveLayouts should save layout changes", () => {
|
||||
// Act
|
||||
await caller.saveLayouts({
|
||||
id: boardId,
|
||||
layouts: [updatedLayout],
|
||||
layouts: [createMobileLayout(mobileLayoutId), updatedLayout],
|
||||
});
|
||||
|
||||
// Assert
|
||||
@@ -1424,6 +1483,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,
|
||||
@@ -1435,7 +1495,7 @@ describe("saveLayouts should save layout changes", () => {
|
||||
// Act
|
||||
await caller.saveLayouts({
|
||||
id: boardId,
|
||||
layouts: [updatedLayout],
|
||||
layouts: [createMobileLayout(mobileLayoutId), updatedLayout],
|
||||
});
|
||||
|
||||
// Assert
|
||||
@@ -1451,16 +1511,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 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();
|
||||
});
|
||||
@@ -1475,12 +1545,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");
|
||||
});
|
||||
});
|
||||
|
||||
const expectInputToBeFullBoardWithName = (
|
||||
@@ -1521,7 +1652,8 @@ const createFullBoardAsync = async (db: Database, name: string) => {
|
||||
id: layoutId,
|
||||
name: "Base",
|
||||
columnCount: 10,
|
||||
breakpoint: 0,
|
||||
breakpoint: 768,
|
||||
role: "base",
|
||||
boardId,
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user