fix: simplify custom widget preview controls
This commit is contained in:
8 files changed
+116
-120
No files matched your search
@@ -25,10 +25,10 @@ Every option has a default. Supported controls include text, textarea, number, s
|
||||
|
||||
## ChatGPT or Claude
|
||||
|
||||
Enter the desired widget and optional API documentation URL, then select **Copy AI prompt**. The single self-contained prompt is below 12,000 characters, starts with your request, includes the lean contract and two polished examples, and never claims that an offline chat has MCP tools.
|
||||
Open **Create or fix with any AI**, enter the desired widget and optional API documentation URL, then select the copy action beside the accordion title. The single self-contained prompt is below 12,000 characters, starts with your request, includes the lean contract and two polished examples, and never claims that an offline chat has MCP tools.
|
||||
|
||||
The model must return one fenced `json` block containing the complete widget, including the JSX in its `template`
|
||||
field. Copy that single block and select **Paste AI response**. Homarr fills the visual builders and the JSX editor
|
||||
field. Copy that single block and select **Paste**. Homarr fills the visual builders and the JSX editor
|
||||
without any placeholder replacement or second paste.
|
||||
|
||||
Homarr shows all actionable schema or JSX errors inline. Enter credentials separately. The prompt deliberately
|
||||
|
||||
@@ -30,7 +30,13 @@ describe("Copy AI prompt", () => {
|
||||
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.module.css`,
|
||||
"utf8",
|
||||
);
|
||||
expect(card).toContain("<SimpleGrid cols={{ base: 1, sm: 2 }}>");
|
||||
const actions = readFileSync(
|
||||
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx`,
|
||||
"utf8",
|
||||
);
|
||||
expect(card).toContain('<Accordion variant="contained">');
|
||||
expect(card).not.toContain("defaultValue");
|
||||
expect(actions).toContain("<ActionIcon");
|
||||
expect(styles).toContain(".sectionNav > a");
|
||||
expect(styles).toContain("flex: 0 0 auto");
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@mantine/core";
|
||||
import { IconSparkles } from "@tabler/icons-react";
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import { IconCopy } from "@tabler/icons-react";
|
||||
|
||||
import { buildCustomWidgetAiPrompt } from "@homarr/custom-widgets/authoring-prompt";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
@@ -36,15 +36,16 @@ export const CopyAiPromptButton = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
leftSection={<IconSparkles size={16} />}
|
||||
onClick={() => void handleCopy()}
|
||||
fullWidth
|
||||
size="sm"
|
||||
>
|
||||
{t("action.copyAiPrompt")}
|
||||
</Button>
|
||||
<Tooltip label={t("action.copyAiPrompt")}>
|
||||
<ActionIcon
|
||||
type="button"
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t("action.copyAiPrompt")}
|
||||
onClick={() => void handleCopy()}
|
||||
>
|
||||
<IconCopy size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Card, Group, SimpleGrid, Stack, Text, TextInput, Textarea } from "@mantine/core";
|
||||
import { IconSparkles } from "@tabler/icons-react";
|
||||
import { Accordion, Button, Group, Stack, Text, TextInput, Textarea } from "@mantine/core";
|
||||
import { IconClipboard, IconSparkles } from "@tabler/icons-react";
|
||||
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
@@ -20,38 +20,41 @@ interface AiCardProps {
|
||||
export function CustomWidgetAiCard(props: AiCardProps) {
|
||||
const t = useScopedI18n("customWidget.workbench.ai");
|
||||
return (
|
||||
<Card withBorder p="md">
|
||||
<Stack gap="sm">
|
||||
<Group gap="xs">
|
||||
<IconSparkles size={18} />
|
||||
<Text fw={600}>{t("title")}</Text>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("description")}
|
||||
</Text>
|
||||
<Textarea
|
||||
label={t("request")}
|
||||
value={props.request}
|
||||
onChange={(event) => props.onRequestChange(event.currentTarget.value)}
|
||||
autosize
|
||||
minRows={2}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("documentationUrl")}
|
||||
value={props.documentationUrl}
|
||||
onChange={(event) => props.onDocumentationUrlChange(event.currentTarget.value)}
|
||||
/>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }}>
|
||||
<Accordion variant="contained">
|
||||
<Accordion.Item value="ai">
|
||||
<Group gap={0} wrap="nowrap">
|
||||
<Accordion.Control icon={<IconSparkles size={18} />} style={{ flex: 1 }}>
|
||||
{t("title")}
|
||||
</Accordion.Control>
|
||||
<CopyAiPromptButton
|
||||
currentConfig={props.candidate}
|
||||
request={props.request}
|
||||
documentationUrl={props.documentationUrl}
|
||||
/>
|
||||
<Button type="button" variant="light" leftSection={<IconSparkles size={16} />} onClick={props.onPaste}>
|
||||
{t("paste")}
|
||||
</Button>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Group>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("description")}
|
||||
</Text>
|
||||
<Textarea
|
||||
label={t("request")}
|
||||
value={props.request}
|
||||
onChange={(event) => props.onRequestChange(event.currentTarget.value)}
|
||||
autosize
|
||||
minRows={2}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("documentationUrl")}
|
||||
value={props.documentationUrl}
|
||||
onChange={(event) => props.onDocumentationUrlChange(event.currentTarget.value)}
|
||||
/>
|
||||
<Button type="button" variant="light" leftSection={<IconClipboard size={16} />} onClick={props.onPaste}>
|
||||
{t("paste")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
@@ -67,7 +67,6 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
const [documentationUrl, setDocumentationUrl] = useState("");
|
||||
const [preview, setPreview] = useState<PreviewState>({ data: {}, status: {}, session: null, outcome: "idle" });
|
||||
const [previewSize, setPreviewSize] = useState("standard");
|
||||
const [previewTheme, setPreviewTheme] = useState<"light" | "dark">("dark");
|
||||
const [optionsSnapshot, setOptionsSnapshot] = useState<Record<string, unknown>>(() => {
|
||||
const initialDefinition = buildDefinition(formInitialValues);
|
||||
return initialDefinition.success ? getDefinitionDefaults(initialDefinition.data) : {};
|
||||
@@ -267,8 +266,6 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
preview={preview}
|
||||
size={previewSize}
|
||||
onSizeChange={setPreviewSize}
|
||||
theme={previewTheme}
|
||||
onThemeChange={setPreviewTheme}
|
||||
optionsSnapshot={optionsSnapshot}
|
||||
onOptionsChange={setOptionsSnapshot}
|
||||
onLiveActionsChange={(enabled) =>
|
||||
|
||||
+13
-8
@@ -1,5 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
@@ -69,6 +70,16 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("Custom Widget preview panel", () => {
|
||||
test("inherits the application theme instead of forcing a reload-sensitive preview theme", () => {
|
||||
const source = readFileSync(
|
||||
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview-panel.tsx`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(source).not.toContain("forceColorScheme");
|
||||
expect(source).not.toContain("onThemeChange");
|
||||
});
|
||||
|
||||
test("contains invalid definitions inside the widget canvas while keeping every inspection tab", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
@@ -79,8 +90,6 @@ describe("Custom Widget preview panel", () => {
|
||||
preview={{ data: { previous: true }, status: {}, session: null, outcome: "idle" }}
|
||||
size="standard"
|
||||
onSizeChange={vi.fn()}
|
||||
theme="dark"
|
||||
onThemeChange={vi.fn()}
|
||||
optionsSnapshot={{}}
|
||||
onOptionsChange={vi.fn()}
|
||||
onLiveActionsChange={vi.fn()}
|
||||
@@ -124,6 +133,8 @@ describe("Custom Widget preview panel", () => {
|
||||
|
||||
expect(host.textContent).toContain("sources.0.baseUrl");
|
||||
expect(host.textContent).toContain("Enter a complete URL.");
|
||||
expect(host.textContent).not.toContain("sessionActive");
|
||||
expect(host.textContent).not.toContain("simulationPending");
|
||||
});
|
||||
|
||||
test("contains renderer failures inside the widget tab and keeps the inspection tabs usable", async () => {
|
||||
@@ -149,8 +160,6 @@ describe("Custom Widget preview panel", () => {
|
||||
preview={preview}
|
||||
size="standard"
|
||||
onSizeChange={vi.fn()}
|
||||
theme="dark"
|
||||
onThemeChange={vi.fn()}
|
||||
optionsSnapshot={{ ...failingOptions }}
|
||||
onOptionsChange={vi.fn()}
|
||||
onLiveActionsChange={vi.fn()}
|
||||
@@ -175,8 +184,6 @@ describe("Custom Widget preview panel", () => {
|
||||
preview={preview}
|
||||
size="standard"
|
||||
onSizeChange={vi.fn()}
|
||||
theme="dark"
|
||||
onThemeChange={vi.fn()}
|
||||
optionsSnapshot={failingOptions}
|
||||
onOptionsChange={vi.fn()}
|
||||
onLiveActionsChange={vi.fn()}
|
||||
@@ -211,8 +218,6 @@ describe("Custom Widget preview panel", () => {
|
||||
preview={preview}
|
||||
size="standard"
|
||||
onSizeChange={vi.fn()}
|
||||
theme="dark"
|
||||
onThemeChange={vi.fn()}
|
||||
optionsSnapshot={{}}
|
||||
onOptionsChange={vi.fn()}
|
||||
onLiveActionsChange={vi.fn()}
|
||||
|
||||
+47
-57
@@ -1,20 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Card,
|
||||
Group,
|
||||
MantineProvider,
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Switch,
|
||||
Tabs,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { Alert, Box, Card, Group, Paper, SegmentedControl, Stack, Switch, Tabs, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
@@ -44,8 +31,6 @@ interface PreviewPanelProps {
|
||||
preview: PreviewState;
|
||||
size: string;
|
||||
onSizeChange(value: string): void;
|
||||
theme: "light" | "dark";
|
||||
onThemeChange(value: "light" | "dark"): void;
|
||||
optionsSnapshot: Record<string, unknown>;
|
||||
onOptionsChange(value: Record<string, unknown>): void;
|
||||
onLiveActionsChange(enabled: boolean): void;
|
||||
@@ -98,39 +83,51 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
|
||||
return (
|
||||
<Card withBorder p="md">
|
||||
<Stack gap="md">
|
||||
<Group grow>
|
||||
<Group gap="xs" justify="space-between">
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={props.size}
|
||||
onChange={props.onSizeChange}
|
||||
data={[
|
||||
{ value: "compact", label: t("size.compact") },
|
||||
{ value: "standard", label: t("size.standard") },
|
||||
{ value: "wide", label: t("size.wide") },
|
||||
{
|
||||
value: "compact",
|
||||
label: (
|
||||
<span aria-label={t("size.compact")} title={t("size.compact")}>
|
||||
S
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "standard",
|
||||
label: (
|
||||
<span aria-label={t("size.standard")} title={t("size.standard")}>
|
||||
M
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "wide",
|
||||
label: (
|
||||
<span aria-label={t("size.wide")} title={t("size.wide")}>
|
||||
L
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={props.theme}
|
||||
onChange={(value) => props.onThemeChange(value as "light" | "dark")}
|
||||
value={fixture}
|
||||
disabled={!candidate}
|
||||
onChange={(value) => setFixture(value as typeof fixture)}
|
||||
data={[
|
||||
{ value: "light", label: t("theme.light") },
|
||||
{ value: "dark", label: t("theme.dark") },
|
||||
{ value: "live", label: t("fixture.live") },
|
||||
{ value: "loading", label: t("fixture.loading") },
|
||||
{ value: "empty", label: t("fixture.empty") },
|
||||
{ value: "error", label: t("fixture.error") },
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={fixture}
|
||||
disabled={!candidate}
|
||||
onChange={(value) => setFixture(value as typeof fixture)}
|
||||
data={[
|
||||
{ value: "live", label: t("fixture.live") },
|
||||
{ value: "loading", label: t("fixture.loading") },
|
||||
{ value: "empty", label: t("fixture.empty") },
|
||||
{ value: "error", label: t("fixture.error") },
|
||||
]}
|
||||
/>
|
||||
{candidate && (
|
||||
<PreviewResult
|
||||
outcome={props.preview.outcome}
|
||||
@@ -153,22 +150,20 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
|
||||
</Tabs.List>
|
||||
<Tabs.Panel value="widget" pt="sm">
|
||||
<Box className={classes.previewCanvas}>
|
||||
<MantineProvider forceColorScheme={props.theme}>
|
||||
<Paper withBorder p="sm" h={360} w={widths[props.size] ?? 480} maw="100%" style={{ overflow: "auto" }}>
|
||||
{displayData ? (
|
||||
<PreviewErrorBoundary
|
||||
title={errorBoundaryMessages("title")}
|
||||
description={t("containedFailures")}
|
||||
retryLabel={errorBoundaryMessages("retry")}
|
||||
resetKeys={[rendererResetKey]}
|
||||
>
|
||||
<CustomJsxDisplay data={displayData} />
|
||||
</PreviewErrorBoundary>
|
||||
) : (
|
||||
<Alert color="yellow">{t("invalid")}</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
</MantineProvider>
|
||||
<Paper withBorder p="sm" h={360} w={widths[props.size] ?? 480} maw="100%" style={{ overflow: "auto" }}>
|
||||
{displayData ? (
|
||||
<PreviewErrorBoundary
|
||||
title={errorBoundaryMessages("title")}
|
||||
description={t("containedFailures")}
|
||||
retryLabel={errorBoundaryMessages("retry")}
|
||||
resetKeys={[rendererResetKey]}
|
||||
>
|
||||
<CustomJsxDisplay data={displayData} />
|
||||
</PreviewErrorBoundary>
|
||||
) : (
|
||||
<Alert color="yellow">{t("invalid")}</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="data" pt="sm">
|
||||
@@ -221,11 +216,6 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="diagnostics" pt="sm">
|
||||
<Stack gap="xs">
|
||||
{props.preview.session ? (
|
||||
<Badge color="green">{t("sessionActive")}</Badge>
|
||||
) : (
|
||||
<Badge color="gray">{t("simulationPending")}</Badge>
|
||||
)}
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("containedFailures")}
|
||||
</Text>
|
||||
|
||||
@@ -976,7 +976,7 @@
|
||||
"description": "Copy one prompt, then paste the AI's single JSON block. Homarr loads its JSX into the editor automatically.",
|
||||
"request": "What should this widget do?",
|
||||
"documentationUrl": "API documentation URL (optional)",
|
||||
"paste": "Paste AI response",
|
||||
"paste": "Paste",
|
||||
"response": "AI response",
|
||||
"loaded": "Widget loaded into the editor",
|
||||
"invalidResponse": "Clipboard does not contain a valid v2 widget",
|
||||
@@ -1010,8 +1010,6 @@
|
||||
"liveActions": "Run actions against the configured API",
|
||||
"liveActionsDescription": "Off is safe simulation. Turning this on affects the real service.",
|
||||
"noActions": "No actions are defined.",
|
||||
"sessionActive": "Preview session active",
|
||||
"simulationPending": "Actions are simulated until you run preview",
|
||||
"containedFailures": "Runtime failures are contained inside this widget. Request journals never include secret values.",
|
||||
"validation": {
|
||||
"title": "Validation",
|
||||
@@ -1046,10 +1044,6 @@
|
||||
"standard": "Standard",
|
||||
"wide": "Wide"
|
||||
},
|
||||
"theme": {
|
||||
"light": "Light",
|
||||
"dark": "Dark"
|
||||
},
|
||||
"fixture": {
|
||||
"live": "Live",
|
||||
"loading": "Loading",
|
||||
|
||||
Reference in new issue
Block a user