refactor(custom-widgets): extract domain package
This commit is contained in:
167 files changed
+12282
-10033
No files matched your search
@@ -1,18 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, Anchor, Text } from "@mantine/core";
|
||||
import { IconFlask } from "@tabler/icons-react";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
const FEEDBACK_URL = "https://github.com/homarr-labs/homarr/discussions/categories/custom-widgets";
|
||||
|
||||
export function CustomWidgetBetaBanner() {
|
||||
const t = useScopedI18n("customWidget.betaBanner");
|
||||
return (
|
||||
<Alert variant="light" color="yellow" title="Custom Widgets are in Beta" icon={<IconFlask />}>
|
||||
<Alert variant="light" color="yellow" title={t("title")} icon={<IconFlask />}>
|
||||
<Text size="sm">
|
||||
This feature is experimental and will probably change a lot in the future. If you encounter bugs, wish it did
|
||||
more, or find something confusing, then please complain to us as much as possible! Only feedback posted on the{" "}
|
||||
{t("description")}{" "}
|
||||
<Anchor href={FEEDBACK_URL} target="_blank" fw={600}>
|
||||
official discussion thread
|
||||
{t("link")}
|
||||
</Anchor>{" "}
|
||||
will be taken into account. Happy Hacking!
|
||||
{t("closing")}
|
||||
</Text>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { Alert, Stack, Text, UnstyledButton } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
import type { EditorDiagnostic } from "@homarr/custom-widgets/workbench";
|
||||
|
||||
import classes from "./_code-editor.module.css";
|
||||
|
||||
interface CodeEditorDiagnosticsProps {
|
||||
diagnostics: EditorDiagnostic[];
|
||||
editorView: EditorView | null;
|
||||
title: string;
|
||||
formatDiagnostic: (diagnostic: EditorDiagnostic) => string;
|
||||
}
|
||||
|
||||
export function CodeEditorDiagnostics({
|
||||
diagnostics,
|
||||
editorView,
|
||||
title,
|
||||
formatDiagnostic,
|
||||
}: CodeEditorDiagnosticsProps) {
|
||||
if (diagnostics.length === 0) return null;
|
||||
return (
|
||||
<Stack component="ul" gap={6} mt="xs" pl={0} style={{ listStyle: "none" }} aria-label={title}>
|
||||
{diagnostics.map((diagnostic, index) => (
|
||||
<li key={`${diagnostic.code}-${diagnostic.line ?? 0}-${index}`}>
|
||||
<UnstyledButton
|
||||
type="button"
|
||||
w="100%"
|
||||
className={classes.diagnosticButton}
|
||||
onClick={() => {
|
||||
if (!editorView || diagnostic.index === undefined) return;
|
||||
const anchor = Math.min(diagnostic.index, editorView.state.doc.length);
|
||||
editorView.dispatch({ selection: { anchor }, scrollIntoView: true });
|
||||
editorView.focus();
|
||||
}}
|
||||
>
|
||||
<Alert
|
||||
color={diagnostic.severity === "error" ? "red" : "yellow"}
|
||||
variant="light"
|
||||
p="xs"
|
||||
icon={<IconAlertTriangle size={15} />}
|
||||
>
|
||||
<Text size="xs">{formatDiagnostic(diagnostic)}</Text>
|
||||
</Alert>
|
||||
</UnstyledButton>
|
||||
</li>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -8,37 +8,15 @@ import { redo, redoDepth, undo, undoDepth } from "@codemirror/commands";
|
||||
import { javascript } from "@codemirror/lang-javascript";
|
||||
import { json } from "@codemirror/lang-json";
|
||||
import { linter } from "@codemirror/lint";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Input,
|
||||
Loader,
|
||||
Popover,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
UnstyledButton,
|
||||
useComputedColorScheme,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconArrowBackUp,
|
||||
IconArrowForwardUp,
|
||||
IconCheck,
|
||||
IconCode,
|
||||
IconComponents,
|
||||
IconFileCode,
|
||||
IconSearch,
|
||||
IconWand,
|
||||
} from "@tabler/icons-react";
|
||||
import { enabledCustomJsxComponents } from "@homarr/definitions";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { Badge, Button, Group, Input, Loader, Text, useComputedColorScheme } from "@mantine/core";
|
||||
import { IconArrowBackUp, IconArrowForwardUp, IconCheck, IconCode, IconFileCode, IconWand } from "@tabler/icons-react";
|
||||
import { enabledCustomJsxComponents } from "@homarr/custom-widgets/core";
|
||||
import { ComponentReference } from "@homarr/custom-widgets/workbench";
|
||||
import type { EditorDiagnostic } from "@homarr/custom-widgets/workbench";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { validateCustomJsxTemplate } from "@homarr/validation/custom-jsx-template";
|
||||
|
||||
import { CodeEditorDiagnostics } from "./_code-editor-diagnostics";
|
||||
import classes from "./_code-editor.module.css";
|
||||
|
||||
const Editor = dynamic(() => import("@uiw/react-codemirror").then((module) => module.default), {
|
||||
@@ -50,43 +28,6 @@ const Editor = dynamic(() => import("@uiw/react-codemirror").then((module) => mo
|
||||
),
|
||||
});
|
||||
|
||||
export const CUSTOM_JSX_TEMPLATE_LIMIT = 50_000;
|
||||
|
||||
export type EditorDiagnosticCode =
|
||||
| "ast"
|
||||
| "dangerousProperty"
|
||||
| "dynamicRequestId"
|
||||
| "eventHandler"
|
||||
| "invalidRequestManifest"
|
||||
| "legacyNetworkProps"
|
||||
| "missingRequestId"
|
||||
| "templateEmpty"
|
||||
| "templateTooLong"
|
||||
| "unknownRequest"
|
||||
| "unsafeTag";
|
||||
|
||||
export interface EditorDiagnostic {
|
||||
code: EditorDiagnosticCode;
|
||||
severity: "error" | "warning";
|
||||
line?: number;
|
||||
column?: number;
|
||||
index?: number;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
interface AnalyzeJsxTemplateOptions {
|
||||
apiVersion?: 1 | 2;
|
||||
requestIds?: string[];
|
||||
}
|
||||
|
||||
const unsafeTagPattern = /<\s*\/?\s*(script|iframe|object|embed|form|style|link|meta|base)\b/giu;
|
||||
const eventHandlerPattern = /\s(on[A-Z][\w]*)\s*=/gu;
|
||||
const dangerousPropertyPattern =
|
||||
/(?:\.|\[\s*["'])(constructor|prototype|__proto__|caller|callee|arguments|bind|call|apply)(?:["']\s*\])?/giu;
|
||||
const networkComponentPattern = /<(SubFetch|ActionButton|ToggleSwitch)\b([^>]*)>/giu;
|
||||
|
||||
const getLine = (source: string, index: number) => source.slice(0, index).split("\n").length;
|
||||
|
||||
const customJsxCompletionSource: CompletionSource = (context) => {
|
||||
const word = context.matchBefore(/[A-Z][\w.]*/u);
|
||||
if (!word && !context.explicit) return null;
|
||||
@@ -100,111 +41,6 @@ const customJsxCompletionSource: CompletionSource = (context) => {
|
||||
};
|
||||
};
|
||||
|
||||
export function analyzeJsxTemplate(
|
||||
template: string,
|
||||
{ apiVersion = 1, requestIds = [] }: AnalyzeJsxTemplateOptions = {},
|
||||
): EditorDiagnostic[] {
|
||||
const diagnostics: EditorDiagnostic[] = [];
|
||||
|
||||
if (template.trim().length === 0) {
|
||||
diagnostics.push({ code: "templateEmpty", severity: "error", line: 1, column: 1, index: 0 });
|
||||
}
|
||||
if (template.length > CUSTOM_JSX_TEMPLATE_LIMIT) {
|
||||
diagnostics.push({ code: "templateTooLong", severity: "error", line: 1, column: 1, index: 0 });
|
||||
}
|
||||
|
||||
for (const diagnostic of validateCustomJsxTemplate(template)) {
|
||||
diagnostics.push({
|
||||
code: "ast",
|
||||
severity: diagnostic.severity,
|
||||
line: diagnostic.line,
|
||||
column: diagnostic.column,
|
||||
index: diagnostic.index,
|
||||
value: diagnostic.message,
|
||||
});
|
||||
}
|
||||
|
||||
for (const match of template.matchAll(unsafeTagPattern)) {
|
||||
diagnostics.push({
|
||||
code: "unsafeTag",
|
||||
severity: "error",
|
||||
line: getLine(template, match.index),
|
||||
index: match.index,
|
||||
value: match[1],
|
||||
});
|
||||
}
|
||||
for (const match of template.matchAll(eventHandlerPattern)) {
|
||||
if (match[1] === "onParams") continue;
|
||||
diagnostics.push({
|
||||
code: "eventHandler",
|
||||
severity: "error",
|
||||
line: getLine(template, match.index),
|
||||
index: match.index,
|
||||
value: match[1],
|
||||
});
|
||||
}
|
||||
for (const match of template.matchAll(dangerousPropertyPattern)) {
|
||||
diagnostics.push({
|
||||
code: "dangerousProperty",
|
||||
severity: "error",
|
||||
line: getLine(template, match.index),
|
||||
index: match.index,
|
||||
value: match[1],
|
||||
});
|
||||
}
|
||||
|
||||
if (apiVersion === 2) {
|
||||
const knownRequestIds = new Set(requestIds);
|
||||
for (const match of template.matchAll(networkComponentPattern)) {
|
||||
const props = match[2] ?? "";
|
||||
const line = getLine(template, match.index);
|
||||
if (/\b(?:url|method|headers|body|onBody|offBody)\s*=/iu.test(props)) {
|
||||
diagnostics.push({ code: "legacyNetworkProps", severity: "error", line, index: match.index });
|
||||
}
|
||||
|
||||
const staticRequest = props.match(/\brequestId\s*=\s*["']([^"']+)["']/iu);
|
||||
if (staticRequest?.[1]) {
|
||||
if (!knownRequestIds.has(staticRequest[1])) {
|
||||
diagnostics.push({
|
||||
code: "unknownRequest",
|
||||
severity: "error",
|
||||
line,
|
||||
index: match.index,
|
||||
value: staticRequest[1],
|
||||
});
|
||||
}
|
||||
} else if (/\brequestId\s*=/iu.test(props)) {
|
||||
diagnostics.push({ code: "dynamicRequestId", severity: "error", line, index: match.index });
|
||||
} else {
|
||||
diagnostics.push({ code: "missingRequestId", severity: "error", line, index: match.index });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
export function analyzeRequestManifest(value: string): EditorDiagnostic[] {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [{ code: "invalidRequestManifest", severity: "error", line: 1 }];
|
||||
}
|
||||
return [];
|
||||
} catch {
|
||||
return [{ code: "invalidRequestManifest", severity: "error", line: 1 }];
|
||||
}
|
||||
}
|
||||
|
||||
export function parseRequestManifest(value: string): unknown[] {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
interface CodeEditorProps {
|
||||
id: string;
|
||||
value: string;
|
||||
@@ -251,10 +87,19 @@ export function CodeEditor({
|
||||
};
|
||||
}),
|
||||
);
|
||||
const accessibilityExtension = EditorView.contentAttributes.of({
|
||||
id,
|
||||
"aria-label": label,
|
||||
});
|
||||
return language === "jsx"
|
||||
? [javascript({ jsx: true }), autocompletion({ override: [customJsxCompletionSource] }), diagnosticsExtension]
|
||||
: [json(), diagnosticsExtension];
|
||||
}, [diagnostics, language, t]);
|
||||
? [
|
||||
javascript({ jsx: true }),
|
||||
autocompletion({ override: [customJsxCompletionSource] }),
|
||||
diagnosticsExtension,
|
||||
accessibilityExtension,
|
||||
]
|
||||
: [json(), diagnosticsExtension, accessibilityExtension];
|
||||
}, [diagnostics, id, label, language, t]);
|
||||
const errorCount = diagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
|
||||
const warningCount = diagnostics.length - errorCount;
|
||||
|
||||
@@ -293,6 +138,7 @@ export function CodeEditor({
|
||||
</Group>
|
||||
<Group gap={6}>
|
||||
<Button
|
||||
type="button"
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconArrowBackUp size={14} />}
|
||||
@@ -302,6 +148,7 @@ export function CodeEditor({
|
||||
{t("action.undo")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconArrowForwardUp size={14} />}
|
||||
@@ -310,9 +157,19 @@ export function CodeEditor({
|
||||
>
|
||||
{t("action.redo")}
|
||||
</Button>
|
||||
{language === "jsx" && <ComponentReference />}
|
||||
{language === "jsx" && (
|
||||
<ComponentReference
|
||||
messages={{
|
||||
action: t("action.components"),
|
||||
search: t("componentReference.search"),
|
||||
empty: t("componentReference.empty"),
|
||||
count: (count) => t("componentReference.count", { count }),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{starter && value.trim().length === 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconFileCode size={14} />}
|
||||
@@ -322,6 +179,7 @@ export function CodeEditor({
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconWand size={14} />}
|
||||
@@ -335,7 +193,7 @@ export function CodeEditor({
|
||||
|
||||
<div className={classes.viewport}>
|
||||
<Editor
|
||||
id={id}
|
||||
id={`${id}-root`}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
@@ -397,94 +255,17 @@ export function CodeEditor({
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
{diagnostics.length > 0 && (
|
||||
<Stack component="ul" gap={6} mt="xs" pl={0} style={{ listStyle: "none" }} aria-label={t("diagnostics.title")}>
|
||||
{diagnostics.map((diagnostic, index) => (
|
||||
<li key={`${diagnostic.code}-${diagnostic.line ?? 0}-${index}`}>
|
||||
<UnstyledButton
|
||||
w="100%"
|
||||
className={classes.diagnosticButton}
|
||||
onClick={() => {
|
||||
if (!editorView || diagnostic.index === undefined) return;
|
||||
const anchor = Math.min(diagnostic.index, editorView.state.doc.length);
|
||||
editorView.dispatch({ selection: { anchor }, scrollIntoView: true });
|
||||
editorView.focus();
|
||||
}}
|
||||
>
|
||||
<Alert
|
||||
color={diagnostic.severity === "error" ? "red" : "yellow"}
|
||||
variant="light"
|
||||
p="xs"
|
||||
icon={<IconAlertTriangle size={15} />}
|
||||
>
|
||||
<Text size="xs">
|
||||
{diagnostic.line ? `${t("diagnostics.line", { line: diagnostic.line })}: ` : ""}
|
||||
{t(`diagnostics.${diagnostic.code}` as never, { value: diagnostic.value ?? "" } as never)}
|
||||
</Text>
|
||||
</Alert>
|
||||
</UnstyledButton>
|
||||
</li>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
<CodeEditorDiagnostics
|
||||
diagnostics={diagnostics}
|
||||
editorView={editorView}
|
||||
title={t("diagnostics.title")}
|
||||
formatDiagnostic={(diagnostic) =>
|
||||
`${diagnostic.line ? `${t("diagnostics.line", { line: diagnostic.line })}: ` : ""}${t(
|
||||
`diagnostics.${diagnostic.code}` as never,
|
||||
{ value: diagnostic.value ?? "" } as never,
|
||||
)}`
|
||||
}
|
||||
/>
|
||||
</Input.Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
function ComponentReference() {
|
||||
const t = useScopedI18n("customWidget.editor");
|
||||
const [query, setQuery] = useState("");
|
||||
const components = useMemo(() => {
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase();
|
||||
return enabledCustomJsxComponents.filter(
|
||||
(component) =>
|
||||
normalizedQuery.length === 0 ||
|
||||
component.name.toLocaleLowerCase().includes(normalizedQuery) ||
|
||||
component.category.toLocaleLowerCase().includes(normalizedQuery),
|
||||
);
|
||||
}, [query]);
|
||||
|
||||
return (
|
||||
<Popover width={320} position="bottom-end" withinPortal shadow="md">
|
||||
<Popover.Target>
|
||||
<Button size="compact-xs" variant="subtle" leftSection={<IconComponents size={14} />}>
|
||||
{t("action.components")}
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p="xs">
|
||||
<Stack gap="xs">
|
||||
<TextInput
|
||||
size="xs"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.currentTarget.value)}
|
||||
placeholder={t("componentReference.search")}
|
||||
aria-label={t("componentReference.search")}
|
||||
leftSection={<IconSearch size={14} />}
|
||||
/>
|
||||
<ScrollArea.Autosize mah={300} type="auto">
|
||||
<Stack gap={4}>
|
||||
{components.map((component) => (
|
||||
<Group key={component.name} justify="space-between" wrap="nowrap" py={3} px={4}>
|
||||
<Text size="xs" ff="monospace" fw={600}>
|
||||
{component.name}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light" color={component.safety === "wrapped" ? "yellow" : "gray"}>
|
||||
{component.category}
|
||||
</Badge>
|
||||
</Group>
|
||||
))}
|
||||
{components.length === 0 && (
|
||||
<Text size="xs" c="dimmed" ta="center" py="md">
|
||||
{t("componentReference.empty")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("componentReference.count", { count: enabledCustomJsxComponents.length })}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -5,176 +5,10 @@ import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconCopy, IconSparkles } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { customJsxExamples, enabledCustomJsxComponents } from "@homarr/definitions";
|
||||
import { buildCustomWidgetAiPrompt } from "@homarr/custom-widgets/core";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
const PROMPT_HEADER = `You are configuring a Homarr custom widget. Ask a concise clarifying question only when the requested behavior is ambiguous.
|
||||
|
||||
For Custom JSX, return a Homarr authoring bundle with exactly two fenced code blocks:
|
||||
1. A \`json\` block containing the complete import object. Set \`displayConfig.template\` to \`"__HOMARR_TEMPLATE__"\`.
|
||||
2. A \`jsx\` block containing the readable, multiline template with no JSON escaping.
|
||||
|
||||
The user can paste both blocks together into Homarr. Homarr combines and validates them before import. For non-Custom-JSX display types, return only the complete JSON block.
|
||||
|
||||
## Rules
|
||||
- Use \`"$schema": "homarr-custom-widget-v3"\`.
|
||||
- \`displayType\` and \`displayConfig.type\` must match.
|
||||
- Prefer \`customJsx\` with \`jsxApiVersion: 2\` for custom layouts.
|
||||
- The base widget request must use GET. Mutations belong in named action requests.
|
||||
- JSX cannot call \`fetch\` or use event handlers. Use SubFetch, ActionButton, ToggleSwitch, and RefreshButton.
|
||||
- Version 2 network components reference a literal \`requestId\` and pass only declared \`params\`.
|
||||
- SubFetch can reference query requests only. ActionButton and ToggleSwitch can reference action requests only.
|
||||
- Prefer the SubFetch function-as-children API for fetched data. The callback receives \`(response, metadata)\` and may return any safe JSX tree. Use SubData only for very small path-based displays.
|
||||
- SubFetch owns loading, failure, retry, cancellation, and stale-response handling. The render callback runs only when response data is available.
|
||||
- Named request paths are same-origin paths beginning with \`/\`. Do not put credentials in a template or export.
|
||||
- Query requests use GET and view permission. POST, PUT, and PATCH actions default to modify. DELETE requires full permission and confirmation.
|
||||
- Use only components in the generated component reference below.
|
||||
- Template output must fit inside a dashboard widget and remain usable at narrow widths.
|
||||
- Return the complete import object, not a template fragment. Preserve unrelated fields from the current configuration.
|
||||
- Include stable keys for rendered collections, accessible labels for controls and images, useful empty states, and concise content that works in light and dark themes.
|
||||
- Prefer one clear information hierarchy over nested cards. Use ActionButton and ToggleSwitch only when the named action and required permission are declared.
|
||||
|
||||
## Named request shape
|
||||
\`\`\`ts
|
||||
interface CustomJsxRequest {
|
||||
id: string;
|
||||
kind: "query" | "action";
|
||||
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
pathTemplate: \`/\${string}\`;
|
||||
parameters: Record<string, "string" | "number" | "boolean">;
|
||||
bodyTemplate?: unknown;
|
||||
staticHeaders?: Record<string, string>;
|
||||
auth: "inherit" | "none";
|
||||
minimumBoardPermission: "view" | "modify" | "full";
|
||||
cacheTtlSeconds?: number;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Path parameters use \`{name}\`. JSON body parameters use \`{ "$param": "name" }\` and preserve the declared type.
|
||||
|
||||
## SubFetch rendering
|
||||
Use a render child when a named query needs its own layout:
|
||||
|
||||
\`\`\`jsx
|
||||
<SubFetch requestId="service-detail" params={{ id: data.serviceId }}>
|
||||
{(detail, meta) =>
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Text fw={700}>{detail.name}</Text>
|
||||
<Badge color={meta.ok ? "green" : "red"}>HTTP {meta.status}</Badge>
|
||||
</Group>
|
||||
{detail.metrics.map((metric) =>
|
||||
<Group key={metric.name} justify="space-between">
|
||||
<Text size="sm">{metric.name}</Text>
|
||||
<Text fw={600}>{metric.value}</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
}
|
||||
</SubFetch>
|
||||
\`\`\`
|
||||
|
||||
The callback is an interpreter-managed inline arrow function. Do not use native callbacks, event handlers, global state, or direct network access. Keep list keys stable and design for narrow and wide widget sizes.
|
||||
`;
|
||||
|
||||
const PROMPT_NO_RESPONSE = `No sample response is available. Leave this marker in your reasoning and ask the user for a representative response if its shape is required:
|
||||
|
||||
\`\`\`json
|
||||
PASTE_API_RESPONSE_HERE
|
||||
\`\`\`
|
||||
`;
|
||||
|
||||
function buildComponentReference() {
|
||||
const categories = new Map<string, string[]>();
|
||||
for (const component of enabledCustomJsxComponents) {
|
||||
const names = categories.get(component.category) ?? [];
|
||||
names.push(component.name);
|
||||
categories.set(component.category, names);
|
||||
}
|
||||
|
||||
return [...categories.entries()].map(([category, names]) => `- ${category}: ${names.join(", ")}`).join("\n");
|
||||
}
|
||||
|
||||
function buildExamples() {
|
||||
return customJsxExamples
|
||||
.map(
|
||||
(example) => `### ${example.title}
|
||||
${example.description}
|
||||
\`\`\`json
|
||||
${JSON.stringify(
|
||||
{
|
||||
displayType: "customJsx",
|
||||
displayConfig: {
|
||||
type: "customJsx",
|
||||
jsxApiVersion: 2,
|
||||
networkScope: "public",
|
||||
template: "__HOMARR_TEMPLATE__",
|
||||
requests: example.requests,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
\`\`\`
|
||||
\`\`\`jsx
|
||||
${example.template}
|
||||
\`\`\``,
|
||||
)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
function buildAiPrompt(
|
||||
jsonSchema: unknown,
|
||||
rawResponse?: string | null,
|
||||
currentConfig?: Record<string, unknown> | null,
|
||||
) {
|
||||
const responseSection = rawResponse
|
||||
? `## API response\n\n\`\`\`json\n${rawResponse}\n\`\`\`\n`
|
||||
: `## API response\n\n${PROMPT_NO_RESPONSE}`;
|
||||
const currentDisplayConfig = currentConfig?.displayConfig;
|
||||
const currentTemplate =
|
||||
currentDisplayConfig && typeof currentDisplayConfig === "object" && "template" in currentDisplayConfig
|
||||
? currentDisplayConfig.template
|
||||
: null;
|
||||
const readableCurrentConfig = currentConfig
|
||||
? {
|
||||
...currentConfig,
|
||||
...(currentTemplate && typeof currentTemplate === "string"
|
||||
? {
|
||||
displayConfig: {
|
||||
...(currentDisplayConfig as Record<string, unknown>),
|
||||
template: "__HOMARR_TEMPLATE__",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: null;
|
||||
const configSection = readableCurrentConfig
|
||||
? `## Current configuration\n\nUse this as the starting point and preserve unrelated settings.\n\n\`\`\`json\n${JSON.stringify(readableCurrentConfig, null, 2)}\n\`\`\`\n${typeof currentTemplate === "string" ? `\n\`\`\`jsx\n${currentTemplate}\n\`\`\`\n` : ""}`
|
||||
: "";
|
||||
|
||||
return `${PROMPT_HEADER}
|
||||
## Import schema
|
||||
The \`__HOMARR_TEMPLATE__\` value is an authoring transport placeholder. Homarr replaces it with the following JSX block before schema and AST validation.
|
||||
|
||||
\`\`\`json
|
||||
${JSON.stringify(jsonSchema, null, 2)}
|
||||
\`\`\`
|
||||
|
||||
## Generated component reference
|
||||
${buildComponentReference()}
|
||||
|
||||
## Tested examples
|
||||
${buildExamples()}
|
||||
|
||||
${responseSection}
|
||||
${configSection}
|
||||
## Requested widget
|
||||
Describe what the widget should show and which interactions it needs:
|
||||
`;
|
||||
}
|
||||
|
||||
interface CopyAiPromptButtonProps {
|
||||
rawResponse?: string | null;
|
||||
currentConfig?: Record<string, unknown> | null;
|
||||
@@ -188,7 +22,7 @@ export const CopyAiPromptButton = ({ rawResponse, currentConfig }: CopyAiPromptB
|
||||
const handleCopy = async () => {
|
||||
if (!schema) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(buildAiPrompt(schema, rawResponse, currentConfig));
|
||||
await navigator.clipboard.writeText(buildCustomWidgetAiPrompt(schema, rawResponse, currentConfig));
|
||||
close();
|
||||
showSuccessNotification({ title: t("action.copyAiPrompt"), message: t("notification.aiPromptCopied") });
|
||||
} catch {
|
||||
@@ -200,6 +34,7 @@ export const CopyAiPromptButton = ({ rawResponse, currentConfig }: CopyAiPromptB
|
||||
<Popover opened={opened} onClose={close} width={320} position="bottom" shadow="md" withinPortal>
|
||||
<Popover.Target>
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
leftSection={<IconSparkles size={16} />}
|
||||
onClick={open}
|
||||
@@ -219,7 +54,7 @@ export const CopyAiPromptButton = ({ rawResponse, currentConfig }: CopyAiPromptB
|
||||
{t("notification.aiPromptNoResponse")}
|
||||
</Text>
|
||||
)}
|
||||
<Button leftSection={<IconCopy size={16} />} onClick={() => void handleCopy()} fullWidth>
|
||||
<Button type="button" leftSection={<IconCopy size={16} />} onClick={() => void handleCopy()} fullWidth>
|
||||
{t("notification.aiPromptCopy")}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
@@ -1,31 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Code,
|
||||
Group,
|
||||
Menu,
|
||||
Modal,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { ActionIcon, Menu } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconAlertTriangle, IconCopy, IconDots, IconDownload, IconTrash, IconUpload } from "@tabler/icons-react";
|
||||
import { IconCopy, IconDots, IconDownload, IconTrash, IconUpload } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { getImportReview, parseCustomWidgetClipboard } from "@homarr/custom-widgets/core";
|
||||
import { ImportReviewDialog } from "@homarr/custom-widgets/workbench";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { getImportReview, parseCustomWidgetClipboard } from "./_custom-widget-import-utils";
|
||||
|
||||
const iconProps = { size: 16, stroke: 1.5 };
|
||||
|
||||
@@ -214,80 +202,28 @@ export const ImportCustomWidgetButton = () => {
|
||||
onChange={handleImport}
|
||||
aria-label={t("importReview.fileLabel")}
|
||||
/>
|
||||
<Modal opened={reviewOpened} onClose={closeReview} title={t("importReview.title")} centered size="lg">
|
||||
{pendingImport && review && (
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("importReview.description")}
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="xs">
|
||||
<ImportFact label={t("importReview.name")} value={review.name} />
|
||||
<ImportFact label={t("importReview.origin")} value={review.origin} />
|
||||
<ImportFact label={t("importReview.authentication")} value={review.authType} />
|
||||
<ImportFact label={t("importReview.networkScope")} value={review.networkScope} />
|
||||
</SimpleGrid>
|
||||
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb={6}>
|
||||
{t("importReview.methods")}
|
||||
</Text>
|
||||
<Group gap={6}>
|
||||
{review.methods.map((method) => (
|
||||
<Badge
|
||||
key={method}
|
||||
color={method === "DELETE" ? "red" : method === "GET" ? "blue" : "orange"}
|
||||
variant="light"
|
||||
>
|
||||
{method}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb={6}>
|
||||
{t("importReview.permissions")}
|
||||
</Text>
|
||||
<Group gap={6}>
|
||||
{review.permissions.map((permission) => (
|
||||
<Badge key={permission} color="gray" variant="light">
|
||||
{t(`preview.request.permission.${permission}` as never)}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
{review.hasActions && (
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
|
||||
<Text size="sm" fw={600}>
|
||||
{t("importReview.actionWarning.title")}
|
||||
</Text>
|
||||
<Text size="sm">{t("importReview.actionWarning.description")}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={closeReview} disabled={importMutation.isPending}>
|
||||
{t("importReview.cancel")}
|
||||
</Button>
|
||||
<Button onClick={() => importMutation.mutate(pendingImport as never)} loading={importMutation.isPending}>
|
||||
{t("importReview.confirm")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
<ImportReviewDialog
|
||||
opened={reviewOpened}
|
||||
review={review}
|
||||
pending={importMutation.isPending}
|
||||
onClose={closeReview}
|
||||
onConfirm={() => pendingImport && importMutation.mutate(pendingImport as never)}
|
||||
messages={{
|
||||
title: t("importReview.title"),
|
||||
description: t("importReview.description"),
|
||||
name: t("importReview.name"),
|
||||
origin: t("importReview.origin"),
|
||||
authentication: t("importReview.authentication"),
|
||||
networkScope: t("importReview.networkScope"),
|
||||
methods: t("importReview.methods"),
|
||||
permissions: t("importReview.permissions"),
|
||||
actionWarningTitle: t("importReview.actionWarning.title"),
|
||||
actionWarningDescription: t("importReview.actionWarning.description"),
|
||||
cancel: t("importReview.cancel"),
|
||||
confirm: t("importReview.confirm"),
|
||||
permission: (permission) => t(`preview.request.permission.${permission}` as never),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function ImportFact({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Paper withBorder p="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Code>{value || "—"}</Code>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
const serverToFormFieldMap: Record<string, Record<string, string>> = {
|
||||
statGrid: { items: "statGridItems" },
|
||||
countGrid: { items: "countGridItems" },
|
||||
statusIndicator: { items: "statusItems" },
|
||||
progressBars: { bars: "progressBars" },
|
||||
keyValue: { mappings: "mappings" },
|
||||
table: { columns: "columns", tablePath: "tablePath" },
|
||||
singleValue: { jsonPath: "jsonPath", label: "label", unit: "unit" },
|
||||
raw: { jsonPath: "rawJsonPath" },
|
||||
actionButton: { buttonLabel: "buttonLabel" },
|
||||
customJsx: { template: "template", requests: "requestManifest", networkScope: "networkScope" },
|
||||
};
|
||||
|
||||
export function extractServerErrors(error: unknown, displayType: string): Record<string, string> {
|
||||
const errors: Record<string, string> = {};
|
||||
const trpcError = error as {
|
||||
data?: { zodError?: { fieldErrors?: Record<string, string[]> } };
|
||||
message?: string;
|
||||
};
|
||||
if (trpcError.data?.zodError?.fieldErrors) {
|
||||
for (const [field, messages] of Object.entries(trpcError.data.zodError.fieldErrors)) {
|
||||
if (messages?.[0]) errors[field] = messages[0];
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
try {
|
||||
const issues = JSON.parse(trpcError.message ?? "[]") as Array<{ path: (string | number)[]; message: string }>;
|
||||
const fieldMap = serverToFormFieldMap[displayType] ?? {};
|
||||
for (const issue of issues) {
|
||||
const path = [...issue.path];
|
||||
if (path[0] === "displayConfig") {
|
||||
path.shift();
|
||||
const serverField = String(path[0]);
|
||||
path[0] = fieldMap[serverField] ?? serverField;
|
||||
}
|
||||
errors[path.join(".")] = issue.message;
|
||||
}
|
||||
} catch {
|
||||
// Non-validation server errors are handled by the caller's notification.
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import type { RefObject } from "react";
|
||||
import {
|
||||
Button,
|
||||
Fieldset,
|
||||
Group,
|
||||
PasswordInput,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { IconCode, IconLayoutGrid, IconPlayerPlay } from "@tabler/icons-react";
|
||||
import { IconPicker } from "@homarr/forms-collection";
|
||||
import { CUSTOM_JSX_STARTER } from "@homarr/custom-widgets/core";
|
||||
import {
|
||||
CUSTOM_WIDGET_AUTH_SECRET_FIELDS,
|
||||
CUSTOM_WIDGET_AUTH_USES_HEADER_NAME,
|
||||
} from "@homarr/custom-widgets/workbench";
|
||||
|
||||
import type { DisplayFieldsProps } from "./_display-field-types";
|
||||
import { DISPLAY_TYPE_ICONS, DisplayTypeFields, DisplayTypePicker } from "./_display-type-fields";
|
||||
import classes from "./_custom-widget-form.module.css";
|
||||
|
||||
interface SharedSectionProps extends DisplayFieldsProps {
|
||||
sectionRef: RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
interface FormatSectionProps extends SharedSectionProps {
|
||||
onContinue: () => void;
|
||||
}
|
||||
|
||||
export function FormatSection({ form, t, sectionRef, onContinue }: FormatSectionProps) {
|
||||
return (
|
||||
<section ref={sectionRef} className={classes.formSection} tabIndex={-1}>
|
||||
<ThemeIcon className={classes.sectionMarker} variant="filled" size={40} radius="xl">
|
||||
<IconLayoutGrid size={20} />
|
||||
</ThemeIcon>
|
||||
<Stack gap="xl">
|
||||
<SectionHeading label={t("steps.format.label")} description={t("steps.format.description")} />
|
||||
<Fieldset legend={t("fieldset.general")}>
|
||||
<Stack gap="sm">
|
||||
<TextInput label={t("field.name")} required {...form.getInputProps("name")} />
|
||||
<Textarea label={t("field.description")} {...form.getInputProps("description")} />
|
||||
<IconPicker withAsterisk={false} {...form.getInputProps("iconUrl")} />
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
<Fieldset legend={t("field.displayType")}>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("steps.format.help")}
|
||||
</Text>
|
||||
<DisplayTypePicker
|
||||
value={form.values.displayType}
|
||||
onChange={(value) => {
|
||||
form.setFieldValue("displayType", value);
|
||||
if (value === "customJsx" && !form.values.template.trim())
|
||||
form.setFieldValue("template", CUSTOM_JSX_STARTER);
|
||||
}}
|
||||
t={t}
|
||||
/>
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
<ContinueButton label={t("steps.continueTo", { step: t("steps.connection.label") })} onClick={onContinue} />
|
||||
</Stack>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConnectionSectionProps extends SharedSectionProps {
|
||||
isTesting: boolean;
|
||||
onTest: () => void;
|
||||
onContinue: () => void;
|
||||
}
|
||||
|
||||
export function ConnectionSection({ form, t, sectionRef, isTesting, onTest, onContinue }: ConnectionSectionProps) {
|
||||
const secretFields = CUSTOM_WIDGET_AUTH_SECRET_FIELDS[form.values.authType] ?? [];
|
||||
const authTypeOptions = ["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"].map((value) => ({
|
||||
value,
|
||||
label: t(`authType.${value}` as never),
|
||||
}));
|
||||
const handleAuthTypeChange = (value: string) => {
|
||||
form.setFieldValue("authType", value);
|
||||
form.setFieldValue(
|
||||
"secrets",
|
||||
(CUSTOM_WIDGET_AUTH_SECRET_FIELDS[value] ?? []).map((field) => ({
|
||||
kind: field.kind,
|
||||
value: form.values.secrets.find((secret) => secret.kind === field.kind)?.value ?? "",
|
||||
})),
|
||||
);
|
||||
};
|
||||
return (
|
||||
<section ref={sectionRef} className={classes.formSection} tabIndex={-1}>
|
||||
<ThemeIcon className={classes.sectionMarker} variant="light" size={40} radius="xl">
|
||||
<IconPlayerPlay size={20} />
|
||||
</ThemeIcon>
|
||||
<Stack gap="xl">
|
||||
<SectionHeading label={t("steps.connection.label")} description={t("steps.connection.description")} />
|
||||
<Fieldset legend={t("fieldset.connection")}>
|
||||
<Stack gap="sm">
|
||||
<Group align="end" wrap="wrap" gap="xs">
|
||||
<Select
|
||||
label={t("field.method")}
|
||||
data={["GET", "POST", "PUT", "DELETE", "PATCH"].map((value) => ({
|
||||
value,
|
||||
label: t(`method.${value}` as never),
|
||||
}))}
|
||||
w={110}
|
||||
{...form.getInputProps("method")}
|
||||
allowDeselect={false}
|
||||
disabled={form.values.displayType === "customJsx" && form.values.jsxApiVersion === "2"}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("field.url")}
|
||||
required
|
||||
placeholder={t("placeholder.url")}
|
||||
style={{ flex: "1 1 260px" }}
|
||||
{...form.getInputProps("url")}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="light"
|
||||
leftSection={<IconPlayerPlay size={16} />}
|
||||
onClick={onTest}
|
||||
loading={isTesting}
|
||||
disabled={!form.values.url || form.values.method !== "GET"}
|
||||
>
|
||||
{t("preview.test")}
|
||||
</Button>
|
||||
</Group>
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb={4}>
|
||||
{t("field.authType")}
|
||||
</Text>
|
||||
<Select
|
||||
hiddenFrom="sm"
|
||||
value={form.values.authType}
|
||||
data={authTypeOptions}
|
||||
onChange={(value) => handleAuthTypeChange(value ?? "none")}
|
||||
allowDeselect={false}
|
||||
aria-label={t("field.authType")}
|
||||
/>
|
||||
<SegmentedControl
|
||||
visibleFrom="sm"
|
||||
fullWidth
|
||||
value={form.values.authType}
|
||||
data={authTypeOptions}
|
||||
onChange={handleAuthTypeChange}
|
||||
/>
|
||||
</div>
|
||||
{CUSTOM_WIDGET_AUTH_USES_HEADER_NAME[form.values.authType] && (
|
||||
<TextInput
|
||||
label={t("field.headerName")}
|
||||
placeholder={t("placeholder.headerName")}
|
||||
{...form.getInputProps("headerName")}
|
||||
/>
|
||||
)}
|
||||
{secretFields.map((field) => {
|
||||
const index = form.values.secrets.findIndex((secret) => secret.kind === field.kind);
|
||||
const secret = form.values.secrets[index];
|
||||
if (index === -1 || !secret) return null;
|
||||
const props = {
|
||||
label: t(`secret.${field.labelKey}` as never),
|
||||
placeholder: secret.hasValue && !secret.value ? t("secret.savedPlaceholder" as never) : undefined,
|
||||
...form.getInputProps(`secrets.${index}.value`),
|
||||
};
|
||||
return field.isPassword ? (
|
||||
<PasswordInput key={field.kind} {...props} />
|
||||
) : (
|
||||
<TextInput key={field.kind} {...props} />
|
||||
);
|
||||
})}
|
||||
{form.values.method !== "GET" && (
|
||||
<Textarea label={t("field.requestBody")} minRows={3} {...form.getInputProps("requestBody")} />
|
||||
)}
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
<ContinueButton label={t("steps.continueTo", { step: t("steps.configure.label") })} onClick={onContinue} />
|
||||
</Stack>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConfigureSection({ form, t, previewJson, sectionRef }: SharedSectionProps) {
|
||||
const SelectedIcon = DISPLAY_TYPE_ICONS[form.values.displayType as keyof typeof DISPLAY_TYPE_ICONS];
|
||||
return (
|
||||
<section ref={sectionRef} className={classes.formSection} tabIndex={-1}>
|
||||
<ThemeIcon className={classes.sectionMarker} variant="light" size={40} radius="xl">
|
||||
<IconCode size={20} />
|
||||
</ThemeIcon>
|
||||
<Stack gap="xl">
|
||||
<SectionHeading label={t("steps.configure.label")} description={t("steps.configure.description")} />
|
||||
<Fieldset legend={t("fieldset.display")}>
|
||||
<Stack gap="sm">
|
||||
<Group gap="xs">
|
||||
<ThemeIcon variant="light" size="lg">
|
||||
{SelectedIcon ? <SelectedIcon size={20} /> : null}
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={600}>{t(`displayType.${form.values.displayType}` as never)}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(`displayTypeDescription.${form.values.displayType}` as never)}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<DisplayTypeFields form={form} t={t} previewJson={previewJson} />
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
</Stack>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHeading({ label, description }: { label: string; description: string }) {
|
||||
return (
|
||||
<div>
|
||||
<Text fw={700} size="lg">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{description}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContinueButton({ label, onClick }: { label: string; onClick: () => void }) {
|
||||
return (
|
||||
<Group justify="flex-end">
|
||||
<Button type="button" variant="light" size="md" onClick={onClick}>
|
||||
{label}
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
-44
@@ -1,44 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseCustomWidgetClipboard } from "./_custom-widget-import-utils";
|
||||
|
||||
const widget = {
|
||||
$schema: "homarr-custom-widget-v3",
|
||||
name: "Example",
|
||||
url: "https://example.com/api",
|
||||
authType: "none",
|
||||
method: "GET",
|
||||
displayType: "customJsx",
|
||||
displayConfig: {
|
||||
type: "customJsx",
|
||||
jsxApiVersion: 2,
|
||||
networkScope: "public",
|
||||
requests: [],
|
||||
template: "__HOMARR_TEMPLATE__",
|
||||
},
|
||||
};
|
||||
|
||||
describe("parseCustomWidgetClipboard", () => {
|
||||
it("keeps supporting raw JSON imports", () => {
|
||||
const result = parseCustomWidgetClipboard(JSON.stringify({ ...widget, displayType: "raw" }));
|
||||
expect(result?.name).toBe("Example");
|
||||
});
|
||||
|
||||
it("combines a JSON metadata block with a readable JSX block", () => {
|
||||
const result = parseCustomWidgetClipboard(`Here is the widget:
|
||||
|
||||
\`\`\`json
|
||||
${JSON.stringify(widget, null, 2)}
|
||||
\`\`\`
|
||||
|
||||
\`\`\`jsx
|
||||
<Stack gap="sm">
|
||||
<Text>{data.name}</Text>
|
||||
</Stack>
|
||||
\`\`\``);
|
||||
|
||||
expect((result?.displayConfig as Record<string, unknown> | undefined)?.template).toBe(
|
||||
'<Stack gap="sm">\n <Text>{data.name}</Text>\n</Stack>',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,194 +0,0 @@
|
||||
export interface ImportReview {
|
||||
name: string;
|
||||
origin: string;
|
||||
authType: string;
|
||||
networkScope: string;
|
||||
methods: string[];
|
||||
permissions: string[];
|
||||
hasActions: boolean;
|
||||
}
|
||||
|
||||
export function parseCustomWidgetClipboard(text: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const jsonBlock = text.match(/```(?:json)?\s*([\s\S]*?)```/iu)?.[1];
|
||||
const jsxBlock = text.match(/```(?:jsx|tsx)\s*([\s\S]*?)```/iu)?.[1]?.trim();
|
||||
const value: unknown = JSON.parse(jsonBlock ?? text);
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const widget = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof widget.name !== "string" ||
|
||||
typeof widget.url !== "string" ||
|
||||
typeof widget.displayType !== "string" ||
|
||||
!widget.displayConfig ||
|
||||
typeof widget.displayConfig !== "object" ||
|
||||
Array.isArray(widget.displayConfig)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (jsxBlock && widget.displayType === "customJsx") {
|
||||
const displayConfig = widget.displayConfig as Record<string, unknown>;
|
||||
if (displayConfig.template === "__HOMARR_TEMPLATE__" || !displayConfig.template) {
|
||||
displayConfig.template = jsxBlock;
|
||||
}
|
||||
}
|
||||
return widget;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getImportReview(value: Record<string, unknown> | null): ImportReview | null {
|
||||
if (!value) return null;
|
||||
const displayConfig = value.displayConfig as Record<string, unknown>;
|
||||
const requests = Array.isArray(displayConfig.requests)
|
||||
? displayConfig.requests.filter(
|
||||
(request): request is Record<string, unknown> =>
|
||||
request !== null && typeof request === "object" && !Array.isArray(request),
|
||||
)
|
||||
: [];
|
||||
const baseMethod = typeof value.method === "string" ? value.method : "GET";
|
||||
const methods = [
|
||||
...new Set([
|
||||
baseMethod,
|
||||
...requests.map((request) => request.method).filter((method): method is string => typeof method === "string"),
|
||||
]),
|
||||
];
|
||||
const permissions = [
|
||||
...new Set(
|
||||
requests
|
||||
.map((request) => request.minimumBoardPermission)
|
||||
.filter(
|
||||
(permission): permission is string =>
|
||||
permission === "view" || permission === "modify" || permission === "full",
|
||||
),
|
||||
),
|
||||
];
|
||||
if (permissions.length === 0) permissions.push(baseMethod === "GET" ? "view" : "modify");
|
||||
|
||||
let origin = value.url;
|
||||
try {
|
||||
origin = new URL(value.url as string).origin;
|
||||
} catch {
|
||||
// The server returns the localized validation error after confirmation.
|
||||
}
|
||||
|
||||
return {
|
||||
name: value.name as string,
|
||||
origin: String(origin),
|
||||
authType: typeof value.authType === "string" ? value.authType : "none",
|
||||
networkScope: typeof displayConfig.networkScope === "string" ? displayConfig.networkScope : "legacy",
|
||||
methods,
|
||||
permissions,
|
||||
hasActions: methods.some((method) => method !== "GET"),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDisplayFormValues(displayType: string, config: Record<string, unknown>) {
|
||||
const base = {
|
||||
displayType,
|
||||
jsonPath: "$",
|
||||
label: "",
|
||||
unit: "",
|
||||
valueSize: "lg",
|
||||
labelPosition: "below",
|
||||
mappings: [{ label: "", jsonPath: "$", unit: "" }],
|
||||
kvLayout: "list",
|
||||
kvColumns: 2,
|
||||
tablePath: "$",
|
||||
columns: [{ header: "", jsonPath: "$" }],
|
||||
striped: true,
|
||||
compact: false,
|
||||
statGridItems: [{ label: "", jsonPath: "$", unit: "", color: "blue" }],
|
||||
statGridColumns: 2,
|
||||
cardStyle: "filled",
|
||||
progressBars: [{ label: "", valuePath: "$", maxPath: "", unit: "", color: "blue" }],
|
||||
showPercentage: true,
|
||||
barSize: "md",
|
||||
statusItems: [{ label: "", jsonPath: "$", goodValues: "online,true" }],
|
||||
statusLayout: "list",
|
||||
dotSize: "md",
|
||||
countGridItems: [{ label: "", jsonPath: "$", unit: "" }],
|
||||
countGridColumns: 2,
|
||||
countValueSize: "md",
|
||||
rawJsonPath: "$",
|
||||
rawMaxHeight: 300,
|
||||
buttonLabel: "Execute",
|
||||
buttonColor: "blue",
|
||||
confirmText: "",
|
||||
successMessage: "",
|
||||
template: CUSTOM_JSX_STARTER,
|
||||
jsxApiVersion: "2",
|
||||
networkScope: "public",
|
||||
requestManifest: "[]",
|
||||
};
|
||||
|
||||
const typeOverrides: Record<string, () => Record<string, unknown>> = {
|
||||
singleValue: () => ({
|
||||
jsonPath: (config.jsonPath as string) ?? "$",
|
||||
label: (config.label as string) ?? "",
|
||||
unit: (config.unit as string) ?? "",
|
||||
valueSize: (config.valueSize as string) ?? "lg",
|
||||
labelPosition: (config.labelPosition as string) ?? "below",
|
||||
}),
|
||||
keyValue: () => ({
|
||||
mappings: (config.mappings as typeof base.mappings) ?? base.mappings,
|
||||
kvLayout: (config.layout as string) ?? "list",
|
||||
kvColumns: (config.columns as number) ?? 2,
|
||||
}),
|
||||
table: () => ({
|
||||
tablePath: (config.tablePath as string) ?? "$",
|
||||
columns: (config.columns as typeof base.columns) ?? base.columns,
|
||||
striped: (config.striped as boolean) ?? true,
|
||||
compact: (config.compact as boolean) ?? false,
|
||||
}),
|
||||
statGrid: () => ({
|
||||
statGridItems: (config.items as typeof base.statGridItems) ?? base.statGridItems,
|
||||
statGridColumns: (config.columns as number) ?? 2,
|
||||
cardStyle: (config.cardStyle as string) ?? "filled",
|
||||
}),
|
||||
progressBars: () => ({
|
||||
progressBars: (
|
||||
(config.bars as Array<{ label: string; valuePath: string; maxPath?: string; unit: string; color?: string }>) ??
|
||||
[]
|
||||
).map((bar) => ({ ...bar, maxPath: bar.maxPath ?? "", color: bar.color ?? "blue" })),
|
||||
showPercentage: (config.showPercentage as boolean) ?? true,
|
||||
barSize: (config.barSize as string) ?? "md",
|
||||
}),
|
||||
statusIndicator: () => ({
|
||||
statusItems: ((config.items as Array<{ label: string; jsonPath: string; goodValues: string[] }>) ?? []).map(
|
||||
(item) => ({ ...item, goodValues: item.goodValues.join(", ") }),
|
||||
),
|
||||
statusLayout: (config.layout as string) ?? "list",
|
||||
dotSize: (config.dotSize as string) ?? "md",
|
||||
}),
|
||||
countGrid: () => ({
|
||||
countGridItems: (config.items as typeof base.countGridItems) ?? base.countGridItems,
|
||||
countGridColumns: (config.columns as number) ?? 2,
|
||||
countValueSize: (config.valueSize as string) ?? "md",
|
||||
}),
|
||||
raw: () => ({
|
||||
rawJsonPath: (config.jsonPath as string) ?? "$",
|
||||
rawMaxHeight: (config.maxHeight as number) ?? 300,
|
||||
}),
|
||||
actionButton: () => ({
|
||||
buttonLabel: (config.buttonLabel as string) ?? "Execute",
|
||||
buttonColor: (config.buttonColor as string) ?? "blue",
|
||||
confirmText: (config.confirmText as string) ?? "",
|
||||
successMessage: (config.successMessage as string) ?? "",
|
||||
}),
|
||||
customJsx: () => ({
|
||||
template:
|
||||
typeof config.template === "string" && config.template.trim().length > 0 ? config.template : CUSTOM_JSX_STARTER,
|
||||
jsxApiVersion: String((config.jsxApiVersion as number | undefined) ?? 1),
|
||||
networkScope: (config.networkScope as string) ?? "public",
|
||||
requestManifest: JSON.stringify((config.requests as unknown[] | undefined) ?? [], null, 2),
|
||||
}),
|
||||
};
|
||||
|
||||
return { ...base, ...typeOverrides[displayType]?.() };
|
||||
}
|
||||
import { customJsxExamples } from "@homarr/definitions";
|
||||
|
||||
export const CUSTOM_JSX_STARTER =
|
||||
customJsxExamples.find((example) => example.id === "simple-metric")?.template ??
|
||||
'<Stack align="center" justify="center" h="100%"><Text size="xs" c="dimmed">Connect data to begin</Text></Stack>';
|
||||
@@ -1,78 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { Component as ReactComponent, useEffect, useMemo, useState } from "react";
|
||||
import type { ErrorInfo, ReactNode } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Code,
|
||||
Collapse,
|
||||
CopyButton,
|
||||
getTreeExpandedState,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Popover,
|
||||
ScrollArea,
|
||||
SegmentedControl,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Switch,
|
||||
Tabs,
|
||||
Text,
|
||||
Textarea,
|
||||
Title,
|
||||
Tree,
|
||||
useTree,
|
||||
} from "@mantine/core";
|
||||
import type { RenderTreeNodePayload, TreeNodeData } from "@mantine/core";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconClipboard,
|
||||
IconCode,
|
||||
IconCopy,
|
||||
IconExternalLink,
|
||||
IconInfoCircle,
|
||||
IconNetwork,
|
||||
IconPlayerPlay,
|
||||
IconPlus,
|
||||
} from "@tabler/icons-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Card, Stack, Tabs } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type {
|
||||
CustomWidgetAuthType,
|
||||
CustomWidgetDisplayType,
|
||||
CustomWidgetMethod,
|
||||
CustomWidgetSecretKind,
|
||||
DisplayConfig,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import { extractDisplayData } from "@homarr/custom-widgets/core";
|
||||
import {
|
||||
analyzeJsxTemplate,
|
||||
getPreviewLegacyMethods,
|
||||
getPreviewNamedRequests,
|
||||
PreviewDiagnosticsPanel,
|
||||
PreviewErrorBoundary,
|
||||
PreviewHeader,
|
||||
PreviewRequestPanel,
|
||||
PreviewResponsePanel,
|
||||
redactPreviewUrl,
|
||||
} from "@homarr/custom-widgets/workbench";
|
||||
import { CUSTOM_JSX_METHOD_COLORS } from "@homarr/widgets/custom-api/custom-jsx-display";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { displayComponents } from "@homarr/widgets/custom-api/component";
|
||||
import { extractDisplayData } from "@homarr/widgets/custom-api/extract-display-data";
|
||||
|
||||
import { analyzeJsxTemplate } from "./_code-editor";
|
||||
import { PreviewWidgetPanel } from "./_preview-widget-panel";
|
||||
|
||||
interface PreviewInput {
|
||||
export interface PreviewInput {
|
||||
url: string;
|
||||
method: string;
|
||||
authType: string;
|
||||
method: CustomWidgetMethod;
|
||||
authType: CustomWidgetAuthType;
|
||||
headerName?: string;
|
||||
requestBody?: string;
|
||||
displayType: string;
|
||||
displayConfig: Record<string, unknown>;
|
||||
secrets: Array<{ kind: string; value: string }>;
|
||||
displayType: CustomWidgetDisplayType;
|
||||
displayConfig: DisplayConfig;
|
||||
secrets: Array<{ kind: CustomWidgetSecretKind; value: string }>;
|
||||
definitionId?: string;
|
||||
}
|
||||
|
||||
interface NamedRequestSummary {
|
||||
id: string;
|
||||
kind: "query" | "action";
|
||||
method: string;
|
||||
pathTemplate: string;
|
||||
minimumBoardPermission?: string;
|
||||
}
|
||||
|
||||
export interface PreviewFetchResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
@@ -98,94 +65,6 @@ interface CustomWidgetPreviewProps {
|
||||
|
||||
const methodColor = (method: string) => CUSTOM_JSX_METHOD_COLORS[method] ?? "gray";
|
||||
|
||||
function journalEntryColor(entry: { simulated: boolean; status: number | null }): string {
|
||||
if (entry.simulated) return "yellow";
|
||||
if (entry.status !== null && entry.status >= 200 && entry.status < 400) return "green";
|
||||
return "red";
|
||||
}
|
||||
|
||||
const redactUrl = (value: string) => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
for (const key of url.searchParams.keys()) url.searchParams.set(key, "redacted");
|
||||
return url.toString();
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const getNamedRequests = (displayConfig: Record<string, unknown>): NamedRequestSummary[] => {
|
||||
if (!Array.isArray(displayConfig.requests)) return [];
|
||||
return displayConfig.requests.filter(
|
||||
(request): request is NamedRequestSummary =>
|
||||
request !== null &&
|
||||
typeof request === "object" &&
|
||||
"id" in request &&
|
||||
typeof request.id === "string" &&
|
||||
"kind" in request &&
|
||||
(request.kind === "query" || request.kind === "action") &&
|
||||
"method" in request &&
|
||||
typeof request.method === "string" &&
|
||||
"pathTemplate" in request &&
|
||||
typeof request.pathTemplate === "string",
|
||||
);
|
||||
};
|
||||
|
||||
const getLegacyMethods = (template: string) => {
|
||||
const methods = new Set<string>();
|
||||
for (const match of template.matchAll(/<(?:SubFetch|ActionButton|ToggleSwitch)\b([^>]*)>/giu)) {
|
||||
const method = match[1]?.match(/\bmethod\s*=\s*["'](GET|POST|PUT|PATCH|DELETE)["']/iu)?.[1];
|
||||
const defaultMethod = match[0].startsWith("<SubFetch") ? "GET" : "POST";
|
||||
methods.add(method?.toUpperCase() ?? defaultMethod);
|
||||
}
|
||||
return [...methods];
|
||||
};
|
||||
|
||||
interface PreviewErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
retryLabel: string;
|
||||
}
|
||||
|
||||
interface PreviewErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
class PreviewErrorBoundary extends ReactComponent<PreviewErrorBoundaryProps, PreviewErrorBoundaryState> {
|
||||
state: PreviewErrorBoundaryState = { hasError: false };
|
||||
|
||||
static getDerivedStateFromError(): PreviewErrorBoundaryState {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(_error: Error, _info: ErrorInfo) {
|
||||
// The renderer exposes redacted diagnostics. Avoid logging imported template data here.
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.hasError) return this.props.children;
|
||||
return (
|
||||
<Card p="xl" role="alert">
|
||||
<Stack align="center" gap="sm" py="xl">
|
||||
<IconAlertTriangle size={28} color="var(--mantine-color-red-6)" />
|
||||
<div>
|
||||
<Text fw={600} ta="center">
|
||||
{this.props.title}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ta="center" maw={480}>
|
||||
{this.props.description}
|
||||
</Text>
|
||||
</div>
|
||||
<Button variant="light" onClick={() => this.setState({ hasError: false })}>
|
||||
{this.props.retryLabel}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function CustomWidgetPreview(props: CustomWidgetPreviewProps) {
|
||||
const t = useScopedI18n("customWidget.preview");
|
||||
return (
|
||||
@@ -216,8 +95,7 @@ function CustomWidgetPreviewContent({
|
||||
const t = useScopedI18n("customWidget");
|
||||
const editorT = useScopedI18n("customWidget.editor");
|
||||
const [previewSize, setPreviewSize] = useState("standard");
|
||||
const [sampleEditorOpen, setSampleEditorOpen] = useState(false);
|
||||
const [sampleDraft, setSampleDraft] = useState("");
|
||||
const displayConfig = formValues.displayConfig as DisplayConfig & Record<string, unknown>;
|
||||
const previewSessionId = fetchResult?.previewSession?.id ?? "";
|
||||
const journalQuery = clientApi.customWidget.previewJournal.useQuery(
|
||||
{ sessionId: previewSessionId },
|
||||
@@ -225,7 +103,7 @@ function CustomWidgetPreviewContent({
|
||||
);
|
||||
|
||||
const displayData = useMemo((): Record<string, unknown> | null => {
|
||||
const { displayType, displayConfig } = formValues;
|
||||
const { displayType } = formValues;
|
||||
if (displayType === "actionButton") {
|
||||
return extractDisplayData(null, displayType, displayConfig) as Record<string, unknown>;
|
||||
}
|
||||
@@ -238,24 +116,24 @@ function CustomWidgetPreviewContent({
|
||||
previewLiveActions: fetchResult.previewSession.liveActions,
|
||||
}
|
||||
: extracted;
|
||||
}, [fetchResult, cachedJson, formValues]);
|
||||
}, [fetchResult, cachedJson, formValues, displayConfig]);
|
||||
|
||||
const namedRequests = useMemo(() => getNamedRequests(formValues.displayConfig), [formValues.displayConfig]);
|
||||
const namedRequests = useMemo(() => getPreviewNamedRequests(displayConfig), [displayConfig]);
|
||||
const httpMethods = useMemo(() => {
|
||||
if (formValues.displayType !== "customJsx") return [];
|
||||
if (namedRequests.length > 0) return [...new Set(namedRequests.map((request) => request.method))];
|
||||
return getLegacyMethods((formValues.displayConfig.template as string) ?? "");
|
||||
}, [formValues.displayType, formValues.displayConfig.template, namedRequests]);
|
||||
return getPreviewLegacyMethods((displayConfig.template as string) ?? "");
|
||||
}, [formValues.displayType, displayConfig.template, namedRequests]);
|
||||
const hasNamedActions = namedRequests.some((request) => request.kind === "action");
|
||||
const templateDiagnostics = useMemo(
|
||||
() =>
|
||||
formValues.displayType === "customJsx"
|
||||
? analyzeJsxTemplate((formValues.displayConfig.template as string) ?? "", {
|
||||
apiVersion: formValues.displayConfig.jsxApiVersion === 2 ? 2 : 1,
|
||||
? analyzeJsxTemplate((displayConfig.template as string) ?? "", {
|
||||
apiVersion: displayConfig.jsxApiVersion === 2 ? 2 : 1,
|
||||
requestIds: namedRequests.map((request) => request.id),
|
||||
})
|
||||
: [],
|
||||
[formValues.displayConfig, formValues.displayType, namedRequests],
|
||||
[displayConfig, formValues.displayType, namedRequests],
|
||||
);
|
||||
|
||||
const handleTest = () => {
|
||||
@@ -264,131 +142,40 @@ function CustomWidgetPreviewContent({
|
||||
onTest();
|
||||
};
|
||||
|
||||
const PREVIEW_HEIGHTS: Record<string, number> = { compact: 240, standard: 360, wide: 520 };
|
||||
const previewHeight = PREVIEW_HEIGHTS[previewSize] ?? 360;
|
||||
const responseJson = cachedJson == null ? "" : JSON.stringify(cachedJson, null, 2);
|
||||
const sampleError = useMemo(() => {
|
||||
if (!sampleDraft.trim()) return null;
|
||||
try {
|
||||
JSON.parse(sampleDraft);
|
||||
return null;
|
||||
} catch {
|
||||
return t("preview.response.invalidSample");
|
||||
}
|
||||
}, [sampleDraft, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sampleEditorOpen) setSampleDraft(responseJson);
|
||||
}, [responseJson, sampleEditorOpen]);
|
||||
|
||||
const openSampleEditor = () => {
|
||||
setSampleDraft(responseJson || JSON.stringify({ name: "Sample service", status: "online", value: 42 }, null, 2));
|
||||
setSampleEditorOpen(true);
|
||||
};
|
||||
|
||||
const applySampleData = () => {
|
||||
if (!sampleDraft.trim() || sampleError) return;
|
||||
onSampleDataChange?.(JSON.parse(sampleDraft) as unknown);
|
||||
setSampleEditorOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card withBorder p="lg">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Title order={5}>{t("preview.title")}</Title>
|
||||
{httpMethods.length > 0 && (
|
||||
<Popover width={300} position="bottom" withinPortal shadow="md">
|
||||
<Popover.Target>
|
||||
<Button size="compact-xs" variant="subtle" color="gray" leftSection={<IconNetwork size={14} />}>
|
||||
{t("preview.capabilities.interactive")}
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
{t("preview.capabilities.title")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("preview.capabilities.description")}
|
||||
</Text>
|
||||
<Group gap={4}>
|
||||
{httpMethods.map((method) => (
|
||||
<Badge key={method} size="xs" color={methodColor(method)} variant="light">
|
||||
{method}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
{hasNamedActions && (
|
||||
<Stack gap={4} mt="xs">
|
||||
<Switch
|
||||
label={t("preview.capabilities.liveActions")}
|
||||
description={
|
||||
fetchResult?.previewSession
|
||||
? t("preview.capabilities.liveActionsDescription")
|
||||
: t("preview.capabilities.runTestFirst")
|
||||
}
|
||||
checked={fetchResult?.previewSession?.liveActions ?? false}
|
||||
disabled={
|
||||
!fetchResult?.previewSession || !onSetPreviewLiveActions || isUpdatingPreviewActions
|
||||
}
|
||||
onChange={(event) => onSetPreviewLiveActions?.(event.currentTarget.checked)}
|
||||
/>
|
||||
{!fetchResult?.previewSession?.liveActions && (
|
||||
<Badge size="xs" color="yellow" variant="light" style={{ alignSelf: "flex-start" }}>
|
||||
{t("preview.capabilities.simulated")}
|
||||
</Badge>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)}
|
||||
</Group>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={isTesting ? <Loader size={14} /> : <IconPlayerPlay size={14} />}
|
||||
onClick={handleTest}
|
||||
loading={isTesting}
|
||||
disabled={formValues.method !== "GET" || !formValues.url}
|
||||
>
|
||||
{t("preview.test")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{formValues.method !== "GET" && (
|
||||
<Alert color="yellow" variant="light" p="xs" icon={<IconInfoCircle size={15} />}>
|
||||
<Text size="xs">{t("preview.mutationDisabled")}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
{isSampleStale && (
|
||||
<Alert color="yellow" variant="light" p="xs" icon={<IconAlertTriangle size={15} />}>
|
||||
<Text size="xs" fw={600}>
|
||||
{t("preview.stale.title")}
|
||||
</Text>
|
||||
<Text size="xs">{t("preview.stale.description")}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
{testError && (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={16} />} p="xs">
|
||||
<Text size="xs">{testError}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
{fetchResult && !fetchResult.success && (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={16} />} p="xs">
|
||||
<Text size="xs" fw={500}>
|
||||
{fetchResult.error}
|
||||
</Text>
|
||||
{fetchResult.responseInfo && (
|
||||
<Badge size="xs" color="red" variant="light" mt={4}>
|
||||
{fetchResult.responseInfo.status} {fetchResult.responseInfo.statusText}
|
||||
</Badge>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
<PreviewHeader
|
||||
method={formValues.method}
|
||||
url={formValues.url}
|
||||
methods={httpMethods}
|
||||
hasNamedActions={hasNamedActions}
|
||||
hasPreviewSession={Boolean(fetchResult?.previewSession)}
|
||||
liveActions={fetchResult?.previewSession?.liveActions ?? false}
|
||||
isUpdatingLiveActions={isUpdatingPreviewActions}
|
||||
isTesting={isTesting}
|
||||
isSampleStale={isSampleStale}
|
||||
testError={testError}
|
||||
responseError={fetchResult?.success === false ? fetchResult.error : undefined}
|
||||
responseStatus={fetchResult?.success === false ? fetchResult.responseInfo : undefined}
|
||||
onTest={handleTest}
|
||||
onSetLiveActions={onSetPreviewLiveActions}
|
||||
methodColor={methodColor}
|
||||
messages={{
|
||||
title: t("preview.title"),
|
||||
interactive: t("preview.capabilities.interactive"),
|
||||
capabilitiesTitle: t("preview.capabilities.title"),
|
||||
capabilitiesDescription: t("preview.capabilities.description"),
|
||||
liveActions: t("preview.capabilities.liveActions"),
|
||||
liveActionsDescription: t("preview.capabilities.liveActionsDescription"),
|
||||
runTestFirst: t("preview.capabilities.runTestFirst"),
|
||||
simulated: t("preview.capabilities.simulated"),
|
||||
test: t("preview.test"),
|
||||
mutationDisabled: t("preview.mutationDisabled"),
|
||||
staleTitle: t("preview.stale.title"),
|
||||
staleDescription: t("preview.stale.description"),
|
||||
}}
|
||||
/>
|
||||
|
||||
<Tabs defaultValue="widget" keepMounted={false}>
|
||||
<Tabs.List grow>
|
||||
@@ -399,436 +186,92 @@ function CustomWidgetPreviewContent({
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="widget" pt="sm">
|
||||
<Stack gap="xs">
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
fullWidth
|
||||
value={previewSize}
|
||||
onChange={setPreviewSize}
|
||||
data={(["compact", "standard", "wide"] as const).map((value) => ({
|
||||
value,
|
||||
label: t(`preview.size.${value}` as never),
|
||||
}))}
|
||||
/>
|
||||
{displayData && (formValues.displayType === "actionButton" || fetchResult?.success) ? (
|
||||
<>
|
||||
{fetchResult?.success && (
|
||||
<Badge
|
||||
size="xs"
|
||||
color={fetchResult.responseInfo ? "green" : "gray"}
|
||||
variant="light"
|
||||
style={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
{fetchResult.responseInfo
|
||||
? `${fetchResult.responseInfo.status} ${fetchResult.responseInfo.statusText}`
|
||||
: t("preview.response.localSample")}
|
||||
</Badge>
|
||||
)}
|
||||
<Paper withBorder p="xs" h={previewHeight} style={{ overflow: "auto" }}>
|
||||
<PreviewDisplay data={displayData} />
|
||||
</Paper>
|
||||
</>
|
||||
) : (
|
||||
<Center h={previewHeight}>
|
||||
<Stack gap={4} align="center" maw={280}>
|
||||
<IconPlayerPlay size={22} color="var(--mantine-color-dimmed)" />
|
||||
<Text size="sm" fw={500} ta="center">
|
||||
{t("preview.empty.title")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
{t("preview.hint")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
)}
|
||||
</Stack>
|
||||
<PreviewWidgetPanel
|
||||
data={displayData}
|
||||
displayType={formValues.displayType}
|
||||
success={fetchResult?.success ?? false}
|
||||
responseInfo={fetchResult?.responseInfo ?? null}
|
||||
previewSize={previewSize}
|
||||
onPreviewSizeChange={setPreviewSize}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="response" pt="sm">
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Text size="xs" c="dimmed">
|
||||
{responseJson ? t("preview.response.sampleHint") : t("preview.response.empty")}
|
||||
</Text>
|
||||
<Group gap={4}>
|
||||
{onSampleDataChange && (
|
||||
<Button size="compact-xs" variant="light" onClick={openSampleEditor}>
|
||||
{responseJson ? t("preview.response.editSample") : t("preview.response.addSample")}
|
||||
</Button>
|
||||
)}
|
||||
{responseJson && (
|
||||
<CopyButton value={responseJson}>
|
||||
{({ copied, copy }) => (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
|
||||
onClick={copy}
|
||||
>
|
||||
{copied ? t("preview.response.copied") : t("preview.response.copy")}
|
||||
</Button>
|
||||
)}
|
||||
</CopyButton>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
<Collapse expanded={sampleEditorOpen}>
|
||||
<Paper p="sm" bg="var(--mantine-color-default-hover)">
|
||||
<Stack gap="sm">
|
||||
<Textarea
|
||||
label={t("preview.response.sampleLabel")}
|
||||
description={t("preview.response.sampleDescription")}
|
||||
value={sampleDraft}
|
||||
onChange={(event) => setSampleDraft(event.currentTarget.value)}
|
||||
error={sampleError}
|
||||
autosize
|
||||
minRows={8}
|
||||
maxRows={18}
|
||||
styles={{ input: { fontFamily: "var(--mantine-font-family-monospace)" } }}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" size="xs" onClick={() => setSampleEditorOpen(false)}>
|
||||
{t("preview.response.cancelSample")}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={applySampleData}
|
||||
disabled={!sampleDraft.trim() || Boolean(sampleError)}
|
||||
>
|
||||
{t("preview.response.applySample")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Collapse>
|
||||
{responseJson ? (
|
||||
<>
|
||||
<ScrollArea h={360} type="auto">
|
||||
<ResponseTree value={cachedJson} onInsertDataPath={onInsertDataPath} />
|
||||
</ScrollArea>
|
||||
{fetchResult?.rawResponse && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconExternalLink size={14} />}
|
||||
onClick={() => {
|
||||
const blob = new Blob([fetchResult.rawResponse ?? ""], { type: "application/json" });
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
window.open(blobUrl);
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
|
||||
}}
|
||||
>
|
||||
{t("preview.rawResponse")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<EmptyPreview icon={<IconCode size={22} />} text={t("preview.response.empty")} />
|
||||
)}
|
||||
</Stack>
|
||||
<PreviewResponsePanel
|
||||
value={cachedJson}
|
||||
rawResponse={fetchResult?.rawResponse}
|
||||
onInsertDataPath={onInsertDataPath}
|
||||
onSampleDataChange={onSampleDataChange}
|
||||
messages={{
|
||||
empty: t("preview.response.empty"),
|
||||
sampleHint: t("preview.response.sampleHint"),
|
||||
editSample: t("preview.response.editSample"),
|
||||
addSample: t("preview.response.addSample"),
|
||||
copied: t("preview.response.copied"),
|
||||
copy: t("preview.response.copy"),
|
||||
sampleLabel: t("preview.response.sampleLabel"),
|
||||
sampleDescription: t("preview.response.sampleDescription"),
|
||||
invalidSample: t("preview.response.invalidSample"),
|
||||
cancelSample: t("preview.response.cancelSample"),
|
||||
applySample: t("preview.response.applySample"),
|
||||
copyPath: t("preview.response.copyPath"),
|
||||
pathCopied: t("preview.response.pathCopied"),
|
||||
insertPath: t("preview.response.insertPath"),
|
||||
openRaw: t("preview.rawResponse"),
|
||||
}}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="request" pt="sm">
|
||||
<Stack gap="sm">
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
<RequestFact label={t("preview.request.method")} value={formValues.method} />
|
||||
<RequestFact label={t("preview.request.authentication")} value={formValues.authType} />
|
||||
<RequestFact label={t("preview.request.endpoint")} value={redactUrl(formValues.url)} />
|
||||
<RequestFact
|
||||
label={t("preview.request.status")}
|
||||
value={
|
||||
fetchResult?.responseInfo ? String(fetchResult.responseInfo.status) : t("preview.request.notRun")
|
||||
}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
{namedRequests.length > 0 && (
|
||||
<Stack gap={6}>
|
||||
<Text size="xs" fw={600}>
|
||||
{t("preview.request.named", { count: namedRequests.length })}
|
||||
</Text>
|
||||
{namedRequests.map((request) => (
|
||||
<Paper key={request.id} withBorder p="xs">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<div>
|
||||
<Text size="xs" fw={600} ff="monospace">
|
||||
{request.id}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ff="monospace" lineClamp={1}>
|
||||
{request.pathTemplate}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Badge size="xs" variant="light" color={methodColor(request.method)}>
|
||||
{request.method}
|
||||
</Badge>
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
{t(`preview.request.permission.${request.minimumBoardPermission ?? "view"}` as never)}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
{journalQuery.data && journalQuery.data.length > 0 ? (
|
||||
<Stack gap={6}>
|
||||
<Text size="xs" fw={600}>
|
||||
{t("preview.request.journal")}
|
||||
</Text>
|
||||
{journalQuery.data.map((entry) => (
|
||||
<Paper key={entry.id} withBorder p="xs">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Text size="xs" fw={600} ff="monospace">
|
||||
{entry.requestId}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ff="monospace" truncate>
|
||||
{entry.pathTemplate}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Badge size="xs" color={methodColor(entry.method)} variant="light">
|
||||
{entry.method}
|
||||
</Badge>
|
||||
<Badge size="xs" color={journalEntryColor(entry)}>
|
||||
{entry.simulated ? t("preview.request.simulated") : (entry.status ?? "—")}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed" w={56} ta="right">
|
||||
{t("preview.request.duration", { duration: entry.durationMs })}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("preview.request.journalEmpty")}
|
||||
</Text>
|
||||
)}
|
||||
<Alert color="blue" variant="light" p="xs" icon={<IconClipboard size={15} />}>
|
||||
<Text size="xs">{t("preview.request.redacted")}</Text>
|
||||
</Alert>
|
||||
</Stack>
|
||||
<PreviewRequestPanel
|
||||
method={formValues.method}
|
||||
authentication={formValues.authType}
|
||||
endpoint={redactPreviewUrl(formValues.url)}
|
||||
status={fetchResult?.responseInfo?.status}
|
||||
namedRequests={namedRequests}
|
||||
journal={journalQuery.data}
|
||||
methodColor={methodColor}
|
||||
messages={{
|
||||
method: t("preview.request.method"),
|
||||
authentication: t("preview.request.authentication"),
|
||||
endpoint: t("preview.request.endpoint"),
|
||||
status: t("preview.request.status"),
|
||||
notRun: t("preview.request.notRun"),
|
||||
named: t("preview.request.named", { count: namedRequests.length }),
|
||||
journal: t("preview.request.journal"),
|
||||
journalEmpty: t("preview.request.journalEmpty"),
|
||||
simulated: t("preview.request.simulated"),
|
||||
redacted: t("preview.request.redacted"),
|
||||
permission: (permission) => t(`preview.request.permission.${permission}` as never),
|
||||
duration: (duration) => t("preview.request.duration", { duration }),
|
||||
}}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="diagnostics" pt="sm">
|
||||
<Stack gap="xs">
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
<RequestFact
|
||||
label={t("preview.diagnostics.templateSize")}
|
||||
value={t("preview.diagnostics.characters", {
|
||||
count: (formValues.displayConfig.template as string | undefined)?.length ?? 0,
|
||||
})}
|
||||
/>
|
||||
<RequestFact label={t("preview.diagnostics.namedRequests")} value={String(namedRequests.length)} />
|
||||
<RequestFact label={t("preview.diagnostics.methods")} value={httpMethods.join(", ") || "GET"} />
|
||||
<RequestFact
|
||||
label={t("preview.diagnostics.networkScope")}
|
||||
value={
|
||||
(formValues.displayConfig.networkScope as string | undefined) ?? t("preview.diagnostics.legacy")
|
||||
}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
{templateDiagnostics.length === 0 ? (
|
||||
<Alert color="green" variant="light" p="xs" icon={<IconCheck size={15} />}>
|
||||
<Text size="xs">{t("preview.diagnostics.ready")}</Text>
|
||||
</Alert>
|
||||
) : (
|
||||
templateDiagnostics.map((diagnostic, index) => (
|
||||
<Alert
|
||||
key={`${diagnostic.code}-${index}`}
|
||||
color={{ error: "red", warning: "yellow" }[diagnostic.severity] ?? "yellow"}
|
||||
variant="light"
|
||||
p="xs"
|
||||
icon={<IconAlertTriangle size={15} />}
|
||||
>
|
||||
<Text size="xs">
|
||||
{diagnostic.line ? `${editorT("diagnostics.line", { line: diagnostic.line })}: ` : ""}
|
||||
{editorT(`diagnostics.${diagnostic.code}` as never, { value: diagnostic.value ?? "" } as never)}
|
||||
</Text>
|
||||
</Alert>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
<PreviewDiagnosticsPanel
|
||||
templateLength={(displayConfig.template as string | undefined)?.length ?? 0}
|
||||
namedRequestCount={namedRequests.length}
|
||||
methods={httpMethods.join(", ") || "GET"}
|
||||
networkScope={(displayConfig.networkScope as string | undefined) ?? t("preview.diagnostics.legacy")}
|
||||
diagnostics={templateDiagnostics}
|
||||
messages={{
|
||||
templateSize: t("preview.diagnostics.templateSize"),
|
||||
characters: (count) => t("preview.diagnostics.characters", { count }),
|
||||
namedRequests: t("preview.diagnostics.namedRequests"),
|
||||
methods: t("preview.diagnostics.methods"),
|
||||
networkScope: t("preview.diagnostics.networkScope"),
|
||||
ready: t("preview.diagnostics.ready"),
|
||||
diagnostic: (diagnostic) =>
|
||||
`${diagnostic.line ? `${editorT("diagnostics.line", { line: diagnostic.line })}: ` : ""}${editorT(
|
||||
`diagnostics.${diagnostic.code}` as never,
|
||||
{ value: diagnostic.value ?? "" } as never,
|
||||
)}`,
|
||||
}}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ResponseTree({ value, onInsertDataPath }: { value: unknown; onInsertDataPath?: (path: string) => void }) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const data = useMemo(() => [createResponseTreeNode(value, "data", "data")], [value]);
|
||||
const tree = useTree({ initialExpandedState: getTreeExpandedState(data, ["data"]) });
|
||||
return (
|
||||
<Tree
|
||||
data={data}
|
||||
tree={tree}
|
||||
levelOffset={14}
|
||||
expandOnClick
|
||||
selectOnClick={false}
|
||||
renderNode={(payload) => (
|
||||
<ResponseTreeNode
|
||||
{...payload}
|
||||
onInsertDataPath={onInsertDataPath}
|
||||
copyLabel={t("preview.response.copyPath")}
|
||||
copiedLabel={t("preview.response.pathCopied")}
|
||||
insertLabel={t("preview.response.insertPath")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface ResponseTreeData extends TreeNodeData {
|
||||
path: string;
|
||||
displayValue: string;
|
||||
}
|
||||
|
||||
function collectionDisplayValue(value: unknown, count: number): string {
|
||||
if (Array.isArray(value)) return `[${count}]`;
|
||||
return `{${count}}`;
|
||||
}
|
||||
|
||||
function createResponseTreeNode(value: unknown, path: string, label: string): ResponseTreeData {
|
||||
const isCollection = value !== null && typeof value === "object";
|
||||
const entries = isCollection ? Object.entries(value as Record<string, unknown>) : [];
|
||||
return {
|
||||
value: path,
|
||||
label,
|
||||
path,
|
||||
displayValue: isCollection ? collectionDisplayValue(value, entries.length) : formatTreeValue(value),
|
||||
children: entries.map(([key, childValue]) =>
|
||||
createResponseTreeNode(childValue, appendDataPath(path, key, Array.isArray(value)), key),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function ResponseTreeNode({
|
||||
node,
|
||||
expanded,
|
||||
hasChildren,
|
||||
elementProps,
|
||||
onInsertDataPath,
|
||||
copyLabel,
|
||||
copiedLabel,
|
||||
insertLabel,
|
||||
}: RenderTreeNodePayload & {
|
||||
onInsertDataPath?: (path: string) => void;
|
||||
copyLabel: string;
|
||||
copiedLabel: string;
|
||||
insertLabel: string;
|
||||
}) {
|
||||
const responseNode = node as ResponseTreeData;
|
||||
return (
|
||||
<Group {...elementProps} gap={4} wrap="nowrap" mih={28} className="response-tree-row">
|
||||
<div style={{ width: 16, flexShrink: 0 }}>
|
||||
{hasChildren && (expanded ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />)}
|
||||
</div>
|
||||
<Code style={{ flex: "0 1 auto", overflow: "hidden", textOverflow: "ellipsis", fontSize: 11 }}>
|
||||
{responseNode.label}
|
||||
</Code>
|
||||
<Text size="xs" c="dimmed" truncate style={{ flex: 1 }}>
|
||||
{responseNode.displayValue}
|
||||
</Text>
|
||||
<Group gap={2} wrap="nowrap" className="response-tree-actions">
|
||||
<CopyButton value={responseNode.path}>
|
||||
{({ copied, copy }) => (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color={copied ? "green" : "gray"}
|
||||
size={24}
|
||||
aria-label={copied ? copiedLabel : copyLabel}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
copy();
|
||||
}}
|
||||
>
|
||||
{copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
|
||||
</ActionIcon>
|
||||
)}
|
||||
</CopyButton>
|
||||
{onInsertDataPath && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size={24}
|
||||
aria-label={insertLabel}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onInsertDataPath(responseNode.path);
|
||||
}}
|
||||
>
|
||||
<IconPlus size={14} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function appendDataPath(parent: string, key: string, isArray: boolean) {
|
||||
if (isArray) return `${parent}[${key}]`;
|
||||
return /^[A-Za-z_$][\w$]*$/u.test(key) ? `${parent}.${key}` : `${parent}[${JSON.stringify(key)}]`;
|
||||
}
|
||||
|
||||
function formatTreeValue(value: unknown) {
|
||||
if (typeof value === "string") return JSON.stringify(value);
|
||||
if (value === undefined) return "undefined";
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function EmptyPreview({ icon, text }: { icon: React.ReactNode; text: string }) {
|
||||
return (
|
||||
<Center h={240}>
|
||||
<Stack align="center" gap="xs" c="dimmed">
|
||||
{icon}
|
||||
<Text size="xs" ta="center">
|
||||
{text}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
function RequestFact({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Paper withBorder p="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="xs" fw={600} lineClamp={2}>
|
||||
{value || "—"}
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewDisplay({ data }: { data: unknown }) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const typed = data as Record<string, unknown>;
|
||||
if (!typed || typeof typed !== "object") return null;
|
||||
|
||||
const dataType = typed.type as string | undefined;
|
||||
if (dataType === "actionButton") {
|
||||
return (
|
||||
<Center p="sm" h="100%">
|
||||
<Button size="sm" color={(typed.buttonColor as string) ?? "blue"} disabled>
|
||||
{(typed.buttonLabel as string) ?? t("preview.execute")}
|
||||
</Button>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const Component = dataType ? displayComponents[dataType] : undefined;
|
||||
if (Component) return <Component data={typed} />;
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%">
|
||||
<Code block style={{ fontSize: 11 }}>
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</Code>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
export const listItemDefaults = {
|
||||
mapping: { label: "", jsonPath: "$", unit: "" },
|
||||
column: { header: "", jsonPath: "$" },
|
||||
statGridItem: { label: "", jsonPath: "$", unit: "", color: "blue" },
|
||||
progressBar: { label: "", valuePath: "$", maxPath: "", unit: "", color: "blue" },
|
||||
statusItem: { label: "", jsonPath: "$", goodValues: "online,true" },
|
||||
countGridItem: { label: "", jsonPath: "$", unit: "" },
|
||||
} as const;
|
||||
|
||||
export function cloneLast<T extends Record<string, unknown>>(items: T[], fallback: T): T {
|
||||
return { ...(items.at(-1) ?? fallback) };
|
||||
}
|
||||
|
||||
export const MANTINE_COLORS = [
|
||||
"blue",
|
||||
"teal",
|
||||
"green",
|
||||
"red",
|
||||
"orange",
|
||||
"yellow",
|
||||
"violet",
|
||||
"pink",
|
||||
"cyan",
|
||||
"grape",
|
||||
"indigo",
|
||||
"lime",
|
||||
] as const;
|
||||
export const REQUEST_MANIFEST_STARTER = `[
|
||||
{
|
||||
"id": "details",
|
||||
"kind": "query",
|
||||
"method": "GET",
|
||||
"pathTemplate": "/api/details/{id}",
|
||||
"parameters": { "id": "string" },
|
||||
"auth": "inherit",
|
||||
"minimumBoardPermission": "view",
|
||||
"cacheTtlSeconds": 30
|
||||
}
|
||||
]`;
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { useZodForm } from "@homarr/form";
|
||||
import type { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { customWidgetFormSchema } from "@homarr/custom-widgets/workbench";
|
||||
|
||||
export type CustomWidgetFormInstance = ReturnType<typeof useZodForm<typeof customWidgetFormSchema>>;
|
||||
export type CustomWidgetTranslator = ReturnType<typeof useScopedI18n<"customWidget">>;
|
||||
|
||||
export interface DisplayFieldsProps {
|
||||
form: CustomWidgetFormInstance;
|
||||
t: CustomWidgetTranslator;
|
||||
previewJson: unknown;
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { ActionIcon, Box, Button, Group, NumberInput, Select, Stack, Switch, Text, TextInput } from "@mantine/core";
|
||||
import { IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
import { JsonPathTreePicker } from "@homarr/widgets/_inputs/json-path-tree-picker";
|
||||
import { cloneLast, listItemDefaults } from "./_display-field-shared";
|
||||
import type { DisplayFieldsProps } from "./_display-field-types";
|
||||
|
||||
export function BasicDisplayFields({ form, t, previewJson }: DisplayFieldsProps) {
|
||||
const dt = form.values.displayType;
|
||||
|
||||
if (dt === "singleValue") {
|
||||
return (
|
||||
<>
|
||||
<JsonPathTreePicker
|
||||
json={previewJson}
|
||||
label={t("field.jsonPath")}
|
||||
description={t("field.jsonPathHint")}
|
||||
required
|
||||
placeholder={t("placeholder.jsonPath")}
|
||||
{...form.getInputProps("jsonPath")}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("field.label")}
|
||||
placeholder={t("placeholder.exampleLabel")}
|
||||
{...form.getInputProps("label")}
|
||||
/>
|
||||
<TextInput label={t("field.unit")} placeholder={t("placeholder.exampleUnit")} {...form.getInputProps("unit")} />
|
||||
<Group grow>
|
||||
<Select
|
||||
label={t("field.valueSize")}
|
||||
data={["sm", "md", "lg", "xl"].map((v) => ({
|
||||
value: v,
|
||||
label: t(`sizeOption.${v}` as never),
|
||||
}))}
|
||||
{...form.getInputProps("valueSize")}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<Select
|
||||
label={t("field.labelPosition")}
|
||||
data={["above", "below"].map((v) => ({
|
||||
value: v,
|
||||
label: t(`labelPositionOption.${v}` as never),
|
||||
}))}
|
||||
{...form.getInputProps("labelPosition")}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (dt === "keyValue") {
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Group grow>
|
||||
<Select
|
||||
label={t("field.kvLayout")}
|
||||
data={["list", "grid"].map((v) => ({
|
||||
value: v,
|
||||
label: t(`layoutOption.${v}` as never),
|
||||
}))}
|
||||
{...form.getInputProps("kvLayout")}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
{form.values.kvLayout === "grid" && (
|
||||
<NumberInput label={t("field.gridColumns")} min={1} max={3} {...form.getInputProps("kvColumns")} />
|
||||
)}
|
||||
</Group>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("field.mappings")}
|
||||
</Text>
|
||||
{form.values.mappings.map((_m, i) => (
|
||||
<Group key={i} align="end" wrap="nowrap">
|
||||
<TextInput
|
||||
label={t("field.label")}
|
||||
placeholder={t("placeholder.exampleLabelShort")}
|
||||
style={{ flex: 1 }}
|
||||
{...form.getInputProps(`mappings.${i}.label`)}
|
||||
/>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<JsonPathTreePicker
|
||||
json={previewJson}
|
||||
label={t("field.jsonPath")}
|
||||
placeholder={t("placeholder.jsonPath")}
|
||||
{...form.getInputProps(`mappings.${i}.jsonPath`)}
|
||||
/>
|
||||
</Box>
|
||||
<TextInput
|
||||
label={t("field.unit")}
|
||||
placeholder={t("placeholder.exampleUnit")}
|
||||
style={{ width: 80 }}
|
||||
{...form.getInputProps(`mappings.${i}.unit`)}
|
||||
/>
|
||||
<ActionIcon
|
||||
type="button"
|
||||
color="red"
|
||||
variant="subtle"
|
||||
onClick={() => form.removeListItem("mappings", i)}
|
||||
disabled={form.values.mappings.length <= 1}
|
||||
aria-label={t("action.removeMapping")}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={() => form.insertListItem("mappings", cloneLast(form.values.mappings, listItemDefaults.mapping))}
|
||||
>
|
||||
{t("action.addMapping")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (dt === "table") {
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<JsonPathTreePicker
|
||||
json={previewJson}
|
||||
label={t("field.tablePath")}
|
||||
description={t("field.tablePathHint")}
|
||||
required
|
||||
placeholder={t("placeholder.tablePath")}
|
||||
{...form.getInputProps("tablePath")}
|
||||
/>
|
||||
<Group grow>
|
||||
<Switch label={t("field.striped")} {...form.getInputProps("striped", { type: "checkbox" })} />
|
||||
<Switch label={t("field.compact")} {...form.getInputProps("compact", { type: "checkbox" })} />
|
||||
</Group>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("field.columns")}
|
||||
</Text>
|
||||
{form.values.columns.map((_col, i) => (
|
||||
<Group key={i} align="end" wrap="nowrap">
|
||||
<TextInput
|
||||
label={t("field.header")}
|
||||
placeholder={t("placeholder.exampleHeader")}
|
||||
style={{ flex: 1 }}
|
||||
{...form.getInputProps(`columns.${i}.header`)}
|
||||
/>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<JsonPathTreePicker
|
||||
json={previewJson}
|
||||
label={t("field.jsonPath")}
|
||||
placeholder={t("placeholder.columnJsonPath")}
|
||||
{...form.getInputProps(`columns.${i}.jsonPath`)}
|
||||
/>
|
||||
</Box>
|
||||
<ActionIcon
|
||||
type="button"
|
||||
color="red"
|
||||
variant="subtle"
|
||||
onClick={() => form.removeListItem("columns", i)}
|
||||
disabled={form.values.columns.length <= 1}
|
||||
aria-label={t("action.removeColumn")}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={() => form.insertListItem("columns", cloneLast(form.values.columns, listItemDefaults.column))}
|
||||
>
|
||||
{t("action.addColumn")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Alert, SegmentedControl, Select, Stack, Text } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
import { CUSTOM_JSX_STARTER } from "@homarr/custom-widgets/core";
|
||||
import {
|
||||
analyzeJsxTemplate,
|
||||
analyzeRequestManifest,
|
||||
CUSTOM_JSX_TEMPLATE_LIMIT,
|
||||
parseRequestManifest,
|
||||
} from "@homarr/custom-widgets/workbench";
|
||||
import { CodeEditor } from "./_code-editor";
|
||||
import { REQUEST_MANIFEST_STARTER } from "./_display-field-shared";
|
||||
import type { DisplayFieldsProps } from "./_display-field-types";
|
||||
|
||||
export function CustomJsxDisplayFields({ form, t }: DisplayFieldsProps) {
|
||||
const dt = form.values.displayType;
|
||||
|
||||
if (dt === "customJsx") {
|
||||
const parsedRequests = parseRequestManifest(form.values.requestManifest);
|
||||
const requestIds = parsedRequests
|
||||
.map((request) => (request && typeof request === "object" && "id" in request ? request.id : null))
|
||||
.filter((id): id is string => typeof id === "string");
|
||||
const templateDiagnostics = analyzeJsxTemplate(form.values.template, {
|
||||
apiVersion: form.values.jsxApiVersion === "2" ? 2 : 1,
|
||||
requestIds,
|
||||
});
|
||||
const manifestDiagnostics =
|
||||
form.values.jsxApiVersion === "2" ? analyzeRequestManifest(form.values.requestManifest) : [];
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb={4}>
|
||||
{t("field.jsxApiVersion.label")}
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
value={form.values.jsxApiVersion}
|
||||
onChange={(value) => form.setFieldValue("jsxApiVersion", value)}
|
||||
data={[
|
||||
{ value: "2", label: t("field.jsxApiVersion.v2") },
|
||||
{ value: "1", label: t("field.jsxApiVersion.v1") },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{form.values.jsxApiVersion === "1" ? (
|
||||
<Alert color="yellow" variant="light" icon={<IconAlertTriangle size={16} />}>
|
||||
<Text size="sm">{t("field.jsxApiVersion.legacyWarning")}</Text>
|
||||
</Alert>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
<Select
|
||||
label={t("field.networkScope.label")}
|
||||
description={t(`field.networkScope.description.${form.values.networkScope}` as never)}
|
||||
value={form.values.networkScope}
|
||||
onChange={(value) => form.setFieldValue("networkScope", value ?? "public")}
|
||||
data={(["public", "private", "loopback"] as const).map((value) => ({
|
||||
value,
|
||||
label: t(`field.networkScope.option.${value}` as never),
|
||||
}))}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<CodeEditor
|
||||
id="custom-widget-request-manifest"
|
||||
language="json"
|
||||
label={t("field.requestManifest.label")}
|
||||
description={t("field.requestManifest.description")}
|
||||
value={form.values.requestManifest}
|
||||
onChange={(value) => form.setFieldValue("requestManifest", value)}
|
||||
placeholder={REQUEST_MANIFEST_STARTER}
|
||||
starter={REQUEST_MANIFEST_STARTER}
|
||||
diagnostics={manifestDiagnostics}
|
||||
error={form.errors.requestManifest ? t("field.requestManifest.invalid") : undefined}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<CodeEditor
|
||||
id="custom-widget-jsx-template"
|
||||
language="jsx"
|
||||
label={t("field.template.label")}
|
||||
description={t("field.template.description")}
|
||||
value={form.values.template}
|
||||
onChange={(value) => form.setFieldValue("template", value)}
|
||||
placeholder={CUSTOM_JSX_STARTER}
|
||||
starter={CUSTOM_JSX_STARTER}
|
||||
diagnostics={templateDiagnostics}
|
||||
error={form.errors.template}
|
||||
required
|
||||
maxLength={CUSTOM_JSX_TEMPLATE_LIMIT}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { ActionIcon, Box, Button, Group, Select, Stack, Switch, Text, TextInput, NumberInput } from "@mantine/core";
|
||||
import { IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
import { JsonPathTreePicker } from "@homarr/widgets/_inputs/json-path-tree-picker";
|
||||
import { cloneLast, listItemDefaults, MANTINE_COLORS } from "./_display-field-shared";
|
||||
import type { DisplayFieldsProps } from "./_display-field-types";
|
||||
|
||||
export function MetricDisplayFields({ form, t, previewJson }: DisplayFieldsProps) {
|
||||
const dt = form.values.displayType;
|
||||
|
||||
if (dt === "statGrid") {
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Group grow>
|
||||
<NumberInput label={t("field.gridColumns")} min={1} max={4} {...form.getInputProps("statGridColumns")} />
|
||||
<Select
|
||||
label={t("field.cardStyle")}
|
||||
data={["filled", "outline", "subtle"].map((v) => ({
|
||||
value: v,
|
||||
label: t(`cardStyleOption.${v}` as never),
|
||||
}))}
|
||||
{...form.getInputProps("cardStyle")}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Group>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("field.items")}
|
||||
</Text>
|
||||
{form.values.statGridItems.map((_item, i) => (
|
||||
<Group key={i} align="end" wrap="nowrap">
|
||||
<TextInput
|
||||
label={t("field.label")}
|
||||
placeholder={t("placeholder.exampleLabelShort")}
|
||||
style={{ flex: 1 }}
|
||||
{...form.getInputProps(`statGridItems.${i}.label`)}
|
||||
/>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<JsonPathTreePicker
|
||||
json={previewJson}
|
||||
label={t("field.jsonPath")}
|
||||
placeholder={t("placeholder.jsonPath")}
|
||||
{...form.getInputProps(`statGridItems.${i}.jsonPath`)}
|
||||
/>
|
||||
</Box>
|
||||
<TextInput
|
||||
label={t("field.unit")}
|
||||
placeholder={t("placeholder.exampleUnit")}
|
||||
style={{ width: 80 }}
|
||||
{...form.getInputProps(`statGridItems.${i}.unit`)}
|
||||
/>
|
||||
<Select
|
||||
label={t("field.color")}
|
||||
data={MANTINE_COLORS.map((c) => ({ value: c, label: c }))}
|
||||
style={{ width: 100 }}
|
||||
{...form.getInputProps(`statGridItems.${i}.color`)}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<ActionIcon
|
||||
type="button"
|
||||
color="red"
|
||||
variant="subtle"
|
||||
onClick={() => form.removeListItem("statGridItems", i)}
|
||||
disabled={form.values.statGridItems.length <= 1}
|
||||
aria-label={t("action.removeItem")}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={() =>
|
||||
form.insertListItem("statGridItems", cloneLast(form.values.statGridItems, listItemDefaults.statGridItem))
|
||||
}
|
||||
>
|
||||
{t("action.addItem")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (dt === "progressBars") {
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Group grow>
|
||||
<Switch label={t("field.showPercentage")} {...form.getInputProps("showPercentage", { type: "checkbox" })} />
|
||||
<Select
|
||||
label={t("field.barSize")}
|
||||
data={["sm", "md", "lg"].map((v) => ({
|
||||
value: v,
|
||||
label: t(`sizeOption.${v}` as never),
|
||||
}))}
|
||||
{...form.getInputProps("barSize")}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Group>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("field.bars")}
|
||||
</Text>
|
||||
{form.values.progressBars.map((_bar, i) => (
|
||||
<Group key={i} align="end" wrap="nowrap">
|
||||
<TextInput
|
||||
label={t("field.label")}
|
||||
placeholder={t("placeholder.exampleLabelShort")}
|
||||
style={{ flex: 1 }}
|
||||
{...form.getInputProps(`progressBars.${i}.label`)}
|
||||
/>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<JsonPathTreePicker
|
||||
json={previewJson}
|
||||
label={t("field.valuePath")}
|
||||
placeholder={t("placeholder.valuePath")}
|
||||
{...form.getInputProps(`progressBars.${i}.valuePath`)}
|
||||
/>
|
||||
</Box>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<JsonPathTreePicker
|
||||
json={previewJson}
|
||||
label={t("field.maxPath")}
|
||||
placeholder={t("placeholder.maxPath")}
|
||||
{...form.getInputProps(`progressBars.${i}.maxPath`)}
|
||||
/>
|
||||
</Box>
|
||||
<TextInput
|
||||
label={t("field.unit")}
|
||||
placeholder={t("placeholder.exampleUnit")}
|
||||
style={{ width: 60 }}
|
||||
{...form.getInputProps(`progressBars.${i}.unit`)}
|
||||
/>
|
||||
<Select
|
||||
label={t("field.color")}
|
||||
data={MANTINE_COLORS.map((c) => ({ value: c, label: c }))}
|
||||
style={{ width: 100 }}
|
||||
{...form.getInputProps(`progressBars.${i}.color`)}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<ActionIcon
|
||||
type="button"
|
||||
color="red"
|
||||
variant="subtle"
|
||||
onClick={() => form.removeListItem("progressBars", i)}
|
||||
disabled={form.values.progressBars.length <= 1}
|
||||
aria-label={t("action.removeItem")}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={() =>
|
||||
form.insertListItem("progressBars", cloneLast(form.values.progressBars, listItemDefaults.progressBar))
|
||||
}
|
||||
>
|
||||
{t("action.addBar")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { ActionIcon, Box, Button, Group, NumberInput, Select, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
import { JsonPathTreePicker } from "@homarr/widgets/_inputs/json-path-tree-picker";
|
||||
import { cloneLast, listItemDefaults, MANTINE_COLORS } from "./_display-field-shared";
|
||||
import type { DisplayFieldsProps } from "./_display-field-types";
|
||||
|
||||
export function StatusDisplayFields({ form, t, previewJson }: DisplayFieldsProps) {
|
||||
const dt = form.values.displayType;
|
||||
|
||||
if (dt === "statusIndicator") {
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Group grow>
|
||||
<Select
|
||||
label={t("field.kvLayout")}
|
||||
data={["list", "grid"].map((v) => ({
|
||||
value: v,
|
||||
label: t(`layoutOption.${v}` as never),
|
||||
}))}
|
||||
{...form.getInputProps("statusLayout")}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<Select
|
||||
label={t("field.dotSize")}
|
||||
data={["sm", "md", "lg"].map((v) => ({
|
||||
value: v,
|
||||
label: t(`sizeOption.${v}` as never),
|
||||
}))}
|
||||
{...form.getInputProps("dotSize")}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Group>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("field.items")}
|
||||
</Text>
|
||||
{form.values.statusItems.map((_item, i) => (
|
||||
<Group key={i} align="end" wrap="nowrap">
|
||||
<TextInput
|
||||
label={t("field.label")}
|
||||
placeholder={t("placeholder.exampleLabelShort")}
|
||||
style={{ flex: 1 }}
|
||||
{...form.getInputProps(`statusItems.${i}.label`)}
|
||||
/>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<JsonPathTreePicker
|
||||
json={previewJson}
|
||||
label={t("field.jsonPath")}
|
||||
placeholder={t("placeholder.jsonPath")}
|
||||
{...form.getInputProps(`statusItems.${i}.jsonPath`)}
|
||||
/>
|
||||
</Box>
|
||||
<TextInput
|
||||
label={t("field.goodValues")}
|
||||
placeholder={t("placeholder.goodValues")}
|
||||
style={{ flex: 1 }}
|
||||
{...form.getInputProps(`statusItems.${i}.goodValues`)}
|
||||
/>
|
||||
<ActionIcon
|
||||
type="button"
|
||||
color="red"
|
||||
variant="subtle"
|
||||
onClick={() => form.removeListItem("statusItems", i)}
|
||||
disabled={form.values.statusItems.length <= 1}
|
||||
aria-label={t("action.removeItem")}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={() =>
|
||||
form.insertListItem("statusItems", cloneLast(form.values.statusItems, listItemDefaults.statusItem))
|
||||
}
|
||||
>
|
||||
{t("action.addItem")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (dt === "countGrid") {
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Group grow>
|
||||
<NumberInput label={t("field.gridColumns")} min={2} max={4} {...form.getInputProps("countGridColumns")} />
|
||||
<Select
|
||||
label={t("field.valueSize")}
|
||||
data={["sm", "md", "lg"].map((v) => ({
|
||||
value: v,
|
||||
label: t(`sizeOption.${v}` as never),
|
||||
}))}
|
||||
{...form.getInputProps("countValueSize")}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Group>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("field.items")}
|
||||
</Text>
|
||||
{form.values.countGridItems.map((_item, i) => (
|
||||
<Group key={i} align="end" wrap="nowrap">
|
||||
<TextInput
|
||||
label={t("field.label")}
|
||||
placeholder={t("placeholder.exampleLabelShort")}
|
||||
style={{ flex: 1 }}
|
||||
{...form.getInputProps(`countGridItems.${i}.label`)}
|
||||
/>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<JsonPathTreePicker
|
||||
json={previewJson}
|
||||
label={t("field.jsonPath")}
|
||||
placeholder={t("placeholder.jsonPath")}
|
||||
{...form.getInputProps(`countGridItems.${i}.jsonPath`)}
|
||||
/>
|
||||
</Box>
|
||||
<TextInput
|
||||
label={t("field.unit")}
|
||||
placeholder={t("placeholder.exampleUnit")}
|
||||
style={{ width: 80 }}
|
||||
{...form.getInputProps(`countGridItems.${i}.unit`)}
|
||||
/>
|
||||
<ActionIcon
|
||||
type="button"
|
||||
color="red"
|
||||
variant="subtle"
|
||||
onClick={() => form.removeListItem("countGridItems", i)}
|
||||
disabled={form.values.countGridItems.length <= 1}
|
||||
aria-label={t("action.removeItem")}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={() =>
|
||||
form.insertListItem("countGridItems", cloneLast(form.values.countGridItems, listItemDefaults.countGridItem))
|
||||
}
|
||||
>
|
||||
{t("action.addItem")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (dt === "raw") {
|
||||
return (
|
||||
<>
|
||||
<JsonPathTreePicker
|
||||
json={previewJson}
|
||||
label={t("field.jsonPath")}
|
||||
description={t("field.jsonPathHint")}
|
||||
placeholder={t("placeholder.jsonPath")}
|
||||
{...form.getInputProps("rawJsonPath")}
|
||||
/>
|
||||
<NumberInput
|
||||
label={t("field.maxHeight")}
|
||||
min={50}
|
||||
max={1000}
|
||||
step={50}
|
||||
{...form.getInputProps("rawMaxHeight")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (dt === "actionButton") {
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<TextInput label={t("field.buttonLabel")} required {...form.getInputProps("buttonLabel")} />
|
||||
<Select
|
||||
label={t("field.buttonColor")}
|
||||
data={MANTINE_COLORS.map((c) => ({ value: c, label: c }))}
|
||||
{...form.getInputProps("buttonColor")}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("field.confirmText")}
|
||||
description={t("field.confirmTextHint")}
|
||||
{...form.getInputProps("confirmText")}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("field.successMessage")}
|
||||
description={t("field.successMessageHint")}
|
||||
{...form.getInputProps("successMessage")}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { SimpleGrid, Text, ThemeIcon, UnstyledButton } from "@mantine/core";
|
||||
import {
|
||||
Icon123,
|
||||
IconActivityHeartbeat,
|
||||
IconBraces,
|
||||
IconCode,
|
||||
IconHash,
|
||||
IconLayoutGrid,
|
||||
IconListDetails,
|
||||
IconPointer,
|
||||
IconProgress,
|
||||
IconTable,
|
||||
} from "@tabler/icons-react";
|
||||
import type { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { CustomWidgetDisplayType } from "@homarr/custom-widgets/core";
|
||||
import { customWidgetDisplayDescriptors } from "@homarr/custom-widgets/core";
|
||||
|
||||
import { BasicDisplayFields } from "./_display-fields-basic";
|
||||
import { CustomJsxDisplayFields } from "./_display-fields-jsx";
|
||||
import { MetricDisplayFields } from "./_display-fields-metric";
|
||||
import { StatusDisplayFields } from "./_display-fields-status";
|
||||
import type { DisplayFieldsProps } from "./_display-field-types";
|
||||
import classes from "./_custom-widget-form.module.css";
|
||||
|
||||
export const DISPLAY_TYPE_ICONS = {
|
||||
number: Icon123,
|
||||
keyValue: IconListDetails,
|
||||
table: IconTable,
|
||||
grid: IconLayoutGrid,
|
||||
progress: IconProgress,
|
||||
status: IconActivityHeartbeat,
|
||||
count: IconHash,
|
||||
json: IconBraces,
|
||||
action: IconPointer,
|
||||
code: IconCode,
|
||||
} as const;
|
||||
|
||||
export function DisplayTypePicker({
|
||||
value,
|
||||
onChange,
|
||||
t,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: CustomWidgetDisplayType) => void;
|
||||
t: ReturnType<typeof useScopedI18n<"customWidget">>;
|
||||
}) {
|
||||
return (
|
||||
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="sm">
|
||||
{customWidgetDisplayDescriptors.map((descriptor) => {
|
||||
const displayType = descriptor.type;
|
||||
const Icon = DISPLAY_TYPE_ICONS[descriptor.icon];
|
||||
const selected = value === displayType;
|
||||
return (
|
||||
<UnstyledButton
|
||||
type="button"
|
||||
key={displayType}
|
||||
className={classes.displayTypeOption}
|
||||
data-selected={selected || undefined}
|
||||
onClick={() => onChange(displayType)}
|
||||
aria-pressed={selected}
|
||||
>
|
||||
<ThemeIcon variant={selected ? "filled" : "light"} size={42} radius="md">
|
||||
<Icon size={22} />
|
||||
</ThemeIcon>
|
||||
<div className={classes.displayTypeCopy}>
|
||||
<Text fw={600} size="sm">
|
||||
{t(`displayType.${displayType}` as never)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" lineClamp={3}>
|
||||
{t(`displayTypeDescription.${displayType}` as never)}
|
||||
</Text>
|
||||
</div>
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
|
||||
export function DisplayTypeFields(props: DisplayFieldsProps) {
|
||||
return (
|
||||
<>
|
||||
<BasicDisplayFields {...props} />
|
||||
<MetricDisplayFields {...props} />
|
||||
<StatusDisplayFields {...props} />
|
||||
<CustomJsxDisplayFields {...props} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Badge, Button, Center, Code, Paper, ScrollArea, SegmentedControl, Stack, Text } from "@mantine/core";
|
||||
import { IconPlayerPlay } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { createDisplayComponents } from "@homarr/widgets/custom-api/component";
|
||||
|
||||
interface PreviewWidgetPanelProps {
|
||||
data: Record<string, unknown> | null;
|
||||
displayType: string;
|
||||
success: boolean;
|
||||
responseInfo: { status: number; statusText: string } | null;
|
||||
previewSize: string;
|
||||
onPreviewSizeChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const HEIGHTS: Record<string, number> = { compact: 240, standard: 360, wide: 520 };
|
||||
|
||||
export function PreviewWidgetPanel({
|
||||
data,
|
||||
displayType,
|
||||
success,
|
||||
responseInfo,
|
||||
previewSize,
|
||||
onPreviewSizeChange,
|
||||
}: PreviewWidgetPanelProps) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const height = HEIGHTS[previewSize] ?? HEIGHTS.standard;
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
fullWidth
|
||||
value={previewSize}
|
||||
onChange={onPreviewSizeChange}
|
||||
data={(["compact", "standard", "wide"] as const).map((value) => ({
|
||||
value,
|
||||
label: t(`preview.size.${value}` as never),
|
||||
}))}
|
||||
/>
|
||||
{data && (displayType === "actionButton" || success) ? (
|
||||
<>
|
||||
{success && (
|
||||
<Badge
|
||||
size="xs"
|
||||
color={responseInfo ? "green" : "gray"}
|
||||
variant="light"
|
||||
style={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
{responseInfo ? `${responseInfo.status} ${responseInfo.statusText}` : t("preview.response.localSample")}
|
||||
</Badge>
|
||||
)}
|
||||
<Paper withBorder p="xs" h={height} style={{ overflow: "auto" }}>
|
||||
<PreviewDisplay data={data} />
|
||||
</Paper>
|
||||
</>
|
||||
) : (
|
||||
<Center h={height}>
|
||||
<Stack gap={4} align="center" maw={280}>
|
||||
<IconPlayerPlay size={22} color="var(--mantine-color-dimmed)" />
|
||||
<Text size="sm" fw={500} ta="center">
|
||||
{t("preview.empty.title")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
{t("preview.hint")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewDisplay({ data }: { data: Record<string, unknown> }) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const widgetT = useScopedI18n("widget.customApi");
|
||||
const displayComponents = useMemo(() => createDisplayComponents(widgetT("openJson")), [widgetT]);
|
||||
const dataType = data.type as string | undefined;
|
||||
if (dataType === "actionButton") {
|
||||
return (
|
||||
<Center p="sm" h="100%">
|
||||
<Button type="button" size="sm" color={(data.buttonColor as string) ?? "blue"} disabled>
|
||||
{(data.buttonLabel as string) ?? t("preview.execute")}
|
||||
</Button>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
const Component = dataType ? displayComponents[dataType] : undefined;
|
||||
if (Component) return <Component data={data} />;
|
||||
return (
|
||||
<ScrollArea h="100%">
|
||||
<Code block style={{ fontSize: 11 }}>
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</Code>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { Alert, Badge, Group, Stack, Text } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
import { buildDisplayFormValues, getImportReview, parseCustomWidgetClipboard } from "@homarr/custom-widgets/core";
|
||||
import { CUSTOM_WIDGET_AUTH_SECRET_FIELDS } from "@homarr/custom-widgets/workbench";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showSuccessNotification } from "@homarr/notifications";
|
||||
|
||||
import type { CustomWidgetFormInstance, CustomWidgetTranslator } from "./_display-field-types";
|
||||
|
||||
interface UseCustomWidgetClipboardOptions {
|
||||
enabled: boolean;
|
||||
form: CustomWidgetFormInstance;
|
||||
t: CustomWidgetTranslator;
|
||||
onReplace: () => void;
|
||||
}
|
||||
|
||||
export function useCustomWidgetClipboard({ enabled, form, t, onReplace }: UseCustomWidgetClipboardOptions) {
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const queueReplacement = useCallback(
|
||||
(widget: Record<string, unknown>) => {
|
||||
const review = getImportReview(widget);
|
||||
if (!review) return;
|
||||
openConfirmModal({
|
||||
title: t("importReview.replaceTitle"),
|
||||
children: (
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">{t("importReview.replaceDescription", { name: review.name, origin: review.origin })}</Text>
|
||||
<Group gap={6}>
|
||||
{review.methods.map((method) => (
|
||||
<Badge key={method} color={{ DELETE: "red", GET: "blue" }[method] ?? "orange"}>
|
||||
{method}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
{review.hasActions && (
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
|
||||
<Text size="sm">{t("importReview.actionWarning.description")}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
),
|
||||
labels: { confirm: t("importReview.replaceConfirm"), cancel: t("importReview.cancel") },
|
||||
onConfirm: () => {
|
||||
const displayType = String(widget.displayType);
|
||||
const displayConfig = widget.displayConfig as Record<string, unknown>;
|
||||
const authType = typeof widget.authType === "string" ? widget.authType : "none";
|
||||
const secrets = (CUSTOM_WIDGET_AUTH_SECRET_FIELDS[authType] ?? []).map((field) => {
|
||||
const existing = form.values.secrets.find((secret) => secret.kind === field.kind);
|
||||
return { kind: field.kind, value: existing?.value ?? "", hasValue: existing?.hasValue ?? false };
|
||||
});
|
||||
form.setValues({
|
||||
...form.values,
|
||||
name: String(widget.name),
|
||||
description: typeof widget.description === "string" ? widget.description : "",
|
||||
iconUrl: typeof widget.iconUrl === "string" ? widget.iconUrl : "",
|
||||
url: String(widget.url),
|
||||
authType,
|
||||
headerName: typeof widget.headerName === "string" ? widget.headerName : "",
|
||||
method: typeof widget.method === "string" ? widget.method : "GET",
|
||||
requestBody: typeof widget.requestBody === "string" ? widget.requestBody : "",
|
||||
...buildDisplayFormValues(displayType, displayConfig),
|
||||
secrets,
|
||||
});
|
||||
onReplace();
|
||||
showSuccessNotification({ title: t("action.import"), message: t("notification.pastedForReplacement") });
|
||||
},
|
||||
});
|
||||
},
|
||||
[form, onReplace, openConfirmModal, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
const widget = parseCustomWidgetClipboard(event.clipboardData?.getData("text/plain") ?? "");
|
||||
if (!widget) return;
|
||||
event.preventDefault();
|
||||
queueReplacement(widget);
|
||||
};
|
||||
window.addEventListener("paste", handlePaste);
|
||||
return () => window.removeEventListener("paste", handlePaste);
|
||||
}, [enabled, queueReplacement]);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type {
|
||||
CustomWidgetAuthType,
|
||||
CustomWidgetDisplayType,
|
||||
CustomWidgetMethod,
|
||||
CustomWidgetSecretKind,
|
||||
DisplayConfig,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import { buildDisplayConfigFromFormValues } from "@homarr/custom-widgets/core";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
|
||||
import type { CustomWidgetFormInstance, CustomWidgetTranslator } from "./_display-field-types";
|
||||
import type { PreviewFetchResult, PreviewInput } from "./_custom-widget-preview";
|
||||
|
||||
interface UseCustomWidgetPreviewOptions {
|
||||
form: CustomWidgetFormInstance;
|
||||
definitionId?: string;
|
||||
refreshSignal: number;
|
||||
onOpenPreview: () => void;
|
||||
t: CustomWidgetTranslator;
|
||||
}
|
||||
|
||||
export function useCustomWidgetPreview({
|
||||
form,
|
||||
definitionId,
|
||||
refreshSignal,
|
||||
onOpenPreview,
|
||||
t,
|
||||
}: UseCustomWidgetPreviewOptions) {
|
||||
const previewMutation = clientApi.customWidget.preview.useMutation();
|
||||
const liveActionsMutation = clientApi.customWidget.setPreviewLiveActions.useMutation();
|
||||
const [json, setJson] = useState<unknown>(null);
|
||||
const [fetchResult, setFetchResult] = useState<PreviewFetchResult | null>(null);
|
||||
const [lastFingerprint, setLastFingerprint] = useState<string | null>(null);
|
||||
const hasTestedRef = useRef(false);
|
||||
|
||||
const getInput = useCallback((): PreviewInput => {
|
||||
const values = form.values;
|
||||
return {
|
||||
url: values.url,
|
||||
method: values.method as CustomWidgetMethod,
|
||||
authType: values.authType as CustomWidgetAuthType,
|
||||
headerName: values.headerName || undefined,
|
||||
requestBody: values.requestBody || undefined,
|
||||
displayType: values.displayType as CustomWidgetDisplayType,
|
||||
displayConfig: buildDisplayConfigFromFormValues(values) as DisplayConfig,
|
||||
secrets: values.secrets
|
||||
.filter((secret) => secret.value)
|
||||
.map((secret) => ({ kind: secret.kind as CustomWidgetSecretKind, value: secret.value })),
|
||||
definitionId,
|
||||
};
|
||||
}, [definitionId, form.values]);
|
||||
|
||||
const test = useCallback(async () => {
|
||||
const input = getInput();
|
||||
if (!input.url || input.method !== "GET") return;
|
||||
onOpenPreview();
|
||||
setLastFingerprint(JSON.stringify(input));
|
||||
try {
|
||||
const result = await previewMutation.mutateAsync(input);
|
||||
hasTestedRef.current = true;
|
||||
setFetchResult({
|
||||
success: result.success,
|
||||
error: result.success ? undefined : result.error,
|
||||
responseInfo: result.responseInfo,
|
||||
rawResponse: result.rawResponse,
|
||||
previewSession: result.previewSession,
|
||||
});
|
||||
if (!result.success || !result.rawResponse) return setJson(null);
|
||||
try {
|
||||
setJson(JSON.parse(result.rawResponse));
|
||||
} catch {
|
||||
setJson(null);
|
||||
}
|
||||
} catch {
|
||||
setFetchResult({ success: false, error: t("notification.previewError"), responseInfo: null, rawResponse: null });
|
||||
setJson(null);
|
||||
}
|
||||
}, [getInput, onOpenPreview, previewMutation, t]);
|
||||
|
||||
const setLiveActions = useCallback(
|
||||
async (enabled: boolean) => {
|
||||
const sessionId = fetchResult?.previewSession?.id;
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
const previewSession = await liveActionsMutation.mutateAsync({ sessionId, enabled });
|
||||
setFetchResult((current) => (current ? { ...current, previewSession } : current));
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("preview.capabilities.liveActions"),
|
||||
message: t("notification.previewActionsError"),
|
||||
});
|
||||
}
|
||||
},
|
||||
[fetchResult?.previewSession?.id, liveActionsMutation, t],
|
||||
);
|
||||
|
||||
const testRef = useRef(test);
|
||||
testRef.current = test;
|
||||
const getInputRef = useRef(getInput);
|
||||
getInputRef.current = getInput;
|
||||
const pendingRefreshRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (refreshSignal <= 0 || !hasTestedRef.current) return;
|
||||
if (previewMutation.isPending) return void (pendingRefreshRef.current = true);
|
||||
if (getInputRef.current().method === "GET") void testRef.current();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- refreshSignal is the explicit event source
|
||||
}, [refreshSignal]);
|
||||
useEffect(() => {
|
||||
if (previewMutation.isPending || !pendingRefreshRef.current) return;
|
||||
pendingRefreshRef.current = false;
|
||||
if (getInputRef.current().method === "GET" && hasTestedRef.current) void testRef.current();
|
||||
}, [previewMutation.isPending]);
|
||||
|
||||
const input = getInput();
|
||||
const isStale = lastFingerprint !== null && lastFingerprint !== JSON.stringify(input);
|
||||
const insertDataPath = useCallback(
|
||||
(path: string) => {
|
||||
if (form.values.displayType !== "customJsx") return;
|
||||
const template = form.values.template.trimEnd();
|
||||
const snippet = `<Text>{${path}}</Text>`;
|
||||
form.setFieldValue("template", template ? `${template}\n${snippet}` : snippet);
|
||||
},
|
||||
[form],
|
||||
);
|
||||
const setSampleData = useCallback((value: unknown) => {
|
||||
setJson(value);
|
||||
setFetchResult({ success: true, responseInfo: null, rawResponse: JSON.stringify(value, null, 2) });
|
||||
setLastFingerprint(null);
|
||||
}, []);
|
||||
const reset = useCallback(() => {
|
||||
setJson(null);
|
||||
setFetchResult(null);
|
||||
setLastFingerprint(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
input,
|
||||
getInput,
|
||||
json,
|
||||
fetchResult,
|
||||
isStale,
|
||||
test,
|
||||
setLiveActions,
|
||||
insertDataPath,
|
||||
setSampleData,
|
||||
reset,
|
||||
isTesting: previewMutation.isPending,
|
||||
testError: previewMutation.error,
|
||||
isUpdatingLiveActions: liveActionsMutation.isPending,
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Container, Stack, Title } from "@mantine/core";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { buildDisplayFormValues } from "@homarr/custom-widgets/core";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
||||
@@ -28,128 +29,6 @@ function buildInitialSecrets(authType: string, dbSecrets: Array<{ kind: string;
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildDisplayInitialValues(displayType: string, config: Record<string, unknown>) {
|
||||
const base = {
|
||||
displayType,
|
||||
jsonPath: "$",
|
||||
label: "",
|
||||
unit: "",
|
||||
valueSize: "lg",
|
||||
labelPosition: "below",
|
||||
mappings: [{ label: "", jsonPath: "$", unit: "" }],
|
||||
kvLayout: "list",
|
||||
kvColumns: 2,
|
||||
tablePath: "$",
|
||||
columns: [{ header: "", jsonPath: "$" }],
|
||||
striped: true,
|
||||
compact: false,
|
||||
statGridItems: [{ label: "", jsonPath: "$", unit: "", color: "blue" }],
|
||||
statGridColumns: 2,
|
||||
cardStyle: "filled",
|
||||
progressBars: [{ label: "", valuePath: "$", maxPath: "", unit: "", color: "blue" }],
|
||||
showPercentage: true,
|
||||
barSize: "md",
|
||||
statusItems: [{ label: "", jsonPath: "$", goodValues: "online,true" }],
|
||||
statusLayout: "list",
|
||||
dotSize: "md",
|
||||
countGridItems: [{ label: "", jsonPath: "$", unit: "" }],
|
||||
countGridColumns: 2,
|
||||
countValueSize: "md",
|
||||
rawJsonPath: "$",
|
||||
rawMaxHeight: 300,
|
||||
buttonLabel: "Execute",
|
||||
buttonColor: "blue",
|
||||
confirmText: "",
|
||||
successMessage: "",
|
||||
template: "",
|
||||
jsxApiVersion: "2",
|
||||
networkScope: "public",
|
||||
requestManifest: "[]",
|
||||
};
|
||||
|
||||
const typeOverrides: Record<string, () => Record<string, unknown>> = {
|
||||
singleValue: () => ({
|
||||
jsonPath: (config.jsonPath as string) ?? "$",
|
||||
label: (config.label as string) ?? "",
|
||||
unit: (config.unit as string) ?? "",
|
||||
valueSize: (config.valueSize as string) ?? "lg",
|
||||
labelPosition: (config.labelPosition as string) ?? "below",
|
||||
}),
|
||||
keyValue: () => ({
|
||||
mappings: (config.mappings as Array<{ label: string; jsonPath: string; unit: string }>) ?? base.mappings,
|
||||
kvLayout: (config.layout as string) ?? "list",
|
||||
kvColumns: (config.columns as number) ?? 2,
|
||||
}),
|
||||
table: () => ({
|
||||
tablePath: (config.tablePath as string) ?? "$",
|
||||
columns: (config.columns as Array<{ header: string; jsonPath: string }>) ?? base.columns,
|
||||
striped: (config.striped as boolean) ?? true,
|
||||
compact: (config.compact as boolean) ?? false,
|
||||
}),
|
||||
statGrid: () => ({
|
||||
statGridItems: (
|
||||
(config.items as Array<{ label: string; jsonPath: string; unit: string; color?: string }>) ?? []
|
||||
).map((item) => ({
|
||||
label: item.label,
|
||||
jsonPath: item.jsonPath,
|
||||
unit: item.unit,
|
||||
color: item.color ?? "blue",
|
||||
})),
|
||||
statGridColumns: (config.columns as number) ?? 2,
|
||||
cardStyle: (config.cardStyle as string) ?? "filled",
|
||||
}),
|
||||
progressBars: () => ({
|
||||
progressBars: (
|
||||
(config.bars as Array<{ label: string; valuePath: string; maxPath?: string; unit: string; color?: string }>) ??
|
||||
[]
|
||||
).map((bar) => ({
|
||||
label: bar.label,
|
||||
valuePath: bar.valuePath,
|
||||
maxPath: bar.maxPath ?? "",
|
||||
unit: bar.unit,
|
||||
color: bar.color ?? "blue",
|
||||
})),
|
||||
showPercentage: (config.showPercentage as boolean) ?? true,
|
||||
barSize: (config.barSize as string) ?? "md",
|
||||
}),
|
||||
statusIndicator: () => ({
|
||||
statusItems: ((config.items as Array<{ label: string; jsonPath: string; goodValues: string[] }>) ?? []).map(
|
||||
(item) => ({
|
||||
label: item.label,
|
||||
jsonPath: item.jsonPath,
|
||||
goodValues: item.goodValues.join(", "),
|
||||
}),
|
||||
),
|
||||
statusLayout: (config.layout as string) ?? "list",
|
||||
dotSize: (config.dotSize as string) ?? "md",
|
||||
}),
|
||||
countGrid: () => ({
|
||||
countGridItems: (config.items as Array<{ label: string; jsonPath: string; unit: string }>) ?? base.countGridItems,
|
||||
countGridColumns: (config.columns as number) ?? 2,
|
||||
countValueSize: (config.valueSize as string) ?? "md",
|
||||
}),
|
||||
raw: () => ({
|
||||
rawJsonPath: (config.jsonPath as string) ?? "$",
|
||||
rawMaxHeight: (config.maxHeight as number) ?? 300,
|
||||
}),
|
||||
actionButton: () => ({
|
||||
buttonLabel: (config.buttonLabel as string) ?? "Execute",
|
||||
buttonColor: (config.buttonColor as string) ?? "blue",
|
||||
confirmText: (config.confirmText as string) ?? "",
|
||||
successMessage: (config.successMessage as string) ?? "",
|
||||
}),
|
||||
customJsx: () => ({
|
||||
template: (config.template as string) ?? "",
|
||||
jsxApiVersion: String((config.jsxApiVersion as number | undefined) ?? 1),
|
||||
networkScope: (config.networkScope as string) ?? "public",
|
||||
requestManifest: JSON.stringify((config.requests as unknown[] | undefined) ?? [], null, 2),
|
||||
}),
|
||||
};
|
||||
|
||||
const overrides = typeOverrides[displayType]?.() ?? {};
|
||||
return { ...base, ...overrides };
|
||||
}
|
||||
|
||||
interface EditCustomWidgetPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
@@ -164,7 +43,7 @@ export default async function EditCustomWidgetPage(props: EditCustomWidgetPagePr
|
||||
const definition = await api.customWidget.byId({ id: params.id }).catch(catchTrpcNotFound);
|
||||
|
||||
const displayConfig = definition.displayConfig as Record<string, unknown>;
|
||||
const displayValues = buildDisplayInitialValues(definition.displayType, displayConfig);
|
||||
const displayValues = buildDisplayFormValues(definition.displayType, displayConfig);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
Reference in new issue
Block a user