fix assistant widget configuration
This commit is contained in:
1 parent
8d06201b41
commit
1f60b4b4be
16 files changed
+696
-11
No files matched your search
@@ -0,0 +1,153 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { parse } from "superjson";
|
||||
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 { createDb } from "@homarr/db/test";
|
||||
|
||||
import { boardRouter } from "../board";
|
||||
|
||||
const createBoardCallerAsync = async () => {
|
||||
const db = createDb();
|
||||
const userId = createId();
|
||||
const boardId = createId();
|
||||
await db.insert(users).values({ id: userId });
|
||||
await db.insert(boards).values({ id: boardId, name: "Home", creatorId: userId });
|
||||
await db.insert(layouts).values({
|
||||
id: createId(),
|
||||
name: "Base",
|
||||
boardId,
|
||||
columnCount: 12,
|
||||
breakpoint: 0,
|
||||
});
|
||||
await db.insert(sections).values({ id: createId(), 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 }) };
|
||||
};
|
||||
|
||||
describe("board.addItem", () => {
|
||||
test("preserves configured notebook content when placing the widget", async () => {
|
||||
const { db, boardId, caller } = await createBoardCallerAsync();
|
||||
const content = "<h2>Self-host Plex</h2><p>Install Docker and configure your media volumes.</p>";
|
||||
|
||||
const result = await caller.addItem({
|
||||
boardId,
|
||||
kind: "notebook",
|
||||
options: { content, showToolbar: true, allowReadOnlyCheck: true },
|
||||
integrationIds: [],
|
||||
});
|
||||
|
||||
const item = await db.query.items.findFirst({ where: (table, { eq }) => eq(table.id, result.itemId) });
|
||||
expect(item?.kind).toBe("notebook");
|
||||
expect(parse(item?.options ?? "{}")).toEqual({
|
||||
content,
|
||||
showToolbar: true,
|
||||
allowReadOnlyCheck: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("accepts an integration the current user can use", async () => {
|
||||
const { db, userId, boardId, caller } = await createBoardCallerAsync();
|
||||
const integrationId = createId();
|
||||
await db.insert(integrations).values({
|
||||
id: integrationId,
|
||||
name: "Plex",
|
||||
kind: "plex",
|
||||
url: "https://plex.example.com",
|
||||
});
|
||||
await db.insert(integrationUserPermissions).values({ integrationId, userId, permission: "use" });
|
||||
|
||||
await expect(
|
||||
caller.addItem({ boardId, kind: "mediaServer", options: {}, integrationIds: [integrationId] }),
|
||||
).resolves.toMatchObject({ itemId: expect.any(String) });
|
||||
});
|
||||
|
||||
test("rejects an integration the current user cannot use", async () => {
|
||||
const { db, boardId, caller } = await createBoardCallerAsync();
|
||||
const integrationId = createId();
|
||||
await db.insert(integrations).values({
|
||||
id: integrationId,
|
||||
name: "Private Plex",
|
||||
kind: "plex",
|
||||
url: "https://private.example.com",
|
||||
});
|
||||
|
||||
await expect(
|
||||
caller.addItem({ boardId, kind: "mediaServer", options: {}, integrationIds: [integrationId] }),
|
||||
).rejects.toThrow("Integration not found");
|
||||
|
||||
await expect(db.$count(items)).resolves.toBe(0);
|
||||
});
|
||||
|
||||
test("rejects a missing required integration", async () => {
|
||||
const { db, boardId, caller } = await createBoardCallerAsync();
|
||||
|
||||
await expect(caller.addItem({ boardId, kind: "mediaServer", options: {}, integrationIds: [] })).rejects.toThrow(
|
||||
"mediaServer requires an integration",
|
||||
);
|
||||
|
||||
await expect(db.$count(items)).resolves.toBe(0);
|
||||
});
|
||||
|
||||
test("allows an explicitly optional integration to be omitted", async () => {
|
||||
const { boardId, caller } = await createBoardCallerAsync();
|
||||
|
||||
await expect(caller.addItem({ boardId, kind: "calendar", options: {}, integrationIds: [] })).resolves.toMatchObject(
|
||||
{ itemId: expect.any(String) },
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects an incompatible integration kind", async () => {
|
||||
const { db, userId, boardId, caller } = await createBoardCallerAsync();
|
||||
const integrationId = createId();
|
||||
await db.insert(integrations).values({
|
||||
id: integrationId,
|
||||
name: "Sonarr",
|
||||
kind: "sonarr",
|
||||
url: "https://sonarr.example.com",
|
||||
});
|
||||
await db.insert(integrationUserPermissions).values({ integrationId, userId, permission: "use" });
|
||||
|
||||
await expect(
|
||||
caller.addItem({ boardId, kind: "mediaServer", options: {}, integrationIds: [integrationId] }),
|
||||
).rejects.toThrow("mediaServer does not support integration kind: sonarr");
|
||||
|
||||
await expect(db.$count(items)).resolves.toBe(0);
|
||||
});
|
||||
|
||||
test("rejects more integrations than the widget supports", async () => {
|
||||
const { db, userId, boardId, caller } = await createBoardCallerAsync();
|
||||
const navidromeId = createId();
|
||||
const audiobookshelfId = createId();
|
||||
await db.insert(integrations).values([
|
||||
{ id: navidromeId, name: "Navidrome", kind: "navidrome", url: "https://music.example.com" },
|
||||
{
|
||||
id: audiobookshelfId,
|
||||
name: "Audiobookshelf",
|
||||
kind: "audiobookshelf",
|
||||
url: "https://books.example.com",
|
||||
},
|
||||
]);
|
||||
await db.insert(integrationUserPermissions).values([
|
||||
{ integrationId: navidromeId, userId, permission: "use" },
|
||||
{ integrationId: audiobookshelfId, userId, permission: "use" },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
caller.addItem({
|
||||
boardId,
|
||||
kind: "audioStats",
|
||||
options: {},
|
||||
integrationIds: [navidromeId, audiobookshelfId],
|
||||
}),
|
||||
).rejects.toThrow("audioStats supports at most 1 integration");
|
||||
|
||||
await expect(db.$count(items)).resolves.toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,10 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
|
||||
mediaReleases: ["mock", "emby", "jellyfin", "plex"],
|
||||
systemResources: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology"],
|
||||
systemDisks: ["dashDot", "openmediavault", "truenas", "unraid", "synology"],
|
||||
beszelAlerts: ["beszel", "mock"],
|
||||
beszelSystemGrid: ["beszel", "mock"],
|
||||
beszelSystemStats: ["beszel", "mock"],
|
||||
beszelSystemTable: ["beszel", "mock"],
|
||||
coolify: ["coolify"],
|
||||
"immich-serverStats": ["immich"],
|
||||
"immich-albumCarousel": ["immich"],
|
||||
@@ -45,16 +49,20 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
|
||||
umami: ["umami"],
|
||||
vpn: getIntegrationKindsByCategory("vpn"),
|
||||
ups: getIntegrationKindsByCategory("ups"),
|
||||
beszelSystemTable: ["beszel", "mock"],
|
||||
beszelSystemGrid: ["beszel", "mock"],
|
||||
beszelAlerts: ["beszel", "mock"],
|
||||
beszelSystemStats: ["beszel", "mock"],
|
||||
archiveTeamWarrior: ["archiveTeamWarrior"],
|
||||
anchorNote: ["anchor"],
|
||||
traefik: ["traefik"],
|
||||
wud: ["wud"],
|
||||
};
|
||||
|
||||
/** Widgets that remain useful without a configured integration. */
|
||||
export const widgetKindsWithOptionalIntegrations = new Set<WidgetKind>(["calendar"]);
|
||||
|
||||
/** Widgets that support fewer integrations than the default unlimited selection. */
|
||||
export const widgetIntegrationLimits: Partial<Record<WidgetKind, number>> = {
|
||||
audioStats: 1,
|
||||
};
|
||||
|
||||
export const getWidgetKindsForIntegration = (integrationKind: IntegrationKind): WidgetKind[] => {
|
||||
const result: WidgetKind[] = [];
|
||||
for (const [widgetKind, supportedIntegrations] of Object.entries(widgetIntegrationSupport)) {
|
||||
|
||||
@@ -58,6 +58,9 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
||||
customError: zodErrorMap(t),
|
||||
});
|
||||
const { definition } = innerProps;
|
||||
const integrationsRequired =
|
||||
innerProps.integrationSupport &&
|
||||
(!("integrationsRequired" in definition) || definition.integrationsRequired !== false);
|
||||
const options = definition.createOptions(innerProps.settings) as Record<string, OptionsBuilderResult[string]>;
|
||||
const optionsSuperRefine = (options as Record<symbol, unknown>)[OPTIONS_SUPER_REFINE] as
|
||||
| ((data: Record<string, unknown>, ctx: z.RefinementCtx) => void)
|
||||
@@ -82,7 +85,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
||||
validate: schemaResolver(
|
||||
z.object({
|
||||
options: optionsSchema,
|
||||
integrationIds: z.array(z.string()),
|
||||
integrationIds: integrationsRequired ? z.array(z.string()).min(1) : z.array(z.string()),
|
||||
advancedOptions: z.object({
|
||||
customCssClasses: z.array(z.string()),
|
||||
borderColor: z.string(),
|
||||
@@ -130,6 +133,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
||||
label={t("item.edit.field.integrations.label")}
|
||||
data={innerProps.integrationData}
|
||||
canSelectMultiple={((definition as { maxIntegrations?: number }).maxIntegrations ?? Infinity) > 1}
|
||||
withAsterisk={integrationsRequired}
|
||||
{...form.getInputProps("integrationIds")}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user