feat: rebuild board grid experience

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

No files matched your search

+166 -88
View File
@@ -29,12 +29,16 @@ import {
sections,
users,
} from "@homarr/db/schema";
import type { WidgetKind } from "@homarr/definitions";
import type { BoardLane, WidgetKind } from "@homarr/definitions";
import {
boardLanes,
emptySuperJSON,
everyoneGroup,
getBoardLaneColumnCount,
getRootSectionLane,
getPermissionsWithChildren,
getPermissionsWithParents,
rootSectionOffsets,
widgetDefaultSizes,
widgetKinds,
} from "@homarr/definitions";
@@ -321,6 +325,8 @@ export const boardRouter = createTRPCRouter({
id: createId(),
name: "Base",
columnCount: input.columnCount,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
breakpoint: 0,
boardId,
});
@@ -690,7 +696,25 @@ export const boardRouter = createTRPCRouter({
saveLayouts: protectedProcedure.input(boardSaveLayoutsSchema).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
const board = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
let board = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
const missingGutterRoots = (["left", "right"] as const).filter(
(lane) =>
input.layouts.some((layout) => getBoardLaneColumnCount(layout, lane) > 0) &&
!getRootSectionForLane(board, lane),
);
if (missingGutterRoots.length > 0) {
await ctx.db.insert(sections).values(
missingGutterRoots.map((lane) => ({
id: createId(),
boardId: board.id,
kind: "empty" as const,
xOffset: rootSectionOffsets[lane],
yOffset: 0,
options: emptySuperJSON,
})),
);
board = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
}
const addedLayouts = filterAddedItems(input.layouts, board.layouts);
@@ -701,14 +725,6 @@ export const boardRouter = createTRPCRouter({
for (const addedLayout of addedLayouts) {
const layoutId = createId();
layoutsToInsert.push({
id: layoutId,
name: addedLayout.name,
columnCount: addedLayout.columnCount,
breakpoint: addedLayout.breakpoint,
boardId: board.id,
});
const sortedLayouts = board.layouts.toSorted((layoutA, layoutB) => layoutA.columnCount - layoutB.columnCount);
// Fallback to biggest if none exists with columnCount bigger than addedLayout.columnCount
const layoutToClone =
@@ -719,13 +735,27 @@ export const boardRouter = createTRPCRouter({
previous: {
layoutId: layoutToClone.id,
columnCount: layoutToClone.columnCount,
leftGutterColumnCount: layoutToClone.leftGutterColumnCount,
rightGutterColumnCount: layoutToClone.rightGutterColumnCount,
},
current: {
layoutId,
columnCount: addedLayout.columnCount,
leftGutterColumnCount: addedLayout.leftGutterColumnCount,
rightGutterColumnCount: addedLayout.rightGutterColumnCount,
},
});
layoutsToInsert.push({
id: layoutId,
name: addedLayout.name,
columnCount: addedLayout.columnCount,
leftGutterColumnCount: addedLayout.leftGutterColumnCount,
rightGutterColumnCount: addedLayout.rightGutterColumnCount,
breakpoint: addedLayout.breakpoint,
boardId: board.id,
});
itemSectionLayoutsToInsert.push(...updatedBoardLayout.itemSectionLayouts);
sectionLayoutsToInsert.push(...updatedBoardLayout.sectionLayouts);
}
@@ -746,16 +776,25 @@ export const boardRouter = createTRPCRouter({
for (const updatedLayout of updatedLayouts) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const dbLayout = board.layouts.find((layout) => layout.id === updatedLayout.id)!;
let updatedBoardLayout: ReturnType<typeof getUpdatedBoardLayout> | undefined;
if (dbLayout.columnCount !== updatedLayout.columnCount) {
const updatedBoardLayout = getUpdatedBoardLayout(board, {
if (
dbLayout.columnCount !== updatedLayout.columnCount ||
dbLayout.leftGutterColumnCount !== updatedLayout.leftGutterColumnCount ||
dbLayout.rightGutterColumnCount !== updatedLayout.rightGutterColumnCount
) {
updatedBoardLayout = getUpdatedBoardLayout(board, {
previous: {
layoutId: dbLayout.id,
columnCount: dbLayout.columnCount,
leftGutterColumnCount: dbLayout.leftGutterColumnCount,
rightGutterColumnCount: dbLayout.rightGutterColumnCount,
},
current: {
layoutId: dbLayout.id,
columnCount: updatedLayout.columnCount,
leftGutterColumnCount: updatedLayout.leftGutterColumnCount,
rightGutterColumnCount: updatedLayout.rightGutterColumnCount,
},
});
@@ -801,6 +840,8 @@ export const boardRouter = createTRPCRouter({
.set({
name: updatedLayout.name,
columnCount: updatedLayout.columnCount,
leftGutterColumnCount: updatedLayout.leftGutterColumnCount,
rightGutterColumnCount: updatedLayout.rightGutterColumnCount,
breakpoint: updatedLayout.breakpoint,
})
.where(eq(layouts.id, updatedLayout.id));
@@ -877,8 +918,8 @@ export const boardRouter = createTRPCRouter({
id: section.id,
kind: section.kind,
yOffset: section.kind !== "dynamic" ? section.yOffset : null,
xOffset: section.kind === "dynamic" ? null : 0,
options: section.kind === "dynamic" ? superjson.stringify(section.options) : emptySuperJSON,
xOffset: section.kind === "dynamic" ? null : section.xOffset,
options: section.kind === "empty" ? emptySuperJSON : superjson.stringify(section.options),
name: "name" in section ? section.name : null,
boardId: dbBoard.id,
})),
@@ -1003,8 +1044,8 @@ export const boardRouter = createTRPCRouter({
.update(schema.sections)
.set({
yOffset: prev?.kind !== "dynamic" && "yOffset" in section ? section.yOffset : null,
xOffset: prev?.kind !== "dynamic" && "yOffset" in section ? 0 : null,
options: section.kind === "dynamic" ? superjson.stringify(section.options) : emptySuperJSON,
xOffset: prev?.kind !== "dynamic" && "xOffset" in section ? section.xOffset : null,
options: section.kind === "empty" ? emptySuperJSON : superjson.stringify(section.options),
name: prev?.kind === "category" && "name" in section ? section.name : null,
})
.where(eq(schema.sections.id, section.id));
@@ -1077,8 +1118,8 @@ export const boardRouter = createTRPCRouter({
id: section.id,
kind: section.kind,
yOffset: section.kind !== "dynamic" ? section.yOffset : null,
xOffset: section.kind === "dynamic" ? null : 0,
options: section.kind === "dynamic" ? superjson.stringify(section.options) : emptySuperJSON,
xOffset: section.kind === "dynamic" ? null : section.xOffset,
options: section.kind === "empty" ? emptySuperJSON : superjson.stringify(section.options),
name: "name" in section ? section.name : null,
boardId: dbBoard.id,
})),
@@ -1213,8 +1254,8 @@ export const boardRouter = createTRPCRouter({
.update(sections)
.set({
yOffset: prev?.kind !== "dynamic" && "yOffset" in section ? section.yOffset : null,
xOffset: prev?.kind !== "dynamic" && "yOffset" in section ? 0 : null,
options: section.kind === "dynamic" ? superjson.stringify(section.options) : emptySuperJSON,
xOffset: prev?.kind !== "dynamic" && "xOffset" in section ? section.xOffset : null,
options: section.kind === "empty" ? emptySuperJSON : superjson.stringify(section.options),
name: prev?.kind === "category" && "name" in section ? section.name : null,
})
.where(eq(sections.id, section.id))
@@ -1657,77 +1698,96 @@ const noBoardWithSimilarNameAsync = async (db: Database, name: string, ignoredId
const getUpdatedBoardLayout = (
board: Awaited<ReturnType<typeof getFullBoardWithWhereAsync>>,
options: {
previous: {
layoutId: string;
columnCount: number;
};
current: {
layoutId: string;
columnCount: number;
};
previous: BoardLayoutGeometry;
current: BoardLayoutGeometry;
},
) => {
const itemSectionLayoutsCollection: InferInsertModel<typeof itemLayouts>[] = [];
const sectionLayoutsCollection: InferInsertModel<typeof sectionLayouts>[] = [];
const elements = getElementsForLayout(board, options.previous.layoutId);
const rootSections = board.sections.filter((section) => section.kind !== "dynamic");
for (const rootSection of rootSections) {
const result = generateResponsiveGridFor({
items: elements,
previousWidth: options.previous.columnCount,
width: options.current.columnCount,
sectionId: rootSection.id,
});
itemSectionLayoutsCollection.push(
...board.items
.map((item): InferInsertModel<typeof itemLayouts> | null => {
const currentElement = result.items.find((element) => element.type === "item" && element.id === item.id);
if (!currentElement) {
return null;
}
return {
itemId: item.id,
layoutId: options.current.layoutId,
sectionId: currentElement.sectionId,
height: currentElement.height,
width: currentElement.width,
xOffset: currentElement.xOffset,
yOffset: currentElement.yOffset,
};
})
.filter((item) => item !== null),
);
sectionLayoutsCollection.push(
...board.sections
.filter((section) => section.kind === "dynamic")
.map((section): InferInsertModel<typeof sectionLayouts> | null => {
const currentElement = result.items.find(
(element) => element.type === "section" && element.id === section.id,
);
if (!currentElement) {
return null;
}
return {
layoutId: options.current.layoutId,
sectionId: section.id,
parentSectionId: currentElement.sectionId,
height: currentElement.height,
width: currentElement.width,
xOffset: currentElement.xOffset,
yOffset: currentElement.yOffset,
};
})
.filter((section) => section !== null),
);
const emptyRoots = board.sections
.filter((section) => section.kind === "empty")
.toSorted((first, second) => (first.yOffset ?? 0) - (second.yOffset ?? 0) || first.id.localeCompare(second.id));
const rootByLane = new Map<BoardLane, (typeof emptyRoots)[number]>();
for (const root of emptyRoots) {
const lane = getRootSectionLane(root.xOffset);
if (!rootByLane.has(lane)) rootByLane.set(lane, root);
}
const mainRoot = rootByLane.get("main");
if (!mainRoot) throw new Error(`Board "${board.id}" has no main canvas root`);
const sourceRootById = new Map(emptyRoots.map((section) => [section.id, getRootSectionLane(section.xOffset)] as const));
const targetLaneBySourceLane = new Map<BoardLane, BoardLane>(
boardLanes.map((lane) => [
lane,
lane !== "main" && getBoardLaneColumnCount(options.current, lane) === 0 ? "main" : lane,
]),
);
const remappedElements = elements.map((element) => {
const sourceLane = sourceRootById.get(element.sectionId);
if (!sourceLane) return element;
const targetLane = targetLaneBySourceLane.get(sourceLane) ?? "main";
const targetRoot = rootByLane.get(targetLane) ?? mainRoot;
return {
...element,
sectionId: targetRoot.id,
};
});
const results = boardLanes.flatMap((lane) => {
const root = rootByLane.get(lane);
const width = getBoardLaneColumnCount(options.current, lane);
if (!root || width === 0) return [];
const sourceLanes = boardLanes.filter((sourceLane) => targetLaneBySourceLane.get(sourceLane) === lane);
const previousWidth =
sourceLanes.length === 1
? getBoardLaneColumnCount(options.previous, sourceLanes[0] ?? "main")
: Number.MAX_SAFE_INTEGER;
return [
generateResponsiveGridFor({
items: remappedElements,
previousWidth,
width,
sectionId: root.id,
}),
];
});
const updatedElements = results.flatMap((result) => result.items);
const updatedElementById = new Map(updatedElements.map((element) => [element.id, element]));
const itemSectionLayoutsCollection = board.items.flatMap((item): InferInsertModel<typeof itemLayouts>[] => {
const currentElement = updatedElementById.get(item.id);
if (!currentElement || currentElement.type !== "item") return [];
return [
{
itemId: item.id,
layoutId: options.current.layoutId,
sectionId: currentElement.sectionId,
height: currentElement.height,
width: currentElement.width,
xOffset: currentElement.xOffset,
yOffset: currentElement.yOffset,
},
];
});
const sectionLayoutsCollection = board.sections.flatMap((section): InferInsertModel<typeof sectionLayouts>[] => {
if (section.kind !== "dynamic") return [];
const currentElement = updatedElementById.get(section.id);
if (!currentElement || currentElement.type !== "section") return [];
return [
{
layoutId: options.current.layoutId,
sectionId: section.id,
parentSectionId: currentElement.sectionId,
height: currentElement.height,
width: currentElement.width,
xOffset: currentElement.xOffset,
yOffset: currentElement.yOffset,
},
];
});
return {
itemSectionLayouts: itemSectionLayoutsCollection,
@@ -1735,6 +1795,24 @@ const getUpdatedBoardLayout = (
};
};
interface BoardLayoutGeometry {
layoutId: string;
columnCount: number;
leftGutterColumnCount: number;
rightGutterColumnCount: number;
}
const getRootSectionForLane = (
board: Awaited<ReturnType<typeof getFullBoardWithWhereAsync>>,
lane: BoardLane,
) => {
const roots = board.sections.filter(
(section) => section.kind === "empty" && getRootSectionLane(section.xOffset) === lane,
);
if (roots.length > 1) throw new Error(`Board "${board.id}" has multiple ${lane} canvas roots`);
return roots[0];
};
const getElementsForLayout = (board: Awaited<ReturnType<typeof getFullBoardWithWhereAsync>>, layoutId: string) => {
const sectionElements = board.sections
.filter((section) => section.kind === "dynamic")
@@ -1848,7 +1926,7 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
parentSectionId: layout.parentSectionId,
layoutId: layout.layoutId,
})),
collapsed: collapseStates.at(0)?.collapsed ?? false,
collapsed: collapseStates.at(0)?.collapsed ?? section.kind === "category",
}),
),
items: items
@@ -1,8 +1,11 @@
import { TRPCError } from "@trpc/server";
import superjson from "superjson";
import { z } from "zod/v4";
import { and, eq } from "@homarr/db";
import { sectionCollapseStates, sections } from "@homarr/db/schema";
import { emptySuperJSON } from "@homarr/definitions";
import { categorySectionOptionsSchema, dynamicSectionOptionsSchema } from "@homarr/validation/shared";
import { createTRPCRouter, protectedProcedure } from "../../trpc";
@@ -16,7 +19,7 @@ export const sectionRouter = createTRPCRouter({
)
.mutation(async ({ ctx, input }) => {
const section = await ctx.db.query.sections.findFirst({
where: and(eq(sections.id, input.sectionId), eq(sections.kind, "category")),
where: eq(sections.id, input.sectionId),
with: {
collapseStates: {
where: eq(sectionCollapseStates.userId, ctx.session.user.id),
@@ -31,6 +34,26 @@ export const sectionRouter = createTRPCRouter({
});
}
if (section.kind === "empty") {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Section cannot be collapsed id=${input.sectionId}`,
});
}
const rawOptions = superjson.parse(section.options ?? emptySuperJSON);
const parsedOptions =
section.kind === "category"
? categorySectionOptionsSchema.safeParse(rawOptions)
: dynamicSectionOptionsSchema.safeParse(rawOptions);
if (!parsedOptions.success || !parsedOptions.data.collapsible) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Section cannot be collapsed id=${input.sectionId}`,
});
}
if (section.collapseStates.length === 0) {
await ctx.db.insert(sectionCollapseStates).values({
sectionId: section.id,
+148
View File
@@ -572,6 +572,35 @@ describe("getBoardByName should return board by name", () => {
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "view");
});
it("normalizes legacy category behavior options in the response", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, sectionId } = await createFullBoardAsync(db, "default");
await db
.update(sections)
.set({
kind: "category",
name: "Media",
})
.where(eq(sections.id, sectionId));
const result = await caller.getBoardByName({ name: "default" });
const section = expectToBeDefined(result.sections[0]);
expect(result.id).toBe(boardId);
expect(section.kind).toBe("category");
if (section.kind === "category") {
expect(section.collapsed).toBe(true);
expect(section.options).toEqual({
showLabel: true,
collapsible: true,
showOpenAll: true,
railPlacement: "main",
columnCount: 2,
});
}
});
it("should throw error when not present", async () => {
// Arrange
const db = createDb();
@@ -882,6 +911,68 @@ describe("saveBoard should save full board", () => {
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
},
);
it("should add and update category behavior options", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId } = await createFullBoardAsync(db, "default");
const sectionId = createId();
await caller.saveBoard({
id: boardId,
sections: [
{
id: sectionId,
kind: "category",
name: "Navigation",
xOffset: 0,
yOffset: 0,
collapsed: false,
options: {
showLabel: true,
collapsible: true,
showOpenAll: true,
railPlacement: "left",
columnCount: 3,
},
},
],
items: [],
});
await caller.saveBoard({
id: boardId,
sections: [
{
id: sectionId,
kind: "category",
name: "Navigation",
xOffset: 0,
yOffset: 0,
collapsed: false,
options: {
showLabel: false,
collapsible: false,
showOpenAll: false,
railPlacement: "right",
columnCount: 4,
},
},
],
items: [],
});
const section = await db.query.sections.findFirst({
where: eq(sections.id, sectionId),
});
expect(SuperJSON.parse(section?.options ?? "")).toEqual({
showLabel: false,
collapsible: false,
showOpenAll: false,
railPlacement: "right",
columnCount: 4,
});
});
it("should add item when present in input", async () => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
@@ -1328,12 +1419,16 @@ const createExistingLayout = (id: string) => ({
id,
name: "Base",
columnCount: 10,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
breakpoint: 0,
});
const createNewLayout = (columnCount: number) => ({
id: createId(),
name: "New layout",
columnCount,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
breakpoint: 1400,
});
describe("saveLayouts should save layout changes", () => {
@@ -1445,6 +1540,59 @@ describe("saveLayouts should save layout changes", () => {
});
await expectLayoutForDynamicSectionAsync(db, assignments.inRoot.f, layoutId, assignments.inDynamicSection);
});
test("should reserve gutter columns and return gutter contents to the canvas when disabled", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "gutters");
await db
.update(itemLayouts)
.set({
xOffset: 9,
})
.where(and(eq(itemLayouts.itemId, itemId), eq(itemLayouts.layoutId, layoutId)));
await caller.saveLayouts({
id: boardId,
layouts: [
{
...createExistingLayout(layoutId),
leftGutterColumnCount: 2,
},
],
});
const enabledLayout = expectToBeDefined(await db.query.layouts.findFirst({ where: eq(layouts.id, layoutId) }));
expect(enabledLayout.leftGutterColumnCount).toBe(2);
const leftRoot = expectToBeDefined(
await db.query.sections.findFirst({
where: and(eq(sections.boardId, boardId), eq(sections.kind, "empty"), eq(sections.xOffset, -1)),
}),
);
const reflowedMainItem = expectToBeDefined(
await db.query.itemLayouts.findFirst({
where: and(eq(itemLayouts.itemId, itemId), eq(itemLayouts.layoutId, layoutId)),
}),
);
expect(reflowedMainItem.xOffset + reflowedMainItem.width).toBeLessThanOrEqual(8);
const gutterItemId = await addItemAsync(db, {
boardId,
layoutId,
sectionId: leftRoot.id,
});
await caller.saveLayouts({
id: boardId,
layouts: [createExistingLayout(layoutId)],
});
const returnedItem = expectToBeDefined(
await db.query.itemLayouts.findFirst({
where: and(eq(itemLayouts.itemId, gutterItemId), eq(itemLayouts.layoutId, layoutId)),
}),
);
expect(returnedItem.sectionId).toBe(sectionId);
});
test("should remove layout when not present in input", async () => {
// Arrange
const db = createDb();
@@ -0,0 +1,96 @@
import { stringify } from "superjson";
import { describe, expect, it } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import { eq } from "@homarr/db";
import { boards, sectionCollapseStates, sections, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { sectionRouter } from "../section/section-router";
const userId = createId();
const session = {
user: {
id: userId,
permissions: [],
colorScheme: "light",
},
expires: new Date().toISOString(),
} satisfies Session;
const createCallerAsync = async () => {
const db = createDb();
const boardId = createId();
await db.insert(users).values({ id: userId });
await db.insert(boards).values({
id: boardId,
name: createId(),
creatorId: userId,
});
return {
boardId,
db,
caller: sectionRouter.createCaller({
db,
deviceType: undefined,
session,
}),
};
};
describe("changeCollapsed", () => {
it("preserves collapse support for legacy categories", async () => {
const { boardId, caller, db } = await createCallerAsync();
const sectionId = createId();
await db.insert(sections).values({
id: sectionId,
boardId,
kind: "category",
name: "Media",
xOffset: 0,
yOffset: 0,
});
await caller.changeCollapsed({ sectionId, collapsed: true });
const state = await db.query.sectionCollapseStates.findFirst({
where: eq(sectionCollapseStates.sectionId, sectionId),
});
expect(state?.collapsed).toBe(true);
});
it("creates and updates collapse state for an enabled dynamic section", async () => {
const { boardId, caller, db } = await createCallerAsync();
const sectionId = createId();
await db.insert(sections).values({
id: sectionId,
boardId,
kind: "dynamic",
options: stringify({ collapsible: true }),
});
await caller.changeCollapsed({ sectionId, collapsed: true });
await caller.changeCollapsed({ sectionId, collapsed: false });
const states = await db.query.sectionCollapseStates.findMany({
where: eq(sectionCollapseStates.sectionId, sectionId),
});
expect(states).toHaveLength(1);
expect(states[0]?.collapsed).toBe(false);
});
it("rejects a dynamic section when collapsing is disabled", async () => {
const { boardId, caller, db } = await createCallerAsync();
const sectionId = createId();
await db.insert(sections).values({
id: sectionId,
boardId,
kind: "dynamic",
});
await expect(caller.changeCollapsed({ sectionId, collapsed: true })).rejects.toThrow("Section cannot be collapsed");
});
});
@@ -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);
};
+1
View File
@@ -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"
},
+69 -23
View File
@@ -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");
});
});
+24
View File
@@ -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));
+2
View File
@@ -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
}
]
}
+2
View File
@@ -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),
});
+2
View File
@@ -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),
});
+2
View File
@@ -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),
});
+31
View File
@@ -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;
};
+200 -70
View File
@@ -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);
});
});
+69
View File
@@ -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,
},
});
});
});
+16 -8
View File
@@ -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({
+46 -6
View File
@@ -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(),