fix(board): restore layouts after column changes (#6979)
* fix: preserve geometry across layout resizing * style: format layout migration snapshots
This commit is contained in:
1 parent
eec910aabb
commit
e048ae9cd7
14 files changed
+5567
-10
No files matched your search
@@ -42,6 +42,11 @@ Tiles use fixed `200 × 200` logical-pixel cells with a `24px` gap. Homarr scale
|
||||
width instead of changing each widget's proportions. **Reset from Base** regenerates a Mobile or custom layout from the
|
||||
current Base layout.
|
||||
|
||||
Changing column or sidebar counts keeps a saved source arrangement while its generated positions remain unedited.
|
||||
Widening back restores that arrangement, including Container sizes. Moving, resizing, adding, or removing content
|
||||
starts a new source arrangement on the next layout resize. This cannot restore positions lost before this behavior
|
||||
was introduced; widen the affected Container before moving widgets into its right-hand columns.
|
||||
|
||||
### Containers and rails
|
||||
|
||||
A **Container** groups apps, widgets, and nested Containers. It can have a label, collapse control, app-opening action,
|
||||
|
||||
@@ -4,7 +4,12 @@ import { z } from "zod/v4";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { constructBoardPermissions } from "@homarr/auth/shared";
|
||||
import { createId, generateResponsiveGridFor } from "@homarr/common";
|
||||
import {
|
||||
createId,
|
||||
generateResponsiveGridFor,
|
||||
getLayoutResizeSource,
|
||||
serializeLayoutResizeSource,
|
||||
} from "@homarr/common";
|
||||
import type { GridAlgorithmItem } from "@homarr/common";
|
||||
import type { DeviceType } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
@@ -864,6 +869,7 @@ export const boardRouter = createTRPCRouter({
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
id: layoutsMap.get(layout.id)!,
|
||||
boardId: newBoardId,
|
||||
resizeSource: null,
|
||||
}));
|
||||
|
||||
const sectionMap = new Map<string, string>(boardSections.map((section) => [section.id, createId()]));
|
||||
@@ -1358,6 +1364,7 @@ export const boardRouter = createTRPCRouter({
|
||||
const itemSectionLayoutsToUpdate: InferInsertModel<typeof itemLayouts>[] = [];
|
||||
const sectionLayoutsToUpdate: InferInsertModel<typeof sectionLayouts>[] = [];
|
||||
const layoutsToUpdate = filterUpdatedItems(input.layouts, board.layouts);
|
||||
const resizeSourcesToUpdate = new Map<string, string | null>();
|
||||
|
||||
for (const updatedLayout of layoutsToUpdate) {
|
||||
const dbLayout = existingLayoutsById.get(updatedLayout.id);
|
||||
@@ -1386,6 +1393,7 @@ export const boardRouter = createTRPCRouter({
|
||||
rightGutterColumnCount: updatedLayout.rightGutterColumnCount,
|
||||
},
|
||||
});
|
||||
resizeSourcesToUpdate.set(updatedLayout.id, projectedLayout.resizeSource);
|
||||
itemSectionLayoutsToUpdate.push(...projectedLayout.itemSectionLayouts);
|
||||
sectionLayoutsToUpdate.push(...projectedLayout.sectionLayouts);
|
||||
}
|
||||
@@ -1441,6 +1449,7 @@ export const boardRouter = createTRPCRouter({
|
||||
await transaction
|
||||
.update(schema.layouts)
|
||||
.set({
|
||||
...getResizeSourceUpdate(resizeSourcesToUpdate, layout.id),
|
||||
name: layout.name,
|
||||
columnCount: layout.columnCount,
|
||||
leftGutterColumnCount: layout.leftGutterColumnCount,
|
||||
@@ -1499,6 +1508,7 @@ export const boardRouter = createTRPCRouter({
|
||||
transaction
|
||||
.update(layouts)
|
||||
.set({
|
||||
...getResizeSourceUpdate(resizeSourcesToUpdate, layout.id),
|
||||
name: layout.name,
|
||||
columnCount: layout.columnCount,
|
||||
leftGutterColumnCount: layout.leftGutterColumnCount,
|
||||
@@ -1564,6 +1574,10 @@ export const boardRouter = createTRPCRouter({
|
||||
await handleTransactionsAsync(ctx.db, {
|
||||
async handleAsync(db, schema) {
|
||||
await db.transaction(async (transaction) => {
|
||||
await transaction
|
||||
.update(schema.layouts)
|
||||
.set({ resizeSource: null })
|
||||
.where(eq(schema.layouts.id, targetLayout.id));
|
||||
await transaction.delete(schema.itemLayouts).where(eq(schema.itemLayouts.layoutId, targetLayout.id));
|
||||
await transaction.delete(schema.sectionLayouts).where(eq(schema.sectionLayouts.layoutId, targetLayout.id));
|
||||
if (projectedLayout.itemSectionLayouts.length > 0) {
|
||||
@@ -1576,6 +1590,7 @@ export const boardRouter = createTRPCRouter({
|
||||
},
|
||||
handleSync(db) {
|
||||
db.transaction((transaction) => {
|
||||
transaction.update(layouts).set({ resizeSource: null }).where(eq(layouts.id, targetLayout.id)).run();
|
||||
transaction.delete(itemLayouts).where(eq(itemLayouts.layoutId, targetLayout.id)).run();
|
||||
transaction.delete(sectionLayouts).where(eq(sectionLayouts.layoutId, targetLayout.id)).run();
|
||||
if (projectedLayout.itemSectionLayouts.length > 0) {
|
||||
@@ -2511,8 +2526,14 @@ const noBoardWithSimilarNameAsync = async (db: Database, name: string, ignoredId
|
||||
}
|
||||
};
|
||||
|
||||
const getResizeSourceUpdate = (sources: ReadonlyMap<string, string | null>, layoutId: string) => {
|
||||
if (!sources.has(layoutId)) return {};
|
||||
return { resizeSource: sources.get(layoutId) ?? null };
|
||||
};
|
||||
|
||||
interface BoardForLayoutProjection {
|
||||
id: string;
|
||||
layouts: Array<{ id: string; resizeSource?: string | null }>;
|
||||
items: Array<{
|
||||
id: string;
|
||||
layouts: Array<{
|
||||
@@ -2547,7 +2568,13 @@ const getUpdatedBoardLayout = (
|
||||
current: BoardLayoutGeometry;
|
||||
},
|
||||
) => {
|
||||
const elements = options.previous.elements ?? getElementsForLayout(board, options.previous.layoutId);
|
||||
const currentElements = options.previous.elements ?? getElementsForLayout(board, options.previous.layoutId);
|
||||
const savedLayout = board.layouts.find((layout) => layout.id === options.previous.layoutId);
|
||||
let serializedSource: string | null | undefined;
|
||||
if (options.previous.layoutId === options.current.layoutId) serializedSource = savedLayout?.resizeSource;
|
||||
const source = getLayoutResizeSource(options.previous, currentElements, serializedSource);
|
||||
const elements = source.elements;
|
||||
const previousGeometry = { ...source.geometry, layoutId: options.previous.layoutId };
|
||||
const emptyRoots = board.sections
|
||||
.filter((section) => section.kind === "empty")
|
||||
.toSorted((first, second) => (first.yOffset ?? 0) - (second.yOffset ?? 0) || first.id.localeCompare(second.id));
|
||||
@@ -2586,12 +2613,12 @@ const getUpdatedBoardLayout = (
|
||||
const sourceLanes = boardLanes.filter(
|
||||
(sourceLane) =>
|
||||
rootByLane.has(sourceLane) &&
|
||||
getBoardLaneColumnCount(options.previous, sourceLane) > 0 &&
|
||||
getBoardLaneColumnCount(previousGeometry, sourceLane) > 0 &&
|
||||
targetLaneBySourceLane.get(sourceLane) === lane,
|
||||
);
|
||||
const previousWidth =
|
||||
sourceLanes.length === 1
|
||||
? getBoardLaneColumnCount(options.previous, sourceLanes[0] ?? "main")
|
||||
? getBoardLaneColumnCount(previousGeometry, sourceLanes[0] ?? "main")
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
|
||||
return [
|
||||
@@ -2644,6 +2671,7 @@ const getUpdatedBoardLayout = (
|
||||
return {
|
||||
itemSectionLayouts: itemSectionLayoutsCollection,
|
||||
sectionLayouts: sectionLayoutsCollection,
|
||||
resizeSource: serializeLayoutResizeSource(source, options.current, updatedElements),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2948,7 +2976,11 @@ const getFullBoardWithWhereAsync = async (
|
||||
return {
|
||||
...otherBoardProperties,
|
||||
layouts: layouts
|
||||
.map(({ boardId: _, ...layout }) => layout)
|
||||
.map(({ boardId: _, resizeSource, ...layout }) => {
|
||||
const result: typeof layout & { resizeSource?: string | null } = layout;
|
||||
if (resizeSource) result.resizeSource = resizeSource;
|
||||
return result;
|
||||
})
|
||||
.toSorted((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint),
|
||||
sections: sections.map(({ collapseStates, ...section }) =>
|
||||
parseSection({
|
||||
|
||||
@@ -352,6 +352,7 @@ export const onboardRouter = createTRPCRouter({
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 0,
|
||||
resizeSource: null,
|
||||
role: "mobile" as const,
|
||||
},
|
||||
{
|
||||
@@ -362,6 +363,7 @@ export const onboardRouter = createTRPCRouter({
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 768,
|
||||
resizeSource: null,
|
||||
role: "base" as const,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { generateResponsiveGridFor } from "@homarr/common";
|
||||
import { generateResponsiveGridFor, getLayoutResizeSource } from "@homarr/common";
|
||||
import type { GridAlgorithmItem } from "@homarr/common";
|
||||
import type { BoardLane, LayoutRole, WidgetKind } from "@homarr/definitions";
|
||||
import { boardLanes, getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
|
||||
@@ -10,6 +10,7 @@ export interface BoardPreviewLayout {
|
||||
rightGutterColumnCount: number;
|
||||
breakpoint: number;
|
||||
role: LayoutRole;
|
||||
resizeSource?: string | null;
|
||||
}
|
||||
|
||||
interface BoardPreviewItemLayout {
|
||||
@@ -77,7 +78,13 @@ export const projectBoardLayout = (
|
||||
sourceLayout: LayoutGeometry,
|
||||
targetLayout: LayoutGeometry,
|
||||
) => {
|
||||
const elements = getElementsForLayout(board, sourceLayout.id);
|
||||
const currentElements = getElementsForLayout(board, sourceLayout.id);
|
||||
let serializedSource: string | null | undefined;
|
||||
if (sourceLayout.id === targetLayout.id) {
|
||||
serializedSource = board.layouts.find((layout) => layout.id === sourceLayout.id)?.resizeSource;
|
||||
}
|
||||
const source = getLayoutResizeSource(sourceLayout, currentElements, serializedSource);
|
||||
const elements = source.elements;
|
||||
|
||||
if (
|
||||
sourceLayout.id === targetLayout.id &&
|
||||
@@ -85,7 +92,7 @@ export const projectBoardLayout = (
|
||||
sourceLayout.leftGutterColumnCount === targetLayout.leftGutterColumnCount &&
|
||||
sourceLayout.rightGutterColumnCount === targetLayout.rightGutterColumnCount
|
||||
) {
|
||||
return elements;
|
||||
return currentElements;
|
||||
}
|
||||
|
||||
const emptyRoots = board.sections
|
||||
@@ -124,12 +131,12 @@ export const projectBoardLayout = (
|
||||
const sourceLanes = boardLanes.filter(
|
||||
(sourceLane) =>
|
||||
rootByLane.has(sourceLane) &&
|
||||
getBoardLaneColumnCount(sourceLayout, sourceLane) > 0 &&
|
||||
getBoardLaneColumnCount(source.geometry, sourceLane) > 0 &&
|
||||
targetLaneBySourceLane.get(sourceLane) === lane,
|
||||
);
|
||||
const previousWidth =
|
||||
sourceLanes.length === 1
|
||||
? getBoardLaneColumnCount(sourceLayout, sourceLanes[0] ?? "main")
|
||||
? getBoardLaneColumnCount(source.geometry, sourceLanes[0] ?? "main")
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
|
||||
return generateResponsiveGridFor({
|
||||
|
||||
@@ -12,3 +12,5 @@ export * from "./error";
|
||||
export * from "./theme";
|
||||
export * from "./function";
|
||||
export * from "./grid-algorithm";
|
||||
|
||||
export * from "./layout-resize-source";
|
||||
@@ -0,0 +1,79 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { GridAlgorithmItem } from "./grid-algorithm";
|
||||
|
||||
const geometrySchema = z.object({
|
||||
columnCount: z.number().int().positive(),
|
||||
leftGutterColumnCount: z.number().int().nonnegative(),
|
||||
rightGutterColumnCount: z.number().int().nonnegative(),
|
||||
});
|
||||
const elementSchema = z.object({
|
||||
id: z.string(),
|
||||
type: z.enum(["item", "section"]),
|
||||
sectionId: z.string(),
|
||||
xOffset: z.number().int().nonnegative(),
|
||||
yOffset: z.number().int().nonnegative(),
|
||||
width: z.number().int().positive(),
|
||||
height: z.number().int().positive(),
|
||||
});
|
||||
const resizeSourceSchema = z.object({
|
||||
geometry: geometrySchema,
|
||||
elements: z.array(elementSchema),
|
||||
projectedGeometry: geometrySchema,
|
||||
projectedElements: z.array(elementSchema),
|
||||
});
|
||||
|
||||
type LayoutGeometry = z.infer<typeof geometrySchema>;
|
||||
export interface LayoutResizeSource {
|
||||
geometry: LayoutGeometry;
|
||||
elements: GridAlgorithmItem[];
|
||||
}
|
||||
|
||||
const geometryKey = (geometry: LayoutGeometry) =>
|
||||
JSON.stringify([geometry.columnCount, geometry.leftGutterColumnCount, geometry.rightGutterColumnCount]);
|
||||
const elementsKey = (elements: readonly GridAlgorithmItem[]) =>
|
||||
JSON.stringify(
|
||||
elements
|
||||
.map((element) => [
|
||||
element.id,
|
||||
element.type,
|
||||
element.sectionId,
|
||||
element.xOffset,
|
||||
element.yOffset,
|
||||
element.width,
|
||||
element.height,
|
||||
])
|
||||
.toSorted((first, second) => String(first[0]).localeCompare(String(second[0]))),
|
||||
);
|
||||
|
||||
/** Keep the pre-resize arrangement only while the projected placements remain untouched. */
|
||||
export const getLayoutResizeSource = (
|
||||
geometry: LayoutGeometry,
|
||||
elements: GridAlgorithmItem[],
|
||||
serializedSource?: string | null,
|
||||
): LayoutResizeSource => {
|
||||
if (serializedSource) {
|
||||
try {
|
||||
const parsed = resizeSourceSchema.safeParse(JSON.parse(serializedSource));
|
||||
if (
|
||||
parsed.success &&
|
||||
geometryKey(parsed.data.projectedGeometry) === geometryKey(geometry) &&
|
||||
elementsKey(parsed.data.projectedElements) === elementsKey(elements)
|
||||
) {
|
||||
return { geometry: parsed.data.geometry, elements: parsed.data.elements };
|
||||
}
|
||||
} catch {
|
||||
// Malformed or obsolete history must never prevent resizing the current layout.
|
||||
}
|
||||
}
|
||||
return { geometry, elements };
|
||||
};
|
||||
|
||||
export const serializeLayoutResizeSource = (
|
||||
source: LayoutResizeSource,
|
||||
projectedGeometry: LayoutGeometry,
|
||||
projectedElements: GridAlgorithmItem[],
|
||||
): string | null => {
|
||||
if (geometryKey(source.geometry) === geometryKey(projectedGeometry)) return null;
|
||||
return JSON.stringify({ ...source, projectedGeometry, projectedElements });
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "layout" ADD COLUMN "resize_source" text;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -113,6 +113,13 @@
|
||||
"when": 1788763848932,
|
||||
"tag": "0015_add_byte_unit_system",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1790988597957,
|
||||
"tag": "0016_add_layout_resize_source",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `layout` ADD `resize_source` text;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -337,6 +337,13 @@
|
||||
"when": 1788763848930,
|
||||
"tag": "0047_add_byte_unit_system",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 48,
|
||||
"version": "6",
|
||||
"when": 1790988595595,
|
||||
"tag": "0048_add_layout_resize_source",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -349,6 +349,7 @@ export const layouts = pgTable("layout", {
|
||||
rightGutterColumnCount: smallint().notNull().default(0),
|
||||
breakpoint: smallint().notNull().default(0),
|
||||
role: varchar({ length: 16 }).$type<LayoutRole>().notNull().default("custom"),
|
||||
resizeSource: text(),
|
||||
});
|
||||
|
||||
export const itemLayouts = pgTable(
|
||||
|
||||
@@ -334,6 +334,7 @@ export const layouts = sqliteTable("layout", {
|
||||
rightGutterColumnCount: int().notNull().default(0),
|
||||
breakpoint: int().notNull().default(0),
|
||||
role: text().$type<LayoutRole>().notNull().default("custom"),
|
||||
resizeSource: text(),
|
||||
});
|
||||
|
||||
export const itemLayouts = sqliteTable(
|
||||
|
||||
Reference in new issue
Block a user