feat(custom-widgets): add secure v2 authoring
This commit is contained in:
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
+44
@@ -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
|
||||
|
||||
Reference in new issue
Block a user