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

+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");
});
});