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

@@ -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),
});