feat!: release v2 (#6545)
Co-authored-by: Artur <145776561+arturict@users.noreply.github.com> Co-authored-by: arturict <arturict@users.noreply.github.com> Co-authored-by: Thomas <thomas@ajnart.fr> Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com> Co-authored-by: Danura30082 <danura30082+github@gmail.com> Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com> Co-authored-by: ajnart <thomas@ajnart.fr> Co-authored-by: ajnart <thomas@ajnart.dev> Co-authored-by: chenjun <chenjun@foxmail.com> Co-authored-by: Wikibear <wikibear@users.noreply.github.com> Co-authored-by: Bergasha <bergasha@gmail.com> Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com> Co-authored-by: Lucas Araujo <lucas.lra@gmail.com> Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com> Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social> Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com> Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com> Co-authored-by: thrawnx <tight.bulb9384@fastmail.com> Co-authored-by: Chris <58918656+nordada@users.noreply.github.com> Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com> Co-authored-by: Yanis <naamaneyanis@gmail.com> Co-authored-by: Wikibear <me@sej.de> Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
This commit is contained in:
3286 files changed
+284386
-139176
No files matched your search
@@ -1,493 +1,131 @@
|
||||
"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 { Button, Center, 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 { useSession } from "@homarr/auth/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Link, zoomCompensatedSize } from "@homarr/ui";
|
||||
|
||||
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { isLegacyCustomWidgetMigrationError, isTerminalCustomWidgetDefinitionError } from "./migration-state";
|
||||
|
||||
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 [lastSuccess, setLastSuccess] = useState(false);
|
||||
|
||||
const buttonLabel = (data.buttonLabel as string) ?? "Execute";
|
||||
const buttonColor = (data.buttonColor as string) ?? "blue";
|
||||
const confirmText = (data.confirmText as string) || "";
|
||||
const successMessage = (data.successMessage as string) || t("executeSuccess");
|
||||
const definitionId = data.widgetDefinitionId as string | undefined;
|
||||
|
||||
const handleExecute = async () => {
|
||||
if (!definitionId) return;
|
||||
setLastSuccess(false);
|
||||
try {
|
||||
const result = await executeMutation.mutateAsync({ definitionId });
|
||||
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) {
|
||||
openConfirmModal({
|
||||
title: buttonLabel,
|
||||
children: confirmText,
|
||||
onConfirm: () => void handleExecute(),
|
||||
});
|
||||
} else {
|
||||
void handleExecute();
|
||||
}
|
||||
};
|
||||
|
||||
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"}
|
||||
>
|
||||
{executeMutation.isPending ? t("executing") : buttonLabel}
|
||||
</Button>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
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 default function CustomApiWidget({ options }: 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} refreshInterval={refreshInterval as number} />;
|
||||
}
|
||||
|
||||
function CustomApiWidgetInner({ definitionId, refreshInterval }: { definitionId: string; refreshInterval: number }) {
|
||||
const t = useScopedI18n("widget.customApi");
|
||||
const tCustomWidget = useScopedI18n("customWidget");
|
||||
const safeInterval = Number.isFinite(refreshInterval) ? refreshInterval : 30;
|
||||
const intervalMs = Math.max(1000, safeInterval * 1000);
|
||||
const { data, isLoading, error } = clientApi.widget.customApi.getData.useQuery(
|
||||
{ definitionId },
|
||||
export default function CustomApiWidget({
|
||||
options,
|
||||
itemId,
|
||||
isEditMode,
|
||||
removeItem,
|
||||
}: WidgetComponentProps<"customApi">) {
|
||||
const t = useI18n("widget.customApi");
|
||||
const tCustomJsx = useI18n("widget.customApi.customJsx");
|
||||
const { data: session } = useSession();
|
||||
const isAdmin = session?.user.permissions.includes("admin") ?? false;
|
||||
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 ?? "" },
|
||||
{
|
||||
refetchInterval: (query) => {
|
||||
const result = query.state.data as Record<string, unknown> | undefined;
|
||||
if (result?.type === "actionButton" || result?.type === "disabled") 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) =>
|
||||
isTerminalCustomWidgetDefinitionError(currentQuery.state.error) ? false : intervalMs,
|
||||
retry: (failureCount, error) => !isTerminalCustomWidgetDefinitionError(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 <WidgetQueryLoadingState />;
|
||||
if (query.error) {
|
||||
const errorCode = query.error.data?.code;
|
||||
const migrationRequired = isLegacyCustomWidgetMigrationError(query.error);
|
||||
if (isEditMode && errorCode === "NOT_FOUND") {
|
||||
return <Unavailable message={t("editModePending")} />;
|
||||
}
|
||||
const isUnavailable = errorCode === "NOT_FOUND" || errorCode === "FORBIDDEN";
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
<Unavailable
|
||||
message={
|
||||
migrationRequired
|
||||
? t("migrationRequired")
|
||||
: isUnavailable
|
||||
? t("definitionNotFound")
|
||||
: errorCode === "PRECONDITION_FAILED"
|
||||
? t("configurationNeedsRepair")
|
||||
: tCustomJsx("requestFailed")
|
||||
}
|
||||
danger={!isUnavailable && !migrationRequired}
|
||||
removeLabel={isUnavailable ? t("removeFromBoard") : undefined}
|
||||
actionLabel={migrationRequired && isAdmin ? t("manageMigration") : undefined}
|
||||
actionHref={migrationRequired && isAdmin ? "/manage/custom-widgets" : undefined}
|
||||
supplementaryMessage={migrationRequired && !isAdmin ? t("contactAdmin") : undefined}
|
||||
onRemove={removeItem}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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") : error.message}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const widgetData = data as Record<string, unknown>;
|
||||
const dataType = widgetData.type as string | undefined;
|
||||
|
||||
if (dataType === "disabled") {
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Text c="dimmed" size="sm">
|
||||
{tCustomWidget("widget.disabled")}
|
||||
</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const Component = dataType ? displayComponents[dataType] : undefined;
|
||||
if (Component) {
|
||||
const enrichedData = dataType === "actionButton" ? { ...widgetData, widgetDefinitionId: definitionId } : 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 Unavailable({
|
||||
message,
|
||||
danger = false,
|
||||
removeLabel,
|
||||
actionLabel,
|
||||
actionHref,
|
||||
supplementaryMessage,
|
||||
onRemove,
|
||||
}: {
|
||||
message: string;
|
||||
danger?: boolean;
|
||||
removeLabel?: string;
|
||||
actionLabel?: string;
|
||||
actionHref?: string;
|
||||
supplementaryMessage?: string;
|
||||
onRemove?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Center h="100%" p="sm">
|
||||
<Stack align="center" gap="xs">
|
||||
<IconAlertTriangle
|
||||
style={zoomCompensatedSize(32)}
|
||||
color={`var(--mantine-color-${danger ? "red" : "yellow"}-6)`}
|
||||
/>
|
||||
<Text c="dimmed" size="sm" ta="center">
|
||||
{message}
|
||||
</Text>
|
||||
{supplementaryMessage && (
|
||||
<Text size="sm" fw={600} ta="center">
|
||||
{supplementaryMessage}
|
||||
</Text>
|
||||
)}
|
||||
{actionLabel && actionHref && (
|
||||
<Button component={Link} href={actionHref} size="compact-sm" color="yellow" variant="light">
|
||||
{actionLabel}
|
||||
</Button>
|
||||
)}
|
||||
{removeLabel && onRemove && (
|
||||
<Button size="compact-sm" color="red" variant="light" onClick={onRemove}>
|
||||
{removeLabel}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,47 @@
|
||||
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 } from "vitest";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { Root } from "react-dom/client";
|
||||
|
||||
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 { SAFE_BINDINGS } from "./jsx-whitelist";
|
||||
import { createCustomWidgetComponents, SAFE_BINDINGS } from "./jsx-components";
|
||||
import { SAFE_TABLER_ICON_NAMES } from "./jsx-icon-adapter";
|
||||
|
||||
const CUSTOM_WIDGET_COMPONENTS = createCustomWidgetComponents({ copy: "Copy", copied: "Copied" });
|
||||
|
||||
vi.mock("@homarr/translation/client", () => ({
|
||||
useI18n: () => (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", () => {
|
||||
it("keeps safe Tabler icon metadata synchronized with the runtime", () => {
|
||||
expect(SAFE_TABLER_ICON_NAMES).toEqual(customJsxTablerIconNames);
|
||||
});
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
@@ -27,10 +60,18 @@ describe("CustomJsxDisplay", () => {
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
Object.defineProperty(globalThis, "ResizeObserver", {
|
||||
writable: true,
|
||||
value: class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
root?.unmount();
|
||||
afterEach(async () => {
|
||||
await act(async () => root?.unmount());
|
||||
container?.remove();
|
||||
});
|
||||
|
||||
@@ -38,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 })),
|
||||
),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -53,6 +101,245 @@ 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.each([
|
||||
{
|
||||
id: "seed-dispatcharr-channels",
|
||||
expected: "BBC One HD",
|
||||
data: {
|
||||
channels: {
|
||||
count: 1,
|
||||
results: [
|
||||
{
|
||||
id: 41,
|
||||
uuid: "channel-41",
|
||||
channel_number: 101,
|
||||
name: "BBC One",
|
||||
effective_channel_number: 101,
|
||||
effective_name: "BBC One HD",
|
||||
channel_group_id: 3,
|
||||
epg_data_id: 62,
|
||||
is_catchup: true,
|
||||
catchup_days: 7,
|
||||
hidden_from_output: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
status: { channels: { loading: false, ok: true, status: 200 } },
|
||||
},
|
||||
{
|
||||
id: "seed-karakeep-bookmarks",
|
||||
expected: "A useful article",
|
||||
data: {
|
||||
bookmarks: {
|
||||
nextCursor: null,
|
||||
bookmarks: [
|
||||
{
|
||||
id: "bookmark-1",
|
||||
title: "A useful article",
|
||||
favourited: true,
|
||||
summary: "Saved for later",
|
||||
tags: [{ id: "tag-1", name: "reading" }],
|
||||
content: { type: "link", title: "A useful article", url: "https://example.com" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
status: { bookmarks: { loading: false, ok: true, status: 200 } },
|
||||
},
|
||||
{
|
||||
id: "seed-mealie-today",
|
||||
expected: "Mushroom risotto",
|
||||
data: {
|
||||
meals: [
|
||||
{
|
||||
id: 51,
|
||||
entryType: "dinner",
|
||||
title: "",
|
||||
text: "",
|
||||
recipe: { name: "Mushroom risotto", recipeServings: 4, totalTime: "45 minutes", rating: 4.5 },
|
||||
},
|
||||
],
|
||||
},
|
||||
status: { meals: { loading: false, ok: true, status: 200 } },
|
||||
},
|
||||
{
|
||||
id: "seed-romm-library",
|
||||
expected: "F-Zero GX",
|
||||
data: {
|
||||
stats: { PLATFORMS: 4, ROMS: 120, SAVES: 10, STATES: 8 },
|
||||
recent: {
|
||||
items: [
|
||||
{
|
||||
id: 702,
|
||||
name: "F-Zero GX",
|
||||
fs_name_no_ext: "F-Zero GX (USA)",
|
||||
platform_display_name: "Nintendo GameCube",
|
||||
created_at: "2026-09-19T12:20:00Z",
|
||||
missing_from_fs: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
status: {
|
||||
stats: { loading: false, ok: true, status: 200 },
|
||||
recent: { loading: false, ok: true, status: 200 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "seed-tubearchivist-queue",
|
||||
expected: "Building a resilient home server",
|
||||
data: {
|
||||
channels: { doc_count: 86, subscribed_true: 42 },
|
||||
downloads: { pending: 13, pending_videos: 9, pending_shorts: 0, pending_streams: 4 },
|
||||
queue: {
|
||||
data: [
|
||||
{
|
||||
youtube_id: "video-1",
|
||||
title: "Building a resilient home server",
|
||||
channel_name: "Homelab Notes",
|
||||
duration: "18:42",
|
||||
status: "pending",
|
||||
vid_type: "videos",
|
||||
},
|
||||
],
|
||||
paginate: { current_page: 0, last_page: 2, total_hits: 13 },
|
||||
},
|
||||
},
|
||||
status: {
|
||||
channels: { loading: false, ok: true, status: 200 },
|
||||
downloads: { loading: false, ok: true, status: 200 },
|
||||
queue: { loading: false, ok: true, status: 200 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "seed-frigate-alerts",
|
||||
expected: "front door",
|
||||
data: {
|
||||
alerts: [
|
||||
{
|
||||
id: "alert-1",
|
||||
camera: "front_door",
|
||||
start_time: Math.floor(Date.now() / 1000),
|
||||
end_time: null,
|
||||
severity: "alert",
|
||||
has_been_reviewed: false,
|
||||
data: { objects: ["person"], verified_objects: [], zones: ["porch"] },
|
||||
},
|
||||
],
|
||||
},
|
||||
status: { alerts: { loading: false, ok: true, status: 200 } },
|
||||
},
|
||||
{
|
||||
id: "seed-frigate-system",
|
||||
expected: "front_door",
|
||||
data: {
|
||||
stats: {
|
||||
camera_fps: 5,
|
||||
detection_fps: 1.4,
|
||||
process_fps: 5,
|
||||
skipped_fps: 0,
|
||||
service: { version: "0.16.1", uptime: 86400, storage: {} },
|
||||
cameras: {
|
||||
front_door: {
|
||||
camera_fps: 5,
|
||||
connection_quality: "excellent",
|
||||
reconnects_last_hour: 0,
|
||||
stalls_last_hour: 0,
|
||||
},
|
||||
},
|
||||
detectors: {},
|
||||
},
|
||||
},
|
||||
status: { stats: { loading: false, ok: true, status: 200 } },
|
||||
},
|
||||
{
|
||||
id: "seed-frigate-live-streams",
|
||||
expected: "front_door",
|
||||
data: {
|
||||
streams: {
|
||||
front_door: {
|
||||
producers: [{ medias: ["video, recvonly, H264"] }],
|
||||
consumers: [{ remote_addr: "127.0.0.1:8554" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
status: { streams: { loading: false, ok: true, status: 200 } },
|
||||
},
|
||||
])("renders the bundled integration widget $id", async ({ id, expected, data, status }) => {
|
||||
const bundled = BUNDLED_CUSTOM_WIDGETS.find((candidate) => candidate.id === id)?.widget;
|
||||
if (!bundled) throw new Error(`Bundled widget '${id}' was not found`);
|
||||
|
||||
await renderDisplay({
|
||||
template: bundled.template,
|
||||
data,
|
||||
status,
|
||||
options: getCustomWidgetDefaultOptions(bundled.options ?? {}),
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain(expected);
|
||||
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>',
|
||||
@@ -64,6 +351,127 @@ 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("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 +561,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 +595,209 @@ 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(CUSTOM_WIDGET_COMPONENTS, name));
|
||||
|
||||
expect(missing, "Enabled components without a runtime implementation").toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
const renderTemplate = (template: string, data: Record<string, unknown> = {}) =>
|
||||
renderSafeJsx({ template, components: CUSTOM_WIDGET_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.widget.template,
|
||||
components: CUSTOM_WIDGET_COMPONENTS,
|
||||
bindings: {
|
||||
...SAFE_BINDINGS(sampleData),
|
||||
status: {},
|
||||
options: getCustomWidgetDefaultOptions(example.widget.options ?? {}),
|
||||
inputs: {},
|
||||
},
|
||||
});
|
||||
|
||||
expect(rendered.warnings).toEqual([]);
|
||||
expect(rendered.node).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,74 +1,121 @@
|
||||
"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 { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import { WHITELISTED_COMPONENTS, SAFE_BINDINGS } from "./jsx-whitelist";
|
||||
import { isRecord } from "@homarr/common";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { CustomJsxRendererMessages, CustomWidgetPublishedQueryState } from "@homarr/custom-widgets/runtime";
|
||||
import { CustomJsxRenderer, parseRequestCapabilities } from "@homarr/custom-widgets/runtime";
|
||||
|
||||
const MAX_PARSE_ERRORS = 5;
|
||||
import { createCustomWidgetComponents, SAFE_BINDINGS } from "./jsx-components";
|
||||
import { InactiveWidgetDefinitionProvider, 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";
|
||||
|
||||
const EMPTY_QUERY_STATE: Record<string, CustomWidgetPublishedQueryState> = {};
|
||||
|
||||
interface ScopedQueryState {
|
||||
key: string;
|
||||
values: Record<string, CustomWidgetPublishedQueryState>;
|
||||
}
|
||||
|
||||
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()) {
|
||||
const t = useI18n("widget.customApi.customJsx");
|
||||
const actionT = useI18n("common.action");
|
||||
const diagnosticsT = useI18n("customWidget.editor.diagnostics");
|
||||
const capabilities = useMemo(() => parseRequestCapabilities(data.requestCapabilities), [data.requestCapabilities]);
|
||||
const copyLabel = actionT("copy");
|
||||
const copiedLabel = t("copied");
|
||||
const components = useMemo(
|
||||
() => createCustomWidgetComponents({ copy: copyLabel, copied: copiedLabel }),
|
||||
[copiedLabel, copyLabel],
|
||||
);
|
||||
const bindingTypeConflict = useCallback<CustomJsxRendererMessages["bindingTypeConflict"]>(
|
||||
(name, firstType, secondType) => diagnosticsT("runtimeBindingTypeConflict", { value: name, firstType, secondType }),
|
||||
[diagnosticsT],
|
||||
);
|
||||
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;
|
||||
const queryCacheKey = typeof data.queryCacheKey === "string" ? data.queryCacheKey : undefined;
|
||||
const queryScopeKey = [definitionId, itemId, previewSessionId, queryCacheKey].join("\u001f");
|
||||
const [publishedQueryState, setPublishedQueryState] = useState<ScopedQueryState>(() => ({
|
||||
key: queryScopeKey,
|
||||
values: {},
|
||||
}));
|
||||
const queryState = publishedQueryState.key === queryScopeKey ? publishedQueryState.values : EMPTY_QUERY_STATE;
|
||||
const publishQueryState = useCallback(
|
||||
(requestId: string, value: CustomWidgetPublishedQueryState | null) => {
|
||||
setPublishedQueryState((current) => {
|
||||
const currentValues = current.key === queryScopeKey ? current.values : EMPTY_QUERY_STATE;
|
||||
if (!value) {
|
||||
if (!Object.hasOwn(currentValues, requestId)) {
|
||||
if (current.key === queryScopeKey) return current;
|
||||
return { key: queryScopeKey, values: currentValues };
|
||||
}
|
||||
const next = { ...currentValues };
|
||||
delete next[requestId];
|
||||
return { key: queryScopeKey, values: next };
|
||||
}
|
||||
const previous = currentValues[requestId];
|
||||
if (previous && previous.data === value.data && sameStatus(previous.status, value.status)) {
|
||||
if (current.key === queryScopeKey) return current;
|
||||
return { key: queryScopeKey, values: currentValues };
|
||||
}
|
||||
return { key: queryScopeKey, values: { ...currentValues, [requestId]: value } };
|
||||
});
|
||||
},
|
||||
[queryScopeKey],
|
||||
);
|
||||
const baseData = isRecord(data.data) ? data.data : {};
|
||||
const baseStatus = isRecord(data.status) ? data.status : {};
|
||||
const renderer = (
|
||||
<CustomJsxRenderer
|
||||
template={String(data.template ?? "")}
|
||||
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"),
|
||||
templateWarnings: (count) => t("templateWarnings", { count: String(count) }),
|
||||
bindingTypeConflict,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
if (!itemId && !previewSessionId) {
|
||||
return (
|
||||
<Alert color="gray" variant="light" p="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
No JSX template configured
|
||||
</Text>
|
||||
</Alert>
|
||||
<InactiveWidgetDefinitionProvider definitionId={definitionId} isEditMode={data.isEditMode === true}>
|
||||
{renderer}
|
||||
</InactiveWidgetDefinitionProvider>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
<WidgetDefinitionProvider
|
||||
definitionId={definitionId}
|
||||
itemId={itemId}
|
||||
queryCacheKey={queryCacheKey}
|
||||
previewSessionId={previewSessionId}
|
||||
previewLiveActions={data.previewLiveActions === true}
|
||||
queriesDisabled={data.queriesDisabled === true}
|
||||
isEditMode={data.isEditMode === true}
|
||||
requestCapabilities={capabilities}
|
||||
setQueryState={publishQueryState}
|
||||
>
|
||||
{renderer}
|
||||
</WidgetDefinitionProvider>
|
||||
);
|
||||
}
|
||||
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
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { extractDisplayData } from "@homarr/api/custom-widget/display-data";
|
||||
@@ -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),
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
import { createCustomJsxBindings, createCustomJsxComponents } from "@homarr/custom-widgets/jsx";
|
||||
|
||||
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 createCustomWidgetComponents = (copyLabels: { copy: string; copied: string }) =>
|
||||
createCustomJsxComponents({ TablerIcon: SafeTablerIcon as ComponentType<never>, copyLabels });
|
||||
|
||||
export const SAFE_BINDINGS = createCustomJsxBindings;
|
||||
@@ -0,0 +1,248 @@
|
||||
import {
|
||||
IconActivity,
|
||||
IconAlertCircle,
|
||||
IconAlertTriangle,
|
||||
IconApi,
|
||||
IconArrowDown,
|
||||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
IconArrowUp,
|
||||
IconBell,
|
||||
IconBluetooth,
|
||||
IconBolt,
|
||||
IconBookmark,
|
||||
IconBrandDocker,
|
||||
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,
|
||||
IconPokeball,
|
||||
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,
|
||||
IconBrandDocker,
|
||||
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,
|
||||
IconPokeball,
|
||||
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,
|
||||
};
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
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 +0,0 @@
|
||||
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";
|
||||
|
||||
const SAFE_URL_PATTERN = /^https?:\/\//i;
|
||||
|
||||
function isSafeUrl(url: unknown): boolean {
|
||||
if (typeof url !== "string") return false;
|
||||
return SAFE_URL_PATTERN.test(url) || url.startsWith("/") || url.startsWith("#");
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isLegacyCustomWidgetMigrationError, isTerminalCustomWidgetDefinitionError } from "./migration-state";
|
||||
|
||||
describe("custom widget migration state", () => {
|
||||
it("recognizes the preserved legacy definition error and stops retries", () => {
|
||||
const error = {
|
||||
message: "LEGACY_CUSTOM_WIDGET_MIGRATION_REQUIRED",
|
||||
data: { code: "PRECONDITION_FAILED" },
|
||||
};
|
||||
|
||||
expect(isLegacyCustomWidgetMigrationError(error)).toBe(true);
|
||||
expect(isTerminalCustomWidgetDefinitionError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not label an invalid v2 configuration as a legacy migration", () => {
|
||||
const error = {
|
||||
message: "Custom widget configuration needs repair",
|
||||
data: { code: "PRECONDITION_FAILED" },
|
||||
};
|
||||
|
||||
expect(isLegacyCustomWidgetMigrationError(error)).toBe(false);
|
||||
expect(isTerminalCustomWidgetDefinitionError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows transient request failures to retry", () => {
|
||||
const error = { message: "Request failed", data: { code: "INTERNAL_SERVER_ERROR" } };
|
||||
|
||||
expect(isLegacyCustomWidgetMigrationError(error)).toBe(false);
|
||||
expect(isTerminalCustomWidgetDefinitionError(error)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
const LEGACY_MIGRATION_ERROR = "LEGACY_CUSTOM_WIDGET_MIGRATION_REQUIRED";
|
||||
const TERMINAL_DEFINITION_CODES = new Set(["NOT_FOUND", "FORBIDDEN", "PRECONDITION_FAILED"]);
|
||||
|
||||
export function isLegacyCustomWidgetMigrationError(error: unknown): boolean {
|
||||
return Boolean(error && typeof error === "object" && "message" in error && error.message === LEGACY_MIGRATION_ERROR);
|
||||
}
|
||||
|
||||
export function isTerminalCustomWidgetDefinitionError(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) || typeof data.code !== "string") return false;
|
||||
return TERMINAL_DEFINITION_CODES.has(data.code);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { fetchApi, clientApi } from "@homarr/api/client";
|
||||
import type {
|
||||
CustomWidgetPublishedQueryState,
|
||||
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 { useI18n } from "@homarr/translation/client";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
interface WidgetDefinitionProviderProps {
|
||||
itemId?: string;
|
||||
definitionId?: string;
|
||||
queryCacheKey?: string;
|
||||
previewSessionId?: string;
|
||||
previewLiveActions?: boolean;
|
||||
queriesDisabled?: boolean;
|
||||
isEditMode?: boolean;
|
||||
requestCapabilities?: readonly CustomJsxRequestCapability[];
|
||||
setQueryState?(requestId: string, value: CustomWidgetPublishedQueryState | null): void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
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 = useI18n("widget.customApi.customJsx");
|
||||
const actionT = useI18n("common.action");
|
||||
return {
|
||||
requestIdRequired: t("requestIdRequired"),
|
||||
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: actionT("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) {
|
||||
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 (targets.length === 0) return;
|
||||
const invalidateAll = targets.includes("*");
|
||||
if (previewSessionId) {
|
||||
if (props.queriesDisabled) return;
|
||||
const requests = (props.requestCapabilities ?? []).filter(
|
||||
(request) => request.kind === "query" && (invalidateAll || targets.includes(request.id)),
|
||||
);
|
||||
await fetchApi.customWidget.previewRefresh.mutate({
|
||||
sessionId: previewSessionId,
|
||||
requestIds: requests.map((request) => request.id),
|
||||
});
|
||||
// Preview load data starts as a snapshot, not an active React Query subscription.
|
||||
// Publish refreshed load results so both editor and standalone previews update.
|
||||
await Promise.all(
|
||||
requests
|
||||
.filter((request) => request.trigger === "load")
|
||||
.map(async (request) => {
|
||||
props.setQueryState?.(request.id, { data: null, status: { loading: true } });
|
||||
try {
|
||||
const result = await port.query({ previewSessionId, requestId: request.id, params: {} });
|
||||
props.setQueryState?.(request.id, {
|
||||
data: result.data,
|
||||
status: {
|
||||
loading: false,
|
||||
ok: result.ok,
|
||||
status: result.status,
|
||||
statusText: result.statusText,
|
||||
error: result.error,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
props.setQueryState?.(request.id, {
|
||||
data: null,
|
||||
status: { loading: false, ok: false, error: messages.requestFailed },
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
await queryClient.invalidateQueries({
|
||||
predicate: (query) => {
|
||||
const key = query.queryKey;
|
||||
return (
|
||||
key[0] === "custom-widget" &&
|
||||
key[1] === "preview" &&
|
||||
key[2] === previewSessionId &&
|
||||
(invalidateAll || targets.includes(String(key[3])))
|
||||
);
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!itemId) return;
|
||||
if (invalidateAll) await fetchApi.widget.customApi.refresh.mutate({ itemId });
|
||||
const tasks: Promise<unknown>[] = [
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => {
|
||||
const key = query.queryKey;
|
||||
return (
|
||||
key[0] === "custom-widget" &&
|
||||
key[1] === "item" &&
|
||||
key[2] === itemId &&
|
||||
(invalidateAll || targets.includes(String(key[3])))
|
||||
);
|
||||
},
|
||||
}),
|
||||
];
|
||||
if (
|
||||
invalidateAll ||
|
||||
(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, confirmLabel, destructive }) =>
|
||||
new Promise((resolve) => {
|
||||
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),
|
||||
};
|
||||
return (
|
||||
<CustomWidgetRuntimeProvider
|
||||
itemId={props.itemId}
|
||||
definitionId={props.definitionId}
|
||||
queryCacheKey={props.queryCacheKey}
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user