From aa8410d326e38e5006154418aa921bfeb20206b8 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 12 Jul 2026 00:42:31 +0200 Subject: [PATCH] feat(custom-widgets): full Mantine v9 + SubFetch support for custom JSX Massively expands the custom JSX widget system to support virtually all Mantine v9 components and adds in-widget HTTP request capabilities via a server-proxied SubFetch system. - Whitelist ~100 Mantine components (core, all 16 charts, dates) - Add SubFetch, SubData, ActionButton, ToggleSwitch, RefreshButton - Add server-side subFetch tRPC mutation with same-origin + auth - Show HTTP method warning badges in widget and admin preview - Remove `fetch` from forbidden keywords, increase template to 50KB - Expand bindings: Date.*, parseInt, parseFloat, Array.from, etc. - Update AI prompt with full component reference + demo templates --- .../custom-widgets/_copy-ai-prompt-button.tsx | 343 +++++++------ .../custom-widgets/_custom-widget-form.tsx | 4 +- .../custom-widgets/_custom-widget-preview.tsx | 24 +- packages/api/src/router/widgets/custom-api.ts | 121 +++++ packages/validation/src/custom-widget.ts | 8 +- packages/widgets/package.json | 2 + packages/widgets/src/custom-api/component.tsx | 5 +- .../src/custom-api/custom-jsx-display.tsx | 104 ++-- .../widgets/src/custom-api/jsx-sub-fetch.tsx | 459 ++++++++++++++++++ .../widgets/src/custom-api/jsx-whitelist.ts | 328 +++++++++++-- .../custom-api/widget-definition-context.tsx | 20 + pnpm-lock.yaml | 3 + 12 files changed, 1164 insertions(+), 257 deletions(-) create mode 100644 packages/widgets/src/custom-api/jsx-sub-fetch.tsx create mode 100644 packages/widgets/src/custom-api/widget-definition-context.tsx diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx index d78aafbab..119cf2666 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx @@ -34,219 +34,120 @@ const PROMPT_RULES = ` - For arrays of items, inspect the API response to find the correct paths ## Display Type Guide -- \`singleValue\`: Show one prominent number/text (e.g. total count, status) -- \`keyValue\`: Show labeled pairs like "CPU: 45%" — use \`mappings\` array -- \`table\`: Show tabular data from an array — needs \`tablePath\` for the array and \`columns\` for headers -- \`statGrid\`: Grid of stat cards with optional colors — great for dashboards (e.g. "Movies: 38, Series: 63") -- \`progressBars\`: Visual progress bars with value/max — for storage, quotas, etc. -- \`statusIndicator\`: Green/red dots based on value matching — for service health checks -- \`countGrid\`: Simple grid of counts — like statGrid but simpler, no colors -- \`raw\`: Raw JSON display — for debugging or complex data -- \`actionButton\`: A button that triggers the API call on click — for POST/PUT actions -- \`customJsx\`: Custom JSX layout using whitelisted Mantine components — full creative control over presentation. Set \`displayConfig.template\` to a JSX string. Access API data via \`{data.fieldName}\` bindings (e.g. \`{data.name}\`, \`{data.items[0].title}\`). +- \`singleValue\`: One prominent number/text +- \`keyValue\`: Labeled pairs — use \`mappings\` array +- \`table\`: Tabular data — \`tablePath\` + \`columns\` +- \`statGrid\`: Grid of stat cards with colors +- \`progressBars\`: Visual progress bars with value/max +- \`statusIndicator\`: Green/red dots based on value matching +- \`countGrid\`: Simple count grid +- \`raw\`: Raw JSON display +- \`actionButton\`: Button that triggers the API call on click +- \`customJsx\`: **Full Mantine v9 JSX** — complete creative control. Set \`displayConfig.template\` to a JSX string. -## Custom JSX (\`customJsx\`) — Available Mantine Components -When using \`customJsx\`, the template supports whitelisted Mantine UI components. These are React components from the Mantine v9 library. Use their props as documented below. +## Custom JSX (\`customJsx\`) — Component Reference -### Layout Components -- **Stack** — Vertical flex container. Props: \`gap\` (xs|sm|md|lg|xl or number), \`align\` (stretch|center|flex-start|flex-end), \`justify\` -- **Group** — Horizontal flex container. Props: \`gap\`, \`justify\` (flex-start|center|space-between|space-around|flex-end), \`wrap\` (wrap|nowrap), \`grow\` (boolean) -- **Flex** — Generic flex container. Props: \`direction\` (row|column), \`gap\`, \`align\`, \`justify\`, \`wrap\` -- **Grid** + **Grid.Col** — CSS grid layout. Grid props: \`gutter\` (gap). Grid.Col props: \`span\` (1-12 or "auto"), \`offset\` -- **SimpleGrid** — Auto-column grid. Props: \`cols\` (number), \`spacing\`, \`verticalSpacing\` -- **Center** — Centers content. Props: \`inline\` (boolean) -- **Space** — Empty spacing. Props: \`h\` (height), \`w\` (width) -- **Container** — Max-width wrapper. Props: \`size\` (xs|sm|md|lg|xl), \`fluid\` (boolean) -- **AspectRatio** — Maintains aspect ratio. Props: \`ratio\` (number, e.g. 16/9) +All Mantine v9 components listed below are available. For full Mantine prop documentation, refer to https://mantine.dev/llms.txt or use Context7/web search for any component's detailed API. -### Typography Components -- **Text** — Body text. Props: \`size\` (xs|sm|md|lg|xl), \`fw\` (font-weight number, e.g. 700), \`c\` (color: "dimmed"|"red"|"blue"|...), \`ta\` (text-align), \`tt\` (text-transform: "capitalize"|"uppercase"|"lowercase"), \`td\` (text-decoration), \`lineClamp\` (number), \`truncate\` (boolean), \`span\` (renders as span) -- **Title** — Heading. Props: \`order\` (1-6, maps to h1-h6), \`size\`, \`c\`, \`ta\`, \`tt\` -- **Code** — Inline code. Props: \`color\`, \`block\` (boolean for code block) -- **Highlight** — Text with highlighted substring. Props: \`highlight\` (string or string[]) -- **Mark** — Highlighted text background. Props: \`color\` -- **Kbd** — Keyboard key display -- **Blockquote** — Styled quote. Props: \`color\`, \`cite\`, \`icon\` -- **Anchor** — Link. Props: \`href\`, \`target\`, \`underline\` ("always"|"hover"|"never") -- **NumberFormatter** — Formatted number display. Props: \`value\` (number), \`prefix\` (e.g. "$"), \`suffix\` (e.g. "%"), \`thousandSeparator\` (boolean or string), \`decimalScale\` (number) +### Layout +Box, Stack, Group, Flex, Grid + Grid.Col, SimpleGrid, Center, Space, Container, AspectRatio, Overlay, ScrollArea -### Data Display Components -- **Badge** — Label badge. Props: \`color\`, \`variant\` (filled|light|outline|dot|default), \`size\` (xs|sm|md|lg|xl), \`radius\`, \`tt\` -- **Card** + **Card.Section** — Content card. Card props: \`shadow\` (xs|sm|md|lg|xl), \`padding\`/\`p\`, \`radius\`, \`withBorder\` (boolean). Card.Section props: \`withBorder\`, \`inheritPadding\` -- **Paper** — Surface container. Props: \`shadow\`, \`p\`, \`radius\`, \`withBorder\` -- **Alert** — Notification box. Props: \`color\`, \`variant\` (filled|light|outline), \`title\`, \`icon\` -- **ThemeIcon** — Icon container. Props: \`color\`, \`variant\`, \`size\`, \`radius\` -- **ColorSwatch** — Color preview. Props: \`color\` (CSS color string) -- **Table** + **Table.Thead**, **Table.Tbody**, **Table.Tr**, **Table.Th**, **Table.Td** — HTML table. Table props: \`striped\` (boolean), \`withTableBorder\`, \`withColumnBorders\`, \`highlightOnHover\` -- **List** + **List.Item** — Ordered/unordered list. List props: \`type\` ("ordered"|"unordered"), \`icon\`, \`spacing\` -- **Timeline** + **Timeline.Item** — Vertical timeline. Timeline props: \`active\` (number), \`bulletSize\`. Item props: \`title\`, \`bullet\` -- **Accordion** + **.Item** + **.Control** + **.Panel** — Collapsible sections. Item props: \`value\` (string). Control: clickable header. Panel: content. -- **Indicator** — Dot indicator on content. Props: \`color\`, \`position\`, \`size\`, \`label\`, \`processing\` (boolean) -- **Pill** — Removable tag. Props: \`size\`, \`withRemoveButton\` -- **Spoiler** — Show more/less. Props: \`maxHeight\` (number), \`showLabel\`, \`hideLabel\` +### Typography +Text, Title, Code, Highlight, Mark, Kbd, Blockquote, Anchor (href sanitized), NumberFormatter, Marquee, RollingNumber -### Feedback Components -- **Progress** + **Progress.Section** — Progress bar. Progress props: \`value\` (0-100), \`color\`, \`size\`, \`radius\`, \`striped\`, \`animated\`. Section props: \`value\`, \`color\` -- **RingProgress** — Circular progress. Props: \`sections\` (array of {value, color}), \`size\`, \`thickness\`, \`label\` (ReactNode) -- **Skeleton** — Loading placeholder. Props: \`height\`, \`width\`, \`radius\`, \`visible\` (boolean) -- **Loader** — Spinner. Props: \`size\`, \`color\`, \`type\` (oval|bars|dots) +### Data Display +Badge, Card + Card.Section, Paper, Alert, ThemeIcon, ColorSwatch, Table (+ Thead/Tbody/Tfoot/Caption/Tr/Th/Td), List + List.Item, Timeline + Timeline.Item, Accordion (+ Item/Control/Panel), Indicator, Pill, Spoiler, Progress + Progress.Section, RingProgress, SemiCircleProgress, Skeleton, Loader, Image, Avatar + Avatar.Group, BackgroundImage, Tooltip, Divider, DataList (+ Item/ItemLabel/ItemValue), EmptyState (+ Indicator/Title/Description/Actions), Fieldset, Notification (visual), Rating (read-only) -### Media Components -- **Image** — Responsive image. Props: \`src\`, \`alt\`, \`w\` (width), \`h\` (height), \`radius\`, \`fit\` (cover|contain|fill) -- **Avatar** + **Avatar.Group** — User avatar. Props: \`src\`, \`alt\`, \`size\` (xs|sm|md|lg|xl or number), \`radius\` (sm|xl), \`color\` -- **BackgroundImage** — Background image container. Props: \`src\`, \`radius\` -- **Tooltip** — Hover tooltip. Props: \`label\` (string), \`position\` (top|bottom|left|right), \`withArrow\` -- **Divider** — Horizontal/vertical rule. Props: \`orientation\` (horizontal|vertical), \`label\`, \`labelPosition\`, \`size\` -- **ScrollArea** — Scrollable container. Props: \`h\` (height), \`type\` (auto|always|scroll|hover) +### Navigation +Breadcrumbs, NavLink (href sanitized), Stepper + Stepper.Step, Tabs + Tabs.List + Tabs.Tab + Tabs.Panel, Tree -### Charts (@mantine/charts) -- **AreaChart**, **BarChart**, **LineChart** — Props: \`data\` (array of objects), \`dataKey\` (x-axis key), \`series\` (array of {name, color}), \`h\` (height), \`curveType\` (for Area/Line) -- **DonutChart**, **PieChart** — Props: \`data\` (array of {name, value, color}), \`h\`, \`withLabels\`, \`withLabelsLine\` -- **RadarChart** — Props: \`data\`, \`dataKey\`, \`series\` -- **RadialBarChart** — Props: \`data\` (array of {name, value, color}), \`h\` -- **Sparkline** — Props: \`data\` (flat number array), \`h\`, \`w\`, \`color\`, \`curveType\` +### Interactive Display (visual only — no event handlers) +Button, ActionIcon, Burger, CloseButton, Chip + Chip.Group, Pagination, SegmentedControl, Slider (read-only), Switch (read-only) -### Common Prop Patterns -- **Spacing/sizing:** Most components accept \`p\` (padding), \`m\` (margin), \`px\`/\`py\`/\`mx\`/\`my\`/\`pt\`/\`pb\`/\`pl\`/\`pr\`/\`mt\`/\`mb\`/\`ml\`/\`mr\` with values xs|sm|md|lg|xl or numbers -- **Colors:** Use Mantine color names: "red", "blue", "green", "orange", "violet", "pink", "cyan", "grape", "teal", "yellow", "lime", "indigo", "gray", "dark". Add shade suffix: "red.6", "blue.4" -- **Styles:** Use \`style={{...}}\` for inline CSS. E.g. \`style={{flex: 1, minWidth: 0}}\` -- **Conditional rendering:** Use ternaries: \`{condition ? Yes : No}\` -- **Responsive:** Most size/spacing props accept object: \`{{ base: "sm", md: "lg" }}\` +### Hover Overlays & Menus +HoverCard + HoverCard.Target + HoverCard.Dropdown, Menu + Menu.Target + Menu.Dropdown + Menu.Item + Menu.Label + Menu.Divider -### Interactive Components (built-in, state-managed) -These components manage their own internal state — they provide interactivity without event handlers: +### Utility +CopyButton (click-to-copy, props: \`value\`), Collapse (animate show/hide, props: \`in\`), Transition (animate, props: \`mounted\`, \`transition\`) -**PaginatedList** — Paginates its children with prev/next buttons. -Props: \`pageSize\` (number, default 6) -Usage: Wrap a \`.map()\` expression to paginate results. +### Charts (@mantine/charts — ALL) +AreaChart, BarChart, LineChart, DonutChart, PieChart, RadarChart, RadialBarChart, Sparkline, BubbleChart, CompositeChart, FunnelChart, Heatmap, ScatterChart, SankeyChart, Treemap, BarsList + +### Dates (@mantine/dates) +Calendar (static), MiniCalendar (static), DatePicker (static/read-only), TimeValue + +### SubFetch — In-Widget HTTP Requests (server-proxied) +These components make HTTP requests through the Homarr server proxy (same-origin as widget URL, inherits widget auth). HTTP method warnings are shown in the widget UI. + +**SubFetch** — Fetch data from a sub-endpoint and display results. +Props: \`url\` (relative or absolute), \`method\` (GET|POST|PUT|DELETE|PATCH), \`body\` (JSON string), \`headers\` (JSON string), \`trigger\` ("auto"|"manual"), \`label\`, \`color\`, \`display\` ("json"|"text"), \`path\` (dot-path for display="text") \`\`\`jsx - - {data.results.map(item => - - {item.name} - - )} - + + + + \`\`\` -**TabsContainer + TabPanel** — Tabbed interface with automatic tab switching. -TabsContainer props: \`defaultTab\` (string, optional — defaults to first tab) -TabPanel props: \`value\` (string, required — unique tab ID), \`label\` (string, optional — tab display text) +**SubData** — Reads data from parent SubFetch context. +Props: \`path\` (dot-notation), \`as\` ("Text"|"Title"|"Badge"|"Code"), \`size\`, \`color\`, \`fw\`, \`c\`, \`order\` + +**ActionButton** — Button that fires an HTTP request on click. +Props: \`url\`, \`method\` (default POST), \`body\`, \`headers\`, \`label\`, \`color\`, \`variant\`, \`size\`, \`confirmMessage\`, \`successMessage\`, \`icon\` (play|check|refresh|power|trash), \`fullWidth\`, \`disabled\` \`\`\`jsx - - - {data.description} - - - Total: {data.count} - - + \`\`\` -**Collapsible** — Expandable/collapsible section with a title. -Props: \`title\` (string, required), \`defaultOpen\` (boolean, default false) +**ToggleSwitch** — Switch that sends different payloads for on/off states. +Props: \`url\`, \`method\` (default POST), \`onBody\` (JSON), \`offBody\` (JSON), \`initialValue\`, \`label\`, \`color\`, \`size\`, \`disabled\` \`\`\`jsx - - {data.details} - + \`\`\` -**StatBar** — A horizontal stat bar with label/value (great for RPG-style stats, progress). -Props: \`value\` (number), \`max\` (number, default 100), \`label\` (string), \`color\` (Mantine color) -\`\`\`jsx - - -\`\`\` +**RefreshButton** — Re-fetches the parent widget data. +Props: \`label\`, \`color\`, \`variant\`, \`size\` -**TypeBadge** — A colored badge that maps common type names to colors (normal, fire, water, electric, grass, ice, fighting, poison, ground, flying, psychic, bug, rock, ghost, dragon, dark, steel, fairy). -Props: \`type\` (string), \`size\` (xs|sm|md|lg|xl, default sm) -\`\`\`jsx - - - - -\`\`\` +### Custom Interactive Components (built-in state) +**PaginatedList** — Paginates children. Props: \`pageSize\` (default 6) +**TabsContainer + TabPanel** — Tabbed UI. TabsContainer: \`defaultTab\`. TabPanel: \`value\`, \`label\` +**Collapsible** — Expand/collapse. Props: \`title\`, \`defaultOpen\` +**StatBar** — Horizontal stat bar. Props: \`value\`, \`max\`, \`label\`, \`color\` +**TypeBadge** — Colored type badge (auto-maps: fire→red, water→blue, etc.). Props: \`type\`, \`size\` -**Available bindings in templates:** -- \`data\` — the full API response JSON object -- \`String(v)\`, \`Number(v)\`, \`Boolean(v)\` — type coercion helpers -- \`Math.round\`, \`Math.floor\`, \`Math.ceil\`, \`Math.abs\`, \`Math.min\`, \`Math.max\`, \`Math.pow\`, \`Math.sqrt\`, \`Math.PI\` -- \`JSON.stringify(v)\`, \`Array.isArray(v)\`, \`Object.keys(v)\`, \`Object.values(v)\`, \`Object.entries(v)\` -- Expression arrows for \`.map()\`, \`.filter()\`, \`.slice()\` — e.g. \`{data.items.map(item => {item.name})}\` -- Ternaries for conditionals — e.g. \`{data.count > 0 ? "active" : "idle"}\` +### Bindings +- \`data\` — full API response +- \`String(v)\`, \`Number(v)\`, \`Boolean(v)\`, \`parseInt(v)\`, \`parseFloat(v)\` +- \`Math.round/floor/ceil/abs/min/max/pow/sqrt/PI\` +- \`JSON.stringify(v)\`, \`Array.isArray(v)\`, \`Array.from(v)\`, \`Object.keys/values/entries(v)\` +- \`Date.now()\`, \`Date.create(v)\`, \`Date.toISOString(v)\`, \`Date.toLocaleDateString(v, locale?)\`, \`Date.toLocaleTimeString(v, locale?)\`, \`Date.getTime(v)\`, \`Date.getYear(v)\`, \`Date.getMonth(v)\`, \`Date.getDay(v)\` +- \`encodeURIComponent(v)\`, \`decodeURIComponent(v)\`, \`isNaN(v)\`, \`isFinite(v)\` +- \`.map()\`, \`.filter()\`, \`.slice()\`, ternaries for conditionals -**FORBIDDEN keywords (template will be rejected):** constructor, __proto__, eval, Function, import, require, globalThis, window, document, fetch +**FORBIDDEN keywords:** constructor, __proto__, eval, Function, import, require, globalThis, window, document -**Chart data formats:** -- BarChart/LineChart/AreaChart: \`data={data.items}\` + \`dataKey="month"\` (x-axis key) + \`series={[{ name: "count", color: "blue" }]}\` where series \`name\` matches a numeric key in each data row -- DonutChart/PieChart: \`data={data.items}\` where each item is \`{ name: "Label", value: 42, color: "blue" }\` — NO dataKey or series -- Sparkline: \`data={[1,2,3]}\` — flat number array -- NumberFormatter: \`\` +### Chart Data Formats +- BarChart/LineChart/AreaChart: \`data={items}\` + \`dataKey="x"\` + \`series={[{name:"y",color:"blue"}]}\` +- DonutChart/PieChart: \`data={[{name,value,color}]}\` +- ScatterChart: \`data={items}\` + \`dataKey={xKey}\` + \`series={[{name, color}]}\` +- CompositeChart: same as Bar/Line/Area + \`composedChart\` with type per series +- Sparkline: \`data={[numbers]}\` +- Heatmap: \`data={items}\` + \`dataKey\` + \`series\` -### Full Pokémon Card Example (demonstrating TypeBadge, StatBar, TabsContainer, Collapsible) -API: \`https://pokeapi.co/api/v2/pokemon/charizard\` -\`\`\`jsx - - - - - {data.name} - #{String(data.id).padStart(3, "0")} - - {data.types.map(t => )} - - - - - - - {data.stats.map(s => - 100 ? "green" : "red"} /> - )} - - - - - {data.abilities.map(a => - - {a.ability.name}{a.is_hidden ? " (hidden)" : ""} - - )} - - - - - - {data.moves.slice(0, 20).map(m => - {m.move.name} - )} - - - - - -\`\`\` - -### Paginated List Example -API: \`https://pokeapi.co/api/v2/pokemon?limit=50\` +### Pokédex Example (75 pokemon) +API: \`https://pokeapi.co/api/v2/pokemon?limit=75\` \`\`\`jsx Pokédex - {data.count} Pokémon + {data.count} total {data.results.map((pokemon, i) => - - + + {pokemon.name} - #{String(i + 1)} + #{String(i + 1).padStart(3, "0")} @@ -255,6 +156,90 @@ API: \`https://pokeapi.co/api/v2/pokemon?limit=50\` \`\`\` +### Pokédex with Detail Fetch (SubFetch for sub-resources) +API: \`https://pokeapi.co/api/v2/pokemon?limit=75\` +\`\`\`jsx + + Pokédex + + {data.results.map((pokemon, i) => + + + + + {pokemon.name} + + + + + + + + + + )} + + +\`\`\` + +### Stocks Dashboard (Charts + Live Data) +API: \`https://your-stocks-api.com/portfolio\` +\`\`\`jsx + + + Portfolio + + + + {data.holdings.map(stock => + + {stock.symbol} + + 0 ? "green" : "red"} /> + + )} + + + +\`\`\` + +### Navidrome Mini-Player (Buttons + State) +API: \`https://your-navidrome.local/api/nowplaying\` +\`\`\`jsx + + + + + {data.title} + {data.artist} + + + + + + + + + + + + + +\`\`\` + +### Smart Home Light Switch +API: \`https://your-ha.local/api/states/light.living_room\` +\`\`\`jsx + + + Living Room + {data.state} + + + + +\`\`\` + ## API Response `; diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx index c2820757f..2bb7424b0 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx @@ -1393,9 +1393,9 @@ function DisplayTypeFields({ '\n {data.name}\n {data.description}\n' } minRows={8} - maxRows={20} + maxRows={30} autosize - maxLength={10000} + maxLength={50000} styles={{ input: { fontFamily: "monospace" } }} {...form.getInputProps("template")} /> diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview.tsx index 25a53ffd2..8f19067e2 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview.tsx @@ -2,10 +2,11 @@ import { useMemo } from "react"; import { Alert, Badge, Button, Card, Center, Code, Group, Loader, ScrollArea, Stack, Text, Title } from "@mantine/core"; -import { IconAlertTriangle, IconExternalLink, IconPlayerPlay } from "@tabler/icons-react"; +import { IconAlertTriangle, IconExternalLink, IconNetwork, IconPlayerPlay } from "@tabler/icons-react"; import { useScopedI18n } from "@homarr/translation/client"; import { extractDisplayData } from "@homarr/widgets/custom-api/extract-display-data"; +import { extractHttpMethods } from "@homarr/widgets/custom-api/custom-jsx-display"; import { displayComponents } from "@homarr/widgets/custom-api/component"; @@ -58,6 +59,12 @@ export function CustomWidgetPreview({ return extractDisplayData(cachedJson, displayType, displayConfig) as Record; }, [fetchResult?.success, cachedJson, formValues]); + const httpMethods = useMemo(() => { + if (formValues.displayType !== "customJsx") return []; + const template = (formValues.displayConfig.template as string) ?? ""; + return extractHttpMethods(template); + }, [formValues.displayType, formValues.displayConfig]); + const handleTest = () => { const values = getFormValues(); if (!values.url) return; @@ -80,6 +87,21 @@ export function CustomWidgetPreview({ + {httpMethods.length > 0 && ( + }> + + + Widget makes HTTP requests: + + {httpMethods.map((m) => ( + + {m} + + ))} + + + )} + {testError && ( } p="xs"> {testError} diff --git a/packages/api/src/router/widgets/custom-api.ts b/packages/api/src/router/widgets/custom-api.ts index 09fafa880..c48d7219d 100644 --- a/packages/api/src/router/widgets/custom-api.ts +++ b/packages/api/src/router/widgets/custom-api.ts @@ -13,9 +13,37 @@ import { extractActionButtonDisplay, extractDisplayDataWithFallback } from "../c const logger = createLogger({ module: "widget:customApi" }); const FETCH_TIMEOUT_MS = 10_000; +const MAX_BODY_SIZE = 10_000; + +const HTTP_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const; const validateUrl = (urlString: string): URL => new URL(urlString); +const resolveTargetUrl = (definitionUrl: string, targetUrl: string): URL => new URL(targetUrl, validateUrl(definitionUrl)); + +const assertSameOrigin = (definitionUrl: string, targetUrl: URL): void => { + const definitionOrigin = validateUrl(definitionUrl).origin; + if (targetUrl.origin !== definitionOrigin) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "URL must be on the same domain as the widget definition", + }); + } +}; + +const parseResponseData = async (response: Response): Promise => { + const contentType = response.headers.get("content-type") ?? ""; + if (contentType.includes("application/json")) { + return response.json(); + } + const text = await response.text(); + try { + return JSON.parse(text); + } catch { + return text; + } +}; + export const customApiRouter = createTRPCRouter({ getData: protectedProcedure.input(z.object({ definitionId: z.string() })).query(async ({ ctx, input }) => { const definition = await ctx.db.query.customWidgetDefinitions.findFirst({ @@ -88,4 +116,97 @@ export const customApiRouter = createTRPCRouter({ clearTimeout(timeout); } }), + + subFetch: protectedProcedure + .meta({ + mcp: { + enabled: true, + description: + "Proxy an HTTP request from a custom JSX widget to an external API on the same domain as the widget definition. Applies the definition's auth credentials. Requires definitionId from the widget configuration.", + }, + }) + .input( + z.object({ + definitionId: z.string(), + url: z.string().min(1).max(2048), + method: z.enum(HTTP_METHODS).default("GET"), + body: z.string().max(MAX_BODY_SIZE).optional(), + headers: z.record(z.string(), z.string()).optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + if (input.body && input.body.length > MAX_BODY_SIZE) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Request body exceeds 10KB limit" }); + } + + const definition = await ctx.db.query.customWidgetDefinitions.findFirst({ + where: eq(customWidgetDefinitions.id, input.definitionId), + with: { secrets: true }, + }); + + if (!definition) { + throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" }); + } + + if (!definition.enabled) { + throw new TRPCError({ code: "FORBIDDEN", message: "Widget is disabled" }); + } + + const decryptedSecrets = definition.secrets.map((s) => ({ + kind: s.kind, + value: decryptSecret(s.value), + })); + + const targetUrl = resolveTargetUrl(definition.url, input.url); + assertSameOrigin(definition.url, targetUrl); + + const headers = new Headers({ Accept: "application/json" }); + if (input.headers) { + for (const [key, value] of Object.entries(input.headers)) { + headers.set(key, value); + } + } + + if (input.method !== "GET" && input.body) { + headers.set("Content-Type", "application/json"); + } + + applyAuth(headers, targetUrl, definition.authType, decryptedSecrets, definition.headerName); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + try { + const response = await fetch(targetUrl.toString(), { + method: input.method, + headers, + body: input.method !== "GET" ? input.body : undefined, + redirect: "follow", + signal: controller.signal, + }); + + const data = await parseResponseData(response); + + if (!response.ok) { + return { + ok: false as const, + status: response.status, + data, + error: `HTTP ${response.status}: ${response.statusText}`, + }; + } + + return { ok: true as const, status: response.status, data }; + } catch (error) { + logger.error("SubFetch proxy request failed", { definitionId: input.definitionId, url: input.url, error }); + return { + ok: false as const, + status: 0, + data: null, + error: error instanceof Error ? error.message : "Request failed", + }; + } finally { + clearTimeout(timeout); + } + }), }); diff --git a/packages/validation/src/custom-widget.ts b/packages/validation/src/custom-widget.ts index a9298c20d..7c14ad6f6 100644 --- a/packages/validation/src/custom-widget.ts +++ b/packages/validation/src/custom-widget.ts @@ -258,7 +258,7 @@ const rawDisplayConfigSchema = z.object({ }); const FORBIDDEN_TEMPLATE_PATTERN = - /\bconstructor\b|\b__proto__\b|\bprototype\b|\beval\b|\bFunction\b|\bimport\s*\(|\brequire\b|\bglobalThis\b|\bwindow\b|\bdocument\b|\bfetch\b|\bXMLHttpRequest\b/i; + /\bconstructor\b|\b__proto__\b|\bprototype\b|\beval\b|\bFunction\b|\bimport\s*\(|\brequire\b|\bglobalThis\b|\bwindow\b|\bdocument\b|\bXMLHttpRequest\b/i; const customJsxDisplayConfigSchema = z.object({ type: z @@ -267,13 +267,13 @@ const customJsxDisplayConfigSchema = z.object({ template: z .string() .min(1) - .max(10000) + .max(50000) .refine((t) => !FORBIDDEN_TEMPLATE_PATTERN.test(t), { message: - "Template contains forbidden keywords (constructor, __proto__, eval, Function, import, require, globalThis, window, document, fetch)", + "Template contains forbidden keywords (constructor, __proto__, eval, Function, import, require, globalThis, window, document)", }) .describe( - "JSX template string using whitelisted Mantine components. Access API data via {data.fieldName}. Forbidden keywords: constructor, __proto__, eval, Function, import, require, globalThis, window, document, fetch", + "JSX template string using whitelisted Mantine components. Access API data via {data.fieldName}. Use SubFetch for in-widget HTTP requests. Forbidden keywords: constructor, __proto__, eval, Function, import, require, globalThis, window, document", ), }); diff --git a/packages/widgets/package.json b/packages/widgets/package.json index 2f835dafd..aaab3859d 100644 --- a/packages/widgets/package.json +++ b/packages/widgets/package.json @@ -15,6 +15,7 @@ ".": "./index.ts", "./_inputs/json-path-tree-picker": "./src/_inputs/json-path-tree-picker.tsx", "./custom-api/component": "./src/custom-api/component.tsx", + "./custom-api/custom-jsx-display": "./src/custom-api/custom-jsx-display.tsx", "./custom-api/extract-display-data": "./src/custom-api/extract-display-data.ts", "./errors": "./src/errors/component.tsx", "./errors/classes": "./src/errors/index.ts", @@ -54,6 +55,7 @@ "@homarr/validation": "workspace:^0.1.0", "@mantine/charts": "catalog:", "@mantine/core": "catalog:", + "@mantine/dates": "catalog:", "@mantine/form": "catalog:", "@mantine/hooks": "catalog:", "@tabler/icons-react": "catalog:", diff --git a/packages/widgets/src/custom-api/component.tsx b/packages/widgets/src/custom-api/component.tsx index ea89301d8..cbcf77f84 100644 --- a/packages/widgets/src/custom-api/component.tsx +++ b/packages/widgets/src/custom-api/component.tsx @@ -479,7 +479,10 @@ function CustomApiWidgetInner({ definitionId, refreshInterval }: { definitionId: 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 } + : widgetData; return ; } diff --git a/packages/widgets/src/custom-api/custom-jsx-display.tsx b/packages/widgets/src/custom-api/custom-jsx-display.tsx index 1a6bf2f93..15fc496cc 100644 --- a/packages/widgets/src/custom-api/custom-jsx-display.tsx +++ b/packages/widgets/src/custom-api/custom-jsx-display.tsx @@ -2,10 +2,11 @@ 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 { Alert, Badge, Group, Stack, Text } from "@mantine/core"; +import { IconAlertTriangle, IconNetwork } from "@tabler/icons-react"; import { WHITELISTED_COMPONENTS, SAFE_BINDINGS } from "./jsx-whitelist"; +import { WidgetDefinitionProvider } from "./widget-definition-context"; const MAX_PARSE_ERRORS = 5; @@ -15,11 +16,40 @@ function appendParseError(prev: string[], message: string): string[] { return [...prev, message]; } +const METHOD_PATTERN = /(?:method|method)\s*=\s*["'](\w+)["']/gi; +const SUBFETCH_PATTERN = /<(?:SubFetch|ActionButton|ToggleSwitch)\b/g; + +export function extractHttpMethods(template: string): string[] { + const methods = new Set(); + if (SUBFETCH_PATTERN.test(template)) { + SUBFETCH_PATTERN.lastIndex = 0; + let match = METHOD_PATTERN.exec(template); + while (match) { + methods.add(match[1].toUpperCase()); + match = METHOD_PATTERN.exec(template); + } + if (/ = { + GET: "blue", + POST: "orange", + PUT: "yellow", + DELETE: "red", + PATCH: "grape", +}; + export default function CustomJsxDisplay({ data }: { data: Record }) { const template = String(data.template ?? ""); const apiData = data.data; + const definitionId = String(data.widgetDefinitionId ?? ""); const [parseErrors, setParseErrors] = useState([]); const bindings = useMemo(() => SAFE_BINDINGS(apiData), [apiData]); + const httpMethods = useMemo(() => extractHttpMethods(template), [template]); useEffect(() => { setParseErrors([]); @@ -40,35 +70,51 @@ export default function CustomJsxDisplay({ data }: { data: Record - ( - } p="xs"> - {String(error)} + + + {httpMethods.length > 0 && ( + }> + + + HTTP: + + {httpMethods.map((m) => ( + + {m} + + ))} + )} - /> - {parseErrors.length > 0 && ( - - - {parseErrors.length} template warning(s): - - {parseErrors.map((msg) => ( - - {msg} + ( + } p="xs"> + {String(error)} + + )} + /> + {parseErrors.length > 0 && ( + + + {parseErrors.length} template warning(s): - ))} - - )} - + {parseErrors.map((msg) => ( + + {msg} + + ))} + + )} + + ); } diff --git a/packages/widgets/src/custom-api/jsx-sub-fetch.tsx b/packages/widgets/src/custom-api/jsx-sub-fetch.tsx new file mode 100644 index 000000000..c18eb7f05 --- /dev/null +++ b/packages/widgets/src/custom-api/jsx-sub-fetch.tsx @@ -0,0 +1,459 @@ +"use client"; + +import type { ComponentType, ReactNode } from "react"; +import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react"; +import { Alert, Badge, Button, Code, Skeleton, Switch, Text, Title } from "@mantine/core"; +import { + IconAlertTriangle, + IconCheck, + IconPlayerPlay, + IconPower, + IconRefresh, + IconTrash, +} from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import { useConfirmModal } from "@homarr/modals"; +import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; + +import { useWidgetDefinitionId } from "./widget-definition-context"; + +const MAX_CALLS_PER_MINUTE = 10; +const RATE_WINDOW_MS = 60_000; +const callTimestamps = new Map(); + +const HTTP_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const; +type HttpMethod = (typeof HTTP_METHODS)[number]; + +type SubFetchResult = + | { ok: true; status: number; data: unknown } + | { ok: false; status: number; data: unknown; error?: string }; + +const ICON_MAP: Record> = { + play: IconPlayerPlay, + check: IconCheck, + refresh: IconRefresh, + power: IconPower, + trash: IconTrash, +}; + +const SubFetchDataContext = createContext(undefined); + +function checkRateLimit(definitionId: string): boolean { + const now = Date.now(); + const calls = callTimestamps.get(definitionId) ?? []; + const recent = calls.filter((t) => now - t < RATE_WINDOW_MS); + if (recent.length >= MAX_CALLS_PER_MINUTE) return false; + recent.push(now); + callTimestamps.set(definitionId, recent); + return true; +} + +function parseBool(value: unknown, defaultValue = false): boolean { + if (typeof value === "boolean") return value; + if (value === "true") return true; + if (value === "false") return false; + return defaultValue; +} + +function parseMethod(method?: string): HttpMethod { + const upper = (method ?? "GET").toUpperCase(); + if (HTTP_METHODS.includes(upper as HttpMethod)) return upper as HttpMethod; + return "GET"; +} + +function parseJsonRecord(str?: string): Record | undefined { + if (!str?.trim()) return undefined; + try { + const parsed: unknown = JSON.parse(str); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined; + const result: Record = {}; + for (const [key, value] of Object.entries(parsed)) { + result[key] = String(value); + } + return result; + } catch { + return undefined; + } +} + +function getByPath(obj: unknown, path?: string): unknown { + if (!path?.trim()) return obj; + return path.split(".").reduce((acc, key) => { + if (acc == null || typeof acc !== "object") return undefined; + return (acc as Record)[key]; + }, obj); +} + +function formatDisplayValue(value: unknown): string { + if (value == null) return "—"; + if (typeof value === "object") return JSON.stringify(value); + return String(value); +} + +function useSubFetchExecutor() { + const definitionId = useWidgetDefinitionId(); + const mutation = clientApi.widget.customApi.subFetch.useMutation(); + + const execute = useCallback( + async (params: { url: string; method?: string; body?: string; headers?: string }): Promise => { + if (!definitionId) { + return { ok: false as const, status: 0, data: null, error: "Widget definition not available" }; + } + if (!checkRateLimit(definitionId)) { + return { ok: false as const, status: 0, data: null, error: "Rate limit exceeded (10 calls/minute)" }; + } + + return mutation.mutateAsync({ + definitionId, + url: params.url, + method: parseMethod(params.method), + body: params.body, + headers: parseJsonRecord(params.headers), + }); + }, + [definitionId, mutation], + ); + + return { execute, isPending: mutation.isPending }; +} + +export interface SubFetchProps { + url: string; + method?: string; + body?: string; + headers?: string; + trigger?: "auto" | "manual"; + label?: string; + color?: string; + variant?: string; + children?: ReactNode; + loadingText?: string; + errorText?: string; + display?: "json" | "text"; + path?: string; +} + +export function SubFetch({ + url, + method, + body, + headers, + trigger = "auto", + label = "Fetch", + color = "blue", + variant = "filled", + children, + loadingText = "Loading...", + errorText, + display, + path, +}: SubFetchProps) { + const { execute, isPending } = useSubFetchExecutor(); + const [subData, setSubData] = useState(undefined); + const [error, setError] = useState(null); + const [fetched, setFetched] = useState(false); + + const runFetch = useCallback(async () => { + setError(null); + const result = await execute({ url, method, body, headers }); + if (result.ok) { + setSubData(result.data); + setFetched(true); + } else { + setError(result.error ?? errorText ?? `Request failed (${result.status})`); + setFetched(true); + } + }, [execute, url, method, body, headers, errorText]); + + useEffect(() => { + if (trigger === "auto") { + void runFetch(); + } + }, [trigger, runFetch]); + + if (trigger === "manual" && !fetched && !isPending) { + return ( + + ); + } + + if (isPending) { + return ( +
+ + + {loadingText} + +
+ ); + } + + if (error) { + return ( + } p="xs"> + {error} + + ); + } + + const contextValue = subData; + + if (display === "json") { + return ( + + + {JSON.stringify(subData, null, 2)} + + + ); + } + + if (display === "text" && path) { + return ( + + {formatDisplayValue(getByPath(subData, path))} + + ); + } + + if (!children) { + return ( + + + {JSON.stringify(subData, null, 2)} + + + ); + } + + return {children}; +} + +export interface SubDataProps { + path?: string; + as?: string; + order?: number; + size?: string; + color?: string; + variant?: string; + fw?: number; + c?: string; +} + + +export function SubData({ path, as = "Text", ...props }: SubDataProps) { + const subData = useContext(SubFetchDataContext); + const value = getByPath(subData, path); + + if (value === undefined && path) { + return ( + + — + + ); + } + + const displayValue = as === "Code" ? JSON.stringify(value, null, 2) : formatDisplayValue(value); + + if (as === "Title") { + return ( + + {displayValue} + + ); + } + + if (as === "Badge") { + return ( + + {displayValue} + + ); + } + + if (as === "Code") { + return {displayValue}; + } + + return ( + + {displayValue} + + ); +} + +export interface ActionButtonProps { + url: string; + method?: string; + body?: string; + headers?: string; + label: string; + color?: string; + variant?: string; + size?: string; + confirmMessage?: string; + successMessage?: string; + icon?: string; + fullWidth?: boolean | string; + disabled?: boolean | string; +} + +export function ActionButton({ + url, + method = "POST", + body, + headers, + label, + color = "blue", + variant = "filled", + size = "sm", + confirmMessage, + successMessage, + icon, + fullWidth, + disabled, +}: ActionButtonProps) { + const { execute, isPending } = useSubFetchExecutor(); + const { openConfirmModal } = useConfirmModal(); + const Icon = icon ? ICON_MAP[icon.toLowerCase()] : undefined; + + const runAction = async () => { + const result = await execute({ url, method, body, headers }); + if (result.ok) { + if (successMessage) { + showSuccessNotification({ title: label, message: successMessage }); + } + } else { + showErrorNotification({ title: label, message: result.error ?? `Request failed (${result.status})` }); + } + }; + + const handleClick = () => { + if (confirmMessage) { + openConfirmModal({ + title: label, + children: confirmMessage, + onConfirm: () => void runAction(), + }); + } else { + void runAction(); + } + }; + + return ( + + ); +} + +export interface ToggleSwitchProps { + url: string; + method?: string; + onBody?: string; + offBody?: string; + initialValue?: boolean | string; + label?: string; + color?: string; + size?: string; + disabled?: boolean | string; +} + +export function ToggleSwitch({ + url, + method = "POST", + onBody, + offBody, + initialValue = false, + label, + color = "blue", + size = "sm", + disabled, +}: ToggleSwitchProps) { + const { execute, isPending } = useSubFetchExecutor(); + const [checked, setChecked] = useState(parseBool(initialValue)); + const pendingRef = useRef(false); + + const handleChange = async (next: boolean) => { + if (pendingRef.current) return; + pendingRef.current = true; + const prev = checked; + setChecked(next); + + const result = await execute({ + url, + method, + body: next ? onBody : offBody, + }); + + pendingRef.current = false; + if (!result.ok) { + setChecked(prev); + showErrorNotification({ title: label ?? "Toggle", message: result.error ?? "Request failed" }); + } + }; + + return ( + void handleChange(event.currentTarget.checked)} + disabled={parseBool(disabled) || isPending} + /> + ); +} + +export interface RefreshButtonProps { + label?: string; + color?: string; + variant?: string; + size?: string; +} + +export function RefreshButton({ + label = "Refresh", + color = "blue", + variant = "light", + size = "sm", +}: RefreshButtonProps) { + const definitionId = useWidgetDefinitionId(); + const utils = clientApi.useUtils(); + const [loading, setLoading] = useState(false); + + const handleRefresh = async () => { + if (!definitionId) return; + setLoading(true); + try { + await utils.widget.customApi.getData.invalidate({ definitionId }); + } finally { + setLoading(false); + } + }; + + return ( + + ); +} diff --git a/packages/widgets/src/custom-api/jsx-whitelist.ts b/packages/widgets/src/custom-api/jsx-whitelist.ts index 983821955..5c0bfebdc 100644 --- a/packages/widgets/src/custom-api/jsx-whitelist.ts +++ b/packages/widgets/src/custom-api/jsx-whitelist.ts @@ -1,60 +1,99 @@ -import type { ComponentType } from "react"; -import { createElement } from "react"; +import type { ComponentType, ReactNode } from "react"; +import { createElement, useState } 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, + ActionIcon, + Alert, + Anchor, + AspectRatio, Avatar, BackgroundImage, - Tooltip, + Badge, + Blockquote, + Box, + Breadcrumbs, + Burger, + Button, + Card, + Center, + Chip, + CloseButton, + Code, + Collapse, + ColorSwatch, + Container, + CopyButton, + DataList, Divider, + EmptyState, + Fieldset, + Flex, + Grid, + Group, + Highlight, + HoverCard, + Image, + Indicator, + Kbd, + List, + Loader, + Mark, + Marquee, + Menu, + NavLink, + Notification, + NumberFormatter, + Overlay, + Pagination, + Paper, + Pill, + Progress, + Rating, + RingProgress, + RollingNumber, ScrollArea, + SegmentedControl, + SemiCircleProgress, + SimpleGrid, + Skeleton, + Slider, + Space, + Spoiler, + Stack, + Stepper, + Switch, + Table, + Tabs, + Text, + ThemeIcon, + Timeline, + Title, + Tooltip, + Transition, + Tree, } from "@mantine/core"; import { AreaChart, BarChart, - LineChart, + BarsList, + BubbleChart, + CompositeChart, DonutChart, + FunnelChart, + Heatmap, + LineChart, PieChart, RadarChart, RadialBarChart, + SankeyChart, + ScatterChart, Sparkline, + Treemap, } from "@mantine/charts"; +import { Calendar, DatePicker, MiniCalendar, TimeValue } from "@mantine/dates"; import { PaginatedList, TabsContainer, TabPanel, Collapsible, StatBar, TypeBadge } from "./jsx-interactive-components"; +import { SubFetch, SubData, ActionButton, ToggleSwitch, RefreshButton } from "./jsx-sub-fetch"; const SAFE_URL_PATTERN = /^https?:\/\//i; @@ -63,13 +102,112 @@ function isSafeUrl(url: unknown): boolean { return SAFE_URL_PATTERN.test(url) || url.startsWith("/") || url.startsWith("#"); } +function stripEventHandlers(props: Record): Record { + const safe: Record = {}; + for (const [key, value] of Object.entries(props)) { + if (/^on[A-Z]/.test(key)) continue; + safe[key] = value; + } + return safe; +} + function SafeAnchor(props: Record) { const href = props.href; const safeHref = href && isSafeUrl(href) ? href : undefined; - return createElement(Anchor as never, { ...props, href: safeHref }); + return createElement(Anchor as never, { ...stripEventHandlers(props), href: safeHref }); +} + +function SafeNavLink(props: Record) { + const href = props.href; + const safeHref = href && isSafeUrl(href) ? href : undefined; + return createElement(NavLink as never, { ...stripEventHandlers(props), href: safeHref }); +} + +function SafeButton(props: Record) { + return createElement(Button as never, stripEventHandlers(props)); +} + +function SafeActionIcon(props: Record) { + return createElement(ActionIcon as never, stripEventHandlers(props)); +} + +function SafeBurger(props: Record) { + return createElement(Burger as never, stripEventHandlers(props)); +} + +function SafeCloseButton(props: Record) { + return createElement(CloseButton as never, stripEventHandlers(props)); +} + +function SafeChip(props: Record) { + return createElement(Chip as never, stripEventHandlers(props)); +} + +function SafeNotification(props: Record) { + return createElement(Notification as never, stripEventHandlers(props)); +} + +function SafeRating(props: Record) { + return createElement(Rating as never, { ...stripEventHandlers(props), readOnly: true }); +} + +function SafeSlider(props: Record) { + return createElement(Slider as never, { ...stripEventHandlers(props), readOnly: true }); +} + +function SafeSwitch(props: Record) { + return createElement(Switch as never, { ...stripEventHandlers(props), readOnly: true }); +} + +function SafeSegmentedControl(props: Record) { + return createElement(SegmentedControl as never, stripEventHandlers(props)); +} + +function SafePagination(props: Record) { + return createElement(Pagination as never, stripEventHandlers(props)); +} + +function SafeStepper(props: Record) { + return createElement(Stepper as never, stripEventHandlers(props)); +} + +function SafeCalendar(props: Record) { + return createElement(Calendar as never, { ...stripEventHandlers(props), static: true }); +} + +function SafeMiniCalendar(props: Record) { + return createElement(MiniCalendar as never, { ...stripEventHandlers(props), static: true }); +} + +function SafeTree(props: Record) { + return createElement(Tree as never, stripEventHandlers(props)); +} + +function SafeDatePicker(props: Record) { + return createElement(DatePicker as never, { ...stripEventHandlers(props), static: true }); +} + +function SafeCopyButton({ value }: { value?: string; children?: ReactNode }) { + const [copied, setCopied] = useState(false); + const handleCopy = () => { + if (!value) return; + void navigator.clipboard.writeText(value).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }; + return createElement(Button as never, { + size: "xs", + variant: copied ? "filled" : "light", + color: copied ? "teal" : "blue", + onClick: handleCopy, + children: copied ? "Copied!" : "Copy", + }); } export const WHITELISTED_COMPONENTS: Record> = { + // Layout + Box, Stack, Group, Flex, @@ -80,6 +218,10 @@ export const WHITELISTED_COMPONENTS: Record> = { Space, Container, AspectRatio, + Overlay, + ScrollArea, + + // Typography & text Text, Title, Code, @@ -89,6 +231,10 @@ export const WHITELISTED_COMPONENTS: Record> = { Blockquote, Anchor: SafeAnchor, NumberFormatter, + Marquee, + RollingNumber, + + // Data display Badge, Card, "Card.Section": Card.Section, @@ -99,6 +245,8 @@ export const WHITELISTED_COMPONENTS: Record> = { Table, "Table.Thead": Table.Thead, "Table.Tbody": Table.Tbody, + "Table.Tfoot": Table.Tfoot, + "Table.Caption": Table.Caption, "Table.Tr": Table.Tr, "Table.Th": Table.Th, "Table.Td": Table.Td, @@ -116,6 +264,7 @@ export const WHITELISTED_COMPONENTS: Record> = { Progress, "Progress.Section": Progress.Section, RingProgress, + SemiCircleProgress, Skeleton, Loader, Image, @@ -124,7 +273,59 @@ export const WHITELISTED_COMPONENTS: Record> = { BackgroundImage, Tooltip, Divider, - ScrollArea, + DataList, + "DataList.Item": DataList.Item, + "DataList.ItemLabel": DataList.ItemLabel, + "DataList.ItemValue": DataList.ItemValue, + EmptyState, + "EmptyState.Indicator": EmptyState.Indicator, + "EmptyState.Title": EmptyState.Title, + "EmptyState.Description": EmptyState.Description, + "EmptyState.Actions": EmptyState.Actions, + Fieldset, + Notification: SafeNotification, + Rating: SafeRating, + + // Navigation & structure + Breadcrumbs, + NavLink: SafeNavLink, + Stepper: SafeStepper, + "Stepper.Step": Stepper.Step, + Tabs, + "Tabs.List": Tabs.List, + "Tabs.Tab": Tabs.Tab, + "Tabs.Panel": Tabs.Panel, + Tree: SafeTree, + + // Interactive display (handlers stripped — wired via SubFetch separately) + Button: SafeButton, + ActionIcon: SafeActionIcon, + Burger: SafeBurger, + CloseButton: SafeCloseButton, + Chip: SafeChip, + "Chip.Group": Chip.Group, + Pagination: SafePagination, + SegmentedControl: SafeSegmentedControl, + Slider: SafeSlider, + Switch: SafeSwitch, + + // Hover overlays & menus + HoverCard, + "HoverCard.Target": HoverCard.Target, + "HoverCard.Dropdown": HoverCard.Dropdown, + Menu, + "Menu.Target": Menu.Target, + "Menu.Dropdown": Menu.Dropdown, + "Menu.Item": Menu.Item, + "Menu.Label": Menu.Label, + "Menu.Divider": Menu.Divider, + + // Utility + CopyButton: SafeCopyButton, + Collapse, + Transition, + + // Charts AreaChart, BarChart, LineChart, @@ -133,12 +334,35 @@ export const WHITELISTED_COMPONENTS: Record> = { RadarChart, RadialBarChart, Sparkline, + BubbleChart, + CompositeChart, + FunnelChart, + Heatmap, + ScatterChart, + SankeyChart, + Treemap, + BarsList, + + // Dates + Calendar: SafeCalendar, + MiniCalendar: SafeMiniCalendar, + DatePicker: SafeDatePicker, + TimeValue, + + // Custom interactive components (own state management) PaginatedList, TabsContainer, TabPanel, Collapsible, StatBar, TypeBadge, + + // SubFetch — server-proxied HTTP from within templates + SubFetch, + SubData, + ActionButton, + ToggleSwitch, + RefreshButton, }; function sanitizeData(obj: unknown): unknown { @@ -172,6 +396,7 @@ Object.freeze(safeJSON); const safeArray: Record = Object.create(null); safeArray.isArray = (v: unknown) => Array.isArray(v); +safeArray.from = (v: ArrayLike | Iterable) => Array.from(v); Object.freeze(safeArray); const safeObject: Record = Object.create(null); @@ -180,6 +405,20 @@ safeObject.values = (v: object) => Object.values(v); safeObject.entries = (v: object) => Object.entries(v); Object.freeze(safeObject); +const safeDate: Record = Object.create(null); +safeDate.now = () => Date.now(); +safeDate.create = (v?: string | number) => (v != null ? new Date(v) : new Date()); +safeDate.toISOString = (v: string | number) => new Date(v).toISOString(); +safeDate.toLocaleDateString = (v: string | number, locale?: string) => + new Date(v).toLocaleDateString(locale ?? "en-US"); +safeDate.toLocaleTimeString = (v: string | number, locale?: string) => + new Date(v).toLocaleTimeString(locale ?? "en-US"); +safeDate.getTime = (v: string | number) => new Date(v).getTime(); +safeDate.getYear = (v: string | number) => new Date(v).getFullYear(); +safeDate.getMonth = (v: string | number) => new Date(v).getMonth(); +safeDate.getDay = (v: string | number) => new Date(v).getDate(); +Object.freeze(safeDate); + export const SAFE_BINDINGS = (apiData: unknown) => ({ data: sanitizeData(apiData), String: (v: unknown) => String(v), @@ -189,4 +428,11 @@ export const SAFE_BINDINGS = (apiData: unknown) => ({ JSON: safeJSON, Array: safeArray, Object: safeObject, + Date: safeDate, + parseInt: (v: string, radix?: number) => parseInt(v, radix), + parseFloat: (v: string) => parseFloat(v), + encodeURIComponent: (v: string) => encodeURIComponent(v), + decodeURIComponent: (v: string) => decodeURIComponent(v), + isNaN: (v: unknown) => Number.isNaN(Number(v)), + isFinite: (v: unknown) => Number.isFinite(Number(v)), }); diff --git a/packages/widgets/src/custom-api/widget-definition-context.tsx b/packages/widgets/src/custom-api/widget-definition-context.tsx new file mode 100644 index 000000000..4db3a541a --- /dev/null +++ b/packages/widgets/src/custom-api/widget-definition-context.tsx @@ -0,0 +1,20 @@ +"use client"; + +import type { ReactNode } from "react"; +import { createContext, useContext } from "react"; + +const WidgetDefinitionContext = createContext(""); + +export function WidgetDefinitionProvider({ + definitionId, + children, +}: { + definitionId: string; + children: ReactNode; +}) { + return {children}; +} + +export function useWidgetDefinitionId(): string { + return useContext(WidgetDefinitionContext); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 574aad9dc..38b46700c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2587,6 +2587,9 @@ importers: '@mantine/core': specifier: 'catalog:' version: 9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@mantine/dates': + specifier: 'catalog:' + version: 9.4.1(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@mantine/form': specifier: 'catalog:' version: 9.4.1(react@19.2.7)