fix(custom-widgets): harden v2 authoring
This commit is contained in:
171 files changed
+20311
-50527
No files matched your search
@@ -10,12 +10,30 @@ metadata:
|
||||
|
||||
Create one widget at a time. Repeat the workflow when the user asks for several widgets; do not invent packs or shared connections.
|
||||
|
||||
## Choose the authoring reference
|
||||
|
||||
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.
|
||||
|
||||
For offline authoring, read these installed, release-matched references in order:
|
||||
|
||||
1. [Schema](references/schema.md)
|
||||
2. [JSX runtime](references/runtime.md)
|
||||
3. [Security boundary](references/security.md)
|
||||
4. [Component catalog](references/components.md)
|
||||
5. [Mantine Core](references/mantine-core.md)
|
||||
6. [Mantine Dates](references/mantine-dates.md)
|
||||
7. [Mantine Charts](references/mantine-charts.md)
|
||||
8. [Homarr components](references/homarr-components.md)
|
||||
|
||||
Together, this file and those references are the complete offline authoring bundle for the matching Homarr release. Do not require MCP tools, repository access, or external component documentation to produce a best-effort widget. If live metadata is available, prefer it over the installed static bundle.
|
||||
|
||||
## 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. Return the complete manifest and JSX in the exact fenced-block format requested by the authoring prompt.
|
||||
4. When the user supplies Homarr diagnostics, correct every reported issue while preserving unrelated fields.
|
||||
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
|
||||
|
||||
@@ -23,8 +41,6 @@ Create one widget at a time. Repeat the workflow when the user asks for several
|
||||
- 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.
|
||||
- Never put a credential in JSX, options, headers, URLs, examples, exports, logs, or chat output.
|
||||
- Use declarative `bind` controls and Homarr request components; never emit imports, hooks, callbacks, browser requests, or arbitrary JavaScript.
|
||||
- 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.
|
||||
|
||||
Follow this document and the supplied authoring prompt as the complete authoring reference. Do not assume access to repository-relative files.
|
||||
@@ -0,0 +1,563 @@
|
||||
# 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 offline Custom JSX component contract for this Homarr release. When connected to Homarr, prefer the live catalog because the installed release may differ. The catalog is descriptive, not a prop allowlist: 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.
|
||||
- The effective prop API is the shared prop table plus the component-specific table in the package reference. A component-specific record overrides the shared record with the same name.
|
||||
- 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`](mantine-core.md#component-accordion), [`Accordion.Chevron`](mantine-core.md#component-accordion-2e-chevron), [`Accordion.Control`](mantine-core.md#component-accordion-2e-control), [`Accordion.Item`](mantine-core.md#component-accordion-2e-item), [`Accordion.Panel`](mantine-core.md#component-accordion-2e-panel), [`AccordionChevron`](mantine-core.md#component-accordionchevron), [`AccordionControl`](mantine-core.md#component-accordioncontrol), [`AccordionItem`](mantine-core.md#component-accordionitem), [`AccordionPanel`](mantine-core.md#component-accordionpanel), [`ActionIcon`](mantine-core.md#component-actionicon), [`ActionIcon.Group`](mantine-core.md#component-actionicon-2e-group), [`ActionIcon.GroupSection`](mantine-core.md#component-actionicon-2e-groupsection), [`ActionIconGroup`](mantine-core.md#component-actionicongroup), [`ActionIconGroupSection`](mantine-core.md#component-actionicongroupsection), [`Alert`](mantine-core.md#component-alert), [`AlphaSlider`](mantine-core.md#component-alphaslider), [`Anchor`](mantine-core.md#component-anchor), [`AngleSlider`](mantine-core.md#component-angleslider), [`AspectRatio`](mantine-core.md#component-aspectratio), [`Autocomplete`](mantine-core.md#component-autocomplete), [`Avatar`](mantine-core.md#component-avatar), [`Avatar.Group`](mantine-core.md#component-avatar-2e-group), [`AvatarGroup`](mantine-core.md#component-avatargroup), [`BackgroundImage`](mantine-core.md#component-backgroundimage), [`Badge`](mantine-core.md#component-badge), [`Blockquote`](mantine-core.md#component-blockquote), [`Box`](mantine-core.md#component-box), [`Breadcrumbs`](mantine-core.md#component-breadcrumbs), [`Burger`](mantine-core.md#component-burger), [`Button`](mantine-core.md#component-button), [`Button.Group`](mantine-core.md#component-button-2e-group), [`Button.GroupSection`](mantine-core.md#component-button-2e-groupsection), [`ButtonGroup`](mantine-core.md#component-buttongroup), [`ButtonGroupSection`](mantine-core.md#component-buttongroupsection), [`Card`](mantine-core.md#component-card), [`Card.Section`](mantine-core.md#component-card-2e-section), [`CardSection`](mantine-core.md#component-cardsection), [`Center`](mantine-core.md#component-center), [`CheckIcon`](mantine-core.md#component-checkicon), [`Checkbox`](mantine-core.md#component-checkbox), [`Checkbox.Card`](mantine-core.md#component-checkbox-2e-card), [`Checkbox.Group`](mantine-core.md#component-checkbox-2e-group), [`Checkbox.Indicator`](mantine-core.md#component-checkbox-2e-indicator), [`CheckboxCard`](mantine-core.md#component-checkboxcard), [`CheckboxGroup`](mantine-core.md#component-checkboxgroup), [`CheckboxIndicator`](mantine-core.md#component-checkboxindicator), [`Chip`](mantine-core.md#component-chip), [`Chip.Group`](mantine-core.md#component-chip-2e-group), [`ChipGroup`](mantine-core.md#component-chipgroup), [`CloseButton`](mantine-core.md#component-closebutton), [`CloseIcon`](mantine-core.md#component-closeicon), [`Code`](mantine-core.md#component-code), [`Collapse`](mantine-core.md#component-collapse), [`ColorInput`](mantine-core.md#component-colorinput), [`ColorPicker`](mantine-core.md#component-colorpicker), [`ColorSwatch`](mantine-core.md#component-colorswatch), [`Container`](mantine-core.md#component-container), [`CopyButton`](mantine-core.md#component-copybutton), [`DataList`](mantine-core.md#component-datalist), [`DataList.Item`](mantine-core.md#component-datalist-2e-item), [`DataList.ItemLabel`](mantine-core.md#component-datalist-2e-itemlabel), [`DataList.ItemValue`](mantine-core.md#component-datalist-2e-itemvalue), [`DataListItem`](mantine-core.md#component-datalistitem), [`DataListItemLabel`](mantine-core.md#component-datalistitemlabel), [`DataListItemValue`](mantine-core.md#component-datalistitemvalue), [`Divider`](mantine-core.md#component-divider), [`EmptyState`](mantine-core.md#component-emptystate), [`EmptyState.Actions`](mantine-core.md#component-emptystate-2e-actions), [`EmptyState.Description`](mantine-core.md#component-emptystate-2e-description), [`EmptyState.Indicator`](mantine-core.md#component-emptystate-2e-indicator), [`EmptyState.Title`](mantine-core.md#component-emptystate-2e-title), [`EmptyStateActions`](mantine-core.md#component-emptystateactions), [`EmptyStateDescription`](mantine-core.md#component-emptystatedescription), [`EmptyStateIndicator`](mantine-core.md#component-emptystateindicator), [`EmptyStateTitle`](mantine-core.md#component-emptystatetitle), [`Fieldset`](mantine-core.md#component-fieldset), [`FlatTreeNode`](mantine-core.md#component-flattreenode), [`Flex`](mantine-core.md#component-flex), [`FloatingArrow`](mantine-core.md#component-floatingarrow), [`FloatingIndicator`](mantine-core.md#component-floatingindicator), [`Grid`](mantine-core.md#component-grid), [`Grid.Col`](mantine-core.md#component-grid-2e-col), [`GridCol`](mantine-core.md#component-gridcol), [`Group`](mantine-core.md#component-group), [`Highlight`](mantine-core.md#component-highlight), [`HoverCardLine truncated
|
||||
|
||||
### Mantine Dates
|
||||
|
||||
[`Calendar`](mantine-dates.md#component-calendar), [`CalendarHeader`](mantine-dates.md#component-calendarheader), [`DateInput`](mantine-dates.md#component-dateinput), [`DatePicker`](mantine-dates.md#component-datepicker), [`DatePickerInput`](mantine-dates.md#component-datepickerinput), [`DateTimePicker`](mantine-dates.md#component-datetimepicker), [`Day`](mantine-dates.md#component-day), [`DecadeLevel`](mantine-dates.md#component-decadelevel), [`DecadeLevelGroup`](mantine-dates.md#component-decadelevelgroup), [`InlineDateTimePicker`](mantine-dates.md#component-inlinedatetimepicker), [`LevelsGroup`](mantine-dates.md#component-levelsgroup), [`MiniCalendar`](mantine-dates.md#component-minicalendar), [`Month`](mantine-dates.md#component-month), [`MonthLevel`](mantine-dates.md#component-monthlevel), [`MonthLevelGroup`](mantine-dates.md#component-monthlevelgroup), [`MonthPicker`](mantine-dates.md#component-monthpicker), [`MonthPickerInput`](mantine-dates.md#component-monthpickerinput), [`MonthsList`](mantine-dates.md#component-monthslist), [`PickerControl`](mantine-dates.md#component-pickercontrol), [`TimeGrid`](mantine-dates.md#component-timegrid), [`TimeInput`](mantine-dates.md#component-timeinput), [`TimePicker`](mantine-dates.md#component-timepicker), [`TimeValue`](mantine-dates.md#component-timevalue), [`WeekdaysRow`](mantine-dates.md#component-weekdaysrow), [`YearLevel`](mantine-dates.md#component-yearlevel), [`YearLevelGroup`](mantine-dates.md#component-yearlevelgroup), [`YearPicker`](mantine-dates.md#component-yearpicker), [`YearPickerInput`](mantine-dates.md#component-yearpickerinput), [`YearsList`](mantine-dates.md#component-yearslist)
|
||||
|
||||
### Mantine Charts
|
||||
|
||||
[`AreaChart`](mantine-charts.md#component-areachart), [`BarChart`](mantine-charts.md#component-barchart), [`BarsList`](mantine-charts.md#component-barslist), [`BubbleChart`](mantine-charts.md#component-bubblechart), [`ChartLegend`](mantine-charts.md#component-chartlegend), [`ChartTooltip`](mantine-charts.md#component-charttooltip), [`CompositeChart`](mantine-charts.md#component-compositechart), [`DonutChart`](mantine-charts.md#component-donutchart), [`FunnelChart`](mantine-charts.md#component-funnelchart), [`Heatmap`](mantine-charts.md#component-heatmap), [`LineChart`](mantine-charts.md#component-linechart), [`PieChart`](mantine-charts.md#component-piechart), [`RadarChart`](mantine-charts.md#component-radarchart), [`RadialBarChart`](mantine-charts.md#component-radialbarchart), [`SankeyChart`](mantine-charts.md#component-sankeychart), [`ScatterChart`](mantine-charts.md#component-scatterchart), [`Sparkline`](mantine-charts.md#component-sparkline), [`Treemap`](mantine-charts.md#component-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`](mantine-core.md#component-affix) — Escapes the widget layout boundary, [`AppShell`](mantine-core.md#component-appshell) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShell.Aside`](mantine-core.md#component-appshell-2e-aside) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShell.Footer`](mantine-core.md#component-appshell-2e-footer) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShell.Header`](mantine-core.md#component-appshell-2e-header) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShell.Main`](mantine-core.md#component-appshell-2e-main) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShell.Navbar`](mantine-core.md#component-appshell-2e-navbar) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShell.Section`](mantine-core.md#component-appshell-2e-section) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShellAside`](mantine-core.md#component-appshellaside) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShellFooter`](mantine-core.md#component-appshellfooter) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShellHeader`](mantine-core.md#component-appshellheader) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShellMain`](mantine-core.md#component-appshellmain) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShellNavbar`](mantine-core.md#component-appshellnavbar) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`AppShellSection`](mantine-core.md#component-appshellsection) — Escapes or replaces the widget layout, focus, scrolling, or overlay boundary, [`ColorSchemeScript`](mantine-core.md#component-colorschemescript) — Writes global document state, [`Combobox`](mantine-core.md#component-combobox) — Low-level composition primitive that requires authored callbacks, [`Combobox.Chevron`](mantine-core.md#component-combobox-2e-chevron) — Low-level composition primitive that requires authored callbacks, [`Combobox.ClearButton`](mantine-core.md#component-combobox-2e-clearbutton) — Low-level composition primitive that requires authored callbacks, [`Combobox.Dropdown`](mantine-core.md#component-combobox-2e-dropdown) — Low-level composition primitive that requires authored callbacks, [`Combobox.DropdownTarget`](mantine-core.md#component-combobox-2e-dropdowntarget) — Low-level composition primitive that requires authored callbacks, [`Combobox.Empty`](mantine-core.md#component-combobox-2e-empty) — Low-level composition primitive that requires authored callbacks, [`Combobox.EventsTarget`](mantine-core.md#component-combobox-2e-eventstarget) — Low-level composition primitive that requires authored callbacks, [`Combobox.Footer`](mantine-core.md#component-combobox-2e-footer) — Low-level composition primitive that requires authored callbacks, [`Combobox.Group`](mantine-core.md#component-combobox-2e-group) — Low-level composition primitive that requires authored callbacks, [`Combobox.Header`](mantine-core.md#component-combobox-2e-header) — Low-level composition primitive that requires authored callbacks, [`Combobox.HiddenInput`](mantine-core.md#component-combobox-2e-hiddeninput) — Low-level composition primitive that requires authored callbacks, [`Combobox.Option`](mantine-core.md#component-combobox-2e-option) — Low-level composition primitive that requires authored callbacks, [`Combobox.Options`](mantine-core.md#component-combobox-2e-options) — Low-level composition primitive that requires authored callbacks, [`Combobox.Search`](mantine-core.md#component-combobox-2e-search) — Low-level composition primitive that requires authored callbacks, [`Combobox.Target`](mantine-core.md#component-combobox-2e-target) — Low-level composition primitive that requires authored callbacks, [`ComboboxChevron`](mantine-core.md#component-comboboxchevron) — Low-level composition primitive that requires authored callbacks, [`ComboboxClearButton`](mantine-core.md#component-comboboxclearbutton) — Low-level composition primitive that requires authored callbacks, [`ComboboxDropdown`](mantine-core.md#component-comboboxdropdown) — Low-level composition primitive that requires authored callbacks, [`ComboboxDropdownTarget`](mantine-core.md#component-comboboxdropdowntarget) — Low-level composition primitive that requires authored callbacks, [`ComboboxEmpty`](mantine-core.md#component-comboboxempty) — Low-level composition primitive that requires authored callbacks, [`ComboboxEventsTarget`](mantine-core.md#component-comboboxeventstarget) — Low-level composition primitive that requires authored callbacks, [`ComboboxFooter`](mantine-core.md#componenLine truncated
|
||||
|
||||
### Mantine Dates
|
||||
|
||||
[`DatesProvider`](mantine-dates.md#component-datesprovider) — Replaces a Homarr-owned provider boundary, [`HiddenDatesInput`](mantine-dates.md#component-hiddendatesinput) — Internal date-input primitive, [`PickerInputBase`](mantine-dates.md#component-pickerinputbase) — Internal date-input primitive
|
||||
|
||||
### Mantine Charts
|
||||
|
||||
[`AreaGradient`](mantine-charts.md#component-areagradient) — Low-level chart implementation helper
|
||||
|
||||
### Homarr components
|
||||
|
||||
_None._
|
||||
|
||||
|
||||
## Shared prop API
|
||||
|
||||
These props are available to enabled components unless a component-specific record overrides the same name.
|
||||
|
||||
| Prop | Required | Type | Literal values | Description |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| <code>abbr</code> | no | <code>string</code> | — | — |
|
||||
| <code>about</code> | no | <code>string</code> | — | — |
|
||||
| <code>accentHeight</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>accept</code> | no | <code>string</code> | — | — |
|
||||
| <code>accessKey</code> | no | <code>string</code> | — | — |
|
||||
| <code>accumulate</code> | no | <code>"none" | "sum"</code> | <code>"none"</code>, <code>"sum"</code> | — |
|
||||
| <code>additive</code> | no | <code>"sum" | "replace"</code> | <code>"replace"</code>, <code>"sum"</code> | — |
|
||||
| <code>align</code> | no | <code>"center" | "left" | "right"</code> | <code>"center"</code>, <code>"char"</code>, <code>"justify"</code>, <code>"left"</code>, <code>"right"</code> | — |
|
||||
| <code>alignmentBaseline</code> | no | <code>"inherit" | "auto" | "baseline" | "before-edge" | "text-before-edge" | "middle" | "central" | "after-edge" | "text-after-edge" | "ideographic" | "alphabetic" | "hanging" | "mathematical"</code> | <code>"after-edge"</code>, <code>"alphabetic"</code>, <code>"auto"</code>, <code>"baseline"</code>, <code>"before-edge"</code>, <code>"central"</code>, <code>"hanging"</code>, <code>"ideographic"</code>, <code>"inherit"</code>, <code>"mathematical"</code>, <code>"middle"</code>, <code>"text-after-edge"</code>, <code>"text-before-edge"</code> | — |
|
||||
| <code>allowReorder</code> | no | <code>"yes" | "no"</code> | <code>"no"</code>, <code>"yes"</code> | — |
|
||||
| <code>alphabetic</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>alt</code> | no | <code>string</code> | — | — |
|
||||
| <code>amplitude</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>arabicForm</code> | no | <code>"initial" | "medial" | "terminal" | "isolated"</code> | <code>"initial"</code>, <code>"isolated"</code>, <code>"medial"</code>, <code>"terminal"</code> | — |
|
||||
| <code>aria-activedescendant</code> | no | <code>string</code> | — | Identifies the currently active element when DOM focus is on a composite widget, textbox, group, or application. |
|
||||
| <code>aria-atomic</code> | no | <code>false | true | "true" | "false"</code> | <code>false</code>, <code>"false"</code>, <code>true</code>, <code>"true"</code> | Indicates whether assistive technologies will present all, or only parts of, the changed region based on the change notifications defined by the aria-relevant attribute. |
|
||||
| <code>aria-autocomplete</code> | no | <code>"none" | "inline" | "list" | "both"</code> | <code>"both"</code>, <code>"inline"</code>, <code>"list"</code>, <code>"none"</code> | Indicates whether inputting text could trigger display of one or more predictions of the user's intended value for an input and specifies how predictions would be presented if they are made. |
|
||||
| <code>aria-braillelabel</code> | no | <code>string</code> | — | Defines a string value that labels the current element, which is intended to be converted into Braille. |
|
||||
| <code>aria-brailleroledescription</code> | no | <code>string</code> | — | Defines a human-readable, author-localized abbreviated description for the role of an element, which is intended to be converted into Braille. |
|
||||
| <code>aria-busy</code> | no | <code>false | true | "true" | "false"</code> | <code>false</code>, <code>"false"</code>, <code>true</code>, <code>"true"</code> | — |
|
||||
| <code>aria-checked</code> | no | <code>false | true | "true" | "false" | "mixed"</code> | <code>false</code>, <code>"false"</code>, <code>"mixed"</code>, <code>true</code>, <code>"true"</code> | Indicates the current "checked" state of checkboxes, radio buttons, and other widgets. |
|
||||
| <code>aria-colcount</code> | no | <code>number</code> | — | Defines the total number of columns in a table, grid, or treegrid. |
|
||||
| <code>aria-colindex</code> | no | <code>number</code> | — | Defines an element's column index or position with respect to the total number of columns within a table, grid, or treegrid. |
|
||||
| <code>aria-colindextext</code> | no | <code>string</code> | — | Defines a human readable text alternative of aria-colindex. |
|
||||
| <code>aria-colspan</code> | no | <code>number</code> | — | Defines the number of columns spanned by a cell or gridcell within a table, grid, or treegrid. |
|
||||
| <code>aria-controls</code> | no | <code>string</code> | — | Identifies the element (or elements) whose contents or presence are controlled by the current element. |
|
||||
| <code>aria-current</code> | no | <code>false | true | "time" | "true" | "false" | "page" | "step" | "location" | "date"</code> | <code>"date"</code>, <code>false</code>, <code>"false"</code>, <code>"location"</code>, <code>"page"</code>, <code>"step"</code>, <code>"time"</code>, <code>true</code>, <code>"true"</code> | Indicates the element that represents the current item within a container or set of related elements. |
|
||||
| <code>aria-describedby</code> | no | <code>string</code> | — | Identifies the element (or elements) that describes the object. |
|
||||
| <code>aria-description</code> | no | <code>string</code> | — | Defines a string value that describes or annotates the current element. |
|
||||
| <code>aria-details</code> | no | <code>string</code> | — | Identifies the element that provides a detailed, extended description for the object. |
|
||||
| <code>aria-disabled</code> | no | <code>false | true | "true" | "false"</code> | <code>false</code>, <code>"false"</code>, <code>true</code>, <code>"true"</code> | Indicates that the element is perceivable but disabled, so it is not editable or otherwise operable. |
|
||||
| <code>aria-dropeffect</code> | no | <code>"link" | "none" | "copy" | "execute" | "move" | "popup"</code> | <code>"copy"</code>, <code>"execute"</code>, <code>"link"</code>, <code>"move"</code>, <code>"none"</code>, <code>"popup"</code> | Indicates what functions can be performed when a dragged object is released on the drop target. |
|
||||
| <code>aria-errormessage</code> | no | <code>string</code> | — | Identifies the element that provides an error message for the object. |
|
||||
| <code>aria-expanded</code> | no | <code>false | true | "true" | "false"</code> | <code>false</code>, <code>"false"</code>, <code>true</code>, <code>"true"</code> | Indicates whether the element, or another grouping element it controls, is currently expanded or collapsed. |
|
||||
| <code>aria-flowto</code> | no | <code>string</code> | — | Identifies the next element (or elements) in an alternate reading order of content which, at the user's discretion, allows assistive technology to override the general default of reading in document source order. |
|
||||
| <code>aria-grabbed</code> | no | <code>false | true | "true" | "false"</code> | <code>false</code>, <code>"false"</code>, <code>true</code>, <code>"true"</code> | Indicates an element's "grabbed" state in a drag-and-drop operation. |
|
||||
| <code>aria-haspopup</code> | no | <code>false | true | "dialog" | "menu" | "grid" | "true" | "false" | "listbox" | "tree"</code> | <code>"dialog"</code>, <code>false</code>, <code>"false"</code>, <code>"grid"</code>, <code>"listbox"</code>, <code>"menu"</code>, <code>"tree"</code>, <code>true</code>, <code>"true"</code> | Indicates the availability and type of interactive popup element, such as menu or dialog, that can be triggered by an element. |
|
||||
| <code>aria-hidden</code> | no | <code>false | true | "true" | "false"</code> | <code>false</code>, <code>"false"</code>, <code>true</code>, <code>"true"</code> | Indicates whether the element is exposed to an accessibility API. |
|
||||
| <code>aria-invalid</code> | no | <code>false | true | "true" | "false" | "grammar" | "spelling"</code> | <code>false</code>, <code>"false"</code>, <code>"grammar"</code>, <code>"spelling"</code>, <code>true</code>, <code>"true"</code> | Indicates the entered value does not conform to the format expected by the application. |
|
||||
| <code>aria-keyshortcuts</code> | no | <code>string</code> | — | Indicates keyboard shortcuts that an author has implemented to activate or give focus to an element. |
|
||||
| <code>aria-label</code> | no | <code>string</code> | — | Defines a string value that labels the current element. |
|
||||
| <code>aria-labelledby</code> | no | <code>string</code> | — | Identifies the element (or elements) that labels the current element. |
|
||||
| <code>aria-level</code> | no | <code>number</code> | — | Defines the hierarchical level of an element within a structure. |
|
||||
| <code>aria-live</code> | no | <code>"off" | "assertive" | "polite"</code> | <code>"assertive"</code>, <code>"off"</code>, <code>"polite"</code> | Indicates that an element will be updated, and describes the types of updates the user agents, assistive technologies, and user can expect from the live region. |
|
||||
| <code>aria-modal</code> | no | <code>false | true | "true" | "false"</code> | <code>false</code>, <code>"false"</code>, <code>true</code>, <code>"true"</code> | Indicates whether an element is modal when displayed. |
|
||||
| <code>aria-multiline</code> | no | <code>false | true | "true" | "false"</code> | <code>false</code>, <code>"false"</code>, <code>true</code>, <code>"true"</code> | Indicates whether a text box accepts multiple lines of input or only a single line. |
|
||||
| <code>aria-multiselectable</code> | no | <code>false | true | "true" | "false"</code> | <code>false</code>, <code>"false"</code>, <code>true</code>, <code>"true"</code> | Indicates that the user may select more than one item from the current selectable descendants. |
|
||||
| <code>aria-orientation</code> | no | <code>"horizontal" | "vertical"</code> | <code>"horizontal"</code>, <code>"vertical"</code> | Indicates whether the element's orientation is horizontal, vertical, or unknown/ambiguous. |
|
||||
| <code>aria-owns</code> | no | <code>string</code> | — | Identifies an element (or elements) in order to define a visual, functional, or contextual parent/child relationship between DOM elements where the DOM hierarchy cannot be used to represent the relationship. |
|
||||
| <code>aria-placeholder</code> | no | <code>string</code> | — | Defines a short hint (a word or short phrase) intended to aid the user with data entry when the control has no value. A hint could be a sample value or a brief description of the expected format. |
|
||||
| <code>aria-posinset</code> | no | <code>number</code> | — | Defines an element's number or position in the current set of listitems or treeitems. Not required if all elements in the set are present in the DOM. |
|
||||
| <code>aria-pressed</code> | no | <code>false | true | "true" | "false" | "mixed"</code> | <code>false</code>, <code>"false"</code>, <code>"mixed"</code>, <code>true</code>, <code>"true"</code> | Indicates the current "pressed" state of toggle buttons. |
|
||||
| <code>aria-readonly</code> | no | <code>false | true | "true" | "false"</code> | <code>false</code>, <code>"false"</code>, <code>true</code>, <code>"true"</code> | Indicates that the element is not editable, but is otherwise operable. |
|
||||
| <code>aria-relevant</code> | no | <code>"text" | "additions" | "additions removals" | "additions text" | "all" | "removals" | "removals additions" | "removals text" | "text additions" | "text removals"</code> | <code>"additions"</code>, <code>"additions removals"</code>, <code>"additions text"</code>, <code>"all"</code>, <code>"removals"</code>, <code>"removals additions"</code>, <code>"removals text"</code>, <code>"text"</code>, <code>"text additions"</code>, <code>"text removals"</code> | Indicates what notifications the user agent will trigger when the accessibility tree within a live region is modified. |
|
||||
| <code>aria-required</code> | no | <code>false | true | "true" | "false"</code> | <code>false</code>, <code>"false"</code>, <code>true</code>, <code>"true"</code> | Indicates that user input is required on the element before a form may be submitted. |
|
||||
| <code>aria-roledescription</code> | no | <code>string</code> | — | Defines a human-readable, author-localized description for the role of an element. |
|
||||
| <code>aria-rowcount</code> | no | <code>number</code> | — | Defines the total number of rows in a table, grid, or treegrid. |
|
||||
| <code>aria-rowindex</code> | no | <code>number</code> | — | Defines an element's row index or position with respect to the total number of rows within a table, grid, or treegrid. |
|
||||
| <code>aria-rowindextext</code> | no | <code>string</code> | — | Defines a human readable text alternative of aria-rowindex. |
|
||||
| <code>aria-rowspan</code> | no | <code>number</code> | — | Defines the number of rows spanned by a cell or gridcell within a table, grid, or treegrid. |
|
||||
| <code>aria-selected</code> | no | <code>false | true | "true" | "false"</code> | <code>false</code>, <code>"false"</code>, <code>true</code>, <code>"true"</code> | Indicates the current "selected" state of various widgets. |
|
||||
| <code>aria-setsize</code> | no | <code>number</code> | — | Defines the number of items in the current set of listitems or treeitems. Not required if all elements in the set are present in the DOM. |
|
||||
| <code>aria-sort</code> | no | <code>"none" | "ascending" | "descending" | "other"</code> | <code>"ascending"</code>, <code>"descending"</code>, <code>"none"</code>, <code>"other"</code> | Indicates if items in a table or grid are sorted in ascending or descending order. |
|
||||
| <code>aria-valuemax</code> | no | <code>number</code> | — | Defines the maximum allowed value for a range widget. |
|
||||
| <code>aria-valuemin</code> | no | <code>number</code> | — | Defines the minimum allowed value for a range widget. |
|
||||
| <code>aria-valuenow</code> | no | <code>number</code> | — | Defines the current value for a range widget. |
|
||||
| <code>aria-valuetext</code> | no | <code>string</code> | — | Defines the human readable text alternative of aria-valuenow for a range widget. |
|
||||
| <code>ascent</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>attributeName</code> | no | <code>string</code> | — | — |
|
||||
| <code>attributeType</code> | no | <code>string</code> | — | — |
|
||||
| <code>autoCapitalize</code> | no | <code>(string & {}) | "none" | "off" | "on" | "sentences" | "words" | "characters"</code> | <code>"characters"</code>, <code>"none"</code>, <code>"off"</code>, <code>"on"</code>, <code>"sentences"</code>, <code>"words"</code> | — |
|
||||
| <code>autoComplete</code> | no | <code>HTMLInputAutoCompleteAttribute</code> | — | — |
|
||||
| <code>autoCorrect</code> | no | <code>string</code> | — | — |
|
||||
| <code>autoReverse</code> | no | <code>false | true | "true" | "false"</code> | <code>false</code>, <code>"false"</code>, <code>true</code>, <code>"true"</code> | — |
|
||||
| <code>autoSave</code> | no | <code>string</code> | — | — |
|
||||
| <code>azimuth</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>baseFrequency</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>baseProfile</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>baselineShift</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>bbox</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>bd</code> | no | <code>StyleProp<Property.Border<string | number>></code> | — | Border |
|
||||
| <code>bdrs</code> | no | <code>StyleProp<MantineSpacing></code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | BorderRadius, theme key: theme.radius |
|
||||
| <code>begin</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>bg</code> | no | <code>StyleProp<DefaultMantineColor></code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Background, theme key: theme.colors |
|
||||
| <code>bga</code> | no | <code>StyleProp<Property.BackgroundAttachment></code> | <code>"-moz-initial"</code>, <code>"fixed"</code>, <code>"inherit"</code>, <code>"initial"</code>, <code>"local"</code>, <code>"revert"</code>, <code>"revert-layer"</code>, <code>"scroll"</code>, <code>"unset"</code> | BackgroundAttachment |
|
||||
| <code>bgcolor</code> | no | <code>string</code> | — | — |
|
||||
| <code>bgp</code> | no | <code>StyleProp<Property.BackgroundPosition<string | number>></code> | — | BackgroundPosition |
|
||||
| <code>bgr</code> | no | <code>StyleProp<Property.BackgroundRepeat></code> | <code>"-moz-initial"</code>, <code>"inherit"</code>, <code>"initial"</code>, <code>"no-repeat"</code>, <code>"repeat"</code>, <code>"repeat-x"</code>, <code>"repeat-y"</code>, <code>"revert"</code>, <code>"revert-layer"</code>, <code>"round"</code>, <code>"space"</code>, <code>"unset"</code> | BackgroundRepeat |
|
||||
| <code>bgsz</code> | no | <code>StyleProp<Property.BackgroundSize<string | number>></code> | — | BackgroundSize |
|
||||
| <code>bias</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>border</code> | no | <code>number</code> | — | — |
|
||||
| <code>by</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>c</code> | no | <code>StyleProp<DefaultMantineColor></code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Color |
|
||||
| <code>calcMode</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>capHeight</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>capture</code> | no | <code>false | true | "user" | "environment"</code> | <code>"environment"</code>, <code>false</code>, <code>true</code>, <code>"user"</code> | — |
|
||||
| <code>cellPadding</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>cellSpacing</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>checked</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | — |
|
||||
| <code>clip</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>clipPath</code> | no | <code>string</code> | — | — |
|
||||
| <code>clipPathUnits</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>clipRule</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>colSpan</code> | no | <code>number</code> | — | — |
|
||||
| <code>color</code> | no | <code>string</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"initials"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Key of `theme.colors` or any valid CSS color |
|
||||
| <code>colorInterpolation</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>colorInterpolationFilters</code> | no | <code>"inherit" | "auto" | "sRGB" | "linearRGB"</code> | <code>"auto"</code>, <code>"inherit"</code>, <code>"linearRGB"</code>, <code>"sRGB"</code> | — |
|
||||
| <code>colorProfile</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>colorRendering</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>cols</code> | no | <code>number</code> | — | — |
|
||||
| <code>content</code> | no | <code>string</code> | — | — |
|
||||
| <code>contentScriptType</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>contentStyleType</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>contextMenu</code> | no | <code>string</code> | — | — |
|
||||
| <code>crossOrigin</code> | no | <code>"" | "anonymous" | "use-credentials"</code> | <code>""</code>, <code>"anonymous"</code>, <code>"use-credentials"</code> | — |
|
||||
| <code>cursor</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>cx</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>cy</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>d</code> | no | <code>string</code> | — | — |
|
||||
| <code>darkHidden</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether component should be hidden in dark color scheme with `display: none` |
|
||||
| <code>datatype</code> | no | <code>string</code> | — | — |
|
||||
| <code>decelerate</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>defaultChecked</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | — |
|
||||
| <code>defaultValue</code> | no | <code>string | number | readonly string[]</code> | — | — |
|
||||
| <code>descent</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>diffuseConstant</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>dir</code> | no | <code>string</code> | — | — |
|
||||
| <code>dirName</code> | no | <code>string</code> | — | — |
|
||||
| <code>direction</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>disabled</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | — |
|
||||
| <code>display</code> | no | <code>StyleProp<Property.Display></code> | <code>"-moz-initial"</code>, <code>"-ms-flexbox"</code>, <code>"-ms-grid"</code>, <code>"-ms-inline-flexbox"</code>, <code>"-ms-inline-grid"</code>, <code>"-webkit-flex"</code>, <code>"-webkit-inline-flex"</code>, <code>"block"</code>, <code>"contents"</code>, <code>"flex"</code>, <code>"flow"</code>, <code>"flow-root"</code>, <code>"grid"</code>, <code>"inherit"</code>, <code>"initial"</code>, <code>"inline"</code>, <code>"inline-block"</code>, <code>"inline-flex"</code>, <code>"inline-grid"</code>, <code>"inline-list-item"</code>, <code>"inline-table"</code>, <code>"list-item"</code>, <code>"none"</code>, <code>"revert"</code>, <code>"revert-layer"</code>, <code>"ruby"</code>, <code>"ruby-base"</code>, <code>"ruby-base-container"</code>, <code>"ruby-text"</code>, <code>"ruby-text-container"</code>, <code>"run-in"</code>, <code>"table"</code>, <code>"table-caption"</code>, <code>"table-cell"</code>, <code>"table-column"</code>, <code>"table-column-group"</code>, <code>"table-footer-group"</code>, <code>"table-header-group"</code>, <code>"table-row"</code>, <code>"table-row-group"</code>, <code>"unset"</code> | — |
|
||||
| <code>divisor</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>dominantBaseline</code> | no | <code>"inherit" | "auto" | "text-before-edge" | "middle" | "central" | "text-after-edge" | "ideographic" | "alphabetic" | "hanging" | "mathematical" | "use-script" | "no-change" | "reset-size"</code> | <code>"alphabetic"</code>, <code>"auto"</code>, <code>"central"</code>, <code>"hanging"</code>, <code>"ideographic"</code>, <code>"inherit"</code>, <code>"mathematical"</code>, <code>"middle"</code>, <code>"no-change"</code>, <code>"reset-size"</code>, <code>"text-after-edge"</code>, <code>"text-before-edge"</code>, <code>"use-script"</code> | — |
|
||||
| <code>draggable</code> | no | <code>false | true | "true" | "false"</code> | <code>false</code>, <code>"false"</code>, <code>true</code>, <code>"true"</code> | — |
|
||||
| <code>dur</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>dx</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>dy</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>edgeMode</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>elevation</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>enableBackground</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>end</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>enterKeyHint</code> | no | <code>"search" | "enter" | "done" | "go" | "next" | "previous" | "send"</code> | <code>"done"</code>, <code>"enter"</code>, <code>"go"</code>, <code>"next"</code>, <code>"previous"</code>, <code>"search"</code>, <code>"send"</code> | — |
|
||||
| <code>exponent</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>exportparts</code> | no | <code>string</code> | — | — |
|
||||
| <code>externalResourcesRequired</code> | no | <code>false | true | "true" | "false"</code> | <code>false</code>, <code>"false"</code>, <code>true</code>, <code>"true"</code> | — |
|
||||
| <code>ff</code> | no | <code>StyleProp<"text" | (string & {}) | "monospace" | "heading"></code> | <code>"heading"</code>, <code>"monospace"</code>, <code>"text"</code> | FontFamily |
|
||||
| <code>fill</code> | no | <code>string</code> | — | — |
|
||||
| <code>fillOpacity</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>fillRule</code> | no | <code>"inherit" | "nonzero" | "evenodd"</code> | <code>"evenodd"</code>, <code>"inherit"</code>, <code>"nonzero"</code> | — |
|
||||
| <code>filter</code> | no | <code>string</code> | — | — |
|
||||
| <code>filterRes</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>filterUnits</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>flex</code> | no | <code>StyleProp<Property.Flex<string | number>></code> | — | — |
|
||||
| <code>floodColor</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>floodOpacity</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>focusable</code> | no | <code>false | true | "auto" | "true" | "false"</code> | <code>"auto"</code>, <code>false</code>, <code>"false"</code>, <code>true</code>, <code>"true"</code> | — |
|
||||
| <code>fontFamily</code> | no | <code>string</code> | — | — |
|
||||
| <code>fontSize</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>fontSizeAdjust</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>fontStretch</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>fontStyle</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>fontVariant</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>fontWeight</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>format</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>fr</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>frame</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | — |
|
||||
| <code>from</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>fs</code> | no | <code>StyleProp<Property.FontStyle></code> | <code>"-moz-initial"</code>, <code>"inherit"</code>, <code>"initial"</code>, <code>"italic"</code>, <code>"normal"</code>, <code>"oblique"</code>, <code>"revert"</code>, <code>"revert-layer"</code>, <code>"unset"</code> | FontStyle |
|
||||
| <code>fw</code> | no | <code>StyleProp<Property.FontWeight></code> | <code>"-moz-initial"</code>, <code>"bold"</code>, <code>"bolder"</code>, <code>"inherit"</code>, <code>"initial"</code>, <code>"lighter"</code>, <code>"normal"</code>, <code>"revert"</code>, <code>"revert-layer"</code>, <code>"unset"</code> | FontWeight |
|
||||
| <code>fx</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>fy</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>fz</code> | no | <code>StyleProp<number | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | MantineFontSize></code> | <code>"h1"</code>, <code>"h2"</code>, <code>"h3"</code>, <code>"h4"</code>, <code>"h5"</code>, <code>"h6"</code>, <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | FontSize, theme key: theme.fontSizes |
|
||||
| <code>g1</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>g2</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>glyphName</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>glyphOrientationHorizontal</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>glyphOrientationVertical</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>gradientTransform</code> | no | <code>string</code> | — | — |
|
||||
| <code>gradientUnits</code> | no | <code>string</code> | — | — |
|
||||
| <code>h</code> | no | <code>StyleProp<Property.Height<string | number>></code> | — | Height, theme key: theme.spacing |
|
||||
| <code>hanging</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>headers</code> | no | <code>string</code> | — | — |
|
||||
| <code>height</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>hidden</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | — |
|
||||
| <code>hiddenFrom</code> | no | <code>MantineBreakpoint</code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | Breakpoint above which the component is hidden with `display: none` |
|
||||
| <code>horizAdvX</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>horizOriginX</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>href</code> | no | <code>string</code> | — | — |
|
||||
| <code>id</code> | no | <code>string</code> | — | Static id used as base to generate `aria-` attributes, by default generates random id |
|
||||
| <code>ideographic</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>imageRendering</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>in</code> | no | <code>string</code> | — | — |
|
||||
| <code>in2</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>inert</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | — |
|
||||
| <code>inlist</code> | no | <code>unknown</code> | — | — |
|
||||
| <code>inputMode</code> | no | <code>"search" | "text" | "none" | "tel" | "url" | "email" | "numeric" | "decimal"</code> | <code>"decimal"</code>, <code>"email"</code>, <code>"none"</code>, <code>"numeric"</code>, <code>"search"</code>, <code>"tel"</code>, <code>"text"</code>, <code>"url"</code> | Hints at the type of data that might be entered by the user while editing the element or its contents |
|
||||
| <code>intercept</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>is</code> | no | <code>string</code> | — | Specify that a standard HTML element should behave like a defined custom built-in element |
|
||||
| <code>itemID</code> | no | <code>string</code> | — | — |
|
||||
| <code>itemProp</code> | no | <code>string</code> | — | — |
|
||||
| <code>itemScope</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | — |
|
||||
| <code>itemType</code> | no | <code>string</code> | — | — |
|
||||
| <code>k</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>k1</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>k2</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>k3</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>k4</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>kernelMatrix</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>kernelUnitLength</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>kerning</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>key</code> | no | <code>Key | null</code> | — | Stable identity used by React when rendering collections. |
|
||||
| <code>keyPoints</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>keySplines</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>keyTimes</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>lang</code> | no | <code>string</code> | — | — |
|
||||
| <code>lengthAdjust</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>letterSpacing</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>lh</code> | no | <code>StyleProp<number | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | MantineLineHeight></code> | <code>"h1"</code>, <code>"h2"</code>, <code>"h3"</code>, <code>"h4"</code>, <code>"h5"</code>, <code>"h6"</code>, <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | LineHeight, theme key: lineHeights |
|
||||
| <code>lightHidden</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether component should be hidden in light color scheme with `display: none` |
|
||||
| <code>lightingColor</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>limitingConeAngle</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>list</code> | no | <code>string</code> | — | — |
|
||||
| <code>local</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>lts</code> | no | <code>StyleProp<Property.LetterSpacing<string | number>></code> | — | LetterSpacing |
|
||||
| <code>m</code> | no | <code>StyleProp<MantineSpacing></code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | Margin, theme key: theme.spacing |
|
||||
| <code>mah</code> | no | <code>StyleProp<Property.MaxHeight<string | number>></code> | — | MaxHeight, theme key: theme.spacing |
|
||||
| <code>markerEnd</code> | no | <code>string</code> | — | — |
|
||||
| <code>markerHeight</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>markerMid</code> | no | <code>string</code> | — | — |
|
||||
| <code>markerStart</code> | no | <code>string</code> | — | — |
|
||||
| <code>markerUnits</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>markerWidth</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>mask</code> | no | <code>string</code> | — | — |
|
||||
| <code>maskContentUnits</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>maskUnits</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>mathematical</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>maw</code> | no | <code>StyleProp<Property.MaxWidth<string | number>></code> | — | MaxWidth, theme key: theme.spacing |
|
||||
| <code>max</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>maxLength</code> | no | <code>number</code> | — | — |
|
||||
| <code>mb</code> | no | <code>StyleProp<MantineSpacing></code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | MarginBottom, theme key: theme.spacing |
|
||||
| <code>me</code> | no | <code>StyleProp<MantineSpacing></code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | MarginInlineEnd, theme key: theme.spacing |
|
||||
| <code>media</code> | no | <code>string</code> | — | — |
|
||||
| <code>method</code> | no | <code>string</code> | — | — |
|
||||
| <code>mie</code> | no | <code>StyleProp<MantineSpacing></code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | MarginInlineEnd, theme key: theme.spacing |
|
||||
| <code>mih</code> | no | <code>StyleProp<Property.MinHeight<string | number>></code> | — | MinHeight, theme key: theme.spacing |
|
||||
| <code>min</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>minLength</code> | no | <code>number</code> | — | — |
|
||||
| <code>mis</code> | no | <code>StyleProp<MantineSpacing></code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | MarginInlineStart, theme key: theme.spacing |
|
||||
| <code>miw</code> | no | <code>StyleProp<Property.MinWidth<string | number>></code> | — | MinWidth, theme key: theme.spacing |
|
||||
| <code>ml</code> | no | <code>StyleProp<MantineSpacing></code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | MarginLeft, theme key: theme.spacing |
|
||||
| <code>mod</code> | no | <code>BoxMod</code> | — | Element modifiers transformed into `data-` attributes, for example, `{ 'data-size': 'xl' }`, falsy values are removed |
|
||||
| <code>mode</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>mr</code> | no | <code>StyleProp<MantineSpacing></code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | MarginRight, theme key: theme.spacing |
|
||||
| <code>ms</code> | no | <code>StyleProp<MantineSpacing></code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | MarginInlineStart, theme key: theme.spacing |
|
||||
| <code>mt</code> | no | <code>StyleProp<MantineSpacing></code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | MarginTop, theme key: theme.spacing |
|
||||
| <code>multiple</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | — |
|
||||
| <code>mx</code> | no | <code>StyleProp<MantineSpacing></code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | MarginInline, theme key: theme.spacing |
|
||||
| <code>my</code> | no | <code>StyleProp<MantineSpacing></code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | MarginBlock, theme key: theme.spacing |
|
||||
| <code>name</code> | no | <code>string</code> | — | — |
|
||||
| <code>nonce</code> | no | <code>string</code> | — | — |
|
||||
| <code>numOctaves</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>offset</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>opacity</code> | no | <code>StyleProp<Property.Opacity></code> | <code>"-moz-initial"</code>, <code>"inherit"</code>, <code>"initial"</code>, <code>"revert"</code>, <code>"revert-layer"</code>, <code>"unset"</code> | — |
|
||||
| <code>operator</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>order</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>orient</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>orientation</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>origin</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>overflow</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>overlinePosition</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>overlineThickness</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>p</code> | no | <code>StyleProp<MantineSpacing></code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | Padding, theme key: theme.spacing |
|
||||
| <code>paintOrder</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>panose1</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>part</code> | no | <code>string</code> | — | — |
|
||||
| <code>path</code> | no | <code>string</code> | — | — |
|
||||
| <code>pathLength</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>pattern</code> | no | <code>string</code> | — | — |
|
||||
| <code>patternContentUnits</code> | no | <code>string</code> | — | — |
|
||||
| <code>patternTransform</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>patternUnits</code> | no | <code>string</code> | — | — |
|
||||
| <code>pb</code> | no | <code>StyleProp<MantineSpacing></code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | PaddingBottom, theme key: theme.spacing |
|
||||
| <code>pe</code> | no | <code>StyleProp<MantineSpacing></code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | PaddingInlineEnd, theme key: theme.spacing |
|
||||
| <code>pie</code> | no | <code>StyleProp<MantineSpacing></code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | PaddingInlineEnd, theme key: theme.spacing |
|
||||
| <code>pis</code> | no | <code>StyleProp<MantineSpacing></code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | PaddingInlineStart, theme key: theme.spacing |
|
||||
| <code>pl</code> | no | <code>StyleProp<MantineSpacing></code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | PaddingLeft, theme key: theme.spacing |
|
||||
| <code>placeholder</code> | no | <code>string</code> | — | — |
|
||||
| <code>pointerEvents</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>points</code> | no | <code>string</code> | — | — |
|
||||
| <code>pointsAtX</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>pointsAtY</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>pointsAtZ</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>pr</code> | no | <code>StyleProp<MantineSpacing></code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | PaddingRight, theme key: theme.spacing |
|
||||
| <code>prefix</code> | no | <code>string</code> | — | — |
|
||||
| <code>preserveAlpha</code> | no | <code>false | true | "true" | "false"</code> | <code>false</code>, <code>"false"</code>, <code>true</code>, <code>"true"</code> | — |
|
||||
| <code>preserveAspectRatio</code> | no | <code>string</code> | — | — |
|
||||
| <code>primitiveUnits</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>property</code> | no | <code>string</code> | — | — |
|
||||
| <code>ps</code> | no | <code>StyleProp<MantineSpacing></code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | PaddingInlineStart, theme key: theme.spacing |
|
||||
| <code>pt</code> | no | <code>StyleProp<MantineSpacing></code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | PaddingTop, theme key: theme.spacing |
|
||||
| <code>px</code> | no | <code>StyleProp<MantineSpacing></code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | PaddingInline, theme key: theme.spacing |
|
||||
| <code>py</code> | no | <code>StyleProp<MantineSpacing></code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | PaddingBlock, theme key: theme.spacing |
|
||||
| <code>r</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>radioGroup</code> | no | <code>string</code> | — | — |
|
||||
| <code>radius</code> | no | <code>MantineRadius</code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | Key of `theme.radius` or any valid CSS value to set `border-radius`, numbers are converted to rem |
|
||||
| <code>readOnly</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | — |
|
||||
| <code>rel</code> | no | <code>string</code> | — | — |
|
||||
| <code>renderingIntent</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>repeatCount</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>repeatDur</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>required</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | — |
|
||||
| <code>requiredExtensions</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>requiredFeatures</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>resource</code> | no | <code>string</code> | — | — |
|
||||
| <code>restart</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>result</code> | no | <code>string</code> | — | — |
|
||||
| <code>results</code> | no | <code>number</code> | — | — |
|
||||
| <code>rev</code> | no | <code>string</code> | — | — |
|
||||
| <code>role</code> | no | <code>AriaRole</code> | — | — |
|
||||
| <code>rotate</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>rowSpan</code> | no | <code>number</code> | — | — |
|
||||
| <code>rows</code> | no | <code>number</code> | — | — |
|
||||
| <code>rules</code> | no | <code>"none" | "all" | "rows" | "groups" | "columns"</code> | <code>"all"</code>, <code>"columns"</code>, <code>"groups"</code>, <code>"none"</code>, <code>"rows"</code> | — |
|
||||
| <code>rx</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>ry</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>scale</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>scope</code> | no | <code>string</code> | — | — |
|
||||
| <code>security</code> | no | <code>string</code> | — | — |
|
||||
| <code>seed</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>shadow</code> | no | <code>MantineShadow</code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | Key of `theme.shadows` or any other valid CSS `box-shadow` value |
|
||||
| <code>shapeRendering</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>size</code> | no | <code>"xs" | "sm" | "md" | "lg" | "xl"</code> | <code>"compact-lg"</code>, <code>"compact-md"</code>, <code>"compact-sm"</code>, <code>"compact-xl"</code>, <code>"compact-xs"</code>, <code>"input-lg"</code>, <code>"input-md"</code>, <code>"input-sm"</code>, <code>"input-xl"</code>, <code>"input-xs"</code>, <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | Component size |
|
||||
| <code>slope</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>slot</code> | no | <code>string</code> | — | — |
|
||||
| <code>spacing</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>specularConstant</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>specularExponent</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>speed</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>spellCheck</code> | no | <code>false | true | "true" | "false"</code> | <code>false</code>, <code>"false"</code>, <code>true</code>, <code>"true"</code> | — |
|
||||
| <code>spreadMethod</code> | no | <code>string</code> | — | — |
|
||||
| <code>src</code> | no | <code>string</code> | — | — |
|
||||
| <code>startOffset</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>stdDeviation</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>stemh</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>stemv</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>step</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>stitchTiles</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>stopColor</code> | no | <code>string</code> | — | — |
|
||||
| <code>stopOpacity</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>strikethroughPosition</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>strikethroughThickness</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>string</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>stroke</code> | no | <code>string</code> | — | — |
|
||||
| <code>strokeDasharray</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>strokeDashoffset</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>strokeLinecap</code> | no | <code>"inherit" | "round" | "butt" | "square"</code> | <code>"butt"</code>, <code>"inherit"</code>, <code>"round"</code>, <code>"square"</code> | — |
|
||||
| <code>strokeLinejoin</code> | no | <code>"inherit" | "round" | "miter" | "bevel"</code> | <code>"bevel"</code>, <code>"inherit"</code>, <code>"miter"</code>, <code>"round"</code> | — |
|
||||
| <code>strokeMiterlimit</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>strokeOpacity</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>strokeWidth</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>style</code> | no | <code>CSSProperties</code> | — | Style to apply to the root element, used for virtualizer positioning |
|
||||
| <code>styles</code> | no | <code>Partial<Record<"label", CSSProperties>></code> | — | — |
|
||||
| <code>summary</code> | no | <code>string</code> | — | — |
|
||||
| <code>surfaceScale</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>systemLanguage</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>ta</code> | no | <code>StyleProp<Property.TextAlign></code> | <code>"-khtml-center"</code>, <code>"-khtml-left"</code>, <code>"-khtml-right"</code>, <code>"-moz-center"</code>, <code>"-moz-initial"</code>, <code>"-moz-left"</code>, <code>"-moz-right"</code>, <code>"-webkit-center"</code>, <code>"-webkit-left"</code>, <code>"-webkit-match-parent"</code>, <code>"-webkit-right"</code>, <code>"center"</code>, <code>"end"</code>, <code>"inherit"</code>, <code>"initial"</code>, <code>"justify"</code>, <code>"left"</code>, <code>"match-parent"</code>, <code>"revert"</code>, <code>"revert-layer"</code>, <code>"right"</code>, <code>"start"</code>, <code>"unset"</code> | TextAlign |
|
||||
| <code>tabIndex</code> | no | <code>number</code> | — | Tab index for the node |
|
||||
| <code>tableValues</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>target</code> | no | <code>string</code> | — | — |
|
||||
| <code>targetX</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>targetY</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>td</code> | no | <code>StyleProp<Property.TextDecoration<string | number>></code> | — | TextDecoration |
|
||||
| <code>textAnchor</code> | no | <code>"inherit" | "end" | "start" | "middle"</code> | <code>"end"</code>, <code>"inherit"</code>, <code>"middle"</code>, <code>"start"</code> | — |
|
||||
| <code>textDecoration</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>textLength</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>textRendering</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>title</code> | no | <code>string</code> | — | Item title, displayed next to the bullet |
|
||||
| <code>to</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>transform</code> | no | <code>string</code> | — | — |
|
||||
| <code>translate</code> | no | <code>"yes" | "no"</code> | <code>"no"</code>, <code>"yes"</code> | — |
|
||||
| <code>tt</code> | no | <code>StyleProp<Property.TextTransform></code> | <code>"-moz-initial"</code>, <code>"capitalize"</code>, <code>"full-size-kana"</code>, <code>"full-width"</code>, <code>"inherit"</code>, <code>"initial"</code>, <code>"lowercase"</code>, <code>"math-auto"</code>, <code>"none"</code>, <code>"revert"</code>, <code>"revert-layer"</code>, <code>"unset"</code>, <code>"uppercase"</code> | TextTransform |
|
||||
| <code>type</code> | no | <code>"button" | "submit" | "reset"</code> | <code>"button"</code>, <code>"checkbox"</code>, <code>"color"</code>, <code>"date"</code>, <code>"datetime-local"</code>, <code>"email"</code>, <code>"file"</code>, <code>"hidden"</code>, <code>"image"</code>, <code>"month"</code>, <code>"number"</code>, <code>"password"</code>, <code>"radio"</code>, <code>"range"</code>, <code>"reset"</code>, <code>"search"</code>, <code>"submit"</code>, <code>"tel"</code>, <code>"text"</code>, <code>"time"</code>, <code>"url"</code>, <code>"week"</code> | — |
|
||||
| <code>typeof</code> | no | <code>string</code> | — | — |
|
||||
| <code>u1</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>u2</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>underlinePosition</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>underlineThickness</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>unicode</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>unicodeBidi</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>unicodeRange</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>unitsPerEm</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>unselectable</code> | no | <code>"off" | "on"</code> | <code>"off"</code>, <code>"on"</code> | — |
|
||||
| <code>vAlphabetic</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>vHanging</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>vIdeographic</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>vMathematical</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>valign</code> | no | <code>"bottom" | "top" | "baseline" | "middle"</code> | <code>"baseline"</code>, <code>"bottom"</code>, <code>"middle"</code>, <code>"top"</code> | — |
|
||||
| <code>value</code> | no | <code>string | number | readonly string[]</code> | — | — |
|
||||
| <code>values</code> | no | <code>string</code> | — | — |
|
||||
| <code>variant</code> | no | <code>string</code> | <code>"contained"</code>, <code>"contrast"</code>, <code>"dashed"</code>, <code>"default"</code>, <code>"dot"</code>, <code>"dotted"</code>, <code>"filled"</code>, <code>"gradient"</code>, <code>"light"</code>, <code>"outline"</code>, <code>"pills"</code>, <code>"separated"</code>, <code>"solid"</code>, <code>"subtle"</code>, <code>"text"</code>, <code>"transparent"</code>, <code>"unstyled"</code>, <code>"vertical"</code>, <code>"white"</code> | Variant passed from parent component, sets `data-variant` |
|
||||
| <code>vectorEffect</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>version</code> | no | <code>string</code> | — | — |
|
||||
| <code>vertAdvY</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>vertOriginX</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>vertOriginY</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>viewBox</code> | no | <code>string</code> | — | — |
|
||||
| <code>viewTarget</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>visibility</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>visibleFrom</code> | no | <code>MantineBreakpoint</code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | Breakpoint below which the component is hidden with `display: none` |
|
||||
| <code>vocab</code> | no | <code>string</code> | — | — |
|
||||
| <code>w</code> | no | <code>StyleProp<Property.Width<string | number>></code> | — | Width, theme key: theme.spacing |
|
||||
| <code>width</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>widths</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>withBorder</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Adds border to the root element |
|
||||
| <code>wordSpacing</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>wrap</code> | no | <code>string</code> | — | — |
|
||||
| <code>writingMode</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>x</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>x1</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>x2</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>xChannelSelector</code> | no | <code>string</code> | — | — |
|
||||
| <code>xHeight</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>xlinkActuate</code> | no | <code>string</code> | — | — |
|
||||
| <code>xlinkArcrole</code> | no | <code>string</code> | — | — |
|
||||
| <code>xlinkHref</code> | no | <code>string</code> | — | — |
|
||||
| <code>xlinkRole</code> | no | <code>string</code> | — | — |
|
||||
| <code>xlinkShow</code> | no | <code>string</code> | — | — |
|
||||
| <code>xlinkTitle</code> | no | <code>string</code> | — | — |
|
||||
| <code>xlinkType</code> | no | <code>string</code> | — | — |
|
||||
| <code>xmlBase</code> | no | <code>string</code> | — | — |
|
||||
| <code>xmlLang</code> | no | <code>string</code> | — | — |
|
||||
| <code>xmlSpace</code> | no | <code>string</code> | — | — |
|
||||
| <code>xmlns</code> | no | <code>string</code> | — | — |
|
||||
| <code>xmlnsXlink</code> | no | <code>string</code> | — | — |
|
||||
| <code>y</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>y1</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>y2</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>yChannelSelector</code> | no | <code>string</code> | — | — |
|
||||
| <code>z</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>zoomAndPan</code> | no | <code>string</code> | — | — |
|
||||
|
||||
## 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. |
|
||||
@@ -0,0 +1,325 @@
|
||||
# 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`. Effective props combine the [shared prop API](components.md#shared-prop-api) with each component's specific props below. Component-specific records override shared records 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>confirmMessage</code> | no | <code>string</code> | — | — |
|
||||
| <code>disabled</code> | no | <code>boolean | string</code> | — | — |
|
||||
| <code>errorMessage</code> | no | <code>string</code> | — | — |
|
||||
| <code>fullWidth</code> | no | <code>boolean | string</code> | — | — |
|
||||
| <code>icon</code> | no | <code>string</code> | — | — |
|
||||
| <code>label</code> | no | <code>string</code> | — | — |
|
||||
| <code>params</code> | no | <code>Record<string, string | number | boolean></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> | — | — |
|
||||
|
||||
<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>label</code> | no | <code>string</code> | — | — |
|
||||
| <code>size</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>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' | 'Title' | 'Badge' | 'Code' | '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>fit</code> | no | <code>string</code> | — | — |
|
||||
| <code>fw</code> | no | <code>number</code> | — | — |
|
||||
| <code>h</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>order</code> | no | <code>1 | 2 | 3 | 4 | 5 | 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 | number</code> | — | — |
|
||||
| <code>size</code> | no | <code>string</code> | — | — |
|
||||
| <code>w</code> | no | <code>string | 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' | '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<string, string | number | boolean></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' | '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>name</code> | yes | <code>string</code> | — | — |
|
||||
| <code>size</code> | no | <code>string | number</code> | — | — |
|
||||
| <code>stroke</code> | no | <code>string | 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>disabled</code> | no | <code>boolean | string</code> | — | — |
|
||||
| <code>errorMessage</code> | no | <code>string</code> | — | — |
|
||||
| <code>initialValue</code> | no | <code>boolean | string</code> | — | — |
|
||||
| <code>label</code> | no | <code>string</code> | — | — |
|
||||
| <code>offParams</code> | no | <code>Record<string, string | number | boolean></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' | 'sm' | 'md' | 'lg' | 'xl'</code> | <code>"xs"</code>, <code>"sm"</code>, <code>"md"</code>, <code>"lg"</code>, <code>"xl"</code> | — |
|
||||
| <code>type</code> | yes | <code>string</code> | — | — |
|
||||
@@ -0,0 +1,703 @@
|
||||
# Mantine Charts
|
||||
|
||||
> 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: `@mantine/charts`. Effective props combine the [shared prop API](components.md#shared-prop-api) with each component's specific props below. Component-specific records override shared records with the same name.
|
||||
|
||||
## 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.
|
||||
|
||||
## Enabled inventory
|
||||
|
||||
<code>AreaChart</code>, <code>BarChart</code>, <code>BarsList</code>, <code>BubbleChart</code>, <code>ChartLegend</code>, <code>ChartTooltip</code>, <code>CompositeChart</code>, <code>DonutChart</code>, <code>FunnelChart</code>, <code>Heatmap</code>, <code>LineChart</code>, <code>PieChart</code>, <code>RadarChart</code>, <code>RadialBarChart</code>, <code>SankeyChart</code>, <code>ScatterChart</code>, <code>Sparkline</code>, <code>Treemap</code>
|
||||
|
||||
## Denied inventory
|
||||
|
||||
<code>AreaGradient</code>
|
||||
|
||||
## Component records
|
||||
|
||||
<a id="component-areachart"></a>
|
||||
### `AreaChart`
|
||||
|
||||
- Safety: `wrapped`
|
||||
- Category: `charts`
|
||||
- Documentation: [upstream component API](https://mantine.dev/charts/area-chart/)
|
||||
- Temporary bind: not available
|
||||
- Subcomponents: none
|
||||
- Component-specific blocked props: none
|
||||
- Accessibility:
|
||||
- Include a nearby text summary of the chart data.
|
||||
|
||||
#### Component-specific props
|
||||
|
||||
| Prop | Required | Type | Literal values | Description |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| <code>activeDotProps</code> | no | <code>MantineChartDotProps</code> | — | Props passed down to all active dots. Ignored if `withDots={false}` is set. |
|
||||
| <code>areaChartProps</code> | no | <code>(CartesianChartProps<unknown> & { ref?: React.Ref<SVGSVGElement>; })</code> | — | Props passed down to recharts `AreaChart` component |
|
||||
| <code>attributes</code> | no | <code>{ area?: Record<string, any>; legend?: Record<string, any>; grid?: Record<string, any>; root?: Record<string, any>; ... 14 more ...; tooltipBody?: Record<...>; }</code> | — | — |
|
||||
| <code>connectNulls</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | If set, points with `null` values are connected |
|
||||
| <code>curveType</code> | no | <code>"step" | "bump" | "linear" | "natural" | "monotone" | "stepBefore" | "stepAfter"</code> | <code>"bump"</code>, <code>"linear"</code>, <code>"monotone"</code>, <code>"natural"</code>, <code>"step"</code>, <code>"stepAfter"</code>, <code>"stepBefore"</code> | Type of the curve |
|
||||
| <code>data</code> | yes | <code>ChartData</code> | — | Data used to display chart |
|
||||
| <code>dataKey</code> | yes | <code>string</code> | — | Key of the `data` object for x-axis values |
|
||||
| <code>dotProps</code> | no | <code>MantineChartDotProps</code> | — | Props passed down to all dots. Ignored if `withDots={false}` is set. |
|
||||
| <code>gridAxis</code> | no | <code>"none" | "x" | "y" | "xy"</code> | <code>"none"</code>, <code>"x"</code>, <code>"xy"</code>, <code>"y"</code> | Specifies which lines should be displayed in the grid, `'x'` by default |
|
||||
| <code>gridColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Color of the grid and cursor lines, by default depends on color scheme |
|
||||
| <code>gridProps</code> | no | <code>Omit<CartesianGridProps, "ref"></code> | — | Props passed down to the `CartesianGrid` component |
|
||||
| <code>legendProps</code> | no | <code>Omit<LegendProps, "ref"></code> | — | Props passed down to the `Legend` component |
|
||||
| <code>referenceLines</code> | no | <code>ChartReferenceLineProps[]</code> | — | Reference lines that should be displayed on the chart |
|
||||
| <code>rightYAxisLabel</code> | no | <code>string</code> | — | A label to display next to the right y-axis |
|
||||
| <code>rightYAxisProps</code> | no | <code>Omit<YAxisProps, "ref"></code> | — | Props passed down to the `YAxis` recharts component rendered on the right side |
|
||||
| <code>series</code> | yes | <code>AreaChartSeries[]</code> | — | An array of objects with `name` and `color` keys. Determines which data should be consumed from the `data` array. |
|
||||
| <code>splitColors</code> | no | <code>[DefaultMantineColor, DefaultMantineColor]</code> | — | A tuple of colors used when `type="split"` is set, ignored in all other cases. A tuple may include theme colors reference or any valid CSS colors |
|
||||
| <code>splitOffset</code> | no | <code>number</code> | — | Offset for the split gradient. By default, value is inferred from `data` and `series` if possible. Must be generated from the data array with `getSplitOffset` function. |
|
||||
| <code>textColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Color of the text displayed inside the chart, `'dimmed'` by default |
|
||||
| <code>tickLine</code> | no | <code>"none" | "x" | "y" | "xy"</code> | <code>"none"</code>, <code>"x"</code>, <code>"xy"</code>, <code>"y"</code> | Specifies which axis should have tick line, `'y'` by default |
|
||||
| <code>tooltipAnimationDuration</code> | no | <code>number</code> | — | Tooltip position animation duration in ms, `0` by default |
|
||||
| <code>tooltipProps</code> | no | <code>Omit<TooltipProps<any, any>, "ref"></code> | — | Props passed down to the `Tooltip` component |
|
||||
| <code>unit</code> | no | <code>string</code> | — | Unit displayed next to each tick in y-axis |
|
||||
| <code>unstyled</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | — |
|
||||
| <code>withDots</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether dots should be displayed |
|
||||
| <code>withGradient</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether the chart area should be represented with a gradient instead of the solid color |
|
||||
| <code>withLegend</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether chart legend should be displayed, `false` by default |
|
||||
| <code>withPointLabels</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | If set, each point has an associated label |
|
||||
| <code>withRightYAxis</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether additional y-axis should be displayed on the right side of the chart, `false` by default |
|
||||
| <code>withTooltip</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether chart tooltip should be displayed, `true` by default |
|
||||
| <code>withXAxis</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether x-axis should be displayed, `true` by default |
|
||||
| <code>withYAxis</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether y-axis should be displayed, `true` by default |
|
||||
| <code>xAxisLabel</code> | no | <code>string</code> | — | A label to display below the x-axis |
|
||||
| <code>xAxisProps</code> | no | <code>Omit<XAxisProps, "ref"></code> | — | Props passed down to the `XAxis` recharts component |
|
||||
| <code>yAxisLabel</code> | no | <code>string</code> | — | A label to display next to the y-axis |
|
||||
| <code>yAxisProps</code> | no | <code>Omit<YAxisProps, "ref"></code> | — | Props passed down to the `YAxis` recharts component |
|
||||
|
||||
<a id="component-areagradient"></a>
|
||||
### `AreaGradient`
|
||||
|
||||
- Safety: `denied`
|
||||
- Category: `blocked`
|
||||
- Documentation: [upstream component API](https://mantine.dev/charts/area-gradient/)
|
||||
- Temporary bind: not available
|
||||
- Subcomponents: none
|
||||
- Component-specific blocked props: none
|
||||
- Accessibility:
|
||||
- No additional catalog requirement; preserve semantic labels and text alternatives.
|
||||
|
||||
Denied reason: Low-level chart implementation helper.
|
||||
|
||||
#### Component-specific props
|
||||
|
||||
_No authorable props because this component is denied._
|
||||
|
||||
<a id="component-barchart"></a>
|
||||
### `BarChart`
|
||||
|
||||
- Safety: `wrapped`
|
||||
- Category: `charts`
|
||||
- Documentation: [upstream component API](https://mantine.dev/charts/bar-chart/)
|
||||
- Temporary bind: not available
|
||||
- Subcomponents: none
|
||||
- Component-specific blocked props: none
|
||||
- Accessibility:
|
||||
- Include a nearby text summary of the chart data.
|
||||
|
||||
#### Component-specific props
|
||||
|
||||
| Prop | Required | Type | Literal values | Description |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| <code>attributes</code> | no | <code>{ legend?: Record<string, any>; grid?: Record<string, any>; root?: Record<string, any>; tooltip?: Record<string, any>; ... 14 more ...; tooltipBody?: Record<...>; }</code> | — | — |
|
||||
| <code>barChartProps</code> | no | <code>(CartesianChartProps<unknown> & { ref?: React.Ref<SVGSVGElement>; })</code> | — | Props passed down to recharts `BarChart` component |
|
||||
| <code>barLabelColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Controls color of the bar label, by default the value is determined by the chart orientation |
|
||||
| <code>cursorFill</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Fill of hovered bar section, by default value is based on color scheme |
|
||||
| <code>data</code> | yes | <code>Record<string, any>[]</code> | — | Data used to display chart. |
|
||||
| <code>dataKey</code> | yes | <code>string</code> | — | Key of the `data` object for x-axis values |
|
||||
| <code>gridAxis</code> | no | <code>"none" | "x" | "y" | "xy"</code> | <code>"none"</code>, <code>"x"</code>, <code>"xy"</code>, <code>"y"</code> | Specifies which lines should be displayed in the grid, `'x'` by default |
|
||||
| <code>gridColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Color of the grid and cursor lines, by default depends on color scheme |
|
||||
| <code>gridProps</code> | no | <code>Omit<CartesianGridProps, "ref"></code> | — | Props passed down to the `CartesianGrid` component |
|
||||
| <code>legendProps</code> | no | <code>Omit<LegendProps, "ref"></code> | — | Props passed down to the `Legend` component |
|
||||
| <code>maxBarWidth</code> | no | <code>number</code> | — | Maximum bar width in px |
|
||||
| <code>minBarSize</code> | no | <code>number</code> | — | Sets minimum height of the bar in px |
|
||||
| <code>referenceLines</code> | no | <code>ChartReferenceLineProps[]</code> | — | Reference lines that should be displayed on the chart |
|
||||
| <code>rightYAxisLabel</code> | no | <code>string</code> | — | A label to display next to the right y-axis |
|
||||
| <code>rightYAxisProps</code> | no | <code>Omit<YAxisProps, "ref"></code> | — | Props passed down to the `YAxis` recharts component rendered on the right side |
|
||||
| <code>series</code> | yes | <code>BarChartSeries[]</code> | — | An array of objects with `name` and `color` keys. Determines which data should be consumed from the `data` array. |
|
||||
| <code>textColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Color of the text displayed inside the chart, `'dimmed'` by default |
|
||||
| <code>tickLine</code> | no | <code>"none" | "x" | "y" | "xy"</code> | <code>"none"</code>, <code>"x"</code>, <code>"xy"</code>, <code>"y"</code> | Specifies which axis should have tick line, `'y'` by default |
|
||||
| <code>tooltipAnimationDuration</code> | no | <code>number</code> | — | Tooltip position animation duration in ms, `0` by default |
|
||||
| <code>tooltipProps</code> | no | <code>Omit<TooltipProps<any, any>, "ref"></code> | — | Props passed down to the `Tooltip` component |
|
||||
| <code>unit</code> | no | <code>string</code> | — | Unit displayed next to each tick in y-axis |
|
||||
| <code>unstyled</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | — |
|
||||
| <code>withBarValueLabel</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether a label with bar value should be displayed on top of each bar, incompatible with `type="stacked"` and `type="percent"` |
|
||||
| <code>withLegend</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether chart legend should be displayed, `false` by default |
|
||||
| <code>withRightYAxis</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether additional y-axis should be displayed on the right side of the chart, `false` by default |
|
||||
| <code>withTooltip</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether chart tooltip should be displayed, `true` by default |
|
||||
| <code>withXAxis</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether x-axis should be displayed, `true` by default |
|
||||
| <code>withYAxis</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether y-axis should be displayed, `true` by default |
|
||||
| <code>xAxisLabel</code> | no | <code>string</code> | — | A label to display below the x-axis |
|
||||
| <code>xAxisProps</code> | no | <code>Omit<XAxisProps, "ref"></code> | — | Props passed down to the `XAxis` recharts component |
|
||||
| <code>yAxisLabel</code> | no | <code>string</code> | — | A label to display next to the y-axis |
|
||||
| <code>yAxisProps</code> | no | <code>Omit<YAxisProps, "ref"></code> | — | Props passed down to the `YAxis` recharts component |
|
||||
|
||||
<a id="component-barslist"></a>
|
||||
### `BarsList`
|
||||
|
||||
- Safety: `wrapped`
|
||||
- Category: `charts`
|
||||
- Documentation: [upstream component API](https://mantine.dev/charts/bars-list/)
|
||||
- Temporary bind: not available
|
||||
- Subcomponents: none
|
||||
- Component-specific blocked props: none
|
||||
- Accessibility:
|
||||
- Include a nearby text summary of the chart data.
|
||||
|
||||
#### Component-specific props
|
||||
|
||||
| Prop | Required | Type | Literal values | Description |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| <code>attributes</code> | no | <code>{ root?: Record<string, any>; bar?: Record<string, any>; barLabel?: Record<string, any>; barValue?: Record<string, any>; labelsRow?: Record<...>; }</code> | — | — |
|
||||
| <code>autoContrast</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | If set, adjusts text color based on background color |
|
||||
| <code>barColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Default bar background color, used when item does not have color specified |
|
||||
| <code>barGap</code> | no | <code>MantineSpacing</code> | <code>"lg"</code>, <code>"md"</code>, <code>"sm"</code>, <code>"xl"</code>, <code>"xs"</code> | Controls gap between bars |
|
||||
| <code>barHeight</code> | no | <code>string | number</code> | — | Bar height |
|
||||
| <code>barTextColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Bar text color, overrides autoContrast |
|
||||
| <code>barsLabel</code> | no | <code>string</code> | — | Label displayed above the bars column |
|
||||
| <code>data</code> | yes | <code>BarsListBarData[]</code> | — | Data for bars |
|
||||
| <code>minBarSize</code> | no | <code>string | number</code> | — | Minimum bar width |
|
||||
| <code>unstyled</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | — |
|
||||
| <code>valueLabel</code> | no | <code>string</code> | — | Label displayed above the values column |
|
||||
|
||||
<a id="component-bubblechart"></a>
|
||||
### `BubbleChart`
|
||||
|
||||
- Safety: `wrapped`
|
||||
- Category: `charts`
|
||||
- Documentation: [upstream component API](https://mantine.dev/charts/bubble-chart/)
|
||||
- Temporary bind: not available
|
||||
- Subcomponents: none
|
||||
- Component-specific blocked props: none
|
||||
- Accessibility:
|
||||
- Include a nearby text summary of the chart data.
|
||||
|
||||
#### Component-specific props
|
||||
|
||||
| Prop | Required | Type | Literal values | Description |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| <code>attributes</code> | no | <code>{ root?: Record<string, any>; tooltip?: Record<string, any>; axis?: Record<string, any>; }</code> | — | — |
|
||||
| <code>data</code> | yes | <code>Record<string, any>[]</code> | — | Chart data |
|
||||
| <code>dataKey</code> | yes | <code>BubbleChartDataKey</code> | — | Data keys for x, y and z axis |
|
||||
| <code>gridColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Color of the grid and cursor lines, by default depends on color scheme |
|
||||
| <code>label</code> | no | <code>string</code> | — | Chart label displayed next to the x axis |
|
||||
| <code>range</code> | yes | <code>[number, number]</code> | — | Z axis range |
|
||||
| <code>scatterProps</code> | no | <code>Partial<Omit<ScatterProps, "ref">></code> | — | Props passed down to the `Scatter` component |
|
||||
| <code>textColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Color of the text displayed inside the chart |
|
||||
| <code>tooltipProps</code> | no | <code>Omit<TooltipProps<any, any>, "ref"></code> | — | Props passed down to the `Tooltip` component |
|
||||
| <code>unstyled</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | — |
|
||||
| <code>withTooltip</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether the tooltip should be displayed |
|
||||
| <code>xAxisProps</code> | no | <code>Omit<XAxisProps, "ref"></code> | — | Props passed down to the `XAxis` recharts component |
|
||||
| <code>yAxisProps</code> | no | <code>Omit<YAxisProps, "ref"></code> | — | Props passed down to the `YAxis` recharts component |
|
||||
| <code>zAxisProps</code> | no | <code>Omit<ZAxisProps<any, any>, "ref"></code> | — | Props passed down to the `ZAxis` recharts component |
|
||||
|
||||
<a id="component-chartlegend"></a>
|
||||
### `ChartLegend`
|
||||
|
||||
- Safety: `wrapped`
|
||||
- Category: `charts`
|
||||
- Documentation: [upstream component API](https://mantine.dev/charts/chart-legend/)
|
||||
- Temporary bind: not available
|
||||
- Subcomponents: none
|
||||
- Component-specific blocked props: none
|
||||
- Accessibility:
|
||||
- Include a nearby text summary of the chart data.
|
||||
|
||||
#### Component-specific props
|
||||
|
||||
| Prop | Required | Type | Literal values | Description |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| <code>attributes</code> | no | <code>{ legend?: Record<string, any>; legendItem?: Record<string, any>; legendItemColor?: Record<string, any>; legendItemName?: Record<...>; }</code> | — | — |
|
||||
| <code>centered</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether the legend should be centered |
|
||||
| <code>legendPosition</code> | yes | <code>"bottom" | "top" | "middle"</code> | <code>"bottom"</code>, <code>"middle"</code>, <code>"top"</code> | Position of the legend relative to the chart, used to apply margin on the corresponding side |
|
||||
| <code>payload</code> | no | <code>readonly Record<string, any>[]</code> | — | Chart data provided by recharts |
|
||||
| <code>series</code> | no | <code>ChartSeries[]</code> | — | Data used for labels, only applicable for area charts: AreaChart, LineChart, BarChart |
|
||||
| <code>showColor</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether color swatch should be shown next to the label |
|
||||
| <code>unstyled</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | — |
|
||||
|
||||
<a id="component-charttooltip"></a>
|
||||
### `ChartTooltip`
|
||||
|
||||
- Safety: `wrapped`
|
||||
- Category: `charts`
|
||||
- Documentation: [upstream component API](https://mantine.dev/charts/chart-tooltip/)
|
||||
- Temporary bind: not available
|
||||
- Subcomponents: none
|
||||
- Component-specific blocked props: none
|
||||
- Accessibility:
|
||||
- Include a nearby text summary of the chart data.
|
||||
|
||||
#### Component-specific props
|
||||
|
||||
| Prop | Required | Type | Literal values | Description |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| <code>attributes</code> | no | <code>{ tooltip?: Record<string, any>; tooltipItem?: Record<string, any>; tooltipItemBody?: Record<string, any>; tooltipItemColor?: Record<...>; tooltipItemName?: Record<...>; tooltipItemData?: Record<...>; tooltipLabel?: Record<...>; tool...</code> | — | — |
|
||||
| <code>label</code> | no | <code>React.ReactNode</code> | — | Main tooltip label |
|
||||
| <code>payload</code> | no | <code>Record<string, any>[] | readonly Record<string, any>[]</code> | — | Chart data provided by recharts |
|
||||
| <code>segmentId</code> | no | <code>string | number</code> | — | Segment to display data for, identified by its index in the data array (preferred, isolates duplicate names) or by its name. Only applicable when `type="radial"`. If not set, all data is rendered. |
|
||||
| <code>series</code> | no | <code>ChartSeries[]</code> | — | Chart series data, applicable only for `area` type |
|
||||
| <code>showColor</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether the color swatch should be visible |
|
||||
| <code>unit</code> | no | <code>string</code> | — | Data units, provided by parent component |
|
||||
| <code>unstyled</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | — |
|
||||
|
||||
<a id="component-compositechart"></a>
|
||||
### `CompositeChart`
|
||||
|
||||
- Safety: `wrapped`
|
||||
- Category: `charts`
|
||||
- Documentation: [upstream component API](https://mantine.dev/charts/composite-chart/)
|
||||
- Temporary bind: not available
|
||||
- Subcomponents: none
|
||||
- Component-specific blocked props: none
|
||||
- Accessibility:
|
||||
- Include a nearby text summary of the chart data.
|
||||
|
||||
#### Component-specific props
|
||||
|
||||
| Prop | Required | Type | Literal values | Description |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| <code>activeDotProps</code> | no | <code>MantineChartDotProps</code> | — | Props passed down to all active dots. Ignored if `withDots={false}` is set. |
|
||||
| <code>attributes</code> | no | <code>{ area?: Record<string, any>; legend?: Record<string, any>; line?: Record<string, any>; grid?: Record<string, any>; ... 16 more ...; tooltipBody?: Record<...>; }</code> | — | — |
|
||||
| <code>composedChartProps</code> | no | <code>(CartesianChartProps<unknown> & { ref?: React.Ref<SVGSVGElement>; })</code> | — | Props passed down to recharts `AreaChart` component |
|
||||
| <code>connectNulls</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether points with `null` values should be connected |
|
||||
| <code>curveType</code> | no | <code>"step" | "bump" | "linear" | "natural" | "monotone" | "stepBefore" | "stepAfter"</code> | <code>"bump"</code>, <code>"linear"</code>, <code>"monotone"</code>, <code>"natural"</code>, <code>"step"</code>, <code>"stepAfter"</code>, <code>"stepBefore"</code> | Type of the curve |
|
||||
| <code>data</code> | yes | <code>Record<string, any>[]</code> | — | Data used to display chart |
|
||||
| <code>dataKey</code> | yes | <code>string</code> | — | Key of the `data` object for x-axis values |
|
||||
| <code>dotProps</code> | no | <code>MantineChartDotProps</code> | — | Props passed down to all dots. Ignored if `withDots={false}` is set. |
|
||||
| <code>gridAxis</code> | no | <code>"none" | "x" | "y" | "xy"</code> | <code>"none"</code>, <code>"x"</code>, <code>"xy"</code>, <code>"y"</code> | Specifies which lines should be displayed in the grid, `'x'` by default |
|
||||
| <code>gridColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Color of the grid and cursor lines, by default depends on color scheme |
|
||||
| <code>gridProps</code> | no | <code>Omit<CartesianGridProps, "ref"></code> | — | Props passed down to the `CartesianGrid` component |
|
||||
| <code>legendProps</code> | no | <code>Omit<LegendProps, "ref"></code> | — | Props passed down to the `Legend` component |
|
||||
| <code>maxBarWidth</code> | no | <code>number</code> | — | Maximum bar width in px |
|
||||
| <code>minBarSize</code> | no | <code>number</code> | — | Sets minimum height of the bar in px |
|
||||
| <code>referenceLines</code> | no | <code>ChartReferenceLineProps[]</code> | — | Reference lines that should be displayed on the chart |
|
||||
| <code>rightYAxisLabel</code> | no | <code>string</code> | — | A label to display next to the right y-axis |
|
||||
| <code>rightYAxisProps</code> | no | <code>Omit<YAxisProps, "ref"></code> | — | Props passed down to the `YAxis` recharts component rendered on the right side |
|
||||
| <code>series</code> | yes | <code>CompositeChartSeries[]</code> | — | An array of objects with `name` and `color` keys. Determines which data should be consumed from the `data` array. |
|
||||
| <code>textColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Color of the text displayed inside the chart, `'dimmed'` by default |
|
||||
| <code>tickLine</code> | no | <code>"none" | "x" | "y" | "xy"</code> | <code>"none"</code>, <code>"x"</code>, <code>"xy"</code>, <code>"y"</code> | Specifies which axis should have tick line, `'y'` by default |
|
||||
| <code>tooltipAnimationDuration</code> | no | <code>number</code> | — | Tooltip position animation duration in ms, `0` by default |
|
||||
| <code>tooltipProps</code> | no | <code>Omit<TooltipProps<any, any>, "ref"></code> | — | Props passed down to the `Tooltip` component |
|
||||
| <code>unit</code> | no | <code>string</code> | — | Unit displayed next to each tick in y-axis |
|
||||
| <code>unstyled</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | — |
|
||||
| <code>withBarValueLabel</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether a label with bar value should be displayed on top of each bar |
|
||||
| <code>withDots</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether dots should be displayed |
|
||||
| <code>withLegend</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether chart legend should be displayed, `false` by default |
|
||||
| <code>withPointLabels</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether each point should have associated label |
|
||||
| <code>withRightYAxis</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether additional y-axis should be displayed on the right side of the chart, `false` by default |
|
||||
| <code>withTooltip</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether chart tooltip should be displayed, `true` by default |
|
||||
| <code>withXAxis</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether x-axis should be displayed, `true` by default |
|
||||
| <code>withYAxis</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether y-axis should be displayed, `true` by default |
|
||||
| <code>xAxisLabel</code> | no | <code>string</code> | — | A label to display below the x-axis |
|
||||
| <code>xAxisProps</code> | no | <code>Omit<XAxisProps, "ref"></code> | — | Props passed down to the `XAxis` recharts component |
|
||||
| <code>yAxisLabel</code> | no | <code>string</code> | — | A label to display next to the y-axis |
|
||||
| <code>yAxisProps</code> | no | <code>Omit<YAxisProps, "ref"></code> | — | Props passed down to the `YAxis` recharts component |
|
||||
|
||||
<a id="component-donutchart"></a>
|
||||
### `DonutChart`
|
||||
|
||||
- Safety: `wrapped`
|
||||
- Category: `charts`
|
||||
- Documentation: [upstream component API](https://mantine.dev/charts/donut-chart/)
|
||||
- Temporary bind: not available
|
||||
- Subcomponents: none
|
||||
- Component-specific blocked props: none
|
||||
- Accessibility:
|
||||
- Include a nearby text summary of the chart data.
|
||||
|
||||
#### Component-specific props
|
||||
|
||||
| Prop | Required | Type | Literal values | Description |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| <code>attributes</code> | no | <code>{ label?: Record<string, any>; legend?: Record<string, any>; root?: Record<string, any>; tooltip?: Record<string, any>; ... 9 more ...; tooltipBody?: Record<...>; }</code> | — | — |
|
||||
| <code>chartLabel</code> | no | <code>string | number</code> | — | Chart label, displayed in the center of the chart |
|
||||
| <code>data</code> | yes | <code>DonutChartCell[]</code> | — | Data used to render chart |
|
||||
| <code>endAngle</code> | no | <code>number</code> | — | Controls angle at which charts ends. Set to `0` to render the chart as semicircle. |
|
||||
| <code>labelColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Controls text color of all labels, by default depends on color scheme |
|
||||
| <code>labelsType</code> | no | <code>"value" | "name" | "percent"</code> | <code>"name"</code>, <code>"percent"</code>, <code>"value"</code> | Type of labels to display, `'value'` by default |
|
||||
| <code>legendProps</code> | no | <code>Omit<LegendProps, "ref"></code> | — | Props passed down to recharts `Legend` component |
|
||||
| <code>paddingAngle</code> | no | <code>number</code> | — | Controls padding between segments |
|
||||
| <code>pieChartProps</code> | no | <code>(PolarChartProps<unknown> & { ref?: React.Ref<SVGSVGElement>; })</code> | — | Props passed down to recharts `PieChart` component |
|
||||
| <code>pieProps</code> | no | <code>Partial<Omit<PieProps, "ref">></code> | — | Props passed down to recharts `Pie` component |
|
||||
| <code>startAngle</code> | no | <code>number</code> | — | Controls angle at which chart starts. Set to `180` to render the chart as semicircle. |
|
||||
| <code>strokeColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Controls color of the segments stroke, by default depends on color scheme |
|
||||
| <code>thickness</code> | no | <code>number</code> | — | Controls thickness of the chart segments |
|
||||
| <code>tooltipAnimationDuration</code> | no | <code>number</code> | — | Tooltip animation duration in ms |
|
||||
| <code>tooltipDataSource</code> | no | <code>"all" | "segment"</code> | <code>"all"</code>, <code>"segment"</code> | Determines which data is displayed in the tooltip. `'all'` – display all values, `'segment'` – display only hovered segment. |
|
||||
| <code>tooltipProps</code> | no | <code>Omit<TooltipProps<any, any>, "ref"></code> | — | Props passed down to `Tooltip` recharts component |
|
||||
| <code>unstyled</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | — |
|
||||
| <code>withLabels</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether each segment should have associated label |
|
||||
| <code>withLabelsLine</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether segments labels should have lines that connect the segment with the label |
|
||||
| <code>withLegend</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether the legend should be displayed |
|
||||
| <code>withTooltip</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether the tooltip should be displayed when one of the section is hovered |
|
||||
|
||||
<a id="component-funnelchart"></a>
|
||||
### `FunnelChart`
|
||||
|
||||
- Safety: `wrapped`
|
||||
- Category: `charts`
|
||||
- Documentation: [upstream component API](https://mantine.dev/charts/funnel-chart/)
|
||||
- Temporary bind: not available
|
||||
- Subcomponents: none
|
||||
- Component-specific blocked props: none
|
||||
- Accessibility:
|
||||
- Include a nearby text summary of the chart data.
|
||||
|
||||
#### Component-specific props
|
||||
|
||||
| Prop | Required | Type | Literal values | Description |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| <code>attributes</code> | no | <code>{ legend?: Record<string, any>; root?: Record<string, any>; tooltip?: Record<string, any>; legendItem?: Record<string, any>; ... 8 more ...; tooltipBody?: Record<...>; }</code> | — | — |
|
||||
| <code>data</code> | yes | <code>FunnelChartCell[]</code> | — | Data used to render chart |
|
||||
| <code>funnelChartProps</code> | no | <code>(CartesianChartProps<unknown> & { ref?: React.Ref<SVGSVGElement>; })</code> | — | Props passed down to recharts `FunnelChart` component |
|
||||
| <code>funnelProps</code> | no | <code>Partial<Omit<FunnelProps, "ref">></code> | — | Props passed down to recharts `Pie` component |
|
||||
| <code>labelColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Controls text color of all labels |
|
||||
| <code>labelsPosition</code> | no | <code>"left" | "right" | "inside"</code> | <code>"inside"</code>, <code>"left"</code>, <code>"right"</code> | Controls labels position relative to the segment |
|
||||
| <code>legendProps</code> | no | <code>Omit<LegendProps, "ref"></code> | — | Props passed down to recharts `Legend` component |
|
||||
| <code>strokeColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Controls color of the segments stroke, by default depends on color scheme |
|
||||
| <code>tooltipAnimationDuration</code> | no | <code>number</code> | — | Tooltip animation duration in ms |
|
||||
| <code>tooltipDataSource</code> | no | <code>"all" | "segment"</code> | <code>"all"</code>, <code>"segment"</code> | Determines which data is displayed in the tooltip. `'all'` – display all values, `'segment'` – display only hovered segment. |
|
||||
| <code>tooltipProps</code> | no | <code>Omit<TooltipProps<any, any>, "ref"></code> | — | Props passed down to `Tooltip` recharts component |
|
||||
| <code>unstyled</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | — |
|
||||
| <code>withLabels</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether each segment should have associated label |
|
||||
| <code>withLegend</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether the legend should be displayed |
|
||||
| <code>withTooltip</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether the tooltip should be displayed when a section is hovered |
|
||||
|
||||
<a id="component-heatmap"></a>
|
||||
### `Heatmap`
|
||||
|
||||
- Safety: `wrapped`
|
||||
- Category: `charts`
|
||||
- Documentation: [upstream component API](https://mantine.dev/charts/heatmap/)
|
||||
- Temporary bind: not available
|
||||
- Subcomponents: none
|
||||
- Component-specific blocked props: none
|
||||
- Accessibility:
|
||||
- Include a nearby text summary of the chart data.
|
||||
|
||||
#### Component-specific props
|
||||
|
||||
| Prop | Required | Type | Literal values | Description |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| <code>attributes</code> | no | <code>{ legend?: Record<string, any>; rect?: Record<string, any>; root?: Record<string, any>; weekdayLabel?: Record<string, any>; monthLabel?: Record<...>; legendLabel?: Record<...>; legendRect?: Record<...>; }</code> | — | — |
|
||||
| <code>colors</code> | no | <code>string[]</code> | — | Colors array, used to calculate color for each value, by default 4 shades of green colors are used |
|
||||
| <code>data</code> | yes | <code>Record<string, number></code> | — | Heatmap data, key is date in `YYYY-MM-DD` format (interpreted as a UTC calendar day) |
|
||||
| <code>domain</code> | no | <code>[number, number]</code> | — | Heatmap domain, array of 2 numbers, min and max values, calculated from data by default |
|
||||
| <code>endDate</code> | no | <code>string | Date</code> | — | Heatmap end date. Current date by default. Date is normalized to UTC midnight of the intended calendar day. |
|
||||
| <code>firstDayOfWeek</code> | no | <code>0 | 2 | 3 | 4 | 5 | 6 | 1</code> | <code>0</code>, <code>1</code>, <code>2</code>, <code>3</code>, <code>4</code>, <code>5</code>, <code>6</code> | First day of week, 0 – Sunday, 1 – Monday. |
|
||||
| <code>gap</code> | no | <code>number</code> | — | Gap between rects in px |
|
||||
| <code>legendLabels</code> | no | <code>[string, string]</code> | — | Legend labels, array of 2 elements: [min label, max label] |
|
||||
| <code>monthLabels</code> | no | <code>string[]</code> | — | Month labels, array of 12 elements, can be used for localization |
|
||||
| <code>monthsLabelsHeight</code> | no | <code>number</code> | — | Height of month labels row |
|
||||
| <code>rectRadius</code> | no | <code>number</code> | — | Rect radius in px |
|
||||
| <code>rectSize</code> | no | <code>number</code> | — | Size of day rect in px |
|
||||
| <code>splitMonths</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | If set, inserts a spacer column between months |
|
||||
| <code>startDate</code> | no | <code>string | Date</code> | — | Heatmap start date. Current date - 1 year by default. Date is normalized to UTC midnight of the intended calendar day. |
|
||||
| <code>tooltipProps</code> | no | <code>Partial<TooltipFloatingProps></code> | — | Props passed down to the `Tooltip.Floating` component |
|
||||
| <code>unstyled</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | — |
|
||||
| <code>weekdayLabels</code> | no | <code>string[]</code> | — | Weekday labels, array of 7 elements, can be used for localization |
|
||||
| <code>weekdaysLabelsWidth</code> | no | <code>number</code> | — | Width of weekday labels column |
|
||||
| <code>withLegend</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | If set, legend with color levels is displayed below the heatmap |
|
||||
| <code>withMonthLabels</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | If set, month labels are displayed |
|
||||
| <code>withOutsideDates</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | If set, trailing dates that do not fall into the given `startDate` – `endDate` range are displayed to fill empty space. |
|
||||
| <code>withTooltip</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | If set, tooltip is displayed on rect hover |
|
||||
| <code>withWeekdayLabels</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | If set, weekday labels are displayed |
|
||||
|
||||
<a id="component-linechart"></a>
|
||||
### `LineChart`
|
||||
|
||||
- Safety: `wrapped`
|
||||
- Category: `charts`
|
||||
- Documentation: [upstream component API](https://mantine.dev/charts/line-chart/)
|
||||
- Temporary bind: not available
|
||||
- Subcomponents: none
|
||||
- Component-specific blocked props: none
|
||||
- Accessibility:
|
||||
- Include a nearby text summary of the chart data.
|
||||
|
||||
#### Component-specific props
|
||||
|
||||
| Prop | Required | Type | Literal values | Description |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| <code>activeDotProps</code> | no | <code>MantineChartDotProps</code> | — | Props passed down to all active dots. Ignored if `withDots={false}` is set. |
|
||||
| <code>attributes</code> | no | <code>{ legend?: Record<string, any>; line?: Record<string, any>; grid?: Record<string, any>; root?: Record<string, any>; ... 14 more ...; tooltipBody?: Record<...>; }</code> | — | — |
|
||||
| <code>connectNulls</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether points with `null` values should be connected |
|
||||
| <code>curveType</code> | no | <code>"step" | "bump" | "linear" | "natural" | "monotone" | "stepBefore" | "stepAfter"</code> | <code>"bump"</code>, <code>"linear"</code>, <code>"monotone"</code>, <code>"natural"</code>, <code>"step"</code>, <code>"stepAfter"</code>, <code>"stepBefore"</code> | Type of the curve |
|
||||
| <code>data</code> | yes | <code>Record<string, any>[]</code> | — | Data used to display chart |
|
||||
| <code>dataKey</code> | yes | <code>string</code> | — | Key of the `data` object for x-axis values |
|
||||
| <code>dotProps</code> | no | <code>MantineChartDotProps</code> | — | Props passed down to all dots. Ignored if `withDots={false}` is set. |
|
||||
| <code>gradientStops</code> | no | <code>LineChartGradientStop[]</code> | — | Data used to generate gradient stops |
|
||||
| <code>gridAxis</code> | no | <code>"none" | "x" | "y" | "xy"</code> | <code>"none"</code>, <code>"x"</code>, <code>"xy"</code>, <code>"y"</code> | Specifies which lines should be displayed in the grid, `'x'` by default |
|
||||
| <code>gridColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Color of the grid and cursor lines, by default depends on color scheme |
|
||||
| <code>gridProps</code> | no | <code>Omit<CartesianGridProps, "ref"></code> | — | Props passed down to the `CartesianGrid` component |
|
||||
| <code>legendProps</code> | no | <code>Omit<LegendProps, "ref"></code> | — | Props passed down to the `Legend` component |
|
||||
| <code>lineChartProps</code> | no | <code>(CartesianChartProps<unknown> & { ref?: React.Ref<SVGSVGElement>; })</code> | — | Props passed down to recharts `LineChart` component |
|
||||
| <code>referenceLines</code> | no | <code>ChartReferenceLineProps[]</code> | — | Reference lines that should be displayed on the chart |
|
||||
| <code>rightYAxisLabel</code> | no | <code>string</code> | — | A label to display next to the right y-axis |
|
||||
| <code>rightYAxisProps</code> | no | <code>Omit<YAxisProps, "ref"></code> | — | Props passed down to the `YAxis` recharts component rendered on the right side |
|
||||
| <code>series</code> | yes | <code>LineChartSeries[]</code> | — | An array of objects with `name` and `color` keys. Determines which data should be consumed from the `data` array. |
|
||||
| <code>textColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Color of the text displayed inside the chart, `'dimmed'` by default |
|
||||
| <code>tickLine</code> | no | <code>"none" | "x" | "y" | "xy"</code> | <code>"none"</code>, <code>"x"</code>, <code>"xy"</code>, <code>"y"</code> | Specifies which axis should have tick line, `'y'` by default |
|
||||
| <code>tooltipAnimationDuration</code> | no | <code>number</code> | — | Tooltip position animation duration in ms, `0` by default |
|
||||
| <code>tooltipProps</code> | no | <code>Omit<TooltipProps<any, any>, "ref"></code> | — | Props passed down to the `Tooltip` component |
|
||||
| <code>unit</code> | no | <code>string</code> | — | Unit displayed next to each tick in y-axis |
|
||||
| <code>unstyled</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | — |
|
||||
| <code>withDots</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether dots should be displayed |
|
||||
| <code>withLegend</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether chart legend should be displayed, `false` by default |
|
||||
| <code>withPointLabels</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether each point should have associated label |
|
||||
| <code>withRightYAxis</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether additional y-axis should be displayed on the right side of the chart, `false` by default |
|
||||
| <code>withTooltip</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether chart tooltip should be displayed, `true` by default |
|
||||
| <code>withXAxis</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether x-axis should be displayed, `true` by default |
|
||||
| <code>withYAxis</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether y-axis should be displayed, `true` by default |
|
||||
| <code>xAxisLabel</code> | no | <code>string</code> | — | A label to display below the x-axis |
|
||||
| <code>xAxisProps</code> | no | <code>Omit<XAxisProps, "ref"></code> | — | Props passed down to the `XAxis` recharts component |
|
||||
| <code>yAxisLabel</code> | no | <code>string</code> | — | A label to display next to the y-axis |
|
||||
| <code>yAxisProps</code> | no | <code>Omit<YAxisProps, "ref"></code> | — | Props passed down to the `YAxis` recharts component |
|
||||
|
||||
<a id="component-piechart"></a>
|
||||
### `PieChart`
|
||||
|
||||
- Safety: `wrapped`
|
||||
- Category: `charts`
|
||||
- Documentation: [upstream component API](https://mantine.dev/charts/pie-chart/)
|
||||
- Temporary bind: not available
|
||||
- Subcomponents: none
|
||||
- Component-specific blocked props: none
|
||||
- Accessibility:
|
||||
- Include a nearby text summary of the chart data.
|
||||
|
||||
#### Component-specific props
|
||||
|
||||
| Prop | Required | Type | Literal values | Description |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| <code>attributes</code> | no | <code>{ legend?: Record<string, any>; root?: Record<string, any>; tooltip?: Record<string, any>; legendItem?: Record<string, any>; ... 8 more ...; tooltipBody?: Record<...>; }</code> | — | — |
|
||||
| <code>data</code> | yes | <code>PieChartCell[]</code> | — | Data used to render chart |
|
||||
| <code>endAngle</code> | no | <code>number</code> | — | Controls angle at which charts ends. Set to `0` to render the chart as semicircle. |
|
||||
| <code>labelColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Controls text color of all labels, white by default |
|
||||
| <code>labelsPosition</code> | no | <code>"inside" | "outside"</code> | <code>"inside"</code>, <code>"outside"</code> | Controls labels position relative to the segment |
|
||||
| <code>labelsType</code> | no | <code>"value" | "name" | "percent"</code> | <code>"name"</code>, <code>"percent"</code>, <code>"value"</code> | Type of labels to display |
|
||||
| <code>legendProps</code> | no | <code>Omit<LegendProps, "ref"></code> | — | Props passed down to recharts `Legend` component |
|
||||
| <code>paddingAngle</code> | no | <code>number</code> | — | Controls padding between segments |
|
||||
| <code>pieChartProps</code> | no | <code>(PolarChartProps<unknown> & { ref?: React.Ref<SVGSVGElement>; })</code> | — | Props passed down to recharts `PieChart` component |
|
||||
| <code>pieProps</code> | no | <code>Partial<Omit<PieProps, "ref">></code> | — | Props passed down to recharts `Pie` component |
|
||||
| <code>startAngle</code> | no | <code>number</code> | — | Controls angle at which chart starts. Set to `180` to render the chart as semicircle. |
|
||||
| <code>strokeColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Controls color of the segments stroke, by default depends on color scheme |
|
||||
| <code>tooltipAnimationDuration</code> | no | <code>number</code> | — | Tooltip animation duration in ms |
|
||||
| <code>tooltipDataSource</code> | no | <code>"all" | "segment"</code> | <code>"all"</code>, <code>"segment"</code> | Determines which data is displayed in the tooltip. `'all'` – display all values, `'segment'` – display only hovered segment. |
|
||||
| <code>tooltipProps</code> | no | <code>Omit<TooltipProps<any, any>, "ref"></code> | — | Props passed down to `Tooltip` recharts component |
|
||||
| <code>unstyled</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | — |
|
||||
| <code>withLabels</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether each segment should have associated label |
|
||||
| <code>withLabelsLine</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether segments labels should have lines that connect the segment with the label |
|
||||
| <code>withLegend</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether the legend should be displayed |
|
||||
| <code>withTooltip</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether the tooltip should be displayed when one of the section is hovered |
|
||||
|
||||
<a id="component-radarchart"></a>
|
||||
### `RadarChart`
|
||||
|
||||
- Safety: `wrapped`
|
||||
- Category: `charts`
|
||||
- Documentation: [upstream component API](https://mantine.dev/charts/radar-chart/)
|
||||
- Temporary bind: not available
|
||||
- Subcomponents: none
|
||||
- Component-specific blocked props: none
|
||||
- Accessibility:
|
||||
- Include a nearby text summary of the chart data.
|
||||
|
||||
#### Component-specific props
|
||||
|
||||
| Prop | Required | Type | Literal values | Description |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| <code>activeDotProps</code> | no | <code>MantineChartDotProps</code> | — | Props passed down to all active dots. Ignored if `withDots={false}` is set. |
|
||||
| <code>attributes</code> | no | <code>{ legend?: Record<string, any>; root?: Record<string, any>; tooltip?: Record<string, any>; container?: Record<string, any>; ... 9 more ...; tooltipBody?: Record<...>; }</code> | — | — |
|
||||
| <code>data</code> | yes | <code>Record<string, any>[]</code> | — | Data used in the chart |
|
||||
| <code>dataKey</code> | yes | <code>string</code> | — | Key of the `data` object for axis values |
|
||||
| <code>dotProps</code> | no | <code>MantineChartDotProps</code> | — | Props passed down to all dots. Ignored if `withDots={false}` is set. |
|
||||
| <code>gridColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Controls color of the grid lines. By default, color depends on the color scheme. |
|
||||
| <code>legendProps</code> | no | <code>Omit<LegendProps, "ref"></code> | — | Props passed down to recharts Legend component |
|
||||
| <code>polarAngleAxisProps</code> | no | <code>Omit<PolarAngleAxisProps, "ref"></code> | — | Props passed down to recharts PolarAngleAxis component |
|
||||
| <code>polarGridProps</code> | no | <code>Omit<PolarGridProps, "ref"></code> | — | Props passed down to recharts PolarGrid component |
|
||||
| <code>polarRadiusAxisProps</code> | no | <code>Omit<PolarRadiusAxisProps, "ref"></code> | — | Props passed down to recharts PolarRadiusAxis component |
|
||||
| <code>radarChartProps</code> | no | <code>(PolarChartProps<unknown> & { ref?: React.Ref<SVGSVGElement>; })</code> | — | Props passed down to recharts RadarChart component |
|
||||
| <code>series</code> | yes | <code>RadarChartSeries[]</code> | — | Determines which data should be consumed from the `data` array. |
|
||||
| <code>textColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Controls color of all text elements. By default, color depends on the color scheme. |
|
||||
| <code>tooltipAnimationDuration</code> | no | <code>number</code> | — | Tooltip position animation duration in ms |
|
||||
| <code>tooltipProps</code> | no | <code>Omit<TooltipProps<any, any>, "ref"></code> | — | Props passed down to recharts Tooltip component |
|
||||
| <code>unstyled</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | — |
|
||||
| <code>withDots</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether dots should be displayed |
|
||||
| <code>withLegend</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether the legend should be displayed |
|
||||
| <code>withPolarAngleAxis</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether PolarAngleAxis component should be displayed |
|
||||
| <code>withPolarGrid</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether PolarGrid component should be displayed |
|
||||
| <code>withPolarRadiusAxis</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether PolarRadiusAxisProps component should be displayed |
|
||||
| <code>withTooltip</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether Tooltip component should be displayed |
|
||||
|
||||
<a id="component-radialbarchart"></a>
|
||||
### `RadialBarChart`
|
||||
|
||||
- Safety: `wrapped`
|
||||
- Category: `charts`
|
||||
- Documentation: [upstream component API](https://mantine.dev/charts/radial-bar-chart/)
|
||||
- Temporary bind: not available
|
||||
- Subcomponents: none
|
||||
- Component-specific blocked props: none
|
||||
- Accessibility:
|
||||
- Include a nearby text summary of the chart data.
|
||||
|
||||
#### Component-specific props
|
||||
|
||||
| Prop | Required | Type | Literal values | Description |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| <code>attributes</code> | no | <code>{ legend?: Record<string, any>; root?: Record<string, any>; tooltip?: Record<string, any>; legendItem?: Record<string, any>; legendItemColor?: Record<...>; legendItemName?: Record<...>; }</code> | — | — |
|
||||
| <code>barSize</code> | no | <code>number</code> | — | Size of bars in px, `20` by default |
|
||||
| <code>data</code> | yes | <code>Record<string, any>[]</code> | — | Chart data |
|
||||
| <code>dataKey</code> | yes | <code>string</code> | — | Key from data object to use as data key |
|
||||
| <code>emptyBackgroundColor</code> | no | <code>string</code> | — | Color of the empty background, by default depends on the color scheme |
|
||||
| <code>endAngle</code> | no | <code>number</code> | — | Angle at which chart ends |
|
||||
| <code>legendProps</code> | no | <code>Omit<LegendProps, "ref"></code> | — | Props passed down to recharts Legend component |
|
||||
| <code>radialBarChartProps</code> | no | <code>(PolarChartProps<unknown> & { ref?: React.Ref<SVGSVGElement>; })</code> | — | Props passed down to recharts RadarChartChart component |
|
||||
| <code>radialBarProps</code> | no | <code>Omit<RadialBarProps, "ref"></code> | — | Props passed down to recharts RadialBar component |
|
||||
| <code>startAngle</code> | no | <code>number</code> | — | Angle at which chart starts |
|
||||
| <code>tooltipProps</code> | no | <code>Omit<TooltipProps<any, any>, "ref"></code> | — | Props passed down to `Tooltip` recharts component |
|
||||
| <code>unstyled</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | — |
|
||||
| <code>withBackground</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether empty bars area should be visible |
|
||||
| <code>withLabels</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether labels should be displayed |
|
||||
| <code>withLegend</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether the legend should be displayed |
|
||||
| <code>withTooltip</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether the tooltip should be displayed when one of the bars is hovered |
|
||||
|
||||
<a id="component-sankeychart"></a>
|
||||
### `SankeyChart`
|
||||
|
||||
- Safety: `wrapped`
|
||||
- Category: `charts`
|
||||
- Documentation: [upstream component API](https://mantine.dev/charts/sankey-chart/)
|
||||
- Temporary bind: not available
|
||||
- Subcomponents: none
|
||||
- Component-specific blocked props: none
|
||||
- Accessibility:
|
||||
- Include a nearby text summary of the chart data.
|
||||
|
||||
#### Component-specific props
|
||||
|
||||
| Prop | Required | Type | Literal values | Description |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| <code>attributes</code> | no | <code>{ root?: Record<string, any>; tooltip?: Record<string, any>; tooltipItem?: Record<string, any>; tooltipItemBody?: Record<...>; ... 4 more ...; tooltipBody?: Record<...>; }</code> | — | — |
|
||||
| <code>colors</code> | no | <code>DefaultMantineColor[]</code> | — | An array of colors used for nodes when no `color` is set on individual nodes |
|
||||
| <code>data</code> | yes | <code>SankeyChartData</code> | — | Data used to render chart, must include `nodes` and `links` arrays |
|
||||
| <code>iterations</code> | no | <code>number</code> | — | The number of relaxation iterations |
|
||||
| <code>linkColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Default color for links, by default depends on color scheme |
|
||||
| <code>linkCurvature</code> | no | <code>number</code> | — | The curvature of links between nodes |
|
||||
| <code>linkOpacity</code> | no | <code>number</code> | — | Opacity of the link fill |
|
||||
| <code>nodeColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Default color for nodes, by default depends on color scheme |
|
||||
| <code>nodePadding</code> | no | <code>number</code> | — | The padding between nodes |
|
||||
| <code>nodeWidth</code> | no | <code>number</code> | — | The width of each node |
|
||||
| <code>sankeyProps</code> | no | <code>Partial<Omit<RechartsSankeyProps, "data" | "ref">></code> | — | Props passed down to recharts `Sankey` component |
|
||||
| <code>textColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Controls text color of labels |
|
||||
| <code>tooltipAnimationDuration</code> | no | <code>number</code> | — | Tooltip animation duration in ms |
|
||||
| <code>tooltipProps</code> | no | <code>Omit<TooltipProps<any, any>, "ref"></code> | — | Props passed down to `Tooltip` recharts component |
|
||||
| <code>unstyled</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | — |
|
||||
| <code>withTooltip</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether the tooltip should be displayed when hovered |
|
||||
|
||||
<a id="component-scatterchart"></a>
|
||||
### `ScatterChart`
|
||||
|
||||
- Safety: `wrapped`
|
||||
- Category: `charts`
|
||||
- Documentation: [upstream component API](https://mantine.dev/charts/scatter-chart/)
|
||||
- Temporary bind: not available
|
||||
- Subcomponents: none
|
||||
- Component-specific blocked props: none
|
||||
- Accessibility:
|
||||
- Include a nearby text summary of the chart data.
|
||||
|
||||
#### Component-specific props
|
||||
|
||||
| Prop | Required | Type | Literal values | Description |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| <code>attributes</code> | no | <code>{ legend?: Record<string, any>; grid?: Record<string, any>; root?: Record<string, any>; tooltip?: Record<string, any>; ... 14 more ...; scatter?: Record<...>; }</code> | — | — |
|
||||
| <code>data</code> | yes | <code>ScatterChartSeries[]</code> | — | Data that is used to build the chart |
|
||||
| <code>dataKey</code> | yes | <code>{ x: string; y: string; }</code> | — | Keys that should be used to retrieve data from the data array on x and y axis |
|
||||
| <code>gridAxis</code> | no | <code>"none" | "x" | "y" | "xy"</code> | <code>"none"</code>, <code>"x"</code>, <code>"xy"</code>, <code>"y"</code> | Specifies which lines should be displayed in the grid, `'x'` by default |
|
||||
| <code>gridColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Color of the grid and cursor lines, by default depends on color scheme |
|
||||
| <code>gridProps</code> | no | <code>Omit<CartesianGridProps, "ref"></code> | — | Props passed down to the `CartesianGrid` component |
|
||||
| <code>labels</code> | no | <code>{ x?: string; y?: string; }</code> | — | Labels that should be used instead of keys names in the tooltip |
|
||||
| <code>legendProps</code> | no | <code>Omit<LegendProps, "ref"></code> | — | Props passed down to the `Legend` component |
|
||||
| <code>pointLabels</code> | no | <code>"x" | "y"</code> | <code>"x"</code>, <code>"y"</code> | If set, displays labels next to points for the given axis |
|
||||
| <code>referenceLines</code> | no | <code>ChartReferenceLineProps[]</code> | — | Reference lines that should be displayed on the chart |
|
||||
| <code>rightYAxisLabel</code> | no | <code>string</code> | — | A label to display next to the right y-axis |
|
||||
| <code>rightYAxisProps</code> | no | <code>Omit<YAxisProps, "ref"></code> | — | Props passed down to the `YAxis` recharts component rendered on the right side |
|
||||
| <code>scatterChartProps</code> | no | <code>(CartesianChartProps<unknown> & { ref?: React.Ref<SVGSVGElement>; })</code> | — | Props passed down to recharts `ScatterChart` component |
|
||||
| <code>scatterProps</code> | no | <code>Partial<Omit<ScatterProps, "ref">></code> | — | Props passed down to recharts `Scatter` component |
|
||||
| <code>textColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Color of the text displayed inside the chart, `'dimmed'` by default |
|
||||
| <code>tickLine</code> | no | <code>"none" | "x" | "y" | "xy"</code> | <code>"none"</code>, <code>"x"</code>, <code>"xy"</code>, <code>"y"</code> | Specifies which axis should have tick line, `'y'` by default |
|
||||
| <code>tooltipAnimationDuration</code> | no | <code>number</code> | — | Tooltip position animation duration in ms, `0` by default |
|
||||
| <code>tooltipProps</code> | no | <code>Omit<TooltipProps<any, any>, "ref"></code> | — | Props passed down to the `Tooltip` component |
|
||||
| <code>unit</code> | no | <code>{ x?: string; y?: string; }</code> | — | Units displayed after value on axis and inside the tooltip |
|
||||
| <code>unstyled</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | — |
|
||||
| <code>withLegend</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether chart legend should be displayed, `false` by default |
|
||||
| <code>withRightYAxis</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether additional y-axis should be displayed on the right side of the chart, `false` by default |
|
||||
| <code>withTooltip</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether chart tooltip should be displayed, `true` by default |
|
||||
| <code>withXAxis</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether x-axis should be displayed, `true` by default |
|
||||
| <code>withYAxis</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether y-axis should be displayed, `true` by default |
|
||||
| <code>xAxisLabel</code> | no | <code>string</code> | — | A label to display below the x-axis |
|
||||
| <code>xAxisProps</code> | no | <code>Omit<XAxisProps, "ref"></code> | — | Props passed down to the `XAxis` recharts component |
|
||||
| <code>yAxisLabel</code> | no | <code>string</code> | — | A label to display next to the y-axis |
|
||||
| <code>yAxisProps</code> | no | <code>Omit<YAxisProps, "ref"></code> | — | Props passed down to the `YAxis` recharts component |
|
||||
|
||||
<a id="component-sparkline"></a>
|
||||
### `Sparkline`
|
||||
|
||||
- Safety: `wrapped`
|
||||
- Category: `charts`
|
||||
- Documentation: [upstream component API](https://mantine.dev/charts/sparkline/)
|
||||
- Temporary bind: not available
|
||||
- Subcomponents: none
|
||||
- Component-specific blocked props: none
|
||||
- Accessibility:
|
||||
- Include a nearby text summary of the chart data.
|
||||
|
||||
#### Component-specific props
|
||||
|
||||
| Prop | Required | Type | Literal values | Description |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| <code>areaProps</code> | no | <code>Omit<AreaProps<any, any>, "data" | "ref" | "dataKey"></code> | — | Props passed down to the underlying recharts `Area` component |
|
||||
| <code>attributes</code> | no | <code>{ root?: Record<string, any>; }</code> | — | — |
|
||||
| <code>connectNulls</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether null values should be connected with other values |
|
||||
| <code>curveType</code> | no | <code>"step" | "bump" | "linear" | "natural" | "monotone" | "stepBefore" | "stepAfter"</code> | <code>"bump"</code>, <code>"linear"</code>, <code>"monotone"</code>, <code>"natural"</code>, <code>"step"</code>, <code>"stepAfter"</code>, <code>"stepBefore"</code> | Type of the curve |
|
||||
| <code>data</code> | yes | <code>(number | null)[]</code> | — | Data used to render the chart |
|
||||
| <code>trendColors</code> | no | <code>SparklineTrendColors</code> | — | If set, `color` prop is ignored and chart color is determined by the difference between first and last value. |
|
||||
| <code>unstyled</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | — |
|
||||
| <code>withGradient</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether the chart fill should be a gradient |
|
||||
|
||||
<a id="component-treemap"></a>
|
||||
### `Treemap`
|
||||
|
||||
- Safety: `wrapped`
|
||||
- Category: `charts`
|
||||
- Documentation: [upstream component API](https://mantine.dev/charts/treemap/)
|
||||
- Temporary bind: not available
|
||||
- Subcomponents: none
|
||||
- Component-specific blocked props: none
|
||||
- Accessibility:
|
||||
- Include a nearby text summary of the chart data.
|
||||
|
||||
#### Component-specific props
|
||||
|
||||
| Prop | Required | Type | Literal values | Description |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| <code>aspectRatio</code> | no | <code>number</code> | — | The treemap will try to keep every single rectangle's aspect ratio near the aspectRatio given |
|
||||
| <code>attributes</code> | no | <code>{ root?: Record<string, any>; tooltip?: Record<string, any>; tooltipItem?: Record<string, any>; tooltipItemBody?: Record<...>; ... 4 more ...; tooltipBody?: Record<...>; }</code> | — | — |
|
||||
| <code>autoContrast</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether text color should be adjusted based on background color to improve contrast |
|
||||
| <code>data</code> | yes | <code>TreemapData[]</code> | — | Data used to render chart |
|
||||
| <code>dataKey</code> | no | <code>string</code> | — | Key in data object for the value |
|
||||
| <code>strokeColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Controls color of the node stroke, by default depends on color scheme |
|
||||
| <code>textColor</code> | no | <code>DefaultMantineColor</code> | <code>"blue"</code>, <code>"cyan"</code>, <code>"dark"</code>, <code>"grape"</code>, <code>"gray"</code>, <code>"green"</code>, <code>"indigo"</code>, <code>"lime"</code>, <code>"orange"</code>, <code>"pink"</code>, <code>"red"</code>, <code>"teal"</code>, <code>"violet"</code>, <code>"yellow"</code> | Controls text color of labels |
|
||||
| <code>tooltipAnimationDuration</code> | no | <code>number</code> | — | Tooltip animation duration in ms |
|
||||
| <code>tooltipProps</code> | no | <code>Omit<TooltipProps<any, any>, "ref"></code> | — | Props passed down to `Tooltip` recharts component |
|
||||
| <code>treemapProps</code> | no | <code>Partial<Omit<RechartsTreemapProps<TreemapDataType, any>, "data" | "ref" | "dataKey">></code> | — | Props passed down to recharts `Treemap` component |
|
||||
| <code>unstyled</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | — |
|
||||
| <code>withTooltip</code> | no | <code>false | true</code> | <code>false</code>, <code>true</code> | Determines whether the tooltip should be displayed when a node is hovered |
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -33,10 +33,108 @@ Bind local controls declaratively:
|
||||
|
||||
```jsx
|
||||
<TextInput bind="search" label="Search" />
|
||||
<Calendar bind="selectedDate" />
|
||||
<DatePicker bind="selectedDate" />
|
||||
<Select bind="selectedEntity" data={options.entities} />
|
||||
```
|
||||
|
||||
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 callbacks.
|
||||
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.
|
||||
|
||||
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. Retrieve `homarr://custom-widgets/components/{name}` for exact installed component guidance.
|
||||
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.
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
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.
|
||||
|
||||
```ts
|
||||
interface HomarrCustomWidgetV2 {
|
||||
$schema: "homarr-custom-widget-v2";
|
||||
@@ -30,4 +32,6 @@ Option and bind names start with a letter and use letters, numbers, `-`, or `_`;
|
||||
|
||||
Do not model credentials as options or static defaults. Credential-like option names and values are rejected; source authentication is configured separately.
|
||||
|
||||
When connected, retrieve `homarr://custom-widgets/schema` for the exact machine-readable schema.
|
||||
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.
|
||||
@@ -4,8 +4,16 @@ Homarr executes requests on the server and constrains them to a declared source.
|
||||
|
||||
Never include secrets in a widget document. Configure them per widget source. Stored plaintext is never returned, duplicated, or exported.
|
||||
|
||||
The runtime blocks imports, hooks, refs, raw callbacks, 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.
|
||||
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.
|
||||
+2
-1
@@ -62,7 +62,8 @@ TURBO_TELEMETRY_DISABLED=1
|
||||
UNSAFE_ENABLE_MOCK_INTEGRATION=true
|
||||
|
||||
# Public Workshop API origin used by Homarr's Workshop browser. The default is https://homarr.dev.
|
||||
# NEXT_PUBLIC_WORKSHOP_API_URL=http://127.0.0.1:8090
|
||||
# This is read when the server starts, including in the production Docker image.
|
||||
# WORKSHOP_API_URL=http://127.0.0.1:3003
|
||||
|
||||
# Enable demo mode (seeds a demo user "demo"/"demo", a sample board and a mock integration)
|
||||
# DEMO_MODE=true
|
||||
|
||||
+12
-1
@@ -3,5 +3,16 @@
|
||||
"printWidth": 120,
|
||||
"endOfLine": "lf",
|
||||
"sortPackageJson": true,
|
||||
"ignorePatterns": ["**/*.cjs", "**/lang/**", "static-data/**", "CHANGELOG.md", "apps/docs/.docusaurus/**", "apps/docs/build/**", "apps/docs/blog/**"]
|
||||
"ignorePatterns": [
|
||||
"**/*.cjs",
|
||||
"**/lang/**",
|
||||
"static-data/**",
|
||||
"CHANGELOG.md",
|
||||
"apps/docs/.docusaurus/**",
|
||||
"apps/docs/build/**",
|
||||
"apps/docs/blog/**",
|
||||
"packages/custom-widgets/src/core/component-catalog.generated.json",
|
||||
"packages/custom-widgets/src/core/component-runtime.generated.json",
|
||||
"apps/docs/static/custom-widgets/component-catalog-v1.json"
|
||||
]
|
||||
}
|
||||
@@ -105,7 +105,7 @@ External redis is currently not supported with Proxmox Community Scripts
|
||||
The advanced deployments environment variables should only be used if you know what you are doing.
|
||||
:::
|
||||
|
||||
| Environment Variable | Description |
|
||||
| ------------------------------ | --------------------------------------------------------------------------------------------------------------- |
|
||||
| `DB_MIGRATIONS_DISABLED` | Disable db migrations. For example for helm charts |
|
||||
| `NEXT_PUBLIC_WORKSHOP_API_URL` | Build-time public origin for a separately hosted Workshop PocketBase service. Defaults to `https://homarr.dev`. |
|
||||
| Environment Variable | Description |
|
||||
| ------------------------ | --------------------------------------------------------------------------------------------------------------- |
|
||||
| `DB_MIGRATIONS_DISABLED` | Disable db migrations. For example for helm charts |
|
||||
| `WORKSHOP_API_URL` | Runtime public origin of the separate Workshop PocketBase service. Homarr uses `https://homarr.dev` by default. |
|
||||
@@ -27,11 +27,21 @@ So make sure to manually whitelist this address.
|
||||
|
||||
### Can I add my own widgets? Do I need programming skills?
|
||||
|
||||
Yes. [Custom Widgets](/docs/management/custom-widgets) can read public or private HTTP APIs, render responsive Mantine JSX, expose per-board options, and run permission-checked actions. Custom JSX v2 is the only custom-widget format.
|
||||
Yes. A [Custom Widget](/docs/management/custom-widgets) can read a public or private HTTP API.
|
||||
|
||||
You can author the JSON and JSX directly, use the one-click prompt with ChatGPT or another assistant, or let an MCP agent validate and preview a widget before saving it. The workbench parses a pasted AI response, shows request capabilities, and produces redacted diagnostics for another iteration.
|
||||
It can show responsive Mantine JSX. It can also use widget options and actions with board permission checks.
|
||||
|
||||
The template is declarative: it cannot import packages, execute arbitrary JavaScript, or call browser `fetch`. Network operations are named, same-origin server requests protected by Homarr's board permissions and network policy. You should still review origins, methods, permissions, and actions before installing or publishing a widget.
|
||||
Custom JSX v2 is the only Custom Widget format. Write the JSON and JSX in the workbench.
|
||||
|
||||
You can also use the AI copy prompt. An MCP agent can validate and preview a definition before it saves the definition.
|
||||
|
||||
The workbench reads a pasted AI response. It shows request capabilities and redacted diagnostics.
|
||||
|
||||
A template cannot import packages or run arbitrary JavaScript. It cannot call browser `fetch`.
|
||||
|
||||
Named requests use the Homarr server. The server applies board permissions and the network policy.
|
||||
|
||||
Before installation, examine the origins, methods, permissions, and actions. Use the same review before publication.
|
||||
|
||||
### Is it secure to expose Homarr to the web?
|
||||
|
||||
|
||||
@@ -84,11 +84,13 @@ The OpenAPI and MCP APIs include board management endpoints for common automatio
|
||||
|
||||
Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags.
|
||||
|
||||
## Custom Widget Authoring Resources
|
||||
## Get Custom Widget authoring resources
|
||||
|
||||
Custom JSX v2 has two stable HTTP discovery endpoints:
|
||||
Custom JSX v2 provides two HTTP discovery endpoints:
|
||||
|
||||
- `GET /api/custom-widgets/authoring-prompt` returns the concise current authoring prompt and live resource identifiers.
|
||||
- `GET /api/custom-widgets/skill` returns the Agent Skill name, version, live `SKILL.md`, skills.sh URL, repository source URL, and install command.
|
||||
- `GET /api/custom-widgets/authoring-prompt` returns the current authoring prompt and live resource identifiers.
|
||||
- `GET /api/custom-widgets/skill` returns information about the Agent Skill.
|
||||
|
||||
The short-lived credential entry route is generated by `customWidget_secretRequestUser`; do not construct or persist those URLs yourself. Definition CRUD, validation, previews, template patches, and secret writes are exposed through authenticated tRPC and MCP procedures. Exported definitions never include credentials.
|
||||
Call `customWidget_secretRequestUser` to create a temporary credential entry URL. Do not construct or save this URL.
|
||||
|
||||
Authenticated tRPC and MCP procedures support definition management, validation, previews, template patches, and credential writes. An exported definition does not contain credentials.
|
||||
@@ -29,7 +29,7 @@ Click the **Export** button in the export card. You'll be prompted to type `I un
|
||||
| `db.sqlite` | Full SQLite database with all entities |
|
||||
| `metadata.json` | Backup metadata: Homarr version, creation timestamp, encryption key |
|
||||
|
||||
The backup includes everything: apps, integrations, secrets, boards, users, groups, permissions, search engines, custom widgets, and server settings. (It just backs-up the whole database)
|
||||
The backup contains the complete database. It includes Custom Widgets and all other Homarr configuration.
|
||||
|
||||
## Restoring a Backup
|
||||
|
||||
|
||||
@@ -1,28 +1,47 @@
|
||||
# Agents and skills.sh
|
||||
# Use agents and skills.sh
|
||||
|
||||
An external agent creates widgets one at a time through Homarr's MCP server. The harness owns any multi-widget task list; Homarr does not create packs or multi-widget drafts.
|
||||
An external agent creates one widget at a time with the Homarr MCP server. The agent harness manages the list for multiple widgets.
|
||||
|
||||
Homarr does not create widget packs or drafts for multiple widgets.
|
||||
|
||||
<a className="button button--primary" href="https://www.skills.sh/homarr-labs/homarr/homarr-custom-widget">
|
||||
View the Custom Widget Skill on skills.sh
|
||||
</a>
|
||||
|
||||
Install the Agent Skill directly from the verified Homarr repository:
|
||||
Install the Agent Skill from the verified Homarr repository:
|
||||
|
||||
```sh
|
||||
npx skills add https://github.com/homarr-labs/homarr --skill homarr-custom-widget
|
||||
```
|
||||
|
||||
The portable skill is a tool-agnostic authoring reference. Connected workflow and tool instructions come from Homarr's native MCP prompt instead, so the same complete skill can also be embedded safely in the UI's offline copy/paste prompt.
|
||||
The portable skill gives authoring instructions that do not require a tool. The Homarr MCP prompt gives instructions for a connected workflow.
|
||||
|
||||
The source lives at [`.agents/skills/homarr-custom-widget`](https://github.com/homarr-labs/homarr/tree/dev/.agents/skills/homarr-custom-widget). There is no ZIP archive. After the skill exists on the repository's default branch, run the command once to register installation activity, verify the skills.sh page, and request official maker attribution for `homarr-labs` through skills.sh. Repository code cannot grant that attribution itself.
|
||||
The offline copy prompt includes the complete portable skill and all eight release-matched references. Those references contain the generated component APIs and runtime examples, so Homarr does not append duplicate raw catalog data. A model can author directly from this bundle without MCP tools or repository access.
|
||||
|
||||
## MCP discovery
|
||||
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 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.
|
||||
|
||||
After publication, run the installation command once. This action registers installation activity.
|
||||
|
||||
Then, verify the skills.sh page. Request official maker attribution for `homarr-labs` from skills.sh.
|
||||
|
||||
Repository code cannot give this attribution.
|
||||
|
||||
## Get MCP resources
|
||||
|
||||
- Prompt: `homarr-custom-widget-author`
|
||||
- Resources: `homarr://custom-widgets/schema`, `components`, `components/{name}`, `examples/{name}`, and `skill`
|
||||
- HTTP: `GET /api/custom-widgets/authoring-prompt` and `GET /api/custom-widgets/skill`
|
||||
|
||||
The authoring-prompt tool description intentionally contains only one sentence. Workflow instructions live in the prompt and link to resources.
|
||||
The authoring-prompt tool description contains one sentence. The prompt contains the workflow instructions and resource links.
|
||||
|
||||
`GET /api/custom-widgets/skill`, the MCP tool, and the MCP resource return the live `SKILL.md` with:
|
||||
|
||||
@@ -37,17 +56,24 @@ The authoring-prompt tool description intentionally contains only one sentence.
|
||||
}
|
||||
```
|
||||
|
||||
They do not return an archive URL or hash.
|
||||
These interfaces do not return an archive URL or archive hash.
|
||||
|
||||
## Agent loop
|
||||
## Create a Custom Widget with MCP
|
||||
|
||||
1. Fetch the prompt, live schema, and component catalog.
|
||||
1. Get the prompt, live schema, and component catalog.
|
||||
2. Read the external API documentation.
|
||||
3. Build and validate one definition without saving.
|
||||
4. Create a preview session and test its queries/actions.
|
||||
5. Open the returned preview URL with browser tools.
|
||||
6. Set a supplied credential with the dedicated secret-write permission, or ask the user through the ten-minute Homarr credential URL.
|
||||
7. Inspect the redacted journal and patch until valid.
|
||||
8. Create the widget, then repeat for the next requested widget.
|
||||
3. Build one definition.
|
||||
4. Validate the definition without saving it.
|
||||
5. Create a preview session.
|
||||
6. Test the queries and actions.
|
||||
7. Open the preview URL with browser tools.
|
||||
8. Set the credential if the user supplied it.
|
||||
9. Otherwise, ask the user for the credential with the ten-minute Homarr credential URL.
|
||||
10. Inspect the redacted journal.
|
||||
11. Patch the definition until it is valid.
|
||||
12. Create the widget.
|
||||
13. Repeat the procedure for the next widget.
|
||||
|
||||
Credential URLs are scoped to one definition/preview, source, and required fields. The agent receives only pending/completed status. Plaintext is encrypted immediately and never returned.
|
||||
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.
|
||||
@@ -1,47 +1,125 @@
|
||||
# Workbench and ChatGPT
|
||||
# Create a Custom Widget
|
||||
|
||||
import { CanonicalCustomWidgetExample } from "@site/src/components/custom-widget-code";
|
||||
|
||||
The desktop workbench keeps code and preview side by side. Mobile uses Configure and Preview tabs. Save and preview actions stay available while the form scrolls.
|
||||
The desktop workbench shows the code and preview next to each other. On a mobile device, use the Configure and Preview tabs.
|
||||
|
||||
CodeMirror edits requests, options, and JSX. The editor supplies installed Mantine components, request/source IDs, option names, temporary input names, runtime components, and icons. The request toolbar jumps to a declaration and atomically renames its invalidation, dynamic-option, and JSX references. Save validation errors appear as selectable plain text with a direct **Copy errors** action.
|
||||
The save and preview actions remain available when the form scrolls.
|
||||
|
||||
The **Schema** action beside Undo, Redo, and Format expands the generated JSON Schema plus minimal and full examples. These references are generated from the same validators used by imports, MCP, Workshop, and saved definitions.
|
||||
Use CodeMirror to edit requests, widget options, and JSX. The editor gives completions for the installed Mantine components.
|
||||
|
||||
## Manual workflow
|
||||
The editor also gives completions for identifiers, widget options, temporary inputs, runtime components, and icons.
|
||||
|
||||
1. Add the API source and select its network scope and authentication mode.
|
||||
2. Enter credentials. Existing values are shown only as configured; plaintext is never revealed.
|
||||
3. Declare named load/manual queries and actions.
|
||||
4. Define persisted item options.
|
||||
5. Write JSX using `data`, `status`, `options`, and temporary `inputs`.
|
||||
6. Select **Test and preview**. Its loading state shows while load requests run; the Data tab dot and result summary turn green or red when they finish. Open Data for responses and request errors, then preview compact/standard/wide layouts and inspect runtime and accessibility diagnostics. A preview that initially lacks request data retries rendering when that data arrives.
|
||||
7. Save, then select the definition and its options in the board widget picker.
|
||||
Use the request toolbar to go to a declaration. The rename action changes all related request references.
|
||||
|
||||
## ChatGPT copy/paste
|
||||
The rename operation updates `invalidates`, dynamic widget option, and JSX references as one transaction.
|
||||
|
||||
Enter a short request and optional API documentation URL, then select **Copy AI prompt**. Its dropdown also offers a self-contained version with the complete Custom Widget authoring skill embedded. Both copied prompts are strictly offline authoring references: they contain no MCP resource names, tool calls, or connected-workflow instructions. Paste either prompt into ChatGPT or another model. The model must return exactly:
|
||||
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.
|
||||
|
||||
Use **Paste AI response** to fill the workbench. Enter credentials yourself, test, and preview. If saving fails, select or copy the validation errors shown by Homarr and give that plain text to the model. Homarr does not generate a separate fix prompt or assume the model can access local skill files.
|
||||
Select **Paste AI response** to fill the workbench. Enter the credentials.
|
||||
|
||||
The normal prompt is under 8,000 characters. It includes the installed Mantine version and capability rules but not a complete prop list, MCP workflow, server URL, or credentials. The embedded-skill variant is longer because it includes the portable offline instructions.
|
||||
Test the requests. Preview the widget.
|
||||
|
||||
## Options
|
||||
If the save operation fails, copy the validation errors. Give the error text to the model.
|
||||
|
||||
Options use a restricted object JSON Schema. Supported controls include text, textarea, number, switch, select, multi-select, slider, date, time, color, URL, nested groups, conditions, and advanced JSON.
|
||||
Homarr does not make a separate correction prompt. Homarr does not assume that the model can read local skill files.
|
||||
|
||||
Credentials are not widget options. Credential-like option names and static default values are rejected; configure authentication under the widget's API source so values stay encrypted and out of exports and board configuration.
|
||||
The standard prompt contains fewer than 8,000 characters. It includes the installed Mantine version and the capability rules.
|
||||
|
||||
Dynamic selects use `x-homarr.optionsSource` and a named query. Add `itemsPath` when the response wraps its result array, then use `valuePath` and `labelPath` relative to each item. These requests run only while configuring the item and use the same protected server executor as widget requests.
|
||||
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:
|
||||
|
||||
### Named requests and parameter bindings
|
||||
- `SKILL.md` and all eight release-matched reference files
|
||||
- The Custom Widget, Mantine, skill, and catalog-schema versions
|
||||
|
||||
Request parameters are a map from parameter name to `string`, `number`, or `boolean`. Query and body templates reference them with `{ "$param": "name" }`. Declaring a parameter does not give it a value, even when an option or temporary input has the same name.
|
||||
The generated reference files already contain the exact component and prop APIs. Homarr does not append a second raw catalog or a duplicate examples payload.
|
||||
|
||||
For a load query, bind every parameter explicitly in `optionsBinding`. Each entry is either `{ "$option": "optionName" }` or a primitive literal matching the declared parameter type:
|
||||
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
|
||||
- Advanced JSON
|
||||
|
||||
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:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -55,9 +133,11 @@ For a load query, bind every parameter explicitly in `optionsBinding`. Each entr
|
||||
}
|
||||
```
|
||||
|
||||
For manual queries and actions, `SubFetch`, `ActionButton`, or `ToggleSwitch` supplies the complete parameter object through its `params` prop. This is the only route from JSX values such as `inputs.search` or a loop variable into a request.
|
||||
For a manual query or action, supply all parameters with the component `params` prop. Use `SubFetch`, `ActionButton`, or `ToggleSwitch`.
|
||||
|
||||
Dynamic option requests also run without a JSX invoker. If they declare parameters, give them explicit `optionsBinding` entries so the configuration form can resolve those values.
|
||||
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.
|
||||
|
||||
<CanonicalCustomWidgetExample
|
||||
id="named-widget-requests"
|
||||
@@ -66,9 +146,9 @@ Dynamic option requests also run without a JSX invoker. If they declare paramete
|
||||
height="420px"
|
||||
/>
|
||||
|
||||
### Options schema and defaults
|
||||
### Define the widget options schema and defaults
|
||||
|
||||
Defaults must validate against the options schema. Keep secrets out of both documents.
|
||||
Default values must conform to the widget options schema. Do not put secrets in these documents.
|
||||
|
||||
<CanonicalCustomWidgetExample
|
||||
id="widget-options-schema"
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# Custom JSX runtime
|
||||
# Use the Custom JSX runtime
|
||||
|
||||
Templates are declarative JSX interpreted inside a widget-local error boundary. They cannot import modules or run arbitrary JavaScript.
|
||||
import { CustomJsxComponentReference } from "@site/src/components/custom-jsx-component-reference";
|
||||
|
||||
## Data roots
|
||||
Homarr interprets declarative JSX in the error boundary of the widget. A template cannot import a module or run arbitrary JavaScript.
|
||||
|
||||
## Use data roots
|
||||
|
||||
```jsx
|
||||
<Stack>
|
||||
@@ -20,37 +22,187 @@ Templates are declarative JSX interpreted inside a widget-local error boundary.
|
||||
```
|
||||
|
||||
- `data.<requestId>` contains sanitized JSON or text results.
|
||||
- `status.<requestId>` contains loading, success, HTTP, and error state.
|
||||
- `options` contains the validated board-item configuration.
|
||||
- `inputs` contains temporary values from controls using `bind`.
|
||||
- `status.<requestId>` contains the request status.
|
||||
- `options` contains the validated board item configuration.
|
||||
- `inputs` contains temporary values from controls that use `bind`.
|
||||
|
||||
Load-triggered queries populate data automatically and follow the widget refresh interval. `SubFetch` runs a manual query. `ActionButton` and `ToggleSwitch` invoke actions.
|
||||
Load queries get data automatically. They run at the widget refresh interval.
|
||||
|
||||
## Mantine capabilities
|
||||
`SubFetch` runs a manual query. `ActionButton` and `ToggleSwitch` run actions.
|
||||
|
||||
Homarr discovers exports from the installed Mantine Core, Dates, and Charts packages and generates prop metadata from their installed TypeScript definitions. Ordinary serializable props pass through. Style props, `style`, and `styles` are sanitized and scoped to the tile. Safe new Mantine exports become available without a hand-maintained allowlist; provider, portal, app-shell, modal, drawer, file, and low-level callback families are classified by the capability denylist.
|
||||
## Use Mantine components
|
||||
|
||||
Use safe Tabler icons through the resolver, for example `<TablerIcon name="server" size={18} />`. The editor completes installed icon names, and `homarr://custom-widgets/components/TablerIcon` exposes the same names to MCP agents.
|
||||
Homarr discovers exports from the installed Mantine Core, Dates, and Charts packages. It generates prop metadata from the installed TypeScript definitions.
|
||||
|
||||
The runtime blocks imports, hooks, refs, callbacks, polymorphic roots, provider replacement, arbitrary portal targets, browser networking, npm packages, evaluation, prototype-pollution keys, unsafe URLs, global selectors, and escaping CSS.
|
||||
Homarr passes ordinary serializable props to Mantine. It sanitizes style props, `style`, and `styles`.
|
||||
|
||||
## Temporary inputs
|
||||
Homarr scopes these styles to the widget tile. Safe new Mantine exports become available without a hand-maintained allowlist.
|
||||
|
||||
Bind a control to a literal name and read the value from `inputs`:
|
||||
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:
|
||||
|
||||
```jsx
|
||||
{
|
||||
data.pokemon.map((pokemon) => {
|
||||
const segments = pokemon.url.split("/").filter((part) => part);
|
||||
const dexId = segments.at(-1);
|
||||
const isSelected = inputs.selectedPokemon === pokemon.name;
|
||||
|
||||
return (
|
||||
<Card key={dexId} withBorder={isSelected}>
|
||||
<Text>#{dexId}</Text>
|
||||
<Text tt="capitalize">{pokemon.name}</Text>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
```jsx
|
||||
{
|
||||
(() => {
|
||||
const visiblePokemon = data.pokemon.filter(
|
||||
(pokemon) => options.types.length === 0 || options.types.includes(pokemon.type),
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
{visiblePokemon.map((pokemon) => (
|
||||
<Text>{pokemon.name}</Text>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
})();
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
```jsx
|
||||
<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>
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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 temporary inputs
|
||||
|
||||
Bind a control to a literal name. Read its value from `inputs`:
|
||||
|
||||
```jsx
|
||||
<TextInput bind="search" defaultValue="" />
|
||||
<Calendar bind="selectedDate" />
|
||||
<DatePicker bind="selectedDate" />
|
||||
<Switch bind="enabled" defaultChecked={false} />
|
||||
<MultiSelect bind="selectedEntities" data={options.entities} />
|
||||
<SubFetch requestId="search" params={{ query: inputs.search }} />
|
||||
```
|
||||
|
||||
These values exist only in React memory while the widget is mounted. They are never written to the definition, board options, database, browser storage, or `localStorage`; reloading or remounting resets them. Use widget options for persisted user configuration.
|
||||
These values exist in React memory while the widget is mounted. Homarr does not save them in any storage location.
|
||||
|
||||
Homarr infers the adapter type and handles ordinary `value/onChange`, `checked/onChange`, `active/onChange`, and `opened/onChange` patterns plus unusual Mantine signatures. `defaultValue` and `defaultChecked` initialize the temporary value and are not forwarded beside the controlled prop. Multiple components may share a name only when their adapter types match. Authored callbacks are not allowed.
|
||||
A reload or remount resets the values. Use widget options for saved user configuration.
|
||||
|
||||
## Diagnostics
|
||||
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.
|
||||
@@ -60,4 +212,4 @@ Homarr infers the adapter type and handles ordinary `value/onChange`, `checked/o
|
||||
- `BINDING_TYPE_CONFLICT`: components sharing one input name require incompatible value types.
|
||||
- `RUNTIME_RENDER_ERROR`: rendering failed inside this tile only.
|
||||
|
||||
Homarr never silently drops an ordinary prop.
|
||||
Homarr does not silently remove an ordinary prop.
|
||||
@@ -35,19 +35,29 @@ export const completeManifest = `{
|
||||
|
||||
# Custom Widgets
|
||||
|
||||
Custom JSX v2 is Homarr's only custom-widget format. A definition owns its metadata, inline API sources, named requests, options, and safe JSX template. Credentials belong to a source but are encrypted separately and never exported.
|
||||
Custom JSX v2 is the only Custom Widget format in Homarr. A definition contains metadata, API sources, named requests, widget options, and JSX.
|
||||
|
||||
There are no legacy display types, reusable connections, packs, or compatibility modes.
|
||||
Homarr encrypts source credentials separately. Homarr does not include credentials in an export.
|
||||
|
||||
## Start here
|
||||
Homarr does not support old display types, reusable connections, widget packs, or compatibility modes.
|
||||
|
||||
- [Workbench and ChatGPT](./authoring): create widgets manually or use the copy/paste AI loop.
|
||||
- [JSX runtime](./custom-jsx): data roots, Mantine components, bindings, and diagnostics.
|
||||
- [Requests and security](./requests-and-security): sources, actions, permissions, and network protections.
|
||||
- [Agents and skills.sh](./agent-authoring): MCP prompt/resources/tools and the portable Agent Skill.
|
||||
- [Troubleshooting](./troubleshooting): fix validation, request, configuration, and runtime failures.
|
||||
## Audience and prerequisites
|
||||
|
||||
## One definition
|
||||
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
|
||||
|
||||
```ts
|
||||
interface HomarrCustomWidgetV2 {
|
||||
@@ -70,24 +80,44 @@ interface HomarrCustomWidgetV2 {
|
||||
height="420px"
|
||||
/>
|
||||
|
||||
Exports, imports, the UI, MCP, and Workshop all validate this schema. Old files are rejected. If a board references a missing definition, only that tile shows **Custom widget unavailable**; the board and sibling widgets continue rendering.
|
||||
Exports, imports, the UI, MCP, and Workshop validate this schema. Homarr rejects files that use an old format.
|
||||
|
||||
## Create and configure
|
||||
If a definition is missing, its tile shows **Custom widget unavailable**. The other board items continue to operate.
|
||||
|
||||
Open **Management → Custom Widgets → Add custom widget**. The continuous workbench has sections for general information, API sources and credentials, requests, options, JSX, and preview. Use the sticky navigation to jump between sections; there are no artificial “next” steps.
|
||||
The browser renders the safe JSX template. The Homarr server executes named requests and injects encrypted credentials.
|
||||
|
||||
A board item selects a definition from a searchable picker and immediately renders the definition's options. Invalid options block insertion. If an updated definition makes existing configuration invalid, opening the item shows the normal repair form.
|
||||
The browser does not receive API source credentials.
|
||||
|
||||
## Import, export, and Workshop
|
||||
## Create and configure a Custom Widget
|
||||
|
||||
Export downloads one canonical JSON document without credentials. Import validates and shows the origins, auth modes, network scopes, methods, actions, and required board permissions before creating anything. The same review screen is used for file, clipboard, and Workshop installs. It provides optional credential fields for every authenticated source; entered values are encrypted locally and never added to the imported document.
|
||||
Open **Management → Custom Widgets → Add custom widget**. The workbench contains sections for information, sources, requests, widget options, JSX, and preview.
|
||||
|
||||
Widgets that still need a bearer token, API key, username, or password show a **Credentials missing** indicator on the Custom Widgets management page. Importing without credentials remains supported, and the indicator disappears once every required source credential is configured.
|
||||
Use the fixed navigation to go to a section.
|
||||
|
||||
The [Workshop](/docs/management/workshop) publishes valid widgets immediately. Users can vote or report a submission. Workshop administrators may dismiss reports or delete any post; deletion does not remove installed local copies.
|
||||
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.
|
||||
|
||||
Homarr seeds four disabled examples. They use the same canonical schema as user widgets and are never overwritten after a user edits them. Select one to explore its manifest and JSX.
|
||||
## 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.
|
||||
|
||||
<BundledCustomWidgetGallery />
|
||||
@@ -1,24 +1,64 @@
|
||||
# Requests and security
|
||||
# Configure requests and security
|
||||
|
||||
Each widget owns one or more API sources. Most use a single `default` source. Add another only when one widget genuinely combines APIs, such as a Sonarr/Radarr calendar.
|
||||
Each Custom Widget has one or more API sources. Most definitions use one `default` source.
|
||||
|
||||
Sources support no authentication, bearer tokens, basic authentication, API-key headers, and API-key query parameters. Credentials are encrypted per definition, source, and kind. Duplicating or exporting a widget does not copy credentials.
|
||||
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:
|
||||
|
||||
Queries and actions support GET, POST, PUT, PATCH, and DELETE. `kind`, not method, controls behavior:
|
||||
- No authentication
|
||||
- Bearer token
|
||||
- Basic authentication
|
||||
- API-key header
|
||||
- API-key query parameter
|
||||
|
||||
- Queries may load automatically or run through `SubFetch`; they may include a body.
|
||||
- Actions only run after a user gesture; they may require confirmation and invalidate named queries.
|
||||
Homarr encrypts credentials for each definition, API source, and credential type. A duplicate or export does not contain credentials.
|
||||
|
||||
Successful actions evict the declared queries from both the browser and server response cache before refetching them.
|
||||
## Configure named requests
|
||||
|
||||
Path parameters use `{name}`. Structured query/body templates use `{ "$param": "name" }`. Every declared parameter has an explicit source: load queries use `optionsBinding` entries containing `{ "$option": "optionName" }` or a primitive literal, while manual queries and actions receive the full parameter object from the invoking component's `params` prop. Homarr never matches parameter names to option or input names implicitly.
|
||||
Named requests support GET, POST, PUT, PATCH, and DELETE. The `kind` value controls runtime behavior.
|
||||
|
||||
## Protections
|
||||
The HTTP method does not determine whether a named request is a query or action.
|
||||
|
||||
All requests use Homarr's server executor. It enforces source origin, network scope, DNS and redirect validation, SSRF blocking, permissions, rate limits, request/parameter/body limits, timeouts, response-size limits, and secret injection. Static headers cannot override auth, routing, proxy, cookie, or hop-by-hop headers. Credential-like static values are rejected.
|
||||
- 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.
|
||||
|
||||
`public` allows public destinations, `private` additionally allows private LAN destinations, and `loopback` additionally allows loopback. Sensitive infrastructure ranges remain blocked according to the executor policy.
|
||||
A successful action removes each declared query from the browser cache and server cache. Homarr then runs the query again.
|
||||
|
||||
Every request declares view, modify, or full board access and the server enforces it before contacting the source. DELETE always needs full access and confirmation. Actions are never cached, retried, or triggered on load.
|
||||
Path parameters use `{name}`. Structured query and body templates use `{ "$param": "name" }`.
|
||||
|
||||
Give each declared parameter an explicit value source. For a load query, use `optionsBinding` with an option reference or a primitive value.
|
||||
|
||||
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.
|
||||
@@ -1,29 +1,61 @@
|
||||
# Troubleshooting
|
||||
# Correct Custom Widget problems
|
||||
|
||||
## Widget unavailable
|
||||
## Correct an unavailable widget
|
||||
|
||||
The definition was deleted or reset. Remove the broken tile from the board and install or create a current Custom JSX v2 definition. There is no legacy recovery or migration wizard.
|
||||
The definition does not exist. Remove the unavailable tile from the board.
|
||||
|
||||
## Invalid stored definition
|
||||
Then, install or create a Custom JSX v2 definition. Homarr does not provide recovery or migration for old formats.
|
||||
|
||||
The Custom Widgets management page isolates definitions that no longer match the current schema instead of failing the entire page. Invalid definitions show their validation issues and can be deleted, but cannot be edited, enabled, exported, duplicated, published, or selected for a board. Recreate or import a valid definition; Homarr does not silently migrate unreleased Custom JSX formats.
|
||||
## Correct an invalid saved definition
|
||||
|
||||
## Validation fails
|
||||
The Custom Widgets page isolates a definition that does not conform to the current schema. The other definitions remain available.
|
||||
|
||||
Use the first schema/JSX diagnostic and its path. Ensure source/request IDs are unique, every request references a source, parameters are declared, defaults match the options schema, and network components reference the correct request kind.
|
||||
An invalid definition shows its validation errors. You can delete it.
|
||||
|
||||
Incomplete API source and icon URLs are normal form validation errors. The workbench keeps the current draft mounted and highlights the invalid field while you type; malformed URLs must never replace the form with the error boundary.
|
||||
You cannot edit, enable, export, duplicate, publish, or select an invalid definition. Recreate the definition or import a valid definition.
|
||||
|
||||
An invalid definition disables only the rendered Widget canvas. The preview shell and its Data, Options, Actions, and Diagnostics tabs remain available, and Diagnostics lists the current schema issues.
|
||||
Homarr does not migrate old Custom JSX formats.
|
||||
|
||||
## Request fails
|
||||
## Correct validation errors
|
||||
|
||||
Check the source network scope, base URL, credentials, request method/path/body, runtime parameters, and board permission. For “invalid placeholder” or an unresolved parameter on a load query, first verify that every declared parameter has an explicit `optionsBinding` option reference or literal. Matching parameter and option names are not a binding. The preview journal shows redacted method, request ID, path template, status, and duration without credentials.
|
||||
Read the first schema or JSX diagnostic. Use its path to find the invalid value.
|
||||
|
||||
## Mantine prop warning
|
||||
Make sure that each API source ID is unique. Make sure that each request ID is unique.
|
||||
|
||||
`UNKNOWN_MANTINE_PROP` is advisory and the prop is passed through. Confirm the prop against the installed Mantine version. `BLOCKED_CAPABILITY` is deliberate and must be expressed through safe options, temporary bindings, or named requests instead.
|
||||
Make sure that each named request references an API source. Declare each request parameter.
|
||||
|
||||
## Existing item needs repair
|
||||
Make sure that default values conform to the widget options schema. Use the correct request kind for each runtime component.
|
||||
|
||||
The definition's options schema changed incompatibly. Open the item settings and repair the highlighted configuration. Homarr does not guess option migrations.
|
||||
An incomplete API source URL or icon URL causes a form validation error. The workbench keeps the draft open.
|
||||
|
||||
The workbench marks the invalid field while you type. An invalid URL does not replace the form with the error boundary.
|
||||
|
||||
An invalid definition disables only the widget preview. The Data, Options, Actions, and Diagnostics tabs remain available.
|
||||
|
||||
The Diagnostics tab shows the current schema errors.
|
||||
|
||||
## Correct request errors
|
||||
|
||||
Check the API source network scope. Check the base URL and credentials.
|
||||
|
||||
Check the request method, path, body, parameters, 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.
|
||||
|
||||
The journal shows the method, request ID, path template, status, and duration.
|
||||
|
||||
## Correct a Mantine prop warning
|
||||
|
||||
`UNKNOWN_MANTINE_PROP` is a warning. Homarr passes the prop to Mantine.
|
||||
|
||||
Check the prop in the documentation for the installed Mantine version. `BLOCKED_CAPABILITY` identifies an unsafe capability.
|
||||
|
||||
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.
|
||||
|
||||
Correct the marked configuration values. Homarr does not infer widget option migrations.
|
||||
@@ -162,18 +162,28 @@ The tools table on the MCP tab is dynamically generated - it always reflects the
|
||||
- **"How many users are on my Homarr instance?"** - AI lists all users (admin only)
|
||||
- **"Toggle Pi-hole blocking for 5 minutes"** - AI interacts with DNS controls
|
||||
- **"How are my servers doing?"** - AI fetches Beszel system stats including CPU, memory, disk, and alerts
|
||||
- **"Build and refine a custom widget for this API"** - AI validates the draft, tests GET data and named queries, simulates actions, and creates or updates the widget
|
||||
- **"Build a Custom Widget for this API"** - AI validates and previews one definition before it saves the definition
|
||||
|
||||
## Custom Widget Workflow for AI Agents
|
||||
## Create a Custom Widget with an AI agent
|
||||
|
||||
Agents create one `homarr-custom-widget-v2` definition at a time. Start with the native prompt `homarr-custom-widget-author`, then read the live schema and component resources rather than guessing the installed Mantine surface.
|
||||
An agent creates one `homarr-custom-widget-v2` definition at a time. Start with the native prompt `homarr-custom-widget-author`.
|
||||
|
||||
Then, read the live schema and component resources. These resources describe the installed Mantine components.
|
||||
|
||||
1. Call `customWidget_getAuthoringPrompt` and `customWidget_schema`.
|
||||
2. Build one widget and call `customWidget_validate` without saving.
|
||||
3. Call `customWidget_previewCreate`, then test named requests with `customWidget_previewQuery` and `customWidget_previewAction`.
|
||||
4. Open the returned preview URL, inspect `customWidget_previewJournal`, and iterate with `customWidget_templatePatch` or `customWidget_update`.
|
||||
5. Set a supplied credential with `customWidget_secretSet`, or use `customWidget_secretRequestUser` to issue a ten-minute Homarr entry URL. The agent receives completion status, never the plaintext.
|
||||
6. Call `customWidget_create`, then repeat for the next widget.
|
||||
2. Build one Custom Widget definition.
|
||||
3. Call `customWidget_validate` without saving the definition.
|
||||
4. Call `customWidget_previewCreate`.
|
||||
5. Test named requests with `customWidget_previewQuery` and `customWidget_previewAction`.
|
||||
6. Open the returned preview URL.
|
||||
7. Examine `customWidget_previewJournal`.
|
||||
8. Change the definition with `customWidget_templatePatch` or `customWidget_update`.
|
||||
9. Set a supplied credential with `customWidget_secretSet`.
|
||||
10. Otherwise, call `customWidget_secretRequestUser` to create a ten-minute Homarr entry URL.
|
||||
11. Call `customWidget_create`.
|
||||
12. Repeat this procedure for the next Custom Widget.
|
||||
|
||||
The agent receives the credential request status. The agent does not receive the plaintext credential.
|
||||
|
||||
Discovery resources:
|
||||
|
||||
@@ -183,8 +193,27 @@ Discovery resources:
|
||||
- `homarr://custom-widgets/examples/{name}`
|
||||
- `homarr://custom-widgets/skill`
|
||||
|
||||
The complete tool surface is `customWidget_schema`, `customWidget_validate`, `customWidget_list`, `customWidget_get`, `customWidget_create`, `customWidget_update`, `customWidget_delete`, `customWidget_templatePatch`, `customWidget_previewCreate`, `customWidget_previewQuery`, `customWidget_previewAction`, `customWidget_previewJournal`, `customWidget_secretSet`, `customWidget_secretRequestUser`, `customWidget_getAuthoringPrompt`, and `customWidget_getSkill`.
|
||||
The Custom Widget tools are:
|
||||
|
||||
`custom-widget-manage` permits authoring. Supplying a secret directly requires the separate `custom-widget-secret-write` permission. Request execution retains Homarr's SSRF, DNS, redirect, response-size, timeout, rate, network-scope, and board-permission protections.
|
||||
- `customWidget_schema`
|
||||
- `customWidget_validate`
|
||||
- `customWidget_list`
|
||||
- `customWidget_get`
|
||||
- `customWidget_create`
|
||||
- `customWidget_update`
|
||||
- `customWidget_delete`
|
||||
- `customWidget_templatePatch`
|
||||
- `customWidget_previewCreate`
|
||||
- `customWidget_previewQuery`
|
||||
- `customWidget_previewAction`
|
||||
- `customWidget_previewJournal`
|
||||
- `customWidget_secretSet`
|
||||
- `customWidget_secretRequestUser`
|
||||
- `customWidget_getAuthoringPrompt`
|
||||
- `customWidget_getSkill`
|
||||
|
||||
See [Agents and skills.sh](./custom-widgets/agent-authoring) for the full loop and portable Agent Skill.
|
||||
The `custom-widget-manage` permission permits authoring. A direct credential write also requires the `custom-widget-secret-write` permission.
|
||||
|
||||
Homarr applies its request security controls during request execution. These controls include SSRF, DNS, redirect, size, timeout, rate, and permission controls.
|
||||
|
||||
Read [Use agents and skills.sh](./custom-widgets/agent-authoring) for the complete procedure and the portable Agent Skill.
|
||||
@@ -1,25 +1,49 @@
|
||||
# Workshop
|
||||
# Use Workshop
|
||||
|
||||
[Open Workshop](/workshop) to discover, inspect, vote on, report, download, and publish Custom JSX v2 widgets.
|
||||
[Open Workshop](/workshop) to browse, inspect, vote on, report, download, and publish Custom JSX v2 widgets.
|
||||
|
||||
Valid submissions publish immediately. Authors can edit or delete their posts, and content changes increment the revision. Widget credentials are rejected and never belong in a submission.
|
||||
Submissions publish immediately. Authors can edit or delete their own submissions, while Workshop administrators can
|
||||
review reports and delete any submission. Deleting a Workshop submission never removes widgets already installed in a
|
||||
self-hosted Homarr instance.
|
||||
|
||||
Votes are unique per user and submission. Public listings show upvotes, downvotes, and score without exposing voter identities. Reports are also unique per reporter and submission and contain a category plus explanation.
|
||||
Workshop validates widget JSON in the browser before publishing. Homarr validates it again before installation. Widget
|
||||
exports must not contain credentials; users configure required credentials only after importing a widget.
|
||||
|
||||
Workshop administrators use a protected administration page to review reports grouped by submission, see reporter identities and explanations, inspect vote totals, dismiss reports, delete any submission, browse unreported posts, and view the audit trail. Workshop has no banning, account-state, moderator, approval, quarantine, badge, or remote-deletion system.
|
||||
Votes and reports are unique per user and submission. Listings expose vote totals without exposing voter identities.
|
||||
Reports include a category and explanation. Administrators can see the reporter, dismiss the report, or delete the
|
||||
reported submission.
|
||||
|
||||
Deleting a Workshop post never deletes widgets already installed in a self-hosted Homarr instance.
|
||||
Workshop deliberately has no comments, email notifications, approval queue, moderator roles, user bans, or audit-history
|
||||
system.
|
||||
|
||||
## GitHub sign-in deployment
|
||||
## Access control
|
||||
|
||||
Workshop authentication is hosted by the central Workshop PocketBase service. Register this single callback in the GitHub OAuth application:
|
||||
Workshop uses PocketBase collection API rules for access control:
|
||||
|
||||
- Anyone can browse submissions and vote totals.
|
||||
- Signed-in users can publish, vote, and report.
|
||||
- An author can update or delete only their own submission.
|
||||
- Workshop administrators can read and dismiss reports and delete any submission.
|
||||
|
||||
The frontend uses PocketBase's normal record create, update, and delete APIs. There are no custom deletion endpoints or
|
||||
deletion hooks. PocketBase superusers bypass collection API rules, as documented in
|
||||
[API rules and filters](https://pocketbase.io/docs/api-rules-and-filters/).
|
||||
|
||||
Set `users.isAdmin` from the PocketBase dashboard to grant Workshop administration access. The users collection update
|
||||
rule prevents ordinary users from granting this flag to themselves.
|
||||
|
||||
## Configure GitHub sign-in
|
||||
|
||||
The central Workshop PocketBase service controls authentication. Register this callback in the GitHub OAuth application:
|
||||
|
||||
```text
|
||||
https://<workshop-host>/api/oauth2-redirect
|
||||
```
|
||||
|
||||
Self-hosted Homarr origins such as `http://localhost:3000`, `https://home.example.com`, or another custom domain do not need separate GitHub callback registrations. The popup starts directly from the user's click, authenticates against the Workshop host, and returns the result to the calling Homarr page.
|
||||
Self-hosted Homarr origins, including localhost and custom domains, do not need separate GitHub callbacks. The Workshop
|
||||
host completes authentication in a popup and returns the result to the calling Homarr page.
|
||||
|
||||
PocketBase permits all origins by default. If the Workshop deployment uses PocketBase's `--origins` option or a restrictive reverse-proxy CORS policy, it must allow arbitrary self-hosted Homarr origins. Restricting it to `homarr.dev` breaks sign-in from private installations. See [PocketBase routing and CORS](https://pocketbase.io/docs/go-routing/).
|
||||
PocketBase permits all origins by default. If the deployment uses `--origins` or a restrictive reverse-proxy CORS policy,
|
||||
it must permit self-hosted Homarr origins. See [PocketBase routing and CORS](https://pocketbase.io/docs/go-routing/).
|
||||
|
||||
Homarr reports provider misconfiguration, popup blocking, cancellation, and unavailable OAuth as visible notifications. Keep the Workshop host and GitHub provider configuration centralized; do not register every Homarr installation hostname.
|
||||
Homarr shows a visible error when the provider is misconfigured or the popup is blocked, cancelled, or times out.
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: "Custom API"
|
||||
description: "Renders an administrator-defined Custom JSX v2 widget with server-side API requests, options, and actions."
|
||||
description: "The Custom API widget shows a Custom JSX v2 definition with server-side API requests, widget options, and actions."
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
@@ -10,28 +10,48 @@ import { customApiWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={customApiWidget} categories={["Custom", "API"]} />
|
||||
|
||||
The Custom API widget displays a Custom JSX v2 definition created in [Management → Custom Widgets](/docs/management/custom-widgets). A definition owns one or more inline API sources, named requests, an options schema, and one safe JSX template. Temporary bound inputs exist only while the tile is mounted. Source credentials are encrypted separately and never reach the browser or an export.
|
||||
The Custom API widget shows a Custom JSX v2 definition. Create the definition in [Management → Custom Widgets](/docs/management/custom-widgets).
|
||||
|
||||
A definition contains API sources, named requests, a widget options schema, and one safe JSX template.
|
||||
|
||||
Temporary inputs exist only while the tile is mounted. Homarr encrypts source credentials separately.
|
||||
|
||||
Homarr does not send these credentials to the browser. Homarr does not include them in an export.
|
||||
|
||||
### Add the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
Choose a definition from the searchable picker, configure its generated options, and select a refresh interval. The board item stores only the selected definition ID and validated option values; credentials remain server-side.
|
||||
Select a definition from the searchable picker. Configure its generated widget options.
|
||||
|
||||
Select a refresh interval. The board item stores the definition ID and the validated widget option values.
|
||||
|
||||
Credentials remain on the server.
|
||||
|
||||
:::note
|
||||
|
||||
A definition must be enabled and available to the board before it appears in the widget picker.
|
||||
Enable the definition before you use it. The definition must also be available to the board.
|
||||
|
||||
:::
|
||||
|
||||
### Permissions and actions
|
||||
|
||||
Each request declares its minimum board permission independently of its HTTP method. Queries and actions may use GET, POST, PUT, PATCH, or DELETE; `kind` controls runtime behavior. DELETE always requires full board permission and confirmation. Anonymous viewers cannot execute actions.
|
||||
Each named request declares its minimum board permission. This permission does not depend on the HTTP method.
|
||||
|
||||
Interactive controls are disabled while the board is in edit mode. Actions run only after an explicit user gesture and are never triggered by mounting, refresh polling, or automatic retries. Authoring previews simulate actions unless an administrator explicitly enables live actions for the short-lived preview session.
|
||||
Queries and actions can use GET, POST, PUT, PATCH, or DELETE. The `kind` value controls runtime behavior.
|
||||
|
||||
A DELETE request requires full board permission and confirmation. An anonymous viewer cannot run an action.
|
||||
|
||||
Homarr disables interactive controls in board edit mode. An action runs only after a user action.
|
||||
|
||||
Mounting, refresh polling, and automatic retries do not run actions. An authoring preview simulates actions by default.
|
||||
|
||||
An administrator can enable live actions for the short preview session.
|
||||
|
||||
### Refresh behavior
|
||||
|
||||
The refresh interval reruns load-triggered queries. Manual queries run through `SubFetch`, and each query may declare its own cache duration. Successful actions can invalidate only their declared load or manual queries.
|
||||
The refresh interval runs load queries again. `SubFetch` runs manual queries.
|
||||
|
||||
See [Custom Widgets management](/docs/management/custom-widgets) for the workbench, options, named-request manifest, Mantine JSX surface, network scopes, and security limits.
|
||||
Each query can declare a cache duration. A successful action can invalidate only its declared queries.
|
||||
|
||||
Read [Custom Widgets management](/docs/management/custom-widgets) for authoring, widget options, named requests, Mantine, network scopes, and security limits.
|
||||
@@ -18,6 +18,7 @@
|
||||
"write-translations": "docusaurus write-translations",
|
||||
"write-heading-ids": "docusaurus write-heading-ids",
|
||||
"api-dump-contributors": "node ./scripts/dump-contributors.mjs",
|
||||
"check:custom-widgets-ste": "node ./scripts/check-custom-widgets-ste.mjs",
|
||||
"verify:search": "node ./scripts/verify-algolia-search.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
import { britishSpellings, imperativeVerbs, nonSteTerms, technicalTerms } from "./custom-widgets-ste-terms.mjs";
|
||||
|
||||
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
|
||||
const docsDirectory = resolve(scriptDirectory, "../docs");
|
||||
|
||||
const files = [
|
||||
{ path: "management/custom-widgets/agent-authoring.mdx" },
|
||||
{ path: "management/custom-widgets/authoring.mdx" },
|
||||
{ path: "management/custom-widgets/custom-jsx.mdx" },
|
||||
{ path: "management/custom-widgets/index.mdx" },
|
||||
{ path: "management/custom-widgets/requests-and-security.mdx" },
|
||||
{ path: "management/custom-widgets/troubleshooting.mdx" },
|
||||
{ path: "management/workshop/index.mdx" },
|
||||
{ path: "widgets/custom-api/index.mdx" },
|
||||
{ path: "management/api/index.mdx", section: "Get Custom Widget authoring resources" },
|
||||
{ path: "management/mcp.mdx", section: "Create a Custom Widget with an AI agent", includeMatching: /Custom Widget/ },
|
||||
{ path: "advanced/environment-variables/index.mdx", includeMatching: /WORKSHOP_API_URL/ },
|
||||
{ path: "community/faq.mdx", section: "Can I add my own widgets? Do I need programming skills?" },
|
||||
{ path: "management/backup.mdx", includeMatching: /Custom Widget/ },
|
||||
];
|
||||
|
||||
const imperativeSet = new Set(imperativeVerbs);
|
||||
const issues = [];
|
||||
|
||||
const overview = await readFile(resolve(docsDirectory, "management/custom-widgets/index.mdx"), "utf8");
|
||||
if (!overview.includes("## Audience and prerequisites")) {
|
||||
addIssue("management/custom-widgets/index.mdx", 1, "Add the required audience and prerequisites section.");
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const absolutePath = resolve(docsDirectory, file.path);
|
||||
const source = await readFile(absolutePath, "utf8");
|
||||
const selectedLines = selectLines(source, file);
|
||||
const paragraphs = getProseParagraphs(selectedLines);
|
||||
|
||||
for (const paragraph of paragraphs) {
|
||||
const sentences = splitSentences(paragraph.text);
|
||||
if (sentences.length > 6) {
|
||||
addIssue(file.path, paragraph.line, `Paragraph has ${sentences.length} sentences. The limit is 6.`);
|
||||
}
|
||||
|
||||
for (const sentence of sentences) {
|
||||
const wordCount = countWords(sentence);
|
||||
const isProcedure = paragraph.isList || startsWithImperative(sentence);
|
||||
const limit = isProcedure ? 20 : 25;
|
||||
if (wordCount > limit) {
|
||||
const type = isProcedure ? "Procedural" : "Descriptive";
|
||||
addIssue(file.path, paragraph.line, `${type} sentence has ${wordCount} words. The limit is ${limit}.`);
|
||||
}
|
||||
}
|
||||
|
||||
checkTerms(file.path, paragraph.line, paragraph.text);
|
||||
}
|
||||
}
|
||||
|
||||
if (issues.length > 0) {
|
||||
console.error("Custom Widget STE check failed:\n");
|
||||
for (const issue of issues) console.error(`- ${issue}`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(`Custom Widget STE check passed. ${technicalTerms.length} technical terms are registered.`);
|
||||
}
|
||||
|
||||
function selectLines(source, file) {
|
||||
const lines = source.split("\n").map((text, index) => ({ text, line: index + 1 }));
|
||||
if (!file.section && !file.includeMatching) return lines;
|
||||
|
||||
const selected = [];
|
||||
if (file.section) {
|
||||
const start = lines.findIndex(({ text }) => text.trim().replace(/^#{2,6}\s+/, "") === file.section);
|
||||
if (start === -1) throw new Error(`Section '${file.section}' was not found in ${file.path}`);
|
||||
const headingLevel = lines[start].text.trim().match(/^#+/)?.[0].length ?? 2;
|
||||
|
||||
for (let index = start; index < lines.length; index += 1) {
|
||||
const nextHeadingLevel = lines[index].text.trim().match(/^#+(?=\s)/)?.[0].length;
|
||||
if (index > start && nextHeadingLevel && nextHeadingLevel <= headingLevel) break;
|
||||
selected.push(lines[index]);
|
||||
}
|
||||
}
|
||||
|
||||
if (file.includeMatching) {
|
||||
for (const line of lines) {
|
||||
if (file.includeMatching.test(line.text) && !selected.some((item) => item.line === line.line))
|
||||
selected.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
return selected.toSorted((left, right) => left.line - right.line);
|
||||
}
|
||||
|
||||
function getProseParagraphs(lines) {
|
||||
const paragraphs = [];
|
||||
let paragraph = null;
|
||||
let inFence = false;
|
||||
let inFrontmatter = false;
|
||||
let inTemplateExport = false;
|
||||
let inJsxTag = false;
|
||||
|
||||
const flush = () => {
|
||||
if (paragraph?.text.trim()) paragraphs.push({ ...paragraph, text: paragraph.text.trim() });
|
||||
paragraph = null;
|
||||
};
|
||||
|
||||
for (const { text: originalText, line } of lines) {
|
||||
const trimmed = originalText.trim();
|
||||
|
||||
if (line === 1 && trimmed === "---") {
|
||||
inFrontmatter = true;
|
||||
continue;
|
||||
}
|
||||
if (inFrontmatter && trimmed === "---") {
|
||||
inFrontmatter = false;
|
||||
continue;
|
||||
}
|
||||
if (trimmed.startsWith("```")) {
|
||||
flush();
|
||||
inFence = !inFence;
|
||||
continue;
|
||||
}
|
||||
if (inFence) continue;
|
||||
|
||||
if (/^export const .*?=\s*`/.test(trimmed)) inTemplateExport = true;
|
||||
if (inTemplateExport) {
|
||||
if (/`;\s*$/.test(trimmed)) inTemplateExport = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^(import|export)\s/.test(trimmed)) continue;
|
||||
if (trimmed.startsWith("<")) inJsxTag = true;
|
||||
if (inJsxTag) {
|
||||
if (trimmed.includes(">")) inJsxTag = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
let text = trimmed;
|
||||
if (inFrontmatter) {
|
||||
const match = text.match(/^(title|description|sidebar_label):\s*["']?(.*?)["']?$/);
|
||||
if (!match) continue;
|
||||
text = match[2];
|
||||
}
|
||||
|
||||
if (!text || /^\|?\s*:?-+:?/.test(text)) {
|
||||
flush();
|
||||
continue;
|
||||
}
|
||||
|
||||
const isList = /^\s*(?:[-*]|\d+\.)\s+/.test(text);
|
||||
text = cleanMarkdown(text);
|
||||
if (!text) {
|
||||
flush();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isList || text.startsWith("#") || text.startsWith("|")) flush();
|
||||
text = text
|
||||
.replace(/^#{1,6}\s+/, "")
|
||||
.replace(/^\s*(?:[-*]|\d+\.)\s+/, "")
|
||||
.replace(/^\|\s*/, "")
|
||||
.replace(/\s*\|\s*$/, "");
|
||||
|
||||
if (!paragraph) paragraph = { text, line, isList };
|
||||
else paragraph.text += ` ${text}`;
|
||||
|
||||
if (isList || trimmed.startsWith("#") || trimmed.startsWith("|")) flush();
|
||||
}
|
||||
|
||||
flush();
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
function cleanMarkdown(text) {
|
||||
return text
|
||||
.replace(/\[([^\]]+)]\([^\s)]+(?:\s+"[^"]*")?\)/g, "$1")
|
||||
.replace(/`[^`]*`/g, " ")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/[*_~]/g, "")
|
||||
.replace(/https?:\/\/\S+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function splitSentences(text) {
|
||||
const normalized = text.replace(/\b(?:for example|such as)\b/gi, "example");
|
||||
const matches = normalized.match(/[^.!?]+[.!?]+|[^.!?]+$/g) ?? [];
|
||||
return matches.map((sentence) => sentence.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function countWords(sentence) {
|
||||
return sentence.match(/[A-Za-z0-9]+(?:[-'][A-Za-z0-9]+)*/g)?.length ?? 0;
|
||||
}
|
||||
|
||||
function startsWithImperative(sentence) {
|
||||
const firstWord = sentence.toLowerCase().match(/[a-z]+/)?.[0];
|
||||
return firstWord ? imperativeSet.has(firstWord) : false;
|
||||
}
|
||||
|
||||
function checkTerms(path, line, text) {
|
||||
const lowerText = text.toLowerCase();
|
||||
for (const spelling of britishSpellings) {
|
||||
if (new RegExp(`\\b${spelling}\\b`, "i").test(text)) {
|
||||
addIssue(path, line, `Use American English instead of '${spelling}'.`);
|
||||
}
|
||||
}
|
||||
for (const term of nonSteTerms) {
|
||||
if (new RegExp(`\\b${term}\\b`, "i").test(lowerText)) {
|
||||
addIssue(path, line, `Replace the non-STE term '${term}'.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addIssue(path, line, message) {
|
||||
issues.push(`${path}:${line} ${message}`);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
export const technicalTerms = [
|
||||
"Homarr",
|
||||
"Custom Widget",
|
||||
"Custom JSX v2",
|
||||
"Workshop",
|
||||
"API source",
|
||||
"named request",
|
||||
"load query",
|
||||
"manual query",
|
||||
"action",
|
||||
"widget option",
|
||||
"temporary input",
|
||||
"CodeMirror",
|
||||
"Mantine",
|
||||
"PocketBase",
|
||||
"MCP",
|
||||
"JSON Schema",
|
||||
"API key",
|
||||
"network scope",
|
||||
"preview session",
|
||||
"error boundary",
|
||||
"HTTP",
|
||||
"JSON",
|
||||
"JSX",
|
||||
"React",
|
||||
"TypeScript",
|
||||
"Zod",
|
||||
"TLS",
|
||||
"DNS",
|
||||
"CORS",
|
||||
"OAuth",
|
||||
"SSRF",
|
||||
"tRPC",
|
||||
"GitHub",
|
||||
"Docker",
|
||||
"reverse proxy",
|
||||
"container networking",
|
||||
"API authentication",
|
||||
"bearer token",
|
||||
"basic authentication",
|
||||
"primitive literal",
|
||||
"prop metadata",
|
||||
"allowlist",
|
||||
"denylist",
|
||||
];
|
||||
|
||||
export const imperativeVerbs = [
|
||||
"add",
|
||||
"bind",
|
||||
"build",
|
||||
"call",
|
||||
"check",
|
||||
"configure",
|
||||
"correct",
|
||||
"create",
|
||||
"declare",
|
||||
"define",
|
||||
"delete",
|
||||
"disable",
|
||||
"download",
|
||||
"enable",
|
||||
"enter",
|
||||
"examine",
|
||||
"export",
|
||||
"get",
|
||||
"give",
|
||||
"import",
|
||||
"inspect",
|
||||
"install",
|
||||
"keep",
|
||||
"open",
|
||||
"paste",
|
||||
"read",
|
||||
"register",
|
||||
"remove",
|
||||
"repeat",
|
||||
"request",
|
||||
"run",
|
||||
"save",
|
||||
"select",
|
||||
"set",
|
||||
"start",
|
||||
"supply",
|
||||
"test",
|
||||
"use",
|
||||
"verify",
|
||||
"wait",
|
||||
"write",
|
||||
];
|
||||
|
||||
export const britishSpellings = [
|
||||
"authorise",
|
||||
"authorised",
|
||||
"behaviour",
|
||||
"cancelled",
|
||||
"centre",
|
||||
"colour",
|
||||
"customise",
|
||||
"customised",
|
||||
"initialise",
|
||||
"labelling",
|
||||
"licence",
|
||||
"organisation",
|
||||
"recognise",
|
||||
"serialise",
|
||||
"synchronise",
|
||||
"utilise",
|
||||
"visualise",
|
||||
];
|
||||
|
||||
export const nonSteTerms = [
|
||||
"easy",
|
||||
"easily",
|
||||
"genuinely",
|
||||
"leverage",
|
||||
"magic",
|
||||
"obviously",
|
||||
"seamless",
|
||||
"seamlessly",
|
||||
"simply",
|
||||
"straightforward",
|
||||
"utilize",
|
||||
"utilized",
|
||||
"wizard",
|
||||
];
|
||||
@@ -0,0 +1,459 @@
|
||||
.explorer {
|
||||
--catalog-border: color-mix(in srgb, var(--ifm-color-emphasis-500) 42%, transparent);
|
||||
--catalog-surface: color-mix(in srgb, var(--ifm-background-surface-color) 90%, var(--ifm-color-primary) 10%);
|
||||
border: 1px solid var(--catalog-border);
|
||||
border-radius: 18px;
|
||||
margin: 1.5rem 0 2rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
align-items: flex-start;
|
||||
background: linear-gradient(135deg, color-mix(in srgb, var(--ifm-color-primary) 13%, transparent), transparent 65%);
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
justify-content: space-between;
|
||||
padding: 1.4rem;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: var(--ifm-color-primary);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.09em;
|
||||
margin: 0 0 0.35rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: clamp(1.25rem, 3vw, 1.65rem);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.summary {
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
margin: 0.45rem 0 0;
|
||||
max-width: 68ch;
|
||||
}
|
||||
|
||||
.rawLink,
|
||||
.expandButton,
|
||||
.showMoreButton,
|
||||
.resetButton {
|
||||
align-items: center;
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
font: inherit;
|
||||
font-weight: 650;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.rawLink,
|
||||
.expandButton,
|
||||
.showMoreButton {
|
||||
background: var(--ifm-color-primary);
|
||||
border: 1px solid var(--ifm-color-primary);
|
||||
color: var(--ifm-color-white);
|
||||
padding: 0.58rem 0.85rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.rawLink {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rawLink:hover,
|
||||
.expandButton:hover,
|
||||
.showMoreButton:hover {
|
||||
background: var(--ifm-color-primary-dark);
|
||||
color: var(--ifm-color-white);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.versions {
|
||||
border-bottom: 1px solid var(--catalog-border);
|
||||
border-top: 1px solid var(--catalog-border);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.globalProps {
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--catalog-border);
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
padding: 1rem 1.4rem;
|
||||
scroll-margin-top: calc(var(--ifm-navbar-height) + 1rem);
|
||||
}
|
||||
|
||||
.globalProps h4,
|
||||
.globalProps p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.globalProps p {
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-size: 0.86rem;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.globalPropsTable {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.versions div {
|
||||
padding: 0.75rem 1.4rem;
|
||||
}
|
||||
|
||||
.versions div + div {
|
||||
border-left: 1px solid var(--catalog-border);
|
||||
}
|
||||
|
||||
.versions dt {
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 650;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.versions dd {
|
||||
font-family: var(--ifm-font-family-monospace);
|
||||
font-size: 0.86rem;
|
||||
margin: 0.12rem 0 0;
|
||||
}
|
||||
|
||||
.filters {
|
||||
align-items: end;
|
||||
background: var(--catalog-surface);
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
grid-template-columns: minmax(220px, 2fr) repeat(3, minmax(130px, 1fr));
|
||||
padding: 1.15rem 1.4rem;
|
||||
}
|
||||
|
||||
.filters label:not(.checkboxField) {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.filters label > span {
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.filters input[type="search"],
|
||||
.filters select {
|
||||
background: var(--ifm-background-color);
|
||||
border: 1px solid var(--catalog-border);
|
||||
border-radius: 9px;
|
||||
color: var(--ifm-font-color-base);
|
||||
font: inherit;
|
||||
min-height: 42px;
|
||||
padding: 0.55rem 0.68rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filters input:focus-visible,
|
||||
.filters select:focus-visible,
|
||||
.expandButton:focus-visible,
|
||||
.showMoreButton:focus-visible,
|
||||
.resetButton:focus-visible,
|
||||
.anchorLink:focus-visible {
|
||||
outline: 3px solid color-mix(in srgb, var(--ifm-color-primary) 45%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.checkboxField {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.checkboxField input {
|
||||
height: 1rem;
|
||||
margin: 0;
|
||||
width: 1rem;
|
||||
}
|
||||
|
||||
.resultSummary {
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--catalog-border);
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
min-height: 48px;
|
||||
padding: 0.7rem 1.4rem;
|
||||
}
|
||||
|
||||
.resetButton {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--ifm-color-primary);
|
||||
margin-left: auto;
|
||||
padding: 0.35rem;
|
||||
}
|
||||
|
||||
.componentList {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.componentCard {
|
||||
scroll-margin-top: calc(var(--ifm-navbar-height) + 1rem);
|
||||
}
|
||||
|
||||
.componentCard + .componentCard {
|
||||
border-top: 1px solid var(--catalog-border);
|
||||
}
|
||||
|
||||
.componentHeading {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.4rem;
|
||||
}
|
||||
|
||||
.componentNameRow {
|
||||
align-items: baseline;
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.componentNameRow h4 {
|
||||
font-family: var(--ifm-font-family-monospace);
|
||||
font-size: 1.05rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.anchorLink {
|
||||
font-weight: 700;
|
||||
opacity: 0;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.componentCard:hover .anchorLink,
|
||||
.anchorLink:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.38rem;
|
||||
margin-top: 0.45rem;
|
||||
}
|
||||
|
||||
.badges span {
|
||||
background: var(--ifm-color-emphasis-200);
|
||||
border-radius: 999px;
|
||||
color: var(--ifm-color-emphasis-800);
|
||||
font-size: 0.7rem;
|
||||
padding: 0.2rem 0.48rem;
|
||||
}
|
||||
|
||||
.badges span[data-safety="allowed"] {
|
||||
background: color-mix(in srgb, #2f9e44 18%, transparent);
|
||||
color: color-mix(in srgb, #2f9e44 78%, var(--ifm-font-color-base));
|
||||
}
|
||||
|
||||
.badges span[data-safety="wrapped"] {
|
||||
background: color-mix(in srgb, var(--ifm-color-primary) 18%, transparent);
|
||||
color: var(--ifm-color-primary-darkest);
|
||||
}
|
||||
|
||||
.badges span[data-safety="denied"] {
|
||||
background: color-mix(in srgb, #e03131 18%, transparent);
|
||||
color: color-mix(in srgb, #e03131 78%, var(--ifm-font-color-base));
|
||||
}
|
||||
|
||||
.expandButton {
|
||||
background: transparent;
|
||||
color: var(--ifm-color-primary);
|
||||
flex: 0 0 auto;
|
||||
padding: 0.42rem 0.7rem;
|
||||
}
|
||||
|
||||
.expandButton:hover {
|
||||
background: color-mix(in srgb, var(--ifm-color-primary) 12%, transparent);
|
||||
color: var(--ifm-color-primary-dark);
|
||||
}
|
||||
|
||||
.description,
|
||||
.deniedReason {
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
margin: -0.45rem 1.4rem 1rem;
|
||||
}
|
||||
|
||||
.deniedReason {
|
||||
color: #c92a2a;
|
||||
}
|
||||
|
||||
.details {
|
||||
background: var(--catalog-surface);
|
||||
border-top: 1px solid var(--catalog-border);
|
||||
padding: 1.25rem 1.4rem;
|
||||
}
|
||||
|
||||
.details h5 {
|
||||
font-size: 0.9rem;
|
||||
margin: 0 0 0.6rem;
|
||||
}
|
||||
|
||||
.detailsGrid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr);
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
|
||||
.detailsGrid > section {
|
||||
background: var(--ifm-background-color);
|
||||
border: 1px solid var(--catalog-border);
|
||||
border-radius: 12px;
|
||||
min-width: 0;
|
||||
padding: 0.9rem;
|
||||
}
|
||||
|
||||
.detailsGrid p:last-of-type {
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.codeBlock {
|
||||
font-size: 0.75rem;
|
||||
margin: 0;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.a11yList {
|
||||
font-size: 0.86rem;
|
||||
margin: 0 0 0.7rem;
|
||||
padding-left: 1.1rem;
|
||||
}
|
||||
|
||||
.tableScroll {
|
||||
border: 1px solid var(--catalog-border);
|
||||
border-radius: 10px;
|
||||
max-height: 480px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.propsTable {
|
||||
display: table;
|
||||
font-size: 0.78rem;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.propsTable th {
|
||||
background: var(--ifm-background-color);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.propsTable th,
|
||||
.propsTable td {
|
||||
min-width: 96px;
|
||||
padding: 0.58rem;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.propsTable td:nth-child(2) {
|
||||
max-width: 340px;
|
||||
min-width: 190px;
|
||||
}
|
||||
|
||||
.type {
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
}
|
||||
|
||||
.rawComponent {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.rawComponent summary {
|
||||
color: var(--ifm-color-primary);
|
||||
cursor: pointer;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.rawComponent .codeBlock {
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
.showMoreButton {
|
||||
display: flex;
|
||||
margin: 1.1rem auto;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error,
|
||||
.empty {
|
||||
background: var(--ifm-background-surface-color);
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 12px;
|
||||
margin: 1rem 0;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.empty {
|
||||
border: 0;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error {
|
||||
border-color: color-mix(in srgb, #e03131 58%, transparent);
|
||||
}
|
||||
|
||||
@media (max-width: 996px) {
|
||||
.filters {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.searchField {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 650px) {
|
||||
.header,
|
||||
.componentHeading {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.rawLink,
|
||||
.expandButton {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.versions,
|
||||
.filters,
|
||||
.detailsGrid,
|
||||
.globalProps {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.versions div + div {
|
||||
border-left: 0;
|
||||
border-top: 1px solid var(--catalog-border);
|
||||
}
|
||||
|
||||
.searchField,
|
||||
.checkboxField {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
@@ -1,66 +1,564 @@
|
||||
import React from "react";
|
||||
import BrowserOnly from "@docusaurus/BrowserOnly";
|
||||
import useBaseUrl from "@docusaurus/useBaseUrl";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import customJsxComponentRegistry from "../generated/custom-jsx-components.json";
|
||||
import styles from "./custom-jsx-component-reference.module.css";
|
||||
|
||||
const enabledComponents = customJsxComponentRegistry.filter((component) => component.safety !== "denied");
|
||||
const deniedComponents = customJsxComponentRegistry.filter((component) => component.safety === "denied");
|
||||
const categories = [...new Set(enabledComponents.map((component) => component.category))];
|
||||
type ComponentSafety = "allowed" | "wrapped" | "denied";
|
||||
type ComponentPackage = "@mantine/core" | "@mantine/dates" | "@mantine/charts" | "@homarr/widgets";
|
||||
type ComponentBindingType = "string" | "number" | "boolean" | "string[]" | "number[]";
|
||||
|
||||
interface CatalogProp {
|
||||
name: string;
|
||||
typeRef: number;
|
||||
required: boolean;
|
||||
source: "global" | "component";
|
||||
literalValues?: Array<string | number | boolean | null>;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface CatalogComponent {
|
||||
name: string;
|
||||
package: ComponentPackage;
|
||||
category: string;
|
||||
safety: ComponentSafety;
|
||||
description?: string;
|
||||
documentationUrl: string;
|
||||
props: CatalogProp[];
|
||||
blockedProps: Array<{ name: string; reason: string }>;
|
||||
subcomponents: string[];
|
||||
bind?: {
|
||||
type: ComponentBindingType;
|
||||
initialProp: "defaultValue" | "defaultChecked";
|
||||
};
|
||||
accessibilityRequirements: string[];
|
||||
deniedReason?: string;
|
||||
}
|
||||
|
||||
interface ComponentCatalog {
|
||||
schemaVersion: number;
|
||||
mantineVersion: string;
|
||||
customWidgetVersion: string;
|
||||
types: string[];
|
||||
globalProps: CatalogProp[];
|
||||
blockedCapabilities: Array<{ kind: string; name: string; reason: string }>;
|
||||
components: CatalogComponent[];
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 40;
|
||||
|
||||
export function CustomJsxComponentReference() {
|
||||
return (
|
||||
<>
|
||||
<h3>Enabled and wrapped components</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Category</th>
|
||||
<th>Components</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{categories.map((category) => (
|
||||
<tr key={category}>
|
||||
<td>
|
||||
<code>{category}</code>
|
||||
</td>
|
||||
<td>
|
||||
{enabledComponents
|
||||
.filter((component) => component.category === category)
|
||||
.map((component) => (
|
||||
<span key={component.name} style={{ display: "inline-block", margin: "0 0.5rem 0.35rem 0" }}>
|
||||
<a href={component.documentationUrl}>
|
||||
<code>{component.name}</code>
|
||||
</a>
|
||||
{component.safety === "wrapped" && <small> (wrapped)</small>}
|
||||
</span>
|
||||
))}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Denied components</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Component</th>
|
||||
<th>Reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{deniedComponents.map((component) => (
|
||||
<tr key={component.name}>
|
||||
<td>
|
||||
<a href={component.documentationUrl} aria-label={component.name}>
|
||||
<code>{component.name}</code>
|
||||
</a>
|
||||
</td>
|
||||
<td>{component.reason}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
<BrowserOnly fallback={<ComponentReferenceFallback />}>{() => <CustomJsxComponentReferenceClient />}</BrowserOnly>
|
||||
);
|
||||
}
|
||||
|
||||
function ComponentReferenceFallback() {
|
||||
return (
|
||||
<div className={styles.loading} aria-busy="true">
|
||||
Loading the Custom JSX component catalog…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomJsxComponentReferenceClient() {
|
||||
const catalogUrl = useBaseUrl("/custom-widgets/component-catalog-v1.json");
|
||||
const [catalog, setCatalog] = useState<ComponentCatalog | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [packageFilter, setPackageFilter] = useState("all");
|
||||
const [categoryFilter, setCategoryFilter] = useState("all");
|
||||
const [safetyFilter, setSafetyFilter] = useState("available");
|
||||
const [bindableOnly, setBindableOnly] = useState(false);
|
||||
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
||||
const [expanded, setExpanded] = useState<ReadonlySet<string>>(() => new Set());
|
||||
const [showGlobalProps, setShowGlobalProps] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLoadError(null);
|
||||
|
||||
void fetch(catalogUrl, { signal: controller.signal })
|
||||
.then(async (response) => {
|
||||
if (!response.ok) throw new Error(`The catalog request returned HTTP ${response.status}.`);
|
||||
return (await response.json()) as ComponentCatalog;
|
||||
})
|
||||
.then((value) => {
|
||||
if (!Array.isArray(value.components) || !Array.isArray(value.types) || !Array.isArray(value.globalProps)) {
|
||||
throw new Error("The catalog response has an unsupported shape.");
|
||||
}
|
||||
setCatalog(value);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setLoadError(error instanceof Error ? error.message : "The component catalog could not be loaded.");
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [catalogUrl]);
|
||||
|
||||
const categories = useMemo(
|
||||
() => [...new Set(catalog?.components.map((component) => component.category) ?? [])].sort(),
|
||||
[catalog],
|
||||
);
|
||||
const packages = useMemo(
|
||||
() => [...new Set(catalog?.components.map((component) => component.package) ?? [])].sort(),
|
||||
[catalog],
|
||||
);
|
||||
const filteredComponents = useMemo(() => {
|
||||
if (!catalog) return [];
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase();
|
||||
return catalog.components.filter((component) => {
|
||||
if (packageFilter !== "all" && component.package !== packageFilter) return false;
|
||||
if (categoryFilter !== "all" && component.category !== categoryFilter) return false;
|
||||
if (safetyFilter === "available" && component.safety === "denied") return false;
|
||||
if (safetyFilter !== "all" && safetyFilter !== "available" && component.safety !== safetyFilter) return false;
|
||||
if (bindableOnly && !component.bind) return false;
|
||||
if (!normalizedQuery) return true;
|
||||
|
||||
return [
|
||||
component.name,
|
||||
component.package,
|
||||
component.category,
|
||||
component.description,
|
||||
component.deniedReason,
|
||||
...component.subcomponents,
|
||||
...component.props.map((prop) => prop.name),
|
||||
].some((value) => value?.toLocaleLowerCase().includes(normalizedQuery));
|
||||
});
|
||||
}, [bindableOnly, catalog, categoryFilter, packageFilter, query, safetyFilter]);
|
||||
const visibleComponents = filteredComponents.slice(0, visibleCount);
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleCount(PAGE_SIZE);
|
||||
}, [bindableOnly, categoryFilter, packageFilter, query, safetyFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!catalog) return;
|
||||
|
||||
const revealHashTarget = () => {
|
||||
const hash = decodeLocationHash(window.location.hash);
|
||||
const component = catalog.components.find((entry) => componentAnchor(entry.name) === hash);
|
||||
if (!component) return;
|
||||
|
||||
if (!document.getElementById(hash)) {
|
||||
setQuery(component.name);
|
||||
setPackageFilter("all");
|
||||
setCategoryFilter("all");
|
||||
setSafetyFilter(component.safety === "denied" ? "all" : "available");
|
||||
setBindableOnly(false);
|
||||
setVisibleCount(PAGE_SIZE);
|
||||
}
|
||||
setExpanded((current) => new Set([...current, component.name]));
|
||||
window.requestAnimationFrame(() => {
|
||||
window.requestAnimationFrame(() => document.getElementById(hash)?.scrollIntoView({ block: "start" }));
|
||||
});
|
||||
};
|
||||
|
||||
revealHashTarget();
|
||||
window.addEventListener("hashchange", revealHashTarget);
|
||||
return () => window.removeEventListener("hashchange", revealHashTarget);
|
||||
}, [catalog]);
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className={styles.error} role="alert">
|
||||
<strong>Component catalog unavailable.</strong> {loadError} <a href={catalogUrl}>Open the raw catalog</a> to
|
||||
inspect the generated data directly.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!catalog) return <ComponentReferenceFallback />;
|
||||
|
||||
return (
|
||||
<section className={styles.explorer} aria-label="Custom JSX component catalog">
|
||||
<header className={styles.header}>
|
||||
<div>
|
||||
<p className={styles.eyebrow}>Release-matched static catalog</p>
|
||||
<h3 className={styles.title}>Explore {catalog.components.length} Custom JSX components</h3>
|
||||
<p className={styles.summary}>
|
||||
This browser reads the same versioned JSON artifact used by offline authoring. It never imports or executes
|
||||
the listed component objects.
|
||||
</p>
|
||||
</div>
|
||||
<a className={styles.rawLink} href={catalogUrl} target="_blank" rel="noreferrer">
|
||||
Open raw JSON
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<dl className={styles.versions}>
|
||||
<div>
|
||||
<dt>Catalog schema</dt>
|
||||
<dd>v{catalog.schemaVersion}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Custom Widget</dt>
|
||||
<dd>{catalog.customWidgetVersion}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Mantine</dt>
|
||||
<dd>{catalog.mantineVersion}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Blocked capabilities</dt>
|
||||
<dd>{catalog.blockedCapabilities.length}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<section className={styles.globalProps} id="custom-jsx-global-props">
|
||||
<div>
|
||||
<h4>Shared serializable props</h4>
|
||||
<p>
|
||||
Every available component also receives these {catalog.globalProps.length} generated global, ARIA, data, and
|
||||
safe style props. Component cards below list only their component-specific additions.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.expandButton}
|
||||
aria-expanded={showGlobalProps}
|
||||
aria-controls="custom-jsx-global-props-table"
|
||||
onClick={() => setShowGlobalProps((current) => !current)}
|
||||
>
|
||||
{showGlobalProps ? "Hide global props" : "View global props"}
|
||||
</button>
|
||||
{showGlobalProps && (
|
||||
<div className={styles.globalPropsTable} id="custom-jsx-global-props-table">
|
||||
<PropTable props={catalog.globalProps} types={catalog.types} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className={styles.filters}>
|
||||
<label className={styles.searchField}>
|
||||
<span>Search names, props, and subcomponents</span>
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.currentTarget.value)}
|
||||
placeholder="For example: TextInput, bind, chart…"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Package</span>
|
||||
<select value={packageFilter} onChange={(event) => setPackageFilter(event.currentTarget.value)}>
|
||||
<option value="all">All packages</option>
|
||||
{packages.map((packageName) => (
|
||||
<option key={packageName} value={packageName}>
|
||||
{packageName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Category</span>
|
||||
<select value={categoryFilter} onChange={(event) => setCategoryFilter(event.currentTarget.value)}>
|
||||
<option value="all">All categories</option>
|
||||
{categories.map((category) => (
|
||||
<option key={category} value={category}>
|
||||
{humanize(category)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Availability</span>
|
||||
<select value={safetyFilter} onChange={(event) => setSafetyFilter(event.currentTarget.value)}>
|
||||
<option value="available">Available only</option>
|
||||
<option value="all">Available and denied</option>
|
||||
<option value="allowed">Allowed only</option>
|
||||
<option value="wrapped">Wrapped only</option>
|
||||
<option value="denied">Denied only</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className={styles.checkboxField}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={bindableOnly}
|
||||
onChange={(event) => setBindableOnly(event.currentTarget.checked)}
|
||||
/>
|
||||
Supports temporary <code>bind</code>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className={styles.resultSummary} aria-live="polite">
|
||||
<strong>{filteredComponents.length}</strong> matching component{filteredComponents.length === 1 ? "" : "s"}
|
||||
{(query ||
|
||||
packageFilter !== "all" ||
|
||||
categoryFilter !== "all" ||
|
||||
safetyFilter !== "available" ||
|
||||
bindableOnly) && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.resetButton}
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
setPackageFilter("all");
|
||||
setCategoryFilter("all");
|
||||
setSafetyFilter("available");
|
||||
setBindableOnly(false);
|
||||
}}
|
||||
>
|
||||
Reset filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{filteredComponents.length === 0 ? (
|
||||
<div className={styles.empty}>No component matches these filters.</div>
|
||||
) : (
|
||||
<div className={styles.componentList}>
|
||||
{visibleComponents.map((component) => {
|
||||
const isExpanded = expanded.has(component.name);
|
||||
return (
|
||||
<article className={styles.componentCard} id={componentAnchor(component.name)} key={component.name}>
|
||||
<div className={styles.componentHeading}>
|
||||
<div>
|
||||
<div className={styles.componentNameRow}>
|
||||
<h4>{component.name}</h4>
|
||||
<a
|
||||
className={styles.anchorLink}
|
||||
href={`#${componentAnchor(component.name)}`}
|
||||
aria-label={`Link to ${component.name}`}
|
||||
>
|
||||
#
|
||||
</a>
|
||||
</div>
|
||||
<div className={styles.badges}>
|
||||
<span>{component.package}</span>
|
||||
<span>{humanize(component.category)}</span>
|
||||
<span data-safety={component.safety}>{component.safety}</span>
|
||||
{component.bind && <span>bind: {component.bind.type}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.expandButton}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={`${componentAnchor(component.name)}-details`}
|
||||
onClick={() => {
|
||||
setExpanded((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(component.name)) next.delete(component.name);
|
||||
else next.add(component.name);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{isExpanded ? "Hide API" : "View API"}
|
||||
</button>
|
||||
</div>
|
||||
{(component.description || component.deniedReason) && (
|
||||
<p className={component.safety === "denied" ? styles.deniedReason : styles.description}>
|
||||
{component.deniedReason ?? component.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isExpanded && (
|
||||
<ComponentDetails
|
||||
id={`${componentAnchor(component.name)}-details`}
|
||||
component={component}
|
||||
types={catalog.types}
|
||||
/>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visibleCount < filteredComponents.length && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.showMoreButton}
|
||||
onClick={() => setVisibleCount((current) => current + PAGE_SIZE)}
|
||||
>
|
||||
Show {Math.min(PAGE_SIZE, filteredComponents.length - visibleCount)} more
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function decodeLocationHash(hash: string) {
|
||||
const value = hash.slice(1);
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function ComponentDetails({ id, component, types }: { id: string; component: CatalogComponent; types: string[] }) {
|
||||
return (
|
||||
<div className={styles.details} id={id}>
|
||||
<div className={styles.detailsGrid}>
|
||||
<section>
|
||||
<h5>Usage example</h5>
|
||||
<pre className={styles.codeBlock}>
|
||||
<code>{buildUsageExample(component, types)}</code>
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h5>Component contract</h5>
|
||||
{component.bind ? (
|
||||
<p>
|
||||
<code>bind</code> stores a temporary <code>{component.bind.type}</code> input. Initialize it with{" "}
|
||||
<code>{component.bind.initialProp}</code>.
|
||||
</p>
|
||||
) : (
|
||||
<p>This component does not expose a temporary-input adapter.</p>
|
||||
)}
|
||||
{component.subcomponents.length > 0 && (
|
||||
<p>
|
||||
<strong>Subcomponents:</strong>{" "}
|
||||
{component.subcomponents.map((name, index) => (
|
||||
<span key={name}>
|
||||
{index > 0 && ", "}
|
||||
<code>{name}</code>
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
{component.blockedProps.length > 0 && (
|
||||
<ul className={styles.a11yList}>
|
||||
{component.blockedProps.map((prop) => (
|
||||
<li key={prop.name}>
|
||||
<code>{prop.name}</code> is blocked: {prop.reason}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{component.accessibilityRequirements.length > 0 && (
|
||||
<ul className={styles.a11yList}>
|
||||
{component.accessibilityRequirements.map((requirement) => (
|
||||
<li key={requirement}>{requirement}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<a href={component.documentationUrl} target="_blank" rel="noreferrer">
|
||||
Open upstream documentation
|
||||
</a>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h5>
|
||||
Component props <span className={styles.muted}>({component.props.length})</span>
|
||||
</h5>
|
||||
{component.props.length === 0 ? (
|
||||
<p>No component-specific props are exposed.</p>
|
||||
) : (
|
||||
<PropTable props={component.props} types={types} />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<details className={styles.rawComponent}>
|
||||
<summary>Raw component JSON</summary>
|
||||
<pre className={styles.codeBlock}>
|
||||
<code>{JSON.stringify(component, null, 2)}</code>
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PropTable({ props, types }: { props: CatalogProp[]; types: string[] }) {
|
||||
return (
|
||||
<div className={styles.tableScroll}>
|
||||
<table className={styles.propsTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Prop</th>
|
||||
<th>Type</th>
|
||||
<th>Required</th>
|
||||
<th>Known values</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{props.map((prop) => (
|
||||
<tr key={prop.name}>
|
||||
<td>
|
||||
<code>{prop.name}</code>
|
||||
</td>
|
||||
<td>
|
||||
<code className={styles.type}>{types[prop.typeRef] ?? "unknown"}</code>
|
||||
</td>
|
||||
<td>{prop.required ? <strong>Yes</strong> : "No"}</td>
|
||||
<td>
|
||||
{prop.literalValues?.length ? (
|
||||
<code>{prop.literalValues.map(formatLiteral).join(" | ")}</code>
|
||||
) : (
|
||||
<span className={styles.muted}>—</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{prop.description ?? <span className={styles.muted}>—</span>}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function componentAnchor(name: string) {
|
||||
return `custom-jsx-component-${name
|
||||
.replaceAll(".", "-")
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
||||
.toLowerCase()}`;
|
||||
}
|
||||
|
||||
function humanize(value: string) {
|
||||
return value.replaceAll("-", " ").replace(/\b\w/g, (character) => character.toUpperCase());
|
||||
}
|
||||
|
||||
function formatLiteral(value: string | number | boolean | null) {
|
||||
return typeof value === "string" ? JSON.stringify(value) : String(value);
|
||||
}
|
||||
|
||||
function buildUsageExample(component: CatalogComponent, types: string[]) {
|
||||
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)
|
||||
.map((prop) => `${prop.name}=${examplePropValue(prop, types[prop.typeRef] ?? "unknown")}`);
|
||||
if (component.bind) {
|
||||
props.unshift(
|
||||
`bind="value"`,
|
||||
`${component.bind.initialProp}=${bindingInitialValue(component.bind.type, component.bind.initialProp)}`,
|
||||
);
|
||||
}
|
||||
const propText = props.length > 0 ? ` ${props.join(" ")}` : "";
|
||||
return `<${component.name}${propText} />`;
|
||||
}
|
||||
|
||||
function bindingInitialValue(type: ComponentBindingType, initialProp: "defaultValue" | "defaultChecked") {
|
||||
if (initialProp === "defaultChecked") return "{false}";
|
||||
if (type === "string") return '""';
|
||||
if (type === "number") return "{0}";
|
||||
if (type === "boolean") return "{false}";
|
||||
return "{[]}";
|
||||
}
|
||||
|
||||
function examplePropValue(prop: CatalogProp, type: string) {
|
||||
const literal = prop.literalValues?.[0];
|
||||
if (literal !== undefined) return `{${formatLiteral(literal)}}`;
|
||||
if (type.includes("string")) return `"${prop.name}"`;
|
||||
if (type.includes("boolean")) return "{true}";
|
||||
if (type.includes("number")) return "{0}";
|
||||
if (type.includes("[]") || type.includes("Array<")) return "{[]}";
|
||||
return `{options.${prop.name}}`;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useState } from "react";
|
||||
import { MantineProvider, Select, Stack, Text } from "@mantine/core";
|
||||
/* oxlint-disable import/no-unassigned-import -- Mantine component styles intentionally load for side effects. */
|
||||
import "@mantine/core/styles/Badge.css";
|
||||
import "@mantine/core/styles/Button.css";
|
||||
import "@mantine/core/styles/Combobox.css";
|
||||
import "@mantine/core/styles/Group.css";
|
||||
import "@mantine/core/styles/Input.css";
|
||||
import "@mantine/core/styles/Loader.css";
|
||||
import "@mantine/core/styles/Pill.css";
|
||||
import "@mantine/core/styles/PillsInput.css";
|
||||
import "@mantine/core/styles/Popover.css";
|
||||
import "@mantine/core/styles/ScrollArea.css";
|
||||
import "@mantine/core/styles/Stack.css";
|
||||
import "@mantine/core/styles/Text.css";
|
||||
import "@mantine/core/styles/UnstyledButton.css";
|
||||
/* oxlint-enable import/no-unassigned-import */
|
||||
|
||||
import {
|
||||
BUNDLED_CUSTOM_WIDGETS,
|
||||
CUSTOM_WIDGET_OPTIONS_EXAMPLES,
|
||||
CUSTOM_WIDGET_REQUEST_EXAMPLES,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import type { CustomWidgetEditorMessages } from "@homarr/custom-widgets/workbench";
|
||||
import { ReadOnlyCustomWidgetCode } from "@homarr/custom-widgets/workbench";
|
||||
|
||||
import type { CanonicalCustomWidgetExampleProps, CustomWidgetCodeExampleProps } from "./custom-widget-code";
|
||||
|
||||
const messages: CustomWidgetEditorMessages = {
|
||||
languageJsx: "JSX",
|
||||
languageJson: "JSON",
|
||||
undo: "Undo",
|
||||
redo: "Redo",
|
||||
components: "Components",
|
||||
componentSearch: "Search components",
|
||||
componentEmpty: "No matching components",
|
||||
componentCount: (count) => `${count} components`,
|
||||
insertStarter: "Insert starter",
|
||||
format: "Format",
|
||||
copy: "Copy",
|
||||
copied: "Copied",
|
||||
schema: "Schema",
|
||||
schemaTab: "JSON Schema",
|
||||
minimalTab: "Minimal example",
|
||||
fullTab: "Full example",
|
||||
errors: (count) => `${count} errors`,
|
||||
warnings: (count) => `${count} warnings`,
|
||||
ready: "Read-only example",
|
||||
position: ({ line, column }) => `Ln ${line}, Col ${column}`,
|
||||
characters: (count, limit) => (limit ? `${count} / ${limit}` : `${count} characters`),
|
||||
diagnosticsTitle: "Diagnostics",
|
||||
diagnostic: (diagnostic) => diagnostic.value ?? diagnostic.code,
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
return <CustomWidgetCodeExampleClient id={id} label={label} code={JSON.stringify(value, null, 2)} height={height} />;
|
||||
}
|
||||
|
||||
export function CustomWidgetCodeExampleClient(props: CustomWidgetCodeExampleProps) {
|
||||
return (
|
||||
<MantineProvider forceColorScheme={getDocumentationColorScheme()}>
|
||||
<ReadOnlyCustomWidgetCode
|
||||
id={props.id}
|
||||
label={props.label}
|
||||
language={props.language ?? "json"}
|
||||
value={props.code}
|
||||
messages={messages}
|
||||
height={props.height}
|
||||
/>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function BundledCustomWidgetGalleryClient() {
|
||||
const [selectedId, setSelectedId] = useState(BUNDLED_CUSTOM_WIDGETS[0]?.id ?? null);
|
||||
const selected = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === selectedId) ?? BUNDLED_CUSTOM_WIDGETS[0];
|
||||
if (!selected) return null;
|
||||
const manifest = { ...selected.widget, template: "__HOMARR_TEMPLATE__" };
|
||||
|
||||
return (
|
||||
<MantineProvider forceColorScheme={getDocumentationColorScheme()}>
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
label="Bundled widget"
|
||||
data={BUNDLED_CUSTOM_WIDGETS.map(({ id, widget }) => ({ value: id, label: widget.name }))}
|
||||
value={selected.id}
|
||||
onChange={(value) => {
|
||||
if (value) setSelectedId(value);
|
||||
}}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{selected.widget.description}
|
||||
</Text>
|
||||
<ReadOnlyCustomWidgetCode
|
||||
id={`${selected.id}-manifest`}
|
||||
label="widget.json"
|
||||
language="json"
|
||||
value={JSON.stringify(manifest, null, 2)}
|
||||
messages={messages}
|
||||
height="340px"
|
||||
/>
|
||||
<ReadOnlyCustomWidgetCode
|
||||
id={`${selected.id}-template`}
|
||||
label="widget.jsx"
|
||||
language="jsx"
|
||||
value={selected.widget.template}
|
||||
messages={messages}
|
||||
height="340px"
|
||||
/>
|
||||
</Stack>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function getDocumentationColorScheme() {
|
||||
return document.documentElement.dataset.theme === "dark" ? "dark" : "light";
|
||||
}
|
||||
@@ -1,57 +1,7 @@
|
||||
import BrowserOnly from "@docusaurus/BrowserOnly";
|
||||
import { useState } from "react";
|
||||
import { MantineProvider, Select, Stack, Text } from "@mantine/core";
|
||||
/* oxlint-disable import/no-unassigned-import -- Mantine component styles intentionally load for side effects. */
|
||||
import "@mantine/core/styles/Badge.css";
|
||||
import "@mantine/core/styles/Button.css";
|
||||
import "@mantine/core/styles/Combobox.css";
|
||||
import "@mantine/core/styles/Group.css";
|
||||
import "@mantine/core/styles/Input.css";
|
||||
import "@mantine/core/styles/Loader.css";
|
||||
import "@mantine/core/styles/Pill.css";
|
||||
import "@mantine/core/styles/PillsInput.css";
|
||||
import "@mantine/core/styles/Popover.css";
|
||||
import "@mantine/core/styles/ScrollArea.css";
|
||||
import "@mantine/core/styles/Stack.css";
|
||||
import "@mantine/core/styles/Text.css";
|
||||
import "@mantine/core/styles/UnstyledButton.css";
|
||||
/* oxlint-enable import/no-unassigned-import */
|
||||
import { lazy, Suspense } from "react";
|
||||
|
||||
import {
|
||||
BUNDLED_CUSTOM_WIDGETS,
|
||||
CUSTOM_WIDGET_OPTIONS_EXAMPLES,
|
||||
CUSTOM_WIDGET_REQUEST_EXAMPLES,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import type { CustomWidgetEditorMessages } from "@homarr/custom-widgets/workbench";
|
||||
import { ReadOnlyCustomWidgetCode } from "@homarr/custom-widgets/workbench";
|
||||
|
||||
const messages: CustomWidgetEditorMessages = {
|
||||
languageJsx: "JSX",
|
||||
languageJson: "JSON",
|
||||
undo: "Undo",
|
||||
redo: "Redo",
|
||||
components: "Components",
|
||||
componentSearch: "Search components",
|
||||
componentEmpty: "No matching components",
|
||||
componentCount: (count) => `${count} components`,
|
||||
insertStarter: "Insert starter",
|
||||
format: "Format",
|
||||
copy: "Copy",
|
||||
copied: "Copied",
|
||||
schema: "Schema",
|
||||
schemaTab: "JSON Schema",
|
||||
minimalTab: "Minimal example",
|
||||
fullTab: "Full example",
|
||||
errors: (count) => `${count} errors`,
|
||||
warnings: (count) => `${count} warnings`,
|
||||
ready: "Read-only example",
|
||||
position: ({ line, column }) => `Ln ${line}, Col ${column}`,
|
||||
characters: (count, limit) => (limit ? `${count} / ${limit}` : `${count} characters`),
|
||||
diagnosticsTitle: "Diagnostics",
|
||||
diagnostic: (diagnostic) => diagnostic.value ?? diagnostic.code,
|
||||
};
|
||||
|
||||
interface CustomWidgetCodeExampleProps {
|
||||
export interface CustomWidgetCodeExampleProps {
|
||||
id: string;
|
||||
label: string;
|
||||
code: string;
|
||||
@@ -59,40 +9,47 @@ interface CustomWidgetCodeExampleProps {
|
||||
height?: string;
|
||||
}
|
||||
|
||||
export function CanonicalCustomWidgetExample({
|
||||
id,
|
||||
label,
|
||||
example,
|
||||
height,
|
||||
}: {
|
||||
export interface CanonicalCustomWidgetExampleProps {
|
||||
id: string;
|
||||
label: string;
|
||||
example: "requests" | "optionsSchema" | "defaultOptions";
|
||||
height?: string;
|
||||
}) {
|
||||
const value =
|
||||
example === "requests"
|
||||
? CUSTOM_WIDGET_REQUEST_EXAMPLES.full
|
||||
: example === "optionsSchema"
|
||||
? CUSTOM_WIDGET_OPTIONS_EXAMPLES.full.schema
|
||||
: CUSTOM_WIDGET_OPTIONS_EXAMPLES.full.defaults;
|
||||
return <CustomWidgetCodeExample id={id} label={label} code={JSON.stringify(value, null, 2)} height={height} />;
|
||||
}
|
||||
|
||||
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>
|
||||
)}
|
||||
</BrowserOnly>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomWidgetCodeExample(props: CustomWidgetCodeExampleProps) {
|
||||
return (
|
||||
<BrowserOnly fallback={<div style={{ minHeight: props.height ?? "220px" }} />}>
|
||||
<BrowserOnly fallback={<CodeExampleFallback height={props.height} />}>
|
||||
{() => (
|
||||
<MantineProvider forceColorScheme={getDocumentationColorScheme()}>
|
||||
<ReadOnlyCustomWidgetCode
|
||||
id={props.id}
|
||||
label={props.label}
|
||||
language={props.language ?? "json"}
|
||||
value={props.code}
|
||||
messages={messages}
|
||||
height={props.height}
|
||||
/>
|
||||
</MantineProvider>
|
||||
<Suspense fallback={<CodeExampleFallback height={props.height} />}>
|
||||
<LazyCustomWidgetCodeExample {...props} />
|
||||
</Suspense>
|
||||
)}
|
||||
</BrowserOnly>
|
||||
);
|
||||
@@ -100,53 +57,16 @@ export function CustomWidgetCodeExample(props: CustomWidgetCodeExampleProps) {
|
||||
|
||||
export function BundledCustomWidgetGallery() {
|
||||
return (
|
||||
<BrowserOnly fallback={<div style={{ minHeight: "520px" }} />}>
|
||||
{() => <BundledCustomWidgetGalleryClient />}
|
||||
<BrowserOnly fallback={<CodeExampleFallback height="520px" />}>
|
||||
{() => (
|
||||
<Suspense fallback={<CodeExampleFallback height="520px" />}>
|
||||
<LazyBundledCustomWidgetGallery />
|
||||
</Suspense>
|
||||
)}
|
||||
</BrowserOnly>
|
||||
);
|
||||
}
|
||||
|
||||
function BundledCustomWidgetGalleryClient() {
|
||||
const [selectedId, setSelectedId] = useState(BUNDLED_CUSTOM_WIDGETS[0]?.id ?? null);
|
||||
const selected = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === selectedId) ?? BUNDLED_CUSTOM_WIDGETS[0];
|
||||
if (!selected) return null;
|
||||
const manifest = { ...selected.widget, template: "__HOMARR_TEMPLATE__" };
|
||||
return (
|
||||
<MantineProvider forceColorScheme={getDocumentationColorScheme()}>
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
label="Bundled widget"
|
||||
data={BUNDLED_CUSTOM_WIDGETS.map(({ id, widget }) => ({ value: id, label: widget.name }))}
|
||||
value={selected.id}
|
||||
onChange={(value) => {
|
||||
if (value) setSelectedId(value);
|
||||
}}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{selected.widget.description}
|
||||
</Text>
|
||||
<ReadOnlyCustomWidgetCode
|
||||
id={`${selected.id}-manifest`}
|
||||
label="widget.json"
|
||||
language="json"
|
||||
value={JSON.stringify(manifest, null, 2)}
|
||||
messages={messages}
|
||||
height="340px"
|
||||
/>
|
||||
<ReadOnlyCustomWidgetCode
|
||||
id={`${selected.id}-template`}
|
||||
label="widget.jsx"
|
||||
language="jsx"
|
||||
value={selected.widget.template}
|
||||
messages={messages}
|
||||
height="340px"
|
||||
/>
|
||||
</Stack>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function getDocumentationColorScheme() {
|
||||
return document.documentElement.dataset.theme === "dark" ? "dark" : "light";
|
||||
function CodeExampleFallback({ height }: { height?: string }) {
|
||||
return <div style={{ minHeight: height ?? "220px" }} aria-busy="true" />;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { WorkshopAdminAction, WorkshopReport, WorkshopSubmissionSummary, WorkshopUser } from "@homarr/workshop";
|
||||
import type { WorkshopReport, WorkshopSubmissionSummary, WorkshopUser } from "@homarr/workshop";
|
||||
import { WorkshopClient } from "@homarr/workshop";
|
||||
|
||||
import styles from "./workshop.module.css";
|
||||
@@ -12,34 +12,61 @@ export function WorkshopAdmin() {
|
||||
const [user, setUser] = useState<WorkshopUser | null>(null);
|
||||
const [reports, setReports] = useState<WorkshopReport[]>([]);
|
||||
const [submissions, setSubmissions] = useState<WorkshopSubmissionSummary[]>([]);
|
||||
const [actions, setActions] = useState<WorkshopAdminAction[]>([]);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [nextReports, nextSubmissions, nextActions] = await Promise.all([
|
||||
const [nextReports, nextSubmissions] = await Promise.all([
|
||||
client.listReports(),
|
||||
client.listAll({ sort: "newest" }),
|
||||
client.listAdminActions(),
|
||||
]);
|
||||
setReports(nextReports);
|
||||
setSubmissions(nextSubmissions);
|
||||
setActions(nextActions);
|
||||
setError("");
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Unable to load Workshop administration");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [client]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = client.subscribeToAuth(setUser);
|
||||
void client.refreshAuth().then((next) => {
|
||||
setUser(next);
|
||||
if (next?.isAdmin) void load();
|
||||
void client.refreshAuth().then((nextUser) => {
|
||||
setUser(nextUser);
|
||||
if (nextUser?.isAdmin) void load();
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [client, load]);
|
||||
|
||||
const dismiss = async (reportId: string) => {
|
||||
setBusyId(reportId);
|
||||
try {
|
||||
await client.dismissReport(reportId);
|
||||
await load();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Unable to dismiss report");
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (submission: WorkshopSubmissionSummary) => {
|
||||
if (!window.confirm(`Delete ${submission.title}? Installed local copies will remain.`)) return;
|
||||
setBusyId(submission.id);
|
||||
try {
|
||||
await client.delete(submission.id);
|
||||
await load();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Unable to delete submission");
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const grouped = submissions.map((submission) => ({
|
||||
submission,
|
||||
reports: reports.filter((report) => report.submission === submission.id),
|
||||
@@ -49,51 +76,74 @@ export function WorkshopAdmin() {
|
||||
<main className={styles.shell}>
|
||||
<section className={styles.hero}>
|
||||
<h1>Workshop administration</h1>
|
||||
<p>
|
||||
Review reports, see each reporter and vote totals, dismiss reports, or delete any submission. This page does
|
||||
not manage user accounts.
|
||||
</p>
|
||||
{!user && (
|
||||
<button
|
||||
className={styles.button}
|
||||
onClick={() =>
|
||||
void client.signInWithGitHub().then((next) => {
|
||||
setUser(next);
|
||||
if (next?.isAdmin) void load();
|
||||
})
|
||||
}
|
||||
>
|
||||
Sign in with GitHub
|
||||
</button>
|
||||
)}
|
||||
<p>Review community reports and remove submissions. PocketBase rules enforce every moderation action.</p>
|
||||
<div className={styles.actions}>
|
||||
<a className={styles.secondary} href="/workshop">
|
||||
Back to Workshop
|
||||
</a>
|
||||
{!user && (
|
||||
<button
|
||||
className={styles.button}
|
||||
onClick={() =>
|
||||
void client
|
||||
.signInWithGitHub()
|
||||
.then((nextUser) => {
|
||||
setUser(nextUser);
|
||||
if (nextUser?.isAdmin) void load();
|
||||
})
|
||||
.catch((cause: Error) => setError(cause.message))
|
||||
}
|
||||
>
|
||||
Sign in with GitHub
|
||||
</button>
|
||||
)}
|
||||
{user?.isAdmin && (
|
||||
<button className={styles.secondary} disabled={loading} onClick={() => void load()}>
|
||||
{loading ? "Refreshing…" : "Refresh"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{user && !user.isAdmin && <div className={styles.error}>This Workshop account is not an administrator.</div>}
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
{user?.isAdmin && (
|
||||
<div className={styles.stack} style={{ marginTop: "2rem" }}>
|
||||
<h2>Submissions and open reports</h2>
|
||||
<section className={styles.adminList} aria-busy={loading}>
|
||||
<div className={styles.adminSummary}>
|
||||
<strong>{reports.length} open reports</strong>
|
||||
<span>{submissions.length} published widgets</span>
|
||||
</div>
|
||||
{grouped.map(({ submission, reports: submissionReports }) => (
|
||||
<section key={submission.id} className={styles.adminGroup}>
|
||||
<div className={styles.meta}>
|
||||
<strong>{submission.title}</strong>
|
||||
<span>
|
||||
▲ {submission.upvotes} · ▼ {submission.downvotes} · score {submission.score} ·{" "}
|
||||
{submissionReports.length} reports
|
||||
<article key={submission.id} className={styles.adminGroup}>
|
||||
<div className={styles.adminHeading}>
|
||||
<div>
|
||||
<h2>{submission.title}</h2>
|
||||
<span>by {submission.authorName}</span>
|
||||
</div>
|
||||
<span className={submissionReports.length > 0 ? styles.reportCount : undefined}>
|
||||
{submissionReports.length} {submissionReports.length === 1 ? "report" : "reports"}
|
||||
</span>
|
||||
</div>
|
||||
<p>{submission.description}</p>
|
||||
<p>{submission.description || "No description"}</p>
|
||||
<div className={styles.meta}>
|
||||
<span>
|
||||
▲ {submission.upvotes} · ▼ {submission.downvotes} · score {submission.score}
|
||||
</span>
|
||||
<span>Updated {new Date(submission.updated).toLocaleDateString()}</span>
|
||||
</div>
|
||||
{submissionReports.map((report) => (
|
||||
<div key={report.id} className={styles.report}>
|
||||
<strong>{report.category}</strong> by {report.reporterName}
|
||||
<div key={report.id} className={styles.moderationReport}>
|
||||
<div>
|
||||
<strong>{report.category}</strong>
|
||||
<span>Reported by {report.reporterName}</span>
|
||||
</div>
|
||||
<p>{report.explanation}</p>
|
||||
<button
|
||||
className={styles.secondary}
|
||||
onClick={() => {
|
||||
const reason = window.prompt("Why is this report being dismissed?")?.trim();
|
||||
if (reason) void client.dismissReport(report.id, reason).then(load);
|
||||
}}
|
||||
disabled={busyId === report.id}
|
||||
onClick={() => void dismiss(report.id)}
|
||||
>
|
||||
Dismiss report
|
||||
{busyId === report.id ? "Dismissing…" : "Dismiss report"}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
@@ -103,26 +153,16 @@ export function WorkshopAdmin() {
|
||||
</a>
|
||||
<button
|
||||
className={styles.danger}
|
||||
onClick={() => {
|
||||
if (window.confirm(`Delete ${submission.title}? Installed local copies will remain.`))
|
||||
void client.delete(submission.id, "Deleted after administrator review").then(load);
|
||||
}}
|
||||
disabled={busyId === submission.id}
|
||||
onClick={() => void remove(submission)}
|
||||
>
|
||||
Delete submission
|
||||
{busyId === submission.id ? "Deleting…" : "Delete submission"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
))}
|
||||
<h2>Administrator audit trail</h2>
|
||||
{actions.map((action) => (
|
||||
<div key={action.id} className={`${styles.adminGroup} ${styles.audit}`}>
|
||||
{new Date(action.created).toLocaleString()} · {action.actorName} · {action.action} · submission{" "}
|
||||
{action.submissionId || "—"} · report {action.reportId || "—"}
|
||||
<br />
|
||||
{action.reason}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!loading && grouped.length === 0 && <p className={styles.empty}>No Workshop submissions.</p>}
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -6,16 +6,12 @@ import type {
|
||||
WorkshopSubmissionSummary,
|
||||
WorkshopUser,
|
||||
} from "@homarr/workshop";
|
||||
import { validateWorkshopWidget, WorkshopClient } from "@homarr/workshop";
|
||||
import { validateWorkshopWidget, WorkshopClient, workshopExportFilename } from "@homarr/workshop";
|
||||
|
||||
import { CustomWidgetCodeExample } from "../custom-widget-code";
|
||||
import styles from "./workshop.module.css";
|
||||
|
||||
const emptyDraft: WorkshopSubmissionInput = {
|
||||
title: "",
|
||||
description: "",
|
||||
content: "",
|
||||
changelog: "Initial publication",
|
||||
};
|
||||
const emptyDraft: WorkshopSubmissionInput = { title: "", description: "", content: "" };
|
||||
|
||||
export function WorkshopPage() {
|
||||
const client = useMemo(
|
||||
@@ -24,9 +20,10 @@ export function WorkshopPage() {
|
||||
);
|
||||
const [user, setUser] = useState<WorkshopUser | null>(null);
|
||||
const [items, setItems] = useState<WorkshopSubmissionSummary[]>([]);
|
||||
const [reports, setReports] = useState<WorkshopReport[]>([]);
|
||||
const [selected, setSelected] = useState<WorkshopSubmissionDetail | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [sort, setSort] = useState<"top" | "newest">("top");
|
||||
const [sort, setSort] = useState<"top" | "newest" | "reported">("top");
|
||||
const [draft, setDraft] = useState<WorkshopSubmissionInput>(emptyDraft);
|
||||
const [screenshots, setScreenshots] = useState<File[]>([]);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
@@ -35,15 +32,34 @@ export function WorkshopPage() {
|
||||
const [reportText, setReportText] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const draftValidation = useMemo(() => validateWorkshopWidget(draft.content), [draft.content]);
|
||||
const reportCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
reports.forEach((report) => counts.set(report.submission, (counts.get(report.submission) ?? 0) + 1));
|
||||
return counts;
|
||||
}, [reports]);
|
||||
const selectedReports = selected ? reports.filter((report) => report.submission === selected.id) : [];
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setItems((await client.list({ search, sort, perPage: 60 })).items);
|
||||
const [nextReports, nextItems] = await Promise.all([
|
||||
user?.isAdmin ? client.listReports() : Promise.resolve([]),
|
||||
sort === "reported" && user?.isAdmin
|
||||
? client.listAll({ search, sort: "newest" })
|
||||
: client.list({ search, sort: sort === "newest" ? "newest" : "top", perPage: 60 }).then((page) => page.items),
|
||||
]);
|
||||
if (sort === "reported") {
|
||||
const counts = new Map<string, number>();
|
||||
nextReports.forEach((report) => counts.set(report.submission, (counts.get(report.submission) ?? 0) + 1));
|
||||
nextItems.sort((a, b) => (counts.get(b.id) ?? 0) - (counts.get(a.id) ?? 0));
|
||||
}
|
||||
setReports(nextReports);
|
||||
setItems(nextItems);
|
||||
setError("");
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Workshop is unavailable");
|
||||
}
|
||||
}, [client, search, sort]);
|
||||
}, [client, search, sort, user?.isAdmin]);
|
||||
|
||||
const openDetail = useCallback(
|
||||
async (id: string) => {
|
||||
@@ -73,7 +89,15 @@ export function WorkshopPage() {
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.isAdmin && sort === "reported") setSort("top");
|
||||
}, [sort, user?.isAdmin]);
|
||||
|
||||
const save = async () => {
|
||||
if (!draftValidation.success) {
|
||||
setError(draftValidation.error);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
@@ -82,6 +106,7 @@ export function WorkshopPage() {
|
||||
setEditorOpen(false);
|
||||
setDraft(emptyDraft);
|
||||
setScreenshots([]);
|
||||
setSelected(null);
|
||||
await load();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Unable to publish");
|
||||
@@ -90,30 +115,72 @@ export function WorkshopPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const edit = () => {
|
||||
const vote = async (value: 1 | -1) => {
|
||||
if (!selected) return;
|
||||
setDraft({
|
||||
title: selected.title,
|
||||
description: selected.description,
|
||||
content: selected.content,
|
||||
changelog: selected.changelog,
|
||||
});
|
||||
setEditorOpen(true);
|
||||
setError("");
|
||||
try {
|
||||
await client.vote(selected.id, value);
|
||||
await Promise.all([openDetail(selected.id), load()]);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Unable to save vote");
|
||||
}
|
||||
};
|
||||
|
||||
const dismissReport = async (id: string) => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await client.dismissReport(id);
|
||||
await load();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Unable to dismiss report");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteSubmission = async () => {
|
||||
if (!selected || !window.confirm(`Delete ${selected.title}? Installed copies will remain.`)) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await client.delete(selected.id);
|
||||
setSelected(null);
|
||||
await load();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Unable to delete submission");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submitReport = async () => {
|
||||
if (!selected || reportText.trim().length < 3) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await client.report(selected.id, reportCategory, reportText);
|
||||
setReportOpen(false);
|
||||
setReportText("");
|
||||
await load();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Unable to report submission");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className={styles.shell}>
|
||||
<section className={styles.hero}>
|
||||
<h1>Workshop</h1>
|
||||
<p>
|
||||
Discover and publish Custom JSX v2 widgets. Every submission is public immediately; the community can vote and
|
||||
report unsafe content.
|
||||
</p>
|
||||
<p>Discover, inspect, and publish Custom JSX v2 widgets. Submissions are public immediately.</p>
|
||||
<div className={styles.actions}>
|
||||
{user ? (
|
||||
<>
|
||||
<span>
|
||||
Signed in as <strong>{user.displayName}</strong>
|
||||
{user.isAdmin && <span className={styles.adminBadge}>Admin</span>}
|
||||
</span>
|
||||
<button
|
||||
className={styles.secondary}
|
||||
@@ -129,11 +196,18 @@ export function WorkshopPage() {
|
||||
onClick={() => {
|
||||
setSelected(null);
|
||||
setDraft(emptyDraft);
|
||||
setScreenshots([]);
|
||||
setError("");
|
||||
setEditorOpen(true);
|
||||
}}
|
||||
>
|
||||
Publish widget
|
||||
</button>
|
||||
{user.isAdmin && (
|
||||
<a className={styles.secondary} href="/workshop/admin">
|
||||
Review reports
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
@@ -150,8 +224,10 @@ export function WorkshopPage() {
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className={styles.notice}>
|
||||
Inspect API origins, methods, permissions, and JSX before importing. Workshop never stores widget credentials.
|
||||
Inspect API origins, methods, permissions, and JSX before importing. Widget exports must not contain
|
||||
credentials.
|
||||
</div>
|
||||
<div className={styles.toolbar}>
|
||||
<input
|
||||
@@ -165,42 +241,54 @@ export function WorkshopPage() {
|
||||
className={styles.select}
|
||||
style={{ width: 180 }}
|
||||
value={sort}
|
||||
onChange={(event) => setSort(event.currentTarget.value as "top" | "newest")}
|
||||
onChange={(event) => {
|
||||
const value = event.currentTarget.value;
|
||||
setSort(value === "reported" && user?.isAdmin ? "reported" : value === "newest" ? "newest" : "top");
|
||||
}}
|
||||
>
|
||||
<option value="top">Top rated</option>
|
||||
<option value="newest">Newest</option>
|
||||
{user?.isAdmin && <option value="reported">Most reported</option>}
|
||||
</select>
|
||||
</div>
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
<div className={styles.grid}>
|
||||
{items.map((item) => (
|
||||
<article key={item.id} className={styles.card}>
|
||||
{item.screenshots[0] ? (
|
||||
<img
|
||||
className={styles.previewImage}
|
||||
src={client.fileUrl(item.id, item.screenshots[0], "480x320")}
|
||||
alt=""
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.preview}>JSX</div>
|
||||
)}
|
||||
<div className={styles.cardBody}>
|
||||
<div className={styles.meta}>
|
||||
<span>Revision {item.revision}</span>
|
||||
<span>
|
||||
▲ {item.upvotes} · ▼ {item.downvotes}
|
||||
</span>
|
||||
{items.map((item) => {
|
||||
const reportCount = reportCounts.get(item.id) ?? 0;
|
||||
return (
|
||||
<article key={item.id} className={`${styles.card} ${reportCount ? styles.reportedCard : ""}`}>
|
||||
{item.screenshots[0] ? (
|
||||
<img
|
||||
className={styles.previewImage}
|
||||
src={client.fileUrl(item.id, item.screenshots[0], "480x320")}
|
||||
alt=""
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.preview}>JSX</div>
|
||||
)}
|
||||
<div className={styles.cardBody}>
|
||||
<div className={styles.meta}>
|
||||
<span>by {item.authorName}</span>
|
||||
<span>
|
||||
▲ {item.upvotes} · ▼ {item.downvotes}
|
||||
</span>
|
||||
</div>
|
||||
{user?.isAdmin && reportCount > 0 && (
|
||||
<span className={styles.reportCount}>
|
||||
{reportCount} {reportCount === 1 ? "report" : "reports"}
|
||||
</span>
|
||||
)}
|
||||
<h2>{item.title}</h2>
|
||||
<p>{item.description || "No description"}</p>
|
||||
<button className={styles.secondary} disabled={busy} onClick={() => void openDetail(item.id)}>
|
||||
Inspect widget
|
||||
</button>
|
||||
</div>
|
||||
<h2>{item.title}</h2>
|
||||
<p>{item.description || "No description"}</p>
|
||||
<small>by {item.authorName}</small>
|
||||
<button className={styles.secondary} disabled={busy} onClick={() => void openDetail(item.id)}>
|
||||
Inspect widget
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{items.length === 0 && <p className={styles.empty}>No widgets match this search.</p>}
|
||||
|
||||
{selected && (
|
||||
<div
|
||||
@@ -209,14 +297,41 @@ export function WorkshopPage() {
|
||||
onMouseDown={(event) => event.target === event.currentTarget && setSelected(null)}
|
||||
>
|
||||
<dialog open className={`${styles.dialog} ${styles.stack}`} aria-modal="true" aria-label={selected.title}>
|
||||
<button type="button" className={styles.dialogClose} aria-label="Close" onClick={() => setSelected(null)}>
|
||||
×
|
||||
</button>
|
||||
<h2>{selected.title}</h2>
|
||||
<p>{selected.description}</p>
|
||||
<div className={styles.meta}>
|
||||
<span>by {selected.authorName}</span>
|
||||
<span>
|
||||
by {selected.authorName} · revision {selected.revision}
|
||||
▲ {selected.upvotes} · ▼ {selected.downvotes}
|
||||
</span>
|
||||
<span>SHA-256 {selected.contentHash.slice(0, 12)}…</span>
|
||||
</div>
|
||||
{user?.isAdmin && selectedReports.length > 0 && (
|
||||
<section className={styles.moderation} aria-labelledby="submission-reports-title">
|
||||
<h3 id="submission-reports-title">
|
||||
{selectedReports.length} {selectedReports.length === 1 ? "report" : "reports"}
|
||||
</h3>
|
||||
{selectedReports.map((report) => (
|
||||
<div key={report.id} className={styles.moderationReport}>
|
||||
<div>
|
||||
<strong>{report.category}</strong>
|
||||
<span>Reported by {report.reporterName}</span>
|
||||
</div>
|
||||
<p>{report.explanation}</p>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.secondary}
|
||||
disabled={busy}
|
||||
onClick={() => void dismissReport(report.id)}
|
||||
>
|
||||
Dismiss report
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
{selected.screenshots.length > 0 && (
|
||||
<div className={styles.screenshots}>
|
||||
{selected.screenshots.map((file, index) => (
|
||||
@@ -229,52 +344,47 @@ export function WorkshopPage() {
|
||||
</div>
|
||||
)}
|
||||
<WidgetCapabilitySummary content={selected.content} />
|
||||
<pre className={styles.code}>{selected.content}</pre>
|
||||
<CustomWidgetCodeExample
|
||||
id={`workshop-submission-${selected.id}`}
|
||||
label="widget.json"
|
||||
code={selected.content}
|
||||
height="340px"
|
||||
/>
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
className={styles.secondary}
|
||||
disabled={!user}
|
||||
onClick={() =>
|
||||
void client
|
||||
.vote(selected.id, 1)
|
||||
.then(() => openDetail(selected.id))
|
||||
.then(load)
|
||||
}
|
||||
>
|
||||
<button className={styles.secondary} disabled={!user} onClick={() => void vote(1)}>
|
||||
▲ {selected.upvotes}
|
||||
</button>
|
||||
<button
|
||||
className={styles.secondary}
|
||||
disabled={!user}
|
||||
onClick={() =>
|
||||
void client
|
||||
.vote(selected.id, -1)
|
||||
.then(() => openDetail(selected.id))
|
||||
.then(load)
|
||||
}
|
||||
>
|
||||
<button className={styles.secondary} disabled={!user} onClick={() => void vote(-1)}>
|
||||
▼ {selected.downvotes}
|
||||
</button>
|
||||
<button className={styles.danger} disabled={!user} onClick={() => setReportOpen(true)}>
|
||||
<button
|
||||
className={styles.danger}
|
||||
disabled={!user}
|
||||
onClick={() => {
|
||||
setError("");
|
||||
setReportOpen(true);
|
||||
}}
|
||||
>
|
||||
Report
|
||||
</button>
|
||||
{selected.author === user?.id && (
|
||||
<>
|
||||
<button className={styles.secondary} onClick={edit}>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
className={styles.danger}
|
||||
onClick={() =>
|
||||
void client.delete(selected.id).then(() => {
|
||||
setSelected(null);
|
||||
return load();
|
||||
})
|
||||
}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
<button
|
||||
className={styles.secondary}
|
||||
onClick={() => {
|
||||
setDraft({ title: selected.title, description: selected.description, content: selected.content });
|
||||
setScreenshots([]);
|
||||
setError("");
|
||||
setEditorOpen(true);
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
{(selected.author === user?.id || user?.isAdmin) && (
|
||||
<button className={styles.danger} disabled={busy} onClick={() => void deleteSubmission()}>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={styles.button}
|
||||
@@ -282,16 +392,13 @@ export function WorkshopPage() {
|
||||
const url = URL.createObjectURL(new Blob([selected.content], { type: "application/json" }));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `${selected.title}.json`;
|
||||
link.download = workshopExportFilename(selected.title);
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}}
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
<button className={styles.secondary} onClick={() => setSelected(null)}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
@@ -325,14 +432,13 @@ export function WorkshopPage() {
|
||||
value={draft.content}
|
||||
onChange={(event) => setDraft({ ...draft, content: event.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
Changelog
|
||||
<textarea
|
||||
className={styles.textarea}
|
||||
value={draft.changelog}
|
||||
onChange={(event) => setDraft({ ...draft, changelog: event.currentTarget.value })}
|
||||
/>
|
||||
{!draft.content.trim() ? (
|
||||
<span className={styles.hint}>Paste a complete Custom Widget manifest.</span>
|
||||
) : draftValidation.success ? (
|
||||
<span className={styles.success}>Valid {draftValidation.data.$schema} manifest.</span>
|
||||
) : (
|
||||
<span className={styles.error}>{draftValidation.error}</span>
|
||||
)}
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
Screenshots
|
||||
@@ -343,14 +449,29 @@ export function WorkshopPage() {
|
||||
multiple
|
||||
onChange={(event) => setScreenshots(Array.from(event.currentTarget.files ?? []))}
|
||||
/>
|
||||
<span className={styles.hint}>Up to five images, 5 MiB each.</span>
|
||||
<span className={styles.hint}>
|
||||
{selected?.author === user?.id
|
||||
? "Choose files to replace the current screenshots, or leave this empty to keep them."
|
||||
: "Up to five images, 5 MiB each."}
|
||||
</span>
|
||||
</label>
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
<div className={styles.actions}>
|
||||
<button className={styles.button} disabled={busy || !user} onClick={() => void save()}>
|
||||
{busy ? "Publishing…" : "Publish immediately"}
|
||||
<button
|
||||
className={styles.button}
|
||||
disabled={busy || !user || !draftValidation.success}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{busy ? "Saving…" : selected?.author === user?.id ? "Save changes" : "Publish immediately"}
|
||||
</button>
|
||||
<button className={styles.secondary} onClick={() => setEditorOpen(false)}>
|
||||
<button
|
||||
className={styles.secondary}
|
||||
onClick={() => {
|
||||
setEditorOpen(false);
|
||||
setScreenshots([]);
|
||||
setError("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
@@ -382,16 +503,12 @@ export function WorkshopPage() {
|
||||
onChange={(event) => setReportText(event.currentTarget.value)}
|
||||
/>
|
||||
</label>
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
className={styles.danger}
|
||||
disabled={reportText.trim().length < 3}
|
||||
onClick={() =>
|
||||
void client.report(selected.id, reportCategory, reportText).then(() => {
|
||||
setReportOpen(false);
|
||||
setReportText("");
|
||||
})
|
||||
}
|
||||
disabled={busy || reportText.trim().length < 3}
|
||||
onClick={() => void submitReport()}
|
||||
>
|
||||
Send report
|
||||
</button>
|
||||
|
||||
@@ -60,6 +60,20 @@
|
||||
color: white;
|
||||
background: var(--ifm-color-danger-dark);
|
||||
}
|
||||
.button:hover:not(:disabled),
|
||||
.secondary:hover:not(:disabled),
|
||||
.danger:hover:not(:disabled) {
|
||||
filter: brightness(0.94);
|
||||
}
|
||||
.button:focus-visible,
|
||||
.secondary:focus-visible,
|
||||
.danger:focus-visible,
|
||||
.input:focus-visible,
|
||||
.textarea:focus-visible,
|
||||
.select:focus-visible {
|
||||
outline: 3px solid color-mix(in srgb, var(--ifm-color-primary) 45%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.button:disabled,
|
||||
.secondary:disabled,
|
||||
.danger:disabled {
|
||||
@@ -98,6 +112,11 @@
|
||||
grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.empty {
|
||||
margin: 3rem 0;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
text-align: center;
|
||||
}
|
||||
.card {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--ifm-color-emphasis-200);
|
||||
@@ -135,6 +154,29 @@
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.adminBadge,
|
||||
.reportCount {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.adminBadge {
|
||||
margin-left: 0.45rem;
|
||||
color: var(--ifm-color-primary-darkest);
|
||||
background: color-mix(in srgb, var(--ifm-color-primary) 16%, transparent);
|
||||
}
|
||||
.reportCount {
|
||||
color: var(--ifm-color-warning-darkest);
|
||||
background: color-mix(in srgb, var(--ifm-color-warning) 24%, transparent);
|
||||
}
|
||||
.reportedCard {
|
||||
border-color: var(--ifm-color-warning-dark);
|
||||
background: color-mix(in srgb, var(--ifm-color-warning) 5%, var(--ifm-card-background-color));
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
@@ -161,20 +203,37 @@
|
||||
}
|
||||
.dialog h2 {
|
||||
margin-top: 0;
|
||||
padding-right: 2.5rem;
|
||||
}
|
||||
.dialogClose {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0.5rem;
|
||||
color: var(--ifm-font-color-base);
|
||||
background: var(--ifm-color-emphasis-200);
|
||||
font: inherit;
|
||||
font-size: 1.35rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dialogClose:hover {
|
||||
background: var(--ifm-color-emphasis-300);
|
||||
}
|
||||
.dialogClose:focus-visible {
|
||||
outline: 3px solid color-mix(in srgb, var(--ifm-color-primary) 45%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.code {
|
||||
max-height: 340px;
|
||||
overflow: auto;
|
||||
padding: 1rem;
|
||||
border-radius: 0.65rem;
|
||||
background: var(--ifm-color-emphasis-100);
|
||||
white-space: pre;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.notice,
|
||||
.error,
|
||||
.success {
|
||||
@@ -186,6 +245,7 @@
|
||||
}
|
||||
.error {
|
||||
background: color-mix(in srgb, var(--ifm-color-danger) 20%, transparent);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.success {
|
||||
background: color-mix(in srgb, var(--ifm-color-success) 20%, transparent);
|
||||
@@ -214,22 +274,77 @@
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.adminGroup {
|
||||
.moderation {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 0.8rem;
|
||||
border: 1px solid var(--ifm-color-warning-dark);
|
||||
border-radius: 0.75rem;
|
||||
background: color-mix(in srgb, var(--ifm-color-warning) 10%, var(--ifm-background-color));
|
||||
}
|
||||
.report {
|
||||
padding-left: 0.75rem;
|
||||
border-left: 3px solid var(--ifm-color-warning);
|
||||
.moderation h3,
|
||||
.moderationReport p {
|
||||
margin: 0;
|
||||
}
|
||||
.audit {
|
||||
font-family: var(--ifm-font-family-monospace);
|
||||
font-size: 0.78rem;
|
||||
.moderationReport {
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid color-mix(in srgb, var(--ifm-color-warning) 35%, transparent);
|
||||
}
|
||||
.moderationReport > div {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
.moderationReport span {
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.moderationReport p {
|
||||
margin-top: 0.35rem;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.moderationReport > button {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.adminList {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
.adminSummary,
|
||||
.adminHeading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
.adminSummary {
|
||||
padding: 1rem;
|
||||
border-radius: 0.75rem;
|
||||
background: var(--ifm-color-emphasis-100);
|
||||
}
|
||||
.adminGroup {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--ifm-color-emphasis-200);
|
||||
border-radius: 0.9rem;
|
||||
background: var(--ifm-card-background-color);
|
||||
}
|
||||
.adminGroup h2,
|
||||
.adminGroup p {
|
||||
margin: 0;
|
||||
}
|
||||
.adminHeading > div {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.adminHeading span {
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.shell {
|
||||
width: min(100% - 1rem, 1180px);
|
||||
@@ -241,4 +356,14 @@
|
||||
.actions > * {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.moderationReport > div {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.adminSummary,
|
||||
.adminHeading {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +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\"","\"activity\" | \"display-none\"","\"all\" | \"parent\" | \"child\"","\"all\" | \"segment\"","\"always\" | \"hover\" | \"not-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\" | \"side\" | \"merge\"","\"dashed\" | \"dotted\" | \"solid\"","\"default\" | \"responsive\"","\"div\" | \"label\"","\"end\" | \"start\"","\"fixed\" | \"absolute\"","\"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\"","\"month\" | \"year\" | \"decade\"","\"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\"","\"off\" | \"assertive\" | \"polite\"","\"off\" | \"on\"","\"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\"","\"value\" | \"name\" | \"percent\"","\"viewport\"","\"wrap\" | \"nowrap\" | \"balance\" | \"pretty\" | \"stable\"","\"x\" | \"y\"","\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\"","\"yes\" | \"no\"","'Text' | 'Title' | 'Badge' | 'Code' | 'Image'","'auto' | 'manual'","'json' | 'text'","'xs' | 'sm' | 'md' | 'lg' | 'xl'","(\"input\" | \"label\" | \"description\" | \"error\")[]","(\"next\" | \"previous\" | \"level\")[]","(ActionIconProps & React.ClassAttributes<HTMLButtonElement> & React.ButtonHTMLAttributes<HTMLButtonElement>)","(CartesianChartProps<unknown> & { ref?: React.Ref<SVGSVGElement>; })","(CloseButtonProps & ElementProps<\"button\"> & DataAttributes)","(CloseButtonProps & React.ClassAttributes<HTMLButtonElement> & React.ButtonHTMLAttributes<HTMLButtonElement>)","(ElementProps<\"button\"> & DataAttributes)","(InputDescriptionProps & DataAttributes)","(InputErrorProps & DataAttributes)","(InputLabelProps & DataAttributes)","(InputSuccessProps & DataAttributes)","(OverlayProps & ElementProps<\"div\">)","(PolarChartProps<unknown> & { ref?: React.Ref<SVGSVGElement>; })","(React.ClassAttributes<HTMLButtonElement> & React.ButtonHTMLAttributes<HTMLButtonElement> & DataAttributes)","(React.ClassAttributes<HTMLDivElement> &Line truncated
|
||||
@@ -70,6 +70,7 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
|
||||
};
|
||||
|
||||
const AddMenu = () => {
|
||||
const board = useRequiredBoard();
|
||||
const { data: session } = useSession();
|
||||
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
|
||||
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
||||
@@ -101,8 +102,8 @@ const AddMenu = () => {
|
||||
);
|
||||
|
||||
const handleSelectItem = useCallback(() => {
|
||||
openItemSelectModal();
|
||||
}, [openItemSelectModal]);
|
||||
openItemSelectModal({ boardId: board.id });
|
||||
}, [board.id, openItemSelectModal]);
|
||||
|
||||
const handleSelectApp = useCallback(() => {
|
||||
openAppSelectModal({
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { DayOfWeek } from "@mantine/dates";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { env } from "@homarr/auth/env";
|
||||
import { env as authEnv } from "@homarr/auth/env";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { db } from "@homarr/db";
|
||||
import { getServerSettingsAsync } from "@homarr/db/queries";
|
||||
@@ -24,9 +24,11 @@ import { SettingsProvider } from "@homarr/settings";
|
||||
import { SpotlightProvider } from "@homarr/spotlight";
|
||||
import type { SupportedLanguage } from "@homarr/translation";
|
||||
import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
|
||||
import { WORKSHOP_API_URL } from "@homarr/workshop/schema";
|
||||
|
||||
import { Analytics } from "~/components/layout/analytics";
|
||||
import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation";
|
||||
import { env } from "~/env";
|
||||
|
||||
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
|
||||
import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration";
|
||||
@@ -90,7 +92,7 @@ export default async function Layout(props: {
|
||||
|
||||
const StackedProvider = composeWrappers([
|
||||
(innerProps) => {
|
||||
return <AuthProvider session={session} logoutUrl={env.AUTH_LOGOUT_REDIRECT_URL} {...innerProps} />;
|
||||
return <AuthProvider session={session} logoutUrl={authEnv.AUTH_LOGOUT_REDIRECT_URL} {...innerProps} />;
|
||||
},
|
||||
(innerProps) => (
|
||||
<SettingsProvider
|
||||
@@ -139,6 +141,7 @@ export default async function Layout(props: {
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
<meta name="homarr-workshop-api-url" content={env.WORKSHOP_API_URL ?? WORKSHOP_API_URL} />
|
||||
<SearchEngineOptimization />
|
||||
<CrowdinLiveTranslation locale={locale} />
|
||||
</head>
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
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"),
|
||||
"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}`,
|
||||
});
|
||||
});
|
||||
|
||||
test("copies the complete offline bundle 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.endsWith(`${CUSTOM_WIDGET_OFFLINE_BUNDLE_SENTINEL}\nCharacters: ${prompt.length}`)).toBe(true);
|
||||
expect(showSuccessNotification).toHaveBeenCalledWith({
|
||||
title: "action.copyAiPrompt",
|
||||
message: `notification.aiPromptCopiedWithCount:${prompt.length}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Button, Menu } from "@mantine/core";
|
||||
import { IconChevronDown, IconFileText, IconSparkles } from "@tabler/icons-react";
|
||||
|
||||
import { buildCustomWidgetAiPrompt, CUSTOM_WIDGET_SKILL_MD } from "@homarr/custom-widgets/core";
|
||||
import { buildCustomWidgetAiPrompt } from "@homarr/custom-widgets/authoring-prompt";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
@@ -24,17 +24,23 @@ export const CopyAiPromptButton = ({
|
||||
|
||||
const handleCopy = async (includeSkill = false) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(
|
||||
buildCustomWidgetAiPrompt(
|
||||
undefined,
|
||||
rawResponse,
|
||||
currentConfig,
|
||||
request,
|
||||
documentationUrl,
|
||||
includeSkill ? CUSTOM_WIDGET_SKILL_MD : undefined,
|
||||
),
|
||||
);
|
||||
showSuccessNotification({ title: t("action.copyAiPrompt"), message: t("notification.aiPromptCopied") });
|
||||
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);
|
||||
await navigator.clipboard.writeText(prompt);
|
||||
showSuccessNotification({
|
||||
title: t("action.copyAiPrompt"),
|
||||
message: t("notification.aiPromptCopiedWithCount", { count: prompt.length }),
|
||||
});
|
||||
} catch {
|
||||
showErrorNotification({ title: t("action.copyAiPrompt"), message: t("notification.aiPromptCopyError") });
|
||||
}
|
||||
|
||||
@@ -155,8 +155,7 @@ export const ImportCustomWidgetButton = () => {
|
||||
|
||||
useEffect(() => {
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.matches("input, textarea, [contenteditable='true']")) return;
|
||||
if (event.target instanceof Element && event.target.matches("input, textarea, [contenteditable='true']")) return;
|
||||
const text = event.clipboardData?.getData("text/plain") ?? "";
|
||||
if (!looksLikeCustomWidgetClipboard(text)) return;
|
||||
event.preventDefault();
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
CUSTOM_WIDGET_OPTIONS_EXAMPLES,
|
||||
CUSTOM_WIDGET_REQUEST_EXAMPLES,
|
||||
getCustomWidgetDefaultOptionsJsonSchema,
|
||||
getCustomWidgetOptionsJsonSchema,
|
||||
getCustomWidgetRequestsJsonSchema,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
|
||||
export const customWidgetRequestReference = {
|
||||
schema: getCustomWidgetRequestsJsonSchema(),
|
||||
minimal: CUSTOM_WIDGET_REQUEST_EXAMPLES.minimal,
|
||||
full: CUSTOM_WIDGET_REQUEST_EXAMPLES.full,
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
|
||||
import { getCustomWidgetPreviewOptionIssues, loadPreviewQueries } from "./_custom-widget-form-utils";
|
||||
|
||||
const previewQuery = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@homarr/api/client", () => ({
|
||||
fetchApi: { customWidget: { previewQuery: { query: previewQuery } } },
|
||||
}));
|
||||
|
||||
const definition: HomarrCustomWidgetV2 = {
|
||||
$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 },
|
||||
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("rejects invalid selected options before creating a preview session", () => {
|
||||
expect(getCustomWidgetPreviewOptionIssues(definition, { environmentId: "wrong" })).toEqual([
|
||||
{ path: "configuration.environmentId", message: "Expected number" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,9 @@
|
||||
import { fetchApi } from "@homarr/api/client";
|
||||
import { customWidgetDefinitionSchema, resolveCustomWidgetOptionsBinding } from "@homarr/custom-widgets/core";
|
||||
import {
|
||||
customWidgetDefinitionSchema,
|
||||
resolveCustomWidgetOptionsBinding,
|
||||
validateCustomWidgetOptions,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import type { CustomWidgetSource, HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
|
||||
import type { UseFormReturnType } from "@mantine/form";
|
||||
@@ -58,6 +62,10 @@ export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function getCustomWidgetPreviewOptionIssues(definition: HomarrCustomWidgetV2, options: Record<string, unknown>) {
|
||||
return validateCustomWidgetOptions(definition.optionsSchema, options);
|
||||
}
|
||||
|
||||
export function isRuntimeParams(
|
||||
value: unknown,
|
||||
schema: Record<string, "string" | "number" | "boolean">,
|
||||
@@ -66,12 +74,16 @@ export function isRuntimeParams(
|
||||
return Object.entries(schema).every(([name, type]) => typeof value[name] === type);
|
||||
}
|
||||
|
||||
export async function loadPreviewQueries(definition: HomarrCustomWidgetV2, sessionId: string) {
|
||||
export async function loadPreviewQueries(
|
||||
definition: HomarrCustomWidgetV2,
|
||||
sessionId: string,
|
||||
options: Record<string, unknown>,
|
||||
) {
|
||||
const data: Record<string, unknown> = {};
|
||||
const status: Record<string, unknown> = {};
|
||||
for (const request of definition.requests.filter((entry) => entry.kind === "query" && entry.trigger === "load")) {
|
||||
try {
|
||||
const params = resolveCustomWidgetOptionsBinding(request, definition.defaultOptions);
|
||||
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 };
|
||||
|
||||
@@ -1,37 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
CopyButton,
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { Box, Button, Paper, SegmentedControl, Stack, TextInput, Textarea } from "@mantine/core";
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconApi,
|
||||
IconBraces,
|
||||
IconCheck,
|
||||
IconCode,
|
||||
IconCopy,
|
||||
IconDatabase,
|
||||
IconEye,
|
||||
IconSettings,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import {
|
||||
CUSTOM_WIDGET_OPTIONS_EXAMPLES,
|
||||
CUSTOM_WIDGET_REQUEST_EXAMPLES,
|
||||
getCustomWidgetDefaultOptionsJsonSchema,
|
||||
getCustomWidgetOptionsJsonSchema,
|
||||
getCustomWidgetRequestsJsonSchema,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import {
|
||||
analyzeJsxTemplate,
|
||||
analyzeRequestManifest,
|
||||
@@ -48,10 +28,16 @@ import { CodeEditor } from "./_code-editor";
|
||||
import { CustomWidgetAiCard } from "./_custom-widget-ai-card";
|
||||
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 { CustomWidgetPreviewPanel } from "./_custom-widget-preview-panel";
|
||||
import type { PreviewState } from "./_custom-widget-preview-panel";
|
||||
import { CustomWidgetRequestTools } from "./_custom-widget-request-tools";
|
||||
import { CustomWidgetSaveIssuesAlert } from "./_custom-widget-save-issues-alert";
|
||||
import { CustomWidgetSourcesEditor } from "./_custom-widget-sources-editor";
|
||||
import { useCustomWidgetFormActions } from "./_use-custom-widget-form-actions";
|
||||
import classes from "./_custom-widget-form.module.css";
|
||||
@@ -71,35 +57,21 @@ const sectionLinks = [
|
||||
["preview", "section.preview", IconEye],
|
||||
] as const;
|
||||
|
||||
const requestReference = {
|
||||
schema: getCustomWidgetRequestsJsonSchema(),
|
||||
minimal: CUSTOM_WIDGET_REQUEST_EXAMPLES.minimal,
|
||||
full: CUSTOM_WIDGET_REQUEST_EXAMPLES.full,
|
||||
};
|
||||
const optionsSchemaReference = {
|
||||
schema: getCustomWidgetOptionsJsonSchema(),
|
||||
minimal: CUSTOM_WIDGET_OPTIONS_EXAMPLES.minimal.schema,
|
||||
full: CUSTOM_WIDGET_OPTIONS_EXAMPLES.full.schema,
|
||||
};
|
||||
const defaultOptionsReference = {
|
||||
schema: getCustomWidgetDefaultOptionsJsonSchema(),
|
||||
minimal: CUSTOM_WIDGET_OPTIONS_EXAMPLES.minimal.defaults,
|
||||
full: CUSTOM_WIDGET_OPTIONS_EXAMPLES.full.defaults,
|
||||
};
|
||||
|
||||
export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWidgetFormProps) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const w = useScopedI18n("customWidget.workbench");
|
||||
const form = useZodForm(customWidgetFormSchema, {
|
||||
initialValues: { ...DEFAULT_CUSTOM_WIDGET_FORM_VALUES, ...initialValues },
|
||||
});
|
||||
const formInitialValues: CustomWidgetFormValues = { ...DEFAULT_CUSTOM_WIDGET_FORM_VALUES, ...initialValues };
|
||||
const form = useZodForm(customWidgetFormSchema, { initialValues: formInitialValues });
|
||||
const [mobilePane, setMobilePane] = useState<"configure" | "preview">("configure");
|
||||
const [request, setRequest] = useState("");
|
||||
const [documentationUrl, setDocumentationUrl] = useState("");
|
||||
const [preview, setPreview] = useState<PreviewState>({ data: {}, status: {}, session: null, outcome: "idle" });
|
||||
const [previewSize, setPreviewSize] = useState("standard");
|
||||
const [previewTheme, setPreviewTheme] = useState<"light" | "dark">("dark");
|
||||
const [optionsSnapshot, setOptionsSnapshot] = useState<Record<string, unknown>>({});
|
||||
const [optionsSnapshot, setOptionsSnapshot] = useState<Record<string, unknown>>(() => {
|
||||
const initialDefinition = buildDefinition(formInitialValues);
|
||||
return initialDefinition.success ? initialDefinition.data.defaultOptions : {};
|
||||
});
|
||||
const [revealedRequest, setRevealedRequest] = useState({ text: "", key: 0 });
|
||||
const dirtyRef = useRef(false);
|
||||
dirtyRef.current = form.isDirty();
|
||||
@@ -140,10 +112,9 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
preview,
|
||||
setPreview,
|
||||
setMobilePane,
|
||||
optionsSnapshot,
|
||||
setOptionsSnapshot,
|
||||
});
|
||||
const saveIssueText = saveIssues.map((issue) => `${issue.path ? `${issue.path}: ` : ""}${issue.message}`).join("\n");
|
||||
|
||||
useEffect(() => {
|
||||
const beforeUnload = (event: BeforeUnloadEvent) => {
|
||||
if (dirtyRef.current) event.preventDefault();
|
||||
@@ -173,36 +144,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
{ value: "preview", label: w("section.preview") },
|
||||
]}
|
||||
/>
|
||||
{saveIssues.length > 0 && (
|
||||
<Alert color="red" title={w("saveError.title")} icon={<IconAlertCircle size={18} />}>
|
||||
<Text size="sm" mb="xs">
|
||||
{w("saveError.description")}
|
||||
</Text>
|
||||
<Textarea
|
||||
value={saveIssueText}
|
||||
readOnly
|
||||
autosize
|
||||
minRows={2}
|
||||
maxRows={10}
|
||||
aria-label={w("saveError.errors")}
|
||||
mb="xs"
|
||||
/>
|
||||
<CopyButton value={saveIssueText} timeout={2_000}>
|
||||
{({ copied, copy }) => (
|
||||
<Button
|
||||
type="button"
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color={copied ? "green" : "red"}
|
||||
leftSection={copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
|
||||
onClick={copy}
|
||||
>
|
||||
{copied ? w("saveError.copied") : w("saveError.copy")}
|
||||
</Button>
|
||||
)}
|
||||
</CopyButton>
|
||||
</Alert>
|
||||
)}
|
||||
<CustomWidgetSaveIssuesAlert issues={saveIssues} />
|
||||
<nav className={classes.sectionNav} aria-label={w("sectionNavigation")}>
|
||||
{sectionLinks.map(([id, key, SectionIcon]) => (
|
||||
<Button
|
||||
@@ -244,7 +186,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
error={form.errors.requests}
|
||||
revealText={revealedRequest.text}
|
||||
revealKey={revealedRequest.key}
|
||||
reference={requestReference}
|
||||
reference={customWidgetRequestReference}
|
||||
required
|
||||
/>
|
||||
</EditorSection>
|
||||
@@ -256,7 +198,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
value={form.values.optionsSchema}
|
||||
onChange={(value) => form.setFieldValue("optionsSchema", value)}
|
||||
error={form.errors.optionsSchema}
|
||||
reference={optionsSchemaReference}
|
||||
reference={customWidgetOptionsSchemaReference}
|
||||
/>
|
||||
<CodeEditor
|
||||
id="default-options"
|
||||
@@ -265,7 +207,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
value={form.values.defaultOptions}
|
||||
onChange={(value) => form.setFieldValue("defaultOptions", value)}
|
||||
error={form.errors.defaultOptions}
|
||||
reference={defaultOptionsReference}
|
||||
reference={customWidgetDefaultOptionsReference}
|
||||
/>
|
||||
</EditorSection>
|
||||
<EditorSection id="jsx" title={w("jsx.title")} icon={IconCode}>
|
||||
|
||||
+4
-7
@@ -14,21 +14,18 @@ import { isRuntimeParams, parseJson } from "./_custom-widget-form-utils";
|
||||
export function PreviewActionControl({
|
||||
request,
|
||||
sessionId,
|
||||
options,
|
||||
}: {
|
||||
request: HomarrCustomWidgetV2["requests"][number];
|
||||
sessionId?: string;
|
||||
options: Record<string, unknown>;
|
||||
}) {
|
||||
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]) => {
|
||||
const bound = options[name];
|
||||
if (typeof bound === type) return [name, bound];
|
||||
return [name, type === "number" ? 0 : type === "boolean" ? false : ""];
|
||||
}),
|
||||
Object.entries(request.parameters).map(([name, type]) => [
|
||||
name,
|
||||
type === "number" ? 0 : type === "boolean" ? false : "",
|
||||
]),
|
||||
);
|
||||
const [params, setParams] = useState(JSON.stringify(initialParams, null, 2));
|
||||
const parsed = parseJson(params);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
|
||||
interface PreviewSnapshot {
|
||||
data: Record<string, unknown>;
|
||||
status: Record<string, unknown>;
|
||||
session: { id: string; liveActions: boolean } | null;
|
||||
}
|
||||
|
||||
export function createPreviewDisplayData({
|
||||
candidate,
|
||||
fixture,
|
||||
preview,
|
||||
options,
|
||||
fixtureError,
|
||||
}: {
|
||||
candidate: HomarrCustomWidgetV2 | null;
|
||||
fixture: "live" | "loading" | "empty" | "error";
|
||||
preview: PreviewSnapshot;
|
||||
options: Record<string, unknown>;
|
||||
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 status =
|
||||
fixture === "loading"
|
||||
? Object.fromEntries(candidate.requests.map((entry) => [entry.id, { loading: true }]))
|
||||
: fixture === "error"
|
||||
? Object.fromEntries(
|
||||
candidate.requests.map((entry) => [entry.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,
|
||||
}),
|
||||
),
|
||||
previewSessionId: preview.session?.id,
|
||||
previewLiveActions: preview.session?.liveActions ?? false,
|
||||
queriesDisabled: fixture !== "live",
|
||||
isEditMode: fixture !== "live",
|
||||
};
|
||||
}
|
||||
+131
-2
@@ -6,6 +6,8 @@ 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 { CustomWidgetPreviewPanel } from "./_custom-widget-preview-panel";
|
||||
|
||||
vi.mock("@homarr/api/client", () => ({
|
||||
@@ -18,7 +20,17 @@ vi.mock("@homarr/api/client", () => ({
|
||||
}));
|
||||
vi.mock("@homarr/translation/client", () => ({ useScopedI18n: () => (key: string) => key }));
|
||||
vi.mock("@homarr/notifications", () => ({ showErrorNotification: vi.fn() }));
|
||||
vi.mock("@homarr/widgets/custom-api/custom-jsx-display", () => ({ default: () => <div>widget-renderer</div> }));
|
||||
const rendererAttempt = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@homarr/widgets/custom-api/custom-jsx-display", () => ({
|
||||
default: ({ data }: { data: Record<string, unknown> }) => {
|
||||
rendererAttempt(data);
|
||||
if ((data.options as Record<string, unknown> | undefined)?.throwPreview === true) {
|
||||
throw new Error("authored widget failed");
|
||||
}
|
||||
return <div>widget-renderer</div>;
|
||||
},
|
||||
}));
|
||||
vi.mock("./_code-editor", () => ({ CodeEditor: ({ label }: { label: string }) => <div>{label}</div> }));
|
||||
vi.mock("./_custom-widget-preview-action", () => ({ PreviewActionControl: () => <div>action-control</div> }));
|
||||
|
||||
@@ -77,11 +89,33 @@ describe("Custom Widget preview panel", () => {
|
||||
);
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain("invalid");
|
||||
const previewCanvas = host.querySelector('[class*="previewCanvas"]');
|
||||
expect(previewCanvas?.textContent).toContain("invalid");
|
||||
for (const tab of ["widget", "data", "options", "actions", "diagnostics"]) {
|
||||
expect(host.textContent).toContain(`tab.${tab}`);
|
||||
}
|
||||
|
||||
const dataTab = [...host.querySelectorAll<HTMLElement>('[role="tab"]')].find((element) =>
|
||||
element.textContent?.includes("tab.data"),
|
||||
);
|
||||
await act(async () => dataTab?.click());
|
||||
expect(host.textContent).toContain("requestData");
|
||||
expect(host.textContent).not.toContain("invalid");
|
||||
|
||||
const optionsTab = [...host.querySelectorAll<HTMLElement>('[role="tab"]')].find((element) =>
|
||||
element.textContent?.includes("tab.options"),
|
||||
);
|
||||
await act(async () => optionsTab?.click());
|
||||
expect(host.textContent).toContain("instanceOptions");
|
||||
expect(host.textContent).not.toContain("invalid");
|
||||
|
||||
const actionsTab = [...host.querySelectorAll<HTMLElement>('[role="tab"]')].find((element) =>
|
||||
element.textContent?.includes("tab.actions"),
|
||||
);
|
||||
await act(async () => actionsTab?.click());
|
||||
expect(host.textContent).toContain("liveActions");
|
||||
expect(host.textContent).not.toContain("invalid");
|
||||
|
||||
const diagnosticsTab = [...host.querySelectorAll<HTMLElement>('[role="tab"]')].find((element) =>
|
||||
element.textContent?.includes("tab.diagnostics"),
|
||||
);
|
||||
@@ -91,4 +125,99 @@ describe("Custom Widget preview panel", () => {
|
||||
expect(host.textContent).toContain("sources.0.baseUrl");
|
||||
expect(host.textContent).toContain("Enter a complete URL.");
|
||||
});
|
||||
|
||||
test("contains renderer failures inside the widget tab and keeps the inspection tabs usable", async () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
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 preview = { data: {}, status: {}, session: null, outcome: "idle" } as const;
|
||||
const failingOptions = { throwPreview: true };
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<CustomWidgetPreviewPanel
|
||||
candidate={{
|
||||
...candidate,
|
||||
requests: candidate.requests.map((request) => ({ ...request })),
|
||||
}}
|
||||
validationIssues={[]}
|
||||
preview={preview}
|
||||
size="standard"
|
||||
onSizeChange={vi.fn()}
|
||||
theme="dark"
|
||||
onThemeChange={vi.fn()}
|
||||
optionsSnapshot={{ ...failingOptions }}
|
||||
onOptionsChange={vi.fn()}
|
||||
onLiveActionsChange={vi.fn()}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain("title");
|
||||
expect(host.querySelector('[class*="previewCanvas"]')?.textContent).toContain("title");
|
||||
for (const tab of ["widget", "data", "options", "actions", "diagnostics"]) {
|
||||
expect(host.textContent).toContain(`tab.${tab}`);
|
||||
}
|
||||
const attemptsAfterFailure = rendererAttempt.mock.calls.length;
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<CustomWidgetPreviewPanel
|
||||
candidate={candidate}
|
||||
validationIssues={[]}
|
||||
preview={preview}
|
||||
size="standard"
|
||||
onSizeChange={vi.fn()}
|
||||
theme="dark"
|
||||
onThemeChange={vi.fn()}
|
||||
optionsSnapshot={failingOptions}
|
||||
onOptionsChange={vi.fn()}
|
||||
onLiveActionsChange={vi.fn()}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
});
|
||||
expect(rendererAttempt).toHaveBeenCalledTimes(attemptsAfterFailure);
|
||||
|
||||
const dataTab = [...host.querySelectorAll<HTMLElement>('[role="tab"]')].find((element) =>
|
||||
element.textContent?.includes("tab.data"),
|
||||
);
|
||||
await act(async () => dataTab?.click());
|
||||
expect(host.textContent).toContain("requestData");
|
||||
|
||||
const diagnosticsTab = [...host.querySelectorAll<HTMLElement>('[role="tab"]')].find((element) =>
|
||||
element.textContent?.includes("tab.diagnostics"),
|
||||
);
|
||||
await act(async () => diagnosticsTab?.click());
|
||||
expect(host.textContent).toContain("validation.title");
|
||||
|
||||
const widgetTab = [...host.querySelectorAll<HTMLElement>('[role="tab"]')].find((element) =>
|
||||
element.textContent?.includes("tab.widget"),
|
||||
);
|
||||
await act(async () => widgetTab?.click());
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<CustomWidgetPreviewPanel
|
||||
candidate={candidate}
|
||||
validationIssues={[]}
|
||||
preview={preview}
|
||||
size="standard"
|
||||
onSizeChange={vi.fn()}
|
||||
theme="dark"
|
||||
onThemeChange={vi.fn()}
|
||||
optionsSnapshot={{}}
|
||||
onOptionsChange={vi.fn()}
|
||||
onLiveActionsChange={vi.fn()}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
});
|
||||
expect(host.textContent).toContain("widget-renderer");
|
||||
});
|
||||
});
|
||||
+34
-75
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
@@ -18,16 +18,17 @@ import {
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import { analyzeCustomWidgetAccessibility } from "@homarr/custom-widgets/workbench";
|
||||
import { analyzeCustomWidgetAccessibility, PreviewErrorBoundary } from "@homarr/custom-widgets/workbench";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import CustomJsxDisplay from "@homarr/widgets/custom-api/custom-jsx-display";
|
||||
|
||||
import { CodeEditor } from "./_code-editor";
|
||||
import { isRecord, parseJson } from "./_custom-widget-form-utils";
|
||||
import { getPreviewSummary, PreviewResult, PreviewStatusDot } from "./_custom-widget-preview-status";
|
||||
import type { PreviewOutcome } from "./_custom-widget-preview-status";
|
||||
import { PreviewActionControl } from "./_custom-widget-preview-action";
|
||||
import { createPreviewDisplayData } from "./_custom-widget-preview-data";
|
||||
import { JsonPreviewEditor } from "./_json-preview-editor";
|
||||
import classes from "./_custom-widget-form.module.css";
|
||||
|
||||
export interface PreviewState {
|
||||
@@ -52,6 +53,7 @@ interface PreviewPanelProps {
|
||||
|
||||
export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
|
||||
const t = useScopedI18n("customWidget.workbench.preview");
|
||||
const errorBoundaryMessages = useScopedI18n("customWidget.editor.errorBoundary");
|
||||
const [fixture, setFixture] = useState<"live" | "loading" | "empty" | "error">("live");
|
||||
const liveActionsMutation = clientApi.customWidget.setPreviewLiveActions.useMutation();
|
||||
const journalQuery = clientApi.customWidget.previewJournal.useQuery(
|
||||
@@ -61,42 +63,23 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
|
||||
const widths: Record<string, number> = { compact: 320, standard: 480, wide: 720 };
|
||||
const candidate = props.candidate;
|
||||
const accessibilityIssues = candidate ? analyzeCustomWidgetAccessibility(candidate.template) : [];
|
||||
const fixtureData =
|
||||
fixture === "empty" && candidate
|
||||
? Object.fromEntries(candidate.requests.map((entry) => [entry.id, []]))
|
||||
: props.preview.data;
|
||||
const fixtureStatus =
|
||||
fixture === "loading" && candidate
|
||||
? Object.fromEntries(candidate.requests.map((entry) => [entry.id, { loading: true }]))
|
||||
: fixture === "error" && candidate
|
||||
? Object.fromEntries(
|
||||
candidate.requests.map((entry) => [entry.id, { loading: false, ok: false, error: t("fixtureError") }]),
|
||||
)
|
||||
: props.preview.status;
|
||||
const displayData = candidate
|
||||
? {
|
||||
template: candidate.template,
|
||||
data: fixtureData,
|
||||
status: fixtureStatus,
|
||||
options: props.optionsSnapshot,
|
||||
requestCapabilities: candidate.requests.map(
|
||||
({ id, kind, method, trigger, minimumBoardPermission, confirmation, invalidates }) => ({
|
||||
id,
|
||||
kind,
|
||||
method,
|
||||
trigger,
|
||||
minimumBoardPermission,
|
||||
confirmation,
|
||||
invalidates,
|
||||
}),
|
||||
),
|
||||
previewSessionId: props.preview.session?.id,
|
||||
previewLiveActions: props.preview.session?.liveActions ?? false,
|
||||
queriesDisabled: fixture !== "live",
|
||||
isEditMode: fixture !== "live",
|
||||
}
|
||||
: null;
|
||||
const displayData = createPreviewDisplayData({
|
||||
candidate,
|
||||
fixture,
|
||||
preview: props.preview,
|
||||
options: props.optionsSnapshot,
|
||||
fixtureError: t("fixtureError"),
|
||||
});
|
||||
const previewSummary = getPreviewSummary(props.preview.status);
|
||||
const rendererResetKey = JSON.stringify({
|
||||
template: candidate?.template,
|
||||
requests: candidate?.requests,
|
||||
fixture,
|
||||
data: props.preview.data,
|
||||
status: props.preview.status,
|
||||
sessionId: props.preview.session?.id,
|
||||
options: props.optionsSnapshot,
|
||||
});
|
||||
const previewResult =
|
||||
props.preview.outcome === "success"
|
||||
? {
|
||||
@@ -172,7 +155,18 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
|
||||
<Box className={classes.previewCanvas}>
|
||||
<MantineProvider forceColorScheme={props.theme}>
|
||||
<Paper withBorder p="sm" h={360} w={widths[props.size] ?? 480} maw="100%" style={{ overflow: "auto" }}>
|
||||
{displayData ? <CustomJsxDisplay data={displayData} /> : <Alert color="yellow">{t("invalid")}</Alert>}
|
||||
{displayData ? (
|
||||
<PreviewErrorBoundary
|
||||
title={errorBoundaryMessages("title")}
|
||||
description={t("containedFailures")}
|
||||
retryLabel={errorBoundaryMessages("retry")}
|
||||
resetKeys={[rendererResetKey]}
|
||||
>
|
||||
<CustomJsxDisplay data={displayData} />
|
||||
</PreviewErrorBoundary>
|
||||
) : (
|
||||
<Alert color="yellow">{t("invalid")}</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
</MantineProvider>
|
||||
</Box>
|
||||
@@ -216,14 +210,8 @@ 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}
|
||||
options={props.optionsSnapshot}
|
||||
/>
|
||||
<PreviewActionControl key={request.id} request={request} sessionId={props.preview.session?.id} />
|
||||
))}
|
||||
{!candidate && <Alert color="yellow">{t("invalid")}</Alert>}
|
||||
{candidate?.requests.every((request) => request.kind !== "action") && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("noActions")}
|
||||
@@ -283,32 +271,3 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function JsonPreviewEditor({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: Record<string, unknown>;
|
||||
onChange(value: Record<string, unknown>): void;
|
||||
}) {
|
||||
const serialized = JSON.stringify(value, null, 2);
|
||||
const [draft, setDraft] = useState(serialized);
|
||||
useEffect(() => setDraft(serialized), [serialized]);
|
||||
return (
|
||||
<CodeEditor
|
||||
id={id}
|
||||
label={label}
|
||||
language="json"
|
||||
value={draft}
|
||||
onChange={(next) => {
|
||||
setDraft(next);
|
||||
const parsed = parseJson(next);
|
||||
if (isRecord(parsed)) onChange(parsed);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -40,6 +40,23 @@ describe("extractCustomWidgetSaveIssues", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves array indexes in serialized Zod issue paths", () => {
|
||||
const message = JSON.stringify([
|
||||
{
|
||||
code: "custom",
|
||||
message: "A load parameter requires an explicit binding.",
|
||||
path: ["requests", 0, "optionsBinding", "endpointId"],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(extractCustomWidgetSaveIssues({ message })).toEqual([
|
||||
{
|
||||
path: "requests.0.optionsBinding.endpointId",
|
||||
message: "A load parameter requires an explicit binding.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("extracts root validation errors from the tRPC error shape", () => {
|
||||
expect(
|
||||
extractCustomWidgetSaveIssues({
|
||||
|
||||
@@ -35,7 +35,12 @@ function parseZodIssueMessage(message: string): CustomWidgetSaveIssue[] {
|
||||
return parsed.flatMap((candidate) => {
|
||||
const issue = asRecord(candidate);
|
||||
if (typeof issue?.message !== "string") return [];
|
||||
const path = Array.isArray(issue.path) ? issue.path.filter((part) => typeof part === "string").join(".") : undefined;
|
||||
const path = Array.isArray(issue.path)
|
||||
? issue.path
|
||||
.filter((part) => typeof part === "string" || typeof part === "number")
|
||||
.map(String)
|
||||
.join(".")
|
||||
: undefined;
|
||||
return [{ path: path || undefined, message: issue.message }];
|
||||
});
|
||||
} catch {
|
||||
@@ -45,10 +50,15 @@ function parseZodIssueMessage(message: string): CustomWidgetSaveIssue[] {
|
||||
|
||||
function uniqueIssues(issues: CustomWidgetSaveIssue[]) {
|
||||
return issues
|
||||
.filter((issue, index) => issues.findIndex((candidate) => candidate.path === issue.path && candidate.message === issue.message) === index)
|
||||
.filter(
|
||||
(issue, index) =>
|
||||
issues.findIndex((candidate) => candidate.path === issue.path && candidate.message === issue.message) === index,
|
||||
)
|
||||
.slice(0, MAX_SAVE_ISSUES);
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, Button, CopyButton, Text, Textarea } from "@mantine/core";
|
||||
import { IconAlertCircle, IconCheck, IconCopy } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { CustomWidgetSaveIssue } from "./_custom-widget-save-errors";
|
||||
|
||||
export function CustomWidgetSaveIssuesAlert({ issues }: { issues: CustomWidgetSaveIssue[] }) {
|
||||
const t = useScopedI18n("customWidget.workbench.saveError");
|
||||
if (issues.length === 0) return null;
|
||||
const value = issues.map((issue) => `${issue.path ? `${issue.path}: ` : ""}${issue.message}`).join("\n");
|
||||
|
||||
return (
|
||||
<Alert color="red" title={t("title")} icon={<IconAlertCircle size={18} />}>
|
||||
<Text size="sm" mb="xs">
|
||||
{t("description")}
|
||||
</Text>
|
||||
<Textarea value={value} readOnly autosize minRows={2} maxRows={10} aria-label={t("errors")} mb="xs" />
|
||||
<CopyButton value={value} timeout={2_000}>
|
||||
{({ copied, copy }) => (
|
||||
<Button
|
||||
type="button"
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color={copied ? "green" : "red"}
|
||||
leftSection={copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
|
||||
onClick={copy}
|
||||
>
|
||||
{copied ? t("copied") : t("copy")}
|
||||
</Button>
|
||||
)}
|
||||
</CopyButton>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -133,7 +133,7 @@ export function CustomWidgetSourcesEditor({
|
||||
{sources.map((source, index) => {
|
||||
const baseUrlIssue = getCustomWidgetSourceUrlIssue(source.baseUrl);
|
||||
return (
|
||||
<Fieldset key={source.id} legend={index === 0 ? t("primary") : source.name}>
|
||||
<Fieldset key={index} legend={index === 0 ? t("primary") : source.name}>
|
||||
<Stack gap="sm">
|
||||
<Group grow align="start">
|
||||
<TextInput
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { CodeEditor } from "./_code-editor";
|
||||
import { isRecord, parseJson } from "./_custom-widget-form-utils";
|
||||
|
||||
export function JsonPreviewEditor({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: Record<string, unknown>;
|
||||
onChange(value: Record<string, unknown>): void;
|
||||
}) {
|
||||
const serialized = JSON.stringify(value, null, 2);
|
||||
const [draft, setDraft] = useState(serialized);
|
||||
useEffect(() => setDraft(serialized), [serialized]);
|
||||
return (
|
||||
<CodeEditor
|
||||
id={id}
|
||||
label={label}
|
||||
language="json"
|
||||
value={draft}
|
||||
onChange={(next) => {
|
||||
setDraft(next);
|
||||
const parsed = parseJson(next);
|
||||
if (isRecord(parsed)) onChange(parsed);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+25
-5
@@ -15,7 +15,13 @@ import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
|
||||
import { showErrorNotification, showSuccessNotification, showWarningNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { applyDefinition, buildDefinition, isRecord, loadPreviewQueries } from "./_custom-widget-form-utils";
|
||||
import {
|
||||
applyDefinition,
|
||||
buildDefinition,
|
||||
getCustomWidgetPreviewOptionIssues,
|
||||
isRecord,
|
||||
loadPreviewQueries,
|
||||
} from "./_custom-widget-form-utils";
|
||||
import type { PreviewState } from "./_custom-widget-preview-panel";
|
||||
import { extractCustomWidgetSaveIssues } from "./_custom-widget-save-errors";
|
||||
import type { CustomWidgetSaveIssue } from "./_custom-widget-save-errors";
|
||||
@@ -28,6 +34,7 @@ interface FormActionsInput {
|
||||
preview: PreviewState;
|
||||
setPreview: Dispatch<SetStateAction<PreviewState>>;
|
||||
setMobilePane: Dispatch<SetStateAction<"configure" | "preview">>;
|
||||
optionsSnapshot: Record<string, unknown>;
|
||||
setOptionsSnapshot: Dispatch<SetStateAction<Record<string, unknown>>>;
|
||||
}
|
||||
|
||||
@@ -111,6 +118,15 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
|
||||
});
|
||||
return;
|
||||
}
|
||||
const optionIssues = getCustomWidgetPreviewOptionIssues(input.candidate.data, input.optionsSnapshot);
|
||||
if (optionIssues.length > 0) {
|
||||
const issue = optionIssues[0];
|
||||
showErrorNotification({
|
||||
title: w("section.preview"),
|
||||
message: issue ? `${issue.path}: ${issue.message}` : w("invalidWidget"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
input.setMobilePane("preview");
|
||||
input.setPreview({ data: {}, status: {}, session: null, outcome: "loading" });
|
||||
setPreviewPending(true);
|
||||
@@ -118,6 +134,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
|
||||
const created = await previewMutation.mutateAsync({
|
||||
definition: input.candidate.data,
|
||||
definitionId: input.definitionId,
|
||||
options: input.optionsSnapshot,
|
||||
secrets: input.form.values.secrets
|
||||
.filter((secret) => secret.value.trim())
|
||||
.map(({ sourceId, kind, value }) => ({
|
||||
@@ -126,8 +143,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
|
||||
value,
|
||||
})),
|
||||
});
|
||||
input.setOptionsSnapshot(input.candidate.data.defaultOptions);
|
||||
const snapshot = await loadPreviewQueries(input.candidate.data, created.previewSession.id);
|
||||
const snapshot = await loadPreviewQueries(input.candidate.data, created.previewSession.id, input.optionsSnapshot);
|
||||
const failed = Object.values(snapshot.status).filter((status) => isRecord(status) && status.ok === false).length;
|
||||
input.setPreview({
|
||||
...snapshot,
|
||||
@@ -166,6 +182,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.setPreview({ data: {}, status: {}, session: null, outcome: "idle" });
|
||||
showSuccessNotification({
|
||||
title: w("ai.response"),
|
||||
@@ -204,7 +221,8 @@ function reportSaveIssues(
|
||||
const messagesByPath = new Map<string, string[]>();
|
||||
for (const issue of issues) {
|
||||
if (!issue.path) continue;
|
||||
messagesByPath.set(issue.path, [...(messagesByPath.get(issue.path) ?? []), issue.message]);
|
||||
const field = issue.path.split(".")[0] ?? issue.path;
|
||||
messagesByPath.set(field, [...(messagesByPath.get(field) ?? []), issue.message]);
|
||||
}
|
||||
for (const [path, messages] of messagesByPath) {
|
||||
form.setFieldError(path, messages.join(" "));
|
||||
@@ -217,5 +235,7 @@ function reportSaveIssues(
|
||||
}
|
||||
|
||||
function clearSaveIssues(form: UseFormReturnType<CustomWidgetFormValues>, issues: CustomWidgetSaveIssue[]) {
|
||||
for (const path of new Set(issues.flatMap((issue) => (issue.path ? [issue.path] : [])))) form.clearFieldError(path);
|
||||
for (const path of new Set(issues.flatMap((issue) => (issue.path ? [issue.path.split(".")[0] ?? issue.path] : [])))) {
|
||||
form.clearFieldError(path);
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export default async function CustomWidgetPreviewPage({ params }: { params: Prom
|
||||
const results = await Promise.all(
|
||||
loadRequests.map(async (request) => {
|
||||
const result = await Promise.resolve()
|
||||
.then(() => resolveCustomWidgetOptionsBinding(request, preview.defaultOptions))
|
||||
.then(() => resolveCustomWidgetOptionsBinding(request, preview.options))
|
||||
.then((requestParams) =>
|
||||
api.customWidget.previewQuery({ sessionId: id, requestId: request.id, params: requestParams }),
|
||||
)
|
||||
@@ -57,7 +57,7 @@ export default async function CustomWidgetPreviewPage({ params }: { params: Prom
|
||||
template: preview.template,
|
||||
data,
|
||||
status,
|
||||
options: preview.defaultOptions,
|
||||
options: preview.options,
|
||||
requestCapabilities: preview.requests,
|
||||
previewSessionId: preview.id,
|
||||
previewLiveActions: preview.liveActions,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { CUSTOM_WIDGET_MCP_AUTHORING_PROMPT } from "@homarr/custom-widgets/core";
|
||||
import { CUSTOM_WIDGET_MCP_AUTHORING_PROMPT } from "@homarr/custom-widgets/authoring-prompt";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { getCustomWidgetComponentCatalog } from "@homarr/custom-widgets/authoring-resources";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json(getCustomWidgetComponentCatalog());
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { getCustomWidgetSkill } from "@homarr/custom-widgets/core";
|
||||
import { getCustomWidgetSkill } from "@homarr/custom-widgets/authoring-resources";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
|
||||
@@ -18,14 +18,13 @@ import { API_KEY_HEADER_NAME, getSessionFromApiKeyAsync } from "@homarr/auth/api
|
||||
import { extractBaseUrlFromHeaders } from "@homarr/common";
|
||||
import { ipAddressFromHeaders } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { buildCustomWidgetMcpPrompt } from "@homarr/custom-widgets/authoring-prompt";
|
||||
import {
|
||||
buildCustomWidgetMcpPrompt,
|
||||
customJsxExamples,
|
||||
getCustomWidgetComponent,
|
||||
getCustomWidgetComponentCatalog,
|
||||
getCustomWidgetJsonSchema,
|
||||
getCustomWidgetSkill,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
} from "@homarr/custom-widgets/authoring-resources";
|
||||
import { customJsxExamples, getCustomWidgetJsonSchema } from "@homarr/custom-widgets/core";
|
||||
import { db } from "@homarr/db";
|
||||
|
||||
import { getPackageVersion } from "~/versions/package-reader";
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Avatar, Box, Button, Card, Center, Divider, Group, Image, Stack, Text,
|
||||
import { IconApi } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { createId, objectEntries } from "@homarr/common";
|
||||
import { getIconUrl, getIntegrationName } from "@homarr/definitions";
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
@@ -17,14 +16,13 @@ import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||
|
||||
import { useItemActions } from "./item-actions";
|
||||
|
||||
export const ItemSelectModal = createModal<void>(({ actions }) => {
|
||||
export const ItemSelectModal = createModal<{ boardId: string }>(({ actions, innerProps }) => {
|
||||
const [search, setSearch] = useState("");
|
||||
const t = useI18n();
|
||||
const { createItem, updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
|
||||
const { openModal: openEditModal } = useModalAction(WidgetEditModal);
|
||||
const { data: integrationData } = clientApi.integration.all.useQuery();
|
||||
const board = useRequiredBoard();
|
||||
const { data: customWidgetDefs } = clientApi.customWidget.available.useQuery({ boardId: board.id });
|
||||
const { data: customWidgetDefs } = clientApi.customWidget.available.useQuery({ boardId: innerProps.boardId });
|
||||
const settings = useSettings();
|
||||
|
||||
const availableKinds = useMemo(() => new Set((integrationData ?? []).map((i) => i.kind)), [integrationData]);
|
||||
|
||||
@@ -34,12 +34,11 @@ import {
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import type { WorkshopReport, WorkshopSubmissionDetail, WorkshopUser } from "@homarr/workshop";
|
||||
import { WORKSHOP_API_URL, WorkshopClient, workshopExportFilename } from "@homarr/workshop";
|
||||
import { customWidgetImportSchema } from "@homarr/custom-widgets/core";
|
||||
import { validateWorkshopWidget, workshopExportFilename } from "@homarr/workshop";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
const workshopUrl = process.env.NEXT_PUBLIC_WORKSHOP_API_URL ?? WORKSHOP_API_URL;
|
||||
import { createWorkshopClient } from "./workshop-client";
|
||||
|
||||
function downloadWorkshopSubmission(submission: WorkshopSubmissionDetail) {
|
||||
const url = URL.createObjectURL(new Blob([submission.content], { type: "application/json" }));
|
||||
@@ -52,7 +51,7 @@ function downloadWorkshopSubmission(submission: WorkshopSubmissionDetail) {
|
||||
|
||||
export function WorkshopBrowser({ onInstall }: { onInstall?(submission: WorkshopSubmissionDetail): Promise<void> }) {
|
||||
const t = useScopedI18n("workshop");
|
||||
const client = useMemo(() => new WorkshopClient(workshopUrl), []);
|
||||
const client = useMemo(createWorkshopClient, []);
|
||||
const queryClient = useQueryClient();
|
||||
const [user, setUser] = useState<WorkshopUser | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -82,6 +81,10 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
|
||||
queryFn: ({ signal }) => client.get(selectedId ?? "", signal),
|
||||
enabled: selectedId !== null,
|
||||
});
|
||||
const detailValidation = useMemo(
|
||||
() => (detail.data ? validateWorkshopWidget(detail.data.content) : null),
|
||||
[detail.data],
|
||||
);
|
||||
const signIn = () => {
|
||||
setLoginPending(true);
|
||||
setLoginError(null);
|
||||
@@ -213,7 +216,9 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
|
||||
)}
|
||||
<Stack gap="xs" mt="md">
|
||||
<Group justify="space-between">
|
||||
<Badge variant="light">v{item.revision}</Badge>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("author", { name: item.authorName })}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
<IconThumbUp size={13} /> {item.upvotes} · <IconThumbDown size={13} /> {item.downvotes}
|
||||
</Text>
|
||||
@@ -224,9 +229,6 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
|
||||
<Text size="sm" c="dimmed" lineClamp={2}>
|
||||
{item.description || t("noDescription")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("author", { name: item.authorName })}
|
||||
</Text>
|
||||
<Button variant="light" onClick={() => setSelectedId(item.id)}>
|
||||
{t("inspect")}
|
||||
</Button>
|
||||
@@ -256,12 +258,9 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
|
||||
) : (
|
||||
<Stack>
|
||||
<Text>{detail.data.description}</Text>
|
||||
<Group>
|
||||
<Badge>{t("revision", { revision: detail.data.revision })}</Badge>
|
||||
<Text size="sm" c="dimmed">
|
||||
SHA-256 {detail.data.contentHash.slice(0, 12)}…
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("author", { name: detail.data.authorName })}
|
||||
</Text>
|
||||
{detail.data.screenshots.length > 0 && (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }}>
|
||||
{detail.data.screenshots.map((file, index) => (
|
||||
@@ -275,6 +274,11 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
|
||||
</SimpleGrid>
|
||||
)}
|
||||
<CapabilitySummary content={detail.data.content} />
|
||||
{detailValidation && !detailValidation.success && (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={18} />}>
|
||||
{t("installErrorDescription")} {detailValidation.error}
|
||||
</Alert>
|
||||
)}
|
||||
<Code block mah={360} style={{ overflow: "auto", whiteSpace: "pre" }}>
|
||||
{detail.data.content}
|
||||
</Code>
|
||||
@@ -314,7 +318,11 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
|
||||
</Button>
|
||||
</Group>
|
||||
{onInstall && (
|
||||
<Button loading={install.isPending} onClick={() => install.mutate(detail.data)}>
|
||||
<Button
|
||||
loading={install.isPending}
|
||||
disabled={!detailValidation?.success}
|
||||
onClick={() => install.mutate(detail.data)}
|
||||
>
|
||||
{t("install")}
|
||||
</Button>
|
||||
)}
|
||||
@@ -361,13 +369,7 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
|
||||
|
||||
function CapabilitySummary({ content }: { content: string }) {
|
||||
const t = useScopedI18n("workshop");
|
||||
let candidate: unknown;
|
||||
try {
|
||||
candidate = JSON.parse(content) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const parsed = customWidgetImportSchema.safeParse(candidate);
|
||||
const parsed = validateWorkshopWidget(content);
|
||||
if (!parsed.success) return null;
|
||||
return (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }}>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { WorkshopClient, WORKSHOP_API_URL } from "@homarr/workshop";
|
||||
|
||||
export function createWorkshopClient() {
|
||||
const runtimeUrl =
|
||||
typeof document === "undefined"
|
||||
? undefined
|
||||
: document.querySelector<HTMLMetaElement>('meta[name="homarr-workshop-api-url"]')?.content;
|
||||
|
||||
return new WorkshopClient(runtimeUrl || WORKSHOP_API_URL);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { IconBuildingStore } from "@tabler/icons-react";
|
||||
|
||||
import { customWidgetImportSchema } from "@homarr/custom-widgets/core";
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
|
||||
@@ -25,9 +26,22 @@ export function WorkshopInstallButton() {
|
||||
<Modal opened={opened} onClose={controls.close} title={t("installDialog")} size="90%">
|
||||
<WorkshopBrowser
|
||||
onInstall={async (submission) => {
|
||||
const widget = customWidgetImportSchema.parse(JSON.parse(submission.content));
|
||||
setPendingWidget(widget);
|
||||
reviewControls.open();
|
||||
try {
|
||||
const parsed = customWidgetImportSchema.safeParse(JSON.parse(submission.content) as unknown);
|
||||
if (!parsed.success) throw new Error(t("installErrorDescription"));
|
||||
setPendingWidget(parsed.data);
|
||||
reviewControls.open();
|
||||
} catch (error) {
|
||||
showErrorNotification({
|
||||
title: t("installError"),
|
||||
message:
|
||||
error instanceof SyntaxError
|
||||
? t("installErrorDescription")
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: t("installErrorDescription"),
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
@@ -6,20 +6,36 @@ import { useDisclosure } from "@mantine/hooks";
|
||||
|
||||
import { customWidgetImportSchema } from "@homarr/custom-widgets/core";
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { WorkshopSubmissionDetail } from "@homarr/workshop";
|
||||
|
||||
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
|
||||
import { WorkshopBrowser } from "./workshop-browser";
|
||||
|
||||
export function WorkshopInstaller() {
|
||||
const t = useScopedI18n("workshop");
|
||||
const router = useRouter();
|
||||
const [pendingWidget, setPendingWidget] = useState<HomarrCustomWidgetV2 | null>(null);
|
||||
const [reviewOpened, reviewControls] = useDisclosure(false);
|
||||
|
||||
const install = async (submission: WorkshopSubmissionDetail) => {
|
||||
const widget = customWidgetImportSchema.parse(JSON.parse(submission.content) as unknown);
|
||||
setPendingWidget(widget);
|
||||
reviewControls.open();
|
||||
try {
|
||||
const parsed = customWidgetImportSchema.safeParse(JSON.parse(submission.content) as unknown);
|
||||
if (!parsed.success) throw new Error(t("installErrorDescription"));
|
||||
setPendingWidget(parsed.data);
|
||||
reviewControls.open();
|
||||
} catch (error) {
|
||||
showErrorNotification({
|
||||
title: t("installError"),
|
||||
message:
|
||||
error instanceof SyntaxError
|
||||
? t("installErrorDescription")
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: t("installErrorDescription"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -6,10 +6,9 @@ import { Alert, Button, FileInput, Group, Modal, Stack, Textarea, TextInput } fr
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import type { WorkshopUser } from "@homarr/workshop";
|
||||
import { WORKSHOP_API_URL, WorkshopClient } from "@homarr/workshop";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
const workshopUrl = process.env.NEXT_PUBLIC_WORKSHOP_API_URL ?? WORKSHOP_API_URL;
|
||||
import { createWorkshopClient } from "./workshop-client";
|
||||
|
||||
export function WorkshopPublishModal({
|
||||
opened,
|
||||
@@ -21,17 +20,15 @@ export function WorkshopPublishModal({
|
||||
widget: { id: string; name: string };
|
||||
}) {
|
||||
const t = useScopedI18n("workshop");
|
||||
const client = useMemo(() => new WorkshopClient(workshopUrl), []);
|
||||
const client = useMemo(createWorkshopClient, []);
|
||||
const utils = clientApi.useUtils();
|
||||
const [user, setUser] = useState<WorkshopUser | null>(null);
|
||||
const [title, setTitle] = useState(widget.name);
|
||||
const [description, setDescription] = useState("");
|
||||
const [changelog, setChangelog] = useState("");
|
||||
const [screenshots, setScreenshots] = useState<File[]>([]);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [published, setPublished] = useState(false);
|
||||
const initialChangelog = t("publish.initialChangelog");
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = client.subscribeToAuth(setUser);
|
||||
@@ -42,18 +39,17 @@ export function WorkshopPublishModal({
|
||||
if (!opened) return;
|
||||
setTitle(widget.name);
|
||||
setDescription("");
|
||||
setChangelog(initialChangelog);
|
||||
setScreenshots([]);
|
||||
setError(null);
|
||||
setPublished(false);
|
||||
}, [initialChangelog, opened, widget.name]);
|
||||
}, [opened, widget.name]);
|
||||
|
||||
const publish = async () => {
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const definition = await utils.customWidget.export.fetch({ id: widget.id });
|
||||
await client.create({ title, description, changelog, content: JSON.stringify(definition) }, screenshots);
|
||||
await client.create({ title, description, content: JSON.stringify(definition) }, screenshots);
|
||||
setPublished(true);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : t("publish.error"));
|
||||
@@ -102,12 +98,6 @@ export function WorkshopPublishModal({
|
||||
onChange={(event) => setDescription(event.currentTarget.value)}
|
||||
minRows={3}
|
||||
/>
|
||||
<Textarea
|
||||
label={t("publish.changelog")}
|
||||
value={changelog}
|
||||
onChange={(event) => setChangelog(event.currentTarget.value)}
|
||||
minRows={2}
|
||||
/>
|
||||
<FileInput
|
||||
label={t("publish.screenshots")}
|
||||
description={t("publish.screenshotsDescription")}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env";
|
||||
|
||||
export const env = createEnv({
|
||||
@@ -5,6 +7,7 @@ export const env = createEnv({
|
||||
UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false),
|
||||
DEMO_MODE: createBooleanSchema(false),
|
||||
DEMO_READ_ONLY: createBooleanSchema(true),
|
||||
WORKSHOP_API_URL: z.string().url().optional(),
|
||||
},
|
||||
experimental__runtimeEnv: process.env,
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
# syntax=docker/dockerfile:1.10
|
||||
|
||||
ARG POCKETBASE_IMAGE=ghcr.io/muchobien/pocketbase:0.39
|
||||
|
||||
FROM node:24.18.0-alpine AS dependencies
|
||||
WORKDIR /app
|
||||
ENV COREPACK_HOME=/corepack
|
||||
@@ -16,29 +18,17 @@ 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 alpine:3.22 AS pocketbase
|
||||
ARG TARGETARCH
|
||||
ARG POCKETBASE_VERSION=0.39.6
|
||||
RUN apk add --no-cache ca-certificates curl unzip
|
||||
RUN case "${TARGETARCH}" in amd64) PB_ARCH=amd64 ;; arm64) PB_ARCH=arm64 ;; arm) PB_ARCH=armv7 ;; *) exit 1 ;; esac && \
|
||||
BASE_URL="https://github.com/pocketbase/pocketbase/releases/download/v${POCKETBASE_VERSION}" && \
|
||||
ARCHIVE="pocketbase_${POCKETBASE_VERSION}_linux_${PB_ARCH}.zip" && \
|
||||
curl --fail --location --silent --show-error "${BASE_URL}/${ARCHIVE}" -o "/tmp/${ARCHIVE}" && \
|
||||
curl --fail --location --silent --show-error "${BASE_URL}/checksums.txt" -o /tmp/checksums.txt && \
|
||||
cd /tmp && grep " ${ARCHIVE}$" checksums.txt | sha256sum -c - && unzip "/tmp/${ARCHIVE}" pocketbase -d /out
|
||||
|
||||
FROM alpine:3.22 AS development
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
COPY --from=pocketbase /out/pocketbase /usr/local/bin/pocketbase
|
||||
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:8090/api/health || exit 1
|
||||
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 alpine:3.22 AS production
|
||||
RUN apk add --no-cache ca-certificates tzdata && addgroup -S pocketbase && adduser -S -G pocketbase pocketbase
|
||||
COPY --from=pocketbase /out/pocketbase /usr/local/bin/pocketbase
|
||||
FROM ${POCKETBASE_IMAGE} AS production
|
||||
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
|
||||
COPY apps/workshop/pb_hooks /pb_hooks
|
||||
@@ -46,5 +36,5 @@ 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_hooks /pb_migrations /pb_public
|
||||
USER pocketbase
|
||||
EXPOSE 8090
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD wget -q --spider http://127.0.0.1:8090/api/health || exit 1
|
||||
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"]
|
||||
+34
-3
@@ -2,7 +2,12 @@
|
||||
|
||||
Workshop is a small PocketBase service that serves the public documentation site and stores one Custom JSX v2 widget per submission.
|
||||
|
||||
It intentionally has no approval queue, user bans, reusable widget connections, or remote control over installed widgets. Submissions publish immediately. Authenticated users can vote and report; records in `workshop_admins` can inspect reports, dismiss them, and delete any submission.
|
||||
The runtime image extends `ghcr.io/muchobien/pocketbase:0.39`; Homarr adds only the built documentation, PocketBase
|
||||
collections, one small user-role guard, and its explicit serve arguments.
|
||||
|
||||
It intentionally has no approval queue, user bans, comments, email notifications, audit log, reusable widget connections,
|
||||
or remote control over installed widgets. Submissions publish immediately. Authenticated users can vote and report;
|
||||
users with `isAdmin` enabled can inspect and dismiss reports or delete any submission.
|
||||
|
||||
## Local development
|
||||
|
||||
@@ -10,7 +15,15 @@ It intentionally has no approval queue, user bans, reusable widget connections,
|
||||
PB_EXPOSE_PORT=18090 docker compose -f apps/workshop/docker-compose.yml up --build
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:18090/_/`, create the first PocketBase superuser, configure GitHub OAuth on the `users` collection, and add Workshop administrators through the `workshop_admins` collection.
|
||||
Compose passes the selected port to PocketBase as `PORT`, so it listens on the same port inside and outside the
|
||||
container. With the image directly, set both values: `docker run -e PORT=3003 -p 3003:3003 <workshop-image>`.
|
||||
|
||||
Open `http://127.0.0.1:18090/_/`, create the first PocketBase superuser, and configure GitHub OAuth on the `users` collection.
|
||||
|
||||
The `users.isAdmin` field defaults to `false` and cannot be changed by regular users. Appoint administrators from the
|
||||
PocketBase dashboard with a superuser account.
|
||||
|
||||
Users must sign in again after the role changes. Setting the value back to `false` removes Workshop administrator access.
|
||||
|
||||
The GitHub OAuth application needs one callback for the Workshop host:
|
||||
|
||||
@@ -26,6 +39,12 @@ Run the disposable service integration test from the repository root:
|
||||
pnpm test:workshop
|
||||
```
|
||||
|
||||
The Workshop does not maintain a second Custom Widget schema. Its frontend validates submissions with
|
||||
`customWidgetImportSchema` from `@homarr/custom-widgets`, and Homarr validates canonical content again before installation.
|
||||
PocketBase API rules enforce submission ownership, one vote/report per user and submission, and administrator-only
|
||||
moderation. Reports are dismissed by deleting them; PocketBase cascade deletion removes related votes and reports when a
|
||||
submission is deleted.
|
||||
|
||||
## Production docs preview
|
||||
|
||||
Build the documentation and serve it from PocketBase using the same production image layout as the hosted Workshop:
|
||||
@@ -36,10 +55,22 @@ pnpm docker:docs
|
||||
|
||||
Open `http://127.0.0.1:3003`. Set `DOCS_EXPOSE_PORT` to use another host port. The preview does not mount or persist PocketBase data.
|
||||
|
||||
Point a local Homarr checkout at it with:
|
||||
|
||||
```sh
|
||||
WORKSHOP_API_URL=http://127.0.0.1:3003 pnpm dev
|
||||
```
|
||||
|
||||
The production Homarr image reads the same variable when the container starts:
|
||||
|
||||
```sh
|
||||
docker run -p 7575:7575 -e WORKSHOP_API_URL=http://127.0.0.1:3003 <homarr-image>
|
||||
```
|
||||
|
||||
This command starts the profiled `docs` service from `apps/workshop/docker-compose.yml`. It does not start the development `workshop` service. Stop and remove the preview with:
|
||||
|
||||
```sh
|
||||
docker compose -f apps/workshop/docker-compose.yml --profile docs rm --stop --force docs
|
||||
```
|
||||
|
||||
Production data lives in `/pb_data`. The image serves the built Homarr documentation and Workshop UI from `/pb_public`.
|
||||
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`.
|
||||
@@ -6,15 +6,14 @@ services:
|
||||
context: ../..
|
||||
dockerfile: apps/workshop/Dockerfile
|
||||
target: development
|
||||
args:
|
||||
POCKETBASE_VERSION: 0.39.6
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PB_ALLOWED_ORIGINS: ${PB_ALLOWED_ORIGINS:-*}
|
||||
PORT: ${PB_EXPOSE_PORT:-8090}
|
||||
ports:
|
||||
- "127.0.0.1:${PB_EXPOSE_PORT:-8090}:8090"
|
||||
- "127.0.0.1:${PB_EXPOSE_PORT:-8090}:${PB_EXPOSE_PORT:-8090}"
|
||||
volumes:
|
||||
- ${WORKSHOP_DATA_DIR:-./pb_data}:/pb_data
|
||||
- pb_data:/pb_data
|
||||
- ./pb_hooks:/pb_hooks:ro
|
||||
- ./pb_migrations:/pb_migrations:ro
|
||||
security_opt:
|
||||
@@ -27,11 +26,15 @@ services:
|
||||
context: ../..
|
||||
dockerfile: apps/workshop/Dockerfile
|
||||
target: production
|
||||
args:
|
||||
POCKETBASE_VERSION: 0.39.6
|
||||
environment:
|
||||
PB_ALLOWED_ORIGINS: ${PB_ALLOWED_ORIGINS:-*}
|
||||
PORT: ${DOCS_EXPOSE_PORT:-3003}
|
||||
ports:
|
||||
- "127.0.0.1:${DOCS_EXPOSE_PORT:-3003}:8090"
|
||||
- "127.0.0.1:${DOCS_EXPOSE_PORT:-3003}:${DOCS_EXPOSE_PORT:-3003}"
|
||||
volumes:
|
||||
- pb_data:/pb_data
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
volumes:
|
||||
pb_data:
|
||||
@@ -2,7 +2,7 @@
|
||||
set -eu
|
||||
|
||||
exec pocketbase serve \
|
||||
--http=0.0.0.0:8090 \
|
||||
--http="0.0.0.0:${PORT:-8090}" \
|
||||
--origins="${PB_ALLOWED_ORIGINS:-*}" \
|
||||
--dir=/pb_data \
|
||||
--hooksDir=/pb_hooks \
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
onRecordCreate((e) => {
|
||||
e.record.set("isAdmin", false);
|
||||
e.next();
|
||||
}, "users");
|
||||
@@ -1,633 +0,0 @@
|
||||
const WIDGET_SCHEMA = "homarr-custom-widget-v2";
|
||||
const AUTH_TYPES = ["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"];
|
||||
const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
||||
const NETWORK_SCOPES = ["public", "private", "loopback"];
|
||||
const PERMISSIONS = ["view", "modify", "full"];
|
||||
const PARAMETER_TYPES = ["string", "number", "boolean"];
|
||||
const TOP_LEVEL_FIELDS = [
|
||||
"$schema",
|
||||
"name",
|
||||
"description",
|
||||
"iconUrl",
|
||||
"sources",
|
||||
"requests",
|
||||
"optionsSchema",
|
||||
"defaultOptions",
|
||||
"template",
|
||||
];
|
||||
const SOURCE_FIELDS = ["id", "name", "baseUrl", "networkScope", "auth"];
|
||||
const REQUEST_FIELDS = [
|
||||
"id",
|
||||
"sourceId",
|
||||
"kind",
|
||||
"method",
|
||||
"pathTemplate",
|
||||
"parameters",
|
||||
"queryTemplate",
|
||||
"bodyTemplate",
|
||||
"staticHeaders",
|
||||
"auth",
|
||||
"minimumBoardPermission",
|
||||
"trigger",
|
||||
"cacheTtlSeconds",
|
||||
"confirmation",
|
||||
"invalidates",
|
||||
];
|
||||
const OPTION_FIELDS = [
|
||||
"type",
|
||||
"title",
|
||||
"description",
|
||||
"default",
|
||||
"enum",
|
||||
"const",
|
||||
"minimum",
|
||||
"maximum",
|
||||
"multipleOf",
|
||||
"minLength",
|
||||
"maxLength",
|
||||
"minItems",
|
||||
"maxItems",
|
||||
"format",
|
||||
"items",
|
||||
"properties",
|
||||
"required",
|
||||
"additionalProperties",
|
||||
"if",
|
||||
"then",
|
||||
"else",
|
||||
"x-homarr",
|
||||
];
|
||||
const OPTION_TYPES = ["object", "array", "string", "number", "integer", "boolean", "null"];
|
||||
const OPTION_CONTROLS = [
|
||||
"text",
|
||||
"textarea",
|
||||
"number",
|
||||
"switch",
|
||||
"select",
|
||||
"multi-select",
|
||||
"slider",
|
||||
"date",
|
||||
"time",
|
||||
"color",
|
||||
"icon",
|
||||
"url",
|
||||
"duration",
|
||||
"timeZone",
|
||||
"json",
|
||||
];
|
||||
const OPTION_FORMATS = ["date", "date-time", "time", "color", "uri", "icon", "duration", "time-zone"];
|
||||
const RESERVED_HEADERS = [
|
||||
"authorization",
|
||||
"connection",
|
||||
"content-length",
|
||||
"cookie",
|
||||
"expect",
|
||||
"forwarded",
|
||||
"host",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"via",
|
||||
];
|
||||
const SENSITIVE_KEY =
|
||||
/(^|[-_])(authorization|api[-_]?keys?|passwords?|passwds?|secrets?|tokens?|access[-_]?tokens?|refresh[-_]?tokens?|client[-_]?secrets?)($|[-_])/i;
|
||||
const CREDENTIAL_TEXT =
|
||||
/(?:\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,}["'])/i;
|
||||
|
||||
function bad(message) {
|
||||
throw new BadRequestError(message);
|
||||
}
|
||||
|
||||
function isObject(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function validId(value) {
|
||||
return typeof value === "string" && /^[a-z][a-z0-9_-]{0,63}$/.test(value);
|
||||
}
|
||||
|
||||
function validBindingId(value) {
|
||||
return typeof value === "string" && /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(value);
|
||||
}
|
||||
|
||||
function validHttpUrl(value) {
|
||||
return typeof value === "string" && /^https?:\/\/[^\s]+$/i.test(value);
|
||||
}
|
||||
|
||||
function protectedHeader(name) {
|
||||
const normalized = name.trim().toLowerCase();
|
||||
return (
|
||||
RESERVED_HEADERS.includes(normalized) ||
|
||||
normalized.startsWith("proxy-") ||
|
||||
normalized.startsWith("sec-") ||
|
||||
normalized.startsWith("x-forwarded-")
|
||||
);
|
||||
}
|
||||
|
||||
function validHeaderName(name) {
|
||||
return (
|
||||
typeof name === "string" &&
|
||||
name.length <= 256 &&
|
||||
/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(name) &&
|
||||
!protectedHeader(name)
|
||||
);
|
||||
}
|
||||
|
||||
function hasControlCharacter(value) {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const code = value.charCodeAt(index);
|
||||
if (code <= 31 || code === 127) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function validateSource(source) {
|
||||
if (
|
||||
!isObject(source) ||
|
||||
Object.keys(source).some((key) => !SOURCE_FIELDS.includes(key)) ||
|
||||
!validId(source.id) ||
|
||||
typeof source.name !== "string" ||
|
||||
!source.name.trim() ||
|
||||
source.name.length > 128 ||
|
||||
typeof source.baseUrl !== "string" ||
|
||||
!NETWORK_SCOPES.includes(source.networkScope) ||
|
||||
!isObject(source.auth) ||
|
||||
!AUTH_TYPES.includes(source.auth.type)
|
||||
)
|
||||
bad("Widget contains an invalid source");
|
||||
if (!/^https?:\/\/[^/?#\s]+(?:\/[^?#\s]*)?$/i.test(source.baseUrl)) bad("Widget source URL is invalid");
|
||||
const authority = source.baseUrl.replace(/^https?:\/\//i, "").split("/")[0];
|
||||
if (authority.includes("@")) bad("Widget source URLs must not contain credentials or query values");
|
||||
const authFields =
|
||||
source.auth.type === "apiKeyHeader"
|
||||
? ["type", "headerName"]
|
||||
: source.auth.type === "apiKeyQuery"
|
||||
? ["type", "parameterName"]
|
||||
: ["type"];
|
||||
if (Object.keys(source.auth).some((key) => !authFields.includes(key))) bad("Widget source authentication is invalid");
|
||||
if (source.auth.type === "apiKeyHeader" && !validHeaderName(source.auth.headerName))
|
||||
bad("API key authentication requires a safe HTTP header name");
|
||||
if (
|
||||
source.auth.type === "apiKeyQuery" &&
|
||||
(typeof source.auth.parameterName !== "string" ||
|
||||
!source.auth.parameterName ||
|
||||
source.auth.parameterName.length > 256 ||
|
||||
hasControlCharacter(source.auth.parameterName))
|
||||
)
|
||||
bad("API key query authentication requires a safe parameterName");
|
||||
}
|
||||
|
||||
function validateRequest(request, sourceIds) {
|
||||
if (
|
||||
!isObject(request) ||
|
||||
Object.keys(request).some((key) => !REQUEST_FIELDS.includes(key)) ||
|
||||
!validId(request.id) ||
|
||||
!sourceIds.has(request.sourceId) ||
|
||||
!["query", "action"].includes(request.kind) ||
|
||||
!METHODS.includes(request.method) ||
|
||||
typeof request.pathTemplate !== "string" ||
|
||||
!request.pathTemplate.startsWith("/") ||
|
||||
request.pathTemplate.startsWith("//") ||
|
||||
request.pathTemplate.includes("\\") ||
|
||||
request.pathTemplate.includes("#") ||
|
||||
!isObject(request.parameters) ||
|
||||
!["inherit", "none"].includes(request.auth) ||
|
||||
!PERMISSIONS.includes(request.minimumBoardPermission)
|
||||
)
|
||||
bad("Widget contains an invalid request");
|
||||
if (
|
||||
Object.keys(request.parameters).length > 32 ||
|
||||
Object.entries(request.parameters).some(([name, type]) => !validBindingId(name) || !PARAMETER_TYPES.includes(type))
|
||||
)
|
||||
bad("Widget request parameters are invalid");
|
||||
const trigger = request.trigger || "manual";
|
||||
if (!["load", "manual"].includes(trigger) || (request.kind === "action" && trigger === "load"))
|
||||
bad("Actions cannot run automatically");
|
||||
if (request.kind === "query" && request.confirmation !== undefined) bad("Only actions can require confirmation");
|
||||
if (request.kind === "query" && request.invalidates !== undefined) bad("Only actions can invalidate queries");
|
||||
if (request.method === "DELETE" && request.minimumBoardPermission !== "full")
|
||||
bad("DELETE requests require full permission");
|
||||
if (
|
||||
request.queryTemplate !== undefined &&
|
||||
(!isObject(request.queryTemplate) || Object.keys(request.queryTemplate).some((key) => !key || key.length > 256))
|
||||
)
|
||||
bad("Widget query template is invalid");
|
||||
if (
|
||||
request.confirmation !== undefined &&
|
||||
(!isObject(request.confirmation) ||
|
||||
Object.keys(request.confirmation).some(
|
||||
(key) => !["title", "message", "confirmLabel", "destructive"].includes(key),
|
||||
) ||
|
||||
typeof request.confirmation.title !== "string" ||
|
||||
!request.confirmation.title ||
|
||||
request.confirmation.title.length > 128 ||
|
||||
typeof request.confirmation.message !== "string" ||
|
||||
!request.confirmation.message ||
|
||||
request.confirmation.message.length > 512 ||
|
||||
(request.confirmation.confirmLabel !== undefined &&
|
||||
(typeof request.confirmation.confirmLabel !== "string" ||
|
||||
!request.confirmation.confirmLabel ||
|
||||
request.confirmation.confirmLabel.length > 64)) ||
|
||||
(request.confirmation.destructive !== undefined && typeof request.confirmation.destructive !== "boolean"))
|
||||
)
|
||||
bad("Widget action confirmation is invalid");
|
||||
if (
|
||||
request.invalidates !== undefined &&
|
||||
(!Array.isArray(request.invalidates) ||
|
||||
request.invalidates.length > 32 ||
|
||||
request.invalidates.some((id) => !validId(id)))
|
||||
)
|
||||
bad("Widget invalidation targets are invalid");
|
||||
if (
|
||||
request.cacheTtlSeconds !== undefined &&
|
||||
(!Number.isInteger(request.cacheTtlSeconds) ||
|
||||
request.cacheTtlSeconds < 0 ||
|
||||
request.cacheTtlSeconds > 3600 ||
|
||||
(request.kind === "action" && request.cacheTtlSeconds !== 0))
|
||||
)
|
||||
bad("Widget request cache duration is invalid");
|
||||
if (request.staticHeaders !== undefined) {
|
||||
if (!isObject(request.staticHeaders) || Object.keys(request.staticHeaders).length > 32)
|
||||
bad("Widget static headers are invalid");
|
||||
for (const [name, value] of Object.entries(request.staticHeaders)) {
|
||||
if (!validHeaderName(name) || typeof value !== "string" || value.length > 8192)
|
||||
bad("Widget static headers are invalid");
|
||||
if ((SENSITIVE_KEY.test(name) && value.trim()) || /^\s*bearer\s+\S{8,}\s*$/i.test(value))
|
||||
bad("Widget static headers must not contain credentials");
|
||||
}
|
||||
}
|
||||
const used = new Set();
|
||||
const placeholderPattern = /\{([^{}]+)\}/g;
|
||||
let placeholder;
|
||||
while ((placeholder = placeholderPattern.exec(request.pathTemplate)) !== null) used.add(placeholder[1]);
|
||||
collectParams(request.queryTemplate, used);
|
||||
collectParams(request.bodyTemplate, used);
|
||||
for (const name of used)
|
||||
if (!Object.prototype.hasOwnProperty.call(request.parameters, name))
|
||||
bad("Widget request uses an undeclared parameter");
|
||||
}
|
||||
|
||||
function collectParams(value, result) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) collectParams(item, result);
|
||||
return;
|
||||
}
|
||||
if (!isObject(value)) return;
|
||||
const entries = Object.entries(value);
|
||||
if (entries.length === 1 && entries[0][0] === "$param" && typeof entries[0][1] === "string") {
|
||||
result.add(entries[0][1]);
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) collectParams(entry[1], result);
|
||||
}
|
||||
|
||||
function validateOptionsMetadata(metadata, schema) {
|
||||
if (
|
||||
!isObject(metadata) ||
|
||||
Object.keys(metadata).some((key) => !["control", "placeholder", "advanced", "order", "optionsSource"].includes(key))
|
||||
)
|
||||
bad("Widget option presentation metadata is invalid");
|
||||
if (metadata.control !== undefined && !OPTION_CONTROLS.includes(metadata.control))
|
||||
bad("Widget option control is invalid");
|
||||
const controlsByType = {
|
||||
string: ["text", "textarea", "select", "date", "time", "color", "icon", "url", "timeZone", "json"],
|
||||
number: ["number", "select", "slider", "duration", "json"],
|
||||
integer: ["number", "select", "slider", "duration", "json"],
|
||||
boolean: ["switch", "select", "json"],
|
||||
array: ["multi-select", "json"],
|
||||
object: ["json"],
|
||||
null: ["json"],
|
||||
};
|
||||
if (
|
||||
metadata.control !== undefined &&
|
||||
schema.type !== undefined &&
|
||||
!controlsByType[schema.type].includes(metadata.control)
|
||||
)
|
||||
bad("Widget option control does not match its type");
|
||||
if (metadata.placeholder !== undefined && typeof metadata.placeholder !== "string")
|
||||
bad("Widget option placeholder is invalid");
|
||||
if (metadata.advanced !== undefined && typeof metadata.advanced !== "boolean")
|
||||
bad("Widget advanced option metadata is invalid");
|
||||
if (metadata.order !== undefined && (typeof metadata.order !== "number" || !Number.isFinite(metadata.order)))
|
||||
bad("Widget option order is invalid");
|
||||
if (metadata.optionsSource !== undefined) {
|
||||
const source = metadata.optionsSource;
|
||||
if (
|
||||
!isObject(source) ||
|
||||
Object.keys(source).some((key) => !["requestId", "itemsPath", "valuePath", "labelPath"].includes(key)) ||
|
||||
!["requestId", "valuePath", "labelPath"].every((key) => typeof source[key] === "string" && source[key]) ||
|
||||
(source.itemsPath !== undefined && (typeof source.itemsPath !== "string" || !source.itemsPath))
|
||||
)
|
||||
bad("Widget dynamic option source is invalid");
|
||||
if (!["select", "multi-select"].includes(metadata.control)) bad("Widget dynamic options require a select control");
|
||||
}
|
||||
}
|
||||
|
||||
function validateOptionsSchema(schema, root, requireType) {
|
||||
if (!isObject(schema) || Object.keys(schema).some((key) => !OPTION_FIELDS.includes(key)))
|
||||
bad("Widget options schema is invalid");
|
||||
if (requireType !== false && !OPTION_TYPES.includes(schema.type)) bad("Every widget option needs one supported type");
|
||||
if (schema.type !== undefined && !OPTION_TYPES.includes(schema.type)) bad("Widget option type is invalid");
|
||||
if (root && schema.type !== "object") bad("Widget options schema root must be an object");
|
||||
if (schema.type === "object" && schema.additionalProperties !== false)
|
||||
bad("Widget object options must reject unknown fields");
|
||||
for (const key of ["title", "description"])
|
||||
if (schema[key] !== undefined && typeof schema[key] !== "string") bad("Widget option labels are invalid");
|
||||
for (const key of ["minimum", "maximum", "multipleOf"])
|
||||
if (schema[key] !== undefined && (typeof schema[key] !== "number" || !Number.isFinite(schema[key])))
|
||||
bad("Widget numeric option constraints are invalid");
|
||||
if (schema.multipleOf !== undefined && schema.multipleOf <= 0) bad("Widget multipleOf must be positive");
|
||||
if (schema.minimum !== undefined && schema.maximum !== undefined && schema.minimum > schema.maximum)
|
||||
bad("Widget option bounds are invalid");
|
||||
for (const key of ["minLength", "maxLength", "minItems", "maxItems"])
|
||||
if (schema[key] !== undefined && (!Number.isInteger(schema[key]) || schema[key] < 0))
|
||||
bad("Widget option size constraints are invalid");
|
||||
if (schema.enum !== undefined && (!Array.isArray(schema.enum) || schema.enum.length < 1 || schema.enum.length > 100))
|
||||
bad("Widget option enum is invalid");
|
||||
if (schema.format !== undefined && (!OPTION_FORMATS.includes(schema.format) || schema.type !== "string"))
|
||||
bad("Widget option format is invalid");
|
||||
if (Array.isArray(schema.enum) && schema.enum.some((entry) => !optionMatches({ type: schema.type }, entry)))
|
||||
bad("Widget option enum values do not match their type");
|
||||
if (schema.const !== undefined && !optionMatches({ type: schema.type }, schema.const))
|
||||
bad("Widget option constant does not match its type");
|
||||
if (schema.properties !== undefined) {
|
||||
if (!isObject(schema.properties)) bad("Widget option properties are invalid");
|
||||
for (const [name, child] of Object.entries(schema.properties)) {
|
||||
if (!validBindingId(name))
|
||||
bad("Widget option names must start with a letter and contain letters, numbers, - or _");
|
||||
if (SENSITIVE_KEY.test(name)) bad("Credentials must use source authentication, not widget options");
|
||||
validateOptionsSchema(child, false, true);
|
||||
}
|
||||
if (schema.type !== undefined && schema.type !== "object") bad("Widget option properties require an object type");
|
||||
if (
|
||||
schema.required !== undefined &&
|
||||
(!Array.isArray(schema.required) ||
|
||||
schema.required.some(
|
||||
(key) => typeof key !== "string" || !Object.prototype.hasOwnProperty.call(schema.properties, key),
|
||||
))
|
||||
)
|
||||
bad("Widget required options are invalid");
|
||||
}
|
||||
if (schema.required !== undefined && !Array.isArray(schema.required)) bad("Widget required options are invalid");
|
||||
if (schema.type === "array" && schema.items === undefined) bad("Widget array options require items");
|
||||
if (schema.items !== undefined) {
|
||||
if (schema.type !== undefined && schema.type !== "array") bad("Widget option items require an array type");
|
||||
validateOptionsSchema(schema.items, false, true);
|
||||
}
|
||||
for (const branch of ["if", "then", "else"])
|
||||
if (schema[branch] !== undefined) validateOptionsSchema(schema[branch], false, branch !== "if");
|
||||
if (schema["x-homarr"] !== undefined) validateOptionsMetadata(schema["x-homarr"], schema);
|
||||
}
|
||||
|
||||
function optionMatches(schema, value) {
|
||||
if (schema.const !== undefined && value !== schema.const) return false;
|
||||
if (Array.isArray(schema.enum) && !schema.enum.includes(value)) return false;
|
||||
if (schema.type === "object" && !isObject(value)) return false;
|
||||
if (schema.type === "array" && !Array.isArray(value)) return false;
|
||||
if (schema.type === "integer" && (typeof value !== "number" || !Number.isInteger(value))) return false;
|
||||
if (schema.type === "number" && typeof value !== "number") return false;
|
||||
if (schema.type === "string" && typeof value !== "string") return false;
|
||||
if (schema.type === "boolean" && typeof value !== "boolean") return false;
|
||||
if (schema.type === "null" && value !== null) return false;
|
||||
if (isObject(schema.properties) && isObject(value)) {
|
||||
for (const [key, child] of Object.entries(schema.properties)) {
|
||||
if (value[key] !== undefined && isObject(child) && !optionMatches(child, value[key])) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateOptionsValue(schema, value) {
|
||||
if (!optionMatches(schema, value)) bad("Widget default options do not match their schema");
|
||||
if (schema.type === "object") {
|
||||
if (!isObject(value)) bad("Widget default options do not match their schema");
|
||||
const branch =
|
||||
isObject(schema.if) && optionMatches(schema.if, value)
|
||||
? schema.then
|
||||
: isObject(schema.if)
|
||||
? schema.else
|
||||
: undefined;
|
||||
const properties = Object.assign(
|
||||
{},
|
||||
isObject(schema.properties) ? schema.properties : {},
|
||||
isObject(branch) && isObject(branch.properties) ? branch.properties : {},
|
||||
);
|
||||
const required = (Array.isArray(schema.required) ? schema.required : []).concat(
|
||||
isObject(branch) && Array.isArray(branch.required) ? branch.required : [],
|
||||
);
|
||||
for (const key of required) if (value[key] === undefined) bad("Widget default options omit a required value");
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
if (!isObject(properties[key])) bad("Widget default options contain an unknown value");
|
||||
validateOptionsValue(properties[key], child);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (schema.type === "array") {
|
||||
if (!Array.isArray(value)) bad("Widget default options do not match their schema");
|
||||
if (isObject(schema.items)) for (const item of value) validateOptionsValue(schema.items, item);
|
||||
return;
|
||||
}
|
||||
if (schema.type === "integer" && (typeof value !== "number" || !Number.isInteger(value)))
|
||||
bad("Widget default options do not match their schema");
|
||||
if (schema.type === "number" && typeof value !== "number") bad("Widget default options do not match their schema");
|
||||
if (schema.type === "string" && typeof value !== "string") bad("Widget default options do not match their schema");
|
||||
if (schema.type === "boolean" && typeof value !== "boolean") bad("Widget default options do not match their schema");
|
||||
if (schema.type === "null" && value !== null) bad("Widget default options do not match their schema");
|
||||
}
|
||||
|
||||
function collectDynamicOptionRequestIds(schema, result) {
|
||||
if (!isObject(schema)) return;
|
||||
const metadata = schema["x-homarr"];
|
||||
if (isObject(metadata) && isObject(metadata.optionsSource)) result.add(metadata.optionsSource.requestId);
|
||||
if (isObject(schema.properties))
|
||||
for (const child of Object.values(schema.properties)) collectDynamicOptionRequestIds(child, result);
|
||||
if (schema.items !== undefined) collectDynamicOptionRequestIds(schema.items, result);
|
||||
for (const branch of ["if", "then", "else"])
|
||||
if (schema[branch] !== undefined) collectDynamicOptionRequestIds(schema[branch], result);
|
||||
}
|
||||
|
||||
function containsCredential(value, key) {
|
||||
if (SENSITIVE_KEY.test(key || "") && typeof value === "string" && value.trim()) return true;
|
||||
if (Array.isArray(value)) return value.some((entry) => containsCredential(entry, ""));
|
||||
if (!isObject(value)) return false;
|
||||
if (Object.keys(value).length === 1 && typeof value.$param === "string") return false;
|
||||
return Object.entries(value).some(([childKey, child]) => containsCredential(child, childKey));
|
||||
}
|
||||
|
||||
function containsPrototypeKey(value) {
|
||||
if (Array.isArray(value)) return value.some(containsPrototypeKey);
|
||||
if (!isObject(value)) return false;
|
||||
return Object.entries(value).some(
|
||||
([key, child]) =>
|
||||
["__proto__", "prototype", "constructor"].includes(key.toLowerCase()) || containsPrototypeKey(child),
|
||||
);
|
||||
}
|
||||
|
||||
function validateTemplate(template) {
|
||||
if (typeof template !== "string" || !template.trim() || template.length > 50000) bad("Widget template is invalid");
|
||||
if (
|
||||
/(?:<\s*(?:script|style|iframe|object|embed|form)\b|dangerouslySetInnerHTML\s*=|\bon[A-Z][A-Za-z]*\s*=|\b(?:component|renderRoot|withinPortal|ref)\s*=|\b(?:fetch|eval|XMLHttpRequest)\s*\(|\bimport\s|\{[^{}]*\bstate(?:\.|\[))/i.test(
|
||||
template,
|
||||
)
|
||||
)
|
||||
bad("Widget template requests a blocked capability");
|
||||
}
|
||||
|
||||
function validateTemplateRequests(template, requests) {
|
||||
const byId = new Map(requests.map((request) => [request.id, request]));
|
||||
const componentPattern = /<(SubFetch|ActionButton|ToggleSwitch)\b([^>]*)>/g;
|
||||
let match;
|
||||
while ((match = componentPattern.exec(template)) !== null) {
|
||||
const idMatch = match[2].match(/\brequestId\s*=\s*(?:"([^"]+)"|'([^']+)')/);
|
||||
const requestId = idMatch && (idMatch[1] || idMatch[2]);
|
||||
if (!requestId) bad(`${match[1]} must use a literal requestId`);
|
||||
const request = byId.get(requestId);
|
||||
const expectedKind = match[1] === "SubFetch" ? "query" : "action";
|
||||
if (!request || request.kind !== expectedKind) bad(`${match[1]} references an invalid request`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateTemplateBindings(template) {
|
||||
const bindingPattern = /<[A-Z][A-Za-z0-9.]*(?:\s[^<>]*?)?\bbind\s*=\s*(?:"([^"]+)"|'([^']+)'|\{)/g;
|
||||
let match;
|
||||
while ((match = bindingPattern.exec(template)) !== null) {
|
||||
const inputName = match[1] || match[2];
|
||||
if (!inputName) bad("bind must use a literal input name");
|
||||
if (!validBindingId(inputName)) bad("bind must use a valid input name");
|
||||
}
|
||||
}
|
||||
|
||||
function validateSubmission(record) {
|
||||
const content = record.getString("content");
|
||||
let widget;
|
||||
try {
|
||||
widget = JSON.parse(content);
|
||||
} catch {
|
||||
bad("Widget content is not valid JSON");
|
||||
}
|
||||
if (
|
||||
!isObject(widget) ||
|
||||
widget.$schema !== WIDGET_SCHEMA ||
|
||||
typeof widget.name !== "string" ||
|
||||
!widget.name.trim() ||
|
||||
widget.name.length > 128 ||
|
||||
(widget.description !== undefined && (typeof widget.description !== "string" || widget.description.length > 512)) ||
|
||||
(widget.iconUrl !== undefined && (!validHttpUrl(widget.iconUrl) || /^https?:\/\/[^/]*@/i.test(widget.iconUrl))) ||
|
||||
Object.keys(widget).some((key) => !TOP_LEVEL_FIELDS.includes(key)) ||
|
||||
!Array.isArray(widget.sources) ||
|
||||
widget.sources.length < 1 ||
|
||||
widget.sources.length > 8 ||
|
||||
!Array.isArray(widget.requests) ||
|
||||
widget.requests.length > 64 ||
|
||||
!isObject(widget.defaultOptions)
|
||||
)
|
||||
bad("Widget does not match homarr-custom-widget-v2");
|
||||
validateOptionsSchema(widget.optionsSchema, true, true);
|
||||
validateOptionsValue(widget.optionsSchema, widget.defaultOptions);
|
||||
if (
|
||||
widget.iconUrl &&
|
||||
/[?&](?:authorization|api[-_]?keys?|passwords?|passwds?|secrets?|tokens?|access[-_]?tokens?|refresh[-_]?tokens?|client[-_]?secrets?)=/i.test(
|
||||
widget.iconUrl,
|
||||
)
|
||||
)
|
||||
bad("Widget icon URLs must not contain credentials");
|
||||
const sourceIds = new Set();
|
||||
for (const source of widget.sources) {
|
||||
validateSource(source);
|
||||
if (sourceIds.has(source.id)) bad("Widget source IDs must be unique");
|
||||
sourceIds.add(source.id);
|
||||
}
|
||||
const requestIds = new Set();
|
||||
for (const request of widget.requests) {
|
||||
validateRequest(request, sourceIds);
|
||||
if (requestIds.has(request.id)) bad("Widget request IDs must be unique");
|
||||
requestIds.add(request.id);
|
||||
}
|
||||
for (const request of widget.requests)
|
||||
for (const target of request.invalidates || []) {
|
||||
const targetRequest = widget.requests.find((candidate) => candidate.id === target);
|
||||
if (!targetRequest || targetRequest.kind !== "query") bad("Widget invalidates an unknown or non-query request");
|
||||
}
|
||||
for (const request of widget.requests) {
|
||||
if (request.auth !== "inherit") continue;
|
||||
const source = widget.sources.find((candidate) => candidate.id === request.sourceId);
|
||||
if (
|
||||
source.auth.type === "apiKeyHeader" &&
|
||||
Object.keys(request.staticHeaders || {}).some(
|
||||
(name) => name.toLowerCase() === source.auth.headerName.toLowerCase(),
|
||||
)
|
||||
)
|
||||
bad("Widget request overrides source authentication");
|
||||
if (
|
||||
source.auth.type === "apiKeyQuery" &&
|
||||
Object.keys(request.queryTemplate || {}).some(
|
||||
(name) => name.toLowerCase() === source.auth.parameterName.toLowerCase(),
|
||||
)
|
||||
)
|
||||
bad("Widget request overrides source authentication");
|
||||
}
|
||||
const dynamicOptionRequestIds = new Set();
|
||||
collectDynamicOptionRequestIds(widget.optionsSchema, dynamicOptionRequestIds);
|
||||
for (const requestId of dynamicOptionRequestIds) {
|
||||
const request = widget.requests.find((candidate) => candidate.id === requestId);
|
||||
if (!request || request.kind !== "query") bad("Widget dynamic options must reference a query request");
|
||||
}
|
||||
validateTemplate(widget.template);
|
||||
validateTemplateRequests(widget.template, widget.requests);
|
||||
validateTemplateBindings(widget.template);
|
||||
for (const request of widget.requests) {
|
||||
for (const [name, value] of Object.entries(request.staticHeaders || {})) {
|
||||
if ((SENSITIVE_KEY.test(name) && String(value).trim()) || /^\s*bearer\s+\S{8,}\s*$/i.test(String(value)))
|
||||
bad("Widget static headers must not contain credentials");
|
||||
}
|
||||
}
|
||||
if (
|
||||
CREDENTIAL_TEXT.test(widget.template) ||
|
||||
containsCredential(widget.defaultOptions, "") ||
|
||||
widget.requests.some(
|
||||
(request) => containsCredential(request.bodyTemplate, "") || containsCredential(request.queryTemplate, ""),
|
||||
)
|
||||
)
|
||||
bad("Widget exports must not contain credentials");
|
||||
if (
|
||||
containsPrototypeKey(widget.optionsSchema) ||
|
||||
containsPrototypeKey(widget.defaultOptions) ||
|
||||
widget.requests.some(
|
||||
(request) => containsPrototypeKey(request.queryTemplate) || containsPrototypeKey(request.bodyTemplate),
|
||||
)
|
||||
)
|
||||
bad("Widget contains a prototype-pollution key");
|
||||
record.set("contentHash", $security.sha256(content));
|
||||
}
|
||||
|
||||
function isAdmin(app, userId) {
|
||||
try {
|
||||
app.findFirstRecordByFilter("workshop_admins", "user = {:user}", { user: userId });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function snapshot(record) {
|
||||
const data = record.publicExport();
|
||||
if (typeof data.content === "string") {
|
||||
data.contentHash = $security.sha256(data.content);
|
||||
delete data.content;
|
||||
}
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
|
||||
function audit(app, actor, action, values) {
|
||||
const record = new Record(app.findCollectionByNameOrId("workshop_admin_actions"));
|
||||
record.set("actor", actor.id);
|
||||
record.set("action", action);
|
||||
Object.entries(values).forEach(([key, value]) => record.set(key, value));
|
||||
app.save(record);
|
||||
}
|
||||
|
||||
module.exports = { audit, isAdmin, snapshot, validateSubmission };
|
||||
@@ -1,78 +0,0 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
onRecordCreateRequest((e) => {
|
||||
const utils = require(`${__hooks}/workshop-utils.js`);
|
||||
if (!e.auth) throw new UnauthorizedError("Authentication is required");
|
||||
utils.validateSubmission(e.record);
|
||||
const user = e.app.findRecordById("users", e.auth.id);
|
||||
e.record.set("author", e.auth.id);
|
||||
e.record.set("authorName", user.getString("displayName"));
|
||||
e.record.set("revision", 1);
|
||||
e.next();
|
||||
}, "submissions");
|
||||
|
||||
onRecordUpdateRequest((e) => {
|
||||
const utils = require(`${__hooks}/workshop-utils.js`);
|
||||
const original = e.app.findRecordById("submissions", e.record.id);
|
||||
e.record.set("author", original.get("author"));
|
||||
e.record.set("authorName", original.get("authorName"));
|
||||
e.record.set("revision", original.getInt("revision") + 1);
|
||||
utils.validateSubmission(e.record);
|
||||
e.next();
|
||||
}, "submissions");
|
||||
|
||||
onRecordDeleteRequest((e) => {
|
||||
const utils = require(`${__hooks}/workshop-utils.js`);
|
||||
const adminDelete = e.auth && utils.isAdmin(e.app, e.auth.id) && e.record.getString("author") !== e.auth.id;
|
||||
const snapshot = adminDelete ? utils.snapshot(e.record) : "";
|
||||
e.next();
|
||||
if (adminDelete)
|
||||
utils.audit(e.app, e.auth, "delete_submission", {
|
||||
submissionId: e.record.id,
|
||||
reason: "Deleted by Workshop administrator",
|
||||
snapshot,
|
||||
});
|
||||
}, "submissions");
|
||||
|
||||
onRecordCreateRequest((e) => {
|
||||
if (!e.auth) throw new UnauthorizedError("Authentication is required");
|
||||
const value = e.record.getInt("value");
|
||||
if (value !== 1 && value !== -1) throw new BadRequestError("Vote must be 1 or -1");
|
||||
e.record.set("user", e.auth.id);
|
||||
e.next();
|
||||
}, "votes");
|
||||
|
||||
onRecordUpdateRequest((e) => {
|
||||
const original = e.app.findRecordById("votes", e.record.id);
|
||||
const value = e.record.getInt("value");
|
||||
if (value !== 1 && value !== -1) throw new BadRequestError("Vote must be 1 or -1");
|
||||
e.record.set("user", original.get("user"));
|
||||
e.record.set("submission", original.get("submission"));
|
||||
e.next();
|
||||
}, "votes");
|
||||
|
||||
onRecordCreateRequest((e) => {
|
||||
if (!e.auth) throw new UnauthorizedError("Authentication is required");
|
||||
e.record.set("reporter", e.auth.id);
|
||||
e.record.set("status", "open");
|
||||
e.next();
|
||||
}, "reports");
|
||||
|
||||
onRecordUpdateRequest((e) => {
|
||||
const utils = require(`${__hooks}/workshop-utils.js`);
|
||||
const original = e.app.findRecordById("reports", e.record.id);
|
||||
if (e.record.getString("status") !== "dismissed") throw new BadRequestError("Reports can only be dismissed");
|
||||
e.record.set("submission", original.get("submission"));
|
||||
e.record.set("reporter", original.get("reporter"));
|
||||
e.record.set("category", original.get("category"));
|
||||
e.record.set("explanation", original.get("explanation"));
|
||||
e.next();
|
||||
if (original.getString("status") !== "dismissed") {
|
||||
utils.audit(e.app, e.auth, "dismiss_report", {
|
||||
submissionId: original.getString("submission"),
|
||||
reportId: original.id,
|
||||
reason: e.record.getString("dismissalReason") || "Dismissed by Workshop administrator",
|
||||
snapshot: utils.snapshot(original),
|
||||
});
|
||||
}
|
||||
}, "reports");
|
||||
@@ -12,43 +12,27 @@ migrate(
|
||||
if (!users.fields.getByName("displayName"))
|
||||
users.fields.add(new TextField({ name: "displayName", required: true, min: 1, max: 100 }));
|
||||
if (!users.fields.getByName("avatarUrl")) users.fields.add(new URLField({ name: "avatarUrl" }));
|
||||
app.save(users);
|
||||
if (!users.fields.getByName("isAdmin")) users.fields.add(new BoolField({ name: "isAdmin" }));
|
||||
|
||||
const admins = new Collection({
|
||||
type: "base",
|
||||
name: "workshop_admins",
|
||||
listRule: "user = @request.auth.id",
|
||||
viewRule: "user = @request.auth.id",
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
{ type: "relation", name: "user", required: true, maxSelect: 1, collectionId: users.id, cascadeDelete: true },
|
||||
{ type: "autodate", name: "created", onCreate: true },
|
||||
],
|
||||
});
|
||||
admins.addIndex("idx_workshop_admin_user", true, "user", "");
|
||||
app.save(admins);
|
||||
const adminRule = "@collection.workshop_admins.user ?= @request.auth.id";
|
||||
const adminRule = "@request.auth.isAdmin = true";
|
||||
users.listRule = adminRule;
|
||||
users.viewRule = `id = @request.auth.id || ${adminRule}`;
|
||||
users.updateRule = "id = @request.auth.id";
|
||||
users.updateRule = "id = @request.auth.id && @request.body.isAdmin:isset = false";
|
||||
users.deleteRule = null;
|
||||
app.save(users);
|
||||
|
||||
const submissions = new Collection({
|
||||
type: "base",
|
||||
name: "submissions",
|
||||
listRule: adminRule,
|
||||
listRule: "",
|
||||
viewRule: "",
|
||||
createRule: "@request.auth.id != '' && @request.body.author = @request.auth.id",
|
||||
updateRule: "author = @request.auth.id",
|
||||
updateRule: "author = @request.auth.id && @request.body.author:changed = false",
|
||||
deleteRule: `author = @request.auth.id || ${adminRule}`,
|
||||
fields: [
|
||||
{ type: "text", name: "title", required: true, min: 3, max: 100 },
|
||||
{ type: "text", name: "description", max: 2000 },
|
||||
{ type: "text", name: "content", required: true, max: 1000000 },
|
||||
{ type: "text", name: "contentHash", required: true, max: 64 },
|
||||
{
|
||||
type: "file",
|
||||
name: "screenshots",
|
||||
@@ -58,9 +42,6 @@ migrate(
|
||||
thumbs: ["480x320", "960x640"],
|
||||
},
|
||||
{ type: "relation", name: "author", required: true, maxSelect: 1, collectionId: users.id, cascadeDelete: true },
|
||||
{ type: "text", name: "authorName", required: true, max: 100 },
|
||||
{ type: "number", name: "revision", required: true, onlyInt: true, min: 1 },
|
||||
{ type: "text", name: "changelog", max: 2000 },
|
||||
{ type: "autodate", name: "created", onCreate: true },
|
||||
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
|
||||
],
|
||||
@@ -74,8 +55,10 @@ migrate(
|
||||
name: "votes",
|
||||
listRule: "user = @request.auth.id",
|
||||
viewRule: "user = @request.auth.id",
|
||||
createRule: "@request.auth.id != '' && @request.body.user = @request.auth.id",
|
||||
updateRule: "user = @request.auth.id",
|
||||
createRule:
|
||||
"@request.auth.id != '' && @request.body.user = @request.auth.id && (@request.body.value = 1 || @request.body.value = -1)",
|
||||
updateRule:
|
||||
"user = @request.auth.id && @request.body.user:changed = false && @request.body.submission:changed = false && (@request.body.value = 1 || @request.body.value = -1)",
|
||||
deleteRule: "user = @request.auth.id",
|
||||
fields: [
|
||||
{
|
||||
@@ -102,8 +85,8 @@ migrate(
|
||||
listRule: adminRule,
|
||||
viewRule: `reporter = @request.auth.id || ${adminRule}`,
|
||||
createRule: "@request.auth.id != '' && @request.body.reporter = @request.auth.id",
|
||||
updateRule: adminRule,
|
||||
deleteRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: adminRule,
|
||||
fields: [
|
||||
{
|
||||
type: "relation",
|
||||
@@ -129,55 +112,29 @@ migrate(
|
||||
values: ["malicious", "spam", "copyright", "inappropriate", "other"],
|
||||
},
|
||||
{ type: "text", name: "explanation", required: true, min: 3, max: 1000 },
|
||||
{ type: "select", name: "status", required: true, maxSelect: 1, values: ["open", "dismissed"] },
|
||||
{ type: "text", name: "dismissalReason", max: 1000 },
|
||||
{ type: "autodate", name: "created", onCreate: true },
|
||||
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
|
||||
],
|
||||
});
|
||||
reports.addIndex("idx_reports_reporter_submission", true, "reporter, submission", "");
|
||||
reports.addIndex("idx_reports_status_created", false, "status, created", "");
|
||||
reports.addIndex("idx_reports_created", false, "created", "");
|
||||
app.save(reports);
|
||||
|
||||
const actions = new Collection({
|
||||
type: "base",
|
||||
name: "workshop_admin_actions",
|
||||
listRule: adminRule,
|
||||
viewRule: adminRule,
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
{ type: "relation", name: "actor", required: true, maxSelect: 1, collectionId: users.id },
|
||||
{
|
||||
type: "select",
|
||||
name: "action",
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
values: ["delete_submission", "dismiss_report"],
|
||||
},
|
||||
{ type: "text", name: "submissionId", max: 30 },
|
||||
{ type: "text", name: "reportId", max: 30 },
|
||||
{ type: "text", name: "reason", max: 1000 },
|
||||
{ type: "text", name: "snapshot", max: 100000 },
|
||||
{ type: "autodate", name: "created", onCreate: true },
|
||||
],
|
||||
});
|
||||
actions.addIndex("idx_workshop_admin_actions_created", false, "created", "");
|
||||
app.save(actions);
|
||||
|
||||
const listings = new Collection({
|
||||
type: "view",
|
||||
name: "workshop_listings",
|
||||
listRule: "",
|
||||
viewRule: "",
|
||||
viewQuery: `
|
||||
SELECT s.id, s.title, s.description, s.contentHash, s.screenshots,
|
||||
s.revision, s.changelog, s.author, s.authorName, s.created, s.updated,
|
||||
SELECT s.id, s.title, s.description, s.screenshots, s.author, u.displayName AS authorName,
|
||||
s.created, s.updated,
|
||||
COALESCE(SUM(CASE WHEN v.value = 1 THEN 1 ELSE 0 END), 0) AS upvotes,
|
||||
COALESCE(SUM(CASE WHEN v.value = -1 THEN 1 ELSE 0 END), 0) AS downvotes,
|
||||
COALESCE(SUM(v.value), 0) AS score
|
||||
FROM submissions s LEFT JOIN votes v ON v.submission = s.id GROUP BY s.id
|
||||
FROM submissions s
|
||||
JOIN users u ON u.id = s.author
|
||||
LEFT JOIN votes v ON v.submission = s.id
|
||||
GROUP BY s.id
|
||||
`,
|
||||
});
|
||||
app.save(listings);
|
||||
@@ -195,14 +152,7 @@ migrate(
|
||||
app.save(settings);
|
||||
},
|
||||
(app) => {
|
||||
for (const name of [
|
||||
"workshop_listings",
|
||||
"workshop_admin_actions",
|
||||
"reports",
|
||||
"votes",
|
||||
"submissions",
|
||||
"workshop_admins",
|
||||
]) {
|
||||
for (const name of ["workshop_listings", "reports", "votes", "submissions"]) {
|
||||
try {
|
||||
app.delete(app.findCollectionByNameOrId(name));
|
||||
} catch {}
|
||||
|
||||
@@ -15,7 +15,10 @@ const expectStatus = async (path, init, expected) => {
|
||||
...init,
|
||||
headers: { "content-type": "application/json", ...init.headers },
|
||||
});
|
||||
if (response.status !== expected) throw new Error(`Expected ${expected} for ${path}, received ${response.status}`);
|
||||
if (response.status !== expected) {
|
||||
const body = await response.text();
|
||||
throw new Error(`Expected ${expected} for ${path}, received ${response.status}: ${body}`);
|
||||
}
|
||||
};
|
||||
|
||||
const root = await request("/api/collections/_superusers/auth-with-password", {
|
||||
@@ -25,18 +28,14 @@ const root = await request("/api/collections/_superusers/auth-with-password", {
|
||||
const rootHeaders = { authorization: `Bearer ${root.token}` };
|
||||
const collections = await request("/api/collections?perPage=200", { headers: rootHeaders });
|
||||
const collectionNames = new Set(collections.items.map((collection) => collection.name));
|
||||
for (const required of [
|
||||
"submissions",
|
||||
"votes",
|
||||
"reports",
|
||||
"workshop_admins",
|
||||
"workshop_admin_actions",
|
||||
"workshop_listings",
|
||||
]) {
|
||||
for (const required of ["submissions", "votes", "reports", "workshop_listings"]) {
|
||||
if (!collectionNames.has(required)) throw new Error(`Missing Workshop collection: ${required}`);
|
||||
}
|
||||
for (const removed of ["comments", "workshop_admin_actions", "workshop_admins"]) {
|
||||
if (collectionNames.has(removed)) throw new Error(`Removed Workshop collection still exists: ${removed}`);
|
||||
}
|
||||
|
||||
// Workshop production authentication is GitHub OAuth. Password auth is enabled only in this disposable test database.
|
||||
// Production uses GitHub OAuth. Password auth is enabled only in this disposable database.
|
||||
const usersCollection = await request("/api/collections/users", { headers: rootHeaders });
|
||||
await request("/api/collections/users", {
|
||||
method: "PATCH",
|
||||
@@ -44,29 +43,42 @@ await request("/api/collections/users", {
|
||||
body: JSON.stringify({ ...usersCollection, passwordAuth: { enabled: true, identityFields: ["email"] } }),
|
||||
});
|
||||
|
||||
const password = "WorkshopAuthor123!";
|
||||
const user = await request("/api/collections/users/records", {
|
||||
method: "POST",
|
||||
headers: rootHeaders,
|
||||
body: JSON.stringify({
|
||||
email: "widget-author@example.invalid",
|
||||
emailVisibility: false,
|
||||
verified: true,
|
||||
password,
|
||||
passwordConfirm: password,
|
||||
displayName: "Widget Author",
|
||||
}),
|
||||
});
|
||||
await request("/api/collections/workshop_admins/records", {
|
||||
method: "POST",
|
||||
headers: rootHeaders,
|
||||
body: JSON.stringify({ user: user.id }),
|
||||
});
|
||||
const auth = await request("/api/collections/users/auth-with-password", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ identity: user.email, password }),
|
||||
});
|
||||
const userHeaders = { authorization: `Bearer ${auth.token}` };
|
||||
const createUser = async (email, displayName, password, extra = {}) =>
|
||||
request("/api/collections/users/records", {
|
||||
method: "POST",
|
||||
headers: rootHeaders,
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
emailVisibility: false,
|
||||
verified: true,
|
||||
password,
|
||||
passwordConfirm: password,
|
||||
displayName,
|
||||
...extra,
|
||||
}),
|
||||
});
|
||||
|
||||
const signIn = async (email, password) => {
|
||||
const auth = await request("/api/collections/users/auth-with-password", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ identity: email, password }),
|
||||
});
|
||||
return { auth, headers: { authorization: `Bearer ${auth.token}` } };
|
||||
};
|
||||
|
||||
const authorPassword = "WorkshopAuthor123!";
|
||||
const author = await createUser("widget-author@example.invalid", "Widget Author", authorPassword, { isAdmin: true });
|
||||
if (author.isAdmin !== false) throw new Error("New users must not be able to start as Workshop administrators");
|
||||
const authorSession = await signIn(author.email, authorPassword);
|
||||
await expectStatus(
|
||||
`/api/collections/users/records/${author.id}`,
|
||||
{ method: "PATCH", headers: authorSession.headers, body: JSON.stringify({ isAdmin: true }) },
|
||||
404,
|
||||
);
|
||||
|
||||
const visitorPassword = "WorkshopVisitor123!";
|
||||
const visitor = await createUser("widget-visitor@example.invalid", "Widget Visitor", visitorPassword);
|
||||
const visitorSession = await signIn(visitor.email, visitorPassword);
|
||||
|
||||
const widget = {
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
@@ -85,163 +97,143 @@ const widget = {
|
||||
defaultOptions: {},
|
||||
template: "<Text>Runtime probe</Text>",
|
||||
};
|
||||
|
||||
const submission = await request("/api/collections/submissions/records", {
|
||||
method: "POST",
|
||||
headers: userHeaders,
|
||||
headers: authorSession.headers,
|
||||
body: JSON.stringify({
|
||||
title: "Workshop runtime probe",
|
||||
description: "PocketBase integration test",
|
||||
content: JSON.stringify(widget),
|
||||
changelog: "Initial",
|
||||
author: user.id,
|
||||
author: author.id,
|
||||
}),
|
||||
});
|
||||
if (submission.revision !== 1 || submission.authorName !== "Widget Author" || submission.contentHash.length !== 64) {
|
||||
throw new Error("Submission hooks did not normalize publication metadata");
|
||||
if (submission.author !== author.id || submission.title !== "Workshop runtime probe") {
|
||||
throw new Error("Submission publication failed");
|
||||
}
|
||||
|
||||
await expectStatus(
|
||||
"/api/collections/submissions/records",
|
||||
{
|
||||
method: "POST",
|
||||
headers: userHeaders,
|
||||
body: JSON.stringify({
|
||||
title: "Removed format",
|
||||
content: JSON.stringify({ displayType: "raw", displayConfig: { type: "raw" } }),
|
||||
author: user.id,
|
||||
}),
|
||||
},
|
||||
400,
|
||||
);
|
||||
|
||||
await expectStatus(
|
||||
"/api/collections/submissions/records",
|
||||
{
|
||||
method: "POST",
|
||||
headers: userHeaders,
|
||||
body: JSON.stringify({
|
||||
title: "Unsafe authentication header",
|
||||
content: JSON.stringify({
|
||||
...widget,
|
||||
sources: [{ ...widget.sources[0], auth: { type: "apiKeyHeader", headerName: "X-Forwarded-Authorization" } }],
|
||||
}),
|
||||
}),
|
||||
},
|
||||
400,
|
||||
);
|
||||
|
||||
await expectStatus(
|
||||
"/api/collections/submissions/records",
|
||||
{
|
||||
method: "POST",
|
||||
headers: userHeaders,
|
||||
body: JSON.stringify({
|
||||
title: "Unknown template request",
|
||||
content: JSON.stringify({ ...widget, template: '<SubFetch requestId="missing" />' }),
|
||||
}),
|
||||
},
|
||||
400,
|
||||
);
|
||||
|
||||
const updatedWidget = { ...widget, description: "Updated definition" };
|
||||
const updatedSubmission = await request(`/api/collections/submissions/records/${submission.id}`, {
|
||||
method: "PATCH",
|
||||
headers: userHeaders,
|
||||
body: JSON.stringify({ content: JSON.stringify(updatedWidget), changelog: "Updated" }),
|
||||
headers: authorSession.headers,
|
||||
body: JSON.stringify({ title: "Updated runtime probe" }),
|
||||
});
|
||||
if (updatedSubmission.revision !== 2) throw new Error("Content edits must increment the submission revision");
|
||||
if (updatedSubmission.title !== "Updated runtime probe") throw new Error("Authors must be able to edit their submission");
|
||||
|
||||
await request("/api/collections/votes/records", {
|
||||
await expectStatus(
|
||||
`/api/collections/submissions/records/${submission.id}`,
|
||||
{ method: "PATCH", headers: authorSession.headers, body: JSON.stringify({ author: visitor.id }) },
|
||||
404,
|
||||
);
|
||||
await expectStatus(
|
||||
`/api/collections/submissions/records/${submission.id}`,
|
||||
{ method: "PATCH", headers: visitorSession.headers, body: JSON.stringify({ title: "Not mine" }) },
|
||||
404,
|
||||
);
|
||||
await expectStatus(
|
||||
`/api/collections/submissions/records/${submission.id}`,
|
||||
{ method: "DELETE", headers: visitorSession.headers },
|
||||
404,
|
||||
);
|
||||
|
||||
const vote = await request("/api/collections/votes/records", {
|
||||
method: "POST",
|
||||
headers: userHeaders,
|
||||
body: JSON.stringify({ submission: submission.id, user: user.id, value: 1 }),
|
||||
headers: visitorSession.headers,
|
||||
body: JSON.stringify({ submission: submission.id, user: visitor.id, value: 1 }),
|
||||
});
|
||||
await expectStatus(
|
||||
"/api/collections/votes/records",
|
||||
{
|
||||
method: "POST",
|
||||
headers: userHeaders,
|
||||
body: JSON.stringify({ submission: submission.id, user: user.id, value: 1 }),
|
||||
headers: visitorSession.headers,
|
||||
body: JSON.stringify({ submission: submission.id, user: visitor.id, value: 1 }),
|
||||
},
|
||||
400,
|
||||
);
|
||||
const listing = await request(`/api/collections/workshop_listings/records/${submission.id}`);
|
||||
if (listing.score !== 1 || listing.upvotes !== 1 || listing.downvotes !== 0) {
|
||||
throw new Error("Workshop listing vote totals are incorrect");
|
||||
}
|
||||
|
||||
const report = await request("/api/collections/reports/records", {
|
||||
method: "POST",
|
||||
headers: userHeaders,
|
||||
body: JSON.stringify({
|
||||
submission: submission.id,
|
||||
reporter: user.id,
|
||||
category: "other",
|
||||
explanation: "Runtime moderation test",
|
||||
status: "dismissed",
|
||||
}),
|
||||
});
|
||||
const reports = await request("/api/collections/reports/records?filter=status%3D%27open%27", {
|
||||
headers: userHeaders,
|
||||
});
|
||||
if (reports.items.length !== 1 || reports.items[0].reporter !== user.id) {
|
||||
throw new Error("Workshop administrators cannot inspect open reports and reporters");
|
||||
}
|
||||
|
||||
await request(`/api/collections/reports/records/${report.id}`, {
|
||||
method: "PATCH",
|
||||
headers: userHeaders,
|
||||
body: JSON.stringify({ status: "dismissed", dismissalReason: "Runtime test complete" }),
|
||||
});
|
||||
const actions = await request("/api/collections/workshop_admin_actions/records", { headers: userHeaders });
|
||||
if (!actions.items.some((action) => action.action === "dismiss_report" && action.reportId === report.id)) {
|
||||
throw new Error("Workshop report dismissal was not audited");
|
||||
}
|
||||
|
||||
const authorPassword = "WorkshopSecondAuthor123!";
|
||||
const secondAuthor = await request("/api/collections/users/records", {
|
||||
method: "POST",
|
||||
headers: rootHeaders,
|
||||
body: JSON.stringify({
|
||||
email: "second-widget-author@example.invalid",
|
||||
emailVisibility: false,
|
||||
verified: true,
|
||||
password: authorPassword,
|
||||
passwordConfirm: authorPassword,
|
||||
displayName: "Second Author",
|
||||
}),
|
||||
});
|
||||
const secondAuth = await request("/api/collections/users/auth-with-password", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ identity: secondAuthor.email, password: authorPassword }),
|
||||
});
|
||||
const secondHeaders = { authorization: `Bearer ${secondAuth.token}` };
|
||||
const secondSubmission = await request("/api/collections/submissions/records", {
|
||||
method: "POST",
|
||||
headers: secondHeaders,
|
||||
body: JSON.stringify({
|
||||
title: "Administrator deletion probe",
|
||||
content: JSON.stringify(widget),
|
||||
author: secondAuthor.id,
|
||||
}),
|
||||
});
|
||||
await expectStatus(
|
||||
`/api/collections/submissions/records/${submission.id}`,
|
||||
{ method: "DELETE", headers: secondHeaders },
|
||||
`/api/collections/votes/records/${vote.id}`,
|
||||
{ method: "PATCH", headers: visitorSession.headers, body: JSON.stringify({ user: author.id }) },
|
||||
404,
|
||||
);
|
||||
await expectStatus(
|
||||
`/api/collections/submissions/records/${secondSubmission.id}`,
|
||||
{ method: "DELETE", headers: userHeaders },
|
||||
204,
|
||||
`/api/collections/votes/records/${vote.id}`,
|
||||
{ method: "PATCH", headers: visitorSession.headers, body: JSON.stringify({ value: 0 }) },
|
||||
404,
|
||||
);
|
||||
const deletionActions = await request("/api/collections/workshop_admin_actions/records", { headers: userHeaders });
|
||||
if (
|
||||
!deletionActions.items.some(
|
||||
(action) => action.action === "delete_submission" && action.submissionId === secondSubmission.id,
|
||||
)
|
||||
) {
|
||||
throw new Error("Workshop administrator deletion was not audited");
|
||||
|
||||
const listing = await request(`/api/collections/workshop_listings/records/${submission.id}`);
|
||||
if (listing.score !== 1 || listing.upvotes !== 1 || listing.downvotes !== 0 || listing.authorName !== "Widget Author") {
|
||||
throw new Error("Workshop listing data is incorrect");
|
||||
}
|
||||
|
||||
await expectStatus(
|
||||
"/api/collections/reports/records",
|
||||
{
|
||||
method: "POST",
|
||||
headers: visitorSession.headers,
|
||||
body: JSON.stringify({
|
||||
submission: submission.id,
|
||||
reporter: author.id,
|
||||
category: "other",
|
||||
explanation: "Spoofed reporter",
|
||||
}),
|
||||
},
|
||||
400,
|
||||
);
|
||||
const report = await request("/api/collections/reports/records", {
|
||||
method: "POST",
|
||||
headers: visitorSession.headers,
|
||||
body: JSON.stringify({
|
||||
submission: submission.id,
|
||||
reporter: visitor.id,
|
||||
category: "other",
|
||||
explanation: "Runtime moderation test",
|
||||
}),
|
||||
});
|
||||
const ownReport = await request(`/api/collections/reports/records/${report.id}`, { headers: visitorSession.headers });
|
||||
if (ownReport.reporter !== visitor.id) throw new Error("Reporters must be able to view their own report");
|
||||
const hiddenReports = await request("/api/collections/reports/records", { headers: visitorSession.headers });
|
||||
if (hiddenReports.items.length !== 0) throw new Error("Regular users must not list moderation reports");
|
||||
await expectStatus(
|
||||
`/api/collections/reports/records/${report.id}`,
|
||||
{ method: "DELETE", headers: visitorSession.headers },
|
||||
404,
|
||||
);
|
||||
|
||||
const promotedAuthor = await request(`/api/collections/users/records/${author.id}`, {
|
||||
method: "PATCH",
|
||||
headers: rootHeaders,
|
||||
body: JSON.stringify({ isAdmin: true }),
|
||||
});
|
||||
if (promotedAuthor.isAdmin !== true) throw new Error("PocketBase superusers must be able to appoint admins");
|
||||
|
||||
const reports = await request("/api/collections/reports/records", { headers: authorSession.headers });
|
||||
if (reports.items.length !== 1 || reports.items[0].reporter !== visitor.id) {
|
||||
throw new Error("Workshop administrators must be able to review reports");
|
||||
}
|
||||
await expectStatus(
|
||||
`/api/collections/reports/records/${report.id}`,
|
||||
{ method: "DELETE", headers: authorSession.headers },
|
||||
204,
|
||||
);
|
||||
|
||||
const visitorSubmission = await request("/api/collections/submissions/records", {
|
||||
method: "POST",
|
||||
headers: visitorSession.headers,
|
||||
body: JSON.stringify({
|
||||
title: "Administrator deletion probe",
|
||||
content: JSON.stringify(widget),
|
||||
author: visitor.id,
|
||||
}),
|
||||
});
|
||||
await expectStatus(
|
||||
`/api/collections/submissions/records/${visitorSubmission.id}`,
|
||||
{ method: "DELETE", headers: authorSession.headers },
|
||||
204,
|
||||
);
|
||||
await expectStatus(
|
||||
`/api/collections/submissions/records/${submission.id}`,
|
||||
{ method: "DELETE", headers: authorSession.headers },
|
||||
204,
|
||||
);
|
||||
|
||||
console.log("Workshop PocketBase integration passed");
|
||||
@@ -4,7 +4,7 @@ import { z } from "zod/v4";
|
||||
import { createId } from "@homarr/common";
|
||||
import { encryptSecret } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { and, eq, notInArray } from "@homarr/db";
|
||||
import { and, eq, handleTransactionsAsync, notInArray } from "@homarr/db";
|
||||
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
|
||||
import {
|
||||
customWidgetCreateSchema,
|
||||
@@ -49,22 +49,34 @@ export const customWidgetRouter = createTRPCRouter({
|
||||
const definition = customWidgetDefinitionSchema.parse(candidate);
|
||||
assertSecretSources(definition.sources, secrets);
|
||||
const id = createId();
|
||||
await ctx.db.insert(customWidgetDefinitions).values({
|
||||
const definitionRow = {
|
||||
id,
|
||||
...serializeCustomWidgetDefinition(definition),
|
||||
creatorId: ctx.session.user.id,
|
||||
};
|
||||
const updatedAt = new Date();
|
||||
const secretRows = secrets.map((secret) => ({
|
||||
definitionId: id,
|
||||
sourceId: secret.sourceId,
|
||||
kind: secret.kind,
|
||||
encryptedValue: encryptSecret(secret.value),
|
||||
updatedAt,
|
||||
}));
|
||||
|
||||
await handleTransactionsAsync(ctx.db, {
|
||||
async handleAsync(db, schema) {
|
||||
await db.transaction(async (transaction) => {
|
||||
await transaction.insert(schema.customWidgetDefinitions).values(definitionRow);
|
||||
if (secretRows.length > 0) await transaction.insert(schema.customWidgetSecrets).values(secretRows);
|
||||
});
|
||||
},
|
||||
handleSync(db) {
|
||||
db.transaction((transaction) => {
|
||||
transaction.insert(customWidgetDefinitions).values(definitionRow).run();
|
||||
if (secretRows.length > 0) transaction.insert(customWidgetSecrets).values(secretRows).run();
|
||||
});
|
||||
},
|
||||
});
|
||||
if (secrets.length > 0) {
|
||||
await ctx.db.insert(customWidgetSecrets).values(
|
||||
secrets.map((secret) => ({
|
||||
definitionId: id,
|
||||
sourceId: secret.sourceId,
|
||||
kind: secret.kind,
|
||||
encryptedValue: encryptSecret(secret.value),
|
||||
updatedAt: new Date(),
|
||||
})),
|
||||
);
|
||||
}
|
||||
logger.info("Created custom widget definition", { id, name: definition.name });
|
||||
return { id };
|
||||
}),
|
||||
@@ -88,50 +100,96 @@ export const customWidgetRouter = createTRPCRouter({
|
||||
}
|
||||
const definition = customWidgetDefinitionSchema.parse({ ...current, ...changes });
|
||||
if (secrets) assertSecretSources(definition.sources, secrets);
|
||||
const definitionChanges = { ...serializeCustomWidgetDefinition(definition), updatedAt: new Date() };
|
||||
const secretRows = secrets?.map((secret) => ({
|
||||
definitionId: id,
|
||||
sourceId: secret.sourceId,
|
||||
kind: secret.kind,
|
||||
encryptedValue: encryptSecret(secret.value),
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
const sourceIds = definition.sources.map((source) => source.id);
|
||||
|
||||
await ctx.db
|
||||
.update(customWidgetDefinitions)
|
||||
.set({ ...serializeCustomWidgetDefinition(definition), updatedAt: new Date() })
|
||||
.where(eq(customWidgetDefinitions.id, id));
|
||||
await handleTransactionsAsync(ctx.db, {
|
||||
async handleAsync(db, schema) {
|
||||
await db.transaction(async (transaction) => {
|
||||
await transaction
|
||||
.update(schema.customWidgetDefinitions)
|
||||
.set(definitionChanges)
|
||||
.where(eq(schema.customWidgetDefinitions.id, id));
|
||||
|
||||
await ctx.db.delete(customWidgetSecrets).where(
|
||||
and(
|
||||
eq(customWidgetSecrets.definitionId, id),
|
||||
notInArray(
|
||||
customWidgetSecrets.sourceId,
|
||||
definition.sources.map((source) => source.id),
|
||||
),
|
||||
),
|
||||
);
|
||||
await transaction
|
||||
.delete(schema.customWidgetSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.customWidgetSecrets.definitionId, id),
|
||||
notInArray(schema.customWidgetSecrets.sourceId, sourceIds),
|
||||
),
|
||||
);
|
||||
|
||||
for (const source of definition.sources) {
|
||||
const kinds = [...requiredSecretKinds(source.auth.type)];
|
||||
const where = and(eq(customWidgetSecrets.definitionId, id), eq(customWidgetSecrets.sourceId, source.id));
|
||||
await ctx.db
|
||||
.delete(customWidgetSecrets)
|
||||
.where(kinds.length > 0 ? and(where, notInArray(customWidgetSecrets.kind, kinds)) : where);
|
||||
}
|
||||
for (const source of definition.sources) {
|
||||
const kinds = [...requiredSecretKinds(source.auth.type)];
|
||||
const where = and(
|
||||
eq(schema.customWidgetSecrets.definitionId, id),
|
||||
eq(schema.customWidgetSecrets.sourceId, source.id),
|
||||
);
|
||||
await transaction
|
||||
.delete(schema.customWidgetSecrets)
|
||||
.where(kinds.length > 0 ? and(where, notInArray(schema.customWidgetSecrets.kind, kinds)) : where);
|
||||
}
|
||||
|
||||
if (secrets !== undefined) {
|
||||
for (const secret of secrets) {
|
||||
await ctx.db
|
||||
.delete(customWidgetSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(customWidgetSecrets.definitionId, id),
|
||||
eq(customWidgetSecrets.sourceId, secret.sourceId),
|
||||
eq(customWidgetSecrets.kind, secret.kind),
|
||||
),
|
||||
);
|
||||
await ctx.db.insert(customWidgetSecrets).values({
|
||||
definitionId: id,
|
||||
sourceId: secret.sourceId,
|
||||
kind: secret.kind,
|
||||
encryptedValue: encryptSecret(secret.value),
|
||||
updatedAt: new Date(),
|
||||
for (const secret of secretRows ?? []) {
|
||||
await transaction
|
||||
.delete(schema.customWidgetSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.customWidgetSecrets.definitionId, id),
|
||||
eq(schema.customWidgetSecrets.sourceId, secret.sourceId),
|
||||
eq(schema.customWidgetSecrets.kind, secret.kind),
|
||||
),
|
||||
);
|
||||
await transaction.insert(schema.customWidgetSecrets).values(secret);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
handleSync(db) {
|
||||
db.transaction((transaction) => {
|
||||
transaction
|
||||
.update(customWidgetDefinitions)
|
||||
.set(definitionChanges)
|
||||
.where(eq(customWidgetDefinitions.id, id))
|
||||
.run();
|
||||
|
||||
transaction
|
||||
.delete(customWidgetSecrets)
|
||||
.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));
|
||||
transaction
|
||||
.delete(customWidgetSecrets)
|
||||
.where(kinds.length > 0 ? and(where, notInArray(customWidgetSecrets.kind, kinds)) : where)
|
||||
.run();
|
||||
}
|
||||
|
||||
for (const secret of secretRows ?? []) {
|
||||
transaction
|
||||
.delete(customWidgetSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(customWidgetSecrets.definitionId, id),
|
||||
eq(customWidgetSecrets.sourceId, secret.sourceId),
|
||||
eq(customWidgetSecrets.kind, secret.kind),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
transaction.insert(customWidgetSecrets).values(secret).run();
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
logger.info("Updated custom widget definition", { id });
|
||||
}),
|
||||
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
CUSTOM_WIDGET_MCP_AUTHORING_PROMPT,
|
||||
customWidgetImportSchema,
|
||||
getCustomWidgetJsonSchema,
|
||||
getCustomWidgetSkill,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import { CUSTOM_WIDGET_MCP_AUTHORING_PROMPT } from "@homarr/custom-widgets/authoring-prompt";
|
||||
import { getCustomWidgetSkill } from "@homarr/custom-widgets/authoring-resources";
|
||||
import { customWidgetImportSchema, getCustomWidgetJsonSchema } from "@homarr/custom-widgets/core";
|
||||
|
||||
import { permissionRequiredProcedure, publicProcedure } from "../../trpc";
|
||||
|
||||
|
||||
@@ -2,7 +2,11 @@ import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { decryptSecret } from "@homarr/common/server";
|
||||
import { customWidgetDefinitionSchema, customWidgetSecretsInputSchema } from "@homarr/custom-widgets/core";
|
||||
import {
|
||||
customWidgetDefinitionSchema,
|
||||
customWidgetSecretsInputSchema,
|
||||
validateCustomWidgetOptions,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import { eq } from "@homarr/db";
|
||||
import { customWidgetDefinitions } from "@homarr/db/schema";
|
||||
|
||||
@@ -15,6 +19,7 @@ const previewCreateInputSchema = z.object({
|
||||
definition: customWidgetDefinitionSchema,
|
||||
secrets: customWidgetSecretsInputSchema.default([]),
|
||||
definitionId: z.string().optional(),
|
||||
options: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
const previewCreateProcedure = manageProcedure
|
||||
@@ -24,6 +29,16 @@ 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);
|
||||
if (optionIssues.length > 0) {
|
||||
const issue = optionIssues[0];
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: issue ? `${issue.path}: ${issue.message}` : "Preview options are invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const secrets = [...input.secrets];
|
||||
if (input.definitionId) {
|
||||
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
@@ -59,7 +74,7 @@ const previewCreateProcedure = manageProcedure
|
||||
name: input.definition.name,
|
||||
template: input.definition.template,
|
||||
optionsSchema: input.definition.optionsSchema,
|
||||
defaultOptions: input.definition.defaultOptions,
|
||||
options,
|
||||
secrets,
|
||||
definitionId: input.definitionId,
|
||||
});
|
||||
@@ -87,7 +102,7 @@ export const previewBaseProcedures = {
|
||||
expiresAt: session.expiresAt,
|
||||
template: session.template,
|
||||
optionsSchema: session.optionsSchema,
|
||||
defaultOptions: session.defaultOptions,
|
||||
options: session.options,
|
||||
requests: session.requests.map(
|
||||
({
|
||||
id,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
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";
|
||||
@@ -42,3 +44,11 @@ export const getPreviewRequestSource = (session: CustomWidgetPreviewSession, sou
|
||||
};
|
||||
return { source, auth };
|
||||
};
|
||||
|
||||
export function resolvePreviewRequestParams(
|
||||
request: CustomJsxRequest,
|
||||
options: Record<string, unknown>,
|
||||
suppliedParams: Record<string, string | number | boolean>,
|
||||
) {
|
||||
return request.trigger === "load" ? resolveCustomWidgetOptionsBinding(request, options) : suppliedParams;
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { resolveCustomWidgetOptionsBinding } from "@homarr/custom-widgets/core";
|
||||
|
||||
import { permissionRequiredProcedure } from "../../trpc";
|
||||
import {
|
||||
getPreviewRequestSource,
|
||||
previewSessionRequestSchema,
|
||||
recordPreviewJournal,
|
||||
resolvePreviewRequestParams,
|
||||
} from "./preview-procedure-helpers";
|
||||
import { executeCustomWidgetRequest } from "./request-executor";
|
||||
import { hashRuntimeParams, renderRequestBody, renderRequestTarget } from "./request-manifest";
|
||||
@@ -33,8 +32,7 @@ export const previewQueryProcedures = {
|
||||
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Preview query was not found" });
|
||||
const resolved = getPreviewRequestSource(session, request.sourceId);
|
||||
if (!resolved) throw new TRPCError({ code: "NOT_FOUND", message: "Preview source was not found" });
|
||||
const params =
|
||||
request.trigger === "load" ? resolveCustomWidgetOptionsBinding(request, session.defaultOptions) : input.params;
|
||||
const params = resolvePreviewRequestParams(request, session.options, input.params);
|
||||
const targetUrl = renderRequestTarget(resolved.source.baseUrl, request, params);
|
||||
const body = renderRequestBody(request.bodyTemplate, params);
|
||||
const release = await acquireCustomWidgetRequestLimit({
|
||||
|
||||
@@ -4,7 +4,7 @@ import { z } from "zod/v4";
|
||||
import { createId } from "@homarr/common";
|
||||
import { encryptSecret } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { eq } from "@homarr/db";
|
||||
import { eq, handleTransactionsAsync } from "@homarr/db";
|
||||
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
|
||||
import { customWidgetImportSchema, customWidgetSecretsInputSchema } from "@homarr/custom-widgets/core";
|
||||
|
||||
@@ -35,22 +35,34 @@ export const transferProcedures = {
|
||||
}
|
||||
assertSecretSources(input.widget.sources, input.secrets);
|
||||
const id = createId();
|
||||
await ctx.db.insert(customWidgetDefinitions).values({
|
||||
const definitionRow = {
|
||||
id,
|
||||
...serializeCustomWidgetDefinition(input.widget),
|
||||
creatorId: ctx.session.user.id,
|
||||
};
|
||||
const updatedAt = new Date();
|
||||
const secretRows = input.secrets.map((secret) => ({
|
||||
definitionId: id,
|
||||
sourceId: secret.sourceId,
|
||||
kind: secret.kind,
|
||||
encryptedValue: encryptSecret(secret.value),
|
||||
updatedAt,
|
||||
}));
|
||||
|
||||
await handleTransactionsAsync(ctx.db, {
|
||||
async handleAsync(db, schema) {
|
||||
await db.transaction(async (transaction) => {
|
||||
await transaction.insert(schema.customWidgetDefinitions).values(definitionRow);
|
||||
if (secretRows.length > 0) await transaction.insert(schema.customWidgetSecrets).values(secretRows);
|
||||
});
|
||||
},
|
||||
handleSync(db) {
|
||||
db.transaction((transaction) => {
|
||||
transaction.insert(customWidgetDefinitions).values(definitionRow).run();
|
||||
if (secretRows.length > 0) transaction.insert(customWidgetSecrets).values(secretRows).run();
|
||||
});
|
||||
},
|
||||
});
|
||||
if (input.secrets.length > 0) {
|
||||
await ctx.db.insert(customWidgetSecrets).values(
|
||||
input.secrets.map((secret) => ({
|
||||
definitionId: id,
|
||||
sourceId: secret.sourceId,
|
||||
kind: secret.kind,
|
||||
encryptedValue: encryptSecret(secret.value),
|
||||
updatedAt: new Date(),
|
||||
})),
|
||||
);
|
||||
}
|
||||
logger.info("Imported custom widget definition", { id, name: input.widget.name });
|
||||
return { id };
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import { eq, sql } from "@homarr/db";
|
||||
import { customWidgetDefinitions, customWidgetSecrets, users } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import { BUNDLED_CUSTOM_WIDGETS } from "@homarr/custom-widgets/core";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { customWidgetRouter } from "../../custom-widget/custom-widget-router";
|
||||
|
||||
const userId = createId();
|
||||
const session = {
|
||||
user: {
|
||||
id: userId,
|
||||
permissions: ["custom-widget-manage", "custom-widget-secret-write"],
|
||||
colorScheme: "light",
|
||||
},
|
||||
expires: new Date().toISOString(),
|
||||
} satisfies Session;
|
||||
|
||||
const jellyfin = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-jellyfin")?.widget;
|
||||
if (!jellyfin) throw new Error("Jellyfin bundled widget is missing");
|
||||
|
||||
function createCaller(db: ReturnType<typeof createDb>) {
|
||||
return customWidgetRouter.createCaller({ db, deviceType: undefined, session });
|
||||
}
|
||||
|
||||
async function prepareDatabase() {
|
||||
const db = createDb();
|
||||
await db.insert(users).values({ id: userId });
|
||||
return db;
|
||||
}
|
||||
|
||||
function rejectSecretInserts(db: ReturnType<typeof createDb>) {
|
||||
db.run(
|
||||
sql.raw(`CREATE TRIGGER reject_custom_widget_secret_insert
|
||||
BEFORE INSERT ON custom_widget_secret
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'forced secret insert failure');
|
||||
END`),
|
||||
);
|
||||
}
|
||||
|
||||
const secret = { sourceId: "default", kind: "apiKey" as const, value: "test-secret" };
|
||||
|
||||
describe("custom widget definition persistence", () => {
|
||||
test("rolls back create when its secret cannot be stored", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
rejectSecretInserts(db);
|
||||
|
||||
await expect(caller.create({ ...jellyfin, secrets: [secret] })).rejects.toThrow("forced secret insert failure");
|
||||
|
||||
expect(await db.query.customWidgetDefinitions.findMany()).toHaveLength(0);
|
||||
expect(await db.query.customWidgetSecrets.findMany()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("rolls back import when its secret cannot be stored", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
rejectSecretInserts(db);
|
||||
|
||||
await expect(caller.import({ widget: jellyfin, secrets: [secret] })).rejects.toThrow(
|
||||
"forced secret insert failure",
|
||||
);
|
||||
|
||||
expect(await db.query.customWidgetDefinitions.findMany()).toHaveLength(0);
|
||||
expect(await db.query.customWidgetSecrets.findMany()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("rolls back definition and secret changes when a replacement secret cannot be stored", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
const created = await caller.create({ ...jellyfin, secrets: [secret] });
|
||||
const originalDefinition = await db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, created.id),
|
||||
});
|
||||
const originalSecret = await db.query.customWidgetSecrets.findFirst({
|
||||
where: eq(customWidgetSecrets.definitionId, created.id),
|
||||
});
|
||||
rejectSecretInserts(db);
|
||||
|
||||
await expect(
|
||||
caller.update({
|
||||
id: created.id,
|
||||
name: "Changed Jellyfin Library",
|
||||
secrets: [{ ...secret, value: "replacement-secret" }],
|
||||
}),
|
||||
).rejects.toThrow("forced secret insert failure");
|
||||
|
||||
const storedDefinition = await db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, created.id),
|
||||
});
|
||||
const storedSecret = await db.query.customWidgetSecrets.findFirst({
|
||||
where: eq(customWidgetSecrets.definitionId, created.id),
|
||||
});
|
||||
expect(storedDefinition?.name).toBe(originalDefinition?.name);
|
||||
expect(storedDefinition?.updatedAt).toEqual(originalDefinition?.updatedAt);
|
||||
expect(storedSecret).toEqual(originalSecret);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { CustomJsxRequest } from "@homarr/custom-widgets/core";
|
||||
|
||||
import { resolvePreviewRequestParams } from "../../custom-widget/preview-procedure-helpers";
|
||||
|
||||
const loadRequest: CustomJsxRequest = {
|
||||
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",
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves invoking component parameters for manual requests", () => {
|
||||
const manualRequest = { ...loadRequest, trigger: "manual" as const, optionsBinding: undefined };
|
||||
expect(resolvePreviewRequestParams(manualRequest, { environmentId: 7 }, { endpointId: 12 })).toEqual({
|
||||
endpointId: 12,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,11 @@
|
||||
}
|
||||
},
|
||||
"exports": {
|
||||
"./authoring-prompt": "./src/core/ai-prompt.ts",
|
||||
"./authoring-resources": "./src/core/authoring-resources.ts",
|
||||
"./catalog": "./src/core/catalog.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",
|
||||
@@ -21,9 +25,11 @@
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"check:architecture": "node ./scripts/check-architecture.mjs",
|
||||
"generate:components": "tsx ./scripts/generate-component-metadata.ts",
|
||||
"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:components && pnpm generate:component-docs",
|
||||
"generate": "pnpm generate:catalog && pnpm generate:component-docs && pnpm generate:skill",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"test": "vitest run",
|
||||
|
||||
@@ -89,6 +89,16 @@ const editorAdapter = await readFile(
|
||||
if (!/lazy\(\(\)\s*=>\s*import\(["'].\/direct-code-mirror["']\)\)/u.test(editorAdapter)) {
|
||||
failures.push("CodeMirror must remain dynamically loaded after the client mounts");
|
||||
}
|
||||
const componentRegistry = await readFile(
|
||||
join(repositoryRoot, "packages/custom-widgets/src/core/component-registry.ts"),
|
||||
"utf8",
|
||||
);
|
||||
if (!/from\s+["'].\/component-runtime\.generated\.json["']/u.test(componentRegistry)) {
|
||||
failures.push("The Custom JSX runtime registry must use the compact generated component index");
|
||||
}
|
||||
if (/from\s+["'].\/component-catalog["']/u.test(componentRegistry)) {
|
||||
failures.push("The Custom JSX runtime registry must not bundle the full authoring catalog");
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(failures.join("\n"));
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
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",
|
||||
"mantine-core.md",
|
||||
"mantine-dates.md",
|
||||
"mantine-charts.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: Exclude<GeneratedComponentReferencePath, "components.md">;
|
||||
title: string;
|
||||
}[];
|
||||
|
||||
export function renderCustomWidgetComponentReferences(
|
||||
catalog: Readonly<CustomJsxAuthoringCatalog>,
|
||||
): Readonly<Record<GeneratedComponentReferencePath, string>> {
|
||||
validateCatalogReferences(catalog);
|
||||
|
||||
const references = Object.fromEntries([
|
||||
["components.md", renderCatalogOverview(catalog)],
|
||||
...packageReferences.map(({ packageName, path, title }) => [
|
||||
path,
|
||||
renderPackageReference(catalog, packageName, title),
|
||||
]),
|
||||
]) 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 offline Custom JSX component contract for this Homarr release. When connected to Homarr, prefer the live catalog because the installed release may differ. The catalog is descriptive, not a prop allowlist: 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.",
|
||||
"- The effective prop API is the shared prop table plus the component-specific table in the package reference. A component-specific record overrides the shared record with the same name.",
|
||||
'- 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),
|
||||
"",
|
||||
"## Shared prop API",
|
||||
"",
|
||||
"These props are available to enabled components unless a component-specific record overrides the same name.",
|
||||
"",
|
||||
renderPropTable(catalog, catalog.globalProps),
|
||||
"",
|
||||
"## 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}\`. Effective props combine the [shared prop API](components.md#shared-prop-api) with each component's specific props below. Component-specific records override shared records 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) =>
|
||||
`[\`${escapeMarkdownLinkLabel(component.name)}\`](${path}#${getComponentReferenceAnchor(component.name)})${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("|", "|");
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("|", "|");
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,20 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { customJsxComponentRegistry } from "../src/core/component-registry";
|
||||
import type { CustomJsxAuthoringCatalog } from "../src/core/component-catalog-types";
|
||||
import { renderCustomWidgetComponentReferences } from "./component-docs";
|
||||
|
||||
const output = customJsxComponentRegistry.map(
|
||||
({ name, package: packageName, category, safety, reason, documentationUrl }) => ({
|
||||
name,
|
||||
package: packageName,
|
||||
category,
|
||||
safety,
|
||||
reason,
|
||||
documentationUrl,
|
||||
}),
|
||||
);
|
||||
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 writeFile(
|
||||
resolve(import.meta.dirname, "../../../apps/docs/src/generated/custom-jsx-components.json"),
|
||||
`${JSON.stringify(output, null, 2)}\n`,
|
||||
);
|
||||
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)),
|
||||
]);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import * as Charts from "@mantine/charts";
|
||||
@@ -6,13 +6,40 @@ import * as Core from "@mantine/core";
|
||||
import * as Dates from "@mantine/dates";
|
||||
import ts from "typescript";
|
||||
|
||||
import { discoveredMantineComponents } from "../src/core/component-discovery";
|
||||
import { deniedCustomJsxComponentNames } from "../src/core/component-registry-denied";
|
||||
import { isBlockedCustomJsxProp } from "../src/jsx/policy";
|
||||
import {
|
||||
CUSTOM_JSX_AUTHORING_CATALOG_SCHEMA_VERSION,
|
||||
CUSTOM_WIDGET_AUTHORING_VERSION,
|
||||
} from "../src/core/component-catalog-types";
|
||||
import type {
|
||||
CustomJsxAuthoringCatalog,
|
||||
CustomJsxBlockedCapability,
|
||||
CustomJsxCatalogBindingType,
|
||||
CustomJsxComponentApi,
|
||||
CustomJsxLiteralValue,
|
||||
CustomJsxPropDescriptor,
|
||||
CustomJsxPropSource,
|
||||
CustomJsxRuntimeCatalog,
|
||||
} from "../src/core/component-catalog-types";
|
||||
import { getCatalogBlockedComponentProps, getCatalogDeniedComponentReason } from "../src/core/component-catalog-policy";
|
||||
import { commonSafeProps } from "../src/core/component-props";
|
||||
import { accessibilityByCategory, documentationUrl, subcomponentsByName } from "../src/core/component-descriptor";
|
||||
import { discoveredMantineComponents, discoveredSubcomponentsByName } from "../src/core/component-discovery";
|
||||
import type {
|
||||
CustomJsxComponentCategory,
|
||||
CustomJsxComponentPackage,
|
||||
CustomJsxComponentSafety,
|
||||
} from "../src/core/component-types";
|
||||
import {
|
||||
CUSTOM_JSX_BLOCKED_PROPERTIES,
|
||||
CUSTOM_JSX_BLOCKED_PROPS,
|
||||
CUSTOM_JSX_BLOCKED_STYLE_KEYS,
|
||||
isBlockedCustomJsxProp,
|
||||
} from "../src/jsx/policy";
|
||||
|
||||
void [Core, Dates, Charts];
|
||||
|
||||
const packageRoot = resolve(import.meta.dirname, "..");
|
||||
const repositoryRoot = resolve(packageRoot, "../..");
|
||||
const configPath = resolve(packageRoot, "tsconfig.json");
|
||||
const configFile = ts.readConfigFile(configPath, ts.sys.readFile);
|
||||
if (configFile.error) throw new Error(ts.flattenDiagnosticMessageText(configFile.error.messageText, "\n"));
|
||||
@@ -20,7 +47,7 @@ const config = ts.parseJsonConfigFileContent(configFile.config, ts.sys, packageR
|
||||
const program = ts.createProgram({ rootNames: [...config.fileNames, import.meta.filename], options: config.options });
|
||||
const checker = program.getTypeChecker();
|
||||
const source = program.getSourceFile(import.meta.filename);
|
||||
if (!source) throw new Error("Unable to inspect the component metadata generator");
|
||||
if (!source) throw new Error("Unable to inspect the component catalog generator");
|
||||
|
||||
const moduleSymbols = new Map<string, ts.Symbol>();
|
||||
for (const declaration of source.statements) {
|
||||
@@ -31,13 +58,121 @@ for (const declaration of source.statements) {
|
||||
if (symbol) moduleSymbols.set(moduleName, symbol);
|
||||
}
|
||||
|
||||
function isCallableType(type: ts.Type): boolean {
|
||||
if (type.getCallSignatures().length > 0) return true;
|
||||
return type.isUnionOrIntersection() && type.types.some(isCallableType);
|
||||
type UninternedPropDescriptor = Omit<CustomJsxPropDescriptor, "typeRef"> & { type: string };
|
||||
type UninternedComponentApi = Omit<CustomJsxComponentApi, "props"> & { props: UninternedPropDescriptor[] };
|
||||
|
||||
interface InspectedProp extends UninternedPropDescriptor {
|
||||
global: boolean;
|
||||
}
|
||||
const metadata: Record<string, string[]> = {};
|
||||
for (const component of discoveredMantineComponents) {
|
||||
if (deniedCustomJsxComponentNames.has(component.name)) continue;
|
||||
|
||||
interface InspectedComponent {
|
||||
description?: string;
|
||||
props: InspectedProp[];
|
||||
}
|
||||
|
||||
interface CatalogSourceComponent {
|
||||
name: string;
|
||||
package: CustomJsxComponentPackage;
|
||||
category: CustomJsxComponentCategory;
|
||||
safety: CustomJsxComponentSafety;
|
||||
subcomponents: readonly string[];
|
||||
accessibilityRequirements: readonly string[];
|
||||
documentationUrl: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
const commonPropNames = new Set<string>(commonSafeProps);
|
||||
const inspectedComponents = new Map<string, InspectedComponent>();
|
||||
const globalCandidates = new Map<string, UninternedPropDescriptor[]>();
|
||||
|
||||
async function generateCatalog() {
|
||||
const sourceComponents = createSourceComponents();
|
||||
for (const component of sourceComponents) {
|
||||
if (component.package === "@homarr/widgets" || component.safety === "denied") continue;
|
||||
const inspected = inspectMantineComponent(component);
|
||||
inspectedComponents.set(component.name, inspected);
|
||||
for (const prop of inspected.props) {
|
||||
if (!prop.global) continue;
|
||||
const candidates = globalCandidates.get(prop.name) ?? [];
|
||||
const { global: _global, ...descriptor } = prop;
|
||||
candidates.push({ ...descriptor, source: "global" });
|
||||
globalCandidates.set(prop.name, candidates);
|
||||
}
|
||||
}
|
||||
|
||||
for (const name of commonSafeProps) {
|
||||
const candidates = globalCandidates.get(name) ?? [];
|
||||
if (candidates.length === 0) candidates.push(createFallbackProp(name, "global"));
|
||||
globalCandidates.set(name, candidates);
|
||||
}
|
||||
|
||||
const uninternedGlobalProps = [...globalCandidates.entries()]
|
||||
.map(([name, candidates]) => mergeGlobalProp(name, candidates))
|
||||
.toSorted((left, right) => compareStrings(left.name, right.name));
|
||||
const globalPropsByName = new Map(uninternedGlobalProps.map((prop) => [prop.name, prop]));
|
||||
|
||||
const uninternedComponents = sourceComponents
|
||||
.map((component) => buildComponentApi(component, globalPropsByName))
|
||||
.toSorted((left, right) => compareStrings(left.name, right.name));
|
||||
const types = [
|
||||
...new Set([
|
||||
...uninternedGlobalProps.map(({ type }) => type),
|
||||
...uninternedComponents.flatMap(({ props }) => props.map(({ type }) => type)),
|
||||
]),
|
||||
].toSorted(compareStrings);
|
||||
const typeReferences = new Map(types.map((type, index) => [type, index]));
|
||||
const internProp = ({ type, ...prop }: UninternedPropDescriptor): CustomJsxPropDescriptor => {
|
||||
const typeRef = typeReferences.get(type);
|
||||
if (typeRef === undefined) throw new Error(`Unable to intern prop type '${type}'`);
|
||||
return { ...prop, typeRef };
|
||||
};
|
||||
const globalProps = uninternedGlobalProps.map(internProp);
|
||||
const components = uninternedComponents.map(
|
||||
(component): CustomJsxComponentApi => ({ ...component, props: component.props.map(internProp) }),
|
||||
);
|
||||
|
||||
const mantineVersions = await Promise.all(
|
||||
["core", "dates", "charts"].map((packageName) =>
|
||||
readPackageVersion(resolve(repositoryRoot, `node_modules/@mantine/${packageName}/package.json`)),
|
||||
),
|
||||
);
|
||||
if (new Set(mantineVersions).size !== 1) {
|
||||
throw new Error(`Mantine package versions must match: ${mantineVersions.join(", ")}`);
|
||||
}
|
||||
const mantineVersion = mantineVersions[0];
|
||||
if (!mantineVersion) throw new Error("Unable to determine the installed Mantine version");
|
||||
|
||||
const catalog: CustomJsxAuthoringCatalog = {
|
||||
schemaVersion: CUSTOM_JSX_AUTHORING_CATALOG_SCHEMA_VERSION,
|
||||
mantineVersion,
|
||||
customWidgetVersion: CUSTOM_WIDGET_AUTHORING_VERSION,
|
||||
types,
|
||||
globalProps,
|
||||
blockedCapabilities: createBlockedCapabilities(),
|
||||
components,
|
||||
};
|
||||
|
||||
const runtimeCatalog: CustomJsxRuntimeCatalog = {
|
||||
schemaVersion: catalog.schemaVersion,
|
||||
globalProps: catalog.globalProps.map(({ name }) => name),
|
||||
components: catalog.components.map((component) => ({
|
||||
name: component.name,
|
||||
package: component.package,
|
||||
safety: component.safety,
|
||||
props: component.props.map(({ name }) => name),
|
||||
blockedProps: component.blockedProps,
|
||||
...(component.bind ? { bind: component.bind } : {}),
|
||||
...(component.deniedReason ? { deniedReason: component.deniedReason } : {}),
|
||||
})),
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
writeFile(resolve(packageRoot, "src/core/component-catalog.generated.json"), `${JSON.stringify(catalog)}\n`),
|
||||
writeFile(resolve(packageRoot, "src/core/component-runtime.generated.json"), `${JSON.stringify(runtimeCatalog)}\n`),
|
||||
]);
|
||||
}
|
||||
|
||||
function inspectMantineComponent(component: CatalogSourceComponent): InspectedComponent {
|
||||
const moduleSymbol = moduleSymbols.get(component.package);
|
||||
const rootName = component.name.split(".")[0];
|
||||
let symbol =
|
||||
@@ -50,23 +185,571 @@ for (const component of discoveredMantineComponents) {
|
||||
location = symbol.valueDeclaration ?? symbol.declarations?.[0] ?? location;
|
||||
type = checker.getTypeOfSymbolAtLocation(symbol, location);
|
||||
}
|
||||
|
||||
const signature = type?.getCallSignatures()[0] ?? type?.getConstructSignatures()[0];
|
||||
const parameter = signature?.parameters[0];
|
||||
const props = parameter
|
||||
? checker.getPropertiesOfType(checker.getTypeOfSymbolAtLocation(parameter, parameter.valueDeclaration ?? location))
|
||||
const propType = parameter && checker.getTypeOfSymbolAtLocation(parameter, parameter.valueDeclaration ?? location);
|
||||
const props = propType
|
||||
? checker
|
||||
.getPropertiesOfType(propType)
|
||||
.flatMap((prop): InspectedProp[] => {
|
||||
const declaration = prop.valueDeclaration ?? prop.declarations?.[0] ?? location;
|
||||
const resolvedType = checker.getTypeOfSymbolAtLocation(prop, declaration);
|
||||
if (prop.name === "bind" || isBlockedCustomJsxProp(prop.name) || isCallableType(resolvedType)) return [];
|
||||
const global = isGlobalProp(prop);
|
||||
return [createPropDescriptor(prop, resolvedType, declaration, global ? "global" : "component", global)];
|
||||
})
|
||||
.toSorted((left, right) => compareStrings(left.name, right.name))
|
||||
: [];
|
||||
metadata[component.name] = props
|
||||
.filter((prop) => {
|
||||
const declaration = prop.valueDeclaration ?? prop.declarations?.[0] ?? location;
|
||||
return (
|
||||
!isBlockedCustomJsxProp(prop.name) && !isCallableType(checker.getTypeOfSymbolAtLocation(prop, declaration))
|
||||
);
|
||||
})
|
||||
.map((prop) => prop.name)
|
||||
.toSorted();
|
||||
return {
|
||||
description: normalizeDescription(symbol && ts.displayPartsToString(symbol.getDocumentationComment(checker))),
|
||||
props,
|
||||
};
|
||||
}
|
||||
|
||||
await writeFile(
|
||||
resolve(packageRoot, "src/core/component-props.generated.json"),
|
||||
`${JSON.stringify(metadata, null, 2)}\n`,
|
||||
);
|
||||
function createSourceComponents(): CatalogSourceComponent[] {
|
||||
const mantineComponents = discoveredMantineComponents.map((component): CatalogSourceComponent => {
|
||||
const reason = getCatalogDeniedComponentReason(component.name);
|
||||
const category = reason ? ("blocked" as const) : component.category;
|
||||
return {
|
||||
name: component.name,
|
||||
package: component.package,
|
||||
category,
|
||||
safety: reason ? "denied" : "wrapped",
|
||||
subcomponents: discoveredSubcomponentsByName[component.name] ?? subcomponentsByName[component.name] ?? [],
|
||||
accessibilityRequirements: accessibilityByCategory[category],
|
||||
documentationUrl: documentationUrl(component.package, component.name),
|
||||
...(reason ? { reason } : {}),
|
||||
};
|
||||
});
|
||||
const homarrComponents = HOMARR_COMPONENT_DEFINITIONS.map(
|
||||
({ name, category, safety }): CatalogSourceComponent => ({
|
||||
name,
|
||||
package: "@homarr/widgets",
|
||||
category,
|
||||
safety,
|
||||
subcomponents: [],
|
||||
accessibilityRequirements: accessibilityByCategory[category],
|
||||
documentationUrl: documentationUrl("@homarr/widgets", name),
|
||||
}),
|
||||
);
|
||||
return [...mantineComponents, ...homarrComponents];
|
||||
}
|
||||
|
||||
function buildComponentApi(
|
||||
component: CatalogSourceComponent,
|
||||
globalPropsByName: ReadonlyMap<string, UninternedPropDescriptor>,
|
||||
): UninternedComponentApi {
|
||||
const blockedProps = getCatalogBlockedComponentProps(component.name);
|
||||
const blockedPropNames = new Set(blockedProps.map(({ name }) => name));
|
||||
const globalNames = new Set(globalPropsByName.keys());
|
||||
const inspected = inspectedComponents.get(component.name);
|
||||
const componentProps = new Map<string, UninternedPropDescriptor>();
|
||||
for (const prop of inspected?.props ?? []) {
|
||||
if (!blockedPropNames.has(prop.name) && !prop.global && !globalNames.has(prop.name)) {
|
||||
const { global: _global, ...descriptor } = prop;
|
||||
componentProps.set(prop.name, { ...descriptor, source: "component" });
|
||||
}
|
||||
}
|
||||
if (component.safety !== "denied" && component.package === "@homarr/widgets") {
|
||||
const homarrProps = HOMARR_COMPONENT_PROPS[component.name];
|
||||
if (!homarrProps) throw new Error(`Homarr component '${component.name}' requires explicit catalog prop metadata`);
|
||||
for (const [propName, metadata] of Object.entries(homarrProps)) {
|
||||
const globalProp = globalPropsByName.get(propName);
|
||||
if (globalProp && !metadata.required && samePropMetadata(globalProp, metadata)) continue;
|
||||
if (isBlockedCustomJsxProp(propName)) continue;
|
||||
componentProps.set(propName, { name: propName, source: "component", ...metadata });
|
||||
}
|
||||
}
|
||||
|
||||
const bindType = component.safety !== "denied" ? CUSTOM_JSX_BINDING_TYPES[component.name] : undefined;
|
||||
const description = inspected?.description ?? HOMARR_COMPONENT_DESCRIPTIONS[component.name];
|
||||
return {
|
||||
name: component.name,
|
||||
package: component.package,
|
||||
category: component.category,
|
||||
safety: component.safety,
|
||||
...(description ? { description } : {}),
|
||||
documentationUrl: component.documentationUrl,
|
||||
props: [...componentProps.values()].toSorted((left, right) => compareStrings(left.name, right.name)),
|
||||
blockedProps: [...blockedProps].toSorted((left, right) => compareStrings(left.name, right.name)),
|
||||
subcomponents: [...component.subcomponents].toSorted(),
|
||||
...(bindType
|
||||
? {
|
||||
bind: {
|
||||
type: bindType,
|
||||
initialProp: bindType === "boolean" ? ("defaultChecked" as const) : ("defaultValue" as const),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
accessibilityRequirements: [...component.accessibilityRequirements],
|
||||
...(component.safety === "denied" && component.reason ? { deniedReason: component.reason } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function createPropDescriptor(
|
||||
prop: ts.Symbol,
|
||||
type: ts.Type,
|
||||
declaration: ts.Node,
|
||||
sourceKind: CustomJsxPropSource,
|
||||
global: boolean,
|
||||
): InspectedProp {
|
||||
const literalValues = getLiteralValues(type);
|
||||
const description = normalizeDescription(ts.displayPartsToString(prop.getDocumentationComment(checker)));
|
||||
return {
|
||||
name: prop.name,
|
||||
type: simplifyType(type, declaration),
|
||||
required: (prop.flags & ts.SymbolFlags.Optional) === 0 && !containsUndefined(type),
|
||||
source: sourceKind,
|
||||
...(literalValues ? { literalValues } : {}),
|
||||
...(description ? { description } : {}),
|
||||
global,
|
||||
};
|
||||
}
|
||||
|
||||
function createFallbackProp(name: string, sourceKind: CustomJsxPropSource): UninternedPropDescriptor {
|
||||
const literalValues = FALLBACK_LITERAL_VALUES[name];
|
||||
return {
|
||||
name,
|
||||
type: FALLBACK_PROP_TYPES[name] ?? inferFallbackType(name),
|
||||
required: false,
|
||||
source: sourceKind,
|
||||
...(literalValues ? { literalValues: [...literalValues] } : {}),
|
||||
...(FALLBACK_PROP_DESCRIPTIONS[name] ? { description: FALLBACK_PROP_DESCRIPTIONS[name] } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function mergeGlobalProp(name: string, candidates: UninternedPropDescriptor[]): UninternedPropDescriptor {
|
||||
const fallback = createFallbackProp(name, "global");
|
||||
const type = mostFrequent(candidates.map((candidate) => candidate.type)) ?? fallback.type;
|
||||
const descriptions = candidates.flatMap(({ description }) => (description ? [description] : []));
|
||||
const literalValues = uniqueLiterals(candidates.flatMap(({ literalValues }) => literalValues ?? []));
|
||||
return {
|
||||
name,
|
||||
type,
|
||||
required: false,
|
||||
source: "global",
|
||||
...(literalValues.length > 0 ? { literalValues } : {}),
|
||||
...((mostFrequent(descriptions) ?? fallback.description)
|
||||
? { description: mostFrequent(descriptions) ?? fallback.description }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function samePropMetadata(globalProp: UninternedPropDescriptor, componentProp: HomarrPropMetadata): boolean {
|
||||
return (
|
||||
globalProp.type === componentProp.type &&
|
||||
(componentProp.literalValues === undefined ||
|
||||
JSON.stringify(globalProp.literalValues ?? []) === JSON.stringify(componentProp.literalValues))
|
||||
);
|
||||
}
|
||||
|
||||
function isCallableType(type: ts.Type): boolean {
|
||||
if (type.getCallSignatures().length > 0) return true;
|
||||
return type.isUnionOrIntersection() && type.types.some(isCallableType);
|
||||
}
|
||||
|
||||
function isGlobalProp(prop: ts.Symbol): boolean {
|
||||
if (commonPropNames.has(prop.name)) return true;
|
||||
return (prop.declarations ?? []).some((declaration) => {
|
||||
const path = declaration.getSourceFile().fileName.replaceAll("\\", "/");
|
||||
return path.includes("/node_modules/@types/react/") || path.includes("/@mantine/core/lib/core/Box/");
|
||||
});
|
||||
}
|
||||
|
||||
function containsUndefined(type: ts.Type): boolean {
|
||||
if ((type.flags & ts.TypeFlags.Undefined) !== 0) return true;
|
||||
return type.isUnion() && type.types.some(containsUndefined);
|
||||
}
|
||||
|
||||
function simplifyType(type: ts.Type, declaration: ts.Node): string {
|
||||
const withoutUndefined = type.isUnion()
|
||||
? type.types.filter((part) => (part.flags & ts.TypeFlags.Undefined) === 0)
|
||||
: [type];
|
||||
const simplified = withoutUndefined.map(simpleTypeName);
|
||||
if (simplified.every((value): value is string => value !== null)) return [...new Set(simplified)].join(" | ");
|
||||
|
||||
const rendered = checker
|
||||
.typeToString(type, declaration, ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope)
|
||||
.replace(/\s+/gu, " ")
|
||||
.replace(/\s*\|\s*undefined\b/gu, "")
|
||||
.trim();
|
||||
if (rendered.length <= 240) return rendered === "any" ? "unknown" : rendered;
|
||||
const alias = type.aliasSymbol?.name;
|
||||
if (alias && !alias.startsWith("__")) return alias;
|
||||
return inferWideType(type);
|
||||
}
|
||||
|
||||
function simpleTypeName(type: ts.Type): string | null {
|
||||
if ((type.flags & ts.TypeFlags.String) !== 0) return "string";
|
||||
if ((type.flags & ts.TypeFlags.Number) !== 0) return "number";
|
||||
if ((type.flags & ts.TypeFlags.Boolean) !== 0) return "boolean";
|
||||
if ((type.flags & ts.TypeFlags.Null) !== 0) return "null";
|
||||
if ((type.flags & ts.TypeFlags.StringLiteral) !== 0) return JSON.stringify((type as ts.StringLiteralType).value);
|
||||
if ((type.flags & ts.TypeFlags.NumberLiteral) !== 0) return String((type as ts.NumberLiteralType).value);
|
||||
if ((type.flags & ts.TypeFlags.BooleanLiteral) !== 0)
|
||||
return (type as ts.Type & { intrinsicName?: string }).intrinsicName ?? "boolean";
|
||||
return null;
|
||||
}
|
||||
|
||||
function inferWideType(type: ts.Type): string {
|
||||
const parts = type.isUnionOrIntersection() ? type.types : [type];
|
||||
const kinds = new Set(
|
||||
parts.map((part) => {
|
||||
if ((part.flags & (ts.TypeFlags.String | ts.TypeFlags.StringLiteral)) !== 0) return "string";
|
||||
if ((part.flags & (ts.TypeFlags.Number | ts.TypeFlags.NumberLiteral)) !== 0) return "number";
|
||||
if ((part.flags & (ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral)) !== 0) return "boolean";
|
||||
if ((part.flags & ts.TypeFlags.Null) !== 0) return "null";
|
||||
return "object";
|
||||
}),
|
||||
);
|
||||
return [...kinds].toSorted().join(" | ") || "unknown";
|
||||
}
|
||||
|
||||
function getLiteralValues(type: ts.Type): CustomJsxLiteralValue[] | undefined {
|
||||
const values: CustomJsxLiteralValue[] = [];
|
||||
let hasNonLiteralValue = false;
|
||||
const visit = (candidate: ts.Type) => {
|
||||
if (candidate.isUnion()) {
|
||||
candidate.types.forEach(visit);
|
||||
return;
|
||||
}
|
||||
if ((candidate.flags & ts.TypeFlags.StringLiteral) !== 0) values.push((candidate as ts.StringLiteralType).value);
|
||||
else if ((candidate.flags & ts.TypeFlags.NumberLiteral) !== 0)
|
||||
values.push((candidate as ts.NumberLiteralType).value);
|
||||
else if ((candidate.flags & ts.TypeFlags.BooleanLiteral) !== 0)
|
||||
values.push((candidate as ts.Type & { intrinsicName?: string }).intrinsicName === "true");
|
||||
else if ((candidate.flags & ts.TypeFlags.Null) !== 0) values.push(null);
|
||||
else if ((candidate.flags & ts.TypeFlags.Undefined) === 0) hasNonLiteralValue = true;
|
||||
};
|
||||
visit(type);
|
||||
const unique = uniqueLiterals(values);
|
||||
if (hasNonLiteralValue && unique.every((value) => value === null || typeof value === "boolean")) return undefined;
|
||||
return unique.length > 0 && unique.length <= 64 ? unique : undefined;
|
||||
}
|
||||
|
||||
function uniqueLiterals(values: CustomJsxLiteralValue[]): CustomJsxLiteralValue[] {
|
||||
return [...new Map(values.map((value) => [`${typeof value}:${String(value)}`, value])).values()].toSorted(
|
||||
(left, right) => compareStrings(String(left), String(right)),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeDescription(value: string | undefined): string | undefined {
|
||||
const normalized = value?.replace(/\s+/gu, " ").trim();
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
function mostFrequent(values: string[]): string | undefined {
|
||||
const counts = new Map<string, number>();
|
||||
for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1);
|
||||
return [...counts.entries()].toSorted(
|
||||
([leftValue, leftCount], [rightValue, rightCount]) =>
|
||||
rightCount - leftCount || leftValue.length - rightValue.length || compareStrings(leftValue, rightValue),
|
||||
)[0]?.[0];
|
||||
}
|
||||
|
||||
function compareStrings(left: string, right: string): number {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
function inferFallbackType(name: string): string {
|
||||
if (/^(?:is|has|with|allow|disabled|readOnly|loading|opened|checked|multiple|grow|wrap|striped|animated)/u.test(name))
|
||||
return "boolean";
|
||||
if (/^(?:count|index|max|min|offset|order|page|pageSize|size|span|step|total|value)$/u.test(name)) return "number";
|
||||
if (/^(?:colors|values|sections|data)$/u.test(name)) return "unknown[]";
|
||||
if (/^(?:children|description|fallback|icon|label|leftSection|rightSection|title)$/u.test(name)) return "ReactNode";
|
||||
if (/^(?:params|onParams|offParams|style|styles)$/u.test(name)) return "Record<string, unknown>";
|
||||
return "string | number | boolean";
|
||||
}
|
||||
|
||||
function createBlockedCapabilities(): CustomJsxBlockedCapability[] {
|
||||
const exactNames = new Set([
|
||||
...CUSTOM_JSX_BLOCKED_PROPS,
|
||||
...CUSTOM_JSX_BLOCKED_STYLE_KEYS,
|
||||
...[...CUSTOM_JSX_BLOCKED_PROPERTIES].filter((name) => name !== "bind"),
|
||||
]);
|
||||
return [
|
||||
...[...exactNames].toSorted().map(
|
||||
(name): CustomJsxBlockedCapability => ({
|
||||
kind: "prop",
|
||||
name,
|
||||
reason: blockedPropReason(name),
|
||||
}),
|
||||
),
|
||||
{
|
||||
kind: "prop-pattern",
|
||||
name: "on*",
|
||||
reason: "Authored event callbacks can execute code outside the declarative runtime.",
|
||||
},
|
||||
{
|
||||
kind: "prop-pattern",
|
||||
name: "*Ref",
|
||||
reason: "Refs expose underlying React components and DOM nodes.",
|
||||
},
|
||||
{
|
||||
kind: "prop-pattern",
|
||||
name: "__*",
|
||||
reason: "Private and prototype-related properties are outside the supported authoring surface.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function blockedPropReason(name: string): string {
|
||||
if (["component", "renderRoot"].includes(name))
|
||||
return "Polymorphic roots can replace the Homarr-owned element boundary.";
|
||||
if (["portalProps", "popoverTarget", "popoverTargetAction", "withinPortal"].includes(name))
|
||||
return "Portal targeting can render or interact outside the widget root.";
|
||||
if (["dangerouslySetInnerHTML", "srcDoc"].includes(name))
|
||||
return "Raw HTML injection is not available to authored widgets.";
|
||||
if (["className", "classNames", "classes"].includes(name))
|
||||
return "Unscoped class injection can escape widget CSS isolation.";
|
||||
if (name === "children") return "Children must be expressed as interpreted JSX, not passed as a raw prop.";
|
||||
if (name.toLowerCase().includes("ref")) return "Refs expose underlying React components and DOM nodes.";
|
||||
if (name.startsWith("form")) return "External form submission is outside the named-request security boundary.";
|
||||
return "This capability is blocked by the Custom JSX safety boundary.";
|
||||
}
|
||||
|
||||
async function readPackageVersion(path: string): Promise<string> {
|
||||
const parsed = JSON.parse(await readFile(path, "utf8")) as { version?: unknown };
|
||||
if (typeof parsed.version !== "string") throw new Error(`Package at ${path} does not declare a version`);
|
||||
return parsed.version;
|
||||
}
|
||||
|
||||
const HOMARR_COMPONENT_DEFINITIONS = [
|
||||
{ name: "PaginatedList", category: "interaction", safety: "allowed" },
|
||||
{ name: "TabsContainer", category: "interaction", safety: "allowed" },
|
||||
{ name: "TabPanel", category: "interaction", safety: "allowed" },
|
||||
{ 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" },
|
||||
{ name: "ToggleSwitch", category: "network", safety: "wrapped" },
|
||||
{ name: "RefreshButton", category: "network", safety: "wrapped" },
|
||||
{ name: "TablerIcon", category: "content", safety: "wrapped" },
|
||||
] as const satisfies readonly {
|
||||
name: string;
|
||||
category: CustomJsxComponentCategory;
|
||||
safety: CustomJsxComponentSafety;
|
||||
}[];
|
||||
|
||||
const stringBindingComponents = [
|
||||
"Accordion",
|
||||
"Autocomplete",
|
||||
"Chip.Group",
|
||||
"ChipGroup",
|
||||
"ColorInput",
|
||||
"ColorPicker",
|
||||
"DateInput",
|
||||
"DatePicker",
|
||||
"DatePickerInput",
|
||||
"DateTimePicker",
|
||||
"InlineDateTimePicker",
|
||||
"JsonInput",
|
||||
"MaskInput",
|
||||
"MiniCalendar",
|
||||
"MonthPicker",
|
||||
"MonthPickerInput",
|
||||
"NativeSelect",
|
||||
"PasswordInput",
|
||||
"PinInput",
|
||||
"Radio.Group",
|
||||
"RadioGroup",
|
||||
"SegmentedControl",
|
||||
"Select",
|
||||
"Tabs",
|
||||
"TextInput",
|
||||
"Textarea",
|
||||
"TimeInput",
|
||||
"TimePicker",
|
||||
"TreeSelect",
|
||||
"YearPicker",
|
||||
"YearPickerInput",
|
||||
] as const;
|
||||
|
||||
const CUSTOM_JSX_BINDING_TYPES: Readonly<Record<string, CustomJsxCatalogBindingType>> = {
|
||||
...Object.fromEntries(stringBindingComponents.map((name) => [name, "string"] as const)),
|
||||
Checkbox: "boolean",
|
||||
Menu: "boolean",
|
||||
Popover: "boolean",
|
||||
Switch: "boolean",
|
||||
NumberInput: "number",
|
||||
Pagination: "number",
|
||||
Rating: "number",
|
||||
Slider: "number",
|
||||
Stepper: "number",
|
||||
"Checkbox.Group": "string[]",
|
||||
CheckboxGroup: "string[]",
|
||||
MultiSelect: "string[]",
|
||||
"Switch.Group": "string[]",
|
||||
SwitchGroup: "string[]",
|
||||
TagsInput: "string[]",
|
||||
RangeSlider: "number[]",
|
||||
};
|
||||
|
||||
const HOMARR_COMPONENT_DESCRIPTIONS: Readonly<Record<string, string>> = {
|
||||
PaginatedList: "Renders a bounded page of collection items.",
|
||||
TabsContainer: "Renders a declarative tab container.",
|
||||
TabPanel: "Renders content for one declarative tab.",
|
||||
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.",
|
||||
ToggleSwitch: "Runs named actions for the enabled and disabled states.",
|
||||
RefreshButton: "Refreshes load-triggered widget queries.",
|
||||
TablerIcon: "Resolves an installed Tabler icon by its safe name.",
|
||||
};
|
||||
|
||||
type HomarrPropMetadata = Omit<UninternedPropDescriptor, "name" | "source">;
|
||||
|
||||
const optionalProp = (
|
||||
type: string,
|
||||
literalValues?: CustomJsxLiteralValue[],
|
||||
description?: string,
|
||||
): HomarrPropMetadata => ({
|
||||
type,
|
||||
required: false,
|
||||
...(literalValues ? { literalValues } : {}),
|
||||
...(description ? { description } : {}),
|
||||
});
|
||||
const requiredProp = (
|
||||
type: string,
|
||||
literalValues?: CustomJsxLiteralValue[],
|
||||
description?: string,
|
||||
): HomarrPropMetadata => ({
|
||||
type,
|
||||
required: true,
|
||||
...(literalValues ? { literalValues } : {}),
|
||||
...(description ? { description } : {}),
|
||||
});
|
||||
|
||||
const HOMARR_COMPONENT_PROPS: Readonly<Record<string, Readonly<Record<string, HomarrPropMetadata>>>> = {
|
||||
PaginatedList: { pageSize: optionalProp("number") },
|
||||
TabsContainer: { defaultTab: optionalProp("string") },
|
||||
TabPanel: { value: requiredProp("string"), label: optionalProp("string") },
|
||||
Collapsible: { title: requiredProp("string"), defaultOpen: optionalProp("boolean") },
|
||||
StatBar: {
|
||||
value: requiredProp("number"),
|
||||
max: optionalProp("number"),
|
||||
label: optionalProp("string"),
|
||||
color: optionalProp("string"),
|
||||
},
|
||||
TypeBadge: {
|
||||
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>"),
|
||||
refreshInterval: optionalProp("number"),
|
||||
loadingLabel: optionalProp("string"),
|
||||
errorMessage: optionalProp("string"),
|
||||
fallback: optionalProp("ReactNode"),
|
||||
path: optionalProp("string"),
|
||||
as: optionalProp("'json' | 'text'", ["json", "text"]),
|
||||
trigger: optionalProp("'auto' | 'manual'", ["auto", "manual"]),
|
||||
},
|
||||
SubData: {
|
||||
path: optionalProp("string"),
|
||||
as: optionalProp("'Text' | 'Title' | 'Badge' | 'Code' | 'Image'", ["Text", "Title", "Badge", "Code", "Image"]),
|
||||
order: optionalProp("1 | 2 | 3 | 4 | 5 | 6", [1, 2, 3, 4, 5, 6]),
|
||||
size: optionalProp("string"),
|
||||
color: optionalProp("string"),
|
||||
variant: optionalProp("string"),
|
||||
fw: optionalProp("number"),
|
||||
c: optionalProp("string"),
|
||||
alt: optionalProp("string"),
|
||||
fit: optionalProp("string"),
|
||||
w: optionalProp("string | number"),
|
||||
h: optionalProp("string | number"),
|
||||
radius: optionalProp("string | number"),
|
||||
},
|
||||
ActionButton: {
|
||||
requestId: optionalProp("string", undefined, "Identifier of the named Custom Widget action."),
|
||||
params: optionalProp("Record<string, string | number | boolean>"),
|
||||
label: optionalProp("string"),
|
||||
color: optionalProp("string"),
|
||||
variant: optionalProp("string"),
|
||||
size: optionalProp("string"),
|
||||
confirmMessage: optionalProp("string"),
|
||||
successMessage: optionalProp("string"),
|
||||
errorMessage: optionalProp("string"),
|
||||
icon: optionalProp("string"),
|
||||
disabled: optionalProp("boolean | string"),
|
||||
fullWidth: optionalProp("boolean | string"),
|
||||
},
|
||||
ToggleSwitch: {
|
||||
requestId: optionalProp("string", undefined, "Identifier of the named Custom Widget action."),
|
||||
onParams: optionalProp("Record<string, string | number | boolean>"),
|
||||
offParams: optionalProp("Record<string, string | number | boolean>"),
|
||||
initialValue: optionalProp("boolean | string"),
|
||||
label: optionalProp("string"),
|
||||
color: optionalProp("string"),
|
||||
size: optionalProp("string"),
|
||||
errorMessage: optionalProp("string"),
|
||||
disabled: optionalProp("boolean | string"),
|
||||
},
|
||||
RefreshButton: {
|
||||
label: optionalProp("string"),
|
||||
color: optionalProp("string"),
|
||||
variant: optionalProp("string"),
|
||||
size: optionalProp("string"),
|
||||
},
|
||||
TablerIcon: {
|
||||
name: requiredProp("string"),
|
||||
size: optionalProp("string | number"),
|
||||
color: optionalProp("string"),
|
||||
stroke: optionalProp("string | number"),
|
||||
},
|
||||
};
|
||||
|
||||
const FALLBACK_PROP_TYPES: Readonly<Record<string, string>> = {
|
||||
key: "string | number",
|
||||
id: "string",
|
||||
style: "CSSProperties",
|
||||
title: "string",
|
||||
role: "AriaRole",
|
||||
"aria-label": "string",
|
||||
"aria-describedby": "string",
|
||||
"aria-hidden": "boolean | 'true' | 'false'",
|
||||
tabIndex: "number",
|
||||
requestId: "string",
|
||||
refreshInterval: "number",
|
||||
path: "string",
|
||||
name: "string",
|
||||
color: "MantineColor",
|
||||
radius: "MantineRadius",
|
||||
variant: "string",
|
||||
size: "MantineSize | string | number",
|
||||
defaultValue: "unknown",
|
||||
defaultChecked: "boolean",
|
||||
};
|
||||
|
||||
const FALLBACK_LITERAL_VALUES: Readonly<Record<string, readonly CustomJsxLiteralValue[]>> = {
|
||||
target: ["_blank", "_parent", "_self", "_top"],
|
||||
orientation: ["horizontal", "vertical"],
|
||||
};
|
||||
|
||||
const FALLBACK_PROP_DESCRIPTIONS: Readonly<Record<string, string>> = {
|
||||
key: "Stable identity used by React when rendering collections.",
|
||||
requestId: "Identifier of the named Custom Widget request.",
|
||||
bind: "Name of the temporary in-memory input value.",
|
||||
};
|
||||
|
||||
await generateCatalog();
|
||||
@@ -0,0 +1,32 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
export const CUSTOM_WIDGET_SKILL_SOURCE_PATHS = [
|
||||
"SKILL.md",
|
||||
"references/schema.md",
|
||||
"references/runtime.md",
|
||||
"references/security.md",
|
||||
"references/components.md",
|
||||
"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");
|
||||
|
||||
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`;
|
||||
}
|
||||
@@ -1,18 +1,22 @@
|
||||
import type { HomarrCustomWidgetV2 } from "./custom-jsx-schema";
|
||||
|
||||
export const CUSTOM_WIDGET_MANTINE_VERSION = "9.4.1";
|
||||
export const CUSTOM_WIDGET_OFFLINE_BUNDLE_SENTINEL = "--- END HOMARR CUSTOM WIDGET OFFLINE BUNDLE ---";
|
||||
const CUSTOM_WIDGET_AI_PROMPT_LIMIT = 8_000;
|
||||
const 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.";
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
This Homarr release uses Mantine ${CUSTOM_WIDGET_MANTINE_VERSION}. Use only the Homarr runtime components and Mantine capabilities described in this prompt and its embedded skill.
|
||||
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 exact release-matched component reference files.
|
||||
|
||||
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>.
|
||||
|
||||
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.
|
||||
|
||||
Templates can read data, status, options, and inputs. 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. Use Mantine Core, Dates, and Charts components. Do not use imports, hooks, refs, event callbacks, 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.
|
||||
Templates can read data, status, options, and inputs. 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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -48,22 +52,42 @@ export function buildCustomWidgetAiPrompt(
|
||||
currentConfig?: Partial<HomarrCustomWidgetV2> | Record<string, unknown> | null,
|
||||
request?: string | null,
|
||||
documentationUrl?: string | null,
|
||||
embeddedSkill?: string,
|
||||
) {
|
||||
const requestedWidget = redactText(request?.trim() || "Describe the widget you want to create.");
|
||||
const sections = [`Please create this Homarr Custom JSX v2 widget:\n\n${requestedWidget}`];
|
||||
if (documentationUrl) sections.push(`API documentation: ${redactUrl(documentationUrl)}`);
|
||||
sections.push(AUTHORING_PROMPT);
|
||||
if (embeddedSkill) sections.push(`Use this complete Homarr Custom Widget authoring skill:\n\n${embeddedSkill}`);
|
||||
if (rawResponse) sections.push(`Sample API response:\n\n\`\`\`json\n${redactResponse(rawResponse)}\n\`\`\``);
|
||||
if (currentConfig) {
|
||||
sections.push(`Current widget:\n\n\`\`\`json\n${JSON.stringify(redactValue(currentConfig), null, 2)}\n\`\`\``);
|
||||
}
|
||||
sections.push(
|
||||
`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.`,
|
||||
const requestedWidget = truncatePromptText(
|
||||
redactText(request?.trim() || "Describe the widget you want to create."),
|
||||
1_500,
|
||||
);
|
||||
const prompt = sections.join("\n\n");
|
||||
return embeddedSkill ? prompt : prompt.slice(0, 8_000);
|
||||
const sections = [`Please create this Homarr Custom JSX v2 widget:\n\n${requestedWidget}`];
|
||||
if (documentationUrl) sections.push(`API documentation: ${truncatePromptText(redactUrl(documentationUrl), 500)}`);
|
||||
sections.push(AUTHORING_PROMPT);
|
||||
sections.push(FINAL_OUTPUT_INSTRUCTION);
|
||||
|
||||
const optionalSections = [
|
||||
...(rawResponse ? [{ label: "Sample API response", content: redactResponse(rawResponse) }] : []),
|
||||
...(currentConfig
|
||||
? [{ 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;
|
||||
}
|
||||
}
|
||||
|
||||
const sensitiveKey =
|
||||
@@ -120,3 +144,17 @@ function redactText(value: string): string {
|
||||
"$1: [REDACTED]",
|
||||
);
|
||||
}
|
||||
|
||||
function formatBudgetedCodeSection(label: string, content: string, budget: number): string | null {
|
||||
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}`;
|
||||
}
|
||||
|
||||
function truncatePromptText(value: string, limit: number): string {
|
||||
if (value.length <= limit) return value;
|
||||
const marker = "\n... [content omitted to fit the prompt budget]";
|
||||
return `${value.slice(0, Math.max(0, limit - marker.length))}${marker}`;
|
||||
}
|
||||
@@ -1,50 +1,14 @@
|
||||
import {
|
||||
customJsxBindableComponentNames,
|
||||
customJsxComponentByName,
|
||||
enabledCustomJsxComponents,
|
||||
} from "./component-registry";
|
||||
import { customJsxAuthoringCatalog, getCustomJsxComponentProps } from "./component-catalog";
|
||||
import { customJsxExamples } from "./examples";
|
||||
import { CUSTOM_WIDGET_SKILL_MD, CUSTOM_WIDGET_SKILL_VERSION } from "./embedded-ai-prompt";
|
||||
import { customJsxTablerIconNames } from "./tabler-icons";
|
||||
|
||||
export const CUSTOM_WIDGET_SKILL_VERSION = "2.0.0";
|
||||
export const CUSTOM_WIDGET_SKILLS_SH_URL = "https://www.skills.sh/homarr-labs/homarr/homarr-custom-widget";
|
||||
export const CUSTOM_WIDGET_SKILL_SOURCE_URL =
|
||||
"https://github.com/homarr-labs/homarr/tree/dev/.agents/skills/homarr-custom-widget";
|
||||
"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_MD = `---
|
||||
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"
|
||||
---
|
||||
|
||||
# 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.
|
||||
|
||||
## 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. Return the complete manifest and JSX in the exact fenced-block format requested by the authoring prompt.
|
||||
4. 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.
|
||||
- Never put a credential in JSX, options, headers, URLs, examples, exports, logs, or chat output.
|
||||
- Use declarative \`bind\` controls and Homarr request components; never emit imports, hooks, callbacks, 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.
|
||||
|
||||
Follow this document and the supplied authoring prompt as the complete authoring reference. Do not assume access to repository-relative files.
|
||||
`;
|
||||
|
||||
export function getCustomWidgetSkill() {
|
||||
return {
|
||||
name: "homarr-custom-widget",
|
||||
@@ -57,36 +21,30 @@ export function getCustomWidgetSkill() {
|
||||
}
|
||||
|
||||
export function getCustomWidgetComponentCatalog() {
|
||||
return enabledCustomJsxComponents.map(({ name, package: packageName, category, supportedProps, subcomponents }) => ({
|
||||
name,
|
||||
package: packageName,
|
||||
category,
|
||||
propCount: supportedProps.filter((prop) => prop !== "bind").length,
|
||||
bindSupported: customJsxBindableComponentNames.has(name),
|
||||
subcomponents,
|
||||
detailUri: `homarr://custom-widgets/components/${encodeURIComponent(name)}`,
|
||||
}));
|
||||
return customJsxAuthoringCatalog;
|
||||
}
|
||||
|
||||
export function getCustomWidgetComponent(name: string) {
|
||||
const component = customJsxComponentByName.get(name);
|
||||
const component = customJsxAuthoringCatalog.components.find((candidate) => candidate.name === name);
|
||||
if (!component) return null;
|
||||
return {
|
||||
schemaVersion: customJsxAuthoringCatalog.schemaVersion,
|
||||
mantineVersion: customJsxAuthoringCatalog.mantineVersion,
|
||||
customWidgetVersion: customJsxAuthoringCatalog.customWidgetVersion,
|
||||
name: component.name,
|
||||
package: component.package,
|
||||
category: component.category,
|
||||
safety: component.safety,
|
||||
knownProps: component.supportedProps.filter((prop) => prop !== "bind"),
|
||||
bindSupported: customJsxBindableComponentNames.has(component.name),
|
||||
description: component.description,
|
||||
props: getCustomJsxComponentProps(name),
|
||||
blockedProps: component.blockedProps,
|
||||
bind: component.bind,
|
||||
subcomponents: component.subcomponents,
|
||||
accessibilityRequirements: component.accessibilityRequirements,
|
||||
documentationUrl: component.documentationUrl,
|
||||
knownValues: component.name === "TablerIcon" ? { name: customJsxTablerIconNames } : undefined,
|
||||
deniedReason: component.safety === "denied" ? component.reason : undefined,
|
||||
propPolicy:
|
||||
component.safety === "denied"
|
||||
? "This component is blocked."
|
||||
: "Ordinary serializable Mantine props pass through. Unsafe capabilities, callbacks, polymorphic roots, and escaping styles are blocked.",
|
||||
deniedReason: component.deniedReason,
|
||||
blockedCapabilities: customJsxAuthoringCatalog.blockedCapabilities,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -210,7 +210,11 @@ export const BUNDLED_CUSTOM_WIDGETS: readonly BundledCustomWidget[] = [
|
||||
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 => <Paper key={pokemon.name} withBorder p="xs"><Group justify="space-between"><Text fw={600} tt="capitalize">{pokemon.name}</Text><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>}
|
||||
<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>}
|
||||
</Stack>`,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./component-catalog";
|
||||
export * from "./component-registry";
|
||||
+22
-29
@@ -1,21 +1,18 @@
|
||||
import { accessibilityByCategory, documentationUrl } from "./component-descriptor";
|
||||
import type { CustomJsxComponentDescriptor } from "./component-descriptor";
|
||||
import { discoveredMantineComponents } from "./component-discovery";
|
||||
|
||||
const exactReasons = new Map<string, string>([
|
||||
const exactDeniedComponentReasons = new Map<string, string>([
|
||||
["Affix", "Escapes the widget layout boundary"],
|
||||
["ColorSchemeScript", "Writes global document state"],
|
||||
["Dialog", "Escapes the widget layout and focus boundary"],
|
||||
["FloatingWindow", "Creates a separate browser window"],
|
||||
["InlineStyles", "Writes unscoped style rules"],
|
||||
["RemoveScroll", "Changes global page scrolling"],
|
||||
["TableOfContents", "Observes headings and scrolling outside the widget root"],
|
||||
["Transition", "Requires an authored render callback"],
|
||||
["AreaGradient", "Low-level chart implementation helper"],
|
||||
["HiddenDatesInput", "Internal date-input primitive"],
|
||||
["PickerInputBase", "Internal date-input primitive"],
|
||||
]);
|
||||
|
||||
const familyReasons: ReadonlyArray<[RegExp, string]> = [
|
||||
const deniedComponentFamilies: ReadonlyArray<[RegExp, string]> = [
|
||||
[
|
||||
/^(?:AppShell|Drawer|FloatingWindow|Modal|RemoveScroll)/u,
|
||||
"Escapes or replaces the widget layout, focus, scrolling, or overlay boundary",
|
||||
@@ -34,29 +31,25 @@ const familyReasons: ReadonlyArray<[RegExp, string]> = [
|
||||
[/^(?:FocusTrap)/u, "Can capture focus outside the widget interaction flow"],
|
||||
];
|
||||
|
||||
export function getDeniedCustomJsxComponentReason(name: string): string | undefined {
|
||||
const exact = exactReasons.get(name);
|
||||
if (exact) return exact;
|
||||
return familyReasons.find(([pattern]) => pattern.test(name))?.[1];
|
||||
const blockedComponentProps: Readonly<Record<string, readonly { name: string; reason: string }[]>> = {
|
||||
Tooltip: [{ name: "target", reason: "String targets can select elements outside the widget root" }],
|
||||
DatePickerInput: pickerModalProps(),
|
||||
DateTimePicker: pickerModalProps(),
|
||||
MonthPickerInput: pickerModalProps(),
|
||||
YearPickerInput: pickerModalProps(),
|
||||
};
|
||||
|
||||
function pickerModalProps() {
|
||||
return [
|
||||
{ name: "dropdownType", reason: "Modal picker mode escapes the widget overlay boundary" },
|
||||
{ name: "modalProps", reason: "Modal picker configuration escapes the widget overlay boundary" },
|
||||
] as const;
|
||||
}
|
||||
|
||||
export const customJsxDeniedComponentRegistry: readonly CustomJsxComponentDescriptor[] =
|
||||
discoveredMantineComponents.flatMap(({ name, package: packageName }) => {
|
||||
const reason = getDeniedCustomJsxComponentReason(name);
|
||||
if (!reason) return [];
|
||||
return [
|
||||
{
|
||||
name,
|
||||
package: packageName,
|
||||
category: "blocked" as const,
|
||||
safety: "denied" as const,
|
||||
supportedProps: [],
|
||||
subcomponents: [],
|
||||
accessibilityRequirements: accessibilityByCategory.blocked,
|
||||
documentationUrl: documentationUrl(packageName, name),
|
||||
reason,
|
||||
},
|
||||
];
|
||||
});
|
||||
export function getCatalogDeniedComponentReason(name: string): string | undefined {
|
||||
return exactDeniedComponentReasons.get(name) ?? deniedComponentFamilies.find(([pattern]) => pattern.test(name))?.[1];
|
||||
}
|
||||
|
||||
export const deniedCustomJsxComponentNames = new Set(customJsxDeniedComponentRegistry.map((entry) => entry.name));
|
||||
export function getCatalogBlockedComponentProps(name: string) {
|
||||
return blockedComponentProps[name] ?? [];
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type {
|
||||
CustomJsxComponentCategory,
|
||||
CustomJsxComponentPackage,
|
||||
CustomJsxComponentSafety,
|
||||
} from "./component-types";
|
||||
|
||||
export const CUSTOM_JSX_AUTHORING_CATALOG_SCHEMA_VERSION = 1 as const;
|
||||
export const CUSTOM_WIDGET_AUTHORING_VERSION = "2.0.0";
|
||||
|
||||
export type CustomJsxPropSource = "global" | "component";
|
||||
export type CustomJsxLiteralValue = string | number | boolean | null;
|
||||
export type CustomJsxCatalogBindingType = "string" | "number" | "boolean" | "string[]" | "number[]";
|
||||
|
||||
export interface CustomJsxPropDescriptor {
|
||||
name: string;
|
||||
typeRef: number;
|
||||
required: boolean;
|
||||
source: CustomJsxPropSource;
|
||||
literalValues?: CustomJsxLiteralValue[];
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface CustomJsxBindingDescriptor {
|
||||
type: CustomJsxCatalogBindingType;
|
||||
initialProp: "defaultValue" | "defaultChecked";
|
||||
}
|
||||
|
||||
export interface CustomJsxBlockedCapability {
|
||||
kind: "prop" | "prop-pattern";
|
||||
name: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface CustomJsxComponentBlockedProp {
|
||||
name: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface CustomJsxComponentApi {
|
||||
name: string;
|
||||
package: CustomJsxComponentPackage;
|
||||
category: CustomJsxComponentCategory;
|
||||
safety: CustomJsxComponentSafety;
|
||||
description?: string;
|
||||
documentationUrl: string;
|
||||
props: CustomJsxPropDescriptor[];
|
||||
blockedProps: CustomJsxComponentBlockedProp[];
|
||||
subcomponents: string[];
|
||||
bind?: CustomJsxBindingDescriptor;
|
||||
accessibilityRequirements: string[];
|
||||
deniedReason?: string;
|
||||
}
|
||||
|
||||
export interface CustomJsxAuthoringCatalog {
|
||||
schemaVersion: typeof CUSTOM_JSX_AUTHORING_CATALOG_SCHEMA_VERSION;
|
||||
mantineVersion: string;
|
||||
customWidgetVersion: string;
|
||||
types: string[];
|
||||
globalProps: CustomJsxPropDescriptor[];
|
||||
blockedCapabilities: CustomJsxBlockedCapability[];
|
||||
components: CustomJsxComponentApi[];
|
||||
}
|
||||
|
||||
export interface CustomJsxRuntimeComponentApi {
|
||||
name: string;
|
||||
package: CustomJsxComponentPackage;
|
||||
safety: CustomJsxComponentSafety;
|
||||
props: string[];
|
||||
blockedProps: CustomJsxComponentBlockedProp[];
|
||||
bind?: CustomJsxBindingDescriptor;
|
||||
deniedReason?: string;
|
||||
}
|
||||
|
||||
export interface CustomJsxRuntimeCatalog {
|
||||
schemaVersion: typeof CUSTOM_JSX_AUTHORING_CATALOG_SCHEMA_VERSION;
|
||||
globalProps: string[];
|
||||
components: CustomJsxRuntimeComponentApi[];
|
||||
}
|
||||
@@ -0,0 +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\"","\"activity\" | \"display-none\"","\"all\" | \"parent\" | \"child\"","\"all\" | \"segment\"","\"always\" | \"hover\" | \"not-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\" | \"side\" | \"merge\"","\"dashed\" | \"dotted\" | \"solid\"","\"default\" | \"responsive\"","\"div\" | \"label\"","\"end\" | \"start\"","\"fixed\" | \"absolute\"","\"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\"","\"month\" | \"year\" | \"decade\"","\"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\"","\"off\" | \"assertive\" | \"polite\"","\"off\" | \"on\"","\"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\"","\"value\" | \"name\" | \"percent\"","\"viewport\"","\"wrap\" | \"nowrap\" | \"balance\" | \"pretty\" | \"stable\"","\"x\" | \"y\"","\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\"","\"yes\" | \"no\"","'Text' | 'Title' | 'Badge' | 'Code' | 'Image'","'auto' | 'manual'","'json' | 'text'","'xs' | 'sm' | 'md' | 'lg' | 'xl'","(\"input\" | \"label\" | \"description\" | \"error\")[]","(\"next\" | \"previous\" | \"level\")[]","(ActionIconProps & React.ClassAttributes<HTMLButtonElement> & React.ButtonHTMLAttributes<HTMLButtonElement>)","(CartesianChartProps<unknown> & { ref?: React.Ref<SVGSVGElement>; })","(CloseButtonProps & ElementProps<\"button\"> & DataAttributes)","(CloseButtonProps & React.ClassAttributes<HTMLButtonElement> & React.ButtonHTMLAttributes<HTMLButtonElement>)","(ElementProps<\"button\"> & DataAttributes)","(InputDescriptionProps & DataAttributes)","(InputErrorProps & DataAttributes)","(InputLabelProps & DataAttributes)","(InputSuccessProps & DataAttributes)","(OverlayProps & ElementProps<\"div\">)","(PolarChartProps<unknown> & { ref?: React.Ref<SVGSVGElement>; })","(React.ClassAttributes<HTMLButtonElement> & React.ButtonHTMLAttributes<HTMLButtonElement> & DataAttributes)","(React.ClassAttributes<HTMLDivElement> &Line truncated
|
||||
@@ -0,0 +1,53 @@
|
||||
import catalog from "./component-catalog.generated.json";
|
||||
import type { CustomJsxAuthoringCatalog, CustomJsxPropDescriptor } from "./component-catalog-types";
|
||||
|
||||
export type * from "./component-catalog-types";
|
||||
|
||||
export const customJsxAuthoringCatalog = catalog as unknown as Readonly<CustomJsxAuthoringCatalog>;
|
||||
export const customJsxCatalogComponentByName = new Map(
|
||||
customJsxAuthoringCatalog.components.map((component) => [component.name, component]),
|
||||
);
|
||||
export const customJsxCatalogGlobalPropByName = new Map(
|
||||
customJsxAuthoringCatalog.globalProps.map((prop) => [prop.name, prop]),
|
||||
);
|
||||
|
||||
export interface ResolvedCustomJsxPropDescriptor extends Omit<CustomJsxPropDescriptor, "typeRef"> {
|
||||
type: string;
|
||||
}
|
||||
|
||||
export function getCustomJsxPropType(
|
||||
prop: Pick<CustomJsxPropDescriptor, "typeRef">,
|
||||
catalogValue: Readonly<CustomJsxAuthoringCatalog> = customJsxAuthoringCatalog,
|
||||
): string {
|
||||
return catalogValue.types[prop.typeRef] ?? "unknown";
|
||||
}
|
||||
|
||||
export function resolveCustomJsxPropDescriptor(
|
||||
prop: CustomJsxPropDescriptor,
|
||||
catalogValue: Readonly<CustomJsxAuthoringCatalog> = customJsxAuthoringCatalog,
|
||||
): ResolvedCustomJsxPropDescriptor {
|
||||
const { typeRef, ...metadata } = prop;
|
||||
return { ...metadata, type: catalogValue.types[typeRef] ?? "unknown" };
|
||||
}
|
||||
|
||||
export function getCustomJsxComponentProps(name: string): ResolvedCustomJsxPropDescriptor[] {
|
||||
const component = customJsxCatalogComponentByName.get(name);
|
||||
if (!component || component.safety === "denied") return [];
|
||||
const blockedProps = new Set(component.blockedProps.map(({ name: propName }) => propName));
|
||||
const props = new Map(
|
||||
customJsxAuthoringCatalog.globalProps
|
||||
.filter((prop) => !blockedProps.has(prop.name))
|
||||
.map((prop) => [prop.name, resolveCustomJsxPropDescriptor(prop)]),
|
||||
);
|
||||
for (const prop of component.props) props.set(prop.name, resolveCustomJsxPropDescriptor(prop));
|
||||
if (component.bind) {
|
||||
props.set("bind", {
|
||||
name: "bind",
|
||||
type: "string",
|
||||
required: false,
|
||||
source: "component",
|
||||
description: `Temporary in-memory ${component.bind.type} input binding initialized by ${component.bind.initialProp}.`,
|
||||
});
|
||||
}
|
||||
return [...props.values()];
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { categorySafeProps, commonSafeProps } from "./component-props";
|
||||
import type {
|
||||
CustomJsxComponentCategory,
|
||||
CustomJsxComponentPackage,
|
||||
@@ -9,12 +8,9 @@ export type * from "./component-types";
|
||||
export interface CustomJsxComponentDescriptor {
|
||||
name: string;
|
||||
package: CustomJsxComponentPackage;
|
||||
category: CustomJsxComponentCategory;
|
||||
safety: CustomJsxComponentSafety;
|
||||
supportedProps: readonly string[];
|
||||
subcomponents: readonly string[];
|
||||
accessibilityRequirements: readonly string[];
|
||||
documentationUrl: string;
|
||||
blockedProps: readonly { name: string; reason: string }[];
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
@@ -46,12 +42,6 @@ export const accessibilityByCategory: Record<CustomJsxComponentCategory, readonl
|
||||
blocked: [],
|
||||
};
|
||||
|
||||
const componentSafeProps: Readonly<Record<string, readonly string[]>> = {
|
||||
"Tabs.List": ["grow"],
|
||||
ScrollArea: ["offsetScrollbars"],
|
||||
SubData: ["alt", "fit"],
|
||||
};
|
||||
|
||||
export function documentationUrl(packageName: CustomJsxComponentPackage, name: string): string {
|
||||
if (packageName === "@homarr/widgets") return "https://homarr.dev/docs/management/custom-widgets/";
|
||||
const section = packageName === "@mantine/core" ? "core" : packageName === "@mantine/charts" ? "charts" : "dates";
|
||||
@@ -61,31 +51,3 @@ export function documentationUrl(packageName: CustomJsxComponentPackage, name: s
|
||||
.toLowerCase();
|
||||
return `https://mantine.dev/${section}/${slug}/`;
|
||||
}
|
||||
|
||||
export const describe = (
|
||||
packageName: CustomJsxComponentPackage,
|
||||
category: CustomJsxComponentCategory,
|
||||
safety: CustomJsxComponentSafety,
|
||||
names: readonly string[],
|
||||
reason?: string,
|
||||
): CustomJsxComponentDescriptor[] =>
|
||||
names.map((name) => ({
|
||||
name,
|
||||
package: packageName,
|
||||
category,
|
||||
safety,
|
||||
supportedProps:
|
||||
safety === "denied"
|
||||
? []
|
||||
: [
|
||||
...new Set<string>([
|
||||
...commonSafeProps,
|
||||
...categorySafeProps[category],
|
||||
...(componentSafeProps[name] ?? []),
|
||||
]),
|
||||
],
|
||||
subcomponents: subcomponentsByName[name] ?? [],
|
||||
accessibilityRequirements: accessibilityByCategory[category],
|
||||
documentationUrl: documentationUrl(packageName, name),
|
||||
reason,
|
||||
}));
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 171 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user