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
This commit is contained in:
Thomas Camlong committed 2026-07-12 00:42:31 +02:00
1 parent 576856d634
commit aa8410d326
12 files changed
+1164 -257

No files matched your search

@@ -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 ? <Text>Yes</Text> : <Text>No</Text>}\`
- **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
<PaginatedList pageSize={8}>
{data.results.map(item =>
<Card withBorder p="xs" mb="xs">
<Text>{item.name}</Text>
</Card>
)}
</PaginatedList>
<SubFetch url="/api/v1/status" trigger="auto">
<SubData path="name" as="Title" order={3} />
<SubData path="status" as="Badge" color="green" />
</SubFetch>
\`\`\`
**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
<TabsContainer defaultTab="overview">
<TabPanel value="overview" label="Overview">
<Text>{data.description}</Text>
</TabPanel>
<TabPanel value="stats" label="Statistics">
<Text>Total: {data.count}</Text>
</TabPanel>
</TabsContainer>
<ActionButton url="/api/lights/1/toggle" method="POST" label="Toggle Light" color="yellow" icon="power" confirmMessage="Toggle the light?" />
\`\`\`
**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
<Collapsible title="Details" defaultOpen={true}>
<Text>{data.details}</Text>
</Collapsible>
<ToggleSwitch url="/api/lights/1" onBody='{"state":"on"}' offBody='{"state":"off"}' label="Living Room" color="yellow" />
\`\`\`
**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
<StatBar label="HP" value={45} max={100} color="red" />
<StatBar label="ATK" value={80} max={255} color="orange" />
\`\`\`
**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
<Group gap="xs">
<TypeBadge type="fire" />
<TypeBadge type="flying" />
</Group>
\`\`\`
### 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 => <Text>{item.name}</Text>)}\`
- 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: \`<NumberFormatter value={data.price} thousandSeparator prefix="$" />\`
### 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
<Stack gap="sm" p="xs">
<Group wrap="nowrap">
<Avatar src={data.sprites.front_default} size={80} radius="sm" />
<Stack gap={4} style={{flex: 1}}>
<Title order={3} tt="capitalize">{data.name}</Title>
<Text size="xs" c="dimmed">#{String(data.id).padStart(3, "0")}</Text>
<Group gap="xs">
{data.types.map(t => <TypeBadge type={t.type.name} />)}
</Group>
</Stack>
</Group>
<TabsContainer defaultTab="stats">
<TabPanel value="stats" label="Stats">
<Stack gap="xs" pt="xs">
{data.stats.map(s =>
<StatBar label={s.stat.name} value={s.base_stat} max={255} color={s.base_stat > 100 ? "green" : "red"} />
)}
</Stack>
</TabPanel>
<TabPanel value="abilities" label="Abilities">
<Stack gap="xs" pt="xs">
{data.abilities.map(a =>
<Badge variant={a.is_hidden ? "outline" : "filled"} tt="capitalize">
{a.ability.name}{a.is_hidden ? " (hidden)" : ""}
</Badge>
)}
</Stack>
</TabPanel>
<TabPanel value="moves" label="Moves">
<Collapsible title="Move list">
<Group gap="xs" pt="xs">
{data.moves.slice(0, 20).map(m =>
<Badge size="xs" variant="light" tt="capitalize">{m.move.name}</Badge>
)}
</Group>
</Collapsible>
</TabPanel>
</TabsContainer>
</Stack>
\`\`\`
### 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
<Stack gap="sm" p="xs">
<Group justify="space-between">
<Title order={3}>Pokédex</Title>
<Badge size="lg" color="red">{data.count} Pokémon</Badge>
<Badge size="lg" color="red">{data.count} total</Badge>
</Group>
<PaginatedList pageSize={10}>
{data.results.map((pokemon, i) =>
<Card withBorder p="xs" mb="xs">
<Group wrap="nowrap">
<Avatar
src={"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/" + String(i + 1) + ".png"}
size="lg"
radius="sm"
/>
<Stack gap={0} style={{ flex: 1 }}>
<Avatar src={"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/" + String(i + 1) + ".png"} size="lg" radius="sm" />
<Stack gap={0} style={{flex: 1}}>
<Text fw={700} tt="capitalize">{pokemon.name}</Text>
<Text size="xs" c="dimmed">#{String(i + 1)}</Text>
<Text size="xs" c="dimmed">#{String(i + 1).padStart(3, "0")}</Text>
</Stack>
</Group>
</Card>
@@ -255,6 +156,90 @@ API: \`https://pokeapi.co/api/v2/pokemon?limit=50\`
</Stack>
\`\`\`
### Pokédex with Detail Fetch (SubFetch for sub-resources)
API: \`https://pokeapi.co/api/v2/pokemon?limit=75\`
\`\`\`jsx
<Stack gap="sm" p="xs">
<Title order={3}>Pokédex</Title>
<PaginatedList pageSize={5}>
{data.results.map((pokemon, i) =>
<Card withBorder p="xs" mb="xs">
<Group wrap="nowrap">
<Avatar src={"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/" + String(i + 1) + ".png"} size={60} radius="sm" />
<Stack gap={2} style={{flex: 1}}>
<Text fw={700} tt="capitalize">{pokemon.name}</Text>
<SubFetch url={pokemon.url} trigger="auto">
<Group gap="xs">
<SubData path="types.0.type.name" as="Badge" color="red" />
<SubData path="stats.0.base_stat" as="Text" size="xs" />
</Group>
</SubFetch>
</Stack>
</Group>
</Card>
)}
</PaginatedList>
</Stack>
\`\`\`
### Stocks Dashboard (Charts + Live Data)
API: \`https://your-stocks-api.com/portfolio\`
\`\`\`jsx
<Stack gap="md" p="sm">
<Group justify="space-between">
<Title order={3}>Portfolio</Title>
<RefreshButton label="Refresh" size="xs" />
</Group>
<SimpleGrid cols={3}>
{data.holdings.map(stock =>
<Card withBorder p="xs">
<Text fw={700}>{stock.symbol}</Text>
<NumberFormatter value={stock.price} prefix="$" decimalScale={2} />
<Sparkline data={stock.history} h={40} color={stock.change > 0 ? "green" : "red"} />
</Card>
)}
</SimpleGrid>
<LineChart h={200} data={data.timeline} dataKey="date" series={[{name: "value", color: "blue"}]} />
</Stack>
\`\`\`
### Navidrome Mini-Player (Buttons + State)
API: \`https://your-navidrome.local/api/nowplaying\`
\`\`\`jsx
<Stack gap="sm" p="sm">
<Group wrap="nowrap">
<Image src={data.albumArt} w={80} h={80} radius="sm" />
<Stack gap={2} style={{flex: 1}}>
<Text fw={700} lineClamp={1}>{data.title}</Text>
<Text size="sm" c="dimmed" lineClamp={1}>{data.artist}</Text>
<Progress value={data.progress} size="xs" color="red" />
</Stack>
</Group>
<Group justify="center" gap="sm">
<ActionButton url="/api/player/previous" method="POST" label="Prev" size="xs" variant="subtle" />
<ActionButton url="/api/player/toggle" method="POST" label={data.playing ? "Pause" : "Play"} icon="play" color="red" />
<ActionButton url="/api/player/next" method="POST" label="Next" size="xs" variant="subtle" />
</Group>
<Group justify="center">
<ToggleSwitch url="/api/player/shuffle" method="POST" onBody='{"shuffle":true}' offBody='{"shuffle":false}' initialValue={data.shuffle} label="Shuffle" size="xs" />
<ToggleSwitch url="/api/player/repeat" method="POST" onBody='{"repeat":true}' offBody='{"repeat":false}' initialValue={data.repeat} label="Repeat" size="xs" />
</Group>
</Stack>
\`\`\`
### Smart Home Light Switch
API: \`https://your-ha.local/api/states/light.living_room\`
\`\`\`jsx
<Stack gap="md" p="sm">
<Group justify="space-between">
<Title order={4}>Living Room</Title>
<Badge color={data.state === "on" ? "yellow" : "gray"}>{data.state}</Badge>
</Group>
<ToggleSwitch url="/api/services/light/toggle" method="POST" onBody='{"entity_id":"light.living_room"}' offBody='{"entity_id":"light.living_room"}' initialValue={data.state === "on"} label="Power" color="yellow" />
<ActionButton url="/api/services/light/turn_off" method="POST" body='{"entity_id":"light.living_room"}' label="Force Off" color="red" icon="power" confirmMessage="Turn off?" />
</Stack>
\`\`\`
## API Response
`;
@@ -1393,9 +1393,9 @@ function DisplayTypeFields({
'<Stack gap="sm">\n <Title order={3}>{data.name}</Title>\n <Text>{data.description}</Text>\n</Stack>'
}
minRows={8}
maxRows={20}
maxRows={30}
autosize
maxLength={10000}
maxLength={50000}
styles={{ input: { fontFamily: "monospace" } }}
{...form.getInputProps("template")}
/>
@@ -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<string, unknown>;
}, [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({
</Button>
</Group>
{httpMethods.length > 0 && (
<Alert color="yellow" variant="light" p="xs" icon={<IconNetwork size={14} />}>
<Group gap={4} align="center">
<Text size="xs" fw={500}>
Widget makes HTTP requests:
</Text>
{httpMethods.map((m) => (
<Badge key={m} size="xs" color={m === "DELETE" ? "red" : m === "GET" ? "blue" : "orange"} variant="filled">
{m}
</Badge>
))}
</Group>
</Alert>
)}
{testError && (
<Alert color="red" icon={<IconAlertTriangle size={16} />} p="xs">
<Text size="xs">{testError}</Text>
@@ -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<unknown> => {
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);
}
}),
});
+4 -4
View File
@@ -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",
),
});
+2
View File
@@ -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:",
@@ -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 <Component data={enrichedData} />;
}
@@ -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<string>();
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 (/<ActionButton\b/.test(template) && methods.size === 0) methods.add("POST");
if (/<ToggleSwitch\b/.test(template) && methods.size === 0) methods.add("POST");
if (/<SubFetch\b/.test(template) && methods.size === 0) methods.add("GET");
}
return [...methods].sort();
}
const METHOD_COLORS: Record<string, string> = {
GET: "blue",
POST: "orange",
PUT: "yellow",
DELETE: "red",
PATCH: "grape",
};
export default function CustomJsxDisplay({ data }: { data: Record<string, unknown> }) {
const template = String(data.template ?? "");
const apiData = data.data;
const definitionId = String(data.widgetDefinitionId ?? "");
const [parseErrors, setParseErrors] = useState<string[]>([]);
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<string, unknow
}
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>
<WidgetDefinitionProvider definitionId={definitionId}>
<Stack gap={0} h="100%">
{httpMethods.length > 0 && (
<Alert color="yellow" variant="light" p={4} mb={4} icon={<IconNetwork size={14} />}>
<Group gap={4} align="center">
<Text size="xs" c="dimmed">
HTTP:
</Text>
{httpMethods.map((m) => (
<Badge key={m} size="xs" color={METHOD_COLORS[m] ?? "gray"} variant="filled">
{m}
</Badge>
))}
</Group>
</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}
<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>
))}
</Alert>
)}
</Stack>
{parseErrors.map((msg) => (
<Text key={msg} size="xs" c="dimmed" style={{ fontFamily: "monospace" }}>
{msg}
</Text>
))}
</Alert>
)}
</Stack>
</WidgetDefinitionProvider>
);
}
@@ -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<string, number[]>();
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<string, ComponentType<{ size?: number | string }>> = {
play: IconPlayerPlay,
check: IconCheck,
refresh: IconRefresh,
power: IconPower,
trash: IconTrash,
};
const SubFetchDataContext = createContext<unknown>(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<string, string> | 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<string, string> = {};
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<unknown>((acc, key) => {
if (acc == null || typeof acc !== "object") return undefined;
return (acc as Record<string, unknown>)[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<SubFetchResult> => {
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<unknown>(undefined);
const [error, setError] = useState<string | null>(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 (
<Button color={color} variant={variant as never} onClick={() => void runFetch()} loading={isPending}>
{label}
</Button>
);
}
if (isPending) {
return (
<div>
<Skeleton height={8} radius="xl" mb="xs" />
<Text size="xs" c="dimmed">
{loadingText}
</Text>
</div>
);
}
if (error) {
return (
<Alert color="red" variant="light" icon={<IconAlertTriangle size={16} />} p="xs">
<Text size="xs">{error}</Text>
</Alert>
);
}
const contextValue = subData;
if (display === "json") {
return (
<SubFetchDataContext.Provider value={contextValue}>
<Code block style={{ fontSize: 11 }}>
{JSON.stringify(subData, null, 2)}
</Code>
</SubFetchDataContext.Provider>
);
}
if (display === "text" && path) {
return (
<SubFetchDataContext.Provider value={contextValue}>
<Text size="sm">{formatDisplayValue(getByPath(subData, path))}</Text>
</SubFetchDataContext.Provider>
);
}
if (!children) {
return (
<SubFetchDataContext.Provider value={contextValue}>
<Code block style={{ fontSize: 11 }}>
{JSON.stringify(subData, null, 2)}
</Code>
</SubFetchDataContext.Provider>
);
}
return <SubFetchDataContext.Provider value={contextValue}>{children}</SubFetchDataContext.Provider>;
}
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 (
<Text size="xs" c="dimmed">
—
</Text>
);
}
const displayValue = as === "Code" ? JSON.stringify(value, null, 2) : formatDisplayValue(value);
if (as === "Title") {
return (
<Title order={(props.order as 1 | 2 | 3 | 4 | 5 | 6) ?? 3} c={props.c}>
{displayValue}
</Title>
);
}
if (as === "Badge") {
return (
<Badge color={props.color} variant={props.variant as never} size={props.size as never}>
{displayValue}
</Badge>
);
}
if (as === "Code") {
return <Code block>{displayValue}</Code>;
}
return (
<Text size={props.size as never} fw={props.fw} c={props.c}>
{displayValue}
</Text>
);
}
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 (
<Button
color={color}
variant={variant as never}
size={size as never}
onClick={handleClick}
loading={isPending}
leftSection={Icon ? <Icon size={16} /> : undefined}
fullWidth={parseBool(fullWidth)}
disabled={parseBool(disabled)}
>
{label}
</Button>
);
}
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 (
<Switch
label={label}
color={color}
size={size as never}
checked={checked}
onChange={(event) => 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 (
<Button
color={color}
variant={variant as never}
size={size as never}
leftSection={<IconRefresh size={16} />}
onClick={() => void handleRefresh()}
loading={loading}
>
{label}
</Button>
);
}
+287 -41
View File
@@ -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<string, unknown>): Record<string, unknown> {
const safe: Record<string, unknown> = {};
for (const [key, value] of Object.entries(props)) {
if (/^on[A-Z]/.test(key)) continue;
safe[key] = value;
}
return safe;
}
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 });
return createElement(Anchor as never, { ...stripEventHandlers(props), href: safeHref });
}
function SafeNavLink(props: Record<string, unknown>) {
const href = props.href;
const safeHref = href && isSafeUrl(href) ? href : undefined;
return createElement(NavLink as never, { ...stripEventHandlers(props), href: safeHref });
}
function SafeButton(props: Record<string, unknown>) {
return createElement(Button as never, stripEventHandlers(props));
}
function SafeActionIcon(props: Record<string, unknown>) {
return createElement(ActionIcon as never, stripEventHandlers(props));
}
function SafeBurger(props: Record<string, unknown>) {
return createElement(Burger as never, stripEventHandlers(props));
}
function SafeCloseButton(props: Record<string, unknown>) {
return createElement(CloseButton as never, stripEventHandlers(props));
}
function SafeChip(props: Record<string, unknown>) {
return createElement(Chip as never, stripEventHandlers(props));
}
function SafeNotification(props: Record<string, unknown>) {
return createElement(Notification as never, stripEventHandlers(props));
}
function SafeRating(props: Record<string, unknown>) {
return createElement(Rating as never, { ...stripEventHandlers(props), readOnly: true });
}
function SafeSlider(props: Record<string, unknown>) {
return createElement(Slider as never, { ...stripEventHandlers(props), readOnly: true });
}
function SafeSwitch(props: Record<string, unknown>) {
return createElement(Switch as never, { ...stripEventHandlers(props), readOnly: true });
}
function SafeSegmentedControl(props: Record<string, unknown>) {
return createElement(SegmentedControl as never, stripEventHandlers(props));
}
function SafePagination(props: Record<string, unknown>) {
return createElement(Pagination as never, stripEventHandlers(props));
}
function SafeStepper(props: Record<string, unknown>) {
return createElement(Stepper as never, stripEventHandlers(props));
}
function SafeCalendar(props: Record<string, unknown>) {
return createElement(Calendar as never, { ...stripEventHandlers(props), static: true });
}
function SafeMiniCalendar(props: Record<string, unknown>) {
return createElement(MiniCalendar as never, { ...stripEventHandlers(props), static: true });
}
function SafeTree(props: Record<string, unknown>) {
return createElement(Tree as never, stripEventHandlers(props));
}
function SafeDatePicker(props: Record<string, unknown>) {
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<string, ComponentType<never>> = {
// Layout
Box,
Stack,
Group,
Flex,
@@ -80,6 +218,10 @@ export const WHITELISTED_COMPONENTS: Record<string, ComponentType<never>> = {
Space,
Container,
AspectRatio,
Overlay,
ScrollArea,
// Typography & text
Text,
Title,
Code,
@@ -89,6 +231,10 @@ export const WHITELISTED_COMPONENTS: Record<string, ComponentType<never>> = {
Blockquote,
Anchor: SafeAnchor,
NumberFormatter,
Marquee,
RollingNumber,
// Data display
Badge,
Card,
"Card.Section": Card.Section,
@@ -99,6 +245,8 @@ export const WHITELISTED_COMPONENTS: Record<string, ComponentType<never>> = {
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<string, ComponentType<never>> = {
Progress,
"Progress.Section": Progress.Section,
RingProgress,
SemiCircleProgress,
Skeleton,
Loader,
Image,
@@ -124,7 +273,59 @@ export const WHITELISTED_COMPONENTS: Record<string, ComponentType<never>> = {
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<string, ComponentType<never>> = {
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<string, unknown> = Object.create(null);
safeArray.isArray = (v: unknown) => Array.isArray(v);
safeArray.from = <T>(v: ArrayLike<T> | Iterable<T>) => Array.from(v);
Object.freeze(safeArray);
const safeObject: Record<string, unknown> = 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<string, unknown> = 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)),
});
@@ -0,0 +1,20 @@
"use client";
import type { ReactNode } from "react";
import { createContext, useContext } from "react";
const WidgetDefinitionContext = createContext<string>("");
export function WidgetDefinitionProvider({
definitionId,
children,
}: {
definitionId: string;
children: ReactNode;
}) {
return <WidgetDefinitionContext.Provider value={definitionId}>{children}</WidgetDefinitionContext.Provider>;
}
export function useWidgetDefinitionId(): string {
return useContext(WidgetDefinitionContext);
}
+3
View File
@@ -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)