fix(custom-widgets): harden v2 authoring

This commit is contained in:
Thomas Camlong committed 2026-07-20 22:16:18 +02:00
1 parent ee832ad3c6
commit 7c66c912e7
171 files changed
+20311 -50527

No files matched your search

@@ -70,6 +70,7 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
};
const AddMenu = () => {
const board = useRequiredBoard();
const { data: session } = useSession();
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
@@ -101,8 +102,8 @@ const AddMenu = () => {
);
const handleSelectItem = useCallback(() => {
openItemSelectModal();
}, [openItemSelectModal]);
openItemSelectModal({ boardId: board.id });
}, [board.id, openItemSelectModal]);
const handleSelectApp = useCallback(() => {
openAppSelectModal({
+5 -2
View File
@@ -14,7 +14,7 @@ import type { DayOfWeek } from "@mantine/dates";
import { NextIntlClientProvider } from "next-intl";
import { api } from "@homarr/api/server";
import { env } from "@homarr/auth/env";
import { env as authEnv } from "@homarr/auth/env";
import { auth } from "@homarr/auth/next";
import { db } from "@homarr/db";
import { getServerSettingsAsync } from "@homarr/db/queries";
@@ -24,9 +24,11 @@ import { SettingsProvider } from "@homarr/settings";
import { SpotlightProvider } from "@homarr/spotlight";
import type { SupportedLanguage } from "@homarr/translation";
import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
import { WORKSHOP_API_URL } from "@homarr/workshop/schema";
import { Analytics } from "~/components/layout/analytics";
import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation";
import { env } from "~/env";
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration";
@@ -90,7 +92,7 @@ export default async function Layout(props: {
const StackedProvider = composeWrappers([
(innerProps) => {
return <AuthProvider session={session} logoutUrl={env.AUTH_LOGOUT_REDIRECT_URL} {...innerProps} />;
return <AuthProvider session={session} logoutUrl={authEnv.AUTH_LOGOUT_REDIRECT_URL} {...innerProps} />;
},
(innerProps) => (
<SettingsProvider
@@ -139,6 +141,7 @@ export default async function Layout(props: {
suppressHydrationWarning
>
<head>
<meta name="homarr-workshop-api-url" content={env.WORKSHOP_API_URL ?? WORKSHOP_API_URL} />
<SearchEngineOptimization />
<CrowdinLiveTranslation locale={locale} />
</head>
@@ -0,0 +1,129 @@
// @vitest-environment jsdom
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { CUSTOM_WIDGET_OFFLINE_BUNDLE_SENTINEL } from "@homarr/custom-widgets/authoring-prompt";
import { showSuccessNotification } from "@homarr/notifications";
import { CopyAiPromptButton } from "./_copy-ai-prompt-button";
vi.mock("@homarr/translation/client", () => ({
useScopedI18n: () => (key: string, values?: { count?: number }) =>
values?.count === undefined ? key : `${key}:${values.count}`,
}));
vi.mock("@homarr/notifications", () => ({
showErrorNotification: vi.fn(),
showSuccessNotification: vi.fn(),
}));
let root: Root;
let host: HTMLDivElement;
let writeText: ReturnType<typeof vi.fn>;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
},
);
window.matchMedia = vi.fn().mockImplementation(() => ({
matches: false,
media: "",
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } });
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(() => root.unmount());
host.remove();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
describe("CopyAiPromptButton", () => {
test("keeps the normal prompt lightweight and the embedded module lazy", async () => {
const source = await readFile(
resolve(process.cwd(), "apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx"),
"utf8",
);
expect(source).toContain('from "@homarr/custom-widgets/authoring-prompt"');
expect(source).toContain('await import("@homarr/custom-widgets/embedded-authoring-prompt")');
expect(source).not.toMatch(/^import .*@homarr\/custom-widgets\/embedded-authoring-prompt/mu);
await act(async () => {
root.render(
<MantineProvider>
<CopyAiPromptButton request="Create a Pokédex" />
</MantineProvider>,
);
});
const copyButton = [...host.querySelectorAll<HTMLButtonElement>("button")].find(
(button) => button.textContent === "action.copyAiPrompt",
);
expect(copyButton).toBeDefined();
await act(async () => copyButton?.click());
await vi.waitFor(() => expect(writeText).toHaveBeenCalledOnce());
const prompt = writeText.mock.calls[0]?.[0] as string;
expect(prompt.length).toBeLessThanOrEqual(8_000);
expect(prompt).not.toContain("--- BEGIN SKILL FILE:");
expect(showSuccessNotification).toHaveBeenCalledWith({
title: "action.copyAiPrompt",
message: `notification.aiPromptCopiedWithCount:${prompt.length}`,
});
});
test("copies the complete offline bundle and reports its exact character count", async () => {
await act(async () => {
root.render(
<MantineProvider>
<CopyAiPromptButton request="Create a Pokédex" />
</MantineProvider>,
);
});
const menuTarget = host.querySelector<HTMLButtonElement>('[aria-label="action.copyAiPromptWithSkill"]');
expect(menuTarget).not.toBeNull();
await act(async () => {
menuTarget?.click();
await new Promise((resolve) => window.setTimeout(resolve, 20));
});
const embeddedSkillItem = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')].find((element) =>
element.textContent?.includes("action.copyAiPromptWithSkill"),
);
expect(embeddedSkillItem).toBeDefined();
await act(async () => embeddedSkillItem?.click());
await vi.waitFor(() => expect(writeText).toHaveBeenCalledOnce());
const prompt = writeText.mock.calls[0]?.[0] as string;
expect(prompt.startsWith("Please create this Homarr Custom JSX v2 widget:\n\nCreate a Pokédex")).toBe(true);
expect(prompt.endsWith(`${CUSTOM_WIDGET_OFFLINE_BUNDLE_SENTINEL}\nCharacters: ${prompt.length}`)).toBe(true);
expect(showSuccessNotification).toHaveBeenCalledWith({
title: "action.copyAiPrompt",
message: `notification.aiPromptCopiedWithCount:${prompt.length}`,
});
});
});
@@ -3,7 +3,7 @@
import { Button, Menu } from "@mantine/core";
import { IconChevronDown, IconFileText, IconSparkles } from "@tabler/icons-react";
import { buildCustomWidgetAiPrompt, CUSTOM_WIDGET_SKILL_MD } from "@homarr/custom-widgets/core";
import { buildCustomWidgetAiPrompt } from "@homarr/custom-widgets/authoring-prompt";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
@@ -24,17 +24,23 @@ export const CopyAiPromptButton = ({
const handleCopy = async (includeSkill = false) => {
try {
await navigator.clipboard.writeText(
buildCustomWidgetAiPrompt(
undefined,
rawResponse,
currentConfig,
request,
documentationUrl,
includeSkill ? CUSTOM_WIDGET_SKILL_MD : undefined,
),
);
showSuccessNotification({ title: t("action.copyAiPrompt"), message: t("notification.aiPromptCopied") });
const prompt = includeSkill
? await import("@homarr/custom-widgets/embedded-authoring-prompt").then(
({ buildCustomWidgetAiPromptWithEmbeddedSkill }) =>
buildCustomWidgetAiPromptWithEmbeddedSkill(
undefined,
rawResponse,
currentConfig,
request,
documentationUrl,
),
)
: buildCustomWidgetAiPrompt(undefined, rawResponse, currentConfig, request, documentationUrl);
await navigator.clipboard.writeText(prompt);
showSuccessNotification({
title: t("action.copyAiPrompt"),
message: t("notification.aiPromptCopiedWithCount", { count: prompt.length }),
});
} catch {
showErrorNotification({ title: t("action.copyAiPrompt"), message: t("notification.aiPromptCopyError") });
}
@@ -155,8 +155,7 @@ export const ImportCustomWidgetButton = () => {
useEffect(() => {
const handlePaste = (event: ClipboardEvent) => {
const target = event.target as HTMLElement | null;
if (target?.matches("input, textarea, [contenteditable='true']")) return;
if (event.target instanceof Element && event.target.matches("input, textarea, [contenteditable='true']")) return;
const text = event.clipboardData?.getData("text/plain") ?? "";
if (!looksLikeCustomWidgetClipboard(text)) return;
event.preventDefault();
@@ -0,0 +1,25 @@
import {
CUSTOM_WIDGET_OPTIONS_EXAMPLES,
CUSTOM_WIDGET_REQUEST_EXAMPLES,
getCustomWidgetDefaultOptionsJsonSchema,
getCustomWidgetOptionsJsonSchema,
getCustomWidgetRequestsJsonSchema,
} from "@homarr/custom-widgets/core";
export const customWidgetRequestReference = {
schema: getCustomWidgetRequestsJsonSchema(),
minimal: CUSTOM_WIDGET_REQUEST_EXAMPLES.minimal,
full: CUSTOM_WIDGET_REQUEST_EXAMPLES.full,
};
export const customWidgetOptionsSchemaReference = {
schema: getCustomWidgetOptionsJsonSchema(),
minimal: CUSTOM_WIDGET_OPTIONS_EXAMPLES.minimal.schema,
full: CUSTOM_WIDGET_OPTIONS_EXAMPLES.full.schema,
};
export const customWidgetDefaultOptionsReference = {
schema: getCustomWidgetDefaultOptionsJsonSchema(),
minimal: CUSTOM_WIDGET_OPTIONS_EXAMPLES.minimal.defaults,
full: CUSTOM_WIDGET_OPTIONS_EXAMPLES.full.defaults,
};
@@ -0,0 +1,67 @@
import { describe, expect, it, vi } from "vitest";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { getCustomWidgetPreviewOptionIssues, loadPreviewQueries } from "./_custom-widget-form-utils";
const previewQuery = vi.hoisted(() => vi.fn());
vi.mock("@homarr/api/client", () => ({
fetchApi: { customWidget: { previewQuery: { query: previewQuery } } },
}));
const definition: HomarrCustomWidgetV2 = {
$schema: "homarr-custom-widget-v2",
name: "Preview options",
sources: [
{
id: "default",
name: "API",
baseUrl: "https://example.com",
networkScope: "public",
auth: { type: "none" },
},
],
requests: [
{
id: "containers",
sourceId: "default",
kind: "query",
method: "GET",
pathTemplate: "/endpoints/{endpointId}/containers",
parameters: { endpointId: "number" },
optionsBinding: { endpointId: { $option: "environmentId" } },
auth: "inherit",
minimumBoardPermission: "view",
trigger: "load",
},
],
optionsSchema: {
type: "object",
properties: { environmentId: { type: "number" } },
required: ["environmentId"],
additionalProperties: false,
},
defaultOptions: { environmentId: 1 },
template: "<Text>Preview</Text>",
};
describe("Custom Widget workbench preview options", () => {
it("passes the selected options to load-query bindings without replacing them with defaults", async () => {
previewQuery.mockResolvedValueOnce({ ok: true, status: 200, data: [] });
await loadPreviewQueries(definition, "preview-1", { environmentId: 7 });
expect(previewQuery).toHaveBeenCalledWith({
sessionId: "preview-1",
requestId: "containers",
params: { endpointId: 7 },
});
});
it("rejects invalid selected options before creating a preview session", () => {
expect(getCustomWidgetPreviewOptionIssues(definition, { environmentId: "wrong" })).toEqual([
{ path: "configuration.environmentId", message: "Expected number" },
]);
});
});
@@ -1,5 +1,9 @@
import { fetchApi } from "@homarr/api/client";
import { customWidgetDefinitionSchema, resolveCustomWidgetOptionsBinding } from "@homarr/custom-widgets/core";
import {
customWidgetDefinitionSchema,
resolveCustomWidgetOptionsBinding,
validateCustomWidgetOptions,
} from "@homarr/custom-widgets/core";
import type { CustomWidgetSource, HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
import type { UseFormReturnType } from "@mantine/form";
@@ -58,6 +62,10 @@ export function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
export function getCustomWidgetPreviewOptionIssues(definition: HomarrCustomWidgetV2, options: Record<string, unknown>) {
return validateCustomWidgetOptions(definition.optionsSchema, options);
}
export function isRuntimeParams(
value: unknown,
schema: Record<string, "string" | "number" | "boolean">,
@@ -66,12 +74,16 @@ export function isRuntimeParams(
return Object.entries(schema).every(([name, type]) => typeof value[name] === type);
}
export async function loadPreviewQueries(definition: HomarrCustomWidgetV2, sessionId: string) {
export async function loadPreviewQueries(
definition: HomarrCustomWidgetV2,
sessionId: string,
options: Record<string, unknown>,
) {
const data: Record<string, unknown> = {};
const status: Record<string, unknown> = {};
for (const request of definition.requests.filter((entry) => entry.kind === "query" && entry.trigger === "load")) {
try {
const params = resolveCustomWidgetOptionsBinding(request, definition.defaultOptions);
const params = resolveCustomWidgetOptionsBinding(request, options);
const result = await fetchApi.customWidget.previewQuery.query({ sessionId, requestId: request.id, params });
data[request.id] = result.data;
status[request.id] = { loading: false, ...result };
@@ -1,37 +1,17 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import {
Alert,
Box,
Button,
CopyButton,
Paper,
SegmentedControl,
Stack,
Text,
TextInput,
Textarea,
} from "@mantine/core";
import { Box, Button, Paper, SegmentedControl, Stack, TextInput, Textarea } from "@mantine/core";
import {
IconAlertCircle,
IconApi,
IconBraces,
IconCheck,
IconCode,
IconCopy,
IconDatabase,
IconEye,
IconSettings,
} from "@tabler/icons-react";
import {
CUSTOM_WIDGET_OPTIONS_EXAMPLES,
CUSTOM_WIDGET_REQUEST_EXAMPLES,
getCustomWidgetDefaultOptionsJsonSchema,
getCustomWidgetOptionsJsonSchema,
getCustomWidgetRequestsJsonSchema,
} from "@homarr/custom-widgets/core";
import {
analyzeJsxTemplate,
analyzeRequestManifest,
@@ -48,10 +28,16 @@ import { CodeEditor } from "./_code-editor";
import { CustomWidgetAiCard } from "./_custom-widget-ai-card";
import { createCustomWidgetCompletions, getInvalidCustomWidgetSections } from "./_custom-widget-form-analysis";
import { EditorSection, SaveActions } from "./_custom-widget-form-layout";
import {
customWidgetDefaultOptionsReference,
customWidgetOptionsSchemaReference,
customWidgetRequestReference,
} from "./_custom-widget-form-references";
import { applyDefinition, buildDefinition, isRecord, parseJsonArray } from "./_custom-widget-form-utils";
import { CustomWidgetPreviewPanel } from "./_custom-widget-preview-panel";
import type { PreviewState } from "./_custom-widget-preview-panel";
import { CustomWidgetRequestTools } from "./_custom-widget-request-tools";
import { CustomWidgetSaveIssuesAlert } from "./_custom-widget-save-issues-alert";
import { CustomWidgetSourcesEditor } from "./_custom-widget-sources-editor";
import { useCustomWidgetFormActions } from "./_use-custom-widget-form-actions";
import classes from "./_custom-widget-form.module.css";
@@ -71,35 +57,21 @@ const sectionLinks = [
["preview", "section.preview", IconEye],
] as const;
const requestReference = {
schema: getCustomWidgetRequestsJsonSchema(),
minimal: CUSTOM_WIDGET_REQUEST_EXAMPLES.minimal,
full: CUSTOM_WIDGET_REQUEST_EXAMPLES.full,
};
const optionsSchemaReference = {
schema: getCustomWidgetOptionsJsonSchema(),
minimal: CUSTOM_WIDGET_OPTIONS_EXAMPLES.minimal.schema,
full: CUSTOM_WIDGET_OPTIONS_EXAMPLES.full.schema,
};
const defaultOptionsReference = {
schema: getCustomWidgetDefaultOptionsJsonSchema(),
minimal: CUSTOM_WIDGET_OPTIONS_EXAMPLES.minimal.defaults,
full: CUSTOM_WIDGET_OPTIONS_EXAMPLES.full.defaults,
};
export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWidgetFormProps) {
const t = useScopedI18n("customWidget");
const w = useScopedI18n("customWidget.workbench");
const form = useZodForm(customWidgetFormSchema, {
initialValues: { ...DEFAULT_CUSTOM_WIDGET_FORM_VALUES, ...initialValues },
});
const formInitialValues: CustomWidgetFormValues = { ...DEFAULT_CUSTOM_WIDGET_FORM_VALUES, ...initialValues };
const form = useZodForm(customWidgetFormSchema, { initialValues: formInitialValues });
const [mobilePane, setMobilePane] = useState<"configure" | "preview">("configure");
const [request, setRequest] = useState("");
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 [optionsSnapshot, setOptionsSnapshot] = useState<Record<string, unknown>>(() => {
const initialDefinition = buildDefinition(formInitialValues);
return initialDefinition.success ? initialDefinition.data.defaultOptions : {};
});
const [revealedRequest, setRevealedRequest] = useState({ text: "", key: 0 });
const dirtyRef = useRef(false);
dirtyRef.current = form.isDirty();
@@ -140,10 +112,9 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
preview,
setPreview,
setMobilePane,
optionsSnapshot,
setOptionsSnapshot,
});
const saveIssueText = saveIssues.map((issue) => `${issue.path ? `${issue.path}: ` : ""}${issue.message}`).join("\n");
useEffect(() => {
const beforeUnload = (event: BeforeUnloadEvent) => {
if (dirtyRef.current) event.preventDefault();
@@ -173,36 +144,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
{ value: "preview", label: w("section.preview") },
]}
/>
{saveIssues.length > 0 && (
<Alert color="red" title={w("saveError.title")} icon={<IconAlertCircle size={18} />}>
<Text size="sm" mb="xs">
{w("saveError.description")}
</Text>
<Textarea
value={saveIssueText}
readOnly
autosize
minRows={2}
maxRows={10}
aria-label={w("saveError.errors")}
mb="xs"
/>
<CopyButton value={saveIssueText} timeout={2_000}>
{({ copied, copy }) => (
<Button
type="button"
size="compact-sm"
variant="light"
color={copied ? "green" : "red"}
leftSection={copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
onClick={copy}
>
{copied ? w("saveError.copied") : w("saveError.copy")}
</Button>
)}
</CopyButton>
</Alert>
)}
<CustomWidgetSaveIssuesAlert issues={saveIssues} />
<nav className={classes.sectionNav} aria-label={w("sectionNavigation")}>
{sectionLinks.map(([id, key, SectionIcon]) => (
<Button
@@ -244,7 +186,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
error={form.errors.requests}
revealText={revealedRequest.text}
revealKey={revealedRequest.key}
reference={requestReference}
reference={customWidgetRequestReference}
required
/>
</EditorSection>
@@ -256,7 +198,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
value={form.values.optionsSchema}
onChange={(value) => form.setFieldValue("optionsSchema", value)}
error={form.errors.optionsSchema}
reference={optionsSchemaReference}
reference={customWidgetOptionsSchemaReference}
/>
<CodeEditor
id="default-options"
@@ -265,7 +207,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
value={form.values.defaultOptions}
onChange={(value) => form.setFieldValue("defaultOptions", value)}
error={form.errors.defaultOptions}
reference={defaultOptionsReference}
reference={customWidgetDefaultOptionsReference}
/>
</EditorSection>
<EditorSection id="jsx" title={w("jsx.title")} icon={IconCode}>
@@ -14,21 +14,18 @@ import { isRuntimeParams, parseJson } from "./_custom-widget-form-utils";
export function PreviewActionControl({
request,
sessionId,
options,
}: {
request: HomarrCustomWidgetV2["requests"][number];
sessionId?: string;
options: Record<string, unknown>;
}) {
const t = useScopedI18n("customWidget.workbench.preview");
const { openConfirmModal } = useConfirmModal();
const actionMutation = clientApi.customWidget.previewAction.useMutation();
const initialParams = Object.fromEntries(
Object.entries(request.parameters).map(([name, type]) => {
const bound = options[name];
if (typeof bound === type) return [name, bound];
return [name, type === "number" ? 0 : type === "boolean" ? false : ""];
}),
Object.entries(request.parameters).map(([name, type]) => [
name,
type === "number" ? 0 : type === "boolean" ? false : "",
]),
);
const [params, setParams] = useState(JSON.stringify(initialParams, null, 2));
const parsed = parseJson(params);
@@ -0,0 +1,54 @@
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
interface PreviewSnapshot {
data: Record<string, unknown>;
status: Record<string, unknown>;
session: { id: string; liveActions: boolean } | null;
}
export function createPreviewDisplayData({
candidate,
fixture,
preview,
options,
fixtureError,
}: {
candidate: HomarrCustomWidgetV2 | null;
fixture: "live" | "loading" | "empty" | "error";
preview: PreviewSnapshot;
options: Record<string, unknown>;
fixtureError: string;
}): Record<string, unknown> | null {
if (!candidate) return null;
const data =
fixture === "empty" ? Object.fromEntries(candidate.requests.map((entry) => [entry.id, []])) : preview.data;
const status =
fixture === "loading"
? Object.fromEntries(candidate.requests.map((entry) => [entry.id, { loading: true }]))
: fixture === "error"
? Object.fromEntries(
candidate.requests.map((entry) => [entry.id, { loading: false, ok: false, error: fixtureError }]),
)
: preview.status;
return {
template: candidate.template,
data,
status,
options,
requestCapabilities: candidate.requests.map(
({ id, kind, method, trigger, minimumBoardPermission, confirmation, invalidates }) => ({
id,
kind,
method,
trigger,
minimumBoardPermission,
confirmation,
invalidates,
}),
),
previewSessionId: preview.session?.id,
previewLiveActions: preview.session?.liveActions ?? false,
queriesDisabled: fixture !== "live",
isEditMode: fixture !== "live",
};
}
@@ -6,6 +6,8 @@ import type { Root } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { BUNDLED_CUSTOM_WIDGETS } from "@homarr/custom-widgets/core";
import { CustomWidgetPreviewPanel } from "./_custom-widget-preview-panel";
vi.mock("@homarr/api/client", () => ({
@@ -18,7 +20,17 @@ vi.mock("@homarr/api/client", () => ({
}));
vi.mock("@homarr/translation/client", () => ({ useScopedI18n: () => (key: string) => key }));
vi.mock("@homarr/notifications", () => ({ showErrorNotification: vi.fn() }));
vi.mock("@homarr/widgets/custom-api/custom-jsx-display", () => ({ default: () => <div>widget-renderer</div> }));
const rendererAttempt = vi.hoisted(() => vi.fn());
vi.mock("@homarr/widgets/custom-api/custom-jsx-display", () => ({
default: ({ data }: { data: Record<string, unknown> }) => {
rendererAttempt(data);
if ((data.options as Record<string, unknown> | undefined)?.throwPreview === true) {
throw new Error("authored widget failed");
}
return <div>widget-renderer</div>;
},
}));
vi.mock("./_code-editor", () => ({ CodeEditor: ({ label }: { label: string }) => <div>{label}</div> }));
vi.mock("./_custom-widget-preview-action", () => ({ PreviewActionControl: () => <div>action-control</div> }));
@@ -77,11 +89,33 @@ describe("Custom Widget preview panel", () => {
);
});
expect(host.textContent).toContain("invalid");
const previewCanvas = host.querySelector('[class*="previewCanvas"]');
expect(previewCanvas?.textContent).toContain("invalid");
for (const tab of ["widget", "data", "options", "actions", "diagnostics"]) {
expect(host.textContent).toContain(`tab.${tab}`);
}
const dataTab = [...host.querySelectorAll<HTMLElement>('[role="tab"]')].find((element) =>
element.textContent?.includes("tab.data"),
);
await act(async () => dataTab?.click());
expect(host.textContent).toContain("requestData");
expect(host.textContent).not.toContain("invalid");
const optionsTab = [...host.querySelectorAll<HTMLElement>('[role="tab"]')].find((element) =>
element.textContent?.includes("tab.options"),
);
await act(async () => optionsTab?.click());
expect(host.textContent).toContain("instanceOptions");
expect(host.textContent).not.toContain("invalid");
const actionsTab = [...host.querySelectorAll<HTMLElement>('[role="tab"]')].find((element) =>
element.textContent?.includes("tab.actions"),
);
await act(async () => actionsTab?.click());
expect(host.textContent).toContain("liveActions");
expect(host.textContent).not.toContain("invalid");
const diagnosticsTab = [...host.querySelectorAll<HTMLElement>('[role="tab"]')].find((element) =>
element.textContent?.includes("tab.diagnostics"),
);
@@ -91,4 +125,99 @@ describe("Custom Widget preview panel", () => {
expect(host.textContent).toContain("sources.0.baseUrl");
expect(host.textContent).toContain("Enter a complete URL.");
});
test("contains renderer failures inside the widget tab and keeps the inspection tabs usable", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
rendererAttempt.mockClear();
const bundledWidget = BUNDLED_CUSTOM_WIDGETS.at(0);
if (!bundledWidget) throw new Error("Expected at least one bundled Custom Widget");
const candidate = bundledWidget.widget;
const preview = { data: {}, status: {}, session: null, outcome: "idle" } as const;
const failingOptions = { throwPreview: true };
await act(async () => {
root.render(
<MantineProvider>
<CustomWidgetPreviewPanel
candidate={{
...candidate,
requests: candidate.requests.map((request) => ({ ...request })),
}}
validationIssues={[]}
preview={preview}
size="standard"
onSizeChange={vi.fn()}
theme="dark"
onThemeChange={vi.fn()}
optionsSnapshot={{ ...failingOptions }}
onOptionsChange={vi.fn()}
onLiveActionsChange={vi.fn()}
/>
</MantineProvider>,
);
});
expect(host.textContent).toContain("title");
expect(host.querySelector('[class*="previewCanvas"]')?.textContent).toContain("title");
for (const tab of ["widget", "data", "options", "actions", "diagnostics"]) {
expect(host.textContent).toContain(`tab.${tab}`);
}
const attemptsAfterFailure = rendererAttempt.mock.calls.length;
await act(async () => {
root.render(
<MantineProvider>
<CustomWidgetPreviewPanel
candidate={candidate}
validationIssues={[]}
preview={preview}
size="standard"
onSizeChange={vi.fn()}
theme="dark"
onThemeChange={vi.fn()}
optionsSnapshot={failingOptions}
onOptionsChange={vi.fn()}
onLiveActionsChange={vi.fn()}
/>
</MantineProvider>,
);
});
expect(rendererAttempt).toHaveBeenCalledTimes(attemptsAfterFailure);
const dataTab = [...host.querySelectorAll<HTMLElement>('[role="tab"]')].find((element) =>
element.textContent?.includes("tab.data"),
);
await act(async () => dataTab?.click());
expect(host.textContent).toContain("requestData");
const diagnosticsTab = [...host.querySelectorAll<HTMLElement>('[role="tab"]')].find((element) =>
element.textContent?.includes("tab.diagnostics"),
);
await act(async () => diagnosticsTab?.click());
expect(host.textContent).toContain("validation.title");
const widgetTab = [...host.querySelectorAll<HTMLElement>('[role="tab"]')].find((element) =>
element.textContent?.includes("tab.widget"),
);
await act(async () => widgetTab?.click());
await act(async () => {
root.render(
<MantineProvider>
<CustomWidgetPreviewPanel
candidate={candidate}
validationIssues={[]}
preview={preview}
size="standard"
onSizeChange={vi.fn()}
theme="dark"
onThemeChange={vi.fn()}
optionsSnapshot={{}}
onOptionsChange={vi.fn()}
onLiveActionsChange={vi.fn()}
/>
</MantineProvider>,
);
});
expect(host.textContent).toContain("widget-renderer");
});
});
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useState } from "react";
import {
Alert,
Badge,
@@ -18,16 +18,17 @@ import {
import { clientApi } from "@homarr/api/client";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { analyzeCustomWidgetAccessibility } from "@homarr/custom-widgets/workbench";
import { analyzeCustomWidgetAccessibility, PreviewErrorBoundary } from "@homarr/custom-widgets/workbench";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
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 { createPreviewDisplayData } from "./_custom-widget-preview-data";
import { JsonPreviewEditor } from "./_json-preview-editor";
import classes from "./_custom-widget-form.module.css";
export interface PreviewState {
@@ -52,6 +53,7 @@ interface PreviewPanelProps {
export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
const t = useScopedI18n("customWidget.workbench.preview");
const errorBoundaryMessages = useScopedI18n("customWidget.editor.errorBoundary");
const [fixture, setFixture] = useState<"live" | "loading" | "empty" | "error">("live");
const liveActionsMutation = clientApi.customWidget.setPreviewLiveActions.useMutation();
const journalQuery = clientApi.customWidget.previewJournal.useQuery(
@@ -61,42 +63,23 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
const widths: Record<string, number> = { compact: 320, standard: 480, wide: 720 };
const candidate = props.candidate;
const accessibilityIssues = candidate ? analyzeCustomWidgetAccessibility(candidate.template) : [];
const fixtureData =
fixture === "empty" && candidate
? Object.fromEntries(candidate.requests.map((entry) => [entry.id, []]))
: props.preview.data;
const fixtureStatus =
fixture === "loading" && candidate
? Object.fromEntries(candidate.requests.map((entry) => [entry.id, { loading: true }]))
: fixture === "error" && candidate
? Object.fromEntries(
candidate.requests.map((entry) => [entry.id, { loading: false, ok: false, error: t("fixtureError") }]),
)
: props.preview.status;
const displayData = candidate
? {
template: candidate.template,
data: fixtureData,
status: fixtureStatus,
options: props.optionsSnapshot,
requestCapabilities: candidate.requests.map(
({ id, kind, method, trigger, minimumBoardPermission, confirmation, invalidates }) => ({
id,
kind,
method,
trigger,
minimumBoardPermission,
confirmation,
invalidates,
}),
),
previewSessionId: props.preview.session?.id,
previewLiveActions: props.preview.session?.liveActions ?? false,
queriesDisabled: fixture !== "live",
isEditMode: fixture !== "live",
}
: null;
const displayData = createPreviewDisplayData({
candidate,
fixture,
preview: props.preview,
options: props.optionsSnapshot,
fixtureError: t("fixtureError"),
});
const previewSummary = getPreviewSummary(props.preview.status);
const rendererResetKey = JSON.stringify({
template: candidate?.template,
requests: candidate?.requests,
fixture,
data: props.preview.data,
status: props.preview.status,
sessionId: props.preview.session?.id,
options: props.optionsSnapshot,
});
const previewResult =
props.preview.outcome === "success"
? {
@@ -172,7 +155,18 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
<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 ? <CustomJsxDisplay data={displayData} /> : <Alert color="yellow">{t("invalid")}</Alert>}
{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>
</Box>
@@ -216,14 +210,8 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
{candidate?.requests
.filter((request) => request.kind === "action")
.map((request) => (
<PreviewActionControl
key={request.id}
request={request}
sessionId={props.preview.session?.id}
options={props.optionsSnapshot}
/>
<PreviewActionControl key={request.id} request={request} sessionId={props.preview.session?.id} />
))}
{!candidate && <Alert color="yellow">{t("invalid")}</Alert>}
{candidate?.requests.every((request) => request.kind !== "action") && (
<Text size="sm" c="dimmed">
{t("noActions")}
@@ -283,32 +271,3 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
</Card>
);
}
function JsonPreviewEditor({
id,
label,
value,
onChange,
}: {
id: string;
label: string;
value: Record<string, unknown>;
onChange(value: Record<string, unknown>): void;
}) {
const serialized = JSON.stringify(value, null, 2);
const [draft, setDraft] = useState(serialized);
useEffect(() => setDraft(serialized), [serialized]);
return (
<CodeEditor
id={id}
label={label}
language="json"
value={draft}
onChange={(next) => {
setDraft(next);
const parsed = parseJson(next);
if (isRecord(parsed)) onChange(parsed);
}}
/>
);
}
@@ -40,6 +40,23 @@ describe("extractCustomWidgetSaveIssues", () => {
]);
});
it("preserves array indexes in serialized Zod issue paths", () => {
const message = JSON.stringify([
{
code: "custom",
message: "A load parameter requires an explicit binding.",
path: ["requests", 0, "optionsBinding", "endpointId"],
},
]);
expect(extractCustomWidgetSaveIssues({ message })).toEqual([
{
path: "requests.0.optionsBinding.endpointId",
message: "A load parameter requires an explicit binding.",
},
]);
});
it("extracts root validation errors from the tRPC error shape", () => {
expect(
extractCustomWidgetSaveIssues({
@@ -35,7 +35,12 @@ function parseZodIssueMessage(message: string): CustomWidgetSaveIssue[] {
return parsed.flatMap((candidate) => {
const issue = asRecord(candidate);
if (typeof issue?.message !== "string") return [];
const path = Array.isArray(issue.path) ? issue.path.filter((part) => typeof part === "string").join(".") : undefined;
const path = Array.isArray(issue.path)
? issue.path
.filter((part) => typeof part === "string" || typeof part === "number")
.map(String)
.join(".")
: undefined;
return [{ path: path || undefined, message: issue.message }];
});
} catch {
@@ -45,10 +50,15 @@ function parseZodIssueMessage(message: string): CustomWidgetSaveIssue[] {
function uniqueIssues(issues: CustomWidgetSaveIssue[]) {
return issues
.filter((issue, index) => issues.findIndex((candidate) => candidate.path === issue.path && candidate.message === issue.message) === index)
.filter(
(issue, index) =>
issues.findIndex((candidate) => candidate.path === issue.path && candidate.message === issue.message) === index,
)
.slice(0, MAX_SAVE_ISSUES);
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
return value !== null && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
@@ -0,0 +1,37 @@
"use client";
import { Alert, Button, CopyButton, Text, Textarea } from "@mantine/core";
import { IconAlertCircle, IconCheck, IconCopy } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import type { CustomWidgetSaveIssue } from "./_custom-widget-save-errors";
export function CustomWidgetSaveIssuesAlert({ issues }: { issues: CustomWidgetSaveIssue[] }) {
const t = useScopedI18n("customWidget.workbench.saveError");
if (issues.length === 0) return null;
const value = issues.map((issue) => `${issue.path ? `${issue.path}: ` : ""}${issue.message}`).join("\n");
return (
<Alert color="red" title={t("title")} icon={<IconAlertCircle size={18} />}>
<Text size="sm" mb="xs">
{t("description")}
</Text>
<Textarea value={value} readOnly autosize minRows={2} maxRows={10} aria-label={t("errors")} mb="xs" />
<CopyButton value={value} timeout={2_000}>
{({ copied, copy }) => (
<Button
type="button"
size="compact-sm"
variant="light"
color={copied ? "green" : "red"}
leftSection={copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
onClick={copy}
>
{copied ? t("copied") : t("copy")}
</Button>
)}
</CopyButton>
</Alert>
);
}
@@ -133,7 +133,7 @@ export function CustomWidgetSourcesEditor({
{sources.map((source, index) => {
const baseUrlIssue = getCustomWidgetSourceUrlIssue(source.baseUrl);
return (
<Fieldset key={source.id} legend={index === 0 ? t("primary") : source.name}>
<Fieldset key={index} legend={index === 0 ? t("primary") : source.name}>
<Stack gap="sm">
<Group grow align="start">
<TextInput
@@ -0,0 +1,35 @@
"use client";
import { useEffect, useState } from "react";
import { CodeEditor } from "./_code-editor";
import { isRecord, parseJson } from "./_custom-widget-form-utils";
export function JsonPreviewEditor({
id,
label,
value,
onChange,
}: {
id: string;
label: string;
value: Record<string, unknown>;
onChange(value: Record<string, unknown>): void;
}) {
const serialized = JSON.stringify(value, null, 2);
const [draft, setDraft] = useState(serialized);
useEffect(() => setDraft(serialized), [serialized]);
return (
<CodeEditor
id={id}
label={label}
language="json"
value={draft}
onChange={(next) => {
setDraft(next);
const parsed = parseJson(next);
if (isRecord(parsed)) onChange(parsed);
}}
/>
);
}
@@ -15,7 +15,13 @@ import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
import { showErrorNotification, showSuccessNotification, showWarningNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { applyDefinition, buildDefinition, isRecord, loadPreviewQueries } from "./_custom-widget-form-utils";
import {
applyDefinition,
buildDefinition,
getCustomWidgetPreviewOptionIssues,
isRecord,
loadPreviewQueries,
} from "./_custom-widget-form-utils";
import type { PreviewState } from "./_custom-widget-preview-panel";
import { extractCustomWidgetSaveIssues } from "./_custom-widget-save-errors";
import type { CustomWidgetSaveIssue } from "./_custom-widget-save-errors";
@@ -28,6 +34,7 @@ interface FormActionsInput {
preview: PreviewState;
setPreview: Dispatch<SetStateAction<PreviewState>>;
setMobilePane: Dispatch<SetStateAction<"configure" | "preview">>;
optionsSnapshot: Record<string, unknown>;
setOptionsSnapshot: Dispatch<SetStateAction<Record<string, unknown>>>;
}
@@ -111,6 +118,15 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
});
return;
}
const optionIssues = getCustomWidgetPreviewOptionIssues(input.candidate.data, input.optionsSnapshot);
if (optionIssues.length > 0) {
const issue = optionIssues[0];
showErrorNotification({
title: w("section.preview"),
message: issue ? `${issue.path}: ${issue.message}` : w("invalidWidget"),
});
return;
}
input.setMobilePane("preview");
input.setPreview({ data: {}, status: {}, session: null, outcome: "loading" });
setPreviewPending(true);
@@ -118,6 +134,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
const created = await previewMutation.mutateAsync({
definition: input.candidate.data,
definitionId: input.definitionId,
options: input.optionsSnapshot,
secrets: input.form.values.secrets
.filter((secret) => secret.value.trim())
.map(({ sourceId, kind, value }) => ({
@@ -126,8 +143,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
value,
})),
});
input.setOptionsSnapshot(input.candidate.data.defaultOptions);
const snapshot = await loadPreviewQueries(input.candidate.data, created.previewSession.id);
const snapshot = await loadPreviewQueries(input.candidate.data, created.previewSession.id, input.optionsSnapshot);
const failed = Object.values(snapshot.status).filter((status) => isRecord(status) && status.ok === false).length;
input.setPreview({
...snapshot,
@@ -166,6 +182,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
throw new Error(formatCustomWidgetImportIssues(result.issues));
}
applyDefinition(input.form, customWidgetDefinitionSchema.parse(result.widget));
input.setOptionsSnapshot(result.widget.defaultOptions);
input.setPreview({ data: {}, status: {}, session: null, outcome: "idle" });
showSuccessNotification({
title: w("ai.response"),
@@ -204,7 +221,8 @@ function reportSaveIssues(
const messagesByPath = new Map<string, string[]>();
for (const issue of issues) {
if (!issue.path) continue;
messagesByPath.set(issue.path, [...(messagesByPath.get(issue.path) ?? []), issue.message]);
const field = issue.path.split(".")[0] ?? issue.path;
messagesByPath.set(field, [...(messagesByPath.get(field) ?? []), issue.message]);
}
for (const [path, messages] of messagesByPath) {
form.setFieldError(path, messages.join(" "));
@@ -217,5 +235,7 @@ function reportSaveIssues(
}
function clearSaveIssues(form: UseFormReturnType<CustomWidgetFormValues>, issues: CustomWidgetSaveIssue[]) {
for (const path of new Set(issues.flatMap((issue) => (issue.path ? [issue.path] : [])))) form.clearFieldError(path);
for (const path of new Set(issues.flatMap((issue) => (issue.path ? [issue.path.split(".")[0] ?? issue.path] : [])))) {
form.clearFieldError(path);
}
}
@@ -17,7 +17,7 @@ export default async function CustomWidgetPreviewPage({ params }: { params: Prom
const results = await Promise.all(
loadRequests.map(async (request) => {
const result = await Promise.resolve()
.then(() => resolveCustomWidgetOptionsBinding(request, preview.defaultOptions))
.then(() => resolveCustomWidgetOptionsBinding(request, preview.options))
.then((requestParams) =>
api.customWidget.previewQuery({ sessionId: id, requestId: request.id, params: requestParams }),
)
@@ -57,7 +57,7 @@ export default async function CustomWidgetPreviewPage({ params }: { params: Prom
template: preview.template,
data,
status,
options: preview.defaultOptions,
options: preview.options,
requestCapabilities: preview.requests,
previewSessionId: preview.id,
previewLiveActions: preview.liveActions,
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { CUSTOM_WIDGET_MCP_AUTHORING_PROMPT } from "@homarr/custom-widgets/core";
import { CUSTOM_WIDGET_MCP_AUTHORING_PROMPT } from "@homarr/custom-widgets/authoring-prompt";
export const dynamic = "force-static";
@@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
import { getCustomWidgetComponentCatalog } from "@homarr/custom-widgets/authoring-resources";
export const dynamic = "force-static";
export function GET() {
return NextResponse.json(getCustomWidgetComponentCatalog());
}
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { getCustomWidgetSkill } from "@homarr/custom-widgets/core";
import { getCustomWidgetSkill } from "@homarr/custom-widgets/authoring-resources";
export const dynamic = "force-static";
@@ -18,14 +18,13 @@ import { API_KEY_HEADER_NAME, getSessionFromApiKeyAsync } from "@homarr/auth/api
import { extractBaseUrlFromHeaders } from "@homarr/common";
import { ipAddressFromHeaders } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { buildCustomWidgetMcpPrompt } from "@homarr/custom-widgets/authoring-prompt";
import {
buildCustomWidgetMcpPrompt,
customJsxExamples,
getCustomWidgetComponent,
getCustomWidgetComponentCatalog,
getCustomWidgetJsonSchema,
getCustomWidgetSkill,
} from "@homarr/custom-widgets/core";
} from "@homarr/custom-widgets/authoring-resources";
import { customJsxExamples, getCustomWidgetJsonSchema } from "@homarr/custom-widgets/core";
import { db } from "@homarr/db";
import { getPackageVersion } from "~/versions/package-reader";
@@ -3,7 +3,6 @@ import { Avatar, Box, Button, Card, Center, Divider, Group, Image, Stack, Text,
import { IconApi } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { createId, objectEntries } from "@homarr/common";
import { getIconUrl, getIntegrationName } from "@homarr/definitions";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
@@ -17,14 +16,13 @@ import { WidgetEditModal } from "@homarr/widgets/modals";
import { useItemActions } from "./item-actions";
export const ItemSelectModal = createModal<void>(({ actions }) => {
export const ItemSelectModal = createModal<{ boardId: string }>(({ actions, innerProps }) => {
const [search, setSearch] = useState("");
const t = useI18n();
const { createItem, updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
const { openModal: openEditModal } = useModalAction(WidgetEditModal);
const { data: integrationData } = clientApi.integration.all.useQuery();
const board = useRequiredBoard();
const { data: customWidgetDefs } = clientApi.customWidget.available.useQuery({ boardId: board.id });
const { data: customWidgetDefs } = clientApi.customWidget.available.useQuery({ boardId: innerProps.boardId });
const settings = useSettings();
const availableKinds = useMemo(() => new Set((integrationData ?? []).map((i) => i.kind)), [integrationData]);
@@ -34,12 +34,11 @@ import {
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { WorkshopReport, WorkshopSubmissionDetail, WorkshopUser } from "@homarr/workshop";
import { WORKSHOP_API_URL, WorkshopClient, workshopExportFilename } from "@homarr/workshop";
import { customWidgetImportSchema } from "@homarr/custom-widgets/core";
import { validateWorkshopWidget, workshopExportFilename } from "@homarr/workshop";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
const workshopUrl = process.env.NEXT_PUBLIC_WORKSHOP_API_URL ?? WORKSHOP_API_URL;
import { createWorkshopClient } from "./workshop-client";
function downloadWorkshopSubmission(submission: WorkshopSubmissionDetail) {
const url = URL.createObjectURL(new Blob([submission.content], { type: "application/json" }));
@@ -52,7 +51,7 @@ function downloadWorkshopSubmission(submission: WorkshopSubmissionDetail) {
export function WorkshopBrowser({ onInstall }: { onInstall?(submission: WorkshopSubmissionDetail): Promise<void> }) {
const t = useScopedI18n("workshop");
const client = useMemo(() => new WorkshopClient(workshopUrl), []);
const client = useMemo(createWorkshopClient, []);
const queryClient = useQueryClient();
const [user, setUser] = useState<WorkshopUser | null>(null);
const [page, setPage] = useState(1);
@@ -82,6 +81,10 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
queryFn: ({ signal }) => client.get(selectedId ?? "", signal),
enabled: selectedId !== null,
});
const detailValidation = useMemo(
() => (detail.data ? validateWorkshopWidget(detail.data.content) : null),
[detail.data],
);
const signIn = () => {
setLoginPending(true);
setLoginError(null);
@@ -213,7 +216,9 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
)}
<Stack gap="xs" mt="md">
<Group justify="space-between">
<Badge variant="light">v{item.revision}</Badge>
<Text size="xs" c="dimmed">
{t("author", { name: item.authorName })}
</Text>
<Text size="sm" c="dimmed">
<IconThumbUp size={13} /> {item.upvotes} · <IconThumbDown size={13} /> {item.downvotes}
</Text>
@@ -224,9 +229,6 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
<Text size="sm" c="dimmed" lineClamp={2}>
{item.description || t("noDescription")}
</Text>
<Text size="xs" c="dimmed">
{t("author", { name: item.authorName })}
</Text>
<Button variant="light" onClick={() => setSelectedId(item.id)}>
{t("inspect")}
</Button>
@@ -256,12 +258,9 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
) : (
<Stack>
<Text>{detail.data.description}</Text>
<Group>
<Badge>{t("revision", { revision: detail.data.revision })}</Badge>
<Text size="sm" c="dimmed">
SHA-256 {detail.data.contentHash.slice(0, 12)}…
</Text>
</Group>
<Text size="sm" c="dimmed">
{t("author", { name: detail.data.authorName })}
</Text>
{detail.data.screenshots.length > 0 && (
<SimpleGrid cols={{ base: 1, sm: 2 }}>
{detail.data.screenshots.map((file, index) => (
@@ -275,6 +274,11 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
</SimpleGrid>
)}
<CapabilitySummary content={detail.data.content} />
{detailValidation && !detailValidation.success && (
<Alert color="red" icon={<IconAlertTriangle size={18} />}>
{t("installErrorDescription")} {detailValidation.error}
</Alert>
)}
<Code block mah={360} style={{ overflow: "auto", whiteSpace: "pre" }}>
{detail.data.content}
</Code>
@@ -314,7 +318,11 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
</Button>
</Group>
{onInstall && (
<Button loading={install.isPending} onClick={() => install.mutate(detail.data)}>
<Button
loading={install.isPending}
disabled={!detailValidation?.success}
onClick={() => install.mutate(detail.data)}
>
{t("install")}
</Button>
)}
@@ -361,13 +369,7 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
function CapabilitySummary({ content }: { content: string }) {
const t = useScopedI18n("workshop");
let candidate: unknown;
try {
candidate = JSON.parse(content) as unknown;
} catch {
return null;
}
const parsed = customWidgetImportSchema.safeParse(candidate);
const parsed = validateWorkshopWidget(content);
if (!parsed.success) return null;
return (
<SimpleGrid cols={{ base: 1, sm: 2 }}>
@@ -0,0 +1,10 @@
import { WorkshopClient, WORKSHOP_API_URL } from "@homarr/workshop";
export function createWorkshopClient() {
const runtimeUrl =
typeof document === "undefined"
? undefined
: document.querySelector<HTMLMetaElement>('meta[name="homarr-workshop-api-url"]')?.content;
return new WorkshopClient(runtimeUrl || WORKSHOP_API_URL);
}
@@ -7,6 +7,7 @@ import { IconBuildingStore } from "@tabler/icons-react";
import { customWidgetImportSchema } from "@homarr/custom-widgets/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
@@ -25,9 +26,22 @@ export function WorkshopInstallButton() {
<Modal opened={opened} onClose={controls.close} title={t("installDialog")} size="90%">
<WorkshopBrowser
onInstall={async (submission) => {
const widget = customWidgetImportSchema.parse(JSON.parse(submission.content));
setPendingWidget(widget);
reviewControls.open();
try {
const parsed = customWidgetImportSchema.safeParse(JSON.parse(submission.content) as unknown);
if (!parsed.success) throw new Error(t("installErrorDescription"));
setPendingWidget(parsed.data);
reviewControls.open();
} catch (error) {
showErrorNotification({
title: t("installError"),
message:
error instanceof SyntaxError
? t("installErrorDescription")
: error instanceof Error
? error.message
: t("installErrorDescription"),
});
}
}}
/>
</Modal>
@@ -6,20 +6,36 @@ import { useDisclosure } from "@mantine/hooks";
import { customWidgetImportSchema } from "@homarr/custom-widgets/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import type { WorkshopSubmissionDetail } from "@homarr/workshop";
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
import { WorkshopBrowser } from "./workshop-browser";
export function WorkshopInstaller() {
const t = useScopedI18n("workshop");
const router = useRouter();
const [pendingWidget, setPendingWidget] = useState<HomarrCustomWidgetV2 | null>(null);
const [reviewOpened, reviewControls] = useDisclosure(false);
const install = async (submission: WorkshopSubmissionDetail) => {
const widget = customWidgetImportSchema.parse(JSON.parse(submission.content) as unknown);
setPendingWidget(widget);
reviewControls.open();
try {
const parsed = customWidgetImportSchema.safeParse(JSON.parse(submission.content) as unknown);
if (!parsed.success) throw new Error(t("installErrorDescription"));
setPendingWidget(parsed.data);
reviewControls.open();
} catch (error) {
showErrorNotification({
title: t("installError"),
message:
error instanceof SyntaxError
? t("installErrorDescription")
: error instanceof Error
? error.message
: t("installErrorDescription"),
});
}
};
return (
@@ -6,10 +6,9 @@ import { Alert, Button, FileInput, Group, Modal, Stack, Textarea, TextInput } fr
import { clientApi } from "@homarr/api/client";
import { showErrorNotification } from "@homarr/notifications";
import type { WorkshopUser } from "@homarr/workshop";
import { WORKSHOP_API_URL, WorkshopClient } from "@homarr/workshop";
import { useScopedI18n } from "@homarr/translation/client";
const workshopUrl = process.env.NEXT_PUBLIC_WORKSHOP_API_URL ?? WORKSHOP_API_URL;
import { createWorkshopClient } from "./workshop-client";
export function WorkshopPublishModal({
opened,
@@ -21,17 +20,15 @@ export function WorkshopPublishModal({
widget: { id: string; name: string };
}) {
const t = useScopedI18n("workshop");
const client = useMemo(() => new WorkshopClient(workshopUrl), []);
const client = useMemo(createWorkshopClient, []);
const utils = clientApi.useUtils();
const [user, setUser] = useState<WorkshopUser | null>(null);
const [title, setTitle] = useState(widget.name);
const [description, setDescription] = useState("");
const [changelog, setChangelog] = useState("");
const [screenshots, setScreenshots] = useState<File[]>([]);
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [published, setPublished] = useState(false);
const initialChangelog = t("publish.initialChangelog");
useEffect(() => {
const unsubscribe = client.subscribeToAuth(setUser);
@@ -42,18 +39,17 @@ export function WorkshopPublishModal({
if (!opened) return;
setTitle(widget.name);
setDescription("");
setChangelog(initialChangelog);
setScreenshots([]);
setError(null);
setPublished(false);
}, [initialChangelog, opened, widget.name]);
}, [opened, widget.name]);
const publish = async () => {
setPending(true);
setError(null);
try {
const definition = await utils.customWidget.export.fetch({ id: widget.id });
await client.create({ title, description, changelog, content: JSON.stringify(definition) }, screenshots);
await client.create({ title, description, content: JSON.stringify(definition) }, screenshots);
setPublished(true);
} catch (cause) {
setError(cause instanceof Error ? cause.message : t("publish.error"));
@@ -102,12 +98,6 @@ export function WorkshopPublishModal({
onChange={(event) => setDescription(event.currentTarget.value)}
minRows={3}
/>
<Textarea
label={t("publish.changelog")}
value={changelog}
onChange={(event) => setChangelog(event.currentTarget.value)}
minRows={2}
/>
<FileInput
label={t("publish.screenshots")}
description={t("publish.screenshotsDescription")}
+3
View File
@@ -1,3 +1,5 @@
import { z } from "zod/v4";
import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env";
export const env = createEnv({
@@ -5,6 +7,7 @@ export const env = createEnv({
UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false),
DEMO_MODE: createBooleanSchema(false),
DEMO_READ_ONLY: createBooleanSchema(true),
WORKSHOP_API_URL: z.string().url().optional(),
},
experimental__runtimeEnv: process.env,
});