Simplify Custom JSX v2 authoring

This commit is contained in:
Thomas Camlong committed 2026-07-21 08:19:22 +02:00
1 parent 1faaf29c3b
commit 05dbad8069
177 files changed
+4775 -43426

No files matched your search

+12 -38
View File
@@ -1,50 +1,24 @@
---
name: homarr-custom-widget
description: Author safe Homarr Custom JSX v2 widgets from API documentation. Use when creating or fixing Homarr custom widgets, including multi-source dashboards, queries, actions, options, temporary inputs, and Mantine JSX.
license: Apache-2.0
metadata:
version: "2.0.0"
description: Author safe Homarr Custom JSX v2 widgets from API documentation.
---
# Homarr Custom Widget
Create one widget at a time. Repeat the workflow when the user asks for several widgets; do not invent packs or shared connections.
Author one widget at a time. When connected to Homarr, retrieve `homarr://custom-widgets/schema` and only the component resources needed for the design. The live resources are authoritative for the installed release.
## Choose the authoring reference
1. Read the API documentation.
2. Create one credential-free widget with keyed `sources`, keyed `requests`, optional keyed `options`, and safe JSX `template`.
3. Validate, preview, test queries and simulated actions, visually inspect, and iterate.
4. Request credentials through Homarr and never repeat plaintext.
5. Save only after narrow/wide, light/dark, loading, empty, error, and success states work.
When connected to Homarr, retrieve the live widget schema and component catalog before authoring. Live metadata is authoritative because Homarr and Mantine versions can differ between installations.
Use `{option:name}` or `$option` for saved options. Use `{param:name}` or `$param` for values supplied by `SubFetch`, `ActionButton`, or `ToggleSwitch`. Load queries cannot use invocation parameters. Templates read `data`, `status`, `options`, and temporary `inputs`.
For offline authoring, read these installed, release-matched references in order:
`SubFetch` with `trigger="manual"` renders its own load button. Its callback is `(result, meta)`; never author `onClick` or a fetch callback.
1. [Schema guide](references/schema.md)
2. [Canonical widget JSON Schema](references/widget-schema.json)
3. [JSX runtime](references/runtime.md)
4. [Security boundary](references/security.md)
5. [Component catalog guide](references/components.md)
6. [Canonical component catalog](references/component-catalog.json)
7. [Canonical examples](references/examples.json)
8. [Official Mantine LLM index](references/mantine-core.md)
9. [Official Mantine Dates guide](references/mantine-dates.md)
10. [Mantine Charts links](references/mantine-charts.md)
11. [Homarr components](references/homarr-components.md)
Use standard Mantine compound names and deliberate visual hierarchy, spacing, restrained semantic color, and responsive layouts. Avoid excessive nested cards.
Together, this file and its references are the complete offline authoring bundle for the matching Homarr release. The JSON catalog contains the exact enabled component names, prop metadata, bindings, blocked capabilities, and versions. If live metadata is available, prefer it because it reflects the installed Homarr and Mantine versions.
Do not use imports, hooks, refs, raw HTML, raw event callbacks, browser requests, arbitrary functions, eval, bigint, npm packages, authored statement blocks, IIFEs, or recursion. Do not pretend MCP tools exist in an offline chat session.
## Authoring workflow
1. Read the API documentation supplied by the user. Ask for a missing base URL only when it cannot be inferred safely.
2. Build one credential-free `homarr-custom-widget-v2` manifest and one safe JSX template.
3. Use immutable local `const` blocks for repeated derived values and `RecursiveList` for arbitrary-depth trees. Do not emulate either feature with unrestricted JavaScript or authored recursion.
4. Return the complete manifest and JSX in the exact fenced-block format requested by the authoring prompt.
5. When the user supplies Homarr diagnostics, correct every reported issue while preserving unrelated fields.
## Output rules
- Use inline sources, named requests, definition-owned options, temporary bound inputs, and one safe JSX template.
- Use `kind`, not the HTTP method, to distinguish reads from user-triggered actions.
- Give every declared request parameter an explicit source. Load queries use `optionsBinding` with an option reference or primitive literal; manual queries and actions receive values only from the invoking component's `params` prop. Never infer bindings from matching names.
- Treat `status.<requestId>` as `{ loading, ok, status, statusText, error }`. Never compare it with `"loading"`, `"success"`, or `"error"`; check `status.list?.loading`, `status.list?.ok === false`, or `status.list?.ok === true`.
- Never put a credential in JSX, options, headers, URLs, examples, exports, logs, or chat output.
- Use declarative `bind` controls and Homarr request components. Controlled collection callbacks, zero-argument derived-value IIFEs, and the `RecursiveList` child template may use the restricted safe-block grammar documented in the runtime reference. Never emit imports, hooks, refs, raw callback props, browser requests, or arbitrary JavaScript.
- Include responsive loading, empty, error, and success states.
- Keep actions explicit, permission-scoped, confirmed when destructive, and followed by targeted invalidation.
When this skill is installed from the repository, optional offline details are in `references/schema.md`, `references/runtime.md`, and `references/security.md`. MCP-only clients should use the live Homarr resources instead.
File diff suppressed because it is too large. Load diff
@@ -1,114 +0,0 @@
# Component catalog
> Generated from `component-catalog-v1.json`. Catalog schema v1 · Custom Widget 2.0.0 · Mantine 9.4.1. Do not edit this file by hand.
This is the complete Custom JSX component boundary for this Homarr release. The machine-readable `component-catalog.json` contains exact generated component, prop, binding, and safety metadata; these Markdown pages provide the human-readable inventory and upstream Mantine indexes. Ordinary serializable props pass through unless the capability boundary explicitly blocks them.
## Package references
- [Mantine Core](mantine-core.md) — 265 enabled, 133 denied
- [Mantine Dates](mantine-dates.md) — 29 enabled, 3 denied
- [Mantine Charts](mantine-charts.md) — 18 enabled, 1 denied
- [Homarr components](homarr-components.md) — 13 enabled, 0 denied
## Authoring boundary
- Use the exact component names below. Compound names such as `Tabs.List`, `Card.Section`, and `Radio.Group` are distinct catalog entries.
- Use the official Mantine LLM links for component props and examples. Homarr's live catalog is authoritative for availability, bindings, and blocked capabilities.
- Use `bind="name"` only when a component record declares bind metadata. Read the temporary in-memory value from `inputs.name`; it is never persisted.
- Ordinary authored callbacks remain blocked. Restricted collection callbacks, directly invoked zero-argument derived-value IIFEs, and `RecursiveList`'s child template may use the immutable local `const` grammar in [JSX runtime](runtime.md#immutable-local-bindings).
- Use [`RecursiveList`](homarr-components.md#component-recursivelist) for custom arbitrary-depth trees. Do not author recursion or pass a renderer callback to Mantine Tree.
- Unknown ordinary Mantine props are advisory and are not silently discarded. Every capability listed in the blocked table is rejected.
## Complete enabled inventory
### Mantine Core
[`Accordion`](https://mantine.dev/core/accordion/), [`Accordion.Chevron`](https://mantine.dev/core/accordion/), [`Accordion.Control`](https://mantine.dev/core/accordion/), [`Accordion.Item`](https://mantine.dev/core/accordion/), [`Accordion.Panel`](https://mantine.dev/core/accordion/), [`AccordionChevron`](https://mantine.dev/core/accordion-chevron/), [`AccordionControl`](https://mantine.dev/core/accordion-control/), [`AccordionItem`](https://mantine.dev/core/accordion-item/), [`AccordionPanel`](https://mantine.dev/core/accordion-panel/), [`ActionIcon`](https://mantine.dev/core/action-icon/), [`ActionIcon.Group`](https://mantine.dev/core/action-icon/), [`ActionIcon.GroupSection`](https://mantine.dev/core/action-icon/), [`ActionIconGroup`](https://mantine.dev/core/action-icon-group/), [`ActionIconGroupSection`](https://mantine.dev/core/action-icon-group-section/), [`Alert`](https://mantine.dev/core/alert/), [`AlphaSlider`](https://mantine.dev/core/alpha-slider/), [`Anchor`](https://mantine.dev/core/anchor/), [`AngleSlider`](https://mantine.dev/core/angle-slider/), [`AspectRatio`](https://mantine.dev/core/aspect-ratio/), [`Autocomplete`](https://mantine.dev/core/autocomplete/), [`Avatar`](https://mantine.dev/core/avatar/), [`Avatar.Group`](https://mantine.dev/core/avatar/), [`AvatarGroup`](https://mantine.dev/core/avatar-group/), [`BackgroundImage`](https://mantine.dev/core/background-image/), [`Badge`](https://mantine.dev/core/badge/), [`Blockquote`](https://mantine.dev/core/blockquote/), [`Box`](https://mantine.dev/core/box/), [`Breadcrumbs`](https://mantine.dev/core/breadcrumbs/), [`Burger`](https://mantine.dev/core/burger/), [`Button`](https://mantine.dev/core/button/), [`Button.Group`](https://mantine.dev/core/button/), [`Button.GroupSection`](https://mantine.dev/core/button/), [`ButtonGroup`](https://mantine.dev/core/button-group/), [`ButtonGroupSection`](https://mantine.dev/core/button-group-section/), [`Card`](https://mantine.dev/core/card/), [`Card.Section`](https://mantine.dev/core/card/), [`CardSection`](https://mantine.dev/core/card-section/), [`Center`](https://mantine.dev/core/center/), [`CheckIcon`](https://mantine.dev/core/check-icon/), [`Checkbox`](https://mantine.dev/core/checkbox/), [`Checkbox.Card`](https://mantine.dev/core/checkbox/), [`Checkbox.Group`](https://mantine.dev/core/checkbox/), [`Checkbox.Indicator`](https://mantine.dev/core/checkbox/), [`CheckboxCard`](https://mantine.dev/core/checkbox-card/), [`CheckboxGroup`](https://mantine.dev/core/checkbox-group/), [`CheckboxIndicator`](https://mantine.dev/core/checkbox-indicator/), [`Chip`](https://mantine.dev/core/chip/), [`Chip.Group`](https://mantine.dev/core/chip/), [`ChipGroup`](https://mantine.dev/core/chip-group/), [`CloseButton`](https://mantine.dev/core/close-button/), [`CloseIcon`](https://mantine.dev/core/close-icon/), [`Code`](https://mantine.dev/core/code/), [`Collapse`](https://mantine.dev/core/collapse/), [`ColorInput`](https://mantine.dev/core/color-input/), [`ColorPicker`](https://mantine.dev/core/color-picker/), [`ColorSwatch`](https://mantine.dev/core/color-swatch/), [`Container`](https://mantine.dev/core/container/), [`CopyButton`](https://mantine.dev/core/copy-button/), [`DataList`](https://mantine.dev/core/data-list/), [`DataList.Item`](https://mantine.dev/core/data-list/), [`DataList.ItemLabel`](https://mantine.dev/core/data-list/), [`DataList.ItemValue`](https://mantine.dev/core/data-list/), [`DataListItem`](https://mantine.dev/core/data-list-item/), [`DataListItemLabel`](https://mantine.dev/core/data-list-item-label/), [`DataListItemValue`](https://mantine.dev/core/data-list-item-value/), [`Divider`](https://mantine.dev/core/divider/), [`EmptyState`](https://mantine.dev/core/empty-state/), [`EmptyState.Actions`](https://mantine.dev/core/empty-state/), [`EmptyState.Description`](https://mantine.dev/core/empty-state/), [`EmptyState.Indicator`](https://mantine.dev/core/empty-state/), [`EmptyState.Title`](https://mantine.dev/core/empty-state/), [`EmptyStateActions`](https://mantine.dev/core/empty-state-actions/), [`EmptyStateDescription`](https://mantine.dev/core/empty-state-description/), [`EmptyStateIndicator`](https://mantine.dev/core/empty-state-indicator/), [`EmptyStateTitle`](https://mantine.dev/core/empty-state-title/), [`Fieldset`](https://mantine.dev/core/fieldset/), [`FlatTreeNode`](https://mantine.dev/core/flat-tree-node/), [`Flex`](https://mantine.dev/core/flex/), [`FloatingArrow`](https://mantine.dev/core/floating-arrow/), [`FloatingIndicator`](https://mantine.dev/core/floating-indicator/), [`Grid`](https://mantine.dev/core/grid/), [`Grid.Col`](https://mantine.dev/core/grid/), [`GridCol`](https://mantine.dev/core/grid-col/), [`Group`](https://mantine.dev/core/group/), [`Highlight`](https://mantine.dev/core/highlight/), [`HoverCard`](https://mantine.dev/core/hover-card/), [`HoverCard.Dropdown`](https://mantine.dev/core/hover-card/), [`HoverCard.Group`](https://mantine.dev/core/hover-card/), [`HoverCard.Line truncated
### Mantine Dates
[`Calendar`](https://mantine.dev/dates/calendar/), [`CalendarHeader`](https://mantine.dev/dates/calendar-header/), [`DateInput`](https://mantine.dev/dates/date-input/), [`DatePicker`](https://mantine.dev/dates/date-picker/), [`DatePickerInput`](https://mantine.dev/dates/date-picker-input/), [`DateTimePicker`](https://mantine.dev/dates/date-time-picker/), [`Day`](https://mantine.dev/dates/day/), [`DecadeLevel`](https://mantine.dev/dates/decade-level/), [`DecadeLevelGroup`](https://mantine.dev/dates/decade-level-group/), [`InlineDateTimePicker`](https://mantine.dev/dates/inline-date-time-picker/), [`LevelsGroup`](https://mantine.dev/dates/levels-group/), [`MiniCalendar`](https://mantine.dev/dates/mini-calendar/), [`Month`](https://mantine.dev/dates/month/), [`MonthLevel`](https://mantine.dev/dates/month-level/), [`MonthLevelGroup`](https://mantine.dev/dates/month-level-group/), [`MonthPicker`](https://mantine.dev/dates/month-picker/), [`MonthPickerInput`](https://mantine.dev/dates/month-picker-input/), [`MonthsList`](https://mantine.dev/dates/months-list/), [`PickerControl`](https://mantine.dev/dates/picker-control/), [`TimeGrid`](https://mantine.dev/dates/time-grid/), [`TimeInput`](https://mantine.dev/dates/time-input/), [`TimePicker`](https://mantine.dev/dates/time-picker/), [`TimeValue`](https://mantine.dev/dates/time-value/), [`WeekdaysRow`](https://mantine.dev/dates/weekdays-row/), [`YearLevel`](https://mantine.dev/dates/year-level/), [`YearLevelGroup`](https://mantine.dev/dates/year-level-group/), [`YearPicker`](https://mantine.dev/dates/year-picker/), [`YearPickerInput`](https://mantine.dev/dates/year-picker-input/), [`YearsList`](https://mantine.dev/dates/years-list/)
### Mantine Charts
[`AreaChart`](https://mantine.dev/charts/area-chart/), [`BarChart`](https://mantine.dev/charts/bar-chart/), [`BarsList`](https://mantine.dev/charts/bars-list/), [`BubbleChart`](https://mantine.dev/charts/bubble-chart/), [`ChartLegend`](https://mantine.dev/charts/chart-legend/), [`ChartTooltip`](https://mantine.dev/charts/chart-tooltip/), [`CompositeChart`](https://mantine.dev/charts/composite-chart/), [`DonutChart`](https://mantine.dev/charts/donut-chart/), [`FunnelChart`](https://mantine.dev/charts/funnel-chart/), [`Heatmap`](https://mantine.dev/charts/heatmap/), [`LineChart`](https://mantine.dev/charts/line-chart/), [`PieChart`](https://mantine.dev/charts/pie-chart/), [`RadarChart`](https://mantine.dev/charts/radar-chart/), [`RadialBarChart`](https://mantine.dev/charts/radial-bar-chart/), [`SankeyChart`](https://mantine.dev/charts/sankey-chart/), [`ScatterChart`](https://mantine.dev/charts/scatter-chart/), [`Sparkline`](https://mantine.dev/charts/sparkline/), [`Treemap`](https://mantine.dev/charts/treemap/)
### Homarr components
[`ActionButton`](homarr-components.md#component-actionbutton), [`Collapsible`](homarr-components.md#component-collapsible), [`PaginatedList`](homarr-components.md#component-paginatedlist), [`RecursiveList`](homarr-components.md#component-recursivelist), [`RefreshButton`](homarr-components.md#component-refreshbutton), [`StatBar`](homarr-components.md#component-statbar), [`SubData`](homarr-components.md#component-subdata), [`SubFetch`](homarr-components.md#component-subfetch), [`TabPanel`](homarr-components.md#component-tabpanel), [`TablerIcon`](homarr-components.md#component-tablericon), [`TabsContainer`](homarr-components.md#component-tabscontainer), [`ToggleSwitch`](homarr-components.md#component-toggleswitch), [`TypeBadge`](homarr-components.md#component-typebadge)
## Complete denied inventory
### Mantine Core
[`Affix`](https://mantine.dev/core/affix/) — Escapes the widget layout boundary, [`AppShell`](https://mantine.dev/core/app-shell/) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShell.Aside`](https://mantine.dev/core/app-shell/) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShell.Footer`](https://mantine.dev/core/app-shell/) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShell.Header`](https://mantine.dev/core/app-shell/) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShell.Main`](https://mantine.dev/core/app-shell/) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShell.Navbar`](https://mantine.dev/core/app-shell/) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShell.Section`](https://mantine.dev/core/app-shell/) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShellAside`](https://mantine.dev/core/app-shell-aside/) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShellFooter`](https://mantine.dev/core/app-shell-footer/) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShellHeader`](https://mantine.dev/core/app-shell-header/) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShellMain`](https://mantine.dev/core/app-shell-main/) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShellNavbar`](https://mantine.dev/core/app-shell-navbar/) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShellSection`](https://mantine.dev/core/app-shell-section/) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`ColorSchemeScript`](https://mantine.dev/core/color-scheme-script/) — Writes global document state, [`Combobox`](https://mantine.dev/core/combobox/) — Low-level composition primitive that requires authored callbacks, [`Combobox.Chevron`](https://mantine.dev/core/combobox/) — Low-level composition primitive that requires authored callbacks, [`Combobox.ClearButton`](https://mantine.dev/core/combobox/) — Low-level composition primitive that requires authored callbacks, [`Combobox.Dropdown`](https://mantine.dev/core/combobox/) — Low-level composition primitive that requires authored callbacks, [`Combobox.DropdownTarget`](https://mantine.dev/core/combobox/) — Low-level composition primitive that requires authored callbacks, [`Combobox.Empty`](https://mantine.dev/core/combobox/) — Low-level composition primitive that requires authored callbacks, [`Combobox.EventsTarget`](https://mantine.dev/core/combobox/) — Low-level composition primitive that requires authored callbacks, [`Combobox.Footer`](https://mantine.dev/core/combobox/) — Low-level composition primitive that requires authored callbacks, [`Combobox.Group`](https://mantine.dev/core/combobox/) — Low-level composition primitive that requires authored callbacks, [`Combobox.Header`](https://mantine.dev/core/combobox/) — Low-level composition primitive that requires authored callbacks, [`Combobox.HiddenInput`](https://mantine.dev/core/combobox/) — Low-level composition primitive that requires authored callbacks, [`Combobox.Option`](https://mantine.dev/core/combobox/) — Low-level composition primitive that requires authored callbacks, [`Combobox.Options`](https://mantine.dev/core/combobox/) — Low-level composition primitive that requires authored callbacks, [`Combobox.Search`](https://mantine.dev/core/combobox/) — Low-level composition primitive that requires authored callbacks, [`Combobox.Target`](https://mantine.dev/core/combobox/) — Low-level composition primitive that requires authored callbacks, [`ComboboxChevron`](https://mantine.dev/core/combobox-chevron/) — Low-level composition primitive that requires authored callbacks, [`ComboboxClearButton`](https://mantine.dev/core/combobox-clear-button/) — Low-level composition primitive that requires authored callbacks, [`ComboboxDropdown`](https://mantine.dev/core/combobox-dropdown/) — Low-level composition primitive that requires authored callbacks, [`ComboboxDropdownTarget`](https://mantine.dev/core/combobox-dropdown-target/) — Low-level composition primitive that requires authored callbacks, [`ComboboxEmpty`](https://mantine.dev/core/combobox-empty/) — Low-level composition primitive that requires authored callbacks, [`ComboboxEventsTarget`](https://mantine.dev/core/combobox-events-target/) — Low-level composition primitive that requires authored callbacks, [`ComboboxFooter`](https://mantine.dev/core/combobox-footer/) — Low-level composition primitive that requires authored callbacks, [`ComboboxGroup`](https://mantine.dev/core/combobox-group/) — Low-level composition primitive that requLine truncated
### Mantine Dates
[`DatesProvider`](https://mantine.dev/dates/dates-provider/) — Replaces a Homarr-owned provider boundary, [`HiddenDatesInput`](https://mantine.dev/dates/hidden-dates-input/) — Internal date-input primitive, [`PickerInputBase`](https://mantine.dev/dates/picker-input-base/) — Internal date-input primitive
### Mantine Charts
[`AreaGradient`](https://mantine.dev/charts/area-gradient/) — Low-level chart implementation helper
### Homarr components
_None._
## Blocked capabilities
| Kind | Name | Reason |
| ------------ | ------------------------------------------- | ------------------------------------------------------------------------------------- |
| prop | <code>**proto**</code> | This capability is blocked by the Custom JSX safety boundary. |
| prop | <code>apply</code> | This capability is blocked by the Custom JSX safety boundary. |
| prop | <code>arguments</code> | This capability is blocked by the Custom JSX safety boundary. |
| prop | <code>autoFocus</code> | This capability is blocked by the Custom JSX safety boundary. |
| prop | <code>backdropFilter</code> | This capability is blocked by the Custom JSX safety boundary. |
| prop | <code>behavior</code> | This capability is blocked by the Custom JSX safety boundary. |
| prop | <code>bottom</code> | This capability is blocked by the Custom JSX safety boundary. |
| prop | <code>call</code> | This capability is blocked by the Custom JSX safety boundary. |
| prop | <code>callee</code> | This capability is blocked by the Custom JSX safety boundary. |
| prop | <code>caller</code> | This capability is blocked by the Custom JSX safety boundary. |
| prop | <code>children</code> | Children must be expressed as interpreted JSX, not passed as a raw prop. |
| prop | <code>className</code> | Unscoped class injection can escape widget CSS isolation. |
| prop | <code>classNames</code> | Unscoped class injection can escape widget CSS isolation. |
| prop | <code>classes</code> | Unscoped class injection can escape widget CSS isolation. |
| prop | <code>clipPath</code> | This capability is blocked by the Custom JSX safety boundary. |
| prop | <code>component</code> | Polymorphic roots can replace the Homarr-owned element boundary. |
| prop | <code>constructor</code> | This capability is blocked by the Custom JSX safety boundary. |
| prop | <code>content</code> | This capability is blocked by the Custom JSX safety boundary. |
| prop | <code>contentEditable</code> | This capability is blocked by the Custom JSX safety boundary. |
| prop | <code>dangerouslySetInnerHTML</code> | Raw HTML injection is not available to authored widgets. |
| prop | <code>filter</code> | This capability is blocked by the Custom JSX safety boundary. |
| prop | <code>form</code> | External form submission is outside the named-request security boundary. |
| prop | <code>formAction</code> | External form submission is outside the named-request security boundary. |
| prop | <code>formEncType</code> | External form submission is outside the named-request security boundary. |
| prop | <code>formMethod</code> | External form submission is outside the named-request security boundary. |
| prop | <code>formNoValidate</code> | External form submission is outside the named-request security boundary. |
| prop | <code>formTarget</code> | External form submission is outside the named-request security boundary. |
| prop | <code>innerRef</code> | Refs expose underlying React components and DOM nodes. |
| prop | <code>inset</code> | This capability is blocked by the Custom JSX safety boundary. |
| prop | <code>left</code> | This capability is blocked by the Custom JSX safety boundary. |
| prop | <code>mask</code> | This capability is blocked by the Custom JSX safety boundary. |
| prop | <code>ping</code> | This capability is blocked by the Custom JSX safety boundary. |
| prop | <code>pointerEvents</code> | This capability is blocked by the Custom JSX safety boundary. |
| prop | <code>popover</code> | This capability is blocked by the Custom JSX safety boundary. |
| prop | <code>popoverTarget</code> | Portal targeting can render or interact outside the widget root. |
| prop | <code>popoverTargetAction</code> | Portal targeting can render or interact outside the widget root. |
| prop | <code>portalProps</code> | Portal targeting can render or interact outside the widget root. |
| prop | <code>pos</code> | This capability is blocked by the Custom JSX safety boundary. |
| prop | <code>position</code> | This capability is blocked by the Custom JSX safety boundary. |
| prop | <code>prototype</code> | This capability is blocked by the Custom JSX safety boundary. |
| prop | <code>ref</code> | Refs expose underlying React components and DOM nodes. |
| prop | <code>renderRoot</code> | Polymorphic roots can replace the Homarr-owned element boundary. |
| prop | <code>right</code> | This capability is blocked by the Custom JSX safety boundary. |
| prop | <code>srcDoc</code> | Raw HTML injection is not available to authored widgets. |
| prop | <code>suppressContentEditableWarning</code> | This capability is blocked by the Custom JSX safety boundary. |
| prop | <code>suppressHydrationWarning</code> | This capability is blocked by the Custom JSX safety boundary. |
| prop | <code>top</code> | This capability is blocked by the Custom JSX safety boundary. |
| prop | <code>withinPortal</code> | Portal targeting can render or interact outside the widget root. |
| prop | <code>zIndex</code> | This capability is blocked by the Custom JSX safety boundary. |
| prop-pattern | <code>on*</code> | Authored event callbacks can execute code outside the declarative runtime. |
| prop-pattern | <code>*Ref</code> | Refs expose underlying React components and DOM nodes. |
| prop-pattern | <code>__*</code> | Private and prototype-related properties are outside the supported authoring surface. |
@@ -1,82 +0,0 @@
[
{
"id": "service-status",
"title": "Service status",
"description": "A compact status widget backed by one load query.",
"widget": {
"$schema": "homarr-custom-widget-v2",
"name": "Service status",
"sources": [
{
"id": "default",
"name": "Service API",
"baseUrl": "https://example.com",
"networkScope": "public",
"auth": {
"type": "none"
}
}
],
"requests": [
{
"id": "status",
"sourceId": "default",
"kind": "query",
"method": "GET",
"pathTemplate": "/api/status",
"parameters": {},
"auth": "inherit",
"minimumBoardPermission": "view",
"trigger": "load"
}
],
"optionsSchema": {
"type": "object",
"properties": {},
"additionalProperties": false
},
"defaultOptions": {},
"template": "<Stack gap=\"xs\" p=\"sm\">\n <Group justify=\"space-between\">\n <Text fw={600}>{data.status.name ?? \"Service\"}</Text>\n <Badge color={data.status.online ? \"green\" : \"red\"}>{data.status.online ? \"Online\" : \"Offline\"}</Badge>\n </Group>\n <Text size=\"sm\" c=\"dimmed\">{data.status.message ?? \"No additional details\"}</Text>\n </Stack>"
}
},
{
"id": "pokemon-evolution-tree",
"title": "Pokémon evolution tree",
"description": "A trusted arbitrary-depth tree with one immutable derived-value block per node.",
"widget": {
"$schema": "homarr-custom-widget-v2",
"name": "Pokémon evolution tree",
"sources": [
{
"id": "default",
"name": "PokeAPI",
"baseUrl": "https://pokeapi.co",
"networkScope": "public",
"auth": {
"type": "none"
}
}
],
"requests": [
{
"id": "evolution",
"sourceId": "default",
"kind": "query",
"method": "GET",
"pathTemplate": "/api/v2/evolution-chain/1",
"parameters": {},
"auth": "inherit",
"minimumBoardPermission": "view",
"trigger": "load"
}
],
"optionsSchema": {
"type": "object",
"properties": {},
"additionalProperties": false
},
"defaultOptions": {},
"template": "<Stack gap=\"sm\" p=\"sm\">\n <Title order={3}>Evolution chain</Title>\n {status.evolution?.loading ? <Skeleton height={160} radius=\"md\" /> : status.evolution?.error ? <Alert color=\"red\">{status.evolution.error}</Alert> : data.evolution?.chain ?\n <RecursiveList data={data.evolution.chain} childrenPath=\"evolves_to\" keyPath=\"species.name\" defaultExpandedDepth={8} showLines>\n {(node, meta) => {\n const segments = node.species.url.split(\"/\").filter((part) => part);\n const dexId = segments.at(-1);\n return <Group gap=\"xs\"><Badge variant=\"light\">#{dexId}</Badge><Text tt=\"capitalize\" fw={600}>{node.species.name}</Text>{meta.hasChildren && <Text size=\"xs\" c=\"dimmed\">{meta.childCount} next</Text>}</Group>;\n }}\n </RecursiveList> : <Text c=\"dimmed\">No evolution chain returned.</Text>}\n</Stack>"
}
}
]
@@ -1,348 +0,0 @@
# Homarr components
> Generated from `component-catalog-v1.json`. Catalog schema v1 · Custom Widget 2.0.0 · Mantine 9.4.1. Do not edit this file by hand.
Package: `@homarr/widgets`. The component-specific props below supplement ordinary safe serializable Mantine props and style props. Component-specific records override ordinary behavior with the same name.
## Package guidance
Homarr components own controlled request, action, pagination, disclosure, icon, and recursive traversal behavior. Manual request parameters come from the invoking component's `params` prop. `RecursiveList` is the only supported arbitrary-depth authored renderer and its child template may use the restricted safe-block grammar.
## Enabled inventory
<code>ActionButton</code>, <code>Collapsible</code>, <code>PaginatedList</code>, <code>RecursiveList</code>, <code>RefreshButton</code>, <code>StatBar</code>, <code>SubData</code>, <code>SubFetch</code>, <code>TabPanel</code>, <code>TablerIcon</code>, <code>TabsContainer</code>, <code>ToggleSwitch</code>, <code>TypeBadge</code>
## Denied inventory
_None._
## Component records
<a id="component-actionbutton"></a>
### `ActionButton`
- Safety: `wrapped`
- Category: `network`
- Documentation: [upstream component API](https://homarr.dev/docs/management/custom-widgets/)
- Temporary bind: not available
- Subcomponents: none
- Component-specific blocked props: none
- Accessibility:
- Action labels must describe their effect; destructive actions require confirmation.
Runs a named user-triggered action.
#### Component-specific props
| Prop | Required | Type | Literal values | Description |
| --------------------------- | -------- | ---------------------------------------------------------------------- | -------------- | --------------------------------------------- |
| <code>color</code> | no | <code>string</code> | — | — |
| <code>confirmMessage</code> | no | <code>string</code> | — | — |
| <code>disabled</code> | no | <code>boolean &#124; string</code> | — | — |
| <code>errorMessage</code> | no | <code>string</code> | — | — |
| <code>fullWidth</code> | no | <code>boolean &#124; string</code> | — | — |
| <code>icon</code> | no | <code>string</code> | — | — |
| <code>label</code> | no | <code>string</code> | — | — |
| <code>params</code> | no | <code>Record&lt;string, string &#124; number &#124; boolean&gt;</code> | — | — |
| <code>requestId</code> | no | <code>string</code> | — | Identifier of the named Custom Widget action. |
| <code>size</code> | no | <code>string</code> | — | — |
| <code>successMessage</code> | no | <code>string</code> | — | — |
| <code>variant</code> | no | <code>string</code> | — | — |
<a id="component-collapsible"></a>
### `Collapsible`
- Safety: `allowed`
- Category: `interaction`
- Documentation: [upstream component API](https://homarr.dev/docs/management/custom-widgets/)
- Temporary bind: not available
- Subcomponents: none
- Component-specific blocked props: none
- Accessibility:
- Provide a visible label or aria-label.
Shows and hides a bounded content section.
#### Component-specific props
| Prop | Required | Type | Literal values | Description |
| ------------------------ | -------- | -------------------- | -------------- | ----------- |
| <code>defaultOpen</code> | no | <code>boolean</code> | — | — |
| <code>title</code> | yes | <code>string</code> | — | — |
<a id="component-paginatedlist"></a>
### `PaginatedList`
- Safety: `allowed`
- Category: `interaction`
- Documentation: [upstream component API](https://homarr.dev/docs/management/custom-widgets/)
- Temporary bind: not available
- Subcomponents: none
- Component-specific blocked props: none
- Accessibility:
- Provide a visible label or aria-label.
Renders a bounded page of collection items.
#### Component-specific props
| Prop | Required | Type | Literal values | Description |
| --------------------- | -------- | ------------------- | -------------- | ----------- |
| <code>pageSize</code> | no | <code>number</code> | — | — |
<a id="component-recursivelist"></a>
### `RecursiveList`
- Safety: `wrapped`
- Category: `content`
- Documentation: [upstream component API](https://homarr.dev/docs/management/custom-widgets/)
- Temporary bind: not available
- Subcomponents: none
- Component-specific blocked props: none
- Accessibility:
- Provide meaningful text alternatives for non-text content.
Renders an arbitrary-depth tree through a bounded, Homarr-owned traversal.
#### Component-specific props
| Prop | Required | Type | Literal values | Description |
| --------------------------------- | -------- | --------------------------- | -------------- | ----------- |
| <code>childrenPath</code> | yes | <code>string</code> | — | — |
| <code>data</code> | yes | <code>unknown</code> | — | — |
| <code>defaultExpandedDepth</code> | no | <code>number</code> | — | — |
| <code>gap</code> | no | <code>MantineSpacing</code> | — | — |
| <code>indent</code> | no | <code>MantineSpacing</code> | — | — |
| <code>keyPath</code> | yes | <code>string</code> | — | — |
| <code>maxDepth</code> | no | <code>number</code> | — | — |
| <code>maxNodes</code> | no | <code>number</code> | — | — |
| <code>showLines</code> | no | <code>boolean</code> | — | — |
<a id="component-refreshbutton"></a>
### `RefreshButton`
- Safety: `wrapped`
- Category: `network`
- Documentation: [upstream component API](https://homarr.dev/docs/management/custom-widgets/)
- Temporary bind: not available
- Subcomponents: none
- Component-specific blocked props: none
- Accessibility:
- Action labels must describe their effect; destructive actions require confirmation.
Refreshes load-triggered widget queries.
#### Component-specific props
| Prop | Required | Type | Literal values | Description |
| -------------------- | -------- | ------------------- | -------------- | ----------- |
| <code>color</code> | no | <code>string</code> | — | — |
| <code>label</code> | no | <code>string</code> | — | — |
| <code>size</code> | no | <code>string</code> | — | — |
| <code>variant</code> | no | <code>string</code> | — | — |
<a id="component-statbar"></a>
### `StatBar`
- Safety: `allowed`
- Category: `interaction`
- Documentation: [upstream component API](https://homarr.dev/docs/management/custom-widgets/)
- Temporary bind: not available
- Subcomponents: none
- Component-specific blocked props: none
- Accessibility:
- Provide a visible label or aria-label.
Displays a compact labeled statistic and progress value.
#### Component-specific props
| Prop | Required | Type | Literal values | Description |
| ------------------ | -------- | ------------------- | -------------- | ----------- |
| <code>color</code> | no | <code>string</code> | — | — |
| <code>label</code> | no | <code>string</code> | — | — |
| <code>max</code> | no | <code>number</code> | — | — |
| <code>value</code> | yes | <code>number</code> | — | — |
<a id="component-subdata"></a>
### `SubData`
- Safety: `wrapped`
- Category: `network`
- Documentation: [upstream component API](https://homarr.dev/docs/management/custom-widgets/)
- Temporary bind: not available
- Subcomponents: none
- Component-specific blocked props: none
- Accessibility:
- Action labels must describe their effect; destructive actions require confirmation.
Reads a nested value from a named manual-query result.
#### Component-specific props
| Prop | Required | Type | Literal values | Description |
| -------------------- | -------- | ------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- | ----------- |
| <code>as</code> | no | <code>'Text' &#124; 'Title' &#124; 'Badge' &#124; 'Code' &#124; 'Image'</code> | <code>"Text"</code>, <code>"Title"</code>, <code>"Badge"</code>, <code>"Code"</code>, <code>"Image"</code> | — |
| <code>c</code> | no | <code>string</code> | — | — |
| <code>color</code> | no | <code>string</code> | — | — |
| <code>fit</code> | no | <code>string</code> | — | — |
| <code>fw</code> | no | <code>number</code> | — | — |
| <code>h</code> | no | <code>string &#124; number</code> | — | — |
| <code>order</code> | no | <code>1 &#124; 2 &#124; 3 &#124; 4 &#124; 5 &#124; 6</code> | <code>1</code>, <code>2</code>, <code>3</code>, <code>4</code>, <code>5</code>, <code>6</code> | — |
| <code>radius</code> | no | <code>string &#124; number</code> | — | — |
| <code>size</code> | no | <code>string</code> | — | — |
| <code>variant</code> | no | <code>string</code> | — | — |
| <code>w</code> | no | <code>string &#124; number</code> | — | — |
<a id="component-subfetch"></a>
### `SubFetch`
- Safety: `wrapped`
- Category: `network`
- Documentation: [upstream component API](https://homarr.dev/docs/management/custom-widgets/)
- Temporary bind: not available
- Subcomponents: none
- Component-specific blocked props: none
- Accessibility:
- Action labels must describe their effect; destructive actions require confirmation.
Runs a named manual query with invocation-time parameters.
#### Component-specific props
| Prop | Required | Type | Literal values | Description |
| ---------------------------- | -------- | ---------------------------------------------------------------------- | ------------------------------------------ | -------------------------------------------- |
| <code>as</code> | no | <code>'json' &#124; 'text'</code> | <code>"json"</code>, <code>"text"</code> | — |
| <code>errorMessage</code> | no | <code>string</code> | — | — |
| <code>fallback</code> | no | <code>ReactNode</code> | — | — |
| <code>loadingLabel</code> | no | <code>string</code> | — | — |
| <code>params</code> | no | <code>Record&lt;string, string &#124; number &#124; boolean&gt;</code> | — | — |
| <code>refreshInterval</code> | no | <code>number</code> | — | — |
| <code>requestId</code> | no | <code>string</code> | — | Identifier of the named Custom Widget query. |
| <code>trigger</code> | no | <code>'auto' &#124; 'manual'</code> | <code>"auto"</code>, <code>"manual"</code> | — |
<a id="component-tabpanel"></a>
### `TabPanel`
- Safety: `allowed`
- Category: `interaction`
- Documentation: [upstream component API](https://homarr.dev/docs/management/custom-widgets/)
- Temporary bind: not available
- Subcomponents: none
- Component-specific blocked props: none
- Accessibility:
- Provide a visible label or aria-label.
Renders content for one declarative tab.
#### Component-specific props
| Prop | Required | Type | Literal values | Description |
| ------------------ | -------- | ------------------- | -------------- | ----------- |
| <code>label</code> | no | <code>string</code> | — | — |
| <code>value</code> | yes | <code>string</code> | — | — |
<a id="component-tablericon"></a>
### `TablerIcon`
- Safety: `wrapped`
- Category: `content`
- Documentation: [upstream component API](https://homarr.dev/docs/management/custom-widgets/)
- Temporary bind: not available
- Subcomponents: none
- Component-specific blocked props: none
- Accessibility:
- Provide meaningful text alternatives for non-text content.
Resolves an installed Tabler icon by its safe name.
#### Component-specific props
| Prop | Required | Type | Literal values | Description |
| ------------------- | -------- | --------------------------------- | -------------- | ----------- |
| <code>color</code> | no | <code>string</code> | — | — |
| <code>name</code> | yes | <code>string</code> | — | — |
| <code>size</code> | no | <code>string &#124; number</code> | — | — |
| <code>stroke</code> | no | <code>string &#124; number</code> | — | — |
<a id="component-tabscontainer"></a>
### `TabsContainer`
- Safety: `allowed`
- Category: `interaction`
- Documentation: [upstream component API](https://homarr.dev/docs/management/custom-widgets/)
- Temporary bind: not available
- Subcomponents: none
- Component-specific blocked props: none
- Accessibility:
- Provide a visible label or aria-label.
Renders a declarative tab container.
#### Component-specific props
| Prop | Required | Type | Literal values | Description |
| ----------------------- | -------- | ------------------- | -------------- | ----------- |
| <code>defaultTab</code> | no | <code>string</code> | — | — |
<a id="component-toggleswitch"></a>
### `ToggleSwitch`
- Safety: `wrapped`
- Category: `network`
- Documentation: [upstream component API](https://homarr.dev/docs/management/custom-widgets/)
- Temporary bind: not available
- Subcomponents: none
- Component-specific blocked props: none
- Accessibility:
- Action labels must describe their effect; destructive actions require confirmation.
Runs named actions for the enabled and disabled states.
#### Component-specific props
| Prop | Required | Type | Literal values | Description |
| --------------------------- | -------- | ---------------------------------------------------------------------- | -------------- | --------------------------------------------- |
| <code>color</code> | no | <code>string</code> | — | — |
| <code>disabled</code> | no | <code>boolean &#124; string</code> | — | — |
| <code>disabledParams</code> | no | <code>Record&lt;string, string &#124; number &#124; boolean&gt;</code> | — | — |
| <code>enabledParams</code> | no | <code>Record&lt;string, string &#124; number &#124; boolean&gt;</code> | — | — |
| <code>errorMessage</code> | no | <code>string</code> | — | — |
| <code>initialValue</code> | no | <code>boolean &#124; string</code> | — | — |
| <code>label</code> | no | <code>string</code> | — | — |
| <code>requestId</code> | no | <code>string</code> | — | Identifier of the named Custom Widget action. |
| <code>size</code> | no | <code>string</code> | — | — |
<a id="component-typebadge"></a>
### `TypeBadge`
- Safety: `allowed`
- Category: `interaction`
- Documentation: [upstream component API](https://homarr.dev/docs/management/custom-widgets/)
- Temporary bind: not available
- Subcomponents: none
- Component-specific blocked props: none
- Accessibility:
- Provide a visible label or aria-label.
Displays a compact semantic type badge.
#### Component-specific props
| Prop | Required | Type | Literal values | Description |
| ----------------- | -------- | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | ----------- |
| <code>size</code> | no | <code>'xs' &#124; 'sm' &#124; 'md' &#124; 'lg' &#124; 'xl'</code> | <code>"xs"</code>, <code>"sm"</code>, <code>"md"</code>, <code>"lg"</code>, <code>"xl"</code> | — |
| <code>type</code> | yes | <code>string</code> | — | — |
@@ -1,5 +0,0 @@
# Mantine Charts
Use the official Mantine LLM index in [Mantine Core](mantine-core.md#charts) to discover chart documentation.
The upstream source is https://mantine.dev/llms.txt. Follow its `## Charts` links for current component documentation. Homarr's live component catalog remains authoritative for the installed component names and blocked capabilities.
@@ -1,471 +0,0 @@
# Mantine UI - LLM Documentation
This index lists Mantine documentation pages formatted for LLMs.
Each link points to a standalone Markdown file under the /llms path.
For a single consolidated file with all content, use:
- https://mantine.dev/llms-full.txt
## FAQ
- [Are Mantine components accessible?](https://mantine.dev/llms/q-are-mantine-components-accessible.md): Learn about Mantine components accessibility features
- [Browser zooms in when input is focused. What should I do?](https://mantine.dev/llms/q-browser-zooms-on-focus.md): Use meta tag to disable browser scaling or increase input size
- [Can I have color schemes other than light and dark?](https://mantine.dev/llms/q-light-dark-is-not-enough.md): Learn about the difference between color scheme and theme
- [Can I have different primary color for light and dark color schemes?](https://mantine.dev/llms/q-primary-virtual-color.md): Learn how to use virtual color with primary color in theme object
- [Can I remove MultiSelect placeholder when the component has selected values?](https://mantine.dev/llms/q-multiselect-value-placeholder.md): Learn why MultiSelect placeholder is not removed when values are selected and how to remove it with CSS
- [Can I use an array of strings as a list in use-form?](https://mantine.dev/llms/q-list-of-strings-in-use-form.md): Learn about use-form lists limitations
- [Can I use Mantine components as server components?](https://mantine.dev/llms/q-server-components.md): Learn about use client directive and server components usage
- [Can I use Mantine with Astro?](https://mantine.dev/llms/q-can-i-use-mantine-with-astro.md): No, Astro does not support React context
- [Can I use Mantine with Create React App (CRA)?](https://mantine.dev/llms/q-can-i-use-mantine-with-cra.md): Learn how to use Mantine without postcss-preset-mantine or how to eject CRA and customize webpack.config.js and use postcss-preset-mantine
- [Can I use Mantine with Emotion/styled-components/tailwindcss?](https://mantine.dev/llms/q-third-party-styles.md): Learn about limitations of third-party styles
- [Can I use Mantine with Vue/Svelte/Angular/etc.?](https://mantine.dev/llms/q-vue-svelte-angular.md): No, Mantine is a React library and does not support other frameworks/libraries
- [Can I use nested inline styles with Mantine components?](https://mantine.dev/llms/q-nested-inline-styles.md): Nested styles are supported only in CSS files
- [Can I use PostCSS function in inline styles?](https://mantine.dev/llms/q-postcss-fns-inline.md): Learn where PostCSS functions can be used in Mantine
- [Can I use private CSS variables to style components?](https://mantine.dev/llms/q-private-css-variables.md): No, it is not safe and will not work with future versions of Mantine.
- [Can I use SegmentedControl with empty value?](https://mantine.dev/llms/q-segmented-control-no-value.md): SegmentedControl cannot be used without a value
- [How can I add dynamic CSS styles?](https://mantine.dev/llms/q-dynamic-css-styles.md): Use data attributes, CSS variables or inline styles
- [How can I add fuzzy search to Select component?](https://mantine.dev/llms/q-select-fuzzy.md): Learn how to integrate third-party fuzzy search libraries with Mantine Select component
- [How can I add hover styles to an element?](https://mantine.dev/llms/q-how-to-add-hover-styles.md): Learn how to add hover classes to an element with CSS modules, &:hover or @mixin hover
- [How can I apply styles to all Mantine components?](https://mantine.dev/llms/q-apply-styles-to-all.md): Learn how to use attribute selector to apply styles to all Mantine components
- [How can I change body background color?](https://mantine.dev/llms/q-body-background.md): Use CSS to change body background color
- [How can I change component color prop value depending on the color scheme?](https://mantine.dev/llms/q-color-scheme-color.md): Learn how to use CSS variables resolver to change color value depending on the color scheme
- [How can I change input focus styles?](https://mantine.dev/llms/q-input-focus-styles.md): Learn how to use Styles API with Mantine inputs
- [How can I change Tabs border color?](https://mantine.dev/llms/q-tabs-border-color.md): Learn how to use Styles API with Tabs component
- [How can I contribute to the library?](https://mantine.dev/llms/q-how-can-i-contribute.md): There are multiple ways to contribute even without writing code
- [How can I disable all inputs/input groups inside a form?](https://mantine.dev/llms/q-disable-all-inputs-in-form.md): Learn how to disable all inputs/input groups inside a form with the unstyled Fieldset component
- [How can I display different elements in light and dark color schemes?](https://mantine.dev/llms/q-light-dark-elements.md): Learn how to hide/show elements based on color scheme
- [How can I focus the first input with error with use-form?](https://mantine.dev/llms/q-focus-first-input-with-error.md): Learn how to handle focus with use-form hook
- [How can I get current color scheme value in JavaScript?](https://mantine.dev/llms/q-how-to-get-color-scheme-value-in-js.md): How to use useMantineColorScheme and useComputedColorScheme hooks to get current color scheme value in JavaScript
- [How can I lint CSS files?](https://mantine.dev/llms/q-how-to-setup-stylelint.md): Learn how to set up Stylelint for Mantine postcss syntax
- [How can I load fonts in Next.js?](https://mantine.dev/llms/q-next-load-fonts.md): A guide to load custom fonts in Next.js with CSS and next/font package
- [How can I load fonts in React Router?](https://mantine.dev/llms/q-react-router-load-fonts.md): A guide to load custom fonts in React Router
- [How can I load fonts in Vite?](https://mantine.dev/llms/q-vite-load-fonts.md): A guide to load custom fonts in Vite
- [How can I lock scroll in my application?](https://mantine.dev/llms/q-how-to-lock-scroll.md): Use react-remove-scroll library to lock scroll in your application
- [How can I submit a template to Mantine documentation?](https://mantine.dev/llms/q-submit-template.md): Learn how to create and submit a template to Mantine documentation
- [How can I test Modal/Drawer/Popover components?](https://mantine.dev/llms/q-portals-testing.md): Learn how to use react-testing-library to test components that use portals and transitions.
- [How can I test Select/MultiSelect components?](https://mantine.dev/llms/q-combobox-testing.md): Learn how to use react-testing-library to test Select and MultiSelect components.
- [How can I upload files from Dropzone component?](https://mantine.dev/llms/q-dropzone-upload.md): Learn how to process files dropped into Dropzone component
- [How is that thing done on the mantine.dev website?](https://mantine.dev/llms/q-how-that-thing-is-done.md): Learn how various elements are implemented on the mantine.dev website
- [How to align input with a button in a flex container?](https://mantine.dev/llms/q-align-input-button.md): Learn how to align Mantine inputs with buttons using Group component
- [How to call a function when Modal/Drawer closes and animation completes?](https://mantine.dev/llms/q-how-to-call-function-when-modal-closes.md): How to use transitionProps in Modal/Drawer components
- [How to change background color for FiveM?](https://mantine.dev/llms/q-fivem-background-color.md): Unset body background color and color-scheme styles for FiveM
- [How to change input placeholder color?](https://mantine.dev/llms/q-inputs-placeholder-color.md): Learn how to change placeholder color with Styles API
- [How to integrate custom input with use-form hook?](https://mantine.dev/llms/q-custom-input-use-form.md): Learn how to add use-form support for custom inputs
- [How to prevent Modal from closing?](https://mantine.dev/llms/q-how-to-prevent-modal-from-closing.md): Learn how to prevent Modal from closing when the user clicks outside of it or presses the Escape key
- [How to scroll to the top of the form if the form is submitted with errors?](https://mantine.dev/llms/q-scroll-to-the-top-of-the-form.md): Learn how to call a function when the form is submitted with errors
- [How to update Mantine dependencies?](https://mantine.dev/llms/q-how-to-update-dependencies.md): Learn how to update @mantine/_, @mantinex/_ and postcss-preset-mantine dependencies manually or with ncu script
- [How to use Dropzone with @mantine/form?](https://mantine.dev/llms/q-how-to-use-dropzone-with-form.md): Learn how to use Dropzone with @mantine/form to handle file selection state
- [How to use Mantine template on GitHub?](https://mantine.dev/llms/q-templates-usage.md): Learn how to create a new repository based on Mantine template
- [I get hydration warning about data-mantine-color-scheme attribute, what does it mean?](https://mantine.dev/llms/q-color-scheme-hydration-warning.md): Learn how hydration works and how to fix warnings
- [Is there a comparison with other libraries?](https://mantine.dev/llms/q-other-libs.md): Comparison with other libraries is not provided, you are trusted to make your own decision based on documentation examples
- [Is there a floating action button component?](https://mantine.dev/llms/q-floating-action-button.md): No, but you can build it with Affix and ActionIcon components
- [Is there DataGrid component that I can use with Mantine?](https://mantine.dev/llms/q-data-grid-i-need.md): A list of community packages that provide DataGrid component that can be used with Mantine.
- [It is not possible to pinch to zoom when a Modal is opened. What should I do?](https://mantine.dev/llms/q-pinch-to-zoom-modal.md): Use removeScrollProps to configure react-remove-scroll options
- [MantineProvider was not found in component tree. What should I do?](https://mantine.dev/llms/q-mantine-provider-missing.md): Learn how to resolve MantineProvider issues
- [My buttons are transparent and the background is visible only on hover, what is wrong?](https://mantine.dev/llms/q-transparent-buttons.md): You have installed a third-party library that overrides Mantine styles
- [My Popover dropdown closes when I click on the dropdown of a nested Popover](https://mantine.dev/llms/q-nested-popover-closes.md): Popover dropdown is closed when it detects click-outside events
- [My styles are broken with disabled JavaScript. What should I do?](https://mantine.dev/llms/q-disabled-js.md): Learn how to use Mantine without JavaScript enabled
- [My styles are overridden by Mantine components styles, what should I do?](https://mantine.dev/llms/q-styles-order.md): Learn how to use CSS layers to control styles order and prevent Mantine components from overriding your styles
- [Native browser validation does not work in some components, what should I do?](https://mantine.dev/llms/q-native-required.md): Learn why native browser validation does not work in some components
- [The use-local-storage hook returns the real value only after mounting. Is it a bug?](https://mantine.dev/llms/q-local-storage-effect.md): Learn how Mantine retrieves local storage value
- [What is the difference between searchable Select and Autocomplete?](https://mantine.dev/llms/q-select-autocomplete-difference.md): Searchable Select and Autocomplete are similar components, but they serve different purposes.
- [Where can I find the roadmap?](https://mantine.dev/llms/q-roadmap.md): The roadmap is private and only available to the team.
- [Why are my Carousel slides in vertical orientation?](https://mantine.dev/llms/q-carousel-missing-styles.md): You forgot to import carousel styles
- [Why are my notifications displayed at the wrong position?](https://mantine.dev/llms/q-notifications-missing-styles.md): Because you did not import styles
- [Why can I not use one polymorphic component in the component prop of another polymorphic component?](https://mantine.dev/llms/q-polymorphic-in-polymorphic.md): Learn how polymorphic components types work
- [Why can I not use value/label data structure with Autocomplete/TagsInput?](https://mantine.dev/llms/q-autocomplete-value-label.md): Learn the difference between Autocomplete and Select components
- [Why can VSCode not autoimport the Text component?](https://mantine.dev/llms/q-why-vscode-cannot-autoimport-text.md): It is confused by the native Text constructor
- [Why do I see color scheme flickering on page load?](https://mantine.dev/llms/q-color-scheme-flickering.md): Color scheme flickering is caused by incorrect usage of ColorSchemeScript
- [Why do my dates components look broken?](https://mantine.dev/llms/q-dates-missing-styles.md): Because you did not import styles
- [Why does FileButton not work in Menu?](https://mantine.dev/llms/q-file-button-in-menu.md): Learn how to use FileButton in Menu component
- [Why is it required to have 10 shades per color?](https://mantine.dev/llms/q-ten-shades-per-color.md): Learn how Mantine uses theme colors
- [Why is my screen completely empty after I](https://mantine.dev/llms/q-notifications-empty-screen.md): You have used Notifications component incorrectly
## Core
- [Accordion](https://mantine.dev/llms/core-accordion.md): Divide content into collapsible sections
- [ActionIcon](https://mantine.dev/llms/core-action-icon.md): Icon button
- [Affix](https://mantine.dev/llms/core-affix.md): Renders children inside portal at fixed position
- [Alert](https://mantine.dev/llms/core-alert.md): Attract user attention with important static message
- [AlphaSlider](https://mantine.dev/llms/core-alpha-slider.md): Slider component for selecting alpha channel in color pickers (0 – 1)
- [Anchor](https://mantine.dev/llms/core-anchor.md): Display link with theme styles
- [AngleSlider](https://mantine.dev/llms/core-angle-slider.md): Pick angle value between 0 and 360
- [AppShell](https://mantine.dev/llms/core-app-shell.md): Responsive shell for your application with header, navbar, aside and footer
- [AspectRatio](https://mantine.dev/llms/core-aspect-ratio.md): Maintain responsive consistent width/height ratio
- [Autocomplete](https://mantine.dev/llms/core-autocomplete.md): Autocomplete user input with any list of options
- [Avatar](https://mantine.dev/llms/core-avatar.md): Display user profile image, initials or fallback icon
- [BackgroundImage](https://mantine.dev/llms/core-background-image.md): Displays image as background
- [Badge](https://mantine.dev/llms/core-badge.md): Display badge, pill or tag
- [Blockquote](https://mantine.dev/llms/core-blockquote.md): Blockquote with optional cite
- [Box](https://mantine.dev/llms/core-box.md): Base component for all Mantine components
- [Breadcrumbs](https://mantine.dev/llms/core-breadcrumbs.md): Separates list of react nodes with given separator
- [Burger](https://mantine.dev/llms/core-burger.md): Open/close navigation button
- [Button](https://mantine.dev/llms/core-button.md): Button component to render button or link
- [Card](https://mantine.dev/llms/core-card.md): Card with sections
- [Center](https://mantine.dev/llms/core-center.md): Centers content vertically and horizontally
- [Checkbox](https://mantine.dev/llms/core-checkbox.md): Capture boolean input from user
- [Chip](https://mantine.dev/llms/core-chip.md): Pick one or multiple values with inline controls
- [CloseButton](https://mantine.dev/llms/core-close-button.md): Button with close icon
- [Code](https://mantine.dev/llms/core-code.md): Inline and block code
- [Collapse](https://mantine.dev/llms/core-collapse.md): Animate presence with slide down/up transition
- [ColorInput](https://mantine.dev/llms/core-color-input.md): Capture color from user
- [ColorPicker](https://mantine.dev/llms/core-color-picker.md): Pick colors in hex(a), rgb(a), hsl(a) and hsv(a) formats
- [ColorSwatch](https://mantine.dev/llms/core-color-swatch.md): Displays color
- [Combobox](https://mantine.dev/llms/core-combobox.md): Create custom select, autocomplete or multiselect inputs
- [ComboboxPopover](https://mantine.dev/llms/core-combobox-popover.md): Add combobox dropdown to any button element
- [Container](https://mantine.dev/llms/core-container.md): Center content with padding and max-width
- [CopyButton](https://mantine.dev/llms/core-copy-button.md): Copies given text to clipboard
- [CorePackage](https://mantine.dev/llms/core-package.md)
- [DataList](https://mantine.dev/llms/core-data-list.md): Display label-value pairs as a description list
- [Dialog](https://mantine.dev/llms/core-dialog.md): Display a fixed overlay dialog at any side of the screen
- [Divider](https://mantine.dev/llms/core-divider.md): Horizontal line with optional label or vertical divider
- [Drawer](https://mantine.dev/llms/core-drawer.md): Display overlay area at any side of the screen
- [EmptyState](https://mantine.dev/llms/core-empty-state.md): Display a placeholder for no data, empty results and first-run states
- [Fieldset](https://mantine.dev/llms/core-fieldset.md): Group related elements in a form
- [FileButton](https://mantine.dev/llms/core-file-button.md): Open file picker with a button click
- [FileInput](https://mantine.dev/llms/core-file-input.md): Capture files from user
- [Flex](https://mantine.dev/llms/core-flex.md): Compose elements in a flex container
- [FloatingIndicator](https://mantine.dev/llms/core-floating-indicator.md): Display a floating indicator over a group of elements
- [FloatingWindow](https://mantine.dev/llms/core-floating-window.md): Draggable floating area
- [FocusTrap](https://mantine.dev/llms/core-focus-trap.md): Trap focus at child node
- [Grid](https://mantine.dev/llms/core-grid.md): Responsive 12 columns grid system
- [Group](https://mantine.dev/llms/core-group.md): Compose elements and components in a horizontal flex container
- [Highlight](https://mantine.dev/llms/core-highlight.md): Highlight given part of a string with mark
- [HoverCard](https://mantine.dev/llms/core-hover-card.md): Display popover section when target element is hovered
- [HueSlider](https://mantine.dev/llms/core-hue-slider.md): Slider component for selecting hue channel in color pickers (0 – 360 degrees)
- [Image](https://mantine.dev/llms/core-image.md): Image with optional fallback
- [Indicator](https://mantine.dev/llms/core-indicator.md): Display element at the corner of another element
- [Input](https://mantine.dev/llms/core-input.md): Base component to create custom inputs
- [JsonInput](https://mantine.dev/llms/core-json-input.md): Capture json data from user
- [Kbd](https://mantine.dev/llms/core-kbd.md): Display keyboard key
- [List](https://mantine.dev/llms/core-list.md): Display ordered or unordered list
- [Loader](https://mantine.dev/llms/core-loader.md): Indicate loading state
- [LoadingOverlay](https://mantine.dev/llms/core-loading-overlay.md): An overlay with centered loader
- [Mark](https://mantine.dev/llms/core-mark.md): Highlight part of the text
- [Marquee](https://mantine.dev/llms/core-marquee.md): Create continuous scrolling animation for content
- [MaskInput](https://mantine.dev/llms/core-mask-input.md): Input with mask pattern for formatted text entry
- [Menu](https://mantine.dev/llms/core-menu.md): Combine a list of secondary actions into single interactive area
- [Menubar](https://mantine.dev/llms/core-menubar.md): Desktop application style menubar with a row of menu triggers
- [Modal](https://mantine.dev/llms/core-modal.md): An accessible overlay dialog
- [MultiSelect](https://mantine.dev/llms/core-multi-select.md): Custom searchable multi select
- [NativeSelect](https://mantine.dev/llms/core-native-select.md): Native select element based on Input
- [NavLink](https://mantine.dev/llms/core-nav-link.md): Navigation link
- [Notification](https://mantine.dev/llms/core-notification.md): Show dynamic notifications and alerts to user, part of notifications system
- [NumberFormatter](https://mantine.dev/llms/core-number-formatter.md): Format number with thousands/decimal separators and suffix/prefix
- [NumberInput](https://mantine.dev/llms/core-number-input.md): Capture number from user
- [OverflowList](https://mantine.dev/llms/core-overflow-list.md): Hide items that do not fit in a container and display them as a single collapsed item
- [Overlay](https://mantine.dev/llms/core-overlay.md): Overlays parent element with div element with any color and opacity
- [Pagination](https://mantine.dev/llms/core-pagination.md): Display active page and navigate between multiple pages
- [Paper](https://mantine.dev/llms/core-paper.md): Renders white or dark background depending on color scheme
- [PasswordInput](https://mantine.dev/llms/core-password-input.md): Capture password data from user
- [Pill](https://mantine.dev/llms/core-pill.md): Removable and non-removable tags
- [PillsInput](https://mantine.dev/llms/core-pills-input.md): Base component for custom tags inputs and multi selects
- [PinInput](https://mantine.dev/llms/core-pin-input.md): Capture pin code or one time password from the user
- [Popover](https://mantine.dev/llms/core-popover.md): Display popover section relative to given target element
- [Portal](https://mantine.dev/llms/core-portal.md): Renders component outside of parent element tree
- [Progress](https://mantine.dev/llms/core-progress.md): Give user feedback for status of the task
- [Radio](https://mantine.dev/llms/core-radio.md): Wrapper for input type radio
- [RangeSlider](https://mantine.dev/llms/core-range-slider.md): RangeSlider component
- [Rating](https://mantine.dev/llms/core-rating.md): Pick and display rating
- [RingProgress](https://mantine.dev/llms/core-ring-progress.md): Give user feedback for status of the task with circle diagram
- [RollingNumber](https://mantine.dev/llms/core-rolling-number.md): Animate value changes with rolling digits
- [ScrollArea](https://mantine.dev/llms/core-scroll-area.md): Area with custom scrollbars
- [Scroller](https://mantine.dev/llms/core-scroller.md): Horizontal scroll container with navigation controls
- [SegmentedControl](https://mantine.dev/llms/core-segmented-control.md): A linear set of two or more segments
- [Select](https://mantine.dev/llms/core-select.md): Custom searchable select
- [SemiCircleProgress](https://mantine.dev/llms/core-semi-circle-progress.md): Represent progress with semi circle diagram
- [SimpleGrid](https://mantine.dev/llms/core-simple-grid.md): Responsive grid in which each item takes equal amount of space
- [Skeleton](https://mantine.dev/llms/core-skeleton.md): Indicate content loading state
- [Slider](https://mantine.dev/llms/core-slider.md): Slider component
- [Space](https://mantine.dev/llms/core-space.md): Add horizontal or vertical spacing from theme
- [Splitter](https://mantine.dev/llms/core-splitter.md): Resizable split pane layout
- [Spoiler](https://mantine.dev/llms/core-spoiler.md): Hide long sections of content under a spoiler
- [Stack](https://mantine.dev/llms/core-stack.md): Compose elements and components in a vertical flex container
- [Stepper](https://mantine.dev/llms/core-stepper.md): Display content divided into a steps sequence
- [Switch](https://mantine.dev/llms/core-switch.md): Capture boolean input from user
- [Table](https://mantine.dev/llms/core-table.md): Render table with theme styles
- [TableOfContents](https://mantine.dev/llms/core-table-of-contents.md): Renders a list of headings on the page and tracks current heading visible in the viewport
- [Tabs](https://mantine.dev/llms/core-tabs.md): Switch between different views
- [TagsInput](https://mantine.dev/llms/core-tags-input.md): Capture a list of values from user with free input and suggestions
- [Text](https://mantine.dev/llms/core-text.md): Display text
- [Textarea](https://mantine.dev/llms/core-textarea.md): Autosize or regular textarea
- [TextInput](https://mantine.dev/llms/core-text-input.md): Capture string input from user
- [ThemeIcon](https://mantine.dev/llms/core-theme-icon.md): Render icon inside element with theme colors
- [Timeline](https://mantine.dev/llms/core-timeline.md): Display list of events in chronological order
- [Title](https://mantine.dev/llms/core-title.md): h1-h6 heading
- [Tooltip](https://mantine.dev/llms/core-tooltip.md): Renders tooltip at given element on mouse over or other event
- [Transition](https://mantine.dev/llms/core-transition.md): Animate presence of component with pre-made animations
- [Tree](https://mantine.dev/llms/core-tree.md): Display a Tree structure
- [TreeSelect](https://mantine.dev/llms/core-tree-select.md): Select with hierarchical tree data
- [Typography](https://mantine.dev/llms/core-typography.md): Styles provider for html content
- [UnstyledButton](https://mantine.dev/llms/core-unstyled-button.md): Unstyled polymorphic button
- [VisuallyHidden](https://mantine.dev/llms/core-visually-hidden.md): Hide element visually but keep it accessible for screen readers
## Hooks
- [HooksPackage](https://mantine.dev/llms/hooks-package.md)
- [use-element-size](https://mantine.dev/llms/hooks-use-element-size.md): Returns element width and height and observes changes with ResizeObserver
- [useClickOutside](https://mantine.dev/llms/hooks-use-click-outside.md)
- [useClipboard](https://mantine.dev/llms/hooks-use-clipboard.md)
- [useCollapse](https://mantine.dev/llms/hooks-use-collapse.md)
- [useColorScheme](https://mantine.dev/llms/hooks-use-color-scheme.md)
- [useCounter](https://mantine.dev/llms/hooks-use-counter.md)
- [useDebouncedCallback](https://mantine.dev/llms/hooks-use-debounced-callback.md)
- [useDebouncedState](https://mantine.dev/llms/hooks-use-debounced-state.md)
- [useDebouncedValue](https://mantine.dev/llms/hooks-use-debounced-value.md)
- [useDidUpdate](https://mantine.dev/llms/hooks-use-did-update.md)
- [useDisclosure](https://mantine.dev/llms/hooks-use-disclosure.md)
- [useDocumentTitle](https://mantine.dev/llms/hooks-use-document-title.md)
- [useDocumentVisibility](https://mantine.dev/llms/hooks-use-document-visibility.md)
- [useDrag](https://mantine.dev/llms/hooks-use-drag.md)
- [useEventListener](https://mantine.dev/llms/hooks-use-event-listener.md)
- [useEyeDropper](https://mantine.dev/llms/hooks-use-eye-dropper.md)
- [useFavicon](https://mantine.dev/llms/hooks-use-favicon.md)
- [useFetch](https://mantine.dev/llms/hooks-use-fetch.md)
- [useFileDialog](https://mantine.dev/llms/hooks-use-file-dialog.md)
- [useFloatingWindow](https://mantine.dev/llms/hooks-use-floating-window.md)
- [useFocusReturn](https://mantine.dev/llms/hooks-use-focus-return.md)
- [useFocusTrap](https://mantine.dev/llms/hooks-use-focus-trap.md)
- [useFocusWithin](https://mantine.dev/llms/hooks-use-focus-within.md)
- [useForceUpdate](https://mantine.dev/llms/hooks-use-force-update.md)
- [useFullscreen](https://mantine.dev/llms/hooks-use-fullscreen.md)
- [useHash](https://mantine.dev/llms/hooks-use-hash.md)
- [useHeadroom](https://mantine.dev/llms/hooks-use-headroom.md)
- [useHotkeys](https://mantine.dev/llms/hooks-use-hotkeys.md)
- [useHover](https://mantine.dev/llms/hooks-use-hover.md)
- [useId](https://mantine.dev/llms/hooks-use-id.md)
- [useIdle](https://mantine.dev/llms/hooks-use-idle.md)
- [useInputState](https://mantine.dev/llms/hooks-use-input-state.md)
- [useIntersection](https://mantine.dev/llms/hooks-use-intersection.md)
- [useInterval](https://mantine.dev/llms/hooks-use-interval.md)
- [useInViewport](https://mantine.dev/llms/hooks-use-in-viewport.md)
- [useIsFirstRender](https://mantine.dev/llms/hooks-use-is-first-render.md)
- [useIsomorphicEffect](https://mantine.dev/llms/hooks-use-isomorphic-effect.md)
- [useListState](https://mantine.dev/llms/hooks-use-list-state.md)
- [useLocalStorage](https://mantine.dev/llms/hooks-use-local-storage.md)
- [useLogger](https://mantine.dev/llms/hooks-use-logger.md)
- [useLongPress](https://mantine.dev/llms/hooks-use-long-press.md)
- [useMap](https://mantine.dev/llms/hooks-use-map.md)
- [useMask](https://mantine.dev/llms/hooks-use-mask.md)
- [useMediaQuery](https://mantine.dev/llms/hooks-use-media-query.md)
- [useMergedRef](https://mantine.dev/llms/hooks-use-merged-ref.md)
- [useMounted](https://mantine.dev/llms/hooks-use-mounted.md)
- [useMouse](https://mantine.dev/llms/hooks-use-mouse.md)
- [useMove](https://mantine.dev/llms/hooks-use-move.md)
- [useMutationObserver](https://mantine.dev/llms/hooks-use-mutation-observer.md)
- [useNetwork](https://mantine.dev/llms/hooks-use-network.md)
- [useOrientation](https://mantine.dev/llms/hooks-use-orientation.md)
- [useOs](https://mantine.dev/llms/hooks-use-os.md)
- [usePageLeave](https://mantine.dev/llms/hooks-use-page-leave.md)
- [usePagination](https://mantine.dev/llms/hooks-use-pagination.md)
- [usePrevious](https://mantine.dev/llms/hooks-use-previous.md)
- [useQueue](https://mantine.dev/llms/hooks-use-queue.md)
- [useRadialMove](https://mantine.dev/llms/hooks-use-radial-move.md)
- [useReducedMotion](https://mantine.dev/llms/hooks-use-reduced-motion.md)
- [useResizeObserver](https://mantine.dev/llms/hooks-use-resize-observer.md)
- [useRovingIndex](https://mantine.dev/llms/hooks-use-roving-index.md)
- [useScrollDirection](https://mantine.dev/llms/hooks-use-scroll-direction.md)
- [useScroller](https://mantine.dev/llms/hooks-use-scroller.md)
- [useScrollIntoView](https://mantine.dev/llms/hooks-use-scroll-into-view.md)
- [useScrollSpy](https://mantine.dev/llms/hooks-use-scroll-spy.md)
- [useSelection](https://mantine.dev/llms/hooks-use-selection.md)
- [useSet](https://mantine.dev/llms/hooks-use-set.md)
- [useSetState](https://mantine.dev/llms/hooks-use-set-state.md)
- [useShallowEffect](https://mantine.dev/llms/hooks-use-shallow-effect.md)
- [useSplitter](https://mantine.dev/llms/hooks-use-splitter.md)
- [useStateHistory](https://mantine.dev/llms/hooks-use-state-history.md)
- [useTextSelection](https://mantine.dev/llms/hooks-use-text-selection.md)
- [useThrottledCallback](https://mantine.dev/llms/hooks-use-throttled-callback.md)
- [useThrottledState](https://mantine.dev/llms/hooks-use-throttled-state.md)
- [useThrottledValue](https://mantine.dev/llms/hooks-use-throttled-value.md)
- [useTimeout](https://mantine.dev/llms/hooks-use-timeout.md)
- [useToggle](https://mantine.dev/llms/hooks-use-toggle.md)
- [useUncontrolled](https://mantine.dev/llms/hooks-use-uncontrolled.md)
- [useValidatedState](https://mantine.dev/llms/hooks-use-validated-state.md)
- [useViewportSize](https://mantine.dev/llms/hooks-use-viewport-size.md)
- [useWindowEvent](https://mantine.dev/llms/hooks-use-window-event.md)
- [useWindowScroll](https://mantine.dev/llms/hooks-use-window-scroll.md)
## Form
- [Form context](https://mantine.dev/llms/form-create-form-context.md): Add context support to use-form with createFormContext
- [formActions](https://mantine.dev/llms/form-actions.md)
- [formAllInputs](https://mantine.dev/llms/form-all-inputs.md)
- [formErrors](https://mantine.dev/llms/form-errors.md)
- [formGetInputProps](https://mantine.dev/llms/form-get-input-props.md)
- [formNested](https://mantine.dev/llms/form-nested.md)
- [FormPackage](https://mantine.dev/llms/form-package.md)
- [formRecipes](https://mantine.dev/llms/form-recipes.md)
- [formSchemaValidation](https://mantine.dev/llms/form-schema-validation.md)
- [formStatus](https://mantine.dev/llms/form-status.md)
- [formUncontrolled](https://mantine.dev/llms/form-uncontrolled.md)
- [formValidation](https://mantine.dev/llms/form-validation.md)
- [formValidators](https://mantine.dev/llms/form-validators.md)
- [formValues](https://mantine.dev/llms/form-values.md)
- [use-field](https://mantine.dev/llms/form-use-field.md): use-field hook – manage single field state
- [use-form](https://mantine.dev/llms/form-use-form.md): Manage form state
## Dates
- [Calendar](https://mantine.dev/llms/dates-calendar.md): Base component for custom date pickers
- [DateInput](https://mantine.dev/llms/dates-date-input.md): Free form date input
- [DatePicker](https://mantine.dev/llms/dates-date-picker.md): Inline date, multiple dates and dates range picker
- [DatePickerInput](https://mantine.dev/llms/dates-date-picker-input.md): Date, multiple dates and dates range picker input
- [DateTimePicker](https://mantine.dev/llms/dates-date-time-picker.md): Capture datetime from the user
- [GettingStartedDates](https://mantine.dev/llms/dates-getting-started.md)
- [InlineDateTimePicker](https://mantine.dev/llms/dates-inline-date-time-picker.md): Inline date and time picker with range support
- [MiniCalendar](https://mantine.dev/llms/dates-mini-calendar.md): Compact calendar to display a small number of days in a row
- [MonthPicker](https://mantine.dev/llms/dates-month-picker.md): Inline month, multiple months and months range picker
- [MonthPickerInput](https://mantine.dev/llms/dates-month-picker-input.md): Month, multiple months and months range picker input
- [TimeGrid](https://mantine.dev/llms/dates-time-grid.md): Captures time value from the user with a predefined set of options
- [TimeInput](https://mantine.dev/llms/dates-time-input.md): Capture time from the user
- [TimePicker](https://mantine.dev/llms/dates-time-picker.md): Captures time value from the user
- [TimeValue](https://mantine.dev/llms/dates-time-value.md): Display a formatted time value
- [YearPicker](https://mantine.dev/llms/dates-year-picker.md): Inline year, multiple years and years range picker
- [YearPickerInput](https://mantine.dev/llms/dates-year-picker-input.md): Inline year, multiple years and years range picker
## Charts
- [AreaChart](https://mantine.dev/llms/charts-area-chart.md): Area chart component with stacked, percent and split variants
- [BarChart](https://mantine.dev/llms/charts-bar-chart.md): Bar chart component with stacked and percent variants
- [BarsList](https://mantine.dev/llms/charts-bars-list.md): Display a list of bars with names and values
- [BubbleChart](https://mantine.dev/llms/charts-bubble-chart.md): Bubble chart component
- [CompositeChart](https://mantine.dev/llms/charts-composite-chart.md): Composed chart with support for Area, Bar and Line charts
- [DonutChart](https://mantine.dev/llms/charts-donut-chart.md): Donut chart component
- [FunnelChart](https://mantine.dev/llms/charts-funnel-chart.md): Funnel chart component
- [GettingStartedCharts](https://mantine.dev/llms/charts-getting-started.md)
- [Heatmap](https://mantine.dev/llms/charts-heatmap.md): Heatmap chart component
- [LineChart](https://mantine.dev/llms/charts-line-chart.md): Line chart component
- [PieChart](https://mantine.dev/llms/charts-pie-chart.md): Pie chart component
- [RadarChart](https://mantine.dev/llms/charts-radar-chart.md): Radar chart component
- [RadialBarChart](https://mantine.dev/llms/charts-radial-bar-chart.md): Radial bar chart component
- [SankeyChart](https://mantine.dev/llms/charts-sankey-chart.md): Sankey diagram component
- [ScatterChart](https://mantine.dev/llms/charts-scatter-chart.md): Scatter chart component
- [Sparkline](https://mantine.dev/llms/charts-sparkline.md): Simplified area chart to show trends
- [Treemap](https://mantine.dev/llms/charts-treemap.md): Treemap chart component
## Schedule
- [AgendaView](https://mantine.dev/llms/schedule-agenda-view.md): Vertical list view of events grouped by date
- [DayView](https://mantine.dev/llms/schedule-day-view.md): Standalone schedule day view component
- [GettingStartedSchedule](https://mantine.dev/llms/schedule-getting-started.md)
- [MobileMonthView](https://mantine.dev/llms/schedule-mobile-month-view.md): Mobile-optimized schedule month view component
- [MonthView](https://mantine.dev/llms/schedule-month-view.md): Standalone schedule month view component
- [RecurringEventsSchedule](https://mantine.dev/llms/schedule-recurring-events.md)
- [ResourcesDayView](https://mantine.dev/llms/schedule-resources-day-view.md): Day view with resource columns for scheduling across resources
- [ResourcesMonthView](https://mantine.dev/llms/schedule-resources-month-view.md): Month view with resource lanes for scheduling across resources
- [ResourcesSchedule](https://mantine.dev/llms/schedule-resources-schedule.md): Schedule wrapper component that combines resource day, week and month views
- [ResourcesWeekView](https://mantine.dev/llms/schedule-resources-week-view.md): Week view with resource rows for scheduling across resources
- [Schedule](https://mantine.dev/llms/schedule-schedule.md): Schedule component with day, week, month and year views
- [ScheduleEventsData](https://mantine.dev/llms/schedule-events-data.md)
- [WeekView](https://mantine.dev/llms/schedule-week-view.md): Standalone schedule week view component
- [YearView](https://mantine.dev/llms/schedule-year-view.md): Standalone schedule year view component
## Guides
- [ControlledVsUncontrolled](https://mantine.dev/llms/guides-controlled-vs-uncontrolled.md)
- [CustomComponents](https://mantine.dev/llms/guides-custom-components.md)
- [EightToNine](https://mantine.dev/llms/guides-8x-to-9x.md)
- [FunctionsReference](https://mantine.dev/llms/guides-functions-reference.md)
- [Gatsby](https://mantine.dev/llms/guides-gatsby.md)
- [Icons](https://mantine.dev/llms/guides-icons.md)
- [JavaScript](https://mantine.dev/llms/guides-javascript.md)
- [Jest](https://mantine.dev/llms/guides-jest.md)
- [LLMDocumentation](https://mantine.dev/llms/guides-llms.md)
- [NextJs](https://mantine.dev/llms/guides-next.md)
- [Polymorphic](https://mantine.dev/llms/guides-polymorphic.md)
- [ReactRouter](https://mantine.dev/llms/guides-react-router.md)
- [Redwood](https://mantine.dev/llms/guides-redwood.md)
- [SevenToEight](https://mantine.dev/llms/guides-7x-to-8x.md)
- [SixToSeven](https://mantine.dev/llms/guides-6x-to-7x.md)
- [Storybook](https://mantine.dev/llms/guides-storybook.md)
- [Tiptap3Migration](https://mantine.dev/llms/guides-tiptap-3-migration.md)
- [TypeScript](https://mantine.dev/llms/guides-typescript.md)
- [Vite](https://mantine.dev/llms/guides-vite.md)
- [Vitest](https://mantine.dev/llms/guides-vitest.md)
## Theming
- [Colors](https://mantine.dev/llms/theming-colors.md)
- [ColorSchemes](https://mantine.dev/llms/theming-color-schemes.md)
- [DefaultProps](https://mantine.dev/llms/theming-default-props.md)
- [MantineProvider](https://mantine.dev/llms/theming-mantine-provider.md)
- [ThemeObject](https://mantine.dev/llms/theming-theme-object.md)
- [Typography](https://mantine.dev/llms/theming-typography.md): Styles provider for html content
## Styles
- [ColorFunctions](https://mantine.dev/llms/styles-color-functions.md)
- [CSSFilesList](https://mantine.dev/llms/styles-css-files-list.md)
- [CSSModules](https://mantine.dev/llms/styles-css-modules.md)
- [CssVariables](https://mantine.dev/llms/styles-css-variables.md)
- [CSSVariablesList](https://mantine.dev/llms/styles-css-variables-list.md)
- [DataAttributes](https://mantine.dev/llms/styles-data-attributes.md)
- [Emotion](https://mantine.dev/llms/styles-emotion.md)
- [GlobalStyles](https://mantine.dev/llms/styles-global-styles.md)
- [MantineStyles](https://mantine.dev/llms/styles-mantine-styles.md)
- [PostCSSPreset](https://mantine.dev/llms/styles-postcss-preset.md)
- [Rem](https://mantine.dev/llms/styles-rem.md)
- [ResponsiveStyles](https://mantine.dev/llms/styles-responsive.md)
- [Rtl](https://mantine.dev/llms/styles-rtl.md)
- [StyleProp](https://mantine.dev/llms/styles-style.md)
- [StyleProps](https://mantine.dev/llms/styles-style-props.md)
- [StylesApi](https://mantine.dev/llms/styles-styles-api.md)
- [StylesOverview](https://mantine.dev/llms/styles-styles-overview.md)
- [StylesPerformance](https://mantine.dev/llms/styles-styles-performance.md)
- [UnstyledComponents](https://mantine.dev/llms/styles-unstyled.md)
- [UsageWithSass](https://mantine.dev/llms/styles-sass.md)
- [VanillaExtract](https://mantine.dev/llms/styles-vanilla-extract.md)
- [VariantsAndSizes](https://mantine.dev/llms/styles-variants-sizes.md)
## X
- [Carousel](https://mantine.dev/llms/x-carousel.md): Embla based carousel component
- [CodeHighlight](https://mantine.dev/llms/x-code-highlight.md): Highlight code with shiki or highlight.js
- [Dropzone](https://mantine.dev/llms/x-dropzone.md): Capture files from user with drag and drop
- [ExtensionsPackage](https://mantine.dev/llms/x-extensions.md)
- [Modals manager](https://mantine.dev/llms/x-modals.md): Centralized modals manager with option to handle state of multi-step modals
- [NavigationProgress](https://mantine.dev/llms/x-nprogress.md): Navigation progress bar
- [Notifications system](https://mantine.dev/llms/x-notifications.md): Mantine notifications system
- [Rich text editor](https://mantine.dev/llms/x-tiptap.md): Tiptap based rich text editor
- [Spotlight](https://mantine.dev/llms/x-spotlight.md): Command center for your application
## Other
- [About](https://mantine.dev/llms/about.md)
- [AllReleases](https://mantine.dev/llms/changelog-all-releases.md)
- [BrowserSupport](https://mantine.dev/llms/browser-support.md)
- [Changelog700](https://mantine.dev/llms/changelog-7-0-0.md)
- [Changelog710](https://mantine.dev/llms/changelog-7-1-0.md)
- [Changelog7100](https://mantine.dev/llms/changelog-7-10-0.md)
- [Changelog7110](https://mantine.dev/llms/changelog-7-11-0.md)
- [Changelog7120](https://mantine.dev/llms/changelog-7-12-0.md)
- [Changelog7130](https://mantine.dev/llms/changelog-7-13-0.md)
- [Changelog7140](https://mantine.dev/llms/changelog-7-14-0.md)
- [Changelog7150](https://mantine.dev/llms/changelog-7-15-0.md)
- [Changelog7160](https://mantine.dev/llms/changelog-7-16-0.md)
- [Changelog7170](https://mantine.dev/llms/changelog-7-17-0.md)
- [Changelog720](https://mantine.dev/llms/changelog-7-2-0.md)
- [Changelog730](https://mantine.dev/llms/changelog-7-3-0.md)
- [Changelog740](https://mantine.dev/llms/changelog-7-4-0.md)
- [Changelog750](https://mantine.dev/llms/changelog-7-5-0.md)
- [Changelog760](https://mantine.dev/llms/changelog-7-6-0.md)
- [Changelog770](https://mantine.dev/llms/changelog-7-7-0.md)
- [Changelog780](https://mantine.dev/llms/changelog-7-8-0.md)
- [Changelog790](https://mantine.dev/llms/changelog-7-9-0.md)
- [Changelog800](https://mantine.dev/llms/changelog-8-0-0.md)
- [Changelog810](https://mantine.dev/llms/changelog-8-1-0.md)
- [Changelog820](https://mantine.dev/llms/changelog-8-2-0.md)
- [Changelog830](https://mantine.dev/llms/changelog-8-3-0.md)
- [Changelog900](https://mantine.dev/llms/changelog-9-0-0.md)
- [Changelog910](https://mantine.dev/llms/changelog-9-1-0.md)
- [Changelog920](https://mantine.dev/llms/changelog-9-2-0.md)
- [Changelog930](https://mantine.dev/llms/changelog-9-3-0.md)
- [Changelog940](https://mantine.dev/llms/changelog-9-4-0.md)
- [Contribute](https://mantine.dev/llms/contribute.md)
- [GettingStarted](https://mantine.dev/llms/getting-started.md)
- [NotFound](https://mantine.dev/llms/404.md)
- [OxcConfig](https://mantine.dev/llms/oxc-config-mantine.md)
- [PreviousChangelogs](https://mantine.dev/llms/changelog-previous-versions.md)
- [Support](https://mantine.dev/llms/support.md)
@@ -1,135 +0,0 @@
# GettingStartedDates
Package: @mantine/dates
Import: import { GettingStartedDates } from '@mantine/dates';
## Installation
```bash
yarn add @mantine/dates dayjs
```
```bash
npm install @mantine/dates dayjs
```
After installation import package styles at the root of your application:
```tsx
import "@mantine/core/styles.css";
// ‼️ import dates styles after core package styles
import "@mantine/dates/styles.css";
```
## Do not forget to import styles
Followed the installation instructions above but something is not working
(calendars and date pickers have no styles and look broken)?
You've fallen into the trap of not importing dates styles!
To fix this issue, import dates styles at the root of your application:
```tsx
import "@mantine/dates/styles.css";
```
## Usage
After installing the `@mantine/dates` package and importing styles, you can use all components from it:
## Date values as strings
`@mantine/dates` components work with date strings: `YYYY-MM-DD` or `YYYY-MM-DD HH:mm:ss` depending on the component. Those strings do not include any timezone-specific information.
## dayjs
`@mantine/dates` components use [dayjs](https://day.js.org/) under the hood for date manipulations and formatting.
dayjs is a required dependency – you cannot change it to another date library. If you want to use a different
date library in your application, you will need to install it separately.
## DatesProvider
The `DatesProvider` component lets you set various settings that are shared across all
components exported from the `@mantine/dates` package. `DatesProvider` supports the following settings:
- `locale` – dayjs locale. Note that you also need to import the corresponding locale module from dayjs. The default value is `en`.
- `firstDayOfWeek` – a number from 0 to 6, where 0 is Sunday and 6 is Saturday. The default value is 1 – Monday.
- `weekendDays` – an array of numbers from 0 to 6, where 0 is Sunday and 6 is Saturday. The default value is `[0, 6]` – Saturday and Sunday.
- `consistentWeeks` – boolean. If `true`, every month will have 6 weeks. The default value is `false`.
```tsx
import "dayjs/locale/ru";
import { DatesProvider, MonthPickerInput, DatePickerInput } from "@mantine/dates";
function Demo() {
return (
<DatesProvider settings={{ locale: "ru", firstDayOfWeek: 0, weekendDays: [0] }}>
<MonthPickerInput label="Pick month" placeholder="Pick month" />
<DatePickerInput mt="md" label="Pick date" placeholder="Pick date" />
</DatesProvider>
);
}
```
## Consistent weeks
If you want to avoid layout shifts, set `consistentWeeks: true` in the `DatesProvider` settings.
This will ensure that every month has 6 weeks, even if outside days are not in the same month.
```tsx
import { DatePicker, DatesProvider } from "@mantine/dates";
function Demo() {
return (
<DatesProvider settings={{ consistentWeeks: true }}>
<DatePicker />
</DatesProvider>
);
}
```
## Custom parse format
Some components like [DateInput](https://mantine.dev/llms/dates-date-input.md) require the [custom parse format](https://day.js.org/docs/en/plugin/custom-parse-format)
dayjs plugin. You need to extend dayjs with this plugin before using components that require it. Note that
this is usually done once in your application root file, so you don't need to do it every time you use the component.
```tsx
import dayjs from "dayjs";
import customParseFormat from "dayjs/plugin/customParseFormat";
dayjs.extend(customParseFormat);
```
## Localization and server components
To add localization, you must import `import 'dayjs/locale/x';` in your application (`x` is the locale name)
and set `locale` either on `DatesProvider` or on each component individually.
Example of setting the locale on DatesProvider:
```tsx
import "dayjs/locale/ru";
import { DatesProvider } from "@mantine/dates";
function Demo() {
return <DatesProvider settings={{ locale: "ru" }}>{/* Your app */}</DatesProvider>;
}
```
The code above works in all environments, except Next.js app router.
If you are using Next.js app router, you must add `'use client';` to the
top of the file where you are importing `dayjs/locale/x` – locale data
is required both on client and server.
```tsx
"use client";
import "dayjs/locale/ru";
import { DatesProvider } from "@mantine/dates";
function Demo() {
return <DatesProvider settings={{ locale: "ru" }}>{/* Your app */}</DatesProvider>;
}
```
@@ -1,164 +1,16 @@
# JSX runtime
# Runtime
Templates read four roots: `data.<requestId>`, `status.<requestId>`, `options`, and `inputs`.
Templates read `data.requestId`, `status.requestId`, `options.name`, and temporary `inputs.name`. Status contains `loading`, `ok`, `status`, `statusText`, and `error`.
Each request status is an object:
```ts
status.<requestId> = {
loading: boolean;
ok?: boolean;
status?: number;
statusText?: string;
error?: string;
};
```
Use `status.list?.loading` for loading, `status.list?.ok === false` for failure, and `status.list?.ok === true` for success. Do not compare `status.list` with the strings `"loading"`, `"success"`, or `"error"`; those comparisons are always false and are rejected by validation.
Use Mantine Core, Dates, Charts, safe Tabler icons, and Homarr runtime components. Safe installed Mantine exports are discovered automatically. Ordinary serializable props and Mantine style props pass through; `style` and `styles` are scoped and sanitized.
Render icons as `<TablerIcon name="server" size={18} />`. When connected, retrieve `homarr://custom-widgets/components/TablerIcon` for the supported icon names.
Use `SubFetch` for a manual query, `ActionButton` or `ToggleSwitch` for an action, and `RefreshButton` to refresh data. A query with `trigger: "load"` populates its data automatically and follows the widget refresh interval.
Manual requests receive values only from the invoking component:
```jsx
<SubFetch requestId="search" params={{ query: inputs.search }} />
<ActionButton requestId="restart" params={{ id: container.Id }}>Restart</ActionButton>
```
Loop variables and other JSX expressions can flow into a request only through that `params` prop. A load query has no invoking component, so every declared parameter must have an `optionsBinding` entry:
```json
{
"parameters": { "endpointId": "string", "showAll": "boolean" },
"optionsBinding": {
"endpointId": { "$option": "endpointId" },
"showAll": false
}
}
```
Do not rely on parameter and option names matching. For an invalid-placeholder or unresolved-parameter error on a load query, check missing `optionsBinding` entries before changing placeholder syntax.
Bind local controls declaratively:
`bind="search"` creates an in-memory input. It is never persisted. Supply invocation values only through `params`, for example:
```jsx
<TextInput bind="search" label="Search" />
<DatePicker bind="selectedDate" />
<Select bind="selectedEntity" data={options.entities} />
<SubFetch requestId="search" params={{ query: inputs.search }}>
{(items) => <Stack>{(items ?? []).map(item => <Text key={item.id}>{item.name}</Text>)}</Stack>}
</SubFetch>
```
Bound inputs exist only in memory while the widget is mounted and reset on reload. They are available as `inputs.<bindName>` and are never saved to local storage. Put durable user configuration in options. Do not author raw component callback props.
`SubFetch` with `trigger="manual"` renders its own load button. Its optional second callback argument contains `{ ok, status, statusText, loading: false }`; loading and request failures are rendered by `SubFetch` before the callback runs. Do not author `onClick` or a fetch callback.
Binding types follow the component mode. `Accordion multiple`, `Chip.Group multiple`, `TreeSelect mode="multiple"|"checkbox"`, and date pickers with `type="multiple"|"range"` expose `string[]`. `DateTimePicker` and `InlineDateTimePicker` support `type="range"`, but not `multiple`. Components that do not advertise a temporary bind cannot use `bind`.
## Immutable local bindings
Collection callbacks may use a tightly constrained block body when a value would otherwise be recomputed throughout the returned JSX:
```jsx
{
data.pokemon.map((pokemon) => {
const segments = pokemon.url.split("/").filter((part) => part);
const dexId = segments.at(-1);
const favorite = inputs.favorites.includes(pokemon.name);
return (
<Card key={dexId}>
<Text>#{dexId}</Text>
<Text>{pokemon.name}</Text>
{favorite && <Badge>Favorite</Badge>}
</Card>
);
});
}
```
A page-level derived value may use a directly invoked, zero-argument inline arrow:
```jsx
{
(() => {
const visiblePokemon = data.pokemon.filter(
(pokemon) => !inputs.favoritesOnly || inputs.favorites.includes(pokemon.name),
);
return (
<Stack>
{visiblePokemon.map((pokemon) => (
<Text>{pokemon.name}</Text>
))}
</Stack>
);
})();
}
```
A safe block contains one or more `const` declarations followed by exactly one final `return`. Each declaration uses one simple identifier and a required initializer made from existing safe expressions. Earlier local bindings are available to later initializers and to the return expression.
The following remain invalid:
- `let`, `var`, assignment, update expressions, or destructuring.
- Function or class declarations, and function values stored in `const`.
- Loops, `if`, `switch`, `try`, `catch`, `throw`, labels, or `new`.
- Async functions, generators, `await`, or `yield`.
- Early or multiple returns.
- Duplicate local names or shadowing `data`, `status`, `options`, or `inputs`.
- Invoking a function obtained from API data or a property. Only a directly invoked zero-argument inline arrow is an IIFE.
Local evaluation shares the parent widget's depth, operation, collection, string, and rendered-node budgets. Blocks cannot reset or evade those limits.
Block diagnostics include `INVALID_LOCAL_DECLARATION`, `LOCAL_BINDING_REQUIRES_INITIALIZER`, `DUPLICATE_LOCAL_BINDING`, `RESERVED_LOCAL_BINDING`, `UNSUPPORTED_BLOCK_STATEMENT`, `BLOCK_REQUIRES_FINAL_RETURN`, and `CALLBACK_VALUE_NOT_ALLOWED`.
## Arbitrary-depth trees
Use Homarr's trusted `RecursiveList` special form instead of authored recursive functions or Mantine callback props:
```jsx
<RecursiveList
data={data.evolution.chain}
childrenPath="evolves_to"
keyPath="species.name"
maxDepth={16}
defaultExpandedDepth={4}
showLines
>
{(node, meta) => {
const segments = node.species.url.split("/").filter((part) => part);
const dexId = segments.at(-1);
return (
<Group gap="xs">
<Badge>#{dexId}</Badge>
<Text>{node.species.name}</Text>
{meta.hasChildren && <Text c="dimmed">{meta.childCount} evolutions</Text>}
</Group>
);
}}
</RecursiveList>
```
The child template receives the current `node` and:
```ts
interface RecursiveListMetadata {
depth: number;
index: number;
path: number[];
key: string;
hasChildren: boolean;
childCount: number;
isLast: boolean;
}
```
`data` accepts one root object or an array of roots. `childrenPath` and `keyPath` use restricted dotted property paths; prototype-related segments are invalid. Missing children mean an empty list. Keys must resolve to primitives; missing or duplicate keys use a stable path fallback and record a warning.
Authorable presentation props are `data`, `childrenPath`, `keyPath`, `maxDepth`, `maxNodes`, `defaultExpandedDepth`, `indent`, `gap`, and `showLines`. Default limits are depth 16 and 500 nodes. Hard limits are depth 32 and 2,000 nodes. Cycles are detected per ancestor chain. Invalid branches and reached limits render a contained diagnostic or an “Additional levels omitted” row without crashing the tile.
Always render a useful loading state from `status`, an empty state, a contained error state, and layouts that work at roughly 320, 480, and 720 pixels wide. Use accessible labels and confirmations.
This reference and the package component references are complete for offline authoring against the matching release. When connected, prefer live component metadata and examples because the installed Homarr and Mantine versions may differ.
Use expression callbacks for supported collection methods and trusted slots. Do not use callback blocks, IIFEs, authored recursion, or raw events. Bounded regex literals work only with safe string matching/replacement operations.
@@ -1,8 +1,4 @@
# Custom Widget v2 schema
Every import and export is one object with `$schema: "homarr-custom-widget-v2"`, metadata, `sources`, `requests`, `optionsSchema`, `defaultOptions`, and `template`. Secrets are accepted separately by create/update/preview operations and never exported.
The safe-local-binding and `RecursiveList` expansion changes only template authoring. It does not add database fields, request fields, persistence, or another widget schema version.
# Schema
```ts
interface HomarrCustomWidgetV2 {
@@ -10,28 +6,15 @@ interface HomarrCustomWidgetV2 {
name: string;
description?: string;
iconUrl?: string;
sources: CustomWidgetSource[];
requests: CustomWidgetRequest[];
optionsSchema: CustomWidgetOptionsSchema;
defaultOptions: Record<string, unknown>;
sources: Record<string, CustomWidgetSource>;
requests: Record<string, CustomWidgetRequest>;
options?: Record<string, CustomWidgetOption>;
template: string;
}
```
A source has a stable lowercase `id`, display `name`, `baseUrl`, `networkScope` (`public`, `private`, or `loopback`), and one auth mode: `none`, `bearer`, `basic`, `apiKeyHeader`, or `apiKeyQuery`.
Sources require `default`. A request defaults to the default source, query, GET, load, inherited auth, and view permission. An action defaults to manual and modify permission. DELETE uses full permission and confirmation.
A request has `id`, `sourceId`, `kind`, any supported HTTP `method`, same-origin `pathTemplate`, declared primitive `parameters`, optional `optionsBinding`, `queryTemplate`, and `bodyTemplate`, optional safe `staticHeaders`, `auth`, `minimumBoardPermission`, optional `trigger`, caching, confirmation, and invalidation. Parameter names may use camelCase. Reference a declared parameter with `{name}` in a path or `{ "$param": "name" }` in structured query/body data.
Paths use `{option:name}` and `{param:name}`. Query/body data uses `{ "$option": "name" }` and `{ "$param": "name" }`. Load queries cannot use params. Runtime parameter names and primitive values are inferred from references.
Every declared parameter needs an explicit source. For a load query, bind each parameter in `optionsBinding` with `{ "$option": "optionName" }` or a primitive literal of the declared type. The referenced option must exist in `optionsSchema`. For manual queries and actions, the invoking `SubFetch`, `ActionButton`, or `ToggleSwitch` supplies the complete parameter object through `params`. Never infer a source because a parameter, option, or temporary input has the same name.
Dynamic option requests have no JSX invoker and therefore also require `optionsBinding` for every parameter they declare.
Options use a restricted object JSON Schema. Set `additionalProperties: false`. Presentation metadata lives under `x-homarr`; dynamic selects use `optionsSource.requestId`, optional `itemsPath` for wrapped arrays, `valuePath`, and `labelPath`. Default options must validate against the schema.
Option and bind names start with a letter and use letters, numbers, `-`, or `_`; camelCase is supported. Every JSX `bind` value is a literal temporary input name.
Do not model credentials as options or static defaults. Credential-like option names and values are rejected; source authentication is configured separately.
Do not add `stateSchema`, `defaultState`, scripts, imports, or component declarations to the manifest. Temporary interaction values come only from JSX `bind` controls and are exposed through `inputs`. Derived values declared with safe `const` blocks exist only while evaluating the template.
This reference is sufficient for offline authoring against the matching release. When connected, retrieve the live Custom Widget schema; it supersedes this static copy if the installed Homarr version differs.
Every option has `label`, `control`, and `default`. Optional fields are `description`, `choices`, `choicesFrom`, `min`, `max`, `step`, `advanced`, and `group`.
@@ -1,19 +1,7 @@
# Security boundary
# Security
Homarr executes requests on the server and constrains them to a declared source. Network scopes, DNS checks, redirect validation, SSRF protection, request/response limits, timeouts, rates, permissions, and secret injection are enforced outside JSX.
All requests use Homarr's protected server executor. Source origin, network scope, DNS, redirects, SSRF, rate limits, permissions, size limits, timeouts, and encrypted credential injection remain enforced.
Never include secrets in a widget document. Configure them per widget source. Stored plaintext is never returned, duplicated, or exported.
The JSX interpreter blocks imports, hooks, refs, raw event callbacks, browser requests, eval, arbitrary functions, prototype access, unsafe URLs, global CSS escape, arbitrary portals, bigint, statement blocks, IIFEs, and recursion. Regex literals must be bounded and reject backreferences, lookbehind, nested quantifiers, excessive length, and unsupported flags.
The runtime blocks imports, hooks, refs, raw callback props, polymorphic roots, provider replacement, arbitrary portals, browser `fetch`, `XMLHttpRequest`, arbitrary JavaScript, evaluation, npm packages, prototype-pollution keys, unsafe URLs, global selectors, and CSS escaping the widget root.
Safe block bodies are not general JavaScript. They permit only immutable `const` declarations with simple identifiers and one final return. Mutation, assignments, declarations of callable values, control-flow statements, async behavior, reserved-root shadowing, and multiple returns are rejected before rendering. A zero-argument IIFE is callable only when its callee is the inline arrow syntax itself. Values obtained from API data can never become executable callbacks.
Collection callbacks, zero-argument derived-value IIFEs, and the `RecursiveList` child template execute inside the interpreter and share the parent widget's budgets. They are never passed to Mantine or React as authored callbacks.
`RecursiveList` is a Homarr-owned traversal boundary, not general recursion. It validates dotted paths, rejects prototype-related segments, treats API objects as read-only, contains malformed branches, detects ancestor cycles, and enforces both depth and node caps. Defaults are 16 levels and 500 nodes; hard maximums are 32 levels and 2,000 nodes. Reaching a cap records a diagnostic and renders an omission row rather than throwing outside the widget.
Do not work around a blocked capability. Express network behavior as a named request and interaction as a declarative Homarr component. Require explicit confirmation for destructive actions. Never let actions trigger on load.
Treat request/response journals and diagnostics as sensitive. Homarr redacts credentials, but an agent should also avoid repeating private URLs, request bodies, or personal data unless necessary to fix the widget.
The installed static references describe the complete offline capability boundary for their matching release. When Homarr exposes live schema or component metadata, that metadata wins if it differs.
Credentials are stored separately and never exported or returned to an agent.
@@ -1,416 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"$schema": {
"type": "string",
"const": "homarr-custom-widget-v2"
},
"name": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"description": {
"type": "string",
"maxLength": 512
},
"iconUrl": {
"type": "string",
"format": "uri"
},
"sources": {
"minItems": 1,
"maxItems": 8,
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[a-z][a-z0-9_-]*$"
},
"name": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"baseUrl": {
"type": "string",
"maxLength": 2048,
"format": "uri"
},
"networkScope": {
"type": "string",
"enum": ["public", "private", "loopback"]
},
"auth": {
"oneOf": [
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "none"
}
},
"required": ["type"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "bearer"
}
},
"required": ["type"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "basic"
}
},
"required": ["type"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "apiKeyHeader"
},
"headerName": {
"type": "string",
"minLength": 1,
"maxLength": 256,
"pattern": "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$"
}
},
"required": ["type", "headerName"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "apiKeyQuery"
},
"parameterName": {
"type": "string",
"minLength": 1,
"maxLength": 256
}
},
"required": ["type", "parameterName"],
"additionalProperties": false
}
]
}
},
"required": ["id", "name", "baseUrl", "networkScope", "auth"],
"additionalProperties": false
}
},
"requests": {
"maxItems": 64,
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[a-z][a-z0-9_-]*$"
},
"sourceId": {
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[a-z][a-z0-9_-]*$"
},
"kind": {
"type": "string",
"enum": ["query", "action"]
},
"method": {
"type": "string",
"enum": ["GET", "POST", "PUT", "DELETE", "PATCH"]
},
"pathTemplate": {
"type": "string",
"minLength": 1,
"maxLength": 2048,
"pattern": "^\\/.*"
},
"parameters": {
"type": "object",
"propertyNames": {
"type": "string",
"minLength": 1,
"maxLength": 64
},
"additionalProperties": {
"type": "string",
"enum": ["string", "number", "boolean"]
}
},
"optionsBinding": {
"type": "object",
"propertyNames": {
"type": "string",
"minLength": 1,
"maxLength": 64
},
"additionalProperties": {
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "object",
"properties": {
"$option": {
"type": "string",
"minLength": 1,
"maxLength": 64
}
},
"required": ["$option"],
"additionalProperties": false
}
]
}
},
"queryTemplate": {
"type": "object",
"propertyNames": {
"type": "string",
"minLength": 1,
"maxLength": 256
},
"additionalProperties": {
"$ref": "#/$defs/__schema0"
}
},
"bodyTemplate": {
"$ref": "#/$defs/__schema1"
},
"staticHeaders": {
"type": "object",
"propertyNames": {
"type": "string",
"minLength": 1,
"maxLength": 256,
"pattern": "^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$"
},
"additionalProperties": {
"type": "string",
"maxLength": 8192
}
},
"auth": {
"type": "string",
"enum": ["inherit", "none"]
},
"minimumBoardPermission": {
"type": "string",
"enum": ["view", "modify", "full"]
},
"trigger": {
"default": "manual",
"type": "string",
"enum": ["load", "manual"]
},
"cacheTtlSeconds": {
"type": "integer",
"minimum": 0,
"maximum": 3600
},
"confirmation": {
"type": "object",
"properties": {
"title": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"message": {
"type": "string",
"minLength": 1,
"maxLength": 512
},
"confirmLabel": {
"type": "string",
"minLength": 1,
"maxLength": 64
},
"destructive": {
"type": "boolean"
}
},
"required": ["title", "message"],
"additionalProperties": false
},
"invalidates": {
"maxItems": 32,
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[a-z][a-z0-9_-]*$"
}
}
},
"required": [
"id",
"sourceId",
"kind",
"method",
"pathTemplate",
"parameters",
"auth",
"minimumBoardPermission",
"trigger"
],
"additionalProperties": false
}
},
"optionsSchema": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "object"
},
"properties": {
"default": {},
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
},
"required": {
"type": "array",
"items": {
"type": "string"
}
},
"additionalProperties": {
"default": false,
"type": "boolean",
"const": false
},
"title": {
"type": "string"
},
"description": {
"type": "string"
},
"if": {},
"then": {},
"else": {}
},
"required": ["type", "properties", "additionalProperties"],
"additionalProperties": false
},
"defaultOptions": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
},
"template": {
"type": "string",
"minLength": 1,
"maxLength": 50000
}
},
"required": ["$schema", "name", "sources", "requests", "optionsSchema", "defaultOptions", "template"],
"additionalProperties": false,
"$defs": {
"__schema0": {
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
},
{
"type": "array",
"items": {
"$ref": "#/$defs/__schema0"
}
},
{
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"$ref": "#/$defs/__schema0"
}
}
]
},
"__schema1": {
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
},
{
"type": "array",
"items": {
"$ref": "#/$defs/__schema1"
}
},
{
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"$ref": "#/$defs/__schema1"
}
}
]
}
},
"title": "Homarr Custom JSX v2 widget",
"description": "The only supported Homarr custom-widget format. Secrets are configured separately."
}
+2 -2
View File
@@ -16,6 +16,6 @@
**When contributing a new integration or widget:**
- [ ] I confirmed manually that the integration or widget is working with a real system and performed basic smoke
tests (e.g. what happens if it is offline)
tests (e.g. what happens if it is offline)
- [ ] Optional but recommended: I confirm that I am willing to be added to the `CODEOWNERS` of this feature and will
provide support and maintain the integration (e.g. in case of breaking or failure)
provide support and maintain the integration (e.g. in case of breaking or failure)
+1
View File
@@ -16,6 +16,7 @@ out/
next-env.d.ts
# artifacts
.ai-evaluations/
packages/db/migrations/*/migrate.cjs
# nitro
+9 -1
View File
@@ -30,7 +30,15 @@
"options": {
"reportUnusedDisableDirectives": "off"
},
"ignorePatterns": ["**/*.config.js", "**/*.config.mjs", "**/*.cjs", "**/lang/*.d.json.ts", ".next/**", "apps/docs/.docusaurus/**", "apps/docs/build/**"],
"ignorePatterns": [
"**/*.config.js",
"**/*.config.mjs",
"**/*.cjs",
"**/lang/*.d.json.ts",
".next/**",
"apps/docs/.docusaurus/**",
"apps/docs/build/**"
],
"settings": {
"react": { "version": "19" }
}
+1 -1
View File
@@ -91,7 +91,7 @@ Custom JSX v2 provides four HTTP discovery endpoints:
- `GET /api/custom-widgets/authoring-prompt` returns the current prompt plus MCP and HTTP resource identifiers.
- `GET /api/custom-widgets/schema` returns the canonical widget JSON Schema.
- `GET /api/custom-widgets/components` returns the release-matched static component catalog.
- `GET /api/custom-widgets/skill` returns the complete Agent Skill files, catalog, examples, and skills.sh metadata.
- `GET /api/custom-widgets/skill` returns the concise live `SKILL.md` plus skills.sh, source, and install metadata. Retrieve the schema, component catalog, and examples through their dedicated resources only when needed.
Call `customWidget_secretRequestUser` to create a temporary credential entry URL. Do not construct or save this URL.
@@ -14,18 +14,9 @@ Install the Agent Skill from the verified Homarr repository:
npx skills add https://github.com/homarr-labs/homarr --skill homarr-custom-widget
```
The portable skill gives authoring instructions that do not require a tool. The Homarr MCP prompt gives instructions for a connected workflow.
The portable skill gives concise authoring guidance. The Homarr MCP prompt gives connected agents a retrieval workflow: read the live schema, then fetch only the component documentation needed for the widget.
The offline copy prompt includes the complete portable skill and all eleven release-matched references, including the canonical widget JSON Schema, minified component catalog, and canonical runtime examples. A model can author directly from this bundle without MCP tools or repository access.
The prompt starts with the user's widget request. It ends with:
```text
--- END HOMARR CUSTOM WIDGET OFFLINE BUNDLE ---
Characters: <exact clipboard character count>
```
Homarr computes the total after assembling the prompt, including the digits in the total itself. The sentinel and count make clipboard truncation detectable. A missing sentinel or mismatched length means the bundle is incomplete and should be copied again.
The workbench's **Copy AI prompt** action is separate. It creates a self-contained prompt below 12,000 characters for an ordinary ChatGPT or Claude conversation. It includes the lean contract, bindings, recommended components, and two polished examples. It never tells an offline model to call MCP tools.
The source is in [`.agents/skills/homarr-custom-widget`](https://github.com/homarr-labs/homarr/tree/HEAD/.agents/skills/homarr-custom-widget). `HEAD` follows the repository default branch, so the link is not tied to an unreleased feature branch. Homarr does not provide a ZIP archive.
@@ -43,23 +34,20 @@ Repository code cannot give this attribution.
The authoring-prompt tool description contains one sentence. The prompt contains the workflow instructions and resource links.
`GET /api/custom-widgets/skill` and the MCP tool return the complete release-matched skill discovery payload:
`GET /api/custom-widgets/skill` and the MCP tool return the concise skill discovery payload:
```ts
{
name: "homarr-custom-widget";
version: string;
skillMd: string;
files: Array<{ path: string; content: string }>;
componentCatalog: CustomJsxAuthoringCatalog;
examples: CustomJsxExample[];
skillsShUrl: string;
sourceUrl: string;
installCommand: string;
}
```
The `homarr://custom-widgets/skill` MCP resource returns the same material as one plain-text offline bundle, ending with the truncation sentinel and exact character count.
The full installed component catalog stays available through `homarr://custom-widgets/components` and per-component resources. It is deliberately not embedded in the skill or clipboard prompt.
These interfaces do not return an archive URL or archive hash.
@@ -82,3 +70,17 @@ These interfaces do not return an archive URL or archive hash.
Each credential URL applies to one definition or preview, one source, and the required fields. The agent receives only the request status.
Homarr encrypts the plaintext immediately. Homarr does not return the plaintext.
## Evaluate the copied prompt
Homarr maintainers can run the same self-contained prompt that the workbench copies through the live generation-quality suite:
```sh
OPENROUTER_API_KEY=... pnpm --filter @homarr/custom-widgets test:ai
```
The suite uses DeepSeek V4 Pro to author five complex widgets and DeepSeek V4 Flash to judge only widgets that pass Homarr's real parser and canonical schema. It saves every response, validated `widget.json`, `widget.jsx`, and detailed category scores under `packages/custom-widgets/.ai-evaluations/`.
The run has a hard budget of ten generation attempts across all scenarios. Override the models or budget with `OPENROUTER_GENERATOR_MODEL`, `OPENROUTER_JUDGE_MODEL`, and `CUSTOM_WIDGET_AI_MAX_LOOPS`. Generated artifacts are local and ignored by Git; promote a reviewed, validated result into the bundled-widget source deliberately.
The reference API check uses a real PokeAPI list/detail request and a read-only Portainer container query when `PORTAINER_BASE_URL` and `PORTAINER_API_KEY` are set. Use `pnpm --filter @homarr/custom-widgets test:reference-apis -- --pokeapi-only` for the public PokeAPI check alone. To exercise start, restart, and stop against a disposable container, also set `PORTAINER_TEST_CONTAINER_ID` and the explicit safeguard `PORTAINER_ALLOW_MUTATIONS=1`; the script restores the container's original running state.
@@ -1,150 +1,16 @@
# Create a Custom Widget
import { CanonicalCustomWidgetExample, CustomWidgetCodeExample } from "@site/src/components/custom-widget-code";
import { CanonicalCustomWidgetExample } from "@site/src/components/custom-widget-code";
export const explicitOptionsBindingExample = `{
"pathTemplate": "/api/endpoints/{endpointId}/docker/containers/json",
"parameters": { "endpointId": "string", "showAll": "boolean" },
"queryTemplate": { "all": { "$param": "showAll" } },
"optionsBinding": {
"endpointId": { "$option": "endpointId" },
"showAll": false
}
}`;
The workbench is visual-first: configure the default API connection, add request cards, add option rows, then write JSX. Additional sources and raw manifest JSON remain under Advanced. Code and preview sit side by side on desktop and use tabs on mobile.
The desktop workbench shows the code and preview next to each other. On a mobile device, use the Configure and Preview tabs.
## Manual workflow
The save and preview actions remain available when the form scrolls.
Use CodeMirror to edit requests, widget options, and JSX. The editor gives completions for the installed Mantine components.
The editor also gives completions for identifiers, widget options, temporary inputs, runtime components, and icons.
Use the request toolbar to go to a declaration. The rename action changes all related request references.
The rename operation updates `invalidates`, dynamic widget option, and JSX references as one transaction.
Save errors appear as selectable text. Use **Copy errors** to copy them.
Select **Schema** next to Undo, Redo, and Format. This action shows the generated JSON Schema and two examples.
Homarr generates these references from the canonical Zod schemas. Imports, MCP, Workshop, and saved definitions use the same schemas.
## Create a Custom Widget manually
1. Add the API source.
2. Select its network scope.
3. Select its authentication mode.
4. Enter the credentials.
5. Declare the named requests.
6. Define the widget options.
7. Write JSX with `data`, `status`, `options`, and temporary `inputs`.
8. Select **Test and preview**.
9. Wait for the load requests to stop.
10. Open the Data tab to examine responses and request errors.
11. Preview the compact, standard, and wide layouts.
12. Examine the runtime and accessibility diagnostics.
13. Save the definition.
14. Select the definition and its widget options in the board widget picker.
Homarr does not show existing plaintext credentials. It shows only the configuration status.
The preview action shows a loading state during load requests. The Data tab indicator shows green or red after completion.
The preview renders again when request data becomes available.
## Create a Custom Widget with ChatGPT
Enter a short request. You can also enter an API documentation URL.
Select **Copy AI prompt**. The menu also provides **Copy with embedded skill**, a complete release-matched offline prompt.
Both prompts support offline authoring. They do not require MCP resources, MCP tools, or file-system access.
Paste one prompt into ChatGPT or a different model. Tell the model to return exactly these two blocks:
1. A fenced `json` block with one complete widget and `template: "__HOMARR_TEMPLATE__"`.
2. A fenced `jsx` block with the readable template.
Select **Paste AI response** to fill the workbench. Enter the credentials.
Test the requests. Preview the widget.
If the save operation fails, copy the validation errors. Give the error text to the model.
Homarr does not make a separate correction prompt. Homarr does not assume that the model can read local skill files.
The standard prompt contains fewer than 8,000 characters. It includes the installed Mantine version and the capability rules.
It does not include a complete prop list, MCP workflow, server URL, or credentials. The prompt with the embedded skill is intentionally longer. It starts with your widget request, then includes:
- `SKILL.md` and all eleven release-matched reference files, including the canonical widget JSON Schema
- The Custom Widget, Mantine, skill, and catalog-schema versions
- The compact canonical component catalog
- Canonical runtime examples
The generated Markdown references remain readable offline. The compact JSON catalog gives tools the identical machine-readable component records used by Homarr.
The embedded prompt ends with this sentinel and an exact total character count:
```text
--- END HOMARR CUSTOM WIDGET OFFLINE BUNDLE ---
Characters: <exact clipboard character count>
```
The count includes the whole prompt and the count itself. If the sentinel is missing or the number does not match the clipboard length, copy the prompt again before sending it to a model.
The embedded content explicitly tells a model to author directly from the offline bundle. It does not claim that MCP tools, Homarr resources, repository files, or file-system access exist in the chat.
## Configure widget options
Widget options use a restricted object JSON Schema. Homarr supports the following controls:
- Text
- Text area
- Number
- Switch
- Select
- Multi-select
- Slider
- Date
- Time
- Color
- URL
- Nested group
- Conditional field
- JSON value
Credentials are not widget options. Homarr rejects credential names and static credential values in widget options.
Configure authentication in the API source. Homarr then keeps the values encrypted and excludes them from exports and board configuration.
Dynamic selects use `x-homarr.optionsSource` and a named query. Add `itemsPath` when the response contains the result array in an object.
Set `valuePath` and `labelPath` relative to each item. These requests run only during widget option configuration.
The requests use the same protected server executor as runtime named requests.
### Bind named request parameters
Request parameters map each parameter name to `string`, `number`, or `boolean`. Query and body templates use `{ "$param": "name" }` references.
A parameter declaration does not give a value to the parameter. A matching widget option or temporary input does not create a binding.
For a load query, bind each parameter in `optionsBinding`. Use an option reference or a type-compatible primitive literal:
<CustomWidgetCodeExample
id="explicit-options-binding"
label="Explicit load-query option bindings"
language="json"
code={explicitOptionsBindingExample}
/>
For a manual query or action, supply all parameters with the component `params` prop. Use `SubFetch`, `ActionButton`, or `ToggleSwitch`.
Only this prop can send JSX values to a request. These values can include temporary inputs and loop variables.
Dynamic widget option requests run without a JSX invoker. Add an `optionsBinding` entry for each declared parameter.
1. Configure the default API source, network scope, authentication, and credential.
2. Add query/action cards. Values can be literals, widget options, or invocation parameters.
3. Add widget options with their control and default value.
4. Write responsive JSX using `data`, `status`, `options`, and temporary `inputs`.
5. Test and preview, inspect Data and the redacted request journal, then save.
<CanonicalCustomWidgetExample
id="named-widget-requests"
@@ -153,20 +19,19 @@ Dynamic widget option requests run without a JSX invoker. Add an `optionsBinding
height="420px"
/>
### Define the widget options schema and defaults
<CanonicalCustomWidgetExample id="widget-options" label="Widget options" example="options" height="360px" />
Default values must conform to the widget options schema. Do not put secrets in these documents.
Every option has a default. Supported controls include text, textarea, number, switch, select, multi-select, slider, date, time, color, icon, URL, duration, time zone, and JSON. Dynamic choices use `choicesFrom` with a query ID and value/label paths.
<CanonicalCustomWidgetExample
id="widget-options-schema"
label="Widget options schema"
example="optionsSchema"
height="380px"
/>
## ChatGPT or Claude
<CanonicalCustomWidgetExample
id="widget-default-options"
label="Default option values"
example="defaultOptions"
height="160px"
/>
Enter the desired widget and optional API documentation URL, then select **Copy AI prompt**. The single self-contained prompt is below 12,000 characters, starts with your request, includes the lean contract and two polished examples, and never claims that an offline chat has MCP tools.
The model must return exactly:
1. One fenced `json` block with `template: "__HOMARR_TEMPLATE__"`.
2. One fenced `jsx` block with the readable template.
Select **Paste AI response**. Homarr fills the visual builders and shows all actionable schema or JSX errors inline. Enter credentials separately. The prompt deliberately recommends a focused Mantine component set, visual hierarchy, responsive layout, theme compatibility, and loading, empty, failure, and success states.
Connected agents should use the skills.sh skill and retrieve the live schema and individual component metadata only as needed.
@@ -1,244 +1,44 @@
# Use the Custom JSX runtime
# Use the safe JSX runtime
import { CustomJsxComponentReference } from "@site/src/components/custom-jsx-component-reference";
import { CustomWidgetCodeExample } from "@site/src/components/custom-widget-code";
Templates are JSX expressions interpreted inside a widget-local safety and render budget. They can read:
export const dataRootsExample = `<Stack>
<TextInput bind="search" label="Search TV shows" />
<SubFetch requestId="search-series" params={{ query: inputs.search }} />
{data["search-series"]?.map((series) => (
<Group key={series.tvdbId}>
<Text>{series.title}</Text>
<ActionButton requestId="monitor-series" params={{ tvdbId: series.tvdbId }}>
Monitor
</ActionButton>
</Group>
))}
</Stack>`;
- `data.requestId` — the latest response
- `status.requestId` — `{ loading, ok, status, statusText, error }`
- `options.name` — saved widget configuration
- `inputs.name` — temporary values from declarative `bind`
export const localBindingExample = `{data.pokemon.map((pokemon) => {
const segments = pokemon.url.split("/").filter((part) => part);
const dexId = segments.at(-1);
const isSelected = inputs.selectedPokemon === pokemon.name;
Use normal Mantine layouts, typography, feedback, dates, and charts. Official compound names such as `Card.Section`, `Tabs.List`, and `Radio.Group` are supported. Ordinary safe props pass through; a dangerous capability produces an explicit error.
return (
<Card key={dexId} withBorder={isSelected}>
<Text>#{dexId}</Text>
<Text tt="capitalize">{pokemon.name}</Text>
</Card>
);
})}`;
export const derivedValueExample = `{(() => {
const visiblePokemon = data.pokemon.filter(
(pokemon) => options.types.length === 0 || options.types.includes(pokemon.type),
);
return (
## Temporary inputs
```jsx
<Stack>
{visiblePokemon.map((pokemon) => (
<Text>{pokemon.name}</Text>
))}
<TextInput bind="search" defaultValue="" label="Search" />
<SubFetch requestId="search" params={{ query: inputs.search }}>
{(results) => (
<Stack>
{(results ?? []).map((item) => (
<Text key={item.id}>{item.name}</Text>
))}
</Stack>
)}
</SubFetch>
</Stack>
);
})()}`;
export const recursiveListExample = `<RecursiveList
data={data.evolution.chain}
childrenPath="evolves_to"
keyPath="species.name"
defaultExpandedDepth={4}
maxDepth={16}
showLines
>
{(node, meta) => {
const segments = node.species.url.split("/").filter((part) => part);
const dexId = segments.at(-1);
return (
<Group gap="xs">
<Badge variant="light">#{dexId}</Badge>
<Text tt="capitalize" fw={600}>
{node.species.name}
</Text>
{meta.hasChildren && <Text c="dimmed">{meta.childCount} evolutions</Text>}
</Group>
);
}}
</RecursiveList>`;
export const temporaryInputsExample = `<TextInput bind="search" defaultValue="" />
<DatePicker bind="selectedDate" />
<Switch bind="enabled" defaultChecked={false} />
<MultiSelect bind="selectedEntities" data={options.entities} />
<SubFetch requestId="search" params={{ query: inputs.search }} />`;
Homarr interprets declarative JSX in the error boundary of the widget. A template cannot import a module or run arbitrary JavaScript.
## Use data roots
<CustomWidgetCodeExample
id="custom-jsx-data-roots"
label="Data roots and manual requests"
language="jsx"
code={dataRootsExample}
/>
- `data.<requestId>` contains sanitized JSON or text results.
- `status.<requestId>` contains `{ loading, ok, status, statusText, error }`.
- `options` contains the validated board item configuration.
- `inputs` contains temporary values from controls that use `bind`.
Check `status.list?.loading` while loading, `status.list?.ok === false` on failure, and `status.list?.ok === true` on success. Do not compare `status.list` with `"loading"`, `"success"`, or `"error"`; the status is an object, and validation rejects those comparisons.
Load queries get data automatically. They run at the widget refresh interval.
`SubFetch` runs a manual query. `ActionButton` and `ToggleSwitch` run actions.
## Use Mantine components
Homarr discovers exports from the installed Mantine Core, Dates, and Charts packages. It generates prop metadata from the installed TypeScript definitions.
Homarr passes ordinary serializable props to Mantine. It sanitizes style props, `style`, and `styles`.
Homarr scopes these styles to the widget tile. Safe new Mantine exports become available without a hand-maintained allowlist.
The capability denylist blocks providers, portals, app shells, modals, drawers, file controls, and low-level callback props.
Use the safe Tabler icon resolver. For example, use `<TablerIcon name="server" size={18} />`.
The editor completes installed icon names. `homarr://custom-widgets/components/TablerIcon` gives the same names to MCP agents.
The runtime blocks these capabilities:
- Imports
- Hooks
- Refs
- Raw callback props
- Polymorphic roots
- Provider replacement
- Arbitrary portal targets
- Browser network requests
- npm packages
- Code evaluation
- Prototype-pollution keys
- Unsafe URLs
- Global selectors
- CSS that escapes the widget tile
### Browse the release-matched component API
Search the generated catalog before guessing a component or prop name. Expand a component to see its actual prop types, required props, literal values, temporary-input adapter, subcomponents, accessibility notes, and a JSX usage sketch.
The explorer fetches only the plain, versioned static catalog. It does not import or execute Mantine or Homarr component objects in this documentation page. Select **Open raw JSON** to give the exact same artifact to an offline tool.
<CustomJsxComponentReference />
## Derive values with immutable local bindings
A collection callback can use a restricted block when the returned JSX needs the same derived value more than once:
<CustomWidgetCodeExample
id="custom-jsx-local-bindings"
label="Immutable local bindings"
language="jsx"
code={localBindingExample}
/>
The block must contain one or more `const` declarations followed by exactly one final `return`. A declaration uses one simple identifier and a required initializer. An earlier local can be used by later declarations and the final JSX.
For one page-level derived value, directly invoke a zero-argument inline arrow:
<CustomWidgetCodeExample
id="custom-jsx-derived-iife"
label="Page-level derived value"
language="jsx"
code={derivedValueExample}
/>
This is not general JavaScript execution. The runtime rejects:
- `let`, `var`, assignment, update expressions, and destructuring
- Functions or classes stored in a local
- `if`, loops, `switch`, `try`, `throw`, labels, `new`, async code, and generators
- Early or multiple returns
- Duplicate locals or names that shadow `data`, `status`, `options`, or `inputs`
- IIFEs with parameters or arguments
- Calling functions obtained from API data
Local evaluation shares the widget's operation, depth, collection, string, and rendered-node limits. A block cannot create a new budget.
Relevant diagnostics include `INVALID_LOCAL_DECLARATION`, `LOCAL_BINDING_REQUIRES_INITIALIZER`, `DUPLICATE_LOCAL_BINDING`, `RESERVED_LOCAL_BINDING`, `UNSUPPORTED_BLOCK_STATEMENT`, `BLOCK_REQUIRES_FINAL_RETURN`, and `CALLBACK_VALUE_NOT_ALLOWED`.
## Render arbitrary-depth data with RecursiveList
Use the trusted `RecursiveList` special form for evolution chains, directory trees, nested comments, and other unknown-depth data. Do not write an authored recursive function or pass Mantine a render callback.
<CustomWidgetCodeExample
id="custom-jsx-recursive-list"
label="Trusted recursive list"
language="jsx"
code={recursiveListExample}
height="420px"
/>
The child template receives the current `node` and this metadata:
```ts
interface RecursiveListMetadata {
depth: number;
index: number;
path: number[];
key: string;
hasChildren: boolean;
childCount: number;
isLast: boolean;
}
```
`data` accepts one root object or an array of roots. `childrenPath` and `keyPath` are restricted dotted paths; prototype-related segments are blocked. Keys resolve to primitives. Missing or duplicate keys receive a stable path fallback.
Bound values live only in React memory while the widget is mounted. They are not saved to the database, board options, exports, browser storage, or `localStorage`. Remounting resets them. Components can share a binding when their adapter types agree.
The authorable presentation props are `data`, `childrenPath`, `keyPath`, `maxDepth`, `maxNodes`, `defaultExpandedDepth`, `indent`, `gap`, and `showLines`. Defaults are 16 levels and 500 nodes; hard limits are 32 levels and 2,000 nodes.
## Expression language
Homarr detects cycles per ancestor chain. A malformed branch, render failure, or reached limit stays contained in that branch and does not crash the widget tile. Traversal and child templates use the same parent interpreter budget.
Use expressions, conditionals, optional chaining, arrays, objects, template strings, safe standard helpers, and expression callbacks for `map`, `filter`, `sort`, and trusted runtime slots. Callback blocks, authored `const`, IIFEs, authored recursion, arbitrary functions, imports, hooks, refs, raw events, browser requests, `eval`, and bigint are blocked.
## Use temporary inputs
Bounded regex literals are supported for string matching, splitting, search, and replacement. Patterns with lookbehind, backreferences, nested quantifiers, excessive length, or unsupported flags are rejected.
Bind a control to a literal name. Read its value from `inputs`:
## Runtime components
<CustomWidgetCodeExample
id="custom-jsx-temporary-inputs"
label="Temporary inputs"
language="jsx"
code={temporaryInputsExample}
/>
- `SubFetch` runs a manual query with its `params`.
- `ActionButton` and `ToggleSwitch` run actions.
- `RefreshButton` refreshes load queries.
- `Icon`/`TablerIcon` resolves installed safe icons.
These values exist in React memory while the widget is mounted. Homarr does not save them in any storage location.
A reload or remount resets the values. Use widget options for saved user configuration.
Homarr infers the adapter type. It supports `value/onChange`, `checked/onChange`, `active/onChange`, and `opened/onChange` patterns.
The type follows the component mode. Multiple accordions and chip groups, multi/checkbox tree selects, and multiple/range date pickers expose `string[]`. `DateTimePicker` and `InlineDateTimePicker` support ranges but not multiple selection. A component that does not advertise a temporary input adapter reports `BINDING_UNAVAILABLE`.
Explicit adapters support Mantine components that use different event signatures.
`defaultValue` and `defaultChecked` set the initial temporary value. Homarr does not send them with the controlled prop.
Multiple components can share a name when their adapter types agree. A template cannot pass an authored callback as a component prop. Only the runtime-owned collection callbacks, zero-argument IIFE, and `RecursiveList` child template use the restricted forms described above.
## Read diagnostics
- `BLOCKED_CAPABILITY`: unsafe behavior; saving fails.
- `UNKNOWN_COMPONENT`: the component does not exist; saving fails.
- `UNKNOWN_MANTINE_PROP`: advisory; the prop is passed through, not discarded.
- `INVALID_PROP_VALUE`: warning or error depending on safety.
- `BINDING_UNAVAILABLE`: the component renders, but `bind` is unavailable.
- `BINDING_TYPE_CONFLICT`: components sharing one input name require incompatible value types.
- `RUNTIME_RENDER_ERROR`: rendering failed inside this tile only.
Homarr does not silently remove an ordinary prop.
Always include responsive loading, empty, error, and success states. Runtime failures remain inside the widget tile.
@@ -9,55 +9,17 @@ import { BundledCustomWidgetGallery, CustomWidgetCodeExample } from "@site/src/c
export const completeManifest = `{
"$schema": "homarr-custom-widget-v2",
"name": "Service status",
"description": "Shows the current service status.",
"sources": [{
"id": "default",
"name": "Service API",
"baseUrl": "https://status.example.com",
"networkScope": "public",
"auth": { "type": "none" }
}],
"requests": [{
"id": "status",
"sourceId": "default",
"kind": "query",
"method": "GET",
"pathTemplate": "/api/status",
"parameters": {},
"auth": "inherit",
"minimumBoardPermission": "view",
"trigger": "load"
}],
"optionsSchema": { "type": "object", "properties": {}, "additionalProperties": false },
"defaultOptions": {},
"sources": {
"default": { "name": "Service API", "baseUrl": "https://status.example.com", "networkScope": "public", "auth": "none" }
},
"requests": { "status": { "path": "/api/status" } },
"options": {},
"template": "<Badge color={data.status?.ok ? 'green' : 'red'}>{data.status?.label ?? 'Unknown'}</Badge>"
}`;
# Custom Widgets
Custom JSX v2 is the only Custom Widget format in Homarr. A definition contains metadata, API sources, named requests, widget options, and JSX.
Homarr encrypts source credentials separately. Homarr does not include credentials in an export.
Homarr does not support old display types, reusable connections, widget packs, or compatibility modes.
## Audience and prerequisites
This documentation is for Homarr administrators and Custom Widget authors. You must understand HTTP, JSON, API authentication, and self-hosted network routing.
For a private API source, you must understand DNS, TLS, reverse proxies, and container networking.
To write a template manually, you must understand JSX and Mantine. An AI-generated definition requires the same security and capability review.
## Select a task
- [Create a Custom Widget](./authoring): Use the workbench or an AI copy procedure.
- [Use the JSX runtime](./custom-jsx): Use data, Mantine components, temporary inputs, and diagnostics.
- [Configure requests and security](./requests-and-security): Configure API sources, actions, permissions, and network protection.
- [Use agents and skills.sh](./agent-authoring): Use MCP resources, MCP tools, and the portable Agent Skill.
- [Correct Custom Widget problems](./troubleshooting): Correct validation, request, configuration, and runtime problems.
## Understand the definition
Custom JSX v2 is Homarr's only Custom Widget format. A definition contains keyed API sources, keyed requests, optional widget options, and one safe JSX template. Credentials are encrypted separately and never exported.
```ts
interface HomarrCustomWidgetV2 {
@@ -65,59 +27,40 @@ interface HomarrCustomWidgetV2 {
name: string;
description?: string;
iconUrl?: string;
sources: CustomWidgetSource[];
requests: CustomWidgetRequest[];
optionsSchema: CustomWidgetOptionsSchema;
defaultOptions: Record<string, unknown>;
sources: Record<string, CustomWidgetSource>;
requests: Record<string, CustomWidgetRequest>;
options?: Record<string, CustomWidgetOption>;
template: string;
}
```
<CustomWidgetCodeExample
id="complete-widget-manifest"
label="Complete Custom Widget manifest"
label="Complete manifest"
code={completeManifest}
height="420px"
height="360px"
/>
Exports, imports, the UI, MCP, and Workshop validate this schema. Homarr rejects files that use an old format.
The browser renders JSX. Homarr's server executes named requests, applies board permissions and network protections, and injects credentials. The browser never receives source credentials.
If a definition is missing, its tile shows **Custom widget unavailable**. The other board items continue to operate.
## Authoring paths
The browser renders the safe JSX template. The Homarr server executes named requests and injects encrypted credentials.
- [Create a Custom Widget](./authoring) visually or with one copied AI prompt.
- [Use the JSX runtime](./custom-jsx) for data, Mantine components, temporary inputs, and diagnostics.
- [Configure requests and security](./requests-and-security).
- [Use MCP and the skills.sh skill](./agent-authoring).
- [Correct problems](./troubleshooting).
The browser does not receive API source credentials.
The workbench keeps one continuous layout with visual source, request, and option editors. Advanced raw JSON remains available for experts and AI paste. Invalid values stay inline and only disable the widget preview.
## Create and configure a Custom Widget
Exports, imports, Workshop, MCP, and storage all validate the same schema. Old formats are rejected. A missing definition only renders **Custom widget unavailable** inside its tile.
Open **Management → Custom Widgets → Add custom widget**. The workbench contains sections for information, sources, requests, widget options, JSX, and preview.
## Import and Workshop
Use the fixed navigation to go to a section.
Import review shows origins, authentication modes, network scopes, methods, actions, and permissions. It also lets the user supply each required credential before import. Workshop publishes technically valid widgets immediately; users can vote or report them, and administrators can delete submissions.
Select a definition from the searchable board widget picker. The picker immediately shows the widget options.
## Bundled examples
Invalid widget options prevent board insertion. If an update invalidates existing options, the item opens the configuration repair form.
## Import, export, and publish
An export contains one canonical JSON document without credentials. An import validates the document before Homarr creates the definition.
The import review shows origins, authentication modes, network scopes, methods, actions, and required board permissions. File, clipboard, and Workshop imports use this review.
The review gives credential fields for each authenticated source. Homarr encrypts entered values and excludes them from the imported document.
A definition shows **Credentials missing** when a source needs credentials. You can import a definition without credentials.
The indicator disappears after you configure all required source credentials.
The [Workshop](/docs/management/workshop) publishes valid widgets immediately. Users can vote on a submission or report it.
Workshop administrators can inspect reported posts and delete any submission. This deletion does not remove installed copies.
## Examine bundled examples
Homarr adds four disabled examples during database setup. These examples use the same schema as user definitions.
Homarr does not overwrite an example after a user changes it. Select an example to examine its manifest and JSX.
Homarr seeds five disabled examples without credentials and never overwrites an edited seed. They use the same public contract. The Tautulli example is the highest-scoring widget preserved from Homarr's AI authoring evaluation:
<BundledCustomWidgetGallery />
@@ -1,64 +1,42 @@
# Configure requests and security
Each Custom Widget has one or more API sources. Most definitions use one `default` source.
Most widgets use a single source named `default`. Extra keyed sources are available under Advanced for widgets that combine APIs. Authentication is `none`, `bearer`, `basic`, an API-key header, or an API-key query parameter. Credentials belong to the widget but remain outside its manifest.
Add another API source when the widget must combine APIs. For example, a calendar can combine Sonarr and Radarr.
## Named requests
An API source supports these authentication modes:
Requests are keyed by ID. `source`, `kind`, `method`, `trigger`, `auth`, and `permission` have sensible defaults, so a load query can be as small as:
- No authentication
- Bearer token
- Basic authentication
- API-key header
- API-key query parameter
```json
{ "status": { "path": "/api/status" } }
```
Homarr encrypts credentials for each definition, API source, and credential type. A duplicate or export does not contain credentials.
The request `path` is appended to the source `baseUrl`, including a base path such as `/api/v2`.
## Configure named requests
GET, POST, PUT, PATCH, and DELETE work for queries and actions. Kind—not method—controls behavior. Load queries run automatically, manual queries run through `SubFetch`, and actions run only from `ActionButton` or `ToggleSwitch`.
Named requests support GET, POST, PUT, PATCH, and DELETE. The `kind` value controls runtime behavior.
Bind values directly where they are used:
The HTTP method does not determine whether a named request is a query or action.
```json
{
"containers": {
"path": "/api/endpoints/{option:endpointId}/docker/containers/json",
"query": { "all": { "$option": "showAll" } }
},
"restart": {
"kind": "action",
"method": "POST",
"path": "/api/endpoints/{option:endpointId}/docker/containers/{param:id}/restart",
"invalidates": ["containers"]
}
}
```
- A load query runs automatically.
- A manual query runs with `SubFetch`.
- A query can include a body.
- An action runs after a user action.
- An action can require confirmation.
- An action can invalidate named queries.
`{option:name}` and `$option` read saved widget options. `{param:name}` and `$param` read invocation-time `params`. Load queries cannot use invocation parameters. Homarr infers the exact parameter names and primitive values from these references, rejecting missing or extra invocation params at the request boundary.
A successful action removes each declared query from the browser cache and server cache. Homarr then runs the query again.
Paths and query-string values must resolve to text, numbers, or booleans. JSON bodies can also use structured `json` and `multiSelect` option values, for example `{ "entity_ids": { "$option": "entities" } }`.
Path parameters use `{name}`. Structured query and body templates use `{ "$param": "name" }`.
## Protection
Give each declared parameter an explicit value source. For a load query, use `optionsBinding` with an option reference or a primitive value.
Every request uses Homarr's existing server executor: same-origin source enforcement, network scope, DNS and redirect validation, SSRF protection, board permissions, rate and request limits, body and response limits, timeouts, and encrypted credential injection. Static headers cannot override credentials, cookies, routing, proxy, forwarding, or hop-by-hop headers.
For a manual query or action, send all parameters with the component `params` prop. Homarr does not bind matching names automatically.
## Understand request protection
All named requests use the Homarr server executor. The executor applies these security controls:
- Source origin
- Network scope
- DNS validation
- Redirect validation
- SSRF protection
- Board permissions
- Rate limits
- Request limits
- Parameter limits
- Body limits
- Timeouts
- Response size limits
- Encrypted credential injection
Static headers cannot replace authentication, routing, proxy, cookie, or hop-by-hop headers. Homarr rejects static values that look like credentials.
The `public` network scope permits public destinations. The `private` network scope also permits private LAN destinations.
The `loopback` network scope also permits loopback destinations. The SSRF policy blocks sensitive infrastructure ranges for all network scopes.
Each named request requires view, modify, or full board access. The server checks this permission before it contacts the API source.
A DELETE request always requires full access and confirmation. Homarr does not cache actions, retry actions, or run actions on load.
Queries default to view permission and actions to modify. DELETE always requires full permission and confirmation. Actions are never cached, retried, or run on load.
@@ -20,11 +20,11 @@ Homarr does not migrate old Custom JSX formats.
Read the first schema or JSX diagnostic. Use its path to find the invalid value.
Make sure that each API source ID is unique. Make sure that each request ID is unique.
Make sure keyed API source and request names are valid and unique.
Make sure that each named request references an API source. Declare each request parameter.
Make sure that each named request references an API source. Use explicit `$option` and `$param` references.
Make sure that default values conform to the widget options schema. Use the correct request kind for each runtime component.
Make sure every option default matches its control. Use the correct request kind for each runtime component.
An incomplete API source URL or icon URL causes a form validation error. The workbench keeps the draft open.
@@ -38,11 +38,9 @@ The Diagnostics tab shows the current schema errors.
Check the API source network scope. Check the base URL and credentials.
Check the request method, path, body, parameters, and board permission.
Check the request method, path, body, direct value bindings, and board permission.
For “invalid placeholder,” examine each declared load query parameter. Add an `optionsBinding` option reference or primitive value for each parameter.
A matching parameter name and widget option name do not create a binding. The preview journal does not show credentials.
For “invalid placeholder,” use `{option:name}` or `{param:name}`. Load queries can only reference options. The preview journal does not show credentials.
The journal shows the method, request ID, path template, status, and duration.
@@ -56,6 +54,6 @@ Use safe widget options, temporary inputs, or named requests instead.
## Repair an existing item
The widget options schema changed and the saved values are invalid. Open the item settings.
The widget option definitions changed and the saved values are invalid. Open the item settings.
Correct the marked configuration values. Homarr does not infer widget option migrations.
@@ -53,12 +53,7 @@ const messages: CustomWidgetEditorMessages = {
};
export function CanonicalCustomWidgetExampleClient({ id, label, example, height }: CanonicalCustomWidgetExampleProps) {
const value =
example === "requests"
? CUSTOM_WIDGET_REQUEST_EXAMPLES.full
: example === "optionsSchema"
? CUSTOM_WIDGET_OPTIONS_EXAMPLES.full.schema
: CUSTOM_WIDGET_OPTIONS_EXAMPLES.full.defaults;
const value = example === "requests" ? CUSTOM_WIDGET_REQUEST_EXAMPLES.full : CUSTOM_WIDGET_OPTIONS_EXAMPLES.full;
return <CustomWidgetCodeExampleClient id={id} label={label} code={JSON.stringify(value, null, 2)} height={height} />;
}
+10 -32
View File
@@ -1,5 +1,10 @@
import BrowserOnly from "@docusaurus/BrowserOnly";
import { lazy, Suspense } from "react";
import {
BundledCustomWidgetGalleryClient,
CanonicalCustomWidgetExampleClient,
CustomWidgetCodeExampleClient,
} from "./custom-widget-code-client";
export interface CustomWidgetCodeExampleProps {
id: string;
@@ -12,33 +17,14 @@ export interface CustomWidgetCodeExampleProps {
export interface CanonicalCustomWidgetExampleProps {
id: string;
label: string;
example: "requests" | "optionsSchema" | "defaultOptions";
example: "requests" | "options";
height?: string;
}
const LazyCanonicalCustomWidgetExample = lazy(async () => {
const module = await import("./custom-widget-code-client");
return { default: module.CanonicalCustomWidgetExampleClient };
});
const LazyCustomWidgetCodeExample = lazy(async () => {
const module = await import("./custom-widget-code-client");
return { default: module.CustomWidgetCodeExampleClient };
});
const LazyBundledCustomWidgetGallery = lazy(async () => {
const module = await import("./custom-widget-code-client");
return { default: module.BundledCustomWidgetGalleryClient };
});
export function CanonicalCustomWidgetExample(props: CanonicalCustomWidgetExampleProps) {
return (
<BrowserOnly fallback={<CodeExampleFallback height={props.height} />}>
{() => (
<Suspense fallback={<CodeExampleFallback height={props.height} />}>
<LazyCanonicalCustomWidgetExample {...props} />
</Suspense>
)}
{() => <CanonicalCustomWidgetExampleClient {...props} />}
</BrowserOnly>
);
}
@@ -46,11 +32,7 @@ export function CanonicalCustomWidgetExample(props: CanonicalCustomWidgetExample
export function CustomWidgetCodeExample(props: CustomWidgetCodeExampleProps) {
return (
<BrowserOnly fallback={<CodeExampleFallback height={props.height} />}>
{() => (
<Suspense fallback={<CodeExampleFallback height={props.height} />}>
<LazyCustomWidgetCodeExample {...props} />
</Suspense>
)}
{() => <CustomWidgetCodeExampleClient {...props} />}
</BrowserOnly>
);
}
@@ -58,11 +40,7 @@ export function CustomWidgetCodeExample(props: CustomWidgetCodeExampleProps) {
export function BundledCustomWidgetGallery() {
return (
<BrowserOnly fallback={<CodeExampleFallback height="520px" />}>
{() => (
<Suspense fallback={<CodeExampleFallback height="520px" />}>
<LazyBundledCustomWidgetGallery />
</Suspense>
)}
{() => <BundledCustomWidgetGalleryClient />}
</BrowserOnly>
);
}
@@ -542,20 +542,21 @@ function WidgetCapabilitySummary({ content }: { content: string }) {
<div className={styles.capabilities}>
<section>
<strong>API sources</strong>
{result.data.sources.map((source) => (
<p key={source.id}>
<code>{new URL(source.baseUrl).origin}</code> · {source.networkScope} · {source.auth.type}
{Object.entries(result.data.sources).map(([sourceId, source]) => (
<p key={sourceId}>
<code>{new URL(source.baseUrl).origin}</code> · {source.networkScope} ·{" "}
{typeof source.auth === "string" ? source.auth : source.auth.type}
</p>
))}
</section>
<section>
<strong>Requests and permissions</strong>
{result.data.requests.length === 0 ? (
{Object.keys(result.data.requests).length === 0 ? (
<p>No network requests</p>
) : (
result.data.requests.map((request) => (
<p key={request.id}>
<code>{request.id}</code> · {request.kind} · {request.method} · {request.minimumBoardPermission}
Object.entries(result.data.requests).map(([requestId, request]) => (
<p key={requestId}>
<code>{requestId}</code> · {request.kind} · {request.method} · {request.permission}
</p>
))
)}
+11
View File
@@ -9,6 +9,17 @@
--breakpoint-3xl: 1697px;
}
html,
body,
#__docusaurus {
min-height: 100%;
background-color: var(--ifm-background-color);
}
#__docusaurus {
min-height: 100vh;
}
:root {
--ifm-color-primary: #fa5252;
--ifm-color-primary-dark: #f93232;
+1 -1
View File
@@ -1 +1 @@
{"schemaVersion":1,"mantineVersion":"9.4.1","customWidgetVersion":"2.0.0","types":["\"\" | \"anonymous\" | \"use-credentials\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"auto\" | \"fixed\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"auto\" | \"all\" | \"fill\" | \"stroke\" | \"painted\" | \"visible\" | \"visibleFill\" | \"visiblePainted\" | \"visibleStroke\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"block\" | \"inline\" | \"both\" | \"horizontal\" | \"vertical\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"contain\" | \"cover\" | \"fill\" | \"scale-down\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"wrap\" | \"nowrap\" | \"wrap-reverse\"","\"12h\" | \"24h\"","\"activity\" | \"display-none\"","\"all\" | \"parent\" | \"child\"","\"all\" | \"segment\"","\"always\" | \"hover\" | \"not-hover\" | \"never\"","\"area\" | \"radial\" | \"scatter\"","\"auto\" | \"scroll\" | \"always\" | \"hover\" | \"never\"","\"auto-fit\" | \"auto-fill\"","\"block\" | \"grid\"","\"both\" | \"rightSection\" | \"clear\"","\"bottom\" | \"top\"","\"bottom\" | \"top\" | \"baseline\" | \"middle\"","\"bottom\" | \"top\" | \"middle\"","\"bottom-end\" | \"bottom-start\" | \"top-end\" | \"top-start\" | \"bottom-center\" | \"top-center\" | \"middle-center\" | \"middle-end\" | \"middle-start\"","\"button\" | \"submit\" | \"reset\"","\"center\" | \"bottom\"","\"center\" | \"left\" | \"right\"","\"center\" | \"left\" | \"right\" | \"justify\" | \"char\"","\"center\" | \"side\" | \"merge\"","\"checkbox\" | \"radio\"","\"dashed\" | \"dotted\" | \"solid\"","\"default\" | \"gradient\"","\"default\" | \"responsive\"","\"default\" | \"stacked\" | \"percent\" | \"split\"","\"default\" | \"stacked\" | \"percent\" | \"waterfall\"","\"div\" | \"label\"","\"end\" | \"start\"","\"filled\" | \"light\"","\"fixed\" | \"absolute\"","\"hex\" | \"hexa\" | \"rgba\" | \"rgb\" | \"hsl\" | \"hsla\"","\"horizontal\" | \"vertical\"","\"hover\" | \"click\" | \"click-hover\"","\"inherit\" | \"auto\" | \"baseline\" | \"before-edge\" | \"text-before-edge\" | \"middle\" | \"central\" | \"after-edge\" | \"text-after-edge\" | \"ideographic\" | \"alphabetic\" | \"hanging\" | \"mathematical\"","\"inherit\" | \"auto\" | \"sRGB\" | \"linearRGB\"","\"inherit\" | \"auto\" | \"text-before-edge\" | \"middle\" | \"central\" | \"text-after-edge\" | \"ideographic\" | \"alphabetic\" | \"hanging\" | \"mathematical\" | \"use-script\" | \"no-change\" | \"reset-size\"","\"inherit\" | \"end\" | \"start\" | \"middle\"","\"inherit\" | \"nonzero\" | \"evenodd\"","\"inherit\" | \"round\" | \"butt\" | \"square\"","\"inherit\" | \"round\" | \"miter\" | \"bevel\"","\"initial\" | \"medial\" | \"terminal\" | \"isolated\"","\"inside\" | \"outside\"","\"left\" | \"right\"","\"left\" | \"right\" | \"bottom\" | \"top\" | \"left-end\" | \"left-start\" | \"right-end\" | \"right-start\" | \"bottom-end\" | \"bottom-start\" | \"top-end\" | \"top-start\"","\"left\" | \"right\" | \"inside\"","\"link\" | \"none\" | \"copy\" | \"execute\" | \"move\" | \"popup\"","\"media\" | \"container\"","\"month\" | \"year\" | \"decade\"","\"native\" | \"scrollarea\"","\"none\" | \"all\" | \"rows\" | \"groups\" | \"columns\"","\"none\" | \"all\" | \"with-indicators\"","\"none\" | \"ascending\" | \"descending\" | \"other\"","\"none\" | \"inline\" | \"list\" | \"both\"","\"none\" | \"strict\" | \"blur\"","\"none\" | \"sum\"","\"none\" | \"thousand\" | \"lakh\" | \"wan\"","\"none\" | \"x\" | \"y\" | \"xy\"","\"number\" | RegExp | \"alphanumeric\"","\"off\" | \"assertive\" | \"polite\"","\"off\" | \"on\"","\"ordered\" | \"unordered\"","\"right-to-left\" | \"left-to-right\"","\"search\" | \"enter\" | \"done\" | \"go\" | \"next\" | \"previous\" | \"send\"","\"search\" | \"text\" | \"none\" | \"tel\" | \"url\" | \"email\" | \"numeric\" | \"decimal\"","\"step\" | \"bump\" | \"linear\" | \"natural\" | \"monotone\" | \"stepBefore\" | \"stepAfter\"","\"stepInactive\" | \"stepProgress\" | \"stepCompleted\"","\"sum\" | \"replace\"","\"td\" | \"th\"","\"text\" | \"additions\" | \"additions removals\" | \"additions text\" | \"all\" | \"removals\" | \"removals additions\" | \"removals text\" | \"text additions\" | \"text removals\"","\"text\" | \"tel\" | \"password\"","\"time\" | \"duration\"","\"up\" | \"down\"","\"value\" | \"name\" | \"percent\"","\"viewport\"","\"wrap\" | \"nowrap\" | \"balance\" | \"pretty\" | \"stable\"","\"x\" | \"y\"","\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\"","\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\" | (string & {})","\"yes\" | \"no\"","'Text' | 'Title' | 'Badge' | 'Code' | 'Image'","'auto' | 'manual'","'json' | 'text'","'xs' | 'sm' | 'md' | 'lg' | 'xl'","(\"input\" | \"label\" | \"description\" | \"error\")[]","(\"next\" | \"previLine truncated
{"schemaVersion":1,"mantineVersion":"9.4.1","customWidgetVersion":"2.0.0","types":["\"\" | \"anonymous\" | \"use-credentials\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"auto\" | \"fixed\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"auto\" | \"all\" | \"fill\" | \"stroke\" | \"painted\" | \"visible\" | \"visibleFill\" | \"visiblePainted\" | \"visibleStroke\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"block\" | \"inline\" | \"both\" | \"horizontal\" | \"vertical\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"contain\" | \"cover\" | \"fill\" | \"scale-down\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"wrap\" | \"nowrap\" | \"wrap-reverse\"","\"12h\" | \"24h\"","\"activity\" | \"display-none\"","\"all\" | \"parent\" | \"child\"","\"all\" | \"segment\"","\"always\" | \"hover\" | \"not-hover\" | \"never\"","\"area\" | \"radial\" | \"scatter\"","\"auto\" | \"scroll\" | \"always\" | \"hover\" | \"never\"","\"auto-fit\" | \"auto-fill\"","\"block\" | \"grid\"","\"both\" | \"rightSection\" | \"clear\"","\"bottom\" | \"top\"","\"bottom\" | \"top\" | \"baseline\" | \"middle\"","\"bottom\" | \"top\" | \"middle\"","\"bottom-end\" | \"bottom-start\" | \"top-end\" | \"top-start\" | \"bottom-center\" | \"top-center\" | \"middle-center\" | \"middle-end\" | \"middle-start\"","\"button\" | \"submit\" | \"reset\"","\"center\" | \"bottom\"","\"center\" | \"left\" | \"right\"","\"center\" | \"left\" | \"right\" | \"justify\" | \"char\"","\"center\" | \"side\" | \"merge\"","\"checkbox\" | \"radio\"","\"dashed\" | \"dotted\" | \"solid\"","\"default\" | \"gradient\"","\"default\" | \"responsive\"","\"default\" | \"stacked\" | \"percent\" | \"split\"","\"default\" | \"stacked\" | \"percent\" | \"waterfall\"","\"div\" | \"label\"","\"end\" | \"start\"","\"filled\" | \"light\"","\"fixed\" | \"absolute\"","\"hex\" | \"hexa\" | \"rgba\" | \"rgb\" | \"hsl\" | \"hsla\"","\"horizontal\" | \"vertical\"","\"hover\" | \"click\" | \"click-hover\"","\"inherit\" | \"auto\" | \"baseline\" | \"before-edge\" | \"text-before-edge\" | \"middle\" | \"central\" | \"after-edge\" | \"text-after-edge\" | \"ideographic\" | \"alphabetic\" | \"hanging\" | \"mathematical\"","\"inherit\" | \"auto\" | \"sRGB\" | \"linearRGB\"","\"inherit\" | \"auto\" | \"text-before-edge\" | \"middle\" | \"central\" | \"text-after-edge\" | \"ideographic\" | \"alphabetic\" | \"hanging\" | \"mathematical\" | \"use-script\" | \"no-change\" | \"reset-size\"","\"inherit\" | \"end\" | \"start\" | \"middle\"","\"inherit\" | \"nonzero\" | \"evenodd\"","\"inherit\" | \"round\" | \"butt\" | \"square\"","\"inherit\" | \"round\" | \"miter\" | \"bevel\"","\"initial\" | \"medial\" | \"terminal\" | \"isolated\"","\"inside\" | \"outside\"","\"left\" | \"right\"","\"left\" | \"right\" | \"bottom\" | \"top\" | \"left-end\" | \"left-start\" | \"right-end\" | \"right-start\" | \"bottom-end\" | \"bottom-start\" | \"top-end\" | \"top-start\"","\"left\" | \"right\" | \"inside\"","\"link\" | \"none\" | \"copy\" | \"execute\" | \"move\" | \"popup\"","\"media\" | \"container\"","\"month\" | \"year\" | \"decade\"","\"native\" | \"scrollarea\"","\"none\" | \"all\" | \"rows\" | \"groups\" | \"columns\"","\"none\" | \"all\" | \"with-indicators\"","\"none\" | \"ascending\" | \"descending\" | \"other\"","\"none\" | \"inline\" | \"list\" | \"both\"","\"none\" | \"strict\" | \"blur\"","\"none\" | \"sum\"","\"none\" | \"thousand\" | \"lakh\" | \"wan\"","\"none\" | \"x\" | \"y\" | \"xy\"","\"number\" | RegExp | \"alphanumeric\"","\"off\" | \"assertive\" | \"polite\"","\"off\" | \"on\"","\"ordered\" | \"unordered\"","\"right-to-left\" | \"left-to-right\"","\"search\" | \"enter\" | \"done\" | \"go\" | \"next\" | \"previous\" | \"send\"","\"search\" | \"text\" | \"none\" | \"tel\" | \"url\" | \"email\" | \"numeric\" | \"decimal\"","\"step\" | \"bump\" | \"linear\" | \"natural\" | \"monotone\" | \"stepBefore\" | \"stepAfter\"","\"stepInactive\" | \"stepProgress\" | \"stepCompleted\"","\"sum\" | \"replace\"","\"td\" | \"th\"","\"text\" | \"additions\" | \"additions removals\" | \"additions text\" | \"all\" | \"removals\" | \"removals additions\" | \"removals text\" | \"text additions\" | \"text removals\"","\"text\" | \"tel\" | \"password\"","\"time\" | \"duration\"","\"up\" | \"down\"","\"value\" | \"name\" | \"percent\"","\"viewport\"","\"wrap\" | \"nowrap\" | \"balance\" | \"pretty\" | \"stable\"","\"x\" | \"y\"","\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\"","\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\" | (string & {})","\"yes\" | \"no\"","'Text' | 'Title' | 'Badge' | 'Code' | 'Image'","'auto' | 'manual'","'json' | 'text'","'xs' | 'sm' | 'md' | 'lg' | 'xl'","(\"input\" | \"label\" | \"description\" | \"error\")[]","(\"next\" | \"previLine truncated
@@ -124,7 +124,7 @@ const AddMenu = () => {
return (
<Menu position="bottom-end">
<Menu.Target>
<HeaderButton w="auto" px={4}>
<HeaderButton w="auto" px={4} aria-label={t("item.action.create")}>
<Group gap={4} wrap="nowrap">
<IconPlus stroke={1.5} />
<IconChevronDown color="gray" size={16} />
@@ -163,6 +163,7 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const board = useRequiredBoard();
const utils = clientApi.useUtils();
const t = useScopedI18n("board.action.edit");
const commonT = useI18n();
const { mutate: saveBoard, isPending } = clientApi.board.saveBoard.useMutation({
onSuccess() {
showSuccessNotification({
@@ -199,7 +200,11 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
return (
<OnboardingTour.Target id="board-edit-mode">
<HeaderButton onClick={toggle} loading={isPending}>
<HeaderButton
onClick={toggle}
loading={isPending}
aria-label={isEditMode ? commonT("common.action.save") : commonT("common.action.edit")}
>
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
</HeaderButton>
</OnboardingTour.Target>
@@ -1,145 +1,23 @@
// @vitest-environment jsdom
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { buildCustomWidgetAiPrompt } from "@homarr/custom-widgets/authoring-prompt";
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { CUSTOM_WIDGET_OFFLINE_BUNDLE_SENTINEL } from "@homarr/custom-widgets/authoring-prompt";
import { showSuccessNotification } from "@homarr/notifications";
import { CopyAiPromptButton } from "./_copy-ai-prompt-button";
vi.mock("@homarr/translation/client", () => ({
useScopedI18n: () => (key: string, values?: { count?: number }) =>
values?.count === undefined ? key : `${key}:${values.count}`,
}));
vi.mock("@homarr/notifications", () => ({
showErrorNotification: vi.fn(),
showSuccessNotification: vi.fn(),
}));
let root: Root;
let host: HTMLDivElement;
let writeText: ReturnType<typeof vi.fn>;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
},
);
window.matchMedia = vi.fn().mockImplementation(() => ({
matches: false,
media: "",
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } });
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(() => root.unmount());
host.remove();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
describe("CopyAiPromptButton", () => {
test("keeps the normal prompt lightweight and the embedded module lazy", async () => {
const source = await readFile(
resolve(process.cwd(), "apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx"),
describe("Copy AI prompt", () => {
it("uses one self-contained prompt without an embedded-skill branch", () => {
const source = readFileSync(
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx`,
"utf8",
);
expect(source).toContain('from "@homarr/custom-widgets/authoring-prompt"');
expect(source).toContain('await import("@homarr/custom-widgets/embedded-authoring-prompt")');
expect(source).not.toMatch(/^import .*@homarr\/custom-widgets\/embedded-authoring-prompt/mu);
await act(async () => {
root.render(
<MantineProvider>
<CopyAiPromptButton request="Create a Pokédex" />
</MantineProvider>,
);
});
const copyButton = [...host.querySelectorAll<HTMLButtonElement>("button")].find(
(button) => button.textContent === "action.copyAiPrompt",
);
expect(copyButton).toBeDefined();
await act(async () => copyButton?.click());
await vi.waitFor(() => expect(writeText).toHaveBeenCalledOnce());
const prompt = writeText.mock.calls[0]?.[0] as string;
expect(prompt.length).toBeLessThanOrEqual(8_000);
expect(prompt).not.toContain("--- BEGIN SKILL FILE:");
expect(showSuccessNotification).toHaveBeenCalledWith({
title: "action.copyAiPrompt",
message: `notification.aiPromptCopiedWithCount:${prompt.length}`,
});
expect(source).toContain("buildCustomWidgetAiPrompt");
expect(source).not.toContain("embedded-authoring-prompt");
expect(source).not.toContain("Menu");
});
test("copies the complete embedded skill and reports its exact character count", async () => {
await act(async () => {
root.render(
<MantineProvider>
<CopyAiPromptButton request="Create a Pokédex" />
</MantineProvider>,
);
});
const menuTarget = host.querySelector<HTMLButtonElement>('[aria-label="action.copyAiPromptWithSkill"]');
expect(menuTarget).not.toBeNull();
await act(async () => {
menuTarget?.click();
await new Promise((resolve) => window.setTimeout(resolve, 20));
});
const embeddedSkillItem = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')].find((element) =>
element.textContent?.includes("action.copyAiPromptWithSkill"),
);
expect(embeddedSkillItem).toBeDefined();
await act(async () => embeddedSkillItem?.click());
await vi.waitFor(() => expect(writeText).toHaveBeenCalledOnce());
const prompt = writeText.mock.calls[0]?.[0] as string;
expect(prompt.startsWith("Please create this Homarr Custom JSX v2 widget:\n\nCreate a Pokédex")).toBe(true);
expect(prompt).toContain("# Homarr Custom Widget offline authoring bundle");
expect(prompt).toContain("--- BEGIN SKILL FILE: SKILL.md ---");
expect(prompt).toContain("--- BEGIN SKILL FILE: references/widget-schema.json ---");
expect(prompt).toContain("--- BEGIN CANONICAL COMPONENT CATALOG ---");
expect(prompt).toContain("--- BEGIN CANONICAL RUNTIME EXAMPLES ---");
expect(prompt.length).toBeGreaterThan(500_000);
expect(prompt.length).toBeLessThanOrEqual(1_700_000);
expect(prompt.endsWith(`${CUSTOM_WIDGET_OFFLINE_BUNDLE_SENTINEL}\nCharacters: ${prompt.length}`)).toBe(true);
expect(showSuccessNotification).toHaveBeenCalledWith({
title: "action.copyAiPrompt",
message: `notification.aiPromptCopiedWithCount:${prompt.length}`,
});
});
test("places the AI authoring card before General information", async () => {
const source = await readFile(
resolve(process.cwd(), "apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx"),
"utf8",
);
expect(source.indexOf("<CustomWidgetAiCard")).toBeLessThan(source.indexOf('<EditorSection id="general"'));
it("copies a complete prompt within the public size limit", () => {
const prompt = buildCustomWidgetAiPrompt(undefined, null, null, "Create a Pokédex");
expect(prompt.length).toBeLessThanOrEqual(12_000);
expect(prompt).toContain("exactly one complete `json` fenced block");
expect(prompt).toContain("one complete `jsx` fenced block");
});
});
@@ -1,7 +1,7 @@
"use client";
import { Button, Menu } from "@mantine/core";
import { IconChevronDown, IconFileText, IconSparkles } from "@tabler/icons-react";
import { Button } from "@mantine/core";
import { IconSparkles } from "@tabler/icons-react";
import { buildCustomWidgetAiPrompt } from "@homarr/custom-widgets/authoring-prompt";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
@@ -22,20 +22,9 @@ export const CopyAiPromptButton = ({
}: CopyAiPromptButtonProps) => {
const t = useScopedI18n("customWidget");
const handleCopy = async (includeSkill = false) => {
const handleCopy = async () => {
try {
const prompt = includeSkill
? await import("@homarr/custom-widgets/embedded-authoring-prompt").then(
({ buildCustomWidgetAiPromptWithEmbeddedSkill }) =>
buildCustomWidgetAiPromptWithEmbeddedSkill(
undefined,
rawResponse,
currentConfig,
request,
documentationUrl,
),
)
: buildCustomWidgetAiPrompt(undefined, rawResponse, currentConfig, request, documentationUrl);
const prompt = buildCustomWidgetAiPrompt(undefined, rawResponse, currentConfig, request, documentationUrl);
await navigator.clipboard.writeText(prompt);
showSuccessNotification({
title: t("action.copyAiPrompt"),
@@ -47,32 +36,15 @@ export const CopyAiPromptButton = ({
};
return (
<Button.Group w="100%">
<Button
type="button"
variant="light"
leftSection={<IconSparkles size={16} />}
onClick={() => void handleCopy()}
fullWidth
size="sm"
>
{t("action.copyAiPrompt")}
</Button>
<Menu position="bottom-end" withinPortal={false}>
<Menu.Target>
<Button type="button" variant="light" px="xs" aria-label={t("action.copyAiPromptWithSkill")}>
<IconChevronDown size={16} />
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item leftSection={<IconSparkles size={16} />} onClick={() => void handleCopy()}>
{t("action.copyAiPrompt")}
</Menu.Item>
<Menu.Item leftSection={<IconFileText size={16} />} onClick={() => void handleCopy(true)}>
{t("action.copyAiPromptWithSkill")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Button.Group>
<Button
type="button"
variant="light"
leftSection={<IconSparkles size={16} />}
onClick={() => void handleCopy()}
fullWidth
size="sm"
>
{t("action.copyAiPrompt")}
</Button>
);
};
@@ -0,0 +1,62 @@
"use client";
import { useEffect, useState } from "react";
import { Accordion } from "@mantine/core";
import { customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
import { useScopedI18n } from "@homarr/translation/client";
import { CodeEditor } from "./_code-editor";
import { applyDefinition, parseJson } from "./_custom-widget-form-utils";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
export function CustomWidgetAdvancedManifest({ form }: { form: CustomWidgetWorkbenchForm }) {
const t = useScopedI18n("customWidget.workbench.builder");
const serialized = JSON.stringify(
{
$schema: "homarr-custom-widget-v2",
name: form.values.name,
...(form.values.description ? { description: form.values.description } : {}),
...(form.values.iconUrl ? { iconUrl: form.values.iconUrl } : {}),
sources: parseJson(form.values.sources),
requests: parseJson(form.values.requests),
options: parseJson(form.values.options),
template: form.values.template,
},
null,
2,
);
const [draft, setDraft] = useState(serialized);
const [error, setError] = useState<string>();
useEffect(() => setDraft(serialized), [serialized]);
return (
<Accordion variant="contained">
<Accordion.Item value="manifest">
<Accordion.Control>{t("advancedManifest")}</Accordion.Control>
<Accordion.Panel>
<CodeEditor
id="raw-widget-manifest"
label={t("manifestFilename")}
language="json"
value={draft}
error={error}
onChange={(next) => {
setDraft(next);
try {
const parsed = customWidgetDefinitionSchema.safeParse(JSON.parse(next) as unknown);
if (!parsed.success) {
setError(parsed.error.issues[0]?.message ?? t("invalidWidget"));
return;
}
setError(undefined);
applyDefinition(form, parsed.data);
} catch (cause) {
setError(cause instanceof Error ? cause.message : t("invalidJson"));
}
}}
/>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
);
}
@@ -3,8 +3,8 @@ import type { CustomWidgetFormValues, EditorDiagnostic } from "@homarr/custom-wi
import { isRecord, parseJson, parseSources } from "./_custom-widget-form-utils";
export function createCustomWidgetCompletions(values: CustomWidgetFormValues, requestIds: string[]) {
const schema = parseJson(values.optionsSchema);
const optionKeys = Object.keys(isRecord(schema) && isRecord(schema.properties) ? schema.properties : {});
const options = parseJson(values.options);
const optionKeys = Object.keys(isRecord(options) ? options : {});
return [
...requestIds.flatMap((id) => [
{ label: `data["${id}"]`, type: "variable", detail: `Response from ${id}` },
@@ -28,7 +28,7 @@ export function getInvalidCustomWidgetSections(
if (["name", "description", "iconUrl"].includes(field)) result.add("general");
else if (field === "sources") result.add("sources");
else if (field === "requests") result.add("requests");
else if (["optionsSchema", "defaultOptions"].includes(field)) result.add("options");
else if (field === "options") result.add("options");
else if (field === "template") result.add("jsx");
}
if (requestDiagnostics.some((entry) => entry.severity === "error")) result.add("requests");
@@ -54,7 +54,7 @@ export function SaveActions({
}) {
const t = useScopedI18n("customWidget.workbench");
return (
<Group justify="space-between" wrap="nowrap">
<Group justify="space-between" wrap="wrap">
<Group gap="xs">
{invalid ? (
<IconAlertTriangle size={16} color="var(--mantine-color-red-6)" />
@@ -62,10 +62,10 @@ export function SaveActions({
<IconCheck size={16} color="var(--mantine-color-green-6)" />
)}
<Text size="sm" fw={600}>
{dirty ? t("status.unsaved") : invalid ? t("status.invalid") : t("status.ready")}
{invalid ? t("status.invalid") : dirty ? t("status.unsaved") : t("status.ready")}
</Text>
</Group>
<Group gap="xs" wrap="nowrap">
<Group gap="xs" wrap="wrap">
<Button
type="button"
variant="light"
@@ -1,7 +1,6 @@
import {
CUSTOM_WIDGET_OPTIONS_EXAMPLES,
CUSTOM_WIDGET_REQUEST_EXAMPLES,
getCustomWidgetDefaultOptionsJsonSchema,
getCustomWidgetOptionsJsonSchema,
getCustomWidgetRequestsJsonSchema,
} from "@homarr/custom-widgets/core";
@@ -14,12 +13,6 @@ export const customWidgetRequestReference = {
export const customWidgetOptionsSchemaReference = {
schema: getCustomWidgetOptionsJsonSchema(),
minimal: CUSTOM_WIDGET_OPTIONS_EXAMPLES.minimal.schema,
full: CUSTOM_WIDGET_OPTIONS_EXAMPLES.full.schema,
};
export const customWidgetDefaultOptionsReference = {
schema: getCustomWidgetDefaultOptionsJsonSchema(),
minimal: CUSTOM_WIDGET_OPTIONS_EXAMPLES.minimal.defaults,
full: CUSTOM_WIDGET_OPTIONS_EXAMPLES.full.defaults,
minimal: CUSTOM_WIDGET_OPTIONS_EXAMPLES.minimal,
full: CUSTOM_WIDGET_OPTIONS_EXAMPLES.full,
};
@@ -1,80 +1,59 @@
import { describe, expect, it, vi } from "vitest";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
import { getChangedSecrets, getCustomWidgetPreviewOptionIssues, loadPreviewQueries } from "./_custom-widget-form-utils";
import {
getChangedSecrets,
getCustomWidgetPreviewOptionIssues,
loadPreviewQueries,
parseSources,
} from "./_custom-widget-form-utils";
const previewQuery = vi.hoisted(() => vi.fn());
vi.mock("@homarr/api/client", () => ({ fetchApi: { customWidget: { previewQuery: { query: previewQuery } } } }));
vi.mock("@homarr/api/client", () => ({
fetchApi: { customWidget: { previewQuery: { query: previewQuery } } },
}));
const definition: HomarrCustomWidgetV2 = {
const definition = customWidgetDefinitionSchema.parse({
$schema: "homarr-custom-widget-v2",
name: "Preview options",
sources: [
{
id: "default",
name: "API",
baseUrl: "https://example.com",
networkScope: "public",
auth: { type: "none" },
},
],
requests: [
{
id: "containers",
sourceId: "default",
kind: "query",
method: "GET",
pathTemplate: "/endpoints/{endpointId}/containers",
parameters: { endpointId: "number" },
optionsBinding: { endpointId: { $option: "environmentId" } },
auth: "inherit",
minimumBoardPermission: "view",
trigger: "load",
},
],
optionsSchema: {
type: "object",
properties: { environmentId: { type: "number" } },
required: ["environmentId"],
additionalProperties: false,
},
defaultOptions: { environmentId: 1 },
sources: { default: { name: "API", baseUrl: "https://example.com", networkScope: "public", auth: "none" } },
requests: { containers: { path: "/endpoints/{option:environmentId}/containers" } },
options: { environmentId: { label: "Environment", control: "number", default: 1 } },
template: "<Text>Preview</Text>",
};
});
describe("Custom Widget workbench preview options", () => {
it("passes the selected options to load-query bindings without replacing them with defaults", async () => {
previewQuery.mockResolvedValueOnce({ ok: true, status: 200, data: [] });
await loadPreviewQueries(definition, "preview-1", { environmentId: 7 });
expect(previewQuery).toHaveBeenCalledWith({
sessionId: "preview-1",
requestId: "containers",
params: { endpointId: 7 },
});
it("shows the default source first regardless of manifest key order", () => {
expect(
parseSources(
JSON.stringify({
secondary: { baseUrl: "https://secondary.example.com", networkScope: "public", auth: "none" },
default: { baseUrl: "https://default.example.com", networkScope: "public", auth: "none" },
}),
).map(({ id }) => id),
).toEqual(["default", "secondary"]);
});
it("rejects invalid selected options before creating a preview session", () => {
it("keeps load-query option resolution on the server", async () => {
previewQuery.mockResolvedValueOnce({ ok: true, status: 200, data: [] });
await loadPreviewQueries(definition, "preview-1");
expect(previewQuery).toHaveBeenCalledWith({ sessionId: "preview-1", requestId: "containers", params: {} });
});
it("rejects invalid selected options", () => {
expect(getCustomWidgetPreviewOptionIssues(definition, { environmentId: "wrong" })).toEqual([
{ path: "configuration.environmentId", message: "Expected number" },
{ path: "configuration.environmentId", message: "Expected a number" },
]);
});
it("normalizes only changed secrets for save and preview", () => {
it("normalizes only changed secrets", () => {
expect(
getChangedSecrets({
name: "Widget",
description: "",
iconUrl: "",
sources: "[]",
requests: "[]",
optionsSchema: "{}",
defaultOptions: "{}",
sources: "{}",
requests: "{}",
options: "{}",
template: "<Text />",
secrets: [
{ sourceId: "default", kind: "apiKey", value: "", hasValue: true },
@@ -1,7 +1,7 @@
import { fetchApi } from "@homarr/api/client";
import {
customWidgetDefinitionSchema,
resolveCustomWidgetOptionsBinding,
getCustomWidgetDefaultOptions,
validateCustomWidgetOptions,
} from "@homarr/custom-widgets/core";
import type { CustomWidgetSource, HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
@@ -18,8 +18,7 @@ export function buildDefinition(values: CustomWidgetFormValues) {
iconUrl: values.iconUrl || undefined,
sources: parseJson(values.sources),
requests: parseJson(values.requests),
optionsSchema: parseJson(values.optionsSchema),
defaultOptions: parseJson(values.defaultOptions),
options: parseJson(values.options),
template: values.template,
});
}
@@ -42,24 +41,20 @@ export function applyDefinition(form: CustomWidgetWorkbenchForm, widget: HomarrC
iconUrl: widget.iconUrl ?? "",
sources: JSON.stringify(widget.sources, null, 2),
requests: JSON.stringify(widget.requests, null, 2),
optionsSchema: JSON.stringify(widget.optionsSchema, null, 2),
defaultOptions: JSON.stringify(widget.defaultOptions, null, 2),
options: JSON.stringify(widget.options, null, 2),
template: widget.template,
});
}
export function parseSources(value: string): CustomWidgetSource[] {
export function parseSources(value: string): Array<CustomWidgetSource & { id: string }> {
const parsed = parseJson(value);
return Array.isArray(parsed)
? parsed.filter((entry): entry is CustomWidgetSource => isRecord(entry) && typeof entry.id === "string")
return isRecord(parsed)
? Object.entries(parsed)
.flatMap(([id, entry]) => (isRecord(entry) ? [{ id, ...(entry as CustomWidgetSource) }] : []))
.toSorted((left, right) => Number(right.id === "default") - Number(left.id === "default"))
: [];
}
export function parseJsonArray(value: string): unknown[] {
const parsed = parseJson(value);
return Array.isArray(parsed) ? parsed : [];
}
export function parseJson(value: string): unknown {
try {
return JSON.parse(value) as unknown;
@@ -73,33 +68,35 @@ export function isRecord(value: unknown): value is Record<string, unknown> {
}
export function getCustomWidgetPreviewOptionIssues(definition: HomarrCustomWidgetV2, options: Record<string, unknown>) {
return validateCustomWidgetOptions(definition.optionsSchema, options);
return validateCustomWidgetOptions(definition.options, options);
}
export function isRuntimeParams(
value: unknown,
schema: Record<string, "string" | "number" | "boolean">,
expectedNames: readonly string[],
): value is Record<string, string | number | boolean> {
if (!isRecord(value)) return false;
return Object.entries(schema).every(([name, type]) => typeof value[name] === type);
const names = Object.keys(value).toSorted();
return (
names.length === expectedNames.length &&
names.every((name, index) => name === expectedNames[index]) &&
Object.values(value).every((entry) => ["string", "number", "boolean"].includes(typeof entry))
);
}
export async function loadPreviewQueries(
definition: HomarrCustomWidgetV2,
sessionId: string,
options: Record<string, unknown>,
) {
export async function loadPreviewQueries(definition: HomarrCustomWidgetV2, sessionId: string) {
const data: Record<string, unknown> = {};
const status: Record<string, unknown> = {};
for (const request of definition.requests.filter((entry) => entry.kind === "query" && entry.trigger === "load")) {
for (const [requestId] of Object.entries(definition.requests).filter(
([, entry]) => entry.kind === "query" && entry.trigger === "load",
)) {
try {
const params = resolveCustomWidgetOptionsBinding(request, options);
const result = await fetchApi.customWidget.previewQuery.query({ sessionId, requestId: request.id, params });
data[request.id] = result.data;
status[request.id] = { loading: false, ...result };
const result = await fetchApi.customWidget.previewQuery.query({ sessionId, requestId, params: {} });
data[requestId] = result.data;
status[requestId] = { loading: false, ...result };
} catch (error) {
data[request.id] = null;
status[request.id] = {
data[requestId] = null;
status[requestId] = {
loading: false,
ok: false,
error: error instanceof Error ? error.message : "Request failed",
@@ -108,3 +105,7 @@ export async function loadPreviewQueries(
}
return { data, status };
}
export function getDefinitionDefaults(definition: HomarrCustomWidgetV2) {
return getCustomWidgetDefaultOptions(definition.options);
}
@@ -45,6 +45,10 @@
}
@media (max-width: 74.9375em) {
.sectionNav {
top: 3.5rem;
}
.workbench[data-mobile-pane="configure"] .previewPane,
.workbench[data-mobile-pane="preview"] .configuration {
display: none;
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { Box, Button, Paper, SegmentedControl, Stack, TextInput, Textarea } from "@mantine/core";
import { Accordion, Box, Button, Paper, SegmentedControl, Stack, TextInput, Textarea } from "@mantine/core";
import {
IconAlertCircle,
IconApi,
@@ -17,8 +17,8 @@ import {
analyzeRequestManifest,
customWidgetFormSchema,
DEFAULT_CUSTOM_WIDGET_FORM_VALUES,
renameCustomWidgetRequest,
} from "@homarr/custom-widgets/workbench";
import { customWidgetOptionsSchema, getCustomWidgetDefaultOptions } from "@homarr/custom-widgets/core";
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
import { useZodForm } from "@homarr/form";
import { IconPicker } from "@homarr/forms-collection";
@@ -26,17 +26,16 @@ import { useScopedI18n } from "@homarr/translation/client";
import { CodeEditor } from "./_code-editor";
import { CustomWidgetAiCard } from "./_custom-widget-ai-card";
import { CustomWidgetAdvancedManifest } from "./_custom-widget-advanced-manifest";
import { createCustomWidgetCompletions, getInvalidCustomWidgetSections } from "./_custom-widget-form-analysis";
import { EditorSection, SaveActions } from "./_custom-widget-form-layout";
import {
customWidgetDefaultOptionsReference,
customWidgetOptionsSchemaReference,
customWidgetRequestReference,
} from "./_custom-widget-form-references";
import { applyDefinition, buildDefinition, isRecord, parseJsonArray } from "./_custom-widget-form-utils";
import { customWidgetOptionsSchemaReference, customWidgetRequestReference } from "./_custom-widget-form-references";
import { buildDefinition, getDefinitionDefaults, isRecord, parseJson } from "./_custom-widget-form-utils";
import { CustomWidgetOptionsEditor } from "./_custom-widget-options-editor";
import { CustomWidgetPreviewPanel } from "./_custom-widget-preview-panel";
import type { PreviewState } from "./_custom-widget-preview-panel";
import { CustomWidgetRequestTools } from "./_custom-widget-request-tools";
import { CustomWidgetRequestsEditor } from "./_custom-widget-requests-editor";
import { createCustomWidgetRenameHandlers } from "./_custom-widget-rename-handlers";
import { CustomWidgetSaveIssuesAlert } from "./_custom-widget-save-issues-alert";
import { CustomWidgetSourcesEditor } from "./_custom-widget-sources-editor";
import { useCustomWidgetFormActions } from "./_use-custom-widget-form-actions";
@@ -70,17 +69,20 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
const [previewTheme, setPreviewTheme] = useState<"light" | "dark">("dark");
const [optionsSnapshot, setOptionsSnapshot] = useState<Record<string, unknown>>(() => {
const initialDefinition = buildDefinition(formInitialValues);
return initialDefinition.success ? initialDefinition.data.defaultOptions : {};
return initialDefinition.success ? getDefinitionDefaults(initialDefinition.data) : {};
});
const [revealedRequest, setRevealedRequest] = useState({ text: "", key: 0 });
const dirtyRef = useRef(false);
dirtyRef.current = form.isDirty();
const candidate = useMemo(() => buildDefinition(form.values), [form.values]);
const parsedOptions = useMemo(
() => customWidgetOptionsSchema.safeParse(parseJson(form.values.options)),
[form.values.options],
);
const requestIds = useMemo(
() =>
parseJsonArray(form.values.requests).flatMap((entry) =>
isRecord(entry) && typeof entry.id === "string" ? [entry.id] : [],
Object.keys(
isRecord(parseJson(form.values.requests)) ? (parseJson(form.values.requests) as Record<string, unknown>) : {},
),
[form.values.requests],
);
@@ -122,16 +124,16 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
window.addEventListener("beforeunload", beforeUnload);
return () => window.removeEventListener("beforeunload", beforeUnload);
}, []);
useEffect(() => {
if (parsedOptions.success) setOptionsSnapshot(getCustomWidgetDefaultOptions(parsedOptions.data));
}, [parsedOptions]);
const invalid = hasDiagnostics || !candidate.success;
const renameRequest = (currentId: string, nextId: string) => {
if (!candidate.success) throw new Error(w("invalidWidget"));
applyDefinition(form, renameCustomWidgetRequest(candidate.data, currentId, nextId));
setRevealedRequest((current) => ({ text: `"id": "${nextId}"`, key: current.key + 1 }));
};
const revealRequest = (requestId: string) => {
setRevealedRequest((current) => ({ text: `"id": "${requestId}"`, key: current.key + 1 }));
};
const { renameRequest, renameOption } = createCustomWidgetRenameHandlers({
form,
candidate,
invalidWidgetMessage: w("invalidWidget"),
});
return (
<form onSubmit={save} className={classes.form}>
<SegmentedControl
@@ -173,6 +175,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
onDocumentationUrlChange={setDocumentationUrl}
onPaste={() => void pasteAiResponse()}
/>
<CustomWidgetAdvancedManifest form={form} />
<EditorSection id="general" title={w("generalInformation")} icon={IconSettings}>
<TextInput label={t("field.name")} required {...form.getInputProps("name")} />
<Textarea label={t("field.description")} autosize minRows={2} {...form.getInputProps("description")} />
@@ -182,41 +185,45 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
<CustomWidgetSourcesEditor form={form} definitionId={definitionId} />
</EditorSection>
<EditorSection id="requests" title={w("requests.title")} icon={IconDatabase}>
<CustomWidgetRequestTools requestIds={requestIds} onGoTo={revealRequest} onRename={renameRequest} />
<CodeEditor
id="requests-editor"
label={w("requests.label")}
description={w("requests.description")}
language="json"
value={form.values.requests}
onChange={(value) => form.setFieldValue("requests", value)}
diagnostics={requestDiagnostics}
error={form.errors.requests}
revealText={revealedRequest.text}
revealKey={revealedRequest.key}
reference={customWidgetRequestReference}
required
/>
<CustomWidgetRequestsEditor form={form} onRename={renameRequest} />
<Accordion variant="contained">
<Accordion.Item value="raw">
<Accordion.Control>{w("builder.advancedJson")}</Accordion.Control>
<Accordion.Panel>
<CodeEditor
id="requests-editor"
label={w("requests.label")}
description={w("requests.description")}
language="json"
value={form.values.requests}
onChange={(value) => form.setFieldValue("requests", value)}
diagnostics={requestDiagnostics}
error={form.errors.requests}
reference={customWidgetRequestReference}
required
/>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</EditorSection>
<EditorSection id="options" title={w("options.title")} icon={IconBraces}>
<CodeEditor
id="options-schema"
label={w("options.schema")}
language="json"
value={form.values.optionsSchema}
onChange={(value) => form.setFieldValue("optionsSchema", value)}
error={form.errors.optionsSchema}
reference={customWidgetOptionsSchemaReference}
/>
<CodeEditor
id="default-options"
label={w("options.defaults")}
language="json"
value={form.values.defaultOptions}
onChange={(value) => form.setFieldValue("defaultOptions", value)}
error={form.errors.defaultOptions}
reference={customWidgetDefaultOptionsReference}
/>
<CustomWidgetOptionsEditor form={form} onRename={renameOption} />
<Accordion variant="contained">
<Accordion.Item value="raw">
<Accordion.Control>{w("builder.advancedJson")}</Accordion.Control>
<Accordion.Panel>
<CodeEditor
id="options"
label={w("options.title")}
language="json"
value={form.values.options}
onChange={(value) => form.setFieldValue("options", value)}
error={form.errors.options}
reference={customWidgetOptionsSchemaReference}
/>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</EditorSection>
<EditorSection id="jsx" title={w("jsx.title")} icon={IconCode}>
<CodeEditor
@@ -0,0 +1,48 @@
"use client";
import { useEffect, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import { TextInput } from "@mantine/core";
export function CustomWidgetIdentifierInput({
label,
value,
error,
disabled,
style,
onCommit,
}: {
label: string;
value: string;
error?: ReactNode;
disabled?: boolean;
style?: CSSProperties;
onCommit(value: string): void;
}) {
const [draft, setDraft] = useState(value);
useEffect(() => setDraft(value), [value]);
const commit = () => {
if (draft !== value) onCommit(draft);
};
return (
<TextInput
label={label}
value={draft}
error={error}
disabled={disabled}
style={style}
onChange={(event) => setDraft(event.currentTarget.value)}
onBlur={commit}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
event.currentTarget.blur();
}
if (event.key === "Escape") {
event.preventDefault();
setDraft(value);
}
}}
/>
);
}
@@ -50,7 +50,8 @@ function CustomWidgetCard({ widget }: { widget: WidgetDef }) {
toggleMutation.mutate(
{ id: widget.id, enabled: !widget.enabled },
{
onSuccess: () => {
onSuccess: async () => {
await utils.widget.customApi.getData.cancel();
void utils.customWidget.list.invalidate();
void utils.widget.customApi.getData.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
@@ -0,0 +1,196 @@
"use client";
import { useEffect, useState } from "react";
import { ActionIcon, Button, Group, MultiSelect, NumberInput, Select, Stack, Switch, TextInput } from "@mantine/core";
import { IconPlus, IconTrash } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { CodeEditor } from "./_code-editor";
export function DefaultValueEditor({
id,
control,
value,
choices = [],
onChange,
}: {
id: string;
control: string;
value: unknown;
choices?: Array<{ label: string; value: string | number }>;
onChange: (value: unknown) => void;
}) {
const t = useScopedI18n("customWidget.workbench.builder");
if (control === "switch")
return (
<Switch
label={t("default")}
checked={value === true}
onChange={(event) => onChange(event.currentTarget.checked)}
/>
);
if (["number", "slider", "duration"].includes(control))
return <NumberInput label={t("default")} value={typeof value === "number" ? value : 0} onChange={onChange} />;
const choiceData = choices.map((choice) => ({ label: choice.label, value: String(choice.value) }));
if (control === "select" && choices.length > 0)
return (
<Select
label={t("default")}
data={choiceData}
value={value === undefined ? null : String(value)}
onChange={(next) => onChange(coerceChoice(next, choices))}
/>
);
if (control === "multiSelect" && choices.length > 0)
return (
<MultiSelect
label={t("default")}
data={choiceData}
value={Array.isArray(value) ? value.map(String) : []}
onChange={(next) => onChange(next.map((entry) => coerceChoice(entry, choices)))}
/>
);
if (control === "json" || control === "multiSelect")
return <JsonValueEditor id={id} label={t("default")} value={value} onChange={onChange} />;
return (
<TextInput
label={t("default")}
value={typeof value === "string" ? value : ""}
onChange={(event) => onChange(event.currentTarget.value)}
/>
);
}
export function optionForControl(option: Record<string, unknown>, control: string | null): Record<string, unknown> {
const nextControl = control ?? "text";
const next: Record<string, unknown> = { ...option, control: nextControl, default: defaultForControl(nextControl) };
if (!["select", "multiSelect"].includes(nextControl)) {
next.choices = undefined;
next.choicesFrom = undefined;
}
if (!["number", "slider", "duration"].includes(nextControl)) {
next.min = undefined;
next.max = undefined;
next.step = undefined;
}
return next;
}
export function OptionChoicesEditor({
value,
onChange,
}: {
value: Array<{ label: string; value: string | number }>;
onChange(value: Array<{ label: string; value: string | number }>): void;
}) {
const t = useScopedI18n("customWidget.workbench.builder");
return (
<Stack gap="xs">
{value.map((choice, index) => (
<Group key={index} align="end" wrap="wrap">
<TextInput
style={{ flex: "1 1 10rem" }}
label={t("choiceLabel")}
value={choice.label}
onChange={(event) =>
onChange(
value.map((entry, entryIndex) =>
entryIndex === index ? { ...entry, label: event.currentTarget.value } : entry,
),
)
}
/>
<TextInput
style={{ flex: "1 1 10rem" }}
label={t("choiceValue")}
value={String(choice.value)}
onChange={(event) =>
onChange(
value.map((entry, entryIndex) =>
entryIndex === index ? { ...entry, value: parseChoiceValue(event.currentTarget.value) } : entry,
),
)
}
/>
<ActionIcon
type="button"
color="red"
variant="subtle"
aria-label={t("removeChoice")}
onClick={() => onChange(value.filter((_, entryIndex) => entryIndex !== index))}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
))}
<Button
type="button"
size="compact-sm"
variant="subtle"
leftSection={<IconPlus size={14} />}
onClick={() => onChange([...value, { label: t("newChoice"), value: `choice-${value.length + 1}` }])}
>
{t("addChoice")}
</Button>
</Stack>
);
}
export function JsonValueEditor({
id,
label,
description,
value,
onChange,
}: {
id: string;
label: string;
description?: string;
value: unknown;
onChange(value: unknown): void;
}) {
const t = useScopedI18n("customWidget.workbench.builder");
const serialized = JSON.stringify(value, null, 2);
const [draft, setDraft] = useState(serialized);
const [error, setError] = useState<string>();
useEffect(() => setDraft(serialized), [serialized]);
return (
<CodeEditor
id={`option-${id}`}
label={label}
description={description}
language="json"
value={draft}
height="140px"
error={error}
onChange={(next) => {
setDraft(next);
try {
onChange(JSON.parse(next) as unknown);
setError(undefined);
} catch (cause) {
setError(cause instanceof Error ? cause.message : t("invalidJson"));
}
}}
/>
);
}
function defaultForControl(control: string | null) {
if (control === "switch") return false;
if (["number", "slider", "duration"].includes(control ?? "")) return 0;
if (control === "multiSelect") return [];
if (control === "json") return {};
return "";
}
function parseChoiceValue(value: string): string | number {
return /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/u.test(value.trim()) ? Number(value) : value;
}
function coerceChoice(value: string | null, choices: Array<{ value: string | number }>) {
if (value === null) return "";
const numeric = choices.find((choice) => typeof choice.value === "number" && String(choice.value) === value);
return numeric?.value ?? value;
}
@@ -0,0 +1,271 @@
"use client";
import {
Accordion,
ActionIcon,
Button,
Fieldset,
Group,
NumberInput,
Select,
Stack,
Switch,
Text,
TextInput,
} from "@mantine/core";
import { IconArrowDown, IconArrowUp, IconPlus, IconTrash } from "@tabler/icons-react";
import { customWidgetOptionControls } from "@homarr/custom-widgets/core";
import { useScopedI18n } from "@homarr/translation/client";
import { isRecord, parseJson } from "./_custom-widget-form-utils";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
import { DefaultValueEditor, OptionChoicesEditor, optionForControl } from "./_custom-widget-option-fields";
import { CustomWidgetIdentifierInput } from "./_custom-widget-identifier-input";
export function CustomWidgetOptionsEditor({
form,
onRename,
}: {
form: CustomWidgetWorkbenchForm;
onRename(currentName: string, nextName: string): void;
}) {
const t = useScopedI18n("customWidget.workbench.builder");
const parsed = parseJson(form.values.options);
const entries = isRecord(parsed) ? Object.entries(parsed) : [];
const requests = parseJson(form.values.requests);
const requestIds = isRecord(requests) ? Object.keys(requests) : [];
const commit = (next: Array<[string, unknown]>) =>
form.setFieldValue("options", JSON.stringify(Object.fromEntries(next), null, 2));
const update = (index: number, id: string, option: Record<string, unknown>) =>
commit(entries.map((entry, i) => (i === index ? [id, option] : entry)));
const remove = (index: number) => commit(entries.filter((_, i) => i !== index));
const move = (index: number, direction: -1 | 1) => {
const next = [...entries];
const target = index + direction;
if (!next[index] || !next[target]) return;
[next[index], next[target]] = [next[target], next[index]];
commit(next);
};
const add = () => {
let suffix = entries.length + 1;
while (entries.some(([id]) => id === `option-${suffix}`)) suffix += 1;
commit([...entries, [`option-${suffix}`, { label: `Option ${suffix}`, control: "text", default: "" }]]);
};
return (
<Stack gap="sm">
{entries.map(([id, rawOption], index) => {
const option = isRecord(rawOption) ? rawOption : {};
const control = typeof option.control === "string" ? option.control : "text";
const choicesFrom = isRecord(option.choicesFrom) ? option.choicesFrom : null;
const choices = Array.isArray(option.choices)
? option.choices.flatMap((choice) =>
isRecord(choice) &&
typeof choice.label === "string" &&
(typeof choice.value === "string" || typeof choice.value === "number")
? [{ label: choice.label, value: choice.value }]
: [],
)
: [];
return (
<Fieldset key={index} legend={typeof option.label === "string" ? option.label : id}>
<Stack gap="sm">
<Group grow align="start">
<CustomWidgetIdentifierInput
label={t("optionName")}
value={id}
error={form.errors.options}
onCommit={(value) => onRename(id, value)}
/>
<TextInput
label={t("label")}
value={typeof option.label === "string" ? option.label : ""}
onChange={(event) => update(index, id, { ...option, label: event.currentTarget.value })}
/>
<Select
label={t("control")}
data={[...customWidgetOptionControls]}
value={control}
allowDeselect={false}
onChange={(value) => update(index, id, optionForControl(option, value))}
/>
</Group>
<TextInput
label={t("description")}
value={typeof option.description === "string" ? option.description : ""}
onChange={(event) =>
update(index, id, { ...option, description: event.currentTarget.value || undefined })
}
/>
<DefaultValueEditor
id={`${index}-default`}
control={control}
value={option.default}
choices={choices}
onChange={(value) => update(index, id, { ...option, default: value })}
/>
<Accordion variant="contained">
<Accordion.Item value="advanced">
<Accordion.Control>{t("optionDetails")}</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
{["number", "slider", "duration"].includes(control) && (
<Group grow>
<NumberInput
label={t("minimum")}
value={typeof option.min === "number" ? option.min : ""}
onChange={(value) =>
update(index, id, { ...option, min: typeof value === "number" ? value : undefined })
}
/>
<NumberInput
label={t("maximum")}
value={typeof option.max === "number" ? option.max : ""}
onChange={(value) =>
update(index, id, { ...option, max: typeof value === "number" ? value : undefined })
}
/>
<NumberInput
label={t("step")}
value={typeof option.step === "number" ? option.step : ""}
onChange={(value) =>
update(index, id, { ...option, step: typeof value === "number" ? value : undefined })
}
/>
</Group>
)}
{["select", "multiSelect"].includes(control) && (
<OptionChoicesEditor
value={choices}
onChange={(nextChoices) =>
update(index, id, {
...option,
choices: nextChoices.length > 0 ? nextChoices : undefined,
choicesFrom: undefined,
default:
control === "select" &&
!nextChoices.some((choice) => Object.is(choice.value, option.default))
? (nextChoices[0]?.value ?? "")
: option.default,
})
}
/>
)}
{["select", "multiSelect"].includes(control) && (
<Group grow align="start">
<Select
label={t("choicesRequest")}
data={requestIds}
value={typeof choicesFrom?.request === "string" ? choicesFrom.request : null}
clearable
onChange={(value) =>
update(index, id, {
...option,
choices: value ? undefined : option.choices,
choicesFrom: value ? { request: value, valuePath: "id", labelPath: "name" } : undefined,
})
}
/>
{choicesFrom && (
<>
<TextInput
label={t("itemsPath")}
placeholder={t("itemsPathPlaceholder")}
value={String(choicesFrom.itemsPath ?? "")}
onChange={(event) =>
update(index, id, {
...option,
choicesFrom: { ...choicesFrom, itemsPath: event.currentTarget.value || undefined },
})
}
/>
<TextInput
label={t("valuePath")}
value={String(choicesFrom.valuePath ?? "")}
onChange={(event) =>
update(index, id, {
...option,
choicesFrom: { ...choicesFrom, valuePath: event.currentTarget.value },
})
}
/>
<TextInput
label={t("labelPath")}
value={String(choicesFrom.labelPath ?? "")}
onChange={(event) =>
update(index, id, {
...option,
choicesFrom: { ...choicesFrom, labelPath: event.currentTarget.value },
})
}
/>
</>
)}
</Group>
)}
<Group grow>
<TextInput
label={t("group")}
value={typeof option.group === "string" ? option.group : ""}
onChange={(event) =>
update(index, id, { ...option, group: event.currentTarget.value || undefined })
}
/>
<Switch
mt="xl"
label={t("advancedOption")}
checked={option.advanced === true}
onChange={(event) => update(index, id, { ...option, advanced: event.currentTarget.checked })}
/>
</Group>
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
<Group justify="space-between">
<Group gap="xs">
<ActionIcon
type="button"
variant="subtle"
aria-label={t("moveOptionUp")}
disabled={index === 0}
onClick={() => move(index, -1)}
>
<IconArrowUp size={16} />
</ActionIcon>
<ActionIcon
type="button"
variant="subtle"
aria-label={t("moveOptionDown")}
disabled={index === entries.length - 1}
onClick={() => move(index, 1)}
>
<IconArrowDown size={16} />
</ActionIcon>
</Group>
<Button
type="button"
color="red"
variant="subtle"
leftSection={<IconTrash size={16} />}
onClick={() => remove(index)}
>
{t("removeOption")}
</Button>
</Group>
</Stack>
</Fieldset>
);
})}
<Button type="button" variant="light" leftSection={<IconPlus size={16} />} onClick={add}>
{t("addOption")}
</Button>
{form.errors.options && (
<Text c="red" size="xs">
{form.errors.options}
</Text>
)}
</Stack>
);
}
@@ -4,7 +4,8 @@ import { useState } from "react";
import { Button, Fieldset, Stack, Textarea } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { collectCustomWidgetRequestReferences, getCustomWidgetConfirmation } from "@homarr/custom-widgets/core";
import type { CustomJsxRequest } from "@homarr/custom-widgets/core";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
@@ -15,31 +16,28 @@ export function PreviewActionControl({
request,
sessionId,
}: {
request: HomarrCustomWidgetV2["requests"][number];
request: CustomJsxRequest & { id: string };
sessionId?: string;
}) {
const t = useScopedI18n("customWidget.workbench.preview");
const { openConfirmModal } = useConfirmModal();
const actionMutation = clientApi.customWidget.previewAction.useMutation();
const initialParams = Object.fromEntries(
Object.entries(request.parameters).map(([name, type]) => [
name,
type === "number" ? 0 : type === "boolean" ? false : "",
]),
);
const expectedParams = [...collectCustomWidgetRequestReferences(request).params].toSorted();
const initialParams = Object.fromEntries(expectedParams.map((name) => [name, ""]));
const [params, setParams] = useState(JSON.stringify(initialParams, null, 2));
const parsed = parseJson(params);
const validParams = isRuntimeParams(parsed, request.parameters) ? parsed : null;
const validParams = isRuntimeParams(parsed, expectedParams) ? parsed : null;
const confirmation = getCustomWidgetConfirmation(request);
const run = async () => {
if (!sessionId || !validParams) return;
const needsConfirmation = request.confirmation !== undefined || request.method === "DELETE";
if (needsConfirmation) {
const confirmed = await new Promise<boolean>((resolve) => {
openConfirmModal({
title: request.confirmation?.title ?? request.id,
children: request.confirmation?.message ?? t("deleteConfirmation"),
labels: { confirm: request.confirmation?.confirmLabel },
confirmProps: { color: request.confirmation?.destructive || request.method === "DELETE" ? "red.9" : "blue" },
title: confirmation?.title ?? request.id,
children: confirmation?.message ?? t("deleteConfirmation"),
labels: { confirm: confirmation?.confirmLabel },
confirmProps: { color: confirmation?.destructive || request.method === "DELETE" ? "red.9" : "blue" },
onConfirm: () => resolve(true),
onCancel: () => resolve(false),
});
@@ -61,7 +59,7 @@ export function PreviewActionControl({
return (
<Fieldset legend={`${request.id} · ${request.method}`}>
<Stack gap="xs">
{Object.keys(request.parameters).length > 0 && (
{expectedParams.length > 0 && (
<Textarea
label={t("actionParameters")}
autosize
@@ -74,13 +72,13 @@ export function PreviewActionControl({
<Button
type="button"
variant="light"
color={request.confirmation?.destructive || request.method === "DELETE" ? "red" : undefined}
color={confirmation?.destructive || request.method === "DELETE" ? "red" : undefined}
disabled={!sessionId || !validParams}
loading={actionMutation.isPending}
onClick={() => void run()}
>
{request.confirmation?.confirmLabel ??
(request.confirmation?.destructive || request.method === "DELETE" ? t("runDestructive") : t("runAction"))}
{confirmation?.confirmLabel ??
(confirmation?.destructive || request.method === "DELETE" ? t("runDestructive") : t("runAction"))}
</Button>
</Stack>
</Fieldset>
@@ -20,32 +20,29 @@ export function createPreviewDisplayData({
fixtureError: string;
}): Record<string, unknown> | null {
if (!candidate) return null;
const data =
fixture === "empty" ? Object.fromEntries(candidate.requests.map((entry) => [entry.id, []])) : preview.data;
const requests = Object.entries(candidate.requests);
const data = fixture === "empty" ? Object.fromEntries(requests.map(([id]) => [id, []])) : preview.data;
const status =
fixture === "loading"
? Object.fromEntries(candidate.requests.map((entry) => [entry.id, { loading: true }]))
? Object.fromEntries(requests.map(([id]) => [id, { loading: true }]))
: fixture === "error"
? Object.fromEntries(
candidate.requests.map((entry) => [entry.id, { loading: false, ok: false, error: fixtureError }]),
)
? Object.fromEntries(requests.map(([id]) => [id, { loading: false, ok: false, error: fixtureError }]))
: preview.status;
return {
template: candidate.template,
data,
status,
options,
requestCapabilities: candidate.requests.map(
({ id, kind, method, trigger, minimumBoardPermission, confirmation, invalidates }) => ({
id,
kind,
method,
trigger,
minimumBoardPermission,
confirmation,
invalidates,
}),
),
requestCapabilities: requests.map(([id, { kind, method, trigger, permission, confirmation, invalidates }]) => ({
id,
kind,
method,
trigger,
minimumBoardPermission: permission,
confirmation:
typeof confirmation === "string" ? { title: "Confirm action", message: confirmation } : confirmation,
invalidates,
})),
previewSessionId: preview.session?.id,
previewLiveActions: preview.session?.liveActions ?? false,
queriesDisabled: fixture !== "live",
@@ -6,7 +6,7 @@ import type { Root } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { BUNDLED_CUSTOM_WIDGETS } from "@homarr/custom-widgets/core";
import { BUNDLED_CUSTOM_WIDGETS, customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
import { CustomWidgetPreviewPanel } from "./_custom-widget-preview-panel";
@@ -131,7 +131,7 @@ describe("Custom Widget preview panel", () => {
rendererAttempt.mockClear();
const bundledWidget = BUNDLED_CUSTOM_WIDGETS.at(0);
if (!bundledWidget) throw new Error("Expected at least one bundled Custom Widget");
const candidate = bundledWidget.widget;
const candidate = customWidgetDefinitionSchema.parse(bundledWidget.widget);
const preview = { data: {}, status: {}, session: null, outcome: "idle" } as const;
const failingOptions = { throwPreview: true };
@@ -141,7 +141,9 @@ describe("Custom Widget preview panel", () => {
<CustomWidgetPreviewPanel
candidate={{
...candidate,
requests: candidate.requests.map((request) => ({ ...request })),
requests: Object.fromEntries(
Object.entries(candidate.requests).map(([id, request]) => [id, { ...request }]),
),
}}
validationIssues={[]}
preview={preview}
@@ -207,12 +207,12 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
);
}}
/>
{candidate?.requests
.filter((request) => request.kind === "action")
.map((request) => (
<PreviewActionControl key={request.id} request={request} sessionId={props.preview.session?.id} />
{Object.entries(candidate?.requests ?? {})
.filter(([, request]) => request.kind === "action")
.map(([id, request]) => (
<PreviewActionControl key={id} request={{ id, ...request }} sessionId={props.preview.session?.id} />
))}
{candidate?.requests.every((request) => request.kind !== "action") && (
{Object.values(candidate?.requests ?? {}).every((request) => request.kind !== "action") && (
<Text size="sm" c="dimmed">
{t("noActions")}
</Text>
@@ -0,0 +1,38 @@
import { renameCustomWidgetOption, renameCustomWidgetRequest } from "@homarr/custom-widgets/workbench";
import { applyDefinition } from "./_custom-widget-form-utils";
import type { buildDefinition, CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
interface RenameHandlersInput {
form: CustomWidgetWorkbenchForm;
candidate: ReturnType<typeof buildDefinition>;
invalidWidgetMessage: string;
}
export function createCustomWidgetRenameHandlers(input: RenameHandlersInput) {
const renameRequest = (currentId: string, nextId: string) => {
if (!input.candidate.success) {
input.form.setFieldError("requests", input.invalidWidgetMessage);
return;
}
try {
applyDefinition(input.form, renameCustomWidgetRequest(input.candidate.data, currentId, nextId));
input.form.clearFieldError("requests");
} catch (error) {
input.form.setFieldError("requests", error instanceof Error ? error.message : input.invalidWidgetMessage);
}
};
const renameOption = (currentName: string, nextName: string) => {
if (!input.candidate.success) {
input.form.setFieldError("options", input.invalidWidgetMessage);
return;
}
try {
applyDefinition(input.form, renameCustomWidgetOption(input.candidate.data, currentName, nextName));
input.form.clearFieldError("options");
} catch (error) {
input.form.setFieldError("options", error instanceof Error ? error.message : input.invalidWidgetMessage);
}
};
return { renameRequest, renameOption };
}
@@ -1,75 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { Button, Group, Select, TextInput } from "@mantine/core";
import { IconEdit, IconTargetArrow } from "@tabler/icons-react";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
export function CustomWidgetRequestTools({
requestIds,
onGoTo,
onRename,
}: {
requestIds: string[];
onGoTo(requestId: string): void;
onRename(currentId: string, nextId: string): void;
}) {
const t = useScopedI18n("customWidget.workbench.requests");
const [selected, setSelected] = useState(requestIds[0] ?? "");
const [nextId, setNextId] = useState(requestIds[0] ?? "");
useEffect(() => {
if (!requestIds.includes(selected)) {
setSelected(requestIds[0] ?? "");
setNextId(requestIds[0] ?? "");
}
}, [requestIds, selected]);
if (requestIds.length === 0) return null;
const rename = () => {
try {
onRename(selected, nextId);
setSelected(nextId);
} catch (error) {
showErrorNotification({
title: t("rename"),
message: error instanceof Error ? error.message : t("renameFailed"),
});
}
};
return (
<Group align="end" grow wrap="wrap">
<Select
label={t("selected")}
data={requestIds}
value={selected}
onChange={(value) => {
setSelected(value ?? "");
setNextId(value ?? "");
}}
searchable
/>
<Button
type="button"
variant="light"
leftSection={<IconTargetArrow size={16} />}
onClick={() => onGoTo(selected)}
>
{t("goTo")}
</Button>
<TextInput label={t("newId")} value={nextId} onChange={(event) => setNextId(event.currentTarget.value)} />
<Button
type="button"
variant="light"
leftSection={<IconEdit size={16} />}
disabled={!nextId || nextId === selected}
onClick={rename}
>
{t("rename")}
</Button>
</Group>
);
}
@@ -0,0 +1,228 @@
"use client";
import { useEffect, useState } from "react";
import { ActionIcon, Button, Group, Select, Stack, Text, TextInput } from "@mantine/core";
import { IconPlus, IconTrash } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { CodeEditor } from "./_code-editor";
import { isRecord } from "./_custom-widget-form-utils";
import { CustomWidgetIdentifierInput } from "./_custom-widget-identifier-input";
export function QueryValuesEditor({
value,
optionNames,
allowParams,
onChange,
}: {
value: Record<string, unknown>;
optionNames: string[];
allowParams: boolean;
onChange(value: Record<string, unknown>): void;
}) {
const t = useScopedI18n("customWidget.workbench.builder");
const entries = Object.entries(value);
const [nameErrors, setNameErrors] = useState<Record<number, string>>({});
return (
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("queryValues")}
</Text>
{entries.map(([name, bound], index) => {
const kind =
isRecord(bound) && typeof bound.$option === "string"
? "option"
: isRecord(bound) && typeof bound.$param === "string"
? "param"
: "literal";
const current =
kind === "option"
? String((bound as Record<string, unknown>).$option)
: kind === "param"
? String((bound as Record<string, unknown>).$param)
: String(bound ?? "");
const commit = (nextName: string, nextKind: string, nextValue: string) =>
onChange(
Object.fromEntries(
entries.map(([key, child], i) =>
i === index
? [
nextName,
nextKind === "option"
? { $option: nextValue }
: nextKind === "param"
? { $param: nextValue }
: parseLiteral(nextValue),
]
: [key, child],
),
),
);
const sourceChoices = [
{ value: "literal", label: t("literal") },
{ value: "option", label: t("widgetOption") },
...(allowParams ? [{ value: "param", label: t("invocationParameter") }] : []),
];
return (
<Group key={index} align="end" wrap="wrap">
<CustomWidgetIdentifierInput
style={{ flex: "1 1 10rem" }}
label={t("queryKey")}
value={name}
error={nameErrors[index]}
onCommit={(nextName) => {
if (
!/^[A-Za-z][A-Za-z0-9_-]*$/u.test(nextName) ||
entries.some(([entryName], entryIndex) => entryIndex !== index && entryName === nextName)
) {
setNameErrors((currentErrors) => ({ ...currentErrors, [index]: t("queryKeyInvalid") }));
return;
}
setNameErrors((currentErrors) => {
const nextErrors = { ...currentErrors };
delete nextErrors[index];
return nextErrors;
});
commit(nextName, kind, current);
}}
/>
<Select
style={{ flex: "1 1 10rem" }}
label={t("valueSource")}
data={sourceChoices}
value={kind}
allowDeselect={false}
onChange={(nextKind) => commit(name, nextKind ?? "literal", current)}
/>
{kind === "option" ? (
<Select
style={{ flex: "1 1 10rem" }}
label={t("option")}
data={optionNames}
value={current}
searchable
onChange={(next) => commit(name, kind, next ?? "")}
/>
) : (
<TextInput
style={{ flex: "1 1 10rem" }}
label={kind === "param" ? t("parameterName") : t("value")}
value={current}
onChange={(event) => commit(name, kind, event.currentTarget.value)}
/>
)}
<ActionIcon
type="button"
color="red"
variant="subtle"
aria-label={t("removeQueryValue")}
onClick={() => onChange(Object.fromEntries(entries.filter((_, i) => i !== index)))}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
);
})}
<Button
type="button"
size="compact-sm"
variant="subtle"
leftSection={<IconPlus size={14} />}
onClick={() => onChange({ ...value, [`value${entries.length + 1}`]: "" })}
>
{t("addQueryValue")}
</Button>
</Stack>
);
}
export function RequestBodyEditor({
id,
value,
optionNames,
allowParams,
onChange,
}: {
id: string;
value: unknown;
optionNames: string[];
allowParams: boolean;
onChange(value: unknown): void;
}) {
const t = useScopedI18n("customWidget.workbench.builder");
const serialized = value === undefined ? "" : JSON.stringify(value, null, 2);
const [draft, setDraft] = useState(serialized);
const [error, setError] = useState<string>();
const [optionName, setOptionName] = useState<string | null>(optionNames[0] ?? null);
const [parameterName, setParameterName] = useState("");
const [insertion, setInsertion] = useState({ text: "", key: 0 });
useEffect(() => setDraft(serialized), [serialized]);
return (
<Stack gap="xs">
<CodeEditor
id={`${id}-request-body-editor`}
label={t("jsonBody")}
description={t("jsonBodyDescription")}
language="json"
value={draft}
height="160px"
error={error}
insertText={insertion.text}
insertKey={insertion.key}
onChange={(next) => {
setDraft(next);
try {
onChange(next.trim() ? (JSON.parse(next) as unknown) : undefined);
setError(undefined);
} catch (cause) {
setError(cause instanceof Error ? cause.message : t("invalidJson"));
}
}}
/>
<Group align="end" wrap="wrap">
<Select label={t("option")} data={optionNames} value={optionName} searchable onChange={setOptionName} />
<Button
type="button"
variant="light"
disabled={!optionName}
onClick={() =>
optionName &&
setInsertion((current) => ({ text: JSON.stringify({ $option: optionName }), key: current.key + 1 }))
}
>
{t("insertOptionReference")}
</Button>
{allowParams && (
<>
<TextInput
label={t("parameterName")}
value={parameterName}
onChange={(event) => setParameterName(event.currentTarget.value)}
/>
<Button
type="button"
variant="light"
disabled={!parameterName.trim()}
onClick={() =>
setInsertion((current) => ({
text: JSON.stringify({ $param: parameterName.trim() }),
key: current.key + 1,
}))
}
>
{t("insertParameterReference")}
</Button>
</>
)}
</Group>
</Stack>
);
}
function parseLiteral(value: string): string | number | boolean {
if (value === "true") return true;
if (value === "false") return false;
if (/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/u.test(value.trim())) return Number(value);
return value;
}
@@ -0,0 +1,232 @@
"use client";
import { Accordion, ActionIcon, Button, Fieldset, Group, Select, Stack, Switch, Text, TextInput } from "@mantine/core";
import { IconArrowDown, IconArrowUp, IconPlus, IconTrash } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { isRecord, parseJson } from "./_custom-widget-form-utils";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
import { QueryValuesEditor, RequestBodyEditor } from "./_custom-widget-request-value-editors";
import { CustomWidgetIdentifierInput } from "./_custom-widget-identifier-input";
const methods = ["GET", "POST", "PUT", "PATCH", "DELETE"];
export function CustomWidgetRequestsEditor({
form,
onRename,
}: {
form: CustomWidgetWorkbenchForm;
onRename(currentId: string, nextId: string): void;
}) {
const t = useScopedI18n("customWidget.workbench.builder");
const parsed = parseJson(form.values.requests);
const entries = isRecord(parsed) ? Object.entries(parsed) : [];
const sources = parseJson(form.values.sources);
const sourceIds = isRecord(sources) ? Object.keys(sources) : ["default"];
const commit = (next: Array<[string, unknown]>) =>
form.setFieldValue("requests", JSON.stringify(Object.fromEntries(next), null, 2));
const update = (index: number, id: string, request: Record<string, unknown>) =>
commit(entries.map((entry, i) => (i === index ? [id, request] : entry)));
const remove = (index: number) => commit(entries.filter((_, i) => i !== index));
const move = (index: number, direction: -1 | 1) => {
const next = [...entries];
const target = index + direction;
if (!next[index] || !next[target]) return;
[next[index], next[target]] = [next[target], next[index]];
commit(next);
};
const add = () => {
let suffix = entries.length + 1;
while (entries.some(([id]) => id === `request-${suffix}`)) suffix += 1;
commit([...entries, [`request-${suffix}`, { path: "/api/data" }]]);
};
return (
<Stack gap="sm">
{entries.map(([id, rawRequest], index) => {
const request = isRecord(rawRequest) ? rawRequest : {};
const kind = request.kind === "action" ? "action" : "query";
const trigger = request.trigger === "manual" ? "manual" : "load";
return (
<Fieldset key={index} legend={id || t("request", { count: index + 1 })}>
<Stack gap="sm">
<Group grow align="start">
<CustomWidgetIdentifierInput
label={t("requestId")}
value={id}
error={form.errors.requests}
onCommit={(value) => onRename(id, value)}
/>
<Select
label={t("type")}
data={["query", "action"]}
value={kind}
allowDeselect={false}
onChange={(value) =>
update(index, id, {
...request,
kind: value,
...(value === "action" ? { trigger: "manual" } : {}),
})
}
/>
<Select
label={t("method")}
data={methods}
value={typeof request.method === "string" ? request.method : "GET"}
allowDeselect={false}
onChange={(value) =>
update(index, id, {
...request,
method: value,
...(value === "DELETE" ? { permission: "full" } : {}),
})
}
/>
</Group>
<Group grow align="start">
<Select
label={t("source")}
data={sourceIds}
value={typeof request.source === "string" ? request.source : "default"}
allowDeselect={false}
onChange={(value) => update(index, id, { ...request, source: value })}
/>
{kind === "query" && (
<Select
label={t("run")}
data={[
{ value: "load", label: t("runLoad") },
{ value: "manual", label: t("runManual") },
]}
value={trigger}
allowDeselect={false}
onChange={(value) => update(index, id, { ...request, trigger: value })}
/>
)}
<Select
label={t("permission")}
data={["view", "modify", "full"]}
value={
typeof request.permission === "string" ? request.permission : kind === "action" ? "modify" : "view"
}
allowDeselect={false}
onChange={(value) => update(index, id, { ...request, permission: value })}
/>
</Group>
<TextInput
label={t("path")}
description={t("pathDescription")}
value={typeof request.path === "string" ? request.path : ""}
onChange={(event) => update(index, id, { ...request, path: event.currentTarget.value })}
/>
<Accordion variant="contained">
<Accordion.Item value="values">
<Accordion.Control>{t("requestDetails")}</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
<QueryValuesEditor
value={isRecord(request.query) ? request.query : {}}
optionNames={Object.keys(
isRecord(parseJson(form.values.options))
? (parseJson(form.values.options) as Record<string, unknown>)
: {},
)}
allowParams={trigger === "manual" || kind === "action"}
onChange={(query) => update(index, id, { ...request, query })}
/>
<RequestBodyEditor
id={id}
value={request.body}
optionNames={Object.keys(
isRecord(parseJson(form.values.options))
? (parseJson(form.values.options) as Record<string, unknown>)
: {},
)}
allowParams={trigger === "manual" || kind === "action"}
onChange={(body) => update(index, id, { ...request, body })}
/>
{kind === "action" && (
<TextInput
label={t("confirmation")}
value={typeof request.confirmation === "string" ? request.confirmation : ""}
onChange={(event) =>
update(index, id, { ...request, confirmation: event.currentTarget.value || undefined })
}
/>
)}
{kind === "action" && (
<TextInput
label={t("invalidation")}
description={t("invalidationDescription")}
value={Array.isArray(request.invalidates) ? request.invalidates.join(", ") : ""}
onChange={(event) =>
update(index, id, {
...request,
invalidates: event.currentTarget.value
.split(",")
.map((value) => value.trim())
.filter(Boolean),
})
}
/>
)}
<Switch
label={t("noAuth")}
checked={request.auth === "none"}
onChange={(event) =>
update(index, id, { ...request, auth: event.currentTarget.checked ? "none" : "inherit" })
}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
<Group justify="space-between">
<Group gap="xs">
<ActionIcon
type="button"
variant="subtle"
aria-label={t("moveRequestUp")}
disabled={index === 0}
onClick={() => move(index, -1)}
>
<IconArrowUp size={16} />
</ActionIcon>
<ActionIcon
type="button"
variant="subtle"
aria-label={t("moveRequestDown")}
disabled={index === entries.length - 1}
onClick={() => move(index, 1)}
>
<IconArrowDown size={16} />
</ActionIcon>
</Group>
<Button
type="button"
color="red"
variant="subtle"
leftSection={<IconTrash size={16} />}
onClick={() => remove(index)}
>
{t("removeRequest")}
</Button>
</Group>
</Stack>
</Fieldset>
);
})}
<Button type="button" variant="light" leftSection={<IconPlus size={16} />} onClick={add}>
{t("addRequest")}
</Button>
{form.errors.requests && (
<Text c="red" size="xs">
{form.errors.requests}
</Text>
)}
</Stack>
);
}
@@ -45,13 +45,13 @@ describe("extractCustomWidgetSaveIssues", () => {
{
code: "custom",
message: "A load parameter requires an explicit binding.",
path: ["requests", 0, "optionsBinding", "endpointId"],
path: ["requests", "containers", "path"],
},
]);
expect(extractCustomWidgetSaveIssues({ message })).toEqual([
{
path: "requests.0.optionsBinding.endpointId",
path: "requests.containers.path",
message: "A load parameter requires an explicit binding.",
},
]);
@@ -9,8 +9,9 @@ import type { CustomWidgetSource, CustomWidgetSourceUrlIssue } from "@homarr/cus
import { showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { isRecord, parseJsonArray, parseSources } from "./_custom-widget-form-utils";
import { isRecord, parseJson, parseSources } from "./_custom-widget-form-utils";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
import { CustomWidgetIdentifierInput } from "./_custom-widget-identifier-input";
const secretFields: Record<string, Array<{ kind: "apiKey" | "username" | "password" }>> = {
bearer: [{ kind: "apiKey" }],
@@ -37,21 +38,38 @@ export function CustomWidgetSourcesEditor({
const utils = clientApi.useUtils();
const clearSecretMutation = clientApi.customWidget.secretClear.useMutation();
const sources = parseSources(form.values.sources);
const update = (index: number, changes: Partial<CustomWidgetSource>) => {
const update = (index: number, changes: Partial<CustomWidgetSource> & { id?: string }) => {
const previousId = sources[index]?.id;
const nextId = changes.id;
const nextId = changes.id ?? previousId;
if (nextId && sources.some((source, sourceIndex) => sourceIndex !== index && source.id === nextId)) {
form.setFieldError("sources", t("duplicateSourceId"));
return;
}
if (form.errors.sources === t("duplicateSourceId")) form.clearFieldError("sources");
const sourceEntries = sources.map(({ id, ...source }) => [id, source] as const);
form.setFieldValue(
"sources",
JSON.stringify(
sources.map((source, i) => (i === index ? { ...source, ...changes } : source)),
Object.fromEntries(
sourceEntries.map(([id, source], i) => [
i === index ? nextId : id,
i === index ? { ...source, ...changes, id: undefined } : source,
]),
),
null,
2,
),
);
if (previousId && nextId && previousId !== nextId) {
const requests = parseJsonArray(form.values.requests).map((request) =>
isRecord(request) && request.sourceId === previousId ? { ...request, sourceId: nextId } : request,
);
const parsedRequests = parseJson(form.values.requests);
const requests = isRecord(parsedRequests)
? Object.fromEntries(
Object.entries(parsedRequests).map(([id, request]) => [
id,
isRecord(request) && request.source === previousId ? { ...request, source: nextId } : request,
]),
)
: {};
form.setFieldValue("requests", JSON.stringify(requests, null, 2));
form.setFieldValue(
"secrets",
@@ -63,11 +81,7 @@ export function CustomWidgetSourcesEditor({
};
const setAuth = (index: number, type: string) => {
const auth =
type === "apiKeyHeader"
? { type, headerName: "X-API-Key" }
: type === "apiKeyQuery"
? { type, parameterName: "api_key" }
: { type };
type === "apiKeyHeader" ? { type, name: "X-API-Key" } : type === "apiKeyQuery" ? { type, name: "api_key" } : type;
update(index, { auth: auth as CustomWidgetSource["auth"] });
};
const setSecret = (sourceId: string, kind: string, value: string) => {
@@ -76,20 +90,22 @@ export function CustomWidgetSourcesEditor({
form.setFieldValue("secrets", [...current, { sourceId, kind, value, hasValue: existing?.hasValue }]);
};
const addSource = () => {
const id = `source-${sources.length + 1}`;
const id = sources.length === 0 ? "default" : `source-${sources.length + 1}`;
form.setFieldValue(
"sources",
JSON.stringify(
[
...sources,
{
Object.fromEntries([
...sources.map(({ id: sourceId, ...source }) => [sourceId, source] as const),
[
id,
name: t("newName", { count: sources.length + 1 }),
baseUrl: "https://example.com",
networkScope: "private",
auth: { type: "none" },
},
],
{
name: t("newName", { count: sources.length + 1 }),
baseUrl: "https://example.com",
networkScope: sources.length === 0 ? "public" : "private",
auth: "none",
},
],
]),
null,
2,
),
@@ -100,7 +116,7 @@ export function CustomWidgetSourcesEditor({
form.setFieldValue(
"sources",
JSON.stringify(
sources.filter((_, i) => i !== index),
Object.fromEntries(sources.filter((_, i) => i !== index).map(({ id, ...source }) => [id, source])),
null,
2,
),
@@ -109,7 +125,13 @@ export function CustomWidgetSourcesEditor({
form.setFieldValue(
"requests",
JSON.stringify(
parseJsonArray(form.values.requests).filter((request) => !isRecord(request) || request.sourceId !== removedId),
Object.fromEntries(
Object.entries(
isRecord(parseJson(form.values.requests))
? (parseJson(form.values.requests) as Record<string, unknown>)
: {},
).filter(([, request]) => !isRecord(request) || request.source !== removedId),
),
null,
2,
),
@@ -136,11 +158,12 @@ export function CustomWidgetSourcesEditor({
<Fieldset key={index} legend={index === 0 ? t("primary") : source.name}>
<Stack gap="sm">
<Group grow align="start">
<TextInput
<CustomWidgetIdentifierInput
label={t("id")}
value={source.id}
disabled={Boolean(definitionId)}
onChange={(event) => update(index, { id: event.currentTarget.value })}
disabled={source.id === "default" || Boolean(definitionId)}
error={form.errors.sources}
onCommit={(value) => update(index, { id: value })}
/>
<TextInput
label={t("name")}
@@ -168,30 +191,30 @@ export function CustomWidgetSourcesEditor({
<Select
label={t("authentication")}
data={["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"]}
value={source.auth.type}
value={typeof source.auth === "string" ? source.auth : source.auth.type}
onChange={(value) => setAuth(index, value ?? "none")}
allowDeselect={false}
/>
</Group>
{source.auth.type === "apiKeyHeader" && (
{typeof source.auth === "object" && source.auth.type === "apiKeyHeader" && (
<TextInput
label={t("headerName")}
value={source.auth.headerName}
value={source.auth.name}
onChange={(event) =>
update(index, { auth: { type: "apiKeyHeader", headerName: event.currentTarget.value } })
update(index, { auth: { type: "apiKeyHeader", name: event.currentTarget.value } })
}
/>
)}
{source.auth.type === "apiKeyQuery" && (
{typeof source.auth === "object" && source.auth.type === "apiKeyQuery" && (
<TextInput
label={t("queryParameter")}
value={source.auth.parameterName}
value={source.auth.name}
onChange={(event) =>
update(index, { auth: { type: "apiKeyQuery", parameterName: event.currentTarget.value } })
update(index, { auth: { type: "apiKeyQuery", name: event.currentTarget.value } })
}
/>
)}
{(secretFields[source.auth.type] ?? []).map((field) => {
{(secretFields[typeof source.auth === "string" ? source.auth : source.auth.type] ?? []).map((field) => {
const secret = form.values.secrets.find(
(entry) => entry.sourceId === source.id && entry.kind === field.kind,
);
@@ -19,6 +19,7 @@ import {
applyDefinition,
buildDefinition,
getChangedSecrets,
getDefinitionDefaults,
getCustomWidgetPreviewOptionIssues,
isRecord,
loadPreviewQueries,
@@ -132,7 +133,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
options: input.optionsSnapshot,
secrets: getChangedSecrets(input.form.values),
});
const snapshot = await loadPreviewQueries(input.candidate.data, created.previewSession.id, input.optionsSnapshot);
const snapshot = await loadPreviewQueries(input.candidate.data, created.previewSession.id);
const failed = Object.values(snapshot.status).filter((status) => isRecord(status) && status.ok === false).length;
input.setPreview({
...snapshot,
@@ -171,7 +172,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
throw new Error(formatCustomWidgetImportIssues(result.issues));
}
applyDefinition(input.form, customWidgetDefinitionSchema.parse(result.widget));
input.setOptionsSnapshot(result.widget.defaultOptions);
input.setOptionsSnapshot(getDefinitionDefaults(result.widget));
input.setPreview({ data: {}, status: {}, session: null, outcome: "idle" });
showSuccessNotification({
title: w("ai.response"),
@@ -38,8 +38,7 @@ export default async function EditCustomWidgetPage(props: EditCustomWidgetPagePr
iconUrl: definition.iconUrl ?? "",
sources: JSON.stringify(definition.sources, null, 2),
requests: JSON.stringify(definition.requests, null, 2),
optionsSchema: JSON.stringify(definition.optionsSchema, null, 2),
defaultOptions: JSON.stringify(definition.defaultOptions, null, 2),
options: JSON.stringify(definition.options, null, 2),
template: definition.template,
secrets: definition.secrets.map((secret) => ({
sourceId: secret.sourceId,
@@ -3,7 +3,6 @@ import { Badge, Box, Container, Group, Paper, Stack, Text, Title } from "@mantin
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { resolveCustomWidgetOptionsBinding } from "@homarr/custom-widgets/core";
import CustomJsxDisplay from "@homarr/widgets/custom-api/custom-jsx-display";
export default async function CustomWidgetPreviewPage({ params }: { params: Promise<{ id: string }> }) {
@@ -16,11 +15,8 @@ export default async function CustomWidgetPreviewPage({ params }: { params: Prom
const loadRequests = preview.requests.filter((request) => request.kind === "query" && request.trigger === "load");
const results = await Promise.all(
loadRequests.map(async (request) => {
const result = await Promise.resolve()
.then(() => resolveCustomWidgetOptionsBinding(request, preview.options))
.then((requestParams) =>
api.customWidget.previewQuery({ sessionId: id, requestId: request.id, params: requestParams }),
)
const result = await api.customWidget
.previewQuery({ sessionId: id, requestId: request.id, params: {} })
.catch((cause: unknown) => ({
ok: false,
status: 0,
@@ -22,7 +22,7 @@ import { buildCustomWidgetMcpPrompt } from "@homarr/custom-widgets/authoring-pro
import {
getCustomWidgetComponent,
getCustomWidgetComponentCatalog,
getCustomWidgetOfflineBundle,
getCustomWidgetSkillContent,
} from "@homarr/custom-widgets/authoring-resources";
import { customJsxExamples, getCustomWidgetJsonSchema } from "@homarr/custom-widgets/core";
import { db } from "@homarr/db";
@@ -252,7 +252,7 @@ const mcpHandler = createMcpHandler(
if (uri === "homarr://custom-widgets/schema") text = JSON.stringify(getCustomWidgetJsonSchema());
else if (uri === "homarr://custom-widgets/components") text = JSON.stringify(getCustomWidgetComponentCatalog());
else if (uri === "homarr://custom-widgets/skill") {
text = getCustomWidgetOfflineBundle();
text = getCustomWidgetSkillContent();
mimeType = "text/markdown";
} else if (uri.startsWith("homarr://custom-widgets/components/")) {
const name = decodeURIComponent(uri.slice("homarr://custom-widgets/components/".length));
@@ -26,7 +26,7 @@ export function CustomWidgetImportDialog({ opened, widget, onClose, onImported }
const utils = clientApi.useUtils();
const [values, setValues] = useState<Record<string, string>>({});
const review = useMemo(() => getImportReview(widget), [widget]);
const requirements = useMemo(() => getCustomWidgetSecretRequirements(widget?.sources ?? []), [widget]);
const requirements = useMemo(() => getCustomWidgetSecretRequirements(widget?.sources ?? {}), [widget]);
const mutation = clientApi.customWidget.import.useMutation({
onSuccess: (result) => {
showSuccessNotification({ title: t("action.import"), message: t("notification.imported") });
@@ -391,8 +391,8 @@ function CapabilitySummary({ widget }: { widget: HomarrCustomWidgetV2 }) {
{t("sources")}
</Text>
<Stack gap={6}>
{widget.sources.map((source) => (
<Box key={source.id}>
{Object.entries(widget.sources).map(([sourceId, source]) => (
<Box key={sourceId}>
<Text size="sm" fw={500}>
{source.name}
</Text>
@@ -404,7 +404,7 @@ function CapabilitySummary({ widget }: { widget: HomarrCustomWidgetV2 }) {
{source.networkScope}
</Badge>
<Badge size="xs" variant="outline">
{source.auth.type}
{typeof source.auth === "string" ? source.auth : source.auth.type}
</Badge>
</Group>
</Box>
@@ -415,16 +415,16 @@ function CapabilitySummary({ widget }: { widget: HomarrCustomWidgetV2 }) {
<Text fw={600} size="sm" mb="xs">
{t("requests")}
</Text>
{widget.requests.length === 0 ? (
{Object.keys(widget.requests).length === 0 ? (
<Text size="xs" c="dimmed">
{t("noRequests")}
</Text>
) : (
<Stack gap={6}>
{widget.requests.map((request) => (
<Group key={request.id} justify="space-between" wrap="nowrap">
{Object.entries(widget.requests).map(([requestId, request]) => (
<Group key={requestId} justify="space-between" wrap="nowrap">
<Text size="xs" ff="monospace" truncate>
{request.id}
{requestId}
</Text>
<Group gap={4} wrap="nowrap">
<Badge size="xs">{request.kind}</Badge>
@@ -432,7 +432,7 @@ function CapabilitySummary({ widget }: { widget: HomarrCustomWidgetV2 }) {
{request.method}
</Badge>
<Badge size="xs" variant="outline">
{request.minimumBoardPermission}
{request.permission}
</Badge>
</Group>
</Group>
+11 -13
View File
@@ -18,22 +18,20 @@ RUN --mount=type=cache,id=workshop-pnpm-store,target=/pnpm/store pnpm config set
FROM dependencies AS website
RUN CI=true SKIP_ENV_VALIDATION=true pnpm turbo build --filter=@homarr/docs
FROM ${POCKETBASE_IMAGE} AS development
ENV PORT=8090
COPY apps/workshop/entrypoint.sh /usr/local/bin/workshop-entrypoint
RUN chmod +x /usr/local/bin/workshop-entrypoint && mkdir -p /pb_data /pb_public
EXPOSE 8090
HEALTHCHECK --interval=10s --timeout=5s --start-period=15s --retries=6 CMD wget -q --spider "http://127.0.0.1:${PORT}/api/health" || exit 1
ENTRYPOINT ["workshop-entrypoint"]
FROM ${POCKETBASE_IMAGE} AS production
FROM ${POCKETBASE_IMAGE} AS pocketbase-runtime
ENV PORT=8090
RUN addgroup -S pocketbase && adduser -S -G pocketbase pocketbase
COPY --from=website /app/apps/docs/build /pb_public
COPY apps/workshop/entrypoint.sh /usr/local/bin/workshop-entrypoint
RUN chmod +x /usr/local/bin/workshop-entrypoint && mkdir -p /pb_data /pb_public && chown pocketbase:pocketbase /pb_data
ENTRYPOINT ["workshop-entrypoint"]
FROM pocketbase-runtime AS development
EXPOSE 8090
HEALTHCHECK --interval=10s --timeout=5s --start-period=15s --retries=6 CMD wget -q --spider "http://127.0.0.1:${PORT}/api/health" || exit 1
FROM pocketbase-runtime AS production
COPY --from=website /app/apps/docs/build /pb_public
COPY apps/workshop/pb_migrations /pb_migrations
RUN chmod +x /usr/local/bin/workshop-entrypoint && mkdir -p /pb_data && chown -R pocketbase:pocketbase /pb_data /pb_migrations /pb_public
USER pocketbase
RUN chown -R pocketbase:pocketbase /pb_migrations /pb_public
EXPOSE 8090
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD wget -q --spider "http://127.0.0.1:${PORT}/api/health" || exit 1
ENTRYPOINT ["workshop-entrypoint"]
+1
View File
@@ -81,3 +81,4 @@ docker compose -f apps/workshop/docker-compose.yml --profile docs rm --stop --fo
```
PocketBase data lives in `/pb_data`. Docker Compose persists it in the `pb_data` named volume for both the development Workshop service and the production documentation/Workshop service. The image serves the built Homarr documentation and Workshop UI from `/pb_public`.
At startup the container repairs ownership of existing data volumes, then runs PocketBase as its non-root user.
+5
View File
@@ -1,6 +1,11 @@
#!/bin/sh
set -eu
if [ "$(id -u)" -eq 0 ]; then
chown -R pocketbase:pocketbase /pb_data
exec su -p -s /bin/sh pocketbase -c 'exec /usr/local/bin/workshop-entrypoint'
fi
exec pocketbase serve \
--http="0.0.0.0:${PORT:-8090}" \
--origins="${PB_ALLOWED_ORIGINS:-*}" \
+6 -8
View File
@@ -85,18 +85,16 @@ const visitorSession = await signIn(visitor.email, visitorPassword);
const widget = {
$schema: "homarr-custom-widget-v2",
name: "Workshop runtime probe",
sources: [
{
id: "default",
sources: {
default: {
name: "API",
baseUrl: "https://example.com",
networkScope: "public",
auth: { type: "none" },
auth: "none",
},
],
requests: [],
optionsSchema: { type: "object", properties: {}, additionalProperties: false },
defaultOptions: {},
},
requests: {},
options: {},
template: "<Text>Runtime probe</Text>",
};
+29 -39
View File
@@ -7,23 +7,6 @@ import { startMockApiContainerAsync } from "./shared/mock-api-container";
import { seedAdminUserAsync } from "./shared/seed-admin-user";
const adminCredentials = { username: "admin", password: "Comp(exP4sswOrd" };
const requests = JSON.stringify(
[
{
id: "status",
sourceId: "default",
kind: "query",
method: "GET",
pathTemplate: "/status",
parameters: {},
auth: "inherit",
minimumBoardPermission: "view",
trigger: "load",
},
],
null,
2,
);
const initialTemplate =
"<Stack><Title order={4}>{data.status?.title}</Title><Badge>{data.status?.status}</Badge></Stack>";
const updatedTemplate = "<Stack><Text fw={700}>{data.status?.title}</Text><Text>{data.status?.value}</Text></Stack>";
@@ -49,11 +32,25 @@ describe("Custom JSX v2 workbench", () => {
await page.waitForURL(baseUrl, { timeout: 15_000 });
await page.goto(`${baseUrl}/manage/custom-widgets/new`);
await page.getByRole("textbox", { name: "Name" }).fill("E2E Custom JSX v2");
await page.locator('input[data-path="name"]:visible').fill("E2E Custom JSX v2");
await page.getByRole("textbox", { name: "Base URL" }).fill(mockApi.url);
await page.getByRole("combobox", { name: "Network scope" }).click();
await page.getByRole("combobox", { name: "Network access" }).click();
await page.getByRole("option", { name: "private" }).click();
await page.locator("#requests-editor-root").fill(requests);
await page.getByRole("group", { name: "status" }).getByRole("textbox", { name: "Path" }).fill("/status");
await page.getByRole("button", { name: "Add option" }).click();
const option = page.getByRole("group", { name: "Option 1" });
await option.getByRole("textbox", { name: "Option name" }).fill("label");
await option.getByRole("textbox", { name: "Default" }).fill("E2E");
const request = page.getByRole("group", { name: "status" });
await request.getByRole("button", { name: "Query, body and behavior" }).click();
await request.getByRole("button", { name: "Add query value" }).click();
await request.getByRole("textbox", { name: "Query key" }).fill("label");
await request.getByRole("combobox", { name: "Value source" }).click();
await page.getByRole("option", { name: "Widget option" }).click();
await request.getByRole("combobox", { name: "Option" }).first().click();
await page.getByRole("option", { name: "label" }).click();
await page.getByRole("button", { name: "Advanced manifest JSON" }).first().click();
await expect(page.locator("#requests-editor-root")).toContainText('"$option": "label"');
await page.locator("#jsx-editor-root").fill(initialTemplate);
await page.getByRole("button", { name: "Test and preview" }).last().click();
@@ -61,35 +58,28 @@ describe("Custom JSX v2 workbench", () => {
timeout: 15_000,
});
await page.getByRole("button", { name: "Create widget" }).last().click();
await page.getByRole("button", { name: "Create", exact: true }).last().click();
await expect(page.getByText('Widget "E2E Custom JSX v2" created successfully.')).toBeVisible({ timeout: 15_000 });
await page.waitForURL("**/manage/custom-widgets/edit/**", { timeout: 15_000 });
await page.locator("#jsx-editor-root").fill(updatedTemplate);
await page.getByRole("button", { name: "Save changes" }).last().click();
await page.getByRole("button", { name: "Save", exact: true }).last().click();
await expect(page.getByText('Widget "E2E Custom JSX v2" updated successfully.')).toBeVisible({ timeout: 15_000 });
await page.goto(baseUrl);
await page
.getByRole("button", { name: /edit mode|pencil/iu })
.first()
.click();
await page
.getByRole("button", { name: /Add widget|Add item/iu })
.first()
.click();
await page
.getByRole("button", { name: /Custom API|Custom Widget/iu })
.first()
.click();
await page.getByRole("button", { name: "Edit", exact: true }).click();
await page.getByRole("button", { name: "New item", exact: true }).click();
await page.getByRole("menuitem", { name: "New item", exact: true }).click();
const picker = page.getByRole("dialog").last();
const customWidgetCard = picker.getByText("E2E Custom JSX v2", { exact: true }).locator("xpath=../../..");
await customWidgetCard.hover();
await customWidgetCard.getByRole("button", { name: "Add to board" }).click();
const addDialog = page.getByRole("dialog").last();
await addDialog.getByText("E2E Custom JSX v2").first().click();
await expect(addDialog.getByText("E2E Custom JSX v2")).toBeVisible();
await addDialog.getByRole("button", { name: "Save changes", exact: true }).click();
await expect(page.getByText("Widget definition not found")).not.toBeVisible();
await page
.getByRole("button", { name: /edit mode|pencil/iu })
.first()
.click();
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect(page.getByText("E2E Widget")).toBeVisible({ timeout: 15_000 });
await expect(page.getByText("42")).toBeVisible({ timeout: 15_000 });
} finally {
@@ -81,7 +81,7 @@ export const customWidgetRouter = createTRPCRouter({
encryptedValue: encryptSecret(secret.value),
updatedAt: new Date(),
}));
const sourceIds = definition.sources.map((source) => source.id);
const sourceIds = Object.keys(definition.sources);
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
@@ -100,11 +100,11 @@ export const customWidgetRouter = createTRPCRouter({
),
);
for (const source of definition.sources) {
const kinds = [...requiredSecretKinds(source.auth.type)];
for (const [sourceId, source] of Object.entries(definition.sources)) {
const kinds = [...requiredSecretKinds(typeof source.auth === "string" ? source.auth : source.auth.type)];
const where = and(
eq(schema.customWidgetSecrets.definitionId, id),
eq(schema.customWidgetSecrets.sourceId, source.id),
eq(schema.customWidgetSecrets.sourceId, sourceId),
);
await transaction
.delete(schema.customWidgetSecrets)
@@ -138,9 +138,9 @@ export const customWidgetRouter = createTRPCRouter({
.where(and(eq(customWidgetSecrets.definitionId, id), notInArray(customWidgetSecrets.sourceId, sourceIds)))
.run();
for (const source of definition.sources) {
const kinds = [...requiredSecretKinds(source.auth.type)];
const where = and(eq(customWidgetSecrets.definitionId, id), eq(customWidgetSecrets.sourceId, source.id));
for (const [sourceId, source] of Object.entries(definition.sources)) {
const kinds = [...requiredSecretKinds(typeof source.auth === "string" ? source.auth : source.auth.type)];
const where = and(eq(customWidgetSecrets.definitionId, id), eq(customWidgetSecrets.sourceId, sourceId));
transaction
.delete(customWidgetSecrets)
.where(kinds.length > 0 ? and(where, notInArray(customWidgetSecrets.kind, kinds)) : where)
@@ -5,13 +5,22 @@ import { decryptSecret } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { eq, or } from "@homarr/db";
import { boards, customWidgetDefinitions } from "@homarr/db/schema";
import { getCustomWidgetSecretRequirements } from "@homarr/custom-widgets/core";
import {
getCustomWidgetConfirmation,
getCustomWidgetDefaultOptions,
getCustomWidgetSecretRequirements,
} from "@homarr/custom-widgets/core";
import { permissionRequiredProcedure, protectedProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
import { parseStoredCustomWidgetDefinition, safeParseStoredCustomWidgetDefinition } from "./stored-definition";
import { executeCustomWidgetRequest } from "./request-executor";
import { hashRuntimeParams, renderRequestBody, renderRequestTarget } from "./request-manifest";
import {
hashRuntimeParams,
renderRequestBody,
renderRequestTarget,
resolveCustomWidgetRequestValues,
} from "./request-manifest";
import { acquireCustomWidgetRequestLimit } from "./request-limits";
import { getCustomWidgetCacheVersion } from "./cache-version";
@@ -41,7 +50,7 @@ export const managementQueryProcedures = {
sources: [],
requestCount: 0,
missingSecrets: [],
defaultOptions: {},
options: {},
updatedAt: definition.updatedAt,
enabled: definition.enabled,
valid: false as const,
@@ -56,18 +65,18 @@ export const managementQueryProcedures = {
name: widget.name,
description: widget.description,
iconUrl: widget.iconUrl,
sources: widget.sources.map(({ id, name, baseUrl, networkScope, auth }) => ({
sources: Object.entries(widget.sources).map(([id, { name, baseUrl, networkScope, auth }]) => ({
id,
name,
origin: new URL(baseUrl).origin,
networkScope,
authType: auth.type,
authType: typeof auth === "string" ? auth : auth.type,
})),
requestCount: widget.requests.length,
requestCount: Object.keys(widget.requests).length,
missingSecrets: getCustomWidgetSecretRequirements(widget.sources).filter(
(requirement) => !configuredSecrets.has(`${requirement.sourceId}:${requirement.kind}`),
),
defaultOptions: widget.defaultOptions,
options: widget.options,
updatedAt: definition.updatedAt,
enabled: definition.enabled,
valid: true as const,
@@ -123,29 +132,29 @@ export const managementQueryProcedures = {
name: definition.name,
description: definition.description,
iconUrl: definition.iconUrl,
optionsSchema: widget.optionsSchema,
defaultOptions: widget.defaultOptions,
options: widget.options,
defaultOptions: getCustomWidgetDefaultOptions(widget.options),
template: widget.template,
sources: widget.sources.map(({ id, name, networkScope, auth }) => ({
sources: Object.entries(widget.sources).map(([id, { name, networkScope, auth }]) => ({
id,
name,
networkScope,
authType: auth.type,
authType: typeof auth === "string" ? auth : auth.type,
})),
requestCapabilities: widget.requests.map(
({ id, kind, method, trigger, minimumBoardPermission, confirmation, invalidates }) => ({
requestCapabilities: Object.entries(widget.requests).map(
([id, { kind, method, trigger, permission, confirmation, invalidates }]) => ({
id,
kind,
method,
trigger,
minimumBoardPermission,
confirmation,
minimumBoardPermission: permission,
confirmation: getCustomWidgetConfirmation({ method, confirmation }),
invalidates,
}),
),
optionRequests: widget.requests
.filter((request) => request.kind === "query")
.map(({ id, parameters, optionsBinding }) => ({ id, parameters, optionsBinding })),
optionRequests: Object.entries(widget.requests).flatMap(([id, request]) =>
request.kind === "query" ? [{ id }] : [],
),
updatedAt: definition.updatedAt,
};
});
@@ -168,12 +177,12 @@ export const managementQueryProcedures = {
});
if (!stored || !stored.enabled) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget not found" });
const definition = parseStoredCustomWidgetDefinition(stored);
const request = definition.requests.find(
(candidate) => candidate.id === input.requestId && candidate.kind === "query",
);
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Option query not found" });
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), request.minimumBoardPermission);
const source = definition.sources.find((candidate) => candidate.id === request.sourceId);
const requestDefinition = definition.requests[input.requestId];
if (requestDefinition?.kind !== "query")
throw new TRPCError({ code: "NOT_FOUND", message: "Option query not found" });
const request = { id: input.requestId, ...requestDefinition };
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), request.permission);
const source = definition.sources[request.source];
if (!source) throw new TRPCError({ code: "NOT_FOUND", message: "Option query source not found" });
const release = await acquireCustomWidgetRequestLimit({
category: "query",
@@ -182,31 +191,33 @@ export const managementQueryProcedures = {
definitionId: input.definitionId,
});
try {
const values = resolveCustomWidgetRequestValues(request, input.params, {});
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
const response = await executeCustomWidgetRequest({
baseUrl: source.baseUrl,
targetUrl: renderRequestTarget(source.baseUrl, request, input.params),
targetUrl: renderRequestTarget(source.baseUrl, request, values),
method: request.method,
body: renderRequestBody(request.bodyTemplate, input.params),
staticHeaders: request.staticHeaders,
body: renderRequestBody(request, values),
staticHeaders: request.headers,
auth:
request.auth === "none" || source.auth.type === "none"
request.auth === "none" || authType === "none"
? undefined
: {
type: source.auth.type,
type: authType,
secrets: stored.secrets
.filter((secret) => secret.sourceId === source.id)
.filter((secret) => secret.sourceId === request.source)
.map((secret) => ({ kind: secret.kind, value: decryptSecret(secret.encryptedValue) })),
headerName:
source.auth.type === "apiKeyHeader"
? source.auth.headerName
: source.auth.type === "apiKeyQuery"
? source.auth.parameterName
typeof source.auth === "object" && source.auth.type === "apiKeyHeader"
? source.auth.name
: typeof source.auth === "object" && source.auth.type === "apiKeyQuery"
? source.auth.name
: undefined,
},
networkScope: source.networkScope,
kind: "query",
cacheKey: `custom-widget:options:${input.definitionId}:${getCustomWidgetCacheVersion(stored)}:${request.id}:${hashRuntimeParams(input.params)}`,
cacheTtlSeconds: request.cacheTtlSeconds ?? 30,
cacheKey: `custom-widget:options:${input.definitionId}:${getCustomWidgetCacheVersion(stored)}:${request.id}:${hashRuntimeParams(values)}`,
cacheTtlSeconds: request.cacheSeconds ?? 30,
});
return {
ok: response.ok,
@@ -8,7 +8,7 @@ import {
recordPreviewJournal,
} from "./preview-procedure-helpers";
import { executeCustomWidgetRequest, invalidateCustomWidgetResponseCache } from "./request-executor";
import { renderRequestBody, renderRequestTarget } from "./request-manifest";
import { renderRequestBody, renderRequestTarget, resolveCustomWidgetRequestValues } from "./request-manifest";
import { acquireCustomWidgetRequestLimit } from "./request-limits";
import { getPreviewSession } from "./preview-sessions";
@@ -22,20 +22,21 @@ export const previewActionProcedures = {
.input(previewSessionRequestSchema.extend({ confirmed: z.boolean().optional() }))
.mutation(async ({ ctx, input }) => {
const session = await getPreviewSession(input.sessionId, ctx.session.user.id);
const request = session.requests.find(
(candidate) => candidate.id === input.requestId && candidate.kind === "action",
);
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Preview action was not found" });
const resolved = getPreviewRequestSource(session, request.sourceId);
const definition = session.requests[input.requestId];
if (definition?.kind !== "action")
throw new TRPCError({ code: "NOT_FOUND", message: "Preview action was not found" });
const request = { id: input.requestId, ...definition };
const resolved = getPreviewRequestSource(session, request.source);
if (!resolved) throw new TRPCError({ code: "NOT_FOUND", message: "Preview source was not found" });
const targetUrl = renderRequestTarget(resolved.source.baseUrl, request, input.params);
const body = renderRequestBody(request.bodyTemplate, input.params);
const params = resolveCustomWidgetRequestValues(request, session.options, input.params);
const targetUrl = renderRequestTarget(resolved.source.baseUrl, request, params);
const body = renderRequestBody(request, params);
if (!session.liveActions) {
await recordPreviewJournal(session, {
requestId: request.id,
kind: "action",
method: request.method,
pathTemplate: request.pathTemplate,
path: request.path,
status: null,
durationMs: 0,
simulated: true,
@@ -58,7 +59,7 @@ export const previewActionProcedures = {
targetUrl,
method: request.method,
body,
staticHeaders: request.staticHeaders,
staticHeaders: request.headers,
auth: request.auth === "none" ? undefined : resolved.auth,
networkScope: resolved.source.networkScope,
kind: "action",
@@ -72,7 +73,7 @@ export const previewActionProcedures = {
requestId: request.id,
kind: "action",
method: request.method,
pathTemplate: request.pathTemplate,
path: request.path,
status: response.status,
durationMs: Date.now() - startedAt,
simulated: false,
@@ -5,6 +5,8 @@ import { decryptSecret } from "@homarr/common/server";
import {
customWidgetDefinitionSchema,
customWidgetSecretsInputSchema,
getCustomWidgetConfirmation,
getCustomWidgetDefaultOptions,
validateCustomWidgetOptions,
} from "@homarr/custom-widgets/core";
import { eq } from "@homarr/db";
@@ -29,8 +31,8 @@ const previewCreateProcedure = manageProcedure
if (input.secrets.length > 0 && !ctx.session.user.permissions.includes("custom-widget-secret-write")) {
throw new TRPCError({ code: "FORBIDDEN", message: "Writing preview secrets requires dedicated permission" });
}
const options = input.options ?? input.definition.defaultOptions;
const optionIssues = validateCustomWidgetOptions(input.definition.optionsSchema, options);
const options = input.options ?? getCustomWidgetDefaultOptions(input.definition.options);
const optionIssues = validateCustomWidgetOptions(input.definition.options, options);
if (optionIssues.length > 0) {
const issue = optionIssues[0];
throw new TRPCError({
@@ -54,11 +56,12 @@ const previewCreateProcedure = manageProcedure
}
const invalid = secrets.find((secret) => {
const source = input.definition.sources.find((candidate) => candidate.id === secret.sourceId);
const source = input.definition.sources[secret.sourceId];
const authType = typeof source?.auth === "string" ? source.auth : source?.auth.type;
const kinds =
source?.auth.type === "basic"
authType === "basic"
? ["username", "password"]
: source && ["bearer", "apiKeyHeader", "apiKeyQuery"].includes(source.auth.type)
: source && authType && ["bearer", "apiKeyHeader", "apiKeyQuery"].includes(authType)
? ["apiKey"]
: [];
return !source || !kinds.includes(secret.kind);
@@ -73,7 +76,7 @@ const previewCreateProcedure = manageProcedure
requests: input.definition.requests,
name: input.definition.name,
template: input.definition.template,
optionsSchema: input.definition.optionsSchema,
optionDefinitions: input.definition.options,
options,
secrets,
definitionId: input.definitionId,
@@ -87,7 +90,7 @@ const previewCreateProcedure = manageProcedure
).toString(),
definition: {
template: input.definition.template,
defaultOptions: input.definition.defaultOptions,
defaultOptions: getCustomWidgetDefaultOptions(input.definition.options),
},
};
});
@@ -101,31 +104,17 @@ export const previewBaseProcedures = {
name: session.name,
expiresAt: session.expiresAt,
template: session.template,
optionsSchema: session.optionsSchema,
optionDefinitions: session.optionDefinitions,
options: session.options,
requests: session.requests.map(
({
id,
kind,
method,
minimumBoardPermission,
trigger,
parameters,
optionsBinding,
confirmation,
invalidates,
}) => ({
id,
kind,
method,
minimumBoardPermission,
trigger,
parameters,
optionsBinding,
confirmation,
invalidates,
}),
),
requests: Object.entries(session.requests).map(([id, request]) => ({
id,
kind: request.kind,
method: request.method,
minimumBoardPermission: request.permission,
trigger: request.trigger,
confirmation: getCustomWidgetConfirmation(request),
invalidates: request.invalidates,
})),
liveActions: session.liveActions,
};
}),
@@ -1,11 +1,11 @@
import { createLogger } from "@homarr/core/infrastructure/logs";
import { resolveCustomWidgetOptionsBinding } from "@homarr/custom-widgets/core";
import type { CustomJsxRequest } from "@homarr/custom-widgets/core";
import { z } from "zod/v4";
import { appendPreviewJournal } from "./preview-sessions";
import { getPreviewSessionSecrets } from "./preview-sessions";
import type { CustomWidgetPreviewSession } from "./preview-sessions";
import { resolveCustomWidgetRequestValues } from "./request-manifest";
const logger = createLogger({ module: "custom-widget-preview" });
@@ -27,19 +27,20 @@ export const recordPreviewJournal = async (...args: Parameters<typeof appendPrev
};
export const getPreviewRequestSource = (session: CustomWidgetPreviewSession, sourceId: string) => {
const source = session.sources.find((candidate) => candidate.id === sourceId);
const source = session.sources[sourceId];
if (!source) return null;
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
const auth =
source.auth.type === "none"
authType === "none"
? undefined
: {
type: source.auth.type,
secrets: getPreviewSessionSecrets(session, source.id),
type: authType,
secrets: getPreviewSessionSecrets(session, sourceId),
headerName:
source.auth.type === "apiKeyHeader"
? source.auth.headerName
: source.auth.type === "apiKeyQuery"
? source.auth.parameterName
typeof source.auth === "object" && source.auth.type === "apiKeyHeader"
? source.auth.name
: typeof source.auth === "object" && source.auth.type === "apiKeyQuery"
? source.auth.name
: undefined,
};
return { source, auth };
@@ -50,5 +51,5 @@ export function resolvePreviewRequestParams(
options: Record<string, unknown>,
suppliedParams: Record<string, string | number | boolean>,
) {
return request.trigger === "load" ? resolveCustomWidgetOptionsBinding(request, options) : suppliedParams;
return resolveCustomWidgetRequestValues(request, options, suppliedParams);
}
@@ -26,15 +26,15 @@ export const previewQueryProcedures = {
.input(previewSessionRequestSchema)
.query(async ({ ctx, input }) => {
const session = await getPreviewSession(input.sessionId, ctx.session.user.id);
const request = session.requests.find(
(candidate) => candidate.id === input.requestId && candidate.kind === "query",
);
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Preview query was not found" });
const resolved = getPreviewRequestSource(session, request.sourceId);
const definition = session.requests[input.requestId];
if (definition?.kind !== "query")
throw new TRPCError({ code: "NOT_FOUND", message: "Preview query was not found" });
const request = { id: input.requestId, ...definition };
const resolved = getPreviewRequestSource(session, request.source);
if (!resolved) throw new TRPCError({ code: "NOT_FOUND", message: "Preview source was not found" });
const params = resolvePreviewRequestParams(request, session.options, input.params);
const targetUrl = renderRequestTarget(resolved.source.baseUrl, request, params);
const body = renderRequestBody(request.bodyTemplate, params);
const body = renderRequestBody(request, params);
const release = await acquireCustomWidgetRequestLimit({
category: "query",
userId: ctx.session.user.id,
@@ -48,18 +48,18 @@ export const previewQueryProcedures = {
targetUrl,
method: request.method,
body,
staticHeaders: request.staticHeaders,
staticHeaders: request.headers,
auth: request.auth === "none" ? undefined : resolved.auth,
networkScope: resolved.source.networkScope,
kind: "query",
cacheKey: `custom-jsx:preview:${session.id}:${request.id}:${hashRuntimeParams(params)}`,
cacheTtlSeconds: request.cacheTtlSeconds,
cacheTtlSeconds: request.cacheSeconds,
});
await recordPreviewJournal(session, {
requestId: request.id,
kind: "query",
method: request.method,
pathTemplate: request.pathTemplate,
path: request.path,
status: response.status,
durationMs: Date.now() - startedAt,
simulated: false,
@@ -3,7 +3,7 @@ import {
hashRuntimeParams,
renderRequestBody as renderDomainBody,
renderRequestTarget as renderDomainTarget,
validateRuntimeParams as validateDomainParams,
resolveCustomWidgetRequestValues as resolveDomainValues,
} from "@homarr/custom-widgets/server";
import type { CustomJsxRuntimeParams } from "@homarr/custom-widgets/server";
@@ -12,26 +12,32 @@ import { toTrpcError } from "./domain-error";
export type { CustomJsxRuntimeParams } from "@homarr/custom-widgets/server";
export { hashRuntimeParams };
export function validateRuntimeParams(request: CustomJsxRequest, params: CustomJsxRuntimeParams): void {
type CustomJsxResolvedValues = ReturnType<typeof resolveDomainValues>;
export function resolveCustomWidgetRequestValues(
request: CustomJsxRequest,
options: Record<string, unknown>,
params: CustomJsxRuntimeParams = {},
) {
try {
validateDomainParams(request, params);
return resolveDomainValues(request, options, params);
} catch (error) {
toTrpcError(error);
return toTrpcError(error);
}
}
export function renderRequestBody(request: CustomJsxRequest["bodyTemplate"], params: CustomJsxRuntimeParams) {
export function renderRequestBody(request: CustomJsxRequest, params: CustomJsxResolvedValues) {
try {
return renderDomainBody(request, params);
} catch (error) {
toTrpcError(error);
return toTrpcError(error);
}
}
export function renderRequestTarget(baseUrl: string, request: CustomJsxRequest, params: CustomJsxRuntimeParams) {
export function renderRequestTarget(baseUrl: string, request: CustomJsxRequest, params: CustomJsxResolvedValues) {
try {
return renderDomainTarget(baseUrl, request, params);
} catch (error) {
toTrpcError(error);
return toTrpcError(error);
}
}
@@ -7,12 +7,13 @@ export function requiredSecretKinds(authType: string) {
}
export function assertSecretSources(
sources: readonly { id: string; auth: { type: string } }[],
sources: Record<string, { auth: string | { type: string } }>,
secrets: readonly { sourceId: string; kind: "apiKey" | "username" | "password" }[],
) {
const invalid = secrets.find((secret) => {
const source = sources.find((candidate) => candidate.id === secret.sourceId);
return !source || !new Set<string>(requiredSecretKinds(source.auth.type)).has(secret.kind);
const source = sources[secret.sourceId];
const authType = typeof source?.auth === "string" ? source.auth : source?.auth.type;
return !source || !authType || !new Set<string>(requiredSecretKinds(authType)).has(secret.kind);
});
if (invalid) {
throw new TRPCError({
@@ -80,7 +80,7 @@ export const secretProcedures = {
}
let widgetName: string;
let source: { id: string; name: string; auth: { type: string } } | undefined;
let source: { id: string; name: string; auth: string | { type: string } } | undefined;
let target: { type: "definition"; id: string } | { type: "preview"; id: string };
if (input.definitionId) {
const stored = await ctx.db.query.customWidgetDefinitions.findFirst({
@@ -89,16 +89,22 @@ export const secretProcedures = {
if (!stored) throw new TRPCError({ code: "NOT_FOUND" });
const definition = parseStoredCustomWidgetDefinition(stored);
widgetName = definition.name;
source = definition.sources.find((candidate) => candidate.id === input.sourceId);
const candidate = definition.sources[input.sourceId ?? ""];
source = candidate
? { id: input.sourceId ?? "", name: candidate.name ?? input.sourceId ?? "", auth: candidate.auth }
: undefined;
target = { type: "definition", id: input.definitionId };
} else {
const preview = await getPreviewSession(input.previewSessionId ?? "", ctx.session.user.id);
widgetName = "Custom widget preview";
source = preview.sources.find((candidate) => candidate.id === input.sourceId);
const candidate = preview.sources[input.sourceId ?? ""];
source = candidate
? { id: input.sourceId ?? "", name: candidate.name ?? input.sourceId ?? "", auth: candidate.auth }
: undefined;
target = { type: "preview", id: preview.id };
}
if (!source) throw new TRPCError({ code: "NOT_FOUND", message: "Widget source not found" });
const kinds = [...requiredSecretKinds(source.auth.type)];
const kinds = [...requiredSecretKinds(typeof source.auth === "string" ? source.auth : source.auth.type)];
if (kinds.length === 0) {
throw new TRPCError({ code: "BAD_REQUEST", message: "This source does not require a secret" });
}
@@ -6,7 +6,7 @@ import type { CustomWidgetDefinition } from "@homarr/db/schema";
type StoredCustomWidgetDefinition = Pick<
CustomWidgetDefinition,
"name" | "description" | "iconUrl" | "sources" | "requests" | "optionsSchema" | "defaultOptions" | "template"
"name" | "description" | "iconUrl" | "sources" | "requests" | "options" | "template"
>;
export interface StoredCustomWidgetIssue {
@@ -25,8 +25,7 @@ export function serializeCustomWidgetDefinition(definition: HomarrCustomWidgetV2
iconUrl: definition.iconUrl ?? null,
sources: stringifySuperJson(definition.sources),
requests: stringifySuperJson(definition.requests),
optionsSchema: stringifySuperJson(definition.optionsSchema),
defaultOptions: stringifySuperJson(definition.defaultOptions),
options: stringifySuperJson(definition.options),
template: definition.template,
};
}
@@ -39,8 +38,7 @@ export function parseStoredCustomWidgetDefinition(definition: StoredCustomWidget
iconUrl: definition.iconUrl ?? undefined,
sources: parseSuperJson(definition.sources),
requests: parseSuperJson(definition.requests),
optionsSchema: parseSuperJson(definition.optionsSchema),
defaultOptions: parseSuperJson(definition.defaultOptions),
options: parseSuperJson(definition.options),
template: definition.template,
});
}
@@ -5,27 +5,28 @@ import type { CustomJsxRequest } from "@homarr/custom-widgets/core";
import { resolvePreviewRequestParams } from "../../custom-widget/preview-procedure-helpers";
const loadRequest: CustomJsxRequest = {
id: "containers",
sourceId: "default",
source: "default",
kind: "query",
method: "GET",
pathTemplate: "/endpoints/{endpointId}/containers",
parameters: { endpointId: "number" },
optionsBinding: { endpointId: { $option: "environmentId" } },
path: "/endpoints/{option:environmentId}/containers",
auth: "inherit",
minimumBoardPermission: "view",
permission: "view",
trigger: "load",
};
describe("preview request option bindings", () => {
it("resolves load request parameters from the selected preview options", () => {
expect(resolvePreviewRequestParams(loadRequest, { environmentId: 7 }, { endpointId: 1 })).toEqual({
endpointId: 7,
expect(resolvePreviewRequestParams(loadRequest, { environmentId: 7 }, {})).toEqual({
"option:environmentId": 7,
});
});
it("preserves invoking component parameters for manual requests", () => {
const manualRequest = { ...loadRequest, trigger: "manual" as const, optionsBinding: undefined };
const manualRequest = {
...loadRequest,
trigger: "manual" as const,
path: "/endpoints/{param:endpointId}/containers",
};
expect(resolvePreviewRequestParams(manualRequest, { environmentId: 7 }, { endpointId: 12 })).toEqual({
endpointId: 12,
});
@@ -1,7 +1,11 @@
import { parse as parseSuperJson, stringify as stringifySuperJson } from "superjson";
import { stringify as stringifySuperJson } from "superjson";
import { describe, expect, test } from "vitest";
import { BUNDLED_CUSTOM_WIDGETS, CUSTOM_WIDGET_STARTER } from "@homarr/custom-widgets/core";
import {
BUNDLED_CUSTOM_WIDGETS,
CUSTOM_WIDGET_STARTER,
customWidgetDefinitionSchema,
} from "@homarr/custom-widgets/core";
import {
safeParseStoredCustomWidgetDefinition,
@@ -10,36 +14,31 @@ import {
describe("stored custom widget definitions", () => {
test("parses a valid stored definition", () => {
const result = safeParseStoredCustomWidgetDefinition(serializeCustomWidgetDefinition(CUSTOM_WIDGET_STARTER));
const result = safeParseStoredCustomWidgetDefinition(
serializeCustomWidgetDefinition(customWidgetDefinitionSchema.parse(CUSTOM_WIDGET_STARTER)),
);
expect(result).toMatchObject({ success: true, widget: { $schema: "homarr-custom-widget-v2" } });
});
test("returns all useful issues for a stale load request instead of throwing", () => {
test("returns useful issues for an invalid stored request instead of throwing", () => {
const currency = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-currency-exchange");
if (!currency) throw new Error("Currency Exchange bundled widget is missing");
const stored = serializeCustomWidgetDefinition(currency.widget);
const requests = parseSuperJson(stored.requests) as Array<Record<string, unknown>>;
delete requests[0]?.optionsBinding;
const stored = serializeCustomWidgetDefinition(customWidgetDefinitionSchema.parse(currency.widget));
const result = safeParseStoredCustomWidgetDefinition({
...stored,
requests: stringifySuperJson(requests),
requests: stringifySuperJson({ rates: { path: "/latest/{option:missing}" } }),
});
expect(result).toEqual({
success: false,
issues: expect.arrayContaining([
expect.objectContaining({ path: "requests.0.optionsBinding.from" }),
expect.objectContaining({ path: "requests.0.optionsBinding.to" }),
expect.objectContaining({ path: "requests.0.optionsBinding.amount" }),
]),
issues: expect.arrayContaining([expect.objectContaining({ path: "requests.rates" })]),
});
});
test("contains malformed serialized data", () => {
const stored = serializeCustomWidgetDefinition(CUSTOM_WIDGET_STARTER);
const stored = serializeCustomWidgetDefinition(customWidgetDefinitionSchema.parse(CUSTOM_WIDGET_STARTER));
expect(safeParseStoredCustomWidgetDefinition({ ...stored, requests: "not-json" })).toEqual({
success: false,
@@ -27,64 +27,55 @@ import { customApiRouter } from "../../widgets/custom-api";
const definition: HomarrCustomWidgetV2 = {
$schema: "homarr-custom-widget-v2",
name: "Router test widget",
sources: [
{
id: "default",
sources: {
default: {
name: "API",
baseUrl: "https://example.com",
networkScope: "public",
auth: { type: "none" },
auth: "none",
},
],
requests: [
{
id: "load-status",
sourceId: "default",
},
requests: {
"load-status": {
source: "default",
kind: "query",
method: "GET",
pathTemplate: "/status",
parameters: {},
path: "/status",
auth: "inherit",
minimumBoardPermission: "view",
permission: "view",
trigger: "load",
},
{
id: "status",
sourceId: "default",
status: {
source: "default",
kind: "query",
method: "GET",
pathTemplate: "/status/{name}",
parameters: { name: "string" },
path: "/status/{param:name}",
auth: "inherit",
minimumBoardPermission: "view",
permission: "view",
trigger: "manual",
},
{
id: "toggle",
sourceId: "default",
toggle: {
source: "default",
kind: "action",
method: "POST",
pathTemplate: "/toggle",
parameters: { enabled: "boolean" },
bodyTemplate: { enabled: { $param: "enabled" } },
path: "/toggle",
body: { enabled: { $param: "enabled" } },
auth: "inherit",
minimumBoardPermission: "modify",
permission: "modify",
trigger: "manual",
},
{
id: "delete",
sourceId: "default",
delete: {
source: "default",
kind: "action",
method: "DELETE",
pathTemplate: "/resource",
parameters: {},
path: "/resource",
auth: "inherit",
minimumBoardPermission: "full",
permission: "full",
trigger: "manual",
confirmation: { title: "Delete resource", message: "Delete this resource?", destructive: true },
},
],
optionsSchema: { type: "object", properties: {}, additionalProperties: false },
defaultOptions: {},
},
options: {},
template: "<Text>{data['load-status']?.value}</Text>",
};
@@ -149,6 +140,17 @@ describe("Custom JSX v2 board router", () => {
.where(eq(customWidgetDefinitions.id, enabledSetup.definitionId));
const publicCaller = customApiRouter.createCaller({ db: enabledSetup.db, deviceType: undefined, session: null });
await expect(publicCaller.getData({ itemId: enabledSetup.itemId })).rejects.toMatchObject({ code: "FORBIDDEN" });
await expect(
publicCaller.queryRequest({ itemId: enabledSetup.itemId, requestId: "status", params: { name: "homarr" } }),
).rejects.toMatchObject({ code: "FORBIDDEN" });
const ownerCaller = customApiRouter.createCaller({
db: enabledSetup.db,
deviceType: undefined,
session: createSession(enabledSetup.ownerId),
});
await expect(
ownerCaller.executeAction({ itemId: enabledSetup.itemId, requestId: "toggle", params: { enabled: true } }),
).rejects.toMatchObject({ code: "FORBIDDEN" });
expect(mocks.executeRequest).not.toHaveBeenCalled();
});
@@ -159,7 +161,7 @@ describe("Custom JSX v2 board router", () => {
await expect(caller.queryRequest({ itemId, requestId: "toggle", params: { enabled: true } })).rejects.toMatchObject(
{ code: "NOT_FOUND" },
);
await expect(caller.queryRequest({ itemId, requestId: "status", params: { name: 42 } })).rejects.toThrow();
await expect(caller.queryRequest({ itemId, requestId: "status", params: {} })).rejects.toThrow();
expect(mocks.executeRequest).not.toHaveBeenCalled();
});
+50 -47
View File
@@ -6,14 +6,22 @@ import { decryptSecret } from "@homarr/common/server";
import { eq } from "@homarr/db";
import { boards, customWidgetDefinitions, items } from "@homarr/db/schema";
import type { BoardPermission } from "@homarr/definitions";
import { validateCustomWidgetOptions } from "@homarr/custom-widgets/core";
import { resolveCustomWidgetOptionsBinding } from "@homarr/custom-widgets/core";
import {
getCustomWidgetConfirmation,
getCustomWidgetDefaultOptions,
validateCustomWidgetOptions,
} from "@homarr/custom-widgets/core";
import type { CustomJsxRequest, CustomWidgetSource } from "@homarr/custom-widgets/core";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
import { executeCustomWidgetRequest, invalidateCustomWidgetResponseCache } from "../custom-widget/request-executor";
import { hashRuntimeParams, renderRequestBody, renderRequestTarget } from "../custom-widget/request-manifest";
import {
hashRuntimeParams,
renderRequestBody,
renderRequestTarget,
resolveCustomWidgetRequestValues,
} from "../custom-widget/request-manifest";
import { acquireCustomWidgetRequestLimit } from "../custom-widget/request-limits";
import { getCustomWidgetCacheVersion } from "../custom-widget/cache-version";
import { parseStoredCustomWidgetDefinition } from "../custom-widget/stored-definition";
@@ -77,8 +85,8 @@ async function resolvePlacedDefinitionAsync(ctx: RouterContext, itemId: string)
if (!stored.enabled) throw new TRPCError({ code: "FORBIDDEN", message: "Widget is disabled" });
const definition = parseStoredCustomWidgetDefinition(stored);
const configuration = { ...definition.defaultOptions, ...itemOptions.configuration };
const issues = validateCustomWidgetOptions(definition.optionsSchema, configuration);
const configuration = { ...getCustomWidgetDefaultOptions(definition.options), ...itemOptions.configuration };
const issues = validateCustomWidgetOptions(definition.options, configuration);
if (issues.length > 0) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
@@ -89,32 +97,35 @@ async function resolvePlacedDefinitionAsync(ctx: RouterContext, itemId: string)
}
const findRequest = (resolved: ResolvedDefinition, requestId: string, kind: "query" | "action") => {
const request = resolved.definition.requests.find(
(candidate) => candidate.id === requestId && candidate.kind === kind,
);
const request = resolved.definition.requests[requestId];
if (request?.kind !== kind) throw new TRPCError({ code: "NOT_FOUND", message: "Named request not found" });
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Named request not found" });
return request;
return { id: requestId, ...request };
};
const findSource = (resolved: ResolvedDefinition, sourceId: string) => {
const source = resolved.definition.sources.find((candidate) => candidate.id === sourceId);
const source = resolved.definition.sources[sourceId];
if (!source) throw new TRPCError({ code: "NOT_FOUND", message: "Widget API source not found" });
return source;
return { id: sourceId, ...source };
};
const getAuth = (resolved: ResolvedDefinition, source: CustomWidgetSource, mode: "inherit" | "none") => {
if (mode === "none" || source.auth.type === "none") return undefined;
type IdentifiedRequest = CustomJsxRequest & { id: string };
type IdentifiedSource = CustomWidgetSource & { id: string };
const getAuth = (resolved: ResolvedDefinition, source: IdentifiedSource, mode: "inherit" | "none") => {
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
if (mode === "none" || authType === "none") return undefined;
const secrets = resolved.stored.secrets
.filter((secret) => secret.sourceId === source.id)
.map((secret) => ({ kind: secret.kind, value: decryptSecret(secret.encryptedValue) }));
return {
type: source.auth.type,
type: authType,
secrets,
headerName:
source.auth.type === "apiKeyHeader"
? source.auth.headerName
: source.auth.type === "apiKeyQuery"
? source.auth.parameterName
typeof source.auth === "object" && source.auth.type === "apiKeyHeader"
? source.auth.name
: typeof source.auth === "object" && source.auth.type === "apiKeyQuery"
? source.auth.name
: undefined,
};
};
@@ -122,7 +133,7 @@ const getAuth = (resolved: ResolvedDefinition, source: CustomWidgetSource, mode:
const withRequestLimit = async <T>(
ctx: RouterContext,
resolved: ResolvedDefinition,
request: CustomJsxRequest,
request: IdentifiedRequest,
callback: () => Promise<T>,
) => {
const release = await acquireCustomWidgetRequestLimit({
@@ -138,38 +149,31 @@ const withRequestLimit = async <T>(
}
};
const getCacheKey = (
resolved: ResolvedDefinition,
request: CustomJsxRequest,
params: Record<string, string | number | boolean>,
) =>
const getCacheKey = (resolved: ResolvedDefinition, request: IdentifiedRequest, params: Record<string, unknown>) =>
`custom-jsx:${resolved.item.id}:${getCustomWidgetCacheVersion(resolved.stored)}:${request.id}:${hashRuntimeParams(params)}`;
const executeRequest = async (
ctx: RouterContext,
resolved: ResolvedDefinition,
request: CustomJsxRequest,
request: IdentifiedRequest,
params: Record<string, string | number | boolean>,
) => {
await throwIfActionForbiddenAsync(
ctx,
eq(boards.id, resolved.item.boardId),
request.minimumBoardPermission as BoardPermission,
);
const source = findSource(resolved, request.sourceId);
const targetUrl = renderRequestTarget(source.baseUrl, request, params);
await throwIfActionForbiddenAsync(ctx, eq(boards.id, resolved.item.boardId), request.permission as BoardPermission);
const source = findSource(resolved, request.source);
const values = resolveCustomWidgetRequestValues(request, resolved.configuration, params);
const targetUrl = renderRequestTarget(source.baseUrl, request, values);
return withRequestLimit(ctx, resolved, request, () =>
executeCustomWidgetRequest({
baseUrl: source.baseUrl,
targetUrl,
method: request.method,
body: renderRequestBody(request.bodyTemplate, params),
staticHeaders: request.staticHeaders,
body: renderRequestBody(request, values),
staticHeaders: request.headers,
auth: getAuth(resolved, source, request.auth),
networkScope: source.networkScope,
kind: request.kind,
cacheKey: request.kind === "query" ? getCacheKey(resolved, request, params) : undefined,
cacheTtlSeconds: request.cacheTtlSeconds,
cacheKey: request.kind === "query" ? getCacheKey(resolved, request, values) : undefined,
cacheTtlSeconds: request.cacheSeconds,
}),
);
};
@@ -177,16 +181,15 @@ const executeRequest = async (
export const customApiRouter = createTRPCRouter({
getData: publicProcedure.input(itemInputSchema).query(async ({ ctx, input }) => {
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
const loadRequests = resolved.definition.requests.filter(
(request) => request.kind === "query" && request.trigger === "load",
const loadRequests = Object.entries(resolved.definition.requests).filter(
([, request]) => request.kind === "query" && request.trigger === "load",
);
const entries = await Promise.all(
loadRequests.map(async (request) => {
loadRequests.map(async ([requestId, request]) => {
try {
const params = resolveCustomWidgetOptionsBinding(request, resolved.configuration);
const response = await executeRequest(ctx, resolved, request, params);
const response = await executeRequest(ctx, resolved, { id: requestId, ...request }, {});
return [
request.id,
requestId,
{
data: response.data,
status: {
@@ -200,7 +203,7 @@ export const customApiRouter = createTRPCRouter({
] as const;
} catch (error) {
return [
request.id,
requestId,
{
data: null,
status: {
@@ -221,13 +224,13 @@ export const customApiRouter = createTRPCRouter({
data: Object.fromEntries(entries.map(([id, result]) => [id, result.data])),
status: Object.fromEntries(entries.map(([id, result]) => [id, result.status])),
options: resolved.configuration,
requestCapabilities: resolved.definition.requests.map((request) => ({
id: request.id,
requestCapabilities: Object.entries(resolved.definition.requests).map(([id, request]) => ({
id,
kind: request.kind,
method: request.method,
trigger: request.trigger,
minimumBoardPermission: request.minimumBoardPermission,
confirmation: request.confirmation,
minimumBoardPermission: request.permission,
confirmation: getCustomWidgetConfirmation(request),
invalidates: request.invalidates ?? [],
})),
};
+4 -4
View File
@@ -17,7 +17,6 @@
"./catalog": "./src/core/catalog.ts",
"./catalog-example": "./src/core/component-example.ts",
"./core": "./src/core/index.ts",
"./embedded-authoring-prompt": "./src/core/embedded-ai-prompt.ts",
"./jsx": "./src/jsx/index.ts",
"./runtime": "./src/runtime/index.ts",
"./server": "./src/server/index.ts",
@@ -27,10 +26,11 @@
"clean": "rm -rf .turbo node_modules",
"check:architecture": "node ./scripts/check-architecture.mjs",
"generate:catalog": "tsx ./scripts/generate-component-metadata.ts",
"generate:skill": "tsx ./scripts/generate-skill-content.ts",
"generate:components": "pnpm generate:catalog",
"generate:component-docs": "tsx ./scripts/generate-component-docs.ts",
"generate": "pnpm generate:catalog && pnpm generate:component-docs && pnpm generate:skill",
"generate": "pnpm generate:catalog",
"evaluate:ai": "tsx ./scripts/evaluate-ai-authoring.ts",
"test:ai": "tsx ./scripts/evaluate-ai-authoring.ts --ci",
"test:reference-apis": "tsx ./scripts/verify-reference-apis.ts",
"format": "oxfmt .",
"lint": "oxlint",
"test": "vitest run",
@@ -0,0 +1,49 @@
export interface CustomWidgetAiEvaluationCase {
id: string;
request: string;
documentationUrl: string;
apiNotes: string;
}
export const CUSTOM_WIDGET_AI_EVALUATION_CASES: readonly CustomWidgetAiEvaluationCase[] = [
{
id: "pokedex",
documentationUrl: "https://pokeapi.co/docs/v2",
request:
"Create a polished Pokédex browser with searchable Pokémon, a responsive result grid, manual detail loading, sprites, types, abilities, base-stat progress bars, and clear loading, empty, and failure states.",
apiNotes:
"Use GET /api/v2/pokemon?limit=<option>&offset=<option> for the list and GET /api/v2/pokemon/{param:name} as a manual detail query. PokeAPI needs no authentication.",
},
{
id: "portainer-containers",
documentationUrl: "https://docs.portainer.io/api/examples",
request:
"Create an excellent Portainer container dashboard: environment option, running/stopped summary, responsive container list, health/status badges, and explicit start, stop, and restart actions with confirmation and refresh after success.",
apiNotes:
"Use X-API-Key auth. List containers with GET /api/endpoints/{option:endpointId}/docker/containers/json?all=true. Actions are POST /api/endpoints/{option:endpointId}/docker/containers/{param:id}/start, /stop, and /restart and invalidate the list query.",
},
{
id: "tautulli-activity",
documentationUrl: "https://github.com/Tautulli/Tautulli/wiki/Tautulli-API-Reference",
request:
"Create a beautiful Tautulli activity widget with active stream cards, user/player details, progress, transcode/direct-play badges, bandwidth summary, and a refresh control that works in narrow and wide tiles.",
apiNotes:
"Use an apiKeyQuery source with parameter name apikey. Query GET /api/v2 with query cmd=get_activity. The response payload is under response.data and sessions is an array.",
},
{
id: "bambubuddy-printer",
documentationUrl: "https://wiki.bambuddy.cool/reference/api/",
request:
"Create a premium BambuBuddy printer status widget with printer selector, current job, progress, remaining time, nozzle and bed temperatures, connection state, and safe pause/resume/stop controls when supported.",
apiNotes:
"Use X-API-Key auth and base path /api/v1. GET /printers lists printers and GET /printers/{id}/status returns state, progress, remaining_time, temperatures.nozzle, temperatures.bed, and hms_status. The official reference does not document pause, resume, or stop endpoints, so omit those actions. Configure the selected printer with choicesFrom on a widget option and use that option in the status path.",
},
{
id: "home-assistant-control",
documentationUrl: "https://developers.home-assistant.io/docs/api/rest/",
request:
"Create a refined Home Assistant room widget with temperature and humidity readings, light status, a room/entity option, and an actionable light toggle. Use calm hierarchy and responsive controls, not a pile of nested cards.",
apiNotes:
"Use bearer auth. GET /api/states/{option:sensorEntity} and /api/states/{option:lightEntity} load entity state. POST /api/services/light/turn_on and /turn_off accept a body with entity_id from an option and should invalidate the light query.",
},
] as const;
@@ -0,0 +1,235 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { z } from "zod/v4";
import { buildCustomWidgetAiPrompt } from "../src/core/ai-prompt";
import { customWidgetDefinitionSchema } from "../src/core/custom-jsx-schema";
import type { HomarrCustomWidgetV2 } from "../src/core/custom-jsx-schema";
import { formatCustomWidgetImportIssues, parseCustomWidgetAiResponse } from "../src/core/import";
import type { CustomWidgetAiEvaluationCase } from "./ai-evaluation-cases";
export const DEFAULT_GENERATOR_MODEL = "deepseek/deepseek-v4-pro";
export const DEFAULT_JUDGE_MODEL = "deepseek/deepseek-v4-flash";
export const MAX_AI_EVALUATION_LOOPS = 10;
const scoreSchema = z.number().int().min(0).max(100);
const judgeResultSchema = z.strictObject({
total: scoreSchema,
verdict: z.enum(["pass", "fail"]),
categories: z.strictObject({
schemaAndBindings: scoreSchema,
apiAndRequestDesign: scoreSchema,
runtimeCompatibility: scoreSchema,
visualHierarchy: scoreSchema,
responsiveAndTheme: scoreSchema,
loadingEmptyErrorSuccess: scoreSchema,
accessibility: scoreSchema,
actionSafety: scoreSchema,
simplicity: scoreSchema,
}),
strengths: z.array(z.string()).max(8),
problems: z.array(z.string()).max(12),
highestImpactFixes: z.array(z.string()).max(8),
});
export type CustomWidgetJudgeResult = z.infer<typeof judgeResultSchema>;
export interface AiEvaluationResult {
caseId: string;
attempts: number;
widget: HomarrCustomWidgetV2 | null;
judge: CustomWidgetJudgeResult | null;
outputDirectory: string;
errors: string[];
}
interface OpenRouterResponse {
choices?: Array<{ message?: { content?: string | null } }>;
error?: { message?: string };
}
export function buildEvaluationPrompt(testCase: CustomWidgetAiEvaluationCase): string {
return buildCustomWidgetAiPrompt(
undefined,
null,
null,
`${testCase.request}\n\nVerified API notes:\n${testCase.apiNotes}`,
testCase.documentationUrl,
);
}
export function buildRepairPrompt(
originalPrompt: string,
previousResponse: string,
issues: readonly { path?: Array<string | number>; message: string }[],
): string {
const diagnostics = issues
.map((issue) => `${issue.path?.length ? `${issue.path.join(".")}: ` : ""}${issue.message}`)
.join("\n");
return `${originalPrompt}\n\nYour previous response did not validate. Correct only the generic contract/runtime problems below while preserving a polished design.\n\nDiagnostics:\n${diagnostics}\n\nPrevious response:\n${previousResponse}`;
}
export function buildJudgePrompt(testCase: CustomWidgetAiEvaluationCase, widget: HomarrCustomWidgetV2): string {
return `You are a strict evaluator for a safe dashboard-widget language. Judge the widget against the request and API notes. Do not reward unsupported capabilities, invented API routes, or visual claims not present in the JSX.
Request:
${testCase.request}
Verified API notes:
${testCase.apiNotes}
Validated widget:
${JSON.stringify(widget, null, 2)}
Score every category from 0 to 100. The total must reflect the whole result. A pass requires total >= 80 and no category below 65. Return JSON only with this exact shape:
{"total":0,"verdict":"fail","categories":{"schemaAndBindings":0,"apiAndRequestDesign":0,"runtimeCompatibility":0,"visualHierarchy":0,"responsiveAndTheme":0,"loadingEmptyErrorSuccess":0,"accessibility":0,"actionSafety":0,"simplicity":0},"strengths":[],"problems":[],"highestImpactFixes":[]}`;
}
export function judgePasses(result: CustomWidgetJudgeResult): boolean {
return result.total >= 80 && Object.values(result.categories).every((score) => score >= 65);
}
export async function evaluateCustomWidgetCase(args: {
testCase: CustomWidgetAiEvaluationCase;
apiKey: string;
outputRoot: string;
maxLoops: number;
generatorModel?: string;
judgeModel?: string;
}): Promise<AiEvaluationResult> {
const caseDirectory = path.join(args.outputRoot, args.testCase.id);
await mkdir(caseDirectory, { recursive: true });
const originalPrompt = buildEvaluationPrompt(args.testCase);
await writeFile(path.join(caseDirectory, "prompt.md"), originalPrompt, "utf8");
let prompt = originalPrompt;
const errors: string[] = [];
let bestWidget: HomarrCustomWidgetV2 | null = null;
let bestJudge: CustomWidgetJudgeResult | null = null;
for (let attempt = 1; attempt <= Math.min(args.maxLoops, MAX_AI_EVALUATION_LOOPS); attempt += 1) {
let response: string;
try {
response = await callOpenRouter({
apiKey: args.apiKey,
model: args.generatorModel ?? DEFAULT_GENERATOR_MODEL,
prompt,
json: false,
});
} catch (error) {
errors.push(
`Attempt ${attempt}: generator request failed — ${error instanceof Error ? error.message : "Unknown error"}`,
);
continue;
}
await writeFile(path.join(caseDirectory, `attempt-${attempt}.md`), response, "utf8");
const parsed = parseCustomWidgetAiResponse(response);
if (!parsed.success) {
const message = formatCustomWidgetImportIssues(parsed.issues);
errors.push(`Attempt ${attempt}: ${message}`);
prompt = buildRepairPrompt(originalPrompt, response, parsed.issues);
continue;
}
const canonical = customWidgetDefinitionSchema.parse(parsed.widget);
await writeWidgetFiles(caseDirectory, canonical, `attempt-${attempt}`);
let judge: CustomWidgetJudgeResult;
try {
const judgeRaw = await callOpenRouter({
apiKey: args.apiKey,
model: args.judgeModel ?? DEFAULT_JUDGE_MODEL,
prompt: buildJudgePrompt(args.testCase, canonical),
json: true,
});
await writeFile(path.join(caseDirectory, `judge-${attempt}.json`), judgeRaw, "utf8");
judge = parseJudgeResult(judgeRaw);
} catch (error) {
errors.push(
`Attempt ${attempt}: judge response invalid — ${error instanceof Error ? error.message : "Unknown error"}`,
);
continue;
}
if (!bestJudge || judge.total > bestJudge.total) {
bestJudge = judge;
bestWidget = canonical;
await writeWidgetFiles(caseDirectory, canonical, "best");
await writeFile(path.join(caseDirectory, "best-judge.json"), JSON.stringify(judge, null, 2), "utf8");
}
if (judgePasses(judge)) {
await writeFile(path.join(caseDirectory, "result.json"), JSON.stringify(judge, null, 2), "utf8");
return {
caseId: args.testCase.id,
attempts: attempt,
widget: canonical,
judge,
outputDirectory: caseDirectory,
errors,
};
}
errors.push(`Attempt ${attempt}: judge ${judge.total}/100 — ${judge.highestImpactFixes.join("; ")}`);
prompt = buildRepairPrompt(
originalPrompt,
response,
judge.highestImpactFixes.map((message) => ({ message })),
);
}
return {
caseId: args.testCase.id,
attempts: Math.min(args.maxLoops, MAX_AI_EVALUATION_LOOPS),
widget: bestWidget,
judge: bestJudge,
outputDirectory: caseDirectory,
errors,
};
}
export function parseJudgeResult(raw: string): CustomWidgetJudgeResult {
const fenced = /```(?:json)?\s*([\s\S]*?)```/iu.exec(raw)?.[1];
const parsed: unknown = JSON.parse(fenced ?? raw);
const result = judgeResultSchema.parse(parsed);
const normalized = {
...result,
total: Math.round(Object.values(result.categories).reduce((sum, score) => sum + score, 0) / 9),
};
return { ...normalized, verdict: judgePasses(normalized) ? "pass" : "fail" };
}
async function writeWidgetFiles(directory: string, widget: HomarrCustomWidgetV2, basename: string) {
const { template, ...manifest } = widget;
await Promise.all([
writeFile(
path.join(directory, `${basename}.widget.json`),
JSON.stringify({ ...manifest, template: "__HOMARR_TEMPLATE__" }, null, 2),
"utf8",
),
writeFile(path.join(directory, `${basename}.widget.jsx`), template, "utf8"),
]);
}
async function callOpenRouter(args: { apiKey: string; model: string; prompt: string; json: boolean }): Promise<string> {
const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${args.apiKey}`,
"Content-Type": "application/json",
"HTTP-Referer": "https://homarr.dev",
"X-Title": "Homarr Custom Widget AI Evaluation",
},
body: JSON.stringify({
model: args.model,
messages: [{ role: "user", content: args.prompt }],
temperature: args.json ? 0 : 0.25,
max_tokens: args.json ? 8_000 : 20_000,
...(args.json ? { reasoning: { effort: "low", exclude: true } } : {}),
...(args.json ? { response_format: { type: "json_object" } } : {}),
}),
signal: AbortSignal.timeout(180_000),
});
const payload = (await response.json()) as OpenRouterResponse;
if (!response.ok)
throw new Error(`OpenRouter request failed (${response.status}): ${payload.error?.message ?? "Unknown error"}`);
const content = payload.choices?.[0]?.message?.content;
if (!content) throw new Error("OpenRouter returned no message content");
return content;
}
@@ -1,326 +0,0 @@
import type {
CustomJsxAuthoringCatalog,
CustomJsxComponentApi,
CustomJsxLiteralValue,
CustomJsxPropDescriptor,
} from "../src/core/component-catalog-types";
import type { CustomJsxComponentPackage } from "../src/core/component-types";
export const GENERATED_COMPONENT_REFERENCE_PATHS = ["components.md", "homarr-components.md"] as const;
export type GeneratedComponentReferencePath = (typeof GENERATED_COMPONENT_REFERENCE_PATHS)[number];
const packageReferences = [
{
packageName: "@mantine/core",
path: "mantine-core.md",
title: "Mantine Core",
},
{
packageName: "@mantine/dates",
path: "mantine-dates.md",
title: "Mantine Dates",
},
{
packageName: "@mantine/charts",
path: "mantine-charts.md",
title: "Mantine Charts",
},
{
packageName: "@homarr/widgets",
path: "homarr-components.md",
title: "Homarr components",
},
] as const satisfies readonly {
packageName: CustomJsxComponentPackage;
path: string;
title: string;
}[];
export function renderCustomWidgetComponentReferences(
catalog: Readonly<CustomJsxAuthoringCatalog>,
): Readonly<Record<GeneratedComponentReferencePath, string>> {
validateCatalogReferences(catalog);
const references = Object.fromEntries([
["components.md", renderCatalogOverview(catalog)],
["homarr-components.md", renderPackageReference(catalog, "@homarr/widgets", "Homarr components")],
]) as Record<GeneratedComponentReferencePath, string>;
return references;
}
export function getComponentReferenceAnchor(name: string): string {
return `component-${[...name]
.map((character) =>
/^[a-z0-9-]$/iu.test(character) ? character.toLowerCase() : `-${character.codePointAt(0)?.toString(16)}-`,
)
.join("")}`;
}
function renderCatalogOverview(catalog: Readonly<CustomJsxAuthoringCatalog>): string {
const lines = [
"# Component catalog",
"",
generatedNotice(catalog),
"",
"This is the complete Custom JSX component boundary for this Homarr release. The machine-readable `component-catalog.json` contains exact generated component, prop, binding, and safety metadata; these Markdown pages provide the human-readable inventory and upstream Mantine indexes. Ordinary serializable props pass through unless the capability boundary explicitly blocks them.",
"",
"## Package references",
"",
...packageReferences.map(({ packageName, path, title }) => {
const components = catalog.components.filter((component) => component.package === packageName);
const enabledCount = components.filter((component) => component.safety !== "denied").length;
const deniedCount = components.length - enabledCount;
return `- [${title}](${path}) — ${enabledCount} enabled, ${deniedCount} denied`;
}),
"",
"## Authoring boundary",
"",
"- Use the exact component names below. Compound names such as `Tabs.List`, `Card.Section`, and `Radio.Group` are distinct catalog entries.",
"- Use the official Mantine LLM links for component props and examples. Homarr's live catalog is authoritative for availability, bindings, and blocked capabilities.",
'- Use `bind="name"` only when a component record declares bind metadata. Read the temporary in-memory value from `inputs.name`; it is never persisted.',
"- Ordinary authored callbacks remain blocked. Restricted collection callbacks, directly invoked zero-argument derived-value IIFEs, and `RecursiveList`'s child template may use the immutable local `const` grammar in [JSX runtime](runtime.md#immutable-local-bindings).",
`- Use [\`RecursiveList\`](homarr-components.md#${getComponentReferenceAnchor("RecursiveList")}) for custom arbitrary-depth trees. Do not author recursion or pass a renderer callback to Mantine Tree.`,
"- Unknown ordinary Mantine props are advisory and are not silently discarded. Every capability listed in the blocked table is rejected.",
"",
"## Complete enabled inventory",
"",
...renderInventoryByPackage(catalog, false),
"",
"## Complete denied inventory",
"",
...renderInventoryByPackage(catalog, true),
"",
"## Blocked capabilities",
"",
"| Kind | Name | Reason |",
"| --- | --- | --- |",
...catalog.blockedCapabilities.map(
(capability) =>
`| ${escapeTableCell(capability.kind)} | ${renderCode(capability.name)} | ${escapeTableCell(capability.reason)} |`,
),
"",
];
return `${lines.join("\n").trimEnd()}\n`;
}
function renderPackageReference(
catalog: Readonly<CustomJsxAuthoringCatalog>,
packageName: CustomJsxComponentPackage,
title: string,
): string {
const components = catalog.components.filter((component) => component.package === packageName);
const enabled = components.filter((component) => component.safety !== "denied");
const denied = components.filter((component) => component.safety === "denied");
const lines = [
`# ${title}`,
"",
generatedNotice(catalog),
"",
`Package: \`${packageName}\`. The component-specific props below supplement ordinary safe serializable Mantine props and style props. Component-specific records override ordinary behavior with the same name.`,
"",
...packageGuidance(packageName),
"",
"## Enabled inventory",
"",
enabled.length > 0 ? renderCodeList(enabled.map(({ name }) => name)) : "_None._",
"",
"## Denied inventory",
"",
denied.length > 0 ? renderCodeList(denied.map(({ name }) => name)) : "_None._",
"",
"## Component records",
"",
...components.flatMap((component) => renderComponentRecord(catalog, component)),
];
return `${lines.join("\n").trimEnd()}\n`;
}
function renderComponentRecord(
catalog: Readonly<CustomJsxAuthoringCatalog>,
component: Readonly<CustomJsxComponentApi>,
): string[] {
const details = [
`<a id="${getComponentReferenceAnchor(component.name)}"></a>`,
`### \`${component.name}\``,
"",
`- Safety: \`${component.safety}\``,
`- Category: \`${component.category}\``,
`- Documentation: [upstream component API](${component.documentationUrl})`,
`- Temporary bind: ${renderBinding(component)}`,
`- Subcomponents: ${component.subcomponents.length > 0 ? renderCodeList(component.subcomponents) : "none"}`,
`- Component-specific blocked props: ${
component.blockedProps.length > 0
? component.blockedProps
.map(({ name, reason }) => `${renderCode(name)} — ${escapeTableCell(reason)}`)
.join(", ")
: "none"
}`,
"- Accessibility:",
...(component.accessibilityRequirements.length > 0
? component.accessibilityRequirements.map((requirement) => ` - ${requirement}`)
: [" - No additional catalog requirement; preserve semantic labels and text alternatives."]),
];
if (component.description) details.push("", component.description);
if (component.deniedReason) details.push("", `Denied reason: ${component.deniedReason}.`);
details.push("", "#### Component-specific props", "");
if (component.props.length === 0) {
details.push(
component.safety === "denied"
? "_No authorable props because this component is denied._"
: "_No component-specific props. Use the shared prop API._",
);
} else {
details.push(renderPropTable(catalog, component.props));
}
details.push("");
return details;
}
function renderPropTable(
catalog: Readonly<CustomJsxAuthoringCatalog>,
props: readonly Readonly<CustomJsxPropDescriptor>[],
): string {
const lines = [
"| Prop | Required | Type | Literal values | Description |",
"| --- | --- | --- | --- | --- |",
...props.map((prop) => {
const type = catalog.types[prop.typeRef];
if (type === undefined) throw new Error(`Prop '${prop.name}' references missing type ${prop.typeRef}`);
return `| ${renderCode(prop.name)} | ${prop.required ? "yes" : "no"} | ${renderCode(type)} | ${renderLiteralValues(prop.literalValues)} | ${escapeTableCell(prop.description ?? "—")} |`;
}),
];
return lines.join("\n");
}
function renderInventoryByPackage(catalog: Readonly<CustomJsxAuthoringCatalog>, denied: boolean): string[] {
return packageReferences.flatMap(({ packageName, path, title }) => {
const components = catalog.components.filter(
(component) => component.package === packageName && (component.safety === "denied") === denied,
);
return [
`### ${title}`,
"",
components.length > 0
? components
.map((component) => {
const target =
component.package === "@homarr/widgets"
? `${path}#${getComponentReferenceAnchor(component.name)}`
: component.documentationUrl;
return `[\`${escapeMarkdownLinkLabel(component.name)}\`](${target})${component.deniedReason ? ` — ${component.deniedReason}` : ""}`;
})
.join(", ")
: "_None._",
"",
];
});
}
function packageGuidance(packageName: CustomJsxComponentPackage): string[] {
switch (packageName) {
case "@mantine/core":
return [
"## Package guidance",
"",
"Use declarative props and `bind` adapters instead of event callbacks. Homarr scopes supported overlays to the widget root. Use Homarr's `RecursiveList` when a tree needs a custom arbitrary-depth node template.",
];
case "@mantine/dates":
return [
"## Package guidance",
"",
"Date bindings are temporary serialized inputs. Persisted locale, time-zone, and date choices belong in widget options. Include a textual date or agenda when a visual date control carries meaning.",
];
case "@mantine/charts":
return [
"## Package guidance",
"",
"Pass sanitized JSON data through serializable props. Use [immutable local `const` blocks](runtime.md#immutable-local-bindings) for repeated derived chart values; authored formatters and renderer callbacks remain blocked. Include a textual summary and do not rely on color alone.",
];
case "@homarr/widgets":
return [
"## Package guidance",
"",
"Homarr components own controlled request, action, pagination, disclosure, icon, and recursive traversal behavior. Manual request parameters come from the invoking component's `params` prop. `RecursiveList` is the only supported arbitrary-depth authored renderer and its child template may use the restricted safe-block grammar.",
];
}
}
function renderBinding(component: Readonly<CustomJsxComponentApi>): string {
if (!component.bind) return "not available";
const conditionalType = getConditionalBindingType(component.name);
return `\`${component.bind.type}\` via \`${component.bind.initialProp}\`; read from \`inputs.<bindName>\`${conditionalType ? `; ${conditionalType}` : ""}`;
}
function getConditionalBindingType(componentName: string): string | null {
if (componentName === "Accordion") return "becomes `string[]` when `multiple` is true";
if (["Chip.Group", "ChipGroup"].includes(componentName)) return "becomes `string[]` when `multiple` is true";
if (componentName === "TreeSelect") return "becomes `string[]` when `mode` is `multiple` or `checkbox`";
if (
["DatePicker", "DatePickerInput", "MonthPicker", "MonthPickerInput", "YearPicker", "YearPickerInput"].includes(
componentName,
)
) {
return "becomes `string[]` when `type` is `multiple` or `range`";
}
if (["DateTimePicker", "InlineDateTimePicker"].includes(componentName))
return "becomes `string[]` when `type` is `range`; `multiple` is not supported";
return null;
}
function renderLiteralValues(values: readonly CustomJsxLiteralValue[] | undefined): string {
return values && values.length > 0
? values.map((value) => renderCode(JSON.stringify(value) ?? "null")).join(", ")
: "—";
}
function renderCodeList(values: readonly string[]): string {
return values.map(renderCode).join(", ");
}
function renderCode(value: string): string {
return `<code>${escapeHtml(value)}</code>`;
}
function escapeTableCell(value: string): string {
return escapeHtml(value).replaceAll("|", "&#124;");
}
function escapeHtml(value: string): string {
return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("|", "&#124;");
}
function escapeMarkdownLinkLabel(value: string): string {
return value.replaceAll("[", "\\[").replaceAll("]", "\\]");
}
function generatedNotice(catalog: Readonly<CustomJsxAuthoringCatalog>): string {
return `> Generated from \`component-catalog-v${catalog.schemaVersion}.json\`. Catalog schema v${catalog.schemaVersion} · Custom Widget ${catalog.customWidgetVersion} · Mantine ${catalog.mantineVersion}. Do not edit this file by hand.`;
}
function validateCatalogReferences(catalog: Readonly<CustomJsxAuthoringCatalog>) {
const componentNames = catalog.components.map(({ name }) => name);
if (new Set(componentNames).size !== componentNames.length) throw new Error("Catalog component names must be unique");
for (const component of catalog.components) {
if (!packageReferences.some(({ packageName }) => packageName === component.package)) {
throw new Error(`Catalog component '${component.name}' uses unsupported package '${component.package}'`);
}
for (const prop of component.props) assertPropType(catalog, prop, `${component.name}.${prop.name}`);
}
for (const prop of catalog.globalProps) assertPropType(catalog, prop, prop.name);
}
function assertPropType(
catalog: Readonly<CustomJsxAuthoringCatalog>,
prop: Readonly<CustomJsxPropDescriptor>,
label: string,
) {
if (!Number.isSafeInteger(prop.typeRef) || catalog.types[prop.typeRef] === undefined) {
throw new Error(`Catalog prop '${label}' references missing type ${prop.typeRef}`);
}
}
@@ -0,0 +1,66 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { CUSTOM_WIDGET_AI_EVALUATION_CASES } from "./ai-evaluation-cases";
import { DEFAULT_GENERATOR_MODEL, DEFAULT_JUDGE_MODEL, evaluateCustomWidgetCase } from "./ai-evaluation";
import type { AiEvaluationResult } from "./ai-evaluation";
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) throw new Error("OPENROUTER_API_KEY is required for the live Custom Widget AI evaluation");
const requestedCase = process.argv.find((value) => value.startsWith("--case="))?.slice("--case=".length);
const configuredLoops = Number(process.env.CUSTOM_WIDGET_AI_MAX_LOOPS ?? 10);
const totalLoopBudget = Number.isInteger(configuredLoops) && configuredLoops > 0 ? Math.min(configuredLoops, 10) : 10;
const selectedCases = requestedCase
? CUSTOM_WIDGET_AI_EVALUATION_CASES.filter((testCase) => testCase.id === requestedCase)
: CUSTOM_WIDGET_AI_EVALUATION_CASES;
if (selectedCases.length === 0) throw new Error(`Unknown AI evaluation case '${requestedCase}'`);
if (totalLoopBudget < selectedCases.length) {
throw new Error(`CUSTOM_WIDGET_AI_MAX_LOOPS must be at least ${selectedCases.length} for this run`);
}
const runId = new Date().toISOString().replaceAll(/[:.]/gu, "-");
const outputRoot = path.resolve(process.cwd(), ".ai-evaluations", runId);
await mkdir(outputRoot, { recursive: true });
const results: AiEvaluationResult[] = [];
let remainingLoopBudget = totalLoopBudget;
for (const testCase of selectedCases) {
const remainingCases = selectedCases.length - results.length;
const maxLoops = Math.max(1, Math.floor(remainingLoopBudget / remainingCases));
process.stdout.write(`Evaluating ${testCase.id}...\n`);
const result = await evaluateCustomWidgetCase({
testCase,
apiKey,
outputRoot,
maxLoops,
generatorModel: process.env.OPENROUTER_GENERATOR_MODEL ?? DEFAULT_GENERATOR_MODEL,
judgeModel: process.env.OPENROUTER_JUDGE_MODEL ?? DEFAULT_JUDGE_MODEL,
});
results.push(result);
remainingLoopBudget -= result.attempts;
process.stdout.write(
result.judge
? ` ${result.judge.total}/100 after ${result.attempts} attempt(s)\n`
: ` failed after ${result.attempts} attempt(s)\n`,
);
}
const summary = {
generatedAt: new Date().toISOString(),
generatorModel: process.env.OPENROUTER_GENERATOR_MODEL ?? DEFAULT_GENERATOR_MODEL,
judgeModel: process.env.OPENROUTER_JUDGE_MODEL ?? DEFAULT_JUDGE_MODEL,
results: results.map((result) => ({
caseId: result.caseId,
attempts: result.attempts,
score: result.judge?.total ?? null,
verdict: result.judge?.verdict ?? "fail",
categories: result.judge?.categories ?? null,
errors: result.errors,
outputDirectory: path.relative(process.cwd(), result.outputDirectory),
})),
};
await writeFile(path.join(outputRoot, "summary.json"), JSON.stringify(summary, null, 2), "utf8");
process.stdout.write(`Results saved to ${path.relative(process.cwd(), outputRoot)}\n`);
if (results.some((result) => result.judge?.verdict !== "pass")) process.exitCode = 1;
@@ -1,20 +0,0 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import type { CustomJsxAuthoringCatalog } from "../src/core/component-catalog-types";
import { renderCustomWidgetComponentReferences } from "./component-docs";
const packageRoot = resolve(import.meta.dirname, "..");
const sourcePath = resolve(packageRoot, "src/core/component-catalog.generated.json");
const publicDirectory = resolve(packageRoot, "../../apps/docs/static/custom-widgets");
const publicPath = resolve(publicDirectory, "component-catalog-v1.json");
const skillReferenceDirectory = resolve(packageRoot, "../../.agents/skills/homarr-custom-widget/references");
const contents = await readFile(sourcePath, "utf8");
const catalog = JSON.parse(contents) as CustomJsxAuthoringCatalog;
const references = renderCustomWidgetComponentReferences(catalog);
await Promise.all([mkdir(publicDirectory, { recursive: true }), mkdir(skillReferenceDirectory, { recursive: true })]);
await Promise.all([
writeFile(publicPath, contents),
...Object.entries(references).map(([path, content]) => writeFile(resolve(skillReferenceDirectory, path), content)),
]);
@@ -151,7 +151,11 @@ async function generateCatalog() {
components,
};
await writeFile(resolve(packageRoot, "src/core/component-catalog.generated.json"), `${JSON.stringify(catalog)}\n`);
const serialized = `${JSON.stringify(catalog)}\n`;
await Promise.all([
writeFile(resolve(packageRoot, "src/core/component-catalog.generated.json"), serialized),
writeFile(resolve(repositoryRoot, "apps/docs/static/custom-widgets/component-catalog-v1.json"), serialized),
]);
}
function inspectMantineComponent(component: CatalogSourceComponent): InspectedComponent {
@@ -506,7 +510,6 @@ const HOMARR_COMPONENT_DEFINITIONS = [
{ name: "Collapsible", category: "interaction", safety: "allowed" },
{ name: "StatBar", category: "interaction", safety: "allowed" },
{ name: "TypeBadge", category: "interaction", safety: "allowed" },
{ name: "RecursiveList", category: "content", safety: "wrapped" },
{ name: "SubFetch", category: "network", safety: "wrapped" },
{ name: "SubData", category: "network", safety: "wrapped" },
{ name: "ActionButton", category: "network", safety: "wrapped" },
@@ -580,7 +583,6 @@ const HOMARR_COMPONENT_DESCRIPTIONS: Readonly<Record<string, string>> = {
Collapsible: "Shows and hides a bounded content section.",
StatBar: "Displays a compact labeled statistic and progress value.",
TypeBadge: "Displays a compact semantic type badge.",
RecursiveList: "Renders an arbitrary-depth tree through a bounded, Homarr-owned traversal.",
SubFetch: "Runs a named manual query with invocation-time parameters.",
SubData: "Reads a nested value from a named manual-query result.",
ActionButton: "Runs a named user-triggered action.",
@@ -627,17 +629,6 @@ const HOMARR_COMPONENT_PROPS: Readonly<Record<string, Readonly<Record<string, Ho
type: requiredProp("string"),
size: optionalProp("'xs' | 'sm' | 'md' | 'lg' | 'xl'", ["xs", "sm", "md", "lg", "xl"]),
},
RecursiveList: {
data: requiredProp("unknown"),
childrenPath: requiredProp("string"),
keyPath: requiredProp("string"),
maxDepth: optionalProp("number"),
maxNodes: optionalProp("number"),
defaultExpandedDepth: optionalProp("number"),
indent: optionalProp("MantineSpacing"),
gap: optionalProp("MantineSpacing"),
showLines: optionalProp("boolean"),
},
SubFetch: {
requestId: optionalProp("string", undefined, "Identifier of the named Custom Widget query."),
params: optionalProp("Record<string, string | number | boolean>"),
@@ -1,49 +0,0 @@
import { readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { customJsxAuthoringCatalog } from "../src/core/component-catalog";
import { customJsxExamples } from "../src/core/examples";
import { getCustomWidgetJsonSchema } from "../src/core/schema";
export const CUSTOM_WIDGET_SKILL_SOURCE_PATHS = [
"SKILL.md",
"references/schema.md",
"references/widget-schema.json",
"references/runtime.md",
"references/security.md",
"references/components.md",
"references/component-catalog.json",
"references/examples.json",
"references/mantine-core.md",
"references/mantine-dates.md",
"references/mantine-charts.md",
"references/homarr-components.md",
] as const;
const packageRoot = resolve(import.meta.dirname, "..");
const repositoryRoot = resolve(packageRoot, "../..");
const skillRoot = resolve(repositoryRoot, ".agents/skills/homarr-custom-widget");
const outputPath = resolve(packageRoot, "src/core/skill-content.generated.json");
await writeFile(
resolve(skillRoot, "references/widget-schema.json"),
`${JSON.stringify(getCustomWidgetJsonSchema(), null, 2)}\n`,
);
await writeFile(
resolve(skillRoot, "references/component-catalog.json"),
`${JSON.stringify(customJsxAuthoringCatalog)}\n`,
);
await writeFile(resolve(skillRoot, "references/examples.json"), `${JSON.stringify(customJsxExamples, null, 2)}\n`);
const files = await Promise.all(
CUSTOM_WIDGET_SKILL_SOURCE_PATHS.map(async (path) => ({
path,
content: normalizeMarkdown(await readFile(resolve(skillRoot, path), "utf8")),
})),
);
await writeFile(outputPath, `${JSON.stringify({ schemaVersion: 1, files }, null, 2)}\n`);
function normalizeMarkdown(value: string) {
return `${value.replace(/\r\n?/gu, "\n").trimEnd()}\n`;
}
@@ -0,0 +1,138 @@
import { customJsxRequestSchema } from "../src/core/request-schema";
import { executeCustomWidgetRequest } from "../src/server/request-executor";
import { renderRequestTarget, resolveCustomWidgetRequestValues } from "../src/server/request-manifest";
const pokemonListRequest = customJsxRequestSchema.parse({
path: "/api/v2/pokemon",
query: { limit: { $option: "limit" }, offset: 0 },
});
const pokemonDetailRequest = customJsxRequestSchema.parse({
path: "/api/v2/pokemon/{param:name}",
trigger: "manual",
});
const listTarget = renderRequestTarget(
"https://pokeapi.co",
pokemonListRequest,
resolveCustomWidgetRequestValues(pokemonListRequest, { limit: 3 }),
);
const list = await fetchJson(listTarget, {});
if (!isRecord(list) || !Array.isArray(list.results) || list.results.length !== 3) {
throw new Error("PokeAPI list response did not match the expected shape");
}
const firstName = isRecord(list.results[0]) && typeof list.results[0].name === "string" ? list.results[0].name : null;
if (!firstName) throw new Error("PokeAPI list did not contain a Pokémon name");
const detailTarget = renderRequestTarget(
"https://pokeapi.co",
pokemonDetailRequest,
resolveCustomWidgetRequestValues(pokemonDetailRequest, {}, { name: firstName }),
);
const detail = await fetchJson(detailTarget, {});
if (!isRecord(detail) || detail.name !== firstName || !Array.isArray(detail.stats)) {
throw new Error("PokeAPI detail response did not match the expected shape");
}
process.stdout.write("PokeAPI list and detail requests passed\n");
const portainerBaseUrl = process.env.PORTAINER_BASE_URL;
const portainerApiKey = process.env.PORTAINER_API_KEY;
if (!portainerBaseUrl || !portainerApiKey) {
if (process.argv.includes("--pokeapi-only")) {
process.stdout.write("Portainer check skipped (--pokeapi-only)\n");
process.exit(0);
}
throw new Error("PORTAINER_BASE_URL and PORTAINER_API_KEY are required for the Portainer reference check");
}
const endpointId = process.env.PORTAINER_ENDPOINT_ID ?? "2";
const containersRequest = customJsxRequestSchema.parse({
path: "/api/endpoints/{option:endpointId}/docker/containers/json",
query: { all: true },
});
const containersTarget = renderRequestTarget(
portainerBaseUrl,
containersRequest,
resolveCustomWidgetRequestValues(containersRequest, { endpointId }),
);
const containers = await fetchJson(containersTarget, { "X-API-Key": portainerApiKey });
if (!Array.isArray(containers)) throw new Error("Portainer containers response was not an array");
const actionRequests = ["start", "stop", "restart"].map((action) =>
customJsxRequestSchema.parse({
kind: "action",
method: "POST",
path: `/api/endpoints/{option:endpointId}/docker/containers/{param:id}/${action}`,
confirmation: `${action} this container?`,
invalidates: ["containers"],
}),
);
for (const request of actionRequests) {
const target = renderRequestTarget(
portainerBaseUrl,
request,
resolveCustomWidgetRequestValues(request, { endpointId }, { id: "container/id" }),
);
if (!target.pathname.includes("container%2Fid")) throw new Error("Portainer action target did not encode its ID");
}
process.stdout.write(
`Portainer container query passed (${containers.length} containers); start/stop/restart targets passed\n`,
);
const mutationContainerId = process.env.PORTAINER_TEST_CONTAINER_ID;
if (mutationContainerId) {
if (process.env.PORTAINER_ALLOW_MUTATIONS !== "1") {
throw new Error("Set PORTAINER_ALLOW_MUTATIONS=1 to test actions against PORTAINER_TEST_CONTAINER_ID");
}
const container = containers.find(
(candidate) =>
isRecord(candidate) && (candidate.Id === mutationContainerId || candidate.ID === mutationContainerId),
);
if (!isRecord(container)) throw new Error("PORTAINER_TEST_CONTAINER_ID was not returned by the container query");
const initiallyRunning =
container.State === "running" || (isRecord(container.State) && container.State.Running === true);
const actionOrder = initiallyRunning ? ["restart", "stop", "start"] : ["start", "restart", "stop"];
try {
for (const action of actionOrder) await runPortainerAction(action, mutationContainerId);
} finally {
await runPortainerAction(initiallyRunning ? "start" : "stop", mutationContainerId, true);
}
process.stdout.write(
`Portainer ${actionOrder.join("/")} actions passed and the original running state was restored\n`,
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
async function fetchJson(target: URL, headers: Record<string, string>): Promise<unknown> {
const response = await fetch(target, { headers, signal: AbortSignal.timeout(15_000) });
if (!response.ok) throw new Error(`Reference API returned HTTP ${response.status}`);
return response.json() as Promise<unknown>;
}
async function runPortainerAction(action: string, containerId: string, ignoreConflict = false) {
const request = customJsxRequestSchema.parse({
kind: "action",
method: "POST",
path: `/api/endpoints/{option:endpointId}/docker/containers/{param:id}/${action}`,
});
const target = renderRequestTarget(
portainerBaseUrl,
request,
resolveCustomWidgetRequestValues(request, { endpointId }, { id: containerId }),
);
const response = await executeCustomWidgetRequest({
baseUrl: portainerBaseUrl,
targetUrl: target,
method: "POST",
networkScope: "private",
kind: "action",
auth: {
type: "apiKeyHeader",
headerName: "X-API-Key",
secrets: [{ kind: "apiKey", value: portainerApiKey }],
},
});
if (!response.ok && !(ignoreConflict && response.status === 304)) {
throw new Error(`Portainer ${action} returned HTTP ${response.status}`);
}
}
+124 -54
View File
@@ -1,44 +1,131 @@
import type { HomarrCustomWidgetV2 } from "./custom-jsx-schema";
import { customJsxAuthoringCatalog } from "./component-catalog";
import { customJsxExamples } from "./examples";
export const CUSTOM_WIDGET_MANTINE_VERSION = customJsxAuthoringCatalog.mantineVersion;
export const CUSTOM_WIDGET_OFFLINE_BUNDLE_SENTINEL = "--- END HOMARR CUSTOM WIDGET OFFLINE BUNDLE ---";
const CUSTOM_WIDGET_AI_PROMPT_LIMIT = 8_000;
const CUSTOM_WIDGET_AI_PROMPT_LIMIT = 12_000;
export const CUSTOM_WIDGET_FINAL_OUTPUT_INSTRUCTION =
"Create the requested widget now and return exactly one complete `json` fenced block followed by one complete `jsx` fenced block. Homarr will validate the result after it is pasted into the workbench.";
"Return exactly one complete `json` fenced block followed by one complete `jsx` fenced block. Do not include prose outside those blocks.";
const AUTHORING_PROMPT = `You create Homarr Custom JSX v2 widgets. Return exactly two fenced blocks: a json block containing one complete widget object with template set to "__HOMARR_TEMPLATE__", then a jsx block containing the readable template.
const RECOMMENDED_COMPONENTS = [
"Stack",
"Group",
"SimpleGrid",
"Grid",
"Box",
"Center",
"Paper",
"Card",
"Card.Section",
"ScrollArea",
"Text",
"Title",
"Badge",
"Alert",
"Progress",
"RingProgress",
"ThemeIcon",
"Indicator",
"Avatar",
"Image",
"Divider",
"Skeleton",
"Loader",
"Table",
"Tabs",
"Tabs.List",
"Tabs.Tab",
"Tabs.Panel",
"Accordion",
"TextInput",
"NumberInput",
"Select",
"MultiSelect",
"Switch",
"Checkbox",
"Radio",
"Radio.Group",
"Radio.Card",
"Radio.Indicator",
"Slider",
"SegmentedControl",
"Button",
"ActionIcon",
"Tooltip",
"Popover",
"Calendar",
"AreaChart",
"BarChart",
"LineChart",
"DonutChart",
] as const;
Use $schema "homarr-custom-widget-v2". A widget contains metadata, inline API sources, named requests, an options schema, defaults, and one JSX template. Never include credentials. Use one source named "default" unless the widget genuinely combines APIs.
const leanShape = `{
"$schema": "homarr-custom-widget-v2",
"name": "Widget name",
"description": "Optional summary",
"sources": {
"default": { "name": "API", "baseUrl": "https://api.example.com", "networkScope": "public", "auth": "none" }
},
"requests": {
"items": { "path": "/items/{option:category}", "query": { "limit": { "$option": "limit" } } },
"update": { "kind": "action", "method": "POST", "path": "/items/{param:id}", "invalidates": ["items"] }
},
"options": {
"category": { "label": "Category", "control": "text", "default": "all" },
"limit": { "label": "Result limit", "control": "number", "default": 20, "min": 1, "max": 100 }
},
"template": "__HOMARR_TEMPLATE__"
}`;
This Homarr release uses Mantine ${CUSTOM_WIDGET_MANTINE_VERSION}. Use only the Homarr runtime components and Mantine capabilities described in this prompt. The embedded-skill variant includes the complete release-matched skill, widget schema, component catalog, and canonical examples for self-contained offline authoring.
function compactExample(index: number) {
const example = customJsxExamples[index];
if (!example) return "";
const { template, ...manifest } = example.widget;
return `Example — ${example.title}:\n\n\`\`\`json\n${JSON.stringify({ ...manifest, template: "__HOMARR_TEMPLATE__" }, null, 2)}\n\`\`\`\n\n\`\`\`jsx\n${template}\n\`\`\``;
}
Queries and actions may use GET, POST, PUT, PATCH, or DELETE. Set kind to query for reads and action for user-triggered changes. Actions never use trigger "load". Declare every path, query, or body parameter. Use {name} in paths and {"$param":"name"} in query/body templates. Every declared parameter needs an explicit value source: SubFetch, ActionButton, and ToggleSwitch supply manual request values through their params prop; every load-query parameter must have an optionsBinding entry containing {"$option":"optionName"} or a primitive literal. Never infer a binding from matching names. Use load-triggered query results through data.<requestId>.
const AUTHORING_PROMPT = `You are writing one safe Homarr Custom JSX v2 dashboard widget for Mantine ${CUSTOM_WIDGET_MANTINE_VERSION}.
Source authentication is exactly one of {"type":"none"}, {"type":"bearer"}, {"type":"basic"}, {"type":"apiKeyHeader","headerName":"X-API-Key"}, or {"type":"apiKeyQuery","parameterName":"api_key"}. Credentials are configured separately by the user.
Manifest contract:
${leanShape}
Templates can read data, status, options, and inputs. Each status.<requestId> is an object shaped { loading: boolean, ok?: boolean, status?: number, statusText?: string, error?: string }; never compare it with the strings "loading", "success", or "error". Use status.list?.loading while loading, status.list?.ok === false for failure, and status.list?.ok === true for success. Bind supported Mantine controls with bind="search" and read the temporary value as inputs.search. Bound inputs live only in memory and reset when the widget reloads; saved configuration belongs in options. Restricted callback blocks may declare immutable local const values followed by one final return. Use RecursiveList for arbitrary-depth trees instead of authored recursion. Use Mantine Core, Dates, and Charts components. Do not use imports, hooks, refs, raw event callback props, component/renderRoot, arbitrary portals, raw HTML, fetch, eval, npm packages, or credentials. Design for compact and wide dashboard sizes with useful loading, empty, and error states.
Sources are keyed by name and must include "default". Auth is "none", "bearer", "basic", {"type":"apiKeyHeader","name":"X-Api-Key"}, or {"type":"apiKeyQuery","name":"api_key"}. Never put credentials in the manifest.
Options use a restricted object JSON Schema. x-homarr controls include text, textarea, number, switch, select, multi-select, slider, date, time, color, icon, url, duration, timeZone, and json. Dynamic selects use x-homarr.optionsSource with requestId, optional itemsPath for wrapped arrays, valuePath, and labelPath.
Requests are keyed by ID. Defaults are source "default", kind "query", method "GET", query trigger "load", inherited auth, and permission "view" for queries or "modify" for actions. Actions are always manual. DELETE requires full permission and receives confirmation automatically. Use {option:name} or {"$option":"name"} for saved options. Use {param:name} or {"$param":"name"} only for invocation-time params supplied by SubFetch, ActionButton, or ToggleSwitch. Load queries cannot use params. Values and primitive types are inferred from references; do not declare parameters or option bindings. Paths and query values must be primitive; JSON bodies may bind structured options.
Widget shape:
{ "$schema":"homarr-custom-widget-v2", "name":"...", "description":"...", "iconUrl":"https://...", "sources":[{ "id":"default", "name":"API", "baseUrl":"https://host", "networkScope":"public|private|loopback", "auth":{ "type":"none|bearer|basic|apiKeyHeader|apiKeyQuery" } }], "requests":[{ "id":"data", "sourceId":"default", "kind":"query", "method":"GET", "pathTemplate":"/api/{id}", "parameters":{ "id":"string", "limit":"number" }, "optionsBinding":{ "id":{ "$option":"resourceId" }, "limit":20 }, "queryTemplate":{ "limit":{ "$param":"limit" } }, "auth":"inherit", "minimumBoardPermission":"view", "trigger":"load" }], "optionsSchema":{ "type":"object", "properties":{ "resourceId":{ "type":"string" } }, "required":["resourceId"], "additionalProperties":false }, "defaultOptions":{ "resourceId":"example" }, "template":"__HOMARR_TEMPLATE__" }
Options are keyed by name. Every option has label, control, and default. Controls: text, textarea, number, switch, select, multiSelect, slider, date, time, color, icon, url, duration, timeZone, json. Select choices use \`"choices": [{"label":"...","value":"..."}]\`. Dynamic choices must use \`"choicesFrom": {"request":"requestId","itemsPath":"optional.path","valuePath":"id","labelPath":"name"}\`, never an object under \`choices\`. Options are configured outside the widget and read through \`options.name\`; a bound control writes only to \`inputs.name\` and never changes an option.
Keep the result concise, accessible, responsive, and valid. Preserve unrelated fields when fixing an existing widget.`;
JSX reads data.requestId, status.requestId, options.name, and temporary inputs.name. A status has loading, ok, status, statusText, and error. Use bind="search" on supported controls and inputs.search in params. SubFetch invokes a manual query. With trigger="manual", it renders its own load button; never author onClick or a fetch callback. Its child callback is (result, meta), where meta has ok, status, statusText, loading=false, and no error because SubFetch renders loading/error states itself. Use expression callbacks for map, filter, sort, and SubFetch. Do not use imports, hooks, refs, raw HTML, event callbacks, fetch, eval, bigint, npm packages, authored const blocks, IIFEs, or recursion. Regex is only for bounded string matching/replacement. Do not embed secrets.
Hard syntax rule: never write \`=> {\` anywhere. Every callback must be one concise expression, for example \`items.map(item => <Card key={item.id}>...</Card>)\`. Inline derived values directly, even when that repeats a short expression. Never use an IIFE to create local variables or branch; use JSX ternaries instead. Callback parameter names must not shadow the reserved roots \`data\`, \`status\`, \`options\`, or \`inputs\`.
Use only API routes grounded in the user request, documentation, or verified API notes. When a requested mutation is undocumented, omit it and explain the limitation through the widget design rather than inventing an endpoint.
Recommended components: ${RECOMMENDED_COMPONENTS.join(", ")}. This list is not exhaustive. Standard Mantine compound names are encouraged. Runtime helpers: RefreshButton, SubFetch, ActionButton, ToggleSwitch, and <Icon name="tabler-icon-name" />.
Make the result genuinely attractive: establish clear visual hierarchy, use deliberate spacing, restrained semantic color, responsive layouts, and theme-safe colors. Prefer one strong primary surface over excessive nested cards. Include useful loading, empty, error, and success states. Make narrow and wide tiles both work.
Visual quality bar:
- Give the widget a purposeful header with title, useful context, and its primary status or action.
- When the data supports it, lead with 2–4 scannable summary metrics before detailed rows.
- Use responsive SimpleGrid/Grid column objects and let long content wrap on narrow tiles.
- Use theme tokens and semantic Mantine colors; avoid hard-coded light backgrounds and decorative gradients.
${compactExample(0)}
${compactExample(1)}
Output the manifest with template set exactly to "__HOMARR_TEMPLATE__", then put the complete template in the JSX block.`;
export const CUSTOM_WIDGET_AUTHORING_PROMPT = AUTHORING_PROMPT;
export const CUSTOM_WIDGET_MCP_AUTHORING_PROMPT = `Author one Homarr Custom JSX v2 widget at a time. Repeat this workflow independently when the user asks for several widgets.
export const CUSTOM_WIDGET_MCP_AUTHORING_PROMPT = `Author one Homarr Custom JSX v2 widget at a time.
1. Read homarr://custom-widgets/schema, homarr://custom-widgets/components, and homarr://custom-widgets/skill.
2. Read the external API documentation and construct one credential-free homarr-custom-widget-v2 document.
3. Call customWidget_validate before saving, then customWidget_previewCreate.
4. Test queries with customWidget_previewQuery and actions with customWidget_previewAction. Actions are simulated until the user explicitly enables live preview actions.
5. Open the preview URL, inspect customWidget_previewJournal, and iterate with customWidget_templatePatch or another preview session.
6. Use customWidget_secretSet only for a credential the user supplied and only with dedicated permission. Otherwise call customWidget_secretRequestUser and poll its completion status; never receive or repeat the plaintext.
7. Call customWidget_create only after validation, request tests, action simulation, and visual inspection pass.
Read the live schema and only the component resources needed for the design. Construct a credential-free widget, validate it, create a preview, test queries and simulated actions, visually inspect it, then create it. Request credentials through Homarr when needed and never repeat plaintext. The live resources are authoritative for this Homarr release.
Use inline sources, named queries/actions, restricted JSON Schema options, temporary declarative bind controls exposed through inputs, and safe Mantine JSX. Every load-query parameter needs an explicit optionsBinding option reference or literal; manual queries and actions receive all parameters from the invoking component's params prop. Never infer sources from matching names. Each status.<requestId> is an object with loading, ok, status, statusText, and error fields; never compare it with "loading", "success", or "error". Use kind, not method, to distinguish reads from user-triggered changes. Never include credentials, imports, hooks, refs, callbacks, browser requests, arbitrary JavaScript, npm packages, polymorphic roots, or arbitrary portals. Make the widget responsive, accessible, loading-aware, empty-aware, and error-aware. The live schema and component resources are authoritative for the installed Homarr release.`;
Use the lean keyed sources, requests, and options contract. Bind saved values directly with $option and invocation values with $param. Load queries cannot use $param. Keep the design responsive, accessible, theme-safe, loading-aware, empty-aware, and error-aware.`;
export function buildCustomWidgetMcpPrompt(request?: string | null, documentationUrl?: string | null) {
const sections = [CUSTOM_WIDGET_MCP_AUTHORING_PROMPT];
@@ -54,14 +141,11 @@ export function buildCustomWidgetAiPrompt(
request?: string | null,
documentationUrl?: string | null,
) {
const requestedWidget = truncatePromptText(
redactText(request?.trim() || "Describe the widget you want to create."),
1_500,
);
const sections = [`Please create this Homarr Custom JSX v2 widget:\n\n${requestedWidget}`];
const sections = [
`Create this Homarr Custom JSX v2 widget:\n\n${truncatePromptText(redactText(request?.trim() || "Describe the widget you want to create."), 1_500)}`,
];
if (documentationUrl) sections.push(`API documentation: ${truncatePromptText(redactUrl(documentationUrl), 500)}`);
sections.push(AUTHORING_PROMPT);
sections.push(CUSTOM_WIDGET_FINAL_OUTPUT_INSTRUCTION);
const optionalSections = [
...(rawResponse ? [{ label: "Sample API response", content: redactResponse(rawResponse) }] : []),
@@ -69,26 +153,14 @@ export function buildCustomWidgetAiPrompt(
? [{ label: "Current widget", content: JSON.stringify(redactValue(currentConfig), null, 2) }]
: []),
];
for (const [index, section] of optionalSections.entries()) {
const remainingSections = optionalSections.length - index;
const remainingCharacters = CUSTOM_WIDGET_AI_PROMPT_LIMIT - sections.join("\n\n").length - 2;
const sectionBudget = Math.floor(remainingCharacters / remainingSections);
const formatted = formatBudgetedCodeSection(section.label, section.content, sectionBudget);
if (formatted) sections.splice(-1, 0, formatted);
}
return sections.join("\n\n");
}
export function finalizeCustomWidgetOfflineContent(content: string) {
const footerPrefix = `${content.trimEnd()}\n\n${CUSTOM_WIDGET_OFFLINE_BUNDLE_SENTINEL}\nCharacters: `;
let digitCount = String(footerPrefix.length).length;
while (true) {
const characterCount = footerPrefix.length + digitCount;
const countText = String(characterCount);
if (countText.length === digitCount) return `${footerPrefix}${countText}`;
digitCount = countText.length;
for (const section of optionalSections) {
const remaining =
CUSTOM_WIDGET_AI_PROMPT_LIMIT - sections.join("\n\n").length - CUSTOM_WIDGET_FINAL_OUTPUT_INSTRUCTION.length - 8;
const formatted = formatBudgetedCodeSection(section.label, section.content, remaining);
if (formatted) sections.push(formatted);
}
const footer = `\n\n${CUSTOM_WIDGET_FINAL_OUTPUT_INSTRUCTION}`;
return `${truncatePromptText(sections.join("\n\n"), CUSTOM_WIDGET_AI_PROMPT_LIMIT - footer.length)}${footer}`;
}
const sensitiveKey =
@@ -97,17 +169,16 @@ const sensitiveKey =
function redactValue(value: unknown, key = ""): unknown {
if (sensitiveKey.test(key)) return "[REDACTED]";
if (Array.isArray(value)) return value.map((entry) => redactValue(entry));
if (value !== null && typeof value === "object") {
if (value !== null && typeof value === "object")
return Object.fromEntries(
Object.entries(value).map(([entryKey, entry]) => [entryKey, redactValue(entry, entryKey)]),
);
}
if (typeof value === "string") {
if (["sources", "requests", "optionsSchema", "defaultOptions"].includes(key)) {
if (["sources", "requests", "options"].includes(key)) {
try {
return redactValue(JSON.parse(value) as unknown);
} catch {
// Keep invalid editor JSON useful while applying the text redactor below.
/* Keep invalid editor JSON useful. */
}
}
return key === "baseUrl" || key === "iconUrl" ? redactUrl(value) : redactText(value);
@@ -119,7 +190,7 @@ function redactResponse(value: string) {
try {
return JSON.stringify(redactValue(JSON.parse(value) as unknown), null, 2);
} catch {
return "[REDACTED_URL]";
return truncatePromptText(redactText(value), 1_500);
}
}
@@ -150,8 +221,7 @@ function formatBudgetedCodeSection(label: string, content: string, budget: numbe
const prefix = `${label}:\n\n\`\`\`json\n`;
const suffix = "\n```";
if (budget <= prefix.length + suffix.length + 80) return null;
const contentBudget = budget - prefix.length - suffix.length;
return `${prefix}${truncatePromptText(content, contentBudget)}${suffix}`;
return `${prefix}${truncatePromptText(content, budget - prefix.length - suffix.length)}${suffix}`;
}
function truncatePromptText(value: string, limit: number): string {
@@ -1,11 +1,5 @@
import { customJsxAuthoringCatalog, getCustomJsxComponentProps } from "./component-catalog";
import { customJsxExamples } from "./examples";
import {
CUSTOM_WIDGET_OFFLINE_BUNDLE,
CUSTOM_WIDGET_SKILL_FILES,
CUSTOM_WIDGET_SKILL_MD,
CUSTOM_WIDGET_SKILL_VERSION,
} from "./embedded-ai-prompt";
import { customJsxTablerIconNames } from "./tabler-icons";
export const CUSTOM_WIDGET_SKILLS_SH_URL = "https://www.skills.sh/homarr-labs/homarr/homarr-custom-widget";
@@ -13,23 +7,47 @@ export const CUSTOM_WIDGET_SKILL_SOURCE_URL =
"https://github.com/homarr-labs/homarr/tree/HEAD/.agents/skills/homarr-custom-widget";
export const CUSTOM_WIDGET_SKILL_INSTALL_COMMAND =
"npx skills add https://github.com/homarr-labs/homarr --skill homarr-custom-widget";
export const CUSTOM_WIDGET_SKILL_VERSION = "2.0.0";
export const CUSTOM_WIDGET_SKILL_MD = `---
name: homarr-custom-widget
description: Author safe Homarr Custom JSX v2 widgets from API documentation.
---
# Homarr Custom Widget
Author one widget at a time. When connected to Homarr, retrieve \`homarr://custom-widgets/schema\` and only the component resources needed for the design. The live resources are authoritative for the installed release.
1. Read the API documentation.
2. Create one credential-free widget with keyed \`sources\`, keyed \`requests\`, optional keyed \`options\`, and safe JSX \`template\`.
3. Validate, preview, test queries and simulated actions, visually inspect, and iterate.
4. Request credentials through Homarr and never repeat plaintext.
5. Save only after narrow/wide, light/dark, loading, empty, error, and success states work.
Use \`{option:name}\` or \`$option\` for saved options. Use \`{param:name}\` or \`$param\` for values supplied by \`SubFetch\`, \`ActionButton\`, or \`ToggleSwitch\`. Load queries cannot use invocation parameters. Templates read \`data\`, \`status\`, \`options\`, and temporary \`inputs\`.
\`SubFetch\` with \`trigger="manual"\` renders its own load button. Its callback is \`(result, meta)\`; never author \`onClick\` or a fetch callback.
Use standard Mantine compound names and deliberate visual hierarchy, spacing, restrained semantic color, and responsive layouts. Avoid excessive nested cards.
Do not use imports, hooks, refs, raw HTML, raw event callbacks, browser requests, arbitrary functions, eval, bigint, npm packages, authored statement blocks, IIFEs, or recursion. Do not pretend MCP tools exist in an offline chat session.
When this skill is installed from the repository, optional offline details are in \`references/schema.md\`, \`references/runtime.md\`, and \`references/security.md\`. MCP-only clients should use the live Homarr resources instead.
`;
export function getCustomWidgetSkill() {
return {
name: "homarr-custom-widget",
version: CUSTOM_WIDGET_SKILL_VERSION,
skillMd: CUSTOM_WIDGET_SKILL_MD,
files: CUSTOM_WIDGET_SKILL_FILES,
componentCatalog: customJsxAuthoringCatalog,
examples: customJsxExamples,
skillsShUrl: CUSTOM_WIDGET_SKILLS_SH_URL,
sourceUrl: CUSTOM_WIDGET_SKILL_SOURCE_URL,
installCommand: CUSTOM_WIDGET_SKILL_INSTALL_COMMAND,
};
}
export function getCustomWidgetOfflineBundle() {
return CUSTOM_WIDGET_OFFLINE_BUNDLE;
export function getCustomWidgetSkillContent() {
return CUSTOM_WIDGET_SKILL_MD;
}
export function getCustomWidgetComponentCatalog() {
@@ -37,7 +55,8 @@ export function getCustomWidgetComponentCatalog() {
}
export function getCustomWidgetComponent(name: string) {
const component = customJsxAuthoringCatalog.components.find((candidate) => candidate.name === name);
const canonicalName = name === "Icon" ? "TablerIcon" : name;
const component = customJsxAuthoringCatalog.components.find((candidate) => candidate.name === canonicalName);
if (!component) return null;
return {
schemaVersion: customJsxAuthoringCatalog.schemaVersion,
@@ -48,7 +67,7 @@ export function getCustomWidgetComponent(name: string) {
category: component.category,
safety: component.safety,
description: component.description,
props: getCustomJsxComponentProps(name),
props: getCustomJsxComponentProps(canonicalName),
blockedProps: component.blockedProps,
bind: component.bind,
subcomponents: component.subcomponents,
@@ -56,7 +75,6 @@ export function getCustomWidgetComponent(name: string) {
documentationUrl: component.documentationUrl,
knownValues: component.name === "TablerIcon" ? { name: customJsxTablerIconNames } : undefined,
deniedReason: component.deniedReason,
blockedCapabilities: customJsxAuthoringCatalog.blockedCapabilities,
};
}
@@ -1,8 +1,8 @@
import type { HomarrCustomWidgetV2 } from "./custom-jsx-schema";
import type { HomarrCustomWidgetV2Input } from "./custom-jsx-schema";
export interface BundledCustomWidget {
id: "seed-dog-facts" | "seed-currency-exchange" | "seed-jellyfin" | "seed-pokedex";
widget: HomarrCustomWidgetV2;
id: "seed-dog-facts" | "seed-currency-exchange" | "seed-jellyfin" | "seed-pokedex" | "seed-tautulli-activity";
widget: HomarrCustomWidgetV2Input;
}
export const BUNDLED_CUSTOM_WIDGETS: readonly BundledCustomWidget[] = [
@@ -12,39 +12,12 @@ export const BUNDLED_CUSTOM_WIDGETS: readonly BundledCustomWidget[] = [
$schema: "homarr-custom-widget-v2",
name: "Random Dog Fact",
description: "Displays a random fun fact about dogs.",
sources: [
{
id: "default",
name: "Dog API",
baseUrl: "https://dogapi.dog",
networkScope: "public",
auth: { type: "none" },
},
],
requests: [
{
id: "fact",
sourceId: "default",
kind: "query",
method: "GET",
pathTemplate: "/api/v2/facts",
parameters: {},
auth: "inherit",
minimumBoardPermission: "view",
trigger: "load",
cacheTtlSeconds: 30,
},
],
optionsSchema: { type: "object", properties: {}, additionalProperties: false },
defaultOptions: {},
sources: { default: { name: "Dog API", baseUrl: "https://dogapi.dog", networkScope: "public", auth: "none" } },
requests: { fact: { path: "/api/v2/facts", cacheSeconds: 30 } },
options: {},
template: `<Stack gap="sm" p="sm" h="100%" justify="center">
<Group justify="space-between">
<Text fw={700}>Dog fact</Text>
<RefreshButton />
</Group>
{status.fact?.loading ? <Skeleton height={72} radius="md" /> :
status.fact?.error ? <Alert color="red">{status.fact.error}</Alert> :
<Text size="sm">{data.fact?.data?.[0]?.attributes?.body ?? "No dog fact was returned."}</Text>}
<Group justify="space-between"><Text fw={700}>Dog fact</Text><RefreshButton /></Group>
{status.fact?.loading ? <Skeleton height={72} radius="md" /> : status.fact?.error ? <Alert color="red">{status.fact.error}</Alert> : <Text size="sm">{data.fact?.data?.[0]?.attributes?.body ?? "No dog fact was returned."}</Text>}
</Stack>`,
},
},
@@ -54,56 +27,29 @@ export const BUNDLED_CUSTOM_WIDGETS: readonly BundledCustomWidget[] = [
$schema: "homarr-custom-widget-v2",
name: "Currency Exchange",
description: "Converts an amount using European Central Bank exchange rates.",
sources: [
{
id: "default",
name: "Frankfurter",
baseUrl: "https://api.frankfurter.dev",
networkScope: "public",
auth: { type: "none" },
},
],
requests: [
{
id: "rates",
sourceId: "default",
kind: "query",
method: "GET",
pathTemplate: "/v1/latest",
parameters: { from: "string", to: "string", amount: "number" },
optionsBinding: {
from: { $option: "from" },
to: { $option: "to" },
amount: { $option: "amount" },
},
queryTemplate: {
from: { $param: "from" },
to: { $param: "to" },
amount: { $param: "amount" },
},
auth: "inherit",
minimumBoardPermission: "view",
trigger: "load",
cacheTtlSeconds: 300,
},
],
optionsSchema: {
type: "object",
properties: {
from: { type: "string", title: "From currency", minLength: 3, maxLength: 3 },
to: { type: "string", title: "Target currencies", description: "Comma-separated currency codes" },
amount: { type: "number", title: "Amount", minimum: 0 },
},
required: ["from", "to", "amount"],
additionalProperties: false,
sources: {
default: { name: "Frankfurter", baseUrl: "https://api.frankfurter.dev", networkScope: "public", auth: "none" },
},
requests: {
rates: {
path: "/v1/latest",
query: { from: { $option: "from" }, to: { $option: "to" }, amount: { $option: "amount" } },
cacheSeconds: 300,
},
},
options: {
from: { label: "From currency", control: "text", default: "JPY" },
to: {
label: "Target currencies",
description: "Comma-separated currency codes",
control: "text",
default: "EUR,USD",
},
amount: { label: "Amount", control: "number", default: 50, min: 0 },
},
defaultOptions: { from: "JPY", to: "EUR,USD", amount: 50 },
template: `<Stack gap="sm" p="sm">
<Group justify="space-between"><Text fw={700}>{options.amount} {options.from}</Text><Badge>{data.rates?.date ?? "Latest"}</Badge></Group>
{status.rates?.loading ? <Skeleton height={80} radius="md" /> : status.rates?.error ? <Alert color="red">{status.rates.error}</Alert> :
<SimpleGrid cols={{ base: 1, xs: 2 }}>
{Object.entries(data.rates?.rates ?? {}).map(entry => <Paper key={entry[0]} withBorder p="sm"><Text size="xs" c="dimmed">{entry[0]}</Text><Text fw={700} size="xl">{entry[1]}</Text></Paper>)}
</SimpleGrid>}
{status.rates?.loading ? <Skeleton height={80} radius="md" /> : status.rates?.error ? <Alert color="red">{status.rates.error}</Alert> : <SimpleGrid cols={{ base: 1, xs: 2 }}>{Object.entries(data.rates?.rates ?? {}).map(entry => <Paper key={entry[0]} withBorder p="sm"><Text size="xs" c="dimmed">{entry[0]}</Text><Text fw={700} size="xl">{entry[1]}</Text></Paper>)}</SimpleGrid>}
</Stack>`,
},
},
@@ -114,37 +60,19 @@ export const BUNDLED_CUSTOM_WIDGETS: readonly BundledCustomWidget[] = [
name: "Jellyfin Library",
description: "Counts movies, series, episodes, and songs in a Jellyfin library.",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyfin.svg",
sources: [
{
id: "default",
sources: {
default: {
name: "Jellyfin",
baseUrl: "http://jellyfin.local",
networkScope: "private",
auth: { type: "apiKeyHeader", headerName: "X-Emby-Token" },
auth: { type: "apiKeyHeader", name: "X-Emby-Token" },
},
],
requests: [
{
id: "counts",
sourceId: "default",
kind: "query",
method: "GET",
pathTemplate: "/Items/Counts",
parameters: {},
auth: "inherit",
minimumBoardPermission: "view",
trigger: "load",
cacheTtlSeconds: 60,
},
],
optionsSchema: { type: "object", properties: {}, additionalProperties: false },
defaultOptions: {},
},
requests: { counts: { path: "/Items/Counts", cacheSeconds: 60 } },
options: {},
template: `<Stack gap="sm" p="sm">
<Group justify="space-between"><Text fw={700}>Jellyfin library</Text><RefreshButton /></Group>
{status.counts?.loading ? <Skeleton height={96} radius="md" /> : status.counts?.error ? <Alert color="red">{status.counts.error}</Alert> :
<SimpleGrid cols={{ base: 2, sm: 4 }}>
{[{ label: "Movies", value: data.counts?.MovieCount }, { label: "Series", value: data.counts?.SeriesCount }, { label: "Episodes", value: data.counts?.EpisodeCount }, { label: "Songs", value: data.counts?.SongCount }].map(item => <Paper key={item.label} withBorder p="sm"><Text size="xs" c="dimmed">{item.label}</Text><Text fw={700} size="xl">{item.value ?? 0}</Text></Paper>)}
</SimpleGrid>}
{status.counts?.loading ? <Skeleton height={96} radius="md" /> : status.counts?.error ? <Alert color="red">{status.counts.error}</Alert> : <SimpleGrid cols={{ base: 2, sm: 4 }}>{[{ label: "Movies", value: data.counts?.MovieCount }, { label: "Series", value: data.counts?.SeriesCount }, { label: "Episodes", value: data.counts?.EpisodeCount }, { label: "Songs", value: data.counts?.SongCount }].map(item => <Paper key={item.label} withBorder p="sm"><Text size="xs" c="dimmed">{item.label}</Text><Text fw={700} size="xl">{item.value ?? 0}</Text></Paper>)}</SimpleGrid>}
</Stack>`,
},
},
@@ -155,66 +83,57 @@ export const BUNDLED_CUSTOM_WIDGETS: readonly BundledCustomWidget[] = [
name: "Pokédex",
description: "Browse Pokémon and open a responsive detail view.",
iconUrl: "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/items/poke-ball.png",
sources: [
{
id: "default",
name: "PokeAPI",
baseUrl: "https://pokeapi.co",
networkScope: "public",
auth: { type: "none" },
sources: { default: { name: "PokeAPI", baseUrl: "https://pokeapi.co", networkScope: "public", auth: "none" } },
requests: {
pokemon: {
path: "/api/v2/pokemon",
query: { limit: { $option: "limit" }, offset: { $option: "offset" } },
cacheSeconds: 300,
},
],
requests: [
{
id: "pokemon",
sourceId: "default",
kind: "query",
method: "GET",
pathTemplate: "/api/v2/pokemon",
parameters: { limit: "number" },
optionsBinding: { limit: { $option: "limit" } },
queryTemplate: { limit: { $param: "limit" }, offset: 0 },
auth: "inherit",
minimumBoardPermission: "view",
trigger: "load",
cacheTtlSeconds: 300,
},
{
id: "detail",
sourceId: "default",
kind: "query",
method: "GET",
pathTemplate: "/api/v2/pokemon/{name}",
parameters: { name: "string" },
auth: "inherit",
minimumBoardPermission: "view",
trigger: "manual",
cacheTtlSeconds: 300,
},
],
optionsSchema: {
type: "object",
properties: {
limit: {
type: "number",
title: "Pokémon to load",
minimum: 1,
maximum: 100,
"x-homarr": { control: "number" },
},
},
required: ["limit"],
additionalProperties: false,
detail: { path: "/api/v2/pokemon/{param:name}", trigger: "manual", cacheSeconds: 300 },
},
defaultOptions: { limit: 20 },
template: `<Stack gap="sm" p="sm">
<Group justify="space-between"><Title order={3}>Pokédex</Title><Badge color="red">{data.pokemon?.count ?? 0} Pokémon</Badge></Group>
{status.pokemon?.loading ? <Skeleton height={180} radius="md" /> : status.pokemon?.error ? <Alert color="red">{status.pokemon.error}</Alert> :
<Stack gap="xs">{(data.pokemon?.results ?? []).map((pokemon) => {
const segments = pokemon.url.split("/").filter((part) => part);
const dexId = segments.at(-1);
return <Paper key={dexId} withBorder p="xs"><Group justify="space-between"><Group gap="xs"><Badge variant="light">#{dexId}</Badge><Text fw={600} tt="capitalize">{pokemon.name}</Text></Group><SubFetch requestId="detail" params={{ name: pokemon.name }} trigger="manual"><Stack gap="xs"><SubData path="sprites.front_default" as="Image" alt={pokemon.name} w={80} h={80} /><SubData path="name" as="Title" order={4} /><SubData path="types" as="Code" /></Stack></SubFetch></Group></Paper>;
})}</Stack>}
options: {
limit: { label: "Pokémon to load", control: "number", default: 24, min: 1, max: 100 },
offset: { label: "Start at Pokédex number", control: "number", default: 0, min: 0, advanced: true },
accent: { label: "Accent color", control: "color", default: "red" },
},
template: `<Stack gap="md" p="md" h="100%">
<Group justify="space-between" align="flex-start">
<Stack gap={2}><Group gap="xs"><ThemeIcon color={options.accent} variant="light" radius="xl"><Icon name="pokeball" /></ThemeIcon><Title order={3}>Pokédex</Title></Group><Text size="xs" c="dimmed">Explore species, types, abilities, and base stats</Text></Stack>
<Group gap="xs"><Badge color={options.accent} variant="light">{data.pokemon?.count?.toLocaleString() ?? "—"} species</Badge><RefreshButton /></Group>
</Group>
<TextInput bind="search" label="Filter loaded Pokémon" placeholder="Search by name…" />
{status.pokemon?.loading ? <SimpleGrid cols={{ base: 1, xs: 2 }}>{[1, 2, 3, 4].map(item => <Skeleton key={item} height={116} radius="md" />)}</SimpleGrid> : status.pokemon?.error ? <Alert color="red" title="The Pokédex could not be loaded">{status.pokemon.error}</Alert> : (data.pokemon?.results ?? []).filter(pokemon => pokemon.name.toLowerCase().includes(String(inputs.search ?? "").toLowerCase())).length === 0 ? <Alert color="gray" title="No Pokémon found">Try another name or increase the result limit in widget options.</Alert> : <ScrollArea h={300} type="auto"><SimpleGrid cols={{ base: 1, xs: 2 }} spacing="sm">{(data.pokemon?.results ?? []).filter(pokemon => pokemon.name.toLowerCase().includes(String(inputs.search ?? "").toLowerCase())).map((pokemon, index) => <Card key={pokemon.name} withBorder radius="md" padding="sm"><Stack gap="sm"><Group justify="space-between"><Group gap="xs"><Badge color={options.accent} variant="outline">#{options.offset + index + 1}</Badge><Text fw={700} tt="capitalize">{pokemon.name}</Text></Group><Text size="xs" c="dimmed">Details</Text></Group><SubFetch requestId="detail" params={{ name: pokemon.name }} trigger="manual" loadingLabel="Loading Pokémon…">{(detail) => <Stack gap="sm"><Group align="flex-start" wrap="nowrap"><Image src={detail.sprites?.other?.["official-artwork"]?.front_default ?? detail.sprites?.front_default} alt={detail.name} w={92} h={92} fit="contain" fallbackSrc="https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/items/poke-ball.png" /><Stack gap="xs"><Group gap={6}>{(detail.types ?? []).map(type => <Badge key={type.type?.name} variant="light" tt="capitalize">{type.type?.name}</Badge>)}</Group><Text size="xs" c="dimmed">Height {detail.height / 10} m · Weight {detail.weight / 10} kg</Text><Text size="xs"><Text span fw={600}>Abilities: </Text>{(detail.abilities ?? []).map(ability => ability.ability?.name?.replace("-", " ")).join(", ")}</Text></Stack></Group><Divider /><Stack gap={6}>{(detail.stats ?? []).map(stat => <Stack key={stat.stat?.name} gap={3}><Group justify="space-between"><Text size="xs" tt="capitalize">{stat.stat?.name?.replace("-", " ")}</Text><Text size="xs" fw={700}>{stat.base_stat}</Text></Group><Progress value={Math.min(100, stat.base_stat / 2)} color={options.accent} size="sm" /></Stack>)}</Stack></Stack>}</SubFetch></Stack></Card>)}</SimpleGrid></ScrollArea>}
</Stack>`,
},
},
{
id: "seed-tautulli-activity",
widget: {
$schema: "homarr-custom-widget-v2",
name: "Tautulli Activity",
description: "Shows active Plex streams from Tautulli.",
sources: {
default: {
name: "Tautulli API",
baseUrl: "http://tautulli.local",
networkScope: "private",
auth: { type: "apiKeyQuery", name: "apikey" },
},
},
requests: { activity: { path: "/api/v2", query: { cmd: "get_activity" }, cacheSeconds: 15 } },
options: {},
template: `<Stack gap="md" p="md">
<Group justify="space-between"><Stack gap={2}><Text fw={700}>Tautulli Activity</Text><Text size="xs" c="dimmed">Live streaming sessions</Text></Stack><RefreshButton /></Group>
{status.activity?.loading ? <Stack gap="sm"><Skeleton height={80} radius="md" /><Skeleton height={80} radius="md" /></Stack> : status.activity?.error ? <Alert color="red" title="Could not load activity">{status.activity.error}</Alert> : (data.activity?.response?.data?.sessions ?? []).length === 0 ? <Alert color="gray" title="No active streams">All quiet.</Alert> : <>
<SimpleGrid cols={{ base: 2, xs: 4 }}>
<Paper withBorder p="sm" radius="md"><Text size="xs" c="dimmed">Active streams</Text><Text size="xl" fw={700}>{(data.activity?.response?.data?.sessions ?? []).length}</Text></Paper>
<Paper withBorder p="sm" radius="md"><Text size="xs" c="dimmed">Total bandwidth</Text><Text size="xl" fw={700}>{((data.activity?.response?.data?.sessions ?? []).reduce((sum, session) => sum + (session.bandwidth || 0), 0) / 1000000).toFixed(1)}<Text span size="xs" c="dimmed"> Mbps</Text></Text></Paper>
<Paper withBorder p="sm" radius="md"><Text size="xs" c="dimmed">Direct play</Text><Text size="xl" fw={700}>{(data.activity?.response?.data?.sessions ?? []).filter(session => session.transcode_decision === "direct play" || session.transcode_decision === "copy").length}</Text></Paper>
<Paper withBorder p="sm" radius="md"><Text size="xs" c="dimmed">Transcodes</Text><Text size="xl" fw={700}>{(data.activity?.response?.data?.sessions ?? []).filter(session => session.transcode_decision === "transcode").length}</Text></Paper>
</SimpleGrid>
<SimpleGrid cols={{ base: 1, sm: 2 }}>{(data.activity?.response?.data?.sessions ?? []).map(session => <Paper key={session.session_key} withBorder p="sm" radius="md"><Stack gap="xs"><Group justify="space-between"><Group gap="xs"><Text size="sm" fw={600}>{session.user}</Text><Badge variant="light" color={session.state === "playing" ? "green" : session.state === "paused" ? "yellow" : "orange"}>{session.state}</Badge></Group><Badge variant="light" color="gray">{session.player}</Badge></Group><Text size="sm" lineClamp={1}>{session.full_title}</Text><Progress value={session.progress_percent} size="sm" color="blue" /><Group justify="space-between"><Group gap="xs"><Badge variant="light" color={session.transcode_decision === "transcode" ? "red" : session.transcode_decision === "copy" ? "orange" : "teal"}>{session.transcode_decision}</Badge><Badge variant="light" color="gray">{session.bandwidth ? (session.bandwidth / 1000000).toFixed(1) + " Mbps" : "—"}</Badge></Group><Badge variant="outline" color={session.location === "lan" ? "blue" : "yellow"}>{session.location}</Badge></Group></Stack></Paper>)}</SimpleGrid>
</>}
</Stack>`,
},
},
@@ -1 +1 @@
{"schemaVersion":1,"mantineVersion":"9.4.1","customWidgetVersion":"2.0.0","types":["\"\" | \"anonymous\" | \"use-credentials\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"auto\" | \"fixed\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"auto\" | \"all\" | \"fill\" | \"stroke\" | \"painted\" | \"visible\" | \"visibleFill\" | \"visiblePainted\" | \"visibleStroke\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"block\" | \"inline\" | \"both\" | \"horizontal\" | \"vertical\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"contain\" | \"cover\" | \"fill\" | \"scale-down\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"wrap\" | \"nowrap\" | \"wrap-reverse\"","\"12h\" | \"24h\"","\"activity\" | \"display-none\"","\"all\" | \"parent\" | \"child\"","\"all\" | \"segment\"","\"always\" | \"hover\" | \"not-hover\" | \"never\"","\"area\" | \"radial\" | \"scatter\"","\"auto\" | \"scroll\" | \"always\" | \"hover\" | \"never\"","\"auto-fit\" | \"auto-fill\"","\"block\" | \"grid\"","\"both\" | \"rightSection\" | \"clear\"","\"bottom\" | \"top\"","\"bottom\" | \"top\" | \"baseline\" | \"middle\"","\"bottom\" | \"top\" | \"middle\"","\"bottom-end\" | \"bottom-start\" | \"top-end\" | \"top-start\" | \"bottom-center\" | \"top-center\" | \"middle-center\" | \"middle-end\" | \"middle-start\"","\"button\" | \"submit\" | \"reset\"","\"center\" | \"bottom\"","\"center\" | \"left\" | \"right\"","\"center\" | \"left\" | \"right\" | \"justify\" | \"char\"","\"center\" | \"side\" | \"merge\"","\"checkbox\" | \"radio\"","\"dashed\" | \"dotted\" | \"solid\"","\"default\" | \"gradient\"","\"default\" | \"responsive\"","\"default\" | \"stacked\" | \"percent\" | \"split\"","\"default\" | \"stacked\" | \"percent\" | \"waterfall\"","\"div\" | \"label\"","\"end\" | \"start\"","\"filled\" | \"light\"","\"fixed\" | \"absolute\"","\"hex\" | \"hexa\" | \"rgba\" | \"rgb\" | \"hsl\" | \"hsla\"","\"horizontal\" | \"vertical\"","\"hover\" | \"click\" | \"click-hover\"","\"inherit\" | \"auto\" | \"baseline\" | \"before-edge\" | \"text-before-edge\" | \"middle\" | \"central\" | \"after-edge\" | \"text-after-edge\" | \"ideographic\" | \"alphabetic\" | \"hanging\" | \"mathematical\"","\"inherit\" | \"auto\" | \"sRGB\" | \"linearRGB\"","\"inherit\" | \"auto\" | \"text-before-edge\" | \"middle\" | \"central\" | \"text-after-edge\" | \"ideographic\" | \"alphabetic\" | \"hanging\" | \"mathematical\" | \"use-script\" | \"no-change\" | \"reset-size\"","\"inherit\" | \"end\" | \"start\" | \"middle\"","\"inherit\" | \"nonzero\" | \"evenodd\"","\"inherit\" | \"round\" | \"butt\" | \"square\"","\"inherit\" | \"round\" | \"miter\" | \"bevel\"","\"initial\" | \"medial\" | \"terminal\" | \"isolated\"","\"inside\" | \"outside\"","\"left\" | \"right\"","\"left\" | \"right\" | \"bottom\" | \"top\" | \"left-end\" | \"left-start\" | \"right-end\" | \"right-start\" | \"bottom-end\" | \"bottom-start\" | \"top-end\" | \"top-start\"","\"left\" | \"right\" | \"inside\"","\"link\" | \"none\" | \"copy\" | \"execute\" | \"move\" | \"popup\"","\"media\" | \"container\"","\"month\" | \"year\" | \"decade\"","\"native\" | \"scrollarea\"","\"none\" | \"all\" | \"rows\" | \"groups\" | \"columns\"","\"none\" | \"all\" | \"with-indicators\"","\"none\" | \"ascending\" | \"descending\" | \"other\"","\"none\" | \"inline\" | \"list\" | \"both\"","\"none\" | \"strict\" | \"blur\"","\"none\" | \"sum\"","\"none\" | \"thousand\" | \"lakh\" | \"wan\"","\"none\" | \"x\" | \"y\" | \"xy\"","\"number\" | RegExp | \"alphanumeric\"","\"off\" | \"assertive\" | \"polite\"","\"off\" | \"on\"","\"ordered\" | \"unordered\"","\"right-to-left\" | \"left-to-right\"","\"search\" | \"enter\" | \"done\" | \"go\" | \"next\" | \"previous\" | \"send\"","\"search\" | \"text\" | \"none\" | \"tel\" | \"url\" | \"email\" | \"numeric\" | \"decimal\"","\"step\" | \"bump\" | \"linear\" | \"natural\" | \"monotone\" | \"stepBefore\" | \"stepAfter\"","\"stepInactive\" | \"stepProgress\" | \"stepCompleted\"","\"sum\" | \"replace\"","\"td\" | \"th\"","\"text\" | \"additions\" | \"additions removals\" | \"additions text\" | \"all\" | \"removals\" | \"removals additions\" | \"removals text\" | \"text additions\" | \"text removals\"","\"text\" | \"tel\" | \"password\"","\"time\" | \"duration\"","\"up\" | \"down\"","\"value\" | \"name\" | \"percent\"","\"viewport\"","\"wrap\" | \"nowrap\" | \"balance\" | \"pretty\" | \"stable\"","\"x\" | \"y\"","\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\"","\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\" | (string & {})","\"yes\" | \"no\"","'Text' | 'Title' | 'Badge' | 'Code' | 'Image'","'auto' | 'manual'","'json' | 'text'","'xs' | 'sm' | 'md' | 'lg' | 'xl'","(\"input\" | \"label\" | \"description\" | \"error\")[]","(\"next\" | \"previLine truncated
{"schemaVersion":1,"mantineVersion":"9.4.1","customWidgetVersion":"2.0.0","types":["\"\" | \"anonymous\" | \"use-credentials\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"auto\" | \"fixed\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"auto\" | \"all\" | \"fill\" | \"stroke\" | \"painted\" | \"visible\" | \"visibleFill\" | \"visiblePainted\" | \"visibleStroke\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"block\" | \"inline\" | \"both\" | \"horizontal\" | \"vertical\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"contain\" | \"cover\" | \"fill\" | \"scale-down\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"wrap\" | \"nowrap\" | \"wrap-reverse\"","\"12h\" | \"24h\"","\"activity\" | \"display-none\"","\"all\" | \"parent\" | \"child\"","\"all\" | \"segment\"","\"always\" | \"hover\" | \"not-hover\" | \"never\"","\"area\" | \"radial\" | \"scatter\"","\"auto\" | \"scroll\" | \"always\" | \"hover\" | \"never\"","\"auto-fit\" | \"auto-fill\"","\"block\" | \"grid\"","\"both\" | \"rightSection\" | \"clear\"","\"bottom\" | \"top\"","\"bottom\" | \"top\" | \"baseline\" | \"middle\"","\"bottom\" | \"top\" | \"middle\"","\"bottom-end\" | \"bottom-start\" | \"top-end\" | \"top-start\" | \"bottom-center\" | \"top-center\" | \"middle-center\" | \"middle-end\" | \"middle-start\"","\"button\" | \"submit\" | \"reset\"","\"center\" | \"bottom\"","\"center\" | \"left\" | \"right\"","\"center\" | \"left\" | \"right\" | \"justify\" | \"char\"","\"center\" | \"side\" | \"merge\"","\"checkbox\" | \"radio\"","\"dashed\" | \"dotted\" | \"solid\"","\"default\" | \"gradient\"","\"default\" | \"responsive\"","\"default\" | \"stacked\" | \"percent\" | \"split\"","\"default\" | \"stacked\" | \"percent\" | \"waterfall\"","\"div\" | \"label\"","\"end\" | \"start\"","\"filled\" | \"light\"","\"fixed\" | \"absolute\"","\"hex\" | \"hexa\" | \"rgba\" | \"rgb\" | \"hsl\" | \"hsla\"","\"horizontal\" | \"vertical\"","\"hover\" | \"click\" | \"click-hover\"","\"inherit\" | \"auto\" | \"baseline\" | \"before-edge\" | \"text-before-edge\" | \"middle\" | \"central\" | \"after-edge\" | \"text-after-edge\" | \"ideographic\" | \"alphabetic\" | \"hanging\" | \"mathematical\"","\"inherit\" | \"auto\" | \"sRGB\" | \"linearRGB\"","\"inherit\" | \"auto\" | \"text-before-edge\" | \"middle\" | \"central\" | \"text-after-edge\" | \"ideographic\" | \"alphabetic\" | \"hanging\" | \"mathematical\" | \"use-script\" | \"no-change\" | \"reset-size\"","\"inherit\" | \"end\" | \"start\" | \"middle\"","\"inherit\" | \"nonzero\" | \"evenodd\"","\"inherit\" | \"round\" | \"butt\" | \"square\"","\"inherit\" | \"round\" | \"miter\" | \"bevel\"","\"initial\" | \"medial\" | \"terminal\" | \"isolated\"","\"inside\" | \"outside\"","\"left\" | \"right\"","\"left\" | \"right\" | \"bottom\" | \"top\" | \"left-end\" | \"left-start\" | \"right-end\" | \"right-start\" | \"bottom-end\" | \"bottom-start\" | \"top-end\" | \"top-start\"","\"left\" | \"right\" | \"inside\"","\"link\" | \"none\" | \"copy\" | \"execute\" | \"move\" | \"popup\"","\"media\" | \"container\"","\"month\" | \"year\" | \"decade\"","\"native\" | \"scrollarea\"","\"none\" | \"all\" | \"rows\" | \"groups\" | \"columns\"","\"none\" | \"all\" | \"with-indicators\"","\"none\" | \"ascending\" | \"descending\" | \"other\"","\"none\" | \"inline\" | \"list\" | \"both\"","\"none\" | \"strict\" | \"blur\"","\"none\" | \"sum\"","\"none\" | \"thousand\" | \"lakh\" | \"wan\"","\"none\" | \"x\" | \"y\" | \"xy\"","\"number\" | RegExp | \"alphanumeric\"","\"off\" | \"assertive\" | \"polite\"","\"off\" | \"on\"","\"ordered\" | \"unordered\"","\"right-to-left\" | \"left-to-right\"","\"search\" | \"enter\" | \"done\" | \"go\" | \"next\" | \"previous\" | \"send\"","\"search\" | \"text\" | \"none\" | \"tel\" | \"url\" | \"email\" | \"numeric\" | \"decimal\"","\"step\" | \"bump\" | \"linear\" | \"natural\" | \"monotone\" | \"stepBefore\" | \"stepAfter\"","\"stepInactive\" | \"stepProgress\" | \"stepCompleted\"","\"sum\" | \"replace\"","\"td\" | \"th\"","\"text\" | \"additions\" | \"additions removals\" | \"additions text\" | \"all\" | \"removals\" | \"removals additions\" | \"removals text\" | \"text additions\" | \"text removals\"","\"text\" | \"tel\" | \"password\"","\"time\" | \"duration\"","\"up\" | \"down\"","\"value\" | \"name\" | \"percent\"","\"viewport\"","\"wrap\" | \"nowrap\" | \"balance\" | \"pretty\" | \"stable\"","\"x\" | \"y\"","\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\"","\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\" | (string & {})","\"yes\" | \"no\"","'Text' | 'Title' | 'Badge' | 'Code' | 'Image'","'auto' | 'manual'","'json' | 'text'","'xs' | 'sm' | 'md' | 'lg' | 'xl'","(\"input\" | \"label\" | \"description\" | \"error\")[]","(\"next\" | \"previLine truncated
@@ -9,17 +9,6 @@ export function buildCustomJsxComponentUsageExample(
component: CustomJsxComponentApi,
catalog: Pick<CustomJsxAuthoringCatalog, "types">,
) {
if (component.name === "RecursiveList") {
return `<RecursiveList data={data.tree} childrenPath="children" keyPath="id" showLines>
{(node, meta) => (
<Group gap="xs">
<Text>{node.label}</Text>
{meta.hasChildren && <Badge>{meta.childCount}</Badge>}
</Group>
)}
</RecursiveList>`;
}
const props = component.props
.filter((prop) => prop.required)
.slice(0, 4)
@@ -33,6 +33,13 @@ export const customJsxSupportedPropsByName = new Map(
customJsxComponentRegistry.map((entry) => [entry.name, new Set(entry.supportedProps)]),
);
export function resolveCustomJsxComponentName(name: string): string {
if (name === "Icon") return "TablerIcon";
if (customJsxComponentByName.has(name)) return name;
const flat = name.replaceAll(".", "");
return customJsxComponentByName.has(flat) ? flat : name;
}
const catalogComponentsByName = new Map(
customJsxAuthoringCatalog.components.map((component) => [component.name, component]),
);
@@ -57,6 +64,7 @@ export function getCustomJsxBindingType(
componentName: string,
props: Readonly<Record<string, unknown>> = {},
): CustomJsxBindingType | null {
componentName = resolveCustomJsxComponentName(componentName);
const type = catalogComponentsByName.get(componentName)?.bind?.type;
if (!type) return null;
if (multipleDateBindingComponents.has(componentName) && ["multiple", "range"].includes(String(props.type))) {
@@ -2,15 +2,18 @@ import { z } from "zod/v4";
import { validateCustomJsxTemplate } from "../jsx/analyzer";
import { validateCredentialFreeExport, validatePrototypeKeys } from "./definition-security";
import { validateCustomWidgetOptions } from "./options";
import { customWidgetOptionsSchemaSchema } from "./options-schema";
import { customJsxRequestSchema, customWidgetIdentifierSchema, customWidgetSourceSchema } from "./request-schema";
import { customWidgetOptionsSchema } from "./options-schema";
import {
collectCustomWidgetRequestReferences,
customWidgetIdentifierSchema,
customWidgetRequestsSchema,
customWidgetSourcesSchema,
} from "./request-schema";
import type { CustomJsxRequest } from "./request-schema";
import { customWidgetAuthTypes, customWidgetSecretKinds } from "./schema-types";
import { customWidgetSecretKinds } from "./schema-types";
export * from "./options-schema";
export * from "./request-schema";
export type CustomWidgetOptionsSchema = z.infer<typeof customWidgetOptionsSchemaSchema>;
export const CUSTOM_WIDGET_SCHEMA = "homarr-custom-widget-v2";
export const customWidgetBindingIdentifierSchema = z
@@ -18,6 +21,7 @@ export const customWidgetBindingIdentifierSchema = z
.min(1)
.max(64)
.regex(/^[A-Za-z][A-Za-z0-9_-]*$/u, "Binding names must start with a letter and contain letters, numbers, - or _");
export const customJsxTemplateSchema = z
.string()
.min(1)
@@ -33,7 +37,7 @@ export const customJsxTemplateSchema = z
}
});
export const customWidgetSecretInputSchema = z.object({
export const customWidgetSecretInputSchema = z.strictObject({
sourceId: customWidgetIdentifierSchema,
kind: z.enum(customWidgetSecretKinds),
value: z.string().min(1).max(8_192),
@@ -46,15 +50,14 @@ export const customWidgetSecretsInputSchema = z
const identities = new Set<string>();
secrets.forEach((secret, index) => {
const identity = `${secret.sourceId}:${secret.kind}`;
if (identities.has(identity)) {
if (identities.has(identity))
ctx.addIssue({ code: "custom", path: [index], message: "A widget secret can only be supplied once" });
}
identities.add(identity);
});
});
export const customWidgetDefinitionSchema = z
.object({
.strictObject({
$schema: z.literal(CUSTOM_WIDGET_SCHEMA),
name: z.string().trim().min(1).max(128),
description: z.string().max(512).optional(),
@@ -67,25 +70,14 @@ export const customWidgetDefinitionSchema = z
return (url.protocol === "http:" || url.protocol === "https:") && !url.username && !url.password;
}, "Widget icons must use HTTP or HTTPS without embedded credentials")
.optional(),
sources: z.array(customWidgetSourceSchema).min(1).max(8),
requests: z.array(customJsxRequestSchema).max(64),
optionsSchema: customWidgetOptionsSchemaSchema,
defaultOptions: z.record(z.string(), z.unknown()),
sources: customWidgetSourcesSchema,
requests: customWidgetRequestsSchema,
options: customWidgetOptionsSchema,
template: customJsxTemplateSchema,
})
.strict()
.superRefine((definition, ctx) => {
for (const issue of validateCustomWidgetOptions(definition.optionsSchema, definition.defaultOptions)) {
ctx.addIssue({
code: "custom",
path: ["defaultOptions", ...issue.path.split(".").slice(1)],
message: issue.message,
});
}
const sourceIds = validateSourceIds(definition.sources, ctx);
const requests = validateRequests(definition, sourceIds, ctx);
validateDynamicOptions(definition.optionsSchema, requests, ctx);
validateTemplateRequests(definition.template, requests, ctx);
validateRequests(definition, ctx);
validateTemplateRequests(definition.template, definition.requests, ctx);
validateTemplateBindings(definition.template, ctx);
validateCredentialFreeExport(definition, ctx);
validatePrototypeKeys(definition, ctx);
@@ -93,142 +85,76 @@ export const customWidgetDefinitionSchema = z
type Definition = z.infer<typeof customWidgetDefinitionSchema>;
function validateSourceIds(sources: Definition["sources"], ctx: z.RefinementCtx) {
const ids = new Set<string>();
sources.forEach((source, index) => {
if (ids.has(source.id))
ctx.addIssue({ code: "custom", path: ["sources", index, "id"], message: `Duplicate source ID: ${source.id}` });
ids.add(source.id);
});
return ids;
}
function validateDynamicOptions(
schema: unknown,
requests: Map<string, CustomJsxRequest>,
ctx: z.RefinementCtx,
path: Array<string | number> = ["optionsSchema"],
) {
if (schema === null || typeof schema !== "object" || Array.isArray(schema)) return;
const node = schema as Record<string, unknown>;
const presentation = node["x-homarr"];
if (presentation !== null && typeof presentation === "object" && !Array.isArray(presentation)) {
const source = (presentation as Record<string, unknown>).optionsSource;
if (source !== null && typeof source === "object" && !Array.isArray(source)) {
const requestId = (source as Record<string, unknown>).requestId;
const request = typeof requestId === "string" ? requests.get(requestId) : undefined;
if (!request || request.kind !== "query") {
ctx.addIssue({
code: "custom",
path: [...path, "x-homarr", "optionsSource", "requestId"],
message: `Dynamic options require a query request named '${String(requestId)}'`,
});
} else {
for (const parameterName of Object.keys(request.parameters)) {
if (!Object.hasOwn(request.optionsBinding ?? {}, parameterName)) {
ctx.addIssue({
code: "custom",
path: [...path, "x-homarr", "optionsSource", "requestId"],
message: `Dynamic option request '${request.id}' parameter '${parameterName}' requires an explicit optionsBinding entry`,
});
}
}
}
}
}
if (node.properties !== null && typeof node.properties === "object" && !Array.isArray(node.properties)) {
for (const [key, child] of Object.entries(node.properties))
validateDynamicOptions(child, requests, ctx, [...path, "properties", key]);
}
if (node.items !== undefined) validateDynamicOptions(node.items, requests, ctx, [...path, "items"]);
for (const branch of ["if", "then", "else"] as const) {
if (node[branch] !== undefined) validateDynamicOptions(node[branch], requests, ctx, [...path, branch]);
}
}
function validateRequests(definition: Definition, sourceIds: Set<string>, ctx: z.RefinementCtx) {
const requests = new Map<string, CustomJsxRequest>();
definition.requests.forEach((request, index) => {
if (requests.has(request.id))
ctx.addIssue({ code: "custom", path: ["requests", index, "id"], message: `Duplicate request ID: ${request.id}` });
if (!sourceIds.has(request.sourceId)) {
function validateRequests(definition: Definition, ctx: z.RefinementCtx) {
for (const [requestId, request] of Object.entries(definition.requests)) {
if (!definition.sources[request.source]) {
ctx.addIssue({
code: "custom",
path: ["requests", index, "sourceId"],
message: `Unknown source ID: ${request.sourceId}`,
path: ["requests", requestId, "source"],
message: `Unknown source '${request.source}'`,
});
}
const source = definition.sources.find((candidate) => candidate.id === request.sourceId);
if (source?.auth.type === "apiKeyHeader" && request.auth === "inherit") {
const authHeader = source.auth.headerName.toLowerCase();
if (Object.keys(request.staticHeaders ?? {}).some((header) => header.toLowerCase() === authHeader)) {
const references = collectCustomWidgetRequestReferences(request);
for (const optionName of references.options) {
if (!definition.options[optionName]) {
ctx.addIssue({
code: "custom",
path: ["requests", index, "staticHeaders"],
message: "Static headers cannot override the source authentication header",
});
}
}
if (source?.auth.type === "apiKeyQuery" && request.auth === "inherit") {
const authParameter = source.auth.parameterName.toLowerCase();
if (Object.keys(request.queryTemplate ?? {}).some((parameter) => parameter.toLowerCase() === authParameter)) {
ctx.addIssue({
code: "custom",
path: ["requests", index, "queryTemplate"],
message: "Query templates cannot override the source authentication parameter",
});
}
}
for (const [parameterName, binding] of Object.entries(request.optionsBinding ?? {})) {
if (typeof binding !== "object") continue;
const optionName = binding.$option;
const optionSchema = definition.optionsSchema.properties?.[optionName];
if (optionSchema === null || typeof optionSchema !== "object" || Array.isArray(optionSchema)) {
ctx.addIssue({
code: "custom",
path: ["requests", index, "optionsBinding", parameterName, "$option"],
path: ["requests", requestId],
message: `Unknown widget option '${optionName}'`,
});
continue;
}
const parameterType = request.parameters[parameterName];
const declaredOptionType = (optionSchema as Record<string, unknown>).type;
const optionType = declaredOptionType === "integer" ? "number" : declaredOptionType;
if (parameterType !== undefined && optionType !== parameterType) {
ctx.addIssue({
code: "custom",
path: ["requests", index, "optionsBinding", parameterName, "$option"],
message: `Option '${optionName}' must be ${parameterType} to supply parameter '${parameterName}'`,
});
}
}
requests.set(request.id, request);
});
definition.requests.forEach((request, index) => {
request.invalidates?.forEach((requestId, invalidationIndex) => {
const target = requests.get(requestId);
if (!target || target.kind !== "query") {
if (request.trigger === "load" && references.params.size > 0) {
ctx.addIssue({
code: "custom",
path: ["requests", requestId],
message: `Load requests cannot use invocation parameters: ${[...references.params].join(", ")}`,
});
}
request.invalidates?.forEach((target, index) => {
if (definition.requests[target]?.kind !== "query") {
ctx.addIssue({
code: "custom",
path: ["requests", index, "invalidates", invalidationIndex],
message: `Invalidation target '${requestId}' must be a query request`,
path: ["requests", requestId, "invalidates", index],
message: `Invalidation target '${target}' must be a query`,
});
}
});
});
return requests;
}
for (const [optionName, option] of Object.entries(definition.options)) {
const requestId = option.choicesFrom?.request;
if (requestId && definition.requests[requestId]?.kind !== "query") {
ctx.addIssue({
code: "custom",
path: ["options", optionName, "choicesFrom", "request"],
message: `Dynamic choices require a query named '${requestId}'`,
});
}
if (requestId) {
const request = definition.requests[requestId];
const references = request ? collectCustomWidgetRequestReferences(request) : null;
if (request && (request.trigger !== "load" || (references?.params.size ?? 0) > 0)) {
ctx.addIssue({
code: "custom",
path: ["options", optionName, "choicesFrom", "request"],
message: "Dynamic choices require a load query without invocation parameters",
});
}
}
}
}
function validateTemplateRequests(template: string, requests: Map<string, CustomJsxRequest>, ctx: z.RefinementCtx) {
for (const match of template.matchAll(/<(SubFetch|ActionButton|ToggleSwitch)\b([^>]*)>/g)) {
function validateTemplateRequests(template: string, requests: Record<string, CustomJsxRequest>, ctx: z.RefinementCtx) {
for (const match of template.matchAll(/<(SubFetch|ActionButton|ToggleSwitch)\b([^>]*)>/gu)) {
const component = match[1] as "SubFetch" | "ActionButton" | "ToggleSwitch";
const idMatch = (match[2] ?? "").match(/\brequestId\s*=\s*(?:"([^"]+)"|'([^']+)')/);
const attributes = match[2] ?? "";
const idMatch = attributes.match(/\brequestId\s*=\s*(?:"([^"]+)"|'([^']+)')/u);
const requestId = idMatch?.[1] ?? idMatch?.[2];
if (!requestId) {
ctx.addIssue({ code: "custom", path: ["template"], message: `${component} must use a literal requestId` });
continue;
}
const request = requests.get(requestId);
const request = requests[requestId];
const expectedKind = component === "SubFetch" ? "query" : "action";
if (!request)
ctx.addIssue({
@@ -236,25 +162,10 @@ function validateTemplateRequests(template: string, requests: Map<string, Custom
path: ["template"],
message: `${component} references unknown request '${requestId}'`,
});
else if (request.kind !== expectedKind) {
ctx.addIssue({
code: "custom",
path: ["template"],
message: `${component} requires a ${expectedKind} request, but '${requestId}' is ${request.kind}`,
});
} else if (component === "SubFetch" && request.trigger !== "manual") {
ctx.addIssue({
code: "custom",
path: ["template"],
message: `SubFetch requires a manual query, but '${requestId}' is load-triggered`,
});
} else if (Object.keys(request.parameters).length > 0 && !/\bparams\s*=/u.test(match[2] ?? "")) {
ctx.addIssue({
code: "custom",
path: ["template"],
message: `${component} must supply params for request '${requestId}'`,
});
}
else if (request.kind !== expectedKind)
ctx.addIssue({ code: "custom", path: ["template"], message: `${component} requires a ${expectedKind} request` });
else if (component === "SubFetch" && request.trigger !== "manual")
ctx.addIssue({ code: "custom", path: ["template"], message: `SubFetch requires '${requestId}' to be manual` });
}
}
@@ -268,19 +179,15 @@ function validateTemplateBindings(template: string, ctx: z.RefinementCtx) {
}
export type HomarrCustomWidgetV2 = z.infer<typeof customWidgetDefinitionSchema>;
export type HomarrCustomWidgetV2Input = z.input<typeof customWidgetDefinitionSchema>;
export const customWidgetCreateSchema = customWidgetDefinitionSchema.safeExtend({
secrets: customWidgetSecretsInputSchema.default([]),
});
export const customWidgetUpdateSchema = z
.object(customWidgetDefinitionSchema.shape)
.strictObject(customWidgetDefinitionSchema.shape)
.partial()
.extend({
id: z.string().min(1),
secrets: customWidgetSecretsInputSchema.optional(),
})
.strict();
.extend({ id: z.string().min(1), secrets: customWidgetSecretsInputSchema.optional() });
export const customWidgetImportSchema = customWidgetDefinitionSchema;
export type CustomWidgetCreateInput = z.infer<typeof customWidgetCreateSchema>;
export type CustomWidgetUpdateInput = z.infer<typeof customWidgetUpdateSchema>;
export type CustomWidgetImport = z.infer<typeof customWidgetImportSchema>;
export const customWidgetAuthTypeSchema = z.enum(customWidgetAuthTypes);
@@ -9,4 +9,5 @@ export type {
CustomWidgetImport,
CustomWidgetUpdateInput,
HomarrCustomWidgetV2,
HomarrCustomWidgetV2Input,
} from "./custom-jsx-schema";
@@ -1,101 +1,48 @@
import type { RefinementCtx } from "zod/v4";
import type { z } from "zod/v4";
import type { CustomJsxRequest } from "./request-schema";
const credentialKeyPattern =
const credentialKey =
/(^|[-_])(authorization|api[-_]?keys?|passwords?|passwds?|secrets?|tokens?|access[-_]?tokens?|refresh[-_]?tokens?|client[-_]?secrets?)($|[-_])/iu;
const credentialTextPattern =
/(?:\bauthorization\s*[:=]\s*["']?bearer\s+[A-Za-z0-9._~+/%-]{8,}|\b(?:api[ _-]?key|access[ _-]?token|refresh[ _-]?token|client[ _-]?secret|token|password|passwd|secret)\s*[:=]\s*["'][^"']{4,}["'])/iu;
const bearerHeaderValuePattern = /^\s*bearer\s+\S{8,}\s*$/iu;
const blockedObjectKeys = new Set(["__proto__", "prototype", "constructor"]);
interface SecurityDefinition {
defaultOptions: Record<string, unknown>;
requests: CustomJsxRequest[];
template: string;
iconUrl?: string;
optionsSchema: unknown;
}
function isCredentialKeyName(value: string) {
return credentialKeyPattern.test(value);
}
export function validateCredentialFreeExport(definition: SecurityDefinition, ctx: RefinementCtx) {
if (containsCredentialLikeValue(definition.defaultOptions)) {
ctx.addIssue({ code: "custom", path: ["defaultOptions"], message: "Default options cannot contain credentials" });
}
definition.requests.forEach((request, index) => {
if (
containsCredentialLikeValue(request.optionsBinding) ||
containsCredentialLikeValue(request.queryTemplate) ||
containsCredentialLikeValue(request.bodyTemplate) ||
Object.entries(request.staticHeaders ?? {}).some(
([name, value]) => containsCredentialLikeValue(value, name) || bearerHeaderValuePattern.test(value),
)
) {
ctx.addIssue({
code: "custom",
path: ["requests", index],
message: "Static request templates cannot contain credentials",
});
}
});
if (credentialTextPattern.test(definition.template)) {
ctx.addIssue({ code: "custom", path: ["template"], message: "Templates cannot contain literal credentials" });
}
if (definition.iconUrl && URL.canParse(definition.iconUrl)) {
const icon = new URL(definition.iconUrl);
if ([...icon.searchParams].some(([key, value]) => isCredentialKeyName(key) && value.trim().length > 0)) {
ctx.addIssue({ code: "custom", path: ["iconUrl"], message: "Widget icon URLs cannot contain credentials" });
export function validateCredentialFreeExport(definition: Record<string, unknown>, ctx: z.RefinementCtx) {
inspect(definition.options, ["options"], ctx);
inspect(definition.requests, ["requests"], ctx);
if (typeof definition.iconUrl === "string" && URL.canParse(definition.iconUrl)) {
const url = new URL(definition.iconUrl);
if ([...url.searchParams.keys()].some((key) => credentialKey.test(key))) {
ctx.addIssue({ code: "custom", path: ["iconUrl"], message: "Credentials must not be embedded in the icon URL" });
}
}
}
function containsCredentialLikeValue(value: unknown, key = ""): boolean {
if (isCredentialKeyName(key) && typeof value === "string" && value.trim().length > 0) return true;
if (Array.isArray(value)) return value.some((entry) => containsCredentialLikeValue(entry));
if (value === null || typeof value !== "object") return false;
const entries = Object.entries(value);
if (entries.length === 1 && entries[0]?.[0] === "$param" && typeof entries[0][1] === "string") return false;
return entries.some(([childKey, child]) => containsCredentialLikeValue(child, childKey));
}
export function validatePrototypeKeys(definition: SecurityDefinition, ctx: RefinementCtx) {
const values: Array<[Array<string | number>, unknown]> = [
[["optionsSchema"], definition.optionsSchema],
[["defaultOptions"], definition.defaultOptions],
];
definition.requests.forEach((request, index) => {
values.push([["requests", index, "optionsBinding"], request.optionsBinding]);
values.push([["requests", index, "queryTemplate"], request.queryTemplate]);
values.push([["requests", index, "bodyTemplate"], request.bodyTemplate]);
});
for (const [path, value] of values) {
const blockedPath = findBlockedObjectKey(value);
if (blockedPath) {
ctx.addIssue({
code: "custom",
path: [...path, ...blockedPath],
message: "Prototype-pollution keys are not allowed",
});
}
if (
typeof definition.template === "string" &&
/(?:authorization\s*:\s*bearer|api[-_ ]?key\s*[:=]|password\s*[:=]|secret\s*[:=]|token\s*[:=])\s*["']?[A-Za-z0-9._~+/%=-]{8,}/iu.test(
definition.template,
)
) {
ctx.addIssue({ code: "custom", path: ["template"], message: "Credentials must not be embedded in JSX" });
}
}
function findBlockedObjectKey(value: unknown, path: string[] = []): string[] | null {
if (Array.isArray(value)) {
for (const [index, entry] of value.entries()) {
const found = findBlockedObjectKey(entry, [...path, String(index)]);
if (found) return found;
}
return null;
}
if (value === null || typeof value !== "object") return null;
function inspect(value: unknown, path: Array<string | number>, ctx: z.RefinementCtx): void {
if (Array.isArray(value)) return value.forEach((entry, index) => inspect(entry, [...path, index], ctx));
if (!value || typeof value !== "object") return;
for (const [key, child] of Object.entries(value)) {
if (blockedObjectKeys.has(key.toLowerCase())) return [...path, key];
const found = findBlockedObjectKey(child, [...path, key]);
if (found) return found;
if (credentialKey.test(key) && typeof child === "string" && child.trim()) {
ctx.addIssue({ code: "custom", path: [...path, key], message: "Credentials must use source authentication" });
}
inspect(child, [...path, key], ctx);
}
}
export function validatePrototypeKeys(value: unknown, ctx: z.RefinementCtx) {
walk(value, [], ctx);
}
function walk(value: unknown, path: Array<string | number>, ctx: z.RefinementCtx): void {
if (Array.isArray(value)) return value.forEach((entry, index) => walk(entry, [...path, index], ctx));
if (!value || typeof value !== "object") return;
for (const [key, child] of Object.entries(value)) {
if (["__proto__", "prototype", "constructor"].includes(key))
ctx.addIssue({ code: "custom", path: [...path, key], message: "Unsafe object key" });
walk(child, [...path, key], ctx);
}
return null;
}
@@ -1,76 +0,0 @@
import generatedSkillContent from "./skill-content.generated.json";
import {
buildCustomWidgetAiPrompt,
CUSTOM_WIDGET_FINAL_OUTPUT_INSTRUCTION,
CUSTOM_WIDGET_MANTINE_VERSION,
finalizeCustomWidgetOfflineContent,
} from "./ai-prompt";
import {
CUSTOM_JSX_AUTHORING_CATALOG_SCHEMA_VERSION,
CUSTOM_WIDGET_AUTHORING_VERSION,
} from "./component-catalog-types";
import type { HomarrCustomWidgetV2 } from "./custom-jsx-schema";
export { CUSTOM_WIDGET_OFFLINE_BUNDLE_SENTINEL } from "./ai-prompt";
export const CUSTOM_WIDGET_SKILL_VERSION = "2.0.0";
interface GeneratedSkillContent {
schemaVersion: 1;
files: Array<{ path: string; content: string }>;
}
const skillContent = generatedSkillContent as GeneratedSkillContent;
export const CUSTOM_WIDGET_SKILL_FILES = skillContent.files as readonly Readonly<{
path: string;
content: string;
}>[];
const skillFile = CUSTOM_WIDGET_SKILL_FILES.find(({ path }) => path === "SKILL.md");
if (!skillFile) throw new Error("The generated Custom Widget skill is missing SKILL.md");
export const CUSTOM_WIDGET_SKILL_MD = skillFile.content;
const componentCatalogFile = CUSTOM_WIDGET_SKILL_FILES.find(({ path }) => path === "references/component-catalog.json");
const examplesFile = CUSTOM_WIDGET_SKILL_FILES.find(({ path }) => path === "references/examples.json");
if (!componentCatalogFile || !examplesFile)
throw new Error("The generated Custom Widget skill is missing its catalog or examples");
export const CUSTOM_WIDGET_CANONICAL_COMPONENT_CATALOG = componentCatalogFile.content.trim();
export const CUSTOM_WIDGET_CANONICAL_RUNTIME_EXAMPLES = examplesFile.content.trim();
const machineReferencePaths = new Set([componentCatalogFile.path, examplesFile.path]);
export const CUSTOM_WIDGET_OFFLINE_BUNDLE_CONTENT = [
"# Homarr Custom Widget offline authoring bundle",
[
`Skill version: ${CUSTOM_WIDGET_SKILL_VERSION}`,
`Custom Widget authoring version: ${CUSTOM_WIDGET_AUTHORING_VERSION}`,
`Mantine version: ${CUSTOM_WIDGET_MANTINE_VERSION}`,
`Component catalog schema: ${CUSTOM_JSX_AUTHORING_CATALOG_SCHEMA_VERSION}`,
].join("\n"),
...CUSTOM_WIDGET_SKILL_FILES.filter(({ path }) => !machineReferencePaths.has(path)).map(
({ path, content }) => `--- BEGIN SKILL FILE: ${path} ---\n${content.trimEnd()}\n--- END SKILL FILE: ${path} ---`,
),
`--- BEGIN CANONICAL COMPONENT CATALOG ---\n${CUSTOM_WIDGET_CANONICAL_COMPONENT_CATALOG}\n--- END CANONICAL COMPONENT CATALOG ---`,
`--- BEGIN CANONICAL RUNTIME EXAMPLES ---\n${CUSTOM_WIDGET_CANONICAL_RUNTIME_EXAMPLES}\n--- END CANONICAL RUNTIME EXAMPLES ---`,
].join("\n\n");
export const CUSTOM_WIDGET_OFFLINE_BUNDLE = finalizeCustomWidgetOfflineContent(CUSTOM_WIDGET_OFFLINE_BUNDLE_CONTENT);
export function buildCustomWidgetAiPromptWithEmbeddedSkill(
jsonSchema?: unknown,
rawResponse?: string | null,
currentConfig?: Partial<HomarrCustomWidgetV2> | Record<string, unknown> | null,
request?: string | null,
documentationUrl?: string | null,
) {
const prompt = [
buildCustomWidgetAiPrompt(jsonSchema, rawResponse, currentConfig, request, documentationUrl),
"This clipboard workflow has no Homarr MCP tools, repository access, or local files. The complete release-matched skill, schema, component catalog, and examples are embedded below. Author the best-effort widget now from this content; do not ask for tools or files. Homarr will validate it after paste.",
CUSTOM_WIDGET_OFFLINE_BUNDLE_CONTENT,
CUSTOM_WIDGET_FINAL_OUTPUT_INSTRUCTION,
].join("\n\n");
return finalizeCustomWidgetOfflineContent(prompt);
}
+47 -92
View File
@@ -1,121 +1,76 @@
import type { HomarrCustomWidgetV2 } from "./custom-jsx-schema";
import type { HomarrCustomWidgetV2Input } from "./custom-jsx-schema";
export interface CustomJsxExample {
id: string;
title: string;
description: string;
widget: HomarrCustomWidgetV2;
widget: HomarrCustomWidgetV2Input;
}
export const CUSTOM_JSX_STARTER = `<Stack gap="xs" p="sm">
<Group justify="space-between">
<Text fw={600}>Service status</Text>
<Badge color={data.status === "online" ? "green" : "red"}>{data.status ?? "unknown"}</Badge>
</Group>
<Text size="sm" c="dimmed">Configure a load query or paste sample data to start.</Text>
export const CUSTOM_JSX_STARTER = `<Stack gap="sm" p="sm">
<Group justify="space-between"><Text fw={700}>Service status</Text><RefreshButton /></Group>
{status.status?.loading ? <Skeleton height={72} radius="md" /> : status.status?.error ? <Alert color="red">{status.status.error}</Alert> : <Badge color={data.status?.online ? "green" : "red"}>{data.status?.online ? "Online" : "Offline"}</Badge>}
</Stack>`;
export const CUSTOM_WIDGET_STARTER: HomarrCustomWidgetV2 = {
export const CUSTOM_WIDGET_STARTER: HomarrCustomWidgetV2Input = {
$schema: "homarr-custom-widget-v2",
name: "New custom widget",
description: "",
sources: [
{
id: "default",
name: "API",
baseUrl: "https://example.com",
networkScope: "public",
auth: { type: "none" },
},
],
requests: [],
optionsSchema: { type: "object", properties: {}, additionalProperties: false },
defaultOptions: {},
sources: { default: { name: "API", baseUrl: "https://example.com", networkScope: "public", auth: "none" } },
requests: { status: { path: "/api/status" } },
options: {},
template: CUSTOM_JSX_STARTER,
};
export const customJsxExamples: readonly CustomJsxExample[] = [
{
id: "service-status",
title: "Service status",
description: "A compact status widget backed by one load query.",
id: "service-dashboard",
title: "Service dashboard",
description: "A polished responsive dashboard with clear loading and error states.",
widget: {
$schema: "homarr-custom-widget-v2",
name: "Service status",
sources: [
{
id: "default",
name: "Service API",
baseUrl: "https://example.com",
networkScope: "public",
auth: { type: "none" },
},
],
requests: [
{
id: "status",
sourceId: "default",
kind: "query",
method: "GET",
pathTemplate: "/api/status",
parameters: {},
auth: "inherit",
minimumBoardPermission: "view",
trigger: "load",
},
],
optionsSchema: { type: "object", properties: {}, additionalProperties: false },
defaultOptions: {},
template: `<Stack gap="xs" p="sm">
<Group justify="space-between">
<Text fw={600}>{data.status.name ?? "Service"}</Text>
<Badge color={data.status.online ? "green" : "red"}>{data.status.online ? "Online" : "Offline"}</Badge>
</Group>
<Text size="sm" c="dimmed">{data.status.message ?? "No additional details"}</Text>
</Stack>`,
name: "Service dashboard",
sources: {
default: { name: "Service API", baseUrl: "https://example.com", networkScope: "public", auth: "none" },
},
requests: { overview: { path: "/api/overview", cacheSeconds: 30 } },
options: {},
template: `<Stack gap="md" p="md">
<Group justify="space-between"><Stack gap={2}><Text fw={700}>System overview</Text><Text size="xs" c="dimmed">Live service health</Text></Stack><RefreshButton /></Group>
{status.overview?.loading ? <Skeleton height={120} radius="lg" /> : status.overview?.error ? <Alert color="red" title="Could not load status">{status.overview.error}</Alert> : <SimpleGrid cols={{ base: 1, xs: 3 }}>{(data.overview?.services ?? []).map(service => <Paper key={service.name} withBorder p="sm" radius="md"><Group justify="space-between"><Text size="sm" fw={600}>{service.name}</Text><Badge color={service.online ? "green" : "red"} variant="light">{service.online ? "Online" : "Offline"}</Badge></Group><Text mt="xs" size="xl" fw={700}>{service.latency ?? "—"}<Text span size="xs" c="dimmed"> ms</Text></Text></Paper>)}</SimpleGrid>}
</Stack>`,
},
},
{
id: "pokemon-evolution-tree",
title: "Pokémon evolution tree",
description: "A trusted arbitrary-depth tree with one immutable derived-value block per node.",
id: "search-and-action",
title: "Search and action",
description: "A temporary search input, manual query, and explicit mutation.",
widget: {
$schema: "homarr-custom-widget-v2",
name: "Pokémon evolution tree",
sources: [
{
id: "default",
name: "PokeAPI",
baseUrl: "https://pokeapi.co",
networkScope: "public",
auth: { type: "none" },
name: "Series search",
sources: {
default: {
name: "Media API",
baseUrl: "https://example.com",
networkScope: "private",
auth: { type: "apiKeyHeader", name: "X-Api-Key" },
},
],
requests: [
{
id: "evolution",
sourceId: "default",
kind: "query",
method: "GET",
pathTemplate: "/api/v2/evolution-chain/1",
parameters: {},
auth: "inherit",
minimumBoardPermission: "view",
trigger: "load",
},
requests: {
search: { path: "/api/series/search", trigger: "manual", query: { term: { $param: "query" } } },
monitor: {
kind: "action",
method: "POST",
path: "/api/series/{param:id}/monitor",
confirmation: "Monitor this series?",
},
],
optionsSchema: { type: "object", properties: {}, additionalProperties: false },
defaultOptions: {},
template: `<Stack gap="sm" p="sm">
<Title order={3}>Evolution chain</Title>
{status.evolution?.loading ? <Skeleton height={160} radius="md" /> : status.evolution?.error ? <Alert color="red">{status.evolution.error}</Alert> : data.evolution?.chain ?
<RecursiveList data={data.evolution.chain} childrenPath="evolves_to" keyPath="species.name" defaultExpandedDepth={8} showLines>
{(node, meta) => {
const segments = node.species.url.split("/").filter((part) => part);
const dexId = segments.at(-1);
return <Group gap="xs"><Badge variant="light">#{dexId}</Badge><Text tt="capitalize" fw={600}>{node.species.name}</Text>{meta.hasChildren && <Text size="xs" c="dimmed">{meta.childCount} next</Text>}</Group>;
}}
</RecursiveList> : <Text c="dimmed">No evolution chain returned.</Text>}
},
options: {},
template: `<Stack gap="sm" p="md">
<TextInput bind="search" label="Search TV shows" placeholder="Start typing…" />
<SubFetch requestId="search" params={{ query: inputs.search }}>
{(results) => <Stack gap="xs">{(results ?? []).map(series => <Paper key={series.id} withBorder p="sm" radius="md"><Group justify="space-between" wrap="nowrap"><Stack gap={2}><Text fw={600}>{series.title}</Text><Text size="xs" c="dimmed">{series.year ?? "Unknown year"}</Text></Stack><ActionButton requestId="monitor" params={{ id: series.id }}>Monitor</ActionButton></Group></Paper>)}</Stack>}
</SubFetch>
</Stack>`,
},
},
+12 -33
View File
@@ -73,32 +73,6 @@ function isTemplatePlaceholder(value: unknown) {
return typeof value === "string" && value.replace(/[\\*_`\s]/gu, "").toUpperCase() === "HOMARRTEMPLATE";
}
function normalizeAiRequestParameters(value: Record<string, unknown>, warnings: CustomWidgetImportIssue[]) {
if (!Array.isArray(value.requests)) return;
value.requests = value.requests.map((candidate, requestIndex) => {
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return candidate;
const request = { ...(candidate as Record<string, unknown>) };
if (!request.parameters || typeof request.parameters !== "object" || Array.isArray(request.parameters))
return request;
const parameters = { ...(request.parameters as Record<string, unknown>) };
for (const [name, parameter] of Object.entries(parameters)) {
if (!parameter || typeof parameter !== "object" || Array.isArray(parameter)) continue;
const keys = Object.keys(parameter);
const type = (parameter as Record<string, unknown>).type;
if (keys.length === 1 && ["string", "number", "boolean"].includes(String(type))) {
parameters[name] = type;
warnings.push({
code: "NORMALIZED_PARAMETER_TYPE",
path: ["requests", requestIndex, "parameters", name],
message: `Converted { type: "${String(type)}" } to "${String(type)}".`,
});
}
}
request.parameters = parameters;
return request;
});
}
type ParsedWidgetValue =
| { success: true; value: Record<string, unknown> }
| { success: false; issue: CustomWidgetImportIssue };
@@ -179,7 +153,6 @@ export function parseCustomWidgetAiResponse(text: string): CustomWidgetParseResu
const removedIssues = removedStateIssues(widget);
if (removedIssues.length > 0) formatIssues.push(...removedIssues);
const warnings: CustomWidgetImportIssue[] = [];
normalizeAiRequestParameters(widget, warnings);
if (jsxBlocks[0]) {
if (isTemplatePlaceholder(widget.template) || !widget.template) widget.template = jsxBlocks[0].content.trim();
else if (widget.template !== jsxBlocks[0].content.trim())
@@ -214,11 +187,17 @@ export function getImportReview(value: unknown): ImportReview | null {
const widget = parsed.data;
return {
name: widget.name,
origins: widget.sources.map((source) => new URL(source.baseUrl).origin),
authTypes: [...new Set(widget.sources.map((source) => source.auth.type))],
networkScopes: [...new Set(widget.sources.map((source) => source.networkScope))],
methods: [...new Set(widget.requests.map((request) => request.method))],
permissions: [...new Set(widget.requests.map((request) => request.minimumBoardPermission))],
hasActions: widget.requests.some((request) => request.kind === "action"),
origins: Object.values(widget.sources).map((source) => new URL(source.baseUrl).origin),
authTypes: [
...new Set(
Object.values(widget.sources).map((source) =>
typeof source.auth === "string" ? source.auth : source.auth.type,
),
),
],
networkScopes: [...new Set(Object.values(widget.sources).map((source) => source.networkScope))],
methods: [...new Set(Object.values(widget.requests).map((request) => request.method))],
permissions: [...new Set(Object.values(widget.requests).map((request) => request.permission))],
hasActions: Object.values(widget.requests).some((request) => request.kind === "action"),
};
}
@@ -1,39 +1,14 @@
import { z } from "zod/v4";
import { matchesDeclaredOptionType } from "./option-value-types";
import { customWidgetIdentifierSchema } from "./request-schema";
const optionSchemaKeys = new Set([
"type",
"title",
"description",
"default",
"enum",
"const",
"minimum",
"maximum",
"multipleOf",
"minLength",
"maxLength",
"minItems",
"maxItems",
"format",
"items",
"properties",
"required",
"additionalProperties",
"if",
"then",
"else",
"x-homarr",
]);
const optionTypes = new Set(["object", "array", "string", "number", "integer", "boolean", "null"]);
const optionControls = new Set([
export const customWidgetOptionControls = [
"text",
"textarea",
"number",
"switch",
"select",
"multi-select",
"multiSelect",
"slider",
"date",
"time",
@@ -43,258 +18,91 @@ const optionControls = new Set([
"duration",
"timeZone",
"json",
]);
const optionFormats = new Set(["date", "date-time", "time", "color", "uri", "icon", "duration", "time-zone"]);
const finiteNumberKeywords = ["minimum", "maximum", "multipleOf"] as const;
const nonNegativeIntegerKeywords = ["minLength", "maxLength", "minItems", "maxItems"] as const;
const credentialOptionName =
/(^|[-_])(authorization|api[-_]?keys?|passwords?|passwds?|secrets?|tokens?|access[-_]?tokens?|refresh[-_]?tokens?|client[-_]?secrets?)($|[-_])/iu;
const controlsByType: Readonly<Record<string, ReadonlySet<string>>> = {
string: new Set(["text", "textarea", "select", "date", "time", "color", "icon", "url", "timeZone", "json"]),
number: new Set(["number", "select", "slider", "duration", "json"]),
integer: new Set(["number", "select", "slider", "duration", "json"]),
boolean: new Set(["switch", "select", "json"]),
array: new Set(["multi-select", "json"]),
object: new Set(["json"]),
null: new Set(["json"]),
};
] as const;
function validateOptionsSource(value: unknown, ctx: z.RefinementCtx, path: Array<string | number>) {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
ctx.addIssue({ code: "custom", path, message: "optionsSource must be an object" });
return;
}
const keys = Object.keys(value);
if (
keys.some((key) => !["requestId", "itemsPath", "valuePath", "labelPath"].includes(key)) ||
!["requestId", "valuePath", "labelPath"].every((key) => {
const candidate = (value as Record<string, unknown>)[key];
return typeof candidate === "string" && candidate.length > 0;
})
) {
ctx.addIssue({ code: "custom", path, message: "optionsSource requires requestId, valuePath, and labelPath" });
}
const itemsPath = (value as Record<string, unknown>).itemsPath;
if (itemsPath !== undefined && (typeof itemsPath !== "string" || itemsPath.length === 0)) {
ctx.addIssue({ code: "custom", path: [...path, "itemsPath"], message: "itemsPath must be a non-empty path" });
}
}
const choiceSchema = z.strictObject({
label: z.string().trim().min(1).max(128),
value: z.union([z.string(), z.number().finite()]),
});
function validatePresentation(
value: unknown,
schema: Record<string, unknown>,
ctx: z.RefinementCtx,
path: Array<string | number>,
) {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
ctx.addIssue({ code: "custom", path, message: "x-homarr must be an object" });
return;
}
const metadata = value as Record<string, unknown>;
const allowed = new Set(["control", "placeholder", "advanced", "order", "optionsSource"]);
for (const key of Object.keys(metadata)) {
if (!allowed.has(key))
ctx.addIssue({ code: "custom", path: [...path, key], message: `Unsupported x-homarr field '${key}'` });
}
if (
metadata.control !== undefined &&
(typeof metadata.control !== "string" || !optionControls.has(metadata.control))
) {
ctx.addIssue({ code: "custom", path: [...path, "control"], message: "Unknown Homarr option control" });
} else if (
typeof metadata.control === "string" &&
typeof schema.type === "string" &&
!controlsByType[schema.type]?.has(metadata.control)
) {
ctx.addIssue({
code: "custom",
path: [...path, "control"],
message: `Control '${metadata.control}' does not support ${schema.type} values`,
});
}
if (metadata.placeholder !== undefined && typeof metadata.placeholder !== "string") {
ctx.addIssue({ code: "custom", path: [...path, "placeholder"], message: "placeholder must be a string" });
}
if (metadata.advanced !== undefined && typeof metadata.advanced !== "boolean") {
ctx.addIssue({ code: "custom", path: [...path, "advanced"], message: "advanced must be a boolean" });
}
if (metadata.order !== undefined && (typeof metadata.order !== "number" || !Number.isFinite(metadata.order))) {
ctx.addIssue({ code: "custom", path: [...path, "order"], message: "order must be a finite number" });
}
if (metadata.optionsSource !== undefined) {
validateOptionsSource(metadata.optionsSource, ctx, [...path, "optionsSource"]);
if (metadata.control !== "select" && metadata.control !== "multi-select") {
const choicesFromSchema = z.strictObject({
request: customWidgetIdentifierSchema,
itemsPath: z.preprocess(
(value) => (typeof value === "string" && value.trim() === "" ? undefined : value),
z.string().trim().min(1).optional(),
),
valuePath: z.string().trim().min(1),
labelPath: z.string().trim().min(1),
});
export const customWidgetOptionSchema = z
.strictObject({
label: z.string().trim().min(1).max(128),
description: z.string().max(512).optional(),
control: z.enum(customWidgetOptionControls),
default: z.json(),
choices: z.array(choiceSchema).min(1).max(100).optional(),
choicesFrom: choicesFromSchema.optional(),
min: z.number().finite().optional(),
max: z.number().finite().optional(),
step: z.number().finite().positive().optional(),
advanced: z.boolean().optional(),
group: z.string().trim().min(1).max(128).optional(),
})
.superRefine((option, ctx) => {
const issue = getCustomWidgetOptionValueIssue(option, option.default);
if (issue) ctx.addIssue({ code: "custom", path: ["default"], message: issue });
if (option.min !== undefined && option.max !== undefined && option.min > option.max) {
ctx.addIssue({ code: "custom", path: ["min"], message: "Minimum cannot be greater than maximum" });
}
if ((option.choices || option.choicesFrom) && !["select", "multiSelect"].includes(option.control)) {
ctx.addIssue({ code: "custom", path: ["choices"], message: "Choices require a select or multiSelect control" });
}
if (option.choices && option.choicesFrom) {
ctx.addIssue({
code: "custom",
path: [...path, "optionsSource"],
message: "Dynamic options require a select or multi-select control",
path: ["choicesFrom"],
message: "Use static choices or dynamic choices, not both",
});
}
}
}
function validateScalarKeywords(schema: Record<string, unknown>, ctx: z.RefinementCtx, path: Array<string | number>) {
for (const key of finiteNumberKeywords) {
if (schema[key] !== undefined && (typeof schema[key] !== "number" || !Number.isFinite(schema[key]))) {
ctx.addIssue({ code: "custom", path: [...path, key], message: `${key} must be a finite number` });
if (
option.choices &&
new Set(option.choices.map((choice) => String(choice.value))).size !== option.choices.length
) {
ctx.addIssue({ code: "custom", path: ["choices"], message: "Choice values must be unique" });
}
}
if (typeof schema.multipleOf === "number" && schema.multipleOf <= 0)
ctx.addIssue({ code: "custom", path: [...path, "multipleOf"], message: "multipleOf must be greater than zero" });
for (const key of nonNegativeIntegerKeywords) {
if (schema[key] !== undefined && (!Number.isInteger(schema[key]) || Number(schema[key]) < 0)) {
ctx.addIssue({ code: "custom", path: [...path, key], message: `${key} must be a non-negative integer` });
}
}
for (const [minimum, maximum] of [
["minimum", "maximum"],
["minLength", "maxLength"],
["minItems", "maxItems"],
] as const) {
if (typeof schema[minimum] === "number" && typeof schema[maximum] === "number" && schema[minimum] > schema[maximum])
ctx.addIssue({ code: "custom", path, message: `${minimum} cannot be greater than ${maximum}` });
}
}
});
function validateChoices(schema: Record<string, unknown>, ctx: z.RefinementCtx, path: Array<string | number>) {
if (
schema.enum !== undefined &&
(!Array.isArray(schema.enum) || schema.enum.length === 0 || schema.enum.length > 100)
) {
ctx.addIssue({ code: "custom", path: [...path, "enum"], message: "enum must contain between 1 and 100 values" });
} else if (
Array.isArray(schema.enum) &&
schema.enum.some((entry) => !matchesDeclaredOptionType(schema.type, entry))
) {
ctx.addIssue({ code: "custom", path: [...path, "enum"], message: "Every enum value must match the option type" });
}
if (schema.const !== undefined && !matchesDeclaredOptionType(schema.type, schema.const))
ctx.addIssue({ code: "custom", path: [...path, "const"], message: "const must match the option type" });
}
export const customWidgetOptionsSchema = z
.record(customWidgetIdentifierSchema, customWidgetOptionSchema)
.refine((options) => Object.keys(options).length <= 64, "A widget can define at most 64 options")
.default({});
function validateOptionSchemaNode(
export type CustomWidgetOption = z.infer<typeof customWidgetOptionSchema>;
export type CustomWidgetOptions = z.infer<typeof customWidgetOptionsSchema>;
export function getCustomWidgetOptionValueIssue(
option: Pick<CustomWidgetOption, "control" | "min" | "max" | "choices">,
value: unknown,
ctx: z.RefinementCtx,
path: Array<string | number>,
isRoot = false,
requiresType = true,
propertyTypesRequired = true,
): void {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
ctx.addIssue({ code: "custom", path, message: "Option schemas must be objects" });
return;
}
const schema = value as Record<string, unknown>;
for (const key of Object.keys(schema)) {
if (!optionSchemaKeys.has(key)) {
ctx.addIssue({ code: "custom", path: [...path, key], message: `Unsupported option schema keyword '${key}'` });
}
}
if (
(requiresType && typeof schema.type !== "string") ||
(schema.type !== undefined && !optionTypes.has(String(schema.type)))
) {
ctx.addIssue({ code: "custom", path: [...path, "type"], message: "Every option schema needs one supported type" });
}
for (const key of ["title", "description"] as const) {
if (schema[key] !== undefined && typeof schema[key] !== "string") {
ctx.addIssue({ code: "custom", path: [...path, key], message: `${key} must be a string` });
}
}
validateScalarKeywords(schema, ctx, path);
if (schema.format !== undefined && (typeof schema.format !== "string" || !optionFormats.has(schema.format))) {
ctx.addIssue({ code: "custom", path: [...path, "format"], message: "Unsupported option format" });
}
if (schema.format !== undefined && schema.type !== "string") {
ctx.addIssue({ code: "custom", path: [...path, "format"], message: "Option formats require a string type" });
}
validateChoices(schema, ctx, path);
if (isRoot && schema.type !== "object") {
ctx.addIssue({ code: "custom", path: [...path, "type"], message: "The root options schema must be an object" });
}
if (schema.additionalProperties !== undefined && schema.additionalProperties !== false) {
ctx.addIssue({
code: "custom",
path: [...path, "additionalProperties"],
message: "additionalProperties can only be false",
});
}
if (schema.type === "object" && schema.additionalProperties !== false) {
ctx.addIssue({
code: "custom",
path: [...path, "additionalProperties"],
message: "Object options must set additionalProperties to false",
});
}
if (schema.properties !== undefined) {
if (schema.type !== undefined && schema.type !== "object") {
ctx.addIssue({ code: "custom", path: [...path, "properties"], message: "properties require an object type" });
}
if (schema.properties === null || typeof schema.properties !== "object" || Array.isArray(schema.properties)) {
ctx.addIssue({ code: "custom", path: [...path, "properties"], message: "properties must be an object" });
} else {
for (const [key, child] of Object.entries(schema.properties)) {
if (!/^[A-Za-z][A-Za-z0-9_-]{0,63}$/u.test(key)) {
ctx.addIssue({
code: "custom",
path: [...path, "properties", key],
message: "Option names must start with a letter and contain letters, numbers, - or _",
});
}
if (credentialOptionName.test(key)) {
ctx.addIssue({
code: "custom",
path: [...path, "properties", key],
message: "Credentials must use source authentication, not widget options",
});
}
validateOptionSchemaNode(child, ctx, [...path, "properties", key], false, propertyTypesRequired);
}
}
}
if (schema.required !== undefined && !Array.isArray(schema.required)) {
ctx.addIssue({ code: "custom", path: [...path, "required"], message: "required must be an array" });
} else if (
Array.isArray(schema.required) &&
schema.properties !== null &&
typeof schema.properties === "object" &&
!Array.isArray(schema.properties)
) {
for (const key of schema.required) {
if (typeof key !== "string" || !Object.hasOwn(schema.properties, key)) {
ctx.addIssue({ code: "custom", path: [...path, "required"], message: "required contains an unknown option" });
}
}
}
if (schema.type === "array" && schema.items === undefined) {
ctx.addIssue({ code: "custom", path: [...path, "items"], message: "Array options require an items schema" });
}
if (schema.items !== undefined) {
if (schema.type !== undefined && schema.type !== "array") {
ctx.addIssue({ code: "custom", path: [...path, "items"], message: "items require an array type" });
}
validateOptionSchemaNode(schema.items, ctx, [...path, "items"]);
}
for (const branch of ["if", "then", "else"] as const) {
if (schema[branch] !== undefined) {
validateOptionSchemaNode(schema[branch], ctx, [...path, branch], false, false, branch !== "if");
}
}
if (schema["x-homarr"] !== undefined) validatePresentation(schema["x-homarr"], schema, ctx, [...path, "x-homarr"]);
}
): string | null {
if (["number", "slider", "duration"].includes(option.control)) {
if (typeof value !== "number" || !Number.isFinite(value)) return "Expected a number";
if (option.min !== undefined && value < option.min) return `Must be at least ${option.min}`;
if (option.max !== undefined && value > option.max) return `Must be at most ${option.max}`;
} else if (option.control === "switch") {
if (typeof value !== "boolean") return "Expected a boolean";
} else if (option.control === "multiSelect") {
if (!Array.isArray(value) || value.some((entry) => !["string", "number"].includes(typeof entry)))
return "Expected a list of choices";
} else if (option.control !== "json" && typeof value !== "string") return "Expected text";
export const customWidgetOptionsSchemaSchema = z
.object({
type: z.literal("object"),
properties: z.record(z.string(), z.unknown()).default({}),
required: z.array(z.string()).optional(),
additionalProperties: z.literal(false).default(false),
title: z.string().optional(),
description: z.string().optional(),
if: z.unknown().optional(),
// oxlint-disable-next-line unicorn/no-thenable -- JSON Schema uses the standard `then` keyword.
then: z.unknown().optional(),
else: z.unknown().optional(),
})
.strict()
.superRefine((schema, ctx) => validateOptionSchemaNode(schema, ctx, [], true));
if (option.control === "url" && typeof value === "string" && value && !URL.canParse(value))
return "Must be a valid URL";
if (option.choices && (option.control === "select" || option.control === "multiSelect")) {
const allowed = option.choices.map((choice) => choice.value);
const values = Array.isArray(value) ? value : [value];
if (values.some((entry) => !allowed.some((choice) => Object.is(choice, entry))))
return "Value is not one of the allowed choices";
}
return null;
}
+13 -93
View File
@@ -1,106 +1,26 @@
import { matchesDeclaredOptionType } from "./option-value-types";
import { getCustomWidgetOptionValueIssue } from "./options-schema";
import type { CustomWidgetOptions } from "./options-schema";
export interface CustomWidgetOptionIssue {
path: string;
message: string;
}
type JsonSchema = Record<string, unknown>;
export function getCustomWidgetDefaultOptions(options: CustomWidgetOptions): Record<string, unknown> {
return Object.fromEntries(Object.entries(options).map(([name, option]) => [name, option.default]));
}
export function validateCustomWidgetOptions(
schema: JsonSchema,
options: CustomWidgetOptions,
value: Record<string, unknown>,
): CustomWidgetOptionIssue[] {
const issues: CustomWidgetOptionIssue[] = [];
validateValue(schema, value, "configuration", issues);
for (const [name, option] of Object.entries(options)) {
const issue = getCustomWidgetOptionValueIssue(option, value[name]);
if (issue) issues.push({ path: `configuration.${name}`, message: issue });
}
for (const name of Object.keys(value)) {
if (!Object.hasOwn(options, name)) issues.push({ path: `configuration.${name}`, message: "Unknown option" });
}
return issues;
}
function validateValue(schema: JsonSchema, value: unknown, path: string, issues: CustomWidgetOptionIssue[]): void {
if (schema.const !== undefined && !Object.is(value, schema.const)) {
issues.push({ path, message: "Value does not match the required constant" });
return;
}
if (Array.isArray(schema.enum) && !schema.enum.some((candidate) => Object.is(candidate, value))) {
issues.push({ path, message: "Value is not one of the allowed choices" });
return;
}
const type = schema.type;
if (typeof type === "string" && !matchesDeclaredOptionType(type, value)) {
issues.push({ path, message: `Expected ${type}` });
return;
}
validateScalarValue(schema, value, path, issues);
if (Array.isArray(value)) validateArrayValue(schema, value, path, issues);
if (isRecord(value)) validateObjectValue(schema, value, path, issues);
else if (isRecord(schema.if)) {
const branch = matchesSchema(schema.if, value) ? schema.then : schema.else;
if (isRecord(branch)) validateValue(branch, value, path, issues);
}
}
function validateScalarValue(schema: JsonSchema, value: unknown, path: string, issues: CustomWidgetOptionIssue[]) {
if (typeof value === "string") {
if (typeof schema.minLength === "number" && value.length < schema.minLength)
issues.push({ path, message: `Must contain at least ${schema.minLength} characters` });
if (typeof schema.maxLength === "number" && value.length > schema.maxLength)
issues.push({ path, message: `Must contain at most ${schema.maxLength} characters` });
if (schema.format === "uri" && !URL.canParse(value)) issues.push({ path, message: "Must be a valid URL" });
}
if (typeof value === "number") {
if (typeof schema.minimum === "number" && value < schema.minimum)
issues.push({ path, message: `Must be at least ${schema.minimum}` });
if (typeof schema.maximum === "number" && value > schema.maximum)
issues.push({ path, message: `Must be at most ${schema.maximum}` });
if (typeof schema.multipleOf === "number" && schema.multipleOf > 0 && value % schema.multipleOf !== 0)
issues.push({ path, message: `Must be a multiple of ${schema.multipleOf}` });
}
}
function validateArrayValue(schema: JsonSchema, value: unknown[], path: string, issues: CustomWidgetOptionIssue[]) {
if (typeof schema.minItems === "number" && value.length < schema.minItems)
issues.push({ path, message: `Choose at least ${schema.minItems} values` });
if (typeof schema.maxItems === "number" && value.length > schema.maxItems)
issues.push({ path, message: `Choose at most ${schema.maxItems} values` });
if (isRecord(schema.items))
value.forEach((entry, index) => validateValue(schema.items as JsonSchema, entry, `${path}.${index}`, issues));
}
function validateObjectValue(
schema: JsonSchema,
value: Record<string, unknown>,
path: string,
issues: CustomWidgetOptionIssue[],
) {
const branch = isRecord(schema.if) ? (matchesSchema(schema.if, value) ? schema.then : schema.else) : undefined;
const branchSchema = isRecord(branch) ? branch : {};
const properties = {
...(isRecord(schema.properties) ? schema.properties : {}),
...(isRecord(branchSchema.properties) ? branchSchema.properties : {}),
};
const required = [
...(Array.isArray(schema.required) ? schema.required : []),
...(Array.isArray(branchSchema.required) ? branchSchema.required : []),
].filter((entry): entry is string => typeof entry === "string");
for (const key of required) {
if (value[key] === undefined) issues.push({ path: `${path}.${key}`, message: "This field is required" });
}
for (const [key, child] of Object.entries(value)) {
const childSchema = properties[key];
if (isRecord(childSchema)) validateValue(childSchema, child, `${path}.${key}`, issues);
else if (schema.additionalProperties === false) issues.push({ path: `${path}.${key}`, message: "Unknown option" });
}
}
function matchesSchema(schema: JsonSchema, value: unknown): boolean {
const issues: CustomWidgetOptionIssue[] = [];
validateValue(schema, value, "value", issues);
return issues.length === 0;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
Loaded 100 of 177 files, more files were not shown because too many files have changed in this diff. Show more