fix(custom-widgets): harden v2 authoring
This commit is contained in:
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({
|
||||
|
||||
@@ -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}>
|
||||
|
||||
+4
-7
@@ -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",
|
||||
};
|
||||
}
|
||||
+131
-2
@@ -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");
|
||||
});
|
||||
});
|
||||
+34
-75
@@ -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;
|
||||
}
|
||||
+37
@@ -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>
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+25
-5
@@ -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")}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
Reference in new issue
Block a user