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");
|
||||
});
|
||||
});
|
||||
@@ -20,32 +20,27 @@ export const IntegrationProvider = ({ integrations, children }: PropsWithChildre
|
||||
return <IntegrationContext.Provider value={{ integrations }}>{children}</IntegrationContext.Provider>;
|
||||
};
|
||||
|
||||
export const useIntegrationsWithUseAccess = () => {
|
||||
const useIntegrationContext = () => {
|
||||
const context = useContext(IntegrationContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useIntegrationsWithUseAccess must be used within an IntegrationProvider");
|
||||
throw new Error("Integration hooks must be used within an IntegrationProvider");
|
||||
}
|
||||
|
||||
return context.integrations.filter((integration) => integration.permissions.hasUseAccess);
|
||||
return context;
|
||||
};
|
||||
|
||||
export const useIntegrations = () => useIntegrationContext().integrations;
|
||||
|
||||
export const useIntegrationsWithUseAccess = () =>
|
||||
useIntegrationContext().integrations.filter((integration) => integration.permissions.hasUseAccess);
|
||||
|
||||
export const useIntegrationsWithInteractAccess = () => {
|
||||
const context = useContext(IntegrationContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useIntegrationsWithInteractAccess must be used within an IntegrationProvider");
|
||||
}
|
||||
|
||||
const context = useIntegrationContext();
|
||||
return context.integrations.filter((integration) => integration.permissions.hasInteractAccess);
|
||||
};
|
||||
|
||||
export const useIntegrationsWithFullAccess = () => {
|
||||
const context = useContext(IntegrationContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useIntegrationsWithFullAccess must be used within an IntegrationProvider");
|
||||
}
|
||||
|
||||
const context = useIntegrationContext();
|
||||
return context.integrations.filter((integration) => integration.permissions.hasFullAccess);
|
||||
};
|
||||
@@ -13,6 +13,7 @@
|
||||
},
|
||||
"exports": {
|
||||
"./context": "./src/context.tsx",
|
||||
"./layout-selection": "./src/layout-selection.ts",
|
||||
"./updater": "./src/updater.ts",
|
||||
"./edit-mode": "./src/edit-mode.tsx"
|
||||
},
|
||||
|
||||
@@ -1,25 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||
import { createContext, useContext, useEffect, useSyncExternalStore } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
|
||||
import { boardViewportWidthCookieName, getLayoutIdForViewportWidth } from "./layout-selection";
|
||||
import { updateBoardName } from "./updater";
|
||||
|
||||
const BoardContext = createContext<{
|
||||
board: RouterOutputs["board"]["getBoardByName"];
|
||||
currentLayout: string;
|
||||
initialViewportWidth: number;
|
||||
} | null>(null);
|
||||
|
||||
const subscribeToViewport = (onStoreChange: () => void) => {
|
||||
if (typeof window === "undefined") return () => undefined;
|
||||
|
||||
let animationFrame = 0;
|
||||
const handleResize = () => {
|
||||
if (animationFrame !== 0) return;
|
||||
animationFrame = window.requestAnimationFrame(() => {
|
||||
animationFrame = 0;
|
||||
persistViewportWidth(getViewportWidth());
|
||||
onStoreChange();
|
||||
});
|
||||
};
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
window.cancelAnimationFrame(animationFrame);
|
||||
};
|
||||
};
|
||||
|
||||
export const BoardProvider = ({
|
||||
children,
|
||||
initialBoard,
|
||||
initialLayoutId,
|
||||
initialViewportWidth,
|
||||
}: PropsWithChildren<{
|
||||
initialBoard: RouterOutputs["board"]["getBoardByName"];
|
||||
initialLayoutId: string;
|
||||
initialViewportWidth: number;
|
||||
}>) => {
|
||||
const { data } = clientApi.board.getBoardByName.useQuery({ name: initialBoard.name }, { initialData: initialBoard });
|
||||
const { data } = clientApi.board.getBoardByName.useQuery(
|
||||
{ name: initialBoard.name },
|
||||
{
|
||||
initialData: initialBoard,
|
||||
// The route already fetched this board, so keep it fresh long enough to
|
||||
// avoid an immediate duplicate request while still surfacing remote edits
|
||||
// when a user later returns to the board.
|
||||
staleTime: 30_000,
|
||||
},
|
||||
);
|
||||
const currentLayout = useSyncExternalStore(
|
||||
subscribeToViewport,
|
||||
() => getCurrentLayout(data),
|
||||
() => initialLayoutId,
|
||||
);
|
||||
|
||||
// Update the board name so it can be used within updateBoard method
|
||||
updateBoardName(initialBoard.name);
|
||||
@@ -35,10 +76,15 @@ export const BoardProvider = ({
|
||||
};
|
||||
}, [pathname, utils, initialBoard.name]);
|
||||
|
||||
useEffect(() => {
|
||||
persistViewportWidth(getViewportWidth());
|
||||
}, []);
|
||||
return (
|
||||
<BoardContext.Provider
|
||||
value={{
|
||||
board: data,
|
||||
currentLayout,
|
||||
initialViewportWidth,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
@@ -63,34 +109,28 @@ export const useOptionalBoard = () => {
|
||||
};
|
||||
|
||||
export const getCurrentLayout = (board: RouterOutputs["board"]["getBoardByName"]) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
if (typeof window === "undefined") return board.layouts.at(0)!.id;
|
||||
|
||||
const sortedLayouts = board.layouts.toSorted((layoutA, layoutB) => layoutB.breakpoint - layoutA.breakpoint);
|
||||
|
||||
// Fallback to smallest if none exists with breakpoint smaller than window width
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return sortedLayouts.find((layout) => layout.breakpoint <= window.innerWidth)?.id ?? sortedLayouts.at(0)!.id;
|
||||
const viewportWidth = typeof window === "undefined" ? 0 : getViewportWidth();
|
||||
return getLayoutIdForViewportWidth(board.layouts, viewportWidth);
|
||||
};
|
||||
|
||||
export const useCurrentLayout = () => {
|
||||
const board = useRequiredBoard();
|
||||
const [currentLayout, setCurrentLayout] = useState(getCurrentLayout(board));
|
||||
const context = useContext(BoardContext);
|
||||
|
||||
const onResize = useCallback(() => {
|
||||
setCurrentLayout(getCurrentLayout(board));
|
||||
}, [board]);
|
||||
if (!context) {
|
||||
throw new Error("Board is required");
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
window.addEventListener("resize", onResize);
|
||||
return context.currentLayout;
|
||||
};
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", onResize);
|
||||
};
|
||||
}, [onResize]);
|
||||
export const useInitialViewportWidth = () => {
|
||||
const context = useContext(BoardContext);
|
||||
|
||||
return currentLayout;
|
||||
if (!context) {
|
||||
throw new Error("Board is required");
|
||||
}
|
||||
|
||||
return context.initialViewportWidth;
|
||||
};
|
||||
|
||||
export const getBoardLayouts = (board: RouterOutputs["board"]["getBoardByName"]) =>
|
||||
@@ -101,3 +141,9 @@ export const useLayouts = () => {
|
||||
|
||||
return getBoardLayouts(board);
|
||||
};
|
||||
|
||||
const persistViewportWidth = (viewportWidth: number) => {
|
||||
document.cookie = `${boardViewportWidthCookieName}=${Math.round(viewportWidth)}; Path=/; Max-Age=31536000; SameSite=Lax`;
|
||||
};
|
||||
|
||||
const getViewportWidth = () => document.documentElement.clientWidth || window.innerWidth;
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getLayoutIdForViewportWidth } from "./layout-selection";
|
||||
|
||||
const layouts = [
|
||||
{ id: "mobile", breakpoint: 0 },
|
||||
{ id: "tablet", breakpoint: 768 },
|
||||
{ id: "desktop", breakpoint: 1280 },
|
||||
];
|
||||
|
||||
describe("getLayoutIdForViewportWidth", () => {
|
||||
test.each([
|
||||
[390, "mobile"],
|
||||
[768, "tablet"],
|
||||
[1279, "tablet"],
|
||||
[1920, "desktop"],
|
||||
])("selects the responsive layout for %ipx", (width, expected) => {
|
||||
expect(getLayoutIdForViewportWidth(layouts, width)).toBe(expected);
|
||||
});
|
||||
|
||||
test("falls back to the smallest layout for invalid widths", () => {
|
||||
expect(getLayoutIdForViewportWidth(layouts, Number.NaN)).toBe("mobile");
|
||||
});
|
||||
|
||||
test("rejects boards without layouts", () => {
|
||||
expect(() => getLayoutIdForViewportWidth([], 1280)).toThrow("Expected the board to contain a layout");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
export const boardViewportWidthCookieName = "homarr-viewport-width";
|
||||
|
||||
interface ResponsiveLayout {
|
||||
id: string;
|
||||
breakpoint: number;
|
||||
}
|
||||
|
||||
export const getLayoutIdForViewportWidth = (
|
||||
layouts: readonly ResponsiveLayout[],
|
||||
viewportWidth: number,
|
||||
): string => {
|
||||
if (layouts.length === 0) {
|
||||
throw new Error("Expected the board to contain a layout");
|
||||
}
|
||||
|
||||
const normalizedWidth = Number.isFinite(viewportWidth) ? Math.max(0, viewportWidth) : 0;
|
||||
const sortedLayouts = layouts.toSorted((layoutA, layoutB) => layoutB.breakpoint - layoutA.breakpoint);
|
||||
const selected = sortedLayouts.find((layout) => layout.breakpoint <= normalizedWidth) ?? sortedLayouts.at(-1);
|
||||
if (!selected) {
|
||||
throw new Error("Expected the board to contain a layout");
|
||||
}
|
||||
|
||||
return selected.id;
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
/** @vitest-environment node */
|
||||
|
||||
import { parse, stringify } from "superjson";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { and, eq } from "@homarr/db";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import { categorySectionOptionsSchema, dynamicSectionOptionsSchema } from "@homarr/validation/shared";
|
||||
|
||||
import {
|
||||
boards,
|
||||
itemLayouts,
|
||||
items,
|
||||
layouts,
|
||||
sectionCollapseStates,
|
||||
sectionLayouts,
|
||||
sections,
|
||||
users,
|
||||
} from "../../schema";
|
||||
import { migrateCategoriesToUnifiedSectionsAsync } from "./0004_unify_sections_and_gutters";
|
||||
|
||||
describe("unified section migration", () => {
|
||||
test("converts a rail category into a resizable section inside a board gutter", async () => {
|
||||
const db = createDb();
|
||||
const userId = createId();
|
||||
const boardId = createId();
|
||||
const layoutId = createId();
|
||||
const mainRootId = createId();
|
||||
const categoryId = createId();
|
||||
const itemId = createId();
|
||||
|
||||
await db.insert(users).values({ id: userId });
|
||||
await db.insert(boards).values({ id: boardId, name: "migration", creatorId: userId });
|
||||
await db.insert(layouts).values({ id: layoutId, name: "Base", boardId, columnCount: 8, breakpoint: 0 });
|
||||
await db.insert(sections).values([
|
||||
{ id: mainRootId, boardId, kind: "empty", xOffset: 0, yOffset: 0 },
|
||||
{
|
||||
id: categoryId,
|
||||
boardId,
|
||||
kind: "category",
|
||||
name: "Pinned apps",
|
||||
xOffset: 0,
|
||||
yOffset: 1,
|
||||
options: stringify(
|
||||
categorySectionOptionsSchema.parse({
|
||||
railPlacement: "left",
|
||||
columnCount: 2,
|
||||
}),
|
||||
),
|
||||
},
|
||||
]);
|
||||
await db.insert(items).values({ id: itemId, boardId, kind: "clock" });
|
||||
await db.insert(sectionCollapseStates).values({
|
||||
sectionId: categoryId,
|
||||
userId,
|
||||
// Legacy categories stored true when the section was open.
|
||||
collapsed: true,
|
||||
});
|
||||
await db.insert(itemLayouts).values({
|
||||
itemId,
|
||||
layoutId,
|
||||
sectionId: categoryId,
|
||||
xOffset: 1,
|
||||
yOffset: 0,
|
||||
width: 1,
|
||||
height: 1,
|
||||
});
|
||||
|
||||
await migrateCategoriesToUnifiedSectionsAsync(db as unknown as Database);
|
||||
|
||||
const migratedLayout = await db.query.layouts.findFirst({ where: eq(layouts.id, layoutId) });
|
||||
expect(migratedLayout?.leftGutterColumnCount).toBe(2);
|
||||
|
||||
const gutterRoot = await db.query.sections.findFirst({
|
||||
where: and(eq(sections.boardId, boardId), eq(sections.kind, "empty"), eq(sections.xOffset, -1)),
|
||||
});
|
||||
expect(gutterRoot).toBeDefined();
|
||||
|
||||
const migratedCategory = await db.query.sections.findFirst({ where: eq(sections.id, categoryId) });
|
||||
expect(migratedCategory?.kind).toBe("dynamic");
|
||||
expect(dynamicSectionOptionsSchema.parse(parse(migratedCategory?.options ?? "{}")).title).toBe("Pinned apps");
|
||||
const migratedCollapseState = await db.query.sectionCollapseStates.findFirst({
|
||||
where: and(eq(sectionCollapseStates.sectionId, categoryId), eq(sectionCollapseStates.userId, userId)),
|
||||
});
|
||||
expect(migratedCollapseState?.collapsed).toBe(false);
|
||||
|
||||
const categoryLayout = await db.query.sectionLayouts.findFirst({
|
||||
where: and(eq(sectionLayouts.sectionId, categoryId), eq(sectionLayouts.layoutId, layoutId)),
|
||||
});
|
||||
expect(categoryLayout).toMatchObject({
|
||||
parentSectionId: gutterRoot?.id,
|
||||
width: 2,
|
||||
height: 1,
|
||||
});
|
||||
|
||||
const nestedItem = await db.query.itemLayouts.findFirst({
|
||||
where: and(eq(itemLayouts.itemId, itemId), eq(itemLayouts.layoutId, layoutId)),
|
||||
});
|
||||
expect(nestedItem?.sectionId).toBe(categoryId);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,499 @@
|
||||
import { parse, stringify } from "superjson";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
import type { BoardLane } from "@homarr/definitions";
|
||||
import { emptySuperJSON, getRootSectionLane, rootSectionOffsets } from "@homarr/definitions";
|
||||
import { categorySectionOptionsSchema, dynamicSectionOptionsSchema } from "@homarr/validation/shared";
|
||||
|
||||
import { and, eq, handleTransactionsAsync, inArray } from "../..";
|
||||
import type { Database } from "../..";
|
||||
import type { HomarrDatabase } from "../../driver";
|
||||
import { itemLayouts, layouts, sectionCollapseStates, sectionLayouts, sections } from "../../schema";
|
||||
|
||||
interface MigrationPlacement {
|
||||
id: string;
|
||||
type: "item" | "section";
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
interface MigrationBoard {
|
||||
items: {
|
||||
id: string;
|
||||
layouts: {
|
||||
layoutId: string;
|
||||
sectionId: string;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}[];
|
||||
}[];
|
||||
sections: {
|
||||
id: string;
|
||||
kind: string;
|
||||
layouts: {
|
||||
layoutId: string;
|
||||
parentSectionId: string | null;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}[];
|
||||
}[];
|
||||
}
|
||||
|
||||
interface MigrationOperation {
|
||||
applyAsync(db: Database): Promise<void>;
|
||||
applySync(db: HomarrDatabase): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Categories and dynamic sections used to be separate concepts. Categories
|
||||
* were full-width roots and also doubled as side rails. Convert every category
|
||||
* into the same resizable section used by the canvas, and create internal
|
||||
* gutter roots for the board-level left/right sidebar settings.
|
||||
*/
|
||||
export async function migrateCategoriesToUnifiedSectionsAsync(db: Database) {
|
||||
const existingBoards = await db.query.boards.findMany({
|
||||
with: {
|
||||
layouts: true,
|
||||
sections: {
|
||||
with: {
|
||||
collapseStates: true,
|
||||
layouts: true,
|
||||
},
|
||||
},
|
||||
items: {
|
||||
with: {
|
||||
layouts: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let migratedCategoryCount = 0;
|
||||
|
||||
for (const board of existingBoards) {
|
||||
const categories = board.sections.filter((section) => section.kind === "category");
|
||||
if (categories.length === 0) continue;
|
||||
const operations: MigrationOperation[] = [];
|
||||
|
||||
const categoryDetails = categories.map((category) => {
|
||||
const options = categorySectionOptionsSchema.parse(parse(category.options ?? emptySuperJSON));
|
||||
return {
|
||||
category,
|
||||
lane: options.railPlacement as BoardLane,
|
||||
options,
|
||||
};
|
||||
});
|
||||
const emptyRoots = board.sections.filter((section) => section.kind === "empty");
|
||||
let mainRoot = emptyRoots.find((section) => getRootSectionLane(section.xOffset) === "main");
|
||||
if (!mainRoot) {
|
||||
const id = createId();
|
||||
operations.push(
|
||||
insertSectionsOperation([
|
||||
{
|
||||
id,
|
||||
boardId: board.id,
|
||||
kind: "empty",
|
||||
xOffset: rootSectionOffsets.main,
|
||||
yOffset: 0,
|
||||
options: emptySuperJSON,
|
||||
},
|
||||
]),
|
||||
);
|
||||
mainRoot = {
|
||||
id,
|
||||
boardId: board.id,
|
||||
kind: "empty",
|
||||
xOffset: rootSectionOffsets.main,
|
||||
yOffset: 0,
|
||||
name: null,
|
||||
options: emptySuperJSON,
|
||||
collapseStates: [],
|
||||
layouts: [],
|
||||
};
|
||||
emptyRoots.push(mainRoot);
|
||||
}
|
||||
|
||||
const rootByLane = new Map<BoardLane, (typeof emptyRoots)[number]>([["main", mainRoot]]);
|
||||
for (const lane of ["left", "right"] as const) {
|
||||
const existing = emptyRoots.find((section) => getRootSectionLane(section.xOffset) === lane);
|
||||
if (existing) {
|
||||
rootByLane.set(lane, existing);
|
||||
continue;
|
||||
}
|
||||
if (!categoryDetails.some((detail) => detail.lane === lane)) continue;
|
||||
|
||||
const id = createId();
|
||||
operations.push(
|
||||
insertSectionsOperation([
|
||||
{
|
||||
id,
|
||||
boardId: board.id,
|
||||
kind: "empty",
|
||||
xOffset: rootSectionOffsets[lane],
|
||||
yOffset: 0,
|
||||
options: emptySuperJSON,
|
||||
},
|
||||
]),
|
||||
);
|
||||
rootByLane.set(lane, {
|
||||
id,
|
||||
boardId: board.id,
|
||||
kind: "empty",
|
||||
xOffset: rootSectionOffsets[lane],
|
||||
yOffset: 0,
|
||||
name: null,
|
||||
options: emptySuperJSON,
|
||||
collapseStates: [],
|
||||
layouts: [],
|
||||
});
|
||||
}
|
||||
|
||||
for (const layout of board.layouts) {
|
||||
const requestedLeft = Math.max(
|
||||
0,
|
||||
...categoryDetails.filter((detail) => detail.lane === "left").map((detail) => detail.options.columnCount),
|
||||
);
|
||||
const requestedRight = Math.max(
|
||||
0,
|
||||
...categoryDetails.filter((detail) => detail.lane === "right").map((detail) => detail.options.columnCount),
|
||||
);
|
||||
const left = Math.min(3, requestedLeft, Math.max(0, layout.columnCount - 1));
|
||||
const right = Math.min(3, requestedRight, Math.max(0, layout.columnCount - left - 1));
|
||||
const widths = {
|
||||
left,
|
||||
main: Math.max(1, layout.columnCount - left - right),
|
||||
right,
|
||||
} satisfies Record<BoardLane, number>;
|
||||
|
||||
operations.push(updateLayoutGuttersOperation(layout.id, left, right));
|
||||
|
||||
for (const lane of ["left", "main", "right"] as const) {
|
||||
const targetRoot = rootByLane.get(lane);
|
||||
const laneWidth = widths[lane];
|
||||
if (!targetRoot || laneWidth === 0) continue;
|
||||
|
||||
const sourceRoots = emptyRoots
|
||||
.filter((section) => getRootSectionLane(section.xOffset) === lane)
|
||||
.toSorted((first, second) => (first.yOffset ?? 0) - (second.yOffset ?? 0));
|
||||
const sourceBlocks = [
|
||||
...sourceRoots.map((root) => ({
|
||||
id: root.id,
|
||||
yOffset: root.yOffset ?? 0,
|
||||
type: "root" as const,
|
||||
})),
|
||||
...categoryDetails
|
||||
.filter((detail) => detail.lane === lane)
|
||||
.map((detail) => ({
|
||||
id: detail.category.id,
|
||||
yOffset: detail.category.yOffset ?? 0,
|
||||
type: "category" as const,
|
||||
detail,
|
||||
})),
|
||||
].toSorted(
|
||||
(first, second) =>
|
||||
first.yOffset - second.yOffset ||
|
||||
(first.type === second.type ? first.id.localeCompare(second.id) : first.type === "category" ? -1 : 1),
|
||||
);
|
||||
|
||||
let nextRow = 0;
|
||||
for (const block of sourceBlocks) {
|
||||
if (block.type === "category") {
|
||||
const content = getDirectPlacements(board, layout.id, block.id);
|
||||
const packedContent = packPlacements(content, laneWidth);
|
||||
operations.push(...persistPlacementOperations(layout.id, block.id, packedContent));
|
||||
const height = Math.max(1, getPlacementRowCount(packedContent));
|
||||
const existingLayout = block.detail.category.layouts.find((candidate) => candidate.layoutId === layout.id);
|
||||
const values = {
|
||||
sectionId: block.id,
|
||||
layoutId: layout.id,
|
||||
parentSectionId: targetRoot.id,
|
||||
xOffset: 0,
|
||||
yOffset: nextRow,
|
||||
width: laneWidth,
|
||||
height,
|
||||
};
|
||||
if (existingLayout) {
|
||||
operations.push(updateSectionLayoutOperation(block.id, layout.id, values));
|
||||
} else {
|
||||
operations.push(insertSectionLayoutOperation(values));
|
||||
}
|
||||
nextRow += height;
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = getDirectPlacements(board, layout.id, block.id);
|
||||
const relativePackedContent = packPlacements(content, laneWidth);
|
||||
const packedContent = relativePackedContent.map((placement) => ({
|
||||
...placement,
|
||||
y: placement.y + nextRow,
|
||||
}));
|
||||
operations.push(...persistPlacementOperations(layout.id, targetRoot.id, packedContent));
|
||||
nextRow += getPlacementRowCount(relativePackedContent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const { category, options } of categoryDetails) {
|
||||
const legacyCollapseStates = category.collapseStates;
|
||||
for (const state of legacyCollapseStates) {
|
||||
operations.push(updateCollapseStateOperation(category.id, state.userId, !state.collapsed));
|
||||
}
|
||||
|
||||
operations.push(
|
||||
updateSectionOperation(category.id, {
|
||||
kind: "dynamic",
|
||||
xOffset: null,
|
||||
yOffset: null,
|
||||
name: null,
|
||||
options: stringify(
|
||||
dynamicSectionOptionsSchema.parse({
|
||||
title: (category.name ?? "").slice(0, 64),
|
||||
customCssClasses: [],
|
||||
borderColor: "",
|
||||
showLabel: options.showLabel,
|
||||
collapsible: options.collapsible,
|
||||
showOpenAll: options.showOpenAll,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
);
|
||||
migratedCategoryCount += 1;
|
||||
}
|
||||
|
||||
const retainedRootIds = new Set(Array.from(rootByLane.values(), (root) => root.id));
|
||||
const obsoleteRootIds = emptyRoots.filter((root) => !retainedRootIds.has(root.id)).map((root) => root.id);
|
||||
if (obsoleteRootIds.length > 0) {
|
||||
operations.push(deleteSectionsOperation(obsoleteRootIds));
|
||||
}
|
||||
|
||||
await executeBoardMigrationOperationsAsync(db, operations);
|
||||
}
|
||||
|
||||
if (migratedCategoryCount > 0) {
|
||||
console.log(`Migrated categories to unified sections count="${migratedCategoryCount}"`);
|
||||
}
|
||||
}
|
||||
|
||||
const getDirectPlacements = (board: MigrationBoard, layoutId: string, sectionId: string): MigrationPlacement[] => [
|
||||
...board.items.flatMap((item) => {
|
||||
const layout = item.layouts.find(
|
||||
(candidate) => candidate.layoutId === layoutId && candidate.sectionId === sectionId,
|
||||
);
|
||||
return layout
|
||||
? [
|
||||
{
|
||||
id: item.id,
|
||||
type: "item" as const,
|
||||
x: layout.xOffset,
|
||||
y: layout.yOffset,
|
||||
w: layout.width,
|
||||
h: layout.height,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
...board.sections.flatMap((section) => {
|
||||
if (section.kind !== "dynamic") return [];
|
||||
const layout = section.layouts.find(
|
||||
(candidate) => candidate.layoutId === layoutId && candidate.parentSectionId === sectionId,
|
||||
);
|
||||
return layout
|
||||
? [
|
||||
{
|
||||
id: section.id,
|
||||
type: "section" as const,
|
||||
x: layout.xOffset,
|
||||
y: layout.yOffset,
|
||||
w: layout.width,
|
||||
h: layout.height,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
];
|
||||
|
||||
const executeBoardMigrationOperationsAsync = async (db: Database, operations: readonly MigrationOperation[]) => {
|
||||
await handleTransactionsAsync(db, {
|
||||
async handleAsync(transactionDb) {
|
||||
await transactionDb.transaction(async (transaction) => {
|
||||
for (const operation of operations) {
|
||||
await operation.applyAsync(transaction as unknown as Database);
|
||||
}
|
||||
});
|
||||
},
|
||||
handleSync(sqliteDb) {
|
||||
sqliteDb.transaction((transaction) => {
|
||||
for (const operation of operations) {
|
||||
operation.applySync(transaction);
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const insertSectionsOperation = (values: (typeof sections.$inferInsert)[]): MigrationOperation => ({
|
||||
async applyAsync(db) {
|
||||
await db.insert(sections).values(values);
|
||||
},
|
||||
applySync(db) {
|
||||
db.insert(sections).values(values).run();
|
||||
},
|
||||
});
|
||||
|
||||
const updateLayoutGuttersOperation = (
|
||||
layoutId: string,
|
||||
leftGutterColumnCount: number,
|
||||
rightGutterColumnCount: number,
|
||||
): MigrationOperation => ({
|
||||
async applyAsync(db) {
|
||||
await db.update(layouts).set({ leftGutterColumnCount, rightGutterColumnCount }).where(eq(layouts.id, layoutId));
|
||||
},
|
||||
applySync(db) {
|
||||
db.update(layouts).set({ leftGutterColumnCount, rightGutterColumnCount }).where(eq(layouts.id, layoutId)).run();
|
||||
},
|
||||
});
|
||||
|
||||
const persistPlacementOperations = (
|
||||
layoutId: string,
|
||||
sectionId: string,
|
||||
placements: readonly MigrationPlacement[],
|
||||
): MigrationOperation[] =>
|
||||
placements.map((placement) => {
|
||||
const values = {
|
||||
xOffset: placement.x,
|
||||
yOffset: placement.y,
|
||||
width: placement.w,
|
||||
height: placement.h,
|
||||
};
|
||||
if (placement.type === "item") {
|
||||
return {
|
||||
async applyAsync(db) {
|
||||
await db
|
||||
.update(itemLayouts)
|
||||
.set({ ...values, sectionId })
|
||||
.where(and(eq(itemLayouts.itemId, placement.id), eq(itemLayouts.layoutId, layoutId)));
|
||||
},
|
||||
applySync(db) {
|
||||
db.update(itemLayouts)
|
||||
.set({ ...values, sectionId })
|
||||
.where(and(eq(itemLayouts.itemId, placement.id), eq(itemLayouts.layoutId, layoutId)))
|
||||
.run();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
async applyAsync(db) {
|
||||
await db
|
||||
.update(sectionLayouts)
|
||||
.set({ ...values, parentSectionId: sectionId })
|
||||
.where(and(eq(sectionLayouts.sectionId, placement.id), eq(sectionLayouts.layoutId, layoutId)));
|
||||
},
|
||||
applySync(db) {
|
||||
db.update(sectionLayouts)
|
||||
.set({ ...values, parentSectionId: sectionId })
|
||||
.where(and(eq(sectionLayouts.sectionId, placement.id), eq(sectionLayouts.layoutId, layoutId)))
|
||||
.run();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const updateSectionLayoutOperation = (
|
||||
sectionId: string,
|
||||
layoutId: string,
|
||||
values: typeof sectionLayouts.$inferInsert,
|
||||
): MigrationOperation => ({
|
||||
async applyAsync(db) {
|
||||
await db
|
||||
.update(sectionLayouts)
|
||||
.set(values)
|
||||
.where(and(eq(sectionLayouts.sectionId, sectionId), eq(sectionLayouts.layoutId, layoutId)));
|
||||
},
|
||||
applySync(db) {
|
||||
db.update(sectionLayouts)
|
||||
.set(values)
|
||||
.where(and(eq(sectionLayouts.sectionId, sectionId), eq(sectionLayouts.layoutId, layoutId)))
|
||||
.run();
|
||||
},
|
||||
});
|
||||
|
||||
const insertSectionLayoutOperation = (values: typeof sectionLayouts.$inferInsert): MigrationOperation => ({
|
||||
async applyAsync(db) {
|
||||
await db.insert(sectionLayouts).values(values);
|
||||
},
|
||||
applySync(db) {
|
||||
db.insert(sectionLayouts).values(values).run();
|
||||
},
|
||||
});
|
||||
|
||||
const updateCollapseStateOperation = (sectionId: string, userId: string, collapsed: boolean): MigrationOperation => ({
|
||||
async applyAsync(db) {
|
||||
await db
|
||||
.update(sectionCollapseStates)
|
||||
.set({ collapsed })
|
||||
.where(and(eq(sectionCollapseStates.sectionId, sectionId), eq(sectionCollapseStates.userId, userId)));
|
||||
},
|
||||
applySync(db) {
|
||||
db.update(sectionCollapseStates)
|
||||
.set({ collapsed })
|
||||
.where(and(eq(sectionCollapseStates.sectionId, sectionId), eq(sectionCollapseStates.userId, userId)))
|
||||
.run();
|
||||
},
|
||||
});
|
||||
|
||||
const updateSectionOperation = (
|
||||
sectionId: string,
|
||||
values: Partial<typeof sections.$inferInsert>,
|
||||
): MigrationOperation => ({
|
||||
async applyAsync(db) {
|
||||
await db.update(sections).set(values).where(eq(sections.id, sectionId));
|
||||
},
|
||||
applySync(db) {
|
||||
db.update(sections).set(values).where(eq(sections.id, sectionId)).run();
|
||||
},
|
||||
});
|
||||
|
||||
const deleteSectionsOperation = (sectionIds: string[]): MigrationOperation => ({
|
||||
async applyAsync(db) {
|
||||
await db.delete(sections).where(inArray(sections.id, sectionIds));
|
||||
},
|
||||
applySync(db) {
|
||||
db.delete(sections).where(inArray(sections.id, sectionIds)).run();
|
||||
},
|
||||
});
|
||||
|
||||
const packPlacements = (placements: readonly MigrationPlacement[], columnCount: number): MigrationPlacement[] => {
|
||||
const placed: MigrationPlacement[] = [];
|
||||
const candidates = placements
|
||||
.map((placement) => ({
|
||||
...placement,
|
||||
x: Math.min(Math.max(0, placement.x), Math.max(0, columnCount - Math.min(columnCount, placement.w))),
|
||||
y: Math.max(0, placement.y),
|
||||
w: Math.min(columnCount, Math.max(1, placement.w)),
|
||||
h: Math.max(1, placement.h),
|
||||
}))
|
||||
.toSorted((first, second) => first.y - second.y || first.x - second.x || first.id.localeCompare(second.id));
|
||||
|
||||
for (const candidate of candidates) {
|
||||
let row = 0;
|
||||
while (placed.some((placement) => overlaps({ ...candidate, y: row }, placement))) row += 1;
|
||||
placed.push({ ...candidate, y: row });
|
||||
}
|
||||
|
||||
return placed;
|
||||
};
|
||||
|
||||
const overlaps = (first: MigrationPlacement, second: MigrationPlacement) =>
|
||||
first.x < second.x + second.w &&
|
||||
first.x + first.w > second.x &&
|
||||
first.y < second.y + second.h &&
|
||||
first.y + first.h > second.y;
|
||||
|
||||
const getPlacementRowCount = (placements: readonly MigrationPlacement[]) =>
|
||||
Math.max(0, ...placements.map((placement) => placement.y + placement.h));
|
||||
@@ -3,10 +3,12 @@ import { migrateReleaseWidgetProviderToOptionsAsync } from "./0000_release_widge
|
||||
import { migrateOpnsenseCredentialsAsync } from "./0001_opnsense_credentials";
|
||||
import { migrateAppWidgetShowDescriptionTooltipToDisplayModeAsync } from "./0002_app_widget_show_description_tooltip_to_display_mode";
|
||||
import { migrateWidgetOnlyIntegrationsToOptionsAsync } from "./0003_remove_widget_only_integrations";
|
||||
import { migrateCategoriesToUnifiedSectionsAsync } from "./0004_unify_sections_and_gutters";
|
||||
|
||||
export const applyCustomMigrationsAsync = async (db: Database) => {
|
||||
await migrateReleaseWidgetProviderToOptionsAsync(db);
|
||||
await migrateOpnsenseCredentialsAsync(db);
|
||||
await migrateAppWidgetShowDescriptionTooltipToDisplayModeAsync(db);
|
||||
await migrateWidgetOnlyIntegrationsToOptionsAsync(db);
|
||||
await migrateCategoriesToUnifiedSectionsAsync(db);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `layout` ADD `left_gutter_column_count` tinyint DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `layout` ADD `right_gutter_column_count` tinyint DEFAULT 0 NOT NULL;
|
||||
@@ -316,6 +316,13 @@
|
||||
"when": 1784305537309,
|
||||
"tag": "0044_custom_widget_v2_tables",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 45,
|
||||
"version": "5",
|
||||
"when": 1786212549933,
|
||||
"tag": "0045_add_layout_gutters",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "layout" ADD COLUMN "left_gutter_column_count" smallint DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "layout" ADD COLUMN "right_gutter_column_count" smallint DEFAULT 0 NOT NULL;
|
||||
@@ -78,6 +78,13 @@
|
||||
"when": 1784305412862,
|
||||
"tag": "0010_custom_widget_v2_tables",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1786212457549,
|
||||
"tag": "0011_add_layout_gutters",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `layout` ADD `left_gutter_column_count` integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `layout` ADD `right_gutter_column_count` integer DEFAULT 0 NOT NULL;
|
||||
@@ -302,6 +302,13 @@
|
||||
"when": 1784305283910,
|
||||
"tag": "0042_custom_widget_v2_tables",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 43,
|
||||
"version": "6",
|
||||
"when": 1786212459480,
|
||||
"tag": "0043_add_layout_gutters",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -340,6 +340,8 @@ export const layouts = mysqlTable("layout", {
|
||||
.notNull()
|
||||
.references(() => boards.id, { onDelete: "cascade" }),
|
||||
columnCount: tinyint().notNull(),
|
||||
leftGutterColumnCount: tinyint().notNull().default(0),
|
||||
rightGutterColumnCount: tinyint().notNull().default(0),
|
||||
breakpoint: smallint().notNull().default(0),
|
||||
});
|
||||
|
||||
|
||||
@@ -339,6 +339,8 @@ export const layouts = pgTable("layout", {
|
||||
.notNull()
|
||||
.references(() => boards.id, { onDelete: "cascade" }),
|
||||
columnCount: smallint().notNull(),
|
||||
leftGutterColumnCount: smallint().notNull().default(0),
|
||||
rightGutterColumnCount: smallint().notNull().default(0),
|
||||
breakpoint: smallint().notNull().default(0),
|
||||
});
|
||||
|
||||
|
||||
@@ -324,6 +324,8 @@ export const layouts = sqliteTable("layout", {
|
||||
.notNull()
|
||||
.references(() => boards.id, { onDelete: "cascade" }),
|
||||
columnCount: int().notNull(),
|
||||
leftGutterColumnCount: int().notNull().default(0),
|
||||
rightGutterColumnCount: int().notNull().default(0),
|
||||
breakpoint: int().notNull().default(0),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,2 +1,33 @@
|
||||
export const sectionKinds = ["category", "empty", "dynamic"] as const;
|
||||
export type SectionKind = (typeof sectionKinds)[number];
|
||||
|
||||
export const boardLanes = ["left", "main", "right"] as const;
|
||||
export type BoardLane = (typeof boardLanes)[number];
|
||||
|
||||
export const rootSectionOffsets = {
|
||||
left: -1,
|
||||
main: 0,
|
||||
right: 1,
|
||||
} as const satisfies Record<BoardLane, number>;
|
||||
|
||||
export const getRootSectionLane = (xOffset: number | null | undefined): BoardLane => {
|
||||
if (xOffset === rootSectionOffsets.left) return "left";
|
||||
if (xOffset === rootSectionOffsets.right) return "right";
|
||||
return "main";
|
||||
};
|
||||
|
||||
export const getBoardLaneColumnCount = (
|
||||
layout: {
|
||||
columnCount: number;
|
||||
leftGutterColumnCount?: number | null;
|
||||
rightGutterColumnCount?: number | null;
|
||||
},
|
||||
lane: BoardLane,
|
||||
) => {
|
||||
const total = Math.max(1, Math.floor(layout.columnCount));
|
||||
const left = Math.min(Math.max(0, Math.floor(layout.leftGutterColumnCount ?? 0)), total - 1);
|
||||
const right = Math.min(Math.max(0, Math.floor(layout.rightGutterColumnCount ?? 0)), total - left - 1);
|
||||
if (lane === "left") return left;
|
||||
if (lane === "right") return right;
|
||||
return total - left - right;
|
||||
};
|
||||
@@ -1642,6 +1642,10 @@
|
||||
"name": "Traefik",
|
||||
"description": "Cloud-native reverse proxy and load balancer"
|
||||
},
|
||||
"wud": {
|
||||
"name": "What's Up Docker",
|
||||
"description": "Monitor which of your Docker containers have image updates available"
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "Paperless-ngx",
|
||||
"description": "Document management system for archiving and organizing scanned documents"
|
||||
@@ -1857,10 +1861,35 @@
|
||||
}
|
||||
},
|
||||
"section": {
|
||||
"dynamic": {
|
||||
"option": {
|
||||
"showLabel": {
|
||||
"label": "Show label",
|
||||
"description": "Display the container label above its contents."
|
||||
},
|
||||
"collapsible": {
|
||||
"label": "Allow collapse",
|
||||
"description": "Let viewers expand and collapse this container."
|
||||
},
|
||||
"showOpenAll": {
|
||||
"label": "Show open-all action",
|
||||
"description": "Show an action that opens every app in this container in new tabs."
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"openAllInNewTabs": "Open all in tabs",
|
||||
"openAllInNewTabsFor": "Open all apps in {name} in tabs"
|
||||
},
|
||||
"openAllInNewTabs": {
|
||||
"title": "Open all in tabs",
|
||||
"text": "Some browsers may block the bulk-opening of tabs for security reasons. Homarr was unable to open all windows, because your browser blocked this action. Please allow \"Open pop-up windows\" and re-try."
|
||||
},
|
||||
"container": {
|
||||
"untitled": "Untitled container",
|
||||
"action": {
|
||||
"create": "New dynamic section",
|
||||
"remove": "Remove dynamic section"
|
||||
"create": "New container",
|
||||
"remove": "Remove container",
|
||||
"expand": "Expand",
|
||||
"collapse": "Collapse"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1874,48 +1903,8 @@
|
||||
}
|
||||
},
|
||||
"remove": {
|
||||
"title": "Remove dynamic section",
|
||||
"message": "Are you sure you want to remove this dynamic section? Items will be moved at the same location in the parent section."
|
||||
}
|
||||
},
|
||||
"category": {
|
||||
"field": {
|
||||
"name": {
|
||||
"label": "Name"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"create": "New category",
|
||||
"edit": "Rename category",
|
||||
"remove": "Remove category",
|
||||
"moveUp": "Move up",
|
||||
"moveDown": "Move down",
|
||||
"createAbove": "New category above",
|
||||
"createBelow": "New category below",
|
||||
"openAllInNewTabs": "Open all in tabs"
|
||||
},
|
||||
"create": {
|
||||
"title": "New category",
|
||||
"submit": "Add category"
|
||||
},
|
||||
"remove": {
|
||||
"title": "Remove category",
|
||||
"message": "Are you sure you want to remove the category {name}?"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Rename category",
|
||||
"submit": "Rename category"
|
||||
},
|
||||
"menu": {
|
||||
"label": {
|
||||
"create": "New category",
|
||||
"changePosition": "Change position",
|
||||
"settings": "Settings"
|
||||
}
|
||||
},
|
||||
"openAllInNewTabs": {
|
||||
"title": "Open all in tabs",
|
||||
"text": "Some browsers may block the bulk-opening of tabs for security reasons. Homarr was unable to open all windows, because your browser blocked this action. Please allow \"Open pop-up windows\" and re-try."
|
||||
"title": "Remove container",
|
||||
"message": "Remove this container? Its contents will move into the surrounding area."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1931,6 +1920,7 @@
|
||||
"menu": {
|
||||
"label": {
|
||||
"settings": "Settings",
|
||||
"settingsFor": "Settings for {name}",
|
||||
"refresh": "Refresh",
|
||||
"options": "Options",
|
||||
"actions": "Actions"
|
||||
@@ -1949,7 +1939,21 @@
|
||||
},
|
||||
"moveResize": {
|
||||
"title": "Move / resize item",
|
||||
"entryLabel": "{name}, column {column}, row {row}",
|
||||
"target": {
|
||||
"numbered": "{name}, destination {index}",
|
||||
"located": "{name}, {location}, destination {index}"
|
||||
},
|
||||
"keyboard": {
|
||||
"instructions": "Press Enter or Space to start keyboard editing. Use arrow keys to move, Shift plus arrow keys to resize, and Escape to stop.",
|
||||
"started": "Keyboard editing started. Use arrow keys to move, Shift plus arrow keys to resize, and Escape to stop.",
|
||||
"stopped": "Keyboard editing stopped.",
|
||||
"boundary": "That change does not fit inside this section."
|
||||
},
|
||||
"field": {
|
||||
"section": {
|
||||
"label": "Section or rail"
|
||||
},
|
||||
"width": {
|
||||
"label": "Width"
|
||||
},
|
||||
@@ -2101,6 +2105,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "What's Up Docker",
|
||||
"description": "Show how many watched containers have updates available.",
|
||||
"title": "What's Up Docker",
|
||||
"monitored": "Monitored",
|
||||
"updatesAvailable": "{count, plural, one {# update available} other {# updates available}}",
|
||||
"updateAvailable": "Update available",
|
||||
"openReleaseNotes": "Open release notes",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": "Show title"
|
||||
},
|
||||
"showRing": {
|
||||
"label": "Show update ring"
|
||||
},
|
||||
"layout": {
|
||||
"label": "Layout",
|
||||
"option": {
|
||||
"horizontal": {
|
||||
"label": "Horizontal"
|
||||
},
|
||||
"vertical": {
|
||||
"label": "Vertical"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "Show container update list",
|
||||
"description": "Lists each monitored container that has an image update available"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dnsHoleSummary": {
|
||||
"name": "DNS Hole Summary",
|
||||
"description": "Displays the summary of your DNS Hole",
|
||||
@@ -2213,6 +2249,16 @@
|
||||
"customDateFormat": {
|
||||
"label": "Custom date format",
|
||||
"description": "Use ISO 8601 to format date (this will override other options)"
|
||||
},
|
||||
"showWeather": {
|
||||
"label": "Show weather",
|
||||
"description": "Display a small animated weather icon and temperature in the corner of the card"
|
||||
},
|
||||
"weatherLocation": {
|
||||
"label": "Weather location"
|
||||
},
|
||||
"isWeatherFormatFahrenheit": {
|
||||
"label": "Use Fahrenheit"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2565,7 +2611,8 @@
|
||||
"sunrise": "Sunrise",
|
||||
"sunset": "Sunset",
|
||||
"maxWindSpeed": "Max wind speed: {maxWindSpeed} {unit}",
|
||||
"maxWindGusts": "Max wind gusts: {maxWindGusts} {unit}"
|
||||
"maxWindGusts": "Max wind gusts: {maxWindGusts} {unit}",
|
||||
"humidity": "Humidity: {humidity}%"
|
||||
},
|
||||
"kind": {
|
||||
"clear": "Clear",
|
||||
@@ -2725,10 +2772,21 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": "Invert sorting"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": "Column order"
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": "Column widths"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Failed to fetch containers stats"
|
||||
"internalServerError": "Failed to fetch containers stats",
|
||||
"layoutSaveTitle": "Table layout not saved",
|
||||
"layoutSaveMessage": "The Docker table layout could not be saved. Try moving or resizing the column again."
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": "No containers found"
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -3022,8 +3080,20 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "Show only currently playing",
|
||||
"description": "Disabling this will not work for plex"
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "Show bitrate",
|
||||
"description": "Show the current playback bitrate next to the location"
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "Show location",
|
||||
"description": "Show whether the stream is on your LAN or WAN"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "{count, plural, one {# active stream} other {# active streams}}",
|
||||
"totalBitrate": "Total: {bitrate}"
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "Currently playing",
|
||||
"user": "User",
|
||||
@@ -3031,6 +3101,9 @@
|
||||
"id": "Id",
|
||||
"status": "Status",
|
||||
"directPlay": "Direct play",
|
||||
"directStream": "Direct stream",
|
||||
"transcodeVideo": "Transcoding video",
|
||||
"transcodeAudio": "Transcoding audio",
|
||||
"transcoding": "Transcoding",
|
||||
"paused": "Paused",
|
||||
"remaining": "{minutes} min left",
|
||||
@@ -3314,10 +3387,11 @@
|
||||
},
|
||||
"immich-albumCarousel": {
|
||||
"name": "Immich Album",
|
||||
"allPhotos": "All photos",
|
||||
"option": {
|
||||
"albumId": {
|
||||
"label": "Album",
|
||||
"description": "Select the Immich album to show in the slideshow."
|
||||
"description": "Select an Immich album or All photos. An empty selection also shows random photos from your full library."
|
||||
},
|
||||
"rotationIntervalSeconds": {
|
||||
"label": "Interval in seconds",
|
||||
@@ -3332,9 +3406,8 @@
|
||||
"description": "Show photos in a random order instead of always starting with the same album order."
|
||||
}
|
||||
},
|
||||
"description": "Slideshow of pictures in your Immich album",
|
||||
"noAlbumSelected": "No album selected, edit widget and choose an album",
|
||||
"noPhotos": "No photos in this album"
|
||||
"description": "Slideshow of pictures from an Immich album or your full library",
|
||||
"noPhotos": "No photos found"
|
||||
},
|
||||
"downloads": {
|
||||
"name": "Download Client",
|
||||
@@ -3376,11 +3449,20 @@
|
||||
"limitPerIntegration": {
|
||||
"label": "Limit items per integration",
|
||||
"description": "This will limit the number of items shown per integration, not globally"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": "Column order"
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": "Column widths"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"noColumns": "Select Columns in Items",
|
||||
"noCommunications": "Can't load data from integration"
|
||||
"noCommunications": "Can't load data from integration",
|
||||
"noItems": "No downloads found",
|
||||
"actionFailed": "Action failed",
|
||||
"actionFailedMessage": "The action could not be completed. Please try again."
|
||||
},
|
||||
"items": {
|
||||
"actions": {
|
||||
@@ -3403,7 +3485,8 @@
|
||||
"detailsTitle": "Current index within client"
|
||||
},
|
||||
"id": {
|
||||
"columnTitle": "Id"
|
||||
"columnTitle": "Id",
|
||||
"detailsTitle": "Item ID"
|
||||
},
|
||||
"integration": {
|
||||
"columnTitle": "Integration"
|
||||
@@ -3437,7 +3520,8 @@
|
||||
},
|
||||
"time": {
|
||||
"columnTitle": "Finish time",
|
||||
"detailsTitle": "Time since/to completion"
|
||||
"detailsTitle": "Time since/to completion",
|
||||
"eta": "ETA"
|
||||
},
|
||||
"type": {
|
||||
"columnTitle": "Type",
|
||||
@@ -3481,7 +3565,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"globalRatio": "Global Ratio"
|
||||
"globalRatio": "Global Ratio",
|
||||
"contextMenu": {
|
||||
"copyName": "Copy name",
|
||||
"copyId": "Copy ID",
|
||||
"clearFilters": "Clear filters"
|
||||
},
|
||||
"footer": {
|
||||
"filter": "Filter downloads"
|
||||
},
|
||||
"stats": {
|
||||
"show": "Show global stats",
|
||||
"hide": "Hide global stats",
|
||||
"overallProgress": "Overall queue progress",
|
||||
"totalSize": "Total queue size",
|
||||
"clients": "{count, plural, one {# client} other {# clients}}"
|
||||
},
|
||||
"disconnected": "(disconnected)"
|
||||
},
|
||||
"mediaReleases": {
|
||||
"name": "Media releases",
|
||||
@@ -4038,18 +4138,6 @@
|
||||
"label": "Show songs",
|
||||
"description": "Show the total number of songs in your music library (Navidrome)"
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "Show now playing",
|
||||
"description": "Show tracks currently being played (Navidrome)"
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "Show now playing list",
|
||||
"description": "Show individual tracks instead of only the active stream count (Navidrome)"
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "Max now playing items",
|
||||
"description": "Maximum number of tracks to show in the now playing list (Navidrome)"
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "Show library count",
|
||||
"description": "Show the number of libraries on your instance (Audiobookshelf)"
|
||||
@@ -4083,8 +4171,6 @@
|
||||
"podcasts": "Podcasts",
|
||||
"listeningTime": "Listening Time",
|
||||
"activeSessions": "Active Sessions",
|
||||
"nowPlaying": "Now Playing",
|
||||
"nowPlayingCount": "{count, plural, one {# stream} other {# streams}}",
|
||||
"error": {
|
||||
"internalServerError": "Failed to fetch audio stats data"
|
||||
}
|
||||
@@ -4261,11 +4347,19 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "Show agent version"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": "Column order"
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": "Column widths"
|
||||
}
|
||||
},
|
||||
"noRecords": "No systems found",
|
||||
"error": {
|
||||
"internalServerError": "Unable to connect to Beszel. Check your integration settings."
|
||||
"internalServerError": "Unable to connect to Beszel. Check your integration settings.",
|
||||
"layoutSaveTitle": "Unable to save table layout",
|
||||
"layoutSaveMessage": "Your column order or widths could not be saved. Please try again."
|
||||
},
|
||||
"column": {
|
||||
"system": "System",
|
||||
@@ -4472,6 +4566,8 @@
|
||||
"board": {
|
||||
"action": {
|
||||
"addContent": "Add board content",
|
||||
"settings": "Board settings",
|
||||
"switch": "Switch board",
|
||||
"duplicate": {
|
||||
"title": "Duplicate board",
|
||||
"message": "This will duplicate the board {name} with all its content. If widgets reference integrations, that you are not allowed to use, they will be removed.",
|
||||
@@ -4487,6 +4583,9 @@
|
||||
}
|
||||
},
|
||||
"edit": {
|
||||
"enter": "Edit board",
|
||||
"save": "Save board",
|
||||
"exit": "Exit edit mode",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Changes applied successfully",
|
||||
@@ -4495,6 +4594,10 @@
|
||||
"error": {
|
||||
"title": "Unable to apply changes",
|
||||
"message": "The board could not be saved"
|
||||
},
|
||||
"loadError": {
|
||||
"title": "Unable to start editing",
|
||||
"message": "The board editor could not be loaded. Your dashboard is still available in view mode."
|
||||
}
|
||||
},
|
||||
"confirmLeave": {
|
||||
@@ -4565,6 +4668,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"landmark": {
|
||||
"canvas": "Dashboard canvas",
|
||||
"leftRail": "Left dashboard rail",
|
||||
"rightRail": "Right dashboard rail",
|
||||
"items": "Dashboard items"
|
||||
},
|
||||
"field": {
|
||||
"pageTitle": {
|
||||
"label": "Page title"
|
||||
@@ -4818,6 +4927,26 @@
|
||||
"breakpoint": {
|
||||
"label": "Breakpoint",
|
||||
"description": "Layout will be used on all screens larger than this breakpoint until the next bigger breakpoint."
|
||||
},
|
||||
"gutters": {
|
||||
"label": "Fixed sidebars",
|
||||
"description": "Reserve up to three dashboard columns on either side. The remaining canvas reflows automatically.",
|
||||
"left": {
|
||||
"label": "Left sidebar",
|
||||
"description": "Keep this area visible while the dashboard scrolls.",
|
||||
"thumbLabel": "Left sidebar width"
|
||||
},
|
||||
"right": {
|
||||
"label": "Right sidebar",
|
||||
"description": "Keep this area visible while the dashboard scrolls.",
|
||||
"thumbLabel": "Right sidebar width"
|
||||
},
|
||||
"width": {
|
||||
"label": "Width in columns"
|
||||
},
|
||||
"preview": {
|
||||
"label": "Dashboard layout preview"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -5666,6 +5795,7 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Remove",
|
||||
"confirm": "Click again to remove permanently",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Containers removed",
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { boardLayoutSchema } from "./board";
|
||||
|
||||
describe("board layout gutters", () => {
|
||||
test("defaults both gutters to disabled", () => {
|
||||
expect(
|
||||
boardLayoutSchema.parse({
|
||||
id: "layout",
|
||||
name: "Base",
|
||||
columnCount: 12,
|
||||
breakpoint: 0,
|
||||
}),
|
||||
).toMatchObject({
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test("allows up to three columns per gutter while preserving a main column", () => {
|
||||
expect(
|
||||
boardLayoutSchema.safeParse({
|
||||
id: "layout",
|
||||
name: "Base",
|
||||
columnCount: 6,
|
||||
leftGutterColumnCount: 3,
|
||||
rightGutterColumnCount: 2,
|
||||
breakpoint: 0,
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
boardLayoutSchema.safeParse({
|
||||
id: "layout",
|
||||
name: "Base",
|
||||
columnCount: 6,
|
||||
leftGutterColumnCount: 3,
|
||||
rightGutterColumnCount: 3,
|
||||
breakpoint: 0,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { categorySectionOptionsDefaults, dynamicSectionOptionsDefaults, sectionSchema } from "./shared";
|
||||
|
||||
describe("section behavior validation", () => {
|
||||
it("normalizes a legacy category to the current behavior defaults", () => {
|
||||
const result = sectionSchema.parse({
|
||||
id: "category",
|
||||
name: "Media",
|
||||
kind: "category",
|
||||
yOffset: 0,
|
||||
xOffset: 0,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
collapsed: true,
|
||||
options: categorySectionOptionsDefaults,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes a legacy dynamic section to the current behavior defaults", () => {
|
||||
const result = sectionSchema.parse({
|
||||
id: "dynamic",
|
||||
kind: "dynamic",
|
||||
options: {
|
||||
title: "Media",
|
||||
customCssClasses: [],
|
||||
borderColor: "",
|
||||
},
|
||||
layouts: [],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
collapsed: false,
|
||||
options: {
|
||||
...dynamicSectionOptionsDefaults,
|
||||
title: "Media",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a configured category rail", () => {
|
||||
const result = sectionSchema.parse({
|
||||
id: "category",
|
||||
name: "Navigation",
|
||||
kind: "category",
|
||||
yOffset: 0,
|
||||
xOffset: 0,
|
||||
collapsed: false,
|
||||
options: {
|
||||
showLabel: false,
|
||||
collapsible: false,
|
||||
showOpenAll: false,
|
||||
railPlacement: "left",
|
||||
columnCount: 3,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
options: {
|
||||
showLabel: false,
|
||||
collapsible: false,
|
||||
showOpenAll: false,
|
||||
railPlacement: "left",
|
||||
columnCount: 3,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,7 @@ export const boardNameSchema = z
|
||||
.max(255)
|
||||
.regex(/^[A-Za-z0-9-_]*$/);
|
||||
export const boardColumnCountSchema = z.number().min(1).max(24);
|
||||
export const boardGutterColumnCountSchema = z.number().int().min(0).max(3);
|
||||
|
||||
export const boardByNameSchema = z.object({
|
||||
name: boardNameSchema,
|
||||
@@ -70,16 +71,23 @@ export const boardSavePartialSettingsSchema = z
|
||||
})
|
||||
.partial();
|
||||
|
||||
export const boardLayoutSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string().trim().nonempty().max(32),
|
||||
columnCount: boardColumnCountSchema,
|
||||
leftGutterColumnCount: boardGutterColumnCountSchema.default(0),
|
||||
rightGutterColumnCount: boardGutterColumnCountSchema.default(0),
|
||||
breakpoint: z.number().min(0).max(32767),
|
||||
})
|
||||
.refine((layout) => layout.leftGutterColumnCount + layout.rightGutterColumnCount < layout.columnCount, {
|
||||
message: "Gutters must leave at least one dashboard column",
|
||||
path: ["columnCount"],
|
||||
});
|
||||
|
||||
export const boardSaveLayoutsSchema = z.object({
|
||||
id: z.string(),
|
||||
layouts: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string().trim().nonempty().max(32),
|
||||
columnCount: boardColumnCountSchema,
|
||||
breakpoint: z.number().min(0).max(32767),
|
||||
}),
|
||||
),
|
||||
layouts: z.array(boardLayoutSchema),
|
||||
});
|
||||
|
||||
export const boardSaveSchema = z.object({
|
||||
|
||||
@@ -44,13 +44,36 @@ export const commonItemSchema = z
|
||||
})
|
||||
.and(sharedItemSchema);
|
||||
|
||||
export const sectionRailPlacements = ["main", "left", "right"] as const;
|
||||
|
||||
export const categorySectionOptionsDefaults = {
|
||||
showLabel: true,
|
||||
collapsible: true,
|
||||
showOpenAll: true,
|
||||
railPlacement: "main",
|
||||
columnCount: 2,
|
||||
} as const;
|
||||
|
||||
export const categorySectionOptionsSchema = z
|
||||
.object({
|
||||
showLabel: z.boolean().default(categorySectionOptionsDefaults.showLabel),
|
||||
collapsible: z.boolean().default(categorySectionOptionsDefaults.collapsible),
|
||||
showOpenAll: z.boolean().default(categorySectionOptionsDefaults.showOpenAll),
|
||||
railPlacement: z.enum(sectionRailPlacements).default(categorySectionOptionsDefaults.railPlacement),
|
||||
columnCount: z.number().int().min(1).max(24).default(categorySectionOptionsDefaults.columnCount),
|
||||
})
|
||||
.default(categorySectionOptionsDefaults);
|
||||
|
||||
export type CategorySectionOptions = z.infer<typeof categorySectionOptionsSchema>;
|
||||
|
||||
const categorySectionSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
kind: z.literal("category"),
|
||||
yOffset: z.number(),
|
||||
xOffset: z.number(),
|
||||
collapsed: z.boolean(),
|
||||
collapsed: z.boolean().default(true),
|
||||
options: categorySectionOptionsSchema,
|
||||
});
|
||||
|
||||
const emptySectionSchema = z.object({
|
||||
@@ -60,16 +83,33 @@ const emptySectionSchema = z.object({
|
||||
xOffset: z.number(),
|
||||
});
|
||||
|
||||
export const dynamicSectionOptionsSchema = z.object({
|
||||
title: z.string().max(20).default(""),
|
||||
customCssClasses: z.array(z.string()).default([]),
|
||||
borderColor: z.string().default(""),
|
||||
});
|
||||
export const dynamicSectionOptionsDefaults = {
|
||||
title: "",
|
||||
customCssClasses: [] as string[],
|
||||
borderColor: "",
|
||||
showLabel: true,
|
||||
collapsible: false,
|
||||
showOpenAll: false,
|
||||
} as const;
|
||||
|
||||
export const dynamicSectionOptionsSchema = z
|
||||
.object({
|
||||
title: z.string().max(64).default(dynamicSectionOptionsDefaults.title),
|
||||
customCssClasses: z.array(z.string()).default([]),
|
||||
borderColor: z.string().default(dynamicSectionOptionsDefaults.borderColor),
|
||||
showLabel: z.boolean().default(dynamicSectionOptionsDefaults.showLabel),
|
||||
collapsible: z.boolean().default(dynamicSectionOptionsDefaults.collapsible),
|
||||
showOpenAll: z.boolean().default(dynamicSectionOptionsDefaults.showOpenAll),
|
||||
})
|
||||
.default(dynamicSectionOptionsDefaults);
|
||||
|
||||
export type DynamicSectionOptions = z.infer<typeof dynamicSectionOptionsSchema>;
|
||||
|
||||
const dynamicSectionSchema = z.object({
|
||||
id: z.string(),
|
||||
kind: z.literal("dynamic"),
|
||||
options: dynamicSectionOptionsSchema,
|
||||
collapsed: z.boolean().default(false),
|
||||
layouts: z.array(
|
||||
z.object({
|
||||
layoutId: z.string(),
|
||||
|
||||
Reference in new issue
Block a user