feat(custom-widgets): add secure v2 authoring

This commit is contained in:
Thomas Camlong committed 2026-07-12 14:35:35 +02:00
1 parent d37545c98e
commit 46add018bf
56 files changed
+12613 -1726

No files matched your search

@@ -0,0 +1,80 @@
.root {
margin-top: 4px;
border: 1px solid light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4));
border-radius: var(--mantine-radius-sm);
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
overflow: hidden;
}
.root:focus-within {
border-color: var(--mantine-primary-color-filled);
outline: 2px solid color-mix(in srgb, var(--mantine-primary-color-filled) 20%, transparent);
outline-offset: 1px;
}
.toolbar,
.footer {
padding: 7px 10px;
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
}
.toolbar {
border-bottom: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
}
.footer {
border-top: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
}
.viewport {
max-height: min(62vh, 680px);
overflow: auto;
}
.viewport :global(.cm-editor) {
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
color: light-dark(var(--mantine-color-black), var(--mantine-color-gray-0));
font-family: "Fira Code", "SFMono-Regular", Consolas, monospace;
font-size: 12px;
}
.viewport :global(.cm-editor.cm-focused) {
outline: none;
}
.viewport :global(.cm-scroller) {
line-height: 1.65;
overflow: auto;
}
.viewport :global(.cm-gutters) {
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
border-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
}
.diagnosticButton {
min-height: 44px;
border-radius: var(--mantine-radius-sm);
}
.diagnosticButton:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
@media (max-width: 48em) {
.viewport {
max-height: 52vh;
}
.toolbar,
.footer {
align-items: flex-start;
}
}
@media (prefers-reduced-motion: reduce) {
.root {
scroll-behavior: auto;
}
}
@@ -0,0 +1,490 @@
"use client";
import { useMemo, useState } from "react";
import dynamic from "next/dynamic";
import { autocompletion } from "@codemirror/autocomplete";
import type { CompletionSource } from "@codemirror/autocomplete";
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 { useScopedI18n } from "@homarr/translation/client";
import { validateCustomJsxTemplate } from "@homarr/validation/custom-jsx-template";
import classes from "./_code-editor.module.css";
const Editor = dynamic(() => import("@uiw/react-codemirror").then((module) => module.default), {
ssr: false,
loading: () => (
<Group justify="center" p="xl">
<Loader size="sm" />
</Group>
),
});
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;
return {
from: word?.from ?? context.pos,
options: enabledCustomJsxComponents.map((component) => ({
label: component.name,
type: "class",
detail: `${component.category} · ${component.safety}`,
})),
};
};
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;
onChange: (value: string) => void;
label: string;
description?: string;
placeholder?: string;
language: "jsx" | "json";
diagnostics?: EditorDiagnostic[];
error?: React.ReactNode;
required?: boolean;
maxLength?: number;
starter?: string;
}
export function CodeEditor({
id,
value,
onChange,
label,
description,
placeholder,
language,
diagnostics = [],
error,
required,
maxLength,
starter,
}: CodeEditorProps) {
const t = useScopedI18n("customWidget.editor");
const [cursor, setCursor] = useState({ line: 1, column: 1 });
const [editorView, setEditorView] = useState<EditorView | null>(null);
const [historyDepth, setHistoryDepth] = useState({ undo: 0, redo: 0 });
const colorScheme = useComputedColorScheme("light");
const extensions = useMemo(() => {
const diagnosticsExtension = linter((view) =>
diagnostics.map((diagnostic) => {
const from = Math.min(diagnostic.index ?? 0, view.state.doc.length);
return {
from,
to: Math.min(from + 1, view.state.doc.length),
severity: diagnostic.severity,
message: t(`diagnostics.${diagnostic.code}` as never, { value: diagnostic.value ?? "" } as never),
};
}),
);
return language === "jsx"
? [javascript({ jsx: true }), autocompletion({ override: [customJsxCompletionSource] }), diagnosticsExtension]
: [json(), diagnosticsExtension];
}, [diagnostics, language, t]);
const errorCount = diagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
const warningCount = diagnostics.length - errorCount;
const formattedValue = useMemo(() => {
if (language === "json") {
try {
return `${JSON.stringify(JSON.parse(value) as unknown, null, 2)}\n`;
} catch {
return value;
}
}
return value
.replace(/\r\n?/gu, "\n")
.split("\n")
.map((line) => line.trimEnd())
.join("\n")
.trim();
}, [language, value]);
return (
<Input.Wrapper
label={label}
description={description}
error={error}
required={required}
labelProps={{ htmlFor: id }}
inputWrapperOrder={["label", "description", "input", "error"]}
>
<div className={classes.root}>
<Group className={classes.toolbar} justify="space-between" gap="xs" wrap="wrap">
<Group gap={6}>
<IconCode size={16} aria-hidden />
<Text size="xs" fw={600}>
{language === "jsx" ? t("language.jsx") : t("language.json")}
</Text>
</Group>
<Group gap={6}>
<Button
size="compact-xs"
variant="subtle"
leftSection={<IconArrowBackUp size={14} />}
onClick={() => editorView && undo(editorView)}
disabled={!editorView || historyDepth.undo === 0}
>
{t("action.undo")}
</Button>
<Button
size="compact-xs"
variant="subtle"
leftSection={<IconArrowForwardUp size={14} />}
onClick={() => editorView && redo(editorView)}
disabled={!editorView || historyDepth.redo === 0}
>
{t("action.redo")}
</Button>
{language === "jsx" && <ComponentReference />}
{starter && value.trim().length === 0 && (
<Button
size="compact-xs"
variant="subtle"
leftSection={<IconFileCode size={14} />}
onClick={() => onChange(starter)}
>
{t("action.insertStarter")}
</Button>
)}
<Button
size="compact-xs"
variant="subtle"
leftSection={<IconWand size={14} />}
onClick={() => onChange(formattedValue)}
disabled={formattedValue === value}
>
{t("action.format")}
</Button>
</Group>
</Group>
<div className={classes.viewport}>
<Editor
id={id}
value={value}
onChange={onChange}
placeholder={placeholder}
extensions={extensions}
theme={colorScheme}
height={language === "jsx" ? "320px" : "220px"}
basicSetup={{
autocompletion: language !== "jsx",
bracketMatching: true,
closeBrackets: true,
foldGutter: true,
history: true,
highlightActiveLine: true,
highlightActiveLineGutter: true,
indentOnInput: true,
lineNumbers: true,
}}
onCreateEditor={setEditorView}
onUpdate={(update) => {
if (!update.selectionSet && !update.docChanged) return;
const head = update.state.selection.main.head;
const line = update.state.doc.lineAt(head);
setCursor({ line: line.number, column: head - line.from + 1 });
setHistoryDepth({ undo: undoDepth(update.state), redo: redoDepth(update.state) });
}}
/>
</div>
<Group className={classes.footer} justify="space-between" gap="xs">
<Group gap={6}>
{errorCount > 0 ? (
<Badge size="xs" color="red" variant="light">
{t("status.errors", { count: errorCount })}
</Badge>
) : warningCount > 0 ? (
<Badge size="xs" color="yellow" variant="light">
{t("status.warnings", { count: warningCount })}
</Badge>
) : (
<Group gap={4}>
<IconCheck size={13} color="var(--mantine-color-green-6)" aria-hidden />
<Text size="xs" c="dimmed">
{t("status.ready")}
</Text>
</Group>
)}
<Text size="xs" c="dimmed">
{t("status.position", cursor)}
</Text>
</Group>
<Text
size="xs"
c={maxLength && value.length > maxLength * 0.95 ? (value.length > maxLength ? "red" : "orange") : "dimmed"}
>
{maxLength
? t("status.charactersWithLimit", { count: value.length, limit: maxLength })
: t("status.characters", { count: value.length })}
</Text>
</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>
)}
</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,272 +5,174 @@ 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 { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
const PROMPT_HEADER = `You are helping configure a Homarr custom widget. Homarr is a self-hosted dashboard that can display data from any API endpoint as a widget.
const PROMPT_HEADER = `You are configuring a Homarr custom widget. Ask a concise clarifying question only when the requested behavior is ambiguous.
## Your Task
Generate a valid JSON configuration for a Homarr custom widget based on the user's description and the API response below. If you need clarification, ask specific questions.
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.
## Output Format
- Output the JSON inside a \`\`\`json code block for syntax highlighting. If \`\`\`json is not supported, use a generic \`\`\` code block instead.
- Do NOT include any text before or after the JSON block unless you have clarifying questions.
- If your environment supports structured/JSON output mode, use it.
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.
## JSON Schema
The output must conform to this JSON Schema:
## 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;
}
\`\`\`
const PROMPT_RULES = `
Path parameters use \`{name}\`. JSON body parameters use \`{ "$param": "name" }\` and preserve the declared type.
## Key Rules
- The \`displayConfig.type\` field MUST match the \`displayType\` field exactly
- All \`jsonPath\` fields use JSONPath syntax (e.g. \`$.data.count\`, \`$.items[*].name\`)
- The \`$schema\` field should be \`"homarr-custom-widget-v2"\`
- Do NOT include secrets/passwords in the output — those are configured separately in the UI
- \`url\` must be a full URL including protocol (e.g. \`https://...\`)
- For arrays of items, inspect the API response to find the correct paths
## SubFetch rendering
Use a render child when a named query needs its own layout:
## Display Type Guide
- \`singleValue\`: One prominent number/text
- \`keyValue\`: Labeled pairs — use \`mappings\` array
- \`table\`: Tabular data — \`tablePath\` + \`columns\`
- \`statGrid\`: Grid of stat cards with colors
- \`progressBars\`: Visual progress bars with value/max
- \`statusIndicator\`: Green/red dots based on value matching
- \`countGrid\`: Simple count grid
- \`raw\`: Raw JSON display
- \`actionButton\`: Button that triggers the API call on click
- \`customJsx\`: **Full Mantine v9 JSX** — complete creative control. Set \`displayConfig.template\` to a JSX string.
## Custom JSX (\`customJsx\`) — Component Reference
All Mantine v9 components listed below are available. For full Mantine prop documentation, refer to https://mantine.dev/llms.txt or use Context7/web search for any component's detailed API.
### Layout
Box, Stack, Group, Flex, Grid + Grid.Col, SimpleGrid, Center, Space, Container, AspectRatio, Overlay, ScrollArea
### Typography
Text, Title, Code, Highlight, Mark, Kbd, Blockquote, Anchor (href sanitized), NumberFormatter, Marquee, RollingNumber
### Data Display
Badge, Card + Card.Section, Paper, Alert, ThemeIcon, ColorSwatch, Table (+ Thead/Tbody/Tfoot/Caption/Tr/Th/Td), List + List.Item, Timeline + Timeline.Item, Accordion (+ Item/Control/Panel), Indicator, Pill, Spoiler, Progress + Progress.Section, RingProgress, SemiCircleProgress, Skeleton, Loader, Image, Avatar + Avatar.Group, BackgroundImage, Tooltip, Divider, DataList (+ Item/ItemLabel/ItemValue), EmptyState (+ Indicator/Title/Description/Actions), Fieldset, Notification (visual), Rating (read-only)
### Navigation
Breadcrumbs, NavLink (href sanitized), Stepper + Stepper.Step, Tabs + Tabs.List + Tabs.Tab + Tabs.Panel, Tree
### Interactive Display (visual only — no event handlers)
Button, ActionIcon, Burger, CloseButton, Chip + Chip.Group, Pagination, SegmentedControl, Slider (read-only), Switch (read-only)
### Hover Overlays & Menus
HoverCard + HoverCard.Target + HoverCard.Dropdown, Menu + Menu.Target + Menu.Dropdown + Menu.Item + Menu.Label + Menu.Divider
### Utility
CopyButton (click-to-copy, props: \`value\`), Collapse (animate show/hide, props: \`in\`), Transition (animate, props: \`mounted\`, \`transition\`)
### Charts (@mantine/charts — ALL)
AreaChart, BarChart, LineChart, DonutChart, PieChart, RadarChart, RadialBarChart, Sparkline, BubbleChart, CompositeChart, FunnelChart, Heatmap, ScatterChart, SankeyChart, Treemap, BarsList
### Dates (@mantine/dates)
Calendar (static), MiniCalendar (static), DatePicker (static/read-only), TimeValue
### SubFetch — In-Widget HTTP Requests (server-proxied)
These components make HTTP requests through the Homarr server proxy (same-origin as widget URL, inherits widget auth). HTTP method warnings are shown in the widget UI.
**SubFetch** — Fetch data from a sub-endpoint and display results.
Props: \`url\` (relative or absolute), \`method\` (GET|POST|PUT|DELETE|PATCH), \`body\` (JSON string), \`headers\` (JSON string), \`trigger\` ("auto"|"manual"), \`label\`, \`color\`, \`display\` ("json"|"text"), \`path\` (dot-path for display="text")
\`\`\`jsx
<SubFetch url="/api/v1/status" trigger="auto">
<SubData path="name" as="Title" order={3} />
<SubData path="status" as="Badge" color="green" />
<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>
\`\`\`
**SubData** — Reads data from parent SubFetch context.
Props: \`path\` (dot-notation), \`as\` ("Text"|"Title"|"Badge"|"Code"), \`size\`, \`color\`, \`fw\`, \`c\`, \`order\`
**ActionButton** — Button that fires an HTTP request on click.
Props: \`url\`, \`method\` (default POST), \`body\`, \`headers\`, \`label\`, \`color\`, \`variant\`, \`size\`, \`confirmMessage\`, \`successMessage\`, \`icon\` (play|check|refresh|power|trash), \`fullWidth\`, \`disabled\`
\`\`\`jsx
<ActionButton url="/api/lights/1/toggle" method="POST" label="Toggle Light" color="yellow" icon="power" confirmMessage="Toggle the light?" />
\`\`\`
**ToggleSwitch** — Switch that sends different payloads for on/off states.
Props: \`url\`, \`method\` (default POST), \`onBody\` (JSON), \`offBody\` (JSON), \`initialValue\`, \`label\`, \`color\`, \`size\`, \`disabled\`
\`\`\`jsx
<ToggleSwitch url="/api/lights/1" onBody='{"state":"on"}' offBody='{"state":"off"}' label="Living Room" color="yellow" />
\`\`\`
**RefreshButton** — Re-fetches the parent widget data.
Props: \`label\`, \`color\`, \`variant\`, \`size\`
### Custom Interactive Components (built-in state)
**PaginatedList** — Paginates children. Props: \`pageSize\` (default 6)
**TabsContainer + TabPanel** — Tabbed UI. TabsContainer: \`defaultTab\`. TabPanel: \`value\`, \`label\`
**Collapsible** — Expand/collapse. Props: \`title\`, \`defaultOpen\`
**StatBar** — Horizontal stat bar. Props: \`value\`, \`max\`, \`label\`, \`color\`
**TypeBadge** — Colored type badge (auto-maps: fire→red, water→blue, etc.). Props: \`type\`, \`size\`
### Bindings
- \`data\` — full API response
- \`String(v)\`, \`Number(v)\`, \`Boolean(v)\`, \`parseInt(v)\`, \`parseFloat(v)\`
- \`Math.round/floor/ceil/abs/min/max/pow/sqrt/PI\`
- \`JSON.stringify(v)\`, \`Array.isArray(v)\`, \`Array.from(v)\`, \`Object.keys/values/entries(v)\`
- \`Date.now()\`, \`Date.create(v)\`, \`Date.toISOString(v)\`, \`Date.toLocaleDateString(v, locale?)\`, \`Date.toLocaleTimeString(v, locale?)\`, \`Date.getTime(v)\`, \`Date.getYear(v)\`, \`Date.getMonth(v)\`, \`Date.getDay(v)\`
- \`encodeURIComponent(v)\`, \`decodeURIComponent(v)\`, \`isNaN(v)\`, \`isFinite(v)\`
- \`.map()\`, \`.filter()\`, \`.slice()\`, ternaries for conditionals
**FORBIDDEN keywords:** constructor, __proto__, eval, Function, import, require, globalThis, window, document
### Chart Data Formats
- BarChart/LineChart/AreaChart: \`data={items}\` + \`dataKey="x"\` + \`series={[{name:"y",color:"blue"}]}\`
- DonutChart/PieChart: \`data={[{name,value,color}]}\`
- ScatterChart: \`data={items}\` + \`dataKey={xKey}\` + \`series={[{name, color}]}\`
- CompositeChart: same as Bar/Line/Area + \`composedChart\` with type per series
- Sparkline: \`data={[numbers]}\`
- Heatmap: \`data={items}\` + \`dataKey\` + \`series\`
### Pokédex Example (75 pokemon)
API: \`https://pokeapi.co/api/v2/pokemon?limit=75\`
\`\`\`jsx
<Stack gap="sm" p="xs">
<Group justify="space-between">
<Title order={3}>Pokédex</Title>
<Badge size="lg" color="red">{data.count} total</Badge>
</Group>
<PaginatedList pageSize={10}>
{data.results.map((pokemon, i) =>
<Card withBorder p="xs" mb="xs">
<Group wrap="nowrap">
<Avatar src={"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/" + String(i + 1) + ".png"} size="lg" radius="sm" />
<Stack gap={0} style={{flex: 1}}>
<Text fw={700} tt="capitalize">{pokemon.name}</Text>
<Text size="xs" c="dimmed">#{String(i + 1).padStart(3, "0")}</Text>
</Stack>
</Group>
</Card>
)}
</PaginatedList>
</Stack>
\`\`\`
### Pokédex with Detail Fetch (SubFetch for sub-resources)
API: \`https://pokeapi.co/api/v2/pokemon?limit=75\`
\`\`\`jsx
<Stack gap="sm" p="xs">
<Title order={3}>Pokédex</Title>
<PaginatedList pageSize={5}>
{data.results.map((pokemon, i) =>
<Card withBorder p="xs" mb="xs">
<Group wrap="nowrap">
<Avatar src={"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/" + String(i + 1) + ".png"} size={60} radius="sm" />
<Stack gap={2} style={{flex: 1}}>
<Text fw={700} tt="capitalize">{pokemon.name}</Text>
<SubFetch url={pokemon.url} trigger="auto">
<Group gap="xs">
<SubData path="types.0.type.name" as="Badge" color="red" />
<SubData path="stats.0.base_stat" as="Text" size="xs" />
</Group>
</SubFetch>
</Stack>
</Group>
</Card>
)}
</PaginatedList>
</Stack>
\`\`\`
### Stocks Dashboard (Charts + Live Data)
API: \`https://your-stocks-api.com/portfolio\`
\`\`\`jsx
<Stack gap="md" p="sm">
<Group justify="space-between">
<Title order={3}>Portfolio</Title>
<RefreshButton label="Refresh" size="xs" />
</Group>
<SimpleGrid cols={3}>
{data.holdings.map(stock =>
<Card withBorder p="xs">
<Text fw={700}>{stock.symbol}</Text>
<NumberFormatter value={stock.price} prefix="$" decimalScale={2} />
<Sparkline data={stock.history} h={40} color={stock.change > 0 ? "green" : "red"} />
</Card>
)}
</SimpleGrid>
<LineChart h={200} data={data.timeline} dataKey="date" series={[{name: "value", color: "blue"}]} />
</Stack>
\`\`\`
### Navidrome Mini-Player (Buttons + State)
API: \`https://your-navidrome.local/api/nowplaying\`
\`\`\`jsx
<Stack gap="sm" p="sm">
<Group wrap="nowrap">
<Image src={data.albumArt} w={80} h={80} radius="sm" />
<Stack gap={2} style={{flex: 1}}>
<Text fw={700} lineClamp={1}>{data.title}</Text>
<Text size="sm" c="dimmed" lineClamp={1}>{data.artist}</Text>
<Progress value={data.progress} size="xs" color="red" />
</Stack>
</Group>
<Group justify="center" gap="sm">
<ActionButton url="/api/player/previous" method="POST" label="Prev" size="xs" variant="subtle" />
<ActionButton url="/api/player/toggle" method="POST" label={data.playing ? "Pause" : "Play"} icon="play" color="red" />
<ActionButton url="/api/player/next" method="POST" label="Next" size="xs" variant="subtle" />
</Group>
<Group justify="center">
<ToggleSwitch url="/api/player/shuffle" method="POST" onBody='{"shuffle":true}' offBody='{"shuffle":false}' initialValue={data.shuffle} label="Shuffle" size="xs" />
<ToggleSwitch url="/api/player/repeat" method="POST" onBody='{"repeat":true}' offBody='{"repeat":false}' initialValue={data.repeat} label="Repeat" size="xs" />
</Group>
</Stack>
\`\`\`
### Smart Home Light Switch
API: \`https://your-ha.local/api/states/light.living_room\`
\`\`\`jsx
<Stack gap="md" p="sm">
<Group justify="space-between">
<Title order={4}>Living Room</Title>
<Badge color={data.state === "on" ? "yellow" : "gray"}>{data.state}</Badge>
</Group>
<ToggleSwitch url="/api/services/light/toggle" method="POST" onBody='{"entity_id":"light.living_room"}' offBody='{"entity_id":"light.living_room"}' initialValue={data.state === "on"} label="Power" color="yellow" />
<ActionButton url="/api/services/light/turn_off" method="POST" body='{"entity_id":"light.living_room"}' label="Force Off" color="red" icon="power" confirmMessage="Turn off?" />
</Stack>
\`\`\`
## API Response
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 = `Paste the raw JSON response from your API endpoint below:
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_YOUR_API_RESPONSE_HERE
PASTE_API_RESPONSE_HERE
\`\`\`
`;
const PROMPT_FOOTER = `
## Your Request
Describe what you want the widget to show:
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 schemaStr = JSON.stringify(jsonSchema, null, 2);
const responseSection = rawResponse
? `The API returned the following JSON:\n\n\`\`\`json\n${rawResponse}\n\`\`\`\n`
: PROMPT_NO_RESPONSE;
const configSection = currentConfig
? `\n## Current Widget Configuration\nThe widget is currently configured as follows. Use this as a starting point and modify based on the user's request:\n\n\`\`\`json\n${JSON.stringify(currentConfig, null, 2)}\n\`\`\`\n`
? `## 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 + schemaStr + PROMPT_RULES + responseSection + configSection + PROMPT_FOOTER;
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 {
@@ -285,9 +187,8 @@ export const CopyAiPromptButton = ({ rawResponse, currentConfig }: CopyAiPromptB
const handleCopy = async () => {
if (!schema) return;
const prompt = buildAiPrompt(schema, rawResponse, currentConfig);
try {
await navigator.clipboard.writeText(prompt);
await navigator.clipboard.writeText(buildAiPrompt(schema, rawResponse, currentConfig));
close();
showSuccessNotification({ title: t("action.copyAiPrompt"), message: t("notification.aiPromptCopied") });
} catch {
@@ -1,16 +1,22 @@
"use client";
import { useRef } from "react";
import { ActionIcon, Menu } from "@mantine/core";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
IconCopy,
IconDots,
IconDownload,
IconToggleLeft,
IconToggleRight,
IconTrash,
IconUpload,
} from "@tabler/icons-react";
ActionIcon,
Alert,
Badge,
Button,
Code,
Group,
Menu,
Modal,
Paper,
SimpleGrid,
Stack,
Text,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconAlertTriangle, IconCopy, IconDots, IconDownload, IconTrash, IconUpload } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
@@ -19,6 +25,7 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
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 };
@@ -33,28 +40,8 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
const { openConfirmModal } = useConfirmModal();
const deleteMutation = clientApi.customWidget.delete.useMutation();
const duplicateMutation = clientApi.customWidget.duplicate.useMutation();
const toggleEnabledMutation = clientApi.customWidget.toggleEnabled.useMutation();
const utils = clientApi.useUtils();
const handleToggleEnabled = () => {
toggleEnabledMutation.mutate(
{ id: widget.id, enabled: !widget.enabled },
{
onSuccess: () => {
void utils.customWidget.all.invalidate();
void utils.widget.customApi.getData.invalidate({ definitionId: widget.id });
void revalidatePathActionAsync("/manage/custom-widgets");
},
onError: () => {
showErrorNotification({
title: widget.enabled ? t("action.disable") : t("action.enable"),
message: t("notification.toggleError"),
});
},
},
);
};
const handleExport = async () => {
try {
const data = await utils.customWidget.export.fetch({ id: widget.id });
@@ -103,7 +90,7 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
message: t("notification.deleted", { name: widget.name }),
});
void utils.customWidget.all.invalidate();
void utils.widget.customApi.getData.invalidate({ definitionId: widget.id });
void utils.widget.customApi.getData.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
},
onError: () => {
@@ -123,14 +110,6 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
onClick={handleToggleEnabled}
leftSection={widget.enabled ? <IconToggleLeft {...iconProps} /> : <IconToggleRight {...iconProps} />}
disabled={toggleEnabledMutation.isPending}
>
{widget.enabled ? t("action.disable") : t("action.enable")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
onClick={handleDuplicate}
leftSection={<IconCopy {...iconProps} />}
@@ -158,9 +137,13 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
export const ImportCustomWidgetButton = () => {
const t = useScopedI18n("customWidget");
const fileInputRef = useRef<HTMLInputElement>(null);
const [pendingImport, setPendingImport] = useState<Record<string, unknown> | null>(null);
const [reviewOpened, { open: openReview, close: closeReview }] = useDisclosure(false);
const utils = clientApi.useUtils();
const importMutation = clientApi.customWidget.import.useMutation({
onSuccess: () => {
closeReview();
setPendingImport(null);
showSuccessNotification({ title: t("action.import"), message: t("notification.imported") });
void utils.customWidget.all.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
@@ -169,23 +152,45 @@ export const ImportCustomWidgetButton = () => {
showErrorNotification({ title: t("action.import"), message: t("notification.importError") });
},
});
const review = useMemo(() => getImportReview(pendingImport), [pendingImport]);
const queueImport = useCallback(
(value: Record<string, unknown>) => {
setPendingImport(value);
openReview();
},
[openReview],
);
useEffect(() => {
const handlePaste = (event: ClipboardEvent) => {
const target = event.target as HTMLElement | null;
if (target?.matches("input, textarea, [contenteditable='true']")) return;
const widget = parseCustomWidgetClipboard(event.clipboardData?.getData("text/plain") ?? "");
if (!widget) return;
event.preventDefault();
queueImport(widget);
};
window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste);
}, [queueImport]);
const handleImport = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
reader.addEventListener("load", (e) => {
try {
const json = JSON.parse(e.target?.result as string);
importMutation.mutate(json);
const widget = parseCustomWidgetClipboard(e.target?.result as string);
if (!widget) throw new Error("Invalid import");
queueImport(widget);
} catch {
showErrorNotification({ title: t("action.import"), message: t("notification.importError") });
}
};
reader.onerror = () => {
});
reader.addEventListener("error", () => {
showErrorNotification({ title: t("action.import"), message: t("notification.importError") });
};
});
reader.readAsText(file);
event.target.value = "";
};
@@ -197,17 +202,92 @@ export const ImportCustomWidgetButton = () => {
leftSection={<IconUpload size={16} />}
onClick={() => fileInputRef.current?.click()}
loading={importMutation.isPending}
title={t("action.pasteImportHint")}
>
{t("action.import")}
</MobileAffixButton>
<input
ref={fileInputRef}
type="file"
accept=".json"
accept=".json,.md,text/markdown,application/json"
hidden
onChange={handleImport}
aria-label="Import custom widget"
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>
</>
);
};
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,117 @@
.form {
min-width: 0;
}
.workbench {
display: grid;
gap: var(--mantine-spacing-xl);
align-items: start;
}
.paneSwitcher {
position: sticky;
top: var(--mantine-spacing-xs);
z-index: 10;
margin-bottom: var(--mantine-spacing-lg);
background: var(--mantine-color-body);
}
@media (max-width: 74.9375em) {
.workbench[data-mobile-pane="configure"] .previewPane,
.workbench[data-mobile-pane="preview"] .configuration {
display: none;
}
}
.configuration,
.previewPane {
min-width: 0;
}
.previewPane {
display: flex;
flex-direction: column;
gap: var(--mantine-spacing-md);
}
.actionBar {
position: sticky;
bottom: var(--mantine-spacing-sm);
z-index: 10;
background: var(--mantine-color-body);
}
.mobileSaveBar {
position: sticky;
bottom: var(--mantine-spacing-sm);
z-index: 10;
background: var(--mantine-color-body);
}
.displayTypeOption {
display: flex;
align-items: flex-start;
gap: var(--mantine-spacing-sm);
min-height: 7rem;
padding: var(--mantine-spacing-md);
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
background: var(--mantine-color-body);
transition:
border-color 160ms ease,
background-color 160ms ease;
}
.displayTypeOption:hover {
background: var(--mantine-color-default-hover);
}
.displayTypeOption[data-selected] {
border-color: var(--mantine-primary-color-filled);
background: var(--mantine-primary-color-light);
}
.displayTypeOption:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
.displayTypeCopy {
min-width: 0;
}
@media (prefers-reduced-motion: reduce) {
.displayTypeOption {
transition: none;
}
}
@media (min-width: 75em) {
.paneSwitcher {
display: none;
}
.mobileSaveBar {
display: none;
}
.workbench {
grid-template-columns: 1fr 1fr;
}
.previewPane {
position: sticky;
top: 5rem;
}
.actionBar {
top: 5rem;
bottom: auto;
}
}
@media (min-width: 100em) {
.workbench {
grid-template-columns: 0.9fr 1.1fr;
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,44 @@
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>',
);
});
});
@@ -0,0 +1,194 @@
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,9 +1,12 @@
"use client";
import { ActionIcon, Avatar, Badge, Group, Stack, Table, Text } from "@mantine/core";
import { ActionIcon, Avatar, Badge, Card, Group, Stack, Switch, Text, Tooltip } from "@mantine/core";
import { IconApi, IconPencil } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
@@ -23,8 +26,6 @@ const displayTypeBadgeColors: Record<string, string> = {
customJsx: "pink",
};
const iconProps = { size: 16, stroke: 1.5 };
interface CustomWidgetListProps {
definitions: RouterOutputs["customWidget"]["all"];
}
@@ -43,59 +44,99 @@ export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => {
}
return (
<Stack gap="md">
<Table striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>{t("table.name")}</Table.Th>
<Table.Th>{t("table.url")}</Table.Th>
<Table.Th>{t("table.display")}</Table.Th>
<Table.Th w={90} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{definitions.map((def) => (
<Table.Tr key={def.id} style={{ opacity: def.enabled ? undefined : 0.5 }}>
<Table.Td>
<Group gap="xs" wrap="nowrap">
{def.iconUrl ? (
<Avatar size={20} radius="sm" src={def.iconUrl} styles={{ image: { objectFit: "contain" } }} />
) : (
<IconApi size={16} />
)}
<Text size="sm" fw={500} lineClamp={1} c={def.enabled ? undefined : "dimmed"} style={{ minWidth: 0 }}>
{def.name}
</Text>
</Group>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed" lineClamp={1}>
{def.url}
</Text>
</Table.Td>
<Table.Td>
<Badge color={displayTypeBadgeColors[def.displayType] ?? "gray"} size="sm">
{t(`displayType.${def.displayType}` as never)}
</Badge>
</Table.Td>
<Table.Td>
<Group gap={4} justify="flex-end" wrap="nowrap">
<ActionIcon
component={Link}
href={`/manage/custom-widgets/edit/${def.id}`}
variant="filled"
color="red"
aria-label={t("action.edit")}
>
<IconPencil {...iconProps} />
</ActionIcon>
<CustomWidgetRowActions widget={{ id: def.id, name: def.name, enabled: def.enabled }} />
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
<Stack gap="sm">
{definitions.map((def) => (
<CustomWidgetCard key={def.id} widget={def} />
))}
</Stack>
);
};
type WidgetDef = RouterOutputs["customWidget"]["all"][number];
function CustomWidgetCard({ widget }: { widget: WidgetDef }) {
const t = useScopedI18n("customWidget");
const toggleMutation = clientApi.customWidget.toggleEnabled.useMutation();
const utils = clientApi.useUtils();
const handleToggle = () => {
toggleMutation.mutate(
{ id: widget.id, enabled: !widget.enabled },
{
onSuccess: () => {
void utils.customWidget.all.invalidate();
void utils.widget.customApi.getData.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
},
onError: () => {
showErrorNotification({
title: widget.enabled ? t("action.disable") : t("action.enable"),
message: t("notification.toggleError"),
});
},
},
);
};
return (
<Card
padding="sm"
style={{
opacity: widget.enabled ? 1 : 0.55,
transition: "opacity 150ms ease",
}}
>
<Group justify="space-between" wrap="nowrap" gap="md">
<Group wrap="nowrap" gap="sm" style={{ flex: 1, minWidth: 0 }}>
{widget.iconUrl ? (
<Avatar size={36} radius="sm" src={widget.iconUrl} styles={{ image: { objectFit: "contain" } }} />
) : (
<Avatar size={36} radius="sm" color="red">
<IconApi size={18} />
</Avatar>
)}
<Stack gap={2} style={{ minWidth: 0, flex: 1 }}>
<Text size="sm" fw={600} lineClamp={1} style={{ minWidth: 0 }}>
{widget.name}
</Text>
{widget.url && (
<Text size="xs" c="dimmed" ff="monospace" lineClamp={1} style={{ wordBreak: "break-all", minWidth: 0 }}>
{widget.url}
</Text>
)}
</Stack>
<Badge
color={displayTypeBadgeColors[widget.displayType] ?? "gray"}
size="sm"
variant="light"
style={{ flexShrink: 0 }}
>
{t(`displayType.${widget.displayType}` as never)}
</Badge>
</Group>
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
<Tooltip label={widget.enabled ? t("action.disable") : t("action.enable")}>
<Switch
size="sm"
checked={widget.enabled}
onChange={handleToggle}
disabled={toggleMutation.isPending}
aria-label={widget.enabled ? t("action.disable") : t("action.enable")}
/>
</Tooltip>
<ActionIcon
component={Link}
href={`/manage/custom-widgets/edit/${widget.id}`}
variant="subtle"
color="gray"
aria-label={t("action.edit")}
>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
<CustomWidgetRowActions widget={{ id: widget.id, name: widget.name, enabled: widget.enabled }} />
</Group>
</Group>
</Card>
);
}
@@ -1,14 +1,64 @@
"use client";
import { useMemo } from "react";
import { Alert, Badge, Button, Card, Center, Code, Group, Loader, ScrollArea, Stack, Text, Title } from "@mantine/core";
import { IconAlertTriangle, IconExternalLink, IconNetwork, IconPlayerPlay } from "@tabler/icons-react";
import { Component as ReactComponent, useEffect, useMemo, useState } from "react";
import type { ErrorInfo, ReactNode } from "react";
import dynamic from "next/dynamic";
import { json } from "@codemirror/lang-json";
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,
useComputedColorScheme,
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 { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { extractDisplayData } from "@homarr/widgets/custom-api/extract-display-data";
import { extractHttpMethods } from "@homarr/widgets/custom-api/custom-jsx-display";
import { displayComponents } from "@homarr/widgets/custom-api/component";
import { extractDisplayData } from "@homarr/widgets/custom-api/extract-display-data";
import { analyzeJsxTemplate } from "./_code-editor";
const JsonEditor = dynamic(() => import("@uiw/react-codemirror").then((m) => m.default), {
ssr: false,
loading: () => <Loader size="sm" />,
});
interface PreviewInput {
url: string;
@@ -22,11 +72,20 @@ interface PreviewInput {
definitionId?: string;
}
interface NamedRequestSummary {
id: string;
kind: "query" | "action";
method: string;
pathTemplate: string;
minimumBoardPermission?: string;
}
export interface PreviewFetchResult {
success: boolean;
error?: string;
responseInfo: { status: number; statusText: string } | null;
rawResponse: string | null;
previewSession?: { id: string; expiresAt: number; liveActions: boolean } | null;
}
interface CustomWidgetPreviewProps {
@@ -36,19 +95,142 @@ interface CustomWidgetPreviewProps {
cachedJson: unknown;
onTest: () => void;
isTesting: boolean;
isSampleStale?: boolean;
testError?: string | null;
onInsertDataPath?: (path: string) => void;
onSetPreviewLiveActions?: (enabled: boolean) => void;
isUpdatingPreviewActions?: boolean;
onSampleDataChange?: (value: unknown) => void;
}
export function CustomWidgetPreview({
const METHOD_COLORS: Record<string, string> = { DELETE: "red", GET: "blue" };
const methodColor = (method: string) => METHOD_COLORS[method] ?? "orange";
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 (
<PreviewErrorBoundary
title={t("errorBoundary.title")}
description={t("errorBoundary.description")}
retryLabel={t("errorBoundary.retry")}
>
<CustomWidgetPreviewContent {...props} />
</PreviewErrorBoundary>
);
}
function CustomWidgetPreviewContent({
getFormValues,
formValues,
fetchResult,
cachedJson,
onTest,
isTesting,
isSampleStale = false,
testError,
onInsertDataPath,
onSetPreviewLiveActions,
isUpdatingPreviewActions = false,
onSampleDataChange,
}: CustomWidgetPreviewProps) {
const t = useScopedI18n("customWidget");
const editorT = useScopedI18n("customWidget.editor");
const [previewSize, setPreviewSize] = useState("standard");
const [sampleEditorOpen, setSampleEditorOpen] = useState(false);
const [sampleDraft, setSampleDraft] = useState("");
const previewSessionId = fetchResult?.previewSession?.id ?? "";
const journalQuery = clientApi.customWidget.previewJournal.useQuery(
{ sessionId: previewSessionId },
{ enabled: previewSessionId.length > 0, retry: false, refetchInterval: 1_000 },
);
const displayData = useMemo((): Record<string, unknown> | null => {
const { displayType, displayConfig } = formValues;
@@ -56,63 +238,153 @@ export function CustomWidgetPreview({
return extractDisplayData(null, displayType, displayConfig) as Record<string, unknown>;
}
if (!fetchResult?.success || cachedJson == null) return null;
return extractDisplayData(cachedJson, displayType, displayConfig) as Record<string, unknown>;
}, [fetchResult?.success, cachedJson, formValues]);
const extracted = extractDisplayData(cachedJson, displayType, displayConfig) as Record<string, unknown>;
return displayType === "customJsx" && fetchResult.previewSession
? {
...extracted,
previewSessionId: fetchResult.previewSession.id,
previewLiveActions: fetchResult.previewSession.liveActions,
}
: extracted;
}, [fetchResult, cachedJson, formValues]);
const namedRequests = useMemo(() => getNamedRequests(formValues.displayConfig), [formValues.displayConfig]);
const httpMethods = useMemo(() => {
if (formValues.displayType !== "customJsx") return [];
const template = (formValues.displayConfig.template as string) ?? "";
return extractHttpMethods(template);
}, [formValues.displayType, formValues.displayConfig]);
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]);
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,
requestIds: namedRequests.map((request) => request.id),
})
: [],
[formValues.displayConfig, formValues.displayType, namedRequests],
);
const handleTest = () => {
const values = getFormValues();
if (!values.url) return;
if (!values.url || values.method !== "GET") return;
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 shadow="sm" p="md">
<Stack gap="sm">
<Group justify="space-between" align="center">
<Title order={5}>{t("preview.title")}</Title>
<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>
{httpMethods.length > 0 && (
<Alert color="yellow" variant="light" p="xs" icon={<IconNetwork size={14} />}>
<Group gap={4} align="center">
<Text size="xs" fw={500}>
Widget makes HTTP requests:
</Text>
{httpMethods.map((m) => (
<Badge
key={m}
size="xs"
color={m === "DELETE" ? "red" : m === "GET" ? "blue" : "orange"}
variant="filled"
>
{m}
</Badge>
))}
</Group>
{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}>
@@ -126,79 +398,468 @@ export function CustomWidgetPreview({
</Alert>
)}
{displayData && (formValues.displayType === "actionButton" || fetchResult?.success) && (
<Stack gap="xs">
{fetchResult?.success && (
<Group gap="xs">
<Badge size="xs" color="green" variant="light">
{fetchResult.responseInfo?.status} {fetchResult.responseInfo?.statusText}
</Badge>
</Group>
)}
<Tabs defaultValue="widget" keepMounted={false}>
<Tabs.List grow>
<Tabs.Tab value="widget">{t("preview.tab.widget")}</Tabs.Tab>
<Tabs.Tab value="response">{t("preview.tab.response")}</Tabs.Tab>
<Tabs.Tab value="request">{t("preview.tab.request")}</Tabs.Tab>
<Tabs.Tab value="diagnostics">{t("preview.tab.diagnostics")}</Tabs.Tab>
</Tabs.List>
<Card withBorder p="xs" bg="var(--mantine-color-dark-7)" mah={320} style={{ overflow: "auto" }}>
<PreviewDisplay data={displayData} />
</Card>
{fetchResult?.success && fetchResult.rawResponse && (
<Button
<Tabs.Panel value="widget" pt="sm">
<Stack gap="xs">
<SegmentedControl
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>
)}
</Stack>
)}
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>
</Tabs.Panel>
{!displayData && !testError && !isTesting && (
<Text size="xs" c="dimmed" ta="center">
{t("preview.hint")}
</Text>
)}
<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>
<ResponseJsonViewer json={responseJson} label={t("preview.response.raw")} />
{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>
</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>
</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>
</Tabs.Panel>
</Tabs>
</Stack>
</Card>
);
}
function PreviewDisplay({ data }: { data: unknown }) {
const typed = data as Record<string, unknown>;
if (!typed || typeof typed !== "object") {
return (
<Text size="xs" c="dimmed">
No data
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);
}
const JSON_EXTENSIONS = [json()];
function ResponseJsonViewer({ json: jsonStr, label }: { json: string; label: string }) {
const colorScheme = useComputedColorScheme();
const [expanded, setExpanded] = useState(false);
return (
<Stack gap={4}>
<Button size="compact-xs" variant="subtle" onClick={() => setExpanded((v) => !v)}>
{label}
</Button>
{expanded && (
<JsonEditor
value={jsonStr}
readOnly
editable={false}
extensions={JSON_EXTENSIONS}
height="280px"
theme={colorScheme === "dark" ? "dark" : "light"}
basicSetup={{ lineNumbers: true, foldGutter: true, highlightActiveLine: false }}
/>
)}
</Stack>
);
}
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">
<Center p="sm" h="100%">
<Button size="sm" color={(typed.buttonColor as string) ?? "blue"} disabled>
{(typed.buttonLabel as string) ?? "Execute"}
{(typed.buttonLabel as string) ?? t("preview.execute")}
</Button>
</Center>
);
}
const Component = dataType ? displayComponents[dataType] : undefined;
if (Component) {
return <Component data={typed} />;
}
if (Component) return <Component data={typed} />;
return (
<ScrollArea h={100}>
<ScrollArea h="100%">
<Code block style={{ fontSize: 11 }}>
{JSON.stringify(data, null, 2)}
</Code>
@@ -62,6 +62,9 @@ function buildDisplayInitialValues(displayType: string, config: Record<string, u
confirmText: "",
successMessage: "",
template: "",
jsxApiVersion: "2",
networkScope: "public",
requestManifest: "[]",
};
const typeOverrides: Record<string, () => Record<string, unknown>> = {
@@ -137,6 +140,9 @@ function buildDisplayInitialValues(displayType: string, config: Record<string, u
}),
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),
}),
};
@@ -165,7 +165,7 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
</Text>
</Group>
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
{def.description ?? def.url}
{def.description ?? ""}
</Text>
</Stack>
<Box