fix(custom-widgets): stabilize authoring flows
This commit is contained in:
22 files changed
+454
-203
No files matched your search
@@ -15,7 +15,7 @@ The **Schema** action beside Undo, Redo, and Format expands the generated JSON S
|
||||
3. Declare named load/manual queries and actions.
|
||||
4. Define persisted item options.
|
||||
5. Write JSX using `data`, `status`, `options`, and temporary `inputs`.
|
||||
6. Test requests, preview compact/standard/wide layouts, and inspect runtime and accessibility diagnostics.
|
||||
6. Select **Test and preview**. Its loading state shows while load requests run; the Data tab dot and result summary turn green or red when they finish. Open Data for responses and request errors, then preview compact/standard/wide layouts and inspect runtime and accessibility diagnostics. A preview that initially lacks request data retries rendering when that data arrives.
|
||||
7. Save, then select the definition and its options in the board widget picker.
|
||||
|
||||
## ChatGPT copy/paste
|
||||
|
||||
@@ -7,6 +7,7 @@ import { auth } from "@homarr/auth/next";
|
||||
import { BoardProvider } from "@homarr/boards/context";
|
||||
import { EditModeProvider } from "@homarr/boards/edit-mode";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ModalProvider } from "@homarr/modals";
|
||||
|
||||
import { MainHeader } from "~/components/layout/header";
|
||||
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
|
||||
@@ -68,17 +69,19 @@ export const createBoardLayout = <TParams extends Params>({
|
||||
<BoardReadyProvider>
|
||||
<EditModeProvider>
|
||||
<BoardMantineProvider defaultColorScheme={colorScheme}>
|
||||
<CustomCss />
|
||||
<BoardTourWrapper hasSession={withTour && !!session}>
|
||||
<ClientShell hasNavigation={false}>
|
||||
<MainHeader
|
||||
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||
actions={headerActions}
|
||||
hasNavigation={false}
|
||||
/>
|
||||
<AppShellMain>{children}</AppShellMain>
|
||||
</ClientShell>
|
||||
</BoardTourWrapper>
|
||||
<ModalProvider>
|
||||
<CustomCss />
|
||||
<BoardTourWrapper hasSession={withTour && !!session}>
|
||||
<ClientShell hasNavigation={false}>
|
||||
<MainHeader
|
||||
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||
actions={headerActions}
|
||||
hasNavigation={false}
|
||||
/>
|
||||
<AppShellMain>{children}</AppShellMain>
|
||||
</ClientShell>
|
||||
</BoardTourWrapper>
|
||||
</ModalProvider>
|
||||
</BoardMantineProvider>
|
||||
</EditModeProvider>
|
||||
</BoardReadyProvider>
|
||||
|
||||
@@ -39,13 +39,15 @@ export function EditorSection({
|
||||
|
||||
export function SaveActions({
|
||||
dirty,
|
||||
pending,
|
||||
savePending,
|
||||
previewPending,
|
||||
invalid,
|
||||
mode,
|
||||
onPreview,
|
||||
}: {
|
||||
dirty: boolean;
|
||||
pending: boolean;
|
||||
savePending: boolean;
|
||||
previewPending: boolean;
|
||||
invalid: boolean;
|
||||
mode: "create" | "edit";
|
||||
onPreview(): void;
|
||||
@@ -64,10 +66,17 @@ export function SaveActions({
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Button type="button" variant="light" leftSection={<IconPlayerPlay size={16} />} onClick={onPreview}>
|
||||
{t("action.preview")}
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
leftSection={<IconPlayerPlay size={16} />}
|
||||
onClick={onPreview}
|
||||
loading={previewPending}
|
||||
disabled={invalid || savePending}
|
||||
>
|
||||
{previewPending ? t("action.previewLoading") : t("action.preview")}
|
||||
</Button>
|
||||
<Button type="submit" loading={pending} disabled={invalid}>
|
||||
<Button type="submit" loading={savePending} disabled={invalid || previewPending}>
|
||||
{mode === "create" ? t("action.create") : t("action.save")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -83,7 +83,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
const [mobilePane, setMobilePane] = useState<"configure" | "preview">("configure");
|
||||
const [request, setRequest] = useState("");
|
||||
const [documentationUrl, setDocumentationUrl] = useState("");
|
||||
const [preview, setPreview] = useState<PreviewState>({ data: {}, status: {}, session: null });
|
||||
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>>({});
|
||||
@@ -119,20 +119,21 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
[candidate, requestDiagnostics, templateDiagnostics],
|
||||
);
|
||||
|
||||
const { save, runPreview, pasteAiResponse, copyDiagnostics, pending } = useCustomWidgetFormActions({
|
||||
mode,
|
||||
definitionId,
|
||||
form,
|
||||
candidate,
|
||||
templateDiagnostics,
|
||||
requestDiagnostics,
|
||||
preview,
|
||||
setPreview,
|
||||
setMobilePane,
|
||||
setOptionsSnapshot,
|
||||
request,
|
||||
documentationUrl,
|
||||
});
|
||||
const { save, runPreview, pasteAiResponse, copyDiagnostics, savePending, previewPending } =
|
||||
useCustomWidgetFormActions({
|
||||
mode,
|
||||
definitionId,
|
||||
form,
|
||||
candidate,
|
||||
templateDiagnostics,
|
||||
requestDiagnostics,
|
||||
preview,
|
||||
setPreview,
|
||||
setMobilePane,
|
||||
setOptionsSnapshot,
|
||||
request,
|
||||
documentationUrl,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const beforeUnload = (event: BeforeUnloadEvent) => {
|
||||
@@ -254,7 +255,8 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
<Paper p="md" className={classes.mobileSaveBar} shadow="sm">
|
||||
<SaveActions
|
||||
dirty={form.isDirty()}
|
||||
pending={pending}
|
||||
savePending={savePending}
|
||||
previewPending={previewPending}
|
||||
invalid={invalid}
|
||||
mode={mode}
|
||||
onPreview={() => void runPreview()}
|
||||
@@ -265,7 +267,8 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
<Paper p="md" className={classes.actionBar} shadow="sm">
|
||||
<SaveActions
|
||||
dirty={form.isDirty()}
|
||||
pending={pending}
|
||||
savePending={savePending}
|
||||
previewPending={previewPending}
|
||||
invalid={invalid}
|
||||
mode={mode}
|
||||
onPreview={() => void runPreview()}
|
||||
|
||||
+29
-1
@@ -25,6 +25,8 @@ import CustomJsxDisplay from "@homarr/widgets/custom-api/custom-jsx-display";
|
||||
|
||||
import { CodeEditor } from "./_code-editor";
|
||||
import { isRecord, parseJson } from "./_custom-widget-form-utils";
|
||||
import { getPreviewSummary, PreviewResult, PreviewStatusDot } from "./_custom-widget-preview-status";
|
||||
import type { PreviewOutcome } from "./_custom-widget-preview-status";
|
||||
import { PreviewActionControl } from "./_custom-widget-preview-action";
|
||||
import classes from "./_custom-widget-form.module.css";
|
||||
|
||||
@@ -32,6 +34,7 @@ export interface PreviewState {
|
||||
data: Record<string, unknown>;
|
||||
status: Record<string, unknown>;
|
||||
session: { id: string; expiresAt: number; liveActions: boolean } | null;
|
||||
outcome: PreviewOutcome;
|
||||
}
|
||||
|
||||
interface PreviewPanelProps {
|
||||
@@ -89,6 +92,21 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
|
||||
queriesDisabled: fixture !== "live",
|
||||
isEditMode: fixture !== "live",
|
||||
};
|
||||
const previewSummary = getPreviewSummary(props.preview.status);
|
||||
const previewResult =
|
||||
props.preview.outcome === "success"
|
||||
? {
|
||||
title: t("result.success.title"),
|
||||
description: t("result.success.description", previewSummary),
|
||||
}
|
||||
: props.preview.outcome === "error"
|
||||
? {
|
||||
title: t("result.error.title"),
|
||||
description: t("result.error.description", previewSummary),
|
||||
}
|
||||
: props.preview.outcome === "loading"
|
||||
? { title: t("result.loading.title"), description: t("result.loading.description") }
|
||||
: { title: "", description: "" };
|
||||
|
||||
return (
|
||||
<Card withBorder p="md">
|
||||
@@ -125,11 +143,21 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
|
||||
{ value: "error", label: t("fixture.error") },
|
||||
]}
|
||||
/>
|
||||
<PreviewResult
|
||||
outcome={props.preview.outcome}
|
||||
title={previewResult.title}
|
||||
description={previewResult.description}
|
||||
/>
|
||||
<Tabs defaultValue="widget" keepMounted={false}>
|
||||
<Tabs.List grow>
|
||||
{(["widget", "data", "options", "actions", "diagnostics"] as const).map((tab) => (
|
||||
<Tabs.Tab key={tab} value={tab}>
|
||||
{t(`tab.${tab}`)}
|
||||
<Group gap={6} wrap="nowrap" justify="center">
|
||||
{t(`tab.${tab}`)}
|
||||
{tab === "data" && (
|
||||
<PreviewStatusDot outcome={props.preview.outcome} label={t(`status.${props.preview.outcome}`)} />
|
||||
)}
|
||||
</Group>
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
</Tabs.List>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, Box, Text, Tooltip } from "@mantine/core";
|
||||
|
||||
import { isRecord } from "./_custom-widget-form-utils";
|
||||
|
||||
export type PreviewOutcome = "idle" | "loading" | "success" | "error";
|
||||
|
||||
export function PreviewResult({
|
||||
outcome,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
outcome: PreviewOutcome;
|
||||
title: string;
|
||||
description: string;
|
||||
}) {
|
||||
if (outcome === "idle") return null;
|
||||
return (
|
||||
<Alert color={outcome === "error" ? "red" : outcome === "success" ? "green" : "yellow"}>
|
||||
<Text size="sm" fw={600}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text size="sm">{description}</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
export function PreviewStatusDot({ outcome, label }: { outcome: PreviewOutcome; label: string }) {
|
||||
const colors = { idle: "gray", loading: "yellow", success: "green", error: "red" } as const;
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<Box
|
||||
component="output"
|
||||
aria-label={label}
|
||||
w={8}
|
||||
h={8}
|
||||
bg={`var(--mantine-color-${colors[outcome]}-6)`}
|
||||
style={{ borderRadius: "50%", flexShrink: 0 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function getPreviewSummary(status: Record<string, unknown>) {
|
||||
return Object.values(status).reduce<{ succeeded: number; failed: number }>(
|
||||
(summary, value) => {
|
||||
if (isRecord(value) && value.ok === false) summary.failed += 1;
|
||||
else summary.succeeded += 1;
|
||||
return summary;
|
||||
},
|
||||
{ succeeded: 0, failed: 0 },
|
||||
);
|
||||
}
|
||||
+35
-5
@@ -13,10 +13,10 @@ import {
|
||||
parseCustomWidgetAiResponse,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import type { EditorDiagnostic, CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { showErrorNotification, showSuccessNotification, showWarningNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { applyDefinition, buildDefinition, loadPreviewQueries } from "./_custom-widget-form-utils";
|
||||
import { applyDefinition, buildDefinition, isRecord, loadPreviewQueries } from "./_custom-widget-form-utils";
|
||||
import type { PreviewState } from "./_custom-widget-preview-panel";
|
||||
|
||||
interface FormActionsInput {
|
||||
@@ -43,6 +43,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
|
||||
const updateMutation = clientApi.customWidget.update.useMutation();
|
||||
const previewMutation = clientApi.customWidget.previewCreate.useMutation();
|
||||
const [importIssues, setImportIssues] = useState<string[]>([]);
|
||||
const [previewPending, setPreviewPending] = useState(false);
|
||||
|
||||
const save = input.form.onSubmit(async (values) => {
|
||||
const definition = buildDefinition(values);
|
||||
@@ -93,6 +94,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
|
||||
});
|
||||
|
||||
const runPreview = async () => {
|
||||
if (previewPending) return;
|
||||
if (!input.candidate.success) {
|
||||
showErrorNotification({
|
||||
title: w("section.preview"),
|
||||
@@ -101,6 +103,8 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
|
||||
return;
|
||||
}
|
||||
input.setMobilePane("preview");
|
||||
input.setPreview({ data: {}, status: {}, session: null, outcome: "loading" });
|
||||
setPreviewPending(true);
|
||||
try {
|
||||
const created = await previewMutation.mutateAsync({
|
||||
definition: input.candidate.data,
|
||||
@@ -115,9 +119,34 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
|
||||
});
|
||||
input.setOptionsSnapshot(input.candidate.data.defaultOptions);
|
||||
const snapshot = await loadPreviewQueries(input.candidate.data, created.previewSession.id);
|
||||
input.setPreview({ ...snapshot, session: created.previewSession });
|
||||
const failed = Object.values(snapshot.status).filter((status) => isRecord(status) && status.ok === false).length;
|
||||
input.setPreview({
|
||||
...snapshot,
|
||||
session: created.previewSession,
|
||||
outcome: failed > 0 ? "error" : "success",
|
||||
});
|
||||
if (failed > 0) {
|
||||
showWarningNotification({
|
||||
title: w("preview.result.error.title"),
|
||||
message: w("preview.result.error.description", {
|
||||
succeeded: Object.keys(snapshot.status).length - failed,
|
||||
failed,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
showSuccessNotification({
|
||||
title: w("preview.result.success.title"),
|
||||
message: w("preview.result.success.description", {
|
||||
succeeded: Object.keys(snapshot.status).length,
|
||||
failed: 0,
|
||||
}),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
input.setPreview((current) => ({ ...current, outcome: "error" }));
|
||||
showErrorNotification({ title: w("section.preview"), message: t("notification.previewError") });
|
||||
} finally {
|
||||
setPreviewPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -130,7 +159,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
|
||||
}
|
||||
setImportIssues([]);
|
||||
applyDefinition(input.form, customWidgetDefinitionSchema.parse(result.widget));
|
||||
input.setPreview({ data: {}, status: {}, session: null });
|
||||
input.setPreview({ data: {}, status: {}, session: null, outcome: "idle" });
|
||||
showSuccessNotification({
|
||||
title: w("ai.response"),
|
||||
message:
|
||||
@@ -176,6 +205,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
|
||||
runPreview,
|
||||
pasteAiResponse,
|
||||
copyDiagnostics,
|
||||
pending: createMutation.isPending || updateMutation.isPending,
|
||||
savePending: createMutation.isPending || updateMutation.isPending,
|
||||
previewPending,
|
||||
};
|
||||
}
|
||||
@@ -35,6 +35,8 @@
|
||||
"@codemirror/lang-javascript": "catalog:",
|
||||
"@codemirror/lang-json": "catalog:",
|
||||
"@codemirror/lint": "catalog:",
|
||||
"@codemirror/state": "catalog:",
|
||||
"@codemirror/theme-one-dark": "catalog:",
|
||||
"@codemirror/view": "catalog:",
|
||||
"@mantine/charts": "catalog:",
|
||||
"@mantine/core": "catalog:",
|
||||
@@ -42,9 +44,9 @@
|
||||
"@mantine/hooks": "catalog:",
|
||||
"@tabler/icons-react": "catalog:",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@uiw/react-codemirror": "catalog:",
|
||||
"acorn": "catalog:",
|
||||
"acorn-jsx": "catalog:",
|
||||
"codemirror": "catalog:",
|
||||
"jsonpath-plus": "catalog:",
|
||||
"react": "catalog:",
|
||||
"undici": "catalog:",
|
||||
|
||||
@@ -86,8 +86,8 @@ const editorAdapter = await readFile(
|
||||
join(repositoryRoot, "packages/custom-widgets/src/workbench/code-editor.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
if (!/useEffect\(\(\)\s*=>[\s\S]*import\(["']@uiw\/react-codemirror["']\)/u.test(editorAdapter)) {
|
||||
failures.push("CodeMirror must remain dynamically loaded");
|
||||
if (!/lazy\(\(\)\s*=>\s*import\(["'].\/direct-code-mirror["']\)\)/u.test(editorAdapter)) {
|
||||
failures.push("CodeMirror must remain dynamically loaded after the client mounts");
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
|
||||
@@ -60,7 +60,8 @@ export function buildCustomWidgetAiPrompt(
|
||||
if (currentConfig) {
|
||||
sections.push(`Current widget:\n\n\`\`\`json\n${JSON.stringify(redactValue(currentConfig), null, 2)}\n\`\`\``);
|
||||
}
|
||||
return sections.join("\n\n").slice(0, 8_000);
|
||||
const prompt = sections.join("\n\n");
|
||||
return embeddedSkill ? prompt : prompt.slice(0, 8_000);
|
||||
}
|
||||
|
||||
export function buildCustomWidgetFixPrompt(input: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ComponentType, ErrorInfo, ReactNode } from "react";
|
||||
import { Component, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Alert, Badge, Box, Group, Popover, Stack, Text } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconNetwork } from "@tabler/icons-react";
|
||||
import { Alert, Box, Stack, Text } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
import { renderSafeJsx } from "../jsx/interpreter";
|
||||
import { CustomJsxInputsProvider } from "../jsx/runtime-components";
|
||||
@@ -23,8 +23,6 @@ const permissions = new Set(["view", "modify", "full"]);
|
||||
|
||||
export interface CustomJsxRendererMessages {
|
||||
noTemplate: string;
|
||||
interactive: string;
|
||||
networkCapabilities: string;
|
||||
templateWarnings(count: number): string;
|
||||
}
|
||||
|
||||
@@ -33,7 +31,6 @@ export interface CustomJsxRendererProps {
|
||||
data: unknown;
|
||||
status?: Record<string, unknown>;
|
||||
options?: Record<string, unknown>;
|
||||
requestCapabilities: unknown;
|
||||
components: Readonly<Record<string, ComponentType<never>>>;
|
||||
createBindings(data: unknown): Readonly<Record<string, unknown>>;
|
||||
messages: CustomJsxRendererMessages;
|
||||
@@ -107,7 +104,6 @@ export function CustomJsxRenderer({
|
||||
data,
|
||||
status = EMPTY_RECORD,
|
||||
options = EMPTY_RECORD,
|
||||
requestCapabilities: rawCapabilities,
|
||||
components,
|
||||
createBindings,
|
||||
messages,
|
||||
@@ -150,7 +146,7 @@ export function CustomJsxRenderer({
|
||||
() => ({ ...createBindings(data), status, options, inputs }),
|
||||
[createBindings, data, inputs, options, status],
|
||||
);
|
||||
const capabilities = useMemo(() => parseRequestCapabilities(rawCapabilities), [rawCapabilities]);
|
||||
const boundaryKey = useMemo(() => createBoundaryKey(template, bindings), [bindings, template]);
|
||||
const rendered = useMemo(() => {
|
||||
try {
|
||||
return { ...renderSafeJsx({ template, components, bindings }), error: null };
|
||||
@@ -182,47 +178,6 @@ export function CustomJsxRenderer({
|
||||
);
|
||||
return (
|
||||
<Stack gap={0} h="100%">
|
||||
{capabilities.length > 0 && (
|
||||
<Group justify="flex-end" mb={4}>
|
||||
<Popover width={320} position="bottom-end" withinPortal={false} shadow="md">
|
||||
<Popover.Target>
|
||||
<Badge
|
||||
component="button"
|
||||
type="button"
|
||||
size="sm"
|
||||
color="gray"
|
||||
variant="light"
|
||||
leftSection={<IconNetwork size={12} />}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{messages.interactive}
|
||||
</Badge>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Stack gap={6}>
|
||||
<Text size="sm" fw={600}>
|
||||
{messages.networkCapabilities}
|
||||
</Text>
|
||||
{capabilities.map((capability) => (
|
||||
<Group key={capability.id} justify="space-between" gap="xs" wrap="nowrap">
|
||||
<Text size="xs" ff="monospace" truncate>
|
||||
{capability.id}
|
||||
</Text>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Badge size="xs" color={methodColors[capability.method] ?? "gray"} variant="light">
|
||||
{capability.method}
|
||||
</Badge>
|
||||
<Badge size="xs" color="gray" variant="outline">
|
||||
{capability.minimumBoardPermission}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Group>
|
||||
)}
|
||||
<Box h="100%" style={{ contain: "layout paint style", isolation: "isolate", overflow: "auto", minHeight: 0 }}>
|
||||
{rendered.error ? (
|
||||
<ErrorAlert error={rendered.error} />
|
||||
@@ -233,7 +188,7 @@ export function CustomJsxRenderer({
|
||||
registerInput={registerInput}
|
||||
setInputValue={setInputValue}
|
||||
>
|
||||
<RendererErrorBoundary key={template} onError={handleError}>
|
||||
<RendererErrorBoundary key={boundaryKey} onError={handleError}>
|
||||
{rendered.node}
|
||||
</RendererErrorBoundary>
|
||||
</CustomJsxInputsProvider>
|
||||
@@ -254,3 +209,10 @@ export function CustomJsxRenderer({
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function createBoundaryKey(template: string, bindings: Readonly<Record<string, unknown>>) {
|
||||
let hash = 0;
|
||||
const value = `${template}\0${JSON.stringify(bindings)}`;
|
||||
for (let index = 0; index < value.length; index += 1) hash = (hash * 31 + value.charCodeAt(index)) | 0;
|
||||
return `${template.length}:${hash}`;
|
||||
}
|
||||
@@ -18,7 +18,7 @@ describe("buildCustomWidgetAiPrompt", () => {
|
||||
expect(prompt).toContain("Use this embedded Homarr Custom Widget skill while authoring:");
|
||||
expect(prompt).toContain("# Homarr Custom Widget");
|
||||
expect(prompt).toContain("installed Mantine components can differ by Homarr release.");
|
||||
expect(prompt.length).toBeLessThanOrEqual(8_000);
|
||||
expect(prompt.endsWith(CUSTOM_WIDGET_SKILL_MD)).toBe(true);
|
||||
});
|
||||
|
||||
test("is concise and describes the one-widget v2 contract", () => {
|
||||
|
||||
@@ -110,8 +110,6 @@ describe("Custom Widget runtime ports", () => {
|
||||
const setItem = vi.spyOn(Storage.prototype, "setItem");
|
||||
const rendererMessages = {
|
||||
noTemplate: "No template",
|
||||
interactive: "Interactive",
|
||||
networkCapabilities: "Network capabilities",
|
||||
templateWarnings: (count: number) => `${count} warnings`,
|
||||
};
|
||||
const components = createCustomJsxComponents({
|
||||
@@ -122,7 +120,6 @@ describe("Custom Widget runtime ports", () => {
|
||||
<CustomJsxRenderer
|
||||
template={'<Stack><TextInput bind="search" defaultValue="containers"/><Text>{inputs.search}</Text></Stack>'}
|
||||
data={{}}
|
||||
requestCapabilities={[]}
|
||||
components={components}
|
||||
createBindings={() => ({})}
|
||||
messages={rendererMessages}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { Alert, Stack, Text, UnstyledButton } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
@@ -8,12 +7,12 @@ import classes from "./code-editor.module.css";
|
||||
|
||||
export function EditorDiagnostics({
|
||||
diagnostics,
|
||||
editorView,
|
||||
messages,
|
||||
onSelect,
|
||||
}: {
|
||||
diagnostics: EditorDiagnostic[];
|
||||
editorView: EditorView | null;
|
||||
messages: CustomWidgetEditorMessages;
|
||||
onSelect(diagnostic: EditorDiagnostic): void;
|
||||
}) {
|
||||
if (diagnostics.length === 0) return null;
|
||||
return (
|
||||
@@ -24,12 +23,7 @@ export function EditorDiagnostics({
|
||||
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();
|
||||
}}
|
||||
onClick={() => onSelect(diagnostic)}
|
||||
>
|
||||
<Alert
|
||||
color={diagnostic.severity === "error" ? "red" : "yellow"}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { redo, undo } from "@codemirror/commands";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { Button, Group, Text } from "@mantine/core";
|
||||
import {
|
||||
IconArrowBackUp,
|
||||
@@ -17,24 +15,28 @@ import classes from "./code-editor.module.css";
|
||||
|
||||
interface CodeEditorToolbarProps {
|
||||
props: CustomWidgetCodeEditorProps;
|
||||
editorView: EditorView | null;
|
||||
editorCreated: boolean;
|
||||
historyDepth: { undo: number; redo: number };
|
||||
formattedValue: string;
|
||||
copied: boolean;
|
||||
referenceOpened: boolean;
|
||||
onReferenceToggle(): void;
|
||||
onCopy(): void;
|
||||
onUndo(): void;
|
||||
onRedo(): void;
|
||||
}
|
||||
|
||||
export function CodeEditorToolbar({
|
||||
props,
|
||||
editorView,
|
||||
editorCreated,
|
||||
historyDepth,
|
||||
formattedValue,
|
||||
copied,
|
||||
referenceOpened,
|
||||
onReferenceToggle,
|
||||
onCopy,
|
||||
onUndo,
|
||||
onRedo,
|
||||
}: CodeEditorToolbarProps) {
|
||||
const starter = props.starter;
|
||||
return (
|
||||
@@ -53,8 +55,8 @@ export function CodeEditorToolbar({
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconArrowBackUp size={14} />}
|
||||
onClick={() => editorView && undo(editorView)}
|
||||
disabled={!editorView || historyDepth.undo === 0}
|
||||
onClick={onUndo}
|
||||
disabled={!editorCreated || historyDepth.undo === 0}
|
||||
>
|
||||
{props.messages.undo}
|
||||
</Button>
|
||||
@@ -63,8 +65,8 @@ export function CodeEditorToolbar({
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconArrowForwardUp size={14} />}
|
||||
onClick={() => editorView && redo(editorView)}
|
||||
disabled={!editorView || historyDepth.redo === 0}
|
||||
onClick={onRedo}
|
||||
disabled={!editorCreated || historyDepth.redo === 0}
|
||||
>
|
||||
{props.messages.redo}
|
||||
</Button>
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { autocompletion } from "@codemirror/autocomplete";
|
||||
import { redoDepth, undoDepth } from "@codemirror/commands";
|
||||
import { javascript } from "@codemirror/lang-javascript";
|
||||
import { json } from "@codemirror/lang-json";
|
||||
import { linter } from "@codemirror/lint";
|
||||
import { redo, redoDepth, undo, undoDepth } from "@codemirror/commands";
|
||||
import type { EditorView as EditorViewType } from "@codemirror/view";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import type { ComponentType } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { ReactCodeMirrorProps } from "@uiw/react-codemirror";
|
||||
import { lazy, Suspense, useCallback, useEffect, useId, useMemo, useState } from "react";
|
||||
import { Badge, Collapse, Group, Input, Loader, Tabs, Text, useComputedColorScheme } from "@mantine/core";
|
||||
import { IconCheck } from "@tabler/icons-react";
|
||||
|
||||
import { createCustomJsxCompletionSource, customJsxComponentHover } from "./code-language";
|
||||
import { EditorDiagnostics } from "./code-editor-diagnostics";
|
||||
import { CodeEditorToolbar } from "./code-editor-toolbar";
|
||||
import type { EditorDiagnostic } from "./analyzer";
|
||||
import type {
|
||||
CustomWidgetCodeEditorProps,
|
||||
CustomWidgetEditorMessages,
|
||||
@@ -30,53 +24,24 @@ export type {
|
||||
} from "./code-editor-types";
|
||||
|
||||
const EMPTY_DIAGNOSTICS: NonNullable<CustomWidgetCodeEditorProps["diagnostics"]> = [];
|
||||
const LazyCodeMirror = lazy(() => import("./direct-code-mirror"));
|
||||
const editorViews = new Map<string, EditorViewType>();
|
||||
|
||||
export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) {
|
||||
const editorInstanceId = useId();
|
||||
const diagnostics = props.diagnostics ?? EMPTY_DIAGNOSTICS;
|
||||
const [CodeMirror, setCodeMirror] = useState<ComponentType<ReactCodeMirrorProps> | null>(null);
|
||||
const [editorReady, setEditorReady] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [referenceOpened, setReferenceOpened] = useState(false);
|
||||
const [cursor, setCursor] = useState({ line: 1, column: 1 });
|
||||
const [editorView, setEditorView] = useState<EditorViewType | null>(null);
|
||||
const [editorCreated, setEditorCreated] = useState(false);
|
||||
const [historyDepth, setHistoryDepth] = useState({ undo: 0, redo: 0 });
|
||||
const colorScheme = useComputedColorScheme("light");
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void import("@uiw/react-codemirror").then((module) => {
|
||||
if (!cancelled) setCodeMirror(() => module.default);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
setEditorReady(true);
|
||||
}, []);
|
||||
const extensions = useMemo(() => {
|
||||
const diagnosticsExtension = linter((view) =>
|
||||
diagnostics.map((diagnostic) => {
|
||||
const from = Math.min(diagnostic.index ?? 0, view.state.doc.length);
|
||||
return {
|
||||
from,
|
||||
to: Math.min(from + 1, view.state.doc.length),
|
||||
severity: diagnostic.severity,
|
||||
message: props.messages.diagnostic(diagnostic),
|
||||
};
|
||||
}),
|
||||
);
|
||||
const accessibilityExtension = EditorView.contentAttributes.of({ id: props.id, "aria-label": props.label });
|
||||
const editable = props.readOnly
|
||||
? [EditorView.editable.of(false), EditorView.contentAttributes.of({ tabindex: "0" })]
|
||||
: [];
|
||||
return props.language === "jsx"
|
||||
? [
|
||||
javascript({ jsx: true }),
|
||||
autocompletion({ override: [createCustomJsxCompletionSource(props.completions ?? [])] }),
|
||||
customJsxComponentHover,
|
||||
diagnosticsExtension,
|
||||
accessibilityExtension,
|
||||
...editable,
|
||||
]
|
||||
: [json(), diagnosticsExtension, accessibilityExtension, ...editable];
|
||||
}, [diagnostics, props.completions, props.id, props.label, props.language, props.messages, props.readOnly]);
|
||||
useEffect(() => {
|
||||
const editorView = editorViews.get(editorInstanceId);
|
||||
if (!editorView || !props.revealText) return;
|
||||
const from = editorView.state.doc.toString().indexOf(props.revealText);
|
||||
if (from < 0) return;
|
||||
@@ -85,12 +50,27 @@ export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) {
|
||||
effects: EditorView.scrollIntoView(from, { y: "center" }),
|
||||
});
|
||||
editorView.focus();
|
||||
}, [editorView, props.revealKey, props.revealText]);
|
||||
const handleCreateEditor = useCallback((view: EditorViewType) => {
|
||||
// @uiw invokes this while constructing CodeMirror. Deferring the parent update avoids
|
||||
// re-entering React's renderer during initialisation in Next.js and Docusaurus.
|
||||
window.setTimeout(() => setEditorView(view), 0);
|
||||
}, []);
|
||||
}, [editorCreated, editorInstanceId, props.revealKey, props.revealText]);
|
||||
const handleCreateEditor = useCallback(
|
||||
(view: EditorViewType) => {
|
||||
editorViews.set(editorInstanceId, view);
|
||||
window.setTimeout(() => setEditorCreated(true), 0);
|
||||
},
|
||||
[editorInstanceId],
|
||||
);
|
||||
const handleDestroyEditor = useCallback(() => {
|
||||
editorViews.delete(editorInstanceId);
|
||||
}, [editorInstanceId]);
|
||||
const selectDiagnostic = useCallback(
|
||||
(diagnostic: EditorDiagnostic) => {
|
||||
const editorView = editorViews.get(editorInstanceId);
|
||||
if (!editorView || diagnostic.index === undefined) return;
|
||||
const anchor = Math.min(diagnostic.index, editorView.state.doc.length);
|
||||
editorView.dispatch({ selection: { anchor }, scrollIntoView: true });
|
||||
editorView.focus();
|
||||
},
|
||||
[editorInstanceId],
|
||||
);
|
||||
const formattedValue = useMemo(() => formatCode(props.value, props.language), [props.language, props.value]);
|
||||
const errorCount = diagnostics.filter(({ severity }) => severity === "error").length;
|
||||
const warningCount = diagnostics.length - errorCount;
|
||||
@@ -115,13 +95,21 @@ export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) {
|
||||
<div className={classes.root}>
|
||||
<CodeEditorToolbar
|
||||
props={props}
|
||||
editorView={editorView}
|
||||
editorCreated={editorCreated}
|
||||
historyDepth={historyDepth}
|
||||
formattedValue={formattedValue}
|
||||
copied={copied}
|
||||
referenceOpened={referenceOpened}
|
||||
onReferenceToggle={() => setReferenceOpened((value) => !value)}
|
||||
onCopy={() => void copy()}
|
||||
onUndo={() => {
|
||||
const editorView = editorViews.get(editorInstanceId);
|
||||
if (editorView) undo(editorView);
|
||||
}}
|
||||
onRedo={() => {
|
||||
const editorView = editorViews.get(editorInstanceId);
|
||||
if (editorView) redo(editorView);
|
||||
}}
|
||||
/>
|
||||
{props.reference && (
|
||||
<Collapse expanded={referenceOpened}>
|
||||
@@ -129,36 +117,32 @@ export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) {
|
||||
</Collapse>
|
||||
)}
|
||||
<div className={classes.viewport}>
|
||||
{CodeMirror ? (
|
||||
<CodeMirror
|
||||
id={`${props.id}-root`}
|
||||
value={props.value}
|
||||
onChange={props.readOnly ? () => undefined : props.onChange}
|
||||
placeholder={props.placeholder}
|
||||
extensions={extensions}
|
||||
theme={colorScheme}
|
||||
height={props.height ?? (props.language === "jsx" ? "320px" : "220px")}
|
||||
basicSetup={{
|
||||
autocompletion: props.language !== "jsx" && !props.readOnly,
|
||||
bracketMatching: true,
|
||||
closeBrackets: !props.readOnly,
|
||||
foldGutter: true,
|
||||
history: !props.readOnly,
|
||||
highlightActiveLine: !props.readOnly,
|
||||
highlightActiveLineGutter: !props.readOnly,
|
||||
indentOnInput: !props.readOnly,
|
||||
lineNumbers: true,
|
||||
}}
|
||||
onCreateEditor={handleCreateEditor}
|
||||
onUpdate={(update) => {
|
||||
if (!editorView) return;
|
||||
if (!update.selectionSet && !update.docChanged) return;
|
||||
const head = update.state.selection.main.head;
|
||||
const line = update.state.doc.lineAt(head);
|
||||
setCursor({ line: line.number, column: head - line.from + 1 });
|
||||
setHistoryDepth({ undo: undoDepth(update.state), redo: redoDepth(update.state) });
|
||||
}}
|
||||
/>
|
||||
{editorReady ? (
|
||||
<Suspense fallback={<EditorLoader />}>
|
||||
<LazyCodeMirror
|
||||
id={`${props.id}-root`}
|
||||
value={props.value}
|
||||
language={props.language}
|
||||
diagnostics={diagnostics}
|
||||
completions={props.completions ?? []}
|
||||
label={props.label}
|
||||
placeholder={props.placeholder}
|
||||
theme={colorScheme}
|
||||
height={props.height ?? (props.language === "jsx" ? "320px" : "220px")}
|
||||
readOnly={props.readOnly ?? false}
|
||||
diagnosticMessage={props.messages.diagnostic}
|
||||
onChange={props.readOnly ? () => undefined : props.onChange}
|
||||
onCreateEditor={handleCreateEditor}
|
||||
onDestroyEditor={handleDestroyEditor}
|
||||
onUpdate={(update) => {
|
||||
if (!update.selectionSet && !update.docChanged) return;
|
||||
const head = update.state.selection.main.head;
|
||||
const line = update.state.doc.lineAt(head);
|
||||
setCursor({ line: line.number, column: head - line.from + 1 });
|
||||
setHistoryDepth({ undo: undoDepth(update.state), redo: redoDepth(update.state) });
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
<EditorLoader />
|
||||
)}
|
||||
@@ -190,7 +174,7 @@ export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) {
|
||||
</Text>
|
||||
</Group>
|
||||
</div>
|
||||
<EditorDiagnostics diagnostics={diagnostics} editorView={editorView} messages={props.messages} />
|
||||
<EditorDiagnostics diagnostics={diagnostics} messages={props.messages} onSelect={selectDiagnostic} />
|
||||
</Input.Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import { autocompletion } from "@codemirror/autocomplete";
|
||||
import { indentWithTab } from "@codemirror/commands";
|
||||
import { javascript } from "@codemirror/lang-javascript";
|
||||
import { json } from "@codemirror/lang-json";
|
||||
import { linter } from "@codemirror/lint";
|
||||
import { EditorState, StateEffect } from "@codemirror/state";
|
||||
import { oneDark } from "@codemirror/theme-one-dark";
|
||||
import { EditorView, keymap, placeholder as codeMirrorPlaceholder } from "@codemirror/view";
|
||||
import type { ViewUpdate } from "@codemirror/view";
|
||||
import { basicSetup } from "codemirror";
|
||||
import { useCallback, useEffect, useId, useState } from "react";
|
||||
import type { Completion } from "@codemirror/autocomplete";
|
||||
|
||||
import { createCustomJsxCompletionSource, customJsxComponentHover } from "./code-language";
|
||||
import type { EditorDiagnostic } from "./analyzer";
|
||||
|
||||
interface DirectCodeMirrorProps {
|
||||
value: string;
|
||||
language: "jsx" | "json";
|
||||
diagnostics: EditorDiagnostic[];
|
||||
completions: Completion[];
|
||||
id: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
height: string;
|
||||
theme: "light" | "dark";
|
||||
readOnly: boolean;
|
||||
diagnosticMessage(diagnostic: EditorDiagnostic): string;
|
||||
onChange(value: string): void;
|
||||
onCreateEditor(view: EditorView): void;
|
||||
onDestroyEditor(): void;
|
||||
onUpdate(update: ViewUpdate): void;
|
||||
}
|
||||
|
||||
interface EditorCallbacks {
|
||||
onChange(value: string): void;
|
||||
onUpdate(update: ViewUpdate): void;
|
||||
}
|
||||
|
||||
const editorViews = new Map<string, EditorView>();
|
||||
const editorCallbacks = new Map<string, EditorCallbacks>();
|
||||
const editorContainers = new Map<string, HTMLDivElement>();
|
||||
|
||||
export default function DirectCodeMirror(props: DirectCodeMirrorProps) {
|
||||
const instanceId = useId();
|
||||
const [initialProps] = useState(props);
|
||||
const [containerReady, setContainerReady] = useState(false);
|
||||
const setContainer = useCallback(
|
||||
(container: HTMLDivElement | null) => {
|
||||
if (!container) {
|
||||
editorContainers.delete(instanceId);
|
||||
return;
|
||||
}
|
||||
editorContainers.set(instanceId, container);
|
||||
setContainerReady(true);
|
||||
},
|
||||
[instanceId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
editorCallbacks.set(instanceId, { onChange: props.onChange, onUpdate: props.onUpdate });
|
||||
}, [instanceId, props.onChange, props.onUpdate]);
|
||||
|
||||
useEffect(() => {
|
||||
const parent = editorContainers.get(instanceId);
|
||||
if (!parent) return;
|
||||
const view = new EditorView({
|
||||
parent,
|
||||
state: EditorState.create({ doc: initialProps.value, extensions: createExtensions(instanceId, initialProps) }),
|
||||
});
|
||||
editorViews.set(instanceId, view);
|
||||
initialProps.onCreateEditor(view);
|
||||
return () => {
|
||||
editorViews.delete(instanceId);
|
||||
editorCallbacks.delete(instanceId);
|
||||
initialProps.onDestroyEditor();
|
||||
view.destroy();
|
||||
};
|
||||
}, [containerReady, initialProps, instanceId]);
|
||||
|
||||
useEffect(() => {
|
||||
const view = editorViews.get(instanceId);
|
||||
if (!view || view.state.doc.toString() === props.value) return;
|
||||
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: props.value } });
|
||||
}, [instanceId, props.value]);
|
||||
|
||||
useEffect(() => {
|
||||
const view = editorViews.get(instanceId);
|
||||
if (!view) return;
|
||||
view.dispatch({ effects: StateEffect.reconfigure.of(createExtensions(instanceId, props)) });
|
||||
}, [instanceId, props]);
|
||||
|
||||
return <div ref={setContainer} />;
|
||||
}
|
||||
|
||||
function createExtensions(instanceId: string, props: DirectCodeMirrorProps) {
|
||||
const language =
|
||||
props.language === "jsx"
|
||||
? [
|
||||
javascript({ jsx: true }),
|
||||
autocompletion({ override: [createCustomJsxCompletionSource(props.completions)] }),
|
||||
customJsxComponentHover,
|
||||
]
|
||||
: [json()];
|
||||
return [
|
||||
basicSetup,
|
||||
...language,
|
||||
linter((view) =>
|
||||
props.diagnostics.map((diagnostic) => {
|
||||
const from = Math.min(diagnostic.index ?? 0, view.state.doc.length);
|
||||
return {
|
||||
from,
|
||||
to: Math.min(from + 1, view.state.doc.length),
|
||||
severity: diagnostic.severity,
|
||||
message: props.diagnosticMessage(diagnostic),
|
||||
};
|
||||
}),
|
||||
),
|
||||
EditorView.contentAttributes.of({ id: props.id, "aria-label": props.label }),
|
||||
EditorView.editable.of(!props.readOnly),
|
||||
EditorState.readOnly.of(props.readOnly),
|
||||
keymap.of(props.readOnly ? [] : [indentWithTab]),
|
||||
props.placeholder ? codeMirrorPlaceholder(props.placeholder) : [],
|
||||
EditorView.theme({ "&": { height: props.height }, ".cm-scroller": { overflow: "auto" } }),
|
||||
props.theme === "dark" ? oneDark : [],
|
||||
EditorView.updateListener.of((update) => {
|
||||
const callbacks = editorCallbacks.get(instanceId);
|
||||
if (!callbacks) return;
|
||||
if (update.docChanged) callbacks.onChange(update.state.doc.toString());
|
||||
callbacks.onUpdate(update);
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -830,6 +830,7 @@
|
||||
},
|
||||
"action": {
|
||||
"preview": "Test and preview",
|
||||
"previewLoading": "Testing requests",
|
||||
"create": "Create",
|
||||
"save": "Save"
|
||||
},
|
||||
@@ -893,6 +894,26 @@
|
||||
"invalid": "Fix validation errors to preview this widget.",
|
||||
"fixtureError": "Preview fixture error",
|
||||
"requestData": "Request data",
|
||||
"status": {
|
||||
"idle": "Requests have not been tested",
|
||||
"loading": "Testing requests",
|
||||
"success": "All load requests succeeded",
|
||||
"error": "One or more load requests failed"
|
||||
},
|
||||
"result": {
|
||||
"loading": {
|
||||
"title": "Testing API requests",
|
||||
"description": "Homarr is creating a preview session and loading the widget data."
|
||||
},
|
||||
"success": {
|
||||
"title": "Preview ready",
|
||||
"description": "{succeeded, plural, =0 {This widget has no load requests.} one {# load request succeeded.} other {# load requests succeeded.}} Open Data to inspect the response."
|
||||
},
|
||||
"error": {
|
||||
"title": "Preview completed with request errors",
|
||||
"description": "{succeeded, number} succeeded and {failed, number} failed. Open Data for the response and error details."
|
||||
}
|
||||
},
|
||||
"instanceOptions": "Instance options",
|
||||
"liveActions": "Run actions against the configured API",
|
||||
"liveActionsDescription": "Off is safe simulation. Turning this on affects the real service.",
|
||||
|
||||
@@ -84,6 +84,26 @@ describe("CustomJsxDisplay", () => {
|
||||
expect(container.textContent).toContain("3");
|
||||
});
|
||||
|
||||
it("recovers when preview data arrives after an initial render error", async () => {
|
||||
const template = "<Stack>{data.items.map((item) => <Text key={item}>{item}</Text>)}</Stack>";
|
||||
await renderDisplay({ template, data: {} });
|
||||
expect(container.textContent).toContain("RUNTIME_RENDER_ERROR");
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
createElement(
|
||||
MantineProvider,
|
||||
null,
|
||||
createElement(CustomJsxDisplay, { data: { template, data: { items: ["Bulbasaur", "Pikachu"] } } }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Bulbasaur");
|
||||
expect(container.textContent).toContain("Pikachu");
|
||||
expect(container.textContent).not.toContain("RUNTIME_RENDER_ERROR");
|
||||
});
|
||||
|
||||
it("blocks script tags and event handlers", async () => {
|
||||
await renderDisplay({
|
||||
template: '<script>document.title = "hacked"</script><button onClick={() => {}}>Click</button><Text>Safe</Text>',
|
||||
|
||||
@@ -44,13 +44,10 @@ export default function CustomJsxDisplay({ data }: { data: Record<string, unknow
|
||||
...Object.fromEntries(Object.entries(queryState).map(([id, value]) => [id, value.status])),
|
||||
}}
|
||||
options={isRecord(data.options) ? data.options : {}}
|
||||
requestCapabilities={capabilities}
|
||||
components={components}
|
||||
createBindings={SAFE_BINDINGS}
|
||||
messages={{
|
||||
noTemplate: t("noTemplate"),
|
||||
interactive: t("interactive"),
|
||||
networkCapabilities: t("networkCapabilities"),
|
||||
templateWarnings: (count) => t("templateWarnings", { count: String(count) }),
|
||||
}}
|
||||
/>
|
||||
|
||||
Generated
+9
-3
@@ -1719,6 +1719,12 @@ importers:
|
||||
'@codemirror/lint':
|
||||
specifier: 'catalog:'
|
||||
version: 6.9.6
|
||||
'@codemirror/state':
|
||||
specifier: 'catalog:'
|
||||
version: 6.6.0
|
||||
'@codemirror/theme-one-dark':
|
||||
specifier: 'catalog:'
|
||||
version: 6.1.3
|
||||
'@codemirror/view':
|
||||
specifier: 'catalog:'
|
||||
version: 6.43.0
|
||||
@@ -1740,15 +1746,15 @@ importers:
|
||||
'@tanstack/react-query':
|
||||
specifier: 'catalog:'
|
||||
version: 5.101.2(react@19.2.7)
|
||||
'@uiw/react-codemirror':
|
||||
specifier: 'catalog:'
|
||||
version: 4.25.11(@babel/runtime@7.29.7)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.3)(@codemirror/lint@6.9.6)(@codemirror/search@6.7.1)(@codemirror/state@6.6.0)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.43.0)(codemirror@6.0.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
acorn:
|
||||
specifier: 'catalog:'
|
||||
version: 8.16.0
|
||||
acorn-jsx:
|
||||
specifier: 'catalog:'
|
||||
version: 5.3.2(acorn@8.16.0)
|
||||
codemirror:
|
||||
specifier: 'catalog:'
|
||||
version: 6.0.2
|
||||
jsonpath-plus:
|
||||
specifier: 'catalog:'
|
||||
version: 10.4.0
|
||||
|
||||
@@ -8,6 +8,8 @@ catalog:
|
||||
"@codemirror/lang-javascript": ^6.2.5
|
||||
"@codemirror/lang-json": ^6.0.2
|
||||
"@codemirror/lint": ^6.9.6
|
||||
"@codemirror/state": ^6.6.0
|
||||
"@codemirror/theme-one-dark": ^6.1.3
|
||||
"@codemirror/view": ^6.43.0
|
||||
"@auth/core": ^0.41.2
|
||||
"@auth/drizzle-adapter": ^1.11.2
|
||||
@@ -119,6 +121,7 @@ catalog:
|
||||
better-sqlite3: ^12.11.1
|
||||
chroma-js: ^3.2.0
|
||||
clsx: ^2.1.1
|
||||
codemirror: ^6.0.2
|
||||
concurrently: ^10.0.3
|
||||
conventional-changelog-conventionalcommits: ^10.2.0
|
||||
cookie: 2.0.1
|
||||
|
||||
Reference in new issue
Block a user