fix assistant dashboard placement

This commit is contained in:
ajnart committed 2026-08-10 11:58:32 +02:00
1 parent 12fe596a9e
commit 491b430b64
3 files changed
+233 -99

No files matched your search

+115 -95
View File
@@ -80,6 +80,50 @@ import {
} from "./board/custom-widget-placement-access";
import { validateTimetableOptionsChangeAsync } from "./widgets/timetable";
interface BoardItemPlacementRectangle {
xOffset: number;
yOffset: number;
width: number;
height: number;
}
const boardItemPlacementTails = new Map<string, Promise<void>>();
const serializeBoardItemPlacementAsync = async <T>(boardId: string, operation: () => Promise<T>) => {
const previous = boardItemPlacementTails.get(boardId) ?? Promise.resolve();
const { promise: current, resolve: release } = Promise.withResolvers<void>();
const tail = previous.then(() => current);
boardItemPlacementTails.set(boardId, tail);
await previous;
try {
return await operation();
} finally {
release();
if (boardItemPlacementTails.get(boardId) === tail) boardItemPlacementTails.delete(boardId);
}
};
const findFirstAvailableBoardItemPosition = (
placements: readonly BoardItemPlacementRectangle[],
columnCount: number,
size: { width: number; height: number },
) => {
for (let yOffset = 0; yOffset < 9999; yOffset++) {
for (let xOffset = 0; xOffset + size.width <= columnCount; xOffset++) {
const overlaps = placements.some(
(placement) =>
placement.yOffset < yOffset + size.height &&
placement.yOffset + placement.height > yOffset &&
placement.xOffset < xOffset + size.width &&
placement.xOffset + placement.width > xOffset,
);
if (!overlaps) return { xOffset, yOffset };
}
}
return null;
};
export const boardRouter = createTRPCRouter({
exists: permissionRequiredProcedure
.requiresPermission("board-create")
@@ -1816,7 +1860,7 @@ export const boardRouter = createTRPCRouter({
mcp: {
enabled: true,
description:
"Add a widget/app item to a board after configure_widget has reviewed it. Automatically places it in the first empty section at the next free grid position. Use the configure_widget result's boardId, kind, options, and integrationIds exactly. Integration IDs must be accessible to the current user. To create a formatted dashboard note, configure kind 'notebook' with options { content: Tiptap-compatible HTML, showToolbar: boolean, allowReadOnlyCheck: boolean }. Returns { itemId }",
"Add a widget/app item to a board after configure_widget has reviewed it. Automatically places it in the main canvas at the first free grid position without overlapping items or containers. Use the configure_widget result's boardId, kind, options, and integrationIds exactly. Integration IDs must be accessible to the current user. To create a formatted dashboard note, configure kind 'notebook' with options { content: Tiptap-compatible HTML, showToolbar: boolean, allowReadOnlyCheck: boolean }. Returns { itemId }",
},
})
.input(addItemToBoardSchema)
@@ -1878,109 +1922,85 @@ export const boardRouter = createTRPCRouter({
}
}
const board = await ctx.db.query.boards.findFirst({
where: eq(boards.id, input.boardId),
with: {
sections: true,
layouts: true,
items: { with: { layouts: true } },
},
});
return await serializeBoardItemPlacementAsync(input.boardId, async () => {
const board = await ctx.db.query.boards.findFirst({
where: eq(boards.id, input.boardId),
with: {
sections: { with: { layouts: true } },
layouts: true,
items: { with: { layouts: true } },
},
});
if (!board) {
throw new TRPCError({ code: "NOT_FOUND", message: "Board not found" });
}
const emptySection = board.sections
.filter((s) => s.kind === "empty")
.toSorted((a, b) => (a.yOffset ?? 0) - (b.yOffset ?? 0))[0];
if (!emptySection) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Board has no empty section to place items in" });
}
const itemId = createId();
await ctx.db.insert(items).values({
id: itemId,
boardId: input.boardId,
kind: input.kind,
options: superjson.stringify(input.options),
advancedOptions: emptySuperJSON,
});
const layoutRows: (typeof itemLayouts.$inferInsert)[] = [];
for (const layout of board.layouts) {
const existingInSection = board.items
.flatMap((item) => item.layouts)
.filter((il) => il.sectionId === emptySection.id && il.layoutId === layout.id);
const occupied: boolean[][] = [];
for (const il of existingInSection) {
for (let y = il.yOffset; y < il.yOffset + il.height; y++) {
while (occupied.length <= y) occupied.push(Array.from<boolean>({ length: layout.columnCount }).fill(false));
const occupiedRow = occupied[y];
if (!occupiedRow) continue;
for (let x = il.xOffset; x < il.xOffset + il.width; x++) {
occupiedRow[x] = true;
}
}
if (!board) {
throw new TRPCError({ code: "NOT_FOUND", message: "Board not found" });
}
const emptySection = board.sections
.filter((section) => section.kind === "empty" && getRootSectionLane(section.xOffset) === "main")
.toSorted(
(sectionA, sectionB) =>
(sectionA.yOffset ?? 0) - (sectionB.yOffset ?? 0) || sectionA.id.localeCompare(sectionB.id),
)[0];
if (!emptySection) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Board has no main section to place items in" });
}
const itemId = createId();
const defaultSize = widgetDefaultSizes[input.kind as WidgetKind] ?? { width: 1, height: 1 };
const sizeForLayout = { ...defaultSize, width: Math.min(layout.columnCount, defaultSize.width) };
const layoutRows: (typeof itemLayouts.$inferInsert)[] = board.layouts.map((layout) => {
const columnCount = getBoardLaneColumnCount(layout, getRootSectionLane(emptySection.xOffset));
const size = { ...defaultSize, width: Math.min(columnCount, defaultSize.width) };
const itemPlacements = board.items
.flatMap((item) => item.layouts)
.filter((itemLayout) => itemLayout.sectionId === emptySection.id && itemLayout.layoutId === layout.id);
const containerPlacements = board.sections
.filter((section) => section.kind === "container")
.flatMap((section) => section.layouts)
.filter(
(sectionLayout) =>
sectionLayout.parentSectionId === emptySection.id && sectionLayout.layoutId === layout.id,
);
const position = findFirstAvailableBoardItemPosition(
[...itemPlacements, ...containerPlacements],
columnCount,
size,
);
const fitsAt = (x: number, y: number) => {
if (x + sizeForLayout.width > layout.columnCount) return false;
for (let dy = 0; dy < sizeForLayout.height; dy++) {
const row = occupied[y + dy];
if (!row) continue;
for (let dx = 0; dx < sizeForLayout.width; dx++) {
if (row[x + dx]) return false;
}
if (!position) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Board section is full, no free grid position available",
});
}
return true;
};
let placed = false;
for (let y = 0; y < 9999 && !placed; y++) {
if (!occupied[y]) occupied.push(Array.from<boolean>({ length: layout.columnCount }).fill(false));
for (let x = 0; x < layout.columnCount && !placed; x++) {
if (fitsAt(x, y)) {
layoutRows.push({
itemId,
sectionId: emptySection.id,
layoutId: layout.id,
xOffset: x,
yOffset: y,
width: sizeForLayout.width,
height: sizeForLayout.height,
});
placed = true;
}
}
return {
itemId,
sectionId: emptySection.id,
layoutId: layout.id,
...position,
width: size.width,
height: size.height,
};
});
await ctx.db.insert(items).values({
id: itemId,
boardId: input.boardId,
kind: input.kind,
options: superjson.stringify(input.options),
advancedOptions: emptySuperJSON,
});
if (layoutRows.length > 0) await ctx.db.insert(itemLayouts).values(layoutRows);
if (input.integrationIds.length > 0) {
await ctx.db
.insert(integrationItems)
.values(input.integrationIds.map((integrationId) => ({ itemId, integrationId })));
}
if (!placed) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Board section is full, no free grid position available",
});
}
}
if (layoutRows.length > 0) {
await ctx.db.insert(itemLayouts).values(layoutRows);
}
if (input.integrationIds.length > 0) {
await ctx.db
.insert(integrationItems)
.values(input.integrationIds.map((integrationId) => ({ itemId, integrationId })));
}
return { itemId };
return { itemId };
});
}),
});
@@ -5,7 +5,17 @@ import { describe, expect, test } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import { boards, integrations, integrationUserPermissions, items, layouts, sections, users } from "@homarr/db/schema";
import { eq as dbEq } from "@homarr/db";
import {
boards,
integrations,
integrationUserPermissions,
items,
layouts,
sectionLayouts,
sections,
users,
} from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { boardRouter } from "../board";
@@ -16,19 +26,28 @@ const createBoardCallerAsync = async () => {
const boardId = createId();
await db.insert(users).values({ id: userId });
await db.insert(boards).values({ id: boardId, name: "Home", creatorId: userId });
const layoutId = createId();
await db.insert(layouts).values({
id: createId(),
id: layoutId,
name: "Base",
boardId,
columnCount: 12,
breakpoint: 0,
});
await db.insert(sections).values({ id: createId(), boardId, kind: "empty", xOffset: 0, yOffset: 0 });
const sectionId = createId();
await db.insert(sections).values({ id: sectionId, boardId, kind: "empty", xOffset: 0, yOffset: 0 });
const session = {
user: { id: userId, permissions: [], colorScheme: "light" },
expires: new Date().toISOString(),
} satisfies Session;
return { db, userId, boardId, caller: boardRouter.createCaller({ db, deviceType: undefined, session }) };
return {
db,
userId,
boardId,
layoutId,
sectionId,
caller: boardRouter.createCaller({ db, deviceType: undefined, session }),
};
};
describe("board.addItem", () => {
@@ -52,6 +71,97 @@ describe("board.addItem", () => {
});
});
test("places a new item after the complete footprint of an existing container", async () => {
const { db, boardId, caller, layoutId, sectionId } = await createBoardCallerAsync();
const mobileLayoutId = createId();
await db.insert(layouts).values({
id: mobileLayoutId,
name: "Mobile",
boardId,
columnCount: 4,
breakpoint: 640,
});
const containerId = createId();
await db.insert(sections).values({ id: containerId, boardId, kind: "container" });
await db.insert(sectionLayouts).values([
{
sectionId: containerId,
parentSectionId: sectionId,
layoutId,
xOffset: 0,
yOffset: 0,
width: 4,
height: 3,
},
{
sectionId: containerId,
parentSectionId: sectionId,
layoutId: mobileLayoutId,
xOffset: 0,
yOffset: 0,
width: 2,
height: 1,
},
]);
const result = await caller.addItem({ boardId, kind: "app", options: {}, integrationIds: [] });
const placements = await db.query.itemLayouts.findMany({
where: (table, { eq }) => eq(table.itemId, result.itemId),
});
expect(placements).toEqual(
expect.arrayContaining([
expect.objectContaining({ layoutId, xOffset: 4, yOffset: 0, width: 1, height: 1 }),
expect.objectContaining({ layoutId: mobileLayoutId, xOffset: 2, yOffset: 0, width: 1, height: 1 }),
]),
);
});
test("uses the main section width instead of placing into gutter columns", async () => {
const { db, boardId, caller, layoutId, sectionId } = await createBoardCallerAsync();
await db
.update(layouts)
.set({ leftGutterColumnCount: 2, rightGutterColumnCount: 2 })
.where(dbEq(layouts.id, layoutId));
const containerId = createId();
await db.insert(sections).values({ id: containerId, boardId, kind: "container" });
await db.insert(sectionLayouts).values({
sectionId: containerId,
parentSectionId: sectionId,
layoutId,
xOffset: 0,
yOffset: 0,
width: 8,
height: 1,
});
const result = await caller.addItem({ boardId, kind: "app", options: {}, integrationIds: [] });
const placement = await db.query.itemLayouts.findFirst({
where: (table, { eq }) => eq(table.itemId, result.itemId),
});
expect(placement).toMatchObject({ xOffset: 0, yOffset: 1 });
});
test("serializes concurrent automatic placements on the same board", async () => {
const { db, boardId, caller } = await createBoardCallerAsync();
const results = await Promise.all([
caller.addItem({ boardId, kind: "app", options: {}, integrationIds: [] }),
caller.addItem({ boardId, kind: "app", options: {}, integrationIds: [] }),
]);
const placements = await db.query.itemLayouts.findMany({
where: (table, { inArray }) =>
inArray(
table.itemId,
results.map((result) => result.itemId),
),
});
expect(placements).toHaveLength(2);
expect(new Set(placements.map((placement) => `${placement.xOffset}:${placement.yOffset}`)).size).toBe(2);
});
test("accepts an integration the current user can use", async () => {
const { db, userId, boardId, caller } = await createBoardCallerAsync();
const integrationId = createId();