fix: simplify custom widget preview controls

This commit is contained in:
Thomas Camlong committed 2026-08-10 17:22:40 +02:00
1 parent 893fcd6ed1
commit be93fb330a
8 files changed
+116 -120

No files matched your search

@@ -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) =>
@@ -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()}
@@ -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>