From c0596d1b5efcde205a0ded9a56607257eb73a973 Mon Sep 17 00:00:00 2001 From: ajnart Date: Mon, 10 Aug 2026 11:58:32 +0200 Subject: [PATCH] fix assistant dashboard placement --- apps/docs/docs/management/assistant.mdx | 4 + .../src/router/test/board-add-item.spec.ts | 118 +++++++++++++++++- 2 files changed, 118 insertions(+), 4 deletions(-) diff --git a/apps/docs/docs/management/assistant.mdx b/apps/docs/docs/management/assistant.mdx index 4d9624849..2a9f038ba 100644 --- a/apps/docs/docs/management/assistant.mdx +++ b/apps/docs/docs/management/assistant.mdx @@ -209,6 +209,10 @@ the native widget configuration form and standard mutation approval, then places available dashboard position. The generated content is retained even though the Notebook content field is normally hidden from widget settings. +Automatic placement checks every responsive layout and treats both widgets and complete container footprints +as occupied. New assistant-created items use the first free position in the main canvas instead of overlapping +existing content or being placed in a gutter. + Every assistant-created dashboard widget now passes through Homarr's native widget editor before it is added. The form fills widget defaults, validates widget-specific options, and only offers compatible integrations that the current user can use. Required integrations cannot be left empty, single-integration widgets cannot receive diff --git a/packages/api/src/router/test/board-add-item.spec.ts b/packages/api/src/router/test/board-add-item.spec.ts index 4184c2b5d..089bc0c78 100644 --- a/packages/api/src/router/test/board-add-item.spec.ts +++ b/packages/api/src/router/test/board-add-item.spec.ts @@ -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();