Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbde111f01 | ||
|
|
a6bdbbb4ea |
No files matched your search
@@ -1,20 +1,20 @@
|
||||
---
|
||||
name: homarr-custom-widget
|
||||
description: Author, validate, preview, test, install, or configure API-backed Homarr Custom JSX v2 widgets.
|
||||
description: Author, validate, preview, install, or configure Homarr Custom JSX v2/v3 widgets, including static and native integration widgets.
|
||||
---
|
||||
|
||||
# Homarr Custom Widget
|
||||
|
||||
Author one widget or a coordinated set of widgets. Load only needed release-matched context. Run lifecycle tools alone; independent reads may run together. For a set, research once and finish each widget's validation, evidence, and persistence before the next.
|
||||
Load release-matched context. Run lifecycle tools alone; independent reads may run together. For a set, research once, then validate, preview, and persist each widget before the next.
|
||||
|
||||
1. Read primary API documentation. Use web search when documentation is not supplied or may have changed.
|
||||
1. For HTTP widgets, read primary API documentation. For native capabilities, discover installed schemas. Static widgets need no service.
|
||||
2. Create credential-free definitions with keyed `sources`, `requests`, optional `options`, and safe JSX `template`.
|
||||
3. While drafting, use `customWidget_validateTemplate` for focused JSX diagnostics without resending the manifest.
|
||||
4. Send the definition once to `customWidget_previewCreate` and test its queries/actions. For a JSX-only fix, validate, call `customWidget_previewReviseTemplate` with its session, and retest; it inherits the manifest and resets evidence. Create a preview only for source/request/option changes.
|
||||
4. Send the definition once to `customWidget_previewCreate` and test its HTTP/native queries and simulated actions. For a JSX-only fix, validate, call `customWidget_previewReviseTemplate` with its session, and retest; it inherits the manifest and resets evidence. Other definition changes require a fresh preview.
|
||||
5. Configure deployment-specific source URLs and credentials through Homarr; never repeat plaintext.
|
||||
6. Persist each exact final tested preview with `customWidget_createFromPreview`. Do not resend a large definition through `customWidget_create` when a preview session is available.
|
||||
|
||||
Treat a supplied sample or successful preview response as the binding contract. Render every core requested field, guard optional arrays and nested values before indexing, and do not silently drop returned items. Humanize numeric enums with indexed literal label arrays, omit absent numeric values instead of inventing zero, and label timestamp timezones. Give recoverable load errors and empty states a clear refresh or retry path.
|
||||
Treat sample and preview responses as binding contracts. Render requested fields, guard optional values, humanize enums, and label timezones. Give errors and empty states a retry path.
|
||||
|
||||
Use `{option:name}` or `$option` for saved options. Use `{param:name}` or `$param` for values supplied by `SubFetch`, `ActionButton`, or `ToggleSwitch`. Load queries cannot use invocation parameters. Render load queries from `data` and `status` with `RefreshButton`; reserve `SubFetch` for manual parameterized queries. Templates read `data`, `status`, `options`, and temporary `inputs`.
|
||||
|
||||
@@ -24,11 +24,7 @@ For a bound control that depends on another input, declare its default and set `
|
||||
|
||||
Plan capabilities. Use one `customWidget_findComponents` search per job. Batch selected non-obvious binding or interaction documentation with `customWidget_getComponents`; reserve `customWidget_getComponent` for one unknown-prop repair. Failed validation reopens discovery; otherwise search only for a missing capability. The full catalog is for broad exploration; load at most one example. Context never limits composition. Prefer clear hierarchy, responsive grids, divided lists, and aligned actions over nested row cards.
|
||||
|
||||
Do not simplify a useful workflow merely because the template is interpreted. Freely compose any supported installed components with multiple sources, requests, options, `choicesFrom`, bound filters, charts, responsive detail areas, manual queries, and safe actions when they improve the user's job. Complexity must remain purposeful rather than decorative.
|
||||
|
||||
Polish with a divided list or responsive media grid. Make one metric/action asymmetrically primary; keep headers compact, identity/state clear, metadata quiet, and one badge. At base artwork fills its row and caps above xs; never combine full-width media and nowrap.
|
||||
|
||||
Make initial states actionable and wrap variable labels/values on narrow tiles. Do not use an unlabeled decorative icon as an empty state.
|
||||
Compose installed components freely around the requested workflow. Use responsive layouts, clear hierarchy, quiet metadata, and actionable initial states. Wrap variable labels on narrow tiles and label interactive icons.
|
||||
|
||||
Do not use imports, hooks, refs, raw HTML, raw event callbacks, browser requests, arbitrary functions, eval, bigint, npm packages, authored statement blocks, IIFEs, or recursion. Do not pretend MCP tools exist in an offline chat session.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Templates read `data.requestId`, `status.requestId`, `options.name`, and tempora
|
||||
</SubFetch>
|
||||
```
|
||||
|
||||
Manual queries require `trigger: "manual"` on request and `SubFetch`; otherwise they run automatically. `triggerContent` with `triggerAriaLabel` makes custom content the launcher. `SubFetch` owns loading/error/retry; its child receives success plus `{ ok, status, statusText, loading: false }`. Never author `onClick` or fetch callbacks.
|
||||
Manual queries require `trigger: "manual"` on request and `SubFetch`. Use `triggerContent` and `triggerAriaLabel` for a custom launcher. `SubFetch` owns loading/error/retry; its child receives success plus `{ ok, status, statusText, loading: false }`.
|
||||
|
||||
`SubFetch`, `ActionButton`, and `ToggleSwitch` need literal `requestId`; validation rejects missing/computed IDs.
|
||||
|
||||
@@ -35,3 +35,5 @@ Every stateful control must use `bind`, and its `inputs.<name>` value must feed
|
||||
Callback parameters must not shadow the reserved roots `data`, `status`, `options`, or `inputs`. Use `<Icon name="refresh" />` or `<TablerIcon name="refresh" />`; never invent components such as `<IconFoo />`.
|
||||
|
||||
Use expression callbacks for supported collections and trusted slots. No callback blocks, IIFEs, authored recursion, or raw events. Regex is limited to safe string operations.
|
||||
|
||||
V3 adds scoped `className` styles, `View` fragments, `WidgetModal`, `WidgetDrawer`, and `AppEmbed`. Bound controls use `persist` for declared browser preferences or `content` for shared drafts saved with `ContentSaveButton`. `NativeQuery` and `NativeActionButton` invoke declared native IDs; actions retain preview simulation and integration permissions. Fetch component contracts before using these helpers.
|
||||
@@ -13,7 +13,7 @@ interface HomarrCustomWidgetV2 {
|
||||
}
|
||||
```
|
||||
|
||||
The object key `default` is the required source ID; `default` is not a property on a source. Source properties are `name?`, `baseUrl`, `networkScope`, and `auth?`:
|
||||
Sources are keyed by ID. Empty `sources` and `requests` support static widgets. Each HTTP request must reference an existing source; omitting its source selects `default`. Source properties are `name?`, `baseUrl`, `networkScope`, and `auth?`:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -47,3 +47,5 @@ Use stable real URLs for public APIs and clear suggested URLs for self-hosted se
|
||||
Paths use `{option:name}` and `{param:name}`; query/body references use `{ "$option": "name" }` and `{ "$param": "name" }`. Constants stay primitive (`take: 10`); `$param` is only for manual helpers, never load queries. Names and types are inferred.
|
||||
|
||||
Every option has `label`, `control`, and `default`. Optional fields are `description`, `choices`, `choicesFrom`, `min`, `max`, `step`, `advanced`, and `group`.
|
||||
|
||||
V3 uses `$schema: "homarr-custom-widget-v3"` and optional `extensions`: `stylesheet`, `fragments`, `preferences`, `content`, and `native`. Read `customWidget_schema` for their exact shapes. Integration options use `control: "integration"`, `default: ""`, and optional `integrationKinds`; installations choose local integrations. Discover native input schemas through `customWidget_nativeCapabilities`. Editor layout is separate from the manifest.
|
||||
@@ -4,4 +4,4 @@ All requests use Homarr's protected server executor. Source origin, network scop
|
||||
|
||||
The JSX interpreter blocks imports, hooks, refs, raw event callbacks, browser requests, eval, arbitrary functions, prototype access, unsafe URLs, global CSS escape, arbitrary portals, bigint, statement blocks, IIFEs, and recursion. Regex literals must be bounded and reject backreferences, lookbehind, nested quantifiers, excessive length, and unsupported flags.
|
||||
|
||||
Credentials are stored separately and never exported or returned to an agent. A published self-hosted source URL is only a suggestion: installers must confirm or replace private and loopback URLs for their own Homarr deployment. Source origins cannot be controlled through widget options.
|
||||
Credentials stay local and are never exported. Installers confirm or replace suggested private and loopback URLs for their deployment. Source origins are fixed by configuration.
|
||||
@@ -98,3 +98,28 @@ and focused references under `/api/custom-widgets/`.
|
||||
|
||||
Use [Custom Widget agent authoring](../custom-widgets/agent-authoring) for the lifecycle and
|
||||
[MCP](../mcp) when the client supports Model Context Protocol.
|
||||
|
||||
Workshop installation through `customWidget.workshopInstall` and the in-app catalog uses the same server path. Supply
|
||||
`expectedRevision` from `customWidget.workshopGet` to install only the package you reviewed. Source URL overrides and
|
||||
credentials are local; the installed definition retains its Workshop provenance.
|
||||
For native capabilities, pass local integration selections as `integrations: { "optionId": "integrationId" }`; portable
|
||||
defaults do not carry another Homarr instance's integration IDs.
|
||||
|
||||
When renaming a saved source through `customWidget.update` or `customWidget.previewCreate`, include
|
||||
`sourceRenames: { "previousId": "newId" }` to preserve its credentials. The server checks the original and resulting
|
||||
source bindings; changed origin, network scope, or authentication requires re-entered credentials. A rename moves
|
||||
encrypted credentials transactionally and does not copy them into the portable definition.
|
||||
|
||||
For a linked installation, `customWidget.workshopUpdateReview` returns the current and incoming definitions, source-binding
|
||||
changes, local-modification status, and review tokens. Pass its `expectedFingerprint`, `expectedState`,
|
||||
`upstreamFingerprint`, and `revision` unchanged to `customWidget.workshopUpdateInstall`. Both procedures require admin
|
||||
access and are exposed through MCP with an underscore replacing the dot. An intervening edit or upstream revision
|
||||
returns `CONFLICT`; fetch a fresh review instead of retrying the old update. Source/authentication changes and local
|
||||
customizations require a separate installation.
|
||||
|
||||
Shared widget content uses `customWidget.contentRead({ itemId })` and
|
||||
`customWidget.contentWrite({ itemId, name, expectedRevision, value })`. Reads require access to the board; writes require
|
||||
authentication and the declaration's `modify` or `full` board permission. Each value has an independent revision; `0`
|
||||
means the declared default has not been saved. A stale revision returns `CONFLICT`; read again before saving. Content is
|
||||
scoped to the placed item and its selected definition. Workbench previews keep content changes in memory and cannot
|
||||
persist them through these procedures. These procedures are not exposed as MCP tools.
|
||||
@@ -26,7 +26,7 @@ Equivalent HTTP resources are available under `/api/custom-widgets/` for authent
|
||||
## Authoring lifecycle
|
||||
|
||||
1. Load the skill and only the references needed for the design.
|
||||
2. Read the external API documentation and build the definition.
|
||||
2. Read the external API documentation or discover Homarr capabilities with `customWidget_nativeCapabilities`, then build the definition.
|
||||
3. Validate the JSX, then create a preview from the complete definition.
|
||||
4. Run every returned query and simulate every relevant action against the current preview revision.
|
||||
5. Inspect the preview and redacted journal. Revise the existing preview for JSX-only changes.
|
||||
@@ -34,7 +34,9 @@ Equivalent HTTP resources are available under `/api/custom-widgets/` for authent
|
||||
7. Configure deployment-specific source URLs and credentials, then preview the saved definition again.
|
||||
|
||||
Use `templateLines` for multiline JSX tool input. A preview must have current evidence for every query and action before
|
||||
it can be saved. Simulation counts as action evidence without performing the mutation.
|
||||
it can be saved. Simulation counts as action evidence without performing the mutation. V3 native queries and actions use
|
||||
`customWidget_nativeQuery` and `customWidget_nativeAction` with the owned preview session ID; their evidence is recorded
|
||||
under `native:<id>`. Native capabilities must also have current evidence before preview-based persistence.
|
||||
|
||||
If the agent does not have a source URL or credential, it can request a short-lived Homarr configuration URL. An
|
||||
authenticated administrator completes it in Homarr; the agent receives the status, never the plaintext credential.
|
||||
@@ -43,3 +45,10 @@ Agents can also search, inspect, install, and configure existing Workshop widget
|
||||
|
||||
See [MCP](/docs/management/mcp) for client configuration and [requests and security](./requests-and-security) for the
|
||||
runtime limits.
|
||||
|
||||
## Editing an open workbench
|
||||
|
||||
Homarr's built-in Assistant can use `workbench_read`, `workbench_patch`, and `workbench_preview` to change the visible draft.
|
||||
Read before patching, supply the returned draft ID and revision, and describe one coherent change. Credentials are excluded from these
|
||||
tools. The workbench validates the resulting definition and rejects stale patches; one Undo reverses an accepted patch.
|
||||
Browser draft tools require an open workbench and do not grant permission to save, publish, or run live actions.
|
||||
@@ -1,29 +1,67 @@
|
||||
# Create a Custom Widget
|
||||
|
||||
The workbench provides editors for general metadata, API sources, requests, widget options, JSX, and preview data. Raw
|
||||
JSON is available when direct editing is faster.
|
||||
The definition remains the source of truth in the workbench. Existing widgets open without converting or rewriting their
|
||||
JSX. Visual changes use targeted source edits; complex expressions remain editable in code.
|
||||
|
||||
## Workflow
|
||||
## Definition and layout
|
||||
|
||||
1. Configure each API source, network scope, authentication method, and credential.
|
||||
2. Add named queries and actions. Values can reference widget options or invocation parameters.
|
||||
3. Define widget options and their defaults.
|
||||
4. Write JSX using `data`, `status`, `options`, and temporary `inputs`.
|
||||
5. Run **Test and preview**, inspect the response data and redacted request journal, then save.
|
||||
A static widget needs only a JSX template: set `sources` and `requests` to `{}`. It makes no service request.
|
||||
|
||||
Changing the definition, options, or credentials invalidates the previous preview. Preview actions are simulated unless
|
||||
**Run actions against the configured API** is enabled for that preview session.
|
||||
HTTP requests run on the Homarr server. Native capabilities use Homarr's installed integrations and existing
|
||||
permissions. Embeds open in the viewer's browser and need a URL reachable from that browser.
|
||||
|
||||
Widget options support text, textarea, number, switch, select, multi-select, slider, date, time, color, icon, URL,
|
||||
duration, time zone, and JSON controls. Dynamic choices can come from a load query.
|
||||
Node positions and groups are stored separately from the runtime definition. Groups do not change execution. Source
|
||||
associations, data references, action invocations, and cache invalidations are distinct relationships; an invalidation edge
|
||||
does not create an execution dependency. Referenced objects cannot be removed until their dependants are resolved.
|
||||
|
||||
See the [safe JSX runtime](./custom-jsx), [component reference](./component-reference), and
|
||||
[request reference](./requests-and-security) for the authoring contract.
|
||||
## Visual bindings
|
||||
|
||||
## Create with another AI service
|
||||
Connecting a query, option, or action to the Widget opens a binding configuration. Queries can insert text, JSON, lists,
|
||||
or tables; request parameters can use options, temporary controls, or fixed values. Connecting options to a request changes
|
||||
its parameter bindings. These edits do not run queries or actions.
|
||||
|
||||
**Create or fix with any AI** copies a self-contained prompt for an ordinary ChatGPT or Claude conversation. The model
|
||||
must return one fenced `json` block containing the complete definition. Paste that block into the workbench, add
|
||||
credentials separately, and validate it against a real preview.
|
||||
Generated JSX includes ownership comments. The canvas can disconnect or replace unchanged generated bindings as one Undo
|
||||
operation. Editing a generated block or mixing it with authored references makes the relationship code-owned; resolve it
|
||||
in JSX to preserve your changes. Removing ownership comments also removes visual ownership, without changing rendering.
|
||||
|
||||
For an MCP-connected agent, use the [Homarr Custom Widget skill](./agent-authoring) instead.
|
||||
## Preview evidence
|
||||
|
||||
Moving nodes, zooming, and opening inspectors do not execute requests or invalidate the preview. Editing the definition,
|
||||
options, or credentials marks execution evidence stale. Valid JSX renders against the last data; invalid JSX leaves the
|
||||
last valid preview visible with a stale label. Queries run deliberately, not on every keystroke.
|
||||
|
||||
Preview actions simulate by default. Enabling **Run actions against the configured API** applies only to that preview
|
||||
session. Native actions also require declared confirmation and their normal integration permissions. Undo changes the draft;
|
||||
it never undoes or repeats a service action.
|
||||
|
||||
## Undo and draft recovery
|
||||
|
||||
Undo/Redo covers code, structured edits, renames, node movement, and Assistant patches. Typing is grouped and a drag or
|
||||
coherent Assistant change is one operation. The bounded history excludes credentials, responses, selection, and viewport
|
||||
movement. **Reset to saved** restores the saved definition and layout. Saving from an outdated workbench tab reports a
|
||||
conflict rather than overwriting a newer save.
|
||||
Temporary test parameters and unfinished request-body JSON keep local input Undo, so editing them does not undo an unrelated
|
||||
definition change. Credential fields also retain their own input history.
|
||||
|
||||
Optional browser draft recovery keeps a credential-free draft and editor layout. Recovery is local to the browser and
|
||||
requires a valid recoverable configuration. It is not a revision archive or a backup of service data.
|
||||
|
||||
## Assistant changes
|
||||
|
||||
The Assistant reads the visible draft and selection, edits through shared draft commands, and can run preview queries. A stale patch is
|
||||
rejected when the draft has changed. Saving, live service actions, and Workshop publication remain separate operations.
|
||||
|
||||
Form view retains **Create or fix with any AI** for external AI services. Configure credentials locally after importing
|
||||
the returned definition and validate the result.
|
||||
|
||||
## Advanced capabilities
|
||||
|
||||
Native capabilities, scoped CSS, reusable view fragments, typed preferences, and shared content use
|
||||
`homarr-custom-widget-v3`; ordinary HTTP and static widgets remain valid v2 definitions. Native input bindings must match
|
||||
the installed capability's schema. See the [runtime](./custom-jsx) for the expression language and extension contracts.
|
||||
|
||||
V3 adds an integration picker with optional `integrationKinds`. The integration list respects the
|
||||
current user's access. Portable exports clear local integration selections so installers choose their own services.
|
||||
|
||||
See the [runtime](./custom-jsx), [component reference](./component-reference),
|
||||
[native capabilities](./native-capabilities), and [request reference](./requests-and-security).
|
||||
@@ -7,7 +7,7 @@ Templates are JSX expressions interpreted inside a widget-local safety and rende
|
||||
- `options.name` — saved widget configuration
|
||||
- `inputs.name` — temporary values from declarative `bind`
|
||||
|
||||
The v2 manifest requires a `default` source and supports up to eight sources, 64 requests, and 64 options. Source IDs are
|
||||
Manifests support up to eight sources, 64 requests, and 64 options. A static widget can use empty `sources` and `requests`; every HTTP request must name an existing source. Source IDs are
|
||||
case-insensitively unique. Each request allows up to 32 static headers and 32 invalidation targets; a widget can contain
|
||||
up to 24 secrets, each up to 8,192 characters, and templates are limited to 50,000 characters. Validation reports the
|
||||
exact field when a limit is exceeded.
|
||||
@@ -41,7 +41,7 @@ before it reaches `data`, so treat it as untrusted input even when the source is
|
||||
</Stack>
|
||||
```
|
||||
|
||||
Bound values live only in React memory while the widget is mounted. They are not saved to the database, board options, exports, browser storage, or `localStorage`. Remounting resets them. Components can share a binding when their adapter types agree.
|
||||
Without an explicit v3 `persist` binding, bound values live only in React memory while the widget is mounted. They are not saved to the database, board options, exports, browser storage, or `localStorage`. Remounting resets them. Components can share a binding when their adapter types agree.
|
||||
|
||||
Bindable controls accept a scalar `resetKey`. When it changes, Homarr restores that control to its declared `defaultValue` or `defaultChecked` without running a request. This keeps dependent pagination correct while a manual `SubFetch` waits for the user to submit the new query.
|
||||
|
||||
@@ -74,3 +74,38 @@ than one variable quantifier are rejected.
|
||||
- `Icon`/`TablerIcon` resolves installed safe icons.
|
||||
|
||||
Always include responsive loading, empty, error, and success states. Runtime failures remain inside the widget tile.
|
||||
|
||||
## Version 3 capabilities
|
||||
|
||||
Use `$schema: "homarr-custom-widget-v3"` for `extensions`, integration options, scoped classes, and the components below. Version 2 definitions continue to render; opening them in the workbench does not upgrade their runtime schema. Editor positions are Homarr metadata and are excluded from the portable definition.
|
||||
|
||||
`extensions.stylesheet` accepts up to 30,000 characters. `className` references local classes; selectors and keyframe names are scoped per widget instance, including content opened through `WidgetModal` and `WidgetDrawer`. Use flat rules, `@media`, `@supports`, `@container`, and local `@keyframes`. Resource-loading CSS (`url()`, imports, fonts), global registration rules, and unresolved animation names are rejected. Theme CSS variables remain available. Inline style restrictions still apply.
|
||||
|
||||
Templates can read `theme.colorScheme`, `theme.primaryColor`, `theme.colors`, `theme.spacing`, `theme.radius`, and `theme.fontFamily`. `container.width` and `container.height` report the widget content size in CSS pixels, so responsive composition can follow its tile rather than the browser viewport.
|
||||
|
||||
Declare reusable JSX in `extensions.fragments` and render it with `<View name="metric" values={{ label: "CPU" }} />`. The fragment reads `props.label` and inherits the normal data bindings. Fragment names are literal, recursion is rejected, and all expansions share the interpreter's existing operation budget. Up to 32 fragments and 100,000 combined JSX characters are supported.
|
||||
|
||||
```json
|
||||
{
|
||||
"preferences": {
|
||||
"filter": { "type": "string", "defaultValue": "" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The fragment above belongs under `extensions`. A matching `<TextInput bind="filter" persist="filter" label="Filter" />` exposes the value through both `inputs.filter` and `preferences.filter`. Preferences are typed, scoped to the board item, and saved only in that browser; another account or device gets its own values. Preview preferences remain in memory. Exports include the declarations and defaults, never the user's saved values. Storage-disabled browsers retain an in-memory fallback. `resetKey` resets a persisted control to its declared preference default.
|
||||
|
||||
`WidgetModal` and `WidgetDrawer` require `title` and `triggerLabel`; Homarr owns their open/close behavior, focus handling, and portal boundary. They allow detailed views without authored callbacks.
|
||||
Supported selects, menus, tooltips, and popovers use Homarr-owned dropdown targets to escape tile clipping while preserving
|
||||
widget styles and the preview theme. Nested dropdowns stay within their parent overlay. Authored event handlers, refs, and
|
||||
portal targets remain blocked; Mantine's own trigger handlers work through the component adapters.
|
||||
|
||||
`AppEmbed` requires an absolute HTTP(S) `src` and an accessible `title`. Its URL is reached by the browser, whereas HTTP sources are reached by the Homarr server; a Docker service hostname usually cannot be used for both. The embedded service must permit framing. The iframe uses an isolated origin and restricts navigation; apps requiring same-origin storage or unrestricted browser capabilities need their normal external link instead. `allowForms` explicitly enables embedded form submission.
|
||||
|
||||
`extensions.native` maps local IDs to curated Homarr capabilities. Integration-backed entries reference a saved `integration` option through `integrationOption`; the server applies the existing integration and board permissions. `<NativeQuery nativeId="systems">{(systems, meta) => <Text>{systems.length}</Text>}</NativeQuery>` uses the same loading/error behavior as `SubFetch`. Use `trigger="manual"` for invocation parameters and `live` for a supported subscription. Automatic results populate `data` and `status` under their native ID; manual results remain local to the component.
|
||||
|
||||
Native action entries require `kind: "action"`, `trigger: "manual"`, a `modify` or `full` permission, and a `confirmation` object containing `title` and `message`. Invoke them through `NativeActionButton`. Preview actions use the existing simulation mode; declaring an action does not authorize a live mutation. Native capability IDs and HTTP request IDs must be distinct.
|
||||
|
||||
The [Beszel systems studio definition](/custom-widgets/beszel-systems-studio-v3.json) combines systems, configurable columns, a saved system selection, historical charts, and live detail views. Select a local Beszel integration after importing it. The example uses `preferences` directly for values needed before a control mounts; `inputs` appears only after its bound control mounts. Live failures keep the last received event and expose `meta.stale` alongside Retry.
|
||||
|
||||
Shared editable content uses a separate `extensions.content` declaration with the same types/defaults as preferences plus `permission: "modify"` or `"full"`. `<Textarea bind="note" content="note" />` edits a local draft. `<ContentSaveButton name="note">Save note</ContentSaveButton>` commits it to the board item; `<ContentResetButton name="note">Reload note</ContentResetButton>` discards the draft and reloads the current shared value. Templates read `content.note` and `contentStatus.note` (`loading`, `dirty`, `saving`, `error`). Saves use the last read revision, so a stale draft cannot silently replace another saved value. Preview saves stay in memory; exports include declarations/defaults but exclude saved board content.
|
||||
@@ -15,8 +15,9 @@ Custom Widgets may change during the v2 release cycle. Share feedback in the
|
||||
|
||||
:::
|
||||
|
||||
A **Custom Widget** is a validated `homarr-custom-widget-v2` definition containing API sources, queries and actions,
|
||||
optional settings, and a safe JSX template.
|
||||
A **Custom Widget** is a validated definition with a safe JSX template, optional settings, and optional HTTP sources,
|
||||
queries and actions. Existing definitions use `homarr-custom-widget-v2`; advanced composition and native capabilities use
|
||||
`homarr-custom-widget-v3`. Static widgets need no API source.
|
||||
|
||||
Homarr executes requests on the server and renders the template in a restricted runtime. Source credentials are
|
||||
encrypted separately and are not included in exports, prompts, browser data, or Workshop submissions.
|
||||
@@ -49,7 +50,7 @@ and simulate relevant actions before saving. Live preview actions are opt-in bec
|
||||
|
||||
## Import and Workshop
|
||||
|
||||
Use **Import from Workshop** while adding board content or import a v2 JSON definition directly. Review the source,
|
||||
Use **Import from Workshop** while adding board content or import a supported v2 or v3 JSON definition directly. Review the source,
|
||||
requested permissions, API hosts, and actions before installation, then configure deployment-specific URLs and
|
||||
credentials in Homarr.
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# Native capabilities
|
||||
|
||||
V3 Custom Widgets can call a curated set of Homarr capabilities through `NativeQuery` and `NativeActionButton`.
|
||||
These use the same integration procedures, caches, partial-failure responses, and permissions as their native counterparts.
|
||||
A definition declares each capability explicitly; JSX cannot call arbitrary tRPC procedures.
|
||||
|
||||
## Available capabilities
|
||||
|
||||
| Capability | Behavior | Required input |
|
||||
| ----------------------------------------------- | ------------------------------------------------------------------------------- | ---------------------------------------------------------- |
|
||||
| `beszel.systems` | Systems overview, status, CPU, memory, disk, network and other reported metrics | None |
|
||||
| `beszel.history` | Historical system and optional container statistics | `systemId`; optional `timePeriod`, `includeDocker` |
|
||||
| `beszel.alerts` | Alerts and optional history | Optional `includeHistory`, `maxHistoryItems` |
|
||||
| `beszel.live` | Initial short history or a live statistics stream | `systemId` |
|
||||
| `calendar.events` | Calendar events from supported integrations | `year`, `month`; optional `releaseType`, `showUnmonitored` |
|
||||
| `media.requests` | Media requests and partial integration errors | Optional `statuses`, `recentDays` |
|
||||
| `media.stats` | Request totals and users | None |
|
||||
| `media.respond` | Approve or decline a media request | `requestId`, `answer` |
|
||||
| `smartHome.state` | Home Assistant entity state | `entityId` |
|
||||
| `smartHome.details` | Entity state, attributes and timestamps | `entityId` |
|
||||
| `smartHome.switch` | Toggle an entity | `entityId` |
|
||||
| `smartHome.automation` | Trigger an automation | `automationId` |
|
||||
| `docker.containers` | Container inventory and resource use | Optional `endpointIds` |
|
||||
| `docker.start`, `docker.stop`, `docker.restart` | Control a container | `endpointId`, `containerId` |
|
||||
|
||||
Use `customWidget_nativeCapabilities` to discover the exact input schema for the installed Homarr version.
|
||||
Docker capabilities retain the existing administrator requirement and endpoint capability checks.
|
||||
|
||||
## Declare and select an integration
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "homarr-custom-widget-v3",
|
||||
"name": "My systems",
|
||||
"sources": {},
|
||||
"requests": {},
|
||||
"options": {
|
||||
"beszel": {
|
||||
"label": "Beszel integration",
|
||||
"control": "integration",
|
||||
"integrationKinds": ["beszel"],
|
||||
"default": ""
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
"native": {
|
||||
"systems": {
|
||||
"capability": "beszel.systems",
|
||||
"integrationOption": "beszel",
|
||||
"kind": "query",
|
||||
"trigger": "load",
|
||||
"permission": "view",
|
||||
"input": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"template": "<NativeQuery nativeId=\"systems\">{(services) => <Stack>{services.map((service) => <Stack key={service.integrationId}><Text fw={600}>{service.integrationName}</Text>{service.systems.map((system) => <Text key={system.id}>{system.name}</Text>)}</Stack>)}</Stack>}</NativeQuery>"
|
||||
}
|
||||
```
|
||||
|
||||
Choose a local integration in the workbench, during installation, or in board configuration. Portable exports clear the
|
||||
local selection. `integrationOption` reads saved configuration, not invocation parameters; a browser cannot replace the
|
||||
integration ID by sending a different runtime input. An integration still needs to be accessible to the viewer under
|
||||
Homarr's integration permissions. Placing a custom widget does not grant general integration access.
|
||||
|
||||
## Inputs, detail views and live data
|
||||
|
||||
A capability's `input` supports the same recursive `$option` and `$param` references as HTTP request bodies. Invocation
|
||||
parameters are primitive values. Use `trigger: "manual"` when an input depends on a selected row or a temporary input;
|
||||
load capabilities cannot depend on invocation parameters.
|
||||
|
||||
The workbench reads each installed capability's input schema and offers fixed values, saved-option bindings, and invocation
|
||||
parameters. Switching an input to a parameter also switches its trigger to manual. Complex nested inputs retain a JSON
|
||||
editor; scalar and list inputs do not require editing the extensions manifest.
|
||||
|
||||
Use `<NativeQuery nativeId="details" trigger="manual" params={{ systemId: inputs.systemId }}>` for a selected-system
|
||||
query. For a declared `beszel.live` capability, add `live` to receive events. Historical and live responses retain the
|
||||
underlying Beszel response shapes; a live event contains its event type and record. Compose those results with supported
|
||||
charts, tables, fragments, modals, or drawers. Live subscriptions reconnect periodically to renew authorization and bounded
|
||||
request capacity, and stop when their widget unmounts or a preview session expires.
|
||||
|
||||
## Actions and preview
|
||||
|
||||
Native actions require `kind: "action"`, `trigger: "manual"`, `permission: "modify"` or `"full"`, and a `confirmation`
|
||||
object containing `title` and `message`. Render them with `NativeActionButton`. The server checks both board permission and
|
||||
the native integration's interaction permission; Docker retains its administrator policy.
|
||||
|
||||
**Test and preview** runs declared load queries. Manual queries and actions are exercised through preview controls. Native
|
||||
actions simulate until live preview actions are explicitly enabled. The journal records native entries as `native:<id>`;
|
||||
preview-based creation requires current evidence for every declared HTTP and native capability.
|
||||
|
||||
Individual capability tests use the current preview session. Their parameter values and responses remain local to the
|
||||
editor and are not saved into the definition. Action tests in the capability inspector always simulate; they are disabled
|
||||
while the preview has live actions enabled. Use the rendered preview's confirmation flow to exercise live actions.
|
||||
|
||||
This is the initial native capability library. Other self-hosted services can use HTTP sources or browser embeds. Services
|
||||
without an accessible HTTP interface, supported embed, or curated Homarr capability need an external bridge. Custom Widgets
|
||||
do not run authored backend code, shell commands, imports, or arbitrary event callbacks.
|
||||
@@ -1,12 +1,18 @@
|
||||
# Configure requests and security
|
||||
|
||||
Most widgets use a single source named `default`; the manifest must contain it. Extra keyed sources are available for widgets
|
||||
that combine APIs. Source IDs are case-insensitively unique. Base URLs must use HTTP(S), contain no embedded credentials,
|
||||
HTTP widgets often use a source named `default`. Every request must reference an existing source; an omitted request source
|
||||
resolves to `default`. Static and native-only widgets can use empty `sources` and `requests`. Extra keyed sources let widgets
|
||||
combine APIs. Source IDs are case-insensitively unique. Base URLs must use HTTP(S), contain no embedded credentials,
|
||||
query string, or fragment, and sources can be `public`, `private`, or `loopback`. Authentication is `none`, `bearer`,
|
||||
`basic`, an API-key header, or an API-key query parameter. Credentials belong to the widget but remain outside its manifest.
|
||||
Source origins are fixed by the definition and source-setup flow; widget options and invocation parameters cannot redirect a
|
||||
request to a different origin.
|
||||
|
||||
Source URLs are fetched by the Homarr server. In a container, `127.0.0.1` refers to that container; Docker service names such
|
||||
as `sonarr` must resolve from Homarr. LAN addresses and loopback services are supported with the appropriate explicit scope.
|
||||
The workbench suggests a likely local scope from the hostname; server DNS and redirect checks remain authoritative.
|
||||
Browser navigation and app embeds use browser-reachable URLs instead.
|
||||
|
||||
## Named requests
|
||||
|
||||
Requests are keyed by ID. `source`, `kind`, `method`, `trigger`, `auth`, and `permission` have sensible defaults, so a load query can be as small as:
|
||||
|
||||
@@ -70,37 +70,6 @@ through MCP.
|
||||
The built-in [Assistant](./assistant) uses the same tool catalog with the current Homarr session and asks for approval
|
||||
before mutations by default.
|
||||
|
||||
## Authenticated integration requests
|
||||
|
||||
`integration_request` calls API endpoints without a dedicated tool, using stored integration credentials.
|
||||
Get `integrationId` from `integration_all`. Every method requires **full integration access**: arbitrary GET
|
||||
endpoints can expose credentials or change state. DELETE additionally requires `confirmed: true` after user confirmation.
|
||||
|
||||
Ask your agent to consult the service's official API docs with its browsing tools, or provide the endpoint contract.
|
||||
For example, using the [Sonarr API docs](https://sonarr.tv/docs/api/#v3):
|
||||
|
||||
```json
|
||||
{ "integrationId": "<Sonarr ID>", "method": "GET", "path": "/api/v3/series" }
|
||||
```
|
||||
|
||||
Deleting series 42 **and its episode files**, after confirmation:
|
||||
|
||||
```json
|
||||
{ "integrationId": "<Sonarr ID>", "method": "DELETE", "path": "/api/v3/series/42?deleteFiles=true", "confirmed": true }
|
||||
```
|
||||
|
||||
Supports GET, POST, PUT, PATCH and DELETE with an optional JSON `body` (10 KiB maximum; not allowed for GET).
|
||||
Returns `{status, data}` with parsed JSON, text, or `null` for an empty body, including upstream error responses.
|
||||
Known credentials are redacted. Requests have a 15-second total deadline, a 10-second per-request timeout and a
|
||||
1 MiB response limit. HTTPS uses Homarr's trusted certificates and hostname exceptions.
|
||||
|
||||
Paths follow `new URL(path, integration.url)`: `/api/...` starts at the origin root; `api/...` resolves relative to
|
||||
the configured URL's directory. Absolute URLs, protocol-relative paths, credentials, fragments and redirects are rejected.
|
||||
|
||||
Supported integrations: Sonarr, Radarr, Lidarr, Readarr, Prowlarr, Overseerr, Jellyseerr, Seerr, Immich, slskd,
|
||||
Home Assistant, Coolify, Audiobookshelf, Speedtest Tracker, AdGuard Home and Nextcloud. Other authentication schemes
|
||||
are rejected. The built-in Assistant's default mutation approval applies to this tool, including GET calls.
|
||||
|
||||
## Verify the connection
|
||||
|
||||
This request lists tools through the older-client compatibility path. It checks authentication and tool discovery:
|
||||
|
||||
@@ -31,12 +31,37 @@ from Custom Widget and CSS submissions.
|
||||
- Install a Custom Widget from **Management → Custom Widgets → Import from Workshop**.
|
||||
- Import dashboard CSS from **Board settings → Custom CSS → Import from Workshop**.
|
||||
|
||||
Custom Widget credentials are configured in your Homarr instance and are not part of the Workshop submission.
|
||||
Custom Widget credentials stay in your Homarr instance. Ordinary installation does not load the workbench; **Remix in
|
||||
workbench** installs a separate local widget and opens its draft. Widgets without data sources need no URL or credentials.
|
||||
|
||||
Installation records the Workshop endpoint, submission, revision, and content fingerprint. A JSON file import has no
|
||||
Workshop identity, even when its name matches a submission. Editor layout and credentials are not part of the portable
|
||||
definition. Native integration selections are also local: exports clear their default IDs, and installation binds them
|
||||
to existing integrations in your instance. Leaving a selection empty defers setup to each board widget's configuration.
|
||||
|
||||
## Updates and rollback
|
||||
|
||||
The workbench's Workshop panel compares a linked installation with its latest revision. Applying an update preserves
|
||||
local service URLs, network scopes, and credentials. Changed source identities or authentication destinations require
|
||||
installing a separate copy. Locally modified definitions also require a separate copy; Homarr does not merge authored JSX.
|
||||
Compatible native integration selections are retained; new options and options whose allowed integration kinds change
|
||||
start unconfigured. The review also includes shared-content declarations and their write permissions. Stored board content
|
||||
and each user's preferences remain in Homarr and are not included in a submission.
|
||||
|
||||
Each update retains one previous installed package for rollback. Rollback restores the definition, preserves current
|
||||
service URLs and credentials, and works without contacting Workshop. It refuses changed source authentication bindings.
|
||||
Changing the configured Workshop endpoint disables upstream updates for installations linked to the previous endpoint.
|
||||
|
||||
## Publish content
|
||||
|
||||
Publish a saved Custom Widget from its action menu under **Management → Custom Widgets**. Custom CSS and manual
|
||||
submissions can be published from the Workshop website.
|
||||
Publish a saved Custom Widget from its action menu or workbench. The reviewed saved definition must still match when
|
||||
publication starts. Updating a linked submission requires its Workshop author account and preserves its identity,
|
||||
screenshots, votes, and comments. A remix creates a new submission with attribution. Publishing succeeds independently of
|
||||
linking the resulting submission back to your local widget; a linking failure must not be retried as a new publication.
|
||||
|
||||
Custom JSX v2 remains supported. Scoped styles, fragments, preferences, shared content, and native capabilities use
|
||||
`homarr-custom-widget-v3`; Workshop clients and the Workshop service must support that schema before publishing or
|
||||
updating such submissions. Custom CSS and manual submissions can be published from the Workshop website.
|
||||
|
||||
Sign in to the Workshop and open **Yours** to update or delete submissions and review reports. Signed-in users can vote,
|
||||
comment, and report content; Workshop moderators can remove content. Workshop moderation is separate from administration
|
||||
|
||||
@@ -50,7 +50,7 @@ const placeholders: Record<SubmissionType, string> = {
|
||||
customCss: ".grid-stack-item-content {\n border-radius: 16px;\n}",
|
||||
};
|
||||
const contentLabels: Record<SubmissionType, string> = {
|
||||
customWidget: "Widget JSON (homarr-custom-widget-v2)",
|
||||
customWidget: "Widget JSON (homarr-custom-widget-v2 or v3)",
|
||||
customCss: "Custom CSS",
|
||||
};
|
||||
|
||||
@@ -314,7 +314,7 @@ const typeCards: { type: SubmissionType; icon: typeof IconBraces; label: string;
|
||||
type: "customWidget",
|
||||
icon: IconBraces,
|
||||
label: "Custom Widget",
|
||||
desc: "A JSON-based widget using the homarr-custom-widget-v2 schema",
|
||||
desc: "A JSON-based widget using the homarr-custom-widget-v2 or v3 schema",
|
||||
},
|
||||
{
|
||||
type: "customCss",
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"$schema": "homarr-custom-widget-v3",
|
||||
"name": "Beszel systems studio",
|
||||
"description": "Systems overview, configurable columns, selected-system history and live details using your existing Beszel integration. No HTTP source or copied API token required.",
|
||||
"sources": {},
|
||||
"requests": {},
|
||||
"options": {
|
||||
"beszel": {
|
||||
"label": "Beszel integration",
|
||||
"control": "integration",
|
||||
"integrationKinds": ["beszel"],
|
||||
"default": ""
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
"stylesheet": ".metric { padding: 14px; border-radius: 12px; background: var(--mantine-color-default-hover); } .system-table { font-variant-numeric: tabular-nums; }",
|
||||
"preferences": {
|
||||
"system": {
|
||||
"type": "string",
|
||||
"defaultValue": ""
|
||||
},
|
||||
"period": {
|
||||
"type": "string",
|
||||
"defaultValue": "1h"
|
||||
},
|
||||
"columns": {
|
||||
"type": "string[]",
|
||||
"defaultValue": ["cpu", "memory", "disk"]
|
||||
}
|
||||
},
|
||||
"fragments": {
|
||||
"metric": "<Stack className=\"metric\" gap={2}><Text size=\"xs\" c=\"dimmed\">{props.label}</Text><Text fw={700} size=\"xl\">{Number(props.value ?? 0).toFixed(1)}{props.suffix ?? \"%\"}</Text></Stack>",
|
||||
"containers": "<Stack gap=\"xs\">\n <Text size=\"xs\" c=\"dimmed\">{props.items.length} containers \u00b7 {Date.toLocaleTimeString(props.updatedAt)}</Text>\n {!props.items.length && <Text c=\"dimmed\">No containers reported.</Text>}\n {props.items.length > 0 && <ScrollArea><Table striped highlightOnHover>\n <Table.Thead><Table.Tr><Table.Th>Container</Table.Th><Table.Th>CPU</Table.Th><Table.Th>Memory</Table.Th><Table.Th>Sent</Table.Th><Table.Th>Received</Table.Th></Table.Tr></Table.Thead>\n <Table.Tbody>{props.items.map((row) => <Table.Tr key={row.n}>\n <Table.Td><Text fw={500}>{row.n}</Text></Table.Td>\n <Table.Td>{Number(row.c ?? 0).toFixed(1)}%</Table.Td>\n <Table.Td>{Number(row.m ?? 0).toFixed(1)} MiB</Table.Td>\n <Table.Td>{Number((row.b?.[0] ?? (row.ns ?? 0) * 1048576) / 1024).toFixed(1)} KiB/s</Table.Td>\n <Table.Td>{Number((row.b?.[1] ?? (row.nr ?? 0) * 1048576) / 1024).toFixed(1)} KiB/s</Table.Td>\n </Table.Tr>)}</Table.Tbody>\n </Table></ScrollArea>}\n</Stack>"
|
||||
},
|
||||
"native": {
|
||||
"systems": {
|
||||
"capability": "beszel.systems",
|
||||
"integrationOption": "beszel",
|
||||
"kind": "query",
|
||||
"permission": "view",
|
||||
"trigger": "load",
|
||||
"input": {}
|
||||
},
|
||||
"history": {
|
||||
"capability": "beszel.history",
|
||||
"integrationOption": "beszel",
|
||||
"kind": "query",
|
||||
"permission": "view",
|
||||
"trigger": "manual",
|
||||
"input": {
|
||||
"systemId": {
|
||||
"$param": "systemId"
|
||||
},
|
||||
"timePeriod": {
|
||||
"$param": "period"
|
||||
},
|
||||
"includeDocker": true
|
||||
}
|
||||
},
|
||||
"live": {
|
||||
"capability": "beszel.live",
|
||||
"integrationOption": "beszel",
|
||||
"kind": "query",
|
||||
"permission": "view",
|
||||
"trigger": "manual",
|
||||
"input": {
|
||||
"systemId": {
|
||||
"$param": "systemId"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"template": "<NativeQuery nativeId=\"systems\" refreshInterval={30}>{(services) => (\n <Stack p=\"md\" gap=\"md\">\n <Group justify=\"space-between\"><Title order={3}>Systems</Title><Badge variant=\"light\">{services.flatMap((service) => service.systems).length} hosts</Badge></Group>\n <Group grow align=\"flex-start\">\n <TextInput label=\"Filter systems\" bind=\"filter\" />\n <Select label=\"Selected system\" bind=\"system\" persist=\"system\" searchable clearable data={services.flatMap((service) => service.systems.map((system) => ({ value: system.id, label: system.name })))} />\n <MultiSelect label=\"Visible columns\" bind=\"columns\" persist=\"columns\" data={[{value:\"cpu\",label:\"CPU\"},{value:\"memory\",label:\"Memory\"},{value:\"disk\",label:\"Disk\"}]} />\n </Group>\n {services.map((service) => <Stack key={service.integrationId} gap=\"xs\">\n <Group justify=\"space-between\"><Text fw={600}>{service.integrationName}</Text><Text size=\"xs\" c=\"dimmed\">Updated {Date.toLocaleTimeString(service.updatedAt)}</Text></Group>\n {service.error && <Alert color=\"yellow\" title=\"Integration unavailable\">{service.error}</Alert>}\n <ScrollArea><Table className=\"system-table\" striped highlightOnHover>\n <Table.Thead><Table.Tr><Table.Th>System</Table.Th><Table.Th>Status</Table.Th>{preferences.columns.includes(\"cpu\") && <Table.Th>CPU</Table.Th>}{preferences.columns.includes(\"memory\") && <Table.Th>Memory</Table.Th>}{preferences.columns.includes(\"disk\") && <Table.Th>Disk</Table.Th>}</Table.Tr></Table.Thead>\n <Table.Tbody>{service.systems.filter((system) => !inputs.filter || system.name.toLowerCase().includes(inputs.filter.toLowerCase())).map((system) => <Table.Tr key={system.id}>\n <Table.Td><Text fw={600}>{system.name}</Text><Text size=\"xs\" c=\"dimmed\">{system.hostname}</Text></Table.Td>\n <Table.Td><Badge variant=\"light\">{system.status}</Badge></Table.Td>\n {preferences.columns.includes(\"cpu\") && <Table.Td>{Number(system.cpu ?? 0).toFixed(1)}%</Table.Td>}\n {preferences.columns.includes(\"memory\") && <Table.Td>{Number(system.memory ?? 0).toFixed(1)}%</Table.Td>}\n {preferences.columns.includes(\"disk\") && <Table.Td>{Number(system.disk ?? 0).toFixed(1)}%</Table.Td>}\n </Table.Tr>)}</Table.Tbody>\n </Table></ScrollArea>\n {!service.systems.length && !service.error && <Text c=\"dimmed\">No systems reported.</Text>}\n </Stack>)}\n {!preferences.system && <Text size=\"sm\" c=\"dimmed\">Select a system to open historical and live details.</Text>}\n {preferences.system && <WidgetDrawer title=\"Selected system\" triggerLabel=\"Open system details\" size=\"xl\">\n <Stack gap=\"md\">\n {services.flatMap((service) => service.systems).filter((system) => system.id === preferences.system).map((system) => <Stack key={system.id}>\n <Title order={3}>{system.name}</Title><Text size=\"sm\" c=\"dimmed\">{system.osName} \u00b7 {system.cpuModel} \u00b7 {system.cores} cores</Text>\n <SimpleGrid cols={{base:1,sm:3}}><View name=\"metric\" values={{label:\"CPU\",value:system.cpu}}/><View name=\"metric\" values={{label:\"Memory\",value:system.memory}}/><View name=\"metric\" values={{label:\"Disk\",value:system.disk}}/></SimpleGrid>\n </Stack>)}\n <Select label=\"History period\" bind=\"period\" persist=\"period\" data={[{value:\"1h\",label:\"Last hour\"},{value:\"12h\",label:\"12 hours\"},{value:\"24h\",label:\"24 hours\"},{value:\"1w\",label:\"Week\"},{value:\"30d\",label:\"30 days\"}]} />\n <NativeQuery nativeId=\"history\" params={{systemId:preferences.system,period:preferences.period}}>{(history) => <Stack>\n <Text fw={600}>Resource history</Text>\n {!history.systemStats.length && <Text c=\"dimmed\">No history in this period.</Text>}\n {history.systemStats.length > 0 && <LineChart h={230} data={history.systemStats.slice().sort((a,b) => a.created.localeCompare(b.created)).map((point) => ({time:Date.toLocaleTimeString(point.created),cpu:point.stats.cpu,memory:point.stats.mp,disk:point.stats.dp}))} dataKey=\"time\" series={[{name:\"cpu\",color:\"blue.6\"},{name:\"memory\",color:\"violet.6\"},{name:\"disk\",color:\"teal.6\"}]} withLegend unit=\"%\" />}\n <Text fw={600}>Latest container statistics</Text>\n {!history.containerStats.length && <Text c=\"dimmed\">No container history in this period.</Text>}\n {history.containerStats.slice().sort((a,b) => b.created.localeCompare(a.created)).slice(0,1).map((record) => <View key={record.id} name=\"containers\" values={{items:record.stats,updatedAt:record.created}} />)}\n </Stack>}</NativeQuery>\n <NativeQuery nativeId=\"live\" live params={{systemId:preferences.system}}>{(event,meta) => <Stack gap=\"xs\">\n <Group><Text fw={600}>Live event</Text>{meta.stale && <Badge color=\"yellow\">Last received data</Badge>}</Group>\n Line truncated
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"mantineVersion":"9.6.0","customWidgetVersion":"2.0.0","types":["\"\" | \"anonymous\" | \"use-credentials\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"auto\" | \"fixed\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"auto\" | \"all\" | \"fill\" | \"stroke\" | \"painted\" | \"visible\" | \"visibleFill\" | \"visiblePainted\" | \"visibleStroke\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"block\" | \"inline\" | \"both\" | \"horizontal\" | \"vertical\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"contain\" | \"cover\" | \"fill\" | \"scale-down\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"wrap\" | \"nowrap\" | \"wrap-reverse\"","\"12h\" | \"24h\"","\"activity\" | \"display-none\"","\"all\" | \"parent\" | \"child\"","\"all\" | \"segment\"","\"always\" | \"hover\" | \"not-hover\" | \"never\"","\"area\" | \"radial\" | \"scatter\"","\"auto\" | \"scroll\" | \"always\" | \"hover\" | \"never\"","\"auto-fit\" | \"auto-fill\"","\"block\" | \"grid\"","\"both\" | \"rightSection\" | \"clear\"","\"bottom\" | \"top\"","\"bottom\" | \"top\" | \"baseline\" | \"middle\"","\"bottom\" | \"top\" | \"middle\"","\"bottom-end\" | \"bottom-start\" | \"top-end\" | \"top-start\" | \"bottom-center\" | \"top-center\" | \"middle-center\" | \"middle-end\" | \"middle-start\"","\"button\" | \"submit\" | \"reset\"","\"center\" | \"bottom\"","\"center\" | \"left\" | \"right\"","\"center\" | \"left\" | \"right\" | \"justify\" | \"char\"","\"center\" | \"side\" | \"merge\"","\"checkbox\" | \"radio\"","\"dashed\" | \"dotted\" | \"solid\"","\"default\" | \"gradient\"","\"default\" | \"responsive\"","\"default\" | \"stacked\" | \"percent\" | \"split\" | \"stream\"","\"default\" | \"stacked\" | \"percent\" | \"waterfall\"","\"div\" | \"label\"","\"end\" | \"start\"","\"filled\" | \"light\"","\"fixed\" | \"absolute\"","\"hex\" | \"hexa\" | \"rgba\" | \"rgb\" | \"hsl\" | \"hsla\"","\"horizontal\" | \"vertical\"","\"hover\" | \"click\"","\"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\" | \"right\" | \"bottom\" | \"top\" | \"left-end\" | \"left-start\" | \"right-end\" | \"right-start\" | \"bottom-end\" | \"bottom-start\" | \"top-end\" | \"top-start\"","\"left\" | \"right\" | \"inside\"","\"link\" | \"none\" | \"copy\" | \"execute\" | \"move\" | \"popup\"","\"media\" | \"container\"","\"month\" | \"year\" | \"decade\"","\"native\" | \"scrollarea\"","\"none\" | \"all\" | \"rows\" | \"groups\" | \"columns\"","\"none\" | \"all\" | \"with-indicators\"","\"none\" | \"ascending\" | \"descending\" | \"other\"","\"none\" | \"inline\" | \"list\" | \"both\"","\"none\" | \"strict\" | \"blur\"","\"none\" | \"sum\"","\"none\" | \"thousand\" | \"lakh\" | \"wan\"","\"none\" | \"x\" | \"y\" | \"xy\"","\"number\" | RegExp | \"alphanumeric\"","\"off\" | \"assertive\" | \"polite\"","\"off\" | \"on\"","\"ordered\" | \"unordered\"","\"right\" | \"bottom\"","\"right-to-left\" | \"left-to-right\"","\"right-to-left\" | \"left-to-right\" | \"top-to-bottom\" | \"bottom-to-top\"","\"search\" | \"enter\" | \"done\" | \"go\" | \"next\" | \"previous\" | \"send\"","\"search\" | \"text\" | \"none\" | \"tel\" | \"url\" | \"email\" | \"numeric\" | \"decimal\"","\"step\" | \"bump\" | \"linear\" | \"natural\" | \"monotone\" | \"stepBefore\" | \"stepAfter\"","\"stepInactive\" | \"stepProgress\" | \"stepCompleted\"","\"sum\" | \"replace\"","\"td\" | \"th\"","\"text\" | \"additions\" | \"additions removals\" | \"additions text\" | \"all\" | \"removals\" | \"removals additions\" | \"removals text\" | \"text additions\" | \"text removals\"","\"text\" | \"tel\" | \"password\"","\"time\" | \"duration\"","\"up\" | \"down\"","\"value\" | \"name\" | \"percent\"","\"viewport\"","\"wrap\" | \"nowrap\" | \"balance\" | \"pretty\" | \"stable\"","\"x\" | \"y\"","\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\"","\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\" | (string & {})","\"yes\" | \"no\"","'Text' | 'Line truncated
|
||||
{"schemaVersion":1,"mantineVersion":"9.6.0","customWidgetVersion":"3.0.0","types":["\"\" | \"anonymous\" | \"use-credentials\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"auto\" | \"fixed\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"auto\" | \"all\" | \"fill\" | \"stroke\" | \"painted\" | \"visible\" | \"visibleFill\" | \"visiblePainted\" | \"visibleStroke\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"block\" | \"inline\" | \"both\" | \"horizontal\" | \"vertical\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"none\" | \"contain\" | \"cover\" | \"fill\" | \"scale-down\"","\"-moz-initial\" | \"inherit\" | \"initial\" | \"revert\" | \"revert-layer\" | \"unset\" | \"wrap\" | \"nowrap\" | \"wrap-reverse\"","\"12h\" | \"24h\"","\"activity\" | \"display-none\"","\"all\" | \"parent\" | \"child\"","\"all\" | \"segment\"","\"always\" | \"hover\" | \"not-hover\" | \"never\"","\"area\" | \"radial\" | \"scatter\"","\"auto\" | \"scroll\" | \"always\" | \"hover\" | \"never\"","\"auto-fit\" | \"auto-fill\"","\"block\" | \"grid\"","\"both\" | \"rightSection\" | \"clear\"","\"bottom\" | \"top\"","\"bottom\" | \"top\" | \"baseline\" | \"middle\"","\"bottom\" | \"top\" | \"middle\"","\"bottom-end\" | \"bottom-start\" | \"top-end\" | \"top-start\" | \"bottom-center\" | \"top-center\" | \"middle-center\" | \"middle-end\" | \"middle-start\"","\"button\" | \"submit\" | \"reset\"","\"center\" | \"bottom\"","\"center\" | \"left\" | \"right\"","\"center\" | \"left\" | \"right\" | \"justify\" | \"char\"","\"center\" | \"side\" | \"merge\"","\"checkbox\" | \"radio\"","\"dashed\" | \"dotted\" | \"solid\"","\"default\" | \"gradient\"","\"default\" | \"responsive\"","\"default\" | \"stacked\" | \"percent\" | \"split\" | \"stream\"","\"default\" | \"stacked\" | \"percent\" | \"waterfall\"","\"div\" | \"label\"","\"end\" | \"start\"","\"filled\" | \"light\"","\"fixed\" | \"absolute\"","\"hex\" | \"hexa\" | \"rgba\" | \"rgb\" | \"hsl\" | \"hsla\"","\"horizontal\" | \"vertical\"","\"hover\" | \"click\"","\"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\" | \"right\" | \"bottom\" | \"top\" | \"left-end\" | \"left-start\" | \"right-end\" | \"right-start\" | \"bottom-end\" | \"bottom-start\" | \"top-end\" | \"top-start\"","\"left\" | \"right\" | \"inside\"","\"link\" | \"none\" | \"copy\" | \"execute\" | \"move\" | \"popup\"","\"media\" | \"container\"","\"month\" | \"year\" | \"decade\"","\"native\" | \"scrollarea\"","\"none\" | \"all\" | \"rows\" | \"groups\" | \"columns\"","\"none\" | \"all\" | \"with-indicators\"","\"none\" | \"ascending\" | \"descending\" | \"other\"","\"none\" | \"inline\" | \"list\" | \"both\"","\"none\" | \"strict\" | \"blur\"","\"none\" | \"sum\"","\"none\" | \"thousand\" | \"lakh\" | \"wan\"","\"none\" | \"x\" | \"y\" | \"xy\"","\"number\" | RegExp | \"alphanumeric\"","\"off\" | \"assertive\" | \"polite\"","\"off\" | \"on\"","\"ordered\" | \"unordered\"","\"right\" | \"bottom\"","\"right-to-left\" | \"left-to-right\"","\"right-to-left\" | \"left-to-right\" | \"top-to-bottom\" | \"bottom-to-top\"","\"search\" | \"enter\" | \"done\" | \"go\" | \"next\" | \"previous\" | \"send\"","\"search\" | \"text\" | \"none\" | \"tel\" | \"url\" | \"email\" | \"numeric\" | \"decimal\"","\"step\" | \"bump\" | \"linear\" | \"natural\" | \"monotone\" | \"stepBefore\" | \"stepAfter\"","\"stepInactive\" | \"stepProgress\" | \"stepCompleted\"","\"sum\" | \"replace\"","\"td\" | \"th\"","\"text\" | \"additions\" | \"additions removals\" | \"additions text\" | \"all\" | \"removals\" | \"removals additions\" | \"removals text\" | \"text additions\" | \"text removals\"","\"text\" | \"tel\" | \"password\"","\"time\" | \"duration\"","\"up\" | \"down\"","\"value\" | \"name\" | \"percent\"","\"viewport\"","\"wrap\" | \"nowrap\" | \"balance\" | \"pretty\" | \"stable\"","\"x\" | \"y\"","\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\"","\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\" | (string & {})","\"yes\" | \"no\"","'Text' | 'Line truncated
|
||||
@@ -86,6 +86,7 @@
|
||||
"@xterm/addon-canvas": "catalog:",
|
||||
"@xterm/addon-fit": "catalog:",
|
||||
"@xterm/xterm": "catalog:",
|
||||
"@xyflow/react": "catalog:",
|
||||
"adm-zip": "catalog:",
|
||||
"ai": "catalog:",
|
||||
"assistant-stream": "catalog:",
|
||||
@@ -96,6 +97,7 @@
|
||||
"dayjs": "catalog:",
|
||||
"dotenv": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
"elkjs": "catalog:",
|
||||
"flag-icons": "catalog:",
|
||||
"glob": "catalog:",
|
||||
"isomorphic-dompurify": "catalog:",
|
||||
|
||||
@@ -171,7 +171,8 @@ const ScopedTRPCReactProvider = ({
|
||||
splitLink({
|
||||
condition: ({ type }) => type === "subscription",
|
||||
true: splitLink({
|
||||
condition: ({ path }) => path === "widget.beszel.subscribeSystemStats",
|
||||
condition: ({ path }) =>
|
||||
path === "widget.beszel.subscribeSystemStats" || path === "customWidget.nativeSubscribe",
|
||||
true: httpSubscriptionLink({
|
||||
url: getTrpcUrl(),
|
||||
transformer: superjson,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, Anchor, Text } from "@mantine/core";
|
||||
import { Anchor, Badge, Group } from "@mantine/core";
|
||||
import { IconFlask } from "@tabler/icons-react";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
@@ -11,14 +11,13 @@ export function CustomWidgetBetaBanner() {
|
||||
const t = useI18n("customWidget.betaBanner");
|
||||
|
||||
return (
|
||||
<Alert variant="light" color="yellow" title={t("title")} icon={<IconFlask />}>
|
||||
<Text size="sm">
|
||||
{t("description")}{" "}
|
||||
<Anchor href={FEEDBACK_URL} target="_blank" fw={600}>
|
||||
{t("discussionLink")}
|
||||
</Anchor>{" "}
|
||||
{t("closing")}
|
||||
</Text>
|
||||
</Alert>
|
||||
<Group gap="xs">
|
||||
<Badge variant="light" color="yellow" leftSection={<IconFlask size={12} />}>
|
||||
{t("title")}
|
||||
</Badge>
|
||||
<Anchor size="xs" href={FEEDBACK_URL} target="_blank" rel="noopener noreferrer">
|
||||
{t("discussionLink")}
|
||||
</Anchor>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -1 +1,57 @@
|
||||
export { CodeEditor } from "~/components/custom-widgets/code-editor";
|
||||
"use client";
|
||||
import { useSyncExternalStore, useState, useEffect, useCallback, useMemo } from "react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { CodeEditor as SharedCodeEditor } from "~/components/custom-widgets/code-editor";
|
||||
import { useOptionalCustomWidgetFormDocumentStore } from "./_custom-widget-form-state";
|
||||
const subscribeEmpty = () => () => undefined;
|
||||
const zero = () => 0;
|
||||
export function CodeEditor({
|
||||
localHistory = false,
|
||||
...props
|
||||
}: ComponentProps<typeof SharedCodeEditor> & { localHistory?: boolean }) {
|
||||
const [insert, setInsert] = useState<{ text: string; key: number }>();
|
||||
const [reveal, setReveal] = useState<{ index: number; key: number }>();
|
||||
useEffect(() => {
|
||||
const receive = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ editorId: string; index: number }>).detail;
|
||||
if (detail.editorId === props.id) setReveal({ index: detail.index, key: Date.now() });
|
||||
};
|
||||
const receiveInsert = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ editorId: string; text: string }>).detail;
|
||||
if (detail.editorId === props.id && !props.readOnly) setInsert({ text: detail.text, key: Date.now() });
|
||||
};
|
||||
window.addEventListener("homarr:widget-code-insert", receiveInsert);
|
||||
window.addEventListener("homarr:widget-code-reveal", receive);
|
||||
return () => {
|
||||
window.removeEventListener("homarr:widget-code-reveal", receive);
|
||||
window.removeEventListener("homarr:widget-code-insert", receiveInsert);
|
||||
};
|
||||
}, [props.id, props.readOnly]);
|
||||
const store = useOptionalCustomWidgetFormDocumentStore();
|
||||
const getHistoryState = useCallback(() => {
|
||||
if (!store) return 0;
|
||||
return Number(store.canUndo()) + 2 * Number(store.canRedo());
|
||||
}, [store]);
|
||||
const historyState = useSyncExternalStore(store?.subscribeEditor ?? subscribeEmpty, getHistoryState, zero);
|
||||
const history = useMemo(() => {
|
||||
if (!store || localHistory || props.readOnly || props.id.startsWith("preview-")) return undefined;
|
||||
return {
|
||||
canUndo: Boolean(historyState & 1),
|
||||
canRedo: Boolean(historyState & 2),
|
||||
undo: store.undo,
|
||||
redo: store.redo,
|
||||
};
|
||||
}, [store, localHistory, props.readOnly, props.id, historyState]);
|
||||
return (
|
||||
<div data-workbench-local-input={localHistory || undefined}>
|
||||
<SharedCodeEditor
|
||||
{...props}
|
||||
history={history}
|
||||
insertText={insert?.text ?? props.insertText}
|
||||
insertKey={insert?.key ?? props.insertKey}
|
||||
revealIndex={reveal?.index}
|
||||
revealKey={reveal?.key ?? props.revealKey}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,17 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { Dispatch, ReactNode, SetStateAction } from "react";
|
||||
import type { Icon } from "@tabler/icons-react";
|
||||
import { Button, Card, Group, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
getContrastColor,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
useComputedColorScheme,
|
||||
useMantineTheme,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconAlertTriangle,
|
||||
@@ -24,6 +34,7 @@ import { useUnsavedChangesGuard } from "~/components/manage/use-unsaved-changes-
|
||||
import { useCustomWidgetFormDocumentDirty } from "./_custom-widget-form-state";
|
||||
import { areCustomWidgetValuesEqual } from "./_custom-widget-value-equality";
|
||||
import { useCustomWidgetFormAnalysisField } from "./_use-custom-widget-form-analysis";
|
||||
import { useWorkbenchSelection } from "./_flow/selection";
|
||||
import classes from "./_custom-widget-form.module.css";
|
||||
|
||||
const sectionLinks = [
|
||||
@@ -46,10 +57,11 @@ export function EditorSection({
|
||||
icon: Icon;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const compact = useWorkbenchSelection() !== undefined;
|
||||
return (
|
||||
<Card id={id} component="section" withBorder p="lg" className={classes.editorSection}>
|
||||
<Card id={id} component="section" withBorder={!compact} p={compact ? 0 : "lg"} className={classes.editorSection}>
|
||||
<Stack gap="md">
|
||||
<Group gap="sm">
|
||||
<Group gap="sm" data-workbench-section-title>
|
||||
<ThemeIcon variant="light" size="lg">
|
||||
<SectionIcon size={18} />
|
||||
</ThemeIcon>
|
||||
@@ -64,6 +76,7 @@ export function EditorSection({
|
||||
}
|
||||
|
||||
export function SaveActions({
|
||||
formId,
|
||||
dirty,
|
||||
savePending,
|
||||
previewPending,
|
||||
@@ -71,6 +84,7 @@ export function SaveActions({
|
||||
mode,
|
||||
onPreview,
|
||||
}: {
|
||||
formId?: string;
|
||||
dirty: boolean;
|
||||
savePending: boolean;
|
||||
previewPending: boolean;
|
||||
@@ -80,6 +94,13 @@ export function SaveActions({
|
||||
}) {
|
||||
const t = useI18n("customWidget.workbench");
|
||||
const tCommon = useI18n("common");
|
||||
const theme = useMantineTheme();
|
||||
const colorScheme = useComputedColorScheme();
|
||||
const submitColor = getContrastColor({
|
||||
color: theme.primaryColor,
|
||||
colorScheme,
|
||||
theme: { ...theme, luminanceThreshold: 0.179 },
|
||||
});
|
||||
return (
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Group gap="xs">
|
||||
@@ -103,7 +124,13 @@ export function SaveActions({
|
||||
>
|
||||
{previewPending ? t("action.previewLoading") : t("action.preview")}
|
||||
</Button>
|
||||
<Button type="submit" loading={savePending} disabled={invalid || previewPending}>
|
||||
<Button
|
||||
form={formId}
|
||||
type="submit"
|
||||
loading={savePending}
|
||||
disabled={invalid || previewPending}
|
||||
style={{ "--button-color": submitColor }}
|
||||
>
|
||||
{mode === "create" ? tCommon("action.create") : tCommon("action.save")}
|
||||
</Button>
|
||||
</Group>
|
||||
@@ -143,11 +170,13 @@ export function CustomWidgetSectionNavigation({
|
||||
}
|
||||
|
||||
export function CustomWidgetSaveActions({
|
||||
formId,
|
||||
mode,
|
||||
savePending,
|
||||
previewPending,
|
||||
onPreview,
|
||||
}: {
|
||||
formId?: string;
|
||||
mode: "create" | "edit";
|
||||
savePending: boolean;
|
||||
previewPending: boolean;
|
||||
@@ -158,6 +187,7 @@ export function CustomWidgetSaveActions({
|
||||
const hasDiagnostics = useCustomWidgetFormAnalysisField("hasDiagnostics");
|
||||
return (
|
||||
<SaveActions
|
||||
formId={formId}
|
||||
dirty={dirty}
|
||||
savePending={savePending}
|
||||
previewPending={previewPending}
|
||||
|
||||
@@ -13,60 +13,10 @@ import {
|
||||
import type { ReactNode } from "react";
|
||||
import type { UseFormReturnType } from "@mantine/form";
|
||||
|
||||
import { customWidgetFormSchema } from "@homarr/custom-widgets/workbench";
|
||||
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
|
||||
|
||||
import { areCustomWidgetValuesEqual } from "./_custom-widget-value-equality";
|
||||
|
||||
export interface CustomWidgetFormDocumentStore {
|
||||
getValues(): CustomWidgetFormValues;
|
||||
setValues(values: CustomWidgetFormValues): void;
|
||||
getDirty(): boolean;
|
||||
subscribe(listener: () => void): () => void;
|
||||
markSaved(values: CustomWidgetFormValues): void;
|
||||
}
|
||||
|
||||
const documentValueKeys = customWidgetFormSchema.keyof().options;
|
||||
|
||||
function areDocumentValuesEqual(left: CustomWidgetFormValues, right: CustomWidgetFormValues) {
|
||||
if (Object.keys(left).length !== documentValueKeys.length) return false;
|
||||
if (Object.keys(right).length !== documentValueKeys.length) return false;
|
||||
return documentValueKeys.every(
|
||||
(key) => Object.hasOwn(left, key) && Object.hasOwn(right, key) && areCustomWidgetValuesEqual(left[key], right[key]),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCustomWidgetFormDocumentStore(
|
||||
initialValues: CustomWidgetFormValues,
|
||||
): CustomWidgetFormDocumentStore {
|
||||
let values = initialValues;
|
||||
let persistedValues = initialValues;
|
||||
let dirty = false;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
const notify = () => listeners.forEach((listener) => listener());
|
||||
|
||||
return {
|
||||
getValues: () => values,
|
||||
setValues: (nextValues) => {
|
||||
if (Object.is(values, nextValues)) return;
|
||||
values = nextValues;
|
||||
dirty = !areDocumentValuesEqual(nextValues, persistedValues);
|
||||
notify();
|
||||
},
|
||||
getDirty: () => dirty,
|
||||
subscribe: (listener) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
markSaved: (savedValues) => {
|
||||
const wasDirty = dirty;
|
||||
persistedValues = savedValues;
|
||||
dirty = !areDocumentValuesEqual(values, persistedValues);
|
||||
if (wasDirty !== dirty) notify();
|
||||
},
|
||||
};
|
||||
}
|
||||
import type { CustomWidgetFormDocumentStore } from "./_flow/document-store";
|
||||
export { createCustomWidgetFormDocumentStore } from "./_flow/document-store";
|
||||
export type { CustomWidgetFormDocumentStore } from "./_flow/document-store";
|
||||
|
||||
const CustomWidgetFormDocumentContext = createContext<CustomWidgetFormDocumentStore | null>(null);
|
||||
|
||||
@@ -80,6 +30,10 @@ export function CustomWidgetFormDocumentProvider({
|
||||
return <CustomWidgetFormDocumentContext.Provider value={store}>{children}</CustomWidgetFormDocumentContext.Provider>;
|
||||
}
|
||||
|
||||
export function useOptionalCustomWidgetFormDocumentStore() {
|
||||
return useContext(CustomWidgetFormDocumentContext);
|
||||
}
|
||||
|
||||
export function useCustomWidgetFormDocumentStore() {
|
||||
const store = useContext(CustomWidgetFormDocumentContext);
|
||||
if (!store) throw new Error("Custom widget form document provider is missing");
|
||||
@@ -104,9 +58,14 @@ export function useDeferredCustomWidgetFormDocumentValues() {
|
||||
}, 150);
|
||||
};
|
||||
const unsubscribe = store.subscribe(scheduleUpdate);
|
||||
const unsubscribeRenames = store.onNodeRenames(() => {
|
||||
clearTimeout(timeout);
|
||||
setValues(store.getValues());
|
||||
});
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
unsubscribeRenames();
|
||||
};
|
||||
}, [store]);
|
||||
return values;
|
||||
@@ -120,7 +79,7 @@ export function useCustomWidgetFormDocumentField<Key extends keyof CustomWidgetF
|
||||
|
||||
export function useCustomWidgetFormDocumentDirty() {
|
||||
const store = useCustomWidgetFormDocumentStore();
|
||||
return useSyncExternalStore(store.subscribe, store.getDirty, store.getDirty);
|
||||
return useSyncExternalStore(store.subscribeEditor, store.getDirty, store.getDirty);
|
||||
}
|
||||
|
||||
export function useCustomWidgetFormDocumentBridge(
|
||||
@@ -129,6 +88,7 @@ export function useCustomWidgetFormDocumentBridge(
|
||||
) {
|
||||
const formRef = useRef(form);
|
||||
formRef.current = form;
|
||||
useEffect(() => store.onRestore((values) => formRef.current.setValues(values)), [store]);
|
||||
return useMemo(
|
||||
() =>
|
||||
new Proxy({} as UseFormReturnType<CustomWidgetFormValues>, {
|
||||
|
||||
@@ -19,7 +19,8 @@ const PREVIEW_QUERY_CONCURRENCY = 4;
|
||||
|
||||
export function buildDefinition(values: CustomWidgetFormValues) {
|
||||
return customWidgetDefinitionSchema.safeParse({
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
$schema: values.extensions?.trim() ? "homarr-custom-widget-v3" : "homarr-custom-widget-v2",
|
||||
...(values.extensions?.trim() ? { extensions: parseJson(values.extensions) } : {}),
|
||||
name: values.name,
|
||||
description: values.description || undefined,
|
||||
iconUrl: values.iconUrl || undefined,
|
||||
@@ -59,6 +60,7 @@ export function applyDefinition(form: CustomWidgetWorkbenchForm, widget: HomarrC
|
||||
requests: JSON.stringify(widget.requests, null, 2),
|
||||
options: JSON.stringify(widget.options, null, 2),
|
||||
template: widget.template,
|
||||
extensions: widget.$schema === "homarr-custom-widget-v3" ? JSON.stringify(widget.extensions ?? {}, null, 2) : "",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -104,9 +106,14 @@ export function isRuntimeParams(
|
||||
}
|
||||
|
||||
export async function loadPreviewQueries(definition: HomarrCustomWidgetV2, sessionId: string) {
|
||||
const requests = Object.entries(definition.requests).filter(
|
||||
([, entry]) => entry.kind === "query" && entry.trigger === "load",
|
||||
);
|
||||
const requests = [
|
||||
...Object.entries(definition.requests)
|
||||
.filter(([, entry]) => entry.kind === "query" && entry.trigger === "load")
|
||||
.map(([id]) => ({ id, native: false })),
|
||||
...Object.entries(definition.extensions?.native ?? {})
|
||||
.filter(([, entry]) => entry.kind === "query" && entry.trigger === "load")
|
||||
.map(([id]) => ({ id, native: true })),
|
||||
];
|
||||
const results: Array<{
|
||||
requestId: string;
|
||||
data: unknown;
|
||||
@@ -119,9 +126,11 @@ export async function loadPreviewQueries(definition: HomarrCustomWidgetV2, sessi
|
||||
nextIndex += 1;
|
||||
const request = requests[index];
|
||||
if (!request) return;
|
||||
const [requestId] = request;
|
||||
const requestId = request.id;
|
||||
try {
|
||||
const result = await fetchApi.customWidget.previewQuery.query({ sessionId, requestId, params: {} });
|
||||
const result = await (request.native
|
||||
? fetchApi.customWidget.nativeQuery.query({ previewSessionId: sessionId, nativeId: requestId, params: {} })
|
||||
: fetchApi.customWidget.previewQuery.query({ sessionId, requestId, params: {} }));
|
||||
results[index] = { requestId, data: result.data, status: { loading: false, ...result } };
|
||||
} catch (error) {
|
||||
results[index] = {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { customWidgetEditorLayoutSchema, EMPTY_EDITOR_LAYOUT } from "@homarr/custom-widgets/core";
|
||||
import { memo, useId, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import type { Dispatch, FormEventHandler, SetStateAction } from "react";
|
||||
import { Box, Paper, SegmentedControl, Stack } from "@mantine/core";
|
||||
|
||||
import type { CustomWidgetAiDraft } from "@homarr/custom-widgets/authoring-prompt";
|
||||
import { customWidgetFormSchema, DEFAULT_CUSTOM_WIDGET_FORM_VALUES } from "@homarr/custom-widgets/workbench";
|
||||
@@ -14,7 +14,6 @@ import { CustomWidgetAdvancedManifest } from "./_custom-widget-advanced-manifest
|
||||
import {
|
||||
CustomWidgetOptionsSnapshotSync,
|
||||
CustomWidgetSaveActions,
|
||||
CustomWidgetSectionNavigation,
|
||||
CustomWidgetUnsavedChangesGuard,
|
||||
} from "./_custom-widget-form-layout";
|
||||
import {
|
||||
@@ -40,12 +39,15 @@ import type { CustomWidgetSaveIssue } from "./_custom-widget-save-errors";
|
||||
import { CustomWidgetSaveIssuesAlert } from "./_custom-widget-save-issues-alert";
|
||||
import { useCustomWidgetFormActions } from "./_use-custom-widget-form-actions";
|
||||
import { analyzeCustomWidgetAiDiagnostics, CustomWidgetFormAnalysisProvider } from "./_use-custom-widget-form-analysis";
|
||||
import { FlowWorkbench } from "./_flow/workbench";
|
||||
import classes from "./_custom-widget-form.module.css";
|
||||
|
||||
interface CustomWidgetFormProps {
|
||||
mode: "create" | "edit";
|
||||
initialValues?: Partial<CustomWidgetFormValues>;
|
||||
definitionId?: string;
|
||||
editorLayout?: string | null;
|
||||
savedRevision?: string;
|
||||
}
|
||||
|
||||
type MobilePane = "configure" | "preview";
|
||||
@@ -69,7 +71,7 @@ interface CustomWidgetFormViewProps {
|
||||
savePending: boolean;
|
||||
previewPending: boolean;
|
||||
onSubmit: FormEventHandler<HTMLFormElement>;
|
||||
onPreview(): void;
|
||||
onPreview(): Promise<unknown>;
|
||||
onPasteAiResponse(): void;
|
||||
}
|
||||
|
||||
@@ -81,35 +83,32 @@ function getAiDraft(values: CustomWidgetFormValues): CustomWidgetAiDraft {
|
||||
sources: values.sources,
|
||||
requests: values.requests,
|
||||
options: values.options,
|
||||
extensions: values.extensions,
|
||||
template: values.template,
|
||||
};
|
||||
}
|
||||
|
||||
const CustomWidgetFormView = memo(function CustomWidgetFormView(props: CustomWidgetFormViewProps) {
|
||||
const w = useI18n("customWidget.workbench");
|
||||
const saveFormId = useId();
|
||||
const documentStore = props.documentStore;
|
||||
const form = props.form;
|
||||
const setMobilePane = props.setMobilePane;
|
||||
const setPreview = props.setPreview;
|
||||
const { renameRequest, renameOption } = useMemo(
|
||||
() =>
|
||||
createCustomWidgetRenameHandlers({
|
||||
form,
|
||||
transaction: documentStore.transaction,
|
||||
renameRequestBinding: documentStore.renameRequestBinding,
|
||||
invalidWidgetMessage: w("invalidWidget"),
|
||||
}),
|
||||
[form, w],
|
||||
[documentStore, form, w],
|
||||
);
|
||||
const readAiDraft = useCallback(() => getAiDraft(documentStore.getValues()), [documentStore]);
|
||||
const readAiDiagnostics = useCallback(
|
||||
() => analyzeCustomWidgetAiDiagnostics(documentStore.getValues()),
|
||||
[documentStore],
|
||||
);
|
||||
const handleSectionSelect = useCallback(
|
||||
(section: string) => {
|
||||
setMobilePane(section === "preview" ? "preview" : "configure");
|
||||
},
|
||||
[setMobilePane],
|
||||
);
|
||||
const handleLiveActionsChange = useCallback(
|
||||
(enabled: boolean) =>
|
||||
setPreview((current) => ({
|
||||
@@ -122,54 +121,39 @@ const CustomWidgetFormView = memo(function CustomWidgetFormView(props: CustomWid
|
||||
return (
|
||||
<CustomWidgetFormDocumentProvider store={documentStore}>
|
||||
<CustomWidgetFormAnalysisProvider>
|
||||
<form onSubmit={props.onSubmit} className={classes.form}>
|
||||
<div className={classes.form}>
|
||||
<form id={saveFormId} onSubmit={props.onSubmit} />
|
||||
<CustomWidgetUnsavedChangesGuard />
|
||||
<CustomWidgetOptionsSnapshotSync setOptionsSnapshot={props.setOptionsSnapshot} />
|
||||
<SegmentedControl
|
||||
className={classes.paneSwitcher}
|
||||
fullWidth
|
||||
value={props.mobilePane}
|
||||
onChange={(value) => {
|
||||
if (value === "configure" || value === "preview") props.setMobilePane(value);
|
||||
}}
|
||||
data={[
|
||||
{ value: "configure", label: w("configure") },
|
||||
{ value: "preview", label: w("section.preview") },
|
||||
]}
|
||||
/>
|
||||
<CustomWidgetSaveIssuesAlert issues={props.saveIssues} />
|
||||
<CustomWidgetSectionNavigation onSelect={handleSectionSelect} />
|
||||
<div className={classes.workbench} data-mobile-pane={props.mobilePane}>
|
||||
<Stack gap="lg" className={classes.configuration}>
|
||||
<FlowWorkbench
|
||||
feedback={<CustomWidgetSaveIssuesAlert issues={props.saveIssues} />}
|
||||
form={form}
|
||||
onPreview={props.onPreview}
|
||||
execution={props.preview}
|
||||
definitionId={props.definitionId}
|
||||
actions={
|
||||
<CustomWidgetSaveActions
|
||||
formId={saveFormId}
|
||||
mode={props.mode}
|
||||
savePending={props.savePending}
|
||||
previewPending={props.previewPending}
|
||||
onPreview={props.onPreview}
|
||||
/>
|
||||
}
|
||||
general={<CustomWidgetGeneralSection form={form} formRevision={props.formRevision} mode={props.mode} />}
|
||||
sources={<CustomWidgetSourcesSection form={form} definitionId={props.definitionId} />}
|
||||
requests={<CustomWidgetRequestsSection form={form} onRename={renameRequest} />}
|
||||
options={<CustomWidgetOptionsSection form={form} onRename={renameOption} />}
|
||||
template={<CustomWidgetTemplateSection form={form} />}
|
||||
assistant={
|
||||
<CustomWidgetAiSection
|
||||
getDraft={readAiDraft}
|
||||
getDiagnostics={readAiDiagnostics}
|
||||
onPaste={props.onPasteAiResponse}
|
||||
/>
|
||||
<CustomWidgetAdvancedManifest form={form} />
|
||||
<CustomWidgetGeneralSection form={form} formRevision={props.formRevision} mode={props.mode} />
|
||||
<CustomWidgetSourcesSection form={form} definitionId={props.definitionId} />
|
||||
<CustomWidgetRequestsSection form={form} onRename={renameRequest} />
|
||||
<CustomWidgetOptionsSection form={form} onRename={renameOption} />
|
||||
<CustomWidgetTemplateSection form={form} />
|
||||
<Paper p="md" className={classes.mobileSaveBar} shadow="sm">
|
||||
<CustomWidgetSaveActions
|
||||
mode={props.mode}
|
||||
savePending={props.savePending}
|
||||
previewPending={props.previewPending}
|
||||
onPreview={props.onPreview}
|
||||
/>
|
||||
</Paper>
|
||||
</Stack>
|
||||
<Box id="preview" component="aside" className={classes.previewPane} aria-label={w("widgetPreview")}>
|
||||
<Paper p="md" className={classes.actionBar} shadow="sm">
|
||||
<CustomWidgetSaveActions
|
||||
mode={props.mode}
|
||||
savePending={props.savePending}
|
||||
previewPending={props.previewPending}
|
||||
onPreview={props.onPreview}
|
||||
/>
|
||||
</Paper>
|
||||
}
|
||||
advanced={<CustomWidgetAdvancedManifest form={form} />}
|
||||
preview={
|
||||
<CustomWidgetPreviewSection
|
||||
preview={props.preview}
|
||||
size={props.previewSize}
|
||||
@@ -178,20 +162,32 @@ const CustomWidgetFormView = memo(function CustomWidgetFormView(props: CustomWid
|
||||
onOptionsChange={props.setOptionsSnapshot}
|
||||
onLiveActionsChange={handleLiveActionsChange}
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CustomWidgetFormAnalysisProvider>
|
||||
</CustomWidgetFormDocumentProvider>
|
||||
);
|
||||
});
|
||||
|
||||
export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWidgetFormProps) {
|
||||
export function CustomWidgetForm({
|
||||
mode,
|
||||
initialValues,
|
||||
definitionId,
|
||||
editorLayout,
|
||||
savedRevision,
|
||||
}: CustomWidgetFormProps) {
|
||||
const flowT = useI18n("customWidget.flow");
|
||||
const [formInitialValues] = useState<CustomWidgetFormValues>(() => ({
|
||||
...DEFAULT_CUSTOM_WIDGET_FORM_VALUES,
|
||||
sources: "{}",
|
||||
requests: "{}",
|
||||
template: `<Stack align="center" justify="center" h="100%"><Text size="xl" fw={600}>{${JSON.stringify(flowT("welcomeTemplate"))}}</Text></Stack>`,
|
||||
...initialValues,
|
||||
}));
|
||||
const [documentStore] = useState(() => createCustomWidgetFormDocumentStore(formInitialValues));
|
||||
const [documentStore] = useState(() =>
|
||||
createCustomWidgetFormDocumentStore(formInitialValues, parseEditorLayout(editorLayout)),
|
||||
);
|
||||
const mantineForm = useZodForm(customWidgetFormSchema, {
|
||||
initialValues: formInitialValues,
|
||||
mode: "uncontrolled",
|
||||
@@ -208,6 +204,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
return initialDefinition.success ? getDefinitionDefaults(initialDefinition.data) : {};
|
||||
});
|
||||
const actions = useCustomWidgetFormActions({
|
||||
savedRevision,
|
||||
mode,
|
||||
definitionId,
|
||||
form,
|
||||
@@ -222,7 +219,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
latestActions.current = actions;
|
||||
}, [actions]);
|
||||
const handleSubmit = useCallback<FormEventHandler<HTMLFormElement>>((event) => latestActions.current.save(event), []);
|
||||
const handlePreview = useCallback(() => void latestActions.current.runPreview(), []);
|
||||
const handlePreview = useCallback(() => latestActions.current.runPreview(), []);
|
||||
const handlePasteAiResponse = useCallback(() => void latestActions.current.pasteAiResponse(), []);
|
||||
|
||||
return (
|
||||
@@ -250,3 +247,11 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function parseEditorLayout(value?: string | null) {
|
||||
try {
|
||||
return customWidgetEditorLayoutSchema.parse(JSON.parse(value ?? "null"));
|
||||
} catch {
|
||||
return EMPTY_EDITOR_LAYOUT;
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ export function CustomWidgetIdentifierInput({
|
||||
};
|
||||
return (
|
||||
<TextInput
|
||||
data-workbench-local-input
|
||||
label={label}
|
||||
value={draft}
|
||||
error={error}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CustomWidgetIntegrationOption } from "@homarr/widgets/custom-api/integration-option";
|
||||
|
||||
import { CodeEditor } from "./_code-editor";
|
||||
|
||||
export function DefaultValueEditor({
|
||||
@@ -22,6 +24,8 @@ export function DefaultValueEditor({
|
||||
onChange: (value: unknown) => void;
|
||||
}) {
|
||||
const t = useI18n("customWidget.workbench.builder");
|
||||
if (control === "integration")
|
||||
return <CustomWidgetIntegrationOption label={t("default")} value={value} onChange={onChange} />;
|
||||
if (control === "switch")
|
||||
return (
|
||||
<Switch
|
||||
|
||||
@@ -6,7 +6,7 @@ interface PreviewSnapshot {
|
||||
session: { id: string; liveActions: boolean } | null;
|
||||
}
|
||||
|
||||
type PreviewCandidate = Pick<HomarrCustomWidgetV2, "requests" | "template">;
|
||||
type PreviewCandidate = Pick<HomarrCustomWidgetV2, "requests" | "template" | "extensions">;
|
||||
|
||||
export function createPreviewDisplayData({
|
||||
candidate,
|
||||
@@ -23,15 +23,18 @@ export function createPreviewDisplayData({
|
||||
}): Record<string, unknown> | null {
|
||||
if (!candidate) return null;
|
||||
const requests = Object.entries(candidate.requests);
|
||||
const data = fixture === "empty" ? Object.fromEntries(requests.map(([id]) => [id, []])) : preview.data;
|
||||
const queryIds = [...requests.map(([id]) => id), ...Object.keys(candidate.extensions?.native ?? {})];
|
||||
const data =
|
||||
fixture === "empty" ? Object.fromEntries(queryIds.map((id) => [id, emptyFixture(preview.data[id])])) : preview.data;
|
||||
const status =
|
||||
fixture === "loading"
|
||||
? Object.fromEntries(requests.map(([id]) => [id, { loading: true }]))
|
||||
? Object.fromEntries(queryIds.map((id) => [id, { loading: true }]))
|
||||
: fixture === "error"
|
||||
? Object.fromEntries(requests.map(([id]) => [id, { loading: false, ok: false, error: fixtureError }]))
|
||||
? Object.fromEntries(queryIds.map((id) => [id, { loading: false, ok: false, error: fixtureError }]))
|
||||
: preview.status;
|
||||
return {
|
||||
template: candidate.template,
|
||||
extensions: candidate.extensions,
|
||||
data,
|
||||
status,
|
||||
options,
|
||||
@@ -51,3 +54,10 @@ export function createPreviewDisplayData({
|
||||
isEditMode: fixture !== "live",
|
||||
};
|
||||
}
|
||||
|
||||
function emptyFixture(value: unknown, depth = 0): unknown {
|
||||
if (depth > 10 || Array.isArray(value) || value === undefined) return [];
|
||||
if (value && typeof value === "object")
|
||||
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, emptyFixture(entry, depth + 1)]));
|
||||
return value;
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Alert, Group, Stack, Switch, Tabs, Text } from "@mantine/core";
|
||||
import { analyzeCustomWidgetAccessibility } from "@homarr/custom-widgets/workbench";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { PreviewPanelProps } from "./_flow/preview-panel-types";
|
||||
import { PreviewRequestData } from "./_flow/preview-request-data";
|
||||
import { WorkbenchRequestJournal } from "./_flow/request-journal";
|
||||
import { PreviewStatusDot } from "./_custom-widget-preview-status";
|
||||
import { PreviewActionControl } from "./_custom-widget-preview-action";
|
||||
import { JsonPreviewEditor } from "./_json-preview-editor";
|
||||
|
||||
export function PreviewTabList({
|
||||
outcome,
|
||||
widgetLabel,
|
||||
}: {
|
||||
outcome: PreviewPanelProps["preview"]["outcome"];
|
||||
widgetLabel?: string;
|
||||
}) {
|
||||
const t = useI18n("customWidget.workbench.preview");
|
||||
const flowT = useI18n("customWidget.flow");
|
||||
return (
|
||||
<Tabs.List grow>
|
||||
{(["widget", "data", "options", "actions", "diagnostics"] as const).map((tab) => (
|
||||
<Tabs.Tab key={tab} value={tab}>
|
||||
<Group gap={6} wrap="nowrap" justify="center">
|
||||
{tab === "widget" && widgetLabel ? widgetLabel : t(`tab.${tab}`)}
|
||||
{tab === "data" && <PreviewStatusDot outcome={outcome} label={t(`status.${outcome}`)} />}
|
||||
</Group>
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
<Tabs.Tab value="journal">{flowT("requestJournal")}</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
);
|
||||
}
|
||||
|
||||
export function PreviewDetailPanels(
|
||||
props: PreviewPanelProps & {
|
||||
requestId?: string;
|
||||
onShowAll(): void;
|
||||
liveActions: { pending: boolean; toggle(enabled: boolean): void };
|
||||
},
|
||||
) {
|
||||
const t = useI18n("customWidget.workbench.preview");
|
||||
const candidate = props.candidate;
|
||||
const candidateTemplate = candidate?.template;
|
||||
const accessibilityIssues = useMemo(() => {
|
||||
if (candidateTemplate === undefined) return [];
|
||||
return analyzeCustomWidgetAccessibility(candidateTemplate);
|
||||
}, [candidateTemplate]);
|
||||
return (
|
||||
<>
|
||||
<Tabs.Panel value="data" pt="sm">
|
||||
<PreviewRequestData preview={props.preview} requestId={props.requestId} onShowAll={props.onShowAll} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="journal" pt="sm">
|
||||
<WorkbenchRequestJournal />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="options" pt="sm">
|
||||
<JsonPreviewEditor
|
||||
id="preview-options"
|
||||
label={t("instanceOptions")}
|
||||
value={props.optionsSnapshot}
|
||||
onChange={props.onOptionsChange}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="actions" pt="sm">
|
||||
<Stack gap="sm">
|
||||
<Switch
|
||||
label={t("liveActions")}
|
||||
description={t("liveActionsDescription")}
|
||||
checked={props.preview.session?.liveActions ?? false}
|
||||
disabled={!props.preview.session || props.liveActions.pending}
|
||||
onChange={(event) => props.liveActions.toggle(event.currentTarget.checked)}
|
||||
/>
|
||||
{Object.entries(candidate?.requests ?? {})
|
||||
.filter(([, request]) => request.kind === "action")
|
||||
.map(([id, request]) => (
|
||||
<PreviewActionControl key={id} request={{ id, ...request }} sessionId={props.preview.session?.id} />
|
||||
))}
|
||||
{Object.values(candidate?.requests ?? {}).every((request) => request.kind !== "action") && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("noActions")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="diagnostics" pt="sm">
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("containedFailures")}
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{t("validation.title")}
|
||||
</Text>
|
||||
{props.validationIssues.length === 0 ? (
|
||||
<Alert color="green">{t("validation.ready")}</Alert>
|
||||
) : (
|
||||
props.validationIssues.map((issue, index) => (
|
||||
<Alert key={`${issue.path ?? "widget"}-${index}`} color="red" title={issue.path}>
|
||||
{issue.message}
|
||||
</Alert>
|
||||
))
|
||||
)}
|
||||
<Text size="sm" fw={600}>
|
||||
{t("accessibility.title")}
|
||||
</Text>
|
||||
{!candidate ? (
|
||||
<Alert color="gray">{t("accessibility.pending")}</Alert>
|
||||
) : accessibilityIssues.length === 0 ? (
|
||||
<Alert color="green">{t("accessibility.ready")}</Alert>
|
||||
) : (
|
||||
accessibilityIssues.map((issue) => (
|
||||
<Alert key={issue} color="yellow">
|
||||
{t(`accessibility.${issue}`)}
|
||||
</Alert>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+7
-3
@@ -1,6 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
@@ -38,6 +39,9 @@ vi.mock("./_code-editor", () => ({
|
||||
return <div>{props.label}</div>;
|
||||
},
|
||||
}));
|
||||
vi.mock("./_flow/preview-theme", () => ({
|
||||
WorkbenchPreviewSurface: ({ children }: { children: ReactNode }) => children,
|
||||
}));
|
||||
vi.mock("./_custom-widget-preview-action", () => ({ PreviewActionControl: () => <div>action-control</div> }));
|
||||
|
||||
let root: Root;
|
||||
@@ -105,21 +109,21 @@ describe("Custom Widget preview panel", () => {
|
||||
await act(async () => dataTab?.click());
|
||||
expect(host.textContent).toContain("requestData");
|
||||
expect(codeEditorProps).toHaveBeenCalledWith(expect.objectContaining({ label: "requestData", readOnly: true }));
|
||||
expect(host.textContent).not.toContain("invalid");
|
||||
expect(previewCanvas?.closest<HTMLElement>('[role="tabpanel"]')?.style.display).toBe("none");
|
||||
|
||||
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");
|
||||
expect(previewCanvas?.closest<HTMLElement>('[role="tabpanel"]')?.style.display).toBe("none");
|
||||
|
||||
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");
|
||||
expect(previewCanvas?.closest<HTMLElement>('[role="tabpanel"]')?.style.display).toBe("none");
|
||||
|
||||
const diagnosticsTab = [...host.querySelectorAll<HTMLElement>('[role="tab"]')].find((element) =>
|
||||
element.textContent?.includes("tab.diagnostics"),
|
||||
|
||||
+134
-198
@@ -1,70 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useMemo, useState } from "react";
|
||||
import { Alert, Box, Card, Group, Paper, SegmentedControl, Stack, Switch, Tabs, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import { analyzeCustomWidgetAccessibility, PreviewErrorBoundary } from "@homarr/custom-widgets/workbench";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { memo, useMemo, useState, useRef, useEffect } from "react";
|
||||
import { Box, Card, Group, Stack, Tabs, Text } from "@mantine/core";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import CustomJsxDisplay from "@homarr/widgets/custom-api/custom-jsx-display";
|
||||
|
||||
import { CodeEditor } from "./_code-editor";
|
||||
import { WorkbenchPreviewWidget, workbenchPreviewWidth } from "./_flow/preview-widget";
|
||||
import { WorkbenchPreviewControls, WorkbenchPreviewTools } from "./_flow/preview-controls";
|
||||
import { useWorkbenchPreviewPresentation } from "./_flow/preview-presentation";
|
||||
import { WorkbenchPreviewThemeControl } from "./_flow/preview-theme";
|
||||
import type { PreviewPanelProps, PreviewFixture } from "./_flow/preview-panel-types";
|
||||
import { usePreviewExecution } from "./_flow/preview-execution";
|
||||
import { usePreviewLiveActions } from "./_flow/preview-live-actions";
|
||||
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 { PreviewDetailPanels, PreviewTabList } from "./_custom-widget-preview-details";
|
||||
import { createPreviewDisplayData } from "./_custom-widget-preview-data";
|
||||
import { CustomWidgetPreviewSizeControl } from "./_custom-widget-preview-size-control";
|
||||
import { JsonPreviewEditor } from "./_json-preview-editor";
|
||||
import classes from "./_custom-widget-form.module.css";
|
||||
|
||||
const previewWidths: Record<string, number> = { compact: 320, standard: 480, wide: 720 };
|
||||
|
||||
export interface PreviewState {
|
||||
data: Record<string, unknown>;
|
||||
status: Record<string, unknown>;
|
||||
session: { id: string; expiresAt: number; liveActions: boolean } | null;
|
||||
outcome: PreviewOutcome;
|
||||
}
|
||||
|
||||
interface PreviewPanelProps {
|
||||
candidate: HomarrCustomWidgetV2 | null;
|
||||
validationIssues: Array<{ path?: string; message: string }>;
|
||||
preview: PreviewState;
|
||||
size: string;
|
||||
onSizeChange(value: string): void;
|
||||
optionsSnapshot: Record<string, unknown>;
|
||||
onOptionsChange(value: Record<string, unknown>): void;
|
||||
onLiveActionsChange(enabled: boolean): void;
|
||||
}
|
||||
export type { PreviewState } from "./_flow/preview-state";
|
||||
|
||||
function CustomWidgetPreviewPanelContent(props: PreviewPanelProps) {
|
||||
const t = useI18n("customWidget.workbench.preview");
|
||||
const errorBoundaryMessages = useI18n("customWidget.editor.errorBoundary");
|
||||
const actionT = useI18n("common.action");
|
||||
const [fixture, setFixture] = useState<"live" | "loading" | "empty" | "error">("live");
|
||||
const liveActionsMutation = clientApi.customWidget.setPreviewLiveActions.useMutation();
|
||||
const journalQuery = clientApi.customWidget.previewJournal.useQuery(
|
||||
{ sessionId: props.preview.session?.id ?? "" },
|
||||
{ enabled: Boolean(props.preview.session), refetchInterval: props.preview.session ? 2_000 : false },
|
||||
);
|
||||
const candidate = props.candidate;
|
||||
const [inspect, setInspect] = useState(false);
|
||||
const [fixture, setFixture] = useState<PreviewFixture>("live");
|
||||
const execution = usePreviewExecution();
|
||||
const liveActions = usePreviewLiveActions(props);
|
||||
const presentation = useWorkbenchPreviewPresentation();
|
||||
const canvas = presentation === "canvas";
|
||||
const lastValid = useRef(props.candidate);
|
||||
useEffect(() => {
|
||||
if (props.candidate) lastValid.current = props.candidate;
|
||||
}, [props.candidate]);
|
||||
const candidate = props.candidate ?? lastValid.current;
|
||||
const flowT = useI18n("customWidget.flow");
|
||||
const candidateExtensions = candidate?.extensions;
|
||||
const candidateTemplate = candidate?.template;
|
||||
const candidateRequests = candidate?.requests;
|
||||
const hasActions =
|
||||
Object.values(candidateRequests ?? {}).some((request) => request.kind === "action") ||
|
||||
Object.values(candidateExtensions?.native ?? {}).some((capability) => capability.kind === "action");
|
||||
const previewCandidate = useMemo(() => {
|
||||
if (candidateTemplate === undefined || candidateRequests === undefined) return null;
|
||||
return { template: candidateTemplate, requests: candidateRequests };
|
||||
}, [candidateRequests, candidateTemplate]);
|
||||
return { template: candidateTemplate, requests: candidateRequests, extensions: candidateExtensions };
|
||||
}, [candidateRequests, candidateTemplate, candidateExtensions]);
|
||||
const previewSnapshot = useMemo(
|
||||
() => ({ data: props.preview.data, status: props.preview.status, session: props.preview.session }),
|
||||
[props.preview.data, props.preview.session, props.preview.status],
|
||||
);
|
||||
const fixtureError = t("fixtureError");
|
||||
const accessibilityIssues = useMemo(() => {
|
||||
if (candidateTemplate === undefined) return [];
|
||||
return analyzeCustomWidgetAccessibility(candidateTemplate);
|
||||
}, [candidateTemplate]);
|
||||
const displayData = useMemo(
|
||||
() =>
|
||||
createPreviewDisplayData({
|
||||
@@ -82,6 +63,7 @@ function CustomWidgetPreviewPanelContent(props: PreviewPanelProps) {
|
||||
JSON.stringify({
|
||||
template: candidateTemplate,
|
||||
requests: candidateRequests,
|
||||
extensions: candidateExtensions,
|
||||
fixture,
|
||||
data: props.preview.data,
|
||||
status: props.preview.status,
|
||||
@@ -91,6 +73,7 @@ function CustomWidgetPreviewPanelContent(props: PreviewPanelProps) {
|
||||
[
|
||||
candidateRequests,
|
||||
candidateTemplate,
|
||||
candidateExtensions,
|
||||
fixture,
|
||||
props.optionsSnapshot,
|
||||
props.preview.data,
|
||||
@@ -113,161 +96,114 @@ function CustomWidgetPreviewPanelContent(props: PreviewPanelProps) {
|
||||
? { title: t("result.loading.title"), description: t("result.loading.description") }
|
||||
: { title: "", description: "" };
|
||||
|
||||
const controls = (
|
||||
<WorkbenchPreviewControls
|
||||
size={props.size}
|
||||
onSizeChange={props.onSizeChange}
|
||||
inspect={inspect}
|
||||
onInspectChange={setInspect}
|
||||
fixture={fixture}
|
||||
onFixtureChange={setFixture}
|
||||
disabled={!candidate}
|
||||
/>
|
||||
);
|
||||
const result = candidate && (
|
||||
<PreviewResult
|
||||
outcome={props.preview.outcome}
|
||||
title={previewResult.title}
|
||||
description={previewResult.description}
|
||||
/>
|
||||
);
|
||||
const details = (
|
||||
<PreviewDetailPanels
|
||||
{...props}
|
||||
candidate={candidate}
|
||||
requestId={execution.requestId}
|
||||
onShowAll={execution.showAll}
|
||||
liveActions={liveActions}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card withBorder p="md">
|
||||
<Stack gap="md">
|
||||
<Group gap="xs" justify="space-between">
|
||||
<CustomWidgetPreviewSizeControl value={props.size} onChange={props.onSizeChange} />
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={fixture}
|
||||
disabled={!candidate}
|
||||
onChange={(value) => setFixture(value as typeof fixture)}
|
||||
data={[
|
||||
{ value: "live", label: t("fixture.live") },
|
||||
{ value: "loading", label: t("fixture.loading") },
|
||||
{ value: "empty", label: t("fixture.empty") },
|
||||
{ value: "error", label: t("fixture.error") },
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
{candidate && (
|
||||
<PreviewResult
|
||||
outcome={props.preview.outcome}
|
||||
title={previewResult.title}
|
||||
description={previewResult.description}
|
||||
/>
|
||||
<Card
|
||||
withBorder={!canvas}
|
||||
p={canvas ? 0 : "xs"}
|
||||
w={canvas ? workbenchPreviewWidth(props.size) : undefined}
|
||||
data-preview-presentation={presentation}
|
||||
className="nodrag nopan nowheel"
|
||||
>
|
||||
<Stack gap="xs">
|
||||
{(props.preview.stale || !props.candidate) && (
|
||||
<Text size="xs" c="dimmed" component="output">
|
||||
{flowT("stalePreview")}
|
||||
</Text>
|
||||
)}
|
||||
<Tabs defaultValue="widget" keepMounted={false}>
|
||||
<Tabs.List grow>
|
||||
{(["widget", "data", "options", "actions", "diagnostics"] as const).map((tab) => (
|
||||
<Tabs.Tab key={tab} value={tab}>
|
||||
<Group gap={6} wrap="nowrap" justify="center">
|
||||
{t(`tab.${tab}`)}
|
||||
{tab === "data" && (
|
||||
<PreviewStatusDot outcome={props.preview.outcome} label={t(`status.${props.preview.outcome}`)} />
|
||||
)}
|
||||
</Group>
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
</Tabs.List>
|
||||
<Tabs.Panel value="widget" pt="sm">
|
||||
<Box className={classes.previewCanvas}>
|
||||
<Paper
|
||||
withBorder
|
||||
p="sm"
|
||||
h={360}
|
||||
w={previewWidths[props.size] ?? 480}
|
||||
maw="100%"
|
||||
style={{ overflow: "auto" }}
|
||||
>
|
||||
{displayData ? (
|
||||
<PreviewErrorBoundary
|
||||
title={errorBoundaryMessages("title")}
|
||||
description={t("containedFailures")}
|
||||
retryLabel={actionT("tryAgain")}
|
||||
resetKeys={[rendererResetKey]}
|
||||
>
|
||||
<CustomJsxDisplay data={displayData} />
|
||||
</PreviewErrorBoundary>
|
||||
) : (
|
||||
<Alert color="yellow">{t("invalid")}</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="data" pt="sm">
|
||||
<CodeEditor
|
||||
id="preview-data"
|
||||
label={t("requestData")}
|
||||
language="json"
|
||||
value={JSON.stringify({ data: props.preview.data, status: props.preview.status }, null, 2)}
|
||||
readOnly
|
||||
onChange={() => undefined}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="options" pt="sm">
|
||||
<JsonPreviewEditor
|
||||
id="preview-options"
|
||||
label={t("instanceOptions")}
|
||||
value={props.optionsSnapshot}
|
||||
onChange={props.onOptionsChange}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="actions" pt="sm">
|
||||
<Stack gap="sm">
|
||||
<Switch
|
||||
label={t("liveActions")}
|
||||
description={t("liveActionsDescription")}
|
||||
checked={props.preview.session?.liveActions ?? false}
|
||||
disabled={!props.preview.session || liveActionsMutation.isPending}
|
||||
onChange={(event) => {
|
||||
if (!props.preview.session) return;
|
||||
const enabled = event.currentTarget.checked;
|
||||
liveActionsMutation.mutate(
|
||||
{ sessionId: props.preview.session.id, enabled },
|
||||
{
|
||||
onSuccess: () => props.onLiveActionsChange(enabled),
|
||||
onError: (error) => showErrorNotification({ title: t("liveActions"), message: error.message }),
|
||||
},
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{Object.entries(candidate?.requests ?? {})
|
||||
.filter(([, request]) => request.kind === "action")
|
||||
.map(([id, request]) => (
|
||||
<PreviewActionControl key={id} request={{ id, ...request }} sessionId={props.preview.session?.id} />
|
||||
))}
|
||||
{Object.values(candidate?.requests ?? {}).every((request) => request.kind !== "action") && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("noActions")}
|
||||
{canvas && (
|
||||
<Group gap="xs" justify="space-between" wrap="nowrap">
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<PreviewStatusDot outcome={props.preview.outcome} label={t(`status.${props.preview.outcome}`)} />
|
||||
{hasActions && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{props.preview.session?.liveActions ? t("liveActions") : flowT("previewActionsSimulated")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="diagnostics" pt="sm">
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("containedFailures")}
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{t("validation.title")}
|
||||
</Text>
|
||||
{props.validationIssues.length === 0 ? (
|
||||
<Alert color="green">{t("validation.ready")}</Alert>
|
||||
) : (
|
||||
props.validationIssues.map((issue, index) => (
|
||||
<Alert key={`${issue.path ?? "widget"}-${index}`} color="red" title={issue.path}>
|
||||
{issue.message}
|
||||
</Alert>
|
||||
))
|
||||
)}
|
||||
<Text size="sm" fw={600}>
|
||||
{t("accessibility.title")}
|
||||
</Text>
|
||||
{!candidate ? (
|
||||
<Alert color="gray">{t("accessibility.pending")}</Alert>
|
||||
) : accessibilityIssues.length === 0 ? (
|
||||
<Alert color="green">{t("accessibility.ready")}</Alert>
|
||||
) : (
|
||||
accessibilityIssues.map((issue) => (
|
||||
<Alert key={issue} color="yellow">
|
||||
{t(`accessibility.${issue}`)}
|
||||
</Alert>
|
||||
))
|
||||
)}
|
||||
{props.preview.session && (
|
||||
<CodeEditor
|
||||
id="preview-journal"
|
||||
label={t("journal")}
|
||||
language="json"
|
||||
value={JSON.stringify(journalQuery.data ?? [], null, 2)}
|
||||
readOnly
|
||||
onChange={() => undefined}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
<WorkbenchPreviewTools revealVersion={execution.revealVersion}>
|
||||
<Tabs
|
||||
value={execution.activeTab}
|
||||
onChange={execution.setActiveTab}
|
||||
keepMounted
|
||||
keepMountedMode="display-none"
|
||||
>
|
||||
<PreviewTabList outcome={props.preview.outcome} widgetLabel={flowT("previewDisplay")} />
|
||||
<Tabs.Panel value="widget" pt="sm">
|
||||
<Stack gap="sm">
|
||||
{controls}
|
||||
<WorkbenchPreviewThemeControl />
|
||||
{result}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
{details}
|
||||
</Tabs>
|
||||
</WorkbenchPreviewTools>
|
||||
</Group>
|
||||
)}
|
||||
{!canvas && controls}
|
||||
{!canvas && result}
|
||||
<Tabs
|
||||
value={canvas ? "widget" : execution.activeTab}
|
||||
onChange={execution.setActiveTab}
|
||||
keepMounted
|
||||
keepMountedMode="display-none"
|
||||
>
|
||||
{!canvas && <PreviewTabList outcome={props.preview.outcome} />}
|
||||
<Tabs.Panel
|
||||
key="widget-renderer"
|
||||
value="widget"
|
||||
pt={canvas ? 0 : "sm"}
|
||||
renderRoot={(attributes) => {
|
||||
if (canvas) {
|
||||
const canvasAttributes = { ...attributes };
|
||||
delete canvasAttributes.role;
|
||||
delete canvasAttributes["aria-labelledby"];
|
||||
return <section {...canvasAttributes} aria-label={t("tab.widget")} />;
|
||||
}
|
||||
return <section {...attributes} />;
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
className={classes.previewCanvas}
|
||||
style={canvas ? { padding: 0, background: "none", overflow: "visible" } : undefined}
|
||||
>
|
||||
<WorkbenchPreviewWidget
|
||||
data={displayData}
|
||||
size={props.size}
|
||||
inspect={inspect}
|
||||
resetKey={rendererResetKey}
|
||||
/>
|
||||
</Box>
|
||||
</Tabs.Panel>
|
||||
{!canvas && details}
|
||||
</Tabs>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
+10
-2
@@ -5,6 +5,8 @@ import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
|
||||
|
||||
interface RenameHandlersInput {
|
||||
form: CustomWidgetWorkbenchForm;
|
||||
transaction(action: () => void): void;
|
||||
renameRequestBinding(previousId: string, nextId: string): void;
|
||||
invalidWidgetMessage: string;
|
||||
}
|
||||
|
||||
@@ -16,7 +18,11 @@ export function createCustomWidgetRenameHandlers(input: RenameHandlersInput) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
applyDefinition(input.form, renameCustomWidgetRequest(candidate.data, currentId, nextId));
|
||||
input.transaction(() => {
|
||||
const renamed = renameCustomWidgetRequest(candidate.data, currentId, nextId);
|
||||
input.renameRequestBinding(currentId, nextId);
|
||||
applyDefinition(input.form, renamed);
|
||||
});
|
||||
input.form.clearFieldError("requests");
|
||||
} catch (error) {
|
||||
input.form.setFieldError("requests", error instanceof Error ? error.message : input.invalidWidgetMessage);
|
||||
@@ -29,7 +35,9 @@ export function createCustomWidgetRenameHandlers(input: RenameHandlersInput) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
applyDefinition(input.form, renameCustomWidgetOption(candidate.data, currentName, nextName));
|
||||
input.transaction(() =>
|
||||
applyDefinition(input.form, renameCustomWidgetOption(candidate.data, currentName, nextName)),
|
||||
);
|
||||
input.form.clearFieldError("options");
|
||||
} catch (error) {
|
||||
input.form.setFieldError("options", error instanceof Error ? error.message : input.invalidWidgetMessage);
|
||||
|
||||
+7
-2
@@ -157,11 +157,16 @@ export function RequestBodyEditor({
|
||||
const [optionName, setOptionName] = useState<string | null>(optionNames[0] ?? null);
|
||||
const [parameterName, setParameterName] = useState("");
|
||||
const [insertion, setInsertion] = useState({ text: "", key: 0 });
|
||||
useEffect(() => setDraft(serialized), [serialized]);
|
||||
useEffect(() => {
|
||||
setDraft(serialized);
|
||||
setError(undefined);
|
||||
}, [serialized]);
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<CodeEditor
|
||||
id={`${id}-request-body-editor`}
|
||||
// Incomplete JSON is a local buffer; its undo must not change another document field.
|
||||
localHistory
|
||||
label={t("jsonBody")}
|
||||
description={t("jsonBodyDescription")}
|
||||
language="json"
|
||||
@@ -182,7 +187,7 @@ export function RequestBodyEditor({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Group align="end" wrap="wrap">
|
||||
<Group align="end" wrap="wrap" data-workbench-local-input>
|
||||
<Select label={t("option")} data={optionNames} value={optionName} searchable onChange={setOptionName} />
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
+32
-4
@@ -1,5 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { projectWidgetGraph } from "./_flow/graph";
|
||||
import { useCustomWidgetFormDocumentStore } from "./_custom-widget-form-state";
|
||||
import { useWorkbenchSelection } from "./_flow/selection";
|
||||
|
||||
import { ActionIcon, Button, Fieldset, Group, Select, Stack, Switch, Text, TextInput } from "@mantine/core";
|
||||
import { IconArrowDown, IconArrowUp, IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
@@ -20,6 +25,10 @@ export function CustomWidgetRequestsEditor({
|
||||
form: CustomWidgetWorkbenchForm;
|
||||
onRename(currentId: string, nextId: string): void;
|
||||
}) {
|
||||
const selection = useWorkbenchSelection();
|
||||
const store = useCustomWidgetFormDocumentStore();
|
||||
const flowT = useI18n("customWidget.flow");
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const t = useI18n("customWidget.workbench.builder");
|
||||
const parsed = parseJson(form.values.requests);
|
||||
const entries = isRecord(parsed) ? Object.entries(parsed) : [];
|
||||
@@ -32,7 +41,24 @@ export function CustomWidgetRequestsEditor({
|
||||
form.setFieldValue("requests", JSON.stringify(Object.fromEntries(next), null, 2));
|
||||
const update = (index: number, id: string, request: Record<string, unknown>) =>
|
||||
commit(entries.map((entry, i) => (i === index ? [id, request] : entry)));
|
||||
const remove = (index: number) => commit(entries.filter((_, i) => i !== index));
|
||||
const remove = (index: number) => {
|
||||
const id = entries[index]?.[0];
|
||||
const graph = projectWidgetGraph(form.values, store.getLayout());
|
||||
const references = graph.edges
|
||||
.filter((edge) => edge.source === `request:${id}` || edge.target === `request:${id}`)
|
||||
.filter((edge) => edge.data?.relationship !== "source")
|
||||
.map((edge) => (edge.source === `request:${id}` ? edge.target : edge.source));
|
||||
const apply = () => store.transaction(() => commit(entries.filter((_, i) => i !== index)));
|
||||
if (!references.length) {
|
||||
apply();
|
||||
return;
|
||||
}
|
||||
openConfirmModal({
|
||||
title: flowT("removeReferenced"),
|
||||
children: flowT("removeReferencedDescription", { references: [...new Set(references)].join(", ") }),
|
||||
onConfirm: apply,
|
||||
});
|
||||
};
|
||||
const move = (index: number, direction: -1 | 1) => {
|
||||
const next = [...entries];
|
||||
const target = index + direction;
|
||||
@@ -49,13 +75,14 @@ export function CustomWidgetRequestsEditor({
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{entries.map(([id, rawRequest], index) => {
|
||||
if (selection && selection !== `request:${id}`) return null;
|
||||
const request = isRecord(rawRequest) ? rawRequest : {};
|
||||
const kind = request.kind === "action" ? "action" : "query";
|
||||
const trigger = request.trigger === "manual" ? "manual" : "load";
|
||||
return (
|
||||
<Fieldset key={id} legend={id || t("request", { count: index + 1 })}>
|
||||
<Fieldset key={id} legend={id || t("request", { count: index + 1 })} data-workbench-request={id}>
|
||||
<Stack gap="sm">
|
||||
<Group grow align="start">
|
||||
<Group grow align="start" data-workbench-request-row>
|
||||
<CustomWidgetIdentifierInput
|
||||
label={t("requestId")}
|
||||
value={id}
|
||||
@@ -89,7 +116,7 @@ export function CustomWidgetRequestsEditor({
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow align="start">
|
||||
<Group grow align="start" data-workbench-request-row>
|
||||
<Select
|
||||
label={t("source")}
|
||||
data={sourceIds}
|
||||
@@ -121,6 +148,7 @@ export function CustomWidgetRequestsEditor({
|
||||
</Group>
|
||||
<TextInput
|
||||
label={t("path")}
|
||||
data-workbench-request-path
|
||||
description={t("pathDescription")}
|
||||
descriptionProps={{
|
||||
style: { color: "light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-4))" },
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
|
||||
|
||||
type Secrets = CustomWidgetFormValues["secrets"];
|
||||
|
||||
export function getSourceSecretRenames(secrets: Secrets) {
|
||||
return Object.fromEntries(
|
||||
secrets.flatMap((secret) => {
|
||||
if (!secret.hasValue || secret.value.trim() || !secret.savedSourceId || secret.savedSourceId === secret.sourceId)
|
||||
return [];
|
||||
return [[secret.savedSourceId, secret.sourceId]];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** A save changes where credentials live, including when the user renamed a
|
||||
* source again while that save was in flight. Preserve newer input values. */
|
||||
export function markSourceSecretsSaved(
|
||||
submitted: Secrets,
|
||||
current: Secrets,
|
||||
presence?: Array<{ sourceId: string; kind: string }>,
|
||||
): Secrets {
|
||||
return current.map((secret) => {
|
||||
const saved = submitted.find(
|
||||
(candidate) =>
|
||||
candidate.kind === secret.kind &&
|
||||
(candidate.savedSourceId ?? candidate.sourceId) === (secret.savedSourceId ?? secret.sourceId),
|
||||
);
|
||||
if (!saved) return secret;
|
||||
const { savedSourceId: _previousSourceId, ...next } = secret;
|
||||
let hasValue = Boolean(saved.hasValue || saved.value.trim());
|
||||
if (presence) hasValue = presence.some((entry) => entry.sourceId === saved.sourceId && entry.kind === saved.kind);
|
||||
return {
|
||||
...next,
|
||||
hasValue,
|
||||
value: secret.value === saved.value ? "" : secret.value,
|
||||
...(hasValue && secret.sourceId !== saved.sourceId ? { savedSourceId: saved.sourceId } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import { getCustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core";
|
||||
import type { CustomWidgetSource, CustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { SourceNetworkHint } from "./_flow/source-network-hint";
|
||||
|
||||
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
|
||||
import { CustomWidgetIdentifierInput } from "./_custom-widget-identifier-input";
|
||||
|
||||
@@ -98,6 +100,7 @@ export function CustomWidgetSourceField({
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Group>
|
||||
<SourceNetworkHint source={source} onChange={(networkScope) => onUpdate(index, { networkScope })} />
|
||||
{typeof source.auth === "object" && source.auth.type === "apiKeyHeader" && (
|
||||
<TextInput
|
||||
label={t("headerName")}
|
||||
@@ -116,7 +119,7 @@ export function CustomWidgetSourceField({
|
||||
const secret = form.values.secrets.find((entry) => entry.sourceId === source.id && entry.kind === kind);
|
||||
const Input = kind === "username" ? TextInput : PasswordInput;
|
||||
return (
|
||||
<Group key={kind} align="end" wrap="nowrap">
|
||||
<Group key={kind} align="end" wrap="nowrap" data-workbench-credential>
|
||||
<Input
|
||||
style={{ flex: 1 }}
|
||||
label={tSecret(kind)}
|
||||
@@ -131,6 +134,7 @@ export function CustomWidgetSourceField({
|
||||
color="red"
|
||||
variant="subtle"
|
||||
loading={clearSecretPending}
|
||||
disabled={form.submitting}
|
||||
onClick={() => void onClearSecret(source.id, kind)}
|
||||
>
|
||||
{t("clear")}
|
||||
@@ -139,7 +143,7 @@ export function CustomWidgetSourceField({
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
{index > 0 && (
|
||||
{
|
||||
<Button
|
||||
type="button"
|
||||
color="red"
|
||||
@@ -149,7 +153,7 @@ export function CustomWidgetSourceField({
|
||||
>
|
||||
{t("remove")}
|
||||
</Button>
|
||||
)}
|
||||
}
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
);
|
||||
|
||||
+38
-9
@@ -1,5 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useCustomWidgetFormDocumentStore } from "./_custom-widget-form-state";
|
||||
import { useWorkbenchSelection } from "./_flow/selection";
|
||||
|
||||
import { Accordion, Button, List, Stack, Text } from "@mantine/core";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
|
||||
@@ -21,6 +24,8 @@ export function CustomWidgetSourcesEditor({
|
||||
form: CustomWidgetWorkbenchForm;
|
||||
definitionId?: string;
|
||||
}) {
|
||||
const selection = useWorkbenchSelection();
|
||||
const store = useCustomWidgetFormDocumentStore();
|
||||
const t = useI18n("customWidget.workbench.sources");
|
||||
const w = useI18n("customWidget.workbench");
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
@@ -34,7 +39,16 @@ export function CustomWidgetSourcesEditor({
|
||||
form.setFieldError("sources", t("duplicateSourceId"));
|
||||
return;
|
||||
}
|
||||
let parsedRequests: unknown;
|
||||
if (previousId !== nextId) {
|
||||
parsedRequests = parseJson(form.values.requests);
|
||||
if (!isRecord(parsedRequests)) {
|
||||
form.setFieldError("sources", w("invalidWidget"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (form.errors.sources === t("duplicateSourceId")) form.clearFieldError("sources");
|
||||
if (previousId && nextId && previousId !== nextId) store.renameSourceBinding(previousId, nextId);
|
||||
const sourceEntries = sources.map(({ id, ...source }) => [id, source] as const);
|
||||
form.setFieldValue(
|
||||
"sources",
|
||||
@@ -50,12 +64,13 @@ export function CustomWidgetSourcesEditor({
|
||||
),
|
||||
);
|
||||
if (previousId && nextId && previousId !== nextId) {
|
||||
const parsedRequests = parseJson(form.values.requests);
|
||||
const requests = isRecord(parsedRequests)
|
||||
? Object.fromEntries(
|
||||
Object.entries(parsedRequests).map(([id, request]) => [
|
||||
id,
|
||||
isRecord(request) && request.source === previousId ? { ...request, source: nextId } : request,
|
||||
isRecord(request) && (request.source ?? "default") === previousId
|
||||
? { ...request, source: nextId }
|
||||
: request,
|
||||
]),
|
||||
)
|
||||
: {};
|
||||
@@ -63,7 +78,13 @@ export function CustomWidgetSourcesEditor({
|
||||
form.setFieldValue(
|
||||
"secrets",
|
||||
form.values.secrets.map((secret) =>
|
||||
secret.sourceId === previousId ? { ...secret, sourceId: nextId } : secret,
|
||||
secret.sourceId === previousId
|
||||
? {
|
||||
...secret,
|
||||
sourceId: nextId,
|
||||
...(secret.hasValue ? { savedSourceId: secret.savedSourceId ?? previousId } : {}),
|
||||
}
|
||||
: secret,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -84,7 +105,10 @@ export function CustomWidgetSourcesEditor({
|
||||
const setSecret = (sourceId: string, kind: string, value: string) => {
|
||||
const current = form.values.secrets.filter((secret) => !(secret.sourceId === sourceId && secret.kind === kind));
|
||||
const existing = form.values.secrets.find((secret) => secret.sourceId === sourceId && secret.kind === kind);
|
||||
form.setFieldValue("secrets", [...current, { sourceId, kind, value, hasValue: existing?.hasValue }]);
|
||||
form.setFieldValue("secrets", [
|
||||
...current,
|
||||
{ sourceId, kind, value, hasValue: existing?.hasValue, savedSourceId: existing?.savedSourceId },
|
||||
]);
|
||||
};
|
||||
const addSource = () => {
|
||||
let suffix = sources.length + 1;
|
||||
@@ -132,7 +156,7 @@ export function CustomWidgetSourcesEditor({
|
||||
if (!removedId) return;
|
||||
const dependentRequestIds = getDependentRequestIds(form.values.requests, removedId);
|
||||
if (dependentRequestIds.length === 0) {
|
||||
removeSourceAndDependents(index);
|
||||
store.transaction(() => removeSourceAndDependents(index));
|
||||
return;
|
||||
}
|
||||
openConfirmModal({
|
||||
@@ -150,12 +174,16 @@ export function CustomWidgetSourcesEditor({
|
||||
</Stack>
|
||||
),
|
||||
confirmProps: { children: t("remove") },
|
||||
onConfirm: () => removeSourceAndDependents(index),
|
||||
onConfirm: () => store.transaction(() => removeSourceAndDependents(index)),
|
||||
});
|
||||
};
|
||||
const clearSecret = async (sourceId: string, kind: "apiKey" | "username" | "password") => {
|
||||
if (form.submitting) return;
|
||||
if (!definitionId) return;
|
||||
await clearSecretMutation.mutateAsync({ definitionId, sourceId, kind });
|
||||
const savedSourceId = form.values.secrets.find(
|
||||
(secret) => secret.sourceId === sourceId && secret.kind === kind,
|
||||
)?.savedSourceId;
|
||||
await clearSecretMutation.mutateAsync({ definitionId, sourceId: savedSourceId ?? sourceId, kind });
|
||||
const current = form.values.secrets.filter((secret) => !(secret.sourceId === sourceId && secret.kind === kind));
|
||||
form.setFieldValue("secrets", [...current, { sourceId, kind, value: "", hasValue: false }]);
|
||||
await utils.customWidget.get.invalidate({ id: definitionId });
|
||||
@@ -165,6 +193,7 @@ export function CustomWidgetSourcesEditor({
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{sources.map((source, index) => {
|
||||
if (selection && selection !== `source:${source.id}`) return null;
|
||||
return (
|
||||
<CustomWidgetSourceField
|
||||
key={source.id}
|
||||
@@ -173,8 +202,8 @@ export function CustomWidgetSourcesEditor({
|
||||
form={form}
|
||||
definitionId={definitionId}
|
||||
clearSecretPending={clearSecretMutation.isPending}
|
||||
onUpdate={update}
|
||||
onSetAuthentication={setAuth}
|
||||
onUpdate={(sourceIndex, changes) => store.transaction(() => update(sourceIndex, changes))}
|
||||
onSetAuthentication={(sourceIndex, type) => store.transaction(() => setAuth(sourceIndex, type))}
|
||||
onSetSecret={setSecret}
|
||||
onClearSecret={clearSecret}
|
||||
onRemove={removeSource}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# Custom Widget workbench
|
||||
|
||||
The flow editor projects the existing Custom Widget definition. It does not execute a graph or replace the dashboard
|
||||
runtime. The canvas is the default authoring surface; the complete form remains available from the More menu.
|
||||
|
||||
The workspace fills the management area by default, beside a collapsible icon rail. Optional immersive full-screen mode
|
||||
hides management chrome through CSS and background focus isolation; it preserves the React tree, form association, draft,
|
||||
and execution session. Save errors render inside the workspace.
|
||||
|
||||
## Ownership
|
||||
|
||||
- `document-store.ts` owns definition transactions and bounded Undo/Redo. Credentials stay in the live form;
|
||||
`credential-bindings.ts` restores their association across source renames without putting values in history.
|
||||
- `graph.ts` projects references into nodes and edges. `commands.ts` and the specialized editors change manifest fields.
|
||||
Template edits use parser source ranges and preserve unrelated formatting and comments. Complex relationships are
|
||||
inspectable in code rather than converted into a visual component tree.
|
||||
- Layout metadata lives beside the persisted definition. Moving nodes does not notify definition subscribers, parse JSX,
|
||||
invalidate preview evidence, or call the executor. ELK is loaded on demand for explicit arrangement.
|
||||
- Selection, viewport, panes, response bodies, temporary parameters, and Assistant conversation state are session state.
|
||||
Node data contains compact summaries and stable identifiers, not credentials or response bodies.
|
||||
- `canvas-preview.tsx` mounts the live preview inside the Widget node. Its header drags the node; preview controls and
|
||||
authored interactions use `nodrag`, `nopan`, and `nowheel`. CodeMirror and Assistant live in a contextual side pane,
|
||||
outside canvas zoom, and remain mounted when hidden. Preview tools open in a portaled popup. The preview theme surface
|
||||
isolates its styles and portal content from management chrome. Runtime-owned dropdown targets escape widget paint
|
||||
containment while preserving scoped CSS, preview themes, and dialog focus traps. Nested menus and popovers retain
|
||||
inline dropdowns; interpreter-authored events and portal targets remain blocked.
|
||||
- Assistant draft commands use the existing conversation and client-tool infrastructure. Draft identity and generation
|
||||
preconditions reject delayed patches. An accepted patch is one document transaction, not an implicit save or action.
|
||||
|
||||
## Compatibility and capabilities
|
||||
|
||||
Plain HTTP and static widgets retain the v2 schema. A definition with runtime extensions uses v3; strict v2 definitions
|
||||
never acquire extension fields. Editor layout is not portable runtime content. Empty sources and requests support static
|
||||
widgets without a placeholder service URL.
|
||||
|
||||
V3 adds scoped styles and keyframes, theme/container bindings, named views, supported detail overlays and embeds,
|
||||
per-user browser preferences, permissioned shared board content, and curated native capabilities. The initial native
|
||||
catalog covers Beszel systems/history/alerts/live data, calendars, media requests, Home Assistant, and Docker. It does not
|
||||
expose arbitrary tRPC methods. Other services use HTTP or embeds; services without these interfaces need an external bridge.
|
||||
|
||||
The executable Beszel reference definition and current capability inventory are documented under
|
||||
`apps/docs/docs/management/custom-widgets/`. The reference includes system selection, historical charts, container statistics,
|
||||
live statistics, preferences, and partial failures. The catalog is an explicit coverage boundary, not a claim that every
|
||||
native Homarr widget has a dedicated capability descriptor.
|
||||
|
||||
Workshop installation and MCP use the same install procedure. Provenance records endpoint, submission, revision, and
|
||||
portable fingerprints. Update review retains local connections and secrets, rejects incompatible/local changes, and keeps
|
||||
one previous package for rollback. Publication updates the existing owned submission or creates an attributed remix.
|
||||
PocketBase schema validation and SQLite, MySQL, and PostgreSQL migrations ship with these changes.
|
||||
|
||||
## Authoring connections
|
||||
|
||||
`connection-planner.ts` validates the same operations for ports and the inspector picker. Source associations and action
|
||||
invalidations update their manifest fields directly. Query, action, and option connections open a binding dialog: choose a
|
||||
response field, display, or parameter mapping before applying a bounded JSX/manifest edit. Repeated connections can add
|
||||
another field or display for the same relationship. Query and action creation never runs a remote operation implicitly.
|
||||
|
||||
`template-insertion.ts` preserves existing source text around inserted components. Generated bindings carry portable
|
||||
comment markers for ownership checks. Only unchanged, wholly generated relationships can be removed or replaced visually;
|
||||
authored or mixed references open in JSX. Replacement applies the removal and new binding in one document transaction.
|
||||
A stale dialog cannot overwrite changes made after it opened.
|
||||
|
||||
## Acceptance evidence and limits
|
||||
|
||||
The current canvas presentation has been checked in the isolated SQLite development app with Chromium on Linux. Focused
|
||||
browser checks cover real query/option/action bindings, repeated mappings, shared Undo, live JSX rendering, invalid-JSX
|
||||
preview continuity, generated binding disconnect/reconnect and cancellation, compact controls, theme and size changes,
|
||||
and recovery while the inspector is closed. The preview tools fit a 390px viewport. Real pointer movement and zoom retained the preview shadow tree and CodeMirror
|
||||
without execution requests. Scoped light/dark accessibility scans found no violations; overlap prevents automated contrast
|
||||
verification for some canvas elements. The 38 focused existing workbench/runtime/preview tests passed. Tests use local/sample data and simulated actions; these checks do not exercise a real external action
|
||||
or public Workshop publication.
|
||||
|
||||
Earlier implementation passes covered local mock Beszel data and a deterministic Assistant provider, Workshop installation
|
||||
and board rendering, scoped portals, focused runtime tests, and production builds. Earlier production canvas measurements
|
||||
covered 16 nodes, 64 requests, and a template near 50,000 characters with zero execution requests or parser entries during
|
||||
movement; board manifests excluded React Flow, ELK, and workbench chunks. Those measurements predate the preview-in-node
|
||||
presentation and are not production performance or bundle proof for this final UI.
|
||||
|
||||
Physical touch/trackpad testing, a broader browser/device matrix, a fresh production performance pass, real external service
|
||||
actions, and executing the MySQL/PostgreSQL migrations against live database servers remain separate acceptance work.
|
||||
Schema snapshots and migration ancestry were checked for all three drivers. An existing demo-gallery test requires the
|
||||
unavailable llama.cpp demo module. The current development acceptance uses repository routing unchanged; an earlier
|
||||
production QA instance needed a temporary generated server-routing override.
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
import { Button, Modal, Stack, Text } from "@mantine/core";
|
||||
import type { Connection } from "@xyflow/react";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useCustomWidgetFormDocumentStore } from "../_custom-widget-form-state";
|
||||
import type { CustomWidgetWorkbenchForm } from "../_custom-widget-form-utils";
|
||||
import { createFlowCommands, isGraphEditable } from "./commands";
|
||||
import { projectWidgetGraph, readObject, record } from "./graph";
|
||||
import type { FlowKind } from "./graph";
|
||||
import { placeAddedNodes } from "./node-placement";
|
||||
import { useFlowConnection } from "./connection-provider";
|
||||
import { connectionIssue, planFlowConnection } from "./connection-planner";
|
||||
export interface PendingConnection {
|
||||
nodeId: string;
|
||||
handleType: "source" | "target";
|
||||
position: { x: number; y: number };
|
||||
}
|
||||
export function AddNextStep({
|
||||
pending,
|
||||
form,
|
||||
onClose,
|
||||
onSelect,
|
||||
}: {
|
||||
pending: PendingConnection | null;
|
||||
form: CustomWidgetWorkbenchForm;
|
||||
onClose(): void;
|
||||
onSelect(id: string): void;
|
||||
}) {
|
||||
const t = useI18n("customWidget.flow");
|
||||
const store = useCustomWidgetFormDocumentStore();
|
||||
const connect = useFlowConnection();
|
||||
if (!pending) return null;
|
||||
const graph = projectWidgetGraph(store.getValues(), store.getLayout());
|
||||
const node = graph.nodes.find((entry) => entry.id === pending?.nodeId);
|
||||
const kinds: FlowKind[] = [];
|
||||
if (pending?.handleType === "source" && node?.data.kind === "source") kinds.push("query", "action");
|
||||
if (pending?.handleType === "source" && node?.data.kind === "action") kinds.push("query");
|
||||
if (pending?.handleType === "target" && node && ["query", "action"].includes(node.data.kind)) kinds.push("source");
|
||||
if (node?.data.kind === "widget") {
|
||||
kinds.push("query", "action");
|
||||
if (pending.handleType === "target") kinds.push("options");
|
||||
}
|
||||
if (node?.data.kind === "options" && pending.handleType === "source") kinds.push("query", "action");
|
||||
const existing = graph.nodes.filter((entry) => {
|
||||
let source = pending.nodeId;
|
||||
let target = entry.id;
|
||||
if (pending.handleType === "target") {
|
||||
source = entry.id;
|
||||
target = pending.nodeId;
|
||||
}
|
||||
return !connectionIssue({ source, target }, graph.nodes);
|
||||
});
|
||||
const add = (kind: FlowKind) => {
|
||||
if (!pending || !node || !isGraphEditable(store.getValues())) return;
|
||||
let id = "widget";
|
||||
let binding: Connection | undefined;
|
||||
store.transaction(() => {
|
||||
const previous = projectWidgetGraph(store.getValues(), store.getLayout()).nodes;
|
||||
const commands = createFlowCommands(form, store);
|
||||
let sourceId: string | undefined;
|
||||
if (node.data.kind === "source") sourceId = node.data.identifier;
|
||||
if (node.data.kind === "action") {
|
||||
const source = record(readObject(store.getValues().requests)[node.data.identifier]).source;
|
||||
if (typeof source === "string") sourceId = source;
|
||||
}
|
||||
id = commands.add(kind, sourceId);
|
||||
const nextGraph = projectWidgetGraph(store.getValues(), store.getLayout());
|
||||
let source = pending.nodeId;
|
||||
let target = id;
|
||||
if (pending.handleType === "target") {
|
||||
source = id;
|
||||
target = pending.nodeId;
|
||||
}
|
||||
const connection = { source, target, sourceHandle: "out", targetHandle: "in" };
|
||||
if (planFlowConnection(connection, nextGraph.nodes, store.getValues()) === "direct")
|
||||
commands.connect(connection, nextGraph.nodes);
|
||||
else binding = connection;
|
||||
store.setLayout({
|
||||
...store.getLayout(),
|
||||
nodes: { ...store.getLayout().nodes, ...placeAddedNodes(previous, nextGraph.nodes, id, pending.position) },
|
||||
});
|
||||
});
|
||||
onClose();
|
||||
onSelect(id);
|
||||
if (binding) connect(binding, projectWidgetGraph(store.getValues(), store.getLayout()).nodes);
|
||||
};
|
||||
return (
|
||||
<Modal
|
||||
opened={!!pending}
|
||||
onClose={onClose}
|
||||
title={t("addNextStep")}
|
||||
closeButtonProps={{ "aria-label": t("bindingCancel") }}
|
||||
size="sm"
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("nextStepFor", { name: node?.data.label ?? "" })}
|
||||
</Text>
|
||||
{kinds.map((kind) => (
|
||||
<Button key={kind} disabled={!isGraphEditable(store.getValues())} variant="light" onClick={() => add(kind)}>
|
||||
{t(`kind.${kind}`)}
|
||||
</Button>
|
||||
))}
|
||||
{existing.map((entry) => (
|
||||
<Button
|
||||
key={entry.id}
|
||||
disabled={!isGraphEditable(store.getValues())}
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
let source = pending.nodeId;
|
||||
let target = entry.id;
|
||||
if (pending.handleType === "target") {
|
||||
source = entry.id;
|
||||
target = pending.nodeId;
|
||||
}
|
||||
const issue = connect({ source, target, sourceHandle: "out", targetHandle: "in" }, graph.nodes);
|
||||
if (issue) return;
|
||||
onClose();
|
||||
onSelect(entry.id);
|
||||
}}
|
||||
>
|
||||
{t("bindingConnectExisting", { name: entry.data.label })}
|
||||
</Button>
|
||||
))}
|
||||
{kinds.length === 0 && existing.length === 0 && <Text size="sm">{t("codeOwned")}</Text>}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, Stack, Text } from "@mantine/core";
|
||||
import { useAuiState } from "@assistant-ui/react";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useOptionalHomarrAssistant, useAssistantPreferences } from "~/components/assistant/assistant-context";
|
||||
import { AssistantConversationSurface } from "~/components/assistant/assistant-panel";
|
||||
import { AssistantComposerSurfaceBoundary } from "~/components/assistant/assistant-runtime-provider";
|
||||
import { getPendingAssistantAction } from "~/components/assistant/assistant-pending-action";
|
||||
import {
|
||||
applyWorkbenchTemplateEdits,
|
||||
getWorkbenchDraftId,
|
||||
registerAssistantWorkbench,
|
||||
} from "~/components/assistant/assistant-workbench-bridge";
|
||||
import type { CustomWidgetWorkbenchForm } from "../_custom-widget-form-utils";
|
||||
import { buildDefinition } from "../_custom-widget-form-utils";
|
||||
import { useCustomWidgetFormDocumentStore } from "../_custom-widget-form-state";
|
||||
import { withoutCredentials } from "./history";
|
||||
|
||||
export function WorkbenchAssistant({
|
||||
form,
|
||||
selected,
|
||||
onSelect,
|
||||
onPreview,
|
||||
}: {
|
||||
form: CustomWidgetWorkbenchForm;
|
||||
selected: string;
|
||||
onSelect(id: string): void;
|
||||
onPreview(): Promise<unknown>;
|
||||
}) {
|
||||
const store = useCustomWidgetFormDocumentStore();
|
||||
const draftId = getWorkbenchDraftId(store);
|
||||
const t = useI18n("customWidget.flow");
|
||||
const assistant = useOptionalHomarrAssistant();
|
||||
const [result, setResult] = useState("");
|
||||
useEffect(
|
||||
() =>
|
||||
registerAssistantWorkbench({
|
||||
read: () => {
|
||||
const draft = buildDefinition(store.getValues());
|
||||
if (!draft.success)
|
||||
return {
|
||||
success: false,
|
||||
draftId,
|
||||
revision: store.getRevision(),
|
||||
selected,
|
||||
fields: withoutCredentials(store.getValues()),
|
||||
diagnostics: draft.error.issues.map((issue) => ({ path: issue.path, message: issue.message })),
|
||||
};
|
||||
return { success: true, draftId, revision: store.getRevision(), selected, definition: draft.data };
|
||||
},
|
||||
apply: (patch) => {
|
||||
if (patch.draftId !== draftId || patch.revision !== store.getRevision())
|
||||
return { success: false, error: "Draft changed. Read it again before applying changes." };
|
||||
const next = { ...withoutCredentials(store.getValues()), ...patch.changes };
|
||||
if (patch.templateEdits?.length) {
|
||||
if (patch.changes.template !== undefined)
|
||||
return { success: false, error: "Choose targeted edits or a complete template, not both." };
|
||||
try {
|
||||
next.template = applyWorkbenchTemplateEdits(next.template, patch.templateEdits);
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : "Invalid template edit" };
|
||||
}
|
||||
}
|
||||
const parsed = buildDefinition({ ...next, secrets: [] });
|
||||
if (!parsed.success)
|
||||
return {
|
||||
success: false,
|
||||
diagnostics: parsed.error.issues.map((issue) => ({ path: issue.path, message: issue.message })),
|
||||
};
|
||||
store.transaction(() => form.setValues({ ...next, secrets: store.getValues().secrets }));
|
||||
setResult(patch.summary);
|
||||
if (patch.focus) onSelect(patch.focus);
|
||||
return { success: true, draftId, revision: store.getRevision(), summary: patch.summary };
|
||||
},
|
||||
preview: async (precondition) => {
|
||||
if (precondition.draftId !== draftId || precondition.revision !== store.getRevision())
|
||||
return { success: false, error: "Draft changed. Read it again before running queries." };
|
||||
return onPreview();
|
||||
},
|
||||
}),
|
||||
[draftId, form, onPreview, onSelect, selected, store],
|
||||
);
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{result && (
|
||||
<Alert color="teal" aria-live="polite">
|
||||
{result}
|
||||
</Alert>
|
||||
)}
|
||||
{!assistant?.enabled && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{assistant?.unavailableDescription ?? t("assistantUnavailable")}
|
||||
</Text>
|
||||
)}
|
||||
{assistant?.enabled && (
|
||||
<AssistantComposerSurfaceBoundary surfaceId="custom-widget-workbench">
|
||||
<Conversation />
|
||||
</AssistantComposerSurfaceBoundary>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
function Conversation() {
|
||||
const assistant = useOptionalHomarrAssistant();
|
||||
const preferences = useAssistantPreferences();
|
||||
const messages = useAuiState((state) => state.thread.messages);
|
||||
const last = messages.findLast((message) => message.role === "assistant");
|
||||
if (!assistant) return null;
|
||||
return (
|
||||
<div style={{ height: 580, display: "flex", flexDirection: "column", minHeight: 0 }}>
|
||||
<AssistantConversationSurface
|
||||
isRunning={assistant.isRunning}
|
||||
pendingAction={getPendingAssistantAction(last)}
|
||||
modelId={preferences.modelId}
|
||||
models={preferences.models}
|
||||
modelOptionsLoading={preferences.isLoading}
|
||||
reasoning={preferences.reasoning}
|
||||
isRefreshing={assistant.isRefreshing}
|
||||
onRefresh={assistant.refreshCurrentView}
|
||||
onModelChange={preferences.setModelId}
|
||||
onReasoningChange={preferences.setReasoning}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { collectCustomWidgetRequestReferences } from "@homarr/custom-widgets/core";
|
||||
import {
|
||||
collectTemplateReferences,
|
||||
insertCustomWidgetTemplateContent,
|
||||
markGeneratedBinding,
|
||||
removeGeneratedBindings,
|
||||
} from "@homarr/custom-widgets/workbench";
|
||||
import type { GeneratedBindingDescriptor } from "@homarr/custom-widgets/workbench";
|
||||
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
|
||||
import { buildDefinition } from "../_custom-widget-form-utils";
|
||||
import { readObject, record } from "./graph";
|
||||
import type { FlowBindingIntent } from "./connection-planner";
|
||||
import { isGeneratedConnectionCurrent } from "./connection-planner";
|
||||
import { nativeLiteralDefault, nativeParameterFields } from "./native-editor-model";
|
||||
import type { NativeInputSchema } from "./native-editor-model";
|
||||
import { freshFlowIdentifier } from "./identifiers";
|
||||
|
||||
export interface BindingParameter {
|
||||
mode: "control" | "input" | "option" | "literal";
|
||||
name: string;
|
||||
controlName: string;
|
||||
value: unknown;
|
||||
}
|
||||
export interface BindingChoices {
|
||||
format: "json" | "text" | "list" | "table";
|
||||
path: string;
|
||||
columns: string;
|
||||
label: string;
|
||||
option: string;
|
||||
field: string;
|
||||
location: "query" | "body" | "path" | "input";
|
||||
params: Record<string, BindingParameter>;
|
||||
}
|
||||
export interface BindingLabels {
|
||||
loading: string;
|
||||
empty: string;
|
||||
run: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export function bindingDescriptor(intent: FlowBindingIntent) {
|
||||
if (intent.native) return record(record(readObject(intent.snapshot.extensions).native)[intent.identifier]);
|
||||
return record(readObject(intent.snapshot.requests)[intent.identifier]);
|
||||
}
|
||||
export function bindingParameters(intent: FlowBindingIntent, schema: NativeInputSchema) {
|
||||
const descriptor = bindingDescriptor(intent);
|
||||
if (intent.native) return nativeParameterFields(descriptor.input, schema);
|
||||
const references = collectCustomWidgetRequestReferences({
|
||||
path: String(descriptor.path ?? ""),
|
||||
query: record(descriptor.query),
|
||||
body: descriptor.body,
|
||||
});
|
||||
return [...references.params].map((name) => ({ name, schema: {} as NativeInputSchema }));
|
||||
}
|
||||
export function createBindingParameters(intent: FlowBindingIntent, schema: NativeInputSchema) {
|
||||
const occupied = Object.fromEntries(
|
||||
collectTemplateReferences(intent.snapshot.template)
|
||||
.filter((ref) => ref.kind === "bind")
|
||||
.map((ref) => [ref.name, true]),
|
||||
);
|
||||
return Object.fromEntries(
|
||||
bindingParameters(intent, schema).map(({ name, schema: field }) => {
|
||||
const binding = freshFlowIdentifier(occupied, `${intent.identifier}-${name}`);
|
||||
occupied[binding] = true;
|
||||
return [
|
||||
name,
|
||||
{
|
||||
mode: "control",
|
||||
name: binding,
|
||||
controlName: binding,
|
||||
value: nativeLiteralDefault(field),
|
||||
} satisfies BindingParameter,
|
||||
];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const quote = JSON.stringify;
|
||||
function propertyPath(root: string, path: string) {
|
||||
const parts = path.trim().split(".").filter(Boolean);
|
||||
if (parts.length > 16 || parts.some((part) => ["constructor", "prototype", "__proto__"].includes(part)))
|
||||
throw new Error("Invalid data path");
|
||||
return root + parts.map((part) => `?.[${quote(part)}]`).join("");
|
||||
}
|
||||
function parameterCode(choices: BindingChoices) {
|
||||
const controls: string[] = [];
|
||||
const entries = Object.entries(choices.params).map(([key, parameter]) => {
|
||||
let expression = quote(parameter.value);
|
||||
if (!["string", "number", "boolean"].includes(typeof parameter.value))
|
||||
throw new Error("Parameters must be text, numbers, or booleans");
|
||||
if (parameter.mode === "input" || parameter.mode === "control") expression = `inputs[${quote(parameter.name)}]`;
|
||||
if (parameter.mode === "option") expression = `options[${quote(parameter.name)}]`;
|
||||
if (parameter.mode === "control") {
|
||||
let component = "TextInput";
|
||||
let defaultProp = "defaultValue";
|
||||
if (typeof parameter.value === "number") component = "NumberInput";
|
||||
if (typeof parameter.value === "boolean") {
|
||||
component = "Switch";
|
||||
defaultProp = "defaultChecked";
|
||||
}
|
||||
controls.push(
|
||||
`<${component} bind=${quote(parameter.name)} label={${quote(key)}} ${defaultProp}={${quote(parameter.value)}} />`,
|
||||
);
|
||||
}
|
||||
return `${quote(key)}: ${expression}`;
|
||||
});
|
||||
return { controls, params: `params={{ ${entries.join(", ")} }}` };
|
||||
}
|
||||
function renderValue(root: string, choices: BindingChoices, labels: BindingLabels) {
|
||||
const value = propertyPath(root, choices.path);
|
||||
if (choices.format === "text") return `<Text>{String(${value} ?? ${quote(labels.empty)})}</Text>`;
|
||||
if (choices.format === "json")
|
||||
return `{(${value} ?? null) === null ? <Text c="dimmed">{${quote(labels.empty)}}</Text> : <Code block>{JSON.stringify(${value})}</Code>}`;
|
||||
const rows = `(${value}).slice(0, 50)`;
|
||||
let body = `${rows}.map((row, index) => <Text key={index} size="sm">{JSON.stringify(row)}</Text>)`;
|
||||
if (choices.format === "table") {
|
||||
const columns = choices.columns
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 8);
|
||||
if (!columns.length) throw new Error("Choose at least one table column");
|
||||
const header = columns.map((column) => `<Table.Th>{${quote(column)}}</Table.Th>`).join("");
|
||||
const cells = columns
|
||||
.map((column) => `<Table.Td>{String(${propertyPath("row", column)} ?? "")}</Table.Td>`)
|
||||
.join("");
|
||||
body = `<Table.ScrollContainer minWidth={300}><Table><Table.Thead><Table.Tr>${header}</Table.Tr></Table.Thead><Table.Tbody>{${rows}.map((row, index) => <Table.Tr key={index}>${cells}</Table.Tr>)}</Table.Tbody></Table></Table.ScrollContainer>`;
|
||||
} else body = `<Stack gap="xs">{${body}}</Stack>`;
|
||||
return `{Array.isArray(${value}) && ${value}.length > 0 ? (${body}) : <Text c="dimmed">{${quote(labels.empty)}}</Text>}`;
|
||||
}
|
||||
|
||||
export function createBindingSnippet(intent: FlowBindingIntent, choices: BindingChoices, labels: BindingLabels) {
|
||||
if (intent.kind === "option") {
|
||||
if (!record(readObject(intent.snapshot.options)[choices.option]).control)
|
||||
throw new Error("Choose an existing option");
|
||||
return `<Text>{String(options[${quote(choices.option)}] ?? "")}</Text>`;
|
||||
}
|
||||
const descriptor = bindingDescriptor(intent);
|
||||
const { controls, params } = parameterCode(choices);
|
||||
let content: string;
|
||||
if (intent.kind === "action") {
|
||||
let component = "ActionButton";
|
||||
let idProp = "requestId";
|
||||
if (intent.native) {
|
||||
component = "NativeActionButton";
|
||||
idProp = "nativeId";
|
||||
}
|
||||
content = `<${component} ${idProp}=${quote(intent.identifier)} ${params} variant="default">{${quote(choices.label || labels.run)}}</${component}>`;
|
||||
} else if (descriptor.trigger === "manual" || descriptor.capability === "beszel.live") {
|
||||
let component = "SubFetch";
|
||||
let idProp = "requestId";
|
||||
let live = "";
|
||||
if (intent.native) {
|
||||
component = "NativeQuery";
|
||||
idProp = "nativeId";
|
||||
}
|
||||
if (descriptor.capability === "beszel.live") live = " live";
|
||||
content = `<${component} ${idProp}=${quote(intent.identifier)} ${params} trigger="manual"${live}>{(result, meta) => (<Stack gap="xs">${renderValue("result", choices, labels)}</Stack>)}</${component}>`;
|
||||
} else {
|
||||
const status = `status[${quote(intent.identifier)}]`;
|
||||
content = `<Stack gap="xs">\n{${status}?.loading ? <Text c="dimmed">{${quote(labels.loading)}}</Text> : ${status}?.error ? <Alert color="red">{String(${status}.error)}</Alert> : (${`<Stack gap="xs">${renderValue(`data[${quote(intent.identifier)}]`, choices, labels)}</Stack>`})}\n<RefreshButton />\n</Stack>`;
|
||||
}
|
||||
if (!controls.length) return content;
|
||||
return `<Stack gap="xs">\n${controls.join("\n")}\n${content}\n</Stack>`;
|
||||
}
|
||||
|
||||
export function bindingCandidate(
|
||||
intent: FlowBindingIntent,
|
||||
values: CustomWidgetFormValues,
|
||||
choices: BindingChoices,
|
||||
labels: BindingLabels,
|
||||
) {
|
||||
const options = readObject(values.options);
|
||||
const bindings = collectTemplateReferences(values.template)
|
||||
.filter((entry) => entry.kind === "bind")
|
||||
.map((entry) => entry.name);
|
||||
for (const parameter of Object.values(choices.params)) {
|
||||
if (
|
||||
parameter.mode === "option" &&
|
||||
!["string", "number", "boolean"].includes(typeof record(options[parameter.name]).default)
|
||||
)
|
||||
throw new Error("Choose an option with a text, number, or boolean value");
|
||||
if (parameter.mode === "input" && !bindings.includes(parameter.name))
|
||||
throw new Error("Choose an existing widget input");
|
||||
if (parameter.mode === "control" && bindings.includes(parameter.name))
|
||||
throw new Error("A widget input with this name already exists");
|
||||
}
|
||||
const snippet = createBindingSnippet(intent, choices, labels);
|
||||
let template = values.template;
|
||||
if (intent.replaceGeneratedIds) {
|
||||
if (!isGeneratedConnectionCurrent(values, intent.replaceEdgeId, intent.replaceGeneratedIds))
|
||||
throw new Error("The original connection now includes code-owned references. Edit this relationship in code.");
|
||||
const fragments = Object.values(record(readObject(values.extensions).fragments)).filter(
|
||||
(value): value is string => typeof value === "string",
|
||||
);
|
||||
const removed = removeGeneratedBindings(template, intent.replaceGeneratedIds, fragments);
|
||||
if (removed === null) throw new Error("The original binding changed. Edit this relationship in code.");
|
||||
template = removed;
|
||||
}
|
||||
const descriptor: GeneratedBindingDescriptor = { source: intent.source, target: "widget", relationship: "data" };
|
||||
if (intent.kind === "option") descriptor.relationship = "option";
|
||||
if (intent.kind === "action" || (!intent.native && bindingDescriptor(intent).trigger === "manual")) {
|
||||
descriptor.source = "widget";
|
||||
descriptor.target = intent.source;
|
||||
descriptor.relationship = "invoke";
|
||||
}
|
||||
const candidate = {
|
||||
...values,
|
||||
template: insertCustomWidgetTemplateContent(template, markGeneratedBinding(snippet, descriptor)),
|
||||
};
|
||||
const parsed = buildDefinition(candidate);
|
||||
if (!parsed.success) throw new Error(parsed.error.issues[0]?.message ?? "Invalid widget definition");
|
||||
return { patch: { template: candidate.template }, preview: snippet };
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
"use client";
|
||||
import { useMemo, useState, useSyncExternalStore } from "react";
|
||||
import {
|
||||
Accordion,
|
||||
Alert,
|
||||
Autocomplete,
|
||||
Button,
|
||||
Code,
|
||||
Group,
|
||||
Modal,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { collectTemplateReferences } from "@homarr/custom-widgets/workbench";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useCustomWidgetFormDocumentStore } from "../_custom-widget-form-state";
|
||||
import type { CustomWidgetWorkbenchForm } from "../_custom-widget-form-utils";
|
||||
import { isFlowBindingCurrent } from "./connection-planner";
|
||||
import type { FlowBindingIntent } from "./connection-planner";
|
||||
import { bindingCandidate, bindingDescriptor, bindingParameters, createBindingParameters } from "./binding-code";
|
||||
import type { BindingChoices } from "./binding-code";
|
||||
import { BindingParameterEditor } from "./binding-parameter-editor";
|
||||
import { BindingOptionEditor } from "./binding-option-editor";
|
||||
import { requestOptionCandidate } from "./binding-request-option";
|
||||
import { readObject } from "./graph";
|
||||
import { useWorkbenchQuery } from "./execution";
|
||||
import { nativeInputFields } from "./native-editor-model";
|
||||
import type { NativeInputSchema } from "./native-editor-model";
|
||||
|
||||
export function FlowBindingDialog(props: {
|
||||
intent: FlowBindingIntent;
|
||||
form: CustomWidgetWorkbenchForm;
|
||||
onClose(): void;
|
||||
}) {
|
||||
const t = useI18n("customWidget.flow");
|
||||
const catalog = clientApi.customWidget.nativeCapabilities.useQuery(undefined, { enabled: props.intent.native });
|
||||
const descriptor = bindingDescriptor(props.intent);
|
||||
const schema = catalog.data?.find((entry) => entry.id === descriptor.capability)?.inputSchema;
|
||||
let title = t("bindingTitle");
|
||||
if (props.intent.kind === "requestOption") title = t("bindingConfigureRequest");
|
||||
return (
|
||||
<Modal
|
||||
opened
|
||||
onClose={props.onClose}
|
||||
title={title}
|
||||
closeButtonProps={{ "aria-label": t("bindingCancel") }}
|
||||
size="lg"
|
||||
>
|
||||
{props.intent.native && !schema ? (
|
||||
<Alert color="yellow">
|
||||
{catalog.isError ? t("bindingCatalogError") : t("bindingCatalogLoading")}
|
||||
{catalog.isError && (
|
||||
<Button type="button" size="xs" onClick={() => void catalog.refetch()}>
|
||||
{t("bindingRetry")}
|
||||
</Button>
|
||||
)}
|
||||
</Alert>
|
||||
) : (
|
||||
<BindingEditor {...props} schema={schema ?? {}} />
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function BindingEditor({
|
||||
intent,
|
||||
form,
|
||||
onClose,
|
||||
schema,
|
||||
}: {
|
||||
intent: FlowBindingIntent;
|
||||
form: CustomWidgetWorkbenchForm;
|
||||
onClose(): void;
|
||||
schema: NativeInputSchema;
|
||||
}) {
|
||||
const t = useI18n("customWidget.flow");
|
||||
const store = useCustomWidgetFormDocumentStore();
|
||||
useSyncExternalStore(store.subscribe, store.getRevision, store.getRevision);
|
||||
const [choices, setChoices] = useState<BindingChoices>(() => ({
|
||||
format: "json",
|
||||
path: "",
|
||||
columns: "name",
|
||||
label: intent.identifier,
|
||||
option: Object.keys(readObject(intent.snapshot.options))[0] ?? "",
|
||||
field: nativeInputFields(schema)[0]?.name ?? "value",
|
||||
location: intent.native ? "input" : "query",
|
||||
params: createBindingParameters(intent, schema),
|
||||
}));
|
||||
const [applyError, setApplyError] = useState<string | null>(null);
|
||||
const sample = useWorkbenchQuery(intent.identifier);
|
||||
const paths = useMemo(() => samplePaths(sample?.data), [sample?.data]);
|
||||
const options = readObject(intent.snapshot.options);
|
||||
const inputs = [
|
||||
...new Set(
|
||||
collectTemplateReferences(intent.snapshot.template)
|
||||
.filter((entry) => entry.kind === "bind")
|
||||
.map((entry) => entry.name),
|
||||
),
|
||||
];
|
||||
const params = bindingParameters(intent, schema);
|
||||
const current = isFlowBindingCurrent(intent, store.getValues());
|
||||
const change = (patch: Partial<BindingChoices>) => {
|
||||
setChoices((value) => ({ ...value, ...patch }));
|
||||
setApplyError(null);
|
||||
};
|
||||
const prepare = () => {
|
||||
if (intent.kind === "requestOption") return requestOptionCandidate(intent, store.getValues(), choices, schema);
|
||||
return bindingCandidate(intent, store.getValues(), choices, {
|
||||
loading: t("bindingLoading"),
|
||||
empty: t("bindingEmpty"),
|
||||
run: t("bindingRun"),
|
||||
error: t("bindingError"),
|
||||
});
|
||||
};
|
||||
let candidate: ReturnType<typeof prepare> | undefined;
|
||||
let error: string | null = null;
|
||||
try {
|
||||
if (current) candidate = prepare();
|
||||
} catch (reason) {
|
||||
error = reason instanceof Error ? reason.message : t("bindingError");
|
||||
}
|
||||
return (
|
||||
<Stack gap="md" data-workbench-local-input>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("bindingDescription")}
|
||||
</Text>
|
||||
{(intent.kind === "option" || intent.kind === "requestOption") && (
|
||||
<BindingOptionEditor intent={intent} choices={choices} schema={schema} onChange={change} />
|
||||
)}
|
||||
{intent.kind === "query" && (
|
||||
<>
|
||||
<Select
|
||||
label={t("bindingDisplay")}
|
||||
value={choices.format}
|
||||
data={[
|
||||
{ value: "json", label: t("bindingJson") },
|
||||
{ value: "text", label: t("bindingText") },
|
||||
{ value: "list", label: t("bindingList") },
|
||||
{ value: "table", label: t("bindingTable") },
|
||||
]}
|
||||
onChange={(format) => format && change({ format: format as BindingChoices["format"] })}
|
||||
/>
|
||||
<Autocomplete
|
||||
label={t("bindingDataPath")}
|
||||
description={t("bindingDataPathHint")}
|
||||
data={paths}
|
||||
value={choices.path}
|
||||
onChange={(path) => change({ path })}
|
||||
/>
|
||||
{choices.format === "table" && (
|
||||
<TextInput
|
||||
label={t("bindingColumns")}
|
||||
description={t("bindingColumnsHint")}
|
||||
value={choices.columns}
|
||||
onChange={(event) => change({ columns: event.currentTarget.value })}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{intent.kind === "action" && (
|
||||
<TextInput
|
||||
label={t("bindingButtonLabel")}
|
||||
value={choices.label}
|
||||
onChange={(event) => change({ label: event.currentTarget.value })}
|
||||
/>
|
||||
)}
|
||||
{(intent.kind === "query" || intent.kind === "action") && params.length > 0 && (
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("bindingParameterHint")}
|
||||
</Text>
|
||||
{params.map(
|
||||
({ name, schema: fieldSchema }) =>
|
||||
choices.params[name] && (
|
||||
<BindingParameterEditor
|
||||
key={name}
|
||||
name={name}
|
||||
schema={fieldSchema}
|
||||
parameter={choices.params[name]}
|
||||
options={options}
|
||||
inputs={inputs}
|
||||
onChange={(value) => change({ params: { ...choices.params, [name]: value } })}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
{!current && (
|
||||
<Alert color="yellow" aria-live="polite">
|
||||
{t("bindingStale")}
|
||||
</Alert>
|
||||
)}
|
||||
{(error || applyError) && (
|
||||
<Alert color="yellow" aria-live="polite">
|
||||
{applyError ?? error}
|
||||
</Alert>
|
||||
)}
|
||||
{candidate && (
|
||||
<Accordion variant="contained">
|
||||
<Accordion.Item value="code">
|
||||
<Accordion.Control>{t("bindingReview")}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Code block mah={240} style={{ overflow: "auto", whiteSpace: "pre-wrap" }}>
|
||||
{candidate.preview}
|
||||
</Code>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
)}
|
||||
<Group justify="end">
|
||||
<Button type="button" variant="default" onClick={onClose}>
|
||||
{t("bindingCancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!current || !candidate}
|
||||
onClick={() => {
|
||||
try {
|
||||
if (!isFlowBindingCurrent(intent, store.getValues())) throw new Error(t("bindingStale"));
|
||||
const result = prepare();
|
||||
store.transaction(() => form.setValues({ ...store.getValues(), ...result.patch }));
|
||||
onClose();
|
||||
} catch (reason) {
|
||||
setApplyError(reason instanceof Error ? reason.message : t("bindingError"));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("bindingApply")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function samplePaths(value: unknown) {
|
||||
const paths: string[] = [];
|
||||
const visit = (entry: unknown, path: string, depth: number) => {
|
||||
if (paths.length >= 100 || depth > 5 || !entry || typeof entry !== "object") return;
|
||||
for (const [key, child] of Object.entries(entry).slice(0, 25)) {
|
||||
if (key.includes(".")) continue;
|
||||
let next = key;
|
||||
if (path) next = `${path}.${key}`;
|
||||
paths.push(next);
|
||||
if (!Array.isArray(entry)) visit(child, next, depth + 1);
|
||||
}
|
||||
};
|
||||
visit(value, "", 0);
|
||||
return paths.slice(0, 100);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
import { Select, Stack, TextInput } from "@mantine/core";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { FlowBindingIntent } from "./connection-planner";
|
||||
import { bindingDescriptor } from "./binding-code";
|
||||
import type { BindingChoices } from "./binding-code";
|
||||
import { readObject } from "./graph";
|
||||
import { isCompatibleNativeOption, nativeInputFields } from "./native-editor-model";
|
||||
import type { NativeInputSchema } from "./native-editor-model";
|
||||
|
||||
export function BindingOptionEditor({
|
||||
intent,
|
||||
choices,
|
||||
schema,
|
||||
onChange,
|
||||
}: {
|
||||
intent: FlowBindingIntent;
|
||||
choices: BindingChoices;
|
||||
schema: NativeInputSchema;
|
||||
onChange(value: Partial<BindingChoices>): void;
|
||||
}) {
|
||||
const t = useI18n("customWidget.flow");
|
||||
const fields = nativeInputFields(schema);
|
||||
const fieldSchema = fields.find((field) => field.name === choices.field)?.schema ?? {};
|
||||
let options = Object.entries(readObject(intent.snapshot.options));
|
||||
if (intent.native) options = options.filter(([, option]) => isCompatibleNativeOption(option, fieldSchema));
|
||||
const descriptor = bindingDescriptor(intent);
|
||||
const pathFields = [...String(descriptor.path ?? "").matchAll(/\{param:([A-Za-z][A-Za-z0-9_-]*)\}/gu)].map(
|
||||
(match) => match[1] ?? "",
|
||||
);
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Select
|
||||
label={t("bindingOption")}
|
||||
searchable
|
||||
value={choices.option}
|
||||
data={options.map(([name]) => name)}
|
||||
onChange={(value) => value && onChange({ option: value })}
|
||||
/>
|
||||
{intent.kind === "requestOption" && (
|
||||
<>
|
||||
{!intent.native && (
|
||||
<Select
|
||||
label={t("bindingRequestLocation")}
|
||||
value={choices.location}
|
||||
data={[
|
||||
{ value: "query", label: t("bindingQueryParameter") },
|
||||
{ value: "body", label: t("bindingBodyField") },
|
||||
{ value: "path", label: t("bindingPathParameter"), disabled: pathFields.length === 0 },
|
||||
]}
|
||||
onChange={(value) => {
|
||||
if (!value) return;
|
||||
let field = choices.field;
|
||||
if (value === "path") field = pathFields[0] ?? "";
|
||||
onChange({ location: value as BindingChoices["location"], field });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{intent.native && (
|
||||
<Select
|
||||
label={t("bindingRequestField")}
|
||||
searchable
|
||||
value={choices.field}
|
||||
data={fields.map(({ name }) => name)}
|
||||
onChange={(value) => value && onChange({ field: value })}
|
||||
/>
|
||||
)}
|
||||
{!intent.native && choices.location === "path" && (
|
||||
<Select
|
||||
label={t("bindingRequestField")}
|
||||
value={choices.field}
|
||||
data={[...new Set(pathFields)]}
|
||||
onChange={(value) => value && onChange({ field: value })}
|
||||
/>
|
||||
)}
|
||||
{!intent.native && choices.location !== "path" && (
|
||||
<TextInput
|
||||
label={t("bindingRequestField")}
|
||||
value={choices.field}
|
||||
onChange={(event) => onChange({ field: event.currentTarget.value })}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
import { Group, Select, Stack, Text } from "@mantine/core";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { BindingParameter } from "./binding-code";
|
||||
import type { NativeInputSchema } from "./native-editor-model";
|
||||
import { isCompatibleNativeOption } from "./native-editor-model";
|
||||
import { NativeLiteralInput } from "./native-literal-input";
|
||||
|
||||
export function BindingParameterEditor({
|
||||
name,
|
||||
parameter,
|
||||
schema,
|
||||
options,
|
||||
inputs,
|
||||
onChange,
|
||||
}: {
|
||||
name: string;
|
||||
parameter: BindingParameter;
|
||||
schema: NativeInputSchema;
|
||||
options: Record<string, unknown>;
|
||||
inputs: string[];
|
||||
onChange(value: BindingParameter): void;
|
||||
}) {
|
||||
const t = useI18n("customWidget.flow");
|
||||
const optionNames = Object.keys(options).filter((key) => isCompatibleNativeOption(options[key], schema));
|
||||
let fieldSchema = schema;
|
||||
if (!schema.type) fieldSchema = { ...schema, type: typeof parameter.value };
|
||||
return (
|
||||
<Stack gap={6}>
|
||||
<Text size="sm" fw={500}>
|
||||
{name}
|
||||
</Text>
|
||||
<Group grow align="start">
|
||||
<Select
|
||||
label={t("bindingParameterSource")}
|
||||
value={parameter.mode}
|
||||
data={[
|
||||
{ value: "control", label: t("bindingNewControl") },
|
||||
{ value: "literal", label: t("nativeLiteral") },
|
||||
{ value: "option", label: t("nativeOptionBinding"), disabled: optionNames.length === 0 },
|
||||
{ value: "input", label: t("bindingExistingInput"), disabled: inputs.length === 0 },
|
||||
]}
|
||||
onChange={(mode) => {
|
||||
if (!mode) return;
|
||||
let selected = parameter.name;
|
||||
if (mode === "option") selected = optionNames[0] ?? "";
|
||||
if (mode === "input") selected = inputs[0] ?? "";
|
||||
if (mode === "control") selected = parameter.controlName;
|
||||
onChange({ ...parameter, mode: mode as BindingParameter["mode"], name: selected });
|
||||
}}
|
||||
/>
|
||||
{(parameter.mode === "option" || parameter.mode === "input") && (
|
||||
<Select
|
||||
label={t("bindingValue")}
|
||||
value={parameter.name}
|
||||
searchable
|
||||
data={parameter.mode === "option" ? optionNames : inputs}
|
||||
onChange={(value) => value && onChange({ ...parameter, name: value })}
|
||||
/>
|
||||
)}
|
||||
{(parameter.mode === "literal" || parameter.mode === "control") && (
|
||||
<NativeLiteralInput
|
||||
label={t("bindingValue")}
|
||||
value={parameter.value}
|
||||
schema={fieldSchema}
|
||||
required
|
||||
disabled={false}
|
||||
onChange={(value) => onChange({ ...parameter, value })}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
{!schema.type && (parameter.mode === "literal" || parameter.mode === "control") && (
|
||||
<Select
|
||||
label={t("bindingParameterType")}
|
||||
value={typeof parameter.value}
|
||||
data={[
|
||||
{ value: "string", label: t("bindingText") },
|
||||
{ value: "number", label: t("bindingNumber") },
|
||||
{ value: "boolean", label: t("bindingBoolean") },
|
||||
]}
|
||||
onChange={(type) => {
|
||||
let value: unknown = "";
|
||||
if (type === "number") value = 0;
|
||||
if (type === "boolean") value = false;
|
||||
onChange({ ...parameter, value });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
|
||||
import { collectTemplateReferences } from "@homarr/custom-widgets/workbench";
|
||||
import { collectCustomWidgetRequestReferences } from "@homarr/custom-widgets/core";
|
||||
import { buildDefinition, isRecord } from "../_custom-widget-form-utils";
|
||||
import { readObject, record } from "./graph";
|
||||
import type { FlowBindingIntent } from "./connection-planner";
|
||||
import type { BindingChoices } from "./binding-code";
|
||||
import { isCompatibleNativeOption, nativeInputFields } from "./native-editor-model";
|
||||
import type { NativeInputSchema } from "./native-editor-model";
|
||||
|
||||
const parameters = (value: Record<string, unknown>) =>
|
||||
collectCustomWidgetRequestReferences({
|
||||
path: String(value.path ?? ""),
|
||||
query: record(value.query),
|
||||
body: value.body ?? value.input,
|
||||
}).params;
|
||||
|
||||
export function requestOptionCandidate(
|
||||
intent: FlowBindingIntent,
|
||||
values: CustomWidgetFormValues,
|
||||
choices: BindingChoices,
|
||||
schema: NativeInputSchema = {},
|
||||
) {
|
||||
if (!Object.hasOwn(readObject(values.options), choices.option)) throw new Error("Choose an existing option");
|
||||
const key = choices.field.trim();
|
||||
if (intent.native) {
|
||||
const fields = nativeInputFields(schema);
|
||||
const target = fields.find((field) => field.name === key);
|
||||
if (
|
||||
fields.length &&
|
||||
(!target || !isCompatibleNativeOption(readObject(values.options)[choices.option], target.schema))
|
||||
)
|
||||
throw new Error("Choose an option compatible with this capability field");
|
||||
}
|
||||
if (!key || key.length > 256 || ["__proto__", "constructor", "prototype"].includes(key))
|
||||
throw new Error("Choose a valid request field");
|
||||
const reference = { $option: choices.option };
|
||||
if (intent.native && choices.location !== "input") throw new Error("Choose a native capability input");
|
||||
if (!intent.native && choices.location === "input") throw new Error("Choose an HTTP request field");
|
||||
const field = intent.native ? "extensions" : "requests";
|
||||
const document = readObject(values[field]);
|
||||
let entries = document;
|
||||
if (intent.native) entries = record(document.native);
|
||||
const descriptor = record(entries[intent.identifier]);
|
||||
const updated = { ...descriptor };
|
||||
if (choices.location === "path") {
|
||||
const placeholder = `{param:${key}}`;
|
||||
const path = String(descriptor.path ?? "");
|
||||
if (!path.includes(placeholder)) throw new Error("Choose an existing path parameter");
|
||||
updated.path = path.replaceAll(placeholder, `{option:${choices.option}}`);
|
||||
} else {
|
||||
const current = descriptor[choices.location];
|
||||
if (current !== undefined && (!isRecord(current) || "$option" in current || "$param" in current))
|
||||
throw new Error("This field is code-owned. Edit its existing binding before adding a named field.");
|
||||
updated[choices.location] = { ...record(current), [key]: reference };
|
||||
}
|
||||
const nextEntries = { ...entries, [intent.identifier]: updated };
|
||||
const remaining = parameters(updated);
|
||||
if ([...parameters(descriptor)].some((name) => !remaining.has(name))) {
|
||||
const templates = [
|
||||
values.template,
|
||||
...Object.values(record(readObject(values.extensions).fragments)).filter(
|
||||
(value): value is string => typeof value === "string",
|
||||
),
|
||||
];
|
||||
if (
|
||||
templates.some((template) =>
|
||||
collectTemplateReferences(template).some(
|
||||
(entry) =>
|
||||
["request", "native"].includes(entry.kind) && (entry.name === intent.identifier || entry.name === "*"),
|
||||
),
|
||||
)
|
||||
)
|
||||
throw new Error(
|
||||
"This change removes an invocation parameter used by existing JSX. Update that control in code first.",
|
||||
);
|
||||
}
|
||||
let nextDocument = nextEntries;
|
||||
if (intent.native) nextDocument = { ...document, native: nextEntries };
|
||||
const patch = { [field]: JSON.stringify(nextDocument, null, 2) };
|
||||
const parsed = buildDefinition({ ...values, ...patch });
|
||||
if (!parsed.success) throw new Error(parsed.error.issues[0]?.message ?? "Invalid widget definition");
|
||||
return { patch, preview: JSON.stringify(updated, null, 2) };
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
import { Controls } from "@xyflow/react";
|
||||
import { Button, Group, Menu } from "@mantine/core";
|
||||
import { IconArrowBackUp, IconChevronDown, IconFocus2, IconLayoutDistributeHorizontal } from "@tabler/icons-react";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import classes from "./workbench.module.css";
|
||||
export function CanvasControls({
|
||||
arranging,
|
||||
selection,
|
||||
branch,
|
||||
arrange,
|
||||
focus,
|
||||
fit,
|
||||
}: {
|
||||
arranging: boolean;
|
||||
selection: string[];
|
||||
branch: boolean;
|
||||
arrange(selectionOnly?: boolean): void;
|
||||
focus(related: boolean): void;
|
||||
fit(): void;
|
||||
}) {
|
||||
const t = useI18n("customWidget.flow");
|
||||
return (
|
||||
<Group className={classes.canvasTools} gap={4} p="xs">
|
||||
<Menu position="bottom-start" withinPortal>
|
||||
<Menu.Target>
|
||||
<Button size="compact-sm" variant="default" rightSection={<IconChevronDown size={14} />} loading={arranging}>
|
||||
{t("canvasOptions")}
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconLayoutDistributeHorizontal size={16} />}
|
||||
disabled={arranging}
|
||||
onClick={() => arrange()}
|
||||
>
|
||||
{t("arrange")}
|
||||
</Menu.Item>
|
||||
<Menu.Item disabled={!selection.length || arranging} onClick={() => arrange(true)}>
|
||||
{t("arrangeSelection")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item leftSection={<IconFocus2 size={16} />} onClick={fit}>
|
||||
{t("fit")}
|
||||
</Menu.Item>
|
||||
{selection.length > 0 && <Menu.Item onClick={() => focus(false)}>{t("focus")}</Menu.Item>}
|
||||
{selection.length > 0 && <Menu.Item onClick={() => focus(true)}>{t("focusBranch")}</Menu.Item>}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
{branch && (
|
||||
<Button size="compact-sm" variant="default" leftSection={<IconArrowBackUp size={14} />} onClick={fit}>
|
||||
{t("allNodes")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
export function CanvasViewportControls() {
|
||||
const t = useI18n("customWidget.flow");
|
||||
return (
|
||||
<fieldset aria-label={t("canvasControls")} style={{ border: 0, margin: 0, padding: 0, minInlineSize: 0 }}>
|
||||
<Controls showInteractive={false} aria-label="" />
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useEffect } from "react";
|
||||
import { useReducedMotion } from "@mantine/hooks";
|
||||
import { useReactFlow } from "@xyflow/react";
|
||||
|
||||
/** Keep the rendered widget beside its JSX editor after the canvas gives the editor room. */
|
||||
export function useCanvasEditorFocus(opened: boolean, selected: string) {
|
||||
const { fitView } = useReactFlow();
|
||||
const reducedMotion = useReducedMotion();
|
||||
useEffect(() => {
|
||||
if (!opened || selected !== "widget") return;
|
||||
let frame = requestAnimationFrame(() => {
|
||||
frame = requestAnimationFrame(() => {
|
||||
void fitView({ nodes: [{ id: "widget" }], maxZoom: 1, padding: 0.2, duration: reducedMotion ? 0 : 180 });
|
||||
});
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [fitView, opened, selected, reducedMotion]);
|
||||
}
|
||||
|
||||
export function isPreviewInteraction(target: EventTarget) {
|
||||
return target instanceof Element && !!target.closest("[data-flow-preview], [data-mantine-portal]");
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { Dispatch, KeyboardEvent, SetStateAction } from "react";
|
||||
import { applyNodeChanges } from "@xyflow/react";
|
||||
import type { AriaLabelConfig, EdgeChange, NodeChange, NodeMouseHandler, EdgeMouseHandler } from "@xyflow/react";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { CustomWidgetFormDocumentStore } from "../_custom-widget-form-state";
|
||||
import type { createFlowCommands } from "./commands";
|
||||
import type { WidgetEdge, WidgetNode } from "./graph";
|
||||
import { captureLayout } from "./layout";
|
||||
import { isPreviewInteraction } from "./canvas-editor-focus";
|
||||
|
||||
interface Props {
|
||||
edges: WidgetEdge[];
|
||||
selection: string[];
|
||||
store: CustomWidgetFormDocumentStore;
|
||||
commands: ReturnType<typeof createFlowCommands>;
|
||||
getNodes(): WidgetNode[];
|
||||
setNodes: Dispatch<SetStateAction<WidgetNode[]>>;
|
||||
onSelect(id: string): void;
|
||||
onInspect(id: string): void;
|
||||
onSelection(ids: string[]): void;
|
||||
onIssue(issue: string | null): void;
|
||||
}
|
||||
|
||||
/** Owns canvas selection and keyboard commands without putting transient selection into document history. */
|
||||
export function useCanvasInteractions({
|
||||
edges,
|
||||
selection,
|
||||
store,
|
||||
commands,
|
||||
getNodes,
|
||||
setNodes,
|
||||
onSelect,
|
||||
onInspect,
|
||||
onSelection,
|
||||
onIssue,
|
||||
}: Props) {
|
||||
const t = useI18n("customWidget.flow");
|
||||
const ariaLabelConfig = useMemo<Partial<AriaLabelConfig>>(
|
||||
() => ({
|
||||
"controls.zoomIn.ariaLabel": t("zoomIn"),
|
||||
"controls.zoomOut.ariaLabel": t("zoomOut"),
|
||||
"controls.fitView.ariaLabel": t("fit"),
|
||||
"controls.ariaLabel": t("canvasControls"),
|
||||
"handle.ariaLabel": t("connect"),
|
||||
"node.a11yDescription.default": t("nodeKeyboardHelp"),
|
||||
"node.a11yDescription.keyboardDisabled": t("nodePointerHelp"),
|
||||
"node.a11yDescription.ariaLiveMessage": ({ x, y }) => t("nodeMoved", { x, y }),
|
||||
"edge.a11yDescription.default": t("edgeKeyboardHelp"),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
const [selectedEdge, setSelectedEdge] = useState<string | null>(null);
|
||||
const selectionRef = useRef(selection);
|
||||
selectionRef.current = selection;
|
||||
useEffect(() => {
|
||||
setNodes((current) =>
|
||||
current.map((node) => {
|
||||
const next = selection.includes(node.id);
|
||||
if (node.selected === next) return node;
|
||||
return { ...node, selected: next };
|
||||
}),
|
||||
);
|
||||
}, [selection, setNodes]);
|
||||
const onNodesChange = useCallback(
|
||||
(changes: NodeChange<WidgetNode>[]) => {
|
||||
setNodes((current) =>
|
||||
applyNodeChanges(
|
||||
changes.filter((change) => change.type !== "remove"),
|
||||
current,
|
||||
),
|
||||
);
|
||||
const selectionChanges = changes.filter((change) => change.type === "select");
|
||||
if (selectionChanges.length === 0) return;
|
||||
const ids = new Set(selectionRef.current);
|
||||
for (const change of selectionChanges) {
|
||||
if (change.selected) ids.add(change.id);
|
||||
else ids.delete(change.id);
|
||||
}
|
||||
onSelection([...ids]);
|
||||
},
|
||||
[onSelection, setNodes],
|
||||
);
|
||||
const onEdgesChange = useCallback((changes: EdgeChange<WidgetEdge>[]) => {
|
||||
setSelectedEdge((current) => {
|
||||
let next = current;
|
||||
for (const change of changes) {
|
||||
if (change.type !== "select") continue;
|
||||
if (change.selected) next = change.id;
|
||||
else if (next === change.id) next = null;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const inspectEdge = useCallback(
|
||||
(edge: WidgetEdge) => {
|
||||
let id = edge.target;
|
||||
if (edge.data?.relationship === "invalidates") id = edge.source;
|
||||
setSelectedEdge(edge.id);
|
||||
onSelection([]);
|
||||
onInspect(id);
|
||||
},
|
||||
[onInspect, onSelection],
|
||||
);
|
||||
const onNodeClick: NodeMouseHandler<WidgetNode> = useCallback(
|
||||
(event, node) => {
|
||||
if (isPreviewInteraction(event.target)) return;
|
||||
setSelectedEdge(null);
|
||||
onInspect(node.id);
|
||||
},
|
||||
[onInspect],
|
||||
);
|
||||
const onEdgeClick: EdgeMouseHandler<WidgetEdge> = useCallback((_event, edge) => inspectEdge(edge), [inspectEdge]);
|
||||
const onKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (!["Delete", "Backspace"].includes(event.key) || !(event.target instanceof Element)) return;
|
||||
const edgeId = event.target.closest(".react-flow__edge")?.getAttribute("data-id");
|
||||
const edge = edges.find((entry) => entry.id === edgeId);
|
||||
if (!edge) return;
|
||||
// A focused edge owns Delete; never forward it to the workbench's previous node selection.
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onSelection([]);
|
||||
const result = commands.disconnect(edge);
|
||||
onIssue(result);
|
||||
if (!result) {
|
||||
setSelectedEdge(null);
|
||||
// The removed SVG cannot retain focus. Keep Undo and subsequent keyboard commands inside the workbench.
|
||||
event.currentTarget
|
||||
.querySelector<HTMLElement>(`.react-flow__node[data-id="${CSS.escape(edge.source)}"]`)
|
||||
?.focus({ preventScroll: true });
|
||||
}
|
||||
},
|
||||
[commands, edges, onIssue, onSelection],
|
||||
);
|
||||
const onKeyUp = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (isPreviewInteraction(event.target)) return;
|
||||
if (!(event.target instanceof Element)) return;
|
||||
if (event.target.closest('input, textarea, button, [contenteditable="true"], [role="combobox"]')) return;
|
||||
const nodeId = event.target.closest(".react-flow__node")?.getAttribute("data-id");
|
||||
if (nodeId && event.key === "F2") {
|
||||
event.preventDefault();
|
||||
onSelect(nodeId);
|
||||
return;
|
||||
}
|
||||
if (["Enter", " "].includes(event.key)) {
|
||||
if (nodeId) {
|
||||
setSelectedEdge(null);
|
||||
onInspect(nodeId);
|
||||
}
|
||||
const edgeId = event.target.closest(".react-flow__edge")?.getAttribute("data-id");
|
||||
const edge = edges.find((entry) => entry.id === edgeId);
|
||||
if (edge) inspectEdge(edge);
|
||||
return;
|
||||
}
|
||||
if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.key)) return;
|
||||
if (!event.target.closest(".react-flow__node, .react-flow__nodesselection")) return;
|
||||
requestAnimationFrame(() => store.setLayout(captureLayout(getNodes(), store.getLayout())));
|
||||
},
|
||||
[edges, getNodes, inspectEdge, onInspect, onSelect, store],
|
||||
);
|
||||
return { selectedEdge, ariaLabelConfig, onNodesChange, onEdgesChange, onNodeClick, onEdgeClick, onKeyDown, onKeyUp };
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
import { createContext, memo, useContext, useMemo } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Button, Group, Text } from "@mantine/core";
|
||||
import { Position } from "@xyflow/react";
|
||||
import type { NodeProps } from "@xyflow/react";
|
||||
import { IconCode } from "@tabler/icons-react";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { WidgetNode } from "./graph";
|
||||
import { FlowNode, FlowPort, useValidConnectionTarget } from "./node";
|
||||
import { FlowGroup } from "./group";
|
||||
import { WorkbenchPreviewTheme } from "./preview-theme";
|
||||
import { WorkbenchPreviewPresentationProvider } from "./preview-presentation";
|
||||
import classes from "./workbench.module.css";
|
||||
|
||||
const CanvasPreviewContext = createContext<{ preview: ReactNode; edit(id: string): void } | null>(null);
|
||||
export function CanvasPreviewProvider({
|
||||
preview,
|
||||
edit,
|
||||
children,
|
||||
}: {
|
||||
preview: ReactNode;
|
||||
edit(id: string): void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const value = useMemo(() => ({ preview, edit }), [preview, edit]);
|
||||
return <CanvasPreviewContext.Provider value={value}>{children}</CanvasPreviewContext.Provider>;
|
||||
}
|
||||
|
||||
export const CanvasWidgetNode = memo(function CanvasWidgetNode({
|
||||
id,
|
||||
data,
|
||||
selected,
|
||||
isConnectable,
|
||||
}: NodeProps<WidgetNode>) {
|
||||
const context = useContext(CanvasPreviewContext);
|
||||
const t = useI18n("customWidget.flow");
|
||||
const validTarget = useValidConnectionTarget(id, isConnectable);
|
||||
return (
|
||||
<div className={classes.canvasWidgetNode} data-selected={selected} data-valid-target={validTarget}>
|
||||
<FlowPort
|
||||
id="in"
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
connectable={isConnectable}
|
||||
label={`${t("input")} · ${data.label}`}
|
||||
title={t("input")}
|
||||
/>
|
||||
<Group className="widget-node-drag" justify="space-between" wrap="nowrap" p="sm">
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{data.label}
|
||||
</Text>
|
||||
<Button
|
||||
className="nodrag nopan"
|
||||
type="button"
|
||||
variant="default"
|
||||
size="compact-xs"
|
||||
leftSection={<IconCode size={14} />}
|
||||
onClick={() => context?.edit(id)}
|
||||
>
|
||||
{t("editJsx")}
|
||||
</Button>
|
||||
</Group>
|
||||
<div className="nodrag nopan nowheel" data-flow-preview>
|
||||
<WorkbenchPreviewTheme controls={false}>
|
||||
<WorkbenchPreviewPresentationProvider mode="canvas">{context?.preview}</WorkbenchPreviewPresentationProvider>
|
||||
</WorkbenchPreviewTheme>
|
||||
</div>
|
||||
<FlowPort
|
||||
id="out"
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
connectable={isConnectable}
|
||||
label={`${t("output")} · ${data.label}`}
|
||||
title={t("output")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
export const canvasNodeTypes = { widget: FlowNode, preview: CanvasWidgetNode, group: FlowGroup };
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ReactFlowInstance } from "@xyflow/react";
|
||||
import type { CustomWidgetWorkbenchForm } from "../_custom-widget-form-utils";
|
||||
import type { WidgetEdge, WidgetNode } from "./graph";
|
||||
export const defaultEdgeOptions = { type: "smoothstep", selectable: true };
|
||||
export const initialFitOptions = { maxZoom: 1 };
|
||||
|
||||
export interface CanvasProps {
|
||||
form: CustomWidgetWorkbenchForm;
|
||||
selected: string;
|
||||
selection: string[];
|
||||
inspectorOpened: boolean;
|
||||
onSelect(id: string): void;
|
||||
onInspect(id: string): void;
|
||||
onSelection(ids: string[]): void;
|
||||
onInit(instance: ReactFlowInstance<WidgetNode, WidgetEdge>): void;
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
"use client";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Alert } from "@mantine/core";
|
||||
import { useReducedMotion } from "@mantine/hooks";
|
||||
import {
|
||||
Background,
|
||||
MarkerType,
|
||||
ReactFlow,
|
||||
ReactFlowProvider,
|
||||
SelectionMode,
|
||||
useReactFlow,
|
||||
useNodesInitialized,
|
||||
} from "@xyflow/react";
|
||||
import type { Connection, OnConnectEnd } from "@xyflow/react";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import {
|
||||
useDeferredCustomWidgetFormDocumentValues,
|
||||
useCustomWidgetFormDocumentStore,
|
||||
} from "../_custom-widget-form-state";
|
||||
import { defaultEdgeOptions, initialFitOptions } from "./canvas-types";
|
||||
import type { CanvasProps as Props } from "./canvas-types";
|
||||
import { isPreviewInteraction, useCanvasEditorFocus } from "./canvas-editor-focus";
|
||||
import { createFlowCommands, connectionIssue, isGraphEditable } from "./commands";
|
||||
import { arrangeWidgetGraph, projectWidgetGraph } from "./graph";
|
||||
import type { WidgetNode, WidgetEdge } from "./graph";
|
||||
import { captureLayout, projectLayout, expandGroupSelection } from "./layout";
|
||||
import { canvasNodeTypes } from "./canvas-preview";
|
||||
import { useFlowConnection } from "./connection-provider";
|
||||
import { CanvasControls, CanvasViewportControls } from "./canvas-controls";
|
||||
import { useCanvasInteractions } from "./canvas-interactions";
|
||||
import { AddNextStep } from "./add-next-step";
|
||||
import type { PendingConnection } from "./add-next-step";
|
||||
// oxlint-disable-next-line import/no-unassigned-import -- Canvas-only React Flow styles.
|
||||
import "@xyflow/react/dist/style.css";
|
||||
const Canvas = memo(function Canvas(props: Props) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<CanvasContent {...props} />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
});
|
||||
export default Canvas;
|
||||
function CanvasContent({
|
||||
form,
|
||||
selected,
|
||||
selection,
|
||||
onSelect,
|
||||
onInspect,
|
||||
onSelection,
|
||||
onInit,
|
||||
inspectorOpened,
|
||||
}: Props) {
|
||||
useCanvasEditorFocus(inspectorOpened, selected);
|
||||
const t = useI18n("customWidget.flow");
|
||||
const actions = useI18n("common.action");
|
||||
const store = useCustomWidgetFormDocumentStore();
|
||||
const values = useDeferredCustomWidgetFormDocumentValues();
|
||||
const graph = useMemo(() => projectWidgetGraph(values, store.getLayout()), [values, store]);
|
||||
const graphEditable = useMemo(() => isGraphEditable(values), [values]);
|
||||
const [nodes, setNodes] = useState<WidgetNode[]>(graph.nodes);
|
||||
const [issue, setIssue] = useState<string | null>(null);
|
||||
const [arranging, setArranging] = useState(false);
|
||||
const [branch, setBranch] = useState<string[] | null>(null);
|
||||
const [pending, setPending] = useState<PendingConnection | null>(null);
|
||||
const initialized = useRef(false);
|
||||
const nodesInitialized = useNodesInitialized();
|
||||
const generation = useRef(0);
|
||||
const layoutJob = useRef(0);
|
||||
const reconnecting = useRef(false);
|
||||
const selectionRef = useRef(selection);
|
||||
selectionRef.current = selection;
|
||||
const { fitView, getNodes, screenToFlowPosition } = useReactFlow<WidgetNode, WidgetEdge>();
|
||||
const reducedMotion = useReducedMotion();
|
||||
const commands = useMemo(() => createFlowCommands(form, store), [form, store]);
|
||||
const connect = useFlowConnection();
|
||||
const { selectedEdge, ...interactions } = useCanvasInteractions({
|
||||
edges: graph.edges,
|
||||
selection,
|
||||
store,
|
||||
commands,
|
||||
getNodes,
|
||||
setNodes,
|
||||
onSelect,
|
||||
onInspect,
|
||||
onSelection,
|
||||
onIssue: setIssue,
|
||||
});
|
||||
useEffect(() => {
|
||||
generation.current += 1;
|
||||
setNodes((current) => {
|
||||
const measured = new Map(current.map((node) => [node.id, node]));
|
||||
return graph.nodes.map((node) => ({
|
||||
...measured.get(node.id),
|
||||
...node,
|
||||
selected: selectionRef.current.includes(node.id),
|
||||
}));
|
||||
});
|
||||
}, [graph]);
|
||||
useEffect(
|
||||
() =>
|
||||
store.subscribeEditor(() => {
|
||||
generation.current += 1;
|
||||
setNodes((current) => projectLayout(current, store.getLayout()));
|
||||
}),
|
||||
[store],
|
||||
);
|
||||
const arrange = useCallback(
|
||||
async (selectionOnly = false, initial = false) => {
|
||||
const token = ++generation.current;
|
||||
const job = ++layoutJob.current;
|
||||
setArranging(true);
|
||||
const revision = store.getRevision();
|
||||
try {
|
||||
let targets = getNodes();
|
||||
if (selectionOnly) targets = expandGroupSelection(targets, selectionRef.current);
|
||||
if (targets.length === 0) return;
|
||||
const arranged = await arrangeWidgetGraph(targets, graph.edges, selectionOnly);
|
||||
if (token !== generation.current || revision !== store.getRevision()) return;
|
||||
const current = store.getLayout();
|
||||
const next = {
|
||||
...current,
|
||||
nodes: { ...current.nodes, ...arranged.nodes },
|
||||
groups: { ...current.groups, ...arranged.groups },
|
||||
};
|
||||
if (initial) {
|
||||
store.initializeLayout(next);
|
||||
requestAnimationFrame(() =>
|
||||
requestAnimationFrame(() => {
|
||||
void fitView({ duration: 0, maxZoom: 1 });
|
||||
}),
|
||||
);
|
||||
} else store.setLayout(next);
|
||||
setIssue(null);
|
||||
} catch {
|
||||
if (token === generation.current) {
|
||||
setIssue("layoutFailed");
|
||||
}
|
||||
} finally {
|
||||
if (job === layoutJob.current) setArranging(false);
|
||||
}
|
||||
},
|
||||
[getNodes, graph.edges, store, fitView],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (initialized.current || !nodesInitialized) return;
|
||||
initialized.current = true;
|
||||
if (!Object.keys(store.getLayout().nodes).length) void arrange(false, true);
|
||||
}, [arrange, store, nodesInitialized]);
|
||||
const onConnect = useCallback(
|
||||
(connection: Connection) => setIssue(connect(connection, getNodes())),
|
||||
[connect, getNodes],
|
||||
);
|
||||
const isValidConnection = useCallback(
|
||||
(connection: Connection | WidgetEdge) => graphEditable && !connectionIssue(connection, getNodes()),
|
||||
[graphEditable, getNodes],
|
||||
);
|
||||
const endConnection: OnConnectEnd = (event, state) => {
|
||||
if (state.isValid || reconnecting.current || !state.fromNode) return;
|
||||
if (state.toNode) {
|
||||
setIssue("codeOwned");
|
||||
return;
|
||||
}
|
||||
const pointer = "changedTouches" in event ? event.changedTouches[0] : event;
|
||||
if (!pointer) return;
|
||||
const target = document.elementFromPoint(pointer.clientX, pointer.clientY);
|
||||
if (!target?.closest(".react-flow__pane")) return;
|
||||
setPending({
|
||||
nodeId: state.fromNode.id,
|
||||
handleType: state.fromHandle?.type ?? "source",
|
||||
position: screenToFlowPosition({ x: pointer.clientX, y: pointer.clientY }),
|
||||
});
|
||||
};
|
||||
const focus = (related: boolean) => {
|
||||
const ids = new Set(selectionRef.current);
|
||||
if (related)
|
||||
for (const edge of graph.edges) {
|
||||
if (selectionRef.current.includes(edge.source)) ids.add(edge.target);
|
||||
if (selectionRef.current.includes(edge.target)) ids.add(edge.source);
|
||||
}
|
||||
for (const node of getNodes()) if (ids.has(node.id) && node.parentId) ids.add(node.parentId);
|
||||
if (related) setBranch([...ids]);
|
||||
void fitView({ nodes: [...ids].map((id) => ({ id })), maxZoom: 1.1, duration: reducedMotion ? 0 : 200 });
|
||||
};
|
||||
const displayedNodes = useMemo(
|
||||
() =>
|
||||
nodes.map((node) => ({
|
||||
...node,
|
||||
dragHandle: ".widget-node-drag",
|
||||
ariaLabel: `${t(`kind.${node.data.kind}`)}: ${node.data.label}`,
|
||||
hidden: !!branch && !branch.includes(node.id),
|
||||
})),
|
||||
[nodes, branch, t],
|
||||
);
|
||||
const displayedEdges = useMemo(() => {
|
||||
const labels = new Map(graph.nodes.map((node) => [node.id, node.data.label]));
|
||||
return graph.edges.map((edge) => {
|
||||
const related = edge.id === selectedEdge || edge.source === selected || edge.target === selected;
|
||||
return {
|
||||
...edge,
|
||||
selected: edge.id === selectedEdge,
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: edge.style?.stroke?.toString(), width: 18, height: 18 },
|
||||
hidden: !!branch && (!branch.includes(edge.source) || !branch.includes(edge.target)),
|
||||
ariaLabel: `${t(`relationship.${edge.data?.relationship ?? "data"}`)}: ${labels.get(edge.source)} → ${labels.get(edge.target)}`,
|
||||
label: related ? t(`relationship.${edge.data?.relationship ?? "data"}`) : undefined,
|
||||
labelStyle: { fontSize: 10 },
|
||||
labelBgStyle: { fill: "var(--mantine-color-body)" },
|
||||
style: { ...edge.style, opacity: related || !selected ? 1 : 0.4 },
|
||||
};
|
||||
});
|
||||
}, [graph, selected, selectedEdge, branch, t]);
|
||||
return (
|
||||
<>
|
||||
<CanvasControls
|
||||
arranging={arranging}
|
||||
selection={selection}
|
||||
branch={!!branch}
|
||||
arrange={arrange}
|
||||
focus={focus}
|
||||
fit={() => {
|
||||
setBranch(null);
|
||||
requestAnimationFrame(() => {
|
||||
void fitView({ duration: reducedMotion ? 0 : 200 });
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{issue && (
|
||||
<Alert
|
||||
color="yellow"
|
||||
p="xs"
|
||||
withCloseButton
|
||||
closeButtonLabel={actions("close")}
|
||||
onClose={() => setIssue(null)}
|
||||
aria-live="polite"
|
||||
>
|
||||
{t(issue as "codeOwned")}
|
||||
</Alert>
|
||||
)}
|
||||
<ReactFlow<WidgetNode, WidgetEdge>
|
||||
onInit={onInit}
|
||||
nodes={displayedNodes}
|
||||
edges={displayedEdges}
|
||||
nodeTypes={canvasNodeTypes}
|
||||
defaultEdgeOptions={defaultEdgeOptions}
|
||||
aria-label={t("canvas")}
|
||||
{...interactions}
|
||||
onConnect={onConnect}
|
||||
onConnectEnd={endConnection}
|
||||
onClickConnectEnd={endConnection}
|
||||
onReconnectStart={() => {
|
||||
reconnecting.current = true;
|
||||
}}
|
||||
onReconnect={(edge, connection) => setIssue(connect(connection, getNodes(), edge))}
|
||||
onReconnectEnd={() => {
|
||||
reconnecting.current = false;
|
||||
}}
|
||||
isValidConnection={isValidConnection}
|
||||
nodesConnectable={graphEditable}
|
||||
onNodeDoubleClick={(event, node) => {
|
||||
if (!isPreviewInteraction(event.target)) onSelect(node.id);
|
||||
}}
|
||||
onNodeDragStart={() => {
|
||||
generation.current += 1;
|
||||
setArranging(false);
|
||||
}}
|
||||
onNodeDragStop={() => store.setLayout(captureLayout(getNodes(), store.getLayout()))}
|
||||
onSelectionDragStart={() => {
|
||||
generation.current += 1;
|
||||
setArranging(false);
|
||||
}}
|
||||
onSelectionDragStop={() => store.setLayout(captureLayout(getNodes(), store.getLayout()))}
|
||||
panOnScroll
|
||||
selectionOnDrag
|
||||
panOnDrag={false}
|
||||
panActivationKeyCode="Space"
|
||||
selectionMode={SelectionMode.Partial}
|
||||
zoomOnScroll={false}
|
||||
zoomActivationKeyCode={["Control", "Meta"]}
|
||||
multiSelectionKeyCode={["Control", "Meta", "Shift"]}
|
||||
zoomOnDoubleClick={false}
|
||||
connectOnClick
|
||||
connectionRadius={24}
|
||||
reconnectRadius={18}
|
||||
nodesFocusable
|
||||
edgesFocusable
|
||||
autoPanOnNodeFocus
|
||||
disableKeyboardA11y={false}
|
||||
deleteKeyCode={null}
|
||||
minZoom={0.02}
|
||||
maxZoom={1.5}
|
||||
fitView
|
||||
fitViewOptions={initialFitOptions}
|
||||
>
|
||||
<Background gap={24} />
|
||||
<CanvasViewportControls />
|
||||
</ReactFlow>
|
||||
<AddNextStep pending={pending} form={form} onClose={() => setPending(null)} onSelect={onSelect} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import type { Connection } from "@xyflow/react";
|
||||
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
|
||||
import { removeGeneratedBindings } from "@homarr/custom-widgets/workbench";
|
||||
import type { CustomWidgetFormDocumentStore } from "../_custom-widget-form-state";
|
||||
import type { CustomWidgetWorkbenchForm } from "../_custom-widget-form-utils";
|
||||
import { buildDefinition } from "../_custom-widget-form-utils";
|
||||
import { readObject, record } from "./graph";
|
||||
import type { FlowKind, WidgetNode, WidgetEdge } from "./graph";
|
||||
import { freshFlowIdentifier as fresh } from "./identifiers";
|
||||
import { createExtensionCommands, isExtensionKind } from "./extension-commands";
|
||||
import { connectionIssue, isGeneratedConnectionCurrent, planFlowConnection } from "./connection-planner";
|
||||
export { connectionIssue } from "./connection-planner";
|
||||
|
||||
export function isGraphEditable(
|
||||
values: Pick<CustomWidgetFormValues, "sources" | "requests" | "options" | "extensions">,
|
||||
) {
|
||||
return [values.sources, values.requests, values.options, values.extensions || "{}"].every((value) => {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function createFlowCommands(form: CustomWidgetWorkbenchForm, store: CustomWidgetFormDocumentStore) {
|
||||
const extensionCommands = createExtensionCommands(form);
|
||||
const write = (field: "sources" | "requests" | "options", value: Record<string, unknown>) =>
|
||||
form.setFieldValue(field, JSON.stringify(value, null, 2));
|
||||
const disconnect = (edge: WidgetEdge) => {
|
||||
if (!isGraphEditable(form.values)) return "invalidGraphJson";
|
||||
if (edge.data?.generatedBindingIds?.length) {
|
||||
if (!isGeneratedConnectionCurrent(store.getValues(), edge.id, edge.data.generatedBindingIds)) return "codeOwned";
|
||||
const fragments = Object.values(record(readObject(store.getValues().extensions).fragments)).filter(
|
||||
(value): value is string => typeof value === "string",
|
||||
);
|
||||
const template = removeGeneratedBindings(store.getValues().template, edge.data.generatedBindingIds, fragments);
|
||||
if (template === null || !buildDefinition({ ...store.getValues(), template }).success) return "codeOwned";
|
||||
form.setFieldValue("template", template);
|
||||
return null;
|
||||
}
|
||||
if (edge.data?.relationship !== "invalidates") return "codeOwned";
|
||||
const requests = readObject(form.values.requests);
|
||||
const source = edge.source.replace(/^request:/u, "");
|
||||
const target = edge.target.replace(/^request:/u, "");
|
||||
const request = record(requests[source]);
|
||||
if (!Array.isArray(request.invalidates)) return null;
|
||||
write("requests", {
|
||||
...requests,
|
||||
[source]: { ...request, invalidates: request.invalidates.filter((entry) => entry !== target) },
|
||||
});
|
||||
return null;
|
||||
};
|
||||
const connect = (connection: Connection, nodes: WidgetNode[]) => {
|
||||
if (!isGraphEditable(form.values)) return "invalidGraphJson";
|
||||
const issue = connectionIssue(connection, nodes);
|
||||
if (issue) return issue;
|
||||
if (planFlowConnection(connection, nodes, form.values) !== "direct") return "bindingRequired";
|
||||
const source = nodes.find((node) => node.id === connection.source);
|
||||
const target = nodes.find((node) => node.id === connection.target);
|
||||
if (!source || !target) return "missing";
|
||||
const requests = readObject(form.values.requests);
|
||||
if (source.data.kind === "source") {
|
||||
write("requests", {
|
||||
...requests,
|
||||
[target.data.identifier]: { ...record(requests[target.data.identifier]), source: source.data.identifier },
|
||||
});
|
||||
} else {
|
||||
const request = record(requests[source.data.identifier]);
|
||||
const invalidates = Array.isArray(request.invalidates) ? request.invalidates : [];
|
||||
write("requests", {
|
||||
...requests,
|
||||
[source.data.identifier]: { ...request, invalidates: [...new Set([...invalidates, target.data.identifier])] },
|
||||
});
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return {
|
||||
add(kind: FlowKind, preferredSource?: string) {
|
||||
if (!isGraphEditable(form.values)) return "widget";
|
||||
let id = "widget";
|
||||
store.transaction(() => {
|
||||
if (isExtensionKind(kind)) {
|
||||
id = extensionCommands.add(kind);
|
||||
return;
|
||||
}
|
||||
if (kind === "source") {
|
||||
const sources = readObject(form.values.sources);
|
||||
const key = fresh(sources, "source");
|
||||
write("sources", {
|
||||
...sources,
|
||||
[key]: { baseUrl: "http://service:8080", networkScope: "private", auth: "none" },
|
||||
});
|
||||
id = `source:${key}`;
|
||||
} else if (kind === "query" || kind === "action") {
|
||||
const sources = readObject(form.values.sources);
|
||||
let source = Object.keys(sources)[0];
|
||||
if (preferredSource && sources[preferredSource]) source = preferredSource;
|
||||
if (!source) {
|
||||
source = "default";
|
||||
write("sources", { default: { baseUrl: "http://service:8080", networkScope: "private", auth: "none" } });
|
||||
}
|
||||
const requests = readObject(form.values.requests);
|
||||
const key = fresh({ ...requests, ...record(readObject(form.values.extensions).native) }, kind);
|
||||
let trigger = "load";
|
||||
if (kind === "action") trigger = "manual";
|
||||
write("requests", { ...requests, [key]: { source, path: "/api/data", kind, method: "GET", trigger } });
|
||||
id = `request:${key}`;
|
||||
} else if (kind === "options") {
|
||||
const options = readObject(form.values.options);
|
||||
const key = fresh(options, "option");
|
||||
write("options", { ...options, [key]: { control: "text", label: key, default: "" } });
|
||||
id = "options";
|
||||
}
|
||||
});
|
||||
return id;
|
||||
},
|
||||
connect,
|
||||
disconnect,
|
||||
reconnect(edge: WidgetEdge, connection: Connection, nodes: WidgetNode[]) {
|
||||
if (!isGraphEditable(form.values)) return "invalidGraphJson";
|
||||
const issue = connectionIssue(connection, nodes);
|
||||
if (issue) return issue;
|
||||
if (planFlowConnection(connection, nodes, form.values) !== "direct") return "codeOwned";
|
||||
const source = nodes.find((node) => node.id === connection.source);
|
||||
if (edge.data?.relationship === "source" && (edge.target !== connection.target || source?.data.kind !== "source"))
|
||||
return "codeOwned";
|
||||
if (edge.data?.relationship === "invalidates" && source?.data.kind !== "action") return "codeOwned";
|
||||
if (!["source", "invalidates"].includes(edge.data?.relationship ?? "")) return "codeOwned";
|
||||
store.transaction(() => {
|
||||
if (edge.data?.relationship === "invalidates") disconnect(edge);
|
||||
connect(connection, nodes);
|
||||
});
|
||||
return null;
|
||||
},
|
||||
duplicate(node: WidgetNode) {
|
||||
if (!isGraphEditable(form.values)) return node.id;
|
||||
if (isExtensionKind(node.data.kind)) return extensionCommands.duplicate(node);
|
||||
let field: "sources" | "requests" = "requests";
|
||||
let prefix = "request";
|
||||
if (node.data.kind === "source") {
|
||||
field = "sources";
|
||||
prefix = "source";
|
||||
}
|
||||
if (!["source", "query", "action"].includes(node.data.kind)) return node.id;
|
||||
const entries = readObject(form.values[field]);
|
||||
let identifiers = entries;
|
||||
if (field === "requests") identifiers = { ...entries, ...record(readObject(form.values.extensions).native) };
|
||||
const key = fresh(identifiers, `${node.data.identifier}Copy`);
|
||||
const copy = structuredClone(record(entries[node.data.identifier]));
|
||||
if (Array.isArray(copy.invalidates))
|
||||
copy.invalidates = copy.invalidates.map((id) => (id === node.data.identifier ? key : id));
|
||||
write(field, { ...entries, [key]: copy });
|
||||
return `${prefix}:${key}`;
|
||||
},
|
||||
remove(nodes: WidgetNode[]) {
|
||||
if (nodes.every((node) => node.data.kind === "group")) return;
|
||||
if (!isGraphEditable(form.values)) return;
|
||||
store.transaction(() => {
|
||||
const sources = readObject(form.values.sources);
|
||||
const requests = readObject(form.values.requests);
|
||||
let options = readObject(form.values.options);
|
||||
const layout = structuredClone(store.getLayout());
|
||||
for (const node of nodes) {
|
||||
if (node.data.kind === "source") delete sources[node.data.identifier];
|
||||
if (["query", "action"].includes(node.data.kind)) delete requests[node.data.identifier];
|
||||
if (node.data.kind === "options") options = {};
|
||||
delete layout.nodes[node.id];
|
||||
delete layout.groups[node.id];
|
||||
}
|
||||
form.setValues({
|
||||
...form.values,
|
||||
sources: JSON.stringify(sources, null, 2),
|
||||
requests: JSON.stringify(requests, null, 2),
|
||||
options: JSON.stringify(options, null, 2),
|
||||
extensions: form.values.extensions,
|
||||
});
|
||||
extensionCommands.remove(nodes);
|
||||
store.setLayout(layout);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { Connection } from "@xyflow/react";
|
||||
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
|
||||
import { projectWidgetGraph, readObject, record } from "./graph";
|
||||
import type { WidgetNode } from "./graph";
|
||||
|
||||
export interface FlowBindingIntent {
|
||||
kind: "query" | "action" | "option" | "requestOption";
|
||||
source: string;
|
||||
target: string;
|
||||
identifier: string;
|
||||
native: boolean;
|
||||
replaceGeneratedIds?: string[];
|
||||
replaceEdgeId?: string;
|
||||
snapshot: Pick<CustomWidgetFormValues, "template" | "requests" | "options" | "extensions">;
|
||||
}
|
||||
|
||||
export function connectionIssue(connection: Pick<Connection, "source" | "target">, nodes: WidgetNode[]) {
|
||||
const source = nodes.find((entry) => entry.id === connection.source);
|
||||
const target = nodes.find((entry) => entry.id === connection.target);
|
||||
if (!source || !target) return "missing";
|
||||
if (source.data.kind === "widget" && ["query", "action", "native"].includes(target.data.kind)) return null;
|
||||
if (source.data.kind === "source" && ["query", "action"].includes(target.data.kind)) return null;
|
||||
if (source.data.kind === "action" && target.data.kind === "query") return null;
|
||||
if (["query", "action", "native", "options"].includes(source.data.kind) && target.data.kind === "widget") return null;
|
||||
if (source.data.kind === "options" && ["query", "action", "native"].includes(target.data.kind)) return null;
|
||||
return "codeOwned";
|
||||
}
|
||||
|
||||
export function planFlowConnection(
|
||||
connection: Pick<Connection, "source" | "target">,
|
||||
nodes: WidgetNode[],
|
||||
values: CustomWidgetFormValues,
|
||||
): FlowBindingIntent | "direct" | "missing" | "codeOwned" {
|
||||
const issue = connectionIssue(connection, nodes);
|
||||
if (issue) return issue;
|
||||
const source = nodes.find((entry) => entry.id === connection.source);
|
||||
const target = nodes.find((entry) => entry.id === connection.target);
|
||||
if (!source || !target) return "missing";
|
||||
if (source.data.kind === "widget") return planFlowConnection({ source: target.id, target: source.id }, nodes, values);
|
||||
if (source.data.kind === "source" || (source.data.kind === "action" && target.data.kind === "query")) return "direct";
|
||||
let kind: FlowBindingIntent["kind"] = "query";
|
||||
let identifier = source.data.identifier;
|
||||
let native = source.data.kind === "native";
|
||||
if (source.data.kind === "options") {
|
||||
kind = "option";
|
||||
if (target.data.kind !== "widget") {
|
||||
kind = "requestOption";
|
||||
identifier = target.data.identifier;
|
||||
native = target.data.kind === "native";
|
||||
}
|
||||
} else if (source.data.kind === "action") kind = "action";
|
||||
else if (native && record(record(readObject(values.extensions).native)[identifier]).kind === "action")
|
||||
kind = "action";
|
||||
return {
|
||||
kind,
|
||||
source: source.id,
|
||||
target: target.id,
|
||||
identifier,
|
||||
native,
|
||||
snapshot: {
|
||||
template: values.template,
|
||||
requests: values.requests,
|
||||
options: values.options,
|
||||
extensions: values.extensions,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function isFlowBindingCurrent(intent: FlowBindingIntent, values: CustomWidgetFormValues) {
|
||||
return Object.entries(intent.snapshot).every(([key, value]) => values[key as keyof typeof intent.snapshot] === value);
|
||||
}
|
||||
|
||||
/** Deferred graph props cannot authorize removal after a newer authored reference was added. */
|
||||
export function isGeneratedConnectionCurrent(
|
||||
values: CustomWidgetFormValues,
|
||||
edgeId: string | undefined,
|
||||
ids: readonly string[],
|
||||
) {
|
||||
const current = projectWidgetGraph(values, { version: 1, nodes: {}, groups: {} }).edges.find(
|
||||
(edge) => edge.id === edgeId,
|
||||
)?.data?.generatedBindingIds;
|
||||
return !!current?.length && current.length === ids.length && current.every((id) => ids.includes(id));
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
import { createContext, useCallback, useContext, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Connection } from "@xyflow/react";
|
||||
import { useCustomWidgetFormDocumentStore } from "../_custom-widget-form-state";
|
||||
import type { CustomWidgetWorkbenchForm } from "../_custom-widget-form-utils";
|
||||
import { createFlowCommands, isGraphEditable } from "./commands";
|
||||
import { isGeneratedConnectionCurrent, planFlowConnection } from "./connection-planner";
|
||||
import type { FlowBindingIntent } from "./connection-planner";
|
||||
import type { WidgetNode, WidgetEdge } from "./graph";
|
||||
import { FlowBindingDialog } from "./binding-dialog";
|
||||
|
||||
type Connect = (connection: Connection, nodes: WidgetNode[], replacing?: WidgetEdge) => string | null;
|
||||
const FlowConnectionContext = createContext<Connect | null>(null);
|
||||
|
||||
export function FlowConnectionProvider({ form, children }: { form: CustomWidgetWorkbenchForm; children: ReactNode }) {
|
||||
const store = useCustomWidgetFormDocumentStore();
|
||||
const [pending, setPending] = useState<FlowBindingIntent | null>(null);
|
||||
const connect = useCallback<Connect>(
|
||||
(connection, nodes, replacing) => {
|
||||
if (!isGraphEditable(store.getValues())) return "invalidGraphJson";
|
||||
if (replacing && !replacing.data?.generatedBindingIds?.length)
|
||||
return createFlowCommands(form, store).reconnect(replacing, connection, nodes);
|
||||
const plan = planFlowConnection(connection, nodes, store.getValues());
|
||||
if (plan === "direct") {
|
||||
if (replacing) return "codeOwned";
|
||||
let issue: string | null = null;
|
||||
store.transaction(() => {
|
||||
issue = createFlowCommands(form, store).connect(connection, nodes);
|
||||
});
|
||||
return issue;
|
||||
}
|
||||
if (typeof plan === "string") return plan;
|
||||
if (replacing) {
|
||||
if (!replacing.data?.generatedBindingIds?.length) return "codeOwned";
|
||||
if (!isGeneratedConnectionCurrent(store.getValues(), replacing.id, replacing.data.generatedBindingIds))
|
||||
return "codeOwned";
|
||||
plan.replaceGeneratedIds = replacing.data.generatedBindingIds;
|
||||
plan.replaceEdgeId = replacing.id;
|
||||
}
|
||||
setPending(plan);
|
||||
return null;
|
||||
},
|
||||
[form, store],
|
||||
);
|
||||
return (
|
||||
<FlowConnectionContext.Provider value={connect}>
|
||||
{children}
|
||||
{pending && <FlowBindingDialog intent={pending} form={form} onClose={() => setPending(null)} />}
|
||||
</FlowConnectionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useFlowConnection() {
|
||||
const connect = useContext(FlowConnectionContext);
|
||||
if (!connect) throw new Error("Flow connections require FlowConnectionProvider");
|
||||
return connect;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { ActionIcon, Alert, Button, Group, Select, Stack, Tooltip } from "@mantine/core";
|
||||
import { IconArrowLeft, IconArrowRight, IconUnlink } from "@tabler/icons-react";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { connectionIssue, createFlowCommands, isGraphEditable } from "./commands";
|
||||
import type { WidgetNode, WidgetEdge } from "./graph";
|
||||
import type { CustomWidgetWorkbenchForm } from "../_custom-widget-form-utils";
|
||||
import { useCustomWidgetFormDocumentStore } from "../_custom-widget-form-state";
|
||||
import { useFlowConnection } from "./connection-provider";
|
||||
|
||||
interface Props {
|
||||
node: WidgetNode;
|
||||
nodes: WidgetNode[];
|
||||
edges: WidgetEdge[];
|
||||
form: CustomWidgetWorkbenchForm;
|
||||
onSelect(id: string): void;
|
||||
}
|
||||
|
||||
export function Connections(props: Props) {
|
||||
return <ConnectionEditor key={props.node.id} {...props} />;
|
||||
}
|
||||
|
||||
function ConnectionEditor({ node, nodes, edges, form, onSelect }: Props) {
|
||||
const t = useI18n("customWidget.flow");
|
||||
const store = useCustomWidgetFormDocumentStore();
|
||||
const connect = useFlowConnection();
|
||||
const [target, setTarget] = useState<string | null>(null);
|
||||
const [source, setSource] = useState<string | null>(null);
|
||||
const [issue, setIssue] = useState<string | null>(null);
|
||||
const editable = isGraphEditable(store.getValues());
|
||||
const destinations = nodes.filter(
|
||||
(other) =>
|
||||
!connectionIssue({ source: node.id, target: other.id }, nodes) &&
|
||||
(other.data.kind === "widget" ||
|
||||
node.data.kind === "options" ||
|
||||
!edges.some((edge) => edge.source === node.id && edge.target === other.id)),
|
||||
);
|
||||
const sources = nodes.filter(
|
||||
(other) =>
|
||||
!connectionIssue({ source: other.id, target: node.id }, nodes) &&
|
||||
(node.data.kind === "widget" ||
|
||||
other.data.kind === "options" ||
|
||||
!edges.some((edge) => edge.source === other.id && edge.target === node.id)),
|
||||
);
|
||||
const related = edges.filter((edge) => edge.source === node.id || edge.target === node.id);
|
||||
const commands = createFlowCommands(form, store);
|
||||
return (
|
||||
<Stack gap={6} mb="sm">
|
||||
{(!editable || issue) && (
|
||||
<Alert color="yellow" p="xs" aria-live="polite">
|
||||
{t((issue ?? "invalidGraphJson") as "codeOwned")}
|
||||
</Alert>
|
||||
)}
|
||||
{related.map((edge) => {
|
||||
let otherId = edge.source;
|
||||
if (edge.source === node.id) otherId = edge.target;
|
||||
const other = nodes.find((entry) => entry.id === otherId);
|
||||
let Direction = IconArrowLeft;
|
||||
if (edge.source === node.id) Direction = IconArrowRight;
|
||||
let disconnectLabel = t("disconnect");
|
||||
if (edge.data?.generatedBindingIds?.length) disconnectLabel = t("bindingDisconnect");
|
||||
return (
|
||||
<Group key={edge.id} gap={4} wrap="nowrap">
|
||||
<Button
|
||||
type="button"
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<Direction size={14} />}
|
||||
onClick={() => other && onSelect(other.id)}
|
||||
>
|
||||
{t(`relationship.${edge.data?.relationship ?? "data"}`)} · {other?.data.label}
|
||||
</Button>
|
||||
{(edge.data?.relationship === "invalidates" || !!edge.data?.generatedBindingIds?.length) && (
|
||||
<Tooltip label={disconnectLabel}>
|
||||
<ActionIcon
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
aria-label={disconnectLabel}
|
||||
disabled={!editable}
|
||||
onClick={() => setIssue(commands.disconnect(edge))}
|
||||
>
|
||||
<IconUnlink size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
{sources.length > 0 && (
|
||||
<Group align="end" wrap="nowrap">
|
||||
<Select
|
||||
label={t("connectFrom")}
|
||||
size="xs"
|
||||
value={source}
|
||||
onChange={setSource}
|
||||
disabled={!editable}
|
||||
searchable
|
||||
data={sources.map((entry) => ({
|
||||
value: entry.id,
|
||||
label: `${t(`kind.${entry.data.kind}`)} · ${entry.data.label}`,
|
||||
}))}
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
type="button"
|
||||
disabled={!editable || !sources.some((entry) => entry.id === source)}
|
||||
onClick={() => {
|
||||
if (!source) return;
|
||||
const result = connect({ source, target: node.id, sourceHandle: "out", targetHandle: "in" }, nodes);
|
||||
setIssue(result);
|
||||
if (!result) setSource(null);
|
||||
}}
|
||||
>
|
||||
{t("connect")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
{destinations.length > 0 && (
|
||||
<Group align="end" wrap="nowrap">
|
||||
<Select
|
||||
label={t("connectTo")}
|
||||
size="xs"
|
||||
value={target}
|
||||
onChange={setTarget}
|
||||
disabled={!editable}
|
||||
searchable
|
||||
data={destinations.map((entry) => ({
|
||||
value: entry.id,
|
||||
label: `${t(`kind.${entry.data.kind}`)} · ${entry.data.label}`,
|
||||
}))}
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
type="button"
|
||||
disabled={!editable || !destinations.some((entry) => entry.id === target)}
|
||||
onClick={() => {
|
||||
if (!target) return;
|
||||
const result = connect({ source: node.id, target, sourceHandle: "out", targetHandle: "in" }, nodes);
|
||||
setIssue(result);
|
||||
if (!result) setTarget(null);
|
||||
}}
|
||||
>
|
||||
{t("connect")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { createId } from "@homarr/common";
|
||||
import { customWidgetSourcesSchema, getCustomWidgetRequiredSecretKinds } from "@homarr/custom-widgets/core";
|
||||
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
|
||||
|
||||
export type SourceBindings = Record<string, string>;
|
||||
|
||||
/** Only definition identities enter history. Credential values remain in the live form. */
|
||||
export function reconcileDefinitionBindings(raw: string, current: SourceBindings): SourceBindings {
|
||||
try {
|
||||
const sources: unknown = JSON.parse(raw);
|
||||
if (!sources || typeof sources !== "object" || Array.isArray(sources)) return current;
|
||||
return Object.fromEntries(Object.keys(sources).map((id) => [id, current[id] ?? createId()]));
|
||||
} catch {
|
||||
return current;
|
||||
}
|
||||
}
|
||||
|
||||
export function restoreCredentialBindings(
|
||||
secrets: CustomWidgetFormValues["secrets"],
|
||||
previous: SourceBindings,
|
||||
next: SourceBindings,
|
||||
rawSources: string,
|
||||
) {
|
||||
let sources;
|
||||
try {
|
||||
sources = customWidgetSourcesSchema.safeParse(JSON.parse(rawSources));
|
||||
} catch {
|
||||
return secrets;
|
||||
}
|
||||
if (!sources.success) return secrets;
|
||||
const names = new Map(Object.entries(next).map(([name, identity]) => [identity, name]));
|
||||
return secrets.flatMap((secret) => {
|
||||
const sourceId = names.get(previous[secret.sourceId] ?? "");
|
||||
if (!sourceId) return [];
|
||||
const source = sources.data[sourceId];
|
||||
if (!source) return [];
|
||||
let auth: Parameters<typeof getCustomWidgetRequiredSecretKinds>[0];
|
||||
if (typeof source.auth === "object") auth = source.auth.type;
|
||||
else auth = source.auth;
|
||||
if (!new Set<string>(getCustomWidgetRequiredSecretKinds(auth)).has(secret.kind)) return [];
|
||||
let savedSourceId = secret.savedSourceId;
|
||||
if (secret.hasValue && sourceId !== secret.sourceId) savedSourceId ??= secret.sourceId;
|
||||
return [{ ...secret, sourceId, savedSourceId }];
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Code, Group, ScrollArea, Stack, Text } from "@mantine/core";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
function fields(value: unknown) {
|
||||
const result: Array<{ path: string; value: unknown }> = [];
|
||||
let visited = 0;
|
||||
const visit = (entry: unknown, path: string, depth: number) => {
|
||||
if (++visited > 500 || result.length >= 100) return;
|
||||
if (depth >= 5 && entry !== null && typeof entry === "object") return;
|
||||
if (entry === null || typeof entry !== "object") {
|
||||
result.push({ path, value: entry });
|
||||
return;
|
||||
}
|
||||
for (const [key, child] of Object.entries(entry).slice(0, 30)) {
|
||||
if (visited >= 500 || result.length >= 100) break;
|
||||
visit(child, `${path}[${JSON.stringify(key)}]`, depth + 1);
|
||||
}
|
||||
};
|
||||
visit(value, "data", 0);
|
||||
return result;
|
||||
}
|
||||
|
||||
const insert = (expression: string) =>
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("homarr:widget-code-insert", {
|
||||
detail: { editorId: "jsx-editor", text: `{${expression}}` },
|
||||
}),
|
||||
);
|
||||
|
||||
export function DataBindings({ data }: { data: Record<string, unknown> }) {
|
||||
const t = useI18n("customWidget.flow");
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("bindingInspectorHint")}
|
||||
</Text>
|
||||
<ScrollArea mah={260}>
|
||||
<Stack gap={6}>
|
||||
{fields(data).map((field) => (
|
||||
<Group key={field.path} justify="space-between" wrap="nowrap">
|
||||
<Code style={{ overflow: "hidden", textOverflow: "ellipsis" }}>{field.path}</Code>
|
||||
<Button size="compact-xs" variant="subtle" onClick={() => insert(field.path)}>
|
||||
{t("insertBinding")}
|
||||
</Button>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
import { Alert, Button, Group, Modal, Stack, Text } from "@mantine/core";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useCustomWidgetFormDocumentStore } from "../_custom-widget-form-state";
|
||||
import { buildDefinition } from "../_custom-widget-form-utils";
|
||||
import type { CustomWidgetWorkbenchForm } from "../_custom-widget-form-utils";
|
||||
import { createFlowCommands } from "./commands";
|
||||
import { projectWidgetGraph } from "./graph";
|
||||
import type { WidgetNode, WidgetEdge } from "./graph";
|
||||
import { ungroupNodes, captureLayout } from "./layout";
|
||||
|
||||
export function DeleteSelection({
|
||||
opened,
|
||||
onClose,
|
||||
selection,
|
||||
nodes,
|
||||
edges,
|
||||
form,
|
||||
onSelect,
|
||||
}: {
|
||||
opened: boolean;
|
||||
onClose(): void;
|
||||
selection: string[];
|
||||
nodes: WidgetNode[];
|
||||
edges: WidgetEdge[];
|
||||
form: CustomWidgetWorkbenchForm;
|
||||
onSelect(id: string): void;
|
||||
}) {
|
||||
const t = useI18n("customWidget.flow");
|
||||
const store = useCustomWidgetFormDocumentStore();
|
||||
const targets = nodes.filter((node) => selection.includes(node.id) && node.data.kind !== "widget");
|
||||
const validDraft = !opened || buildDefinition(store.getValues()).success;
|
||||
const targetIds = new Set(targets.map((node) => node.id));
|
||||
const dependencies = edges.filter((edge) => {
|
||||
if (targetIds.has(edge.source) && targetIds.has(edge.target)) return false;
|
||||
if (edge.data?.relationship === "invalidates" || edge.data?.relationship === "invoke")
|
||||
return targetIds.has(edge.target);
|
||||
return targetIds.has(edge.source);
|
||||
});
|
||||
const dependants = [...new Set(dependencies.map((edge) => (targetIds.has(edge.source) ? edge.target : edge.source)))];
|
||||
const remove = () => {
|
||||
if (!buildDefinition(store.getValues()).success) return;
|
||||
// Recheck current references when confirming; a deferred graph is never deletion authority.
|
||||
const current = projectWidgetGraph(store.getValues(), store.getLayout());
|
||||
const latestIds = new Set(current.nodes.filter((node) => targetIds.has(node.id)).map((node) => node.id));
|
||||
if (
|
||||
current.edges.some((edge) => {
|
||||
if (latestIds.has(edge.source) && latestIds.has(edge.target)) return false;
|
||||
if (["invalidates", "invoke"].includes(edge.data?.relationship ?? "")) return latestIds.has(edge.target);
|
||||
return latestIds.has(edge.source);
|
||||
})
|
||||
)
|
||||
return;
|
||||
store.transaction(() => {
|
||||
const marked = current.nodes.map((node) => ({
|
||||
...node,
|
||||
selected: targetIds.has(node.id) && node.data.kind === "group",
|
||||
}));
|
||||
store.setLayout(captureLayout(ungroupNodes(marked), store.getLayout()));
|
||||
createFlowCommands(form, store).remove(targets);
|
||||
});
|
||||
onClose();
|
||||
onSelect("widget");
|
||||
};
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title={t("deleteSelection")}>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">
|
||||
{t("deleteSelectionDescription", { names: targets.map((node) => node.data.label).join(", ") })}
|
||||
</Text>
|
||||
{!validDraft && <Alert color="yellow">{t("invalidDeletion")}</Alert>}
|
||||
{dependants.length > 0 && (
|
||||
<Alert color="yellow" title={t("resolveReferences")}>
|
||||
<Text size="sm">{t("resolveReferencesDescription")}</Text>
|
||||
<Stack mt="xs" gap={4}>
|
||||
{dependants.map((id) => (
|
||||
<Button
|
||||
key={id}
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onSelect(id);
|
||||
}}
|
||||
>
|
||||
{nodes.find((node) => node.id === id)?.data.label ?? id}
|
||||
</Button>
|
||||
))}
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
<Group justify="end">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button color="red" disabled={!validDraft || targets.length === 0 || dependants.length > 0} onClick={remove}>
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { customWidgetFormSchema } from "@homarr/custom-widgets/workbench";
|
||||
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
|
||||
import { EMPTY_EDITOR_LAYOUT } from "@homarr/custom-widgets/core";
|
||||
import type { CustomWidgetEditorLayout } from "@homarr/custom-widgets/core";
|
||||
import { reconcileDefinitionBindings, restoreCredentialBindings } from "./credential-bindings";
|
||||
import { getNodeRenames, renameDefinitionBinding, renameEditorNode } from "./node-renames";
|
||||
import { createEditorHistory, withoutCredentials } from "./history";
|
||||
import { areCustomWidgetValuesEqual } from "../_custom-widget-value-equality";
|
||||
|
||||
export interface CustomWidgetFormDocumentStore {
|
||||
getValues(): CustomWidgetFormValues;
|
||||
setValues(values: CustomWidgetFormValues): void;
|
||||
getDirty(): boolean;
|
||||
subscribe(listener: () => void): () => void;
|
||||
markSaved(
|
||||
values: CustomWidgetFormValues,
|
||||
savedLayout?: CustomWidgetEditorLayout,
|
||||
savedSourceBindings?: Record<string, string>,
|
||||
savedRequestBindings?: Record<string, string>,
|
||||
): void;
|
||||
initializeLayout(layout: CustomWidgetEditorLayout): void;
|
||||
getLayout(): CustomWidgetEditorLayout;
|
||||
getSourceBindings(): Record<string, string>;
|
||||
getRequestBindings(): Record<string, string>;
|
||||
setLayout(layout: CustomWidgetEditorLayout): void;
|
||||
subscribeEditor(listener: () => void): () => void;
|
||||
getRevision(): number;
|
||||
renameSourceBinding(previousId: string, nextId: string): void;
|
||||
renameRequestBinding(previousId: string, nextId: string): void;
|
||||
onNodeRenames(listener: (renames: Readonly<Record<string, string>>) => void): () => void;
|
||||
canUndo(): boolean;
|
||||
canRedo(): boolean;
|
||||
undo(): void;
|
||||
redo(): void;
|
||||
reset(): void;
|
||||
transaction(action: () => void): void;
|
||||
onRestore(listener: (values: CustomWidgetFormValues) => void): () => void;
|
||||
}
|
||||
|
||||
const documentValueKeys = customWidgetFormSchema.keyof().options;
|
||||
|
||||
function areDocumentValuesEqual(left: CustomWidgetFormValues, right: CustomWidgetFormValues) {
|
||||
if (Object.keys(left).length !== documentValueKeys.length) return false;
|
||||
if (Object.keys(right).length !== documentValueKeys.length) return false;
|
||||
return documentValueKeys.every(
|
||||
(key) => Object.hasOwn(left, key) && Object.hasOwn(right, key) && areCustomWidgetValuesEqual(left[key], right[key]),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCustomWidgetFormDocumentStore(
|
||||
initialValues: CustomWidgetFormValues,
|
||||
initialLayout: CustomWidgetEditorLayout = EMPTY_EDITOR_LAYOUT,
|
||||
): CustomWidgetFormDocumentStore {
|
||||
let values = initialValues;
|
||||
let persistedValues = initialValues;
|
||||
let layout = initialLayout;
|
||||
let persistedLayout = layout;
|
||||
let sourceBindings = reconcileDefinitionBindings(initialValues.sources, {});
|
||||
let persistedSourceBindings = sourceBindings;
|
||||
let requestBindings = reconcileDefinitionBindings(initialValues.requests, {});
|
||||
let persistedRequestBindings = requestBindings;
|
||||
let notifiedSources = sourceBindings;
|
||||
let notifiedRequests = requestBindings;
|
||||
let revision = 0;
|
||||
let transactionDepth = 0;
|
||||
const history = createEditorHistory();
|
||||
const listeners = new Set<() => void>();
|
||||
const editorListeners = new Set<() => void>();
|
||||
const restoreListeners = new Set<(values: CustomWidgetFormValues) => void>();
|
||||
const renameListeners = new Set<(renames: Readonly<Record<string, string>>) => void>();
|
||||
const snapshot = () => ({ content: withoutCredentials(values), layout, sourceBindings, requestBindings });
|
||||
const notifyEditor = () => {
|
||||
revision += 1;
|
||||
const renames = {
|
||||
...getNodeRenames(notifiedSources, sourceBindings, "source"),
|
||||
...getNodeRenames(notifiedRequests, requestBindings, "request"),
|
||||
};
|
||||
notifiedSources = sourceBindings;
|
||||
notifiedRequests = requestBindings;
|
||||
if (Object.keys(renames).length) renameListeners.forEach((listener) => listener(renames));
|
||||
editorListeners.forEach((listener) => listener());
|
||||
};
|
||||
const notify = () => {
|
||||
listeners.forEach((listener) => listener());
|
||||
notifyEditor();
|
||||
};
|
||||
const restore = (next: ReturnType<typeof snapshot> | undefined) => {
|
||||
if (!next) return;
|
||||
const changed = !areCustomWidgetValuesEqual(withoutCredentials(values), next.content);
|
||||
layout = next.layout;
|
||||
// Secrets intentionally stay in the live form, never in history.
|
||||
if (changed) {
|
||||
values = {
|
||||
...next.content,
|
||||
secrets: restoreCredentialBindings(values.secrets, sourceBindings, next.sourceBindings, next.content.sources),
|
||||
};
|
||||
sourceBindings = next.sourceBindings;
|
||||
requestBindings = next.requestBindings;
|
||||
restoreListeners.forEach((listener) => listener(values));
|
||||
notify();
|
||||
} else {
|
||||
sourceBindings = next.sourceBindings;
|
||||
requestBindings = next.requestBindings;
|
||||
notifyEditor();
|
||||
}
|
||||
};
|
||||
return {
|
||||
getValues: () => values,
|
||||
setValues: (nextValues) => {
|
||||
if (areDocumentValuesEqual(values, nextValues)) return;
|
||||
const changed = documentValueKeys.filter((key) => !areCustomWidgetValuesEqual(values[key], nextValues[key]));
|
||||
if (transactionDepth === 0 && changed.some((key) => key !== "secrets")) {
|
||||
history.record(snapshot(), changed.join(":"));
|
||||
}
|
||||
if (changed.includes("sources")) sourceBindings = reconcileDefinitionBindings(nextValues.sources, sourceBindings);
|
||||
if (changed.includes("requests"))
|
||||
requestBindings = reconcileDefinitionBindings(nextValues.requests, requestBindings);
|
||||
values = nextValues;
|
||||
if (transactionDepth === 0) notify();
|
||||
},
|
||||
getDirty: () =>
|
||||
!areDocumentValuesEqual(values, persistedValues) || !areCustomWidgetValuesEqual(layout, persistedLayout),
|
||||
subscribe: (listener) => {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
markSaved: (
|
||||
savedValues,
|
||||
savedLayout = layout,
|
||||
savedSourceBindings = sourceBindings,
|
||||
savedRequestBindings = requestBindings,
|
||||
) => {
|
||||
persistedValues = savedValues;
|
||||
persistedLayout = savedLayout;
|
||||
persistedSourceBindings = savedSourceBindings;
|
||||
persistedRequestBindings = savedRequestBindings;
|
||||
notifyEditor();
|
||||
},
|
||||
initializeLayout: (next) => {
|
||||
layout = next;
|
||||
persistedLayout = next;
|
||||
notifyEditor();
|
||||
},
|
||||
getLayout: () => layout,
|
||||
getSourceBindings: () => sourceBindings,
|
||||
getRequestBindings: () => requestBindings,
|
||||
setLayout: (next) => {
|
||||
if (areCustomWidgetValuesEqual(layout, next)) return;
|
||||
if (transactionDepth === 0) history.record(snapshot(), "");
|
||||
layout = next;
|
||||
if (transactionDepth === 0) notifyEditor();
|
||||
},
|
||||
subscribeEditor: (listener) => {
|
||||
editorListeners.add(listener);
|
||||
return () => {
|
||||
editorListeners.delete(listener);
|
||||
};
|
||||
},
|
||||
getRevision: () => revision,
|
||||
renameSourceBinding: (previousId, nextId) => {
|
||||
sourceBindings = renameDefinitionBinding(sourceBindings, previousId, nextId);
|
||||
layout = renameEditorNode(layout, `source:${previousId}`, `source:${nextId}`);
|
||||
},
|
||||
renameRequestBinding: (previousId, nextId) => {
|
||||
requestBindings = renameDefinitionBinding(requestBindings, previousId, nextId);
|
||||
layout = renameEditorNode(layout, `request:${previousId}`, `request:${nextId}`);
|
||||
},
|
||||
onNodeRenames: (listener) => {
|
||||
renameListeners.add(listener);
|
||||
return () => {
|
||||
renameListeners.delete(listener);
|
||||
};
|
||||
},
|
||||
canUndo: () => history.canUndo,
|
||||
canRedo: () => history.canRedo,
|
||||
undo: () => restore(history.undo(snapshot())),
|
||||
redo: () => restore(history.redo(snapshot())),
|
||||
reset: () => {
|
||||
history.record(snapshot(), "");
|
||||
restore({
|
||||
content: withoutCredentials(persistedValues),
|
||||
layout: persistedLayout,
|
||||
sourceBindings: persistedSourceBindings,
|
||||
requestBindings: persistedRequestBindings,
|
||||
});
|
||||
},
|
||||
transaction: (action) => {
|
||||
const previous = snapshot();
|
||||
const previousValues = values;
|
||||
transactionDepth += 1;
|
||||
try {
|
||||
action();
|
||||
} catch (error) {
|
||||
layout = previous.layout;
|
||||
values = previousValues;
|
||||
sourceBindings = previous.sourceBindings;
|
||||
requestBindings = previous.requestBindings;
|
||||
restoreListeners.forEach((listener) => listener(values));
|
||||
throw error;
|
||||
} finally {
|
||||
transactionDepth -= 1;
|
||||
if (transactionDepth === 0 && !areCustomWidgetValuesEqual(previous, snapshot())) {
|
||||
history.record(previous, "");
|
||||
if (!areCustomWidgetValuesEqual(previous.content, withoutCredentials(values))) notify();
|
||||
else notifyEditor();
|
||||
} else if (transactionDepth === 0 && !areDocumentValuesEqual(previousValues, values)) notify();
|
||||
}
|
||||
},
|
||||
onRestore: (listener) => {
|
||||
restoreListeners.add(listener);
|
||||
return () => {
|
||||
restoreListeners.delete(listener);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import type { CustomWidgetQueryExecution } from "@homarr/custom-widgets/runtime";
|
||||
import type { PreviewState } from "./preview-state";
|
||||
|
||||
export type WorkbenchJournalEntry = RouterOutputs["customWidget"]["previewJournal"][number];
|
||||
export interface NodeExecutionStatus {
|
||||
state: "idle" | "loading" | "success" | "error";
|
||||
error?: string;
|
||||
code?: number;
|
||||
durationMs?: number;
|
||||
timestamp?: number;
|
||||
simulated: boolean;
|
||||
stale: boolean;
|
||||
}
|
||||
export const EMPTY_JOURNAL: WorkbenchJournalEntry[] = [];
|
||||
export const IDLE_EXECUTION: NodeExecutionStatus = { state: "idle", simulated: false, stale: false };
|
||||
export interface WorkbenchQuerySnapshot {
|
||||
data: unknown;
|
||||
hasData: boolean;
|
||||
status: unknown;
|
||||
}
|
||||
export const EMPTY_EXECUTION_DATA = { data: {}, status: {} };
|
||||
|
||||
export function executionRequestIdentifier(requestId: string) {
|
||||
return requestId.replace(/^native:/u, "");
|
||||
}
|
||||
|
||||
function compactStatus(
|
||||
value: unknown,
|
||||
entry: WorkbenchJournalEntry | undefined,
|
||||
preview: PreviewState,
|
||||
observed = false,
|
||||
): NodeExecutionStatus {
|
||||
let raw: Record<string, unknown> = {};
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) raw = value as Record<string, unknown>;
|
||||
let state: NodeExecutionStatus["state"] = "idle";
|
||||
if (value !== undefined || entry) state = "success";
|
||||
if (
|
||||
raw.ok === false ||
|
||||
raw.error ||
|
||||
(!observed && entry && !entry.simulated && (entry.status === null || entry.status >= 400))
|
||||
)
|
||||
state = "error";
|
||||
if (raw.loading || preview.outcome === "loading") state = "loading";
|
||||
let code = entry?.status ?? (typeof raw.status === "number" ? raw.status : undefined);
|
||||
if (observed) code = typeof raw.status === "number" ? raw.status : undefined;
|
||||
return {
|
||||
state,
|
||||
error: typeof raw.error === "string" ? raw.error.slice(0, 512) : undefined,
|
||||
code,
|
||||
durationMs: entry?.durationMs ?? (typeof raw.durationMs === "number" ? raw.durationMs : undefined),
|
||||
timestamp: entry?.timestamp,
|
||||
simulated: entry?.simulated ?? false,
|
||||
stale: preview.stale ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
export function createWorkbenchExecutionStore(initialPreview?: PreviewState) {
|
||||
let preview: PreviewState = initialPreview ?? { data: {}, status: {}, session: null, outcome: "idle" };
|
||||
let statuses = new Map<string, NodeExecutionStatus>();
|
||||
let journal = EMPTY_JOURNAL;
|
||||
const observedQueries = new Map<string, WorkbenchQuerySnapshot>();
|
||||
let queries = new Map<string, WorkbenchQuerySnapshot>();
|
||||
let dataSnapshot: { data: Record<string, unknown>; status: Record<string, unknown> } = EMPTY_EXECUTION_DATA;
|
||||
const subscribers = new Map<string, Set<() => void>>();
|
||||
const journalSubscribers = new Set<() => void>();
|
||||
const dataSubscribers = new Set<() => void>();
|
||||
const querySubscribers = new Map<string, Set<() => void>>();
|
||||
const updateQueries = () => {
|
||||
const ids = new Set([
|
||||
...queries.keys(),
|
||||
...Object.keys(preview.data),
|
||||
...Object.keys(preview.status),
|
||||
...observedQueries.keys(),
|
||||
]);
|
||||
const next = new Map<string, WorkbenchQuerySnapshot>();
|
||||
const changed: string[] = [];
|
||||
for (const id of ids) {
|
||||
if (!observedQueries.has(id) && !Object.hasOwn(preview.data, id) && !Object.hasOwn(preview.status, id)) {
|
||||
changed.push(id);
|
||||
continue;
|
||||
}
|
||||
const value = observedQueries.get(id) ?? {
|
||||
data: preview.data[id],
|
||||
status: preview.status[id],
|
||||
hasData: Object.hasOwn(preview.data, id),
|
||||
};
|
||||
const previous = queries.get(id);
|
||||
if (
|
||||
previous &&
|
||||
previous.data === value.data &&
|
||||
previous.status === value.status &&
|
||||
previous.hasData === value.hasData
|
||||
)
|
||||
next.set(id, previous);
|
||||
else {
|
||||
next.set(id, value);
|
||||
changed.push(id);
|
||||
}
|
||||
}
|
||||
queries = next;
|
||||
if (!changed.length) return;
|
||||
dataSnapshot = { data: {}, status: {} };
|
||||
for (const [id, value] of queries) {
|
||||
if (value.hasData) dataSnapshot.data[id] = value.data;
|
||||
if (value.status !== undefined) dataSnapshot.status[id] = value.status;
|
||||
}
|
||||
for (const id of changed) querySubscribers.get(id)?.forEach((listener) => listener());
|
||||
dataSubscribers.forEach((listener) => listener());
|
||||
};
|
||||
const updateStatuses = () => {
|
||||
const latest = new Map<string, WorkbenchJournalEntry>();
|
||||
for (const entry of journal) {
|
||||
const id = executionRequestIdentifier(entry.requestId);
|
||||
if (!latest.has(id)) latest.set(id, entry);
|
||||
}
|
||||
const ids = new Set([...statuses.keys(), ...queries.keys(), ...latest.keys()]);
|
||||
const next = new Map<string, NodeExecutionStatus>();
|
||||
const changed: string[] = [];
|
||||
for (const id of ids) {
|
||||
const status = compactStatus(queries.get(id)?.status, latest.get(id), preview, observedQueries.has(id));
|
||||
const previous = statuses.get(id);
|
||||
if (previous && JSON.stringify(previous) === JSON.stringify(status)) next.set(id, previous);
|
||||
else {
|
||||
next.set(id, status);
|
||||
changed.push(id);
|
||||
}
|
||||
}
|
||||
statuses = next;
|
||||
for (const id of changed) subscribers.get(id)?.forEach((listener) => listener());
|
||||
};
|
||||
updateQueries();
|
||||
updateStatuses();
|
||||
return {
|
||||
getStatus: (id: string) => statuses.get(id) ?? IDLE_EXECUTION,
|
||||
getJournal: () => journal,
|
||||
getQuery: (id: string) => queries.get(id),
|
||||
getData: () => dataSnapshot,
|
||||
observeQuery(event: CustomWidgetQueryExecution) {
|
||||
if (!event.previewSessionId || event.previewSessionId !== preview.session?.id) return;
|
||||
const previous = queries.get(event.requestId);
|
||||
let value: WorkbenchQuerySnapshot;
|
||||
if (event.type === "result") {
|
||||
value = {
|
||||
data: event.result.data,
|
||||
hasData: true,
|
||||
status: {
|
||||
loading: false,
|
||||
ok: event.result.ok,
|
||||
status: event.result.status,
|
||||
statusText: event.result.statusText,
|
||||
error: event.result.error,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
value = {
|
||||
data: previous?.data,
|
||||
hasData: previous?.hasData ?? false,
|
||||
status: { loading: false, ok: false, error: event.error },
|
||||
};
|
||||
}
|
||||
observedQueries.set(event.requestId, value);
|
||||
updateQueries();
|
||||
updateStatuses();
|
||||
},
|
||||
subscribeData(listener: () => void) {
|
||||
dataSubscribers.add(listener);
|
||||
return () => {
|
||||
dataSubscribers.delete(listener);
|
||||
};
|
||||
},
|
||||
subscribeQuery(id: string, listener: () => void) {
|
||||
const listeners = querySubscribers.get(id) ?? new Set<() => void>();
|
||||
querySubscribers.set(id, listeners);
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (!listeners.size) querySubscribers.delete(id);
|
||||
};
|
||||
},
|
||||
subscribe: (id: string, listener: () => void) => {
|
||||
const listeners = subscribers.get(id) ?? new Set<() => void>();
|
||||
subscribers.set(id, listeners);
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (!listeners.size) subscribers.delete(id);
|
||||
};
|
||||
},
|
||||
subscribeJournal: (listener: () => void) => {
|
||||
journalSubscribers.add(listener);
|
||||
return () => {
|
||||
journalSubscribers.delete(listener);
|
||||
};
|
||||
},
|
||||
update(nextPreview: PreviewState, entries: WorkbenchJournalEntry[]) {
|
||||
if (
|
||||
(nextPreview.session && nextPreview.session.id !== preview.session?.id) ||
|
||||
nextPreview.data !== preview.data ||
|
||||
nextPreview.status !== preview.status
|
||||
)
|
||||
observedQueries.clear();
|
||||
preview = nextPreview;
|
||||
const journalChanged = journal !== entries;
|
||||
journal = entries;
|
||||
updateQueries();
|
||||
updateStatuses();
|
||||
if (journalChanged) {
|
||||
journalSubscribers.forEach((listener) => listener());
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
export type WorkbenchExecutionStore = ReturnType<typeof createWorkbenchExecutionStore>;
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
import { createContext, useCallback, useContext, useLayoutEffect, useState, useSyncExternalStore } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Badge, Tooltip } from "@mantine/core";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { CustomWidgetExecutionObserverProvider } from "@homarr/custom-widgets/runtime";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { PreviewState } from "./preview-state";
|
||||
import { createWorkbenchExecutionStore, EMPTY_JOURNAL, IDLE_EXECUTION } from "./execution-store";
|
||||
import type { WorkbenchExecutionStore } from "./execution-store";
|
||||
|
||||
const ExecutionContext = createContext<WorkbenchExecutionStore | null>(null);
|
||||
const noopSubscribe = () => () => undefined;
|
||||
export function WorkbenchExecutionProvider({ preview, children }: { preview: PreviewState; children: ReactNode }) {
|
||||
const [store] = useState(() => createWorkbenchExecutionStore(preview));
|
||||
const journal = clientApi.customWidget.previewJournal.useQuery(
|
||||
{ sessionId: preview.session?.id ?? "" },
|
||||
{ enabled: !!preview.session, refetchInterval: preview.session ? 2_000 : false },
|
||||
);
|
||||
useLayoutEffect(() => store.update(preview, journal.data ?? EMPTY_JOURNAL), [store, preview, journal.data]);
|
||||
return (
|
||||
<CustomWidgetExecutionObserverProvider observer={store.observeQuery}>
|
||||
<ExecutionContext.Provider value={store}>{children}</ExecutionContext.Provider>
|
||||
</CustomWidgetExecutionObserverProvider>
|
||||
);
|
||||
}
|
||||
export function useWorkbenchQuery(id: string) {
|
||||
const store = useContext(ExecutionContext);
|
||||
const subscribe = useCallback(
|
||||
(listener: () => void) => store?.subscribeQuery(id, listener) ?? (() => undefined),
|
||||
[store, id],
|
||||
);
|
||||
const snapshot = useCallback(() => store?.getQuery(id), [store, id]);
|
||||
return useSyncExternalStore(subscribe, snapshot, snapshot);
|
||||
}
|
||||
export function useWorkbenchQueryData() {
|
||||
const store = useContext(ExecutionContext);
|
||||
const snapshot = useCallback(() => store?.getData(), [store]);
|
||||
return useSyncExternalStore(store?.subscribeData ?? noopSubscribe, snapshot, snapshot);
|
||||
}
|
||||
export function useWorkbenchRequestStatus(id: string) {
|
||||
const store = useContext(ExecutionContext);
|
||||
const subscribe = useCallback(
|
||||
(listener: () => void) => store?.subscribe(id, listener) ?? (() => undefined),
|
||||
[store, id],
|
||||
);
|
||||
const snapshot = useCallback(() => store?.getStatus(id) ?? IDLE_EXECUTION, [store, id]);
|
||||
return useSyncExternalStore(subscribe, snapshot, snapshot);
|
||||
}
|
||||
export function useWorkbenchJournal() {
|
||||
const store = useContext(ExecutionContext);
|
||||
const snapshot = useCallback(() => store?.getJournal() ?? EMPTY_JOURNAL, [store]);
|
||||
return useSyncExternalStore(store?.subscribeJournal ?? noopSubscribe, snapshot, snapshot);
|
||||
}
|
||||
const statusColors = { idle: "gray", loading: "yellow", success: "teal", error: "red" };
|
||||
export function FlowExecutionStatus({ requestId }: { requestId: string }) {
|
||||
const t = useI18n("customWidget.flow");
|
||||
const status = useWorkbenchRequestStatus(requestId);
|
||||
let label = t(`execution.${status.state}`);
|
||||
if (status.simulated) label = t("execution.simulated");
|
||||
if (status.stale) label = t("execution.stale");
|
||||
let details = label;
|
||||
if (status.code !== undefined) details += ` · ${status.code}`;
|
||||
if (status.durationMs !== undefined) details += ` · ${Math.round(status.durationMs)} ms`;
|
||||
if (status.error) details += ` · ${status.error}`;
|
||||
return (
|
||||
<Tooltip label={details} multiline maw={300}>
|
||||
<Badge component="output" size="xs" variant="dot" color={statusColors[status.state]} aria-label={details}>
|
||||
{label}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { CustomWidgetWorkbenchForm } from "../_custom-widget-form-utils";
|
||||
import { readObject } from "./graph";
|
||||
import type { WidgetNode } from "./graph";
|
||||
import { freshFlowIdentifier } from "./identifiers";
|
||||
|
||||
const fields = { fragment: "fragments", preference: "preferences", content: "content", native: "native" } as const;
|
||||
export function isExtensionKind(kind: string): kind is keyof typeof fields {
|
||||
return Object.hasOwn(fields, kind);
|
||||
}
|
||||
function collection(extensions: Record<string, unknown>, field: string): Record<string, unknown> | null {
|
||||
const value = extensions[field];
|
||||
if (value === undefined) return {};
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>;
|
||||
return null;
|
||||
}
|
||||
export function createExtensionCommands(form: CustomWidgetWorkbenchForm) {
|
||||
const write = (extensions: Record<string, unknown>) =>
|
||||
form.setFieldValue("extensions", JSON.stringify(extensions, null, 2));
|
||||
return {
|
||||
add(kind: keyof typeof fields) {
|
||||
if (kind === "native") return "native:new";
|
||||
const extensions = readObject(form.values.extensions);
|
||||
const field = fields[kind];
|
||||
const entries = collection(extensions, field);
|
||||
if (!entries) return `${kind}:new`;
|
||||
let limit = 32;
|
||||
if (kind === "preference") limit = 64;
|
||||
if (Object.keys(entries).length >= limit) return `${kind}:new`;
|
||||
const key = freshFlowIdentifier(entries, kind);
|
||||
let value: unknown = { type: "string", defaultValue: "" };
|
||||
if (kind === "fragment") value = "<Stack />";
|
||||
if (kind === "content") value = { type: "string", defaultValue: "", permission: "modify" };
|
||||
write({ ...extensions, [field]: { ...entries, [key]: value } });
|
||||
return `${kind}:${key}`;
|
||||
},
|
||||
duplicate(node: WidgetNode) {
|
||||
if (!isExtensionKind(node.data.kind)) return node.id;
|
||||
const extensions = readObject(form.values.extensions);
|
||||
const field = fields[node.data.kind];
|
||||
const entries = collection(extensions, field);
|
||||
if (!entries || !Object.hasOwn(entries, node.data.identifier)) return node.id;
|
||||
let limit = 32;
|
||||
if (node.data.kind === "preference" || node.data.kind === "native") limit = 64;
|
||||
if (Object.keys(entries).length >= limit) return node.id;
|
||||
let identifiers = entries;
|
||||
if (node.data.kind === "native") identifiers = { ...entries, ...readObject(form.values.requests) };
|
||||
const key = freshFlowIdentifier(identifiers, `${node.data.identifier}Copy`);
|
||||
write({ ...extensions, [field]: { ...entries, [key]: structuredClone(entries[node.data.identifier]) } });
|
||||
return `${node.data.kind}:${key}`;
|
||||
},
|
||||
remove(nodes: WidgetNode[]) {
|
||||
const selected = nodes.filter((node) => isExtensionKind(node.data.kind));
|
||||
if (!selected.length) return;
|
||||
const extensions = readObject(form.values.extensions);
|
||||
for (const node of selected) {
|
||||
if (!isExtensionKind(node.data.kind)) continue;
|
||||
const field = fields[node.data.kind];
|
||||
const existing = collection(extensions, field);
|
||||
if (!existing) return;
|
||||
const entries = { ...existing };
|
||||
delete entries[node.data.identifier];
|
||||
extensions[field] = entries;
|
||||
}
|
||||
write(extensions);
|
||||
},
|
||||
};
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { ActionIcon, Button, Group, NumberInput, Stack, Switch, Text, Textarea } from "@mantine/core";
|
||||
import { IconTrash } from "@tabler/icons-react";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
export function ExtensionDefaultEditor({
|
||||
type,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
type: string;
|
||||
value: unknown;
|
||||
onChange(value: unknown): void;
|
||||
}) {
|
||||
const t = useI18n("customWidget.flow.extensionEditor");
|
||||
if (type === "boolean")
|
||||
return (
|
||||
<Switch
|
||||
label={t("defaultValue")}
|
||||
checked={value === true}
|
||||
onChange={(event) => onChange(event.currentTarget.checked)}
|
||||
/>
|
||||
);
|
||||
if (type === "number") return <NumberInput label={t("defaultValue")} value={inputValue(value)} onChange={onChange} />;
|
||||
if (type === "string")
|
||||
return (
|
||||
<Textarea
|
||||
label={t("defaultValue")}
|
||||
autosize
|
||||
minRows={2}
|
||||
maxRows={6}
|
||||
maxLength={8192}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(event) => onChange(event.currentTarget.value)}
|
||||
/>
|
||||
);
|
||||
if (type !== "string[]" && type !== "number[]") return null;
|
||||
const values = Array.isArray(value) ? value : [];
|
||||
const update = (index: number, next: unknown) =>
|
||||
onChange(values.map((entry, position) => (position === index ? next : entry)));
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("defaultValue")}
|
||||
</Text>
|
||||
<Stack gap="xs" mah={240} style={{ overflow: "auto" }}>
|
||||
{values.map((entry, index) => (
|
||||
<Group key={index} gap="xs" wrap="nowrap">
|
||||
{type === "number[]" ? (
|
||||
<NumberInput
|
||||
aria-label={t("listValue", { index: index + 1 })}
|
||||
value={inputValue(entry)}
|
||||
onChange={(next) => update(index, next)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
) : (
|
||||
<Textarea
|
||||
aria-label={t("listValue", { index: index + 1 })}
|
||||
autosize
|
||||
minRows={1}
|
||||
maxRows={4}
|
||||
value={typeof entry === "string" ? entry : ""}
|
||||
maxLength={512}
|
||||
onChange={(event) => update(index, event.currentTarget.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
)}
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
aria-label={t("removeValue", { index: index + 1 })}
|
||||
onClick={() => onChange(values.filter((_, position) => position !== index))}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
<Button
|
||||
variant="light"
|
||||
size="compact-sm"
|
||||
disabled={values.length >= 256}
|
||||
onClick={() => onChange([...values, defaultForType(type.slice(0, -2))])}
|
||||
>
|
||||
{t("addValue")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function inputValue(value: unknown) {
|
||||
if (typeof value === "number" || typeof value === "string") return value;
|
||||
return "";
|
||||
}
|
||||
export function defaultForType(type: string): string | number | boolean | unknown[] {
|
||||
if (type === "number") return 0;
|
||||
if (type === "boolean") return false;
|
||||
if (type.endsWith("[]")) return [];
|
||||
return "";
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
"use client";
|
||||
|
||||
import { useDeferredValue, useEffect, useMemo, useState } from "react";
|
||||
import { Alert, Button, Code, CopyButton, Group, Select, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { customWidgetContentSchema, customWidgetPreferenceSchema } from "@homarr/custom-widgets/core";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { CodeEditor } from "../_code-editor";
|
||||
import { useCustomWidgetFormDocumentField, useCustomWidgetFormDocumentStore } from "../_custom-widget-form-state";
|
||||
import type { CustomWidgetWorkbenchForm } from "../_custom-widget-form-utils";
|
||||
import { isRecord } from "../_custom-widget-form-utils";
|
||||
import { defaultForType, ExtensionDefaultEditor } from "./extension-default-editor";
|
||||
import {
|
||||
extensionCollection,
|
||||
extensionEntryDependants,
|
||||
readExtensionCollection,
|
||||
validExtensionEntryName,
|
||||
} from "./extension-entry-policy";
|
||||
import type { ExtensionEntryKind } from "./extension-entry-policy";
|
||||
|
||||
const valueTypes = ["string", "number", "boolean", "string[]", "number[]"] as const;
|
||||
const typeLabels = {
|
||||
string: "text",
|
||||
number: "number",
|
||||
boolean: "toggle",
|
||||
"string[]": "textList",
|
||||
"number[]": "numberList",
|
||||
} as const;
|
||||
type EntryValues = Record<string, unknown>;
|
||||
|
||||
export function ExtensionEntryEditor({
|
||||
form,
|
||||
kind,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
form: CustomWidgetWorkbenchForm;
|
||||
kind: ExtensionEntryKind;
|
||||
selected?: string;
|
||||
onSelect?(id: string): void;
|
||||
}) {
|
||||
const t = useI18n("customWidget.flow.extensionEditor");
|
||||
const actions = useI18n("common.action");
|
||||
const flow = useI18n("customWidget.flow");
|
||||
const store = useCustomWidgetFormDocumentStore();
|
||||
const raw = useCustomWidgetFormDocumentField("extensions");
|
||||
const template = useCustomWidgetFormDocumentField("template");
|
||||
const [active, setActive] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const collection = readExtensionCollection(raw, kind);
|
||||
const entries = collection?.entries ?? {};
|
||||
const ids = Object.keys(entries);
|
||||
const id = ids.includes(active) ? active : (ids[0] ?? "");
|
||||
const entry = entries[id];
|
||||
const deferredTemplate = useDeferredValue(template);
|
||||
const fragments = useDeferredValue(JSON.stringify(collection?.extensions.fragments));
|
||||
const dependants = useMemo(() => {
|
||||
if (!id) return { invalid: false, nodes: [] as string[] };
|
||||
return extensionEntryDependants(deferredTemplate, fragments ? JSON.parse(fragments) : undefined, kind, id);
|
||||
}, [deferredTemplate, fragments, kind, id]);
|
||||
useEffect(() => {
|
||||
if (selected?.startsWith(`${kind}:`)) setActive(selected.slice(kind.length + 1));
|
||||
setError("");
|
||||
}, [selected, kind]);
|
||||
const select = (next: string) => {
|
||||
setActive(next);
|
||||
onSelect?.(`${kind}:${next}`);
|
||||
};
|
||||
const write = (change: (current: EntryValues) => EntryValues) => {
|
||||
const current = readExtensionCollection(store.getValues().extensions, kind);
|
||||
if (!current) return;
|
||||
form.setFieldValue(
|
||||
"extensions",
|
||||
JSON.stringify(
|
||||
{
|
||||
...current.extensions,
|
||||
[extensionCollection[kind]]: change(current.entries),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
};
|
||||
const update = (value: unknown) => write((current) => ({ ...current, [id]: value }));
|
||||
const create = () => {
|
||||
const current = readExtensionCollection(store.getValues().extensions, kind);
|
||||
if (!current || !validExtensionEntryName(name, current.entries)) return;
|
||||
const maximum = kind === "preference" ? 64 : 32;
|
||||
if (Object.keys(current.entries).length >= maximum) return;
|
||||
let value: unknown = { type: "string", defaultValue: "" };
|
||||
if (kind === "fragment") value = `<Text>{props.label ?? ${JSON.stringify(t("newViewText"))}}</Text>`;
|
||||
if (kind === "content") value = { type: "string", defaultValue: "", permission: "modify" };
|
||||
store.transaction(() => write((values) => ({ ...values, [name]: value })));
|
||||
select(name);
|
||||
setName("");
|
||||
};
|
||||
const remove = () => {
|
||||
const current = readExtensionCollection(store.getValues().extensions, kind);
|
||||
if (!current) return;
|
||||
const dependencies = extensionEntryDependants(store.getValues().template, current.extensions.fragments, kind, id);
|
||||
if (dependencies.invalid || dependencies.nodes.length) {
|
||||
setError(t("deleteBlocked"));
|
||||
return;
|
||||
}
|
||||
store.transaction(() => {
|
||||
write((values) => Object.fromEntries(Object.entries(values).filter(([key]) => key !== id)));
|
||||
const layout = store.getLayout();
|
||||
const nodes = Object.fromEntries(Object.entries(layout.nodes).filter(([key]) => key !== `${kind}:${id}`));
|
||||
store.setLayout({ ...layout, nodes });
|
||||
});
|
||||
setActive("");
|
||||
onSelect?.("widget");
|
||||
};
|
||||
const maximum = kind === "preference" ? 64 : 32;
|
||||
const createDisabled = !collection || ids.length >= maximum || !validExtensionEntryName(name, entries);
|
||||
let snippet = `{${kind === "preference" ? "preferences" : "content"}[${JSON.stringify(id)}]}`;
|
||||
if (kind === "fragment") snippet = `<View name=${JSON.stringify(id)} />`;
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(`${kind}Help`)}
|
||||
</Text>
|
||||
{!collection && <Alert color="red">{t("invalidCollection")}</Alert>}
|
||||
{ids.length > 0 && (
|
||||
<Select
|
||||
label={t(`select${kind}`)}
|
||||
searchable
|
||||
value={id}
|
||||
data={ids}
|
||||
onChange={(next) => {
|
||||
if (next) select(next);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{id && collection && (
|
||||
<Stack gap="sm">
|
||||
{kind === "fragment" && typeof entry === "string" && (
|
||||
<CodeEditor id={`fragment-${id}`} language="jsx" label={t("viewCode")} value={entry} onChange={update} />
|
||||
)}
|
||||
{kind !== "fragment" && isRecord(entry) && <ValueDeclaration kind={kind} entry={entry} onChange={update} />}
|
||||
{((kind === "fragment" && typeof entry !== "string") || (kind !== "fragment" && !isRecord(entry))) && (
|
||||
<Alert color="red">{t("invalidEntry")}</Alert>
|
||||
)}
|
||||
<Group justify="space-between">
|
||||
<Text size="xs" fw={600}>
|
||||
{t("useInJsx")}
|
||||
</Text>
|
||||
<CopyButton value={snippet}>
|
||||
{({ copied, copy }) => (
|
||||
<Button size="compact-xs" variant="subtle" onClick={copy}>
|
||||
{copied ? t("copied") : actions("copy")}
|
||||
</Button>
|
||||
)}
|
||||
</CopyButton>
|
||||
</Group>
|
||||
<Code block style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>
|
||||
{snippet}
|
||||
</Code>
|
||||
{(dependants.invalid || dependants.nodes.length > 0) && (
|
||||
<Alert color="yellow" title={t("deleteBlocked")}>
|
||||
<Text size="xs">{dependants.invalid ? t("resolveJsxFirst") : t("referencedBy")}</Text>
|
||||
{dependants.nodes.map((node) => (
|
||||
<Button key={node} variant="subtle" size="compact-xs" onClick={() => onSelect?.(node)}>
|
||||
{node === "widget" ? t("widgetJsx") : node.slice(9)}
|
||||
</Button>
|
||||
))}
|
||||
</Alert>
|
||||
)}
|
||||
<Button
|
||||
color="red"
|
||||
variant="subtle"
|
||||
disabled={dependants.invalid || dependants.nodes.length > 0}
|
||||
onClick={remove}
|
||||
>
|
||||
{t(`delete${kind}`)}
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
{error && <Alert color="red">{error}</Alert>}
|
||||
<Stack gap="xs">
|
||||
<TextInput
|
||||
label={t(`new${kind}`)}
|
||||
data-workbench-local-input
|
||||
value={name}
|
||||
maxLength={64}
|
||||
disabled={!collection || ids.length >= maximum}
|
||||
placeholder={kind}
|
||||
onChange={(event) => setName(event.currentTarget.value)}
|
||||
error={name && !validExtensionEntryName(name, entries) ? t("invalidName") : undefined}
|
||||
/>
|
||||
<Button onClick={create} disabled={createDisabled}>
|
||||
{t(`create${kind}`)}
|
||||
</Button>
|
||||
{ids.length >= maximum && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("entryLimit", { maximum })}
|
||||
</Text>
|
||||
)}
|
||||
{!collection && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{flow("extensionsJson")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function ValueDeclaration({
|
||||
kind,
|
||||
entry,
|
||||
onChange,
|
||||
}: {
|
||||
kind: "preference" | "content";
|
||||
entry: Record<string, unknown>;
|
||||
onChange(entry: Record<string, unknown>): void;
|
||||
}) {
|
||||
const t = useI18n("customWidget.flow.extensionEditor");
|
||||
const parsed = (kind === "content" ? customWidgetContentSchema : customWidgetPreferenceSchema).safeParse(entry);
|
||||
const type = String(entry.type ?? "");
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Select
|
||||
label={t("valueType")}
|
||||
description={t("typeChangeHelp")}
|
||||
value={type}
|
||||
data={valueTypes.map((value) => ({ value, label: t(typeLabels[value]) }))}
|
||||
onChange={(next) => {
|
||||
if (next) onChange({ ...entry, type: next, defaultValue: defaultForType(next) });
|
||||
}}
|
||||
/>
|
||||
<ExtensionDefaultEditor
|
||||
type={type}
|
||||
value={entry.defaultValue}
|
||||
onChange={(defaultValue) => onChange({ ...entry, defaultValue })}
|
||||
/>
|
||||
{kind === "content" && (
|
||||
<Select
|
||||
label={t("writePermission")}
|
||||
value={String(entry.permission ?? "")}
|
||||
data={[
|
||||
{ value: "modify", label: t("permissionModify") },
|
||||
{ value: "full", label: t("permissionFull") },
|
||||
]}
|
||||
onChange={(permission) => {
|
||||
if (permission) onChange({ ...entry, permission });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!parsed.success && (
|
||||
<Alert color="yellow">
|
||||
<Text size="xs">{parsed.error.issues.map((issue) => issue.message).join(" · ")}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { customWidgetIdentifierSchema } from "@homarr/custom-widgets/core";
|
||||
import { validateCustomJsxTemplate } from "@homarr/custom-widgets/jsx/analyzer";
|
||||
import { collectTemplateReferences } from "@homarr/custom-widgets/workbench";
|
||||
import { isRecord, parseJson } from "../_custom-widget-form-utils";
|
||||
|
||||
export type ExtensionEntryKind = "fragment" | "preference" | "content";
|
||||
export const extensionCollection = { fragment: "fragments", preference: "preferences", content: "content" } as const;
|
||||
|
||||
export function readExtensionCollection(raw: string, kind: ExtensionEntryKind) {
|
||||
const extensions = parseJson(raw || "{}");
|
||||
if (!isRecord(extensions)) return null;
|
||||
const value = extensions[extensionCollection[kind]];
|
||||
if (value !== undefined && !isRecord(value)) return null;
|
||||
return { extensions, entries: value ?? {} };
|
||||
}
|
||||
|
||||
export function validExtensionEntryName(name: string, entries: Record<string, unknown>) {
|
||||
return (
|
||||
customWidgetIdentifierSchema.safeParse(name).success &&
|
||||
!["constructor", "prototype"].includes(name) &&
|
||||
!Object.hasOwn(entries, name)
|
||||
);
|
||||
}
|
||||
|
||||
/** An incomplete template cannot prove an entry is unused, so deletion stays conservative. */
|
||||
export function extensionEntryDependants(template: string, fragments: unknown, kind: ExtensionEntryKind, name: string) {
|
||||
const templates: [string, unknown][] = [["widget", template]];
|
||||
if (isRecord(fragments)) {
|
||||
for (const [id, jsx] of Object.entries(fragments)) {
|
||||
if (kind !== "fragment" || id !== name) templates.push([`fragment:${id}`, jsx]);
|
||||
}
|
||||
} else if (fragments !== undefined) return { invalid: true, nodes: [] as string[] };
|
||||
const nodes = new Set<string>();
|
||||
for (const [id, jsx] of templates) {
|
||||
if (typeof jsx !== "string" || validateCustomJsxTemplate(jsx).some((issue) => issue.severity === "error"))
|
||||
return { invalid: true, nodes: [...nodes] };
|
||||
if (
|
||||
collectTemplateReferences(jsx).some(
|
||||
(reference) => reference.kind === kind && (reference.name === name || reference.name === "*"),
|
||||
)
|
||||
)
|
||||
nodes.add(id);
|
||||
}
|
||||
return { invalid: false, nodes: [...nodes] };
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Alert, Stack, Tabs, Text } from "@mantine/core";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { NativeCapabilityPicker } from "./native-editor";
|
||||
import { ExtensionEntryEditor } from "./extension-entry-editor";
|
||||
import { CodeEditor } from "../_code-editor";
|
||||
import { isRecord, parseJson } from "../_custom-widget-form-utils";
|
||||
import type { CustomWidgetWorkbenchForm } from "../_custom-widget-form-utils";
|
||||
import type { PreviewState } from "../_custom-widget-preview-panel";
|
||||
import { useCustomWidgetFormDocumentField, useCustomWidgetFormDocumentStore } from "../_custom-widget-form-state";
|
||||
|
||||
/** Extension editing writes the same draft as code and Assistant transactions. */
|
||||
export function WorkbenchExtensions({
|
||||
form,
|
||||
selected,
|
||||
preview,
|
||||
onSelect,
|
||||
}: {
|
||||
form: CustomWidgetWorkbenchForm;
|
||||
selected?: string;
|
||||
preview?: PreviewState;
|
||||
onSelect?(id: string): void;
|
||||
}) {
|
||||
const t = useI18n("customWidget.flow");
|
||||
const store = useCustomWidgetFormDocumentStore();
|
||||
const [tab, setTab] = useState<string | null>("native");
|
||||
useEffect(() => {
|
||||
const kind = selected?.split(":")[0];
|
||||
if (kind && ["fragment", "preference", "content", "native"].includes(kind)) setTab(kind);
|
||||
}, [selected]);
|
||||
const raw = useCustomWidgetFormDocumentField("extensions");
|
||||
const parsed = parseJson(raw || "{}");
|
||||
const valid = isRecord(parsed);
|
||||
const extensions = valid ? parsed : {};
|
||||
const writeStylesheet = (stylesheet: string) => {
|
||||
const current = parseJson(store.getValues().extensions || "{}");
|
||||
if (isRecord(current)) form.setFieldValue("extensions", JSON.stringify({ ...current, stylesheet }, null, 2));
|
||||
};
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("extensionDescription")}
|
||||
</Text>
|
||||
{!valid && <Alert color="red">{t("invalidExtensions")}</Alert>}
|
||||
<Tabs value={tab} onChange={setTab} keepMounted keepMountedMode="display-none">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="native">{t("native")}</Tabs.Tab>
|
||||
<Tabs.Tab value="styles">{t("stylesheet")}</Tabs.Tab>
|
||||
<Tabs.Tab value="fragment">{t("extensionEditor.views")}</Tabs.Tab>
|
||||
<Tabs.Tab value="preference">{t("extensionEditor.preferences")}</Tabs.Tab>
|
||||
<Tabs.Tab value="content">{t("extensionEditor.sharedContent")}</Tabs.Tab>
|
||||
<Tabs.Tab value="advanced">{t("extensionsJson")}</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<Tabs.Panel value="native" pt="sm">
|
||||
<NativeCapabilityPicker
|
||||
form={form}
|
||||
selected={selected}
|
||||
disabled={!valid}
|
||||
previewSessionId={preview?.session?.id}
|
||||
previewStale={preview?.stale}
|
||||
previewLiveActions={preview?.session?.liveActions}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="styles" pt="sm">
|
||||
<CodeEditor
|
||||
id="scoped-stylesheet"
|
||||
language="css"
|
||||
label={t("stylesheet")}
|
||||
value={typeof extensions.stylesheet === "string" ? extensions.stylesheet : ""}
|
||||
readOnly={!valid}
|
||||
onChange={writeStylesheet}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
{(["fragment", "preference", "content"] as const).map((kind) => (
|
||||
<Tabs.Panel key={kind} value={kind} pt="sm">
|
||||
<ExtensionEntryEditor form={form} kind={kind} selected={selected} onSelect={onSelect} />
|
||||
</Tabs.Panel>
|
||||
))}
|
||||
<Tabs.Panel value="advanced" pt="sm">
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
{t("extensionsJsonDescription")}
|
||||
</Text>
|
||||
<CodeEditor
|
||||
id="extensions-editor"
|
||||
language="json"
|
||||
label={t("extensionsJson")}
|
||||
value={raw}
|
||||
onChange={(value) => form.setFieldValue("extensions", value)}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
import { ActionIcon, Group, Text, Tooltip, UnstyledButton } from "@mantine/core";
|
||||
import { IconArrowLeft, IconPencil } from "@tabler/icons-react";
|
||||
import { Link } from "@homarr/ui";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useCustomWidgetFormDocumentField } from "../_custom-widget-form-state";
|
||||
|
||||
export function WorkbenchFrameControls({ onGeneral }: { onGeneral(): void }) {
|
||||
const t = useI18n("customWidget.flow");
|
||||
const name = useCustomWidgetFormDocumentField("name");
|
||||
return (
|
||||
<Group gap="sm" wrap="nowrap" style={{ flex: "1 1 180px", minWidth: 0 }}>
|
||||
<Tooltip label={t("backToWidgets")}>
|
||||
<ActionIcon component={Link} href="/manage/custom-widgets" variant="subtle" aria-label={t("backToWidgets")}>
|
||||
<IconArrowLeft size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("general")}>
|
||||
<Text component="h1" m={0} size="sm" fw={600} style={{ minWidth: 0 }}>
|
||||
<UnstyledButton
|
||||
type="button"
|
||||
onClick={onGeneral}
|
||||
aria-label={`${name || t("workbench")} · ${t("general")}`}
|
||||
style={{ display: "flex", alignItems: "center", gap: 6, maxWidth: "100%" }}
|
||||
>
|
||||
<Text component="span" inherit truncate>
|
||||
{name || t("workbench")}
|
||||
</Text>
|
||||
<IconPencil size={13} style={{ flexShrink: 0 }} aria-hidden />
|
||||
</UnstyledButton>
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
/** Fill the management area without remounting the editor; immersive mode also isolates the shell. */
|
||||
export function useWorkbenchFullscreen() {
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
useEffect(() => {
|
||||
const workspace = ref.current;
|
||||
if (!workspace) return;
|
||||
const managementMain = workspace.closest("main");
|
||||
const previousFocus = document.activeElement;
|
||||
const overflow = document.body.style.getPropertyValue("overflow");
|
||||
const overflowPriority = document.body.style.getPropertyPriority("overflow");
|
||||
const isolated: { element: HTMLElement; inert: boolean; hidden: string | null }[] = [];
|
||||
const formId = workspace.querySelector("button[form]")?.getAttribute("form");
|
||||
if (!workspace.contains(previousFocus)) workspace.focus({ preventScroll: true });
|
||||
document.body.style.setProperty("overflow", "hidden");
|
||||
let current: HTMLElement | null = workspace;
|
||||
while (current?.parentElement) {
|
||||
for (const sibling of current.parentElement.children) {
|
||||
if (!(sibling instanceof HTMLElement) || sibling === current) continue;
|
||||
if (sibling.matches("script, style, link, [data-portal], [data-mantine-portal]")) continue;
|
||||
if (sibling instanceof HTMLFormElement && sibling.id === formId) continue;
|
||||
isolated.push({ element: sibling, inert: sibling.inert, hidden: sibling.getAttribute("aria-hidden") });
|
||||
sibling.inert = true;
|
||||
sibling.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
current = current.parentElement;
|
||||
if (!fullscreen && current === managementMain) break;
|
||||
if (current === document.body) break;
|
||||
}
|
||||
return () => {
|
||||
document.body.style.setProperty("overflow", overflow, overflowPriority);
|
||||
for (const { element, inert, hidden } of isolated) {
|
||||
element.inert = inert;
|
||||
if (hidden === null) element.removeAttribute("aria-hidden");
|
||||
else element.setAttribute("aria-hidden", hidden);
|
||||
}
|
||||
if (!workspace.isConnected && previousFocus instanceof HTMLElement && previousFocus.isConnected)
|
||||
previousFocus.focus({ preventScroll: true });
|
||||
};
|
||||
}, [fullscreen]);
|
||||
return { fullscreen, ref, toggleFullscreen: () => setFullscreen((current) => !current) };
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
import { collectCustomWidgetRequestReferences } from "@homarr/custom-widgets/core";
|
||||
import type { CustomWidgetEditorLayout, CustomJsxRequest } from "@homarr/custom-widgets/core";
|
||||
import { collectGeneratedBindings, collectTemplateReferences } from "@homarr/custom-widgets/workbench";
|
||||
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
|
||||
|
||||
export type FlowKind =
|
||||
| "source"
|
||||
| "query"
|
||||
| "action"
|
||||
| "options"
|
||||
| "widget"
|
||||
| "group"
|
||||
| "native"
|
||||
| "fragment"
|
||||
| "preference"
|
||||
| "content";
|
||||
export interface WidgetNodeData extends Record<string, unknown> {
|
||||
kind: FlowKind;
|
||||
identifier: string;
|
||||
label: string;
|
||||
summary: string;
|
||||
}
|
||||
export type WidgetNode = Node<WidgetNodeData, "widget" | "preview" | "group">;
|
||||
export type WidgetEdge = Edge<{
|
||||
relationship: "source" | "data" | "invoke" | "invalidates" | "option" | "composition" | "preference" | "content";
|
||||
generatedBindingIds?: string[];
|
||||
}>;
|
||||
type Relationship = NonNullable<WidgetEdge["data"]>["relationship"];
|
||||
export function readObject(value: string): Record<string, unknown> {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
/* Preserve invalid JSON in the inspector. */
|
||||
}
|
||||
return {};
|
||||
}
|
||||
export function record(value: unknown): Record<string, unknown> {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>;
|
||||
return {};
|
||||
}
|
||||
const colors = {
|
||||
source: "var(--mantine-color-blue-6)",
|
||||
data: "var(--mantine-color-teal-6)",
|
||||
invoke: "var(--mantine-color-orange-6)",
|
||||
invalidates: "var(--mantine-color-grape-6)",
|
||||
option: "var(--mantine-color-gray-6)",
|
||||
composition: "var(--mantine-color-pink-6)",
|
||||
preference: "var(--mantine-color-yellow-6)",
|
||||
content: "var(--mantine-color-cyan-6)",
|
||||
};
|
||||
|
||||
export function projectWidgetGraph(values: CustomWidgetFormValues, layout: CustomWidgetEditorLayout) {
|
||||
const nodes: WidgetNode[] = [];
|
||||
const edges: WidgetEdge[] = [];
|
||||
const generatedEdges = new Map<string, Set<string> | null>();
|
||||
for (const [id, group] of Object.entries(layout.groups)) {
|
||||
const position = layout.nodes[id] ?? { x: 0, y: 0 };
|
||||
nodes.push({
|
||||
id,
|
||||
type: "group",
|
||||
position: { x: position.x, y: position.y },
|
||||
style: { width: group.width, height: group.height },
|
||||
data: { kind: "group", identifier: id, label: group.title, summary: "" },
|
||||
});
|
||||
}
|
||||
const addNode = (
|
||||
id: string,
|
||||
kind: FlowKind,
|
||||
identifier: string,
|
||||
label: string,
|
||||
summary: string,
|
||||
x: number,
|
||||
y: number,
|
||||
) => {
|
||||
const position = layout.nodes[id] ?? { x, y };
|
||||
nodes.push({
|
||||
id,
|
||||
type: kind === "widget" ? "preview" : "widget",
|
||||
ariaLabel: label,
|
||||
position: { x: position.x, y: position.y },
|
||||
parentId: position.parentId && layout.groups[position.parentId] ? position.parentId : undefined,
|
||||
dragHandle: ".widget-node-drag",
|
||||
data: { kind, identifier, label, summary },
|
||||
});
|
||||
};
|
||||
const addEdge = (source: string, target: string, relationship: Relationship, bindingId?: string) => {
|
||||
const id = `${source}:${relationship}:${target}`;
|
||||
if (!bindingId) generatedEdges.set(id, null);
|
||||
else if (!generatedEdges.has(id)) generatedEdges.set(id, new Set([bindingId]));
|
||||
else generatedEdges.get(id)?.add(bindingId);
|
||||
if (edges.some((edge) => edge.id === id)) return;
|
||||
edges.push({
|
||||
id,
|
||||
source,
|
||||
target,
|
||||
sourceHandle: "out",
|
||||
targetHandle: "in",
|
||||
data: { relationship },
|
||||
reconnectable: relationship === "source" ? "source" : relationship === "invalidates",
|
||||
deletable: relationship === "invalidates",
|
||||
style: {
|
||||
stroke: colors[relationship],
|
||||
strokeWidth: 1.5,
|
||||
strokeDasharray: relationship === "invalidates" ? "5 5" : undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
Object.entries(readObject(values.sources)).forEach(([id, source], index) => {
|
||||
const value = record(source);
|
||||
addNode(`source:${id}`, "source", id, String(value.name ?? id), String(value.baseUrl ?? ""), 0, index * 145);
|
||||
});
|
||||
const requests = readObject(values.requests);
|
||||
const extensions = readObject(values.extensions ?? "{}");
|
||||
const native = record(extensions.native);
|
||||
const requestNode = (id: string) => (Object.hasOwn(native, id) ? `native:${id}` : `request:${id}`);
|
||||
Object.entries(requests).forEach(([id, raw], index) => {
|
||||
const request = record(raw);
|
||||
const kind = request.kind === "action" ? "action" : "query";
|
||||
addNode(
|
||||
`request:${id}`,
|
||||
kind,
|
||||
id,
|
||||
id,
|
||||
`${String(request.method ?? "GET")} ${String(request.path ?? "")}`,
|
||||
340,
|
||||
index * 130,
|
||||
);
|
||||
addEdge(`source:${String(request.source ?? "default")}`, `request:${id}`, "source");
|
||||
if (Array.isArray(request.invalidates))
|
||||
for (const target of request.invalidates) {
|
||||
if (typeof target === "string") addEdge(`request:${id}`, `request:${target}`, "invalidates");
|
||||
}
|
||||
if (typeof request.path === "string") {
|
||||
const references = collectCustomWidgetRequestReferences(
|
||||
request as Pick<CustomJsxRequest, "path" | "query" | "body">,
|
||||
);
|
||||
if (references.options.size) addEdge("options", `request:${id}`, "option");
|
||||
}
|
||||
});
|
||||
Object.entries(native).forEach(([id, raw], index) => {
|
||||
const capability = record(raw);
|
||||
addNode(
|
||||
`native:${id}`,
|
||||
"native",
|
||||
id,
|
||||
id,
|
||||
String(capability.capability ?? ""),
|
||||
340,
|
||||
(Object.keys(requests).length + index) * 130,
|
||||
);
|
||||
const references = collectCustomWidgetRequestReferences({ path: "", query: record(capability.input) });
|
||||
if (references.options.size || capability.integrationOption) addEdge("options", `native:${id}`, "option");
|
||||
});
|
||||
const parsedOptions = readObject(values.options);
|
||||
const optionNames = Object.keys(parsedOptions);
|
||||
for (const option of Object.values(parsedOptions)) {
|
||||
const requestId = record(record(option).choicesFrom).request;
|
||||
if (typeof requestId === "string") addEdge(requestNode(requestId), "options", "data");
|
||||
}
|
||||
if (optionNames.length)
|
||||
addNode("options", "options", "options", "options", optionNames.join(", "), 0, nodes.length * 80 + 100);
|
||||
for (const [id, value] of Object.entries(record(extensions.preferences)))
|
||||
addNode(`preference:${id}`, "preference", id, id, String(record(value).type ?? ""), 0, nodes.length * 130);
|
||||
for (const [id, value] of Object.entries(record(extensions.content)))
|
||||
addNode(
|
||||
`content:${id}`,
|
||||
"content",
|
||||
id,
|
||||
id,
|
||||
`${String(record(value).type ?? "")} · ${String(record(value).permission ?? "")}`,
|
||||
0,
|
||||
nodes.length * 130,
|
||||
);
|
||||
const fragments = Object.entries(record(extensions.fragments)).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
);
|
||||
fragments.forEach(([id], index) => addNode(`fragment:${id}`, "fragment", id, id, "JSX", 700, 260 + index * 130));
|
||||
addNode("widget", "widget", "widget", values.name, "JSX", 700, 100);
|
||||
const templates = [
|
||||
["widget", values.template],
|
||||
...fragments.map(([id, source]) => [`fragment:${id}`, source]),
|
||||
] as const;
|
||||
for (const [consumer, template] of templates) {
|
||||
if (!consumer || template === undefined) continue;
|
||||
const bindings =
|
||||
consumer === "widget"
|
||||
? collectGeneratedBindings(
|
||||
template,
|
||||
fragments.map(([, source]) => source),
|
||||
)
|
||||
: [];
|
||||
for (const ref of collectTemplateReferences(template)) {
|
||||
const addReference = (source: string, target: string, relationship: Relationship) => {
|
||||
const binding = bindings.find(
|
||||
(entry) =>
|
||||
entry.source === source &&
|
||||
entry.target === target &&
|
||||
entry.relationship === relationship &&
|
||||
ref.from >= entry.contentFrom &&
|
||||
ref.to <= entry.contentTo,
|
||||
);
|
||||
addEdge(source, target, relationship, binding?.id);
|
||||
};
|
||||
if (ref.kind === "data" || ref.kind === "status") addReference(requestNode(ref.name), consumer, "data");
|
||||
if (ref.kind === "request") addReference(consumer, requestNode(ref.name), "invoke");
|
||||
if (ref.kind === "native") {
|
||||
const descriptor = record(native[ref.name]);
|
||||
if (descriptor.kind === "action") addReference(consumer, `native:${ref.name}`, "invoke");
|
||||
else addReference(`native:${ref.name}`, consumer, "data");
|
||||
}
|
||||
if (ref.kind === "options") addReference("options", consumer, "option");
|
||||
if (["fragment", "preference", "content"].includes(ref.kind)) {
|
||||
const referenced = nodes.filter(
|
||||
(node) => node.data.kind === ref.kind && (ref.name === "*" || node.data.identifier === ref.name),
|
||||
);
|
||||
for (const target of referenced) {
|
||||
if (ref.kind === "fragment") addReference(target.id, consumer, "composition");
|
||||
if (ref.kind === "preference") addReference(target.id, consumer, "preference");
|
||||
if (ref.kind === "content") addReference(target.id, consumer, "content");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const edge of edges) {
|
||||
const bindings = generatedEdges.get(edge.id);
|
||||
if (!edge.data || !bindings?.size) continue;
|
||||
edge.data.generatedBindingIds = [...bindings];
|
||||
edge.deletable = true;
|
||||
edge.reconnectable = edge.data.relationship === "invoke" ? "target" : "source";
|
||||
}
|
||||
const ids = new Set(nodes.map((n) => n.id));
|
||||
return { nodes, edges: edges.filter((edge) => ids.has(edge.source) && ids.has(edge.target)) };
|
||||
}
|
||||
|
||||
export async function arrangeWidgetGraph(nodes: WidgetNode[], edges: WidgetEdge[], preserveAnchor = false) {
|
||||
const engine = await import("./layout-engine");
|
||||
return engine.arrangeWidgetGraph(nodes, edges, preserveAnchor);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
import { memo } from "react";
|
||||
import { Text } from "@mantine/core";
|
||||
import type { NodeProps } from "@xyflow/react";
|
||||
import type { WidgetNode } from "./graph";
|
||||
import classes from "./workbench.module.css";
|
||||
|
||||
export const FlowGroup = memo(function FlowGroup({ data, selected }: NodeProps<WidgetNode>) {
|
||||
return (
|
||||
<div className={classes.group} data-selected={selected}>
|
||||
<Text className="widget-node-drag" fw={600} size="sm">
|
||||
{data.label}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
|
||||
import type { CustomWidgetEditorLayout } from "@homarr/custom-widgets/core";
|
||||
|
||||
export type DocumentContent = Omit<CustomWidgetFormValues, "secrets">;
|
||||
export interface EditorSnapshot {
|
||||
content: DocumentContent;
|
||||
layout: CustomWidgetEditorLayout;
|
||||
sourceBindings: Record<string, string>;
|
||||
requestBindings: Record<string, string>;
|
||||
}
|
||||
export function withoutCredentials({ secrets: _secrets, ...content }: CustomWidgetFormValues): DocumentContent {
|
||||
return content;
|
||||
}
|
||||
|
||||
/** Semantic history never captures credentials, network results, or viewport state. */
|
||||
export function createEditorHistory() {
|
||||
const past: EditorSnapshot[] = [];
|
||||
const future: EditorSnapshot[] = [];
|
||||
let lastGroup = "";
|
||||
let lastTime = 0;
|
||||
return {
|
||||
record(previous: EditorSnapshot, group: string) {
|
||||
const now = Date.now();
|
||||
if (!group || group !== lastGroup || now - lastTime > 750) {
|
||||
past.push(previous);
|
||||
if (past.length > 80) past.shift();
|
||||
}
|
||||
lastGroup = group;
|
||||
lastTime = now;
|
||||
future.length = 0;
|
||||
},
|
||||
undo(current: EditorSnapshot) {
|
||||
const previous = past.pop();
|
||||
if (previous) future.push(current);
|
||||
lastGroup = "";
|
||||
return previous;
|
||||
},
|
||||
redo(current: EditorSnapshot) {
|
||||
const next = future.pop();
|
||||
if (next) past.push(current);
|
||||
lastGroup = "";
|
||||
return next;
|
||||
},
|
||||
get canUndo() {
|
||||
return past.length > 0;
|
||||
},
|
||||
get canRedo() {
|
||||
return future.length > 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export function freshFlowIdentifier(entries: Record<string, unknown>, prefix: string) {
|
||||
let count = 1;
|
||||
while (Object.hasOwn(entries, `${prefix.slice(0, 55)}${count}`)) count += 1;
|
||||
return `${prefix.slice(0, 55)}${count}`;
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
"use client";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useMemo } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import { ActionIcon, Button, Group, Stack, Text, TextInput, Tooltip } from "@mantine/core";
|
||||
import { IconCopy, IconPalette, IconSparkles, IconTrash, IconX } from "@tabler/icons-react";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useCustomWidgetFormDocumentStore } from "../_custom-widget-form-state";
|
||||
import { createFlowCommands } from "./commands";
|
||||
import { isExtensionKind } from "./extension-commands";
|
||||
import { RequestExecutionInspector } from "./request-execution";
|
||||
import { projectWidgetGraph } from "./graph";
|
||||
import type { WidgetNode, WidgetEdge } from "./graph";
|
||||
import type { FlowWorkbenchProps } from "./workbench-types";
|
||||
import { placeAddedNodes } from "./node-placement";
|
||||
import { Connections } from "./connections";
|
||||
import { DraftRecoverySettings } from "./recovery";
|
||||
import classes from "./workbench.module.css";
|
||||
const WorkbenchExtensions = dynamic(() => import("./extensions").then((module) => module.WorkbenchExtensions), {
|
||||
ssr: false,
|
||||
});
|
||||
const WorkbenchAssistant = dynamic(() => import("./assistant").then((module) => module.WorkbenchAssistant), {
|
||||
ssr: false,
|
||||
});
|
||||
export function WorkbenchInspector({
|
||||
sections,
|
||||
node,
|
||||
graph,
|
||||
selected,
|
||||
selection,
|
||||
pane,
|
||||
setPane,
|
||||
mode,
|
||||
editable,
|
||||
extensionsOpened,
|
||||
setExtensionsOpened,
|
||||
select,
|
||||
onDelete,
|
||||
onClose,
|
||||
onAssistant,
|
||||
inspectorRef,
|
||||
}: {
|
||||
sections: FlowWorkbenchProps;
|
||||
node?: WidgetNode;
|
||||
graph: { nodes: WidgetNode[]; edges: WidgetEdge[] };
|
||||
selected: string;
|
||||
selection: string[];
|
||||
pane: string;
|
||||
setPane(value: string): void;
|
||||
mode: string;
|
||||
editable: boolean;
|
||||
extensionsOpened: boolean;
|
||||
setExtensionsOpened(value: boolean): void;
|
||||
select(id: string): void;
|
||||
onDelete(): void;
|
||||
onClose(): void;
|
||||
onAssistant(): void;
|
||||
inspectorRef: RefObject<HTMLDivElement | null>;
|
||||
}) {
|
||||
const t = useI18n("customWidget.flow");
|
||||
const closeLabel = useI18n("common.action")("close");
|
||||
const store = useCustomWidgetFormDocumentStore();
|
||||
const commands = useMemo(() => createFlowCommands(sections.form, store), [sections.form, store]);
|
||||
const category = node?.data.kind ?? "general";
|
||||
const formMode = mode !== "flow";
|
||||
const canDuplicate = node && (["source", "query", "action"].includes(category) || isExtensionKind(category));
|
||||
let title = node?.data.label ?? t("general");
|
||||
if (formMode) title = t("form");
|
||||
else if (pane === "assistant") title = t("assistant");
|
||||
else if (pane === "manifest") title = t("manifest");
|
||||
else if (pane === "extensions" && !isExtensionKind(category)) title = t("stylesAndCapabilities");
|
||||
const assistantPane = useMemo(
|
||||
() => (
|
||||
<WorkbenchAssistant form={sections.form} selected={selected} onSelect={select} onPreview={sections.onPreview} />
|
||||
),
|
||||
[sections.form, sections.onPreview, selected, select],
|
||||
);
|
||||
const openExtensions = () => {
|
||||
setExtensionsOpened(true);
|
||||
setPane("extensions");
|
||||
};
|
||||
const duplicate = () => {
|
||||
if (!node) return;
|
||||
store.transaction(() => {
|
||||
const previous = projectWidgetGraph(store.getValues(), store.getLayout()).nodes;
|
||||
const id = commands.duplicate(node);
|
||||
if (id === node.id) return;
|
||||
const next = projectWidgetGraph(store.getValues(), store.getLayout()).nodes;
|
||||
store.setLayout({
|
||||
...store.getLayout(),
|
||||
nodes: { ...store.getLayout().nodes, ...placeAddedNodes(previous, next, id, node.id) },
|
||||
});
|
||||
select(id);
|
||||
});
|
||||
};
|
||||
return (
|
||||
<aside ref={inspectorRef} className={classes.inspector} aria-label={title}>
|
||||
<Group className={classes.inspectorHeader} justify="space-between" wrap="nowrap" mb="sm">
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text component="h2" m={0} size="sm" fw={600} truncate>
|
||||
{title}
|
||||
</Text>
|
||||
{node && !formMode && pane !== "assistant" && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(`kind.${node.data.kind}`)}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{pane !== "assistant" && (
|
||||
<Tooltip label={t("assistant")}>
|
||||
<ActionIcon variant="subtle" aria-label={t("assistant")} onClick={onAssistant}>
|
||||
<IconSparkles size={17} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label={closeLabel}>
|
||||
<ActionIcon variant="subtle" aria-label={closeLabel} onClick={onClose}>
|
||||
<IconX size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
{(formMode || (pane === "inspector" && category === "general")) && <DraftRecoverySettings />}
|
||||
<div hidden={!formMode && pane !== "inspector"}>
|
||||
<div hidden={!formMode && category !== "general"}>{sections.general}</div>
|
||||
<div hidden={!formMode && category !== "widget"}>{sections.template}</div>
|
||||
<div hidden={!formMode && category !== "source"}>{sections.sources}</div>
|
||||
<div hidden={!formMode && !["query", "action"].includes(category)}>{sections.requests}</div>
|
||||
<div hidden={!formMode && category !== "options"}>{sections.options}</div>
|
||||
{node?.data.kind === "group" && (
|
||||
<TextInput
|
||||
label={t("groupName")}
|
||||
value={node.data.label}
|
||||
maxLength={128}
|
||||
onChange={(event) => {
|
||||
const current = store.getLayout();
|
||||
const group = current.groups[node.id];
|
||||
if (!group) return;
|
||||
store.setLayout({
|
||||
...current,
|
||||
groups: { ...current.groups, [node.id]: { ...group, title: event.currentTarget.value } },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!formMode && category === "widget" && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
mt="sm"
|
||||
variant="subtle"
|
||||
leftSection={<IconPalette size={15} />}
|
||||
onClick={openExtensions}
|
||||
>
|
||||
{t("stylesAndCapabilities")}
|
||||
</Button>
|
||||
)}
|
||||
{node && ["query", "action"].includes(category) && (
|
||||
<RequestExecutionInspector requestId={node.data.identifier} preview={sections.execution} />
|
||||
)}
|
||||
{formMode && sections.assistant}
|
||||
</div>
|
||||
<div className={classes.assistantPane} hidden={formMode || pane !== "assistant"} data-flow-assistant>
|
||||
{assistantPane}
|
||||
</div>
|
||||
<div hidden={formMode || pane !== "extensions"}>
|
||||
{extensionsOpened && (
|
||||
<WorkbenchExtensions
|
||||
form={sections.form}
|
||||
selected={selected}
|
||||
preview={sections.execution}
|
||||
onSelect={select}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{node && !formMode && ["inspector", "extensions"].includes(pane) && (
|
||||
<Connections node={node} nodes={graph.nodes} edges={graph.edges} form={sections.form} onSelect={select} />
|
||||
)}
|
||||
<div hidden={!formMode && pane !== "manifest"}>{sections.advanced}</div>
|
||||
{!formMode && pane !== "assistant" && (
|
||||
<Group gap={4} mt="sm">
|
||||
{pane !== "inspector" && !isExtensionKind(category) && (
|
||||
<Button size="compact-xs" variant="subtle" onClick={() => setPane("inspector")}>
|
||||
{t("backToNode")}
|
||||
</Button>
|
||||
)}
|
||||
{canDuplicate && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
disabled={!editable}
|
||||
leftSection={<IconCopy size={14} />}
|
||||
onClick={duplicate}
|
||||
>
|
||||
{t("duplicate")}
|
||||
</Button>
|
||||
)}
|
||||
{selection.some((id) => id !== "widget") && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="red"
|
||||
variant="subtle"
|
||||
leftSection={<IconTrash size={14} />}
|
||||
onClick={onDelete}
|
||||
>
|
||||
{t("deleteSelection")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useEffect } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import type { CustomWidgetFormDocumentStore } from "../_custom-widget-form-state";
|
||||
import { readObject, record } from "./graph";
|
||||
|
||||
export function useWorkbenchKeybindings({
|
||||
store,
|
||||
selection,
|
||||
onDelete,
|
||||
returnFocus,
|
||||
onSelect,
|
||||
onClear,
|
||||
onCloseLibrary,
|
||||
onShowExecution,
|
||||
}: {
|
||||
store: CustomWidgetFormDocumentStore;
|
||||
selection: string[];
|
||||
onDelete(): void;
|
||||
onSelect(id: string): void;
|
||||
onClear(): void;
|
||||
onCloseLibrary(): void;
|
||||
onShowExecution(): void;
|
||||
returnFocus: RefObject<HTMLElement | null>;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const undo = (event: KeyboardEvent) => {
|
||||
if (!(event.ctrlKey || event.metaKey) || event.altKey || event.key.toLowerCase() !== "z") return;
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element) || !target.closest("[data-widget-workbench]")) return;
|
||||
if (
|
||||
target.closest(
|
||||
'input[type="password"], [data-workbench-credential], [data-workbench-local-input], [data-flow-preview], [data-flow-assistant]',
|
||||
)
|
||||
)
|
||||
return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (event.shiftKey) store.redo();
|
||||
else store.undo();
|
||||
};
|
||||
const graph = (event: KeyboardEvent) => {
|
||||
const target = event.target;
|
||||
if (event.defaultPrevented || !(target instanceof Element) || !target.closest("[data-widget-workbench]")) return;
|
||||
if (event.key === "Escape" && target.closest("[data-workbench-library]")) {
|
||||
event.preventDefault();
|
||||
onCloseLibrary();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
target.closest(
|
||||
'input, textarea, [contenteditable="true"], [role="combobox"], [data-flow-preview], [data-flow-assistant]',
|
||||
)
|
||||
)
|
||||
return;
|
||||
if ((event.key === "Delete" || event.key === "Backspace") && selection.some((id) => id !== "widget")) {
|
||||
event.preventDefault();
|
||||
onDelete();
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
onClear();
|
||||
returnFocus.current?.focus();
|
||||
}
|
||||
};
|
||||
const inspect = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ fragment?: string }>).detail;
|
||||
let id = "widget";
|
||||
if (detail?.fragment) id = `fragment:${detail.fragment}`;
|
||||
onSelect(id);
|
||||
};
|
||||
const insert = () => onSelect("widget");
|
||||
const inspectRequest = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ requestId?: unknown; field?: string }>).detail;
|
||||
if (typeof detail?.requestId !== "string") return;
|
||||
const requestId = detail.requestId;
|
||||
let id = `request:${requestId}`;
|
||||
if (Object.hasOwn(record(readObject(store.getValues().extensions).native), requestId)) id = `native:${requestId}`;
|
||||
else if (!Object.hasOwn(readObject(store.getValues().requests), requestId)) return;
|
||||
onSelect(id);
|
||||
if (detail.field === "path")
|
||||
requestAnimationFrame(() => {
|
||||
document
|
||||
.querySelector<HTMLElement>(
|
||||
`[data-workbench-request="${CSS.escape(requestId)}"] [data-workbench-request-path]`,
|
||||
)
|
||||
?.focus();
|
||||
});
|
||||
};
|
||||
window.addEventListener("homarr:widget-inspect", inspect);
|
||||
window.addEventListener("homarr:widget-code-insert", insert);
|
||||
window.addEventListener("homarr:widget-request-inspect", inspectRequest);
|
||||
window.addEventListener("homarr:widget-execution-panel", onShowExecution);
|
||||
document.addEventListener("keydown", undo, true);
|
||||
document.addEventListener("keydown", graph);
|
||||
return () => {
|
||||
window.removeEventListener("homarr:widget-inspect", inspect);
|
||||
window.removeEventListener("homarr:widget-code-insert", insert);
|
||||
window.removeEventListener("homarr:widget-request-inspect", inspectRequest);
|
||||
window.removeEventListener("homarr:widget-execution-panel", onShowExecution);
|
||||
document.removeEventListener("keydown", undo, true);
|
||||
document.removeEventListener("keydown", graph);
|
||||
};
|
||||
}, [store, selection, onDelete, returnFocus, onSelect, onClear, onCloseLibrary, onShowExecution]);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { ElkNode } from "elkjs/lib/elk-api";
|
||||
import type { CustomWidgetEditorLayout } from "@homarr/custom-widgets/core";
|
||||
import type { WidgetNode, WidgetEdge } from "./graph";
|
||||
|
||||
/** Only editor code loads this module; ELK runs in a real worker. */
|
||||
export async function arrangeWidgetGraph(nodes: WidgetNode[], edges: WidgetEdge[], preserveAnchor = false) {
|
||||
const { default: ELK } = await import("elkjs/lib/elk-api");
|
||||
const worker = new Worker(new URL("elkjs/lib/elk-worker.min.js", import.meta.url));
|
||||
const elk = new ELK({ workerFactory: () => worker });
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const deadline = new Promise<never>((_resolve, reject) => {
|
||||
timeout = setTimeout(() => reject(new Error("Layout timed out")), 15_000);
|
||||
});
|
||||
try {
|
||||
const ids = new Set(nodes.map((node) => node.id));
|
||||
const rootMembers = nodes.filter((node) => !node.parentId || !ids.has(node.parentId));
|
||||
const options = {
|
||||
"elk.algorithm": "layered",
|
||||
"elk.direction": "RIGHT",
|
||||
"elk.spacing.nodeNode": "40",
|
||||
"elk.layered.spacing.nodeNodeBetweenLayers": "100",
|
||||
"elk.hierarchyHandling": "INCLUDE_CHILDREN",
|
||||
};
|
||||
const buildNode = (node: WidgetNode): ElkNode => {
|
||||
if (node.data.kind === "group")
|
||||
return {
|
||||
id: node.id,
|
||||
layoutOptions: {
|
||||
...options,
|
||||
"elk.padding": "[top=60,left=30,bottom=30,right=30]",
|
||||
},
|
||||
children: nodes.filter((member) => member.parentId === node.id).map(buildNode),
|
||||
};
|
||||
return {
|
||||
id: node.id,
|
||||
width: 240,
|
||||
height: 120,
|
||||
layoutOptions: { "elk.portConstraints": "FIXED_SIDE" },
|
||||
ports: [
|
||||
{ id: `${node.id}:in`, layoutOptions: { "elk.port.side": "WEST" } },
|
||||
{ id: `${node.id}:out`, layoutOptions: { "elk.port.side": "EAST" } },
|
||||
],
|
||||
};
|
||||
};
|
||||
const result = await Promise.race([
|
||||
deadline,
|
||||
elk.layout({
|
||||
id: "root",
|
||||
layoutOptions: options,
|
||||
children: rootMembers.map(buildNode),
|
||||
edges: edges
|
||||
.filter(
|
||||
(edge) =>
|
||||
ids.has(edge.source) &&
|
||||
ids.has(edge.target) &&
|
||||
edge.data?.relationship !== "invalidates" &&
|
||||
edge.data?.relationship !== "invoke",
|
||||
)
|
||||
.map((edge) => ({ id: edge.id, sources: [`${edge.source}:out`], targets: [`${edge.target}:in`] })),
|
||||
}),
|
||||
]);
|
||||
const next: Pick<CustomWidgetEditorLayout, "nodes" | "groups"> = { nodes: {}, groups: {} };
|
||||
const original = new Map(nodes.map((node) => [node.id, node]));
|
||||
// Arranging a selection preserves its location and every unrelated position.
|
||||
const x = Math.min(0, ...rootMembers.map((node) => node.position.x));
|
||||
const y = Math.min(0, ...rootMembers.map((node) => node.position.y));
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
if (preserveAnchor && rootMembers.length) {
|
||||
offsetX =
|
||||
Math.min(...rootMembers.map((node) => node.position.x)) -
|
||||
Math.min(...(result.children ?? []).map((node) => node.x ?? 0));
|
||||
offsetY =
|
||||
Math.min(...rootMembers.map((node) => node.position.y)) -
|
||||
Math.min(...(result.children ?? []).map((node) => node.y ?? 0));
|
||||
} else {
|
||||
offsetX = x;
|
||||
offsetY = y;
|
||||
}
|
||||
const collect = (members: ElkNode[], topLevel: boolean) => {
|
||||
for (const member of members) {
|
||||
const previous = original.get(member.id);
|
||||
if (!previous) continue;
|
||||
let memberX = member.x ?? 0;
|
||||
let memberY = member.y ?? 0;
|
||||
if (topLevel) {
|
||||
memberX += offsetX;
|
||||
memberY += offsetY;
|
||||
}
|
||||
next.nodes[member.id] = { x: memberX, y: memberY, parentId: previous.parentId };
|
||||
if (previous.data.kind === "group") {
|
||||
next.groups[member.id] = {
|
||||
title: previous.data.label,
|
||||
width: Math.max(240, member.width ?? 240),
|
||||
height: Math.max(160, member.height ?? 160),
|
||||
};
|
||||
}
|
||||
collect(member.children ?? [], false);
|
||||
}
|
||||
};
|
||||
collect(result.children ?? [], true);
|
||||
return next;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
worker.terminate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { createId } from "@homarr/common";
|
||||
import type { CustomWidgetEditorLayout } from "@homarr/custom-widgets/core";
|
||||
import type { WidgetNode } from "./graph";
|
||||
import { absoluteNodePosition, nodeDimensions } from "./node-geometry";
|
||||
|
||||
export function captureLayout(nodes: WidgetNode[], previous: CustomWidgetEditorLayout): CustomWidgetEditorLayout {
|
||||
return {
|
||||
...previous,
|
||||
nodes: Object.fromEntries(nodes.map((node) => [node.id, { ...node.position, parentId: node.parentId }])),
|
||||
groups: Object.fromEntries(
|
||||
nodes
|
||||
.filter((node) => node.data.kind === "group")
|
||||
.map((node) => [
|
||||
node.id,
|
||||
{
|
||||
title: node.data.label,
|
||||
width: Number(node.style?.width ?? 600),
|
||||
height: Number(node.style?.height ?? 400),
|
||||
},
|
||||
]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function projectLayout(nodes: WidgetNode[], layout: CustomWidgetEditorLayout): WidgetNode[] {
|
||||
const previous = new Map(nodes.map((node) => [node.id, node]));
|
||||
const groups: WidgetNode[] = Object.entries(layout.groups).map(([id, group]) => {
|
||||
const stored = layout.nodes[id] ?? { x: 0, y: 0 };
|
||||
return {
|
||||
...previous.get(id),
|
||||
id,
|
||||
type: "group",
|
||||
dragHandle: ".widget-node-drag",
|
||||
position: { x: stored.x, y: stored.y },
|
||||
style: { width: group.width, height: group.height },
|
||||
data: { kind: "group", identifier: id, label: group.title, summary: "" },
|
||||
};
|
||||
});
|
||||
return [
|
||||
...groups,
|
||||
...nodes
|
||||
.filter((node) => node.data.kind !== "group")
|
||||
.map((node) => {
|
||||
const stored = layout.nodes[node.id];
|
||||
if (!stored) return { ...node, parentId: undefined };
|
||||
let parentId: string | undefined;
|
||||
if (stored.parentId && layout.groups[stored.parentId]) parentId = stored.parentId;
|
||||
return { ...node, parentId, position: { x: stored.x, y: stored.y } };
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
export function absolutePosition(node: WidgetNode, nodes: WidgetNode[]) {
|
||||
return absoluteNodePosition(node, new Map(nodes.map((entry) => [entry.id, entry])));
|
||||
}
|
||||
|
||||
export function groupNodes(nodes: WidgetNode[], title: string): WidgetNode[] {
|
||||
const selected = nodes.filter((node) => node.selected && node.data.kind !== "group");
|
||||
if (selected.length < 2) return nodes;
|
||||
const positions = selected.map((node) => absolutePosition(node, nodes));
|
||||
const dimensions = selected.map(nodeDimensions);
|
||||
const id = `group:${createId()}`;
|
||||
const x = Math.min(...positions.map((position) => position.x)) - 30;
|
||||
const y = Math.min(...positions.map((position) => position.y)) - 60;
|
||||
const width =
|
||||
Math.max(...positions.map((position, index) => position.x + (dimensions[index]?.width ?? 240))) - x + 30;
|
||||
const height =
|
||||
Math.max(...positions.map((position, index) => position.y + (dimensions[index]?.height ?? 140))) - y + 30;
|
||||
const ids = new Set(selected.map((node) => node.id));
|
||||
const result: WidgetNode[] = [
|
||||
{
|
||||
id,
|
||||
type: "group",
|
||||
dragHandle: ".widget-node-drag",
|
||||
position: { x, y },
|
||||
style: { width, height },
|
||||
data: {
|
||||
kind: "group",
|
||||
identifier: id,
|
||||
label: title,
|
||||
summary: "",
|
||||
},
|
||||
selected: true,
|
||||
},
|
||||
...nodes.map((node) => {
|
||||
if (!ids.has(node.id)) return { ...node, selected: false };
|
||||
const position = absolutePosition(node, nodes);
|
||||
return { ...node, parentId: id, position: { x: position.x - x, y: position.y - y }, selected: false };
|
||||
}),
|
||||
];
|
||||
return result;
|
||||
}
|
||||
|
||||
export function ungroupNodes(nodes: WidgetNode[]): WidgetNode[] {
|
||||
const selected = nodes.filter((node) => node.selected);
|
||||
const ids = new Set(selected.filter((node) => node.data.kind === "group").map((node) => node.id));
|
||||
const members = new Set(selected.map((node) => node.id));
|
||||
return nodes
|
||||
.filter((node) => !ids.has(node.id))
|
||||
.map((node) => {
|
||||
if (!node.parentId || (!ids.has(node.parentId) && !members.has(node.id))) return node;
|
||||
return { ...node, parentId: undefined, position: absolutePosition(node, nodes), selected: true };
|
||||
});
|
||||
}
|
||||
|
||||
export function expandGroupSelection(nodes: WidgetNode[], selection: string[]) {
|
||||
const ids = new Set(selection);
|
||||
return nodes.filter((node) => ids.has(node.id) || (node.parentId && ids.has(node.parentId)));
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
import { Accordion, Button, Group, Text, Tooltip } from "@mantine/core";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { FlowKind } from "./graph";
|
||||
|
||||
const primaryKinds = ["source", "query", "action", "options", "native"] as const;
|
||||
const additionalKinds = ["fragment", "preference", "content"] as const;
|
||||
|
||||
export function WorkbenchNodeLibrary({ editable, onAdd }: { editable: boolean; onAdd(kind: FlowKind): void }) {
|
||||
const t = useI18n("customWidget.flow");
|
||||
const buttons = (kinds: readonly ((typeof primaryKinds)[number] | (typeof additionalKinds)[number])[]) => (
|
||||
<Group gap={4}>
|
||||
{kinds.map((kind) => (
|
||||
<Tooltip
|
||||
key={kind}
|
||||
label={t(`nodeHelp.${kind}`)}
|
||||
multiline
|
||||
w={240}
|
||||
events={{ hover: true, focus: true, touch: true }}
|
||||
>
|
||||
<Button disabled={!editable} type="button" size="compact-xs" variant="light" onClick={() => onAdd(kind)}>
|
||||
{t(`kind.${kind}`)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Group>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Text size="xs" fw={600} c="dimmed" mt="md" mb={4}>
|
||||
{t("add")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
{t("libraryHint")}
|
||||
</Text>
|
||||
{buttons(primaryKinds)}
|
||||
<Accordion mt="xs" variant="default" styles={{ control: { padding: 0 }, content: { padding: "0 0 8px" } }}>
|
||||
<Accordion.Item value="composition">
|
||||
<Accordion.Control>
|
||||
<Text size="xs">{t("moreBlocks")}</Text>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>{buttons(additionalKinds)}</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
{!editable && (
|
||||
<Text size="xs" c="orange" mt="xs">
|
||||
{t("invalidGraphJson")}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Alert, Button, Checkbox, Fieldset, Group, Select, Stack, Text, Textarea, TextInput } from "@mantine/core";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { CustomWidgetIntegrationOption } from "@homarr/widgets/custom-api/integration-option";
|
||||
import { useCustomWidgetFormDocumentStore } from "../_custom-widget-form-state";
|
||||
import { buildDefinition, isRecord, parseJson } from "../_custom-widget-form-utils";
|
||||
import type { CustomWidgetWorkbenchForm } from "../_custom-widget-form-utils";
|
||||
import { nativeInputFields, nativeParameterFields, renameNativeCapability } from "./native-editor-model";
|
||||
import type { NativeInputSchema } from "./native-editor-model";
|
||||
import { NativeInputField } from "./native-input-field";
|
||||
import { NativeLiteralInput } from "./native-literal-input";
|
||||
import { NativeRequestTest } from "./native-request-test";
|
||||
import type { NativePreviewProps } from "./native-request-test";
|
||||
|
||||
export function NativeDescriptorEditor({
|
||||
form,
|
||||
id,
|
||||
entry,
|
||||
options,
|
||||
schema,
|
||||
disabled,
|
||||
onSelect,
|
||||
...preview
|
||||
}: NativePreviewProps & {
|
||||
form: CustomWidgetWorkbenchForm;
|
||||
id: string;
|
||||
entry: Record<string, unknown>;
|
||||
options: Record<string, unknown>;
|
||||
schema?: NativeInputSchema;
|
||||
disabled: boolean;
|
||||
onSelect?: (id: string) => void;
|
||||
}) {
|
||||
const t = useI18n("customWidget.flow");
|
||||
const store = useCustomWidgetFormDocumentStore();
|
||||
const [nextName, setName] = useState(id);
|
||||
const [error, setError] = useState<string>();
|
||||
const input = isRecord(entry.input) ? entry.input : {};
|
||||
const fields = nativeInputFields(schema ?? {});
|
||||
const hasParams = nativeParameterFields(input, schema ?? {}).length > 0;
|
||||
const action = entry.kind === "action";
|
||||
const confirmation = isRecord(entry.confirmation) ? entry.confirmation : {};
|
||||
const optionId = String(entry.integrationOption ?? "");
|
||||
const option = options[optionId];
|
||||
const patch = (changes: Record<string, unknown>) => {
|
||||
if (disabled) return;
|
||||
const extensions = parseJson(form.values.extensions);
|
||||
if (!isRecord(extensions) || !isRecord(extensions.native)) return;
|
||||
const current = extensions.native[id];
|
||||
if (!isRecord(current)) return;
|
||||
form.setFieldValue(
|
||||
"extensions",
|
||||
JSON.stringify(
|
||||
{
|
||||
...extensions,
|
||||
native: { ...extensions.native, [id]: { ...current, ...changes } },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
};
|
||||
const updateInput = (name: string, value: unknown) => {
|
||||
const next = { ...input, [name]: value };
|
||||
if (value === undefined) delete next[name];
|
||||
const changes: Record<string, unknown> = { input: next };
|
||||
if (nativeParameterFields(next, schema ?? {}).length) changes.trigger = "manual";
|
||||
patch(changes);
|
||||
};
|
||||
const rename = () => {
|
||||
if (disabled || id === nextName) return;
|
||||
try {
|
||||
const current = buildDefinition(form.values);
|
||||
if (!current.success) throw new Error(t("nativeRenameInvalidDraft"));
|
||||
const next = renameNativeCapability(current.data, id, nextName);
|
||||
store.transaction(() => {
|
||||
form.setFieldValue("extensions", JSON.stringify(next.extensions, null, 2));
|
||||
form.setFieldValue("template", next.template);
|
||||
const layout = store.getLayout();
|
||||
const position = layout.nodes[`native:${id}`];
|
||||
if (position) {
|
||||
const nodes = { ...layout.nodes, [`native:${nextName}`]: position };
|
||||
delete nodes[`native:${id}`];
|
||||
store.setLayout({ ...layout, nodes });
|
||||
}
|
||||
});
|
||||
setError(undefined);
|
||||
onSelect?.(`native:${nextName}`);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : String(cause));
|
||||
}
|
||||
};
|
||||
const extras = Object.fromEntries(
|
||||
Object.entries(input).filter(([name]) => !fields.some((field) => field.name === name)),
|
||||
);
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Text fw={600} size="sm">
|
||||
{String(entry.capability)} · {action ? t("kind.action") : t("kind.query")}
|
||||
</Text>
|
||||
<Group align="end" wrap="nowrap">
|
||||
<TextInput
|
||||
data-workbench-local-input
|
||||
label={t("nativeId")}
|
||||
value={nextName}
|
||||
onChange={(event) => setName(event.currentTarget.value)}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1 }}
|
||||
error={error}
|
||||
/>
|
||||
<Button variant="light" onClick={rename} disabled={disabled || nextName === id || !nextName}>
|
||||
{t("nativeRename")}
|
||||
</Button>
|
||||
</Group>
|
||||
{isRecord(option) && (
|
||||
<CustomWidgetIntegrationOption
|
||||
disabled={disabled}
|
||||
label={String(option.label ?? optionId)}
|
||||
value={option.default}
|
||||
kinds={Array.isArray(option.integrationKinds) ? (option.integrationKinds as string[]) : undefined}
|
||||
onChange={(value) => {
|
||||
if (disabled) return;
|
||||
const current = parseJson(form.values.options);
|
||||
if (!isRecord(current) || !isRecord(current[optionId])) return;
|
||||
form.setFieldValue(
|
||||
"options",
|
||||
JSON.stringify({ ...current, [optionId]: { ...current[optionId], default: value } }, null, 2),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
label={t("nativeTrigger")}
|
||||
value={String(entry.trigger ?? "load")}
|
||||
disabled={disabled || action}
|
||||
description={hasParams ? t("nativeManualRequired") : undefined}
|
||||
data={[
|
||||
{ value: "load", label: t("nativeTriggerLoad"), disabled: hasParams },
|
||||
{ value: "manual", label: t("nativeTriggerManual") },
|
||||
]}
|
||||
onChange={(trigger) => {
|
||||
if (trigger) patch({ trigger });
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
label={t("nativePermission")}
|
||||
value={String(entry.permission ?? "view")}
|
||||
disabled={disabled}
|
||||
data={[
|
||||
{ value: "view", label: t("nativePermissionView"), disabled: action },
|
||||
{ value: "modify", label: t("nativePermissionModify") },
|
||||
{ value: "full", label: t("nativePermissionFull") },
|
||||
]}
|
||||
onChange={(permission) => {
|
||||
if (permission) patch({ permission });
|
||||
}}
|
||||
/>
|
||||
{action && (
|
||||
<Fieldset legend={t("nativeConfirmation")} disabled={disabled}>
|
||||
<Stack gap="xs">
|
||||
<TextInput
|
||||
label={t("nativeConfirmationTitleLabel")}
|
||||
value={String(confirmation.title ?? "")}
|
||||
required
|
||||
maxLength={128}
|
||||
onChange={(event) => patch({ confirmation: { ...confirmation, title: event.currentTarget.value } })}
|
||||
/>
|
||||
<Textarea
|
||||
label={t("nativeConfirmationMessageLabel")}
|
||||
value={String(confirmation.message ?? "")}
|
||||
required
|
||||
maxLength={512}
|
||||
onChange={(event) => patch({ confirmation: { ...confirmation, message: event.currentTarget.value } })}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("nativeConfirmationButton")}
|
||||
value={String(confirmation.confirmLabel ?? "")}
|
||||
maxLength={64}
|
||||
onChange={(event) =>
|
||||
patch({ confirmation: { ...confirmation, confirmLabel: event.currentTarget.value } })
|
||||
}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t("nativeConfirmationDestructive")}
|
||||
checked={confirmation.destructive === true}
|
||||
onChange={(event) =>
|
||||
patch({ confirmation: { ...confirmation, destructive: event.currentTarget.checked } })
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
)}
|
||||
{!schema && <Alert color="yellow">{t("nativeSchemaUnavailable")}</Alert>}
|
||||
{fields.map((field) => (
|
||||
<NativeInputField
|
||||
key={field.name}
|
||||
{...field}
|
||||
value={input[field.name]}
|
||||
options={options}
|
||||
disabled={disabled}
|
||||
onChange={(value) => updateInput(field.name, value)}
|
||||
/>
|
||||
))}
|
||||
{(!schema || Object.keys(extras).length > 0) && (
|
||||
<NativeLiteralInput
|
||||
label={t("nativeAdvancedInputs")}
|
||||
schema={{ type: "object" }}
|
||||
value={schema ? extras : input}
|
||||
disabled={disabled}
|
||||
onChange={(value) => {
|
||||
if (!isRecord(value)) return;
|
||||
const declared = Object.fromEntries(
|
||||
Object.entries(input).filter(([name]) => fields.some((field) => field.name === name)),
|
||||
);
|
||||
const next = { ...declared, ...value };
|
||||
patch({ input: next, ...(nativeParameterFields(next, schema ?? {}).length ? { trigger: "manual" } : {}) });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("nativeBindingHelp", { id })}
|
||||
</Text>
|
||||
<NativeRequestTest
|
||||
key={`${id}:${entry.capability}`}
|
||||
id={id}
|
||||
input={input}
|
||||
schema={schema ?? {}}
|
||||
action={action}
|
||||
{...preview}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { customWidgetDefinitionSchema, customWidgetIdentifierSchema } from "@homarr/custom-widgets/core";
|
||||
import { isRecord } from "@homarr/common";
|
||||
import { collectTemplateReferences } from "@homarr/custom-widgets/workbench";
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
|
||||
export type NativeInputSchema = Record<string, unknown>;
|
||||
|
||||
export function nativeInputFields(schema: NativeInputSchema) {
|
||||
if (!isRecord(schema.properties)) return [];
|
||||
const required = Array.isArray(schema.required) ? schema.required : [];
|
||||
return Object.entries(schema.properties).map(([name, value]) => ({
|
||||
name,
|
||||
schema: isRecord(value) ? value : {},
|
||||
required: required.includes(name),
|
||||
}));
|
||||
}
|
||||
|
||||
export function nativeLiteralDefault(schema: NativeInputSchema): unknown {
|
||||
if (schema.default !== undefined) return schema.default;
|
||||
if (Array.isArray(schema.enum)) return schema.enum[0];
|
||||
if (schema.type === "boolean") return false;
|
||||
if (schema.type === "number" || schema.type === "integer") return schema.minimum ?? 0;
|
||||
if (schema.type === "array") return [];
|
||||
if (schema.type === "object") return {};
|
||||
return "";
|
||||
}
|
||||
|
||||
export function isCompatibleNativeOption(option: unknown, schema: NativeInputSchema): boolean {
|
||||
if (!isRecord(option) || option.control === "integration") return false;
|
||||
const value = option.default;
|
||||
if (schema.type === "integer") return typeof value === "number" && Number.isInteger(value);
|
||||
if (["string", "number", "boolean"].includes(String(schema.type))) return typeof value === schema.type;
|
||||
if (schema.type === "object") return isRecord(value);
|
||||
if (schema.type !== "array") return true;
|
||||
if (!Array.isArray(value)) return false;
|
||||
if (!isRecord(schema.items)) return true;
|
||||
const type = schema.items.type;
|
||||
if (type === "integer") return value.every((entry) => typeof entry === "number" && Number.isInteger(entry));
|
||||
if (["string", "number", "boolean"].includes(String(type))) return value.every((entry) => typeof entry === type);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Match parameter references to their field schema without storing execution values in the draft. */
|
||||
export function nativeParameterFields(input: unknown, schema: NativeInputSchema) {
|
||||
const fields = new Map<string, NativeInputSchema>();
|
||||
const visit = (value: unknown, current: NativeInputSchema) => {
|
||||
if (Array.isArray(value)) {
|
||||
const item = isRecord(current.items) ? current.items : {};
|
||||
value.forEach((entry) => visit(entry, item));
|
||||
return;
|
||||
}
|
||||
if (!isRecord(value)) return;
|
||||
if (typeof value.$param === "string") {
|
||||
fields.set(value.$param, current);
|
||||
return;
|
||||
}
|
||||
const properties = isRecord(current.properties) ? current.properties : {};
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
visit(child, isRecord(properties[key]) ? properties[key] : {});
|
||||
}
|
||||
};
|
||||
visit(input, schema);
|
||||
return [...fields].map(([name, fieldSchema]) => ({ name, schema: fieldSchema }));
|
||||
}
|
||||
|
||||
export function renameNativeCapability(definition: HomarrCustomWidgetV2, currentId: string, nextId: string) {
|
||||
customWidgetIdentifierSchema.parse(nextId);
|
||||
const native = definition.extensions?.native;
|
||||
if (!native?.[currentId]) throw new Error(`Unknown capability '${currentId}'`);
|
||||
if (nextId !== currentId && (native[nextId] || definition.requests[nextId])) {
|
||||
throw new Error(`Request or capability '${nextId}' already exists`);
|
||||
}
|
||||
const rewrite = (template: string) => {
|
||||
const references = collectTemplateReferences(template)
|
||||
.filter((entry) => entry.name === currentId && ["native", "data", "status"].includes(entry.kind))
|
||||
.toSorted((left, right) => right.from - left.from);
|
||||
let result = template;
|
||||
for (const reference of references) {
|
||||
const original = template.slice(reference.from, reference.to);
|
||||
let from = reference.from;
|
||||
let replacement = nextId;
|
||||
if (original.startsWith('"')) replacement = JSON.stringify(nextId);
|
||||
else if (original.startsWith("'")) replacement = `'${nextId}'`;
|
||||
else if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/u.test(nextId)) {
|
||||
if (template[from - 1] !== ".") throw new Error("Use bracket notation in code before renaming this reference.");
|
||||
if (template[from - 2] !== "?") from -= 1;
|
||||
replacement = `[${JSON.stringify(nextId)}]`;
|
||||
}
|
||||
result = result.slice(0, from) + replacement + result.slice(reference.to);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
return customWidgetDefinitionSchema.parse({
|
||||
...definition,
|
||||
template: rewrite(definition.template),
|
||||
extensions: {
|
||||
...definition.extensions,
|
||||
native: Object.fromEntries(Object.entries(native).map(([id, entry]) => [id === currentId ? nextId : id, entry])),
|
||||
fragments:
|
||||
definition.extensions?.fragments &&
|
||||
Object.fromEntries(
|
||||
Object.entries(definition.extensions.fragments).map(([id, template]) => [id, rewrite(template)]),
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { Button, Select, Stack } from "@mantine/core";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { isRecord, parseJson } from "../_custom-widget-form-utils";
|
||||
import type { CustomWidgetWorkbenchForm } from "../_custom-widget-form-utils";
|
||||
import { useCustomWidgetFormDocumentField, useCustomWidgetFormDocumentStore } from "../_custom-widget-form-state";
|
||||
import { NativeDescriptorEditor } from "./native-descriptor-editor";
|
||||
import type { NativePreviewProps } from "./native-request-test";
|
||||
|
||||
export function NativeCapabilityPicker({
|
||||
form,
|
||||
selected,
|
||||
disabled,
|
||||
onSelect,
|
||||
...preview
|
||||
}: NativePreviewProps & {
|
||||
form: CustomWidgetWorkbenchForm;
|
||||
selected?: string;
|
||||
disabled: boolean;
|
||||
onSelect?: (id: string) => void;
|
||||
}) {
|
||||
const t = useI18n("customWidget.flow");
|
||||
const store = useCustomWidgetFormDocumentStore();
|
||||
const raw = useCustomWidgetFormDocumentField("extensions");
|
||||
const rawOptions = useCustomWidgetFormDocumentField("options");
|
||||
const [capability, setCapability] = useState<string | null>("beszel.systems");
|
||||
const [editingId, setEditingId] = useState<string>();
|
||||
const catalog = clientApi.customWidget.nativeCapabilities.useQuery();
|
||||
const parsed = parseJson(raw || "{}");
|
||||
const parsedOptions = parseJson(rawOptions);
|
||||
const extensions = isRecord(parsed) ? parsed : {};
|
||||
const native = isRecord(extensions.native) ? extensions.native : {};
|
||||
const options = isRecord(parsedOptions) ? parsedOptions : {};
|
||||
const add = () => {
|
||||
const contract = catalog.data?.find((entry) => entry.id === capability);
|
||||
if (!contract || !isRecord(parsedOptions)) return;
|
||||
const base = contract.id.replaceAll(".", "-");
|
||||
let id = base;
|
||||
let counter = 2;
|
||||
const requests = parseJson(form.values.requests);
|
||||
if (!isRecord(requests)) return;
|
||||
while (Object.hasOwn(native, id) || Object.hasOwn(requests, id) || Object.hasOwn(options, `${id}Integration`))
|
||||
id = `${base}-${counter++}`;
|
||||
const integrationOption = `${id}Integration`;
|
||||
const values: Record<string, unknown> = {};
|
||||
const inputSchema = contract.inputSchema;
|
||||
for (const key of inputSchema.required ?? []) values[key] = { $param: key };
|
||||
let trigger = "load";
|
||||
if (contract.kind === "action" || Object.keys(values).length) trigger = "manual";
|
||||
const descriptor = {
|
||||
capability: contract.id,
|
||||
kind: contract.kind,
|
||||
trigger,
|
||||
input: values,
|
||||
permission: contract.kind === "action" ? "modify" : "view",
|
||||
...(contract.integration ? { integrationOption } : {}),
|
||||
...(contract.kind === "action"
|
||||
? {
|
||||
confirmation: {
|
||||
title: t("nativeConfirmationTitle"),
|
||||
message: t("nativeConfirmationMessage", { capability: contract.id }),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
store.transaction(() => {
|
||||
form.setFieldValue(
|
||||
"extensions",
|
||||
JSON.stringify({ ...extensions, native: { ...native, [id]: descriptor } }, null, 2),
|
||||
);
|
||||
if (contract.integration)
|
||||
form.setFieldValue(
|
||||
"options",
|
||||
JSON.stringify(
|
||||
{
|
||||
...options,
|
||||
[integrationOption]: {
|
||||
label: t("nativeIntegrationLabel", { capability: contract.id }),
|
||||
control: "integration",
|
||||
default: "",
|
||||
integrationKinds: contract.integrationKinds,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
});
|
||||
setEditingId(id);
|
||||
onSelect?.(`native:${id}`);
|
||||
};
|
||||
let selectedId = editingId;
|
||||
if (selected?.startsWith("native:")) selectedId = selected.slice(7);
|
||||
if (!selectedId || !Object.hasOwn(native, selectedId)) selectedId = Object.keys(native)[0];
|
||||
const entries = Object.entries(native).filter(([id]) => id === selectedId);
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Select
|
||||
searchable
|
||||
label={t("chooseCapability")}
|
||||
value={capability}
|
||||
onChange={setCapability}
|
||||
data={(catalog.data ?? []).map((entry) => ({ value: entry.id, label: entry.id }))}
|
||||
error={catalog.error?.message}
|
||||
/>
|
||||
<Button onClick={add} disabled={disabled || !capability || !isRecord(parsedOptions)}>
|
||||
{t("addCapability")}
|
||||
</Button>
|
||||
{!selected?.startsWith("native:") && Object.keys(native).length > 0 && (
|
||||
<Select
|
||||
label={t("nativeId")}
|
||||
searchable
|
||||
data={Object.keys(native)}
|
||||
value={selectedId ?? null}
|
||||
disabled={disabled}
|
||||
onChange={(id) => {
|
||||
if (id) {
|
||||
setEditingId(id);
|
||||
onSelect?.(`native:${id}`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{entries.map(([id, entry]) => {
|
||||
if (!isRecord(entry)) return null;
|
||||
const contract = catalog.data?.find((candidate) => candidate.id === entry.capability);
|
||||
return (
|
||||
<NativeDescriptorEditor
|
||||
key={id}
|
||||
form={form}
|
||||
id={id}
|
||||
entry={entry}
|
||||
options={options}
|
||||
schema={contract?.inputSchema}
|
||||
disabled={disabled}
|
||||
onSelect={(nextId) => {
|
||||
setEditingId(nextId.slice(7));
|
||||
onSelect?.(nextId);
|
||||
}}
|
||||
{...preview}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { Select, Stack, TextInput } from "@mantine/core";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { customWidgetIdentifierSchema } from "@homarr/custom-widgets/core";
|
||||
import { isRecord } from "../_custom-widget-form-utils";
|
||||
import { isCompatibleNativeOption, nativeLiteralDefault } from "./native-editor-model";
|
||||
import type { NativeInputSchema } from "./native-editor-model";
|
||||
import { NativeLiteralInput } from "./native-literal-input";
|
||||
|
||||
export function NativeInputField({
|
||||
name,
|
||||
schema,
|
||||
required,
|
||||
value,
|
||||
options,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
name: string;
|
||||
schema: NativeInputSchema;
|
||||
required: boolean;
|
||||
value: unknown;
|
||||
options: Record<string, unknown>;
|
||||
disabled: boolean;
|
||||
onChange: (value: unknown) => void;
|
||||
}) {
|
||||
const t = useI18n("customWidget.flow");
|
||||
let mode = "literal";
|
||||
if (value === undefined && !required) mode = "default";
|
||||
if (isRecord(value) && typeof value.$option === "string") mode = "option";
|
||||
if (isRecord(value) && typeof value.$param === "string") mode = "param";
|
||||
const choices = [
|
||||
{ value: "literal", label: t("nativeLiteral") },
|
||||
{ value: "option", label: t("nativeOptionBinding") },
|
||||
];
|
||||
if (schema.type !== "array" && schema.type !== "object")
|
||||
choices.push({ value: "param", label: t("nativeParamBinding") });
|
||||
if (!required) choices.unshift({ value: "default", label: t("nativeUseDefault") });
|
||||
const optionChoices = Object.entries(options)
|
||||
.filter(([, option]) => isCompatibleNativeOption(option, schema))
|
||||
.map(([id, option]) => ({
|
||||
value: id,
|
||||
label: isRecord(option) && typeof option.label === "string" ? `${option.label} (${id})` : id,
|
||||
}));
|
||||
const reference = isRecord(value) ? value : {};
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Select
|
||||
label={name}
|
||||
required={required}
|
||||
value={mode}
|
||||
data={choices}
|
||||
disabled={disabled}
|
||||
onChange={(next) => {
|
||||
if (next === "default") onChange(undefined);
|
||||
if (next === "literal") onChange(nativeLiteralDefault(schema));
|
||||
if (next === "option") onChange({ $option: optionChoices[0]?.value ?? "" });
|
||||
if (next === "param") onChange({ $param: name });
|
||||
}}
|
||||
/>
|
||||
{mode === "literal" && (
|
||||
<NativeLiteralInput
|
||||
label={t("nativeInputValue", { name })}
|
||||
schema={schema}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
{mode === "option" && (
|
||||
<Select
|
||||
label={t("nativeOptionBinding")}
|
||||
searchable
|
||||
data={optionChoices}
|
||||
disabled={disabled}
|
||||
value={typeof reference.$option === "string" ? reference.$option : null}
|
||||
error={!optionChoices.some((option) => option.value === reference.$option) && t("nativeMissingOption")}
|
||||
onChange={(id) => {
|
||||
if (id) onChange({ $option: id });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{mode === "param" && (
|
||||
<TextInput
|
||||
label={t("nativeParameterName")}
|
||||
disabled={disabled}
|
||||
value={String(reference.$param ?? "")}
|
||||
description={t("nativeParameterHelp")}
|
||||
error={!customWidgetIdentifierSchema.safeParse(reference.$param).success && t("nativeInvalidId")}
|
||||
onChange={(event) => onChange({ $param: event.currentTarget.value })}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Checkbox, MultiSelect, NumberInput, Select, TagsInput, Textarea, TextInput } from "@mantine/core";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { isRecord } from "../_custom-widget-form-utils";
|
||||
import type { NativeInputSchema } from "./native-editor-model";
|
||||
|
||||
export interface NativeLiteralInputProps {
|
||||
label: string;
|
||||
schema: NativeInputSchema;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
/** Scalar and list inputs stay visual; only complex nested values need JSON. */
|
||||
export function NativeLiteralInput({ schema, value, onChange, ...props }: NativeLiteralInputProps) {
|
||||
const common = { ...props, description: typeof schema.description === "string" ? schema.description : undefined };
|
||||
if (schema.type === "boolean") {
|
||||
return (
|
||||
<Checkbox {...common} checked={value === true} onChange={(event) => onChange(event.currentTarget.checked)} />
|
||||
);
|
||||
}
|
||||
if (Array.isArray(schema.enum) && schema.enum.every((entry) => typeof entry === "string")) {
|
||||
return (
|
||||
<Select
|
||||
{...common}
|
||||
data={schema.enum as string[]}
|
||||
value={typeof value === "string" ? value : null}
|
||||
onChange={(next) => {
|
||||
if (next !== null) onChange(next);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (schema.type === "number" || schema.type === "integer") {
|
||||
return (
|
||||
<NumberInput
|
||||
{...common}
|
||||
value={typeof value === "number" || typeof value === "string" ? value : ""}
|
||||
min={typeof schema.minimum === "number" ? schema.minimum : undefined}
|
||||
max={typeof schema.maximum === "number" ? schema.maximum : undefined}
|
||||
allowDecimal={schema.type !== "integer"}
|
||||
clampBehavior="blur"
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (schema.type === "string") {
|
||||
return (
|
||||
<TextInput
|
||||
{...common}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
maxLength={typeof schema.maxLength === "number" ? schema.maxLength : undefined}
|
||||
onChange={(event) => onChange(event.currentTarget.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const items = isRecord(schema.items) ? schema.items : {};
|
||||
if (schema.type === "array" && items.type === "string") {
|
||||
const values = Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
|
||||
if (Array.isArray(items.enum) && items.enum.every((entry) => typeof entry === "string")) {
|
||||
return <MultiSelect {...common} data={items.enum as string[]} value={values} onChange={onChange} />;
|
||||
}
|
||||
return <TagsInput {...common} value={values} onChange={onChange} />;
|
||||
}
|
||||
return <NativeJsonInput {...props} value={value} onChange={onChange} />;
|
||||
}
|
||||
|
||||
function NativeJsonInput({ label, value, onChange, disabled, required }: Omit<NativeLiteralInputProps, "schema">) {
|
||||
const t = useI18n("customWidget.flow");
|
||||
const serialized = JSON.stringify(value ?? null, null, 2);
|
||||
const [text, setText] = useState(serialized);
|
||||
const [error, setError] = useState(false);
|
||||
useEffect(() => {
|
||||
setText(serialized);
|
||||
setError(false);
|
||||
}, [serialized]);
|
||||
return (
|
||||
<Textarea
|
||||
data-workbench-local-input={error || undefined}
|
||||
label={label}
|
||||
description={t("nativeNestedJson")}
|
||||
value={text}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
autosize
|
||||
minRows={3}
|
||||
error={error && t("nativeInvalidJson")}
|
||||
onChange={(event) => {
|
||||
const next = event.currentTarget.value;
|
||||
setText(next);
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(next);
|
||||
onChange(parsed);
|
||||
setError(false);
|
||||
} catch {
|
||||
setError(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { Alert, Button, Code, Fieldset, Select, Stack, Text } from "@mantine/core";
|
||||
import { fetchApi } from "@homarr/api/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { nativeLiteralDefault, nativeParameterFields } from "./native-editor-model";
|
||||
import type { NativeInputSchema } from "./native-editor-model";
|
||||
import { NativeLiteralInput } from "./native-literal-input";
|
||||
|
||||
export interface NativePreviewProps {
|
||||
previewSessionId?: string;
|
||||
previewStale?: boolean;
|
||||
previewLiveActions?: boolean;
|
||||
}
|
||||
|
||||
export function NativeRequestTest({
|
||||
id,
|
||||
input,
|
||||
schema,
|
||||
action,
|
||||
previewSessionId,
|
||||
previewStale,
|
||||
previewLiveActions,
|
||||
}: NativePreviewProps & {
|
||||
id: string;
|
||||
input: unknown;
|
||||
schema: NativeInputSchema;
|
||||
action: boolean;
|
||||
}) {
|
||||
const t = useI18n("customWidget.flow");
|
||||
const fields = nativeParameterFields(input, schema);
|
||||
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||
const [types, setTypes] = useState<Record<string, string>>({});
|
||||
const [running, setRunning] = useState(false);
|
||||
const pending = useRef(false);
|
||||
const [result, setResult] = useState<{
|
||||
context: string;
|
||||
error?: string;
|
||||
data?: unknown;
|
||||
status?: number;
|
||||
simulated?: boolean;
|
||||
}>();
|
||||
const responseText = useMemo(() => JSON.stringify(result?.data, null, 2) ?? "", [result?.data]);
|
||||
const params: Record<string, string | number | boolean> = {};
|
||||
let valid = true;
|
||||
const controls = fields.map(({ name, schema: fieldSchema }) => {
|
||||
const type = types[name] ?? fieldSchema.type;
|
||||
const primitive = ["string", "number", "integer", "boolean"].includes(String(type));
|
||||
const needsType = !["string", "number", "integer", "boolean"].includes(String(fieldSchema.type));
|
||||
const selectedSchema = primitive ? { ...fieldSchema, type } : { type: "string" };
|
||||
const value = values[name] ?? nativeLiteralDefault(selectedSchema);
|
||||
if (
|
||||
typeof value === "string" ||
|
||||
typeof value === "boolean" ||
|
||||
(typeof value === "number" && Number.isFinite(value))
|
||||
)
|
||||
params[name] = value;
|
||||
else valid = false;
|
||||
if ((type === "integer" || type === "number") && typeof value !== "number") valid = false;
|
||||
return (
|
||||
<Stack gap="xs" key={name}>
|
||||
{needsType && (
|
||||
<Select
|
||||
label={t("nativeParameterType", { name })}
|
||||
value={types[name] ?? "string"}
|
||||
data={["string", "number", "boolean"]}
|
||||
onChange={(next) => {
|
||||
if (!next) return;
|
||||
setTypes((current) => ({ ...current, [name]: next }));
|
||||
setValues((current) => ({ ...current, [name]: nativeLiteralDefault({ type: next }) }));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<NativeLiteralInput
|
||||
label={name}
|
||||
schema={selectedSchema}
|
||||
value={value}
|
||||
required
|
||||
disabled={running}
|
||||
onChange={(next) => setValues((current) => ({ ...current, [name]: next }))}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
});
|
||||
const context = JSON.stringify({ previewSessionId, id, input, params });
|
||||
const unavailable = !previewSessionId || previewStale;
|
||||
const liveAction = action && previewLiveActions;
|
||||
const run = async () => {
|
||||
if (!previewSessionId || previewStale || liveAction || !valid || pending.current) return;
|
||||
pending.current = true;
|
||||
setRunning(true);
|
||||
try {
|
||||
const request = { previewSessionId, nativeId: id, params };
|
||||
// confirmed=false also prevents a live action if the session mode changed before dispatch.
|
||||
const response = await (action
|
||||
? fetchApi.customWidget.nativeAction.mutate({ ...request, confirmed: false })
|
||||
: fetchApi.customWidget.nativeQuery.query(request));
|
||||
setResult({
|
||||
context,
|
||||
data: response.data,
|
||||
status: response.status,
|
||||
simulated: response.simulated,
|
||||
error: "error" in response ? response.error : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
setResult({ context, error: error instanceof Error ? error.message : String(error) });
|
||||
} finally {
|
||||
pending.current = false;
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Fieldset legend={t("nativeTestTitle")} data-workbench-local-input>
|
||||
<Stack gap="sm">
|
||||
{controls}
|
||||
{unavailable && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("nativeTestNeedsPreview")}
|
||||
</Text>
|
||||
)}
|
||||
{liveAction && <Alert color="yellow">{t("nativeTestLiveDisabled")}</Alert>}
|
||||
<Button
|
||||
variant="light"
|
||||
loading={running}
|
||||
disabled={unavailable || liveAction || !valid}
|
||||
onClick={() => void run()}
|
||||
>
|
||||
{action ? t("nativeTestSimulate") : t("nativeTestQuery")}
|
||||
</Button>
|
||||
{result && (
|
||||
<Stack gap="xs">
|
||||
{(result.context !== context || previewStale) && (
|
||||
<Text size="xs" c="orange">
|
||||
{t("nativeTestStaleResult")}
|
||||
</Text>
|
||||
)}
|
||||
{result.error && <Alert color="red">{result.error}</Alert>}
|
||||
{result.simulated && (
|
||||
<Text size="sm" component="output">
|
||||
{t("nativeTestSimulated")}
|
||||
</Text>
|
||||
)}
|
||||
{result.status !== undefined && !result.simulated && (
|
||||
<Text size="xs" component="output">
|
||||
{t("nativeTestStatus", { status: result.status })}
|
||||
</Text>
|
||||
)}
|
||||
{result.data !== undefined && !result.simulated && (
|
||||
<Code block mah={260} style={{ overflow: "auto" }}>
|
||||
{responseText.slice(0, 32768)}
|
||||
</Code>
|
||||
)}
|
||||
{responseText.length > 32768 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("nativeTestDataTruncated")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { WidgetNode } from "./graph";
|
||||
|
||||
/** Measurements are editor state; defaults cover nodes before React Flow measures them. */
|
||||
export function nodeDimensions(node: WidgetNode) {
|
||||
let fallback = { width: 240, height: 140 };
|
||||
if (node.data.kind === "widget") {
|
||||
// The widest preview is 720px; include its border, status, header and stale-state text.
|
||||
fallback = { width: 722, height: 520 };
|
||||
}
|
||||
if (node.data.kind === "group") fallback = { width: 600, height: 400 };
|
||||
return {
|
||||
width: dimension(node.measured?.width) ?? dimension(node.width) ?? dimension(node.style?.width) ?? fallback.width,
|
||||
height:
|
||||
dimension(node.measured?.height) ?? dimension(node.height) ?? dimension(node.style?.height) ?? fallback.height,
|
||||
};
|
||||
}
|
||||
|
||||
function dimension(value: unknown) {
|
||||
if (typeof value === "number" && Number.isFinite(value) && value > 0) return value;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function absoluteNodePosition(node: WidgetNode | undefined, nodes: ReadonlyMap<string, WidgetNode>) {
|
||||
const position = { x: 0, y: 0 };
|
||||
const visited = new Set<string>();
|
||||
while (node && !visited.has(node.id)) {
|
||||
visited.add(node.id);
|
||||
position.x += node.position.x;
|
||||
position.y += node.position.y;
|
||||
node = nodes.get(node.parentId ?? "");
|
||||
}
|
||||
return position;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { WidgetNode } from "./graph";
|
||||
import { absoluteNodePosition, nodeDimensions } from "./node-geometry";
|
||||
|
||||
interface Position {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
interface Bounds extends Position {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
const gap = 32;
|
||||
const coordinateLimit = 100_000;
|
||||
|
||||
/** Position only newly created nodes, near a selected branch or an explicit canvas drop. */
|
||||
export function placeAddedNodes(
|
||||
previous: WidgetNode[],
|
||||
nodes: WidgetNode[],
|
||||
primaryId: string,
|
||||
origin: string | Position,
|
||||
): Record<string, Position> {
|
||||
const previousIds = new Set(previous.map((node) => node.id));
|
||||
const added = nodes.filter((node) => !previousIds.has(node.id));
|
||||
const primary = added.find((node) => node.id === primaryId);
|
||||
if (!primary) return {};
|
||||
const previousNodes = new Map(previous.map((node) => [node.id, node]));
|
||||
const current = nodes.map((node) => ({
|
||||
...previousNodes.get(node.id),
|
||||
...node,
|
||||
measured: node.measured ?? previousNodes.get(node.id)?.measured,
|
||||
}));
|
||||
const lookup = new Map(current.map((node) => [node.id, node]));
|
||||
let preferred: Position;
|
||||
if (typeof origin === "string") {
|
||||
const anchor = absoluteNodePosition(lookup.get(origin), lookup);
|
||||
preferred = { x: anchor.x + 300, y: anchor.y + 40 };
|
||||
} else preferred = origin;
|
||||
const occupied = current.filter((node) => previousIds.has(node.id)).map((node) => bounds(node, lookup));
|
||||
const positions: Record<string, Position> = {};
|
||||
const ordered = [primary, ...added.filter((node) => node.id !== primaryId)];
|
||||
for (const node of ordered) {
|
||||
let candidate = preferred;
|
||||
if (node.id !== primaryId) {
|
||||
const placed = positions[primaryId] ?? candidate;
|
||||
candidate = { x: placed.x - 300, y: placed.y };
|
||||
}
|
||||
const dimensions = bounds(node, lookup);
|
||||
const position = findFreePosition(candidate, dimensions, occupied);
|
||||
positions[node.id] = position;
|
||||
occupied.push({ ...dimensions, ...position });
|
||||
}
|
||||
return positions;
|
||||
}
|
||||
|
||||
function bounds(node: WidgetNode, nodes: Map<string, WidgetNode>): Bounds {
|
||||
return { ...absoluteNodePosition(node, nodes), ...nodeDimensions(node) };
|
||||
}
|
||||
|
||||
function findFreePosition(preferred: Position, size: Bounds, occupied: Bounds[]): Position {
|
||||
const clamp = (value: number) => Math.max(-coordinateLimit, Math.min(coordinateLimit, value));
|
||||
const xs = [clamp(preferred.x), ...occupied.flatMap((box) => [box.x - size.width - gap, box.x + box.width + gap])];
|
||||
const ys = [clamp(preferred.y), ...occupied.flatMap((box) => [box.y - size.height - gap, box.y + box.height + gap])];
|
||||
ys.sort((left, right) => Math.abs(left - preferred.y) - Math.abs(right - preferred.y));
|
||||
// Keep the branch's column when possible; boundary candidates also handle densely packed groups.
|
||||
for (const x of new Set(xs)) {
|
||||
if (Math.abs(x) > coordinateLimit) continue;
|
||||
for (const y of new Set(ys)) {
|
||||
if (Math.abs(y) > coordinateLimit) continue;
|
||||
const overlaps = occupied.some(
|
||||
(box) =>
|
||||
x < box.x + box.width + gap &&
|
||||
x + size.width + gap > box.x &&
|
||||
y < box.y + box.height + gap &&
|
||||
y + size.height + gap > box.y,
|
||||
);
|
||||
if (!overlaps) return { x, y };
|
||||
}
|
||||
}
|
||||
return { x: clamp(preferred.x), y: clamp(preferred.y) };
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
.handle::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -7px;
|
||||
}
|
||||
|
||||
.handle:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { CustomWidgetEditorLayout } from "@homarr/custom-widgets/core";
|
||||
|
||||
export function renameDefinitionBinding(bindings: Record<string, string>, previousId: string, nextId: string) {
|
||||
const identity = bindings[previousId];
|
||||
if (!identity || previousId === nextId) return bindings;
|
||||
const { [previousId]: _previous, ...rest } = bindings;
|
||||
return { ...rest, [nextId]: identity };
|
||||
}
|
||||
|
||||
export function renameEditorNode(layout: CustomWidgetEditorLayout, previousId: string, nextId: string) {
|
||||
const entry = layout.nodes[previousId];
|
||||
if (previousId === nextId || !entry) return layout;
|
||||
const { [previousId]: _previous, ...nodes } = layout.nodes;
|
||||
return { ...layout, nodes: { ...nodes, [nextId]: entry } };
|
||||
}
|
||||
|
||||
export function getNodeRenames(previous: Record<string, string>, next: Record<string, string>, prefix: string) {
|
||||
const names = new Map(Object.entries(next).map(([name, identity]) => [identity, name]));
|
||||
return Object.fromEntries(
|
||||
Object.entries(previous).flatMap(([name, identity]) => {
|
||||
const nextName = names.get(identity);
|
||||
if (!nextName || nextName === name) return [];
|
||||
return [[`${prefix}:${name}`, `${prefix}:${nextName}`]];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function remapWorkbenchSelection(ids: string[], renames: Readonly<Record<string, string>>) {
|
||||
if (!ids.some((id) => Object.hasOwn(renames, id))) return ids;
|
||||
return ids.map((id) => renames[id] ?? id);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
import { memo, useEffect } from "react";
|
||||
import { Badge, Group, Text } from "@mantine/core";
|
||||
import { Handle, Position, useStore, useUpdateNodeInternals } from "@xyflow/react";
|
||||
import type { NodeProps } from "@xyflow/react";
|
||||
import {
|
||||
IconApi,
|
||||
IconBraces,
|
||||
IconCode,
|
||||
IconDatabase,
|
||||
IconPlayerPlay,
|
||||
IconFolder,
|
||||
IconPlug,
|
||||
IconAdjustments,
|
||||
IconNotes,
|
||||
IconLayoutGrid,
|
||||
} from "@tabler/icons-react";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { WidgetNode } from "./graph";
|
||||
import { connectionIssue } from "./commands";
|
||||
import classes from "./workbench.module.css";
|
||||
import portClasses from "./node-port.module.css";
|
||||
import { FlowExecutionStatus } from "./execution";
|
||||
const icons = {
|
||||
source: IconApi,
|
||||
query: IconDatabase,
|
||||
action: IconPlayerPlay,
|
||||
options: IconBraces,
|
||||
widget: IconCode,
|
||||
group: IconFolder,
|
||||
native: IconPlug,
|
||||
fragment: IconLayoutGrid,
|
||||
preference: IconAdjustments,
|
||||
content: IconNotes,
|
||||
};
|
||||
const colors = {
|
||||
source: "blue",
|
||||
query: "teal",
|
||||
action: "orange",
|
||||
options: "gray",
|
||||
widget: "red",
|
||||
group: "gray",
|
||||
native: "cyan",
|
||||
fragment: "pink",
|
||||
preference: "yellow",
|
||||
content: "cyan",
|
||||
};
|
||||
export function useValidConnectionTarget(id: string, isConnectable: boolean) {
|
||||
return useStore((state) => {
|
||||
let from = state.connection.fromNode;
|
||||
if (!from && state.connectionClickStartHandle) {
|
||||
from = state.nodeLookup.get(state.connectionClickStartHandle.nodeId) ?? null;
|
||||
}
|
||||
const node = state.nodeLookup.get(id);
|
||||
if (!isConnectable || !from || !node || from.id === id) return false;
|
||||
let source = from.id;
|
||||
let target = id;
|
||||
const handleType = state.connection.fromHandle?.type ?? state.connectionClickStartHandle?.type;
|
||||
if (handleType === "target") {
|
||||
source = id;
|
||||
target = from.id;
|
||||
}
|
||||
return !connectionIssue({ source, target }, [from.internals.userNode, node.internals.userNode] as WidgetNode[]);
|
||||
});
|
||||
}
|
||||
export const FlowNode = memo(function FlowNode({ id, data, selected, isConnectable }: NodeProps<WidgetNode>) {
|
||||
const t = useI18n("customWidget.flow");
|
||||
const compact = useStore((state) => state.transform[2] < 0.65);
|
||||
const validTarget = useValidConnectionTarget(id, isConnectable);
|
||||
const updateInternals = useUpdateNodeInternals();
|
||||
useEffect(() => updateInternals(id), [compact, id, updateInternals]);
|
||||
const Icon = icons[data.kind];
|
||||
return (
|
||||
<div className={classes.node} data-selected={selected} data-kind={data.kind} data-valid-target={validTarget}>
|
||||
<FlowPort
|
||||
id="in"
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
connectable={isConnectable}
|
||||
label={`${t("input")} · ${data.label}`}
|
||||
title={t("input")}
|
||||
/>
|
||||
<Group className="widget-node-drag" gap="xs" wrap="nowrap" p="sm">
|
||||
<Icon size={18} color={`var(--mantine-color-${colors[data.kind]}-6)`} />
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{data.label}
|
||||
</Text>
|
||||
</Group>
|
||||
{!compact && (
|
||||
<div className={classes.nodeDetails}>
|
||||
<Badge size="xs" variant="light" color={colors[data.kind]}>
|
||||
{t(`kind.${data.kind}`)}
|
||||
</Badge>
|
||||
{["query", "action", "native"].includes(data.kind) && <FlowExecutionStatus requestId={data.identifier} />}
|
||||
<Text size="xs" c="dimmed" truncate mt={6}>
|
||||
{data.summary}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
<FlowPort
|
||||
id="out"
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
connectable={isConnectable}
|
||||
label={`${t("output")} · ${data.label}`}
|
||||
title={t("output")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/* oxlint-disable jsx-a11y/prefer-tag-over-role -- React Flow requires its Handle div for measured connection anchors. */
|
||||
export function FlowPort({
|
||||
connectable,
|
||||
label,
|
||||
...props
|
||||
}: {
|
||||
id: string;
|
||||
type: "source" | "target";
|
||||
position: Position;
|
||||
connectable: boolean;
|
||||
label: string;
|
||||
title: string;
|
||||
}) {
|
||||
let tabIndex = -1;
|
||||
if (connectable) tabIndex = 0;
|
||||
return (
|
||||
<Handle
|
||||
{...props}
|
||||
className={portClasses.handle}
|
||||
isConnectable={connectable}
|
||||
role="button"
|
||||
tabIndex={tabIndex}
|
||||
aria-disabled={!connectable}
|
||||
aria-label={label}
|
||||
onKeyDown={(event) => {
|
||||
if (!["Enter", " "].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (connectable && !event.repeat) event.currentTarget.click();
|
||||
}}
|
||||
onKeyUp={(event) => {
|
||||
if (!["Enter", " "].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
/* oxlint-enable jsx-a11y/prefer-tag-over-role */
|
||||
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
import type { ReactNode } from "react";
|
||||
import { ActionIcon, Badge, Button, Checkbox, Group, Stack, Text, TextInput, UnstyledButton } from "@mantine/core";
|
||||
import { IconSearch, IconSettings, IconX } from "@tabler/icons-react";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { FlowKind, WidgetNode } from "./graph";
|
||||
import { WorkbenchNodeLibrary } from "./library";
|
||||
import classes from "./workbench.module.css";
|
||||
export function WorkbenchOutline({
|
||||
nodes,
|
||||
selectedIds,
|
||||
selected,
|
||||
search,
|
||||
onSearch,
|
||||
onSelect,
|
||||
onClose,
|
||||
onToggle,
|
||||
onAdd,
|
||||
onGroup,
|
||||
onUngroup,
|
||||
editable,
|
||||
start,
|
||||
}: {
|
||||
nodes: WidgetNode[];
|
||||
start?: ReactNode;
|
||||
selectedIds: string[];
|
||||
selected: string;
|
||||
search: string;
|
||||
onSearch(value: string): void;
|
||||
onSelect(id: string): void;
|
||||
onClose(): void;
|
||||
onToggle(id: string): void;
|
||||
editable: boolean;
|
||||
onAdd(kind: FlowKind): void;
|
||||
onGroup(): void;
|
||||
onUngroup(): void;
|
||||
}) {
|
||||
const t = useI18n("customWidget.flow");
|
||||
const query = search.trim().toLowerCase();
|
||||
const filtered = nodes.filter((node) =>
|
||||
`${node.data.label} ${node.data.identifier} ${node.data.summary} ${t(`kind.${node.data.kind}`)}`
|
||||
.toLowerCase()
|
||||
.includes(query),
|
||||
);
|
||||
const selectedNodes = nodes.filter((node) => selectedIds.includes(node.id));
|
||||
return (
|
||||
<nav className={classes.library} aria-label={t("library")} data-workbench-library>
|
||||
{start}
|
||||
<TextInput
|
||||
size="xs"
|
||||
data-workbench-local-input
|
||||
value={search}
|
||||
onChange={(event) => onSearch(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.stopPropagation();
|
||||
if (search) onSearch("");
|
||||
else onClose();
|
||||
}
|
||||
}}
|
||||
placeholder={t("search")}
|
||||
aria-label={t("search")}
|
||||
leftSection={<IconSearch size={14} />}
|
||||
rightSection={
|
||||
search && (
|
||||
<ActionIcon
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
aria-label={t("clearSearch")}
|
||||
onClick={() => onSearch("")}
|
||||
>
|
||||
<IconX size={12} />
|
||||
</ActionIcon>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<WorkbenchNodeLibrary editable={editable} onAdd={onAdd} />
|
||||
<Group justify="space-between" gap={4} mt="md" mb="xs">
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
{t("outline")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" aria-live="polite">
|
||||
{t("nodeCount", { visible: filtered.length, total: nodes.length })}
|
||||
</Text>
|
||||
</Group>
|
||||
<Stack gap={4}>
|
||||
{filtered.map((node) => (
|
||||
<Group key={node.id} className={classes.outlineRow} data-nested={!!node.parentId} gap={4} wrap="nowrap">
|
||||
<Checkbox
|
||||
size="xs"
|
||||
checked={selectedIds.includes(node.id)}
|
||||
onChange={() => onToggle(node.id)}
|
||||
aria-label={t("selectNode", { name: node.data.label })}
|
||||
/>
|
||||
<UnstyledButton
|
||||
className={classes.outlineItem}
|
||||
data-selected={node.id === selected}
|
||||
aria-current={node.id === selected ? "true" : undefined}
|
||||
title={[node.data.label, node.data.summary].filter(Boolean).join(" · ")}
|
||||
onClick={() => onSelect(node.id)}
|
||||
>
|
||||
<Badge size="xs" variant="dot">
|
||||
{t(`kind.${node.data.kind}`)}
|
||||
</Badge>
|
||||
<Text size="xs" truncate mt={4}>
|
||||
{node.data.label}
|
||||
</Text>
|
||||
{node.data.summary && (
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{node.data.summary}
|
||||
</Text>
|
||||
)}
|
||||
</UnstyledButton>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
{filtered.length === 0 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("noNodes")}
|
||||
</Text>
|
||||
)}
|
||||
<Group mt="sm" gap={4}>
|
||||
<Button
|
||||
type="button"
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
disabled={selectedNodes.filter((node) => node.data.kind !== "group").length < 2}
|
||||
onClick={onGroup}
|
||||
>
|
||||
{t("group")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
disabled={!selectedNodes.some((node) => node.data.kind === "group" || node.parentId)}
|
||||
onClick={onUngroup}
|
||||
>
|
||||
{t("ungroup")}
|
||||
</Button>
|
||||
</Group>
|
||||
<Button
|
||||
mt="sm"
|
||||
fullWidth
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
leftSection={<IconSettings size={14} />}
|
||||
onClick={() => onSelect("general")}
|
||||
>
|
||||
{t("general")}
|
||||
</Button>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Button, CloseButton, Group, Popover, SegmentedControl, Stack, Switch, Text } from "@mantine/core";
|
||||
import { IconAdjustmentsHorizontal } from "@tabler/icons-react";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { CustomWidgetPreviewSizeControl } from "../_custom-widget-preview-size-control";
|
||||
import type { PreviewFixture, PreviewPanelProps } from "./preview-panel-types";
|
||||
|
||||
export function WorkbenchPreviewControls({
|
||||
size,
|
||||
onSizeChange,
|
||||
inspect,
|
||||
onInspectChange,
|
||||
fixture,
|
||||
onFixtureChange,
|
||||
disabled,
|
||||
}: Pick<PreviewPanelProps, "size" | "onSizeChange"> & {
|
||||
inspect: boolean;
|
||||
onInspectChange(value: boolean): void;
|
||||
fixture: PreviewFixture;
|
||||
onFixtureChange(value: PreviewFixture): void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const t = useI18n("customWidget.workbench.preview");
|
||||
const flowT = useI18n("customWidget.flow");
|
||||
return (
|
||||
<Group gap="xs" justify="space-between" className="nodrag nopan nowheel">
|
||||
<CustomWidgetPreviewSizeControl value={size} onChange={onSizeChange} />
|
||||
<Switch
|
||||
size="xs"
|
||||
label={flowT("inspectPreview")}
|
||||
checked={inspect}
|
||||
onChange={(event) => onInspectChange(event.currentTarget.checked)}
|
||||
/>
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={fixture}
|
||||
disabled={disabled}
|
||||
onChange={(value) => onFixtureChange(value as PreviewFixture)}
|
||||
data={(["live", "loading", "empty", "error"] as const).map((value) => ({
|
||||
value,
|
||||
label: t(`fixture.${value}`),
|
||||
}))}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkbenchPreviewTools({ children, revealVersion }: { children: ReactNode; revealVersion: number }) {
|
||||
const t = useI18n("customWidget.flow");
|
||||
const common = useI18n("common.action");
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [returnFocus, setReturnFocus] = useState(true);
|
||||
const changeOpened = (value: boolean) => {
|
||||
setReturnFocus(true);
|
||||
setOpened(value);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (revealVersion > 0) {
|
||||
setReturnFocus(true);
|
||||
setOpened(true);
|
||||
}
|
||||
}, [revealVersion]);
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
const followNavigation = () => {
|
||||
setReturnFocus(false);
|
||||
setOpened(false);
|
||||
};
|
||||
window.addEventListener("homarr:widget-code-insert", followNavigation);
|
||||
window.addEventListener("homarr:widget-request-inspect", followNavigation);
|
||||
return () => {
|
||||
window.removeEventListener("homarr:widget-code-insert", followNavigation);
|
||||
window.removeEventListener("homarr:widget-request-inspect", followNavigation);
|
||||
};
|
||||
}, [opened]);
|
||||
return (
|
||||
<Popover
|
||||
opened={opened}
|
||||
onChange={changeOpened}
|
||||
onDismiss={() => changeOpened(false)}
|
||||
withinPortal
|
||||
trapFocus
|
||||
returnFocus={returnFocus}
|
||||
position="bottom-end"
|
||||
width="min(680px, calc(100vw - 24px))"
|
||||
shadow="lg"
|
||||
zIndex={500}
|
||||
>
|
||||
<Popover.Target>
|
||||
<Button
|
||||
type="button"
|
||||
size="compact-xs"
|
||||
variant="default"
|
||||
className="nodrag nopan"
|
||||
leftSection={<IconAdjustmentsHorizontal size={14} />}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
changeOpened(!opened);
|
||||
}}
|
||||
>
|
||||
{t("previewControls")}
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown
|
||||
className="nodrag nopan nowheel"
|
||||
p="sm"
|
||||
aria-label={t("previewControls")}
|
||||
style={{ maxHeight: "min(680px, calc(100dvh - 96px))", overflow: "auto" }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Tab") event.stopPropagation();
|
||||
}}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" fw={600}>
|
||||
{t("previewControls")}
|
||||
</Text>
|
||||
<CloseButton data-autofocus aria-label={common("close")} onClick={() => changeOpened(false)} />
|
||||
</Group>
|
||||
{children}
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useWorkbenchSelection } from "./selection";
|
||||
|
||||
export function usePreviewExecution() {
|
||||
const selection = useWorkbenchSelection();
|
||||
const [activeTab, setActiveTab] = useState<string | null>("widget");
|
||||
const [revealVersion, setRevealVersion] = useState(0);
|
||||
const [focus, setFocus] = useState<{ selection: string | undefined; requestId?: string }>();
|
||||
let requestId: string | undefined;
|
||||
if (selection?.startsWith("request:") || selection?.startsWith("native:"))
|
||||
requestId = selection.slice(selection.indexOf(":") + 1);
|
||||
if (focus?.selection === selection) requestId = focus?.requestId;
|
||||
useEffect(() => {
|
||||
const show = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ requestId?: unknown; panel?: unknown }>).detail;
|
||||
if (typeof detail?.requestId !== "string" || !["data", "journal"].includes(String(detail.panel))) return;
|
||||
setFocus({ selection, requestId: detail.requestId });
|
||||
setActiveTab(String(detail.panel));
|
||||
setRevealVersion((value) => value + 1);
|
||||
};
|
||||
window.addEventListener("homarr:widget-execution-panel", show);
|
||||
return () => window.removeEventListener("homarr:widget-execution-panel", show);
|
||||
}, [selection]);
|
||||
return { activeTab, setActiveTab, requestId, revealVersion, showAll: () => setFocus({ selection }) };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useRef } from "react";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { PreviewPanelProps } from "./preview-panel-types";
|
||||
|
||||
/** Keep session mode updates alive when the preview tools close. */
|
||||
export function usePreviewLiveActions({ preview, onLiveActionsChange }: PreviewPanelProps) {
|
||||
const t = useI18n("customWidget.workbench.preview");
|
||||
const mutation = clientApi.customWidget.setPreviewLiveActions.useMutation();
|
||||
const current = useRef({ sessionId: preview.session?.id, onLiveActionsChange });
|
||||
current.current = { sessionId: preview.session?.id, onLiveActionsChange };
|
||||
const pending = useRef(false);
|
||||
const toggle = (enabled: boolean) => {
|
||||
const sessionId = current.current.sessionId;
|
||||
if (!sessionId || pending.current) return;
|
||||
pending.current = true;
|
||||
mutation.mutate(
|
||||
{ sessionId, enabled },
|
||||
{
|
||||
onSuccess: () => {
|
||||
if (current.current.sessionId === sessionId) current.current.onLiveActionsChange(enabled);
|
||||
},
|
||||
onError: (error) => {
|
||||
if (current.current.sessionId !== sessionId) return;
|
||||
showErrorNotification({ title: t("liveActions"), message: error.message });
|
||||
},
|
||||
onSettled: () => {
|
||||
pending.current = false;
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
return { pending: mutation.isPending, toggle };
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import type { PreviewState } from "./preview-state";
|
||||
|
||||
export interface PreviewPanelProps {
|
||||
candidate: HomarrCustomWidgetV2 | null;
|
||||
validationIssues: Array<{ path?: string; message: string }>;
|
||||
preview: PreviewState;
|
||||
size: string;
|
||||
onSizeChange(value: string): void;
|
||||
optionsSnapshot: Record<string, unknown>;
|
||||
onOptionsChange(value: Record<string, unknown>): void;
|
||||
onLiveActionsChange(enabled: boolean): void;
|
||||
}
|
||||
|
||||
export type PreviewFixture = "live" | "loading" | "empty" | "error";
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const PreviewPresentation = createContext<"canvas" | "form">("form");
|
||||
|
||||
export function WorkbenchPreviewPresentationProvider({
|
||||
mode,
|
||||
children,
|
||||
}: {
|
||||
mode: "canvas" | "form";
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <PreviewPresentation.Provider value={mode}>{children}</PreviewPresentation.Provider>;
|
||||
}
|
||||
|
||||
export const useWorkbenchPreviewPresentation = () => useContext(PreviewPresentation);
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
import { memo, useMemo } from "react";
|
||||
import { Button, Group, Text } from "@mantine/core";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { CodeEditor } from "../_code-editor";
|
||||
import type { PreviewState } from "./preview-state";
|
||||
import { DataBindings } from "./data-bindings";
|
||||
import { useWorkbenchQueryData } from "./execution";
|
||||
|
||||
export const PreviewRequestData = memo(function PreviewRequestData({
|
||||
preview,
|
||||
requestId,
|
||||
onShowAll,
|
||||
}: {
|
||||
preview: PreviewState;
|
||||
requestId?: string;
|
||||
onShowAll(): void;
|
||||
}) {
|
||||
const t = useI18n("customWidget.flow");
|
||||
const previewT = useI18n("customWidget.workbench.preview");
|
||||
const execution = useWorkbenchQueryData();
|
||||
const data = execution?.data ?? preview.data;
|
||||
const status = execution?.status ?? preview.status;
|
||||
const snapshot = useMemo(() => {
|
||||
if (!requestId) return { data, status };
|
||||
return { data: { [requestId]: data[requestId] }, status: { [requestId]: status[requestId] } };
|
||||
}, [data, status, requestId]);
|
||||
const serialized = useMemo(() => JSON.stringify(snapshot, null, 2), [snapshot]);
|
||||
return (
|
||||
<>
|
||||
{requestId && (
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text size="xs" fw={600}>
|
||||
{requestId}
|
||||
</Text>
|
||||
<Button size="compact-xs" variant="subtle" onClick={onShowAll}>
|
||||
{t("allQueryData")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
<DataBindings data={snapshot.data} />
|
||||
<CodeEditor
|
||||
id="preview-data"
|
||||
label={previewT("requestData")}
|
||||
language="json"
|
||||
value={serialized}
|
||||
readOnly
|
||||
onChange={() => undefined}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
/** Mirror application CSS into the preview boundary without changing its React tree. */
|
||||
export function mirrorPreviewStyles(shadow: ShadowRoot) {
|
||||
const copies = new Map<Element, Element>();
|
||||
const anchor = document.createComment("Application preview styles");
|
||||
shadow.prepend(anchor);
|
||||
const sync = () => {
|
||||
const originals = [...document.head.querySelectorAll('style, link[rel="stylesheet"]')].filter((element) => {
|
||||
if (!(element instanceof HTMLLinkElement)) return true;
|
||||
return new URL(element.href, location.href).origin === location.origin;
|
||||
});
|
||||
for (const [original, copy] of copies) {
|
||||
if (originals.includes(original)) continue;
|
||||
copy.remove();
|
||||
copies.delete(original);
|
||||
}
|
||||
let cursor = shadow.firstChild;
|
||||
for (const original of originals) {
|
||||
let copy = copies.get(original);
|
||||
if (!copy) {
|
||||
copy = original.cloneNode(true) as Element;
|
||||
copies.set(original, copy);
|
||||
}
|
||||
if (copy.textContent !== original.textContent) copy.textContent = original.textContent;
|
||||
// oxlint-disable-next-line unicorn/no-useless-spread -- Removal changes this live NamedNodeMap during iteration.
|
||||
for (const attribute of [...copy.attributes]) {
|
||||
if (!original.hasAttribute(attribute.name)) copy.removeAttribute(attribute.name);
|
||||
}
|
||||
for (const attribute of original.attributes) {
|
||||
if (copy.getAttribute(attribute.name) !== attribute.value) copy.setAttribute(attribute.name, attribute.value);
|
||||
}
|
||||
// Keep source order: it determines which component rules win the cascade.
|
||||
if (copy !== cursor) shadow.insertBefore(copy, cursor);
|
||||
cursor = copy.nextSibling;
|
||||
}
|
||||
};
|
||||
sync();
|
||||
let scheduled = 0;
|
||||
const observer = new MutationObserver(() => {
|
||||
if (scheduled) return;
|
||||
scheduled = requestAnimationFrame(() => {
|
||||
scheduled = 0;
|
||||
sync();
|
||||
});
|
||||
});
|
||||
observer.observe(document.head, { childList: true, subtree: true, characterData: true, attributes: true });
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
cancelAnimationFrame(scheduled);
|
||||
for (const copy of copies.values()) copy.remove();
|
||||
anchor.remove();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { PreviewOutcome } from "../_custom-widget-preview-status";
|
||||
|
||||
export interface PreviewState {
|
||||
stale?: boolean;
|
||||
data: Record<string, unknown>;
|
||||
status: Record<string, unknown>;
|
||||
session: { id: string; expiresAt: number; liveActions: boolean } | null;
|
||||
outcome: PreviewOutcome;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Group, MantineProvider, SegmentedControl, useComputedColorScheme, useMantineTheme } from "@mantine/core";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { mirrorPreviewStyles } from "./preview-shadow";
|
||||
|
||||
const PreviewScheme = createContext<"light" | "dark">("light");
|
||||
const PreviewThemeControl = createContext<{ scheme: string; setScheme(value: string): void } | null>(null);
|
||||
export function WorkbenchPreviewTheme({ children, controls = true }: { children: ReactNode; controls?: boolean }) {
|
||||
const inheritedScheme = useComputedColorScheme("light");
|
||||
const [scheme, setScheme] = useState("app");
|
||||
let colorScheme = inheritedScheme;
|
||||
if (scheme === "light" || scheme === "dark") colorScheme = scheme;
|
||||
const control = useMemo(() => ({ scheme, setScheme }), [scheme]);
|
||||
return (
|
||||
<PreviewScheme.Provider value={colorScheme}>
|
||||
<PreviewThemeControl.Provider value={control}>
|
||||
{controls && (
|
||||
<Group justify="end" mb="xs">
|
||||
<WorkbenchPreviewThemeControl />
|
||||
</Group>
|
||||
)}
|
||||
{children}
|
||||
</PreviewThemeControl.Provider>
|
||||
</PreviewScheme.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkbenchPreviewThemeControl() {
|
||||
const t = useI18n("customWidget.flow");
|
||||
const control = useContext(PreviewThemeControl);
|
||||
if (!control) return null;
|
||||
return (
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
aria-label={t("previewTheme")}
|
||||
value={control.scheme}
|
||||
onChange={control.setScheme}
|
||||
data={[
|
||||
{ value: "app", label: t("themeApp") },
|
||||
{ value: "light", label: t("themeLight") },
|
||||
{ value: "dark", label: t("themeDark") },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkbenchPreviewSurface({ children }: { children: ReactNode }) {
|
||||
const scheme = useContext(PreviewScheme);
|
||||
const inheritedTheme = useMantineTheme();
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const [root, setRoot] = useState<HTMLDivElement | null>(null);
|
||||
const id = `widget-preview-${useId().replace(/[^a-zA-Z0-9_-]/gu, "")}`;
|
||||
useEffect(() => {
|
||||
if (!host.current) return;
|
||||
const shadow = host.current.shadowRoot ?? host.current.attachShadow({ mode: "open" });
|
||||
const container = document.createElement("div");
|
||||
container.className = "nokey";
|
||||
container.id = id;
|
||||
container.style.color = "var(--mantine-color-text)";
|
||||
container.style.width = "fit-content";
|
||||
container.style.maxWidth = "100%";
|
||||
shadow.append(container);
|
||||
const cleanup = mirrorPreviewStyles(shadow);
|
||||
setRoot(container);
|
||||
return () => {
|
||||
cleanup();
|
||||
container.remove();
|
||||
};
|
||||
}, [id]);
|
||||
const getRootElement = useCallback(() => root ?? undefined, [root]);
|
||||
const theme = useMemo(
|
||||
() => ({
|
||||
...inheritedTheme,
|
||||
components: {
|
||||
...inheritedTheme.components,
|
||||
Portal: {
|
||||
...inheritedTheme.components.Portal,
|
||||
defaultProps: { ...inheritedTheme.components.Portal?.defaultProps, target: root },
|
||||
},
|
||||
},
|
||||
}),
|
||||
[inheritedTheme, root],
|
||||
);
|
||||
return (
|
||||
<div
|
||||
ref={host}
|
||||
data-preview-surface
|
||||
className="nodrag nopan nowheel nokey"
|
||||
style={{ maxWidth: "100%", width: "fit-content" }}
|
||||
>
|
||||
{root &&
|
||||
createPortal(
|
||||
<MantineProvider
|
||||
theme={theme}
|
||||
forceColorScheme={scheme}
|
||||
getRootElement={getRootElement}
|
||||
cssVariablesSelector={`#${id}`}
|
||||
withGlobalClasses={false}
|
||||
>
|
||||
<div data-preview-theme className="nokey" style={{ colorScheme: scheme }}>
|
||||
{children}
|
||||
</div>
|
||||
</MantineProvider>,
|
||||
root,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, Paper } from "@mantine/core";
|
||||
import { PreviewErrorBoundary } from "@homarr/custom-widgets/workbench";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import CustomJsxDisplay from "@homarr/widgets/custom-api/custom-jsx-display";
|
||||
import { WorkbenchPreviewSurface } from "./preview-theme";
|
||||
|
||||
const widths: Record<string, number> = { compact: 320, standard: 480, wide: 720 };
|
||||
export const workbenchPreviewWidth = (size: string) => widths[size] ?? 480;
|
||||
export function WorkbenchPreviewWidget({
|
||||
data,
|
||||
size,
|
||||
inspect,
|
||||
resetKey,
|
||||
}: {
|
||||
data: Record<string, unknown> | null;
|
||||
size: string;
|
||||
inspect: boolean;
|
||||
resetKey: string;
|
||||
}) {
|
||||
const t = useI18n("customWidget.workbench.preview");
|
||||
const errors = useI18n("customWidget.editor.errorBoundary");
|
||||
const actions = useI18n("common.action");
|
||||
return (
|
||||
<WorkbenchPreviewSurface>
|
||||
<Paper withBorder p="sm" h={360} w={workbenchPreviewWidth(size)} maw="100%" style={{ overflow: "auto" }}>
|
||||
{data ? (
|
||||
<PreviewErrorBoundary
|
||||
title={errors("title")}
|
||||
description={t("containedFailures")}
|
||||
retryLabel={actions("tryAgain")}
|
||||
resetKeys={[resetKey]}
|
||||
>
|
||||
<CustomJsxDisplay
|
||||
data={data}
|
||||
inspect={inspect}
|
||||
onInspect={(location) => {
|
||||
window.dispatchEvent(new CustomEvent("homarr:widget-inspect", { detail: location }));
|
||||
requestAnimationFrame(() =>
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("homarr:widget-code-reveal", {
|
||||
detail: {
|
||||
editorId: location.fragment ? `fragment-${location.fragment}` : "jsx-editor",
|
||||
index: location.index,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</PreviewErrorBoundary>
|
||||
) : (
|
||||
<Alert color="yellow">{t("invalid")}</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
</WorkbenchPreviewSurface>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button, Collapse, Group, ScrollArea, Stack, Text, UnstyledButton } from "@mantine/core";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useCustomWidgetFormAnalysisField } from "../_use-custom-widget-form-analysis";
|
||||
import classes from "./workbench.module.css";
|
||||
|
||||
export function WorkbenchProblems({ onSelect }: { onSelect(id: string): void }) {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const t = useI18n("customWidget.flow");
|
||||
const diagnosticsT = useI18n("customWidget.editor.diagnostics");
|
||||
const issues = useCustomWidgetFormAnalysisField("previewValidationIssues");
|
||||
const template = useCustomWidgetFormAnalysisField("templateDiagnostics");
|
||||
const requests = useCustomWidgetFormAnalysisField("requestDiagnostics");
|
||||
const entries = [
|
||||
...issues.map((issue) => ({ ...issue, index: undefined as number | undefined })),
|
||||
...template.map((entry) => ({
|
||||
path: "template",
|
||||
index: entry.index,
|
||||
message: diagnosticsT(entry.code, { value: entry.value ?? "" }),
|
||||
})),
|
||||
...requests.map((entry) => ({
|
||||
path: "requests",
|
||||
index: entry.index,
|
||||
message: diagnosticsT(entry.code, { value: entry.value ?? "" }),
|
||||
})),
|
||||
];
|
||||
const reveal = (path: string | undefined, index: number | undefined) => {
|
||||
const [field, id, nativeId] = (path ?? "").split(".");
|
||||
let nodeId = "widget";
|
||||
if (field === "sources" && id) nodeId = `source:${id}`;
|
||||
if (field === "requests" && id) nodeId = `request:${id}`;
|
||||
if (field === "options") nodeId = "options";
|
||||
if (field === "extensions" && id === "native" && nativeId) nodeId = `native:${nativeId}`;
|
||||
onSelect(nodeId);
|
||||
if (index !== undefined)
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("homarr:widget-code-reveal", {
|
||||
detail: { editorId: field === "requests" ? "requests-editor" : "jsx-editor", index },
|
||||
}),
|
||||
);
|
||||
};
|
||||
return (
|
||||
<div className={classes.problems} style={{ borderTop: "1px solid var(--mantine-color-default-border)" }}>
|
||||
<Group px="xs" justify="space-between">
|
||||
<Button size="compact-xs" variant="subtle" aria-expanded={opened} onClick={() => setOpened(!opened)}>
|
||||
{t("problems", { count: entries.length })}
|
||||
</Button>
|
||||
<Text size="xs" c="dimmed" component="output">
|
||||
{entries.length === 0 && t("noProblems")}
|
||||
</Text>
|
||||
</Group>
|
||||
<Collapse expanded={opened}>
|
||||
<ScrollArea mah={220}>
|
||||
<Stack gap={4} p="xs">
|
||||
{entries.map((entry, index) => (
|
||||
<UnstyledButton key={`${entry.path}:${index}`} onClick={() => reveal(entry.path, entry.index)}>
|
||||
<Text size="xs" c="red">
|
||||
{entry.path}: {entry.message}
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Alert, Button, Group, Switch } from "@mantine/core";
|
||||
import { customWidgetEditorLayoutSchema } from "@homarr/custom-widgets/core";
|
||||
import { customWidgetFormSchema } from "@homarr/custom-widgets/workbench";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { CustomWidgetWorkbenchForm } from "../_custom-widget-form-utils";
|
||||
import { buildDefinition } from "../_custom-widget-form-utils";
|
||||
import { useCustomWidgetFormDocumentStore } from "../_custom-widget-form-state";
|
||||
import { withoutCredentials } from "./history";
|
||||
import type { EditorSnapshot } from "./history";
|
||||
|
||||
type RecoveryDraft = Pick<EditorSnapshot, "content" | "layout">;
|
||||
interface RecoveryState {
|
||||
key: string;
|
||||
enabled: boolean;
|
||||
recovered: RecoveryDraft | null;
|
||||
}
|
||||
interface RecoveryContextValue {
|
||||
enabled: boolean;
|
||||
available: boolean;
|
||||
setEnabled(value: boolean): void;
|
||||
restore(): void;
|
||||
discard(): void;
|
||||
}
|
||||
const RecoveryContext = createContext<RecoveryContextValue | null>(null);
|
||||
|
||||
/** One document observer serves the always-visible alert and contextual settings. */
|
||||
export function DraftRecoveryProvider({
|
||||
form,
|
||||
definitionId,
|
||||
children,
|
||||
}: {
|
||||
form: CustomWidgetWorkbenchForm;
|
||||
definitionId?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const store = useCustomWidgetFormDocumentStore();
|
||||
const key = `homarr:custom-widget:draft:${definitionId ?? "new"}`;
|
||||
const [state, setState] = useState<RecoveryState>({ key: "", enabled: false, recovered: null });
|
||||
useEffect(() => {
|
||||
let enabled = false;
|
||||
let recovered: RecoveryDraft | null = null;
|
||||
try {
|
||||
enabled = localStorage.getItem("homarr:custom-widget:recovery-enabled") === "true";
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw && raw.length <= 1_000_000) {
|
||||
const saved = JSON.parse(raw) as EditorSnapshot;
|
||||
const values = customWidgetFormSchema.parse({ ...saved.content, secrets: [] });
|
||||
recovered = { content: withoutCredentials(values), layout: customWidgetEditorLayoutSchema.parse(saved.layout) };
|
||||
}
|
||||
} catch {
|
||||
/* Storage may be disabled or contain an obsolete draft. */
|
||||
}
|
||||
setState({ key, enabled, recovered });
|
||||
}, [key]);
|
||||
useEffect(() => {
|
||||
// Wait for this document's storage read before removing or replacing its draft.
|
||||
if (state.key !== key || !state.enabled || state.recovered) return;
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
const writeSnapshot = () => {
|
||||
try {
|
||||
if (!store.getDirty()) {
|
||||
localStorage.removeItem(key);
|
||||
return;
|
||||
}
|
||||
const content = withoutCredentials(store.getValues());
|
||||
// Validate credential-bearing fields even while the JSX draft has errors.
|
||||
if (!buildDefinition({ ...content, template: "<Text />", secrets: [] }).success) return;
|
||||
localStorage.setItem(key, JSON.stringify({ content, layout: store.getLayout() }));
|
||||
} catch {
|
||||
/* Recovery is optional; unavailable storage must not block editing. */
|
||||
}
|
||||
};
|
||||
const persist = () => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(writeSnapshot, 800);
|
||||
};
|
||||
const unsubscribe = store.subscribeEditor(persist);
|
||||
writeSnapshot();
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
};
|
||||
}, [state.enabled, state.key, state.recovered, key, store]);
|
||||
const value = useMemo<RecoveryContextValue>(
|
||||
() => ({
|
||||
enabled: state.key === key && state.enabled,
|
||||
available: state.key === key && state.recovered !== null,
|
||||
setEnabled(enabled) {
|
||||
setState((current) => ({ ...current, enabled }));
|
||||
try {
|
||||
localStorage.setItem("homarr:custom-widget:recovery-enabled", String(enabled));
|
||||
if (!enabled) localStorage.removeItem(key);
|
||||
} catch {
|
||||
/* Recovery remains optional when storage is unavailable. */
|
||||
}
|
||||
},
|
||||
restore() {
|
||||
if (state.key !== key || !state.recovered) return;
|
||||
const recovered = state.recovered;
|
||||
store.transaction(() => {
|
||||
form.setValues({ ...recovered.content, secrets: store.getValues().secrets });
|
||||
store.setLayout(recovered.layout);
|
||||
});
|
||||
setState((current) => ({ ...current, recovered: null }));
|
||||
},
|
||||
discard() {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
/* Recovery remains optional when storage is unavailable. */
|
||||
}
|
||||
setState((current) => ({ ...current, recovered: null }));
|
||||
},
|
||||
}),
|
||||
[form, key, state, store],
|
||||
);
|
||||
return <RecoveryContext.Provider value={value}>{children}</RecoveryContext.Provider>;
|
||||
}
|
||||
|
||||
export function DraftRecoverySettings() {
|
||||
const recovery = useContext(RecoveryContext);
|
||||
const t = useI18n("customWidget.flow");
|
||||
if (!recovery) return null;
|
||||
return (
|
||||
<Switch
|
||||
size="xs"
|
||||
label={t("recovery")}
|
||||
checked={recovery.enabled}
|
||||
onChange={(event) => recovery.setEnabled(event.currentTarget.checked)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DraftRecoveryAlert() {
|
||||
const recovery = useContext(RecoveryContext);
|
||||
const t = useI18n("customWidget.flow");
|
||||
if (!recovery?.available) return null;
|
||||
return (
|
||||
<Alert title={t("recoverTitle")} color="blue" role="alert">
|
||||
<Group>
|
||||
<Button size="xs" onClick={(event) => completeRecoveryAction(event.currentTarget, recovery.restore)}>
|
||||
{t("recover")}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
onClick={(event) => completeRecoveryAction(event.currentTarget, recovery.discard)}
|
||||
>
|
||||
{t("discardRecovery")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
function completeRecoveryAction(button: HTMLButtonElement, action: () => void) {
|
||||
const workbench = button.closest<HTMLElement>("[data-widget-workbench]");
|
||||
action();
|
||||
// The alert unmounts after either action; keep keyboard Undo in the workbench.
|
||||
workbench?.focus({ preventScroll: true });
|
||||
}
|
||||
Loaded 100 of 269 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user