perf: optimize dashboard loading and builds

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

No files matched your search

@@ -1,5 +1,6 @@
import type { MutableRefObject } from "react";
import type { ComponentType, MutableRefObject } from "react";
import { Suspense, use, useRef } from "react";
import type { CardProps } from "@mantine/core";
import { Badge, Card, Center, Loader } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
@@ -11,10 +12,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 { 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 { getWidgetQueryKeys } from "@homarr/widgets/definition";
import type { WidgetComponentProps, WidgetDefinition } from "@homarr/widgets/definition";
import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import classes from "../sections/item.module.css";
@@ -22,7 +23,7 @@ import { useItemActions } from "./item-actions";
import itemContentClasses from "./item-content.module.css";
import { BoardItemMenu } from "./item-menu";
import { WidgetContextMenu } from "./widget-context-menu";
import { removePersistedWidgetQueries } from "./widget-query-recovery";
import { removeWidgetDataQueries } from "./widget-query-recovery";
interface BoardItemContentProps {
item: SectionItem;
@@ -34,10 +35,51 @@ const getOverflowFromKind = (kind: SectionItem["kind"]) => {
return undefined;
};
const WidgetDefinitionLoadError = ({ error, resetErrorBoundary }: FallbackProps) => (
<Center className={classes.itemCard} h="100%">
<WidgetError error={error} resetErrorBoundary={resetErrorBoundary} />
</Center>
type BoardItemCardProps = CardProps & {
item: SectionItem;
innerRef: (element: HTMLDivElement | null) => void;
};
const BoardItemCard = ({ item, innerRef, children, ...cardProps }: BoardItemCardProps) => {
const board = useRequiredBoard();
return (
<Card
{...cardProps}
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}
>
{children}
</Card>
);
};
const WidgetDefinitionLoadError = ({
error,
resetErrorBoundary,
item,
innerRef,
}: FallbackProps & Pick<BoardItemCardProps, "item" | "innerRef">) => (
<BoardItemCard item={item} innerRef={innerRef}>
<Center h="100%">
<WidgetError error={error} resetErrorBoundary={resetErrorBoundary} />
</Center>
</BoardItemCard>
);
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
@@ -47,12 +89,17 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
return (
<>
<ErrorBoundary resetKeys={[item.kind]} fallbackRender={WidgetDefinitionLoadError}>
<ErrorBoundary
resetKeys={[item.kind]}
fallbackRender={(fallbackProps) => <WidgetDefinitionLoadError {...fallbackProps} item={item} innerRef={ref} />}
>
<Suspense
fallback={
<Center ref={ref} className={classes.itemCard} h="100%">
<Loader size="sm" />
</Center>
<BoardItemCard item={item} innerRef={ref}>
<Center h="100%">
<Loader size="sm" />
</Center>
</BoardItemCard>
}
>
<LoadedBoardItemContent
@@ -89,35 +136,16 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
);
};
interface LoadedBoardItemContentProps extends Omit<InnerContentProps, "definition"> {
interface LoadedBoardItemContentProps extends Omit<InnerContentProps, "definition" | "Component"> {
innerRef: (element: HTMLDivElement | null) => void;
}
const LoadedBoardItemContent = ({ item, innerRef, ...innerContentProps }: LoadedBoardItemContentProps) => {
const board = useRequiredBoard();
const definition = use(loadWidgetDefinition(item.kind));
const { definition, Component } = use(loadWidgetResources(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}
>
<BoardItemCard item={item} innerRef={innerRef}>
<ErrorBoundary
resetKeys={[item.kind]}
fallbackRender={({ resetErrorBoundary, error }) => (
@@ -127,9 +155,9 @@ const LoadedBoardItemContent = ({ item, innerRef, ...innerContentProps }: Loaded
</>
)}
>
<InnerContent item={item} definition={definition} {...innerContentProps} />
<InnerContent item={item} definition={definition} Component={Component} {...innerContentProps} />
</ErrorBoundary>
</Card>
</BoardItemCard>
</WidgetContextMenu>
);
};
@@ -140,13 +168,13 @@ interface InnerContentProps {
height: number;
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
definition: WidgetDefinition;
Component: ComponentType<WidgetComponentProps<SectionItem["kind"]>>;
}
const InnerContent = ({ item, definition, ...dimensions }: InnerContentProps) => {
const InnerContent = ({ item, definition, Component, ...dimensions }: InnerContentProps) => {
const settings = useSettings();
const board = useRequiredBoard();
const [isEditMode] = useEditMode();
const Comp = getWidgetComponent(item.kind);
const options = reduceWidgetOptionsWithDefinition(definition, settings, item.options);
const newItem = { ...item, options };
const { updateItemOptions } = useItemActions();
@@ -155,13 +183,14 @@ const InnerContent = ({ item, definition, ...dimensions }: InnerContentProps) =>
const widgetSupportsIntegrations =
"supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) >= 1;
const queryClient = useQueryClient();
const widgetQueryKeys = getWidgetQueryKeys(definition, item.kind);
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={() => {
removePersistedWidgetQueries(queryClient);
removeWidgetDataQueries(queryClient, widgetQueryKeys);
reset();
}}
fallbackRender={({ resetErrorBoundary, error }) => (
@@ -185,22 +214,24 @@ const InnerContent = ({ item, definition, ...dimensions }: InnerContentProps) =>
}
/>
<BoardItemMenu offset={4} item={newItem} definition={definition} />
<Comp
options={options as never}
integrationIds={item.integrationIds}
isEditMode={isEditMode}
boardId={board.id}
itemId={item.id}
setOptions={(partialNewOptions) =>
updateOptions({
newOptions: {
...options,
...partialNewOptions.newOptions,
},
})
}
{...dimensions}
/>
<div data-homarr-widget-ready={item.kind} style={{ display: "contents" }}>
<Component
options={options as never}
integrationIds={item.integrationIds}
isEditMode={isEditMode}
boardId={board.id}
itemId={item.id}
setOptions={(partialNewOptions) =>
updateOptions({
newOptions: {
...options,
...partialNewOptions.newOptions,
},
})
}
{...dimensions}
/>
</div>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { ActionIcon, Menu } from "@mantine/core";
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
@@ -9,12 +9,12 @@ import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { WidgetDefinition } from "@homarr/widgets/definition";
import { WidgetEditModal } from "@homarr/widgets/modals";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { useSectionContext } from "../sections/section-context";
import { useItemActions } from "./item-actions";
import { ItemMoveModal } from "./item-move-modal";
import { LazyItemMoveModal, preloadItemMoveModal } from "./lazy-item-move-modal";
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
interface BoardItemMenuProps {
offset: number;
@@ -25,7 +25,8 @@ interface BoardItemMenuProps {
export const BoardItemMenu = (props: BoardItemMenuProps) => {
const { data: session } = useSession();
if (!session) return null;
const [isEditMode] = useEditMode();
if (!session || !isEditMode) return null;
return <BoardItemMenuInner {...props} />;
};
@@ -34,13 +35,17 @@ const BoardItemMenuInner = ({ offset, item, definition, resetErrorBoundary }: Bo
const refResetErrorBoundaryOnNextRender = useRef(false);
const tItem = useScopedI18n("item");
const t = useI18n();
const { openModal } = useModalAction(WidgetEditModal);
const { openModal: openMoveModal } = useModalAction(ItemMoveModal);
const { openModal } = useModalAction(LazyWidgetEditModal);
const { openModal: openMoveModal } = useModalAction(LazyItemMoveModal);
const { openConfirmModal } = useConfirmModal();
const [isEditMode] = useEditMode();
const [isMenuOpen, setIsMenuOpen] = useState(false);
const hasSupportedIntegrations =
"supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) > 0;
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
useItemActions();
const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
const { data: integrationData, isPending } = clientApi.integration.all.useQuery(undefined, {
enabled: isMenuOpen && hasSupportedIntegrations,
});
const { gridstack } = useSectionContext().refs;
const settings = useSettings();
@@ -52,12 +57,11 @@ const BoardItemMenuInner = ({ offset, item, definition, resetErrorBoundary }: Bo
}
}, [item, resetErrorBoundary]);
if (!isEditMode || isPending) return null;
const openEditModal = () => {
openModal(
{
kind: item.kind,
definition,
value: {
advancedOptions: item.advancedOptions,
options: item.options,
@@ -107,7 +111,7 @@ const BoardItemMenuInner = ({ offset, item, definition, resetErrorBoundary }: Bo
};
return (
<Menu withinPortal position="right-start" arrowPosition="center">
<Menu withinPortal position="right-start" arrowPosition="center" opened={isMenuOpen} onChange={setIsMenuOpen}>
<Menu.Target>
<ActionIcon
variant="default"
@@ -123,11 +127,19 @@ const BoardItemMenuInner = ({ offset, item, definition, resetErrorBoundary }: Bo
</Menu.Target>
<Menu.Dropdown miw={128}>
<Menu.Label>{tItem("menu.label.settings")}</Menu.Label>
<Menu.Item leftSection={<IconPencil size={16} />} onClick={openEditModal}>
<Menu.Item
leftSection={<IconPencil size={16} />}
onClick={openEditModal}
onFocus={preloadWidgetEditModal}
onPointerEnter={preloadWidgetEditModal}
disabled={hasSupportedIntegrations && isPending}
>
{tItem("action.edit")}
</Menu.Item>
<Menu.Item
leftSection={<IconLayoutKanban size={16} />}
onFocus={preloadItemMoveModal}
onPointerEnter={preloadItemMoveModal}
onClick={() => {
if (!gridstack.current) return;
openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current });
@@ -9,13 +9,13 @@ import { useI18n } from "@homarr/translation/client";
import type { Item, SectionItem } from "~/app/[locale]/boards/_types";
interface InnerProps {
export interface ItemMoveModalProps {
gridStack: GridStack;
item: Pick<SectionItem, "id" | "width" | "height" | "xOffset" | "yOffset">;
columnCount: number;
}
export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) => {
export const ItemMoveModal = createModal<ItemMoveModalProps>(({ actions, innerProps }) => {
const t = useI18n();
const maxWidthRef = useRef(innerProps.columnCount - innerProps.item.xOffset);
const form = useZodForm(
@@ -0,0 +1,48 @@
import { describe, expect, it, vi } from "vitest";
import { resolveMatchingIntegrationsAsync, tryLockSelection, unlockSelection } from "./item-select-data";
describe("widget selection lock", () => {
it("rejects repeated selection synchronously until the active selection finishes", () => {
const lock = { current: false };
expect(tryLockSelection(lock)).toBe(true);
expect(tryLockSelection(lock)).toBe(false);
unlockSelection(lock);
expect(tryLockSelection(lock)).toBe(true);
});
});
describe("resolveMatchingIntegrationsAsync", () => {
it("waits for the shared integration query before a cold integration-backed selection", async () => {
const ensureDataAsync = vi.fn(async () => [
{ id: "matching", kind: "sonarr" as const },
{ id: "other", kind: "radarr" as const },
]);
await expect(
resolveMatchingIntegrationsAsync({
hasIntegrationSupport: true,
supportedIntegrations: ["sonarr"],
currentData: undefined,
ensureDataAsync,
}),
).resolves.toEqual([{ id: "matching", kind: "sonarr" }]);
expect(ensureDataAsync).toHaveBeenCalledOnce();
});
it("does not wait for integrations when the widget has no integration support", async () => {
const ensureDataAsync = vi.fn(async () => [{ id: "unused", kind: "sonarr" as const }]);
await expect(
resolveMatchingIntegrationsAsync({
hasIntegrationSupport: false,
supportedIntegrations: [],
currentData: undefined,
ensureDataAsync,
}),
).resolves.toEqual([]);
expect(ensureDataAsync).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,28 @@
import type { IntegrationKind } from "@homarr/definitions";
export const tryLockSelection = (lock: { current: boolean }) => {
if (lock.current) return false;
lock.current = true;
return true;
};
export const unlockSelection = (lock: { current: boolean }) => {
lock.current = false;
};
export const resolveMatchingIntegrationsAsync = async <TIntegration extends { kind: IntegrationKind }>({
hasIntegrationSupport,
supportedIntegrations,
currentData,
ensureDataAsync,
}: {
hasIntegrationSupport: boolean;
supportedIntegrations: readonly IntegrationKind[];
currentData: TIntegration[] | undefined;
ensureDataAsync: () => Promise<TIntegration[]>;
}) => {
if (!hasIntegrationSupport) return [];
const integrations = currentData ?? (await ensureDataAsync());
return integrations.filter((integration) => supportedIntegrations.includes(integration.kind));
};
@@ -0,0 +1,21 @@
.action {
opacity: 0;
transition: opacity 150ms ease;
}
.card:hover .action,
.card:focus-within .action {
opacity: 1;
}
@media (hover: none), (pointer: coarse) {
.action {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.action {
transition: none;
}
}
@@ -1,29 +1,46 @@
import { use, useMemo, useState } from "react";
import { useMemo, useRef, 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 type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { createId } from "@homarr/common";
import { getIconUrl, getIntegrationName } from "@homarr/definitions";
import { getIconUrl, getIntegrationName, widgetIntegrationSupport, widgetKinds } from "@homarr/definitions";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
import type { TablerIcon } from "@homarr/ui";
import { WidgetEditModal } from "@homarr/widgets/modals";
import { loadAllWidgetDefinitions, reduceWidgetOptionsWithDefinition, widgetKinds } from "@homarr/widgets/manifest";
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import { useItemActions } from "./item-actions";
import { resolveMatchingIntegrationsAsync, tryLockSelection, unlockSelection } from "./item-select-data";
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
import classes from "./item-select-modal.module.css";
export const ItemSelectModal = createModal<void>(({ actions }) => {
const widgetDefinitions = use(loadAllWidgetDefinitions());
interface ItemSelectModalContentProps {
actions: { closeModal: () => void };
innerProps: void;
integrationData: RouterOutputs["integration"]["all"] | undefined;
customWidgetDefs: RouterOutputs["customWidget"]["all"] | undefined;
ensureIntegrationDataAsync: () => Promise<RouterOutputs["integration"]["all"]>;
}
const ItemSelectModalContent = ({
actions,
integrationData,
customWidgetDefs,
ensureIntegrationDataAsync,
}: ItemSelectModalContentProps) => {
const [search, setSearch] = useState("");
const [loadingSelection, setLoadingSelection] = useState<string | null>(null);
const selectionLock = useRef(false);
const t = useI18n();
const { createItem, updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
const { openModal: openEditModal } = useModalAction(WidgetEditModal);
const { data: integrationData } = clientApi.integration.all.useQuery();
const { data: customWidgetDefs } = clientApi.customWidget.all.useQuery();
const { openModal: openEditModal } = useModalAction(LazyWidgetEditModal);
const settings = useSettings();
const availableKinds = useMemo(() => new Set((integrationData ?? []).map((i) => i.kind)), [integrationData]);
@@ -33,27 +50,23 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
widgetKinds
.filter((kind) => kind !== "customApi")
.map((kind) => {
const definition = widgetDefinitions.get(kind);
if (!definition) return null;
return {
kind,
supportedIntegrations: (definition.supportedIntegrations ?? []).filter(
supportedIntegrations: (widgetIntegrationSupport[kind] ?? []).filter(
(integration) => integration !== "mock",
),
icon: definition.icon,
icon: widgetCatalogIcons[kind],
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, widgetDefinitions],
[t],
);
const filteredItems = useMemo(() => {
@@ -72,56 +85,81 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
[customWidgetDefs, search],
);
const handleAddCustomWidget = (definitionId: string) => {
const itemId = createId();
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 notifyDefinitionLoadError = (error: unknown) => {
showErrorNotification({
title: t("common.error"),
message: error instanceof Error ? error.message : String(error),
});
};
const handleAdd = (kind: WidgetKind) => {
const definition = widgetDefinitions.get(kind);
if (!definition) return;
const hasIntegrationSupport = "supportedIntegrations" in definition;
const handleAddCustomWidget = async (definitionId: string) => {
if (!tryLockSelection(selectionLock)) return;
setLoadingSelection(`custom:${definitionId}`);
try {
const definition = await loadWidgetDefinition("customApi");
const itemId = createId();
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
createItem({ id: itemId, kind: "customApi", integrationIds: [] });
updateItemOptions({ itemId, newOptions: { ...defaultOptions, definitionId } });
actions.closeModal();
} catch (error) {
notifyDefinitionLoadError(error);
} finally {
unlockSelection(selectionLock);
setLoadingSelection(null);
}
};
const matchingIntegrations = hasIntegrationSupport
? (integrationData ?? []).filter((integration) =>
(definition.supportedIntegrations ?? []).includes(integration.kind),
)
: [];
const handleAdd = async (kind: WidgetKind) => {
if (!tryLockSelection(selectionLock)) return;
setLoadingSelection(kind);
preloadWidgetEditModal();
try {
const definition = await loadWidgetDefinition(kind);
const hasIntegrationSupport = "supportedIntegrations" in definition;
const integrationIds = matchingIntegrations.map((i) => i.id);
const itemId = createId();
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
const matchingIntegrations = await resolveMatchingIntegrationsAsync({
hasIntegrationSupport,
supportedIntegrations: definition.supportedIntegrations ?? [],
currentData: integrationData,
ensureDataAsync: ensureIntegrationDataAsync,
});
createItem({ id: itemId, kind, integrationIds });
actions.closeModal();
const integrationIds = matchingIntegrations.map((i) => i.id);
const itemId = createId();
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
openEditModal(
{
kind,
value: {
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
options: defaultOptions,
integrationIds,
createItem({ id: itemId, kind, integrationIds });
actions.closeModal();
openEditModal(
{
kind,
definition,
value: {
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
options: defaultOptions,
integrationIds,
},
onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => {
updateItemOptions({ itemId, newOptions: options });
updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions });
updateItemIntegrations({ itemId, newIntegrations: newIntegrationIds });
},
integrationData: matchingIntegrations,
integrationSupport: hasIntegrationSupport,
settings,
},
onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => {
updateItemOptions({ itemId, newOptions: options });
updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions });
updateItemIntegrations({ itemId, newIntegrations: newIntegrationIds });
{
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`,
},
integrationData: matchingIntegrations,
integrationSupport: hasIntegrationSupport,
settings,
},
{
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`,
},
);
);
} catch (error) {
notifyDefinitionLoadError(error);
} finally {
unlockSelection(selectionLock);
setLoadingSelection(null);
}
};
return (
@@ -130,9 +168,9 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
onSearchChange={setSearch}
placeholder={`${t("item.create.search")}...`}
onSearchKeyDown={(event) => {
if (event.key === "Enter" && filteredItems.length === 1) {
if (event.key === "Enter" && loadingSelection === null && filteredItems.length === 1) {
const [item] = filteredItems;
if (item) handleAdd(item.kind);
if (item) void handleAdd(item.kind);
}
}}
>
@@ -140,7 +178,13 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
<WidgetItem
key={item.kind}
item={item}
onSelect={() => handleAdd(item.kind)}
onSelect={() => void handleAdd(item.kind)}
onIntent={() => {
void loadWidgetDefinition(item.kind).catch(() => undefined);
preloadWidgetEditModal();
}}
disabled={loadingSelection !== null}
loading={loadingSelection === item.kind}
hasMatchingIntegration={item.supportedIntegrations.some((kind) => availableKinds.has(kind))}
/>
))}
@@ -156,12 +200,12 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
{filteredCustomWidgets.map((def) => (
<Card
key={def.id}
className={classes.card}
h={selectGridCardHeight}
withBorder
pos="relative"
style={{ overflow: "hidden", "--_hover-opacity": "0" }}
onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")}
onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
style={{ overflow: "hidden" }}
onPointerEnter={() => void loadWidgetDefinition("customApi").catch(() => undefined)}
>
<Stack h="100%" gap="xs">
<Group gap="sm" wrap="nowrap" align="flex-start">
@@ -179,18 +223,24 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
</Text>
</Stack>
<Box
className={classes.action}
pos="absolute"
bottom={0}
left={0}
right={0}
p="xs"
style={{
opacity: "var(--_hover-opacity)",
transition: "opacity 150ms ease",
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
}}
>
<Button onClick={() => handleAddCustomWidget(def.id)} variant="light" size="xs" fullWidth>
<Button
onClick={() => void handleAddCustomWidget(def.id)}
variant="light"
size="xs"
fullWidth
disabled={loadingSelection !== null}
loading={loadingSelection === `custom:${def.id}`}
>
{t("item.create.addToBoard")}
</Button>
</Box>
@@ -206,7 +256,24 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
)}
</SelectGridLayout>
);
}).withOptions({
};
const ItemSelectModalFrame = (props: Pick<ItemSelectModalContentProps, "actions" | "innerProps">) => {
const utils = clientApi.useUtils();
const { data: integrationData } = clientApi.integration.all.useQuery();
const { data: customWidgetDefs } = clientApi.customWidget.all.useQuery();
return (
<ItemSelectModalContent
{...props}
integrationData={integrationData}
customWidgetDefs={customWidgetDefs}
ensureIntegrationDataAsync={() => utils.integration.all.ensureData()}
/>
);
};
export const ItemSelectModal = createModal<void>((props) => <ItemSelectModalFrame {...props} />).withOptions({
defaultTitle: (t) => t("item.create.title"),
size: modalSizeSelect,
});
@@ -214,6 +281,9 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
const WidgetItem = ({
item,
onSelect,
onIntent,
disabled,
loading,
hasMatchingIntegration,
}: {
item: {
@@ -224,23 +294,27 @@ const WidgetItem = ({
icon: TablerIcon;
};
onSelect: () => void;
onIntent: () => void;
disabled: boolean;
loading: boolean;
hasMatchingIntegration: boolean;
}) => {
const t = useI18n();
return (
<Card
className={classes.card}
h={selectGridCardHeight}
withBorder
pos="relative"
style={{
overflow: "hidden",
"--_hover-opacity": "0",
borderColor: hasMatchingIntegration ? "var(--mantine-color-blue-6)" : undefined,
borderWidth: hasMatchingIntegration ? 2 : undefined,
}}
onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")}
onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
onFocus={onIntent}
onPointerEnter={onIntent}
aria-busy={loading || undefined}
>
<Stack h="100%" gap="xs">
<Group gap="sm" wrap="nowrap" align="flex-start">
@@ -257,18 +331,17 @@ const WidgetItem = ({
<SupportedIntegrations integrations={item.supportedIntegrations} />
</Stack>
<Box
className={classes.action}
pos="absolute"
bottom={0}
left={0}
right={0}
p="xs"
style={{
opacity: "var(--_hover-opacity)",
transition: "opacity 150ms ease",
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
}}
>
<Button onClick={onSelect} variant="light" size="xs" fullWidth>
<Button onClick={onSelect} variant="light" size="xs" fullWidth disabled={disabled} loading={loading}>
{t(`item.create.addToBoard`)}
</Button>
</Box>
@@ -0,0 +1,67 @@
"use client";
import { Suspense, use } from "react";
import { Center, Loader } from "@mantine/core";
import { ErrorBoundary } from "react-error-boundary";
import { createModal, modalSizeForm } from "@homarr/modals";
import { WidgetError } from "@homarr/widgets/errors";
import type { ItemMoveModalProps } from "./item-move-modal";
let itemMoveModalPromise: Promise<typeof import("./item-move-modal")> | undefined;
const loadItemMoveModal = () => {
if (itemMoveModalPromise) return itemMoveModalPromise;
const currentPromise = import("./item-move-modal");
itemMoveModalPromise = currentPromise;
void currentPromise.catch(() => {
if (itemMoveModalPromise === currentPromise) itemMoveModalPromise = undefined;
});
return currentPromise;
};
export const preloadItemMoveModal = () => void loadItemMoveModal().catch(() => undefined);
interface LazyItemMoveModalContentProps {
actions: { closeModal: () => void };
innerProps: ItemMoveModalProps;
}
const LazyItemMoveModalContent = (props: LazyItemMoveModalContentProps) => {
const { ItemMoveModal } = use(loadItemMoveModal());
const Component = ItemMoveModal.component;
return <Component {...props} />;
};
export const LazyItemMoveModal = createModal<ItemMoveModalProps>((props) => (
<ErrorBoundary
fallbackRender={({ error, resetErrorBoundary }) => (
<Center py="xl">
<WidgetError
error={error}
resetErrorBoundary={() => {
itemMoveModalPromise = undefined;
resetErrorBoundary();
}}
/>
</Center>
)}
>
<Suspense
fallback={
<Center py="xl">
<Loader size="sm" />
</Center>
}
>
<LazyItemMoveModalContent {...props} />
</Suspense>
</ErrorBoundary>
)).withOptions({
defaultTitle(t) {
return t("item.moveResize.title");
},
size: modalSizeForm,
});
@@ -0,0 +1,68 @@
"use client";
import { Suspense, use } from "react";
import { Center, Loader } from "@mantine/core";
import { ErrorBoundary } from "react-error-boundary";
import type { WidgetKind } from "@homarr/definitions";
import { createModal, modalSizeForm } from "@homarr/modals";
import { WidgetError } from "@homarr/widgets/errors";
import type { WidgetEditModalProps } from "@homarr/widgets/modals";
let widgetEditModalPromise: Promise<typeof import("@homarr/widgets/modals")> | undefined;
export const loadWidgetEditModal = () => {
if (widgetEditModalPromise) return widgetEditModalPromise;
const currentPromise = import("@homarr/widgets/modals");
widgetEditModalPromise = currentPromise;
void currentPromise.catch(() => {
if (widgetEditModalPromise === currentPromise) widgetEditModalPromise = undefined;
});
return currentPromise;
};
export const preloadWidgetEditModal = () => void loadWidgetEditModal().catch(() => undefined);
interface LazyWidgetEditModalContentProps {
actions: { closeModal: () => void };
innerProps: WidgetEditModalProps<WidgetKind>;
}
const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
const { WidgetEditModal } = use(loadWidgetEditModal());
const Component = WidgetEditModal.component;
return <Component {...props} />;
};
export const LazyWidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>((props) => (
<ErrorBoundary
fallbackRender={({ error, resetErrorBoundary }) => (
<Center py="xl">
<WidgetError
error={error}
resetErrorBoundary={() => {
widgetEditModalPromise = undefined;
resetErrorBoundary();
}}
/>
</Center>
)}
>
<Suspense
fallback={
<Center py="xl">
<Loader size="sm" />
</Center>
}
>
<LazyWidgetEditModalContent {...props} />
</Suspense>
</ErrorBoundary>
)).withOptions({
keepMounted: true,
defaultTitle(t) {
return t("item.edit.title");
},
size: modalSizeForm,
});
@@ -1,7 +1,7 @@
"use client";
import type { MutableRefObject, ReactNode } from "react";
import { useCallback, useMemo } from "react";
import { useCallback, useMemo, useState } from "react";
import { Group, Loader, Menu, Switch, Text, Tooltip } from "@mantine/core";
import {
IconAlertTriangle,
@@ -13,7 +13,7 @@ import {
IconTrash,
} from "@tabler/icons-react";
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import { useIsFetching, useQueryClient } from "@tanstack/react-query";
import { partialMatchKey, useIsFetching, useQueryClient } from "@tanstack/react-query";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
@@ -26,13 +26,14 @@ import { translateIfNecessary } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { TranslationFunction } from "@homarr/translation";
import type { WidgetContextMenuAction, WidgetDefinition } from "@homarr/widgets/definition";
import { getWidgetQueryKeys } from "@homarr/widgets/definition";
import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import { WidgetEditModal } from "@homarr/widgets/modals";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { useSectionContext } from "../sections/section-context";
import { useItemActions } from "./item-actions";
import { ItemMoveModal } from "./item-move-modal";
import { LazyItemMoveModal, preloadItemMoveModal } from "./lazy-item-move-modal";
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
interface WidgetContextMenuProps {
item: SectionItem;
@@ -43,31 +44,83 @@ interface WidgetContextMenuProps {
export const WidgetContextMenu = ({ item, definition, widgetStateRef, children }: WidgetContextMenuProps) => {
const { data: session } = useSession();
const settings = useSettings();
if (!session || !settings.enableRightClickOnWidgets) return <>{children}</>;
return (
<WidgetContextMenuInner item={item} definition={definition} widgetStateRef={widgetStateRef} settings={settings}>
{children}
</WidgetContextMenuInner>
);
};
const WidgetContextMenuInner = ({
item,
definition,
widgetStateRef,
children,
settings,
}: WidgetContextMenuProps & { settings: ReturnType<typeof useSettings> }) => {
const [isMenuOpen, setIsMenuOpen] = useState(false);
return (
<Menu
shadow="md"
width={300}
closeOnItemClick={false}
position="right-start"
offset={4}
opened={isMenuOpen}
onChange={setIsMenuOpen}
>
<Menu.ContextMenu>{children}</Menu.ContextMenu>
<Menu.Dropdown>
{isMenuOpen && (
<WidgetContextMenuDropdown
item={item}
definition={definition}
widgetStateRef={widgetStateRef}
settings={settings}
/>
)}
</Menu.Dropdown>
</Menu>
);
};
type WidgetContextMenuDropdownProps = Omit<WidgetContextMenuProps, "children"> & {
settings: ReturnType<typeof useSettings>;
};
const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings }: WidgetContextMenuDropdownProps) => {
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const tItem = useScopedI18n("item");
const tMenu = useScopedI18n("item.menu.label");
const t = useI18n();
const settings = useSettings();
const isRightClickEnabled = settings.enableRightClickOnWidgets;
const { openModal } = useModalAction(WidgetEditModal);
const { openModal: openMoveModal } = useModalAction(ItemMoveModal);
const hasSupportedIntegrations =
"supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) > 0;
const { openModal } = useModalAction(LazyWidgetEditModal);
const { openModal: openMoveModal } = useModalAction(LazyItemMoveModal);
const { openConfirmModal } = useConfirmModal();
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
useItemActions();
const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
const { data: integrationData, isPending } = clientApi.integration.all.useQuery(undefined, {
enabled: hasSupportedIntegrations,
});
const { mutate: saveBoard } = clientApi.board.saveBoard.useMutation();
const { gridstack } = useSectionContext().refs;
const queryClient = useQueryClient();
const widgetQueryKey = useMemo(
() => ("queryKey" in definition ? (definition.queryKey as QueryKey) : [["widget", item.kind]]),
[definition, item.kind],
);
const isWidgetFetching = useIsFetching({ queryKey: widgetQueryKey }) > 0;
const widgetQueryKeys = useMemo(() => getWidgetQueryKeys(definition, item.kind), [definition, item.kind]);
const isWidgetFetching =
useIsFetching({
predicate: (query) => widgetQueryKeys.some((queryKey) => partialMatchKey(query.queryKey, queryKey)),
}) > 0;
const handleRefetch = useCallback(() => {
void queryClient.refetchQueries({ queryKey: widgetQueryKey, type: "all" });
}, [queryClient, widgetQueryKey]);
void Promise.all(widgetQueryKeys.map((queryKey) => queryClient.refetchQueries({ queryKey, type: "all" })));
}, [queryClient, widgetQueryKeys]);
const options = useMemo(
() => reduceWidgetOptionsWithDefinition(definition, settings, item.options),
@@ -107,6 +160,7 @@ export const WidgetContextMenu = ({ item, definition, widgetStateRef, children }
openModal(
{
kind: item.kind,
definition,
value: {
advancedOptions: item.advancedOptions,
options: item.options,
@@ -168,135 +222,133 @@ export const WidgetContextMenu = ({ item, definition, widgetStateRef, children }
[options, item.id, updateItemOptions, persistBoard, board],
);
if (!session) return <>{children}</>;
if (!isRightClickEnabled) return <>{children}</>;
const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden);
return (
<Menu shadow="md" width={300} closeOnItemClick={false} position="right-start" offset={4}>
<Menu.ContextMenu>{children}</Menu.ContextMenu>
<Menu.Dropdown>
{toggleOptions.length > 0 && (
<>
<Menu.Label>{tMenu("options")}</Menu.Label>
{toggleOptions.map(([key]) => (
<Menu.Item key={key} onClick={() => handleToggle(key)(!options[key])}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Group wrap="nowrap">
{String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))}
<Switch size="xs" checked={Boolean(options[key])} readOnly tabIndex={-1} ml="auto" />
</Group>
</Menu.Item>
))}
</>
)}
{visibleWidgetActions.length > 0 && (
<>
{toggleOptions.length > 0 && <Menu.Divider />}
<Menu.Label>{tMenu("actions")}</Menu.Label>
{visibleWidgetActions.map((action) => {
const Icon = action.icon;
return (
<Menu.Item
key={action.key}
closeMenuOnClick
leftSection={Icon ? <Icon size={16} /> : undefined}
onClick={action.onClick}
disabled={action.disabled}
color={action.color}
>
{String(translateIfNecessary(t, action.label))}
</Menu.Item>
);
})}
</>
)}
{isEditMode && (
<>
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />}
<Menu.Item
closeMenuOnClick
leftSection={<IconLayoutKanban size={16} />}
onClick={() => {
if (!gridstack.current) return;
openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current });
}}
>
{tItem("action.moveResize")}
</Menu.Item>
<Menu.Item
closeMenuOnClick
leftSection={<IconCopy size={16} />}
onClick={() => duplicateItem({ itemId: item.id })}
>
{tItem("action.duplicate")}
</Menu.Item>
</>
)}
<>
{toggleOptions.length > 0 && (
<>
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0 || isEditMode) && <Menu.Divider />}
<Menu.Label>{tMenu("options")}</Menu.Label>
{toggleOptions.map(([key]) => (
<Menu.Item key={key} onClick={() => handleToggle(key)(!options[key])}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Group wrap="nowrap">
{String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))}
<Switch size="xs" checked={Boolean(options[key])} readOnly tabIndex={-1} ml="auto" />
</Group>
</Menu.Item>
))}
</>
)}
{visibleWidgetActions.length > 0 && (
<>
{toggleOptions.length > 0 && <Menu.Divider />}
<Menu.Label>{tMenu("actions")}</Menu.Label>
{visibleWidgetActions.map((action) => {
const Icon = action.icon;
return (
<Menu.Item
key={action.key}
closeMenuOnClick
leftSection={Icon ? <Icon size={16} /> : undefined}
onClick={action.onClick}
disabled={action.disabled}
color={action.color}
>
{String(translateIfNecessary(t, action.label))}
</Menu.Item>
);
})}
</>
)}
{isEditMode && (
<>
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />}
<Menu.Item
leftSection={isWidgetFetching ? <Loader size={16} /> : <IconRefresh size={16} />}
onClick={handleRefetch}
disabled={isWidgetFetching}
closeMenuOnClick
leftSection={<IconLayoutKanban size={16} />}
onFocus={preloadItemMoveModal}
onPointerEnter={preloadItemMoveModal}
onClick={() => {
if (!gridstack.current) return;
openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current });
}}
>
<Group justify="space-between" wrap="nowrap" gap="sm">
{tMenu("refresh")}
<WidgetQueryStatus
queryClient={queryClient}
queryKey={widgetQueryKey}
isFetching={isWidgetFetching}
t={t}
/>
</Group>
{tItem("action.moveResize")}
</Menu.Item>
<Menu.Item
closeMenuOnClick
leftSection={<IconSettings size={16} />}
onClick={openEditModal}
disabled={isPending}
leftSection={<IconCopy size={16} />}
onClick={() => duplicateItem({ itemId: item.id })}
>
{tMenu("settings")}
{tItem("action.duplicate")}
</Menu.Item>
</>
)}
{isEditMode && (
<>
<Menu.Divider />
<Menu.Item
closeMenuOnClick
color="red"
leftSection={<IconTrash size={16} />}
onClick={() => {
openConfirmModal({
title: tItem("remove.title"),
children: tItem("remove.message"),
onConfirm: () => removeItem({ itemId: item.id }),
});
}}
>
{tItem("action.remove")}
</Menu.Item>
</>
)}
</Menu.Dropdown>
</Menu>
<>
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0 || isEditMode) && <Menu.Divider />}
<Menu.Item
leftSection={isWidgetFetching ? <Loader size={16} /> : <IconRefresh size={16} />}
onClick={handleRefetch}
disabled={isWidgetFetching}
>
<Group justify="space-between" wrap="nowrap" gap="sm">
{tMenu("refresh")}
<WidgetQueryStatus
queryClient={queryClient}
queryKeys={widgetQueryKeys}
isFetching={isWidgetFetching}
t={t}
/>
</Group>
</Menu.Item>
<Menu.Item
closeMenuOnClick
leftSection={<IconSettings size={16} />}
onClick={openEditModal}
onFocus={preloadWidgetEditModal}
onPointerEnter={preloadWidgetEditModal}
disabled={hasSupportedIntegrations && isPending}
>
{tMenu("settings")}
</Menu.Item>
</>
{isEditMode && (
<>
<Menu.Divider />
<Menu.Item
closeMenuOnClick
color="red"
leftSection={<IconTrash size={16} />}
onClick={() => {
openConfirmModal({
title: tItem("remove.title"),
children: tItem("remove.message"),
onConfirm: () => removeItem({ itemId: item.id }),
});
}}
>
{tItem("action.remove")}
</Menu.Item>
</>
)}
</>
);
};
interface WidgetQueryStatusProps {
queryClient: QueryClient;
queryKey: QueryKey;
queryKeys: QueryKey[];
isFetching: boolean;
t: TranslationFunction;
}
const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQueryStatusProps) => {
const queries = queryClient.getQueryCache().findAll({ queryKey });
const WidgetQueryStatus = ({ queryClient, queryKeys, isFetching, t }: WidgetQueryStatusProps) => {
const queries = [...new Set(queryKeys.flatMap((queryKey) => queryClient.getQueryCache().findAll({ queryKey })))];
const timestamps = queries.map((query) => query.state.dataUpdatedAt).filter(Boolean);
const latest = timestamps.length > 0 ? Math.max(...timestamps) : 0;
const ageLabel = useTimeAgo(new Date(latest));
@@ -334,7 +386,8 @@ const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQuery
<Group gap={4} wrap="nowrap">
<IconAlertTriangle size={12} color="var(--mantine-color-red-6)" />
<Text size="xs" c="red.6">
{t("item.menu.status.error")} · {ageLabel}
{t("item.menu.status.error")}
{latest > 0 && ` · ${ageLabel}`}
</Text>
</Group>
</Tooltip>
@@ -345,7 +398,8 @@ const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQuery
<Group gap={4} wrap="nowrap">
<IconCircleCheck size={12} color="var(--mantine-color-green-6)" />
<Text size="xs" c="dimmed">
{t("item.menu.status.success")} · {ageLabel}
{t("item.menu.status.success")}
{latest > 0 && ` · ${ageLabel}`}
</Text>
</Group>
);
@@ -1,155 +0,0 @@
import { objectEntries } from "@homarr/common";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import type { SettingsContextProps } from "@homarr/settings/creator";
import type { WidgetOptionDefinition, WidgetOptionType } from "@homarr/widgets";
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import type { SectionItem } from "~/app/[locale]/boards/_types";
export type Translator = {
(key: string): string;
};
export interface HoverMetadataItem {
label: string;
value: string;
}
const maxMetadataItems = 3;
const skippedOptionTypes: Partial<Record<WidgetOptionType, true>> = {
app: true,
anchorNote: true,
dynamicSelect: true,
sortableItemList: true,
multiReleasesRepositories: true,
umamiEventName: true,
umamiEventNames: true,
umamiWebsite: true,
};
const isEqualValue = (left: unknown, right: unknown) => JSON.stringify(left) === JSON.stringify(right);
type OptionDefinitionWithRules = WidgetOptionDefinition & {
shouldHide?: (options: never, integrationKinds: IntegrationKind[]) => boolean;
options?: readonly { value: unknown; label: unknown }[];
};
type OptionFormatter = (value: unknown, definition: OptionDefinitionWithRules, t: Translator) => string | null;
const resolveSelectLabel = (value: unknown, definition: OptionDefinitionWithRules, t: Translator) => {
const match = definition.options?.find((option) => option.value === value);
const label = match?.label;
const fallbackLabel = () => String(value);
const labelResolvers: Record<string, () => string> = {
function: () => (label as (translator: Translator) => string)(t),
string: () => label as string,
missing: fallbackLabel,
};
const labelType = typeof label;
const resolveLabel: () => string = labelResolvers[labelType] ?? fallbackLabel;
return resolveLabel();
};
const optionTypeFormatters: Partial<Record<WidgetOptionType, OptionFormatter>> = {
switch: (value, _, t) => {
const switchLabels: Record<string, string> = {
true: t("common.action.show"),
false: t("common.action.hide"),
};
return switchLabels[String(Boolean(value))] ?? null;
},
text: (value) => {
const text = String(value).trim();
return text.length > 0 ? text : null;
},
number: (value) => String(value),
slider: (value) => String(value),
select: (value, definition, t) => resolveSelectLabel(value, definition, t),
multiSelect: (value) => {
const items = value as unknown[];
return items.length > 0 ? String(items.length) : null;
},
multiText: (value) => {
const items = (value as string[]).filter((entry) => entry.trim().length > 0);
return items.length > 0 ? items.join(", ") : null;
},
location: (value) => {
const location = value as { name?: string };
const name = location.name?.trim() ?? "";
return name.length > 0 ? name : null;
},
};
const formatOptionValue = (
type: WidgetOptionType,
value: unknown,
definition: OptionDefinitionWithRules,
t: Translator,
) => {
const formatter = optionTypeFormatters[type];
return formatter?.(value, definition, t) ?? null;
};
const getOptionLabel = (kind: WidgetKind, key: string, t: Translator) => {
const labelKey = `widget.${kind}.option.${key}.label`;
const translated = t(labelKey);
return translated === labelKey ? key : translated;
};
export const collectWidgetHoverMetadata = (
item: SectionItem,
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
integrationKinds: IntegrationKind[],
t: Translator,
): HoverMetadataItem[] => {
const { definition } = widgetImports[item.kind];
const optionDefinitions = definition.createOptions(settings) as Record<string, OptionDefinitionWithRules>;
const resolvedOptions = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
return objectEntries(optionDefinitions)
.filter(([key, optionDefinition]) => {
const skip = skippedOptionTypes[optionDefinition.type];
const hidden = optionDefinition.shouldHide?.(resolvedOptions as never, integrationKinds);
const unchanged = isEqualValue(resolvedOptions[key], optionDefinition.defaultValue);
return !skip && !hidden && !unchanged;
})
.map(([key, optionDefinition]) => {
const formattedValue = formatOptionValue(optionDefinition.type, resolvedOptions[key], optionDefinition, t);
return {
label: getOptionLabel(item.kind, key, t),
value: formattedValue ?? "",
};
})
.filter((entry) => entry.value.length > 0)
.slice(0, maxMetadataItems);
};
interface DisplayNameSource {
matches: (item: SectionItem, options: Record<string, unknown>) => boolean;
resolve: (item: SectionItem, options: Record<string, unknown>) => string;
}
const displayNameSources: DisplayNameSource[] = [
{
matches: (item) => Boolean(item.advancedOptions.title?.trim()),
resolve: (item) => item.advancedOptions.title?.trim() ?? "",
},
{
matches: (item, options) =>
item.kind === "clock" &&
Boolean(options.customTitleToggle) &&
String(options.customTitle ?? "").trim().length > 0,
resolve: (_, options) => String(options.customTitle).trim(),
},
];
export const resolveWidgetDisplayName = (
item: SectionItem,
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
t: Translator,
) => {
const resolvedOptions = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
const source = displayNameSources.find((entry) => entry.matches(item, resolvedOptions));
return source?.resolve(item, resolvedOptions) ?? t(`widget.${item.kind}.name`);
};
@@ -1,27 +1,36 @@
import { QueryClient } from "@tanstack/react-query";
import { describe, expect, test } from "vitest";
import { removePersistedWidgetQueries } from "./widget-query-recovery";
import { removeWidgetDataQueries } from "./widget-query-recovery";
describe("removePersistedWidgetQueries", () => {
test("removes stale widget queries and allows fresh data to be fetched", async () => {
describe("removeWidgetDataQueries", () => {
test("removes only the failed widget prefix and allows fresh data to be fetched", async () => {
const queryClient = new QueryClient();
const crashedWidgetQueryKey = [["widget", "mediaServer", "getCurrentStreams"], { input: {} }];
const sameWidgetQueryKey = [["widget", "mediaServer", "getLibraries"], { input: {} }];
const unrelatedWidgetQueryKey = [["widget", "calendar", "getEvents"], { input: {} }];
const appQueryKey = [["app", "byId"], { input: {} }];
const unrelatedQueryKey = [["user", "me"], { input: {} }];
queryClient.setQueryData(crashedWidgetQueryKey, "stale");
queryClient.setQueryData(sameWidgetQueryKey, "related");
queryClient.setQueryData(unrelatedWidgetQueryKey, "other-widget");
queryClient.setQueryData(appQueryKey, "app");
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
removePersistedWidgetQueries(queryClient);
removeWidgetDataQueries(queryClient, [[["widget", "mediaServer"]]]);
expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined();
expect(queryClient.getQueryData(sameWidgetQueryKey)).toBeUndefined();
expect(queryClient.getQueryData(unrelatedWidgetQueryKey)).toBe("other-widget");
expect(queryClient.getQueryData(appQueryKey)).toBe("app");
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
await expect(
queryClient.fetchQuery({ queryKey: crashedWidgetQueryKey, queryFn: () => Promise.resolve("fresh") }),
).resolves.toBe("fresh");
});
test("removes persisted queries outside the widget router", () => {
test("uses an explicit supporting query prefix without purging other supporting data", () => {
const queryClient = new QueryClient();
const dockerQueryKey = [["docker", "getContainers"], { input: {} }];
const appQueryKey = [["app", "byId"], { input: {} }];
@@ -31,14 +40,14 @@ describe("removePersistedWidgetQueries", () => {
queryClient.setQueryData(appQueryKey, "stale");
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
removePersistedWidgetQueries(queryClient);
removeWidgetDataQueries(queryClient, [[["docker", "getContainers"]]]);
expect(queryClient.getQueryData(dockerQueryKey)).toBeUndefined();
expect(queryClient.getQueryData(appQueryKey)).toBeUndefined();
expect(queryClient.getQueryData(appQueryKey)).toBe("stale");
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
});
test("removes persisted widget queries when the widget kind and query path differ", () => {
test("uses the definition query prefix when the widget kind and query path differ", () => {
const queryClient = new QueryClient();
const crashedWidgetQueryKey = [["widget", "anchorNotes", "getNote"], { input: {} }];
const unrelatedQueryKey = [["user", "me"], { input: {} }];
@@ -46,7 +55,7 @@ describe("removePersistedWidgetQueries", () => {
queryClient.setQueryData(crashedWidgetQueryKey, "stale");
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
removePersistedWidgetQueries(queryClient);
removeWidgetDataQueries(queryClient, [[["widget", "anchorNotes"]]]);
expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined();
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
@@ -1,7 +1,5 @@
import type { QueryClient } from "@tanstack/react-query";
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import { isPersistableWidgetQueryKey } from "@homarr/api/query-cache";
export const removePersistedWidgetQueries = (queryClient: QueryClient) => {
queryClient.removeQueries({ predicate: (query) => isPersistableWidgetQueryKey(query.queryKey) });
export const removeWidgetDataQueries = (queryClient: QueryClient, queryKeys: QueryKey[]) => {
queryKeys.forEach((queryKey) => queryClient.removeQueries({ queryKey }));
};
@@ -4,6 +4,8 @@ import type { GridItemHTMLElement } from "@homarr/gridstack";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useEditMode } from "@homarr/boards/edit-mode";
import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
import { BoardItemContent } from "../items/item-content";
@@ -15,7 +17,11 @@ import { useSectionItems } from "./use-section-items";
export const SectionContent = () => {
const { innerSections, items, refs } = useSectionContext();
const { data: integrations } = clientApi.integration.all.useQuery();
const { data: session } = useSession();
const [isEditMode] = useEditMode();
const { data: integrations } = clientApi.integration.all.useQuery(undefined, {
enabled: Boolean(session) && isEditMode,
});
/**
* IMPORTANT: THE ORDER OF THE BELOW ITEMS HAS TO MATCH THE ORDER OF
@@ -5,27 +5,8 @@ import {
hiddenFromOnboarding,
integrationDefs,
integrationKinds,
getWidgetKindsForIntegration,
} from "@homarr/definitions";
import { loadAllWidgetDefinitions } from "@homarr/widgets/manifest";
const buildWidgetsByIntegration = async () => {
const map = Object.fromEntries(integrationKinds.map((kind) => [kind, [] as WidgetKind[]])) as Record<
IntegrationKind,
WidgetKind[]
>;
const definitions = await loadAllWidgetDefinitions();
for (const [widgetKind, definition] of definitions) {
const supported =
"supportedIntegrations" in definition ? (definition.supportedIntegrations as IntegrationKind[]) : [];
for (const kind of supported) {
if (kind in map) {
map[kind].push(widgetKind);
}
}
}
return map;
};
export const categoryTranslationKeys: Record<string, string> = {
dnsHole: "integration.category.dnsHole",
@@ -64,14 +45,13 @@ export interface IntegrationGridItem {
widgets: WidgetKind[];
}
export const buildSortedIntegrations = async (
export const buildSortedIntegrations = (
options: { enableMockIntegration?: boolean; onboarding?: boolean } = {},
): Promise<IntegrationGridItem[]> => {
const widgetsByIntegration = await buildWidgetsByIntegration();
): IntegrationGridItem[] => {
return integrationKinds
.filter((kind) => {
if (options.onboarding && hiddenFromOnboarding.has(kind)) return false;
if (options.onboarding && widgetsByIntegration[kind].length === 0) return false;
if (options.onboarding && getWidgetKindsForIntegration(kind).length === 0) return false;
if (!options.enableMockIntegration && kind === "mock") return false;
return true;
})
@@ -79,7 +59,7 @@ export const buildSortedIntegrations = async (
kind,
name: getIntegrationName(kind),
categories: [...new Set(integrationDefs[kind].category.flat())] as string[],
widgets: widgetsByIntegration[kind],
widgets: getWidgetKindsForIntegration(kind),
}))
.toSorted((left, right) => {
const leftIdx = featuredIntegrations.indexOf(left.kind);
@@ -1,9 +1,8 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { Badge, Card, Center, Group, Loader, Stack, Text, Tooltip } from "@mantine/core";
import { Badge, Card, Center, Group, 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";
@@ -43,14 +42,10 @@ export const IntegrationMultiSelectGrid = ({
[selectedKindsArray, onSelectionChange],
);
const {
data: integrations = [],
isPending,
isError,
} = useQuery({
queryKey: ["widget-integrations", { enableMockIntegration, onboarding }],
queryFn: () => buildSortedIntegrations({ enableMockIntegration, onboarding }),
});
const integrations = useMemo(
() => buildSortedIntegrations({ enableMockIntegration, onboarding }),
[enableMockIntegration, onboarding],
);
const sorted = useMemo(() => {
if (!detectedKinds || detectedKinds.size === 0) return integrations;
@@ -69,11 +64,6 @@ 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;
@@ -140,14 +130,7 @@ export const IntegrationMultiSelectGrid = ({
);
})}
{isError && (
<Center p="xl">
<Text c="red" role="alert">
{t("common.error")}
</Text>
</Center>
)}
{!isPending && !isError && filtered.length === 0 && (
{filtered.length === 0 && (
<Center p="xl">
<Text c="dimmed">{t("common.noResults")}</Text>
</Center>
@@ -1,9 +1,8 @@
"use client";
import { useMemo, useState } from "react";
import { Badge, Card, Center, Group, Loader, Stack, Text, Tooltip } from "@mantine/core";
import { Badge, Card, Center, Group, 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";
@@ -20,14 +19,7 @@ export const IntegrationSelectGrid = ({ onSelect, enableMockIntegration = false
const [search, setSearch] = useState("");
const t = useI18n();
const {
data: integrations = [],
isPending,
isError,
} = useQuery({
queryKey: ["widget-integrations", { enableMockIntegration }],
queryFn: () => buildSortedIntegrations({ enableMockIntegration }),
});
const integrations = useMemo(() => buildSortedIntegrations({ enableMockIntegration }), [enableMockIntegration]);
const filtered = useMemo(() => filterIntegrations(integrations, search), [integrations, search]);
return (
@@ -36,11 +28,6 @@ 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}
@@ -93,14 +80,7 @@ export const IntegrationSelectGrid = ({ onSelect, enableMockIntegration = false
</Card>
))}
{isError && (
<Center p="xl">
<Text c="red" role="alert">
{t("common.error")}
</Text>
</Center>
)}
{!isPending && !isError && filtered.length === 0 && (
{filtered.length === 0 && (
<Center p="xl">
<Text c="dimmed">{t("common.noResults")}</Text>
</Center>
+2 -2
View File
@@ -1,7 +1,6 @@
import type { ReactNode } from "react";
import { AppShellHeader, Group, UnstyledButton } from "@mantine/core";
import { Spotlight } from "@homarr/spotlight";
import { Link } from "@homarr/ui";
import { ClientBurger } from "./header/burger";
@@ -9,6 +8,7 @@ import { DesktopSearchInput, MobileSearchButton } from "./header/search";
import { TourTarget } from "./header/tour-target";
import { UserButton } from "./header/user";
import { HomarrLogoWithTitle } from "./logo/homarr-logo";
import { LazySpotlight } from "./header/lazy-spotlight";
interface Props {
logo?: ReactNode;
@@ -37,7 +37,7 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
</TourTarget>
</Group>
</Group>
<Spotlight />
<LazySpotlight />
</AppShellHeader>
);
};
@@ -14,6 +14,9 @@ type HeaderButtonProps = (
}
) & {
children: ReactNode;
onFocus?: () => void;
onPointerDown?: () => void;
onPointerEnter?: () => void;
} & Partial<ActionIconProps>;
const headerButtonActionIconProps: ActionIconProps = {
@@ -0,0 +1,89 @@
"use client";
import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import { Center, Loader, Paper } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks";
import { hotkeys } from "@homarr/definitions";
import { hasPendingSpotlightOpen, openSpotlight, spotlightOpenEvent } from "@homarr/spotlight/open";
const loadSpotlight = () => import("@homarr/spotlight/component");
const SpotlightLoading = () => (
<Center
data-homarr-dev-benchmark-spotlight-feedback
pos="fixed"
inset={0}
role="status"
aria-label="Loading search"
style={{ zIndex: 1000, background: "rgb(0 0 0 / 20%)", backdropFilter: "blur(2px)" }}
>
<Paper withBorder radius="md" p="xl" shadow="xl">
<Loader />
</Paper>
</Center>
);
const Spotlight = dynamic(() => loadSpotlight().then(({ Spotlight: Component }) => Component), {
ssr: false,
loading: SpotlightLoading,
});
export const preloadSpotlight = () => void loadSpotlight().catch(() => undefined);
export const LazySpotlight = () => {
const [isMounted, setIsMounted] = useState(false);
const [isPreloaded, setIsPreloaded] = useState(false);
useHotkeys(
isMounted
? []
: [
[
hotkeys.openSpotlight,
() => {
setIsMounted(true);
openSpotlight();
},
],
],
);
useEffect(() => {
let isActive = true;
const mount = () => setIsMounted(true);
window.addEventListener(spotlightOpenEvent, mount);
if (hasPendingSpotlightOpen()) mount();
const preload = () => {
void loadSpotlight().then(
() => {
if (isActive) setIsPreloaded(true);
},
() => undefined,
);
};
let idleId: number | undefined;
const delayId = window.setTimeout(() => {
idleId = window.requestIdleCallback?.(preload, { timeout: 1_000 });
if (idleId === undefined) preload();
}, 1_000);
return () => {
isActive = false;
window.removeEventListener(spotlightOpenEvent, mount);
window.clearTimeout(delayId);
if (idleId !== undefined) window.cancelIdleCallback?.(idleId);
};
}, []);
return isMounted || isPreloaded ? (
<>
{isPreloaded && <span hidden data-homarr-dev-benchmark-spotlight-preloaded />}
{isMounted && (
<>
<span hidden data-homarr-dev-benchmark-spotlight-mounted />
<Spotlight />
</>
)}
</>
) : null;
};
@@ -3,11 +3,12 @@
import { TextInput, UnstyledButton } from "@mantine/core";
import { IconSearch } from "@tabler/icons-react";
import { openSpotlight } from "@homarr/spotlight";
import { openSpotlight } from "@homarr/spotlight/open";
import { useI18n } from "@homarr/translation/client";
import { HeaderButton } from "./button";
import classes from "./search.module.css";
import { preloadSpotlight } from "./lazy-spotlight";
export const DesktopSearchInput = () => {
const t = useI18n();
@@ -21,6 +22,9 @@ export const DesktopSearchInput = () => {
size="sm"
leftSection={<IconSearch size={20} stroke={1.5} />}
onClick={openSpotlight}
onFocus={preloadSpotlight}
onPointerDown={preloadSpotlight}
onPointerEnter={preloadSpotlight}
radius="xl"
>
{`${t("search.placeholder")}...`}
@@ -33,6 +37,9 @@ export const MobileSearchButton = () => {
<HeaderButton
data-homarr-dev-benchmark-interaction="search"
onClick={openSpotlight}
onFocus={preloadSpotlight}
onPointerDown={preloadSpotlight}
onPointerEnter={preloadSpotlight}
className={classes.mobileSearch}
>
<IconSearch size={20} stroke={1.5} />
@@ -0,0 +1,71 @@
import type { ReactNode } from "react";
import { act, createElement } from "react";
import { createRoot } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { TourTarget, TourTargetsProvider } from "./tour-target";
vi.mock("@gfazioli/mantine-onboarding-tour", () => ({
OnboardingTour: {
Target: ({ children }: { children: ReactNode }) => children,
},
}));
const mountedRoots: Array<ReturnType<typeof createRoot>> = [];
beforeAll(() => {
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { value: true, writable: true });
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => undefined,
removeListener: () => undefined,
addEventListener: () => undefined,
removeEventListener: () => undefined,
dispatchEvent: () => false,
}),
});
});
afterEach(async () => {
await act(async () => {
for (const root of mountedRoots.splice(0)) root.unmount();
});
});
const renderTargetAsync = async (enabled: boolean) => {
const host = document.createElement("div");
const root = createRoot(host);
mountedRoots.push(root);
await act(async () => {
root.render(
createElement(
MantineProvider,
null,
createElement(TourTargetsProvider, {
enabled,
children: createElement(TourTarget, { id: "example", children: createElement("button", null, "Target") }),
}),
),
);
});
return host;
};
describe("TourTarget", () => {
it("keeps a stable route-transition marker while a tour is active", async () => {
const host = await renderTargetAsync(true);
expect(host.querySelector('[data-tour-target="example"] button')?.textContent).toBe("Target");
});
it("adds no wrapper for users without an active tour", async () => {
const host = await renderTargetAsync(false);
expect(host.querySelector("[data-tour-target]")).toBeNull();
expect(host.querySelector("button")?.parentElement).toBe(host);
});
});
@@ -1,11 +1,41 @@
"use client";
import type { ReactNode } from "react";
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
import type { PropsWithChildren, ReactNode } from "react";
import { createContext, Suspense, use, useContext } from "react";
import { Box } from "@mantine/core";
export const TourTarget = ({ id, children }: { id: string; children: ReactNode }) => (
<OnboardingTour.Target id={id}>
<Box data-tour-target={id}>{children}</Box>
</OnboardingTour.Target>
const TourTargetsEnabledContext = createContext(false);
let tourLibraryPromise: Promise<typeof import("@gfazioli/mantine-onboarding-tour")> | undefined;
const loadTourLibrary = () => {
if (tourLibraryPromise) return tourLibraryPromise;
const currentPromise = import("@gfazioli/mantine-onboarding-tour");
tourLibraryPromise = currentPromise;
void currentPromise.catch(() => {
if (tourLibraryPromise === currentPromise) tourLibraryPromise = undefined;
});
return currentPromise;
};
export const TourTargetsProvider = ({ enabled = true, children }: PropsWithChildren<{ enabled?: boolean }>) => (
<TourTargetsEnabledContext value={enabled}>{children}</TourTargetsEnabledContext>
);
const LoadedTourTarget = ({ id, children }: { id: string; children: ReactNode }) => {
const { OnboardingTour } = use(loadTourLibrary());
return <OnboardingTour.Target id={id}>{children}</OnboardingTour.Target>;
};
export const TourTarget = ({ id, children }: { id: string; children: ReactNode }) => {
const isEnabled = useContext(TourTargetsEnabledContext);
if (!isEnabled) return children;
const target = <Box data-tour-target={id}>{children}</Box>;
return (
<Suspense fallback={target}>
<LoadedTourTarget id={id}>{target}</LoadedTourTarget>
</Suspense>
);
};
@@ -1,7 +1,6 @@
"use client";
import type { PropsWithChildren } from "react";
import { Suspense, use } from "react";
import { Indicator, Menu, Text } from "@mantine/core";
import { IconBellRinging } from "@tabler/icons-react";
@@ -9,67 +8,28 @@ import type { RouterOutputs } from "@homarr/api";
import { useScopedI18n } from "@homarr/translation/client";
interface UpdateIndicatorProps extends PropsWithChildren {
availableUpdatesPromise: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]> | undefined;
availableUpdates: RouterOutputs["updateChecker"]["getAvailableUpdates"] | undefined;
disabled: boolean;
}
export const UpdateIndicator = ({ children, availableUpdatesPromise, disabled }: UpdateIndicatorProps) => {
if (disabled || availableUpdatesPromise === undefined) {
export const UpdateIndicator = ({ children, availableUpdates, disabled }: UpdateIndicatorProps) => {
if (disabled || availableUpdates === undefined) {
return children;
}
return (
<Suspense fallback={children}>
<InnerUpdateIndicator availableUpdatesPromise={availableUpdatesPromise} disabled={disabled}>
{children}
</InnerUpdateIndicator>
</Suspense>
);
};
interface InnerUpdateIndicatorProps extends PropsWithChildren {
availableUpdatesPromise: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]>;
disabled: boolean;
}
const InnerUpdateIndicator = ({ children, disabled, availableUpdatesPromise }: InnerUpdateIndicatorProps) => {
const availableUpdates = use(availableUpdatesPromise);
return (
<Indicator
disabled={!availableUpdates || availableUpdates.length === 0 || disabled}
size={15}
processing
withBorder
>
<Indicator disabled={availableUpdates.length === 0} size={15} processing withBorder>
{children}
</Indicator>
);
};
interface AvailableUpdatesMenuItemProps {
availableUpdatesPromise: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]> | undefined;
availableUpdates: RouterOutputs["updateChecker"]["getAvailableUpdates"] | undefined;
}
export const AvailableUpdatesMenuItem = ({ availableUpdatesPromise }: AvailableUpdatesMenuItemProps) => {
if (availableUpdatesPromise === undefined) {
return null;
}
return (
<Suspense fallback={null}>
<InnerAvailableUpdatesMenuItem availableUpdatesPromise={availableUpdatesPromise} />
</Suspense>
);
};
interface InnerAvailableUpdatesMenuItemProps {
availableUpdatesPromise: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]>;
}
const InnerAvailableUpdatesMenuItem = ({ availableUpdatesPromise }: InnerAvailableUpdatesMenuItemProps) => {
export const AvailableUpdatesMenuItem = ({ availableUpdates }: AvailableUpdatesMenuItemProps) => {
const t = useScopedI18n("common.userAvatar.menu");
const availableUpdates = use(availableUpdatesPromise);
if (availableUpdates === undefined || availableUpdates.length === 0) {
return null;
}
@@ -0,0 +1,125 @@
"use client";
import type { ComponentProps, ReactNode } from "react";
import { Suspense, use, useEffect, useState } from "react";
import { Box, Loader, UnstyledButton, useMantineColorScheme } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks";
import { ErrorBoundary } from "react-error-boundary";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { hotkeys } from "@homarr/definitions";
import { UpdateIndicator } from "./update";
type UserAvatarMenuModule = typeof import("~/components/user-avatar-menu");
type UserAvatarMenuProps = ComponentProps<UserAvatarMenuModule["UserAvatarMenu"]>;
let userAvatarMenuPromise: Promise<UserAvatarMenuModule> | undefined;
const loadUserAvatarMenu = () => {
if (userAvatarMenuPromise) return userAvatarMenuPromise;
const promise = import("~/components/user-avatar-menu");
userAvatarMenuPromise = promise;
void promise.catch(() => {
if (userAvatarMenuPromise === promise) userAvatarMenuPromise = undefined;
});
return promise;
};
const preloadUserAvatarMenu = () => void loadUserAvatarMenu().catch(() => undefined);
const LazyUserAvatarMenu = (props: UserAvatarMenuProps) => {
const { UserAvatarMenu } = use(loadUserAvatarMenu());
return <UserAvatarMenu {...props} />;
};
interface UserButtonClientProps {
avatar: ReactNode;
isAdmin: boolean;
isDockerEnabled: boolean;
}
export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButtonClientProps) => {
const [canCheckForUpdates, setCanCheckForUpdates] = useState(false);
const [isMenuMounted, setIsMenuMounted] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const session = useSession();
const { toggleColorScheme } = useMantineColorScheme();
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
const isCurrentSessionAdmin =
isAdmin && session.status === "authenticated" && session.data.user.permissions.includes("admin");
useEffect(() => {
if (!isCurrentSessionAdmin) {
setCanCheckForUpdates(false);
return;
}
const timeout = window.setTimeout(() => setCanCheckForUpdates(true), 2_000);
return () => window.clearTimeout(timeout);
}, [isCurrentSessionAdmin]);
const { data: availableUpdates } = clientApi.updateChecker.getAvailableUpdates.useQuery(undefined, {
enabled: canCheckForUpdates,
refetchOnWindowFocus: false,
retry: false,
staleTime: 60 * 60 * 1_000,
});
const visibleUpdates = isCurrentSessionAdmin ? availableUpdates : undefined;
const openMenu = () => {
setIsMenuMounted(true);
setIsMenuOpen(true);
};
const renderButton = (onClick: () => void, isLoading = false) => (
<UnstyledButton
onClick={onClick}
onFocus={preloadUserAvatarMenu}
onMouseEnter={preloadUserAvatarMenu}
onPointerDown={preloadUserAvatarMenu}
aria-busy={isLoading || undefined}
disabled={isLoading}
>
<UpdateIndicator availableUpdates={visibleUpdates} disabled={!isCurrentSessionAdmin}>
<Box pos="relative" style={{ display: "inline-flex" }}>
<Box opacity={isLoading ? 0.35 : 1}>{avatar}</Box>
{isLoading && (
<Loader
aria-label="Loading user menu"
size={18}
pos="absolute"
top="50%"
left="50%"
style={{ transform: "translate(-50%, -50%)" }}
/>
)}
</Box>
</UpdateIndicator>
</UnstyledButton>
);
const button = renderButton(openMenu);
const loadingButton = renderButton(() => undefined, true);
if (!isMenuMounted) return button;
return (
<ErrorBoundary
fallbackRender={({ resetErrorBoundary }) =>
renderButton(() => {
resetErrorBoundary();
openMenu();
})
}
>
<Suspense fallback={loadingButton}>
<LazyUserAvatarMenu
availableUpdates={visibleUpdates}
isDockerEnabled={isDockerEnabled}
opened={isMenuOpen}
onOpenChange={setIsMenuOpen}
>
{button}
</LazyUserAvatarMenu>
</Suspense>
</ErrorBoundary>
);
};
@@ -1,28 +1,14 @@
import { Suspense } from "react";
import { UnstyledButton } from "@mantine/core";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { env } from "@homarr/docker/env";
import { CurrentUserAvatar } from "~/components/user-avatar";
import { UserAvatarMenu } from "~/components/user-avatar-menu";
import { UpdateIndicator } from "./update";
import { UserButtonClient } from "./user-client";
export const UserButton = async () => {
const session = await auth();
const isAdmin = session?.user.permissions.includes("admin");
const isAdmin = Boolean(session?.user.permissions.includes("admin"));
const isDockerEnabled = isAdmin && env.ENABLE_DOCKER;
const availableUpdatesPromise = isAdmin ? api.updateChecker.getAvailableUpdates() : undefined;
return (
<UserAvatarMenu availableUpdatesPromise={availableUpdatesPromise} isDockerEnabled={isDockerEnabled}>
<UnstyledButton>
<Suspense fallback={<CurrentUserAvatar size="md" />}>
<UpdateIndicator availableUpdatesPromise={availableUpdatesPromise} disabled={!isAdmin}>
<CurrentUserAvatar size="md" />
</UpdateIndicator>
</Suspense>
</UnstyledButton>
</UserAvatarMenu>
<UserButtonClient avatar={<CurrentUserAvatar size="md" />} isAdmin={isAdmin} isDockerEnabled={isDockerEnabled} />
);
};
@@ -1,20 +1,19 @@
"use client";
import type { ReactNode } from "react";
import type { ReactElement, ReactNode } from "react";
import { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
import { NavLink } from "@mantine/core";
import { Link } from "@homarr/ui";
import { TourTarget } from "./header/tour-target";
export const CommonNavLink = (props: ClientNavigationLink) =>
"href" in props ? <NavLinkHref {...props} /> : <NavLinkWithItems {...props} />;
const TourTarget = ({ id, children }: { id?: string; children: ReactNode }) => {
if (!id) return <>{children}</>;
return <OnboardingTour.Target id={id}>{children}</OnboardingTour.Target>;
};
const withOptionalTourTarget = (id: string | undefined, children: ReactElement) =>
id ? <TourTarget id={id}>{children}</TourTarget> : children;
const pathMatches = (pathname: string, href: string) => pathname === href || pathname.startsWith(`${href}/`);
@@ -43,7 +42,7 @@ const NavLinkHref = (props: NavigationLinkHref) => {
active={isClient && pathMatches(pathname, props.href)}
/>
);
return <TourTarget id={tourId}>{link}</TourTarget>;
return withOptionalTourTarget(tourId, link);
};
const NavLinkWithItems = (props: NavigationLinkWithItems) => {
@@ -56,7 +55,7 @@ const NavLinkWithItems = (props: NavigationLinkWithItems) => {
))}
</NavLink>
);
return <TourTarget id={props["data-onboarding-tour-id"]}>{nav}</TourTarget>;
return withOptionalTourTarget(props["data-onboarding-tour-id"], nav);
};
interface CommonNavigationLinkProps {
@@ -0,0 +1,11 @@
import type { PropsWithChildren } from "react";
export const BoardTourGate = async ({ enabled, children }: PropsWithChildren<{ enabled: boolean }>) => {
if (!enabled) return children;
// Native server-side import keeps the completed-user client graph lean while
// giving first-time users one stable board tree instead of swapping a
// Suspense fallback after GridStack has mounted.
const { BoardTourProvider } = await import("./board-tour");
return <BoardTourProvider>{children}</BoardTourProvider>;
};
@@ -1,7 +1,7 @@
"use client";
import type { PropsWithChildren } from "react";
import { useMemo } from "react";
import { useMemo, useState } from "react";
import type { OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour";
import { clientApi } from "@homarr/api/client";
@@ -10,6 +10,7 @@ import { createDocumentationLink } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { useBoardPermissions } from "~/components/board/permissions/client";
import { TourTargetsProvider } from "~/components/layout/header/tour-target";
import { TourShell } from "./tour-shell";
import { TourStepContent } from "./tour-step-content";
@@ -17,21 +18,11 @@ export const BoardTourProvider = ({ children }: PropsWithChildren) => {
const t = useScopedI18n("onboardingTour.board");
const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board);
const utils = clientApi.useUtils();
const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
const { mutate: completeTour } = clientApi.user.completeTour.useMutation({
onSuccess() {
void utils.user.getTourStatus.invalidate();
},
});
const started = tourStatus !== undefined && !tourStatus.completedBoardTour;
const [started, setStarted] = useState(true);
const { mutate: completeTour } = clientApi.user.completeTour.useMutation();
const handleEnd = () => {
utils.user.getTourStatus.setData(undefined, {
completedManageTour: tourStatus?.completedManageTour ?? false,
completedBoardTour: true,
});
setStarted(false);
completeTour({ tour: "board" });
};
@@ -101,8 +92,10 @@ export const BoardTourProvider = ({ children }: PropsWithChildren) => {
}, [hasChangeAccess, t]);
return (
<TourShell steps={steps} started={started} onEnd={handleEnd} position={{ base: "bottom", sm: "left" }}>
{children}
</TourShell>
<TourTargetsProvider enabled={started}>
<TourShell steps={steps} started={started} onEnd={handleEnd} position={{ base: "bottom", sm: "left" }}>
{children}
</TourShell>
</TourTargetsProvider>
);
};
@@ -0,0 +1,12 @@
import type { PropsWithChildren } from "react";
export const ManageTourGate = async ({
enabled,
isAdmin,
children,
}: PropsWithChildren<{ enabled: boolean; isAdmin: boolean }>) => {
if (!enabled) return children;
const { ManageTourProvider } = await import("./manage-tour");
return <ManageTourProvider isAdmin={isAdmin}>{children}</ManageTourProvider>;
};
@@ -1,7 +1,7 @@
"use client";
import type { PropsWithChildren } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import type { OnboardingTourFocusRevealProps, OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour";
import { useMediaQuery } from "@mantine/hooks";
@@ -17,6 +17,7 @@ import { clientApi } from "@homarr/api/client";
import { createDocumentationLink } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { TourTargetsProvider } from "~/components/layout/header/tour-target";
import { TourShell } from "./tour-shell";
import { TourStepContent } from "./tour-step-content";
@@ -46,13 +47,7 @@ const usersStepFocusRevealProps: OnboardingTourFocusRevealProps = {
export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProps) => {
const t = useScopedI18n("onboardingTour.manage");
const utils = clientApi.useUtils();
const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
const { mutate: completeTour } = clientApi.user.completeTour.useMutation({
onSuccess() {
void utils.user.getTourStatus.invalidate();
},
});
const { mutate: completeTour } = clientApi.user.completeTour.useMutation();
const isMobile = useMediaQuery("(max-width: 48em)");
const pathname = usePathname();
const router = useRouter();
@@ -62,14 +57,13 @@ export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProp
const [tourActive, setTourActive] = useState(false);
const [tourDismissed, setTourDismissed] = useState(false);
const wasManageTourCompletedRef = useRef(tourStatus?.completedManageTour);
useEffect(() => {
if (tourDismissed) return;
if (isManageHome && tourStatus !== undefined && !tourStatus.completedManageTour && !isMobile) {
if (isManageHome && !isMobile) {
setTourActive(true);
}
}, [isManageHome, tourStatus, isMobile, tourDismissed]);
}, [isManageHome, isMobile, tourDismissed]);
useEffect(() => {
if (!isManageSection) {
@@ -77,25 +71,12 @@ export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProp
}
}, [isManageSection]);
useEffect(() => {
const wasCompleted = wasManageTourCompletedRef.current;
const isCompleted = tourStatus?.completedManageTour;
if (wasCompleted === true && isCompleted === false) {
setTourDismissed(false);
}
wasManageTourCompletedRef.current = isCompleted;
}, [tourStatus?.completedManageTour]);
const handleEnd = useCallback(() => {
setTourDismissed(true);
setTourActive(false);
utils.user.getTourStatus.setData(undefined, {
completedManageTour: true,
completedBoardTour: tourStatus?.completedBoardTour ?? false,
});
completeTour({ tour: "manage" });
router.push("/manage");
}, [completeTour, router, tourStatus?.completedBoardTour, utils.user.getTourStatus]);
}, [completeTour, router]);
const steps = useMemo(() => {
const allSteps: (OnboardingTourStep & { adminOnly?: boolean })[] = [
@@ -207,14 +188,16 @@ export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProp
}, [isAdmin, t]);
return (
<TourShell
steps={steps}
started={tourActive}
onEnd={handleEnd}
stepRoutes={stepRoutes}
position={{ base: "bottom", sm: "right" }}
>
{children}
</TourShell>
<TourTargetsProvider enabled={tourActive}>
<TourShell
steps={steps}
started={tourActive}
onEnd={handleEnd}
stepRoutes={stepRoutes}
position={{ base: "bottom", sm: "right" }}
>
{children}
</TourShell>
</TourTargetsProvider>
);
};
+19 -66
View File
@@ -1,24 +1,13 @@
"use client";
import type { ReactNode } from "react";
import { useCallback, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Center, Menu, Stack, Text, useMantineColorScheme } from "@mantine/core";
import { useHotkeys, useTimeout } from "@mantine/hooks";
import {
IconBrandDocker,
IconCheck,
IconHome,
IconLogin,
IconLogout,
IconSettings,
IconTool,
} from "@tabler/icons-react";
import { useCallback } from "react";
import { Menu } from "@mantine/core";
import { IconBrandDocker, IconHome, IconLogin, IconLogout, IconSettings, IconTool } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { signOut, useSession } from "@homarr/auth/client";
import { hotkeys } from "@homarr/definitions";
import { createModal, useModalAction } from "@homarr/modals";
import { useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
@@ -30,42 +19,38 @@ import { AvailableUpdatesMenuItem } from "./layout/header/update";
interface UserAvatarMenuProps {
children: ReactNode;
availableUpdatesPromise?: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]>;
availableUpdates?: RouterOutputs["updateChecker"]["getAvailableUpdates"];
isDockerEnabled?: boolean;
opened: boolean;
onOpenChange: (opened: boolean) => void;
}
export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnabled }: UserAvatarMenuProps) => {
export const UserAvatarMenu = ({
children,
availableUpdates,
isDockerEnabled,
opened,
onOpenChange,
}: UserAvatarMenuProps) => {
const t = useScopedI18n("common.userAvatar.menu");
const { toggleColorScheme } = useMantineColorScheme();
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
const session = useSession();
const router = useRouter();
const { logoutUrl } = useAuthContext();
const { openModal } = useModalAction(LogoutModal);
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
const handleSignout = useCallback(async () => {
const redirectUrl = logoutUrl ?? "/auth/login";
await signOut({
redirect: false,
});
openModal({
onTimeout: () => {
if (logoutUrl) {
window.location.assign(logoutUrl);
return;
}
router.push("/auth/login");
},
});
}, [logoutUrl, openModal, router]);
window.location.assign(redirectUrl);
}, [logoutUrl]);
return (
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
<Menu width={300} withinPortal keepMounted>
<Menu width={300} withinPortal keepMounted opened={opened} onChange={onOpenChange}>
<Menu.Dropdown>
<AvailableUpdatesMenuItem availableUpdatesPromise={availableUpdatesPromise} />
<AvailableUpdatesMenuItem availableUpdates={availableUpdates} />
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
{t("homeBoard")}
</Menu.Item>
@@ -113,35 +98,3 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnab
</Menu>
);
};
const LogoutModal = createModal<{ onTimeout: () => void }>(({ actions, innerProps }) => {
const t = useScopedI18n("common.userAvatar.menu");
const { start } = useTimeout(() => {
actions.closeModal();
innerProps.onTimeout();
}, 1500);
useEffect(() => {
start();
}, [start]);
return (
<Center h={200 - 2 * 16}>
<Stack align="center" c="green">
<IconCheck size={50} />
<Text ta="center" fw="bold">
{t("loggedOut")}
</Text>
</Stack>
</Center>
);
}).withOptions({
centered: true,
withCloseButton: false,
transitionProps: {
transition: "pop",
},
size: 200,
closeOnClickOutside: false,
closeOnEscape: false,
});