perf(widgets): lazy load the application graph
This commit is contained in:
1 parent
4ca0b747bb
commit
6b74078766
38 files changed
+676
-202
No files matched your search
@@ -2,9 +2,8 @@
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useState } from "react";
|
||||
import type { QueryKey } from "@tanstack/react-query";
|
||||
import dynamic from "next/dynamic";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
|
||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||
import {
|
||||
@@ -34,10 +33,15 @@ import {
|
||||
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { showWarningNotification } from "@homarr/notifications";
|
||||
import { widgetImports } from "@homarr/widgets";
|
||||
import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals";
|
||||
|
||||
import { createWidgetQueryPersister } from "./query-cache-persister";
|
||||
|
||||
const ReactQueryDevtools = dynamic(
|
||||
() => import("@tanstack/react-query-devtools").then(({ ReactQueryDevtools: DevelopmentTools }) => DevelopmentTools),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
const getWebSocketProtocol = () => {
|
||||
if (typeof window === "undefined") {
|
||||
return "ws";
|
||||
@@ -71,8 +75,6 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
||||
queries: {
|
||||
staleTime: queryCacheDefaultStaleTimeMs,
|
||||
gcTime: queryCacheDefaultGcTimeMs,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
retry: 3,
|
||||
},
|
||||
mutations: {
|
||||
@@ -92,12 +94,10 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
||||
},
|
||||
});
|
||||
client.setQueryDefaults([["widget"]], { refetchInterval: queryCacheDefaultRefetchIntervalMs });
|
||||
for (const { definition } of Object.values(widgetImports)) {
|
||||
const def = definition as { refetchInterval?: number | null; queryKey?: QueryKey; kind: string };
|
||||
if (def.refetchInterval === undefined) continue;
|
||||
const key = def.queryKey ?? [["widget", def.kind]];
|
||||
const interval = def.refetchInterval === null ? false : def.refetchInterval * 1000;
|
||||
client.setQueryDefaults(key, { refetchInterval: interval });
|
||||
for (const { queryKey, intervalSeconds } of widgetQueryRefetchIntervals) {
|
||||
client.setQueryDefaults(queryKey, {
|
||||
refetchInterval: intervalSeconds === null ? false : intervalSeconds * 1000,
|
||||
});
|
||||
}
|
||||
return client;
|
||||
});
|
||||
@@ -164,7 +164,7 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
||||
}}
|
||||
>
|
||||
<ReactQueryStreamedHydration transformer={superjson}>{props.children}</ReactQueryStreamedHydration>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
{process.env.NODE_ENV === "development" && <ReactQueryDevtools initialIsOpen={false} />}
|
||||
</PersistQueryClientProvider>
|
||||
</clientApi.Provider>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getHomeBoardAsync } from "@homarr/api/board-server";
|
||||
|
||||
import { createBoardContentPage } from "../_creator";
|
||||
|
||||
export default createBoardContentPage<{ locale: string }>({
|
||||
async getInitialBoardAsync() {
|
||||
return await api.board.getHomeBoard();
|
||||
return await getHomeBoardAsync();
|
||||
},
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getBoardByNameAsync } from "@homarr/api/board-server";
|
||||
|
||||
import { createBoardContentPage } from "../../_creator";
|
||||
|
||||
export default createBoardContentPage<{ locale: string; name: string }>({
|
||||
async getInitialBoardAsync({ name }) {
|
||||
return await api.board.getBoardByName({ name });
|
||||
return await getBoardByNameAsync(name);
|
||||
},
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import { cache, Suspense } from "react";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
// Placed here because gridstack styles are used for board content
|
||||
@@ -9,7 +9,7 @@ import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
|
||||
import type { PersistedClient } from "@tanstack/react-query-persist-client";
|
||||
import superjson from "superjson";
|
||||
import { queryCacheBuster } from "@homarr/api/query-cache";
|
||||
import { getQueryClient } from "@homarr/api/server";
|
||||
import { makeQueryClient } from "@homarr/api/shared";
|
||||
import { IntegrationProvider } from "@homarr/auth/client";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getIntegrationsWithPermissionsAsync } from "@homarr/auth/server";
|
||||
@@ -29,6 +29,7 @@ import { DynamicClientBoard } from "./_dynamic-client";
|
||||
import { BoardContentHeaderActions } from "./_header-actions";
|
||||
|
||||
const logger = createLogger({ module: "createBoardContentPage" });
|
||||
const getQueryClient = cache(makeQueryClient);
|
||||
|
||||
export type Params = Record<string, unknown>;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getBoardByNameAsync } from "@homarr/api/board-server";
|
||||
|
||||
import { BoardOtherHeaderActions } from "../_header-actions";
|
||||
import { createBoardLayout } from "../_layout-creator";
|
||||
@@ -6,6 +6,6 @@ import { createBoardLayout } from "../_layout-creator";
|
||||
export default createBoardLayout<{ locale: string; name: string }>({
|
||||
headerActions: <BoardOtherHeaderActions />,
|
||||
async getInitialBoardAsync({ name }) {
|
||||
return await api.board.getBoardByName({ name });
|
||||
return await getBoardByNameAsync(name);
|
||||
},
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { JSX } from "react";
|
||||
import { Box, Center, Stack, Text, Title } from "@mantine/core";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import type { MaybePromise } from "@homarr/common/types";
|
||||
import { db } from "@homarr/db";
|
||||
import type { OnboardingStep } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
@@ -29,9 +29,16 @@ const stepComponents: Record<OnboardingStep, null | (() => MaybePromise<JSX.Elem
|
||||
finish: InitFinish,
|
||||
};
|
||||
|
||||
const getCurrentOnboardingStepAsync = async () => {
|
||||
const value = await db.query.onboarding.findFirst();
|
||||
if (!value) return { current: "start" as const, previous: null };
|
||||
|
||||
return { current: value.step, previous: value.previousStep };
|
||||
};
|
||||
|
||||
export default async function InitPage() {
|
||||
const t = await getScopedI18n("init.step");
|
||||
const currentStep = await api.onboard.currentStep();
|
||||
const currentStep = await getCurrentOnboardingStepAsync();
|
||||
|
||||
const CurrentComponent = stepComponents[currentStep.current];
|
||||
|
||||
|
||||
@@ -13,11 +13,13 @@ import { notFound } from "next/navigation";
|
||||
import type { DayOfWeek } from "@mantine/dates";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { env } from "@homarr/auth/env";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { db } from "@homarr/db";
|
||||
import { eq } from "@homarr/db";
|
||||
import { getServerSettingsAsync } from "@homarr/db/queries";
|
||||
import { users } from "@homarr/db/schema";
|
||||
import { ModalProvider } from "@homarr/modals";
|
||||
import { Notifications } from "@homarr/notifications";
|
||||
import { SettingsProvider } from "@homarr/settings";
|
||||
@@ -43,6 +45,8 @@ const fontSans = Inter({
|
||||
variable: "--font-sans",
|
||||
});
|
||||
|
||||
const logger = createLogger({ module: "rootLayout" });
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
export const generateMetadata = async (): Promise<Metadata> => ({
|
||||
title: "Homarr",
|
||||
@@ -83,7 +87,34 @@ export default async function Layout(props: {
|
||||
}
|
||||
|
||||
const session = await auth();
|
||||
const user = session ? await api.user.getById({ userId: session.user.id }).catch(() => null) : null;
|
||||
const user = session
|
||||
? await db.query.users
|
||||
.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
emailVerified: true,
|
||||
image: true,
|
||||
provider: true,
|
||||
homeBoardId: true,
|
||||
mobileHomeBoardId: true,
|
||||
firstDayOfWeek: true,
|
||||
pingIconsEnabled: true,
|
||||
enableRightClickOnWidgets: true,
|
||||
defaultSearchEngineId: true,
|
||||
openSearchInNewTab: true,
|
||||
ddgBangs: true,
|
||||
completedManageTour: true,
|
||||
completedBoardTour: true,
|
||||
},
|
||||
where: eq(users.id, session.user.id),
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
logger.error(new Error("Failed to load the authenticated user in the root layout", { cause: error }));
|
||||
return null;
|
||||
})
|
||||
: null;
|
||||
const serverSettings = await getServerSettingsAsync(db);
|
||||
const colorScheme = await getCurrentColorSchemeAsync();
|
||||
const direction = isLocaleRTL((await props.params).locale) ? "rtl" : "ltr";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { use, useCallback, useState } from "react";
|
||||
import { ActionIcon, Affix, Card } from "@mantine/core";
|
||||
import { IconDimensions, IconPencil, IconToggleLeft, IconToggleRight } from "@tabler/icons-react";
|
||||
import { QueryErrorResetBoundary } from "@tanstack/react-query";
|
||||
@@ -12,8 +12,9 @@ import { showSuccessNotification } from "@homarr/notifications";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { BoardItemAdvancedOptions } from "@homarr/validation/shared";
|
||||
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||
import { getWidgetComponent } from "@homarr/widgets/client";
|
||||
import { WidgetError } from "@homarr/widgets/errors";
|
||||
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||
|
||||
import type { Dimensions } from "./_dimension-modal";
|
||||
@@ -34,7 +35,7 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
||||
const t = useI18n();
|
||||
const { openModal: openWidgetEditModal } = useModalAction(WidgetEditModal);
|
||||
const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal);
|
||||
const currentDefinition = useMemo(() => widgetImports[kind].definition, [kind]);
|
||||
const currentDefinition = use(loadWidgetDefinition(kind));
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [dimensions, setDimensions] = useState<Dimensions>({
|
||||
width: 128,
|
||||
@@ -45,7 +46,7 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
||||
integrationIds: string[];
|
||||
advancedOptions: BoardItemAdvancedOptions;
|
||||
}>({
|
||||
options: reduceWidgetOptionsWithDefaultValues(kind, settings, {}),
|
||||
options: reduceWidgetOptionsWithDefinition(currentDefinition, settings, {}),
|
||||
integrationIds: [],
|
||||
advancedOptions: {
|
||||
title: null,
|
||||
@@ -78,7 +79,7 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
||||
);
|
||||
}, [currentDefinition, integrationData, kind, openWidgetEditModal, settings, state]);
|
||||
|
||||
const Comp = loadWidgetDynamic(kind);
|
||||
const Comp = getWidgetComponent(kind);
|
||||
|
||||
const toggleEditMode = useCallback(() => {
|
||||
setEditMode((editMode) => !editMode);
|
||||
@@ -105,7 +106,7 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
||||
<ErrorBoundary
|
||||
onReset={reset}
|
||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||
<WidgetError kind={kind} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
<WidgetError definition={currentDefinition} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
)}
|
||||
>
|
||||
<Comp
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
import { widgetImports } from "@homarr/widgets";
|
||||
import { loadAllWidgetDefinitions } from "@homarr/widgets/manifest";
|
||||
|
||||
import { MainNavigation } from "~/components/layout/navigation";
|
||||
import { ClientShell } from "~/components/layout/shell";
|
||||
|
||||
const getLinks = () => {
|
||||
return Object.entries(widgetImports).map(([key, value]) => {
|
||||
const getLinks = async () => {
|
||||
const definitions = await loadAllWidgetDefinitions();
|
||||
return [...definitions].map(([key, definition]) => {
|
||||
return {
|
||||
href: `/widgets/${key}`,
|
||||
icon: value.definition.icon,
|
||||
label: value.definition.kind,
|
||||
icon: definition.icon,
|
||||
label: key,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export default function WidgetPreviewLayout({ children }: PropsWithChildren) {
|
||||
const links = getLinks();
|
||||
export default async function WidgetPreviewLayout({ children }: PropsWithChildren) {
|
||||
const links = await getLinks();
|
||||
|
||||
return (
|
||||
<ClientShell hasHeader={false}>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Center } from "@mantine/core";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { db } from "@homarr/db";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { widgetImports } from "@homarr/widgets";
|
||||
import { widgetKinds } from "@homarr/widgets/manifest";
|
||||
|
||||
import { WidgetPreviewPageContent } from "./_content";
|
||||
|
||||
@@ -13,7 +13,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export default async function WidgetPreview(props: Props) {
|
||||
if (!((await props.params).kind in widgetImports || env.NODE_ENV !== "development")) {
|
||||
if (!(widgetKinds.includes((await props.params).kind as WidgetKind) || env.NODE_ENV !== "development")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { MutableRefObject } from "react";
|
||||
import { useRef } from "react";
|
||||
import { Badge, Card } from "@mantine/core";
|
||||
import { Suspense, use, useRef } from "react";
|
||||
import { Badge, Card, Center, Loader } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
|
||||
import combineClasses from "clsx";
|
||||
@@ -10,8 +10,10 @@ import { ErrorBoundary } from "react-error-boundary";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||
import { getWidgetComponent } from "@homarr/widgets/client";
|
||||
import { WidgetError } from "@homarr/widgets/errors";
|
||||
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
import type { WidgetDefinition } from "@homarr/widgets/definition";
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import classes from "../sections/item.module.css";
|
||||
@@ -38,29 +40,21 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<WidgetContextMenu item={item} widgetStateRef={widgetStateRef}>
|
||||
<Card
|
||||
ref={ref}
|
||||
className={combineClasses(
|
||||
classes.itemCard,
|
||||
`${item.kind}-wrapper`,
|
||||
"grid-stack-item-content",
|
||||
item.advancedOptions.customCssClasses.join(" "),
|
||||
)}
|
||||
radius={board.itemRadius}
|
||||
styles={{
|
||||
root: {
|
||||
"--opacity": board.opacity / 100,
|
||||
containerType: "size",
|
||||
overflow: getOverflowFromKind(item.kind),
|
||||
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
|
||||
},
|
||||
}}
|
||||
p={0}
|
||||
>
|
||||
<InnerContent item={item} width={width} height={height} widgetStateRef={widgetStateRef} />
|
||||
</Card>
|
||||
</WidgetContextMenu>
|
||||
<Suspense
|
||||
fallback={
|
||||
<Center ref={ref} className={classes.itemCard} h="100%">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
}
|
||||
>
|
||||
<LoadedBoardItemContent
|
||||
item={item}
|
||||
width={width}
|
||||
height={height}
|
||||
widgetStateRef={widgetStateRef}
|
||||
innerRef={ref}
|
||||
/>
|
||||
</Suspense>
|
||||
{item.advancedOptions.title?.trim() && (
|
||||
<Badge
|
||||
pos="absolute"
|
||||
@@ -86,26 +80,71 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
interface LoadedBoardItemContentProps extends Omit<InnerContentProps, "definition"> {
|
||||
innerRef: (element: HTMLDivElement | null) => void;
|
||||
}
|
||||
|
||||
const LoadedBoardItemContent = ({ item, innerRef, ...innerContentProps }: LoadedBoardItemContentProps) => {
|
||||
const board = useRequiredBoard();
|
||||
const definition = use(loadWidgetDefinition(item.kind));
|
||||
|
||||
return (
|
||||
<WidgetContextMenu item={item} definition={definition} widgetStateRef={innerContentProps.widgetStateRef}>
|
||||
<Card
|
||||
ref={innerRef}
|
||||
className={combineClasses(
|
||||
classes.itemCard,
|
||||
`${item.kind}-wrapper`,
|
||||
"grid-stack-item-content",
|
||||
item.advancedOptions.customCssClasses.join(" "),
|
||||
)}
|
||||
radius={board.itemRadius}
|
||||
styles={{
|
||||
root: {
|
||||
"--opacity": board.opacity / 100,
|
||||
containerType: "size",
|
||||
overflow: getOverflowFromKind(item.kind),
|
||||
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
|
||||
},
|
||||
}}
|
||||
p={0}
|
||||
>
|
||||
<ErrorBoundary
|
||||
resetKeys={[item.kind]}
|
||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||
<>
|
||||
<BoardItemMenu offset={4} item={item} definition={definition} resetErrorBoundary={resetErrorBoundary} />
|
||||
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<InnerContent item={item} definition={definition} {...innerContentProps} />
|
||||
</ErrorBoundary>
|
||||
</Card>
|
||||
</WidgetContextMenu>
|
||||
);
|
||||
};
|
||||
|
||||
interface InnerContentProps {
|
||||
item: SectionItem;
|
||||
width: number;
|
||||
height: number;
|
||||
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
||||
definition: WidgetDefinition;
|
||||
}
|
||||
|
||||
const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
||||
const InnerContent = ({ item, definition, ...dimensions }: InnerContentProps) => {
|
||||
const settings = useSettings();
|
||||
const board = useRequiredBoard();
|
||||
const [isEditMode] = useEditMode();
|
||||
const Comp = loadWidgetDynamic(item.kind);
|
||||
const { definition } = widgetImports[item.kind];
|
||||
const options = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
|
||||
const Comp = getWidgetComponent(item.kind);
|
||||
const options = reduceWidgetOptionsWithDefinition(definition, settings, item.options);
|
||||
const newItem = { ...item, options };
|
||||
const { updateItemOptions } = useItemActions();
|
||||
const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) =>
|
||||
updateItemOptions({ itemId: item.id, newOptions });
|
||||
const widgetSupportsIntegrations =
|
||||
"supportedIntegrations" in definition && definition.supportedIntegrations.length >= 1;
|
||||
"supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) >= 1;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return (
|
||||
@@ -118,8 +157,13 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
||||
}}
|
||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||
<>
|
||||
<BoardItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} />
|
||||
<WidgetError kind={item.kind} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
<BoardItemMenu
|
||||
offset={4}
|
||||
item={newItem}
|
||||
definition={definition}
|
||||
resetErrorBoundary={resetErrorBoundary}
|
||||
/>
|
||||
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
</>
|
||||
)}
|
||||
>
|
||||
@@ -131,7 +175,7 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
||||
(!("integrationsRequired" in definition) || definition.integrationsRequired !== false)
|
||||
}
|
||||
/>
|
||||
<BoardItemMenu offset={4} item={newItem} />
|
||||
<BoardItemMenu offset={4} item={newItem} definition={definition} />
|
||||
<Comp
|
||||
options={options as never}
|
||||
integrationIds={item.integrationIds}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { ActionIcon, Menu } from "@mantine/core";
|
||||
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { widgetImports } from "@homarr/widgets";
|
||||
import type { WidgetDefinition } from "@homarr/widgets/definition";
|
||||
import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
@@ -19,6 +19,7 @@ import { ItemMoveModal } from "./item-move-modal";
|
||||
interface BoardItemMenuProps {
|
||||
offset: number;
|
||||
item: SectionItem;
|
||||
definition: WidgetDefinition;
|
||||
resetErrorBoundary?: () => void;
|
||||
}
|
||||
|
||||
@@ -29,7 +30,7 @@ export const BoardItemMenu = (props: BoardItemMenuProps) => {
|
||||
return <BoardItemMenuInner {...props} />;
|
||||
};
|
||||
|
||||
const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuProps) => {
|
||||
const BoardItemMenuInner = ({ offset, item, definition, resetErrorBoundary }: BoardItemMenuProps) => {
|
||||
const refResetErrorBoundaryOnNextRender = useRef(false);
|
||||
const tItem = useScopedI18n("item");
|
||||
const t = useI18n();
|
||||
@@ -40,7 +41,6 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
|
||||
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
|
||||
useItemActions();
|
||||
const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
|
||||
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
|
||||
const { gridstack } = useSectionContext().refs;
|
||||
const settings = useSettings();
|
||||
|
||||
@@ -80,10 +80,10 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
|
||||
},
|
||||
integrationData: (integrationData ?? []).filter(
|
||||
(integration) =>
|
||||
"supportedIntegrations" in currentDefinition &&
|
||||
(currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
||||
"supportedIntegrations" in definition &&
|
||||
(definition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
||||
),
|
||||
integrationSupport: "supportedIntegrations" in currentDefinition,
|
||||
integrationSupport: "supportedIntegrations" in definition,
|
||||
settings,
|
||||
itemId: item.id,
|
||||
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { use, useMemo, useState } from "react";
|
||||
import { Avatar, Box, Button, Card, Center, Divider, Group, Image, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { IconApi } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { createId, objectEntries } from "@homarr/common";
|
||||
import { createId } from "@homarr/common";
|
||||
import { getIconUrl, getIntegrationName } from "@homarr/definitions";
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
|
||||
@@ -11,12 +11,13 @@ import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||
import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||
import { loadAllWidgetDefinitions, reduceWidgetOptionsWithDefinition, widgetKinds } from "@homarr/widgets/manifest";
|
||||
|
||||
import { useItemActions } from "./item-actions";
|
||||
|
||||
export const ItemSelectModal = createModal<void>(({ actions }) => {
|
||||
const widgetDefinitions = use(loadAllWidgetDefinitions());
|
||||
const [search, setSearch] = useState("");
|
||||
const t = useI18n();
|
||||
const { createItem, updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
|
||||
@@ -29,25 +30,30 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
objectEntries(widgetImports)
|
||||
.filter(([kind]) => kind !== "customApi")
|
||||
.map(([kind, value]) => ({
|
||||
kind,
|
||||
supportedIntegrations:
|
||||
"supportedIntegrations" in value.definition
|
||||
? value.definition.supportedIntegrations.filter((integration) => integration !== "mock")
|
||||
: [],
|
||||
icon: value.definition.icon,
|
||||
name: t(`widget.${kind}.name`),
|
||||
description: t(`widget.${kind}.description`),
|
||||
}))
|
||||
widgetKinds
|
||||
.filter((kind) => kind !== "customApi")
|
||||
.map((kind) => {
|
||||
const definition = widgetDefinitions.get(kind);
|
||||
if (!definition) return null;
|
||||
|
||||
return {
|
||||
kind,
|
||||
supportedIntegrations: (definition.supportedIntegrations ?? []).filter(
|
||||
(integration) => integration !== "mock",
|
||||
),
|
||||
icon: definition.icon,
|
||||
name: t(`widget.${kind}.name`),
|
||||
description: t(`widget.${kind}.description`),
|
||||
};
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => item !== null)
|
||||
.sort((itemA, itemB) => {
|
||||
if (itemA.kind === "app") return -1;
|
||||
if (itemB.kind === "app") return 1;
|
||||
|
||||
return itemA.name.localeCompare(itemB.name);
|
||||
}),
|
||||
[t],
|
||||
[t, widgetDefinitions],
|
||||
);
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
@@ -68,25 +74,29 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
||||
|
||||
const handleAddCustomWidget = (definitionId: string) => {
|
||||
const itemId = createId();
|
||||
const defaultOptions = reduceWidgetOptionsWithDefaultValues("customApi", settings);
|
||||
const definition = widgetDefinitions.get("customApi");
|
||||
if (!definition) return;
|
||||
|
||||
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
|
||||
createItem({ id: itemId, kind: "customApi", integrationIds: [] });
|
||||
updateItemOptions({ itemId, newOptions: { ...defaultOptions, definitionId } });
|
||||
actions.closeModal();
|
||||
};
|
||||
|
||||
const handleAdd = (kind: WidgetKind) => {
|
||||
const definition = widgetImports[kind].definition;
|
||||
const definition = widgetDefinitions.get(kind);
|
||||
if (!definition) return;
|
||||
const hasIntegrationSupport = "supportedIntegrations" in definition;
|
||||
|
||||
const matchingIntegrations = hasIntegrationSupport
|
||||
? (integrationData ?? []).filter((integration) =>
|
||||
(definition.supportedIntegrations as string[]).includes(integration.kind),
|
||||
(definition.supportedIntegrations ?? []).includes(integration.kind),
|
||||
)
|
||||
: [];
|
||||
|
||||
const integrationIds = matchingIntegrations.map((i) => i.id);
|
||||
const itemId = createId();
|
||||
const defaultOptions = reduceWidgetOptionsWithDefaultValues(kind, settings);
|
||||
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
|
||||
|
||||
createItem({ id: itemId, kind, integrationIds });
|
||||
actions.closeModal();
|
||||
@@ -121,8 +131,8 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
||||
placeholder={`${t("item.create.search")}...`}
|
||||
onSearchKeyDown={(event) => {
|
||||
if (event.key === "Enter" && filteredItems.length === 1) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
handleAdd(filteredItems[0]!.kind);
|
||||
const [item] = filteredItems;
|
||||
if (item) handleAdd(item.kind);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -25,8 +25,8 @@ import { useSettings } from "@homarr/settings";
|
||||
import { translateIfNecessary } from "@homarr/translation";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import type { TranslationFunction } from "@homarr/translation";
|
||||
import type { WidgetContextMenuAction } from "@homarr/widgets";
|
||||
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||
import type { WidgetContextMenuAction, WidgetDefinition } from "@homarr/widgets/definition";
|
||||
import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
@@ -36,11 +36,12 @@ import { ItemMoveModal } from "./item-move-modal";
|
||||
|
||||
interface WidgetContextMenuProps {
|
||||
item: SectionItem;
|
||||
definition: WidgetDefinition;
|
||||
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetContextMenuProps) => {
|
||||
export const WidgetContextMenu = ({ item, definition, widgetStateRef, children }: WidgetContextMenuProps) => {
|
||||
const { data: session } = useSession();
|
||||
const [isEditMode] = useEditMode();
|
||||
const board = useRequiredBoard();
|
||||
@@ -56,13 +57,12 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
useItemActions();
|
||||
const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
|
||||
const { mutate: saveBoard } = clientApi.board.saveBoard.useMutation();
|
||||
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
|
||||
const { gridstack } = useSectionContext().refs;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const widgetQueryKey = useMemo(
|
||||
() => ("queryKey" in currentDefinition ? (currentDefinition.queryKey as QueryKey) : [["widget", item.kind]]),
|
||||
[currentDefinition, item.kind],
|
||||
() => ("queryKey" in definition ? (definition.queryKey as QueryKey) : [["widget", item.kind]]),
|
||||
[definition, item.kind],
|
||||
);
|
||||
const isWidgetFetching = useIsFetching({ queryKey: widgetQueryKey }) > 0;
|
||||
const handleRefetch = useCallback(() => {
|
||||
@@ -70,18 +70,18 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
}, [queryClient, widgetQueryKey]);
|
||||
|
||||
const options = useMemo(
|
||||
() => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record<string, unknown>,
|
||||
[item.kind, settings, item.options],
|
||||
() => reduceWidgetOptionsWithDefinition(definition, settings, item.options),
|
||||
[definition, settings, item.options],
|
||||
);
|
||||
|
||||
type OptionDef = { type: string; skipContextMenu?: boolean };
|
||||
const toggleOptions = useMemo(() => {
|
||||
const rawOptions = currentDefinition.createOptions(settings) as unknown as Record<string, OptionDef>;
|
||||
const rawOptions = definition.createOptions(settings) as unknown as Record<string, OptionDef>;
|
||||
return Object.entries(rawOptions).filter(([, def]) => def.type === "switch" && !def.skipContextMenu);
|
||||
}, [currentDefinition, settings]);
|
||||
}, [definition, settings]);
|
||||
|
||||
const widgetContextActions = useMemo(() => {
|
||||
const def = currentDefinition as Record<string, unknown>;
|
||||
const def = definition as unknown as Record<string, unknown>;
|
||||
if (typeof def.contextActions !== "function") return [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const actions = (def.contextActions as any)({
|
||||
@@ -94,7 +94,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
widgetStateRef,
|
||||
});
|
||||
return (Array.isArray(actions) ? actions : []) as WidgetContextMenuAction[];
|
||||
}, [currentDefinition, options, item, updateItemOptions, isEditMode, board.id, widgetStateRef]);
|
||||
}, [definition, options, item, updateItemOptions, isEditMode, board.id, widgetStateRef]);
|
||||
|
||||
const persistBoard = useCallback(
|
||||
(updatedItems: typeof board.items) => {
|
||||
@@ -133,10 +133,10 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
},
|
||||
integrationData: (integrationData ?? []).filter(
|
||||
(integration) =>
|
||||
"supportedIntegrations" in currentDefinition &&
|
||||
(currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
||||
"supportedIntegrations" in definition &&
|
||||
(definition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
||||
),
|
||||
integrationSupport: "supportedIntegrations" in currentDefinition,
|
||||
integrationSupport: "supportedIntegrations" in definition,
|
||||
settings,
|
||||
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
||||
},
|
||||
@@ -150,7 +150,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
updateItemAdvancedOptions,
|
||||
updateItemIntegrations,
|
||||
integrationData,
|
||||
currentDefinition,
|
||||
definition,
|
||||
settings,
|
||||
isEditMode,
|
||||
persistBoard,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, use } from "react";
|
||||
import { Avatar, Text, Tooltip } from "@mantine/core";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { getIconUrl } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { widgetImports } from "@homarr/widgets";
|
||||
import { loadWidgetDefinition } from "@homarr/widgets/manifest";
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import classes from "./widget-hover-overlay.module.css";
|
||||
@@ -18,8 +19,19 @@ interface WidgetHoverOverlayProps {
|
||||
|
||||
export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayProps) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
|
||||
if (!isEditMode) return null;
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<LoadedWidgetHoverOverlay item={item} integrations={integrations} />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
const LoadedWidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayProps) => {
|
||||
const t = useI18n();
|
||||
const { definition } = widgetImports[item.kind];
|
||||
const definition = use(loadWidgetDefinition(item.kind));
|
||||
const WidgetIcon = definition.icon;
|
||||
|
||||
const displayName = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`);
|
||||
@@ -28,10 +40,6 @@ export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayPro
|
||||
item.integrationIds.includes(integration.id),
|
||||
);
|
||||
|
||||
if (!isEditMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.wrapper}>
|
||||
<div className={classes.panel}>
|
||||
|
||||
@@ -105,12 +105,14 @@ export const useCategoryMenuActions = (category: CategorySection) => {
|
||||
const settings = useSettings();
|
||||
const openAllInNewTabs = useCallback(async () => {
|
||||
const currentLayoutId = getCurrentLayout(board);
|
||||
const appIds = filterByItemKind(
|
||||
board.items.filter(
|
||||
(item) => item.layouts.find((layout) => layout.layoutId === currentLayoutId)?.sectionId === category.id,
|
||||
),
|
||||
settings,
|
||||
"app",
|
||||
const appIds = (
|
||||
await filterByItemKind(
|
||||
board.items.filter(
|
||||
(item) => item.layouts.find((layout) => layout.layoutId === currentLayoutId)?.sectionId === category.id,
|
||||
),
|
||||
settings,
|
||||
"app",
|
||||
)
|
||||
).map((item) => {
|
||||
return item.options.appId;
|
||||
});
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||
import type { WidgetComponentProps } from "@homarr/widgets";
|
||||
import { reduceWidgetOptionsWithDefaultValues } from "@homarr/widgets";
|
||||
import type { WidgetComponentProps } from "@homarr/widgets/definition";
|
||||
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
|
||||
import type { Item } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export const filterByItemKind = <TKind extends WidgetKind>(
|
||||
export const filterByItemKind = async <TKind extends WidgetKind>(
|
||||
items: Item[],
|
||||
settings: SettingsContextProps,
|
||||
kind: TKind,
|
||||
) => {
|
||||
const definition = await loadWidgetDefinition(kind);
|
||||
|
||||
return items
|
||||
.filter((item) => item.kind === kind)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
options: reduceWidgetOptionsWithDefaultValues(
|
||||
kind,
|
||||
options: reduceWidgetOptionsWithDefinition(
|
||||
definition,
|
||||
settings,
|
||||
item.options,
|
||||
) as WidgetComponentProps<TKind>["options"],
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
import {
|
||||
featuredIntegrations,
|
||||
@@ -7,19 +6,18 @@ import {
|
||||
integrationDefs,
|
||||
integrationKinds,
|
||||
} from "@homarr/definitions";
|
||||
import { widgetImports } from "@homarr/widgets";
|
||||
import { loadAllWidgetDefinitions } from "@homarr/widgets/manifest";
|
||||
|
||||
const buildWidgetsByIntegration = () => {
|
||||
const buildWidgetsByIntegration = async () => {
|
||||
const map = Object.fromEntries(integrationKinds.map((kind) => [kind, [] as WidgetKind[]])) as Record<
|
||||
IntegrationKind,
|
||||
WidgetKind[]
|
||||
>;
|
||||
const definitions = await loadAllWidgetDefinitions();
|
||||
|
||||
for (const [widgetKind, widget] of objectEntries(widgetImports)) {
|
||||
for (const [widgetKind, definition] of definitions) {
|
||||
const supported =
|
||||
"supportedIntegrations" in widget.definition
|
||||
? (widget.definition.supportedIntegrations as IntegrationKind[])
|
||||
: [];
|
||||
"supportedIntegrations" in definition ? (definition.supportedIntegrations as IntegrationKind[]) : [];
|
||||
for (const kind of supported) {
|
||||
if (kind in map) {
|
||||
map[kind].push(widgetKind);
|
||||
@@ -29,8 +27,6 @@ const buildWidgetsByIntegration = () => {
|
||||
return map;
|
||||
};
|
||||
|
||||
export const widgetsByIntegration = buildWidgetsByIntegration();
|
||||
|
||||
export const categoryTranslationKeys: Record<string, string> = {
|
||||
dnsHole: "integration.category.dnsHole",
|
||||
mediaService: "integration.category.mediaService",
|
||||
@@ -68,10 +64,11 @@ export interface IntegrationGridItem {
|
||||
widgets: WidgetKind[];
|
||||
}
|
||||
|
||||
export const buildSortedIntegrations = (
|
||||
export const buildSortedIntegrations = async (
|
||||
options: { enableMockIntegration?: boolean; onboarding?: boolean } = {},
|
||||
): IntegrationGridItem[] =>
|
||||
integrationKinds
|
||||
): Promise<IntegrationGridItem[]> => {
|
||||
const widgetsByIntegration = await buildWidgetsByIntegration();
|
||||
return integrationKinds
|
||||
.filter((kind) => {
|
||||
if (options.onboarding && hiddenFromOnboarding.has(kind)) return false;
|
||||
if (options.onboarding && widgetsByIntegration[kind].length === 0) return false;
|
||||
@@ -84,7 +81,7 @@ export const buildSortedIntegrations = (
|
||||
categories: [...new Set(integrationDefs[kind].category.flat())] as string[],
|
||||
widgets: widgetsByIntegration[kind],
|
||||
}))
|
||||
.sort((left, right) => {
|
||||
.toSorted((left, right) => {
|
||||
const leftIdx = featuredIntegrations.indexOf(left.kind);
|
||||
const rightIdx = featuredIntegrations.indexOf(right.kind);
|
||||
if (leftIdx !== -1 && rightIdx !== -1) return leftIdx - rightIdx;
|
||||
@@ -92,6 +89,7 @@ export const buildSortedIntegrations = (
|
||||
if (rightIdx !== -1) return 1;
|
||||
return right.widgets.length - left.widgets.length || left.name.localeCompare(right.name);
|
||||
});
|
||||
};
|
||||
|
||||
export const filterIntegrations = (items: IntegrationGridItem[], search: string) =>
|
||||
items.filter((i) => i.name.toLowerCase().includes(search.toLowerCase().trim()));
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Badge, Card, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { Badge, Card, Center, Group, Loader, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { IconBrandDocker, IconCheck, IconPuzzle } from "@tabler/icons-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
@@ -42,10 +43,10 @@ export const IntegrationMultiSelectGrid = ({
|
||||
[selectedKindsArray, onSelectionChange],
|
||||
);
|
||||
|
||||
const integrations = useMemo(
|
||||
() => buildSortedIntegrations({ enableMockIntegration, onboarding }),
|
||||
[enableMockIntegration, onboarding],
|
||||
);
|
||||
const { data: integrations = [], isPending } = useQuery({
|
||||
queryKey: ["widget-integrations", { enableMockIntegration, onboarding }],
|
||||
queryFn: () => buildSortedIntegrations({ enableMockIntegration, onboarding }),
|
||||
});
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
if (!detectedKinds || detectedKinds.size === 0) return integrations;
|
||||
@@ -64,6 +65,11 @@ export const IntegrationMultiSelectGrid = ({
|
||||
disableScroll={onboarding}
|
||||
disableAutoFocus={onboarding}
|
||||
>
|
||||
{isPending && (
|
||||
<Center p="xl">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
)}
|
||||
{filtered.map((integration) => {
|
||||
const isSelected = selectedKinds.has(integration.kind);
|
||||
const isDetected = detectedKinds?.has(integration.kind) ?? false;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Badge, Card, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { Badge, Card, Center, Group, Loader, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { IconPuzzle } from "@tabler/icons-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
@@ -19,7 +20,10 @@ export const IntegrationSelectGrid = ({ onSelect, enableMockIntegration = false
|
||||
const [search, setSearch] = useState("");
|
||||
const t = useI18n();
|
||||
|
||||
const integrations = useMemo(() => buildSortedIntegrations({ enableMockIntegration }), [enableMockIntegration]);
|
||||
const { data: integrations = [], isPending } = useQuery({
|
||||
queryKey: ["widget-integrations", { enableMockIntegration }],
|
||||
queryFn: () => buildSortedIntegrations({ enableMockIntegration }),
|
||||
});
|
||||
const filtered = useMemo(() => filterIntegrations(integrations, search), [integrations, search]);
|
||||
|
||||
return (
|
||||
@@ -28,6 +32,11 @@ export const IntegrationSelectGrid = ({ onSelect, enableMockIntegration = false
|
||||
onSearchChange={setSearch}
|
||||
placeholder={`${t("integration.page.list.search")}...`}
|
||||
>
|
||||
{isPending && (
|
||||
<Center p="xl">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
)}
|
||||
{filtered.map((integration) => (
|
||||
<Card
|
||||
key={integration.kind}
|
||||
|
||||
@@ -9,8 +9,10 @@
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./client": "./src/client.ts",
|
||||
"./board-server": "./src/board-server.ts",
|
||||
"./query-cache": "./src/query-cache.ts",
|
||||
"./server": "./src/server.ts",
|
||||
"./widget-prefetch-server": "./src/widget-prefetch-server.ts",
|
||||
"./websocket": "./src/websocket.ts",
|
||||
"./shared": "./src/shared.ts",
|
||||
"./open-api": "./src/open-api.ts",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createRscTrpcContext } from "./rsc-context";
|
||||
import { boardRouter } from "./router/board";
|
||||
|
||||
const createBoardCallerAsync = async () => boardRouter.createCaller(await createRscTrpcContext());
|
||||
|
||||
export const getBoardByNameAsync = async (name: string) => {
|
||||
const caller = await createBoardCallerAsync();
|
||||
return await caller.getBoardByName({ name });
|
||||
};
|
||||
|
||||
export const getHomeBoardAsync = async () => {
|
||||
const caller = await createBoardCallerAsync();
|
||||
return await caller.getHomeBoard();
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { cache } from "react";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
|
||||
import { createTRPCContext } from "./trpc";
|
||||
|
||||
export const createRscTrpcContext = cache(async () => {
|
||||
const requestHeaders = new Headers(await headers());
|
||||
requestHeaders.set("x-trpc-source", "rsc");
|
||||
|
||||
return createTRPCContext({
|
||||
session: await auth(),
|
||||
headers: requestHeaders,
|
||||
});
|
||||
});
|
||||
@@ -1,25 +1,16 @@
|
||||
import { cache } from "react";
|
||||
import { headers } from "next/headers";
|
||||
import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query";
|
||||
|
||||
import { appRouter, createCaller, createTRPCContext } from "@homarr/api";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { appRouter, createCaller } from "@homarr/api";
|
||||
|
||||
import { createRscTrpcContext } from "./rsc-context";
|
||||
import { makeQueryClient } from "./shared";
|
||||
|
||||
/**
|
||||
* This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when
|
||||
* handling a tRPC call from a React Server Component.
|
||||
*/
|
||||
const createContext = cache(async () => {
|
||||
const heads = new Headers(await headers());
|
||||
heads.set("x-trpc-source", "rsc");
|
||||
|
||||
return createTRPCContext({
|
||||
session: await auth(),
|
||||
headers: heads,
|
||||
});
|
||||
});
|
||||
const createContext = () => createRscTrpcContext();
|
||||
|
||||
export const api = createCaller(createContext);
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createRscTrpcContext } from "./rsc-context";
|
||||
import { downloadsRouter } from "./router/widgets/downloads";
|
||||
|
||||
export const getDownloadJobsAndStatusesAsync = async (input: {
|
||||
integrationIds: string[];
|
||||
limitPerIntegration: number;
|
||||
}) => {
|
||||
// Keep this leaf caller narrow: the shared server caller imports the complete app router into every widget-prefetch build.
|
||||
const caller = downloadsRouter.createCaller(await createRscTrpcContext());
|
||||
return await caller.getJobsAndStatuses(input);
|
||||
};
|
||||
@@ -15,13 +15,10 @@ declare module "next-intl" {
|
||||
}
|
||||
}
|
||||
|
||||
export const { useI18n, useScopedI18n } = {
|
||||
useI18n: useTranslations,
|
||||
useScopedI18n: useTranslations,
|
||||
};
|
||||
|
||||
export const { useI18nMessages } = {
|
||||
useI18nMessages: () => useMessages(),
|
||||
};
|
||||
// Keep these as direct exports so Turbopack and webpack can statically inspect
|
||||
// the client package's public surface.
|
||||
export const useI18n = useTranslations;
|
||||
export const useScopedI18n = useTranslations;
|
||||
export const useI18nMessages = () => useMessages();
|
||||
|
||||
export { useTranslations };
|
||||
@@ -16,11 +16,16 @@
|
||||
"./_inputs/json-path-tree-picker": "./src/_inputs/json-path-tree-picker.tsx",
|
||||
"./custom-api/component": "./src/custom-api/component.tsx",
|
||||
"./custom-api/extract-display-data": "./src/custom-api/extract-display-data.ts",
|
||||
"./client": "./src/client.tsx",
|
||||
"./definition": "./src/definition.ts",
|
||||
"./errors": "./src/errors/component.tsx",
|
||||
"./manifest": "./src/manifest.ts",
|
||||
"./options": "./src/options.ts",
|
||||
"./errors/classes": "./src/errors/index.ts",
|
||||
"./widget-integration-select": "./src/widget-integration-select.tsx",
|
||||
"./modals": "./src/modals/index.ts",
|
||||
"./prefetch": "./src/prefetch.ts"
|
||||
"./prefetch": "./src/prefetch.ts",
|
||||
"./refetch-intervals": "./src/refetch-intervals.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { trpc } from "@homarr/api/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { db, inArray } from "@homarr/db";
|
||||
import { apps } from "@homarr/db/schema";
|
||||
|
||||
import type { Prefetch } from "../definition";
|
||||
import { createTrpcQueryKey } from "../trpc-query-key";
|
||||
|
||||
const logger = createLogger({ module: "appWidgetPrefetch" });
|
||||
|
||||
@@ -16,7 +16,7 @@ const prefetchAllAsync: Prefetch<"app"> = async (queryClient, items) => {
|
||||
});
|
||||
|
||||
for (const app of dbApps) {
|
||||
queryClient.setQueryData(trpc.app.byId.queryKey({ id: app.id }), app);
|
||||
queryClient.setQueryData(createTrpcQueryKey("app.byId", { id: app.id }), app);
|
||||
}
|
||||
|
||||
logger.info("Successfully prefetched apps for app widget", { count: dbApps.length });
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { trpc } from "@homarr/api/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { db, inArray } from "@homarr/db";
|
||||
import { apps } from "@homarr/db/schema";
|
||||
|
||||
import type { Prefetch } from "../definition";
|
||||
import { createTrpcQueryKey } from "../trpc-query-key";
|
||||
|
||||
const logger = createLogger({ module: "bookmarksWidgetPrefetch" });
|
||||
|
||||
@@ -21,7 +21,7 @@ const prefetchAllAsync: Prefetch<"bookmarks"> = async (queryClient, items) => {
|
||||
}
|
||||
|
||||
queryClient.setQueryData(
|
||||
trpc.app.byIds.queryKey(item.options.items),
|
||||
createTrpcQueryKey("app.byIds", item.options.items),
|
||||
dbApps.filter((app) => item.options.items.includes(app.id)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"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,7 +1,8 @@
|
||||
import { api, trpc } from "@homarr/api/server";
|
||||
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" });
|
||||
|
||||
@@ -16,8 +17,8 @@ const prefetchAllAsync: Prefetch<"downloads"> = async (queryClient, items) => {
|
||||
};
|
||||
|
||||
try {
|
||||
const data = await api.widget.downloads.getJobsAndStatuses(input);
|
||||
queryClient.setQueryData(trpc.widget.downloads.getJobsAndStatuses.queryKey(input), data);
|
||||
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 });
|
||||
|
||||
@@ -1,30 +1,24 @@
|
||||
import { useMemo } from "react";
|
||||
import { IconExclamationCircle, IconShield } from "@tabler/icons-react";
|
||||
import { TRPCClientError } from "@trpc/client";
|
||||
import type { DefaultErrorData } from "@trpc/server/unstable-core-do-not-import";
|
||||
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
|
||||
import type { WidgetDefinition } from "..";
|
||||
import { widgetImports } from "..";
|
||||
import type { WidgetDefinition } from "../definition";
|
||||
import { ErrorBoundaryError } from "./base";
|
||||
import type { BaseWidgetErrorProps } from "./base-component";
|
||||
import { BaseWidgetError } from "./base-component";
|
||||
|
||||
interface WidgetErrorProps {
|
||||
kind: WidgetKind;
|
||||
definition?: WidgetDefinition;
|
||||
error: unknown;
|
||||
resetErrorBoundary: () => void;
|
||||
}
|
||||
|
||||
export const WidgetError = ({ error, resetErrorBoundary, kind }: WidgetErrorProps) => {
|
||||
const currentDefinition = useMemo(() => widgetImports[kind].definition, [kind]);
|
||||
|
||||
export const WidgetError = ({ error, resetErrorBoundary, definition }: WidgetErrorProps) => {
|
||||
if (error instanceof ErrorBoundaryError) {
|
||||
return <BaseWidgetError {...error.getErrorBoundaryData()} onRetry={resetErrorBoundary} />;
|
||||
}
|
||||
|
||||
const widgetTrpcErrorData = handleWidgetTrpcError(error, currentDefinition);
|
||||
const widgetTrpcErrorData = definition ? handleWidgetTrpcError(error, definition) : null;
|
||||
if (widgetTrpcErrorData) {
|
||||
return <BaseWidgetError {...widgetTrpcErrorData} onRetry={resetErrorBoundary} />;
|
||||
}
|
||||
@@ -34,13 +28,7 @@ export const WidgetError = ({ error, resetErrorBoundary, kind }: WidgetErrorProp
|
||||
return <BaseWidgetError {...trpcErrorData} onRetry={resetErrorBoundary} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseWidgetError
|
||||
icon={IconExclamationCircle}
|
||||
message={(error as { toString: () => string }).toString()}
|
||||
onRetry={resetErrorBoundary}
|
||||
/>
|
||||
);
|
||||
return <BaseWidgetError icon={IconExclamationCircle} message={String(error)} onRetry={resetErrorBoundary} />;
|
||||
};
|
||||
|
||||
const handleWidgetTrpcError = (
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { QueryKey } from "@tanstack/react-query";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { widgetKinds as definedWidgetKinds } from "@homarr/definitions";
|
||||
|
||||
import {
|
||||
createRetryableLoader,
|
||||
loadAllWidgetDefinitions,
|
||||
loadWidgetDefinition,
|
||||
loadWidgetModule,
|
||||
reduceWidgetOptionsWithDefinition,
|
||||
widgetKinds,
|
||||
} from "./manifest";
|
||||
import { widgetQueryRefetchIntervals } from "./refetch-intervals";
|
||||
|
||||
describe("widget manifest promise stability", () => {
|
||||
it("returns the same module promise for every render", () => {
|
||||
expect(loadWidgetModule("clock")).toBe(loadWidgetModule("clock"));
|
||||
});
|
||||
|
||||
it("returns the same derived definition promise for every render", () => {
|
||||
expect(loadWidgetDefinition("clock")).toBe(loadWidgetDefinition("clock"));
|
||||
});
|
||||
|
||||
it("returns the same all-definitions promise for every modal render", () => {
|
||||
expect(loadAllWidgetDefinitions()).toBe(loadAllWidgetDefinitions());
|
||||
});
|
||||
|
||||
it("retries a failed loader instead of retaining its rejected promise", async () => {
|
||||
let attempts = 0;
|
||||
const loader = createRetryableLoader(
|
||||
new Map([
|
||||
[
|
||||
"example",
|
||||
() => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) return Promise.reject(new Error("temporary failure"));
|
||||
return Promise.resolve("loaded");
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
await expect(loader("example")).rejects.toThrow("temporary failure");
|
||||
await expect(loader("example")).resolves.toBe("loaded");
|
||||
expect(attempts).toBe(2);
|
||||
});
|
||||
|
||||
it("covers every declared widget kind", () => {
|
||||
expect(new Set(widgetKinds)).toEqual(new Set(definedWidgetKinds));
|
||||
});
|
||||
|
||||
it("loads matching definitions and component loaders for every widget", async () => {
|
||||
const definitions = await loadAllWidgetDefinitions();
|
||||
|
||||
await Promise.all(
|
||||
widgetKinds.map(async (kind) => {
|
||||
const module = await loadWidgetModule(kind);
|
||||
const component = await module.componentLoader();
|
||||
expect(definitions.get(kind)).toBe(module.definition);
|
||||
expect(component.default).toBeDefined();
|
||||
}),
|
||||
);
|
||||
}, 30_000);
|
||||
|
||||
it("preserves every widget option default", async () => {
|
||||
const definitions = await loadAllWidgetDefinitions();
|
||||
const settings = { enableStatusByDefault: true, forceDisableStatus: false };
|
||||
|
||||
for (const definition of definitions.values()) {
|
||||
const options = definition.createOptions(settings);
|
||||
const reduced = reduceWidgetOptionsWithDefinition(definition, settings);
|
||||
expect(Object.keys(reduced)).toEqual(Object.keys(options));
|
||||
expect(Object.values(reduced).every((value) => value !== undefined)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the client polling policy aligned with widget definitions", async () => {
|
||||
const definitions = await loadAllWidgetDefinitions();
|
||||
type PollingPolicy = { queryKey: QueryKey; intervalSeconds: number | null };
|
||||
const expectedByQueryKey = new Map<string, PollingPolicy>();
|
||||
|
||||
for (const kind of widgetKinds) {
|
||||
const definition = definitions.get(kind);
|
||||
if (definition?.refetchInterval === undefined) continue;
|
||||
|
||||
const entry = {
|
||||
queryKey: definition.queryKey ?? [["widget", kind]],
|
||||
intervalSeconds: definition.refetchInterval,
|
||||
} as const;
|
||||
const serializedQueryKey = JSON.stringify(entry.queryKey);
|
||||
const existing = expectedByQueryKey.get(serializedQueryKey);
|
||||
expect(existing?.intervalSeconds ?? entry.intervalSeconds).toBe(entry.intervalSeconds);
|
||||
expectedByQueryKey.set(serializedQueryKey, entry);
|
||||
}
|
||||
|
||||
const serializePolicy = (entry: PollingPolicy) => JSON.stringify(entry);
|
||||
expect(widgetQueryRefetchIntervals.map(serializePolicy).toSorted()).toEqual(
|
||||
[...expectedByQueryKey.values()].map(serializePolicy).toSorted(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||
|
||||
import type { WidgetComponentProps, WidgetDefinition } from "./definition";
|
||||
import type { WidgetOptionDefinition } from "./options";
|
||||
|
||||
type WidgetModule = {
|
||||
definition: WidgetDefinition;
|
||||
componentLoader: () => Promise<{ default: ComponentType<WidgetComponentProps<WidgetKind>> }>;
|
||||
};
|
||||
|
||||
const moduleLoaders: Record<WidgetKind, () => Promise<WidgetModule>> = {
|
||||
clock: () => import("./clock") as Promise<WidgetModule>,
|
||||
weather: () => import("./weather") as Promise<WidgetModule>,
|
||||
app: () => import("./app") as Promise<WidgetModule>,
|
||||
archiveTeamWarrior: () => import("./archive-team-warrior") as Promise<WidgetModule>,
|
||||
anchorNote: () => import("./anchor-note") as Promise<WidgetModule>,
|
||||
notebook: () => import("./notebook") as Promise<WidgetModule>,
|
||||
iframe: () => import("./iframe") as Promise<WidgetModule>,
|
||||
video: () => import("./video") as Promise<WidgetModule>,
|
||||
dnsHoleSummary: () => import("./dns-hole/summary") as Promise<WidgetModule>,
|
||||
dnsHoleControls: () => import("./dns-hole/controls") as Promise<WidgetModule>,
|
||||
"smartHome-entityState": () => import("./smart-home/entity-state") as Promise<WidgetModule>,
|
||||
"smartHome-executeAutomation": () => import("./smart-home/execute-automation") as Promise<WidgetModule>,
|
||||
stockPrice: () => import("./stocks") as Promise<WidgetModule>,
|
||||
mediaServer: () => import("./media-server") as Promise<WidgetModule>,
|
||||
calendar: () => import("./calendar") as Promise<WidgetModule>,
|
||||
downloads: () => import("./downloads") as Promise<WidgetModule>,
|
||||
"mediaRequests-requestList": () => import("./media-requests/list") as Promise<WidgetModule>,
|
||||
"mediaRequests-requestStats": () => import("./media-requests/stats") as Promise<WidgetModule>,
|
||||
mediaMissing: () => import("./media-missing") as Promise<WidgetModule>,
|
||||
networkControllerSummary: () => import("./network-controller/summary") as Promise<WidgetModule>,
|
||||
networkControllerStatus: () => import("./network-controller/network-status") as Promise<WidgetModule>,
|
||||
rssFeed: () => import("./rssFeed") as Promise<WidgetModule>,
|
||||
bookmarks: () => import("./bookmarks") as Promise<WidgetModule>,
|
||||
bazarr: () => import("./bazarr") as Promise<WidgetModule>,
|
||||
indexerManager: () => import("./indexer-manager") as Promise<WidgetModule>,
|
||||
healthMonitoring: () => import("./health-monitoring") as Promise<WidgetModule>,
|
||||
mediaTranscoding: () => import("./media-transcoding") as Promise<WidgetModule>,
|
||||
minecraftServerStatus: () => import("./minecraft/server-status") as Promise<WidgetModule>,
|
||||
dockerContainers: () => import("./docker") as Promise<WidgetModule>,
|
||||
releases: () => import("./releases") as Promise<WidgetModule>,
|
||||
firewall: () => import("./firewall") as Promise<WidgetModule>,
|
||||
notifications: () => import("./notifications") as Promise<WidgetModule>,
|
||||
mediaReleases: () => import("./media-releases") as Promise<WidgetModule>,
|
||||
systemResources: () => import("./system-resources") as Promise<WidgetModule>,
|
||||
coolify: () => import("./coolify") as Promise<WidgetModule>,
|
||||
systemDisks: () => import("./system-disks") as Promise<WidgetModule>,
|
||||
timetable: () => import("./timetable") as Promise<WidgetModule>,
|
||||
"immich-serverStats": () => import("./immich/server-stats") as Promise<WidgetModule>,
|
||||
"immich-albumCarousel": () => import("./immich/album-carousel") as Promise<WidgetModule>,
|
||||
paperlessNgx: () => import("./paperless-ngx") as Promise<WidgetModule>,
|
||||
patchmon: () => import("./patchmon") as Promise<WidgetModule>,
|
||||
tracearr: () => import("./tracearr") as Promise<WidgetModule>,
|
||||
speedtestTracker: () => import("./speedtest-tracker") as Promise<WidgetModule>,
|
||||
uptimeKuma: () => import("./uptime-kuma") as Promise<WidgetModule>,
|
||||
audioStats: () => import("./audio-stats") as Promise<WidgetModule>,
|
||||
umami: () => import("./umami") as Promise<WidgetModule>,
|
||||
vpn: () => import("./vpn") as Promise<WidgetModule>,
|
||||
ups: () => import("./ups") as Promise<WidgetModule>,
|
||||
beszelSystemTable: () => import("./beszel-system-table") as Promise<WidgetModule>,
|
||||
beszelSystemGrid: () => import("./beszel-system-grid") as Promise<WidgetModule>,
|
||||
beszelAlerts: () => import("./beszel-alerts") as Promise<WidgetModule>,
|
||||
beszelSystemStats: () => import("./beszel-system-stats") as Promise<WidgetModule>,
|
||||
traefik: () => import("./traefik") as Promise<WidgetModule>,
|
||||
customApi: () => import("./custom-api") as Promise<WidgetModule>,
|
||||
};
|
||||
|
||||
const definitionPromises = new Map<WidgetKind, Promise<WidgetDefinition>>();
|
||||
|
||||
export const widgetKinds = Object.keys(moduleLoaders) as WidgetKind[];
|
||||
|
||||
export const createRetryableLoader = <TKey, TValue>(loaders: ReadonlyMap<TKey, () => Promise<TValue>>) => {
|
||||
const promises = new Map<TKey, Promise<TValue>>();
|
||||
|
||||
return (key: TKey) => {
|
||||
const existing = promises.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const loader = loaders.get(key);
|
||||
if (!loader) throw new Error(`No loader is registered for ${String(key)}`);
|
||||
|
||||
const promise = loader();
|
||||
promises.set(key, promise);
|
||||
void promise.catch(() => {
|
||||
if (promises.get(key) === promise) promises.delete(key);
|
||||
});
|
||||
return promise;
|
||||
};
|
||||
};
|
||||
|
||||
export const loadWidgetModule = createRetryableLoader(
|
||||
new Map(Object.entries(moduleLoaders) as [WidgetKind, () => Promise<WidgetModule>][]),
|
||||
);
|
||||
|
||||
export const loadWidgetDefinition = (kind: WidgetKind) => {
|
||||
const existing = definitionPromises.get(kind);
|
||||
if (existing) return existing;
|
||||
|
||||
const promise = loadWidgetModule(kind).then(({ definition }) => definition);
|
||||
definitionPromises.set(kind, promise);
|
||||
void promise.catch(() => {
|
||||
if (definitionPromises.get(kind) === promise) definitionPromises.delete(kind);
|
||||
});
|
||||
return promise;
|
||||
};
|
||||
|
||||
let allWidgetDefinitionsPromise: Promise<Map<WidgetKind, WidgetDefinition>> | undefined;
|
||||
|
||||
export const loadAllWidgetDefinitions = () => {
|
||||
if (allWidgetDefinitionsPromise) return allWidgetDefinitionsPromise;
|
||||
|
||||
const promise = Promise.all(widgetKinds.map(async (kind) => [kind, await loadWidgetDefinition(kind)] as const)).then(
|
||||
(entries) => new Map(entries),
|
||||
);
|
||||
allWidgetDefinitionsPromise = promise;
|
||||
void promise.catch(() => {
|
||||
if (allWidgetDefinitionsPromise === promise) allWidgetDefinitionsPromise = undefined;
|
||||
});
|
||||
|
||||
return promise;
|
||||
};
|
||||
|
||||
export const reduceWidgetOptionsWithDefinition = (
|
||||
definition: WidgetDefinition,
|
||||
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
|
||||
currentValue: Record<string, unknown> = {},
|
||||
) => {
|
||||
const options = definition.createOptions(settings) as Record<string, WidgetOptionDefinition>;
|
||||
return objectEntries(options).reduce(
|
||||
(prev, [key, value]) => ({
|
||||
...prev,
|
||||
[key]: currentValue[key] ?? value.defaultValue,
|
||||
}),
|
||||
{} as Record<string, unknown>,
|
||||
);
|
||||
};
|
||||
@@ -6,7 +6,7 @@ import { getServerSettingsAsync } from "@homarr/db/queries";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { createSettings } from "@homarr/settings/creator";
|
||||
|
||||
import { reduceWidgetOptionsWithDefaultValues } from ".";
|
||||
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "./manifest";
|
||||
import prefetchForApps from "./app/prefetch";
|
||||
import prefetchForBookmarks from "./bookmarks/prefetch";
|
||||
import prefetchForDownloads from "./downloads/prefetch";
|
||||
@@ -37,10 +37,15 @@ export const prefetchForKindAsync = async <TKind extends WidgetKind>(
|
||||
}
|
||||
|
||||
const serverSettings = await cachedGetServerSettingsAsync(db);
|
||||
const definition = await loadWidgetDefinition(kind);
|
||||
|
||||
const itemsWithDefaultOptions = items.map((item) => ({
|
||||
...item,
|
||||
options: reduceWidgetOptionsWithDefaultValues(kind, createSettings({ user: null, serverSettings }), item.options),
|
||||
options: reduceWidgetOptionsWithDefinition(
|
||||
definition,
|
||||
createSettings({ user: null, serverSettings }),
|
||||
item.options,
|
||||
),
|
||||
}));
|
||||
|
||||
await callback(queryClient, itemsWithDefaultOptions as never[]);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Query policy used by the root client provider.
|
||||
*
|
||||
* Keep this module free of widget component/definition imports. Importing the
|
||||
* full widget registry from a root provider makes every widget part of the
|
||||
* initial client graph, even before a board has rendered one widget.
|
||||
*/
|
||||
export const widgetQueryRefetchIntervals = [
|
||||
{ queryKey: [["widget", "dnsHole"]], intervalSeconds: 5 },
|
||||
{ queryKey: [["widget", "downloads"]], intervalSeconds: 5 },
|
||||
{ queryKey: [["widget", "firewall"]], intervalSeconds: 5 },
|
||||
{ queryKey: [["widget", "healthMonitoring"]], intervalSeconds: 5 },
|
||||
{ queryKey: [["widget", "mediaServer"]], intervalSeconds: 5 },
|
||||
{ queryKey: [["widget", "tracearr"]], intervalSeconds: 5 },
|
||||
{ queryKey: [["widget", "immich"]], intervalSeconds: null },
|
||||
{ queryKey: [["widget", "indexerManager"]], intervalSeconds: null },
|
||||
{ queryKey: [["widget", "mediaRelease"]], intervalSeconds: null },
|
||||
{ queryKey: [["widget", "mediaTranscoding"]], intervalSeconds: null },
|
||||
{ queryKey: [["widget", "minecraft"]], intervalSeconds: null },
|
||||
{ queryKey: [["widget", "networkController"]], intervalSeconds: null },
|
||||
{ queryKey: [["widget", "notifications"]], intervalSeconds: null },
|
||||
{ queryKey: [["widget", "paperlessNgx"]], intervalSeconds: null },
|
||||
{ queryKey: [["widget", "releases"]], intervalSeconds: null },
|
||||
{ queryKey: [["widget", "rssFeed"]], intervalSeconds: null },
|
||||
{ queryKey: [["widget", "speedtestTracker"]], intervalSeconds: null },
|
||||
{ queryKey: [["widget", "stockPrice"]], intervalSeconds: null },
|
||||
{ queryKey: [["widget", "umami"]], intervalSeconds: null },
|
||||
{ queryKey: [["widget", "vpn"]], intervalSeconds: null },
|
||||
] as const;
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createTrpcQueryKey } from "./trpc-query-key";
|
||||
|
||||
describe("createTrpcQueryKey", () => {
|
||||
test("matches the tRPC TanStack query key shape", () => {
|
||||
expect(createTrpcQueryKey("widget.downloads.getJobsAndStatuses", { integrationIds: ["one"], limit: 50 })).toEqual([
|
||||
["widget", "downloads", "getJobsAndStatuses"],
|
||||
{ input: { integrationIds: ["one"], limit: 50 }, type: "query" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
export const createTrpcQueryKey = <TInput>(path: string, input: TInput) => [
|
||||
path.split("."),
|
||||
{ input, type: "query" as const },
|
||||
];
|
||||
Reference in new issue
Block a user