refactor(custom-widgets): extract domain package
This commit is contained in:
167 files changed
+12282
-10033
No files matched your search
@@ -0,0 +1,26 @@
|
||||
---
|
||||
sidebar_label: AI and MCP authoring
|
||||
---
|
||||
|
||||
# AI and MCP authoring
|
||||
|
||||
Homarr exposes an iterative, validation-first workflow to authenticated administrators. Agents should continue until validation, preview, and diagnostics are clean; they must not invent inline network access.
|
||||
|
||||
## Recommended loop
|
||||
|
||||
1. Read `customWidget_schema` and the generated component/capability reference.
|
||||
2. Read an existing definition with `customWidget_byId`, or create a v3 draft.
|
||||
3. Call `customWidget_validate`; fix every issue using its exact path.
|
||||
4. Call `customWidget_preview` for the base GET and retain the preview session ID.
|
||||
5. Test each named query with `customWidget_previewQuery`.
|
||||
6. Dry-run every action with `customWidget_simulatePreviewAction`.
|
||||
7. Inspect `customWidget_previewJournal` for status, duration, and method coverage.
|
||||
8. Revalidate, then call import/create or a focused update/patch operation.
|
||||
|
||||
Stored secret values are never readable. Omitting secrets during an update preserves them. MCP actions are simulation-only; enabling live preview actions remains a browser-only administrator decision.
|
||||
|
||||
## Template format
|
||||
|
||||
Use multiline JSX when reading or patching templates. Prefer focused template read/write/patch tools over rewriting an escaped one-line JSON string. When a complete portable artifact is needed, use the authoring bundle placeholder plus a separate fenced JSX block.
|
||||
|
||||
The AI prompt is generated from the same registry, examples, request rules, and limits as the validator. Agents should still trust validation output over prompt memory because the installed Homarr version is authoritative.
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
sidebar_label: Maintainer architecture
|
||||
---
|
||||
|
||||
# Maintainer architecture
|
||||
|
||||
Reusable Custom Widget behavior belongs to `@homarr/custom-widgets`:
|
||||
|
||||
- `core` owns schemas, descriptors, migrations, import/export, examples, diagnostics metadata, and prompt generation.
|
||||
- `jsx` owns AST parsing, shared policy, interpreter, bindings, registry, wrappers, and analysis.
|
||||
- `runtime` owns renderers and named-request controls through injected ports.
|
||||
- `server` owns manifests, network execution, caching, limits, preview sessions, and typed domain errors.
|
||||
- `workbench` owns form contracts, editor analysis, preview UI, response tree, and import review through injected ports.
|
||||
|
||||
The API package retains persistence and board authorization adapters. Widgets retain board registration and runtime transport wiring. Next.js routes retain navigation and shells. The domain package must never import API, widgets, or route modules.
|
||||
|
||||
## Add a display type
|
||||
|
||||
Extend the typed descriptor and discriminated schema, defaults/hydration/serialization, renderer, workbench section, preview, import round-trip fixtures, docs, and translations. Create, edit, import, export, MCP, and preview must all use the same transform.
|
||||
|
||||
## Add a JSX component
|
||||
|
||||
Classify the upstream export, document safety mode and supported props, add a wrapper if behavior needs constraining, and update registry tests. CI fails when a Mantine upgrade introduces an unclassified export.
|
||||
|
||||
## Change networking
|
||||
|
||||
Implement policy below the framework-neutral server boundary and expose infrastructure through a typed port. Add tests for permissions, typed substitution, DNS classes, redirects, headers, timeouts, limits, redaction, cache behavior, and action non-retry semantics before wiring the tRPC adapter.
|
||||
|
||||
Production files target 300 lines. Algorithmic AST/network modules may use a documented 400-line ceiling. Dependency-boundary, cycle, file-size, translation, generated-artifact, component-classification, focused unit, integration, E2E, accessibility, and docs-build gates protect the architecture. After a Next.js production build, `pnpm check:custom-widget-bundle` also enforces a 768 KiB gzip ceiling across the client chunks referenced by Custom JSX.
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
sidebar_label: Authoring workbench
|
||||
---
|
||||
|
||||
# Authoring workbench
|
||||
|
||||
The Custom Widget form is a continuous workflow: choose a display, configure the HTTP connection, configure the display, then test it in the adjacent preview. Each section stays visible so you can revisit earlier choices without losing context.
|
||||
|
||||
## Choose a display
|
||||
|
||||
The display grid is the first product decision. Built-in displays are best for common metrics, tables, statuses, and actions. Choose Custom JSX when the layout needs multiple Mantine components, conditional presentation, charts, dates, or named requests.
|
||||
|
||||
Changing display type preserves shared connection fields and initializes safe display defaults. A Custom JSX template is never blank; new and migrated forms receive the tested starter fixture.
|
||||
|
||||
## Test and preview
|
||||
|
||||
The preview has four tabs:
|
||||
|
||||
- **Widget** renders at compact, standard, or wide board-like sizes.
|
||||
- **Response** uses a tree to inspect data, copy paths, and insert paths into JSX. The external-response action opens a temporary local blob and never publishes the response.
|
||||
- **Request** shows the redacted base request, named capabilities, status, duration, and preview journal.
|
||||
- **Diagnostics** shows template size, named requests, network scope, and blocking or advisory issues.
|
||||
|
||||
Only GET base requests run automatically. Preview actions are simulated until an administrator explicitly enables live actions for the five-minute preview session. DELETE still requires confirmation.
|
||||
|
||||
## Keyboard and clipboard
|
||||
|
||||
The JSX and JSON editors support native Ctrl/Cmd+Z and redo, plus visible toolbar controls. Cursor position, character count, and the 50,000-character limit remain visible.
|
||||
|
||||
On the list page, paste a complete v3 export with Ctrl+V or Command+V to import it as new. On an edit page, pasting opens a replacement review. Nothing is applied before the origin, methods, credential inheritance, and permissions are shown.
|
||||
|
||||
For AI output, prefer the authoring bundle format: a JSON block containing `"template": "__HOMARR_TEMPLATE__"`, followed by a JSX block. This keeps multiline JSX readable and avoids JSON escape errors.
|
||||
|
||||
## Save behavior
|
||||
|
||||
Errors block save; warnings do not. Server validation runs again on every create or update. Leaving with dirty changes triggers protection. Secrets omitted during edit remain unchanged, and secrets are never exported.
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
sidebar_label: Custom JSX language
|
||||
---
|
||||
|
||||
# Custom JSX language
|
||||
|
||||
Custom JSX is a declarative expression language, not browser JavaScript. Homarr parses an explicit AST subset and emits React elements from a classified component registry. Templates cannot access browser globals, native prototypes, constructors, refs, portals, raw event handlers, or direct `fetch`.
|
||||
|
||||
## Data and rendering
|
||||
|
||||
The base GET response is available as `data`. Safe member access, conditionals, arrays, object literals, and interpreter-created collection callbacks can compose displays:
|
||||
|
||||
```jsx
|
||||
<Stack gap="xs">
|
||||
{data.services
|
||||
.filter((service) => service.visible)
|
||||
.map((service) => (
|
||||
<Group key={service.id} justify="space-between">
|
||||
<Text>{service.name}</Text>
|
||||
<Badge color={service.online ? "green" : "red"}>{service.status}</Badge>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
```
|
||||
|
||||
Properties are resolved own-only. Reflective names such as `constructor`, `prototype`, `__proto__`, `caller`, `callee`, `arguments`, `bind`, `call`, and `apply` are rejected even when computed or Unicode escaped.
|
||||
|
||||
## Components and props
|
||||
|
||||
Every Mantine export is classified as allowed, wrapped, or denied. The [generated component reference](./#component-reference) is authoritative. Props are allowlisted per component; unsupported props produce warnings and are ignored. Polymorphic roots, unsafe URLs, portals, positioning escapes, refs, and function props are stripped.
|
||||
|
||||
`SubData` is a convenience for simple response paths. Prefer the render-child pattern for rich nested results:
|
||||
|
||||
```jsx
|
||||
<SubFetch requestId="detail" params={{ id: data.selectedId }}>
|
||||
{(detail, meta) => (
|
||||
<Stack>
|
||||
<Title>{detail.name}</Title>
|
||||
<Text>HTTP {meta.status}</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</SubFetch>
|
||||
```
|
||||
|
||||
## Safe helpers and budgets
|
||||
|
||||
Bindings include restricted conversion, string, array, object, JSON, math, URI, and pure date helpers. Native `Date` objects and native prototypes are not exposed.
|
||||
|
||||
Templates are limited to 50,000 characters, 10,000 rendered nodes, and explicit AST-depth, operation, collection, and string budgets. Budget errors stop only the Custom JSX renderer and are contained by its error boundary.
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
sidebar_label: Extension recipes
|
||||
---
|
||||
|
||||
# Custom Widget extension recipes
|
||||
|
||||
These recipes keep every authoring and runtime path on the same typed source of truth. Run `pnpm check:custom-widgets` after each structural change and add focused package tests before changing an adapter.
|
||||
|
||||
## Add a built-in display type
|
||||
|
||||
1. Add the discriminated configuration schema in `@homarr/custom-widgets/core`.
|
||||
2. Add one descriptor with its icon, category, localized metadata keys, defaults, hydration, serialization, and validation.
|
||||
3. Add the renderer to the runtime display map. It must consume normalized display data and injected actions only.
|
||||
4. Add a bounded workbench section that edits the descriptor-owned form fields.
|
||||
5. Add one fixture and assert create, edit, v2/v3 import, export, MCP, and preview round trips against it.
|
||||
6. Add translation keys and update the display table in the user guide.
|
||||
|
||||
Do not add route-specific conversion code. `buildDisplayFormValues` and `buildDisplayConfigFromFormValues` are the only form conversion boundary.
|
||||
|
||||
## Add or update a JSX component
|
||||
|
||||
1. Classify the Mantine export as `allowed`, `wrapped`, or `denied` in the component registry. Denied entries require a reason.
|
||||
2. Declare supported props and subcomponents. Function props, refs, portals, polymorphic roots, and unsafe positioning stay denied unless a wrapper removes the unsafe behavior.
|
||||
3. Implement wrappers in the JSX package and register them through the package runtime-component factory.
|
||||
4. Add analyzer and renderer tests for accepted props, stripped props, reflective-property attacks, and the rendered-node budget.
|
||||
5. Regenerate the docs reference and verify the editor completion and AI prompt include the same entry.
|
||||
|
||||
The component-classification test must pass against the locked Mantine version. Never expose an upstream namespace or native prototype as a shortcut.
|
||||
|
||||
## Add a named-request capability
|
||||
|
||||
1. Extend the request schema and manifest renderer in `core` and `server` without accepting a raw runtime URL, method, header, or body.
|
||||
2. Enforce permission, network scope, redirect, DNS, header, body, response, concurrency, and rate policies in the framework-neutral server service.
|
||||
3. Expose infrastructure through a typed server port. Translate domain errors only in the API adapter.
|
||||
4. Add the control to runtime through the injected request port. Queries use query semantics; actions require a gesture and use mutation semantics.
|
||||
5. Add preview simulation and redacted-journal coverage. Live actions remain an explicit administrator-only browser choice.
|
||||
6. Update MCP validation and simulation tools without exposing stored secrets or unrestricted action execution.
|
||||
|
||||
Tests must prove actions are never mounted automatically, polled, retried, cached, or redirected. DELETE must retain full permission and confirmation.
|
||||
|
||||
## Add a workbench feature
|
||||
|
||||
1. Put reusable state, analysis, or presentation in `@homarr/custom-widgets/workbench`.
|
||||
2. Receive localized messages and typed callbacks as props. The package cannot import the tRPC client, Next.js navigation, notifications, or Homarr widget adapters.
|
||||
3. Keep the Next.js component as a transport and page-shell adapter.
|
||||
4. Cover keyboard operation, focus order, reduced motion, 320/768/desktop layouts, dirty-state behavior, and error-boundary recovery.
|
||||
5. Keep each production module below 300 lines and preserve the continuous two-pane form behavior.
|
||||
|
||||
## Change examples or AI authoring
|
||||
|
||||
Examples live as typed shared fixtures. Tests, snippets, documentation data, and the AI prompt consume those fixtures. AI output uses an authoring bundle: a JSON block with `__HOMARR_TEMPLATE__`, followed by a readable multiline JSX block. Do not require agents to escape an entire template into one JSON string.
|
||||
|
||||
After changing a fixture, validate every example, render it, regenerate the component reference, and run the prompt snapshot tests. MCP agents must be able to read a widget, patch template lines with a revision, validate, preview named queries, simulate actions, inspect the journal, and update the definition without receiving secret values.
|
||||
|
||||
## Required checks
|
||||
|
||||
```bash
|
||||
pnpm check:custom-widgets
|
||||
pnpm test
|
||||
pnpm test:e2e
|
||||
pnpm turbo typecheck --affected
|
||||
pnpm turbo lint --affected
|
||||
pnpm turbo build --filter=@homarr/docs
|
||||
```
|
||||
|
||||
Also run the component-classification, generated-reference drift, bundle-budget, accessibility, and focused package/API suites configured by the repository. A change is incomplete if any create, edit, import, export, MCP, preview, or board-runtime path uses a separate transform.
|
||||
@@ -12,6 +12,17 @@ import { CustomJsxComponentReference } from "@site/src/components/custom-jsx-com
|
||||
|
||||
Custom widgets connect a dashboard item to an HTTP API without compiling a new Homarr widget. Administrators choose the endpoint and authentication, then use a built-in display type or a Custom JSX template to render the response.
|
||||
|
||||
## Guides
|
||||
|
||||
- [Authoring workbench](./authoring): create, edit, preview, paste, undo, import, and export.
|
||||
- [Custom JSX language](./custom-jsx): supported syntax, components, bindings, diagnostics, and limits.
|
||||
- [Named requests and security](./requests-and-security): permissions, network scopes, request manifests, and server protections.
|
||||
- [AI and MCP authoring](./agent-authoring): an iterative agent workflow that validates and previews before saving.
|
||||
- [Compatibility and migration](./migration): v1/v2 JSX and v2/v3 import behavior.
|
||||
- [Troubleshooting](./troubleshooting): common editor, preview, permission, and networking failures.
|
||||
- [Maintainer architecture](./architecture): package boundaries, extension points, and required tests.
|
||||
- [Extension recipes](./extension-recipes): step-by-step changes for displays, JSX components, requests, and workbench features.
|
||||
|
||||
:::caution
|
||||
|
||||
A custom widget can expose data or perform actions with the credentials configured for it. Import definitions only from sources you trust and review every network capability before using the widget on a shared board.
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
sidebar_label: Compatibility and migration
|
||||
---
|
||||
|
||||
# Compatibility and migration
|
||||
|
||||
Homarr preserves existing display definitions while moving network-enabled Custom JSX to the named-request model.
|
||||
|
||||
## JSX API versions
|
||||
|
||||
Missing `jsxApiVersion` means v1. Display-only v1 templates can be opened and saved as v2 with an empty request manifest. Inline `url`, `method`, `headers`, and body props are inert in v2.
|
||||
|
||||
Static v1 network components can be converted by creating a named request and replacing inline capability props with `requestId` and typed `params`. Dynamic target construction requires an administrator to design an explicit path template and parameter list. Existing non-GET base definitions stop polling and show a network-review state.
|
||||
|
||||
## Import versions
|
||||
|
||||
Exports use `homarr-custom-widget-v3`; v2 imports remain accepted. Import migration normalizes display configuration, preserves v1 semantics, and never imports secret values. The review screen must be accepted before persistence.
|
||||
|
||||
Test migration with the same base response and compare rendered output, query results, action simulation, permissions, and journal entries before enabling the migrated definition on shared boards.
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
sidebar_label: Requests and security
|
||||
---
|
||||
|
||||
# Named requests and security
|
||||
|
||||
Custom JSX v2 uses a saved request manifest. Runtime components pass only a `requestId` and declared primitive parameters; they cannot change the URL, method, headers, authentication, or body shape.
|
||||
|
||||
## Capability kinds
|
||||
|
||||
`SubFetch` references a GET `query`. `ActionButton` and `ToggleSwitch` reference a POST, PUT, PATCH, or DELETE `action`. Base data is GET-only. Actions require an explicit gesture and are never polled, cached, retried automatically, or run on mount.
|
||||
|
||||
Default board permissions are view for queries, modify for POST/PUT/PATCH, and full for DELETE. Viewer actions require explicit high-risk approval; anonymous users cannot execute actions. DELETE always requires confirmation.
|
||||
|
||||
## Parameter substitution
|
||||
|
||||
Path placeholders use `{name}` and are URL-encoded by the server. Runtime parameters must exactly match all declared names and types. JSON bodies substitute `{ "$param": "name" }` recursively and preserve string, number, or boolean types.
|
||||
|
||||
## Network boundary
|
||||
|
||||
Named paths stay on the base URL origin. HTTP and HTTPS are accepted; URL credentials and fragments are rejected. DNS answers are classified and pinned. Public, private, and loopback scopes progressively allow destinations, but link-local, metadata, unspecified, multicast, documentation, carrier-grade NAT, and IPv4-mapped addresses are always blocked.
|
||||
|
||||
Queries may follow three revalidated same-origin redirects. Actions do not follow redirects. Credentials never cross origins. Reserved authentication, cookie, host, forwarding, proxy, content-length, security, and hop-by-hop headers cannot be declared.
|
||||
|
||||
## Limits
|
||||
|
||||
- Request body: 10 KiB UTF-8.
|
||||
- Decompressed response: 1 MiB, 32 JSON levels, 50,000 JSON nodes.
|
||||
- Per user/item: 60 queries, 10 actions, and 3 deletes per minute.
|
||||
- Concurrency: 4 per user/item and 8 per definition.
|
||||
|
||||
Successful GETs can use manifest cache TTLs and single-flight deduplication. Logs contain redacted origin/method/error metadata, never secrets, request bodies, or query values.
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
sidebar_label: Troubleshooting
|
||||
---
|
||||
|
||||
# Troubleshooting
|
||||
|
||||
## The preview says the template is required
|
||||
|
||||
New Custom JSX forms always include the starter fixture. If an imported definition has an empty template, reopen its import review; malformed imports must be corrected rather than previewed as JSX.
|
||||
|
||||
## A prop is ignored
|
||||
|
||||
The diagnostics name the component and prop. Check the generated component reference. Unsupported props are intentionally removed; use a supported layout primitive or wrapper prop instead of polymorphic, portal, ref, positioning, or raw handler props.
|
||||
|
||||
## A request does not run
|
||||
|
||||
Confirm that the component kind matches the manifest: `SubFetch` needs a query; action controls need an action. Parameter names and primitive types must match exactly. Base requests and queries require GET. Actions only run from a gesture, and edit mode disables controls.
|
||||
|
||||
## Network scope errors
|
||||
|
||||
Resolve the hostname from the Homarr server environment. Select private or loopback scope only when required and understood. Link-local and metadata destinations cannot be enabled by any scope. Redirects must remain on the base origin.
|
||||
|
||||
## Permission errors
|
||||
|
||||
The placed board item is the authorization anchor. A definition ID alone cannot authorize credential use. Verify board view/modify/full access, explicit viewer-action approval, and that the caller is not anonymous.
|
||||
|
||||
## Stale or failed preview data
|
||||
|
||||
Connection, authentication, body, method, secret, or manifest changes mark samples stale. Run the GET test again. Background query failures keep prior content visible and offer retry; actions roll toggles back and show error feedback.
|
||||
|
||||
## Import paste is ignored
|
||||
|
||||
Paste a complete v2/v3 JSON export or authoring bundle while the list or edit page is focused. Ordinary text and partial JSON are ignored so normal typing is not intercepted.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -31,6 +31,7 @@
|
||||
"@homarr/boards": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/core": "workspace:^0.1.0",
|
||||
"@homarr/custom-widgets": "workspace:^0.1.0",
|
||||
"@homarr/cron-job-status": "workspace:^0.1.0",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, Anchor, Text } from "@mantine/core";
|
||||
import { IconFlask } from "@tabler/icons-react";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
const FEEDBACK_URL = "https://github.com/homarr-labs/homarr/discussions/categories/custom-widgets";
|
||||
|
||||
export function CustomWidgetBetaBanner() {
|
||||
const t = useScopedI18n("customWidget.betaBanner");
|
||||
return (
|
||||
<Alert variant="light" color="yellow" title="Custom Widgets are in Beta" icon={<IconFlask />}>
|
||||
<Alert variant="light" color="yellow" title={t("title")} icon={<IconFlask />}>
|
||||
<Text size="sm">
|
||||
This feature is experimental and will probably change a lot in the future. If you encounter bugs, wish it did
|
||||
more, or find something confusing, then please complain to us as much as possible! Only feedback posted on the{" "}
|
||||
{t("description")}{" "}
|
||||
<Anchor href={FEEDBACK_URL} target="_blank" fw={600}>
|
||||
official discussion thread
|
||||
{t("link")}
|
||||
</Anchor>{" "}
|
||||
will be taken into account. Happy Hacking!
|
||||
{t("closing")}
|
||||
</Text>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { Alert, Stack, Text, UnstyledButton } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
import type { EditorDiagnostic } from "@homarr/custom-widgets/workbench";
|
||||
|
||||
import classes from "./_code-editor.module.css";
|
||||
|
||||
interface CodeEditorDiagnosticsProps {
|
||||
diagnostics: EditorDiagnostic[];
|
||||
editorView: EditorView | null;
|
||||
title: string;
|
||||
formatDiagnostic: (diagnostic: EditorDiagnostic) => string;
|
||||
}
|
||||
|
||||
export function CodeEditorDiagnostics({
|
||||
diagnostics,
|
||||
editorView,
|
||||
title,
|
||||
formatDiagnostic,
|
||||
}: CodeEditorDiagnosticsProps) {
|
||||
if (diagnostics.length === 0) return null;
|
||||
return (
|
||||
<Stack component="ul" gap={6} mt="xs" pl={0} style={{ listStyle: "none" }} aria-label={title}>
|
||||
{diagnostics.map((diagnostic, index) => (
|
||||
<li key={`${diagnostic.code}-${diagnostic.line ?? 0}-${index}`}>
|
||||
<UnstyledButton
|
||||
type="button"
|
||||
w="100%"
|
||||
className={classes.diagnosticButton}
|
||||
onClick={() => {
|
||||
if (!editorView || diagnostic.index === undefined) return;
|
||||
const anchor = Math.min(diagnostic.index, editorView.state.doc.length);
|
||||
editorView.dispatch({ selection: { anchor }, scrollIntoView: true });
|
||||
editorView.focus();
|
||||
}}
|
||||
>
|
||||
<Alert
|
||||
color={diagnostic.severity === "error" ? "red" : "yellow"}
|
||||
variant="light"
|
||||
p="xs"
|
||||
icon={<IconAlertTriangle size={15} />}
|
||||
>
|
||||
<Text size="xs">{formatDiagnostic(diagnostic)}</Text>
|
||||
</Alert>
|
||||
</UnstyledButton>
|
||||
</li>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -8,37 +8,15 @@ import { redo, redoDepth, undo, undoDepth } from "@codemirror/commands";
|
||||
import { javascript } from "@codemirror/lang-javascript";
|
||||
import { json } from "@codemirror/lang-json";
|
||||
import { linter } from "@codemirror/lint";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Input,
|
||||
Loader,
|
||||
Popover,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
UnstyledButton,
|
||||
useComputedColorScheme,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconArrowBackUp,
|
||||
IconArrowForwardUp,
|
||||
IconCheck,
|
||||
IconCode,
|
||||
IconComponents,
|
||||
IconFileCode,
|
||||
IconSearch,
|
||||
IconWand,
|
||||
} from "@tabler/icons-react";
|
||||
import { enabledCustomJsxComponents } from "@homarr/definitions";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { Badge, Button, Group, Input, Loader, Text, useComputedColorScheme } from "@mantine/core";
|
||||
import { IconArrowBackUp, IconArrowForwardUp, IconCheck, IconCode, IconFileCode, IconWand } from "@tabler/icons-react";
|
||||
import { enabledCustomJsxComponents } from "@homarr/custom-widgets/core";
|
||||
import { ComponentReference } from "@homarr/custom-widgets/workbench";
|
||||
import type { EditorDiagnostic } from "@homarr/custom-widgets/workbench";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { validateCustomJsxTemplate } from "@homarr/validation/custom-jsx-template";
|
||||
|
||||
import { CodeEditorDiagnostics } from "./_code-editor-diagnostics";
|
||||
import classes from "./_code-editor.module.css";
|
||||
|
||||
const Editor = dynamic(() => import("@uiw/react-codemirror").then((module) => module.default), {
|
||||
@@ -50,43 +28,6 @@ const Editor = dynamic(() => import("@uiw/react-codemirror").then((module) => mo
|
||||
),
|
||||
});
|
||||
|
||||
export const CUSTOM_JSX_TEMPLATE_LIMIT = 50_000;
|
||||
|
||||
export type EditorDiagnosticCode =
|
||||
| "ast"
|
||||
| "dangerousProperty"
|
||||
| "dynamicRequestId"
|
||||
| "eventHandler"
|
||||
| "invalidRequestManifest"
|
||||
| "legacyNetworkProps"
|
||||
| "missingRequestId"
|
||||
| "templateEmpty"
|
||||
| "templateTooLong"
|
||||
| "unknownRequest"
|
||||
| "unsafeTag";
|
||||
|
||||
export interface EditorDiagnostic {
|
||||
code: EditorDiagnosticCode;
|
||||
severity: "error" | "warning";
|
||||
line?: number;
|
||||
column?: number;
|
||||
index?: number;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
interface AnalyzeJsxTemplateOptions {
|
||||
apiVersion?: 1 | 2;
|
||||
requestIds?: string[];
|
||||
}
|
||||
|
||||
const unsafeTagPattern = /<\s*\/?\s*(script|iframe|object|embed|form|style|link|meta|base)\b/giu;
|
||||
const eventHandlerPattern = /\s(on[A-Z][\w]*)\s*=/gu;
|
||||
const dangerousPropertyPattern =
|
||||
/(?:\.|\[\s*["'])(constructor|prototype|__proto__|caller|callee|arguments|bind|call|apply)(?:["']\s*\])?/giu;
|
||||
const networkComponentPattern = /<(SubFetch|ActionButton|ToggleSwitch)\b([^>]*)>/giu;
|
||||
|
||||
const getLine = (source: string, index: number) => source.slice(0, index).split("\n").length;
|
||||
|
||||
const customJsxCompletionSource: CompletionSource = (context) => {
|
||||
const word = context.matchBefore(/[A-Z][\w.]*/u);
|
||||
if (!word && !context.explicit) return null;
|
||||
@@ -100,111 +41,6 @@ const customJsxCompletionSource: CompletionSource = (context) => {
|
||||
};
|
||||
};
|
||||
|
||||
export function analyzeJsxTemplate(
|
||||
template: string,
|
||||
{ apiVersion = 1, requestIds = [] }: AnalyzeJsxTemplateOptions = {},
|
||||
): EditorDiagnostic[] {
|
||||
const diagnostics: EditorDiagnostic[] = [];
|
||||
|
||||
if (template.trim().length === 0) {
|
||||
diagnostics.push({ code: "templateEmpty", severity: "error", line: 1, column: 1, index: 0 });
|
||||
}
|
||||
if (template.length > CUSTOM_JSX_TEMPLATE_LIMIT) {
|
||||
diagnostics.push({ code: "templateTooLong", severity: "error", line: 1, column: 1, index: 0 });
|
||||
}
|
||||
|
||||
for (const diagnostic of validateCustomJsxTemplate(template)) {
|
||||
diagnostics.push({
|
||||
code: "ast",
|
||||
severity: diagnostic.severity,
|
||||
line: diagnostic.line,
|
||||
column: diagnostic.column,
|
||||
index: diagnostic.index,
|
||||
value: diagnostic.message,
|
||||
});
|
||||
}
|
||||
|
||||
for (const match of template.matchAll(unsafeTagPattern)) {
|
||||
diagnostics.push({
|
||||
code: "unsafeTag",
|
||||
severity: "error",
|
||||
line: getLine(template, match.index),
|
||||
index: match.index,
|
||||
value: match[1],
|
||||
});
|
||||
}
|
||||
for (const match of template.matchAll(eventHandlerPattern)) {
|
||||
if (match[1] === "onParams") continue;
|
||||
diagnostics.push({
|
||||
code: "eventHandler",
|
||||
severity: "error",
|
||||
line: getLine(template, match.index),
|
||||
index: match.index,
|
||||
value: match[1],
|
||||
});
|
||||
}
|
||||
for (const match of template.matchAll(dangerousPropertyPattern)) {
|
||||
diagnostics.push({
|
||||
code: "dangerousProperty",
|
||||
severity: "error",
|
||||
line: getLine(template, match.index),
|
||||
index: match.index,
|
||||
value: match[1],
|
||||
});
|
||||
}
|
||||
|
||||
if (apiVersion === 2) {
|
||||
const knownRequestIds = new Set(requestIds);
|
||||
for (const match of template.matchAll(networkComponentPattern)) {
|
||||
const props = match[2] ?? "";
|
||||
const line = getLine(template, match.index);
|
||||
if (/\b(?:url|method|headers|body|onBody|offBody)\s*=/iu.test(props)) {
|
||||
diagnostics.push({ code: "legacyNetworkProps", severity: "error", line, index: match.index });
|
||||
}
|
||||
|
||||
const staticRequest = props.match(/\brequestId\s*=\s*["']([^"']+)["']/iu);
|
||||
if (staticRequest?.[1]) {
|
||||
if (!knownRequestIds.has(staticRequest[1])) {
|
||||
diagnostics.push({
|
||||
code: "unknownRequest",
|
||||
severity: "error",
|
||||
line,
|
||||
index: match.index,
|
||||
value: staticRequest[1],
|
||||
});
|
||||
}
|
||||
} else if (/\brequestId\s*=/iu.test(props)) {
|
||||
diagnostics.push({ code: "dynamicRequestId", severity: "error", line, index: match.index });
|
||||
} else {
|
||||
diagnostics.push({ code: "missingRequestId", severity: "error", line, index: match.index });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
export function analyzeRequestManifest(value: string): EditorDiagnostic[] {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [{ code: "invalidRequestManifest", severity: "error", line: 1 }];
|
||||
}
|
||||
return [];
|
||||
} catch {
|
||||
return [{ code: "invalidRequestManifest", severity: "error", line: 1 }];
|
||||
}
|
||||
}
|
||||
|
||||
export function parseRequestManifest(value: string): unknown[] {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
interface CodeEditorProps {
|
||||
id: string;
|
||||
value: string;
|
||||
@@ -251,10 +87,19 @@ export function CodeEditor({
|
||||
};
|
||||
}),
|
||||
);
|
||||
const accessibilityExtension = EditorView.contentAttributes.of({
|
||||
id,
|
||||
"aria-label": label,
|
||||
});
|
||||
return language === "jsx"
|
||||
? [javascript({ jsx: true }), autocompletion({ override: [customJsxCompletionSource] }), diagnosticsExtension]
|
||||
: [json(), diagnosticsExtension];
|
||||
}, [diagnostics, language, t]);
|
||||
? [
|
||||
javascript({ jsx: true }),
|
||||
autocompletion({ override: [customJsxCompletionSource] }),
|
||||
diagnosticsExtension,
|
||||
accessibilityExtension,
|
||||
]
|
||||
: [json(), diagnosticsExtension, accessibilityExtension];
|
||||
}, [diagnostics, id, label, language, t]);
|
||||
const errorCount = diagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
|
||||
const warningCount = diagnostics.length - errorCount;
|
||||
|
||||
@@ -293,6 +138,7 @@ export function CodeEditor({
|
||||
</Group>
|
||||
<Group gap={6}>
|
||||
<Button
|
||||
type="button"
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconArrowBackUp size={14} />}
|
||||
@@ -302,6 +148,7 @@ export function CodeEditor({
|
||||
{t("action.undo")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconArrowForwardUp size={14} />}
|
||||
@@ -310,9 +157,19 @@ export function CodeEditor({
|
||||
>
|
||||
{t("action.redo")}
|
||||
</Button>
|
||||
{language === "jsx" && <ComponentReference />}
|
||||
{language === "jsx" && (
|
||||
<ComponentReference
|
||||
messages={{
|
||||
action: t("action.components"),
|
||||
search: t("componentReference.search"),
|
||||
empty: t("componentReference.empty"),
|
||||
count: (count) => t("componentReference.count", { count }),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{starter && value.trim().length === 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconFileCode size={14} />}
|
||||
@@ -322,6 +179,7 @@ export function CodeEditor({
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconWand size={14} />}
|
||||
@@ -335,7 +193,7 @@ export function CodeEditor({
|
||||
|
||||
<div className={classes.viewport}>
|
||||
<Editor
|
||||
id={id}
|
||||
id={`${id}-root`}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
@@ -397,94 +255,17 @@ export function CodeEditor({
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
{diagnostics.length > 0 && (
|
||||
<Stack component="ul" gap={6} mt="xs" pl={0} style={{ listStyle: "none" }} aria-label={t("diagnostics.title")}>
|
||||
{diagnostics.map((diagnostic, index) => (
|
||||
<li key={`${diagnostic.code}-${diagnostic.line ?? 0}-${index}`}>
|
||||
<UnstyledButton
|
||||
w="100%"
|
||||
className={classes.diagnosticButton}
|
||||
onClick={() => {
|
||||
if (!editorView || diagnostic.index === undefined) return;
|
||||
const anchor = Math.min(diagnostic.index, editorView.state.doc.length);
|
||||
editorView.dispatch({ selection: { anchor }, scrollIntoView: true });
|
||||
editorView.focus();
|
||||
}}
|
||||
>
|
||||
<Alert
|
||||
color={diagnostic.severity === "error" ? "red" : "yellow"}
|
||||
variant="light"
|
||||
p="xs"
|
||||
icon={<IconAlertTriangle size={15} />}
|
||||
>
|
||||
<Text size="xs">
|
||||
{diagnostic.line ? `${t("diagnostics.line", { line: diagnostic.line })}: ` : ""}
|
||||
{t(`diagnostics.${diagnostic.code}` as never, { value: diagnostic.value ?? "" } as never)}
|
||||
</Text>
|
||||
</Alert>
|
||||
</UnstyledButton>
|
||||
</li>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
<CodeEditorDiagnostics
|
||||
diagnostics={diagnostics}
|
||||
editorView={editorView}
|
||||
title={t("diagnostics.title")}
|
||||
formatDiagnostic={(diagnostic) =>
|
||||
`${diagnostic.line ? `${t("diagnostics.line", { line: diagnostic.line })}: ` : ""}${t(
|
||||
`diagnostics.${diagnostic.code}` as never,
|
||||
{ value: diagnostic.value ?? "" } as never,
|
||||
)}`
|
||||
}
|
||||
/>
|
||||
</Input.Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
function ComponentReference() {
|
||||
const t = useScopedI18n("customWidget.editor");
|
||||
const [query, setQuery] = useState("");
|
||||
const components = useMemo(() => {
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase();
|
||||
return enabledCustomJsxComponents.filter(
|
||||
(component) =>
|
||||
normalizedQuery.length === 0 ||
|
||||
component.name.toLocaleLowerCase().includes(normalizedQuery) ||
|
||||
component.category.toLocaleLowerCase().includes(normalizedQuery),
|
||||
);
|
||||
}, [query]);
|
||||
|
||||
return (
|
||||
<Popover width={320} position="bottom-end" withinPortal shadow="md">
|
||||
<Popover.Target>
|
||||
<Button size="compact-xs" variant="subtle" leftSection={<IconComponents size={14} />}>
|
||||
{t("action.components")}
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p="xs">
|
||||
<Stack gap="xs">
|
||||
<TextInput
|
||||
size="xs"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.currentTarget.value)}
|
||||
placeholder={t("componentReference.search")}
|
||||
aria-label={t("componentReference.search")}
|
||||
leftSection={<IconSearch size={14} />}
|
||||
/>
|
||||
<ScrollArea.Autosize mah={300} type="auto">
|
||||
<Stack gap={4}>
|
||||
{components.map((component) => (
|
||||
<Group key={component.name} justify="space-between" wrap="nowrap" py={3} px={4}>
|
||||
<Text size="xs" ff="monospace" fw={600}>
|
||||
{component.name}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light" color={component.safety === "wrapped" ? "yellow" : "gray"}>
|
||||
{component.category}
|
||||
</Badge>
|
||||
</Group>
|
||||
))}
|
||||
{components.length === 0 && (
|
||||
<Text size="xs" c="dimmed" ta="center" py="md">
|
||||
{t("componentReference.empty")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("componentReference.count", { count: enabledCustomJsxComponents.length })}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -5,176 +5,10 @@ import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconCopy, IconSparkles } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { customJsxExamples, enabledCustomJsxComponents } from "@homarr/definitions";
|
||||
import { buildCustomWidgetAiPrompt } from "@homarr/custom-widgets/core";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
const PROMPT_HEADER = `You are configuring a Homarr custom widget. Ask a concise clarifying question only when the requested behavior is ambiguous.
|
||||
|
||||
For Custom JSX, return a Homarr authoring bundle with exactly two fenced code blocks:
|
||||
1. A \`json\` block containing the complete import object. Set \`displayConfig.template\` to \`"__HOMARR_TEMPLATE__"\`.
|
||||
2. A \`jsx\` block containing the readable, multiline template with no JSON escaping.
|
||||
|
||||
The user can paste both blocks together into Homarr. Homarr combines and validates them before import. For non-Custom-JSX display types, return only the complete JSON block.
|
||||
|
||||
## Rules
|
||||
- Use \`"$schema": "homarr-custom-widget-v3"\`.
|
||||
- \`displayType\` and \`displayConfig.type\` must match.
|
||||
- Prefer \`customJsx\` with \`jsxApiVersion: 2\` for custom layouts.
|
||||
- The base widget request must use GET. Mutations belong in named action requests.
|
||||
- JSX cannot call \`fetch\` or use event handlers. Use SubFetch, ActionButton, ToggleSwitch, and RefreshButton.
|
||||
- Version 2 network components reference a literal \`requestId\` and pass only declared \`params\`.
|
||||
- SubFetch can reference query requests only. ActionButton and ToggleSwitch can reference action requests only.
|
||||
- Prefer the SubFetch function-as-children API for fetched data. The callback receives \`(response, metadata)\` and may return any safe JSX tree. Use SubData only for very small path-based displays.
|
||||
- SubFetch owns loading, failure, retry, cancellation, and stale-response handling. The render callback runs only when response data is available.
|
||||
- Named request paths are same-origin paths beginning with \`/\`. Do not put credentials in a template or export.
|
||||
- Query requests use GET and view permission. POST, PUT, and PATCH actions default to modify. DELETE requires full permission and confirmation.
|
||||
- Use only components in the generated component reference below.
|
||||
- Template output must fit inside a dashboard widget and remain usable at narrow widths.
|
||||
- Return the complete import object, not a template fragment. Preserve unrelated fields from the current configuration.
|
||||
- Include stable keys for rendered collections, accessible labels for controls and images, useful empty states, and concise content that works in light and dark themes.
|
||||
- Prefer one clear information hierarchy over nested cards. Use ActionButton and ToggleSwitch only when the named action and required permission are declared.
|
||||
|
||||
## Named request shape
|
||||
\`\`\`ts
|
||||
interface CustomJsxRequest {
|
||||
id: string;
|
||||
kind: "query" | "action";
|
||||
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
pathTemplate: \`/\${string}\`;
|
||||
parameters: Record<string, "string" | "number" | "boolean">;
|
||||
bodyTemplate?: unknown;
|
||||
staticHeaders?: Record<string, string>;
|
||||
auth: "inherit" | "none";
|
||||
minimumBoardPermission: "view" | "modify" | "full";
|
||||
cacheTtlSeconds?: number;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Path parameters use \`{name}\`. JSON body parameters use \`{ "$param": "name" }\` and preserve the declared type.
|
||||
|
||||
## SubFetch rendering
|
||||
Use a render child when a named query needs its own layout:
|
||||
|
||||
\`\`\`jsx
|
||||
<SubFetch requestId="service-detail" params={{ id: data.serviceId }}>
|
||||
{(detail, meta) =>
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Text fw={700}>{detail.name}</Text>
|
||||
<Badge color={meta.ok ? "green" : "red"}>HTTP {meta.status}</Badge>
|
||||
</Group>
|
||||
{detail.metrics.map((metric) =>
|
||||
<Group key={metric.name} justify="space-between">
|
||||
<Text size="sm">{metric.name}</Text>
|
||||
<Text fw={600}>{metric.value}</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
}
|
||||
</SubFetch>
|
||||
\`\`\`
|
||||
|
||||
The callback is an interpreter-managed inline arrow function. Do not use native callbacks, event handlers, global state, or direct network access. Keep list keys stable and design for narrow and wide widget sizes.
|
||||
`;
|
||||
|
||||
const PROMPT_NO_RESPONSE = `No sample response is available. Leave this marker in your reasoning and ask the user for a representative response if its shape is required:
|
||||
|
||||
\`\`\`json
|
||||
PASTE_API_RESPONSE_HERE
|
||||
\`\`\`
|
||||
`;
|
||||
|
||||
function buildComponentReference() {
|
||||
const categories = new Map<string, string[]>();
|
||||
for (const component of enabledCustomJsxComponents) {
|
||||
const names = categories.get(component.category) ?? [];
|
||||
names.push(component.name);
|
||||
categories.set(component.category, names);
|
||||
}
|
||||
|
||||
return [...categories.entries()].map(([category, names]) => `- ${category}: ${names.join(", ")}`).join("\n");
|
||||
}
|
||||
|
||||
function buildExamples() {
|
||||
return customJsxExamples
|
||||
.map(
|
||||
(example) => `### ${example.title}
|
||||
${example.description}
|
||||
\`\`\`json
|
||||
${JSON.stringify(
|
||||
{
|
||||
displayType: "customJsx",
|
||||
displayConfig: {
|
||||
type: "customJsx",
|
||||
jsxApiVersion: 2,
|
||||
networkScope: "public",
|
||||
template: "__HOMARR_TEMPLATE__",
|
||||
requests: example.requests,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
\`\`\`
|
||||
\`\`\`jsx
|
||||
${example.template}
|
||||
\`\`\``,
|
||||
)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
function buildAiPrompt(
|
||||
jsonSchema: unknown,
|
||||
rawResponse?: string | null,
|
||||
currentConfig?: Record<string, unknown> | null,
|
||||
) {
|
||||
const responseSection = rawResponse
|
||||
? `## API response\n\n\`\`\`json\n${rawResponse}\n\`\`\`\n`
|
||||
: `## API response\n\n${PROMPT_NO_RESPONSE}`;
|
||||
const currentDisplayConfig = currentConfig?.displayConfig;
|
||||
const currentTemplate =
|
||||
currentDisplayConfig && typeof currentDisplayConfig === "object" && "template" in currentDisplayConfig
|
||||
? currentDisplayConfig.template
|
||||
: null;
|
||||
const readableCurrentConfig = currentConfig
|
||||
? {
|
||||
...currentConfig,
|
||||
...(currentTemplate && typeof currentTemplate === "string"
|
||||
? {
|
||||
displayConfig: {
|
||||
...(currentDisplayConfig as Record<string, unknown>),
|
||||
template: "__HOMARR_TEMPLATE__",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: null;
|
||||
const configSection = readableCurrentConfig
|
||||
? `## Current configuration\n\nUse this as the starting point and preserve unrelated settings.\n\n\`\`\`json\n${JSON.stringify(readableCurrentConfig, null, 2)}\n\`\`\`\n${typeof currentTemplate === "string" ? `\n\`\`\`jsx\n${currentTemplate}\n\`\`\`\n` : ""}`
|
||||
: "";
|
||||
|
||||
return `${PROMPT_HEADER}
|
||||
## Import schema
|
||||
The \`__HOMARR_TEMPLATE__\` value is an authoring transport placeholder. Homarr replaces it with the following JSX block before schema and AST validation.
|
||||
|
||||
\`\`\`json
|
||||
${JSON.stringify(jsonSchema, null, 2)}
|
||||
\`\`\`
|
||||
|
||||
## Generated component reference
|
||||
${buildComponentReference()}
|
||||
|
||||
## Tested examples
|
||||
${buildExamples()}
|
||||
|
||||
${responseSection}
|
||||
${configSection}
|
||||
## Requested widget
|
||||
Describe what the widget should show and which interactions it needs:
|
||||
`;
|
||||
}
|
||||
|
||||
interface CopyAiPromptButtonProps {
|
||||
rawResponse?: string | null;
|
||||
currentConfig?: Record<string, unknown> | null;
|
||||
@@ -188,7 +22,7 @@ export const CopyAiPromptButton = ({ rawResponse, currentConfig }: CopyAiPromptB
|
||||
const handleCopy = async () => {
|
||||
if (!schema) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(buildAiPrompt(schema, rawResponse, currentConfig));
|
||||
await navigator.clipboard.writeText(buildCustomWidgetAiPrompt(schema, rawResponse, currentConfig));
|
||||
close();
|
||||
showSuccessNotification({ title: t("action.copyAiPrompt"), message: t("notification.aiPromptCopied") });
|
||||
} catch {
|
||||
@@ -200,6 +34,7 @@ export const CopyAiPromptButton = ({ rawResponse, currentConfig }: CopyAiPromptB
|
||||
<Popover opened={opened} onClose={close} width={320} position="bottom" shadow="md" withinPortal>
|
||||
<Popover.Target>
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
leftSection={<IconSparkles size={16} />}
|
||||
onClick={open}
|
||||
@@ -219,7 +54,7 @@ export const CopyAiPromptButton = ({ rawResponse, currentConfig }: CopyAiPromptB
|
||||
{t("notification.aiPromptNoResponse")}
|
||||
</Text>
|
||||
)}
|
||||
<Button leftSection={<IconCopy size={16} />} onClick={() => void handleCopy()} fullWidth>
|
||||
<Button type="button" leftSection={<IconCopy size={16} />} onClick={() => void handleCopy()} fullWidth>
|
||||
{t("notification.aiPromptCopy")}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
@@ -1,31 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Code,
|
||||
Group,
|
||||
Menu,
|
||||
Modal,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { ActionIcon, Menu } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconAlertTriangle, IconCopy, IconDots, IconDownload, IconTrash, IconUpload } from "@tabler/icons-react";
|
||||
import { IconCopy, IconDots, IconDownload, IconTrash, IconUpload } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { getImportReview, parseCustomWidgetClipboard } from "@homarr/custom-widgets/core";
|
||||
import { ImportReviewDialog } from "@homarr/custom-widgets/workbench";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { getImportReview, parseCustomWidgetClipboard } from "./_custom-widget-import-utils";
|
||||
|
||||
const iconProps = { size: 16, stroke: 1.5 };
|
||||
|
||||
@@ -214,80 +202,28 @@ export const ImportCustomWidgetButton = () => {
|
||||
onChange={handleImport}
|
||||
aria-label={t("importReview.fileLabel")}
|
||||
/>
|
||||
<Modal opened={reviewOpened} onClose={closeReview} title={t("importReview.title")} centered size="lg">
|
||||
{pendingImport && review && (
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("importReview.description")}
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="xs">
|
||||
<ImportFact label={t("importReview.name")} value={review.name} />
|
||||
<ImportFact label={t("importReview.origin")} value={review.origin} />
|
||||
<ImportFact label={t("importReview.authentication")} value={review.authType} />
|
||||
<ImportFact label={t("importReview.networkScope")} value={review.networkScope} />
|
||||
</SimpleGrid>
|
||||
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb={6}>
|
||||
{t("importReview.methods")}
|
||||
</Text>
|
||||
<Group gap={6}>
|
||||
{review.methods.map((method) => (
|
||||
<Badge
|
||||
key={method}
|
||||
color={method === "DELETE" ? "red" : method === "GET" ? "blue" : "orange"}
|
||||
variant="light"
|
||||
>
|
||||
{method}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb={6}>
|
||||
{t("importReview.permissions")}
|
||||
</Text>
|
||||
<Group gap={6}>
|
||||
{review.permissions.map((permission) => (
|
||||
<Badge key={permission} color="gray" variant="light">
|
||||
{t(`preview.request.permission.${permission}` as never)}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
{review.hasActions && (
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
|
||||
<Text size="sm" fw={600}>
|
||||
{t("importReview.actionWarning.title")}
|
||||
</Text>
|
||||
<Text size="sm">{t("importReview.actionWarning.description")}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={closeReview} disabled={importMutation.isPending}>
|
||||
{t("importReview.cancel")}
|
||||
</Button>
|
||||
<Button onClick={() => importMutation.mutate(pendingImport as never)} loading={importMutation.isPending}>
|
||||
{t("importReview.confirm")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
<ImportReviewDialog
|
||||
opened={reviewOpened}
|
||||
review={review}
|
||||
pending={importMutation.isPending}
|
||||
onClose={closeReview}
|
||||
onConfirm={() => pendingImport && importMutation.mutate(pendingImport as never)}
|
||||
messages={{
|
||||
title: t("importReview.title"),
|
||||
description: t("importReview.description"),
|
||||
name: t("importReview.name"),
|
||||
origin: t("importReview.origin"),
|
||||
authentication: t("importReview.authentication"),
|
||||
networkScope: t("importReview.networkScope"),
|
||||
methods: t("importReview.methods"),
|
||||
permissions: t("importReview.permissions"),
|
||||
actionWarningTitle: t("importReview.actionWarning.title"),
|
||||
actionWarningDescription: t("importReview.actionWarning.description"),
|
||||
cancel: t("importReview.cancel"),
|
||||
confirm: t("importReview.confirm"),
|
||||
permission: (permission) => t(`preview.request.permission.${permission}` as never),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function ImportFact({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Paper withBorder p="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Code>{value || "—"}</Code>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
const serverToFormFieldMap: Record<string, Record<string, string>> = {
|
||||
statGrid: { items: "statGridItems" },
|
||||
countGrid: { items: "countGridItems" },
|
||||
statusIndicator: { items: "statusItems" },
|
||||
progressBars: { bars: "progressBars" },
|
||||
keyValue: { mappings: "mappings" },
|
||||
table: { columns: "columns", tablePath: "tablePath" },
|
||||
singleValue: { jsonPath: "jsonPath", label: "label", unit: "unit" },
|
||||
raw: { jsonPath: "rawJsonPath" },
|
||||
actionButton: { buttonLabel: "buttonLabel" },
|
||||
customJsx: { template: "template", requests: "requestManifest", networkScope: "networkScope" },
|
||||
};
|
||||
|
||||
export function extractServerErrors(error: unknown, displayType: string): Record<string, string> {
|
||||
const errors: Record<string, string> = {};
|
||||
const trpcError = error as {
|
||||
data?: { zodError?: { fieldErrors?: Record<string, string[]> } };
|
||||
message?: string;
|
||||
};
|
||||
if (trpcError.data?.zodError?.fieldErrors) {
|
||||
for (const [field, messages] of Object.entries(trpcError.data.zodError.fieldErrors)) {
|
||||
if (messages?.[0]) errors[field] = messages[0];
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
try {
|
||||
const issues = JSON.parse(trpcError.message ?? "[]") as Array<{ path: (string | number)[]; message: string }>;
|
||||
const fieldMap = serverToFormFieldMap[displayType] ?? {};
|
||||
for (const issue of issues) {
|
||||
const path = [...issue.path];
|
||||
if (path[0] === "displayConfig") {
|
||||
path.shift();
|
||||
const serverField = String(path[0]);
|
||||
path[0] = fieldMap[serverField] ?? serverField;
|
||||
}
|
||||
errors[path.join(".")] = issue.message;
|
||||
}
|
||||
} catch {
|
||||
// Non-validation server errors are handled by the caller's notification.
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import type { RefObject } from "react";
|
||||
import {
|
||||
Button,
|
||||
Fieldset,
|
||||
Group,
|
||||
PasswordInput,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { IconCode, IconLayoutGrid, IconPlayerPlay } from "@tabler/icons-react";
|
||||
import { IconPicker } from "@homarr/forms-collection";
|
||||
import { CUSTOM_JSX_STARTER } from "@homarr/custom-widgets/core";
|
||||
import {
|
||||
CUSTOM_WIDGET_AUTH_SECRET_FIELDS,
|
||||
CUSTOM_WIDGET_AUTH_USES_HEADER_NAME,
|
||||
} from "@homarr/custom-widgets/workbench";
|
||||
|
||||
import type { DisplayFieldsProps } from "./_display-field-types";
|
||||
import { DISPLAY_TYPE_ICONS, DisplayTypeFields, DisplayTypePicker } from "./_display-type-fields";
|
||||
import classes from "./_custom-widget-form.module.css";
|
||||
|
||||
interface SharedSectionProps extends DisplayFieldsProps {
|
||||
sectionRef: RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
interface FormatSectionProps extends SharedSectionProps {
|
||||
onContinue: () => void;
|
||||
}
|
||||
|
||||
export function FormatSection({ form, t, sectionRef, onContinue }: FormatSectionProps) {
|
||||
return (
|
||||
<section ref={sectionRef} className={classes.formSection} tabIndex={-1}>
|
||||
<ThemeIcon className={classes.sectionMarker} variant="filled" size={40} radius="xl">
|
||||
<IconLayoutGrid size={20} />
|
||||
</ThemeIcon>
|
||||
<Stack gap="xl">
|
||||
<SectionHeading label={t("steps.format.label")} description={t("steps.format.description")} />
|
||||
<Fieldset legend={t("fieldset.general")}>
|
||||
<Stack gap="sm">
|
||||
<TextInput label={t("field.name")} required {...form.getInputProps("name")} />
|
||||
<Textarea label={t("field.description")} {...form.getInputProps("description")} />
|
||||
<IconPicker withAsterisk={false} {...form.getInputProps("iconUrl")} />
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
<Fieldset legend={t("field.displayType")}>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("steps.format.help")}
|
||||
</Text>
|
||||
<DisplayTypePicker
|
||||
value={form.values.displayType}
|
||||
onChange={(value) => {
|
||||
form.setFieldValue("displayType", value);
|
||||
if (value === "customJsx" && !form.values.template.trim())
|
||||
form.setFieldValue("template", CUSTOM_JSX_STARTER);
|
||||
}}
|
||||
t={t}
|
||||
/>
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
<ContinueButton label={t("steps.continueTo", { step: t("steps.connection.label") })} onClick={onContinue} />
|
||||
</Stack>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConnectionSectionProps extends SharedSectionProps {
|
||||
isTesting: boolean;
|
||||
onTest: () => void;
|
||||
onContinue: () => void;
|
||||
}
|
||||
|
||||
export function ConnectionSection({ form, t, sectionRef, isTesting, onTest, onContinue }: ConnectionSectionProps) {
|
||||
const secretFields = CUSTOM_WIDGET_AUTH_SECRET_FIELDS[form.values.authType] ?? [];
|
||||
const authTypeOptions = ["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"].map((value) => ({
|
||||
value,
|
||||
label: t(`authType.${value}` as never),
|
||||
}));
|
||||
const handleAuthTypeChange = (value: string) => {
|
||||
form.setFieldValue("authType", value);
|
||||
form.setFieldValue(
|
||||
"secrets",
|
||||
(CUSTOM_WIDGET_AUTH_SECRET_FIELDS[value] ?? []).map((field) => ({
|
||||
kind: field.kind,
|
||||
value: form.values.secrets.find((secret) => secret.kind === field.kind)?.value ?? "",
|
||||
})),
|
||||
);
|
||||
};
|
||||
return (
|
||||
<section ref={sectionRef} className={classes.formSection} tabIndex={-1}>
|
||||
<ThemeIcon className={classes.sectionMarker} variant="light" size={40} radius="xl">
|
||||
<IconPlayerPlay size={20} />
|
||||
</ThemeIcon>
|
||||
<Stack gap="xl">
|
||||
<SectionHeading label={t("steps.connection.label")} description={t("steps.connection.description")} />
|
||||
<Fieldset legend={t("fieldset.connection")}>
|
||||
<Stack gap="sm">
|
||||
<Group align="end" wrap="wrap" gap="xs">
|
||||
<Select
|
||||
label={t("field.method")}
|
||||
data={["GET", "POST", "PUT", "DELETE", "PATCH"].map((value) => ({
|
||||
value,
|
||||
label: t(`method.${value}` as never),
|
||||
}))}
|
||||
w={110}
|
||||
{...form.getInputProps("method")}
|
||||
allowDeselect={false}
|
||||
disabled={form.values.displayType === "customJsx" && form.values.jsxApiVersion === "2"}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("field.url")}
|
||||
required
|
||||
placeholder={t("placeholder.url")}
|
||||
style={{ flex: "1 1 260px" }}
|
||||
{...form.getInputProps("url")}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="light"
|
||||
leftSection={<IconPlayerPlay size={16} />}
|
||||
onClick={onTest}
|
||||
loading={isTesting}
|
||||
disabled={!form.values.url || form.values.method !== "GET"}
|
||||
>
|
||||
{t("preview.test")}
|
||||
</Button>
|
||||
</Group>
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb={4}>
|
||||
{t("field.authType")}
|
||||
</Text>
|
||||
<Select
|
||||
hiddenFrom="sm"
|
||||
value={form.values.authType}
|
||||
data={authTypeOptions}
|
||||
onChange={(value) => handleAuthTypeChange(value ?? "none")}
|
||||
allowDeselect={false}
|
||||
aria-label={t("field.authType")}
|
||||
/>
|
||||
<SegmentedControl
|
||||
visibleFrom="sm"
|
||||
fullWidth
|
||||
value={form.values.authType}
|
||||
data={authTypeOptions}
|
||||
onChange={handleAuthTypeChange}
|
||||
/>
|
||||
</div>
|
||||
{CUSTOM_WIDGET_AUTH_USES_HEADER_NAME[form.values.authType] && (
|
||||
<TextInput
|
||||
label={t("field.headerName")}
|
||||
placeholder={t("placeholder.headerName")}
|
||||
{...form.getInputProps("headerName")}
|
||||
/>
|
||||
)}
|
||||
{secretFields.map((field) => {
|
||||
const index = form.values.secrets.findIndex((secret) => secret.kind === field.kind);
|
||||
const secret = form.values.secrets[index];
|
||||
if (index === -1 || !secret) return null;
|
||||
const props = {
|
||||
label: t(`secret.${field.labelKey}` as never),
|
||||
placeholder: secret.hasValue && !secret.value ? t("secret.savedPlaceholder" as never) : undefined,
|
||||
...form.getInputProps(`secrets.${index}.value`),
|
||||
};
|
||||
return field.isPassword ? (
|
||||
<PasswordInput key={field.kind} {...props} />
|
||||
) : (
|
||||
<TextInput key={field.kind} {...props} />
|
||||
);
|
||||
})}
|
||||
{form.values.method !== "GET" && (
|
||||
<Textarea label={t("field.requestBody")} minRows={3} {...form.getInputProps("requestBody")} />
|
||||
)}
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
<ContinueButton label={t("steps.continueTo", { step: t("steps.configure.label") })} onClick={onContinue} />
|
||||
</Stack>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConfigureSection({ form, t, previewJson, sectionRef }: SharedSectionProps) {
|
||||
const SelectedIcon = DISPLAY_TYPE_ICONS[form.values.displayType as keyof typeof DISPLAY_TYPE_ICONS];
|
||||
return (
|
||||
<section ref={sectionRef} className={classes.formSection} tabIndex={-1}>
|
||||
<ThemeIcon className={classes.sectionMarker} variant="light" size={40} radius="xl">
|
||||
<IconCode size={20} />
|
||||
</ThemeIcon>
|
||||
<Stack gap="xl">
|
||||
<SectionHeading label={t("steps.configure.label")} description={t("steps.configure.description")} />
|
||||
<Fieldset legend={t("fieldset.display")}>
|
||||
<Stack gap="sm">
|
||||
<Group gap="xs">
|
||||
<ThemeIcon variant="light" size="lg">
|
||||
{SelectedIcon ? <SelectedIcon size={20} /> : null}
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={600}>{t(`displayType.${form.values.displayType}` as never)}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(`displayTypeDescription.${form.values.displayType}` as never)}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<DisplayTypeFields form={form} t={t} previewJson={previewJson} />
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
</Stack>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHeading({ label, description }: { label: string; description: string }) {
|
||||
return (
|
||||
<div>
|
||||
<Text fw={700} size="lg">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{description}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContinueButton({ label, onClick }: { label: string; onClick: () => void }) {
|
||||
return (
|
||||
<Group justify="flex-end">
|
||||
<Button type="button" variant="light" size="md" onClick={onClick}>
|
||||
{label}
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
-44
@@ -1,44 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseCustomWidgetClipboard } from "./_custom-widget-import-utils";
|
||||
|
||||
const widget = {
|
||||
$schema: "homarr-custom-widget-v3",
|
||||
name: "Example",
|
||||
url: "https://example.com/api",
|
||||
authType: "none",
|
||||
method: "GET",
|
||||
displayType: "customJsx",
|
||||
displayConfig: {
|
||||
type: "customJsx",
|
||||
jsxApiVersion: 2,
|
||||
networkScope: "public",
|
||||
requests: [],
|
||||
template: "__HOMARR_TEMPLATE__",
|
||||
},
|
||||
};
|
||||
|
||||
describe("parseCustomWidgetClipboard", () => {
|
||||
it("keeps supporting raw JSON imports", () => {
|
||||
const result = parseCustomWidgetClipboard(JSON.stringify({ ...widget, displayType: "raw" }));
|
||||
expect(result?.name).toBe("Example");
|
||||
});
|
||||
|
||||
it("combines a JSON metadata block with a readable JSX block", () => {
|
||||
const result = parseCustomWidgetClipboard(`Here is the widget:
|
||||
|
||||
\`\`\`json
|
||||
${JSON.stringify(widget, null, 2)}
|
||||
\`\`\`
|
||||
|
||||
\`\`\`jsx
|
||||
<Stack gap="sm">
|
||||
<Text>{data.name}</Text>
|
||||
</Stack>
|
||||
\`\`\``);
|
||||
|
||||
expect((result?.displayConfig as Record<string, unknown> | undefined)?.template).toBe(
|
||||
'<Stack gap="sm">\n <Text>{data.name}</Text>\n</Stack>',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,194 +0,0 @@
|
||||
export interface ImportReview {
|
||||
name: string;
|
||||
origin: string;
|
||||
authType: string;
|
||||
networkScope: string;
|
||||
methods: string[];
|
||||
permissions: string[];
|
||||
hasActions: boolean;
|
||||
}
|
||||
|
||||
export function parseCustomWidgetClipboard(text: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const jsonBlock = text.match(/```(?:json)?\s*([\s\S]*?)```/iu)?.[1];
|
||||
const jsxBlock = text.match(/```(?:jsx|tsx)\s*([\s\S]*?)```/iu)?.[1]?.trim();
|
||||
const value: unknown = JSON.parse(jsonBlock ?? text);
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const widget = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof widget.name !== "string" ||
|
||||
typeof widget.url !== "string" ||
|
||||
typeof widget.displayType !== "string" ||
|
||||
!widget.displayConfig ||
|
||||
typeof widget.displayConfig !== "object" ||
|
||||
Array.isArray(widget.displayConfig)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (jsxBlock && widget.displayType === "customJsx") {
|
||||
const displayConfig = widget.displayConfig as Record<string, unknown>;
|
||||
if (displayConfig.template === "__HOMARR_TEMPLATE__" || !displayConfig.template) {
|
||||
displayConfig.template = jsxBlock;
|
||||
}
|
||||
}
|
||||
return widget;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getImportReview(value: Record<string, unknown> | null): ImportReview | null {
|
||||
if (!value) return null;
|
||||
const displayConfig = value.displayConfig as Record<string, unknown>;
|
||||
const requests = Array.isArray(displayConfig.requests)
|
||||
? displayConfig.requests.filter(
|
||||
(request): request is Record<string, unknown> =>
|
||||
request !== null && typeof request === "object" && !Array.isArray(request),
|
||||
)
|
||||
: [];
|
||||
const baseMethod = typeof value.method === "string" ? value.method : "GET";
|
||||
const methods = [
|
||||
...new Set([
|
||||
baseMethod,
|
||||
...requests.map((request) => request.method).filter((method): method is string => typeof method === "string"),
|
||||
]),
|
||||
];
|
||||
const permissions = [
|
||||
...new Set(
|
||||
requests
|
||||
.map((request) => request.minimumBoardPermission)
|
||||
.filter(
|
||||
(permission): permission is string =>
|
||||
permission === "view" || permission === "modify" || permission === "full",
|
||||
),
|
||||
),
|
||||
];
|
||||
if (permissions.length === 0) permissions.push(baseMethod === "GET" ? "view" : "modify");
|
||||
|
||||
let origin = value.url;
|
||||
try {
|
||||
origin = new URL(value.url as string).origin;
|
||||
} catch {
|
||||
// The server returns the localized validation error after confirmation.
|
||||
}
|
||||
|
||||
return {
|
||||
name: value.name as string,
|
||||
origin: String(origin),
|
||||
authType: typeof value.authType === "string" ? value.authType : "none",
|
||||
networkScope: typeof displayConfig.networkScope === "string" ? displayConfig.networkScope : "legacy",
|
||||
methods,
|
||||
permissions,
|
||||
hasActions: methods.some((method) => method !== "GET"),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDisplayFormValues(displayType: string, config: Record<string, unknown>) {
|
||||
const base = {
|
||||
displayType,
|
||||
jsonPath: "$",
|
||||
label: "",
|
||||
unit: "",
|
||||
valueSize: "lg",
|
||||
labelPosition: "below",
|
||||
mappings: [{ label: "", jsonPath: "$", unit: "" }],
|
||||
kvLayout: "list",
|
||||
kvColumns: 2,
|
||||
tablePath: "$",
|
||||
columns: [{ header: "", jsonPath: "$" }],
|
||||
striped: true,
|
||||
compact: false,
|
||||
statGridItems: [{ label: "", jsonPath: "$", unit: "", color: "blue" }],
|
||||
statGridColumns: 2,
|
||||
cardStyle: "filled",
|
||||
progressBars: [{ label: "", valuePath: "$", maxPath: "", unit: "", color: "blue" }],
|
||||
showPercentage: true,
|
||||
barSize: "md",
|
||||
statusItems: [{ label: "", jsonPath: "$", goodValues: "online,true" }],
|
||||
statusLayout: "list",
|
||||
dotSize: "md",
|
||||
countGridItems: [{ label: "", jsonPath: "$", unit: "" }],
|
||||
countGridColumns: 2,
|
||||
countValueSize: "md",
|
||||
rawJsonPath: "$",
|
||||
rawMaxHeight: 300,
|
||||
buttonLabel: "Execute",
|
||||
buttonColor: "blue",
|
||||
confirmText: "",
|
||||
successMessage: "",
|
||||
template: CUSTOM_JSX_STARTER,
|
||||
jsxApiVersion: "2",
|
||||
networkScope: "public",
|
||||
requestManifest: "[]",
|
||||
};
|
||||
|
||||
const typeOverrides: Record<string, () => Record<string, unknown>> = {
|
||||
singleValue: () => ({
|
||||
jsonPath: (config.jsonPath as string) ?? "$",
|
||||
label: (config.label as string) ?? "",
|
||||
unit: (config.unit as string) ?? "",
|
||||
valueSize: (config.valueSize as string) ?? "lg",
|
||||
labelPosition: (config.labelPosition as string) ?? "below",
|
||||
}),
|
||||
keyValue: () => ({
|
||||
mappings: (config.mappings as typeof base.mappings) ?? base.mappings,
|
||||
kvLayout: (config.layout as string) ?? "list",
|
||||
kvColumns: (config.columns as number) ?? 2,
|
||||
}),
|
||||
table: () => ({
|
||||
tablePath: (config.tablePath as string) ?? "$",
|
||||
columns: (config.columns as typeof base.columns) ?? base.columns,
|
||||
striped: (config.striped as boolean) ?? true,
|
||||
compact: (config.compact as boolean) ?? false,
|
||||
}),
|
||||
statGrid: () => ({
|
||||
statGridItems: (config.items as typeof base.statGridItems) ?? base.statGridItems,
|
||||
statGridColumns: (config.columns as number) ?? 2,
|
||||
cardStyle: (config.cardStyle as string) ?? "filled",
|
||||
}),
|
||||
progressBars: () => ({
|
||||
progressBars: (
|
||||
(config.bars as Array<{ label: string; valuePath: string; maxPath?: string; unit: string; color?: string }>) ??
|
||||
[]
|
||||
).map((bar) => ({ ...bar, maxPath: bar.maxPath ?? "", color: bar.color ?? "blue" })),
|
||||
showPercentage: (config.showPercentage as boolean) ?? true,
|
||||
barSize: (config.barSize as string) ?? "md",
|
||||
}),
|
||||
statusIndicator: () => ({
|
||||
statusItems: ((config.items as Array<{ label: string; jsonPath: string; goodValues: string[] }>) ?? []).map(
|
||||
(item) => ({ ...item, goodValues: item.goodValues.join(", ") }),
|
||||
),
|
||||
statusLayout: (config.layout as string) ?? "list",
|
||||
dotSize: (config.dotSize as string) ?? "md",
|
||||
}),
|
||||
countGrid: () => ({
|
||||
countGridItems: (config.items as typeof base.countGridItems) ?? base.countGridItems,
|
||||
countGridColumns: (config.columns as number) ?? 2,
|
||||
countValueSize: (config.valueSize as string) ?? "md",
|
||||
}),
|
||||
raw: () => ({
|
||||
rawJsonPath: (config.jsonPath as string) ?? "$",
|
||||
rawMaxHeight: (config.maxHeight as number) ?? 300,
|
||||
}),
|
||||
actionButton: () => ({
|
||||
buttonLabel: (config.buttonLabel as string) ?? "Execute",
|
||||
buttonColor: (config.buttonColor as string) ?? "blue",
|
||||
confirmText: (config.confirmText as string) ?? "",
|
||||
successMessage: (config.successMessage as string) ?? "",
|
||||
}),
|
||||
customJsx: () => ({
|
||||
template:
|
||||
typeof config.template === "string" && config.template.trim().length > 0 ? config.template : CUSTOM_JSX_STARTER,
|
||||
jsxApiVersion: String((config.jsxApiVersion as number | undefined) ?? 1),
|
||||
networkScope: (config.networkScope as string) ?? "public",
|
||||
requestManifest: JSON.stringify((config.requests as unknown[] | undefined) ?? [], null, 2),
|
||||
}),
|
||||
};
|
||||
|
||||
return { ...base, ...typeOverrides[displayType]?.() };
|
||||
}
|
||||
import { customJsxExamples } from "@homarr/definitions";
|
||||
|
||||
export const CUSTOM_JSX_STARTER =
|
||||
customJsxExamples.find((example) => example.id === "simple-metric")?.template ??
|
||||
'<Stack align="center" justify="center" h="100%"><Text size="xs" c="dimmed">Connect data to begin</Text></Stack>';
|
||||
@@ -1,78 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { Component as ReactComponent, useEffect, useMemo, useState } from "react";
|
||||
import type { ErrorInfo, ReactNode } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Code,
|
||||
Collapse,
|
||||
CopyButton,
|
||||
getTreeExpandedState,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Popover,
|
||||
ScrollArea,
|
||||
SegmentedControl,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Switch,
|
||||
Tabs,
|
||||
Text,
|
||||
Textarea,
|
||||
Title,
|
||||
Tree,
|
||||
useTree,
|
||||
} from "@mantine/core";
|
||||
import type { RenderTreeNodePayload, TreeNodeData } from "@mantine/core";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconClipboard,
|
||||
IconCode,
|
||||
IconCopy,
|
||||
IconExternalLink,
|
||||
IconInfoCircle,
|
||||
IconNetwork,
|
||||
IconPlayerPlay,
|
||||
IconPlus,
|
||||
} from "@tabler/icons-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Card, Stack, Tabs } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type {
|
||||
CustomWidgetAuthType,
|
||||
CustomWidgetDisplayType,
|
||||
CustomWidgetMethod,
|
||||
CustomWidgetSecretKind,
|
||||
DisplayConfig,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import { extractDisplayData } from "@homarr/custom-widgets/core";
|
||||
import {
|
||||
analyzeJsxTemplate,
|
||||
getPreviewLegacyMethods,
|
||||
getPreviewNamedRequests,
|
||||
PreviewDiagnosticsPanel,
|
||||
PreviewErrorBoundary,
|
||||
PreviewHeader,
|
||||
PreviewRequestPanel,
|
||||
PreviewResponsePanel,
|
||||
redactPreviewUrl,
|
||||
} from "@homarr/custom-widgets/workbench";
|
||||
import { CUSTOM_JSX_METHOD_COLORS } from "@homarr/widgets/custom-api/custom-jsx-display";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { displayComponents } from "@homarr/widgets/custom-api/component";
|
||||
import { extractDisplayData } from "@homarr/widgets/custom-api/extract-display-data";
|
||||
|
||||
import { analyzeJsxTemplate } from "./_code-editor";
|
||||
import { PreviewWidgetPanel } from "./_preview-widget-panel";
|
||||
|
||||
interface PreviewInput {
|
||||
export interface PreviewInput {
|
||||
url: string;
|
||||
method: string;
|
||||
authType: string;
|
||||
method: CustomWidgetMethod;
|
||||
authType: CustomWidgetAuthType;
|
||||
headerName?: string;
|
||||
requestBody?: string;
|
||||
displayType: string;
|
||||
displayConfig: Record<string, unknown>;
|
||||
secrets: Array<{ kind: string; value: string }>;
|
||||
displayType: CustomWidgetDisplayType;
|
||||
displayConfig: DisplayConfig;
|
||||
secrets: Array<{ kind: CustomWidgetSecretKind; value: string }>;
|
||||
definitionId?: string;
|
||||
}
|
||||
|
||||
interface NamedRequestSummary {
|
||||
id: string;
|
||||
kind: "query" | "action";
|
||||
method: string;
|
||||
pathTemplate: string;
|
||||
minimumBoardPermission?: string;
|
||||
}
|
||||
|
||||
export interface PreviewFetchResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
@@ -98,94 +65,6 @@ interface CustomWidgetPreviewProps {
|
||||
|
||||
const methodColor = (method: string) => CUSTOM_JSX_METHOD_COLORS[method] ?? "gray";
|
||||
|
||||
function journalEntryColor(entry: { simulated: boolean; status: number | null }): string {
|
||||
if (entry.simulated) return "yellow";
|
||||
if (entry.status !== null && entry.status >= 200 && entry.status < 400) return "green";
|
||||
return "red";
|
||||
}
|
||||
|
||||
const redactUrl = (value: string) => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
for (const key of url.searchParams.keys()) url.searchParams.set(key, "redacted");
|
||||
return url.toString();
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const getNamedRequests = (displayConfig: Record<string, unknown>): NamedRequestSummary[] => {
|
||||
if (!Array.isArray(displayConfig.requests)) return [];
|
||||
return displayConfig.requests.filter(
|
||||
(request): request is NamedRequestSummary =>
|
||||
request !== null &&
|
||||
typeof request === "object" &&
|
||||
"id" in request &&
|
||||
typeof request.id === "string" &&
|
||||
"kind" in request &&
|
||||
(request.kind === "query" || request.kind === "action") &&
|
||||
"method" in request &&
|
||||
typeof request.method === "string" &&
|
||||
"pathTemplate" in request &&
|
||||
typeof request.pathTemplate === "string",
|
||||
);
|
||||
};
|
||||
|
||||
const getLegacyMethods = (template: string) => {
|
||||
const methods = new Set<string>();
|
||||
for (const match of template.matchAll(/<(?:SubFetch|ActionButton|ToggleSwitch)\b([^>]*)>/giu)) {
|
||||
const method = match[1]?.match(/\bmethod\s*=\s*["'](GET|POST|PUT|PATCH|DELETE)["']/iu)?.[1];
|
||||
const defaultMethod = match[0].startsWith("<SubFetch") ? "GET" : "POST";
|
||||
methods.add(method?.toUpperCase() ?? defaultMethod);
|
||||
}
|
||||
return [...methods];
|
||||
};
|
||||
|
||||
interface PreviewErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
retryLabel: string;
|
||||
}
|
||||
|
||||
interface PreviewErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
class PreviewErrorBoundary extends ReactComponent<PreviewErrorBoundaryProps, PreviewErrorBoundaryState> {
|
||||
state: PreviewErrorBoundaryState = { hasError: false };
|
||||
|
||||
static getDerivedStateFromError(): PreviewErrorBoundaryState {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(_error: Error, _info: ErrorInfo) {
|
||||
// The renderer exposes redacted diagnostics. Avoid logging imported template data here.
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.hasError) return this.props.children;
|
||||
return (
|
||||
<Card p="xl" role="alert">
|
||||
<Stack align="center" gap="sm" py="xl">
|
||||
<IconAlertTriangle size={28} color="var(--mantine-color-red-6)" />
|
||||
<div>
|
||||
<Text fw={600} ta="center">
|
||||
{this.props.title}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ta="center" maw={480}>
|
||||
{this.props.description}
|
||||
</Text>
|
||||
</div>
|
||||
<Button variant="light" onClick={() => this.setState({ hasError: false })}>
|
||||
{this.props.retryLabel}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function CustomWidgetPreview(props: CustomWidgetPreviewProps) {
|
||||
const t = useScopedI18n("customWidget.preview");
|
||||
return (
|
||||
@@ -216,8 +95,7 @@ function CustomWidgetPreviewContent({
|
||||
const t = useScopedI18n("customWidget");
|
||||
const editorT = useScopedI18n("customWidget.editor");
|
||||
const [previewSize, setPreviewSize] = useState("standard");
|
||||
const [sampleEditorOpen, setSampleEditorOpen] = useState(false);
|
||||
const [sampleDraft, setSampleDraft] = useState("");
|
||||
const displayConfig = formValues.displayConfig as DisplayConfig & Record<string, unknown>;
|
||||
const previewSessionId = fetchResult?.previewSession?.id ?? "";
|
||||
const journalQuery = clientApi.customWidget.previewJournal.useQuery(
|
||||
{ sessionId: previewSessionId },
|
||||
@@ -225,7 +103,7 @@ function CustomWidgetPreviewContent({
|
||||
);
|
||||
|
||||
const displayData = useMemo((): Record<string, unknown> | null => {
|
||||
const { displayType, displayConfig } = formValues;
|
||||
const { displayType } = formValues;
|
||||
if (displayType === "actionButton") {
|
||||
return extractDisplayData(null, displayType, displayConfig) as Record<string, unknown>;
|
||||
}
|
||||
@@ -238,24 +116,24 @@ function CustomWidgetPreviewContent({
|
||||
previewLiveActions: fetchResult.previewSession.liveActions,
|
||||
}
|
||||
: extracted;
|
||||
}, [fetchResult, cachedJson, formValues]);
|
||||
}, [fetchResult, cachedJson, formValues, displayConfig]);
|
||||
|
||||
const namedRequests = useMemo(() => getNamedRequests(formValues.displayConfig), [formValues.displayConfig]);
|
||||
const namedRequests = useMemo(() => getPreviewNamedRequests(displayConfig), [displayConfig]);
|
||||
const httpMethods = useMemo(() => {
|
||||
if (formValues.displayType !== "customJsx") return [];
|
||||
if (namedRequests.length > 0) return [...new Set(namedRequests.map((request) => request.method))];
|
||||
return getLegacyMethods((formValues.displayConfig.template as string) ?? "");
|
||||
}, [formValues.displayType, formValues.displayConfig.template, namedRequests]);
|
||||
return getPreviewLegacyMethods((displayConfig.template as string) ?? "");
|
||||
}, [formValues.displayType, displayConfig.template, namedRequests]);
|
||||
const hasNamedActions = namedRequests.some((request) => request.kind === "action");
|
||||
const templateDiagnostics = useMemo(
|
||||
() =>
|
||||
formValues.displayType === "customJsx"
|
||||
? analyzeJsxTemplate((formValues.displayConfig.template as string) ?? "", {
|
||||
apiVersion: formValues.displayConfig.jsxApiVersion === 2 ? 2 : 1,
|
||||
? analyzeJsxTemplate((displayConfig.template as string) ?? "", {
|
||||
apiVersion: displayConfig.jsxApiVersion === 2 ? 2 : 1,
|
||||
requestIds: namedRequests.map((request) => request.id),
|
||||
})
|
||||
: [],
|
||||
[formValues.displayConfig, formValues.displayType, namedRequests],
|
||||
[displayConfig, formValues.displayType, namedRequests],
|
||||
);
|
||||
|
||||
const handleTest = () => {
|
||||
@@ -264,131 +142,40 @@ function CustomWidgetPreviewContent({
|
||||
onTest();
|
||||
};
|
||||
|
||||
const PREVIEW_HEIGHTS: Record<string, number> = { compact: 240, standard: 360, wide: 520 };
|
||||
const previewHeight = PREVIEW_HEIGHTS[previewSize] ?? 360;
|
||||
const responseJson = cachedJson == null ? "" : JSON.stringify(cachedJson, null, 2);
|
||||
const sampleError = useMemo(() => {
|
||||
if (!sampleDraft.trim()) return null;
|
||||
try {
|
||||
JSON.parse(sampleDraft);
|
||||
return null;
|
||||
} catch {
|
||||
return t("preview.response.invalidSample");
|
||||
}
|
||||
}, [sampleDraft, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sampleEditorOpen) setSampleDraft(responseJson);
|
||||
}, [responseJson, sampleEditorOpen]);
|
||||
|
||||
const openSampleEditor = () => {
|
||||
setSampleDraft(responseJson || JSON.stringify({ name: "Sample service", status: "online", value: 42 }, null, 2));
|
||||
setSampleEditorOpen(true);
|
||||
};
|
||||
|
||||
const applySampleData = () => {
|
||||
if (!sampleDraft.trim() || sampleError) return;
|
||||
onSampleDataChange?.(JSON.parse(sampleDraft) as unknown);
|
||||
setSampleEditorOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card withBorder p="lg">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Title order={5}>{t("preview.title")}</Title>
|
||||
{httpMethods.length > 0 && (
|
||||
<Popover width={300} position="bottom" withinPortal shadow="md">
|
||||
<Popover.Target>
|
||||
<Button size="compact-xs" variant="subtle" color="gray" leftSection={<IconNetwork size={14} />}>
|
||||
{t("preview.capabilities.interactive")}
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
{t("preview.capabilities.title")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("preview.capabilities.description")}
|
||||
</Text>
|
||||
<Group gap={4}>
|
||||
{httpMethods.map((method) => (
|
||||
<Badge key={method} size="xs" color={methodColor(method)} variant="light">
|
||||
{method}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
{hasNamedActions && (
|
||||
<Stack gap={4} mt="xs">
|
||||
<Switch
|
||||
label={t("preview.capabilities.liveActions")}
|
||||
description={
|
||||
fetchResult?.previewSession
|
||||
? t("preview.capabilities.liveActionsDescription")
|
||||
: t("preview.capabilities.runTestFirst")
|
||||
}
|
||||
checked={fetchResult?.previewSession?.liveActions ?? false}
|
||||
disabled={
|
||||
!fetchResult?.previewSession || !onSetPreviewLiveActions || isUpdatingPreviewActions
|
||||
}
|
||||
onChange={(event) => onSetPreviewLiveActions?.(event.currentTarget.checked)}
|
||||
/>
|
||||
{!fetchResult?.previewSession?.liveActions && (
|
||||
<Badge size="xs" color="yellow" variant="light" style={{ alignSelf: "flex-start" }}>
|
||||
{t("preview.capabilities.simulated")}
|
||||
</Badge>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)}
|
||||
</Group>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={isTesting ? <Loader size={14} /> : <IconPlayerPlay size={14} />}
|
||||
onClick={handleTest}
|
||||
loading={isTesting}
|
||||
disabled={formValues.method !== "GET" || !formValues.url}
|
||||
>
|
||||
{t("preview.test")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{formValues.method !== "GET" && (
|
||||
<Alert color="yellow" variant="light" p="xs" icon={<IconInfoCircle size={15} />}>
|
||||
<Text size="xs">{t("preview.mutationDisabled")}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
{isSampleStale && (
|
||||
<Alert color="yellow" variant="light" p="xs" icon={<IconAlertTriangle size={15} />}>
|
||||
<Text size="xs" fw={600}>
|
||||
{t("preview.stale.title")}
|
||||
</Text>
|
||||
<Text size="xs">{t("preview.stale.description")}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
{testError && (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={16} />} p="xs">
|
||||
<Text size="xs">{testError}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
{fetchResult && !fetchResult.success && (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={16} />} p="xs">
|
||||
<Text size="xs" fw={500}>
|
||||
{fetchResult.error}
|
||||
</Text>
|
||||
{fetchResult.responseInfo && (
|
||||
<Badge size="xs" color="red" variant="light" mt={4}>
|
||||
{fetchResult.responseInfo.status} {fetchResult.responseInfo.statusText}
|
||||
</Badge>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
<PreviewHeader
|
||||
method={formValues.method}
|
||||
url={formValues.url}
|
||||
methods={httpMethods}
|
||||
hasNamedActions={hasNamedActions}
|
||||
hasPreviewSession={Boolean(fetchResult?.previewSession)}
|
||||
liveActions={fetchResult?.previewSession?.liveActions ?? false}
|
||||
isUpdatingLiveActions={isUpdatingPreviewActions}
|
||||
isTesting={isTesting}
|
||||
isSampleStale={isSampleStale}
|
||||
testError={testError}
|
||||
responseError={fetchResult?.success === false ? fetchResult.error : undefined}
|
||||
responseStatus={fetchResult?.success === false ? fetchResult.responseInfo : undefined}
|
||||
onTest={handleTest}
|
||||
onSetLiveActions={onSetPreviewLiveActions}
|
||||
methodColor={methodColor}
|
||||
messages={{
|
||||
title: t("preview.title"),
|
||||
interactive: t("preview.capabilities.interactive"),
|
||||
capabilitiesTitle: t("preview.capabilities.title"),
|
||||
capabilitiesDescription: t("preview.capabilities.description"),
|
||||
liveActions: t("preview.capabilities.liveActions"),
|
||||
liveActionsDescription: t("preview.capabilities.liveActionsDescription"),
|
||||
runTestFirst: t("preview.capabilities.runTestFirst"),
|
||||
simulated: t("preview.capabilities.simulated"),
|
||||
test: t("preview.test"),
|
||||
mutationDisabled: t("preview.mutationDisabled"),
|
||||
staleTitle: t("preview.stale.title"),
|
||||
staleDescription: t("preview.stale.description"),
|
||||
}}
|
||||
/>
|
||||
|
||||
<Tabs defaultValue="widget" keepMounted={false}>
|
||||
<Tabs.List grow>
|
||||
@@ -399,436 +186,92 @@ function CustomWidgetPreviewContent({
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="widget" pt="sm">
|
||||
<Stack gap="xs">
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
fullWidth
|
||||
value={previewSize}
|
||||
onChange={setPreviewSize}
|
||||
data={(["compact", "standard", "wide"] as const).map((value) => ({
|
||||
value,
|
||||
label: t(`preview.size.${value}` as never),
|
||||
}))}
|
||||
/>
|
||||
{displayData && (formValues.displayType === "actionButton" || fetchResult?.success) ? (
|
||||
<>
|
||||
{fetchResult?.success && (
|
||||
<Badge
|
||||
size="xs"
|
||||
color={fetchResult.responseInfo ? "green" : "gray"}
|
||||
variant="light"
|
||||
style={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
{fetchResult.responseInfo
|
||||
? `${fetchResult.responseInfo.status} ${fetchResult.responseInfo.statusText}`
|
||||
: t("preview.response.localSample")}
|
||||
</Badge>
|
||||
)}
|
||||
<Paper withBorder p="xs" h={previewHeight} style={{ overflow: "auto" }}>
|
||||
<PreviewDisplay data={displayData} />
|
||||
</Paper>
|
||||
</>
|
||||
) : (
|
||||
<Center h={previewHeight}>
|
||||
<Stack gap={4} align="center" maw={280}>
|
||||
<IconPlayerPlay size={22} color="var(--mantine-color-dimmed)" />
|
||||
<Text size="sm" fw={500} ta="center">
|
||||
{t("preview.empty.title")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
{t("preview.hint")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
)}
|
||||
</Stack>
|
||||
<PreviewWidgetPanel
|
||||
data={displayData}
|
||||
displayType={formValues.displayType}
|
||||
success={fetchResult?.success ?? false}
|
||||
responseInfo={fetchResult?.responseInfo ?? null}
|
||||
previewSize={previewSize}
|
||||
onPreviewSizeChange={setPreviewSize}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="response" pt="sm">
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Text size="xs" c="dimmed">
|
||||
{responseJson ? t("preview.response.sampleHint") : t("preview.response.empty")}
|
||||
</Text>
|
||||
<Group gap={4}>
|
||||
{onSampleDataChange && (
|
||||
<Button size="compact-xs" variant="light" onClick={openSampleEditor}>
|
||||
{responseJson ? t("preview.response.editSample") : t("preview.response.addSample")}
|
||||
</Button>
|
||||
)}
|
||||
{responseJson && (
|
||||
<CopyButton value={responseJson}>
|
||||
{({ copied, copy }) => (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
|
||||
onClick={copy}
|
||||
>
|
||||
{copied ? t("preview.response.copied") : t("preview.response.copy")}
|
||||
</Button>
|
||||
)}
|
||||
</CopyButton>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
<Collapse expanded={sampleEditorOpen}>
|
||||
<Paper p="sm" bg="var(--mantine-color-default-hover)">
|
||||
<Stack gap="sm">
|
||||
<Textarea
|
||||
label={t("preview.response.sampleLabel")}
|
||||
description={t("preview.response.sampleDescription")}
|
||||
value={sampleDraft}
|
||||
onChange={(event) => setSampleDraft(event.currentTarget.value)}
|
||||
error={sampleError}
|
||||
autosize
|
||||
minRows={8}
|
||||
maxRows={18}
|
||||
styles={{ input: { fontFamily: "var(--mantine-font-family-monospace)" } }}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" size="xs" onClick={() => setSampleEditorOpen(false)}>
|
||||
{t("preview.response.cancelSample")}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={applySampleData}
|
||||
disabled={!sampleDraft.trim() || Boolean(sampleError)}
|
||||
>
|
||||
{t("preview.response.applySample")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Collapse>
|
||||
{responseJson ? (
|
||||
<>
|
||||
<ScrollArea h={360} type="auto">
|
||||
<ResponseTree value={cachedJson} onInsertDataPath={onInsertDataPath} />
|
||||
</ScrollArea>
|
||||
{fetchResult?.rawResponse && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconExternalLink size={14} />}
|
||||
onClick={() => {
|
||||
const blob = new Blob([fetchResult.rawResponse ?? ""], { type: "application/json" });
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
window.open(blobUrl);
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
|
||||
}}
|
||||
>
|
||||
{t("preview.rawResponse")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<EmptyPreview icon={<IconCode size={22} />} text={t("preview.response.empty")} />
|
||||
)}
|
||||
</Stack>
|
||||
<PreviewResponsePanel
|
||||
value={cachedJson}
|
||||
rawResponse={fetchResult?.rawResponse}
|
||||
onInsertDataPath={onInsertDataPath}
|
||||
onSampleDataChange={onSampleDataChange}
|
||||
messages={{
|
||||
empty: t("preview.response.empty"),
|
||||
sampleHint: t("preview.response.sampleHint"),
|
||||
editSample: t("preview.response.editSample"),
|
||||
addSample: t("preview.response.addSample"),
|
||||
copied: t("preview.response.copied"),
|
||||
copy: t("preview.response.copy"),
|
||||
sampleLabel: t("preview.response.sampleLabel"),
|
||||
sampleDescription: t("preview.response.sampleDescription"),
|
||||
invalidSample: t("preview.response.invalidSample"),
|
||||
cancelSample: t("preview.response.cancelSample"),
|
||||
applySample: t("preview.response.applySample"),
|
||||
copyPath: t("preview.response.copyPath"),
|
||||
pathCopied: t("preview.response.pathCopied"),
|
||||
insertPath: t("preview.response.insertPath"),
|
||||
openRaw: t("preview.rawResponse"),
|
||||
}}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="request" pt="sm">
|
||||
<Stack gap="sm">
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
<RequestFact label={t("preview.request.method")} value={formValues.method} />
|
||||
<RequestFact label={t("preview.request.authentication")} value={formValues.authType} />
|
||||
<RequestFact label={t("preview.request.endpoint")} value={redactUrl(formValues.url)} />
|
||||
<RequestFact
|
||||
label={t("preview.request.status")}
|
||||
value={
|
||||
fetchResult?.responseInfo ? String(fetchResult.responseInfo.status) : t("preview.request.notRun")
|
||||
}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
{namedRequests.length > 0 && (
|
||||
<Stack gap={6}>
|
||||
<Text size="xs" fw={600}>
|
||||
{t("preview.request.named", { count: namedRequests.length })}
|
||||
</Text>
|
||||
{namedRequests.map((request) => (
|
||||
<Paper key={request.id} withBorder p="xs">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<div>
|
||||
<Text size="xs" fw={600} ff="monospace">
|
||||
{request.id}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ff="monospace" lineClamp={1}>
|
||||
{request.pathTemplate}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Badge size="xs" variant="light" color={methodColor(request.method)}>
|
||||
{request.method}
|
||||
</Badge>
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
{t(`preview.request.permission.${request.minimumBoardPermission ?? "view"}` as never)}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
{journalQuery.data && journalQuery.data.length > 0 ? (
|
||||
<Stack gap={6}>
|
||||
<Text size="xs" fw={600}>
|
||||
{t("preview.request.journal")}
|
||||
</Text>
|
||||
{journalQuery.data.map((entry) => (
|
||||
<Paper key={entry.id} withBorder p="xs">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Text size="xs" fw={600} ff="monospace">
|
||||
{entry.requestId}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ff="monospace" truncate>
|
||||
{entry.pathTemplate}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Badge size="xs" color={methodColor(entry.method)} variant="light">
|
||||
{entry.method}
|
||||
</Badge>
|
||||
<Badge size="xs" color={journalEntryColor(entry)}>
|
||||
{entry.simulated ? t("preview.request.simulated") : (entry.status ?? "—")}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed" w={56} ta="right">
|
||||
{t("preview.request.duration", { duration: entry.durationMs })}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("preview.request.journalEmpty")}
|
||||
</Text>
|
||||
)}
|
||||
<Alert color="blue" variant="light" p="xs" icon={<IconClipboard size={15} />}>
|
||||
<Text size="xs">{t("preview.request.redacted")}</Text>
|
||||
</Alert>
|
||||
</Stack>
|
||||
<PreviewRequestPanel
|
||||
method={formValues.method}
|
||||
authentication={formValues.authType}
|
||||
endpoint={redactPreviewUrl(formValues.url)}
|
||||
status={fetchResult?.responseInfo?.status}
|
||||
namedRequests={namedRequests}
|
||||
journal={journalQuery.data}
|
||||
methodColor={methodColor}
|
||||
messages={{
|
||||
method: t("preview.request.method"),
|
||||
authentication: t("preview.request.authentication"),
|
||||
endpoint: t("preview.request.endpoint"),
|
||||
status: t("preview.request.status"),
|
||||
notRun: t("preview.request.notRun"),
|
||||
named: t("preview.request.named", { count: namedRequests.length }),
|
||||
journal: t("preview.request.journal"),
|
||||
journalEmpty: t("preview.request.journalEmpty"),
|
||||
simulated: t("preview.request.simulated"),
|
||||
redacted: t("preview.request.redacted"),
|
||||
permission: (permission) => t(`preview.request.permission.${permission}` as never),
|
||||
duration: (duration) => t("preview.request.duration", { duration }),
|
||||
}}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="diagnostics" pt="sm">
|
||||
<Stack gap="xs">
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
<RequestFact
|
||||
label={t("preview.diagnostics.templateSize")}
|
||||
value={t("preview.diagnostics.characters", {
|
||||
count: (formValues.displayConfig.template as string | undefined)?.length ?? 0,
|
||||
})}
|
||||
/>
|
||||
<RequestFact label={t("preview.diagnostics.namedRequests")} value={String(namedRequests.length)} />
|
||||
<RequestFact label={t("preview.diagnostics.methods")} value={httpMethods.join(", ") || "GET"} />
|
||||
<RequestFact
|
||||
label={t("preview.diagnostics.networkScope")}
|
||||
value={
|
||||
(formValues.displayConfig.networkScope as string | undefined) ?? t("preview.diagnostics.legacy")
|
||||
}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
{templateDiagnostics.length === 0 ? (
|
||||
<Alert color="green" variant="light" p="xs" icon={<IconCheck size={15} />}>
|
||||
<Text size="xs">{t("preview.diagnostics.ready")}</Text>
|
||||
</Alert>
|
||||
) : (
|
||||
templateDiagnostics.map((diagnostic, index) => (
|
||||
<Alert
|
||||
key={`${diagnostic.code}-${index}`}
|
||||
color={{ error: "red", warning: "yellow" }[diagnostic.severity] ?? "yellow"}
|
||||
variant="light"
|
||||
p="xs"
|
||||
icon={<IconAlertTriangle size={15} />}
|
||||
>
|
||||
<Text size="xs">
|
||||
{diagnostic.line ? `${editorT("diagnostics.line", { line: diagnostic.line })}: ` : ""}
|
||||
{editorT(`diagnostics.${diagnostic.code}` as never, { value: diagnostic.value ?? "" } as never)}
|
||||
</Text>
|
||||
</Alert>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
<PreviewDiagnosticsPanel
|
||||
templateLength={(displayConfig.template as string | undefined)?.length ?? 0}
|
||||
namedRequestCount={namedRequests.length}
|
||||
methods={httpMethods.join(", ") || "GET"}
|
||||
networkScope={(displayConfig.networkScope as string | undefined) ?? t("preview.diagnostics.legacy")}
|
||||
diagnostics={templateDiagnostics}
|
||||
messages={{
|
||||
templateSize: t("preview.diagnostics.templateSize"),
|
||||
characters: (count) => t("preview.diagnostics.characters", { count }),
|
||||
namedRequests: t("preview.diagnostics.namedRequests"),
|
||||
methods: t("preview.diagnostics.methods"),
|
||||
networkScope: t("preview.diagnostics.networkScope"),
|
||||
ready: t("preview.diagnostics.ready"),
|
||||
diagnostic: (diagnostic) =>
|
||||
`${diagnostic.line ? `${editorT("diagnostics.line", { line: diagnostic.line })}: ` : ""}${editorT(
|
||||
`diagnostics.${diagnostic.code}` as never,
|
||||
{ value: diagnostic.value ?? "" } as never,
|
||||
)}`,
|
||||
}}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ResponseTree({ value, onInsertDataPath }: { value: unknown; onInsertDataPath?: (path: string) => void }) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const data = useMemo(() => [createResponseTreeNode(value, "data", "data")], [value]);
|
||||
const tree = useTree({ initialExpandedState: getTreeExpandedState(data, ["data"]) });
|
||||
return (
|
||||
<Tree
|
||||
data={data}
|
||||
tree={tree}
|
||||
levelOffset={14}
|
||||
expandOnClick
|
||||
selectOnClick={false}
|
||||
renderNode={(payload) => (
|
||||
<ResponseTreeNode
|
||||
{...payload}
|
||||
onInsertDataPath={onInsertDataPath}
|
||||
copyLabel={t("preview.response.copyPath")}
|
||||
copiedLabel={t("preview.response.pathCopied")}
|
||||
insertLabel={t("preview.response.insertPath")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface ResponseTreeData extends TreeNodeData {
|
||||
path: string;
|
||||
displayValue: string;
|
||||
}
|
||||
|
||||
function collectionDisplayValue(value: unknown, count: number): string {
|
||||
if (Array.isArray(value)) return `[${count}]`;
|
||||
return `{${count}}`;
|
||||
}
|
||||
|
||||
function createResponseTreeNode(value: unknown, path: string, label: string): ResponseTreeData {
|
||||
const isCollection = value !== null && typeof value === "object";
|
||||
const entries = isCollection ? Object.entries(value as Record<string, unknown>) : [];
|
||||
return {
|
||||
value: path,
|
||||
label,
|
||||
path,
|
||||
displayValue: isCollection ? collectionDisplayValue(value, entries.length) : formatTreeValue(value),
|
||||
children: entries.map(([key, childValue]) =>
|
||||
createResponseTreeNode(childValue, appendDataPath(path, key, Array.isArray(value)), key),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function ResponseTreeNode({
|
||||
node,
|
||||
expanded,
|
||||
hasChildren,
|
||||
elementProps,
|
||||
onInsertDataPath,
|
||||
copyLabel,
|
||||
copiedLabel,
|
||||
insertLabel,
|
||||
}: RenderTreeNodePayload & {
|
||||
onInsertDataPath?: (path: string) => void;
|
||||
copyLabel: string;
|
||||
copiedLabel: string;
|
||||
insertLabel: string;
|
||||
}) {
|
||||
const responseNode = node as ResponseTreeData;
|
||||
return (
|
||||
<Group {...elementProps} gap={4} wrap="nowrap" mih={28} className="response-tree-row">
|
||||
<div style={{ width: 16, flexShrink: 0 }}>
|
||||
{hasChildren && (expanded ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />)}
|
||||
</div>
|
||||
<Code style={{ flex: "0 1 auto", overflow: "hidden", textOverflow: "ellipsis", fontSize: 11 }}>
|
||||
{responseNode.label}
|
||||
</Code>
|
||||
<Text size="xs" c="dimmed" truncate style={{ flex: 1 }}>
|
||||
{responseNode.displayValue}
|
||||
</Text>
|
||||
<Group gap={2} wrap="nowrap" className="response-tree-actions">
|
||||
<CopyButton value={responseNode.path}>
|
||||
{({ copied, copy }) => (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color={copied ? "green" : "gray"}
|
||||
size={24}
|
||||
aria-label={copied ? copiedLabel : copyLabel}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
copy();
|
||||
}}
|
||||
>
|
||||
{copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
|
||||
</ActionIcon>
|
||||
)}
|
||||
</CopyButton>
|
||||
{onInsertDataPath && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size={24}
|
||||
aria-label={insertLabel}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onInsertDataPath(responseNode.path);
|
||||
}}
|
||||
>
|
||||
<IconPlus size={14} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function appendDataPath(parent: string, key: string, isArray: boolean) {
|
||||
if (isArray) return `${parent}[${key}]`;
|
||||
return /^[A-Za-z_$][\w$]*$/u.test(key) ? `${parent}.${key}` : `${parent}[${JSON.stringify(key)}]`;
|
||||
}
|
||||
|
||||
function formatTreeValue(value: unknown) {
|
||||
if (typeof value === "string") return JSON.stringify(value);
|
||||
if (value === undefined) return "undefined";
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function EmptyPreview({ icon, text }: { icon: React.ReactNode; text: string }) {
|
||||
return (
|
||||
<Center h={240}>
|
||||
<Stack align="center" gap="xs" c="dimmed">
|
||||
{icon}
|
||||
<Text size="xs" ta="center">
|
||||
{text}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
function RequestFact({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Paper withBorder p="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="xs" fw={600} lineClamp={2}>
|
||||
{value || "—"}
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewDisplay({ data }: { data: unknown }) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const typed = data as Record<string, unknown>;
|
||||
if (!typed || typeof typed !== "object") return null;
|
||||
|
||||
const dataType = typed.type as string | undefined;
|
||||
if (dataType === "actionButton") {
|
||||
return (
|
||||
<Center p="sm" h="100%">
|
||||
<Button size="sm" color={(typed.buttonColor as string) ?? "blue"} disabled>
|
||||
{(typed.buttonLabel as string) ?? t("preview.execute")}
|
||||
</Button>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const Component = dataType ? displayComponents[dataType] : undefined;
|
||||
if (Component) return <Component data={typed} />;
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%">
|
||||
<Code block style={{ fontSize: 11 }}>
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</Code>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
export const listItemDefaults = {
|
||||
mapping: { label: "", jsonPath: "$", unit: "" },
|
||||
column: { header: "", jsonPath: "$" },
|
||||
statGridItem: { label: "", jsonPath: "$", unit: "", color: "blue" },
|
||||
progressBar: { label: "", valuePath: "$", maxPath: "", unit: "", color: "blue" },
|
||||
statusItem: { label: "", jsonPath: "$", goodValues: "online,true" },
|
||||
countGridItem: { label: "", jsonPath: "$", unit: "" },
|
||||
} as const;
|
||||
|
||||
export function cloneLast<T extends Record<string, unknown>>(items: T[], fallback: T): T {
|
||||
return { ...(items.at(-1) ?? fallback) };
|
||||
}
|
||||
|
||||
export const MANTINE_COLORS = [
|
||||
"blue",
|
||||
"teal",
|
||||
"green",
|
||||
"red",
|
||||
"orange",
|
||||
"yellow",
|
||||
"violet",
|
||||
"pink",
|
||||
"cyan",
|
||||
"grape",
|
||||
"indigo",
|
||||
"lime",
|
||||
] as const;
|
||||
export const REQUEST_MANIFEST_STARTER = `[
|
||||
{
|
||||
"id": "details",
|
||||
"kind": "query",
|
||||
"method": "GET",
|
||||
"pathTemplate": "/api/details/{id}",
|
||||
"parameters": { "id": "string" },
|
||||
"auth": "inherit",
|
||||
"minimumBoardPermission": "view",
|
||||
"cacheTtlSeconds": 30
|
||||
}
|
||||
]`;
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { useZodForm } from "@homarr/form";
|
||||
import type { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { customWidgetFormSchema } from "@homarr/custom-widgets/workbench";
|
||||
|
||||
export type CustomWidgetFormInstance = ReturnType<typeof useZodForm<typeof customWidgetFormSchema>>;
|
||||
export type CustomWidgetTranslator = ReturnType<typeof useScopedI18n<"customWidget">>;
|
||||
|
||||
export interface DisplayFieldsProps {
|
||||
form: CustomWidgetFormInstance;
|
||||
t: CustomWidgetTranslator;
|
||||
previewJson: unknown;
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { ActionIcon, Box, Button, Group, NumberInput, Select, Stack, Switch, Text, TextInput } from "@mantine/core";
|
||||
import { IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
import { JsonPathTreePicker } from "@homarr/widgets/_inputs/json-path-tree-picker";
|
||||
import { cloneLast, listItemDefaults } from "./_display-field-shared";
|
||||
import type { DisplayFieldsProps } from "./_display-field-types";
|
||||
|
||||
export function BasicDisplayFields({ form, t, previewJson }: DisplayFieldsProps) {
|
||||
const dt = form.values.displayType;
|
||||
|
||||
if (dt === "singleValue") {
|
||||
return (
|
||||
<>
|
||||
<JsonPathTreePicker
|
||||
json={previewJson}
|
||||
label={t("field.jsonPath")}
|
||||
description={t("field.jsonPathHint")}
|
||||
required
|
||||
placeholder={t("placeholder.jsonPath")}
|
||||
{...form.getInputProps("jsonPath")}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("field.label")}
|
||||
placeholder={t("placeholder.exampleLabel")}
|
||||
{...form.getInputProps("label")}
|
||||
/>
|
||||
<TextInput label={t("field.unit")} placeholder={t("placeholder.exampleUnit")} {...form.getInputProps("unit")} />
|
||||
<Group grow>
|
||||
<Select
|
||||
label={t("field.valueSize")}
|
||||
data={["sm", "md", "lg", "xl"].map((v) => ({
|
||||
value: v,
|
||||
label: t(`sizeOption.${v}` as never),
|
||||
}))}
|
||||
{...form.getInputProps("valueSize")}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<Select
|
||||
label={t("field.labelPosition")}
|
||||
data={["above", "below"].map((v) => ({
|
||||
value: v,
|
||||
label: t(`labelPositionOption.${v}` as never),
|
||||
}))}
|
||||
{...form.getInputProps("labelPosition")}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (dt === "keyValue") {
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Group grow>
|
||||
<Select
|
||||
label={t("field.kvLayout")}
|
||||
data={["list", "grid"].map((v) => ({
|
||||
value: v,
|
||||
label: t(`layoutOption.${v}` as never),
|
||||
}))}
|
||||
{...form.getInputProps("kvLayout")}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
{form.values.kvLayout === "grid" && (
|
||||
<NumberInput label={t("field.gridColumns")} min={1} max={3} {...form.getInputProps("kvColumns")} />
|
||||
)}
|
||||
</Group>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("field.mappings")}
|
||||
</Text>
|
||||
{form.values.mappings.map((_m, i) => (
|
||||
<Group key={i} align="end" wrap="nowrap">
|
||||
<TextInput
|
||||
label={t("field.label")}
|
||||
placeholder={t("placeholder.exampleLabelShort")}
|
||||
style={{ flex: 1 }}
|
||||
{...form.getInputProps(`mappings.${i}.label`)}
|
||||
/>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<JsonPathTreePicker
|
||||
json={previewJson}
|
||||
label={t("field.jsonPath")}
|
||||
placeholder={t("placeholder.jsonPath")}
|
||||
{...form.getInputProps(`mappings.${i}.jsonPath`)}
|
||||
/>
|
||||
</Box>
|
||||
<TextInput
|
||||
label={t("field.unit")}
|
||||
placeholder={t("placeholder.exampleUnit")}
|
||||
style={{ width: 80 }}
|
||||
{...form.getInputProps(`mappings.${i}.unit`)}
|
||||
/>
|
||||
<ActionIcon
|
||||
type="button"
|
||||
color="red"
|
||||
variant="subtle"
|
||||
onClick={() => form.removeListItem("mappings", i)}
|
||||
disabled={form.values.mappings.length <= 1}
|
||||
aria-label={t("action.removeMapping")}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={() => form.insertListItem("mappings", cloneLast(form.values.mappings, listItemDefaults.mapping))}
|
||||
>
|
||||
{t("action.addMapping")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (dt === "table") {
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<JsonPathTreePicker
|
||||
json={previewJson}
|
||||
label={t("field.tablePath")}
|
||||
description={t("field.tablePathHint")}
|
||||
required
|
||||
placeholder={t("placeholder.tablePath")}
|
||||
{...form.getInputProps("tablePath")}
|
||||
/>
|
||||
<Group grow>
|
||||
<Switch label={t("field.striped")} {...form.getInputProps("striped", { type: "checkbox" })} />
|
||||
<Switch label={t("field.compact")} {...form.getInputProps("compact", { type: "checkbox" })} />
|
||||
</Group>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("field.columns")}
|
||||
</Text>
|
||||
{form.values.columns.map((_col, i) => (
|
||||
<Group key={i} align="end" wrap="nowrap">
|
||||
<TextInput
|
||||
label={t("field.header")}
|
||||
placeholder={t("placeholder.exampleHeader")}
|
||||
style={{ flex: 1 }}
|
||||
{...form.getInputProps(`columns.${i}.header`)}
|
||||
/>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<JsonPathTreePicker
|
||||
json={previewJson}
|
||||
label={t("field.jsonPath")}
|
||||
placeholder={t("placeholder.columnJsonPath")}
|
||||
{...form.getInputProps(`columns.${i}.jsonPath`)}
|
||||
/>
|
||||
</Box>
|
||||
<ActionIcon
|
||||
type="button"
|
||||
color="red"
|
||||
variant="subtle"
|
||||
onClick={() => form.removeListItem("columns", i)}
|
||||
disabled={form.values.columns.length <= 1}
|
||||
aria-label={t("action.removeColumn")}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={() => form.insertListItem("columns", cloneLast(form.values.columns, listItemDefaults.column))}
|
||||
>
|
||||
{t("action.addColumn")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Alert, SegmentedControl, Select, Stack, Text } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
import { CUSTOM_JSX_STARTER } from "@homarr/custom-widgets/core";
|
||||
import {
|
||||
analyzeJsxTemplate,
|
||||
analyzeRequestManifest,
|
||||
CUSTOM_JSX_TEMPLATE_LIMIT,
|
||||
parseRequestManifest,
|
||||
} from "@homarr/custom-widgets/workbench";
|
||||
import { CodeEditor } from "./_code-editor";
|
||||
import { REQUEST_MANIFEST_STARTER } from "./_display-field-shared";
|
||||
import type { DisplayFieldsProps } from "./_display-field-types";
|
||||
|
||||
export function CustomJsxDisplayFields({ form, t }: DisplayFieldsProps) {
|
||||
const dt = form.values.displayType;
|
||||
|
||||
if (dt === "customJsx") {
|
||||
const parsedRequests = parseRequestManifest(form.values.requestManifest);
|
||||
const requestIds = parsedRequests
|
||||
.map((request) => (request && typeof request === "object" && "id" in request ? request.id : null))
|
||||
.filter((id): id is string => typeof id === "string");
|
||||
const templateDiagnostics = analyzeJsxTemplate(form.values.template, {
|
||||
apiVersion: form.values.jsxApiVersion === "2" ? 2 : 1,
|
||||
requestIds,
|
||||
});
|
||||
const manifestDiagnostics =
|
||||
form.values.jsxApiVersion === "2" ? analyzeRequestManifest(form.values.requestManifest) : [];
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb={4}>
|
||||
{t("field.jsxApiVersion.label")}
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
value={form.values.jsxApiVersion}
|
||||
onChange={(value) => form.setFieldValue("jsxApiVersion", value)}
|
||||
data={[
|
||||
{ value: "2", label: t("field.jsxApiVersion.v2") },
|
||||
{ value: "1", label: t("field.jsxApiVersion.v1") },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{form.values.jsxApiVersion === "1" ? (
|
||||
<Alert color="yellow" variant="light" icon={<IconAlertTriangle size={16} />}>
|
||||
<Text size="sm">{t("field.jsxApiVersion.legacyWarning")}</Text>
|
||||
</Alert>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
<Select
|
||||
label={t("field.networkScope.label")}
|
||||
description={t(`field.networkScope.description.${form.values.networkScope}` as never)}
|
||||
value={form.values.networkScope}
|
||||
onChange={(value) => form.setFieldValue("networkScope", value ?? "public")}
|
||||
data={(["public", "private", "loopback"] as const).map((value) => ({
|
||||
value,
|
||||
label: t(`field.networkScope.option.${value}` as never),
|
||||
}))}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<CodeEditor
|
||||
id="custom-widget-request-manifest"
|
||||
language="json"
|
||||
label={t("field.requestManifest.label")}
|
||||
description={t("field.requestManifest.description")}
|
||||
value={form.values.requestManifest}
|
||||
onChange={(value) => form.setFieldValue("requestManifest", value)}
|
||||
placeholder={REQUEST_MANIFEST_STARTER}
|
||||
starter={REQUEST_MANIFEST_STARTER}
|
||||
diagnostics={manifestDiagnostics}
|
||||
error={form.errors.requestManifest ? t("field.requestManifest.invalid") : undefined}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<CodeEditor
|
||||
id="custom-widget-jsx-template"
|
||||
language="jsx"
|
||||
label={t("field.template.label")}
|
||||
description={t("field.template.description")}
|
||||
value={form.values.template}
|
||||
onChange={(value) => form.setFieldValue("template", value)}
|
||||
placeholder={CUSTOM_JSX_STARTER}
|
||||
starter={CUSTOM_JSX_STARTER}
|
||||
diagnostics={templateDiagnostics}
|
||||
error={form.errors.template}
|
||||
required
|
||||
maxLength={CUSTOM_JSX_TEMPLATE_LIMIT}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { ActionIcon, Box, Button, Group, Select, Stack, Switch, Text, TextInput, NumberInput } from "@mantine/core";
|
||||
import { IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
import { JsonPathTreePicker } from "@homarr/widgets/_inputs/json-path-tree-picker";
|
||||
import { cloneLast, listItemDefaults, MANTINE_COLORS } from "./_display-field-shared";
|
||||
import type { DisplayFieldsProps } from "./_display-field-types";
|
||||
|
||||
export function MetricDisplayFields({ form, t, previewJson }: DisplayFieldsProps) {
|
||||
const dt = form.values.displayType;
|
||||
|
||||
if (dt === "statGrid") {
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Group grow>
|
||||
<NumberInput label={t("field.gridColumns")} min={1} max={4} {...form.getInputProps("statGridColumns")} />
|
||||
<Select
|
||||
label={t("field.cardStyle")}
|
||||
data={["filled", "outline", "subtle"].map((v) => ({
|
||||
value: v,
|
||||
label: t(`cardStyleOption.${v}` as never),
|
||||
}))}
|
||||
{...form.getInputProps("cardStyle")}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Group>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("field.items")}
|
||||
</Text>
|
||||
{form.values.statGridItems.map((_item, i) => (
|
||||
<Group key={i} align="end" wrap="nowrap">
|
||||
<TextInput
|
||||
label={t("field.label")}
|
||||
placeholder={t("placeholder.exampleLabelShort")}
|
||||
style={{ flex: 1 }}
|
||||
{...form.getInputProps(`statGridItems.${i}.label`)}
|
||||
/>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<JsonPathTreePicker
|
||||
json={previewJson}
|
||||
label={t("field.jsonPath")}
|
||||
placeholder={t("placeholder.jsonPath")}
|
||||
{...form.getInputProps(`statGridItems.${i}.jsonPath`)}
|
||||
/>
|
||||
</Box>
|
||||
<TextInput
|
||||
label={t("field.unit")}
|
||||
placeholder={t("placeholder.exampleUnit")}
|
||||
style={{ width: 80 }}
|
||||
{...form.getInputProps(`statGridItems.${i}.unit`)}
|
||||
/>
|
||||
<Select
|
||||
label={t("field.color")}
|
||||
data={MANTINE_COLORS.map((c) => ({ value: c, label: c }))}
|
||||
style={{ width: 100 }}
|
||||
{...form.getInputProps(`statGridItems.${i}.color`)}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<ActionIcon
|
||||
type="button"
|
||||
color="red"
|
||||
variant="subtle"
|
||||
onClick={() => form.removeListItem("statGridItems", i)}
|
||||
disabled={form.values.statGridItems.length <= 1}
|
||||
aria-label={t("action.removeItem")}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={() =>
|
||||
form.insertListItem("statGridItems", cloneLast(form.values.statGridItems, listItemDefaults.statGridItem))
|
||||
}
|
||||
>
|
||||
{t("action.addItem")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (dt === "progressBars") {
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Group grow>
|
||||
<Switch label={t("field.showPercentage")} {...form.getInputProps("showPercentage", { type: "checkbox" })} />
|
||||
<Select
|
||||
label={t("field.barSize")}
|
||||
data={["sm", "md", "lg"].map((v) => ({
|
||||
value: v,
|
||||
label: t(`sizeOption.${v}` as never),
|
||||
}))}
|
||||
{...form.getInputProps("barSize")}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Group>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("field.bars")}
|
||||
</Text>
|
||||
{form.values.progressBars.map((_bar, i) => (
|
||||
<Group key={i} align="end" wrap="nowrap">
|
||||
<TextInput
|
||||
label={t("field.label")}
|
||||
placeholder={t("placeholder.exampleLabelShort")}
|
||||
style={{ flex: 1 }}
|
||||
{...form.getInputProps(`progressBars.${i}.label`)}
|
||||
/>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<JsonPathTreePicker
|
||||
json={previewJson}
|
||||
label={t("field.valuePath")}
|
||||
placeholder={t("placeholder.valuePath")}
|
||||
{...form.getInputProps(`progressBars.${i}.valuePath`)}
|
||||
/>
|
||||
</Box>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<JsonPathTreePicker
|
||||
json={previewJson}
|
||||
label={t("field.maxPath")}
|
||||
placeholder={t("placeholder.maxPath")}
|
||||
{...form.getInputProps(`progressBars.${i}.maxPath`)}
|
||||
/>
|
||||
</Box>
|
||||
<TextInput
|
||||
label={t("field.unit")}
|
||||
placeholder={t("placeholder.exampleUnit")}
|
||||
style={{ width: 60 }}
|
||||
{...form.getInputProps(`progressBars.${i}.unit`)}
|
||||
/>
|
||||
<Select
|
||||
label={t("field.color")}
|
||||
data={MANTINE_COLORS.map((c) => ({ value: c, label: c }))}
|
||||
style={{ width: 100 }}
|
||||
{...form.getInputProps(`progressBars.${i}.color`)}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<ActionIcon
|
||||
type="button"
|
||||
color="red"
|
||||
variant="subtle"
|
||||
onClick={() => form.removeListItem("progressBars", i)}
|
||||
disabled={form.values.progressBars.length <= 1}
|
||||
aria-label={t("action.removeItem")}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={() =>
|
||||
form.insertListItem("progressBars", cloneLast(form.values.progressBars, listItemDefaults.progressBar))
|
||||
}
|
||||
>
|
||||
{t("action.addBar")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { ActionIcon, Box, Button, Group, NumberInput, Select, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
import { JsonPathTreePicker } from "@homarr/widgets/_inputs/json-path-tree-picker";
|
||||
import { cloneLast, listItemDefaults, MANTINE_COLORS } from "./_display-field-shared";
|
||||
import type { DisplayFieldsProps } from "./_display-field-types";
|
||||
|
||||
export function StatusDisplayFields({ form, t, previewJson }: DisplayFieldsProps) {
|
||||
const dt = form.values.displayType;
|
||||
|
||||
if (dt === "statusIndicator") {
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Group grow>
|
||||
<Select
|
||||
label={t("field.kvLayout")}
|
||||
data={["list", "grid"].map((v) => ({
|
||||
value: v,
|
||||
label: t(`layoutOption.${v}` as never),
|
||||
}))}
|
||||
{...form.getInputProps("statusLayout")}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<Select
|
||||
label={t("field.dotSize")}
|
||||
data={["sm", "md", "lg"].map((v) => ({
|
||||
value: v,
|
||||
label: t(`sizeOption.${v}` as never),
|
||||
}))}
|
||||
{...form.getInputProps("dotSize")}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Group>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("field.items")}
|
||||
</Text>
|
||||
{form.values.statusItems.map((_item, i) => (
|
||||
<Group key={i} align="end" wrap="nowrap">
|
||||
<TextInput
|
||||
label={t("field.label")}
|
||||
placeholder={t("placeholder.exampleLabelShort")}
|
||||
style={{ flex: 1 }}
|
||||
{...form.getInputProps(`statusItems.${i}.label`)}
|
||||
/>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<JsonPathTreePicker
|
||||
json={previewJson}
|
||||
label={t("field.jsonPath")}
|
||||
placeholder={t("placeholder.jsonPath")}
|
||||
{...form.getInputProps(`statusItems.${i}.jsonPath`)}
|
||||
/>
|
||||
</Box>
|
||||
<TextInput
|
||||
label={t("field.goodValues")}
|
||||
placeholder={t("placeholder.goodValues")}
|
||||
style={{ flex: 1 }}
|
||||
{...form.getInputProps(`statusItems.${i}.goodValues`)}
|
||||
/>
|
||||
<ActionIcon
|
||||
type="button"
|
||||
color="red"
|
||||
variant="subtle"
|
||||
onClick={() => form.removeListItem("statusItems", i)}
|
||||
disabled={form.values.statusItems.length <= 1}
|
||||
aria-label={t("action.removeItem")}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={() =>
|
||||
form.insertListItem("statusItems", cloneLast(form.values.statusItems, listItemDefaults.statusItem))
|
||||
}
|
||||
>
|
||||
{t("action.addItem")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (dt === "countGrid") {
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Group grow>
|
||||
<NumberInput label={t("field.gridColumns")} min={2} max={4} {...form.getInputProps("countGridColumns")} />
|
||||
<Select
|
||||
label={t("field.valueSize")}
|
||||
data={["sm", "md", "lg"].map((v) => ({
|
||||
value: v,
|
||||
label: t(`sizeOption.${v}` as never),
|
||||
}))}
|
||||
{...form.getInputProps("countValueSize")}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Group>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("field.items")}
|
||||
</Text>
|
||||
{form.values.countGridItems.map((_item, i) => (
|
||||
<Group key={i} align="end" wrap="nowrap">
|
||||
<TextInput
|
||||
label={t("field.label")}
|
||||
placeholder={t("placeholder.exampleLabelShort")}
|
||||
style={{ flex: 1 }}
|
||||
{...form.getInputProps(`countGridItems.${i}.label`)}
|
||||
/>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<JsonPathTreePicker
|
||||
json={previewJson}
|
||||
label={t("field.jsonPath")}
|
||||
placeholder={t("placeholder.jsonPath")}
|
||||
{...form.getInputProps(`countGridItems.${i}.jsonPath`)}
|
||||
/>
|
||||
</Box>
|
||||
<TextInput
|
||||
label={t("field.unit")}
|
||||
placeholder={t("placeholder.exampleUnit")}
|
||||
style={{ width: 80 }}
|
||||
{...form.getInputProps(`countGridItems.${i}.unit`)}
|
||||
/>
|
||||
<ActionIcon
|
||||
type="button"
|
||||
color="red"
|
||||
variant="subtle"
|
||||
onClick={() => form.removeListItem("countGridItems", i)}
|
||||
disabled={form.values.countGridItems.length <= 1}
|
||||
aria-label={t("action.removeItem")}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={() =>
|
||||
form.insertListItem("countGridItems", cloneLast(form.values.countGridItems, listItemDefaults.countGridItem))
|
||||
}
|
||||
>
|
||||
{t("action.addItem")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (dt === "raw") {
|
||||
return (
|
||||
<>
|
||||
<JsonPathTreePicker
|
||||
json={previewJson}
|
||||
label={t("field.jsonPath")}
|
||||
description={t("field.jsonPathHint")}
|
||||
placeholder={t("placeholder.jsonPath")}
|
||||
{...form.getInputProps("rawJsonPath")}
|
||||
/>
|
||||
<NumberInput
|
||||
label={t("field.maxHeight")}
|
||||
min={50}
|
||||
max={1000}
|
||||
step={50}
|
||||
{...form.getInputProps("rawMaxHeight")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (dt === "actionButton") {
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<TextInput label={t("field.buttonLabel")} required {...form.getInputProps("buttonLabel")} />
|
||||
<Select
|
||||
label={t("field.buttonColor")}
|
||||
data={MANTINE_COLORS.map((c) => ({ value: c, label: c }))}
|
||||
{...form.getInputProps("buttonColor")}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("field.confirmText")}
|
||||
description={t("field.confirmTextHint")}
|
||||
{...form.getInputProps("confirmText")}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("field.successMessage")}
|
||||
description={t("field.successMessageHint")}
|
||||
{...form.getInputProps("successMessage")}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { SimpleGrid, Text, ThemeIcon, UnstyledButton } from "@mantine/core";
|
||||
import {
|
||||
Icon123,
|
||||
IconActivityHeartbeat,
|
||||
IconBraces,
|
||||
IconCode,
|
||||
IconHash,
|
||||
IconLayoutGrid,
|
||||
IconListDetails,
|
||||
IconPointer,
|
||||
IconProgress,
|
||||
IconTable,
|
||||
} from "@tabler/icons-react";
|
||||
import type { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { CustomWidgetDisplayType } from "@homarr/custom-widgets/core";
|
||||
import { customWidgetDisplayDescriptors } from "@homarr/custom-widgets/core";
|
||||
|
||||
import { BasicDisplayFields } from "./_display-fields-basic";
|
||||
import { CustomJsxDisplayFields } from "./_display-fields-jsx";
|
||||
import { MetricDisplayFields } from "./_display-fields-metric";
|
||||
import { StatusDisplayFields } from "./_display-fields-status";
|
||||
import type { DisplayFieldsProps } from "./_display-field-types";
|
||||
import classes from "./_custom-widget-form.module.css";
|
||||
|
||||
export const DISPLAY_TYPE_ICONS = {
|
||||
number: Icon123,
|
||||
keyValue: IconListDetails,
|
||||
table: IconTable,
|
||||
grid: IconLayoutGrid,
|
||||
progress: IconProgress,
|
||||
status: IconActivityHeartbeat,
|
||||
count: IconHash,
|
||||
json: IconBraces,
|
||||
action: IconPointer,
|
||||
code: IconCode,
|
||||
} as const;
|
||||
|
||||
export function DisplayTypePicker({
|
||||
value,
|
||||
onChange,
|
||||
t,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: CustomWidgetDisplayType) => void;
|
||||
t: ReturnType<typeof useScopedI18n<"customWidget">>;
|
||||
}) {
|
||||
return (
|
||||
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="sm">
|
||||
{customWidgetDisplayDescriptors.map((descriptor) => {
|
||||
const displayType = descriptor.type;
|
||||
const Icon = DISPLAY_TYPE_ICONS[descriptor.icon];
|
||||
const selected = value === displayType;
|
||||
return (
|
||||
<UnstyledButton
|
||||
type="button"
|
||||
key={displayType}
|
||||
className={classes.displayTypeOption}
|
||||
data-selected={selected || undefined}
|
||||
onClick={() => onChange(displayType)}
|
||||
aria-pressed={selected}
|
||||
>
|
||||
<ThemeIcon variant={selected ? "filled" : "light"} size={42} radius="md">
|
||||
<Icon size={22} />
|
||||
</ThemeIcon>
|
||||
<div className={classes.displayTypeCopy}>
|
||||
<Text fw={600} size="sm">
|
||||
{t(`displayType.${displayType}` as never)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" lineClamp={3}>
|
||||
{t(`displayTypeDescription.${displayType}` as never)}
|
||||
</Text>
|
||||
</div>
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
|
||||
export function DisplayTypeFields(props: DisplayFieldsProps) {
|
||||
return (
|
||||
<>
|
||||
<BasicDisplayFields {...props} />
|
||||
<MetricDisplayFields {...props} />
|
||||
<StatusDisplayFields {...props} />
|
||||
<CustomJsxDisplayFields {...props} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Badge, Button, Center, Code, Paper, ScrollArea, SegmentedControl, Stack, Text } from "@mantine/core";
|
||||
import { IconPlayerPlay } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { createDisplayComponents } from "@homarr/widgets/custom-api/component";
|
||||
|
||||
interface PreviewWidgetPanelProps {
|
||||
data: Record<string, unknown> | null;
|
||||
displayType: string;
|
||||
success: boolean;
|
||||
responseInfo: { status: number; statusText: string } | null;
|
||||
previewSize: string;
|
||||
onPreviewSizeChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const HEIGHTS: Record<string, number> = { compact: 240, standard: 360, wide: 520 };
|
||||
|
||||
export function PreviewWidgetPanel({
|
||||
data,
|
||||
displayType,
|
||||
success,
|
||||
responseInfo,
|
||||
previewSize,
|
||||
onPreviewSizeChange,
|
||||
}: PreviewWidgetPanelProps) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const height = HEIGHTS[previewSize] ?? HEIGHTS.standard;
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
fullWidth
|
||||
value={previewSize}
|
||||
onChange={onPreviewSizeChange}
|
||||
data={(["compact", "standard", "wide"] as const).map((value) => ({
|
||||
value,
|
||||
label: t(`preview.size.${value}` as never),
|
||||
}))}
|
||||
/>
|
||||
{data && (displayType === "actionButton" || success) ? (
|
||||
<>
|
||||
{success && (
|
||||
<Badge
|
||||
size="xs"
|
||||
color={responseInfo ? "green" : "gray"}
|
||||
variant="light"
|
||||
style={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
{responseInfo ? `${responseInfo.status} ${responseInfo.statusText}` : t("preview.response.localSample")}
|
||||
</Badge>
|
||||
)}
|
||||
<Paper withBorder p="xs" h={height} style={{ overflow: "auto" }}>
|
||||
<PreviewDisplay data={data} />
|
||||
</Paper>
|
||||
</>
|
||||
) : (
|
||||
<Center h={height}>
|
||||
<Stack gap={4} align="center" maw={280}>
|
||||
<IconPlayerPlay size={22} color="var(--mantine-color-dimmed)" />
|
||||
<Text size="sm" fw={500} ta="center">
|
||||
{t("preview.empty.title")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
{t("preview.hint")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewDisplay({ data }: { data: Record<string, unknown> }) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const widgetT = useScopedI18n("widget.customApi");
|
||||
const displayComponents = useMemo(() => createDisplayComponents(widgetT("openJson")), [widgetT]);
|
||||
const dataType = data.type as string | undefined;
|
||||
if (dataType === "actionButton") {
|
||||
return (
|
||||
<Center p="sm" h="100%">
|
||||
<Button type="button" size="sm" color={(data.buttonColor as string) ?? "blue"} disabled>
|
||||
{(data.buttonLabel as string) ?? t("preview.execute")}
|
||||
</Button>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
const Component = dataType ? displayComponents[dataType] : undefined;
|
||||
if (Component) return <Component data={data} />;
|
||||
return (
|
||||
<ScrollArea h="100%">
|
||||
<Code block style={{ fontSize: 11 }}>
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</Code>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { Alert, Badge, Group, Stack, Text } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
import { buildDisplayFormValues, getImportReview, parseCustomWidgetClipboard } from "@homarr/custom-widgets/core";
|
||||
import { CUSTOM_WIDGET_AUTH_SECRET_FIELDS } from "@homarr/custom-widgets/workbench";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showSuccessNotification } from "@homarr/notifications";
|
||||
|
||||
import type { CustomWidgetFormInstance, CustomWidgetTranslator } from "./_display-field-types";
|
||||
|
||||
interface UseCustomWidgetClipboardOptions {
|
||||
enabled: boolean;
|
||||
form: CustomWidgetFormInstance;
|
||||
t: CustomWidgetTranslator;
|
||||
onReplace: () => void;
|
||||
}
|
||||
|
||||
export function useCustomWidgetClipboard({ enabled, form, t, onReplace }: UseCustomWidgetClipboardOptions) {
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const queueReplacement = useCallback(
|
||||
(widget: Record<string, unknown>) => {
|
||||
const review = getImportReview(widget);
|
||||
if (!review) return;
|
||||
openConfirmModal({
|
||||
title: t("importReview.replaceTitle"),
|
||||
children: (
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">{t("importReview.replaceDescription", { name: review.name, origin: review.origin })}</Text>
|
||||
<Group gap={6}>
|
||||
{review.methods.map((method) => (
|
||||
<Badge key={method} color={{ DELETE: "red", GET: "blue" }[method] ?? "orange"}>
|
||||
{method}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
{review.hasActions && (
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
|
||||
<Text size="sm">{t("importReview.actionWarning.description")}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
),
|
||||
labels: { confirm: t("importReview.replaceConfirm"), cancel: t("importReview.cancel") },
|
||||
onConfirm: () => {
|
||||
const displayType = String(widget.displayType);
|
||||
const displayConfig = widget.displayConfig as Record<string, unknown>;
|
||||
const authType = typeof widget.authType === "string" ? widget.authType : "none";
|
||||
const secrets = (CUSTOM_WIDGET_AUTH_SECRET_FIELDS[authType] ?? []).map((field) => {
|
||||
const existing = form.values.secrets.find((secret) => secret.kind === field.kind);
|
||||
return { kind: field.kind, value: existing?.value ?? "", hasValue: existing?.hasValue ?? false };
|
||||
});
|
||||
form.setValues({
|
||||
...form.values,
|
||||
name: String(widget.name),
|
||||
description: typeof widget.description === "string" ? widget.description : "",
|
||||
iconUrl: typeof widget.iconUrl === "string" ? widget.iconUrl : "",
|
||||
url: String(widget.url),
|
||||
authType,
|
||||
headerName: typeof widget.headerName === "string" ? widget.headerName : "",
|
||||
method: typeof widget.method === "string" ? widget.method : "GET",
|
||||
requestBody: typeof widget.requestBody === "string" ? widget.requestBody : "",
|
||||
...buildDisplayFormValues(displayType, displayConfig),
|
||||
secrets,
|
||||
});
|
||||
onReplace();
|
||||
showSuccessNotification({ title: t("action.import"), message: t("notification.pastedForReplacement") });
|
||||
},
|
||||
});
|
||||
},
|
||||
[form, onReplace, openConfirmModal, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
const widget = parseCustomWidgetClipboard(event.clipboardData?.getData("text/plain") ?? "");
|
||||
if (!widget) return;
|
||||
event.preventDefault();
|
||||
queueReplacement(widget);
|
||||
};
|
||||
window.addEventListener("paste", handlePaste);
|
||||
return () => window.removeEventListener("paste", handlePaste);
|
||||
}, [enabled, queueReplacement]);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type {
|
||||
CustomWidgetAuthType,
|
||||
CustomWidgetDisplayType,
|
||||
CustomWidgetMethod,
|
||||
CustomWidgetSecretKind,
|
||||
DisplayConfig,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import { buildDisplayConfigFromFormValues } from "@homarr/custom-widgets/core";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
|
||||
import type { CustomWidgetFormInstance, CustomWidgetTranslator } from "./_display-field-types";
|
||||
import type { PreviewFetchResult, PreviewInput } from "./_custom-widget-preview";
|
||||
|
||||
interface UseCustomWidgetPreviewOptions {
|
||||
form: CustomWidgetFormInstance;
|
||||
definitionId?: string;
|
||||
refreshSignal: number;
|
||||
onOpenPreview: () => void;
|
||||
t: CustomWidgetTranslator;
|
||||
}
|
||||
|
||||
export function useCustomWidgetPreview({
|
||||
form,
|
||||
definitionId,
|
||||
refreshSignal,
|
||||
onOpenPreview,
|
||||
t,
|
||||
}: UseCustomWidgetPreviewOptions) {
|
||||
const previewMutation = clientApi.customWidget.preview.useMutation();
|
||||
const liveActionsMutation = clientApi.customWidget.setPreviewLiveActions.useMutation();
|
||||
const [json, setJson] = useState<unknown>(null);
|
||||
const [fetchResult, setFetchResult] = useState<PreviewFetchResult | null>(null);
|
||||
const [lastFingerprint, setLastFingerprint] = useState<string | null>(null);
|
||||
const hasTestedRef = useRef(false);
|
||||
|
||||
const getInput = useCallback((): PreviewInput => {
|
||||
const values = form.values;
|
||||
return {
|
||||
url: values.url,
|
||||
method: values.method as CustomWidgetMethod,
|
||||
authType: values.authType as CustomWidgetAuthType,
|
||||
headerName: values.headerName || undefined,
|
||||
requestBody: values.requestBody || undefined,
|
||||
displayType: values.displayType as CustomWidgetDisplayType,
|
||||
displayConfig: buildDisplayConfigFromFormValues(values) as DisplayConfig,
|
||||
secrets: values.secrets
|
||||
.filter((secret) => secret.value)
|
||||
.map((secret) => ({ kind: secret.kind as CustomWidgetSecretKind, value: secret.value })),
|
||||
definitionId,
|
||||
};
|
||||
}, [definitionId, form.values]);
|
||||
|
||||
const test = useCallback(async () => {
|
||||
const input = getInput();
|
||||
if (!input.url || input.method !== "GET") return;
|
||||
onOpenPreview();
|
||||
setLastFingerprint(JSON.stringify(input));
|
||||
try {
|
||||
const result = await previewMutation.mutateAsync(input);
|
||||
hasTestedRef.current = true;
|
||||
setFetchResult({
|
||||
success: result.success,
|
||||
error: result.success ? undefined : result.error,
|
||||
responseInfo: result.responseInfo,
|
||||
rawResponse: result.rawResponse,
|
||||
previewSession: result.previewSession,
|
||||
});
|
||||
if (!result.success || !result.rawResponse) return setJson(null);
|
||||
try {
|
||||
setJson(JSON.parse(result.rawResponse));
|
||||
} catch {
|
||||
setJson(null);
|
||||
}
|
||||
} catch {
|
||||
setFetchResult({ success: false, error: t("notification.previewError"), responseInfo: null, rawResponse: null });
|
||||
setJson(null);
|
||||
}
|
||||
}, [getInput, onOpenPreview, previewMutation, t]);
|
||||
|
||||
const setLiveActions = useCallback(
|
||||
async (enabled: boolean) => {
|
||||
const sessionId = fetchResult?.previewSession?.id;
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
const previewSession = await liveActionsMutation.mutateAsync({ sessionId, enabled });
|
||||
setFetchResult((current) => (current ? { ...current, previewSession } : current));
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("preview.capabilities.liveActions"),
|
||||
message: t("notification.previewActionsError"),
|
||||
});
|
||||
}
|
||||
},
|
||||
[fetchResult?.previewSession?.id, liveActionsMutation, t],
|
||||
);
|
||||
|
||||
const testRef = useRef(test);
|
||||
testRef.current = test;
|
||||
const getInputRef = useRef(getInput);
|
||||
getInputRef.current = getInput;
|
||||
const pendingRefreshRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (refreshSignal <= 0 || !hasTestedRef.current) return;
|
||||
if (previewMutation.isPending) return void (pendingRefreshRef.current = true);
|
||||
if (getInputRef.current().method === "GET") void testRef.current();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- refreshSignal is the explicit event source
|
||||
}, [refreshSignal]);
|
||||
useEffect(() => {
|
||||
if (previewMutation.isPending || !pendingRefreshRef.current) return;
|
||||
pendingRefreshRef.current = false;
|
||||
if (getInputRef.current().method === "GET" && hasTestedRef.current) void testRef.current();
|
||||
}, [previewMutation.isPending]);
|
||||
|
||||
const input = getInput();
|
||||
const isStale = lastFingerprint !== null && lastFingerprint !== JSON.stringify(input);
|
||||
const insertDataPath = useCallback(
|
||||
(path: string) => {
|
||||
if (form.values.displayType !== "customJsx") return;
|
||||
const template = form.values.template.trimEnd();
|
||||
const snippet = `<Text>{${path}}</Text>`;
|
||||
form.setFieldValue("template", template ? `${template}\n${snippet}` : snippet);
|
||||
},
|
||||
[form],
|
||||
);
|
||||
const setSampleData = useCallback((value: unknown) => {
|
||||
setJson(value);
|
||||
setFetchResult({ success: true, responseInfo: null, rawResponse: JSON.stringify(value, null, 2) });
|
||||
setLastFingerprint(null);
|
||||
}, []);
|
||||
const reset = useCallback(() => {
|
||||
setJson(null);
|
||||
setFetchResult(null);
|
||||
setLastFingerprint(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
input,
|
||||
getInput,
|
||||
json,
|
||||
fetchResult,
|
||||
isStale,
|
||||
test,
|
||||
setLiveActions,
|
||||
insertDataPath,
|
||||
setSampleData,
|
||||
reset,
|
||||
isTesting: previewMutation.isPending,
|
||||
testError: previewMutation.error,
|
||||
isUpdatingLiveActions: liveActionsMutation.isPending,
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Container, Stack, Title } from "@mantine/core";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { buildDisplayFormValues } from "@homarr/custom-widgets/core";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
||||
@@ -28,128 +29,6 @@ function buildInitialSecrets(authType: string, dbSecrets: Array<{ kind: string;
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildDisplayInitialValues(displayType: string, config: Record<string, unknown>) {
|
||||
const base = {
|
||||
displayType,
|
||||
jsonPath: "$",
|
||||
label: "",
|
||||
unit: "",
|
||||
valueSize: "lg",
|
||||
labelPosition: "below",
|
||||
mappings: [{ label: "", jsonPath: "$", unit: "" }],
|
||||
kvLayout: "list",
|
||||
kvColumns: 2,
|
||||
tablePath: "$",
|
||||
columns: [{ header: "", jsonPath: "$" }],
|
||||
striped: true,
|
||||
compact: false,
|
||||
statGridItems: [{ label: "", jsonPath: "$", unit: "", color: "blue" }],
|
||||
statGridColumns: 2,
|
||||
cardStyle: "filled",
|
||||
progressBars: [{ label: "", valuePath: "$", maxPath: "", unit: "", color: "blue" }],
|
||||
showPercentage: true,
|
||||
barSize: "md",
|
||||
statusItems: [{ label: "", jsonPath: "$", goodValues: "online,true" }],
|
||||
statusLayout: "list",
|
||||
dotSize: "md",
|
||||
countGridItems: [{ label: "", jsonPath: "$", unit: "" }],
|
||||
countGridColumns: 2,
|
||||
countValueSize: "md",
|
||||
rawJsonPath: "$",
|
||||
rawMaxHeight: 300,
|
||||
buttonLabel: "Execute",
|
||||
buttonColor: "blue",
|
||||
confirmText: "",
|
||||
successMessage: "",
|
||||
template: "",
|
||||
jsxApiVersion: "2",
|
||||
networkScope: "public",
|
||||
requestManifest: "[]",
|
||||
};
|
||||
|
||||
const typeOverrides: Record<string, () => Record<string, unknown>> = {
|
||||
singleValue: () => ({
|
||||
jsonPath: (config.jsonPath as string) ?? "$",
|
||||
label: (config.label as string) ?? "",
|
||||
unit: (config.unit as string) ?? "",
|
||||
valueSize: (config.valueSize as string) ?? "lg",
|
||||
labelPosition: (config.labelPosition as string) ?? "below",
|
||||
}),
|
||||
keyValue: () => ({
|
||||
mappings: (config.mappings as Array<{ label: string; jsonPath: string; unit: string }>) ?? base.mappings,
|
||||
kvLayout: (config.layout as string) ?? "list",
|
||||
kvColumns: (config.columns as number) ?? 2,
|
||||
}),
|
||||
table: () => ({
|
||||
tablePath: (config.tablePath as string) ?? "$",
|
||||
columns: (config.columns as Array<{ header: string; jsonPath: string }>) ?? base.columns,
|
||||
striped: (config.striped as boolean) ?? true,
|
||||
compact: (config.compact as boolean) ?? false,
|
||||
}),
|
||||
statGrid: () => ({
|
||||
statGridItems: (
|
||||
(config.items as Array<{ label: string; jsonPath: string; unit: string; color?: string }>) ?? []
|
||||
).map((item) => ({
|
||||
label: item.label,
|
||||
jsonPath: item.jsonPath,
|
||||
unit: item.unit,
|
||||
color: item.color ?? "blue",
|
||||
})),
|
||||
statGridColumns: (config.columns as number) ?? 2,
|
||||
cardStyle: (config.cardStyle as string) ?? "filled",
|
||||
}),
|
||||
progressBars: () => ({
|
||||
progressBars: (
|
||||
(config.bars as Array<{ label: string; valuePath: string; maxPath?: string; unit: string; color?: string }>) ??
|
||||
[]
|
||||
).map((bar) => ({
|
||||
label: bar.label,
|
||||
valuePath: bar.valuePath,
|
||||
maxPath: bar.maxPath ?? "",
|
||||
unit: bar.unit,
|
||||
color: bar.color ?? "blue",
|
||||
})),
|
||||
showPercentage: (config.showPercentage as boolean) ?? true,
|
||||
barSize: (config.barSize as string) ?? "md",
|
||||
}),
|
||||
statusIndicator: () => ({
|
||||
statusItems: ((config.items as Array<{ label: string; jsonPath: string; goodValues: string[] }>) ?? []).map(
|
||||
(item) => ({
|
||||
label: item.label,
|
||||
jsonPath: item.jsonPath,
|
||||
goodValues: item.goodValues.join(", "),
|
||||
}),
|
||||
),
|
||||
statusLayout: (config.layout as string) ?? "list",
|
||||
dotSize: (config.dotSize as string) ?? "md",
|
||||
}),
|
||||
countGrid: () => ({
|
||||
countGridItems: (config.items as Array<{ label: string; jsonPath: string; unit: string }>) ?? base.countGridItems,
|
||||
countGridColumns: (config.columns as number) ?? 2,
|
||||
countValueSize: (config.valueSize as string) ?? "md",
|
||||
}),
|
||||
raw: () => ({
|
||||
rawJsonPath: (config.jsonPath as string) ?? "$",
|
||||
rawMaxHeight: (config.maxHeight as number) ?? 300,
|
||||
}),
|
||||
actionButton: () => ({
|
||||
buttonLabel: (config.buttonLabel as string) ?? "Execute",
|
||||
buttonColor: (config.buttonColor as string) ?? "blue",
|
||||
confirmText: (config.confirmText as string) ?? "",
|
||||
successMessage: (config.successMessage as string) ?? "",
|
||||
}),
|
||||
customJsx: () => ({
|
||||
template: (config.template as string) ?? "",
|
||||
jsxApiVersion: String((config.jsxApiVersion as number | undefined) ?? 1),
|
||||
networkScope: (config.networkScope as string) ?? "public",
|
||||
requestManifest: JSON.stringify((config.requests as unknown[] | undefined) ?? [], null, 2),
|
||||
}),
|
||||
};
|
||||
|
||||
const overrides = typeOverrides[displayType]?.() ?? {};
|
||||
return { ...base, ...overrides };
|
||||
}
|
||||
|
||||
interface EditCustomWidgetPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
@@ -164,7 +43,7 @@ export default async function EditCustomWidgetPage(props: EditCustomWidgetPagePr
|
||||
const definition = await api.customWidget.byId({ id: params.id }).catch(catchTrpcNotFound);
|
||||
|
||||
const displayConfig = definition.displayConfig as Record<string, unknown>;
|
||||
const displayValues = buildDisplayInitialValues(definition.displayType, displayConfig);
|
||||
const displayValues = buildDisplayFormValues(definition.displayType, displayConfig);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
Reference in new issue
Block a user