feat: add assistant board styling tools
This commit is contained in:
1 parent
93113234a7
commit
9da3ff3e9d
20 files changed
+1045
-157
No files matched your search
@@ -43,6 +43,9 @@ import {
|
||||
normalizeBoardLayoutRoles,
|
||||
rootSectionOffsets,
|
||||
widgetDefaultSizes,
|
||||
widgetIntegrationLimits,
|
||||
widgetIntegrationSupport,
|
||||
widgetKindsWithOptionalIntegrations,
|
||||
widgetKinds,
|
||||
} from "@homarr/definitions";
|
||||
import { importOldmarrAsync } from "@homarr/old-import";
|
||||
@@ -60,6 +63,7 @@ import {
|
||||
boardSavePartialSettingsSchema,
|
||||
boardSavePermissionsSchema,
|
||||
boardSaveSchema,
|
||||
boardSettingsSchema,
|
||||
boardSummarySchema,
|
||||
} from "@homarr/validation/board";
|
||||
import { byIdSchema } from "@homarr/validation/common";
|
||||
@@ -69,12 +73,57 @@ import { sectionSchema, sharedItemSchema } from "@homarr/validation/shared";
|
||||
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "./board/board-access";
|
||||
import { throwIfActionForbiddenAsync as throwIfIntegrationActionForbiddenAsync } from "./integration/integration-access";
|
||||
import {
|
||||
throwIfCustomWidgetBoardDuplicationForbidden,
|
||||
throwIfCustomWidgetPlacementChangeForbidden,
|
||||
} 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")
|
||||
@@ -793,6 +842,57 @@ export const boardRouter = createTRPCRouter({
|
||||
|
||||
return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);
|
||||
}),
|
||||
getBoardSettings: protectedProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Read the editable visual and behavior settings for one board, including its current custom CSS. Requires modify permission. REQUIRED: id (board ID). Call this before proposing board settings or custom CSS changes",
|
||||
},
|
||||
})
|
||||
.input(z.object({ id: z.string() }))
|
||||
.output(boardSettingsSchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const boardWhere = eq(boards.id, input.id);
|
||||
await throwIfActionForbiddenAsync(ctx, boardWhere, "modify");
|
||||
|
||||
const board = await ctx.db.query.boards.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
pageTitle: true,
|
||||
metaTitle: true,
|
||||
logoImageUrl: true,
|
||||
faviconImageUrl: true,
|
||||
backgroundImageUrl: true,
|
||||
backgroundImageAttachment: true,
|
||||
backgroundImageRepeat: true,
|
||||
backgroundImageSize: true,
|
||||
primaryColor: true,
|
||||
secondaryColor: true,
|
||||
opacity: true,
|
||||
customCss: true,
|
||||
iconColor: true,
|
||||
itemRadius: true,
|
||||
disableStatus: true,
|
||||
},
|
||||
where: boardWhere,
|
||||
});
|
||||
if (!board) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Board not found" });
|
||||
}
|
||||
|
||||
return {
|
||||
...board,
|
||||
pageTitle: board.pageTitle ?? "",
|
||||
metaTitle: board.metaTitle ?? "",
|
||||
logoImageUrl: board.logoImageUrl ?? "",
|
||||
faviconImageUrl: board.faviconImageUrl ?? "",
|
||||
backgroundImageUrl: board.backgroundImageUrl ?? "",
|
||||
customCss: board.customCss ?? "",
|
||||
iconColor: board.iconColor ?? "",
|
||||
};
|
||||
}),
|
||||
saveLayouts: protectedProcedure
|
||||
.input(boardSaveLayoutsSchema)
|
||||
.output(boardSaveLayoutsSchema.shape.layouts)
|
||||
@@ -1760,7 +1860,7 @@ export const boardRouter = createTRPCRouter({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Add a widget/app item to a board. Automatically places it in the first empty section at the next free grid position. Provide boardId (from board_getAllBoards), kind (widget type like 'app', 'weather', etc.), optional options map, and optional integrationIds array. 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)
|
||||
@@ -1777,9 +1877,28 @@ export const boardRouter = createTRPCRouter({
|
||||
await validateTimetableOptionsChangeAsync(input.options);
|
||||
}
|
||||
|
||||
const supportedIntegrations = widgetIntegrationSupport[input.kind];
|
||||
const integrationLimit = widgetIntegrationLimits[input.kind] ?? Infinity;
|
||||
if (input.integrationIds.length > integrationLimit) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `${input.kind} supports at most ${integrationLimit} integration${integrationLimit === 1 ? "" : "s"}`,
|
||||
});
|
||||
}
|
||||
if (supportedIntegrations === undefined && input.integrationIds.length > 0) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `${input.kind} does not support integrations` });
|
||||
}
|
||||
if (
|
||||
supportedIntegrations !== undefined &&
|
||||
input.integrationIds.length === 0 &&
|
||||
!widgetKindsWithOptionalIntegrations.has(input.kind)
|
||||
) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `${input.kind} requires an integration` });
|
||||
}
|
||||
|
||||
if (input.integrationIds.length > 0) {
|
||||
const existing = await ctx.db.query.integrations.findMany({
|
||||
columns: { id: true },
|
||||
columns: { id: true, kind: true },
|
||||
where: inArray(integrations.id, input.integrationIds),
|
||||
});
|
||||
const validIds = new Set(existing.map((row) => row.id));
|
||||
@@ -1787,111 +1906,101 @@ export const boardRouter = createTRPCRouter({
|
||||
if (invalid.length > 0) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `Invalid integration IDs: ${invalid.join(", ")}` });
|
||||
}
|
||||
}
|
||||
|
||||
const board = await ctx.db.query.boards.findFirst({
|
||||
where: eq(boards.id, input.boardId),
|
||||
with: {
|
||||
sections: true,
|
||||
layouts: true,
|
||||
items: { with: { layouts: true } },
|
||||
},
|
||||
});
|
||||
await Promise.all(
|
||||
input.integrationIds.map((integrationId) =>
|
||||
throwIfIntegrationActionForbiddenAsync(ctx, eq(integrations.id, integrationId), "use"),
|
||||
),
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const defaultSize = widgetDefaultSizes[input.kind as WidgetKind] ?? { width: 1, height: 1 };
|
||||
const sizeForLayout = { ...defaultSize, width: Math.min(layout.columnCount, defaultSize.width) };
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!placed) {
|
||||
const incompatible = existing.filter((integration) => !supportedIntegrations?.includes(integration.kind));
|
||||
if (incompatible.length > 0) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Board section is full, no free grid position available",
|
||||
message: `${input.kind} does not support integration kind${incompatible.length === 1 ? "" : "s"}: ${incompatible.map((integration) => integration.kind).join(", ")}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (layoutRows.length > 0) {
|
||||
await ctx.db.insert(itemLayouts).values(layoutRows);
|
||||
}
|
||||
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 (input.integrationIds.length > 0) {
|
||||
await ctx.db
|
||||
.insert(integrationItems)
|
||||
.values(input.integrationIds.map((integrationId) => ({ itemId, integrationId })));
|
||||
}
|
||||
if (!board) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Board not found" });
|
||||
}
|
||||
|
||||
return { itemId };
|
||||
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 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,
|
||||
);
|
||||
|
||||
if (!position) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Board section is full, no free grid position available",
|
||||
});
|
||||
}
|
||||
|
||||
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 })));
|
||||
}
|
||||
|
||||
return { itemId };
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1908,7 +2017,7 @@ export const boardRouter = createTRPCRouter({
|
||||
* 8. serverSettings.homeBoardId
|
||||
* 9. show NOT_FOUND error
|
||||
*/
|
||||
const getHomeIdBoardAsync = async (
|
||||
export const getHomeIdBoardAsync = async (
|
||||
db: Database,
|
||||
user: InferSelectModel<typeof users> | null,
|
||||
deviceType: DeviceType,
|
||||
|
||||
@@ -658,6 +658,28 @@ describe("getBoardByName should return board by name", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBoardSettings should return editable board settings", () => {
|
||||
test("returns current custom CSS and requires modify permission", async () => {
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const { boardId } = await createFullBoardAsync(db, "default");
|
||||
const customCss = ".dashboard-card { border-radius: 0.5rem; }";
|
||||
await db.update(boards).set({ customCss, primaryColor: "#123456", pageTitle: null }).where(eq(boards.id, boardId));
|
||||
|
||||
const result = await caller.getBoardSettings({ id: boardId });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
id: boardId,
|
||||
name: "default",
|
||||
customCss,
|
||||
primaryColor: "#123456",
|
||||
pageTitle: null,
|
||||
});
|
||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
|
||||
});
|
||||
});
|
||||
|
||||
describe("savePartialBoardSettings should save general settings", () => {
|
||||
it("should save general settings", async () => {
|
||||
// Arrange
|
||||
|
||||
Reference in new issue
Block a user