feat(custom-widgets): Full Mantine v9 + SubFetch support for custom JSX (#6304)

This commit is contained in:
Thomas authored and GitHub committed 2026-07-15 14:06:02 +02:00
1 parent 0a0b3a12c9
commit 3b2fc2527e
181 files changed
+18472 -4364

No files matched your search

+72 -348
View File
@@ -1,352 +1,39 @@
"use client";
import dynamic from "next/dynamic";
import { useState } from "react";
import type { ComponentType } from "react";
import {
ActionIcon,
Button,
Card,
Center,
Code,
Flex,
Group,
Loader,
Progress,
ScrollArea,
SimpleGrid,
Stack,
Table,
Text,
Title,
Tooltip,
} from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { IconAlertTriangle, IconCheck, IconExternalLink, IconPlayerPlay } from "@tabler/icons-react";
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 { 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 });
const valueSizeMap: Record<string, string> = { sm: "sm", md: "md", lg: "lg", xl: "xl" };
function SingleValueDisplay({ data }: { data: Record<string, unknown> }) {
const size = valueSizeMap[(data.valueSize as string) ?? "lg"] ?? "lg";
const labelAbove = (data.labelPosition as string) === "above";
const label = data.label ? (
<Text c="dimmed" size="sm">
{String(data.label)}
</Text>
) : null;
return (
<Stack h="100%" align="center" justify="center" gap="xs">
{labelAbove && label}
<Title order={size === "xl" ? 1 : size === "lg" ? 2 : size === "md" ? 3 : 4}>
{String(data.value ?? "—")}
{data.unit ? ` ${data.unit}` : ""}
</Title>
{!labelAbove && label}
</Stack>
);
}
function KeyValueDisplay({ data }: { data: Record<string, unknown> }) {
const entries = (data.entries as Array<{ label: string; unit: string; value: unknown }>) ?? [];
const layout = (data.layout as string) ?? "list";
const columns = (data.columns as number) ?? 2;
if (layout === "grid") {
return (
<SimpleGrid cols={columns} spacing="xs" p="sm" h="100%">
{entries.map((entry, i) => (
<Stack key={i} align="center" gap={2}>
<Text size="sm" fw={600}>
{String(entry.value ?? "—")}
{entry.unit ? ` ${entry.unit}` : ""}
</Text>
<Text size="xs" c="dimmed">
{entry.label}
</Text>
</Stack>
))}
</SimpleGrid>
);
}
return (
<Stack h="100%" justify="center" gap="xs" p="sm">
{entries.map((entry, i) => (
<Group key={i} justify="space-between" wrap="nowrap">
<Text size="sm" c="dimmed">
{entry.label}
</Text>
<Text size="sm" fw={600}>
{String(entry.value ?? "—")}
{entry.unit ? ` ${entry.unit}` : ""}
</Text>
</Group>
))}
</Stack>
);
}
function TableDisplay({ data }: { data: Record<string, unknown> }) {
const columns = (data.columns as string[]) ?? [];
const rows = (data.rows as unknown[][]) ?? [];
const striped = (data.striped as boolean) ?? true;
const compact = (data.compact as boolean) ?? false;
return (
<ScrollArea>
<Table striped={striped} highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
{columns.map((col, i) => (
<Table.Th key={i} py={compact ? 4 : undefined}>
{col}
</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((row, i) => (
<Table.Tr key={i}>
{row.map((cell, j) => (
<Table.Td key={j} py={compact ? 2 : undefined}>
{String(cell ?? "—")}
</Table.Td>
))}
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea>
);
}
function StatGridCard({
item,
cardStyle,
}: {
item: { label: string; unit: string; color: string; value: unknown };
cardStyle: string;
}) {
const { ref, height, width } = useElementSize();
const isWide = width > height + 20;
const hideLabel = height > 0 && height <= 38;
const bgMap: Record<string, string> = {
filled: `var(--mantine-color-${item.color}-light)`,
outline: "transparent",
subtle: `var(--mantine-color-${item.color}-light)`,
};
return (
<Card
ref={ref}
p="sm"
radius="md"
bg={bgMap[cardStyle] ?? bgMap.filled}
withBorder={cardStyle === "outline"}
h="100%"
style={{ flex: 1 }}
>
<Flex
h="100%"
w="100%"
align="center"
justify="center"
direction={isWide ? "row" : "column"}
gap={isWide ? 8 : 4}
>
<Flex direction="column" align={isWide ? "flex-start" : "center"} gap={0}>
<Text size="md" fw={700} ta="center" lh={1.1}>
{String(item.value ?? "—")}
{item.unit ? ` ${item.unit}` : ""}
</Text>
{!hideLabel && (
<Text size="xs" c="dimmed" ta="center" tt="uppercase" lh={1.3} style={{ letterSpacing: "0.03em" }}>
{item.label}
</Text>
)}
</Flex>
</Flex>
</Card>
);
}
function StatGridDisplay({ data }: { data: Record<string, unknown> }) {
const items = (data.items as Array<{ label: string; unit: string; color: string; value: unknown }>) ?? [];
const columns = (data.columns as number) ?? 2;
const cardStyle = (data.cardStyle as string) ?? "filled";
return (
<SimpleGrid
cols={columns}
spacing="xs"
p="xs"
h="100%"
style={{ gridTemplateRows: `repeat(${Math.ceil(items.length / columns)}, 1fr)` }}
>
{items.map((item, i) => (
<StatGridCard key={i} item={item} cardStyle={cardStyle} />
))}
</SimpleGrid>
);
}
function ProgressBarsDisplay({ data }: { data: Record<string, unknown> }) {
const bars = (data.bars as Array<{ label: string; unit: string; color: string; value: number; max?: number }>) ?? [];
const showPercentage = (data.showPercentage as boolean) ?? true;
const barSize = (data.barSize as string) ?? "md";
const sizeMap: Record<string, number> = { sm: 8, md: 14, lg: 22 };
return (
<Stack h="100%" justify="center" gap="sm" p="sm">
{bars.map((bar, i) => {
const max = bar.max ?? 100;
const pct = max > 0 ? Math.min((bar.value / max) * 100, 100) : 0;
return (
<Stack key={i} gap={4}>
<Group justify="space-between" wrap="nowrap">
<Text size="xs" fw={500}>
{bar.label}
</Text>
<Text size="xs" c="dimmed">
{showPercentage ? `${pct.toFixed(0)}%` : `${bar.value}${bar.unit ? ` ${bar.unit}` : ""}`}
{bar.max !== undefined && showPercentage
? ` (${bar.value}/${max}${bar.unit ? ` ${bar.unit}` : ""})`
: ""}
</Text>
</Group>
<Progress value={pct} size={sizeMap[barSize] ?? 14} color={bar.color} radius="sm" />
</Stack>
);
})}
</Stack>
);
}
function StatusIndicatorDisplay({ data }: { data: Record<string, unknown> }) {
const items = (data.items as Array<{ label: string; value: string; isGood: boolean }>) ?? [];
const layout = (data.layout as string) ?? "list";
const dotSize = (data.dotSize as string) ?? "md";
const dotSizeMap: Record<string, number> = { sm: 8, md: 10, lg: 14 };
const size = dotSizeMap[dotSize] ?? 10;
const renderItem = (item: { label: string; value: string; isGood: boolean }, i: number) => (
<Group key={i} gap="xs" wrap="nowrap">
<div
style={{
width: size,
height: size,
borderRadius: "50%",
backgroundColor: item.isGood ? "var(--mantine-color-green-6)" : "var(--mantine-color-red-6)",
flexShrink: 0,
}}
/>
<Text size="sm" fw={500}>
{item.label}
</Text>
<Text size="xs" c="dimmed" ml="auto">
{item.value}
</Text>
</Group>
);
if (layout === "grid") {
return (
<SimpleGrid cols={2} spacing="xs" p="sm" h="100%">
{items.map(renderItem)}
</SimpleGrid>
);
}
return (
<Stack h="100%" justify="center" gap="xs" p="sm">
{items.map(renderItem)}
</Stack>
);
}
function CountGridDisplay({ data }: { data: Record<string, unknown> }) {
const items = (data.items as Array<{ label: string; unit: string; value: unknown }>) ?? [];
const columns = (data.columns as number) ?? 2;
const valueSize = (data.valueSize as string) ?? "md";
const sizeMap: Record<string, string> = { sm: "sm", md: "md", lg: "lg" };
return (
<SimpleGrid cols={columns} spacing="xs" p="sm" h="100%">
{items.map((item, i) => (
<Stack key={i} align="center" justify="center" gap={0}>
<Text size={sizeMap[valueSize] ?? "md"} fw={700} lh={1.2}>
{String(item.value ?? "—")}
{item.unit ? ` ${item.unit}` : ""}
</Text>
<Text size="xs" c="dimmed" tt="uppercase" ta="center" lh={1.3} style={{ letterSpacing: "0.03em" }}>
{item.label}
</Text>
</Stack>
))}
</SimpleGrid>
);
}
function openJsonInBrowser(json: unknown) {
const blob = new Blob([JSON.stringify(json, null, 2)], { type: "application/json" });
const blobUrl = URL.createObjectURL(blob);
window.open(blobUrl);
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
}
function RawDisplay({ data }: { data: Record<string, unknown> }) {
const maxHeight = (data.maxHeight as number) ?? 300;
const jsonString = JSON.stringify(data.data, null, 2);
return (
<Stack gap={4} p="xs">
<Group justify="flex-end">
<Tooltip label="Open in browser JSON viewer">
<ActionIcon variant="subtle" size="sm" onClick={() => openJsonInBrowser(data.data)}>
<IconExternalLink size={14} />
</ActionIcon>
</Tooltip>
</Group>
<ScrollArea mah={maxHeight}>
<Code block style={{ fontSize: 12 }}>
{jsonString}
</Code>
</ScrollArea>
</Stack>
);
}
function ActionButtonDisplay({ data }: { data: Record<string, unknown> }) {
const t = useScopedI18n("widget.customApi");
const { openConfirmModal } = useConfirmModal();
const executeMutation = clientApi.customWidget.execute.useMutation();
const executeMutation = clientApi.widget.customApi.executeBaseAction.useMutation();
const [lastSuccess, setLastSuccess] = useState(false);
const buttonLabel = (data.buttonLabel as string) ?? "Execute";
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 definitionId = data.widgetDefinitionId as string | undefined;
const itemId = data.widgetItemId as string | undefined;
const isEditMode = data.isEditMode === true;
const handleExecute = async () => {
if (!definitionId) return;
const handleExecute = async (confirmed: boolean) => {
if (!itemId || isEditMode) return;
setLastSuccess(false);
try {
const result = await executeMutation.mutateAsync({ definitionId });
const result = await executeMutation.mutateAsync({ itemId, confirmed });
if (result.success) {
setLastSuccess(true);
showSuccessNotification({ title: buttonLabel, message: successMessage });
@@ -360,14 +47,14 @@ function ActionButtonDisplay({ data }: { data: Record<string, unknown> }) {
};
const handleClick = () => {
if (confirmText) {
if (confirmText || requiresConfirmation) {
openConfirmModal({
title: buttonLabel,
children: confirmText,
onConfirm: () => void handleExecute(),
children: confirmText || t("executeConfirm"),
onConfirm: () => void handleExecute(true),
});
} else {
void handleExecute();
void handleExecute(false);
}
};
@@ -380,6 +67,7 @@ function ActionButtonDisplay({ data }: { data: Record<string, unknown> }) {
loading={executeMutation.isPending}
leftSection={lastSuccess ? <IconCheck size={20} /> : <IconPlayerPlay size={20} />}
variant={lastSuccess ? "light" : "filled"}
disabled={!itemId || isEditMode}
>
{executeMutation.isPending ? t("executing") : buttonLabel}
</Button>
@@ -387,20 +75,14 @@ function ActionButtonDisplay({ data }: { data: Record<string, unknown> }) {
);
}
export const displayComponents: Record<string, ComponentType<{ data: Record<string, unknown> }>> = {
singleValue: SingleValueDisplay,
keyValue: KeyValueDisplay,
table: TableDisplay,
statGrid: StatGridDisplay,
progressBars: ProgressBarsDisplay,
statusIndicator: StatusIndicatorDisplay,
countGrid: CountGridDisplay,
raw: RawDisplay,
actionButton: ActionButtonDisplay,
customJsx: CustomJsxDisplay,
};
export const createDisplayComponents = (openJsonLabel: string) =>
createCustomWidgetDisplayComponents({
actionButton: ActionButtonDisplay,
customJsx: CustomJsxDisplay,
openJsonLabel,
});
export default function CustomApiWidget({ options }: WidgetComponentProps<"customApi">) {
export default function CustomApiWidget({ options, itemId, isEditMode }: WidgetComponentProps<"customApi">) {
const t = useScopedI18n("widget.customApi");
const { definitionId, refreshInterval } = options;
@@ -417,20 +99,46 @@ export default function CustomApiWidget({ options }: WidgetComponentProps<"custo
);
}
return <CustomApiWidgetInner definitionId={definitionId} refreshInterval={refreshInterval as number} />;
return (
<CustomApiWidgetInner
definitionId={definitionId}
itemId={itemId}
isEditMode={isEditMode}
refreshInterval={refreshInterval as number}
/>
);
}
function CustomApiWidgetInner({ definitionId, refreshInterval }: { definitionId: string; refreshInterval: number }) {
function CustomApiWidgetInner({
definitionId,
itemId,
isEditMode,
refreshInterval,
}: {
definitionId: string;
itemId: string | undefined;
isEditMode: boolean;
refreshInterval: number;
}) {
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(
{ definitionId },
{ itemId: itemId ?? "" },
{
enabled: Boolean(itemId),
refetchInterval: (query) => {
const result = query.state.data as Record<string, unknown> | undefined;
if (result?.type === "actionButton" || result?.type === "disabled") return false;
if (
result?.type === "actionButton" ||
result?.type === "disabled" ||
result?.type === "networkAccessNeedsReview"
) {
return false;
}
return intervalMs;
},
retry: (failureCount, err) => {
@@ -455,7 +163,7 @@ function CustomApiWidgetInner({ definitionId, refreshInterval }: { definitionId:
<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") : error.message}
{isNotFound ? t("definitionNotFound") : tCustomJsx("requestFailed")}
</Text>
</Stack>
</Center>
@@ -477,9 +185,25 @@ function CustomApiWidgetInner({ definitionId, refreshInterval }: { definitionId:
);
}
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" ? { ...widgetData, widgetDefinitionId: definitionId } : widgetData;
const enrichedData =
dataType === "actionButton" || dataType === "customJsx"
? { ...widgetData, widgetDefinitionId: definitionId, widgetItemId: itemId, isEditMode }
: widgetData;
return <Component data={enrichedData} />;
}
@@ -1,12 +1,34 @@
import { MantineProvider } from "@mantine/core";
import * as MantineCharts from "@mantine/charts";
import * as MantineCore from "@mantine/core";
import * as MantineDates from "@mantine/dates";
import { act, createElement } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
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 { renderSafeJsx, SafeJsxError, sanitizeCustomJsxProps } from "@homarr/custom-widgets/jsx";
import CustomJsxDisplay from "./custom-jsx-display";
import { SAFE_BINDINGS } from "./jsx-whitelist";
import { createWhitelistedComponents, SAFE_BINDINGS } from "./jsx-whitelist";
const WHITELISTED_COMPONENTS = createWhitelistedComponents({ copy: "Copy", copied: "Copied" });
vi.mock("@homarr/translation/client", () => ({
useScopedI18n: () => (key: string, params?: Record<string, string>) =>
key === "templateWarnings" ? `${params?.count ?? "0"} template warning(s):` : key,
}));
const isComponentLikeExport = ([name, value]: [string, unknown]): boolean => {
if (!/^[A-Z][A-Za-z0-9]*$/.test(name) || name.endsWith("Context")) return false;
if (typeof value === "function") return true;
if (!value || typeof value !== "object") return false;
const reactType = (value as { $$typeof?: unknown }).$$typeof;
return reactType === Symbol.for("react.forward_ref") || reactType === Symbol.for("react.memo");
};
describe("CustomJsxDisplay", () => {
let container: HTMLDivElement;
@@ -29,8 +51,8 @@ describe("CustomJsxDisplay", () => {
});
});
afterEach(() => {
root?.unmount();
afterEach(async () => {
await act(async () => root?.unmount());
container?.remove();
});
@@ -64,6 +86,147 @@ describe("CustomJsxDisplay", () => {
expect(container.textContent).toContain("Safe");
});
it("supports bounded collection callbacks and safe string helpers", async () => {
await renderDisplay({
template:
'<Stack>{data.items.filter((item) => item.enabled).slice(0, 2).map((item, i) => <Text key={i}>{String(i + 1) + ". " + item.name.toUpperCase()}</Text>)}</Stack>',
data: {
items: [
{ name: "alpha", enabled: true },
{ name: "hidden", enabled: false },
{ name: "beta", enabled: true },
{ name: "gamma", enabled: true },
],
},
});
expect(container.textContent).toContain("1. ALPHA");
expect(container.textContent).toContain("2. BETA");
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>',
data: { value: 1 },
});
expect(container.textContent).toContain("reflective property");
});
it.each([
'<Text>{data["constr\\u0075ctor"]}</Text>',
'<Text>{data["__pro" + "to__"]}</Text>',
'<Text>{String["b" + "ind"](null)}</Text>',
"<Text>{data.items.map.call(null, (item) => item)}</Text>",
"<Text>{data.items.map.apply(null, [])}</Text>",
])("blocks Unicode, computed, and call/apply reflective escapes", (template) => {
expect(() =>
renderSafeJsx({
template,
components: { Text: (() => null) as never },
bindings: SAFE_BINDINGS({ items: [1] }),
}),
).toThrow(/reflective property/);
});
it("never resolves inherited data properties", () => {
const inherited = Object.create({ secret: "leak" }) as Record<string, unknown>;
inherited.visible = "safe";
const bindings = SAFE_BINDINGS(inherited);
const data = bindings.data as Record<string, unknown>;
expect(data.visible).toBe("safe");
expect(data.secret).toBeUndefined();
expect(Object.getPrototypeOf(data)).toBeNull();
});
it("blocks direct global calls but allows benign text containing fetch", async () => {
await renderDisplay({ template: "<Text>fetch is available through controlled requests</Text>", data: {} });
expect(container.textContent).toContain("fetch is available through controlled requests");
await act(async () => {
root.render(
createElement(
MantineProvider,
null,
createElement(CustomJsxDisplay, { data: { template: '<Text>{fetch("/private")}</Text>', data: {} } }),
),
);
});
expect(container.textContent).toContain("Unknown binding: fetch");
});
it("strips polymorphic, callback, unsafe URL, and escaping style props", () => {
const props = sanitizeCustomJsxProps({
component: "iframe",
renderRoot: "iframe",
withinPortal: true,
onClick: () => undefined,
href: "javascript:alert(1)",
style: { position: "fixed", zIndex: 999_999, flex: 1, color: "red" },
});
expect(props).toEqual({ style: { flex: 1, color: "red" } });
});
it("enforces collection and template length budgets", () => {
const bindings = SAFE_BINDINGS({ items: [1, 2, 3] });
expect(() =>
renderSafeJsx({
template: "<Stack>{data.items.map((item) => <Text>{item}</Text>)}</Stack>",
components: {
Text: ((props: { children?: unknown }) => props.children) as never,
Stack: (() => null) as never,
},
bindings,
budgets: { maxCollectionItems: 2 },
}),
).toThrow(/collection limit/);
expect(() => renderSafeJsx({ template: " ".repeat(50_001), components: {}, bindings: {} })).toThrow(
new SafeJsxError("Template exceeds the 50000 character limit"),
);
expect(() =>
renderSafeJsx({
template: '<Text>{"x".padStart(1000, "x")}</Text>',
components: { Text: (() => null) as never },
bindings: {},
budgets: { maxStringLength: 10 },
}),
).toThrow(/string length limit/);
expect(() =>
renderSafeJsx({
template: "<B/>".repeat(10_000),
components: { B: (() => null) as never },
bindings: {},
}),
).toThrow("Template exceeded the rendered node limit (10000)");
});
describe("safe bindings security", () => {
it("exposes wrapper functions, not native constructors", () => {
const bindings = SAFE_BINDINGS({ value: 7 });
@@ -153,6 +316,17 @@ describe("CustomJsxDisplay", () => {
expect(math.abs(-5)).toBe(5);
});
it("Date helpers only return primitives and getDay returns the weekday", () => {
const bindings = SAFE_BINDINGS({});
const date = bindings.Date as {
create: (value: string) => number;
getDay: (value: string) => number;
};
expect(typeof date.create("2024-01-07T12:00:00Z")).toBe("number");
expect(date.getDay("2024-01-07T12:00:00Z")).toBe(0);
});
it("JSON.stringify works correctly", () => {
const bindings = SAFE_BINDINGS({});
const json = bindings.JSON as { stringify: (v: unknown) => string };
@@ -176,3 +350,205 @@ describe("CustomJsxDisplay", () => {
});
});
});
describe("Custom JSX component registry", () => {
it.each([
["@mantine/core", MantineCore],
["@mantine/charts", MantineCharts],
["@mantine/dates", MantineDates],
] as const)("classifies every component-like %s export", (packageName, moduleExports) => {
const classified = new Set(
customJsxComponentRegistry
.filter((component) => component.package === packageName)
.flatMap(({ name }) => [name, name.replaceAll(".", "")]),
);
const unclassified = Object.entries(moduleExports)
.filter(isComponentLikeExport)
.map(([name]) => name)
.filter((name) => !classified.has(name));
expect(unclassified, `Unclassified ${packageName} component exports`).toEqual([]);
});
it("has a runtime implementation for every enabled registry component", () => {
const missing = enabledCustomJsxComponents
.map(({ name }) => name)
.filter((name) => !Object.hasOwn(WHITELISTED_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) });
describe("new collection/string methods", () => {
let container: HTMLDivElement;
let root: Root;
afterEach(async () => {
await act(async () => root?.unmount());
container?.remove();
});
const renderAndGetText = async (template: string, data: Record<string, unknown> = {}) => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root.render(createElement(MantineProvider, null, createElement(CustomJsxDisplay, { data: { template, data } })));
});
return container.textContent ?? "";
};
it("find returns the first matching item", async () => {
const text = await renderAndGetText(`<Text>{data.items.find((x) => x.id === 2).name}</Text>`, {
items: [
{ id: 1, name: "a" },
{ id: 2, name: "b" },
],
});
expect(text).toContain("b");
});
it("findIndex returns correct index", async () => {
const text = await renderAndGetText(`<Text>{data.items.findIndex((x) => x > 5)}</Text>`, { items: [1, 3, 7, 9] });
expect(text).toContain("2");
});
it("some returns boolean", async () => {
const text = await renderAndGetText(`<Text>{data.items.some((x) => x > 5) ? "yes" : "no"}</Text>`, {
items: [1, 2, 3],
});
expect(text).toContain("no");
});
it("every returns boolean", async () => {
const text = await renderAndGetText(`<Text>{data.items.every((x) => x > 0) ? "all" : "nope"}</Text>`, {
items: [1, 2, 3],
});
expect(text).toContain("all");
});
it("sort with comparator returns stable order on NaN", async () => {
const text = await renderAndGetText(
`<Text>{data.items.sort((a, b) => a.v - b.v).map((x) => x.n).join(",")}</Text>`,
{ items: [{ n: "b", v: 2 }, { n: "a", v: 1 }, { n: "c" }] },
);
expect(text).toContain("a,");
expect(text).toContain("b");
});
it("reduce accumulates values", async () => {
const text = await renderAndGetText(`<Text>{data.nums.reduce((acc, n) => acc + n, 0)}</Text>`, {
nums: [1, 2, 3],
});
expect(text).toContain("6");
});
it("at returns element by negative index", async () => {
const text = await renderAndGetText(`<Text>{data.items.at(-1)}</Text>`, { items: ["a", "b", "c"] });
expect(text).toContain("c");
});
it("flat(2) throws SafeJsxError", () => {
expect(() => renderTemplate(`<Text>{data.items.flat(2)}</Text>`, { items: [[1, [2]]] })).toThrow(SafeJsxError);
});
it("flat(1) flattens one level", async () => {
const text = await renderAndGetText(`<Text>{data.items.flat().join(",")}</Text>`, { items: [[1, 2], [3]] });
expect(text).toContain("1,2,3");
});
it("reverse returns reversed copy", async () => {
const text = await renderAndGetText(`<Text>{data.items.reverse().join(",")}</Text>`, { items: [1, 2, 3] });
expect(text).toContain("3,2,1");
});
it("string replaceAll works", async () => {
const text = await renderAndGetText(`<Text>{data.s.replaceAll("o", "0")}</Text>`, { s: "foobar" });
expect(text).toContain("f00bar");
});
it("string replaceAll with empty search is a no-op", async () => {
const text = await renderAndGetText(`<Text>{data.s.replaceAll("", "x")}</Text>`, { s: "abc" });
expect(text).toContain("abc");
});
it("string repeat caps at budget", async () => {
const text = await renderAndGetText(`<Text>{data.s.repeat(3)}</Text>`, { s: "hi" });
expect(text).toContain("hihihi");
});
it("string indexOf and lastIndexOf return positions", async () => {
const text = await renderAndGetText(`<Text>{data.s.indexOf("l")}-{data.s.lastIndexOf("l")}</Text>`, {
s: "hello",
});
expect(text).toContain("2-3");
});
it("string trimStart and trimEnd work", async () => {
const text = await renderAndGetText(`<Text>{data.s.trimStart().trimEnd()}</Text>`, { s: " hi " });
expect(text).toContain("hi");
});
it("filter(Boolean) removes falsy values", async () => {
const text = await renderAndGetText(`<Text>{data.items.filter(Boolean).join(",")}</Text>`, {
items: ["a", "", "b", null, "c"],
});
expect(text).toContain("a,b,c");
});
it("pop returns last element", async () => {
const text = await renderAndGetText(`<Text>{data.url.split("/").filter(Boolean).pop()}</Text>`, {
url: "https://pokeapi.co/api/v2/pokemon/1/",
});
expect(text).toContain("1");
});
it("String(n).padStart works for number formatting", async () => {
const text = await renderAndGetText(`<Text>{String(data.n + 1).padStart(4, "0")}</Text>`, { n: 5 });
expect(text).toContain("0006");
});
it("number arithmetic in string concatenation", async () => {
const text = await renderAndGetText(`<Text>{"https://example.com/" + (data.index + 1) + ".png"}</Text>`, {
index: 3,
});
expect(text).toContain("https://example.com/4.png");
});
});
describe("shared Custom JSX examples", () => {
const sampleData: Record<string, unknown> = {
name: "Server A",
status: "online",
value: 42,
count: 1351,
results: [{ name: "bulbasaur" }, { name: "ivysaur" }],
holdings: [{ symbol: "HOMR", price: 42.5, change: 1.2, history: [38, 40, 42.5] }],
timeline: [
{ date: "2026-07-11", value: 40 },
{ date: "2026-07-12", value: 42.5 },
],
albumArt: "https://example.com/cover.jpg",
album: "Album",
title: "Track",
artist: "Artist",
progress: 55,
playing: true,
shuffle: false,
state: "on",
};
it.each(customJsxExamples)("parses and creates the $id renderer tree", (example) => {
const rendered = renderSafeJsx({
template: example.template,
components: WHITELISTED_COMPONENTS,
bindings: SAFE_BINDINGS(sampleData),
});
expect(rendered.warnings).toEqual([]);
expect(rendered.node).not.toBeNull();
});
});
@@ -1,74 +1,47 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import JsxParser from "react-jsx-parser";
import { Alert, Stack, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useMemo } from "react";
import { WHITELISTED_COMPONENTS, SAFE_BINDINGS } from "./jsx-whitelist";
import { useScopedI18n } from "@homarr/translation/client";
import { CustomJsxRenderer, parseRequestCapabilities } from "@homarr/custom-widgets/runtime";
const MAX_PARSE_ERRORS = 5;
import { createWhitelistedComponents, SAFE_BINDINGS } from "./jsx-whitelist";
import { WidgetDefinitionProvider } from "./widget-definition-context";
function appendParseError(prev: string[], message: string): string[] {
if (prev.length >= MAX_PARSE_ERRORS) return prev;
if (prev.includes(message)) return prev;
return [...prev, message];
}
export { CUSTOM_JSX_METHOD_COLORS } from "@homarr/custom-widgets/runtime";
export default function CustomJsxDisplay({ data }: { data: Record<string, unknown> }) {
const template = String(data.template ?? "");
const apiData = data.data;
const [parseErrors, setParseErrors] = useState<string[]>([]);
const bindings = useMemo(() => SAFE_BINDINGS(apiData), [apiData]);
useEffect(() => {
setParseErrors([]);
}, [template, bindings]);
const handleError = useCallback((error: Error) => {
setParseErrors((prev) => appendParseError(prev, error.message));
}, []);
if (!template.trim()) {
return (
<Alert color="gray" variant="light" p="xs">
<Text size="xs" c="dimmed">
No JSX template configured
</Text>
</Alert>
);
}
return (
<Stack gap={0} h="100%">
<JsxParser
jsx={template}
components={WHITELISTED_COMPONENTS as never}
bindings={bindings}
disableKeyGeneration
componentsOnly
allowUnknownElements={false}
blacklistedAttrs={[/^on.+/i, /^dangerously/i]}
blacklistedTags={["script", "iframe", "object", "embed", "form", "style", "link", "meta", "base"]}
onError={handleError}
renderError={({ error }) => (
<Alert color="red" variant="light" icon={<IconAlertTriangle size={16} />} p="xs">
<Text size="xs">{String(error)}</Text>
</Alert>
)}
/>
{parseErrors.length > 0 && (
<Alert color="yellow" variant="light" p="xs" mt="xs">
<Text size="xs" c="dimmed">
{parseErrors.length} template warning(s):
</Text>
{parseErrors.map((msg) => (
<Text key={msg} size="xs" c="dimmed" style={{ fontFamily: "monospace" }}>
{msg}
</Text>
))}
</Alert>
)}
</Stack>
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 renderer = (
<CustomJsxRenderer
template={String(data.template ?? "")}
data={data.data}
requestCapabilities={capabilities}
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 ? (
<WidgetDefinitionProvider
definitionId={String(data.widgetDefinitionId ?? "")}
itemId={typeof data.widgetItemId === "string" ? data.widgetItemId : undefined}
previewSessionId={typeof data.previewSessionId === "string" ? data.previewSessionId : undefined}
previewLiveActions={data.previewLiveActions === true}
isEditMode={data.isEditMode === true}
requestCapabilities={capabilities}
>
{renderer}
</WidgetDefinitionProvider>
) : (
renderer
);
}
@@ -1 +0,0 @@
export { extractDisplayData } from "@homarr/api/custom-widget/display-data";
@@ -0,0 +1,242 @@
import {
IconActivity,
IconAlertCircle,
IconAlertTriangle,
IconApi,
IconArrowDown,
IconArrowLeft,
IconArrowRight,
IconArrowUp,
IconBell,
IconBluetooth,
IconBolt,
IconBookmark,
IconBug,
IconCalendar,
IconCamera,
IconChartBar,
IconChartLine,
IconChartPie,
IconCheck,
IconChevronDown,
IconChevronLeft,
IconChevronRight,
IconChevronUp,
IconCircleCheck,
IconCircleX,
IconClipboard,
IconClock,
IconCloud,
IconCloudOff,
IconCode,
IconCompass,
IconCopy,
IconCpu,
IconDatabase,
IconDeviceDesktop,
IconDeviceMobile,
IconDownload,
IconDroplet,
IconEdit,
IconExternalLink,
IconEye,
IconEyeOff,
IconFile,
IconFlag,
IconFlame,
IconFolder,
IconGauge,
IconGitBranch,
IconHeart,
IconHistory,
IconHome,
IconInfoCircle,
IconKey,
IconLink,
IconLoader,
IconLock,
IconLockOpen,
IconMail,
IconMap,
IconMapPin,
IconMinus,
IconMoon,
IconMusic,
IconNetwork,
IconPhoto,
IconPin,
IconPlayerPause,
IconPlayerPlay,
IconPlayerSkipBack,
IconPlayerSkipForward,
IconPlayerStop,
IconPlus,
IconPower,
IconProgress,
IconHeartbeat,
IconRefresh,
IconReload,
IconSearch,
IconServer,
IconSettings,
IconShield,
IconStar,
IconSun,
IconTerminal,
IconThermometer,
IconTrash,
IconTrendingDown,
IconTrendingUp,
IconUpload,
IconUser,
IconUsers,
IconVideo,
IconVolume,
IconVolume2,
IconVolumeOff,
IconWifi,
IconWifiOff,
IconWorld,
IconX,
IconSpeedboat,
} from "@tabler/icons-react";
function tablerIconExportToKey(exportName: string): string {
return exportName
.slice(4)
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2")
.replace(/([A-Za-z])(\d)/g, "$1-$2")
.toLowerCase();
}
const TABLER_ICON_IMPORTS = {
IconCheck,
IconX,
IconPlus,
IconMinus,
IconArrowUp,
IconArrowDown,
IconArrowLeft,
IconArrowRight,
IconChevronUp,
IconChevronDown,
IconChevronLeft,
IconChevronRight,
IconHome,
IconSettings,
IconUser,
IconUsers,
IconSearch,
IconBell,
IconMail,
IconHeart,
IconStar,
IconBookmark,
IconFlag,
IconPin,
IconPlayerPlay,
IconPlayerPause,
IconPlayerStop,
IconPlayerSkipForward,
IconPlayerSkipBack,
IconVolume,
IconVolumeOff,
IconVolume2,
IconWifi,
IconWifiOff,
IconBluetooth,
IconCloud,
IconCloudOff,
IconServer,
IconDatabase,
IconCpu,
IconDeviceDesktop,
IconDeviceMobile,
IconFolder,
IconFile,
IconDownload,
IconUpload,
IconRefresh,
IconReload,
IconPower,
IconBolt,
IconFlame,
IconDroplet,
IconSun,
IconMoon,
IconEye,
IconEyeOff,
IconLock,
IconLockOpen,
IconShield,
IconKey,
IconAlertTriangle,
IconAlertCircle,
IconInfoCircle,
IconCircleCheck,
IconCircleX,
IconTrash,
IconEdit,
IconCopy,
IconClipboard,
IconExternalLink,
IconLink,
IconPhoto,
IconCamera,
IconMusic,
IconVideo,
IconCalendar,
IconClock,
IconHistory,
IconMap,
IconMapPin,
IconCompass,
IconTerminal,
IconCode,
IconBug,
IconGitBranch,
IconNetwork,
IconWorld,
IconApi,
IconChartBar,
IconChartLine,
IconChartPie,
IconTrendingUp,
IconTrendingDown,
IconActivity,
IconProgress,
IconLoader,
IconThermometer,
IconGauge,
IconSpeedboat,
IconHeartbeat,
} as const;
const TABLER_ICON_MAP: Record<string, ComponentType<never>> = {
...Object.fromEntries(
Object.entries(TABLER_ICON_IMPORTS).map(([exportName, IconComponent]) => [
tablerIconExportToKey(exportName),
IconComponent,
]),
),
pulse: IconHeartbeat,
};
function isValidIconProp(value: unknown): value is string | number {
return typeof value === "number" || typeof value === "string";
}
export function SafeTablerIcon(props: Record<string, unknown>) {
const name = props.name;
if (typeof name !== "string") return null;
const IconComponent = TABLER_ICON_MAP[name];
if (!IconComponent) return null;
const iconProps: Record<string, unknown> = {};
if (isValidIconProp(props.size)) iconProps.size = props.size;
if (typeof props.color === "string") iconProps.color = props.color;
if (isValidIconProp(props.stroke)) iconProps.stroke = props.stroke;
return createElement(IconComponent as never, iconProps);
}
import { createElement } from "react";
import type { ComponentType } from "react";
@@ -1,228 +0,0 @@
"use client";
import type { ReactNode } from "react";
import { Children, isValidElement, useEffect, useRef, useState } from "react";
import { ActionIcon, Badge, Collapse, Group, Stack, Tabs, Text, UnstyledButton } from "@mantine/core";
import { IconChevronDown, IconChevronLeft, IconChevronRight, IconChevronUp } from "@tabler/icons-react";
interface PaginatedListProps {
children: ReactNode;
pageSize?: number;
}
export function PaginatedList({ children, pageSize = 6 }: PaginatedListProps) {
const items = Children.toArray(children);
const totalPages = Math.max(1, Math.ceil(items.length / pageSize));
const [page, setPage] = useState(0);
const prevCountRef = useRef(items.length);
useEffect(() => {
if (items.length !== prevCountRef.current) {
setPage(0);
prevCountRef.current = items.length;
}
}, [items.length]);
const clampedPage = Math.min(page, totalPages - 1);
const start = clampedPage * pageSize;
const visible = items.slice(start, start + pageSize);
return (
<Stack gap="xs">
<div>{visible}</div>
{totalPages > 1 && (
<Group justify="center" gap="xs">
<ActionIcon
variant="subtle"
size="sm"
disabled={clampedPage === 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
<IconChevronLeft size={14} />
</ActionIcon>
<Text size="xs" c="dimmed">
{clampedPage + 1} / {totalPages}
</Text>
<ActionIcon
variant="subtle"
size="sm"
disabled={clampedPage >= totalPages - 1}
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
>
<IconChevronRight size={14} />
</ActionIcon>
</Group>
)}
</Stack>
);
}
interface TabsContainerProps {
children: ReactNode;
defaultTab?: string;
}
function isTabPanel(child: ReactNode): child is React.ReactElement<TabPanelProps> {
return isValidElement<TabPanelProps>(child) && child.type === TabPanel && typeof child.props.value === "string";
}
export function TabsContainer({ children, defaultTab }: TabsContainerProps) {
const panels = Children.toArray(children).filter(isTabPanel);
const firstPanel = panels[0];
const panelValues = new Set(panels.map((p) => p.props.value));
const resolvedDefault = defaultTab && panelValues.has(defaultTab) && defaultTab;
const initialTab = resolvedDefault || firstPanel?.props.value || null;
const [activeTab, setActiveTab] = useState<string | null>(initialTab);
return (
<Tabs value={activeTab} onChange={setActiveTab}>
<Tabs.List>
{panels.map((panel) => (
<Tabs.Tab key={panel.props.value} value={panel.props.value}>
{panel.props.label ?? panel.props.value}
</Tabs.Tab>
))}
</Tabs.List>
{panels.map((panel) => (
<Tabs.Panel key={panel.props.value} value={panel.props.value} pt="sm">
{panel.props.children}
</Tabs.Panel>
))}
</Tabs>
);
}
interface TabPanelProps {
children: ReactNode;
value: string;
label?: string;
}
export function TabPanel({ children }: TabPanelProps) {
return <>{children}</>;
}
interface CollapsibleProps {
children: ReactNode;
title: string;
defaultOpen?: boolean;
}
const COLLAPSE_CHEVRONS = {
closed: IconChevronDown,
open: IconChevronUp,
} as const;
const COLLAPSE_STATE: Record<0 | 1, keyof typeof COLLAPSE_CHEVRONS> = {
0: "closed",
1: "open",
};
export function Collapsible({ children, title, defaultOpen = false }: CollapsibleProps) {
const [opened, setOpened] = useState(defaultOpen);
const Chevron = COLLAPSE_CHEVRONS[COLLAPSE_STATE[Number(opened) as 0 | 1]];
return (
<Stack gap={0}>
<UnstyledButton onClick={() => setOpened((o) => !o)} py={4}>
<Group gap="xs" wrap="nowrap">
<Chevron size={14} />
<Text size="sm" fw={600}>
{title}
</Text>
</Group>
</UnstyledButton>
<Collapse expanded={opened}>{children}</Collapse>
</Stack>
);
}
interface StatBarProps {
value: number;
max?: number;
label?: string;
color?: string;
}
function calcPct(value: number, max: number): number {
if (max <= 0) return 0;
return Math.min((value / max) * 100, 100);
}
export function StatBar({ value, max = 100, label, color = "blue" }: StatBarProps) {
const pct = calcPct(value, max);
return (
<Group gap="xs" wrap="nowrap">
{label && (
<Text size="xs" w={80} ta="right" c="dimmed">
{label}
</Text>
)}
<div
style={{
flex: 1,
height: 8,
borderRadius: 4,
background: "var(--mantine-color-default-border)",
overflow: "hidden",
}}
>
<div
style={{
width: `${pct}%`,
height: "100%",
borderRadius: 4,
background: `var(--mantine-color-${color}-6)`,
transition: "width 0.3s ease",
}}
/>
</div>
<Text size="xs" w={30} c="dimmed">
{value}
</Text>
</Group>
);
}
interface TypeBadgeProps {
type: string;
size?: "xs" | "sm" | "md" | "lg" | "xl";
}
const TYPE_COLORS: Record<string, string> = {
normal: "gray",
fire: "red",
water: "blue",
electric: "yellow",
grass: "green",
ice: "cyan",
fighting: "orange",
poison: "grape",
ground: "yellow",
flying: "indigo",
psychic: "pink",
bug: "lime",
rock: "orange",
ghost: "violet",
dragon: "indigo",
dark: "dark",
steel: "gray",
fairy: "pink",
};
function normalizeType(type: unknown): string {
if (typeof type === "string") return type;
if (type == null) return "";
return String(type);
}
export function TypeBadge({ type, size = "sm" }: TypeBadgeProps) {
const typeStr = normalizeType(type);
const color = TYPE_COLORS[typeStr.toLowerCase()] ?? "gray";
return (
<Badge color={color} size={size} variant="filled" tt="capitalize">
{typeStr}
</Badge>
);
}
@@ -1,192 +1,12 @@
import type { ComponentType } from "react";
import { createElement } from "react";
import {
Stack,
Group,
Flex,
Grid,
SimpleGrid,
Center,
Space,
Container,
AspectRatio,
Text,
Title,
Code,
Highlight,
Mark,
Kbd,
Blockquote,
Anchor,
NumberFormatter,
Badge,
Card,
Paper,
Alert,
ThemeIcon,
ColorSwatch,
Table,
List,
Timeline,
Accordion,
Indicator,
Pill,
Spoiler,
Progress,
RingProgress,
Skeleton,
Loader,
Image,
Avatar,
BackgroundImage,
Tooltip,
Divider,
ScrollArea,
} from "@mantine/core";
import {
AreaChart,
BarChart,
LineChart,
DonutChart,
PieChart,
RadarChart,
RadialBarChart,
Sparkline,
} from "@mantine/charts";
import { PaginatedList, TabsContainer, TabPanel, Collapsible, StatBar, TypeBadge } from "./jsx-interactive-components";
import { createCustomJsxBindings, createCustomJsxComponents } from "@homarr/custom-widgets/jsx";
const SAFE_URL_PATTERN = /^https?:\/\//i;
import { SafeTablerIcon } from "./jsx-icon-adapter";
function isSafeUrl(url: unknown): boolean {
if (typeof url !== "string") return false;
return SAFE_URL_PATTERN.test(url) || url.startsWith("/") || url.startsWith("#");
}
// 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 }) =>
createCustomJsxComponents({ TablerIcon: SafeTablerIcon as ComponentType<never>, copyLabels });
function SafeAnchor(props: Record<string, unknown>) {
const href = props.href;
const safeHref = href && isSafeUrl(href) ? href : undefined;
return createElement(Anchor as never, { ...props, href: safeHref });
}
export const WHITELISTED_COMPONENTS: Record<string, ComponentType<never>> = {
Stack,
Group,
Flex,
Grid,
"Grid.Col": Grid.Col,
SimpleGrid,
Center,
Space,
Container,
AspectRatio,
Text,
Title,
Code,
Highlight,
Mark,
Kbd,
Blockquote,
Anchor: SafeAnchor,
NumberFormatter,
Badge,
Card,
"Card.Section": Card.Section,
Paper,
Alert,
ThemeIcon,
ColorSwatch,
Table,
"Table.Thead": Table.Thead,
"Table.Tbody": Table.Tbody,
"Table.Tr": Table.Tr,
"Table.Th": Table.Th,
"Table.Td": Table.Td,
List,
"List.Item": List.Item,
Timeline,
"Timeline.Item": Timeline.Item,
Accordion,
"Accordion.Item": Accordion.Item,
"Accordion.Control": Accordion.Control,
"Accordion.Panel": Accordion.Panel,
Indicator,
Pill,
Spoiler,
Progress,
"Progress.Section": Progress.Section,
RingProgress,
Skeleton,
Loader,
Image,
Avatar,
"Avatar.Group": Avatar.Group,
BackgroundImage,
Tooltip,
Divider,
ScrollArea,
AreaChart,
BarChart,
LineChart,
DonutChart,
PieChart,
RadarChart,
RadialBarChart,
Sparkline,
PaginatedList,
TabsContainer,
TabPanel,
Collapsible,
StatBar,
TypeBadge,
};
function sanitizeData(obj: unknown): unknown {
if (obj === null || obj === undefined) return obj;
if (typeof obj !== "object") return obj;
if (Array.isArray(obj)) return obj.map(sanitizeData);
const safe: Record<string, unknown> = Object.create(null);
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
if (key === "constructor" || key === "__proto__" || key === "prototype") continue;
safe[key] = sanitizeData(value);
}
return safe;
}
const safeMath: Record<string, unknown> = Object.create(null);
safeMath.round = (v: number) => Math.round(v);
safeMath.floor = (v: number) => Math.floor(v);
safeMath.ceil = (v: number) => Math.ceil(v);
safeMath.abs = (v: number) => Math.abs(v);
safeMath.min = (...args: number[]) => Math.min(...args);
safeMath.max = (...args: number[]) => Math.max(...args);
safeMath.pow = (b: number, e: number) => Math.pow(b, e);
safeMath.sqrt = (v: number) => Math.sqrt(v);
safeMath.PI = Math.PI;
Object.freeze(safeMath);
const safeJSON: Record<string, unknown> = Object.create(null);
safeJSON.stringify = (v: unknown) => JSON.stringify(v);
Object.freeze(safeJSON);
const safeArray: Record<string, unknown> = Object.create(null);
safeArray.isArray = (v: unknown) => Array.isArray(v);
Object.freeze(safeArray);
const safeObject: Record<string, unknown> = Object.create(null);
safeObject.keys = (v: object) => Object.keys(v);
safeObject.values = (v: object) => Object.values(v);
safeObject.entries = (v: object) => Object.entries(v);
Object.freeze(safeObject);
export const SAFE_BINDINGS = (apiData: unknown) => ({
data: sanitizeData(apiData),
String: (v: unknown) => String(v),
Number: (v: unknown) => Number(v),
Boolean: (v: unknown) => Boolean(v),
Math: safeMath,
JSON: safeJSON,
Array: safeArray,
Object: safeObject,
});
export const SAFE_BINDINGS = createCustomJsxBindings;
@@ -0,0 +1,105 @@
"use client";
import type { ReactNode } from "react";
import { fetchApi, clientApi } from "@homarr/api/client";
import type {
CustomJsxRequestCapability,
CustomWidgetRuntimeMessages,
CustomWidgetRuntimePort,
} from "@homarr/custom-widgets/runtime";
import { CustomWidgetRuntimeProvider } from "@homarr/custom-widgets/runtime";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
interface WidgetDefinitionProviderProps {
itemId?: string;
definitionId?: string;
previewSessionId?: string;
previewLiveActions?: boolean;
isEditMode?: boolean;
requestCapabilities?: readonly CustomJsxRequestCapability[];
children: ReactNode;
}
export function WidgetDefinitionProvider(props: WidgetDefinitionProviderProps) {
const t = useScopedI18n("widget.customApi.customJsx");
const utils = clientApi.useUtils();
const { openConfirmModal } = useConfirmModal();
const messages: CustomWidgetRuntimeMessages = {
migrationRequired: t("migrationRequired"),
unsavedPreview: t("unsavedPreview"),
invalidParams: t("invalidParams"),
loadRequest: t("loadRequest"),
requestFailed: t("requestFailed"),
loading: t("loading"),
retry: t("retry"),
widgetItemUnavailable: t("widgetItemUnavailable"),
actionsDisabledEditMode: t("actionsDisabledEditMode"),
actionSimulated: t("actionSimulated"),
actionCompleted: t("actionCompleted"),
confirmDelete: t("confirmDelete"),
toggle: t("toggle"),
refresh: t("refresh"),
};
const port: CustomWidgetRuntimePort = {
query: async (input, signal) => {
if (input.itemId) {
return fetchApi.widget.customApi.queryRequest.query(
{ itemId: input.itemId, requestId: input.requestId, params: input.params },
{ signal },
);
}
return fetchApi.customWidget.previewQuery.query(
{ sessionId: input.previewSessionId ?? "", requestId: input.requestId, params: input.params },
{ signal },
);
},
executeAction: (input) =>
input.itemId
? fetchApi.widget.customApi.executeAction.mutate({
itemId: input.itemId,
requestId: input.requestId,
params: input.params,
confirmed: input.confirmed,
})
: fetchApi.customWidget.previewAction.mutate({
sessionId: input.previewSessionId ?? "",
requestId: input.requestId,
params: input.params,
confirmed: input.confirmed,
}),
invalidate: async ({ itemId, previewSessionId, targets }) => {
if (previewSessionId) {
await utils.customWidget.previewQuery.invalidate();
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());
await Promise.all(tasks);
},
confirm: ({ title, message }) =>
new Promise((resolve) => {
openConfirmModal({ title, children: message, onConfirm: () => resolve(true), onCancel: () => resolve(false) });
}),
notify: ({ kind, ...notification }) =>
kind === "success" ? showSuccessNotification(notification) : showErrorNotification(notification),
};
return (
<CustomWidgetRuntimeProvider
itemId={props.itemId}
definitionId={props.definitionId}
previewSessionId={props.previewSessionId}
previewLiveActions={props.previewLiveActions}
isEditMode={props.isEditMode ?? false}
requestCapabilities={props.requestCapabilities ?? []}
port={port}
messages={messages}
>
{props.children}
</CustomWidgetRuntimeProvider>
);
}