feat: rebuild board grid with dnd-kit

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-13 19:52:59 +02:00
1 parent 5bde4d3cc5
commit 1f5311e93b
107 files changed
+5628 -3209

No files matched your search

+21 -23
View File
@@ -917,18 +917,18 @@ export const boardRouter = createTRPCRouter({
addedSections.map((section) => ({
id: section.id,
kind: section.kind,
yOffset: section.kind !== "dynamic" ? section.yOffset : null,
xOffset: section.kind === "dynamic" ? null : section.xOffset,
yOffset: section.kind === "empty" ? section.yOffset : null,
xOffset: section.kind === "empty" ? section.xOffset : null,
options: section.kind === "empty" ? emptySuperJSON : superjson.stringify(section.options),
name: "name" in section ? section.name : null,
name: null,
boardId: dbBoard.id,
})),
);
if (addedSections.some((section) => section.kind === "dynamic")) {
if (addedSections.some((section) => section.kind === "container")) {
await transaction.insert(schema.sectionLayouts).values(
addedSections
.filter((section) => section.kind === "dynamic")
.filter((section) => section.kind === "container")
.flatMap((section) =>
section.layouts.map(
(sectionLayout): InferInsertModel<typeof schema.sectionLayouts> => ({
@@ -1039,18 +1039,17 @@ export const boardRouter = createTRPCRouter({
const updatedSections = filterUpdatedItems(input.sections, dbBoard.sections);
for (const section of updatedSections) {
const prev = dbBoard.sections.find((dbSection) => dbSection.id === section.id);
await transaction
.update(schema.sections)
.set({
yOffset: prev?.kind !== "dynamic" && "yOffset" in section ? section.yOffset : null,
xOffset: prev?.kind !== "dynamic" && "xOffset" in section ? section.xOffset : null,
yOffset: section.kind === "empty" ? section.yOffset : null,
xOffset: section.kind === "empty" ? section.xOffset : null,
options: section.kind === "empty" ? emptySuperJSON : superjson.stringify(section.options),
name: prev?.kind === "category" && "name" in section ? section.name : null,
name: null,
})
.where(eq(schema.sections.id, section.id));
if (section.kind !== "dynamic") continue;
if (section.kind !== "container") continue;
for (const sectionLayout of section.layouts) {
await transaction
@@ -1117,21 +1116,21 @@ export const boardRouter = createTRPCRouter({
addedSections.map((section) => ({
id: section.id,
kind: section.kind,
yOffset: section.kind !== "dynamic" ? section.yOffset : null,
xOffset: section.kind === "dynamic" ? null : section.xOffset,
yOffset: section.kind === "empty" ? section.yOffset : null,
xOffset: section.kind === "empty" ? section.xOffset : null,
options: section.kind === "empty" ? emptySuperJSON : superjson.stringify(section.options),
name: "name" in section ? section.name : null,
name: null,
boardId: dbBoard.id,
})),
)
.run();
if (addedSections.some((section) => section.kind === "dynamic")) {
if (addedSections.some((section) => section.kind === "container")) {
transaction
.insert(sectionLayouts)
.values(
addedSections
.filter((section) => section.kind === "dynamic")
.filter((section) => section.kind === "container")
.flatMap((section) =>
section.layouts.map(
(sectionLayout): InferInsertModel<typeof sectionLayouts> => ({
@@ -1249,19 +1248,18 @@ export const boardRouter = createTRPCRouter({
const updatedSections = filterUpdatedItems(input.sections, dbBoard.sections);
for (const section of updatedSections) {
const prev = dbBoard.sections.find((dbSection) => dbSection.id === section.id);
transaction
.update(sections)
.set({
yOffset: prev?.kind !== "dynamic" && "yOffset" in section ? section.yOffset : null,
xOffset: prev?.kind !== "dynamic" && "xOffset" in section ? section.xOffset : null,
yOffset: section.kind === "empty" ? section.yOffset : null,
xOffset: section.kind === "empty" ? section.xOffset : null,
options: section.kind === "empty" ? emptySuperJSON : superjson.stringify(section.options),
name: prev?.kind === "category" && "name" in section ? section.name : null,
name: null,
})
.where(eq(sections.id, section.id))
.run();
if (section.kind !== "dynamic") continue;
if (section.kind !== "container") continue;
for (const sectionLayout of section.layouts) {
transaction
@@ -1772,7 +1770,7 @@ const getUpdatedBoardLayout = (
});
const sectionLayoutsCollection = board.sections.flatMap((section): InferInsertModel<typeof sectionLayouts>[] => {
if (section.kind !== "dynamic") return [];
if (section.kind !== "container") return [];
const currentElement = updatedElementById.get(section.id);
if (!currentElement || currentElement.type !== "section") return [];
@@ -1815,7 +1813,7 @@ const getRootSectionForLane = (
const getElementsForLayout = (board: Awaited<ReturnType<typeof getFullBoardWithWhereAsync>>, layoutId: string) => {
const sectionElements = board.sections
.filter((section) => section.kind === "dynamic")
.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)!;
@@ -1926,7 +1924,7 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
parentSectionId: layout.parentSectionId,
layoutId: layout.layoutId,
})),
collapsed: collapseStates.at(0)?.collapsed ?? section.kind === "category",
collapsed: collapseStates.at(0)?.collapsed ?? false,
}),
),
items: items
@@ -42,18 +42,18 @@ export const generateResponsiveGridFor = ({
const newItems: GridAlgorithmItem[] = [];
// Fix height of dynamic sections
const dynamicSectionHeightMap = new Map<string, number>();
const dynamicSectionsOfCurrentSection = normalizedItems.filter((item) => item.type === "section");
for (const dynamicSection of dynamicSectionsOfCurrentSection) {
// A container must remain tall enough for its recursively reflowed contents.
const containerHeightMap = new Map<string, number>();
const containersOfCurrentSection = normalizedItems.filter((item) => item.type === "section");
for (const container of containersOfCurrentSection) {
const result = generateResponsiveGridFor({
items,
previousWidth: dynamicSection.previousWidth,
width: dynamicSection.width,
sectionId: dynamicSection.id,
previousWidth: container.previousWidth,
width: container.width,
sectionId: container.id,
});
newItems.push(...result.items);
dynamicSectionHeightMap.set(dynamicSection.id, result.height);
containerHeightMap.set(container.id, result.height);
}
// Return same positions for items in the current section
@@ -69,7 +69,7 @@ export const generateResponsiveGridFor = ({
for (const item of normalizedItems) {
const itemWithHeight = {
...item,
height: item.type === "section" ? Math.max(dynamicSectionHeightMap.get(item.id) ?? 1, item.height) : item.height,
height: item.type === "section" ? Math.max(containerHeightMap.get(item.id) ?? 1, item.height) : item.height,
};
const position = nextFreeSpot(occupied2d, itemWithHeight, width);
if (!position) throw new Error("No free spot available");
@@ -25,34 +25,34 @@ describe("Grid Algorithm", () => {
expect(output).toBe(item.output);
});
test.each(dynamicSectionTests)(
"should convert a grid with dynamic sections from 16 columns to %i columns",
test.each(containerTests)(
"should convert a grid with nested containers from 16 columns to %i columns",
(_, testInput) => {
const outerDynamicSectionId = "b";
const innerDynamicSectionId = "f";
const outerContainerId = "b";
const innerContainerId = "f";
const items = [
algoItem({ id: "a", width: 2, height: 2 }),
algoItem({ id: outerDynamicSectionId, type: "section", width: 12, height: 3, yOffset: 2 }),
algoItem({ id: "a", width: 2, sectionId: outerDynamicSectionId }),
algoItem({ id: "b", width: 4, sectionId: outerDynamicSectionId, xOffset: 2 }),
algoItem({ id: "c", width: 2, sectionId: outerDynamicSectionId, xOffset: 6 }),
algoItem({ id: "d", width: 1, sectionId: outerDynamicSectionId, xOffset: 8 }),
algoItem({ id: "e", width: 3, sectionId: outerDynamicSectionId, xOffset: 9 }),
algoItem({ id: outerContainerId, type: "section", width: 12, height: 3, yOffset: 2 }),
algoItem({ id: "a", width: 2, sectionId: outerContainerId }),
algoItem({ id: "b", width: 4, sectionId: outerContainerId, xOffset: 2 }),
algoItem({ id: "c", width: 2, sectionId: outerContainerId, xOffset: 6 }),
algoItem({ id: "d", width: 1, sectionId: outerContainerId, xOffset: 8 }),
algoItem({ id: "e", width: 3, sectionId: outerContainerId, xOffset: 9 }),
algoItem({
id: innerDynamicSectionId,
id: innerContainerId,
type: "section",
width: 8,
height: 2,
yOffset: 1,
sectionId: outerDynamicSectionId,
sectionId: outerContainerId,
}),
algoItem({ id: "a", width: 2, sectionId: innerDynamicSectionId }),
algoItem({ id: "b", width: 5, xOffset: 2, sectionId: innerDynamicSectionId }),
algoItem({ id: "c", width: 1, height: 2, xOffset: 7, sectionId: innerDynamicSectionId }),
algoItem({ id: "d", width: 7, yOffset: 1, sectionId: innerDynamicSectionId }),
algoItem({ id: "g", width: 4, yOffset: 1, sectionId: outerDynamicSectionId, xOffset: 8 }),
algoItem({ id: "h", width: 3, yOffset: 2, sectionId: outerDynamicSectionId, xOffset: 8 }),
algoItem({ id: "i", width: 1, yOffset: 2, sectionId: outerDynamicSectionId, xOffset: 11 }),
algoItem({ id: "a", width: 2, sectionId: innerContainerId }),
algoItem({ id: "b", width: 5, xOffset: 2, sectionId: innerContainerId }),
algoItem({ id: "c", width: 1, height: 2, xOffset: 7, sectionId: innerContainerId }),
algoItem({ id: "d", width: 7, yOffset: 1, sectionId: innerContainerId }),
algoItem({ id: "g", width: 4, yOffset: 1, sectionId: outerContainerId, xOffset: 8 }),
algoItem({ id: "h", width: 3, yOffset: 2, sectionId: outerContainerId, xOffset: 8 }),
algoItem({ id: "i", width: 1, yOffset: 2, sectionId: outerContainerId, xOffset: 11 }),
algoItem({ id: "c", width: 5, yOffset: 5 }),
];
@@ -64,10 +64,10 @@ describe("Grid Algorithm", () => {
});
const rootItems = newItems.items.filter((item) => item.sectionId === ROOT_SECTION_ID);
const outerSection = items.find((item) => item.id === outerDynamicSectionId);
const outerItems = newItems.items.filter((item) => item.sectionId === outerDynamicSectionId);
const innerSection = items.find((item) => item.id === innerDynamicSectionId);
const innerItems = newItems.items.filter((item) => item.sectionId === innerDynamicSectionId);
const outerSection = items.find((item) => item.id === outerContainerId);
const outerItems = newItems.items.filter((item) => item.sectionId === outerContainerId);
const innerSection = items.find((item) => item.id === innerContainerId);
const innerItems = newItems.items.filter((item) => item.sectionId === innerContainerId);
expect(generateOutputText(rootItems, testInput.outputColumns)).toBe(testInput.root);
expect(generateOutputText(outerItems, Math.min(testInput.outputColumns, outerSection?.width ?? 999))).toBe(
@@ -192,7 +192,7 @@ const itemTests = [
},
].map((item) => [item.inputColumnCount, item.outputColumnCount, item] as const);
const dynamicSectionTests = [
const containerTests = [
{
outputColumns: 16,
root: `
@@ -5,7 +5,7 @@ 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 { containerSectionOptionsSchema } from "@homarr/validation/shared";
import { createTRPCRouter, protectedProcedure } from "../../trpc";
@@ -34,7 +34,7 @@ export const sectionRouter = createTRPCRouter({
});
}
if (section.kind === "empty") {
if (section.kind !== "container") {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Section cannot be collapsed id=${input.sectionId}`,
@@ -42,10 +42,7 @@ export const sectionRouter = createTRPCRouter({
}
const rawOptions = superjson.parse(section.options ?? emptySuperJSON);
const parsedOptions =
section.kind === "category"
? categorySectionOptionsSchema.safeParse(rawOptions)
: dynamicSectionOptionsSchema.safeParse(rawOptions);
const parsedOptions = containerSectionOptionsSchema.safeParse(rawOptions);
if (!parsedOptions.success || !parsedOptions.data.collapsible) {
throw new TRPCError({
+186 -120
View File
@@ -572,31 +572,34 @@ 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 () => {
it("returns configured container behavior", 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,
const { boardId, sectionId: rootSectionId, layoutId } = await createFullBoardAsync(db, "default");
const sectionId = await addContainerAsync(db, {
boardId,
layoutId,
parentSectionId: rootSectionId,
options: {
title: "Media",
showLabel: false,
collapsible: true,
showOpenAll: true,
},
});
const result = await caller.getBoardByName({ name: "default" });
const section = expectToBeDefined(result.sections.find((candidate) => candidate.id === sectionId));
expect(result.id).toBe(boardId);
expect(section.kind).toBe("container");
if (section.kind === "container") {
expect(section.collapsed).toBe(false);
expect(section.options).toMatchObject({
title: "Media",
showLabel: false,
collapsible: true,
showOpenAll: true,
railPlacement: "main",
columnCount: 2,
});
}
});
@@ -857,83 +860,92 @@ describe("saveBoard should save full board", () => {
expect(integration).toBeUndefined();
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
});
it.each([[{ kind: "empty" as const }], [{ kind: "category" as const, collapsed: false, name: "My first category" }]])(
"should add section when present in input",
async (partialSection) => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, sectionId } = await createFullBoardAsync(db, "default");
const newSectionId = createId();
await caller.saveBoard({
id: boardId,
sections: [
{
id: newSectionId,
xOffset: 0,
yOffset: 1,
...partialSection,
},
{
id: sectionId,
kind: "empty",
xOffset: 0,
yOffset: 0,
},
],
items: [],
});
const board = await db.query.boards.findFirst({
where: eq(boards.id, boardId),
with: {
sections: true,
},
});
const section = await db.query.sections.findFirst({
where: eq(sections.id, newSectionId),
});
const definedBoard = expectToBeDefined(board);
expect(definedBoard.sections.length).toBe(2);
const addedSection = expectToBeDefined(definedBoard.sections.find((section) => section.id === newSectionId));
expect(addedSection).toBeDefined();
expect(addedSection.id).toBe(newSectionId);
expect(addedSection.kind).toBe(partialSection.kind);
expect(addedSection.yOffset).toBe(1);
if ("name" in partialSection) {
expect(addedSection.name).toBe(partialSection.name);
}
expect(section).toBeDefined();
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
},
);
it("should add and update category behavior options", async () => {
it("should add root section when present in input", async () => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId } = await createFullBoardAsync(db, "default");
const sectionId = createId();
const { boardId, sectionId } = await createFullBoardAsync(db, "default");
const newSectionId = createId();
await caller.saveBoard({
id: boardId,
sections: [
{
id: newSectionId,
kind: "empty",
xOffset: 0,
yOffset: 1,
},
{
id: sectionId,
kind: "empty",
xOffset: 0,
yOffset: 0,
},
],
items: [],
});
const board = await db.query.boards.findFirst({
where: eq(boards.id, boardId),
with: {
sections: true,
},
});
const section = await db.query.sections.findFirst({
where: eq(sections.id, newSectionId),
});
const definedBoard = expectToBeDefined(board);
expect(definedBoard.sections.length).toBe(2);
const addedSection = expectToBeDefined(definedBoard.sections.find((section) => section.id === newSectionId));
expect(addedSection).toBeDefined();
expect(addedSection.id).toBe(newSectionId);
expect(addedSection.kind).toBe("empty");
expect(addedSection.yOffset).toBe(1);
expect(addedSection.name).toBeNull();
expect(section).toBeDefined();
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
});
it("should add and update container behavior options", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId, sectionId: rootSectionId } = await createFullBoardAsync(db, "default");
const containerId = createId();
await caller.saveBoard({
id: boardId,
sections: [
{
id: sectionId,
kind: "category",
name: "Navigation",
id: rootSectionId,
kind: "empty",
xOffset: 0,
yOffset: 0,
},
{
id: containerId,
kind: "container",
collapsed: false,
options: {
title: "Navigation",
customCssClasses: ["pinned"],
borderColor: "#123456",
showLabel: true,
collapsible: true,
showOpenAll: true,
railPlacement: "left",
columnCount: 3,
},
layouts: [
{
layoutId,
parentSectionId: rootSectionId,
xOffset: 1,
yOffset: 2,
width: 3,
height: 4,
},
],
},
],
items: [],
@@ -943,34 +955,60 @@ describe("saveBoard should save full board", () => {
id: boardId,
sections: [
{
id: sectionId,
kind: "category",
name: "Navigation",
id: rootSectionId,
kind: "empty",
xOffset: 0,
yOffset: 0,
},
{
id: containerId,
kind: "container",
collapsed: false,
options: {
title: "Operations",
customCssClasses: ["dense"],
borderColor: "#654321",
showLabel: false,
collapsible: false,
showOpenAll: false,
railPlacement: "right",
columnCount: 4,
},
layouts: [
{
layoutId,
parentSectionId: rootSectionId,
xOffset: 2,
yOffset: 3,
width: 4,
height: 5,
},
],
},
],
items: [],
});
const section = await db.query.sections.findFirst({
where: eq(sections.id, sectionId),
where: eq(sections.id, containerId),
});
expect(SuperJSON.parse(section?.options ?? "")).toEqual({
title: "Operations",
customCssClasses: ["dense"],
borderColor: "#654321",
showLabel: false,
collapsible: false,
showOpenAll: false,
railPlacement: "right",
columnCount: 4,
});
expect(
await db.query.sectionLayouts.findFirst({
where: and(eq(sectionLayouts.sectionId, containerId), eq(sectionLayouts.layoutId, layoutId)),
}),
).toMatchObject({
parentSectionId: rootSectionId,
xOffset: 2,
yOffset: 3,
width: 4,
height: 5,
});
});
it("should add item when present in input", async () => {
@@ -1118,15 +1156,22 @@ describe("saveBoard should save full board", () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, sectionId } = await createFullBoardAsync(db, "default");
const newSectionId = createId();
const { boardId, sectionId, layoutId } = await createFullBoardAsync(db, "default");
const containerId = createId();
await db.insert(sections).values({
id: newSectionId,
kind: "category",
name: "Before",
yOffset: 1,
xOffset: 0,
id: containerId,
kind: "container",
boardId,
options: SuperJSON.stringify({ title: "Before" }),
});
await db.insert(sectionLayouts).values({
sectionId: containerId,
layoutId,
parentSectionId: sectionId,
xOffset: 0,
yOffset: 0,
width: 2,
height: 2,
});
await caller.saveBoard({
@@ -1134,19 +1179,32 @@ describe("saveBoard should save full board", () => {
sections: [
{
id: sectionId,
kind: "category",
kind: "empty",
yOffset: 1,
xOffset: 0,
name: "Test",
collapsed: true,
},
{
id: newSectionId,
kind: "category",
name: "After",
yOffset: 0,
xOffset: 0,
id: containerId,
kind: "container",
collapsed: false,
options: {
title: "After",
customCssClasses: ["dense"],
borderColor: "#123456",
showLabel: false,
collapsible: true,
showOpenAll: true,
},
layouts: [
{
layoutId,
parentSectionId: sectionId,
xOffset: 1,
yOffset: 2,
width: 3,
height: 4,
},
],
},
],
items: [],
@@ -1166,11 +1224,17 @@ describe("saveBoard should save full board", () => {
expect(firstSection.kind).toBe("empty");
expect(firstSection.yOffset).toBe(1);
expect(firstSection.name).toBe(null);
const secondSection = expectToBeDefined(definedBoard.sections.find((section) => section.id === newSectionId));
expect(secondSection.id).toBe(newSectionId);
expect(secondSection.kind).toBe("category");
expect(secondSection.yOffset).toBe(0);
expect(secondSection.name).toBe("After");
const secondSection = expectToBeDefined(definedBoard.sections.find((section) => section.id === containerId));
expect(secondSection.id).toBe(containerId);
expect(secondSection.kind).toBe("container");
expect(secondSection.yOffset).toBeNull();
expect(secondSection.name).toBeNull();
expect(SuperJSON.parse(secondSection.options ?? "")).toMatchObject({ title: "After", collapsible: true });
expect(
await db.query.sectionLayouts.findFirst({
where: and(eq(sectionLayouts.sectionId, containerId), eq(sectionLayouts.layoutId, layoutId)),
}),
).toMatchObject({ xOffset: 1, yOffset: 2, width: 3, height: 4, parentSectionId: sectionId });
});
it("should update item when present in input", async () => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
@@ -1456,7 +1520,7 @@ describe("saveLayouts should save layout changes", () => {
expect(definedLayout.columnCount).toBe(newLayout.columnCount);
expect(definedLayout.breakpoint).toBe(newLayout.breakpoint);
});
test("should add items and dynamic sections generated from grid-algorithm when new layout is added", async () => {
test("should add items and containers generated from grid-algorithm when new layout is added", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
@@ -1485,7 +1549,7 @@ describe("saveLayouts should save layout changes", () => {
a: itemId,
});
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await expectLayoutForDynamicSectionAsync(db, assignments.inRoot.f, layout!.id, assignments.inDynamicSection);
await expectLayoutForContainerAsync(db, assignments.inRoot.f, layout!.id, assignments.inContainer);
});
test("should update layout when present in input", async () => {
// Arrange
@@ -1538,7 +1602,7 @@ describe("saveLayouts should save layout changes", () => {
...assignments.inRoot,
a: itemId,
});
await expectLayoutForDynamicSectionAsync(db, assignments.inRoot.f, layoutId, assignments.inDynamicSection);
await expectLayoutForContainerAsync(db, assignments.inRoot.f, layoutId, assignments.inContainer);
});
test("should reserve gutter columns and return gutter contents to the canvas when disabled", async () => {
const db = createDb();
@@ -1806,19 +1870,21 @@ const addItemAsync = async (
return itemId;
};
const addDynamicSectionAsync = async (
const addContainerAsync = async (
db: Database,
section: Partial<Pick<InferInsertModel<typeof sectionLayouts>, "xOffset" | "yOffset" | "width" | "height">> & {
parentSectionId: string;
boardId: string;
layoutId: string;
options?: Record<string, unknown>;
},
) => {
const sectionId = createId();
await db.insert(sections).values({
id: sectionId,
kind: "dynamic",
kind: "container",
boardId: section.boardId,
options: SuperJSON.stringify(section.options ?? {}),
});
await db.insert(sectionLayouts).values({
parentSectionId: section.parentSectionId,
@@ -1860,7 +1926,7 @@ const createItemsAndSectionsAsync = async (
const itemC = await addItemAsync(db, { boardId, layoutId, sectionId, xOffset: 6, width: 2, height: 2 });
const itemD = await addItemAsync(db, { boardId, layoutId, sectionId, xOffset: 8, width: 2, height: 3 });
const itemE = await addItemAsync(db, { boardId, layoutId, sectionId, yOffset: 1, height: 3 });
const sectionF = await addDynamicSectionAsync(db, {
const sectionF = await addContainerAsync(db, {
yOffset: 1,
xOffset: 1,
width: 5,
@@ -1869,7 +1935,7 @@ const createItemsAndSectionsAsync = async (
boardId,
layoutId,
});
const sectionG = await addDynamicSectionAsync(db, {
const sectionG = await addContainerAsync(db, {
yOffset: 2,
xOffset: 6,
width: 2,
@@ -1900,7 +1966,7 @@ const createItemsAndSectionsAsync = async (
f: sectionF,
g: sectionG,
},
inDynamicSection: {
inContainer: {
h: itemH,
i: itemI,
j: itemJ,
@@ -1936,7 +2002,7 @@ gg`,
);
};
const expectLayoutForDynamicSectionAsync = async (
const expectLayoutForContainerAsync = async (
db: Database,
sectionId: string,
layoutId: string,
+7 -13
View File
@@ -42,33 +42,27 @@ const createCallerAsync = async () => {
};
describe("changeCollapsed", () => {
it("preserves collapse support for legacy categories", async () => {
it("rejects a root section", async () => {
const { boardId, caller, db } = await createCallerAsync();
const sectionId = createId();
await db.insert(sections).values({
id: sectionId,
boardId,
kind: "category",
name: "Media",
kind: "empty",
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);
await expect(caller.changeCollapsed({ sectionId, collapsed: true })).rejects.toThrow("Section cannot be collapsed");
});
it("creates and updates collapse state for an enabled dynamic section", async () => {
it("creates and updates collapse state for an enabled container", async () => {
const { boardId, caller, db } = await createCallerAsync();
const sectionId = createId();
await db.insert(sections).values({
id: sectionId,
boardId,
kind: "dynamic",
kind: "container",
options: stringify({ collapsible: true }),
});
@@ -82,13 +76,13 @@ describe("changeCollapsed", () => {
expect(states[0]?.collapsed).toBe(false);
});
it("rejects a dynamic section when collapsing is disabled", async () => {
it("rejects a container when collapsing is disabled", async () => {
const { boardId, caller, db } = await createCallerAsync();
const sectionId = createId();
await db.insert(sections).values({
id: sectionId,
boardId,
kind: "dynamic",
kind: "container",
});
await expect(caller.changeCollapsed({ sectionId, collapsed: true })).rejects.toThrow("Section cannot be collapsed");