feat: rebuild board grid experience

This commit is contained in:
ajnart committed 2026-08-13 19:52:09 +02:00
1 parent 5dc4dbeaef
commit 0110a2ef63
122 files changed
+9582 -4063

No files matched your search

+166 -88
View File
@@ -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,
+148
View File
@@ -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");
});
});