feat: rebuild board grid experience
This commit is contained in:
1 parent
5dc4dbeaef
commit
0110a2ef63
122 files changed
+9582
-4063
No files matched your search
@@ -29,12 +29,16 @@ import {
|
||||
sections,
|
||||
users,
|
||||
} from "@homarr/db/schema";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import type { BoardLane, WidgetKind } from "@homarr/definitions";
|
||||
import {
|
||||
boardLanes,
|
||||
emptySuperJSON,
|
||||
everyoneGroup,
|
||||
getBoardLaneColumnCount,
|
||||
getRootSectionLane,
|
||||
getPermissionsWithChildren,
|
||||
getPermissionsWithParents,
|
||||
rootSectionOffsets,
|
||||
widgetDefaultSizes,
|
||||
widgetKinds,
|
||||
} from "@homarr/definitions";
|
||||
@@ -321,6 +325,8 @@ export const boardRouter = createTRPCRouter({
|
||||
id: createId(),
|
||||
name: "Base",
|
||||
columnCount: input.columnCount,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 0,
|
||||
boardId,
|
||||
});
|
||||
@@ -690,7 +696,25 @@ export const boardRouter = createTRPCRouter({
|
||||
saveLayouts: protectedProcedure.input(boardSaveLayoutsSchema).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);
|
||||
let board = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
|
||||
const missingGutterRoots = (["left", "right"] as const).filter(
|
||||
(lane) =>
|
||||
input.layouts.some((layout) => getBoardLaneColumnCount(layout, lane) > 0) &&
|
||||
!getRootSectionForLane(board, lane),
|
||||
);
|
||||
if (missingGutterRoots.length > 0) {
|
||||
await ctx.db.insert(sections).values(
|
||||
missingGutterRoots.map((lane) => ({
|
||||
id: createId(),
|
||||
boardId: board.id,
|
||||
kind: "empty" as const,
|
||||
xOffset: rootSectionOffsets[lane],
|
||||
yOffset: 0,
|
||||
options: emptySuperJSON,
|
||||
})),
|
||||
);
|
||||
board = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
|
||||
}
|
||||
|
||||
const addedLayouts = filterAddedItems(input.layouts, board.layouts);
|
||||
|
||||
@@ -701,14 +725,6 @@ export const boardRouter = createTRPCRouter({
|
||||
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 =
|
||||
@@ -719,13 +735,27 @@ export const boardRouter = createTRPCRouter({
|
||||
previous: {
|
||||
layoutId: layoutToClone.id,
|
||||
columnCount: layoutToClone.columnCount,
|
||||
leftGutterColumnCount: layoutToClone.leftGutterColumnCount,
|
||||
rightGutterColumnCount: layoutToClone.rightGutterColumnCount,
|
||||
},
|
||||
current: {
|
||||
layoutId,
|
||||
columnCount: addedLayout.columnCount,
|
||||
leftGutterColumnCount: addedLayout.leftGutterColumnCount,
|
||||
rightGutterColumnCount: addedLayout.rightGutterColumnCount,
|
||||
},
|
||||
});
|
||||
|
||||
layoutsToInsert.push({
|
||||
id: layoutId,
|
||||
name: addedLayout.name,
|
||||
columnCount: addedLayout.columnCount,
|
||||
leftGutterColumnCount: addedLayout.leftGutterColumnCount,
|
||||
rightGutterColumnCount: addedLayout.rightGutterColumnCount,
|
||||
breakpoint: addedLayout.breakpoint,
|
||||
boardId: board.id,
|
||||
});
|
||||
|
||||
itemSectionLayoutsToInsert.push(...updatedBoardLayout.itemSectionLayouts);
|
||||
sectionLayoutsToInsert.push(...updatedBoardLayout.sectionLayouts);
|
||||
}
|
||||
@@ -746,16 +776,25 @@ export const boardRouter = createTRPCRouter({
|
||||
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) {
|
||||
const updatedBoardLayout = getUpdatedBoardLayout(board, {
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -801,6 +840,8 @@ export const boardRouter = createTRPCRouter({
|
||||
.set({
|
||||
name: updatedLayout.name,
|
||||
columnCount: updatedLayout.columnCount,
|
||||
leftGutterColumnCount: updatedLayout.leftGutterColumnCount,
|
||||
rightGutterColumnCount: updatedLayout.rightGutterColumnCount,
|
||||
breakpoint: updatedLayout.breakpoint,
|
||||
})
|
||||
.where(eq(layouts.id, updatedLayout.id));
|
||||
@@ -877,8 +918,8 @@ export const boardRouter = createTRPCRouter({
|
||||
id: section.id,
|
||||
kind: section.kind,
|
||||
yOffset: section.kind !== "dynamic" ? section.yOffset : null,
|
||||
xOffset: section.kind === "dynamic" ? null : 0,
|
||||
options: section.kind === "dynamic" ? superjson.stringify(section.options) : emptySuperJSON,
|
||||
xOffset: section.kind === "dynamic" ? null : section.xOffset,
|
||||
options: section.kind === "empty" ? emptySuperJSON : superjson.stringify(section.options),
|
||||
name: "name" in section ? section.name : null,
|
||||
boardId: dbBoard.id,
|
||||
})),
|
||||
@@ -1003,8 +1044,8 @@ export const boardRouter = createTRPCRouter({
|
||||
.update(schema.sections)
|
||||
.set({
|
||||
yOffset: prev?.kind !== "dynamic" && "yOffset" in section ? section.yOffset : null,
|
||||
xOffset: prev?.kind !== "dynamic" && "yOffset" in section ? 0 : null,
|
||||
options: section.kind === "dynamic" ? superjson.stringify(section.options) : emptySuperJSON,
|
||||
xOffset: prev?.kind !== "dynamic" && "xOffset" in section ? section.xOffset : null,
|
||||
options: section.kind === "empty" ? emptySuperJSON : superjson.stringify(section.options),
|
||||
name: prev?.kind === "category" && "name" in section ? section.name : null,
|
||||
})
|
||||
.where(eq(schema.sections.id, section.id));
|
||||
@@ -1077,8 +1118,8 @@ export const boardRouter = createTRPCRouter({
|
||||
id: section.id,
|
||||
kind: section.kind,
|
||||
yOffset: section.kind !== "dynamic" ? section.yOffset : null,
|
||||
xOffset: section.kind === "dynamic" ? null : 0,
|
||||
options: section.kind === "dynamic" ? superjson.stringify(section.options) : emptySuperJSON,
|
||||
xOffset: section.kind === "dynamic" ? null : section.xOffset,
|
||||
options: section.kind === "empty" ? emptySuperJSON : superjson.stringify(section.options),
|
||||
name: "name" in section ? section.name : null,
|
||||
boardId: dbBoard.id,
|
||||
})),
|
||||
@@ -1213,8 +1254,8 @@ export const boardRouter = createTRPCRouter({
|
||||
.update(sections)
|
||||
.set({
|
||||
yOffset: prev?.kind !== "dynamic" && "yOffset" in section ? section.yOffset : null,
|
||||
xOffset: prev?.kind !== "dynamic" && "yOffset" in section ? 0 : null,
|
||||
options: section.kind === "dynamic" ? superjson.stringify(section.options) : emptySuperJSON,
|
||||
xOffset: prev?.kind !== "dynamic" && "xOffset" in section ? section.xOffset : null,
|
||||
options: section.kind === "empty" ? emptySuperJSON : superjson.stringify(section.options),
|
||||
name: prev?.kind === "category" && "name" in section ? section.name : null,
|
||||
})
|
||||
.where(eq(sections.id, section.id))
|
||||
@@ -1657,77 +1698,96 @@ const noBoardWithSimilarNameAsync = async (db: Database, name: string, ignoredId
|
||||
const getUpdatedBoardLayout = (
|
||||
board: Awaited<ReturnType<typeof getFullBoardWithWhereAsync>>,
|
||||
options: {
|
||||
previous: {
|
||||
layoutId: string;
|
||||
columnCount: number;
|
||||
};
|
||||
current: {
|
||||
layoutId: string;
|
||||
columnCount: number;
|
||||
};
|
||||
previous: BoardLayoutGeometry;
|
||||
current: BoardLayoutGeometry;
|
||||
},
|
||||
) => {
|
||||
const itemSectionLayoutsCollection: InferInsertModel<typeof itemLayouts>[] = [];
|
||||
const sectionLayoutsCollection: InferInsertModel<typeof sectionLayouts>[] = [];
|
||||
|
||||
const elements = getElementsForLayout(board, options.previous.layoutId);
|
||||
const rootSections = board.sections.filter((section) => section.kind !== "dynamic");
|
||||
|
||||
for (const rootSection of rootSections) {
|
||||
const result = generateResponsiveGridFor({
|
||||
items: elements,
|
||||
previousWidth: options.previous.columnCount,
|
||||
width: options.current.columnCount,
|
||||
sectionId: rootSection.id,
|
||||
});
|
||||
|
||||
itemSectionLayoutsCollection.push(
|
||||
...board.items
|
||||
.map((item): InferInsertModel<typeof itemLayouts> | null => {
|
||||
const currentElement = result.items.find((element) => element.type === "item" && element.id === item.id);
|
||||
|
||||
if (!currentElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
itemId: item.id,
|
||||
layoutId: options.current.layoutId,
|
||||
sectionId: currentElement.sectionId,
|
||||
height: currentElement.height,
|
||||
width: currentElement.width,
|
||||
xOffset: currentElement.xOffset,
|
||||
yOffset: currentElement.yOffset,
|
||||
};
|
||||
})
|
||||
.filter((item) => item !== null),
|
||||
);
|
||||
|
||||
sectionLayoutsCollection.push(
|
||||
...board.sections
|
||||
.filter((section) => section.kind === "dynamic")
|
||||
.map((section): InferInsertModel<typeof sectionLayouts> | null => {
|
||||
const currentElement = result.items.find(
|
||||
(element) => element.type === "section" && element.id === section.id,
|
||||
);
|
||||
|
||||
if (!currentElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
layoutId: options.current.layoutId,
|
||||
sectionId: section.id,
|
||||
parentSectionId: currentElement.sectionId,
|
||||
height: currentElement.height,
|
||||
width: currentElement.width,
|
||||
xOffset: currentElement.xOffset,
|
||||
yOffset: currentElement.yOffset,
|
||||
};
|
||||
})
|
||||
.filter((section) => section !== null),
|
||||
);
|
||||
const emptyRoots = board.sections
|
||||
.filter((section) => section.kind === "empty")
|
||||
.toSorted((first, second) => (first.yOffset ?? 0) - (second.yOffset ?? 0) || first.id.localeCompare(second.id));
|
||||
const rootByLane = new Map<BoardLane, (typeof emptyRoots)[number]>();
|
||||
for (const root of emptyRoots) {
|
||||
const lane = getRootSectionLane(root.xOffset);
|
||||
if (!rootByLane.has(lane)) rootByLane.set(lane, root);
|
||||
}
|
||||
const mainRoot = rootByLane.get("main");
|
||||
if (!mainRoot) throw new Error(`Board "${board.id}" has no main canvas root`);
|
||||
|
||||
const sourceRootById = new Map(emptyRoots.map((section) => [section.id, getRootSectionLane(section.xOffset)] as const));
|
||||
const targetLaneBySourceLane = new Map<BoardLane, BoardLane>(
|
||||
boardLanes.map((lane) => [
|
||||
lane,
|
||||
lane !== "main" && getBoardLaneColumnCount(options.current, lane) === 0 ? "main" : lane,
|
||||
]),
|
||||
);
|
||||
const remappedElements = elements.map((element) => {
|
||||
const sourceLane = sourceRootById.get(element.sectionId);
|
||||
if (!sourceLane) return element;
|
||||
const targetLane = targetLaneBySourceLane.get(sourceLane) ?? "main";
|
||||
const targetRoot = rootByLane.get(targetLane) ?? mainRoot;
|
||||
return {
|
||||
...element,
|
||||
sectionId: targetRoot.id,
|
||||
};
|
||||
});
|
||||
|
||||
const results = boardLanes.flatMap((lane) => {
|
||||
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 previousWidth =
|
||||
sourceLanes.length === 1
|
||||
? getBoardLaneColumnCount(options.previous, sourceLanes[0] ?? "main")
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
|
||||
return [
|
||||
generateResponsiveGridFor({
|
||||
items: remappedElements,
|
||||
previousWidth,
|
||||
width,
|
||||
sectionId: root.id,
|
||||
}),
|
||||
];
|
||||
});
|
||||
const updatedElements = results.flatMap((result) => result.items);
|
||||
const updatedElementById = new Map(updatedElements.map((element) => [element.id, element]));
|
||||
|
||||
const itemSectionLayoutsCollection = board.items.flatMap((item): InferInsertModel<typeof itemLayouts>[] => {
|
||||
const currentElement = updatedElementById.get(item.id);
|
||||
if (!currentElement || currentElement.type !== "item") return [];
|
||||
|
||||
return [
|
||||
{
|
||||
itemId: item.id,
|
||||
layoutId: options.current.layoutId,
|
||||
sectionId: currentElement.sectionId,
|
||||
height: currentElement.height,
|
||||
width: currentElement.width,
|
||||
xOffset: currentElement.xOffset,
|
||||
yOffset: currentElement.yOffset,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const sectionLayoutsCollection = board.sections.flatMap((section): InferInsertModel<typeof sectionLayouts>[] => {
|
||||
if (section.kind !== "dynamic") return [];
|
||||
const currentElement = updatedElementById.get(section.id);
|
||||
if (!currentElement || currentElement.type !== "section") return [];
|
||||
|
||||
return [
|
||||
{
|
||||
layoutId: options.current.layoutId,
|
||||
sectionId: section.id,
|
||||
parentSectionId: currentElement.sectionId,
|
||||
height: currentElement.height,
|
||||
width: currentElement.width,
|
||||
xOffset: currentElement.xOffset,
|
||||
yOffset: currentElement.yOffset,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
return {
|
||||
itemSectionLayouts: itemSectionLayoutsCollection,
|
||||
@@ -1735,6 +1795,24 @@ const getUpdatedBoardLayout = (
|
||||
};
|
||||
};
|
||||
|
||||
interface BoardLayoutGeometry {
|
||||
layoutId: string;
|
||||
columnCount: number;
|
||||
leftGutterColumnCount: number;
|
||||
rightGutterColumnCount: number;
|
||||
}
|
||||
|
||||
const getRootSectionForLane = (
|
||||
board: Awaited<ReturnType<typeof getFullBoardWithWhereAsync>>,
|
||||
lane: BoardLane,
|
||||
) => {
|
||||
const roots = board.sections.filter(
|
||||
(section) => section.kind === "empty" && getRootSectionLane(section.xOffset) === lane,
|
||||
);
|
||||
if (roots.length > 1) throw new Error(`Board "${board.id}" has multiple ${lane} canvas roots`);
|
||||
return roots[0];
|
||||
};
|
||||
|
||||
const getElementsForLayout = (board: Awaited<ReturnType<typeof getFullBoardWithWhereAsync>>, layoutId: string) => {
|
||||
const sectionElements = board.sections
|
||||
.filter((section) => section.kind === "dynamic")
|
||||
@@ -1848,7 +1926,7 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
|
||||
parentSectionId: layout.parentSectionId,
|
||||
layoutId: layout.layoutId,
|
||||
})),
|
||||
collapsed: collapseStates.at(0)?.collapsed ?? false,
|
||||
collapsed: collapseStates.at(0)?.collapsed ?? section.kind === "category",
|
||||
}),
|
||||
),
|
||||
items: items
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import superjson from "superjson";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { and, eq } from "@homarr/db";
|
||||
import { sectionCollapseStates, sections } from "@homarr/db/schema";
|
||||
import { emptySuperJSON } from "@homarr/definitions";
|
||||
import { categorySectionOptionsSchema, dynamicSectionOptionsSchema } from "@homarr/validation/shared";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../../trpc";
|
||||
|
||||
@@ -16,7 +19,7 @@ export const sectionRouter = createTRPCRouter({
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const section = await ctx.db.query.sections.findFirst({
|
||||
where: and(eq(sections.id, input.sectionId), eq(sections.kind, "category")),
|
||||
where: eq(sections.id, input.sectionId),
|
||||
with: {
|
||||
collapseStates: {
|
||||
where: eq(sectionCollapseStates.userId, ctx.session.user.id),
|
||||
@@ -31,6 +34,26 @@ export const sectionRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
if (section.kind === "empty") {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Section cannot be collapsed id=${input.sectionId}`,
|
||||
});
|
||||
}
|
||||
|
||||
const rawOptions = superjson.parse(section.options ?? emptySuperJSON);
|
||||
const parsedOptions =
|
||||
section.kind === "category"
|
||||
? categorySectionOptionsSchema.safeParse(rawOptions)
|
||||
: dynamicSectionOptionsSchema.safeParse(rawOptions);
|
||||
|
||||
if (!parsedOptions.success || !parsedOptions.data.collapsible) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Section cannot be collapsed id=${input.sectionId}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (section.collapseStates.length === 0) {
|
||||
await ctx.db.insert(sectionCollapseStates).values({
|
||||
sectionId: section.id,
|
||||
|
||||
@@ -572,6 +572,35 @@ describe("getBoardByName should return board by name", () => {
|
||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "view");
|
||||
});
|
||||
|
||||
it("normalizes legacy category behavior options in the response", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const { boardId, sectionId } = await createFullBoardAsync(db, "default");
|
||||
await db
|
||||
.update(sections)
|
||||
.set({
|
||||
kind: "category",
|
||||
name: "Media",
|
||||
})
|
||||
.where(eq(sections.id, sectionId));
|
||||
|
||||
const result = await caller.getBoardByName({ name: "default" });
|
||||
const section = expectToBeDefined(result.sections[0]);
|
||||
|
||||
expect(result.id).toBe(boardId);
|
||||
expect(section.kind).toBe("category");
|
||||
if (section.kind === "category") {
|
||||
expect(section.collapsed).toBe(true);
|
||||
expect(section.options).toEqual({
|
||||
showLabel: true,
|
||||
collapsible: true,
|
||||
showOpenAll: true,
|
||||
railPlacement: "main",
|
||||
columnCount: 2,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("should throw error when not present", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
@@ -882,6 +911,68 @@ describe("saveBoard should save full board", () => {
|
||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
|
||||
},
|
||||
);
|
||||
it("should add and update category behavior options", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const { boardId } = await createFullBoardAsync(db, "default");
|
||||
const sectionId = createId();
|
||||
|
||||
await caller.saveBoard({
|
||||
id: boardId,
|
||||
sections: [
|
||||
{
|
||||
id: sectionId,
|
||||
kind: "category",
|
||||
name: "Navigation",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
collapsed: false,
|
||||
options: {
|
||||
showLabel: true,
|
||||
collapsible: true,
|
||||
showOpenAll: true,
|
||||
railPlacement: "left",
|
||||
columnCount: 3,
|
||||
},
|
||||
},
|
||||
],
|
||||
items: [],
|
||||
});
|
||||
|
||||
await caller.saveBoard({
|
||||
id: boardId,
|
||||
sections: [
|
||||
{
|
||||
id: sectionId,
|
||||
kind: "category",
|
||||
name: "Navigation",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
collapsed: false,
|
||||
options: {
|
||||
showLabel: false,
|
||||
collapsible: false,
|
||||
showOpenAll: false,
|
||||
railPlacement: "right",
|
||||
columnCount: 4,
|
||||
},
|
||||
},
|
||||
],
|
||||
items: [],
|
||||
});
|
||||
|
||||
const section = await db.query.sections.findFirst({
|
||||
where: eq(sections.id, sectionId),
|
||||
});
|
||||
|
||||
expect(SuperJSON.parse(section?.options ?? "")).toEqual({
|
||||
showLabel: false,
|
||||
collapsible: false,
|
||||
showOpenAll: false,
|
||||
railPlacement: "right",
|
||||
columnCount: 4,
|
||||
});
|
||||
});
|
||||
it("should add item when present in input", async () => {
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
const db = createDb();
|
||||
@@ -1328,12 +1419,16 @@ const createExistingLayout = (id: string) => ({
|
||||
id,
|
||||
name: "Base",
|
||||
columnCount: 10,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 0,
|
||||
});
|
||||
const createNewLayout = (columnCount: number) => ({
|
||||
id: createId(),
|
||||
name: "New layout",
|
||||
columnCount,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 1400,
|
||||
});
|
||||
describe("saveLayouts should save layout changes", () => {
|
||||
@@ -1445,6 +1540,59 @@ describe("saveLayouts should save layout changes", () => {
|
||||
});
|
||||
await expectLayoutForDynamicSectionAsync(db, assignments.inRoot.f, layoutId, assignments.inDynamicSection);
|
||||
});
|
||||
test("should reserve gutter columns and return gutter contents to the canvas when disabled", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "gutters");
|
||||
|
||||
await db
|
||||
.update(itemLayouts)
|
||||
.set({
|
||||
xOffset: 9,
|
||||
})
|
||||
.where(and(eq(itemLayouts.itemId, itemId), eq(itemLayouts.layoutId, layoutId)));
|
||||
|
||||
await caller.saveLayouts({
|
||||
id: boardId,
|
||||
layouts: [
|
||||
{
|
||||
...createExistingLayout(layoutId),
|
||||
leftGutterColumnCount: 2,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const enabledLayout = expectToBeDefined(await db.query.layouts.findFirst({ where: eq(layouts.id, layoutId) }));
|
||||
expect(enabledLayout.leftGutterColumnCount).toBe(2);
|
||||
const leftRoot = expectToBeDefined(
|
||||
await db.query.sections.findFirst({
|
||||
where: and(eq(sections.boardId, boardId), eq(sections.kind, "empty"), eq(sections.xOffset, -1)),
|
||||
}),
|
||||
);
|
||||
const reflowedMainItem = expectToBeDefined(
|
||||
await db.query.itemLayouts.findFirst({
|
||||
where: and(eq(itemLayouts.itemId, itemId), eq(itemLayouts.layoutId, layoutId)),
|
||||
}),
|
||||
);
|
||||
expect(reflowedMainItem.xOffset + reflowedMainItem.width).toBeLessThanOrEqual(8);
|
||||
|
||||
const gutterItemId = await addItemAsync(db, {
|
||||
boardId,
|
||||
layoutId,
|
||||
sectionId: leftRoot.id,
|
||||
});
|
||||
await caller.saveLayouts({
|
||||
id: boardId,
|
||||
layouts: [createExistingLayout(layoutId)],
|
||||
});
|
||||
|
||||
const returnedItem = expectToBeDefined(
|
||||
await db.query.itemLayouts.findFirst({
|
||||
where: and(eq(itemLayouts.itemId, gutterItemId), eq(itemLayouts.layoutId, layoutId)),
|
||||
}),
|
||||
);
|
||||
expect(returnedItem.sectionId).toBe(sectionId);
|
||||
});
|
||||
test("should remove layout when not present in input", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { stringify } from "superjson";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import { eq } from "@homarr/db";
|
||||
import { boards, sectionCollapseStates, sections, users } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { sectionRouter } from "../section/section-router";
|
||||
|
||||
const userId = createId();
|
||||
const session = {
|
||||
user: {
|
||||
id: userId,
|
||||
permissions: [],
|
||||
colorScheme: "light",
|
||||
},
|
||||
expires: new Date().toISOString(),
|
||||
} satisfies Session;
|
||||
|
||||
const createCallerAsync = async () => {
|
||||
const db = createDb();
|
||||
const boardId = createId();
|
||||
|
||||
await db.insert(users).values({ id: userId });
|
||||
await db.insert(boards).values({
|
||||
id: boardId,
|
||||
name: createId(),
|
||||
creatorId: userId,
|
||||
});
|
||||
|
||||
return {
|
||||
boardId,
|
||||
db,
|
||||
caller: sectionRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
describe("changeCollapsed", () => {
|
||||
it("preserves collapse support for legacy categories", async () => {
|
||||
const { boardId, caller, db } = await createCallerAsync();
|
||||
const sectionId = createId();
|
||||
await db.insert(sections).values({
|
||||
id: sectionId,
|
||||
boardId,
|
||||
kind: "category",
|
||||
name: "Media",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
});
|
||||
|
||||
await caller.changeCollapsed({ sectionId, collapsed: true });
|
||||
|
||||
const state = await db.query.sectionCollapseStates.findFirst({
|
||||
where: eq(sectionCollapseStates.sectionId, sectionId),
|
||||
});
|
||||
expect(state?.collapsed).toBe(true);
|
||||
});
|
||||
|
||||
it("creates and updates collapse state for an enabled dynamic section", async () => {
|
||||
const { boardId, caller, db } = await createCallerAsync();
|
||||
const sectionId = createId();
|
||||
await db.insert(sections).values({
|
||||
id: sectionId,
|
||||
boardId,
|
||||
kind: "dynamic",
|
||||
options: stringify({ collapsible: true }),
|
||||
});
|
||||
|
||||
await caller.changeCollapsed({ sectionId, collapsed: true });
|
||||
await caller.changeCollapsed({ sectionId, collapsed: false });
|
||||
|
||||
const states = await db.query.sectionCollapseStates.findMany({
|
||||
where: eq(sectionCollapseStates.sectionId, sectionId),
|
||||
});
|
||||
expect(states).toHaveLength(1);
|
||||
expect(states[0]?.collapsed).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a dynamic section when collapsing is disabled", async () => {
|
||||
const { boardId, caller, db } = await createCallerAsync();
|
||||
const sectionId = createId();
|
||||
await db.insert(sections).values({
|
||||
id: sectionId,
|
||||
boardId,
|
||||
kind: "dynamic",
|
||||
});
|
||||
|
||||
await expect(caller.changeCollapsed({ sectionId, collapsed: true })).rejects.toThrow("Section cannot be collapsed");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user