feat: add automatic mobile board layout
This commit is contained in:
1 parent
08588da0d1
commit
d63653142d
15 files changed
+1498
-323
No files matched your search
+579
-191
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user