perf: optimize dashboard loading and builds
This commit is contained in:
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) => ({
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>>;
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("clock", {
|
||||
icon: IconClock,
|
||||
queryKey: [["widget", "weather"]],
|
||||
createOptions() {
|
||||
return optionsBuilder.from(
|
||||
(factory) => ({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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")}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user