perf: optimize dashboard loading and builds

This commit is contained in:
Thomas Camlong committed 2026-08-02 15:58:16 +02:00
1 parent d90e762e4b
commit de801f3f00
135 files changed
+8521 -1718

No files matched your search

@@ -5,6 +5,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("anchorNote", {
icon: IconNotes,
queryKey: [["widget", "anchorNotes"]],
supportedIntegrations: ["anchor"],
createOptions() {
return optionsBuilder.from((factory) => ({
+1
View File
@@ -15,6 +15,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("app", {
icon: IconApps,
queryKeys: [[["app", "byId"]], [["widget", "app"]]],
createOptions(settings) {
return optionsBuilder.from(
(factory) => ({
@@ -6,6 +6,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("beszelAlerts", {
icon: IconBell,
queryKey: [["widget", "beszel"]],
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
+1
View File
@@ -10,6 +10,7 @@ import { BookmarkAddButton } from "./add-button";
export const { definition, componentLoader } = createWidgetDefinition("bookmarks", {
icon: IconBookmark,
queryKeys: [[["app", "byIds"]]],
createOptions() {
return optionsBuilder.from((factory) => ({
title: factory.text(),
+110
View File
@@ -0,0 +1,110 @@
import {
IconActivityHeartbeat,
IconAd,
IconApi,
IconApps,
IconArchive,
IconBadgeCc,
IconBatteryCharging,
IconBell,
IconBinaryTree,
IconBookmark,
IconBrandDocker,
IconBrandMinecraft,
IconBrowser,
IconBuildingBank,
IconBusStop,
IconCalendar,
IconChartAreaLine,
IconChartBar,
IconClock,
IconCloud,
IconDeviceCctv,
IconDeviceGamepad,
IconDownload,
IconFileText,
IconGraphFilled,
IconHeadphones,
IconHeartRateMonitor,
IconHeartbeat,
IconLayoutGrid,
IconMessage,
IconMovie,
IconNotes,
IconPhoto,
IconReportSearch,
IconRocket,
IconRoute,
IconRss,
IconServer2,
IconShieldCheck,
IconShieldLock,
IconSpeedboat,
IconTable,
IconTicket,
IconTopologyFull,
IconTransform,
IconVideo,
IconWall,
IconZoomQuestion,
} from "@tabler/icons-react";
import type { WidgetKind } from "@homarr/definitions";
import type { TablerIcon } from "@homarr/ui";
export const widgetCatalogIcons: Record<WidgetKind, TablerIcon> = {
clock: IconClock,
weather: IconCloud,
app: IconApps,
iframe: IconBrowser,
video: IconDeviceCctv,
notebook: IconNotes,
anchorNote: IconNotes,
dnsHoleSummary: IconAd,
dnsHoleControls: IconDeviceGamepad,
"smartHome-entityState": IconBinaryTree,
"smartHome-executeAutomation": IconBinaryTree,
stockPrice: IconBuildingBank,
mediaServer: IconVideo,
calendar: IconCalendar,
downloads: IconDownload,
"mediaRequests-requestList": IconZoomQuestion,
"mediaRequests-requestStats": IconChartBar,
mediaTranscoding: IconTransform,
mediaMissing: IconMovie,
minecraftServerStatus: IconBrandMinecraft,
networkControllerSummary: IconTopologyFull,
networkControllerStatus: IconTopologyFull,
rssFeed: IconRss,
bookmarks: IconBookmark,
indexerManager: IconReportSearch,
healthMonitoring: IconHeartRateMonitor,
releases: IconRocket,
mediaReleases: IconTicket,
dockerContainers: IconBrandDocker,
firewall: IconWall,
notifications: IconMessage,
systemResources: IconGraphFilled,
coolify: IconCloud,
systemDisks: IconServer2,
timetable: IconBusStop,
"immich-serverStats": IconGraphFilled,
"immich-albumCarousel": IconPhoto,
paperlessNgx: IconFileText,
patchmon: IconShieldCheck,
bazarr: IconBadgeCc,
tracearr: IconActivityHeartbeat,
speedtestTracker: IconSpeedboat,
uptimeKuma: IconHeartbeat,
audioStats: IconHeadphones,
umami: IconChartBar,
vpn: IconShieldLock,
archiveTeamWarrior: IconArchive,
ups: IconBatteryCharging,
beszelSystemTable: IconTable,
beszelSystemGrid: IconLayoutGrid,
beszelAlerts: IconBell,
beszelSystemStats: IconChartAreaLine,
traefik: IconRoute,
customApi: IconApi,
};
-37
View File
@@ -1,37 +0,0 @@
"use client";
import type { ComponentType } from "react";
import dynamic from "next/dynamic";
import { Center, Loader as UiLoader } from "@mantine/core";
import type { WidgetKind } from "@homarr/definitions";
import type { WidgetComponentProps } from "./definition";
import { loadWidgetModule, widgetKinds } from "./manifest";
const createWidgetComponent = (kind: WidgetKind) =>
dynamic<WidgetComponentProps<WidgetKind>>(
async () => {
const { componentLoader } = await loadWidgetModule(kind);
return await componentLoader();
},
{
loading: () => (
<Center w="100%" h="100%">
<UiLoader />
</Center>
),
},
);
// Creating every dynamic wrapper at module scope lets Next register stable
// client boundaries and preload metadata. Widget implementation modules remain
// behind their manifest loaders.
const widgetComponents = Object.fromEntries(widgetKinds.map((kind) => [kind, createWidgetComponent(kind)])) as Record<
WidgetKind,
ComponentType<WidgetComponentProps<WidgetKind>>
>;
export const getWidgetComponent = <TKind extends WidgetKind>(kind: TKind) => {
return widgetComponents[kind] as ComponentType<WidgetComponentProps<TKind>>;
};
+1
View File
@@ -6,6 +6,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("clock", {
icon: IconClock,
queryKey: [["widget", "weather"]],
createOptions() {
return optionsBuilder.from(
(factory) => ({
+4
View File
@@ -70,6 +70,7 @@ export const createWidgetDefinition = <TKind extends WidgetKind, TDefinition ext
export interface WidgetDefinition {
icon: TablerIcon;
queryKey?: QueryKey;
queryKeys?: QueryKey[];
refetchInterval?: number | null;
supportedIntegrations?: IntegrationKind[];
integrationsRequired?: boolean;
@@ -90,6 +91,9 @@ export interface WidgetDefinition {
contextActions?: (props: Omit<WidgetContextActionProps<WidgetKind>, "kind">) => WidgetContextMenuAction[];
}
export const getWidgetQueryKeys = (definition: WidgetDefinition, kind: WidgetKind): QueryKey[] =>
definition.queryKeys ?? [definition.queryKey ?? [["widget", kind]]];
export interface WidgetProps<TKind extends WidgetKind> {
options: inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>;
integrationIds: string[];
@@ -1,30 +0,0 @@
import { getDownloadJobsAndStatusesAsync } from "@homarr/api/widget-prefetch-server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { Prefetch } from "../definition";
import { createTrpcQueryKey } from "../trpc-query-key";
const logger = createLogger({ module: "downloadsWidgetPrefetch" });
const prefetchAllAsync: Prefetch<"downloads"> = async (queryClient, items) => {
await Promise.all(
items.map(async (item) => {
if (item.integrationIds.length === 0) return;
const input = {
integrationIds: item.integrationIds,
limitPerIntegration: Number(item.options.limitPerIntegration) || 50,
};
try {
const data = await getDownloadJobsAndStatusesAsync(input);
queryClient.setQueryData(createTrpcQueryKey("widget.downloads.getJobsAndStatuses", input), data);
logger.info("Prefetched downloads widget data", { integrations: item.integrationIds.length });
} catch (error) {
logger.warn("Failed to prefetch downloads widget data", { error });
}
}),
);
};
export default prefetchAllAsync;
@@ -17,7 +17,7 @@ export const BaseWidgetError = (props: BaseWidgetErrorProps) => {
const t = useI18n();
return (
<Stack h="100%" align="center" justify="center" gap="md">
<Stack h="100%" align="center" justify="center" gap="md" data-homarr-widget-error>
<props.icon size={40} />
<Stack gap={0}>
<Text ta="center">{translateIfNecessary(t, props.message)}</Text>
@@ -8,6 +8,7 @@ import { createStorageVolumeMultiSelectOptions } from "../storage-volume-options
export const { definition, componentLoader } = createWidgetDefinition("healthMonitoring", {
icon: IconHeartRateMonitor,
queryKeys: [[["widget", "healthMonitoring"]], [["integration", "byIds"]]],
refetchInterval: 5,
createOptions() {
return optionsBuilder.from(
+51 -2
View File
@@ -1,17 +1,20 @@
import type { QueryKey } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import { widgetKinds as definedWidgetKinds } from "@homarr/definitions";
import { widgetIntegrationSupport, widgetKinds as definedWidgetKinds } from "@homarr/definitions";
import {
createRetryableLoader,
loadAllWidgetDefinitions,
loadWidgetComponent,
loadWidgetDefinition,
loadWidgetModule,
loadWidgetResources,
reduceWidgetOptionsWithDefinition,
widgetKinds,
} from "./manifest";
import { widgetQueryRefetchIntervals } from "./refetch-intervals";
import { widgetCatalogIcons } from "./catalog";
describe("widget manifest promise stability", () => {
it("returns the same module promise for every render", () => {
@@ -22,6 +25,11 @@ describe("widget manifest promise stability", () => {
expect(loadWidgetDefinition("clock")).toBe(loadWidgetDefinition("clock"));
});
it("returns stable component and combined resource promises", () => {
expect(loadWidgetComponent("clock")).toBe(loadWidgetComponent("clock"));
expect(loadWidgetResources("clock")).toBe(loadWidgetResources("clock"));
});
it("returns the same all-definitions promise for every modal render", () => {
expect(loadAllWidgetDefinitions()).toBe(loadAllWidgetDefinitions());
});
@@ -56,9 +64,14 @@ describe("widget manifest promise stability", () => {
await Promise.all(
widgetKinds.map(async (kind) => {
const module = await loadWidgetModule(kind);
const component = await module.componentLoader();
const canonicalComponent = await module.componentLoader();
const component = await loadWidgetComponent(kind);
const resources = await loadWidgetResources(kind);
expect(definitions.get(kind)).toBe(module.definition);
expect(component.default).toBe(canonicalComponent.default);
expect(component.default).toBeDefined();
expect(resources.definition).toBe(module.definition);
expect(resources.Component).toBe(component.default);
}),
);
}, 30_000);
@@ -75,6 +88,42 @@ describe("widget manifest promise stability", () => {
}
});
it("keeps the lightweight integration support map aligned with widget definitions", async () => {
const definitions = await loadAllWidgetDefinitions();
for (const kind of widgetKinds) {
const definition = definitions.get(kind);
const supportedIntegrations =
definition && "supportedIntegrations" in definition ? (definition.supportedIntegrations ?? []) : [];
expect([...(widgetIntegrationSupport[kind] ?? [])].toSorted()).toEqual([...supportedIntegrations].toSorted());
}
});
it("keeps the lightweight catalog icons aligned with widget definitions", async () => {
const definitions = await loadAllWidgetDefinitions();
for (const kind of widgetKinds) {
expect(widgetCatalogIcons[kind]).toBe(definitions.get(kind)?.icon);
}
});
it("declares the real query prefix for widget kinds that share a router", async () => {
const definitions = await loadAllWidgetDefinitions();
const expectedQueryKeys = new Map([
["anchorNote", [["widget", "anchorNotes"]]],
["beszelAlerts", [["widget", "beszel"]]],
["clock", [["widget", "weather"]]],
["mediaMissing", [["widget", "mediaOrganizer"]]],
["mediaRequests-requestList", [["widget", "mediaRequests"]]],
["mediaRequests-requestStats", [["widget", "mediaRequests"]]],
["smartHome-entityState", [["widget", "smartHome"]]],
] as const);
for (const [kind, queryKey] of expectedQueryKeys) {
expect(definitions.get(kind)?.queryKey).toEqual(queryKey);
}
});
it("keeps the client polling policy aligned with widget definitions", async () => {
const definitions = await loadAllWidgetDefinitions();
type PollingPolicy = { queryKey: QueryKey; intervalSeconds: number | null };
+89
View File
@@ -12,6 +12,15 @@ type WidgetModule = {
componentLoader: () => Promise<{ default: ComponentType<WidgetComponentProps<WidgetKind>> }>;
};
type WidgetComponentModule = {
default: ComponentType<WidgetComponentProps<WidgetKind>>;
};
export type WidgetResources = {
definition: WidgetDefinition;
Component: ComponentType<WidgetComponentProps<WidgetKind>>;
};
const moduleLoaders: Record<WidgetKind, () => Promise<WidgetModule>> = {
clock: () => import("./clock") as Promise<WidgetModule>,
weather: () => import("./weather") as Promise<WidgetModule>,
@@ -69,6 +78,67 @@ const moduleLoaders: Record<WidgetKind, () => Promise<WidgetModule>> = {
customApi: () => import("./custom-api") as Promise<WidgetModule>,
};
// Keep these imports explicit so Turbopack can create one discoverable chunk per
// widget implementation. Loading this registry does not load the implementations.
const componentLoaders: Record<WidgetKind, () => Promise<WidgetComponentModule>> = {
clock: () => import("./clock/component") as Promise<WidgetComponentModule>,
weather: () => import("./weather/component") as Promise<WidgetComponentModule>,
app: () => import("./app/component") as Promise<WidgetComponentModule>,
archiveTeamWarrior: () => import("./archive-team-warrior/component") as Promise<WidgetComponentModule>,
anchorNote: () => import("./anchor-note/component") as Promise<WidgetComponentModule>,
notebook: () => import("./notebook/component") as Promise<WidgetComponentModule>,
iframe: () => import("./iframe/component") as Promise<WidgetComponentModule>,
video: () => import("./video/component") as Promise<WidgetComponentModule>,
dnsHoleSummary: () => import("./dns-hole/summary/component") as Promise<WidgetComponentModule>,
dnsHoleControls: () => import("./dns-hole/controls/component") as Promise<WidgetComponentModule>,
"smartHome-entityState": () => import("./smart-home/entity-state/component") as Promise<WidgetComponentModule>,
"smartHome-executeAutomation": () =>
import("./smart-home/execute-automation/component") as Promise<WidgetComponentModule>,
stockPrice: () => import("./stocks/component") as Promise<WidgetComponentModule>,
mediaServer: () => import("./media-server/component") as Promise<WidgetComponentModule>,
calendar: () => import("./calendar/component") as Promise<WidgetComponentModule>,
downloads: () => import("./downloads/component") as Promise<WidgetComponentModule>,
"mediaRequests-requestList": () => import("./media-requests/list/component") as Promise<WidgetComponentModule>,
"mediaRequests-requestStats": () => import("./media-requests/stats/component") as Promise<WidgetComponentModule>,
mediaMissing: () => import("./media-missing/component") as Promise<WidgetComponentModule>,
networkControllerSummary: () => import("./network-controller/summary/component") as Promise<WidgetComponentModule>,
networkControllerStatus: () =>
import("./network-controller/network-status/component") as Promise<WidgetComponentModule>,
rssFeed: () => import("./rssFeed/component") as Promise<WidgetComponentModule>,
bookmarks: () => import("./bookmarks/component") as Promise<WidgetComponentModule>,
bazarr: () => import("./bazarr/component") as Promise<WidgetComponentModule>,
indexerManager: () => import("./indexer-manager/component") as Promise<WidgetComponentModule>,
healthMonitoring: () => import("./health-monitoring/component") as Promise<WidgetComponentModule>,
mediaTranscoding: () => import("./media-transcoding/component") as Promise<WidgetComponentModule>,
minecraftServerStatus: () => import("./minecraft/server-status/component") as Promise<WidgetComponentModule>,
dockerContainers: () => import("./docker/component") as Promise<WidgetComponentModule>,
releases: () => import("./releases/component") as Promise<WidgetComponentModule>,
firewall: () => import("./firewall/component") as Promise<WidgetComponentModule>,
notifications: () => import("./notifications/component") as Promise<WidgetComponentModule>,
mediaReleases: () => import("./media-releases/component") as Promise<WidgetComponentModule>,
systemResources: () => import("./system-resources/component") as Promise<WidgetComponentModule>,
coolify: () => import("./coolify/component") as Promise<WidgetComponentModule>,
systemDisks: () => import("./system-disks/component") as Promise<WidgetComponentModule>,
timetable: () => import("./timetable/component") as Promise<WidgetComponentModule>,
"immich-serverStats": () => import("./immich/server-stats/component") as Promise<WidgetComponentModule>,
"immich-albumCarousel": () => import("./immich/album-carousel/component") as Promise<WidgetComponentModule>,
paperlessNgx: () => import("./paperless-ngx/component") as Promise<WidgetComponentModule>,
patchmon: () => import("./patchmon/component") as Promise<WidgetComponentModule>,
tracearr: () => import("./tracearr/component") as Promise<WidgetComponentModule>,
speedtestTracker: () => import("./speedtest-tracker/component") as Promise<WidgetComponentModule>,
uptimeKuma: () => import("./uptime-kuma/component") as Promise<WidgetComponentModule>,
audioStats: () => import("./audio-stats/component") as Promise<WidgetComponentModule>,
umami: () => import("./umami/component") as Promise<WidgetComponentModule>,
vpn: () => import("./vpn/component") as Promise<WidgetComponentModule>,
ups: () => import("./ups/component") as Promise<WidgetComponentModule>,
beszelSystemTable: () => import("./beszel-system-table/component") as Promise<WidgetComponentModule>,
beszelSystemGrid: () => import("./beszel-system-grid/component") as Promise<WidgetComponentModule>,
beszelAlerts: () => import("./beszel-alerts/component") as Promise<WidgetComponentModule>,
beszelSystemStats: () => import("./beszel-system-stats/component") as Promise<WidgetComponentModule>,
traefik: () => import("./traefik/component") as Promise<WidgetComponentModule>,
customApi: () => import("./custom-api/component") as Promise<WidgetComponentModule>,
};
const definitionPromises = new Map<WidgetKind, Promise<WidgetDefinition>>();
export const widgetKinds = Object.keys(moduleLoaders) as WidgetKind[];
@@ -96,6 +166,25 @@ export const loadWidgetModule = createRetryableLoader(
new Map(Object.entries(moduleLoaders) as [WidgetKind, () => Promise<WidgetModule>][]),
);
export const loadWidgetComponent = createRetryableLoader(
new Map(Object.entries(componentLoaders) as [WidgetKind, () => Promise<WidgetComponentModule>][]),
);
export const loadWidgetResources = createRetryableLoader(
new Map(
(Object.keys(moduleLoaders) as WidgetKind[]).map((kind) => [
kind,
async () => {
const [{ definition }, { default: Component }] = await Promise.all([
loadWidgetModule(kind),
loadWidgetComponent(kind),
]);
return { definition, Component } satisfies WidgetResources;
},
]),
),
);
export const loadWidgetDefinition = (kind: WidgetKind) => {
const existing = definitionPromises.get(kind);
if (existing) return existing;
@@ -9,6 +9,7 @@ const pageSizeOptions = ["10", "20", "30", "50"] as const;
export const { componentLoader, definition } = createWidgetDefinition("mediaMissing", {
icon: IconMovie,
queryKey: [["widget", "mediaOrganizer"]],
createOptions() {
return optionsBuilder.from((factory) => ({
showMissing: factory.switch({ defaultValue: true }),
@@ -11,6 +11,7 @@ const mediaRequestStatusValues = ["pending", "approved", "declined", "failed", "
export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestList", {
icon: IconZoomQuestion,
queryKey: [["widget", "mediaRequests"]],
createOptions() {
return optionsBuilder.from((factory) => ({
linksTargetNewTab: factory.switch({
@@ -7,6 +7,7 @@ import { createWidgetDefinition } from "../../definition";
export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestStats", {
icon: IconChartBar,
queryKey: [["widget", "mediaRequests"]],
createOptions() {
return {};
},
@@ -14,11 +14,11 @@ import type { SettingsContextProps } from "@homarr/settings/creator";
import { useI18n } from "@homarr/translation/client";
import { zodErrorMap } from "@homarr/validation/form/i18n";
import { widgetImports } from "..";
import { getInputForType } from "../_inputs";
import { FormProvider, useForm } from "../_inputs/form";
import type { BoardItemAdvancedOptions } from "../../../validation/src/shared";
import type { OptionsBuilderResult } from "../options";
import type { WidgetDefinition } from "../definition";
import { OPTIONS_SUPER_REFINE } from "../options";
import type { IntegrationSelectOption } from "../widget-integration-select";
import { WidgetIntegrationSelect } from "../widget-integration-select";
@@ -32,8 +32,9 @@ export interface WidgetEditModalState {
advancedOptions: BoardItemAdvancedOptions;
}
interface ModalProps<TSort extends WidgetKind> {
export interface WidgetEditModalProps<TSort extends WidgetKind> {
kind: TSort;
definition: WidgetDefinition;
value: WidgetEditModalState;
onSuccessfulEdit: (value: WidgetEditModalState) => void;
integrationData: IntegrationSelectOption[];
@@ -43,7 +44,7 @@ interface ModalProps<TSort extends WidgetKind> {
appId?: string;
}
export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, innerProps }) => {
export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({ actions, innerProps }) => {
const t = useI18n();
const { data: session } = useSession();
const [advancedOptions, setAdvancedOptions] = useState<BoardItemAdvancedOptions>(innerProps.value.advancedOptions);
@@ -53,7 +54,7 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
z.config({
customError: zodErrorMap(t),
});
const { definition } = widgetImports[innerProps.kind];
const { definition } = innerProps;
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)
@@ -124,10 +125,7 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
<WidgetIntegrationSelect
label={t("item.edit.field.integrations.label")}
data={innerProps.integrationData}
canSelectMultiple={
((widgetImports[innerProps.kind].definition as { maxIntegrations?: number }).maxIntegrations ?? Infinity) >
1
}
canSelectMultiple={((definition as { maxIntegrations?: number }).maxIntegrations ?? Infinity) > 1}
{...form.getInputProps("integrationIds")}
/>
)}
+2 -9
View File
@@ -1,26 +1,20 @@
import { cache } from "react";
import type { QueryClient } from "@tanstack/react-query";
import { db } from "@homarr/db";
import { getServerSettingsAsync } from "@homarr/db/queries";
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
import type { WidgetKind } from "@homarr/definitions";
import { createSettings } from "@homarr/settings/creator";
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "./manifest";
import prefetchForApps from "./app/prefetch";
import prefetchForBookmarks from "./bookmarks/prefetch";
import prefetchForDownloads from "./downloads/prefetch";
import type { Prefetch, WidgetOptionsRecordOf } from "./definition";
import type { inferOptionsFromCreator } from "./options";
const cachedGetServerSettingsAsync = cache(getServerSettingsAsync);
const prefetchCallbacks: Partial<{
[TKind in WidgetKind]: Prefetch<TKind>;
}> = {
bookmarks: prefetchForBookmarks,
app: prefetchForApps,
downloads: prefetchForDownloads,
};
export const prefetchForKindAsync = async <TKind extends WidgetKind>(
@@ -36,8 +30,7 @@ export const prefetchForKindAsync = async <TKind extends WidgetKind>(
return;
}
const serverSettings = await cachedGetServerSettingsAsync(db);
const definition = await loadWidgetDefinition(kind);
const [serverSettings, definition] = await Promise.all([getRscServerSettingsAsync(), loadWidgetDefinition(kind)]);
const itemsWithDefaultOptions = items.map((item) => ({
...item,
@@ -7,6 +7,7 @@ import { optionsBuilder } from "../../options";
export const { definition, componentLoader } = createWidgetDefinition("smartHome-entityState", {
icon: IconBinaryTree,
queryKey: [["widget", "smartHome"]],
createOptions() {
return optionsBuilder.from((factory) => ({
entityId: factory.text({
+12 -12
View File
@@ -1,15 +1,16 @@
import { describe, expect, it } from "vitest";
import { objectEntries } from "@homarr/common";
import type { SettingsContextProps } from "@homarr/settings/creator";
import { createLanguageMapping } from "@homarr/translation";
import { widgetImports } from "..";
import { loadAllWidgetDefinitions } from "../manifest";
describe("Widget properties with description should have matching translations", async () => {
const enTranslation = await createLanguageMapping().en();
objectEntries(widgetImports).forEach(([key, value]) => {
Object.entries(value.definition.createOptions({} as SettingsContextProps)).forEach(([optionKey, optionValue_]) => {
const widgetDefinitions = await loadAllWidgetDefinitions();
const enTranslation = await createLanguageMapping().en();
describe("Widget properties with description should have matching translations", () => {
for (const [key, definition] of widgetDefinitions) {
Object.entries(definition.createOptions({} as SettingsContextProps)).forEach(([optionKey, optionValue_]) => {
const optionValue = optionValue_ as { withDescription: boolean };
it(`should have matching translations for ${optionKey} option description of ${key} widget`, () => {
const option = enTranslation.default.widget[key].option;
@@ -21,13 +22,12 @@ describe("Widget properties with description should have matching translations",
expect("description" in value).toBe(optionValue.withDescription);
});
});
});
}
});
describe("Widget properties should have matching name translations", async () => {
const enTranslation = await createLanguageMapping().en();
objectEntries(widgetImports).forEach(([key, value]) => {
Object.keys(value.definition.createOptions({} as SettingsContextProps)).forEach((optionKey) => {
describe("Widget properties should have matching name translations", () => {
for (const [key, definition] of widgetDefinitions) {
Object.keys(definition.createOptions({} as SettingsContextProps)).forEach((optionKey) => {
it(`should have matching translations for ${optionKey} option name of ${key} widget`, () => {
const option = enTranslation.default.widget[key].option;
if (!(optionKey in option)) {
@@ -38,5 +38,5 @@ describe("Widget properties should have matching name translations", async () =>
expect("label" in value).toBe(true);
});
});
});
}
});
+1 -1
View File
@@ -43,7 +43,7 @@ export default function WeatherWidget({ isEditMode, options }: WidgetComponentPr
}
interface WeatherProps extends Pick<WidgetComponentProps<"weather">, "options"> {
weather: RouterOutputs["widget"]["weather"]["atLocation"];
weather: NonNullable<RouterOutputs["widget"]["weather"]["atLocation"]>;
}
const DailyWeather = ({ options, weather }: WeatherProps) => {