"use client"; import type { ErrorInfo, ReactNode } from "react"; import { Component, useCallback, useEffect, useMemo, useState } from "react"; import { Alert, Badge, Box, Group, Popover, Stack, Text } from "@mantine/core"; import { IconAlertTriangle, IconNetwork } from "@tabler/icons-react"; import { useScopedI18n } from "@homarr/translation/client"; import { WHITELISTED_COMPONENTS, SAFE_BINDINGS } from "./jsx-whitelist"; import { renderSafeJsx } from "./safe-jsx-interpreter"; import type { CustomJsxRequestCapability } from "./widget-definition-context"; import { WidgetDefinitionProvider } from "./widget-definition-context"; const MAX_PARSE_ERRORS = 5; 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 const CUSTOM_JSX_METHOD_COLORS: Record = { GET: "blue", POST: "orange", PUT: "yellow", DELETE: "red", PATCH: "grape", }; const CAPABILITY_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]); const CAPABILITY_KINDS = new Set(["query", "action"]); const CAPABILITY_PERMISSIONS = new Set(["view", "modify", "full"]); function parseRequestCapabilities(value: unknown): CustomJsxRequestCapability[] { if (!Array.isArray(value)) return []; return value.flatMap((candidate) => { if (!candidate || typeof candidate !== "object") return []; const record = candidate as Record; if ( typeof record.id !== "string" || typeof record.kind !== "string" || !CAPABILITY_KINDS.has(record.kind) || typeof record.method !== "string" || !CAPABILITY_METHODS.has(record.method) || typeof record.minimumBoardPermission !== "string" || !CAPABILITY_PERMISSIONS.has(record.minimumBoardPermission) ) { return []; } return [record as unknown as CustomJsxRequestCapability]; }); } interface RendererErrorBoundaryProps { children: ReactNode; fallback: (error: Error) => ReactNode; onError: (error: Error) => void; } interface RendererErrorBoundaryState { error: Error | null; } class RendererErrorBoundary extends Component { public state: RendererErrorBoundaryState = { error: null }; public static getDerivedStateFromError(error: Error): RendererErrorBoundaryState { return { error }; } public componentDidCatch(error: Error, _info: ErrorInfo): void { this.props.onError(error); } public render(): ReactNode { return this.state.error ? this.props.fallback(this.state.error) : this.props.children; } } function ErrorAlert({ error }: { error: Error }) { return ( } p="xs"> {error.message} ); } const renderErrorFallback = (error: Error) => ; export default function CustomJsxDisplay({ data }: { data: Record }) { const t = useScopedI18n("widget.customApi.customJsx"); const template = String(data.template ?? ""); const apiData = data.data; const definitionId = String(data.widgetDefinitionId ?? ""); const itemId = typeof data.widgetItemId === "string" ? data.widgetItemId : undefined; const previewSessionId = typeof data.previewSessionId === "string" ? data.previewSessionId : undefined; const previewLiveActions = data.previewLiveActions === true; const isEditMode = data.isEditMode === true; const [parseErrors, setParseErrors] = useState([]); const bindings = useMemo(() => SAFE_BINDINGS(apiData), [apiData]); const requestCapabilities = useMemo( () => parseRequestCapabilities(data.requestCapabilities), [data.requestCapabilities], ); const rendered = useMemo(() => { try { const result = renderSafeJsx({ template, components: WHITELISTED_COMPONENTS, bindings }); return { ...result, error: null }; } catch (error) { const normalized = error instanceof Error ? error : new Error(String(error)); return { node: null, warnings: [], error: normalized }; } }, [bindings, template]); useEffect(() => { setParseErrors([]); }, [template, bindings]); const handleError = useCallback((error: Error) => { setParseErrors((prev) => appendParseError(prev, error.message)); }, []); useEffect(() => { rendered.warnings.forEach((warning) => handleError(new Error(warning))); if (rendered.error) handleError(rendered.error); }, [handleError, rendered]); if (!template.trim()) { return ( {t("noTemplate")} ); } return ( {requestCapabilities.length > 0 && ( } style={{ cursor: "pointer" }} > {t("interactive")} {t("networkCapabilities")} {requestCapabilities.map((capability) => ( {capability.id} {capability.method} {capability.minimumBoardPermission} ))} )} {rendered.error ? ( ) : ( {rendered.node} )} {parseErrors.length > 0 && ( {t("templateWarnings", { count: String(parseErrors.length) })} {parseErrors.map((msg) => ( {msg} ))} )} ); }