feat: modernize dashboard widgets

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-13 19:54:28 +02:00
1 parent 1a5e083fd0
commit 6f2cd7fc2b
270 files changed
+10189 -3544

No files matched your search

@@ -1,7 +1,7 @@
import { describe, expect, test } from "vitest";
import type { Item } from "~/app/[locale]/boards/_types";
import { getFirstEmptyPosition } from "../empty-position";
import { getFirstEmptyPosition, getNearestEmptyPosition } from "../empty-position";
describe("get first empty position", () => {
test.each([
@@ -106,6 +106,37 @@ describe("get first empty position", () => {
expect(result).toEqual(expectedX !== undefined ? { xOffset: expectedX, yOffset: expectedY } : undefined);
});
test("does not place a wide item beyond the right edge", () => {
const elements = createElementsFromLayout([["a", "a", "a", " "]]);
expect(getFirstEmptyPosition(elements, 4, undefined, { width: 2, height: 1 })).toEqual({
xOffset: 0,
yOffset: 1,
});
});
});
describe("get nearest empty position", () => {
test("keeps the preferred cell when it fits", () => {
expect(getNearestEmptyPosition([], 4, 4, { xOffset: 2, yOffset: 1 }, { width: 1, height: 1 })).toEqual({
xOffset: 2,
yOffset: 1,
});
});
test("chooses the nearest available cell when the preferred cell is occupied", () => {
const elements = [{ xOffset: 2, yOffset: 1, width: 1, height: 1 }];
expect(getNearestEmptyPosition(elements, 4, 4, { xOffset: 2, yOffset: 1 }, { width: 1, height: 1 })).toEqual({
xOffset: 2,
yOffset: 0,
});
});
test("returns undefined when the item cannot fit within a bounded section", () => {
expect(getNearestEmptyPosition([], 1, 1, { xOffset: 0, yOffset: 0 }, { width: 2, height: 1 })).toBeUndefined();
});
});
const createElementsFromLayout = (layout: string[][]) => {
@@ -3,6 +3,7 @@ import { useCallback } from "react";
import { useUpdateBoard } from "@homarr/boards/updater";
import type { BoardItemAdvancedOptions } from "@homarr/validation/shared";
import type { Board } from "~/app/[locale]/boards/_types";
import type { CreateItemInput } from "./actions/create-item";
import { createItemCallback } from "./actions/create-item";
import type { DuplicateItemInput } from "./actions/duplicate-item";
@@ -34,7 +35,12 @@ export const useItemActions = () => {
const createItem = useCallback(
(input: CreateItemInput) => {
updateBoard(createItemCallback(input));
let nextBoard: Board | undefined;
updateBoard((previous) => {
nextBoard = createItemCallback(input)(previous);
return nextBoard;
});
return nextBoard;
},
[updateBoard],
);
@@ -1,9 +1,10 @@
import type { ComponentType, MutableRefObject } from "react";
import { Suspense, use, useRef } from "react";
import type { ComponentType, CSSProperties, KeyboardEvent, MutableRefObject } from "react";
import { memo, Suspense, use, useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import dynamic from "next/dynamic";
import type { CardProps } from "@mantine/core";
import { Badge, Card, Center, Loader } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { Badge, Box, Card, Center, Loader, Portal } from "@mantine/core";
import { useElementSize, useIsomorphicEffect, useViewportSize } from "@mantine/hooks";
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
import combineClasses from "clsx";
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
@@ -13,12 +14,23 @@ 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 { useI18n } from "@homarr/translation/client";
import { WidgetError } from "@homarr/widgets/errors";
import { getWidgetQueryKeys } from "@homarr/widgets/definition";
import type { WidgetComponentProps, WidgetDefinition } from "@homarr/widgets/definition";
import {
createWidgetRuntimeState,
getWidgetQueryKeys,
supportsAdvancedFocus as definitionSupportsAdvancedFocus,
} from "@homarr/widgets/definition";
import type { WidgetComponentProps, WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition";
import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import advancedFocusClasses from "../advanced-focus/advanced-focus.module.css";
import { useAdvancedFocus } from "../advanced-focus/context";
import { startAdvancedFocusEntrance } from "../advanced-focus/entrance";
import { getAdvancedFocusClosePosition, getAdvancedFocusRect } from "../advanced-focus/geometry";
import { AdvancedFocusManualSurface } from "../advanced-focus/manual-surface";
import { redirectShiftWheel } from "../advanced-focus/wheel";
import classes from "../sections/item.module.css";
import { useItemActions } from "./item-actions";
import itemContentClasses from "./item-content.module.css";
@@ -92,38 +104,305 @@ const WidgetDefinitionLoadError = ({
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
const { ref, width, height } = useElementSize<HTMLDivElement>();
const board = useRequiredBoard();
const widgetStateRef = useRef<Record<string, unknown> | null>(null);
const widgetRuntimeRef = useRef(createWidgetRuntimeState());
return (
<>
<ErrorBoundary
resetKeys={[item.kind]}
fallbackRender={(fallbackProps) => <WidgetDefinitionLoadError {...fallbackProps} item={item} innerRef={ref} />}
<ErrorBoundary
resetKeys={[item.kind]}
fallbackRender={(fallbackProps) => <WidgetDefinitionLoadError {...fallbackProps} item={item} innerRef={ref} />}
>
<Suspense
fallback={
<BoardItemCard item={item} innerRef={ref}>
<Center h="100%">
<Loader size="sm" />
</Center>
</BoardItemCard>
}
>
<Suspense
fallback={
<BoardItemCard item={item} innerRef={ref}>
<Center h="100%">
<Loader size="sm" />
</Center>
</BoardItemCard>
}
<LoadedBoardItemContent
item={item}
width={width}
height={height}
widgetStateRef={widgetStateRef}
widgetRuntimeRef={widgetRuntimeRef}
contentRef={ref}
/>
</Suspense>
</ErrorBoundary>
);
};
interface LoadedBoardItemContentProps {
item: SectionItem;
width: number;
height: number;
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
widgetRuntimeRef: WidgetRuntimeRef;
contentRef: (element: HTMLDivElement | null) => void;
}
const LoadedBoardItemContent = ({
item,
width,
height,
widgetStateRef,
widgetRuntimeRef,
contentRef,
}: LoadedBoardItemContentProps) => {
const { definition, Component } = use(loadWidgetResources(item.kind));
const sourceRef = useRef<HTMLDivElement>(null);
const cardRef = useRef<HTMLDivElement>(null);
const previewEntranceFrameRef = useRef<number | null>(null);
const { width: viewportWidth, height: viewportHeight } = useViewportSize();
const board = useRequiredBoard();
const t = useI18n();
const [isEditMode] = useEditMode();
const [manualSurface, setManualSurface] = useState<HTMLDivElement | null>(null);
const [surfacePortalTarget, setSurfacePortalTarget] = useState<HTMLDivElement | null>(null);
const { active, open, close, dismiss, hover, leave } = useAdvancedFocus();
const supportsAdvancedFocus = definitionSupportsAdvancedFocus(definition);
const widgetName = t(`widget.${item.kind}.name`);
const advancedViewLabel = t("item.advancedFocus.label", { widget: widgetName });
const advancedViewId = `advanced-focus-${item.id}`;
const activeFocus = supportsAdvancedFocus && active?.itemId === item.id ? active : null;
const isAdvanced = activeFocus !== null;
const advancedRect = activeFocus
? getAdvancedFocusRect(activeFocus.sourceRect, { width: viewportWidth, height: viewportHeight })
: null;
const closePosition = advancedRect ? getAdvancedFocusClosePosition(advancedRect, viewportWidth) : null;
const isPreview = activeFocus?.activation === "preview";
const isManual = activeFocus?.activation === "manual";
useIsomorphicEffect(() => {
if (!supportsAdvancedFocus) return;
const target = document.createElement("div");
target.className = advancedFocusClasses.surfacePortalTarget ?? "";
setSurfacePortalTarget(target);
return () => target.remove();
}, [supportsAdvancedFocus]);
useEffect(() => () => dismiss(item.id), [dismiss, item.id]);
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (
event.currentTarget !== event.target ||
event.key !== "Enter" ||
!event.shiftKey ||
isEditMode ||
!supportsAdvancedFocus
)
return;
event.preventDefault();
if (sourceRef.current) open(item.id, sourceRef.current, { activation: "manual", autofocusClose: true });
};
useEffect(() => {
const surface = isManual ? manualSurface : cardRef.current;
if (!isAdvanced || !surface) return;
const handleWheel = (event: WheelEvent) => {
if (!event.shiftKey) return;
const multiplier = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? surface.clientHeight : 1;
const delta = (event.deltaY || event.deltaX) * multiplier;
if (!redirectShiftWheel(surface, event.target, delta)) return;
event.preventDefault();
event.stopPropagation();
};
surface.addEventListener("wheel", handleWheel, { capture: true, passive: false });
return () => surface.removeEventListener("wheel", handleWheel, { capture: true });
}, [isAdvanced, isManual, manualSurface]);
const cancelPreviewEntrance = useCallback(() => {
if (previewEntranceFrameRef.current === null) return;
cancelAnimationFrame(previewEntranceFrameRef.current);
previewEntranceFrameRef.current = null;
}, []);
const mountCompactHost = useCallback(
(host: HTMLDivElement | null) => {
if (!host || !surfacePortalTarget || isAdvanced) return;
cancelPreviewEntrance();
host.append(surfacePortalTarget);
},
[cancelPreviewEntrance, isAdvanced, surfacePortalTarget],
);
const mountPreviewHost = useCallback(
(host: HTMLDivElement | null) => {
if (!host || !surfacePortalTarget || !isPreview) {
if (!host) cancelPreviewEntrance();
return;
}
if (activeFocus?.phase !== "visible") {
host.append(surfacePortalTarget);
return;
}
const card = cardRef.current;
const readyClass = advancedFocusClasses.surfaceReady;
if (!card || !readyClass) return;
cancelPreviewEntrance();
previewEntranceFrameRef.current = startAdvancedFocusEntrance(surfacePortalTarget, host, card, readyClass);
},
[activeFocus?.phase, cancelPreviewEntrance, isPreview, surfacePortalTarget],
);
const mountManualHost = useCallback(
(host: HTMLDivElement | null) => {
if (!host || !surfacePortalTarget || !isManual) return;
cancelPreviewEntrance();
host.append(surfacePortalTarget);
},
[cancelPreviewEntrance, isManual, surfacePortalTarget],
);
useEffect(() => cancelPreviewEntrance, [cancelPreviewEntrance]);
const previewStyle =
isPreview && advancedRect && activeFocus
? ({
left: advancedRect.left,
top: advancedRect.top,
width: advancedRect.width,
height: advancedRect.height,
"--focus-translate-x": `${activeFocus.sourceRect.left - advancedRect.left}px`,
"--focus-translate-y": `${activeFocus.sourceRect.top - advancedRect.top}px`,
"--focus-scale-x": advancedRect.width > 0 ? activeFocus.sourceRect.width / advancedRect.width : 1,
"--focus-scale-y": advancedRect.height > 0 ? activeFocus.sourceRect.height / advancedRect.height : 1,
} as CSSProperties)
: undefined;
const widgetCard = (
<Card
ref={cardRef}
id={isPreview ? advancedViewId : undefined}
role={isPreview ? "region" : undefined}
aria-label={isPreview ? advancedViewLabel : undefined}
data-advanced-focus-surface={isPreview || undefined}
data-grid-item-content
radius={board.itemRadius}
p={isAdvanced ? undefined : 0}
h={isManual ? "100%" : isAdvanced ? undefined : "100%"}
w={isManual ? "100%" : isAdvanced ? undefined : "100%"}
className={combineClasses(
classes.itemCard,
`${item.kind}-wrapper`,
"board-grid-item-content",
isPreview && advancedFocusClasses.surface,
activeFocus?.phase === "closing" && isPreview && advancedFocusClasses.surfaceClosing,
item.advancedOptions.customCssClasses.join(" "),
)}
styles={{
root: {
"--opacity": isAdvanced ? 0.98 : board.opacity / 100,
containerType: "size",
...getOverflowFromKind(item.kind, item.advancedOptions.customCssClasses.length > 0),
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
},
}}
style={previewStyle}
>
<Box ref={contentRef} w="100%" h="100%" mih={0}>
<ErrorBoundary
resetKeys={[item.kind]}
fallbackRender={({ resetErrorBoundary, error }) => (
<>
{isEditMode && (
<BoardItemMenu item={item} definition={definition} resetErrorBoundary={resetErrorBoundary} />
)}
<div
className={itemContentClasses.editModeInertContent}
data-board-grid-inert-content={isEditMode ? "true" : undefined}
inert={isEditMode}
>
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
</div>
</>
)}
>
<LoadedBoardItemContent
<InnerContent
item={item}
width={width}
height={height}
widgetStateRef={widgetStateRef}
innerRef={ref}
widgetRuntimeRef={widgetRuntimeRef}
displayMode={isAdvanced ? "advanced" : "compact"}
definition={definition}
Component={Component}
/>
</Suspense>
</ErrorBoundary>
{item.advancedOptions.title?.trim() && (
</ErrorBoundary>
</Box>
</Card>
);
return (
<>
<WidgetContextMenu
item={item}
definition={definition}
widgetStateRef={widgetStateRef}
widgetRuntimeRef={widgetRuntimeRef}
sourceRef={sourceRef}
>
<Box
ref={sourceRef}
w="100%"
h="100%"
data-advanced-focus-source
data-item-id={item.id}
tabIndex={!isEditMode && supportsAdvancedFocus ? 0 : undefined}
role={!isEditMode && supportsAdvancedFocus ? "group" : undefined}
aria-label={
!isEditMode && supportsAdvancedFocus ? `${widgetName}: ${t("item.advancedFocus.open")}` : undefined
}
aria-expanded={supportsAdvancedFocus ? isAdvanced : undefined}
aria-controls={isAdvanced ? advancedViewId : undefined}
aria-keyshortcuts={supportsAdvancedFocus ? "Shift+Enter" : undefined}
onKeyDown={supportsAdvancedFocus ? handleKeyDown : undefined}
onPointerEnter={() => {
if (supportsAdvancedFocus && sourceRef.current) hover(item.id, sourceRef.current);
}}
onPointerLeave={() => {
if (supportsAdvancedFocus) leave(item.id);
}}
className={combineClasses(isAdvanced && advancedFocusClasses.sourcePlaceholder)}
>
<div ref={mountCompactHost} className={advancedFocusClasses.surfaceHost}>
{!supportsAdvancedFocus && widgetCard}
</div>
{surfacePortalTarget && createPortal(widgetCard, surfacePortalTarget)}
{isPreview && (
<Portal reuseTargetNode={false}>
<div ref={mountPreviewHost} className={advancedFocusClasses.surfaceHostAdvanced} />
</Portal>
)}
{isManual && activeFocus && advancedRect && closePosition && (
<AdvancedFocusManualSurface
opened={activeFocus.phase === "visible"}
phase={activeFocus.phase}
id={advancedViewId}
label={advancedViewLabel}
closeLabel={t("item.advancedFocus.close")}
rect={advancedRect}
closePosition={closePosition}
sourceRect={activeFocus.sourceRect}
radius={board.itemRadius}
contentRef={setManualSurface}
onClose={() => close()}
>
<div ref={mountManualHost} className={advancedFocusClasses.manualSurfaceMount} />
</AdvancedFocusManualSurface>
)}
</Box>
</WidgetContextMenu>
{!isAdvanced && item.advancedOptions.title?.trim() && (
<Badge
pos="absolute"
// It's 4 because of the mantine-react-table that has z-index 3
style={{ zIndex: 4 }}
style={{ zIndex: "var(--mantine-z-index-app)" }}
top={2}
left={16}
size="xs"
@@ -144,49 +423,18 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
);
};
interface LoadedBoardItemContentProps extends Omit<InnerContentProps, "definition" | "Component"> {
innerRef: (element: HTMLDivElement | null) => void;
}
const LoadedBoardItemContent = ({ item, innerRef, ...innerContentProps }: LoadedBoardItemContentProps) => {
const { definition, Component } = use(loadWidgetResources(item.kind));
const [isEditMode] = useEditMode();
return (
<WidgetContextMenu item={item} definition={definition} widgetStateRef={innerContentProps.widgetStateRef}>
<BoardItemCard item={item} innerRef={innerRef}>
<ErrorBoundary
resetKeys={[item.kind]}
fallbackRender={({ resetErrorBoundary, error }) => (
<>
<BoardItemMenu item={item} definition={definition} resetErrorBoundary={resetErrorBoundary} />
<div
className={itemContentClasses.editModeInertContent}
data-board-grid-inert-content={isEditMode ? "true" : undefined}
inert={isEditMode}
>
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
</div>
</>
)}
>
<InnerContent item={item} definition={definition} Component={Component} {...innerContentProps} />
</ErrorBoundary>
</BoardItemCard>
</WidgetContextMenu>
);
};
interface InnerContentProps {
item: SectionItem;
width: number;
height: number;
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
widgetRuntimeRef: WidgetRuntimeRef;
displayMode: "compact" | "advanced";
definition: WidgetDefinition;
Component: ComponentType<WidgetComponentProps<SectionItem["kind"]>>;
}
const InnerContent = ({ item, definition, Component, ...dimensions }: InnerContentProps) => {
const InnerContent = memo(function InnerContent({ item, definition, Component, ...dimensions }: InnerContentProps) {
const settings = useSettings();
const board = useRequiredBoard();
const [isEditMode] = useEditMode();
@@ -260,7 +508,7 @@ const InnerContent = ({ item, definition, Component, ...dimensions }: InnerConte
)}
</QueryErrorResetBoundary>
);
};
});
const Throw = ({ when, error }: { when: boolean; error: Error }) => {
if (when) throw error;
@@ -2,7 +2,6 @@
opacity: 0;
transition: opacity 150ms ease;
}
.card:hover .action,
.card:focus-within .action {
opacity: 1;
@@ -290,7 +290,6 @@ const ItemSelectModalContent = ({
</SelectGridLayout>
);
};
const ItemSelectModalFrame = ({
actions,
innerProps,
@@ -1,149 +1,154 @@
"use client";
import type { MutableRefObject, ReactNode } from "react";
import type { MutableRefObject, ReactNode, RefObject } from "react";
import { useCallback, useMemo, useState } from "react";
import { Group, Loader, Menu, Switch, Text, Tooltip } from "@mantine/core";
import { IconAlertTriangle, IconCircleCheck, IconRefresh, IconSettings } from "@tabler/icons-react";
import { Group, Loader, Menu, Text, Tooltip } from "@mantine/core";
import { IconAlertTriangle, IconCircleCheck, IconMaximize, IconRefresh, IconSettings } from "@tabler/icons-react";
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import { partialMatchKey, useIsFetching, useQueryClient } from "@tanstack/react-query";
import { useIsFetching, useQueryClient } from "@tanstack/react-query";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useIntegrationsWithInteractAccess, useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { usePersistBoard } from "@homarr/boards/updater";
import { useTimeAgo } from "@homarr/common";
import { useModalAction } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications";
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, WidgetDefinition } from "@homarr/widgets/definition";
import { getWidgetQueryKeys } from "@homarr/widgets/definition";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition";
import { getWidgetQueryKeys, getWidgetRuntimeQueries, supportsAdvancedFocus } from "@homarr/widgets/definition";
import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { useAdvancedFocus } from "../advanced-focus/context";
import { useBoardPermissions } from "../permissions/client";
import { useItemActions } from "./item-actions";
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
import { matchesWidgetItemQuery } from "./widget-query-scope";
interface WidgetContextMenuProps {
item: SectionItem;
definition: WidgetDefinition;
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
widgetRuntimeRef: WidgetRuntimeRef;
sourceRef: RefObject<HTMLElement | null>;
children: ReactNode;
}
export const WidgetContextMenu = ({ item, definition, widgetStateRef, children }: WidgetContextMenuProps) => {
const { data: session } = useSession();
const [isEditMode] = useEditMode();
const settings = useSettings();
if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") return <>{children}</>;
return (
<WidgetContextMenuInner item={item} definition={definition} widgetStateRef={widgetStateRef} settings={settings}>
{children}
</WidgetContextMenuInner>
);
};
const WidgetContextMenuInner = ({
export const WidgetContextMenu = ({
item,
definition,
widgetStateRef,
widgetRuntimeRef,
sourceRef,
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) => {
}: WidgetContextMenuProps) => {
const { data: session } = useSession();
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board);
const { updateAndPersistBoard } = usePersistBoard(board);
const tMenu = useScopedI18n("item.menu.label");
const t = useI18n();
const { data: session } = useSession();
const canConfigureWidget = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
const hasSupportedIntegrations =
"supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) > 0;
const settings = useSettings();
const { openModal } = useModalAction(LazyWidgetEditModal);
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
const hasSupportedIntegrations = (definition.supportedIntegrations?.length ?? 0) > 0;
const { data: integrationData = [], isPending } = clientApi.integration.all.useQuery(undefined, {
enabled: hasSupportedIntegrations,
});
const { mutate: saveBoard } = clientApi.board.saveBoard.useMutation();
const canConfigureWidget =
hasChangeAccess && (item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false));
const canOpenAdvancedFocus = supportsAdvancedFocus(definition);
const queryClient = useQueryClient();
const { open: openAdvancedFocus } = useAdvancedFocus();
const integrationsWithInteractAccess = useIntegrationsWithInteractAccess();
const [menuOpened, setMenuOpened] = useState(false);
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 Promise.all(widgetQueryKeys.map((queryKey) => queryClient.refetchQueries({ queryKey, type: "all" })));
}, [queryClient, widgetQueryKeys]);
const persistBoard = useCallback(
(updater: (previous: typeof board) => typeof board) => {
updateAndPersistBoard(updater, {
onError: () => {
showErrorNotification({
title: t("item.menu.notification.saveError.title"),
message: t("item.menu.notification.saveError.message"),
});
},
});
},
[t, updateAndPersistBoard],
);
const options = useMemo(
() => reduceWidgetOptionsWithDefinition(definition, settings, item.options),
[definition, settings, item.options],
);
const widgetQueryKeys = useMemo(() => getWidgetQueryKeys(definition, item.kind), [definition, item.kind]);
const matchesWidgetQuery = useCallback(
(queryKey: QueryKey) =>
matchesWidgetItemQuery(
queryKey,
widgetQueryKeys,
{
itemId: item.id,
boardId: board.id,
integrationIds: item.integrationIds,
options,
runtimeQueries: getWidgetRuntimeQueries(widgetRuntimeRef),
},
definition.queryMatcher,
),
[board.id, definition.queryMatcher, item.id, item.integrationIds, options, widgetQueryKeys, widgetRuntimeRef],
);
const isWidgetFetching =
useIsFetching({
type: "active",
predicate: (query) => matchesWidgetQuery(query.queryKey),
}) > 0;
const handleRefetch = useCallback(() => {
void queryClient.refetchQueries({ type: "active", predicate: (query) => matchesWidgetQuery(query.queryKey) });
}, [matchesWidgetQuery, queryClient]);
const canInteractWithSelectedIntegrations = useMemo(() => {
const allowedIds = new Set(integrationsWithInteractAccess.map(({ id }) => id));
return item.integrationIds.length > 0 && item.integrationIds.every((id) => allowedIds.has(id));
}, [integrationsWithInteractAccess, item.integrationIds]);
const setItemOptions = useCallback(
(partial: Record<string, unknown>) => {
persistBoard((previous) => ({
...previous,
items: previous.items.map((boardItem) =>
boardItem.id === item.id ? { ...boardItem, options: { ...boardItem.options, ...partial } } : boardItem,
),
}));
},
[item.id, persistBoard],
);
type OptionDef = { type: string; skipContextMenu?: boolean };
const toggleOptions = useMemo(() => {
if (!canConfigureWidget) return [];
const rawOptions = definition.createOptions(settings) as unknown as Record<string, OptionDef>;
return Object.entries(rawOptions).filter(([, def]) => def.type === "switch" && !def.skipContextMenu);
return Object.entries(rawOptions).filter(([, option]) => option.type === "switch" && !option.skipContextMenu);
}, [canConfigureWidget, definition, settings]);
const widgetContextActions = useMemo(() => {
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)({
const widgetContextActions =
definition.contextActions?.({
options,
setOptions: (partial: Record<string, unknown>) => {
updateItemOptions({ itemId: item.id, newOptions: { ...options, ...partial } });
},
setOptions: setItemOptions,
integrationIds: item.integrationIds,
context: { isEditMode, boardId: board.id, itemId: item.id },
widgetStateRef,
});
return (Array.isArray(actions) ? actions : []) as WidgetContextMenuAction[];
}, [definition, options, item, updateItemOptions, isEditMode, board.id, widgetStateRef]);
const persistBoard = useCallback(
(updatedItems: typeof board.items) => {
saveBoard({ ...board, items: updatedItems });
},
[board, saveBoard],
);
context: {
isEditMode,
boardId: board.id,
itemId: item.id,
canInteractWithSelectedIntegrations,
},
widgetRuntimeRef,
}) ?? [];
const openEditModal = useCallback(() => {
openModal(
@@ -159,27 +164,24 @@ const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings
updateItemOptions({ itemId: item.id, newOptions: editResult.options });
updateItemAdvancedOptions({ itemId: item.id, newAdvancedOptions: editResult.advancedOptions });
updateItemIntegrations({ itemId: item.id, newIntegrations: editResult.integrationIds });
if (!isEditMode) {
persistBoard(
board.items.map((boardItem) =>
boardItem.id !== item.id
? boardItem
: {
...boardItem,
options: editResult.options,
advancedOptions: editResult.advancedOptions,
integrationIds: editResult.integrationIds,
},
),
);
}
persistBoard((previous) => ({
...previous,
items: previous.items.map((boardItem) =>
boardItem.id !== item.id
? boardItem
: {
...boardItem,
options: editResult.options,
advancedOptions: editResult.advancedOptions,
integrationIds: editResult.integrationIds,
},
),
}));
},
integrationData: integrationData.filter(
(integration) =>
"supportedIntegrations" in definition &&
(definition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
integrationData: integrationData.filter((integration) =>
definition.supportedIntegrations?.some((kind) => kind === integration.kind),
),
integrationSupport: "supportedIntegrations" in definition,
integrationSupport: definition.supportedIntegrations !== undefined,
settings,
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
},
@@ -187,72 +189,87 @@ const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings
{ title: (fn: any) => `${fn("item.edit.title")} - ${fn(`widget.${item.kind}.name`)}` },
);
}, [
openModal,
definition,
integrationData,
item,
updateItemOptions,
openModal,
persistBoard,
settings,
updateItemAdvancedOptions,
updateItemIntegrations,
integrationData,
definition,
settings,
isEditMode,
persistBoard,
board,
updateItemOptions,
]);
const handleToggle = useCallback(
(key: string) => (checked: boolean) => {
const newOptions = { ...options, [key]: checked };
updateItemOptions({ itemId: item.id, newOptions });
persistBoard(
board.items.map((boardItem) => (boardItem.id !== item.id ? boardItem : { ...boardItem, options: newOptions })),
);
},
[options, item.id, updateItemOptions, persistBoard, board],
(key: string) => (checked: boolean) => setItemOptions({ [key]: checked }),
[setItemOptions],
);
const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden);
if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") return <>{children}</>;
const visibleWidgetActions = widgetContextActions.filter((action) => !action.hidden);
return (
<>
{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
shadow="md"
width={300}
closeOnItemClick={false}
position="right-start"
offset={4}
opened={menuOpened}
onChange={setMenuOpened}
>
<Menu.ContextMenu>{children}</Menu.ContextMenu>
<Menu.Dropdown>
{canOpenAdvancedFocus && (
<>
<Menu.Item
closeMenuOnClick
leftSection={<IconMaximize size={16} />}
onClick={() => {
if (sourceRef.current) openAdvancedFocus(item.id, sourceRef.current);
}}
>
{t("item.advancedFocus.open")}
</Menu.Item>
))}
</>
)}
<Menu.Divider />
</>
)}
{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>
);
})}
</>
)}
{toggleOptions.length > 0 && (
<>
<Menu.Label>{tMenu("options")}</Menu.Label>
{toggleOptions.map(([key]) => (
<Menu.CheckboxItem key={key} checked={Boolean(options[key])} onChange={handleToggle(key)}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))}
</Menu.CheckboxItem>
))}
</>
)}
{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}
>
{translateIfNecessary(t, action.label)}
</Menu.Item>
);
})}
</>
)}
<>
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />}
<Menu.Item
leftSection={isWidgetFetching ? <Loader size={16} /> : <IconRefresh size={16} />}
@@ -263,7 +280,7 @@ const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings
{tMenu("refresh")}
<WidgetQueryStatus
queryClient={queryClient}
queryKeys={widgetQueryKeys}
matchesQuery={matchesWidgetQuery}
isFetching={isWidgetFetching}
t={t}
/>
@@ -279,23 +296,27 @@ const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings
>
{tMenu("settings")}
</Menu.Item>
</>
</>
</Menu.Dropdown>
</Menu>
);
};
interface WidgetQueryStatusProps {
queryClient: QueryClient;
queryKeys: QueryKey[];
matchesQuery: (queryKey: QueryKey) => boolean;
isFetching: boolean;
t: TranslationFunction;
}
const WidgetQueryStatus = ({ queryClient, queryKeys, isFetching, t }: WidgetQueryStatusProps) => {
const queries = [...new Set(queryKeys.flatMap((queryKey) => queryClient.getQueryCache().findAll({ queryKey })))];
const WidgetQueryStatus = ({ queryClient, matchesQuery, isFetching, t }: WidgetQueryStatusProps) => {
const queries = queryClient.getQueryCache().findAll({
type: "active",
predicate: (query) => matchesQuery(query.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));
const ageLabel = useTimeAgo(new Date(latest || Date.now()));
const ageSuffix = latest > 0 ? ` · ${ageLabel}` : "";
if (isFetching) {
return (
@@ -316,22 +337,15 @@ const WidgetQueryStatus = ({ queryClient, queryKeys, isFetching, t }: WidgetQuer
);
}
const hasError = queries.some((q) => q.state.status === "error");
const hasError = queries.some((query) => query.state.status === "error");
if (hasError) {
const errorQuery = queries.find((q) => q.state.status === "error");
const errorMessage =
errorQuery?.state.error instanceof Error
? errorQuery.state.error.message
: String(errorQuery?.state.error ?? "Unknown error");
return (
<Tooltip label={errorMessage} multiline position="left" w={250}>
<Tooltip label={t("item.menu.status.error")} position="left">
<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")}
{latest > 0 && ` · ${ageLabel}`}
{ageSuffix}
</Text>
</Group>
</Tooltip>
@@ -343,7 +357,7 @@ const WidgetQueryStatus = ({ queryClient, queryKeys, isFetching, t }: WidgetQuer
<IconCircleCheck size={12} color="var(--mantine-color-green-6)" />
<Text size="xs" c="dimmed">
{t("item.menu.status.success")}
{latest > 0 && ` · ${ageLabel}`}
{ageSuffix}
</Text>
</Group>
);
@@ -105,6 +105,15 @@
flex-shrink: 0;
}
@media (forced-colors: active) {
.panel {
border-color: CanvasText;
background: Canvas;
box-shadow: none;
backdrop-filter: none;
}
}
.metadataValue {
overflow: hidden;
text-overflow: ellipsis;