perf(widgets): lazy load the application graph

This commit is contained in:
ajnart committed 2026-07-29 06:47:07 +02:00
1 parent 4ca0b747bb
commit 6b74078766
38 files changed
+676 -202

No files matched your search

@@ -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}