feat(widgets): consolidate integration metadata + shared response caching (#6691)

This commit is contained in:
Thomas authored and GitHub committed 2026-08-30 18:38:07 +02:00
1 parent 7d1d9062ed
commit 8d8dec5749
206 files changed
+3893 -1946

No files matched your search

@@ -2,25 +2,32 @@ import { describe, expect, test } from "vitest";
import type { WidgetKind } from "@homarr/definitions";
import { widgetIntegrationSupport } from "@homarr/definitions";
import { widgetImports } from "@homarr/widgets";
import { loadAllWidgetDefinitions } from "@homarr/widgets/manifest";
import { getAssistantWidgetConfiguration } from "./assistant-widget-tool";
const settings = { enableStatusByDefault: true, forceDisableStatus: false };
const widgetDefinitions = await loadAllWidgetDefinitions();
const getWidgetDefinition = (kind: WidgetKind) => {
const definition = widgetDefinitions.get(kind);
if (!definition) throw new Error(`No definition is registered for ${kind}`);
return definition;
};
describe("getAssistantWidgetConfiguration", () => {
test("keeps server-side integration support synchronized with widget definitions", () => {
for (const [kind, widgetImport] of Object.entries(widgetImports)) {
const definition = widgetImport.definition;
for (const [kind, definition] of widgetDefinitions) {
const expected = "supportedIntegrations" in definition ? definition.supportedIntegrations : undefined;
const supportedIntegrations = widgetIntegrationSupport[kind];
expect(widgetIntegrationSupport[kind as WidgetKind], kind).toEqual(expected);
expect(supportedIntegrations, kind).toEqual(expected);
}
});
test("preserves generated notebook content and fills its hidden defaults", () => {
const result = getAssistantWidgetConfiguration(
widgetImports.notebook.definition,
getWidgetDefinition("notebook"),
{
boardId: "board-1",
boardName: "Home",
@@ -42,7 +49,7 @@ describe("getAssistantWidgetConfiguration", () => {
test("keeps only accessible, compatible integrations and respects the widget maximum", () => {
const result = getAssistantWidgetConfiguration(
widgetImports.audioStats.definition,
getWidgetDefinition("audioStats"),
{
boardId: "board-1",
boardName: "Home",
@@ -90,13 +97,13 @@ describe("getAssistantWidgetConfiguration", () => {
test("treats integration-backed widgets as required unless explicitly optional", () => {
const mediaServer = getAssistantWidgetConfiguration(
widgetImports.mediaServer.definition,
getWidgetDefinition("mediaServer"),
{ boardId: "board-1", boardName: "Home", kind: "mediaServer", summary: "Show Plex streams" },
settings,
[],
);
const calendar = getAssistantWidgetConfiguration(
widgetImports.calendar.definition,
getWidgetDefinition("calendar"),
{ boardId: "board-1", boardName: "Home", kind: "calendar", summary: "Show releases" },
settings,
[],
@@ -4,7 +4,7 @@ import { IconLayoutGrid } from "@tabler/icons-react";
import type { BoardPreviewData } from "@homarr/boards/layout-preview";
import { projectBoardLayout } from "@homarr/boards/layout-preview";
import { boardLanes, getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
import { widgetCatalogIcons } from "@homarr/ui/widget-icons";
import classes from "./board-layout-thumbnail.module.css";
@@ -63,14 +63,14 @@ describe("item actions create-item", () => {
{
layoutId,
height: 1,
width: 1,
width: 2,
...emptyPosition,
sectionId: firstSectionId,
},
],
}),
);
expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount, undefined, { height: 1, width: 1 });
expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount, undefined, { height: 1, width: 2 });
});
test("should correctly pass containers and items to getFirstEmptyPosition", () => {
// Arrange
@@ -118,14 +118,14 @@ describe("item actions create-item", () => {
expect(createdItem).toEqual(
expect.objectContaining({
kind: itemKind,
layouts: [{ ...emptyPosition, height: 1, width: 1, sectionId: firstSectionId, layoutId }],
layouts: [{ ...emptyPosition, height: 1, width: 2, sectionId: firstSectionId, layoutId }],
}),
);
expect(spy).toHaveBeenCalledWith(
[expect.objectContaining(itemAndSectionPosition), expect.objectContaining(itemAndSectionPosition)],
layout.columnCount,
undefined,
{ height: 1, width: 1 },
{ height: 1, width: 2 },
);
});
@@ -1,5 +1,5 @@
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import { nativeFeatureCapabilities, widgetKinds } from "@homarr/definitions";
import { widgetIntegrationSupport, widgetKinds } from "@homarr/definitions";
export interface BoardRecipeRecommendation {
widgetKind: WidgetKind;
@@ -36,10 +36,10 @@ export const getBoardRecipeRecommendations = ({
return widgetKinds
.filter((widgetKind) => !existingKinds.has(widgetKind))
.flatMap((widgetKind) => {
const capability = nativeFeatureCapabilities[widgetKind as keyof typeof nativeFeatureCapabilities];
if (!capability) return [];
const supportedIntegrations = widgetIntegrationSupport[widgetKind];
if (!supportedIntegrations) return [];
const matches = capability.integrations.filter((integrationKind) => availableKinds.has(integrationKind));
const matches = supportedIntegrations.filter((integrationKind) => availableKinds.has(integrationKind));
if (matches.length === 0) return [];
const preferredMatch = preferredIntegrationKind && matches.includes(preferredIntegrationKind);
@@ -51,10 +51,10 @@ export const getBoardRecipeRecommendations = ({
widgetKind,
integrationKind,
isNewlyAvailable: Boolean(preferredMatch),
score: (preferredMatch ? 1_000 : 0) + matches.length * 10 - capability.integrations.length,
score: (preferredMatch ? 1_000 : 0) + matches.length * 10 - supportedIntegrations.length,
},
];
})
.sort((left, right) => right.score - left.score || left.widgetKind.localeCompare(right.widgetKind))
.toSorted((left, right) => right.score - left.score || left.widgetKind.localeCompare(right.widgetKind))
.slice(0, Math.max(0, limit));
};
@@ -42,7 +42,7 @@ import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { SelectableCard } from "@homarr/ui";
import type { TablerIcon } from "@homarr/ui";
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
import { widgetCatalogIcons } from "@homarr/ui/widget-icons";
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import type { EmptySection } from "~/app/[locale]/boards/_types";
@@ -1,12 +1,14 @@
import type { QueryKey } from "@tanstack/react-query";
import { describe, expect, test } from "vitest";
import type { NormalizedWidgetQuery, WidgetDefinition, WidgetQueryMatcherScope } from "@homarr/widgets";
import { getWidgetQueryKeys, widgetImports } from "@homarr/widgets";
import type { WidgetKind } from "@homarr/definitions";
import type { NormalizedWidgetQuery, WidgetQueryMatcherScope } from "@homarr/widgets";
import { getWidgetQueryKeys } from "@homarr/widgets";
import { loadAllWidgetDefinitions } from "@homarr/widgets/manifest";
import { matchesWidgetItemQuery } from "./widget-query-scope";
type WidgetKind = keyof typeof widgetImports;
const widgetDefinitions = await loadAllWidgetDefinitions();
const allPhotosAlbumId = "all";
const createScope = (overrides: Partial<WidgetQueryMatcherScope> = {}): WidgetQueryMatcherScope => ({
@@ -19,7 +21,9 @@ const createScope = (overrides: Partial<WidgetQueryMatcherScope> = {}): WidgetQu
});
const matches = (kind: WidgetKind, path: readonly string[], input: unknown, scope: WidgetQueryMatcherScope) => {
const definition = widgetImports[kind].definition as WidgetDefinition & { kind: string };
const definition = widgetDefinitions.get(kind);
if (!definition) throw new Error(`No definition is registered for ${kind}`);
const queryKey: QueryKey = [path, { input, type: "query" }];
return matchesWidgetItemQuery(queryKey, getWidgetQueryKeys(definition), scope, definition.queryMatcher);
};
@@ -22,7 +22,7 @@ import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { getBoardLaneColumnCount, getRootSectionLane, getWidgetName } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
import { widgetCatalogIcons } from "@homarr/ui/widget-icons";
import type { EmptySection } from "~/app/[locale]/boards/_types";
import { useRegisteredGridEditors } from "~/components/board/sections/grid/grid-editor-registry";