feat: rebuild board grid with dnd-kit
This commit is contained in:
1 parent
5bde4d3cc5
commit
1f5311e93b
107 files changed
+5628
-3209
No files matched your search
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user