feat(custom-widgets): complete Custom JSX v2 (#6390)

This commit is contained in:
Thomas authored and ajnart committed 2026-08-13 19:48:57 +02:00
1 parent 3a1245cbc5
commit acdfcbb18e
416 files changed
+37853 -18066

No files matched your search

+79 -195
View File
@@ -1,228 +1,112 @@
"use client";
import dynamic from "next/dynamic";
import { useMemo, useState } from "react";
import { Button, Center, Code, Loader, ScrollArea, Stack, Text } from "@mantine/core";
import { IconAlertTriangle, IconCheck, IconPlayerPlay } from "@tabler/icons-react";
import { Button, Center, Loader, Stack, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { createCustomWidgetDisplayComponents } from "@homarr/custom-widgets/runtime";
import type { WidgetComponentProps } from "../definition";
const CustomJsxDisplay = dynamic(() => import("./custom-jsx-display"), { ssr: false });
function ActionButtonDisplay({ data }: { data: Record<string, unknown> }) {
const t = useScopedI18n("widget.customApi");
const { openConfirmModal } = useConfirmModal();
const executeMutation = clientApi.widget.customApi.executeBaseAction.useMutation();
const [lastSuccess, setLastSuccess] = useState(false);
const buttonLabel = (data.buttonLabel as string) ?? t("execute");
const buttonColor = (data.buttonColor as string) ?? "blue";
const confirmText = (data.confirmText as string) || "";
const requiresConfirmation = data.requiresConfirmation === true;
const successMessage = (data.successMessage as string) || t("executeSuccess");
const itemId = data.widgetItemId as string | undefined;
const isEditMode = data.isEditMode === true;
const handleExecute = async (confirmed: boolean) => {
if (!itemId || isEditMode) return;
setLastSuccess(false);
try {
const result = await executeMutation.mutateAsync({ itemId, confirmed });
if (result.success) {
setLastSuccess(true);
showSuccessNotification({ title: buttonLabel, message: successMessage });
setTimeout(() => setLastSuccess(false), 3000);
} else {
showErrorNotification({ title: buttonLabel, message: result.error ?? t("executeFailed") });
}
} catch {
showErrorNotification({ title: buttonLabel, message: t("executeFailed") });
}
};
const handleClick = () => {
if (confirmText || requiresConfirmation) {
openConfirmModal({
title: buttonLabel,
children: confirmText || t("executeConfirm"),
onConfirm: () => void handleExecute(true),
});
} else {
void handleExecute(false);
}
};
return (
<Center h="100%">
<Button
size="lg"
color={buttonColor}
onClick={handleClick}
loading={executeMutation.isPending}
leftSection={lastSuccess ? <IconCheck size={20} /> : <IconPlayerPlay size={20} />}
variant={lastSuccess ? "light" : "filled"}
disabled={!itemId || isEditMode}
>
{executeMutation.isPending ? t("executing") : buttonLabel}
</Button>
</Center>
);
}
export const createDisplayComponents = (openJsonLabel: string) =>
createCustomWidgetDisplayComponents({
actionButton: ActionButtonDisplay,
customJsx: CustomJsxDisplay,
openJsonLabel,
});
export default function CustomApiWidget({ options, itemId, isEditMode }: WidgetComponentProps<"customApi">) {
const t = useScopedI18n("widget.customApi");
const { definitionId, refreshInterval } = options;
if (!definitionId) {
return (
<Center h="100%">
<Stack align="center" gap="xs">
<IconAlertTriangle size={32} color="var(--mantine-color-yellow-6)" />
<Text c="dimmed" size="sm">
{t("noDefinition")}
</Text>
</Stack>
</Center>
);
}
return (
<CustomApiWidgetInner
definitionId={definitionId}
itemId={itemId}
isEditMode={isEditMode}
refreshInterval={refreshInterval as number}
/>
);
}
function CustomApiWidgetInner({
definitionId,
export default function CustomApiWidget({
options,
itemId,
isEditMode,
refreshInterval,
}: {
definitionId: string;
itemId: string | undefined;
isEditMode: boolean;
refreshInterval: number;
}) {
removeItem,
}: WidgetComponentProps<"customApi">) {
const t = useScopedI18n("widget.customApi");
const tCustomWidget = useScopedI18n("customWidget");
const tCustomJsx = useScopedI18n("widget.customApi.customJsx");
const displayComponents = useMemo(() => createDisplayComponents(t("openJson")), [t]);
const safeInterval = Number.isFinite(refreshInterval) ? refreshInterval : 30;
const intervalMs = Math.max(1000, safeInterval * 1000);
const { data, isLoading, error } = clientApi.widget.customApi.getData.useQuery(
const definitionId = options.definitionId;
const safeInterval = Number.isFinite(options.refreshInterval) ? options.refreshInterval : 30;
const intervalMs = Math.max(1_000, safeInterval * 1_000);
const query = clientApi.widget.customApi.getData.useQuery(
{ itemId: itemId ?? "" },
{
enabled: Boolean(itemId) && !isEditMode,
refetchInterval: (query) => {
const result = query.state.data as Record<string, unknown> | undefined;
if (
result?.type === "actionButton" ||
result?.type === "disabled" ||
result?.type === "networkAccessNeedsReview"
) {
return false;
}
return intervalMs;
},
retry: (failureCount, err) => {
if (err.data?.code === "NOT_FOUND") return false;
return failureCount < 3;
},
enabled: Boolean(itemId) && Boolean(definitionId),
refetchInterval: (currentQuery) => (isTerminalDefinitionError(currentQuery.state.error) ? false : intervalMs),
retry: (failureCount, error) => !isTerminalDefinitionError(error) && failureCount < 3,
},
);
if (isLoading) {
if (!definitionId) {
return <Unavailable message={t("definitionNotFound")} removeLabel={t("removeFromBoard")} onRemove={removeItem} />;
}
if (isEditMode && !itemId) return <Unavailable message={t("editModePending")} />;
if (query.isLoading)
return (
<Center h="100%">
<Loader size="sm" />
</Center>
);
}
if (error) {
const isNotFound = error.data?.code === "NOT_FOUND";
return (
<Center h="100%">
<Stack align="center" gap="xs">
<IconAlertTriangle size={32} color={`var(--mantine-color-${isNotFound ? "yellow" : "red"}-6)`} />
<Text c="dimmed" size="sm" ta="center">
{isNotFound ? t("definitionNotFound") : tCustomJsx("requestFailed")}
</Text>
</Stack>
</Center>
);
}
if (!data) {
if (isEditMode) {
return (
<Center h="100%">
<Text c="dimmed" size="sm" ta="center">
{t("editModePending")}
</Text>
</Center>
);
if (query.error) {
const errorCode = query.error.data?.code;
if (isEditMode && errorCode === "NOT_FOUND") {
return <Unavailable message={t("editModePending")} />;
}
return null;
}
const widgetData = data as Record<string, unknown>;
const dataType = widgetData.type as string | undefined;
if (dataType === "disabled") {
const isUnavailable = errorCode === "NOT_FOUND" || errorCode === "FORBIDDEN";
return (
<Center h="100%">
<Text c="dimmed" size="sm">
{tCustomWidget("widget.disabled")}
</Text>
</Center>
<Unavailable
message={
isUnavailable
? t("definitionNotFound")
: errorCode === "PRECONDITION_FAILED"
? t("configurationNeedsRepair")
: tCustomJsx("requestFailed")
}
danger={!isUnavailable}
removeLabel={isUnavailable ? t("removeFromBoard") : undefined}
onRemove={removeItem}
/>
);
}
if (dataType === "networkAccessNeedsReview") {
return (
<Center h="100%" p="sm">
<Stack align="center" gap="xs">
<IconAlertTriangle size={32} color="var(--mantine-color-yellow-6)" />
<Text c="dimmed" size="sm" ta="center">
{tCustomJsx("networkAccessNeedsReview")}
</Text>
</Stack>
</Center>
);
}
const Component = dataType ? displayComponents[dataType] : undefined;
if (Component) {
const enrichedData =
dataType === "actionButton" || dataType === "customJsx"
? { ...widgetData, widgetDefinitionId: definitionId, widgetItemId: itemId, isEditMode }
: widgetData;
return <Component data={enrichedData} />;
}
if (!query.data) return null;
return (
<ScrollArea h="100%" p="xs">
<Code block style={{ fontSize: 11 }}>
{JSON.stringify(data, null, 2)}
</Code>
</ScrollArea>
<CustomJsxDisplay
data={{
...query.data,
widgetDefinitionId: definitionId,
widgetItemId: itemId,
isEditMode,
}}
/>
);
}
function isTerminalDefinitionError(error: unknown): boolean {
if (!error || typeof error !== "object" || !("data" in error)) return false;
const data = error.data;
if (!data || typeof data !== "object" || !("code" in data)) return false;
return data.code === "NOT_FOUND" || data.code === "FORBIDDEN" || data.code === "PRECONDITION_FAILED";
}
function Unavailable({
message,
danger = false,
removeLabel,
onRemove,
}: {
message: string;
danger?: boolean;
removeLabel?: string;
onRemove?: () => void;
}) {
return (
<Center h="100%" p="sm">
<Stack align="center" gap="xs">
<IconAlertTriangle size={32} color={`var(--mantine-color-${danger ? "red" : "yellow"}-6)`} />
<Text c="dimmed" size="sm" ta="center">
{message}
</Text>
{removeLabel && onRemove && (
<Button size="compact-sm" color="red" variant="light" onClick={onRemove}>
{removeLabel}
</Button>
)}
</Stack>
</Center>
);
}
@@ -2,20 +2,27 @@ import { MantineProvider } from "@mantine/core";
import * as MantineCharts from "@mantine/charts";
import * as MantineCore from "@mantine/core";
import * as MantineDates from "@mantine/dates";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, createElement } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import type { ReactNode } from "react";
import type { Root } from "react-dom/client";
import { customJsxComponentRegistry, customJsxExamples, enabledCustomJsxComponents } from "@homarr/custom-widgets/core";
import { customJsxComponentRegistry, enabledCustomJsxComponents } from "@homarr/custom-widgets/catalog";
import {
BUNDLED_CUSTOM_WIDGETS,
customJsxExamples,
customJsxTablerIconNames,
getCustomWidgetDefaultOptions,
} from "@homarr/custom-widgets/core";
import { renderSafeJsx, SafeJsxError, sanitizeCustomJsxProps } from "@homarr/custom-widgets/jsx";
import CustomJsxDisplay from "./custom-jsx-display";
import { createWhitelistedComponents, SAFE_BINDINGS } from "./jsx-whitelist";
import { createCustomWidgetComponents, SAFE_BINDINGS } from "./jsx-components";
import { SAFE_TABLER_ICON_NAMES } from "./jsx-icon-adapter";
const WHITELISTED_COMPONENTS = createWhitelistedComponents({ copy: "Copy", copied: "Copied" });
const CUSTOM_WIDGET_COMPONENTS = createCustomWidgetComponents({ copy: "Copy", copied: "Copied" });
vi.mock("@homarr/translation/client", () => ({
useScopedI18n: () => (key: string, params?: Record<string, string>) =>
@@ -31,6 +38,10 @@ const isComponentLikeExport = ([name, value]: [string, unknown]): boolean => {
};
describe("CustomJsxDisplay", () => {
it("keeps safe Tabler icon metadata synchronized with the runtime", () => {
expect(SAFE_TABLER_ICON_NAMES).toEqual(customJsxTablerIconNames);
});
let container: HTMLDivElement;
let root: Root;
@@ -49,6 +60,14 @@ describe("CustomJsxDisplay", () => {
dispatchEvent: () => false,
}),
});
Object.defineProperty(globalThis, "ResizeObserver", {
writable: true,
value: class {
observe() {}
unobserve() {}
disconnect() {}
},
});
});
afterEach(async () => {
@@ -60,8 +79,15 @@ describe("CustomJsxDisplay", () => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root.render(createElement(MantineProvider, null, createElement(CustomJsxDisplay, { data })));
root.render(
createElement(
MantineProvider,
null,
createElement(QueryClientProvider, { client: queryClient }, createElement(CustomJsxDisplay, { data })),
),
);
});
};
@@ -75,6 +101,65 @@ describe("CustomJsxDisplay", () => {
expect(container.textContent).toContain("3");
});
it("keeps runtime components inside an inert provider before a preview session exists", async () => {
await renderDisplay({ template: '<RefreshButton label="Refresh" />', data: {} });
expect(container.querySelector('button[aria-label="Refresh"]')).not.toBeNull();
expect(container.textContent).not.toContain("RUNTIME_RENDER_ERROR");
expect(container.querySelector("button")?.disabled).toBe(true);
});
it("renders the bundled Pokédex list without a widget-wide runtime failure", async () => {
const pokedex = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-pokedex")?.widget;
expect(pokedex).toBeDefined();
await renderDisplay({
template: pokedex?.template,
data: {
pokemon: {
count: 2,
results: [
{ name: "bulbasaur", url: "https://pokeapi.co/api/v2/pokemon/1/" },
{ name: "pikachu", url: "https://pokeapi.co/api/v2/pokemon/25/" },
],
},
},
status: { pokemon: { loading: false, ok: true, status: 200 } },
options: getCustomWidgetDefaultOptions(pokedex?.options ?? {}),
});
expect(container.textContent).toContain("bulbasaur");
expect(container.textContent).toContain("pikachu");
expect(
container.querySelector('img[src="https://cdn.jsdelivr.net/gh/PokeAPI/sprites@master/sprites/pokemon/1.png"]'),
).not.toBeNull();
expect(
container.querySelector('img[src="https://cdn.jsdelivr.net/gh/PokeAPI/sprites@master/sprites/pokemon/25.png"]'),
).not.toBeNull();
expect(container.querySelectorAll("img.mantine-Image-root")).toHaveLength(2);
expect(container.querySelectorAll(".mantine-Card-root")).toHaveLength(2);
expect(container.textContent).not.toContain("RUNTIME_RENDER_ERROR");
});
it("recovers when preview data arrives after an initial render error", async () => {
const template = "<Stack>{data.items.map((item) => <Text key={item}>{item}</Text>)}</Stack>";
await renderDisplay({ template, data: {} });
expect(container.textContent).toContain("RUNTIME_RENDER_ERROR");
await act(async () => {
root.render(
createElement(
MantineProvider,
null,
createElement(CustomJsxDisplay, { data: { template, data: { items: ["Bulbasaur", "Pikachu"] } } }),
),
);
});
expect(container.textContent).toContain("Bulbasaur");
expect(container.textContent).toContain("Pikachu");
expect(container.textContent).not.toContain("RUNTIME_RENDER_ERROR");
});
it("blocks script tags and event handlers", async () => {
await renderDisplay({
template: '<script>document.title = "hacked"</script><button onClick={() => {}}>Click</button><Text>Safe</Text>',
@@ -105,26 +190,6 @@ describe("CustomJsxDisplay", () => {
expect(container.textContent).not.toContain("GAMMA");
});
it("renders fetched data through an interpreter-created SubFetch child", async () => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
const rendered = renderSafeJsx({
template:
"<SubFetch>{(detail, meta) => <Text>{detail.types.map((entry) => entry.name).join(', ')} · {meta.status}</Text>}</SubFetch>",
components: {
SubFetch: ((props: { render?: (data: unknown, metadata: unknown) => ReactNode }) =>
props.render?.({ types: [{ name: "grass" }, { name: "poison" }] }, { status: 200 })) as never,
Text: ((props: { children?: ReactNode }) => createElement("span", null, props.children)) as never,
},
bindings: {},
});
await act(async () => root.render(rendered.node));
expect(container.textContent).toBe("grass, poison · 200");
});
it("blocks reflective properties assembled with string concatenation", async () => {
await renderDisplay({
template: '<Text>{data["con" + "structor"]}</Text>',
@@ -373,14 +438,14 @@ describe("Custom JSX component registry", () => {
it("has a runtime implementation for every enabled registry component", () => {
const missing = enabledCustomJsxComponents
.map(({ name }) => name)
.filter((name) => !Object.hasOwn(WHITELISTED_COMPONENTS, name));
.filter((name) => !Object.hasOwn(CUSTOM_WIDGET_COMPONENTS, name));
expect(missing, "Enabled components without a runtime implementation").toEqual([]);
});
});
const renderTemplate = (template: string, data: Record<string, unknown> = {}) =>
renderSafeJsx({ template, components: WHITELISTED_COMPONENTS, bindings: SAFE_BINDINGS(data) });
renderSafeJsx({ template, components: CUSTOM_WIDGET_COMPONENTS, bindings: SAFE_BINDINGS(data) });
describe("new collection/string methods", () => {
let container: HTMLDivElement;
@@ -543,9 +608,14 @@ describe("shared Custom JSX examples", () => {
it.each(customJsxExamples)("parses and creates the $id renderer tree", (example) => {
const rendered = renderSafeJsx({
template: example.template,
components: WHITELISTED_COMPONENTS,
bindings: SAFE_BINDINGS(sampleData),
template: example.widget.template,
components: CUSTOM_WIDGET_COMPONENTS,
bindings: {
...SAFE_BINDINGS(sampleData),
status: {},
options: getCustomWidgetDefaultOptions(example.widget.options ?? {}),
inputs: {},
},
});
expect(rendered.warnings).toEqual([]);
@@ -1,47 +1,93 @@
"use client";
import { useMemo } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useScopedI18n } from "@homarr/translation/client";
import type { CustomWidgetPublishedQueryState } from "@homarr/custom-widgets/runtime";
import { CustomJsxRenderer, parseRequestCapabilities } from "@homarr/custom-widgets/runtime";
import { createWhitelistedComponents, SAFE_BINDINGS } from "./jsx-whitelist";
import { WidgetDefinitionProvider } from "./widget-definition-context";
import { createCustomWidgetComponents, SAFE_BINDINGS } from "./jsx-components";
import { InactiveWidgetDefinitionProvider, WidgetDefinitionProvider } from "./widget-definition-context";
export { CUSTOM_JSX_METHOD_COLORS } from "@homarr/custom-widgets/runtime";
export default function CustomJsxDisplay({ data }: { data: Record<string, unknown> }) {
const t = useScopedI18n("widget.customApi.customJsx");
const capabilities = useMemo(() => parseRequestCapabilities(data.requestCapabilities), [data.requestCapabilities]);
const components = useMemo(() => createWhitelistedComponents({ copy: t("copy"), copied: t("copied") }), [t]);
const components = useMemo(() => createCustomWidgetComponents({ copy: t("copy"), copied: t("copied") }), [t]);
const [queryState, setQueryState] = useState<Record<string, CustomWidgetPublishedQueryState>>({});
useEffect(
() => setQueryState({}),
[data.widgetDefinitionId, data.widgetItemId, data.previewSessionId, data.template],
);
const publishQueryState = useCallback((requestId: string, value: CustomWidgetPublishedQueryState | null) => {
setQueryState((current) => {
if (!value) {
if (!Object.hasOwn(current, requestId)) return current;
const next = { ...current };
delete next[requestId];
return next;
}
const previous = current[requestId];
if (previous && previous.data === value.data && sameStatus(previous.status, value.status)) return current;
return { ...current, [requestId]: value };
});
}, []);
const baseData = isRecord(data.data) ? data.data : {};
const baseStatus = isRecord(data.status) ? data.status : {};
const renderer = (
<CustomJsxRenderer
template={String(data.template ?? "")}
data={data.data}
requestCapabilities={capabilities}
data={{ ...baseData, ...Object.fromEntries(Object.entries(queryState).map(([id, value]) => [id, value.data])) }}
status={{
...baseStatus,
...Object.fromEntries(Object.entries(queryState).map(([id, value]) => [id, value.status])),
}}
options={isRecord(data.options) ? data.options : {}}
components={components}
createBindings={SAFE_BINDINGS}
messages={{
noTemplate: t("noTemplate"),
interactive: t("interactive"),
networkCapabilities: t("networkCapabilities"),
templateWarnings: (count) => t("templateWarnings", { count: String(count) }),
}}
/>
);
const hasRuntime = typeof data.widgetItemId === "string" || typeof data.previewSessionId === "string";
return hasRuntime ? (
const definitionId = typeof data.widgetDefinitionId === "string" ? data.widgetDefinitionId : undefined;
const itemId = typeof data.widgetItemId === "string" ? data.widgetItemId : undefined;
const previewSessionId = typeof data.previewSessionId === "string" ? data.previewSessionId : undefined;
if (!itemId && !previewSessionId) {
return (
<InactiveWidgetDefinitionProvider definitionId={definitionId} isEditMode={data.isEditMode === true}>
{renderer}
</InactiveWidgetDefinitionProvider>
);
}
return (
<WidgetDefinitionProvider
definitionId={String(data.widgetDefinitionId ?? "")}
itemId={typeof data.widgetItemId === "string" ? data.widgetItemId : undefined}
previewSessionId={typeof data.previewSessionId === "string" ? data.previewSessionId : undefined}
definitionId={definitionId}
itemId={itemId}
previewSessionId={previewSessionId}
previewLiveActions={data.previewLiveActions === true}
queriesDisabled={data.queriesDisabled === true}
isEditMode={data.isEditMode === true}
requestCapabilities={capabilities}
setQueryState={publishQueryState}
>
{renderer}
</WidgetDefinitionProvider>
) : (
renderer
);
}
function sameStatus(left: CustomWidgetPublishedQueryState["status"], right: CustomWidgetPublishedQueryState["status"]) {
return (
left.loading === right.loading &&
left.ok === right.ok &&
left.status === right.status &&
left.statusText === right.statusText &&
left.error === right.error
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
+2
View File
@@ -9,6 +9,8 @@ export const { definition, componentLoader } = createWidgetDefinition("customApi
createOptions() {
return optionsBuilder.from((factory) => ({
definitionId: factory.customWidgetSelect({ defaultValue: "" }),
configuration: factory.customWidgetConfiguration(),
configurationVersion: factory.internal({ defaultValue: 1 }),
refreshInterval: factory.slider({
defaultValue: 30,
validate: z.number().min(1).max(3600),
@@ -6,7 +6,7 @@ import { SafeTablerIcon } from "./jsx-icon-adapter";
// Labels are replaced by the localized runtime adapter in the renderer. These
// fallbacks are used only in isolated component-map tests.
export const createWhitelistedComponents = (copyLabels: { copy: string; copied: string }) =>
export const createCustomWidgetComponents = (copyLabels: { copy: string; copied: string }) =>
createCustomJsxComponents({ TablerIcon: SafeTablerIcon as ComponentType<never>, copyLabels });
export const SAFE_BINDINGS = createCustomJsxBindings;
@@ -11,6 +11,7 @@ import {
IconBluetooth,
IconBolt,
IconBookmark,
IconBrandDocker,
IconBug,
IconCalendar,
IconCamera,
@@ -64,6 +65,7 @@ import {
IconMusic,
IconNetwork,
IconPhoto,
IconPokeball,
IconPin,
IconPlayerPause,
IconPlayerPlay,
@@ -133,6 +135,7 @@ const TABLER_ICON_IMPORTS = {
IconHeart,
IconStar,
IconBookmark,
IconBrandDocker,
IconFlag,
IconPin,
IconPlayerPlay,
@@ -183,6 +186,7 @@ const TABLER_ICON_IMPORTS = {
IconExternalLink,
IconLink,
IconPhoto,
IconPokeball,
IconCamera,
IconMusic,
IconVideo,
@@ -223,6 +227,8 @@ const TABLER_ICON_MAP: Record<string, ComponentType<never>> = {
pulse: IconHeartbeat,
};
export const SAFE_TABLER_ICON_NAMES = Object.freeze(Object.keys(TABLER_ICON_MAP).toSorted());
function isValidIconProp(value: unknown): value is string | number {
return typeof value === "number" || typeof value === "string";
}
@@ -4,6 +4,7 @@ import type { ReactNode } from "react";
import { fetchApi, clientApi } from "@homarr/api/client";
import type {
CustomWidgetPublishedQueryState,
CustomJsxRequestCapability,
CustomWidgetRuntimeMessages,
CustomWidgetRuntimePort,
@@ -12,23 +13,32 @@ import { CustomWidgetRuntimeProvider } from "@homarr/custom-widgets/runtime";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useQueryClient } from "@tanstack/react-query";
interface WidgetDefinitionProviderProps {
itemId?: string;
definitionId?: string;
previewSessionId?: string;
previewLiveActions?: boolean;
queriesDisabled?: boolean;
isEditMode?: boolean;
requestCapabilities?: readonly CustomJsxRequestCapability[];
setQueryState?(requestId: string, value: CustomWidgetPublishedQueryState | null): void;
children: ReactNode;
}
export function WidgetDefinitionProvider(props: WidgetDefinitionProviderProps) {
const INACTIVE_PORT: CustomWidgetRuntimePort = {
query: async () => ({ ok: false, status: 0, data: null, error: "Custom widget unavailable" }),
executeAction: async () => ({ ok: false, status: 0, data: null, error: "Custom widget unavailable" }),
invalidate: async () => undefined,
confirm: async () => false,
notify: () => undefined,
};
const useRuntimeMessages = (): CustomWidgetRuntimeMessages => {
const t = useScopedI18n("widget.customApi.customJsx");
const utils = clientApi.useUtils();
const { openConfirmModal } = useConfirmModal();
const messages: CustomWidgetRuntimeMessages = {
migrationRequired: t("migrationRequired"),
return {
requestIdRequired: t("requestIdRequired"),
unsavedPreview: t("unsavedPreview"),
invalidParams: t("invalidParams"),
loadRequest: t("loadRequest"),
@@ -43,6 +53,32 @@ export function WidgetDefinitionProvider(props: WidgetDefinitionProviderProps) {
toggle: t("toggle"),
refresh: t("refresh"),
};
};
export function InactiveWidgetDefinitionProvider({
definitionId,
isEditMode,
children,
}: Pick<WidgetDefinitionProviderProps, "definitionId" | "isEditMode" | "children">) {
const messages = useRuntimeMessages();
return (
<CustomWidgetRuntimeProvider
definitionId={definitionId}
isEditMode={isEditMode ?? false}
requestCapabilities={[]}
port={INACTIVE_PORT}
messages={messages}
>
{children}
</CustomWidgetRuntimeProvider>
);
}
export function WidgetDefinitionProvider(props: WidgetDefinitionProviderProps) {
const utils = clientApi.useUtils();
const queryClient = useQueryClient();
const { openConfirmModal } = useConfirmModal();
const messages = useRuntimeMessages();
const port: CustomWidgetRuntimePort = {
query: async (input, signal) => {
if (input.itemId) {
@@ -72,18 +108,41 @@ export function WidgetDefinitionProvider(props: WidgetDefinitionProviderProps) {
}),
invalidate: async ({ itemId, previewSessionId, targets }) => {
if (previewSessionId) {
await utils.customWidget.previewQuery.invalidate();
await queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey;
return key[0] === "custom-widget" && key[1] === previewSessionId && targets.includes(String(key[2]));
},
});
return;
}
if (!itemId) return;
const tasks: Promise<unknown>[] = [];
if (targets.includes("parent")) tasks.push(utils.widget.customApi.getData.invalidate({ itemId }));
if (targets.some((target) => target !== "parent")) tasks.push(utils.widget.customApi.queryRequest.invalidate());
if (targets.length === 0) return;
const tasks: Promise<unknown>[] = [
queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey;
return key[0] === "custom-widget" && key[1] === itemId && targets.includes(String(key[2]));
},
}),
];
if (
(props.requestCapabilities ?? []).some((request) => request.trigger === "load" && targets.includes(request.id))
) {
tasks.push(utils.widget.customApi.getData.invalidate({ itemId }));
}
await Promise.all(tasks);
},
confirm: ({ title, message }) =>
confirm: ({ title, message, confirmLabel, destructive }) =>
new Promise((resolve) => {
openConfirmModal({ title, children: message, onConfirm: () => resolve(true), onCancel: () => resolve(false) });
openConfirmModal({
title,
children: message,
labels: { confirm: confirmLabel },
confirmProps: { color: destructive ? "red.9" : "blue" },
onConfirm: () => resolve(true),
onCancel: () => resolve(false),
});
}),
notify: ({ kind, ...notification }) =>
kind === "success" ? showSuccessNotification(notification) : showErrorNotification(notification),
@@ -94,10 +153,12 @@ export function WidgetDefinitionProvider(props: WidgetDefinitionProviderProps) {
definitionId={props.definitionId}
previewSessionId={props.previewSessionId}
previewLiveActions={props.previewLiveActions}
queriesDisabled={props.queriesDisabled}
isEditMode={props.isEditMode ?? false}
requestCapabilities={props.requestCapabilities ?? []}
port={port}
messages={messages}
setQueryState={props.setQueryState}
>
{props.children}
</CustomWidgetRuntimeProvider>