diff --git a/.agents/skills/homarr-custom-widget/SKILL.md b/.agents/skills/homarr-custom-widget/SKILL.md index 5999daa91..9869523fa 100644 --- a/.agents/skills/homarr-custom-widget/SKILL.md +++ b/.agents/skills/homarr-custom-widget/SKILL.md @@ -1,6 +1,6 @@ --- name: homarr-custom-widget -description: Author, validate, preview, and install safe Homarr Custom JSX v2 widgets from API documentation. Use when creating or fixing Homarr custom widgets, including multi-source dashboards, queries, actions, options, local state, and Mantine JSX. +description: Author, validate, preview, and install safe Homarr Custom JSX v2 widgets from API documentation. Use when creating or fixing Homarr custom widgets, including multi-source dashboards, queries, actions, options, temporary inputs, and Mantine JSX. license: Apache-2.0 metadata: version: "2.0.0" @@ -23,7 +23,7 @@ Create one widget at a time. Repeat the workflow when the user asks for several ## Output rules -- Use inline sources, named requests, definition-owned options, session-local state, and one safe JSX template. +- Use inline sources, named requests, definition-owned options, temporary bound inputs, and one safe JSX template. - Use `kind`, not the HTTP method, to distinguish reads from user-triggered actions. - Never put a credential in JSX, options, headers, URLs, examples, exports, logs, or chat output. - Use declarative `bind` controls and Homarr request components; never emit imports, hooks, callbacks, browser requests, or arbitrary JavaScript. diff --git a/.agents/skills/homarr-custom-widget/references/runtime.md b/.agents/skills/homarr-custom-widget/references/runtime.md index bd19682b9..143f5d091 100644 --- a/.agents/skills/homarr-custom-widget/references/runtime.md +++ b/.agents/skills/homarr-custom-widget/references/runtime.md @@ -1,6 +1,6 @@ # JSX runtime -Templates read four roots: `data.`, `status.`, `options`, and `state`. +Templates read four roots: `data.`, `status.`, `options`, and `inputs`. Use Mantine Core, Dates, Charts, safe Tabler icons, and Homarr runtime components. Safe installed Mantine exports are discovered automatically. Ordinary serializable props and Mantine style props pass through; `style` and `styles` are scoped and sanitized. @@ -16,6 +16,6 @@ Bind local controls declaratively: ({ value: id, label: widget.name }))} + value={selected.id} + onChange={(value) => { + if (value) setSelectedId(value); + }} + allowDeselect={false} + /> + + {selected.widget.description} + + + + + + ); +} + +function getDocumentationColorScheme() { + return document.documentElement.dataset.theme === "dark" ? "dark" : "light"; +} diff --git a/apps/docs/static/downloads/homarr-custom-widget-2.0.0.zip b/apps/docs/static/downloads/homarr-custom-widget-2.0.0.zip deleted file mode 100644 index 745aa616a..000000000 Binary files a/apps/docs/static/downloads/homarr-custom-widget-2.0.0.zip and /dev/null differ diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index 7f4e536a5..07917bdc5 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -14,12 +14,6 @@ "with-env": "dotenv -e ../../.env --" }, "dependencies": { - "@codemirror/autocomplete": "catalog:", - "@codemirror/commands": "catalog:", - "@codemirror/lang-javascript": "catalog:", - "@codemirror/lang-json": "catalog:", - "@codemirror/lint": "catalog:", - "@codemirror/view": "catalog:", "@dnd-kit/core": "catalog:", "@dnd-kit/modifiers": "catalog:", "@dnd-kit/sortable": "catalog:", @@ -76,7 +70,6 @@ "@trpc/next": "catalog:", "@trpc/react-query": "catalog:", "@trpc/server": "catalog:", - "@uiw/react-codemirror": "catalog:", "@xterm/addon-canvas": "catalog:", "@xterm/addon-fit": "catalog:", "@xterm/xterm": "catalog:", diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_code-editor.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_code-editor.tsx index cc2aec75b..82d9db64a 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_code-editor.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_code-editor.tsx @@ -1,276 +1,48 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; -import dynamic from "next/dynamic"; -import { autocompletion } from "@codemirror/autocomplete"; -import type { Completion } from "@codemirror/autocomplete"; -import { redo, redoDepth, undo, undoDepth } from "@codemirror/commands"; -import { javascript } from "@codemirror/lang-javascript"; -import { json } from "@codemirror/lang-json"; -import { linter } from "@codemirror/lint"; -import { EditorView } from "@codemirror/view"; -import { Badge, Button, Group, Input, Loader, Text, useComputedColorScheme } from "@mantine/core"; -import { IconArrowBackUp, IconArrowForwardUp, IconCheck, IconCode, IconFileCode, IconWand } from "@tabler/icons-react"; -import { ComponentReference } from "@homarr/custom-widgets/workbench"; -import type { EditorDiagnostic } from "@homarr/custom-widgets/workbench"; +import type { ComponentProps } from "react"; + +import { CustomWidgetCodeEditor } from "@homarr/custom-widgets/workbench"; import { useScopedI18n } from "@homarr/translation/client"; -import { CodeEditorDiagnostics } from "./_code-editor-diagnostics"; -import { createCustomJsxCompletionSource, customJsxComponentHover } from "./_custom-widget-code-language"; -import classes from "./_code-editor.module.css"; +type SharedEditorProps = ComponentProps; +type CodeEditorProps = Omit; -const Editor = dynamic(() => import("@uiw/react-codemirror").then((module) => module.default), { - ssr: false, - loading: () => ( - - - - ), -}); - -interface CodeEditorProps { - id: string; - value: string; - onChange: (value: string) => void; - label: string; - description?: string; - placeholder?: string; - language: "jsx" | "json"; - diagnostics?: EditorDiagnostic[]; - error?: React.ReactNode; - required?: boolean; - maxLength?: number; - starter?: string; - completions?: Completion[]; - revealText?: string; - revealKey?: number; -} - -export function CodeEditor({ - id, - value, - onChange, - label, - description, - placeholder, - language, - diagnostics = [], - error, - required, - maxLength, - starter, - completions = [], - revealText, - revealKey, -}: CodeEditorProps) { +export function CodeEditor(props: CodeEditorProps) { const t = useScopedI18n("customWidget.editor"); - const [cursor, setCursor] = useState({ line: 1, column: 1 }); - const [editorView, setEditorView] = useState(null); - const [historyDepth, setHistoryDepth] = useState({ undo: 0, redo: 0 }); - const colorScheme = useComputedColorScheme("light"); - const extensions = useMemo(() => { - const diagnosticsExtension = linter((view) => - diagnostics.map((diagnostic) => { - const from = Math.min(diagnostic.index ?? 0, view.state.doc.length); - return { - from, - to: Math.min(from + 1, view.state.doc.length), - severity: diagnostic.severity, - message: t(`diagnostics.${diagnostic.code}` as never, { value: diagnostic.value ?? "" } as never), - }; - }), - ); - const accessibilityExtension = EditorView.contentAttributes.of({ - id, - "aria-label": label, - }); - return language === "jsx" - ? [ - javascript({ jsx: true }), - autocompletion({ override: [createCustomJsxCompletionSource(completions)] }), - customJsxComponentHover, - diagnosticsExtension, - accessibilityExtension, - ] - : [json(), diagnosticsExtension, accessibilityExtension]; - }, [completions, diagnostics, id, label, language, t]); - const errorCount = diagnostics.filter((diagnostic) => diagnostic.severity === "error").length; - const warningCount = diagnostics.length - errorCount; - - useEffect(() => { - if (!editorView || !revealText) return; - const from = editorView.state.doc.toString().indexOf(revealText); - if (from < 0) return; - editorView.dispatch({ - selection: { anchor: from, head: from + revealText.length }, - effects: EditorView.scrollIntoView(from, { y: "center" }), - }); - editorView.focus(); - }, [editorView, revealKey, revealText]); - - const formattedValue = useMemo(() => { - if (language === "json") { - try { - return `${JSON.stringify(JSON.parse(value) as unknown, null, 2)}\n`; - } catch { - return value; - } - } - return value - .replace(/\r\n?/gu, "\n") - .split("\n") - .map((line) => line.trimEnd()) - .join("\n") - .trim(); - }, [language, value]); - return ( - -
- - - - - {language === "jsx" ? t("language.jsx") : t("language.json")} - - - - - - {language === "jsx" && ( - t("componentReference.count", { count }), - }} - /> - )} - {starter && value.trim().length === 0 && ( - - )} - - - - -
- { - if (!update.selectionSet && !update.docChanged) return; - const head = update.state.selection.main.head; - const line = update.state.doc.lineAt(head); - setCursor({ line: line.number, column: head - line.from + 1 }); - setHistoryDepth({ undo: undoDepth(update.state), redo: redoDepth(update.state) }); - }} - /> -
- - - - {errorCount > 0 ? ( - - {t("status.errors", { count: errorCount })} - - ) : warningCount > 0 ? ( - - {t("status.warnings", { count: warningCount })} - - ) : ( - - - - {t("status.ready")} - - - )} - - {t("status.position", cursor)} - - - maxLength * 0.95 ? (value.length > maxLength ? "red" : "orange") : "dimmed"} - > - {maxLength - ? t("status.charactersWithLimit", { count: value.length, limit: maxLength }) - : t("status.characters", { count: value.length })} - - -
- - + t("componentReference.count", { count }), + insertStarter: t("action.insertStarter"), + format: t("action.format"), + copy: t("action.copy"), + copied: t("action.copied"), + schema: t("action.schema"), + schemaTab: t("reference.schema"), + minimalTab: t("reference.minimal"), + fullTab: t("reference.full"), + errors: (count) => t("status.errors", { count }), + warnings: (count) => t("status.warnings", { count }), + ready: t("status.ready"), + position: (cursor) => t("status.position", cursor), + characters: (count, limit) => + limit ? t("status.charactersWithLimit", { count, limit }) : t("status.characters", { count }), + diagnosticsTitle: t("diagnostics.title"), + diagnostic: (diagnostic) => `${diagnostic.line ? `${t("diagnostics.line", { line: diagnostic.line })}: ` : ""}${t( `diagnostics.${diagnostic.code}` as never, { value: diagnostic.value ?? "" } as never, - )}` - } - /> -
+ )}`, + }} + /> ); } diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx index a5c3d4d90..ca74d69f8 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx @@ -7,7 +7,12 @@ import { IconBuildingStore, IconCopy, IconDots, IconDownload, IconTrash, IconUpl import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; -import { getImportReview, parseCustomWidgetClipboard } from "@homarr/custom-widgets/core"; +import { + formatCustomWidgetImportIssues, + getImportReview, + looksLikeCustomWidgetClipboard, + parseCustomWidgetClipboardDetailed, +} from "@homarr/custom-widgets/core"; import { ImportReviewDialog } from "@homarr/custom-widgets/workbench"; import { useConfirmModal } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; @@ -161,14 +166,22 @@ export const ImportCustomWidgetButton = () => { const handlePaste = (event: ClipboardEvent) => { const target = event.target as HTMLElement | null; if (target?.matches("input, textarea, [contenteditable='true']")) return; - const widget = parseCustomWidgetClipboard(event.clipboardData?.getData("text/plain") ?? ""); - if (!widget) return; + const text = event.clipboardData?.getData("text/plain") ?? ""; + if (!looksLikeCustomWidgetClipboard(text)) return; event.preventDefault(); - queueImport(widget); + const result = parseCustomWidgetClipboardDetailed(text); + if (!result.success) { + showErrorNotification({ + title: t("action.import"), + message: formatCustomWidgetImportIssues(result.issues), + }); + return; + } + queueImport(result.widget); }; window.addEventListener("paste", handlePaste); return () => window.removeEventListener("paste", handlePaste); - }, [queueImport]); + }, [queueImport, t]); const handleImport = (event: React.ChangeEvent) => { const file = event.target.files?.[0]; @@ -177,11 +190,14 @@ export const ImportCustomWidgetButton = () => { const reader = new FileReader(); reader.addEventListener("load", (e) => { try { - const widget = parseCustomWidgetClipboard(e.target?.result as string); - if (!widget) throw new Error("Invalid import"); - queueImport(widget); - } catch { - showErrorNotification({ title: t("action.import"), message: t("notification.importError") }); + const result = parseCustomWidgetClipboardDetailed(e.target?.result as string); + if (!result.success) throw new Error(formatCustomWidgetImportIssues(result.issues)); + queueImport(result.widget); + } catch (error) { + showErrorNotification({ + title: t("action.import"), + message: error instanceof Error ? error.message : t("notification.importError"), + }); } }); reader.addEventListener("error", () => { diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-analysis.ts b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-analysis.ts index 31f140651..22225ea41 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-analysis.ts +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-analysis.ts @@ -5,8 +5,6 @@ import { isRecord, parseJson, parseSources } from "./_custom-widget-form-utils"; export function createCustomWidgetCompletions(values: CustomWidgetFormValues, requestIds: string[]) { const schema = parseJson(values.optionsSchema); const optionKeys = Object.keys(isRecord(schema) && isRecord(schema.properties) ? schema.properties : {}); - const state = parseJson(values.stateSchema); - const stateKeys = Object.keys(isRecord(state) ? state : {}); return [ ...requestIds.flatMap((id) => [ { label: `data["${id}"]`, type: "variable", detail: `Response from ${id}` }, @@ -15,7 +13,7 @@ export function createCustomWidgetCompletions(values: CustomWidgetFormValues, re ]), ...parseSources(values.sources).map(({ id }) => ({ label: id, type: "constant", detail: "Source ID" })), ...optionKeys.map((key) => ({ label: `options.${key}`, type: "variable", detail: "Widget option" })), - ...stateKeys.map((key) => ({ label: `state.${key}`, type: "variable", detail: "Local state" })), + { label: "inputs", type: "variable", detail: "Temporary bound input values" }, ]; } @@ -31,7 +29,6 @@ export function getInvalidCustomWidgetSections( else if (field === "sources") result.add("sources"); else if (field === "requests") result.add("requests"); else if (["optionsSchema", "defaultOptions"].includes(field)) result.add("options"); - else if (["stateSchema", "defaultState"].includes(field)) result.add("state"); else if (field === "template") result.add("jsx"); } if (requestDiagnostics.some((entry) => entry.severity === "error")) result.add("requests"); diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-utils.ts b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-utils.ts index 3d240be75..9df4cc319 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-utils.ts +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-utils.ts @@ -16,8 +16,6 @@ export function buildDefinition(values: CustomWidgetFormValues) { requests: parseJson(values.requests), optionsSchema: parseJson(values.optionsSchema), defaultOptions: parseJson(values.defaultOptions), - stateSchema: parseJson(values.stateSchema), - defaultState: parseJson(values.defaultState), template: values.template, }); } @@ -32,8 +30,6 @@ export function applyDefinition(form: CustomWidgetWorkbenchForm, widget: HomarrC requests: JSON.stringify(widget.requests, null, 2), optionsSchema: JSON.stringify(widget.optionsSchema, null, 2), defaultOptions: JSON.stringify(widget.defaultOptions, null, 2), - stateSchema: JSON.stringify(widget.stateSchema ?? {}, null, 2), - defaultState: JSON.stringify(widget.defaultState ?? {}, null, 2), template: widget.template, }); } diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx index 146bdbf4d..145747c17 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useMemo, useRef, useState } from "react"; -import { Box, Button, Paper, SegmentedControl, Stack, Text, TextInput, Textarea } from "@mantine/core"; +import { Box, Button, Paper, SegmentedControl, Stack, TextInput, Textarea } from "@mantine/core"; import { IconAlertCircle, IconApi, @@ -12,6 +12,13 @@ import { 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, @@ -28,7 +35,7 @@ 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 { applyDefinition, buildDefinition, isRecord, parseJson, parseJsonArray } from "./_custom-widget-form-utils"; +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"; @@ -47,11 +54,26 @@ const sectionLinks = [ ["sources", "section.sources", IconApi], ["requests", "section.requests", IconDatabase], ["options", "section.options", IconBraces], - ["state", "section.state", IconSettings], ["jsx", "section.jsx", IconCode], ["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"); @@ -65,7 +87,6 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi const [previewSize, setPreviewSize] = useState("standard"); const [previewTheme, setPreviewTheme] = useState<"light" | "dark">("dark"); const [optionsSnapshot, setOptionsSnapshot] = useState>({}); - const [stateSnapshot, setStateSnapshot] = useState>({}); const [revealedRequest, setRevealedRequest] = useState({ text: "", key: 0 }); const dirtyRef = useRef(false); dirtyRef.current = form.isDirty(); @@ -78,17 +99,13 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi ), [form.values.requests], ); - const stateKeys = useMemo(() => { - const state = parseJson(form.values.stateSchema); - return Object.keys(isRecord(state) ? state : {}); - }, [form.values.stateSchema]); const jsxCompletions = useMemo( () => createCustomWidgetCompletions(form.values, requestIds), [form.values, requestIds], ); const templateDiagnostics = useMemo( - () => analyzeJsxTemplate(form.values.template, { requestIds, stateKeys }), - [form.values.template, requestIds, stateKeys], + () => analyzeJsxTemplate(form.values.template, { requestIds }), + [form.values.template, requestIds], ); const requestDiagnostics = useMemo(() => analyzeRequestManifest(form.values.requests), [form.values.requests]); const hasDiagnostics = [...templateDiagnostics, ...requestDiagnostics].some((entry) => entry.severity === "error"); @@ -113,7 +130,6 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi setPreview, setMobilePane, setOptionsSnapshot, - setStateSnapshot, request, documentationUrl, }); @@ -188,6 +204,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi error={form.errors.requests} revealText={revealedRequest.text} revealKey={revealedRequest.key} + reference={requestReference} required /> @@ -199,6 +216,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi value={form.values.optionsSchema} onChange={(value) => form.setFieldValue("optionsSchema", value)} error={form.errors.optionsSchema} + reference={optionsSchemaReference} /> form.setFieldValue("defaultOptions", value)} error={form.errors.defaultOptions} - /> - - - - {w("state.description")} - - form.setFieldValue("stateSchema", value)} - error={form.errors.stateSchema} - /> - form.setFieldValue("defaultState", value)} - error={form.errors.defaultState} + reference={defaultOptionsReference} /> @@ -282,8 +280,6 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi onThemeChange={setPreviewTheme} optionsSnapshot={optionsSnapshot} onOptionsChange={setOptionsSnapshot} - stateSnapshot={stateSnapshot} - onStateChange={setStateSnapshot} onLiveActionsChange={(enabled) => setPreview((current) => ({ ...current, diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview-action.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview-action.tsx index b6230a84e..421b4c3cb 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview-action.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview-action.tsx @@ -15,19 +15,17 @@ export function PreviewActionControl({ request, sessionId, options, - state, }: { request: HomarrCustomWidgetV2["requests"][number]; sessionId?: string; options: Record; - state: Record; }) { 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 = state[name] ?? options[name]; + const bound = options[name]; if (typeof bound === type) return [name, bound]; return [name, type === "number" ? 0 : type === "boolean" ? false : ""]; }), diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview-panel.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview-panel.tsx index d37725ef0..4367ef867 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview-panel.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview-panel.tsx @@ -43,8 +43,6 @@ interface PreviewPanelProps { onThemeChange(value: "light" | "dark"): void; optionsSnapshot: Record; onOptionsChange(value: Record): void; - stateSnapshot: Record; - onStateChange(value: Record): void; onLiveActionsChange(enabled: boolean): void; } @@ -75,8 +73,6 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) { data: fixtureData, status: fixtureStatus, options: props.optionsSnapshot, - stateSchema: candidate.stateSchema ?? {}, - defaultState: props.stateSnapshot, requestCapabilities: candidate.requests.map( ({ id, kind, method, trigger, minimumBoardPermission, confirmation, invalidates }) => ({ id, @@ -92,7 +88,6 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) { previewLiveActions: props.preview.session?.liveActions ?? false, queriesDisabled: fixture !== "live", isEditMode: fixture !== "live", - onStateChange: props.onStateChange, }; return ( @@ -132,7 +127,7 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) { /> - {(["widget", "data", "options", "state", "actions", "diagnostics"] as const).map((tab) => ( + {(["widget", "data", "options", "actions", "diagnostics"] as const).map((tab) => ( {t(`tab.${tab}`)} @@ -164,14 +159,6 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) { onChange={props.onOptionsChange} /> - - - ))} {candidate.requests.every((request) => request.kind !== "action") && ( diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_use-custom-widget-form-actions.ts b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_use-custom-widget-form-actions.ts index 529d99dcc..fc8e6eb34 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_use-custom-widget-form-actions.ts +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_use-custom-widget-form-actions.ts @@ -1,5 +1,6 @@ "use client"; +import { useState } from "react"; import type { Dispatch, SetStateAction } from "react"; import { useRouter } from "next/navigation"; import type { UseFormReturnType } from "@mantine/form"; @@ -8,7 +9,8 @@ import { clientApi, fetchApi } from "@homarr/api/client"; import { buildCustomWidgetFixPrompt, customWidgetDefinitionSchema, - parseCustomWidgetClipboardDetailed, + formatCustomWidgetImportIssues, + parseCustomWidgetAiResponse, } from "@homarr/custom-widgets/core"; import type { EditorDiagnostic, CustomWidgetFormValues } from "@homarr/custom-widgets/workbench"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; @@ -28,7 +30,6 @@ interface FormActionsInput { setPreview: Dispatch>; setMobilePane: Dispatch>; setOptionsSnapshot: Dispatch>>; - setStateSnapshot: Dispatch>>; request: string; documentationUrl: string; } @@ -41,6 +42,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) { const createMutation = clientApi.customWidget.create.useMutation(); const updateMutation = clientApi.customWidget.update.useMutation(); const previewMutation = clientApi.customWidget.previewCreate.useMutation(); + const [importIssues, setImportIssues] = useState([]); const save = input.form.onSubmit(async (values) => { const definition = buildDefinition(values); @@ -112,7 +114,6 @@ export function useCustomWidgetFormActions(input: FormActionsInput) { })), }); input.setOptionsSnapshot(input.candidate.data.defaultOptions); - input.setStateSnapshot(input.candidate.data.defaultState ?? {}); const snapshot = await loadPreviewQueries(input.candidate.data, created.previewSession.id); input.setPreview({ ...snapshot, session: created.previewSession }); } catch { @@ -122,11 +123,21 @@ export function useCustomWidgetFormActions(input: FormActionsInput) { const pasteAiResponse = async () => { try { - const result = parseCustomWidgetClipboardDetailed(await navigator.clipboard.readText()); - if (!result.success) throw new Error(result.error); + const result = parseCustomWidgetAiResponse(await navigator.clipboard.readText()); + if (!result.success) { + setImportIssues(result.issues.map((issue) => `${issue.path?.join(".") ?? "widget"}: ${issue.message}`)); + throw new Error(formatCustomWidgetImportIssues(result.issues)); + } + setImportIssues([]); applyDefinition(input.form, customWidgetDefinitionSchema.parse(result.widget)); input.setPreview({ data: {}, status: {}, session: null }); - showSuccessNotification({ title: w("ai.response"), message: w("ai.loaded") }); + showSuccessNotification({ + title: w("ai.response"), + message: + result.warnings.length > 0 + ? `${w("ai.loaded")} ${formatCustomWidgetImportIssues(result.warnings)}` + : w("ai.loaded"), + }); } catch (error) { showErrorNotification({ title: w("ai.response"), @@ -140,6 +151,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) { try { const diagnostics = [...input.templateDiagnostics, ...input.requestDiagnostics] .map((entry) => `${entry.severity.toUpperCase()} ${entry.code}: ${entry.value ?? ""}`) + .concat(importIssues.map((issue) => `IMPORT: ${issue}`)) .join("\n"); const journal = input.preview.session ? await fetchApi.customWidget.previewJournal.query({ sessionId: input.preview.session.id }).catch(() => []) diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/edit/[id]/page.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/edit/[id]/page.tsx index b36983fcf..f622e6ff1 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/edit/[id]/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/edit/[id]/page.tsx @@ -40,8 +40,6 @@ export default async function EditCustomWidgetPage(props: EditCustomWidgetPagePr requests: JSON.stringify(definition.requests, null, 2), optionsSchema: JSON.stringify(definition.optionsSchema, null, 2), defaultOptions: JSON.stringify(definition.defaultOptions, null, 2), - stateSchema: JSON.stringify(definition.stateSchema ?? {}, null, 2), - defaultState: JSON.stringify(definition.defaultState ?? {}, null, 2), template: definition.template, secrets: definition.secrets.map((secret) => ({ sourceId: secret.sourceId, diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/page.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/page.tsx index d09e61a9d..5b9f24223 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/page.tsx @@ -1,6 +1,6 @@ import { redirect } from "next/navigation"; import { Group } from "@mantine/core"; -import { IconDownload, IconPlus } from "@tabler/icons-react"; +import { IconExternalLink, IconPlus } from "@tabler/icons-react"; import { api } from "@homarr/api/server"; import { auth } from "@homarr/auth/next"; @@ -30,8 +30,10 @@ export default async function CustomWidgetsPage() { } + href="https://www.skills.sh/homarr-labs/homarr/homarr-custom-widget" + target="_blank" + rel="noopener noreferrer" + leftSection={} variant="default" > {t("action.downloadSkill")} diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/preview/[id]/page.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/preview/[id]/page.tsx index a521e4769..e94b48e86 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/preview/[id]/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/preview/[id]/page.tsx @@ -60,8 +60,6 @@ export default async function CustomWidgetPreviewPage({ params }: { params: Prom data, status, options: preview.defaultOptions, - stateSchema: preview.stateSchema, - defaultState: preview.defaultState, requestCapabilities: preview.requests, previewSessionId: preview.id, previewLiveActions: preview.liveActions, diff --git a/apps/nextjs/src/app/api/mcp/[transport]/route.ts b/apps/nextjs/src/app/api/mcp/[transport]/route.ts index 807d24680..be341f82c 100644 --- a/apps/nextjs/src/app/api/mcp/[transport]/route.ts +++ b/apps/nextjs/src/app/api/mcp/[transport]/route.ts @@ -254,7 +254,7 @@ const mcpHandler = createMcpHandler( else if (uri === "homarr://custom-widgets/components") text = JSON.stringify(getCustomWidgetComponentCatalog()); else if (uri === "homarr://custom-widgets/skill") { const skill = getCustomWidgetSkill(); - text = `${skill.skillMd}\n\nMatching archive: ${skill.downloadUrl}\nSHA-256: ${skill.sha256}\n`; + text = `${skill.skillMd}\n\nInstall from skills.sh: ${skill.skillsShUrl}\nSource: ${skill.sourceUrl}\nCommand: ${skill.installCommand}\n`; mimeType = "text/markdown"; } else if (uri.startsWith("homarr://custom-widgets/components/")) { const name = decodeURIComponent(uri.slice("homarr://custom-widgets/components/".length)); diff --git a/apps/nextjs/src/components/workshop/workshop-browser.tsx b/apps/nextjs/src/components/workshop/workshop-browser.tsx index 05a6faf8e..f186cb1f6 100644 --- a/apps/nextjs/src/components/workshop/workshop-browser.tsx +++ b/apps/nextjs/src/components/workshop/workshop-browser.tsx @@ -36,7 +36,7 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tansta 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 { showSuccessNotification } from "@homarr/notifications"; +import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useScopedI18n } from "@homarr/translation/client"; const workshopUrl = process.env.NEXT_PUBLIC_WORKSHOP_API_URL ?? WORKSHOP_API_URL; @@ -63,6 +63,8 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop const [reportOpened, reportControls] = useDisclosure(false); const [reportCategory, setReportCategory] = useState("other"); const [reportExplanation, setReportExplanation] = useState(""); + const [loginPending, setLoginPending] = useState(false); + const [loginError, setLoginError] = useState(null); useEffect(() => { const unsubscribe = client.subscribeToAuth(setUser); @@ -80,7 +82,19 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop queryFn: ({ signal }) => client.get(selectedId ?? "", signal), enabled: selectedId !== null, }); - const login = useMutation({ mutationFn: () => client.signInWithGitHub(), onSuccess: setUser }); + const signIn = () => { + setLoginPending(true); + setLoginError(null); + void client + .signInWithGitHub() + .then(setUser) + .catch((cause: unknown) => { + const message = cause instanceof Error ? cause.message : t("signInError"); + setLoginError(message); + showErrorNotification({ title: t("signIn"), message }); + }) + .finally(() => setLoginPending(false)); + }; const install = useMutation({ mutationFn: async (submission: WorkshopSubmissionDetail) => onInstall?.(submission), onSuccess: () => setSelectedId(null), @@ -136,7 +150,7 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop ) : ( - )} @@ -145,7 +159,7 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop }> {t("securityNotice")} - {login.error && {login.error.message}} + {loginError && {loginError}} setError(cause.message)) + .catch((cause: Error) => { + setError(cause.message); + showErrorNotification({ title: t("signIn"), message: cause.message }); + }) } > {t("signIn")} diff --git a/apps/workshop/README.md b/apps/workshop/README.md index 7a03c98c7..28a17d0ef 100644 --- a/apps/workshop/README.md +++ b/apps/workshop/README.md @@ -12,6 +12,14 @@ PB_EXPOSE_PORT=18090 docker compose -f apps/workshop/docker-compose.yml up --bui Open `http://127.0.0.1:18090/_/`, create the first PocketBase superuser, configure GitHub OAuth on the `users` collection, and add Workshop administrators through the `workshop_admins` collection. +The GitHub OAuth application needs one callback for the Workshop host: + +```text +https:///api/oauth2-redirect +``` + +Self-hosted Homarr origins do not need to be registered with GitHub. Keep `PB_ALLOWED_ORIGINS=*` (the default) so localhost and arbitrary self-hosted domains can open the central PocketBase OAuth popup. Set a restricted origin list only if it includes every Homarr origin that should use Workshop sign-in. + Run the disposable service integration test from the repository root: ```sh diff --git a/apps/workshop/pb_hooks/workshop-utils.js b/apps/workshop/pb_hooks/workshop-utils.js index 0b5f86002..6a1ea38f9 100644 --- a/apps/workshop/pb_hooks/workshop-utils.js +++ b/apps/workshop/pb_hooks/workshop-utils.js @@ -13,8 +13,6 @@ const TOP_LEVEL_FIELDS = [ "requests", "optionsSchema", "defaultOptions", - "stateSchema", - "defaultState", "template", ]; const SOURCE_FIELDS = ["id", "name", "baseUrl", "networkScope", "auth"]; @@ -452,22 +450,6 @@ function collectDynamicOptionRequestIds(schema, result) { if (schema[branch] !== undefined) collectDynamicOptionRequestIds(schema[branch], result); } -function validateDefaultState(schema, state) { - if (!isObject(state)) bad("Widget default state is invalid"); - for (const [key, value] of Object.entries(state)) { - const type = schema[key]; - const matches = - type === "date" - ? typeof value === "string" - : type === "string[]" || type === "date[]" - ? Array.isArray(value) && value.every((entry) => typeof entry === "string") - : type === "number[]" - ? Array.isArray(value) && value.every((entry) => typeof entry === "number") - : typeof value === type; - if (!type || !matches) bad("Widget default state does not match its schema"); - } -} - function containsCredential(value, key) { if (SENSITIVE_KEY.test(key || "") && typeof value === "string" && value.trim()) return true; if (Array.isArray(value)) return value.some((entry) => containsCredential(entry, "")); @@ -488,7 +470,7 @@ function containsPrototypeKey(value) { function validateTemplate(template) { if (typeof template !== "string" || !template.trim() || template.length > 50000) bad("Widget template is invalid"); if ( - /(?:<\s*(?:script|style|iframe|object|embed|form)\b|dangerouslySetInnerHTML\s*=|\bon[A-Z][A-Za-z]*\s*=|\b(?:component|renderRoot|withinPortal|ref)\s*=|\b(?:fetch|eval|XMLHttpRequest)\s*\(|\bimport\s)/i.test( + /(?:<\s*(?:script|style|iframe|object|embed|form)\b|dangerouslySetInnerHTML\s*=|\bon[A-Z][A-Za-z]*\s*=|\b(?:component|renderRoot|withinPortal|ref)\s*=|\b(?:fetch|eval|XMLHttpRequest)\s*\(|\bimport\s|\{[^{}]*\bstate(?:\.|\[))/i.test( template, ) ) @@ -509,14 +491,13 @@ function validateTemplateRequests(template, requests) { } } -function validateTemplateStateBindings(template, stateSchema) { +function validateTemplateBindings(template) { const bindingPattern = /<[A-Z][A-Za-z0-9.]*(?:\s[^<>]*?)?\bbind\s*=\s*(?:"([^"]+)"|'([^']+)'|\{)/g; let match; while ((match = bindingPattern.exec(template)) !== null) { - const stateName = match[1] || match[2]; - if (!stateName) bad("bind must use a literal state name"); - if (!Object.prototype.hasOwnProperty.call(stateSchema, stateName)) - bad(`bind references unknown state '${stateName}'`); + const inputName = match[1] || match[2]; + if (!inputName) bad("bind must use a literal input name"); + if (!validBindingId(inputName)) bad("bind must use a valid input name"); } } @@ -595,21 +576,9 @@ function validateSubmission(record) { const request = widget.requests.find((candidate) => candidate.id === requestId); if (!request || request.kind !== "query") bad("Widget dynamic options must reference a query request"); } - if ( - widget.stateSchema !== undefined && - (!isObject(widget.stateSchema) || - Object.entries(widget.stateSchema).some( - ([key, type]) => - !validBindingId(key) || - SENSITIVE_KEY.test(key) || - !["string", "number", "boolean", "date", "string[]", "number[]", "date[]"].includes(type), - )) - ) - bad("Widget state schema is invalid"); - if (widget.defaultState !== undefined) validateDefaultState(widget.stateSchema || {}, widget.defaultState); validateTemplate(widget.template); validateTemplateRequests(widget.template, widget.requests); - validateTemplateStateBindings(widget.template, widget.stateSchema || {}); + validateTemplateBindings(widget.template); for (const request of widget.requests) { for (const [name, value] of Object.entries(request.staticHeaders || {})) { if ((SENSITIVE_KEY.test(name) && String(value).trim()) || /^\s*bearer\s+\S{8,}\s*$/i.test(String(value))) @@ -619,7 +588,6 @@ function validateSubmission(record) { if ( CREDENTIAL_TEXT.test(widget.template) || containsCredential(widget.defaultOptions, "") || - containsCredential(widget.defaultState, "") || widget.requests.some( (request) => containsCredential(request.bodyTemplate, "") || containsCredential(request.queryTemplate, ""), ) @@ -628,7 +596,6 @@ function validateSubmission(record) { if ( containsPrototypeKey(widget.optionsSchema) || containsPrototypeKey(widget.defaultOptions) || - containsPrototypeKey(widget.defaultState) || widget.requests.some( (request) => containsPrototypeKey(request.queryTemplate) || containsPrototypeKey(request.bodyTemplate), ) diff --git a/apps/workshop/tests/workshop.integration.mjs b/apps/workshop/tests/workshop.integration.mjs index 716497d5e..9734383a9 100644 --- a/apps/workshop/tests/workshop.integration.mjs +++ b/apps/workshop/tests/workshop.integration.mjs @@ -83,8 +83,6 @@ const widget = { requests: [], optionsSchema: { type: "object", properties: {}, additionalProperties: false }, defaultOptions: {}, - stateSchema: {}, - defaultState: {}, template: "Runtime probe", }; const submission = await request("/api/collections/submissions/records", { diff --git a/packages/api/src/router/custom-widget/management-queries.ts b/packages/api/src/router/custom-widget/management-queries.ts index cd467587a..2692661dc 100644 --- a/packages/api/src/router/custom-widget/management-queries.ts +++ b/packages/api/src/router/custom-widget/management-queries.ts @@ -88,8 +88,6 @@ export const managementQueryProcedures = { optionsSchema: widget.optionsSchema, defaultOptions: widget.defaultOptions, template: widget.template, - stateSchema: widget.stateSchema ?? {}, - defaultState: widget.defaultState ?? {}, sources: widget.sources.map(({ id, name, networkScope, auth }) => ({ id, name, diff --git a/packages/api/src/router/custom-widget/preview-base-procedures.ts b/packages/api/src/router/custom-widget/preview-base-procedures.ts index 1eb08f226..2393989aa 100644 --- a/packages/api/src/router/custom-widget/preview-base-procedures.ts +++ b/packages/api/src/router/custom-widget/preview-base-procedures.ts @@ -60,8 +60,6 @@ const previewCreateProcedure = manageProcedure template: input.definition.template, optionsSchema: input.definition.optionsSchema, defaultOptions: input.definition.defaultOptions, - stateSchema: input.definition.stateSchema ?? {}, - defaultState: input.definition.defaultState ?? {}, secrets, definitionId: input.definitionId, }); @@ -75,7 +73,6 @@ const previewCreateProcedure = manageProcedure definition: { template: input.definition.template, defaultOptions: input.definition.defaultOptions, - defaultState: input.definition.defaultState ?? {}, }, }; }); @@ -91,8 +88,6 @@ export const previewBaseProcedures = { template: session.template, optionsSchema: session.optionsSchema, defaultOptions: session.defaultOptions, - stateSchema: session.stateSchema, - defaultState: session.defaultState, requests: session.requests.map( ({ id, kind, method, minimumBoardPermission, trigger, parameters, confirmation, invalidates }) => ({ id, diff --git a/packages/api/src/router/custom-widget/stored-definition.ts b/packages/api/src/router/custom-widget/stored-definition.ts index 855379e0a..c8106cecc 100644 --- a/packages/api/src/router/custom-widget/stored-definition.ts +++ b/packages/api/src/router/custom-widget/stored-definition.ts @@ -13,8 +13,6 @@ export function serializeCustomWidgetDefinition(definition: HomarrCustomWidgetV2 requests: stringifySuperJson(definition.requests), optionsSchema: stringifySuperJson(definition.optionsSchema), defaultOptions: stringifySuperJson(definition.defaultOptions), - stateSchema: definition.stateSchema ? stringifySuperJson(definition.stateSchema) : null, - defaultState: definition.defaultState ? stringifySuperJson(definition.defaultState) : null, template: definition.template, }; } @@ -22,16 +20,7 @@ export function serializeCustomWidgetDefinition(definition: HomarrCustomWidgetV2 export function parseStoredCustomWidgetDefinition( definition: Pick< CustomWidgetDefinition, - | "name" - | "description" - | "iconUrl" - | "sources" - | "requests" - | "optionsSchema" - | "defaultOptions" - | "stateSchema" - | "defaultState" - | "template" + "name" | "description" | "iconUrl" | "sources" | "requests" | "optionsSchema" | "defaultOptions" | "template" >, ): HomarrCustomWidgetV2 { return customWidgetDefinitionSchema.parse({ @@ -43,8 +32,6 @@ export function parseStoredCustomWidgetDefinition( requests: parseSuperJson(definition.requests), optionsSchema: parseSuperJson(definition.optionsSchema), defaultOptions: parseSuperJson(definition.defaultOptions), - stateSchema: definition.stateSchema ? parseSuperJson(definition.stateSchema) : undefined, - defaultState: definition.defaultState ? parseSuperJson(definition.defaultState) : undefined, template: definition.template, }); } diff --git a/packages/api/src/router/widgets/custom-api.ts b/packages/api/src/router/widgets/custom-api.ts index 8457ca3e9..25bca9b49 100644 --- a/packages/api/src/router/widgets/custom-api.ts +++ b/packages/api/src/router/widgets/custom-api.ts @@ -234,8 +234,6 @@ export const customApiRouter = createTRPCRouter({ data: Object.fromEntries(entries.map(([id, result]) => [id, result.data])), status: Object.fromEntries(entries.map(([id, result]) => [id, result.status])), options: resolved.configuration, - stateSchema: resolved.definition.stateSchema ?? {}, - defaultState: resolved.definition.defaultState ?? {}, requestCapabilities: resolved.definition.requests.map((request) => ({ id: request.id, kind: request.kind, diff --git a/packages/custom-widgets/package.json b/packages/custom-widgets/package.json index 37652942d..204d06cd9 100644 --- a/packages/custom-widgets/package.json +++ b/packages/custom-widgets/package.json @@ -30,12 +30,19 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@codemirror/autocomplete": "catalog:", + "@codemirror/commands": "catalog:", + "@codemirror/lang-javascript": "catalog:", + "@codemirror/lang-json": "catalog:", + "@codemirror/lint": "catalog:", + "@codemirror/view": "catalog:", "@mantine/charts": "catalog:", "@mantine/core": "catalog:", "@mantine/dates": "catalog:", "@mantine/hooks": "catalog:", "@tabler/icons-react": "catalog:", "@tanstack/react-query": "catalog:", + "@uiw/react-codemirror": "catalog:", "acorn": "catalog:", "acorn-jsx": "catalog:", "jsonpath-plus": "catalog:", diff --git a/packages/custom-widgets/scripts/check-architecture.mjs b/packages/custom-widgets/scripts/check-architecture.mjs index 68c7c9c63..2d7489963 100644 --- a/packages/custom-widgets/scripts/check-architecture.mjs +++ b/packages/custom-widgets/scripts/check-architecture.mjs @@ -83,10 +83,10 @@ if (!/dynamic\(\(\)\s*=>\s*import\(["']\.\/custom-jsx-display["']\)/u.test(widge failures.push("Custom JSX board rendering must remain dynamically loaded"); } const editorAdapter = await readFile( - join(repositoryRoot, "apps/nextjs/src/app/[locale]/manage/custom-widgets/_code-editor.tsx"), + join(repositoryRoot, "packages/custom-widgets/src/workbench/code-editor.tsx"), "utf8", ); -if (!/dynamic\(\(\)\s*=>\s*import\(["']@uiw\/react-codemirror["']\)/u.test(editorAdapter)) { +if (!/lazy\(\(\)\s*=>\s*import\(["']@uiw\/react-codemirror["']\)/u.test(editorAdapter)) { failures.push("CodeMirror must remain dynamically loaded"); } diff --git a/packages/custom-widgets/src/core/ai-prompt.ts b/packages/custom-widgets/src/core/ai-prompt.ts index 5f9f22d0c..d5f1a9ce9 100644 --- a/packages/custom-widgets/src/core/ai-prompt.ts +++ b/packages/custom-widgets/src/core/ai-prompt.ts @@ -4,7 +4,7 @@ export const CUSTOM_WIDGET_MANTINE_VERSION = "9.4.1"; const AUTHORING_PROMPT = `You create Homarr Custom JSX v2 widgets. Return exactly two fenced blocks: a json block containing one complete widget object with template set to "__HOMARR_TEMPLATE__", then a jsx block containing the readable template. -Use $schema "homarr-custom-widget-v2". A widget contains metadata, inline API sources, named requests, an options schema, defaults, optional local state, and one JSX template. Never include credentials. Use one source named "default" unless the widget genuinely combines APIs. +Use $schema "homarr-custom-widget-v2". A widget contains metadata, inline API sources, named requests, an options schema, defaults, and one JSX template. Never include credentials. Use one source named "default" unless the widget genuinely combines APIs. This Homarr release uses Mantine ${CUSTOM_WIDGET_MANTINE_VERSION}. When connected, read homarr://custom-widgets/schema and homarr://custom-widgets/components instead of guessing the installed surface. @@ -12,12 +12,12 @@ Queries and actions may use GET, POST, PUT, PATCH, or DELETE. Set kind to query Source authentication is exactly one of {"type":"none"}, {"type":"bearer"}, {"type":"basic"}, {"type":"apiKeyHeader","headerName":"X-API-Key"}, or {"type":"apiKeyQuery","parameterName":"api_key"}. Credentials are configured separately by the user. -Templates can read data, status, options, and state. Bind supported Mantine controls with bind="stateName" instead of event handlers. Use Mantine Core, Dates, and Charts components. Do not use imports, hooks, refs, event callbacks, component/renderRoot, arbitrary portals, raw HTML, fetch, eval, npm packages, or credentials. Design for compact and wide dashboard sizes with useful loading, empty, and error states. +Templates can read data, status, options, and inputs. Bind supported Mantine controls with bind="search" and read the temporary value as inputs.search. Bound inputs live only in memory and reset when the widget reloads; saved configuration belongs in options. Use Mantine Core, Dates, and Charts components. Do not use imports, hooks, refs, event callbacks, component/renderRoot, arbitrary portals, raw HTML, fetch, eval, npm packages, or credentials. Design for compact and wide dashboard sizes with useful loading, empty, and error states. -Options use a restricted object JSON Schema. x-homarr controls include text, textarea, number, switch, select, multi-select, slider, date, time, color, icon, url, duration, timeZone, and json. Dynamic selects use x-homarr.optionsSource with requestId, optional itemsPath for wrapped arrays, valuePath, and labelPath. Local state types are string, number, boolean, date, string[], number[], or date[]. +Options use a restricted object JSON Schema. x-homarr controls include text, textarea, number, switch, select, multi-select, slider, date, time, color, icon, url, duration, timeZone, and json. Dynamic selects use x-homarr.optionsSource with requestId, optional itemsPath for wrapped arrays, valuePath, and labelPath. Widget shape: -{ "$schema":"homarr-custom-widget-v2", "name":"...", "description":"...", "iconUrl":"https://...", "sources":[{ "id":"default", "name":"API", "baseUrl":"https://host", "networkScope":"public|private|loopback", "auth":{ "type":"none|bearer|basic|apiKeyHeader|apiKeyQuery" } }], "requests":[{ "id":"data", "sourceId":"default", "kind":"query|action", "method":"GET|POST|PUT|PATCH|DELETE", "pathTemplate":"/api/path", "parameters":{}, "queryTemplate":{}, "bodyTemplate":{}, "auth":"inherit|none", "minimumBoardPermission":"view|modify|full", "trigger":"load|manual", "confirmation":{ "title":"...", "message":"...", "destructive":false }, "invalidates":[] }], "optionsSchema":{ "type":"object", "properties":{}, "additionalProperties":false }, "defaultOptions":{}, "stateSchema":{}, "defaultState":{}, "template":"__HOMARR_TEMPLATE__" } +{ "$schema":"homarr-custom-widget-v2", "name":"...", "description":"...", "iconUrl":"https://...", "sources":[{ "id":"default", "name":"API", "baseUrl":"https://host", "networkScope":"public|private|loopback", "auth":{ "type":"none|bearer|basic|apiKeyHeader|apiKeyQuery" } }], "requests":[{ "id":"data", "sourceId":"default", "kind":"query|action", "method":"GET|POST|PUT|PATCH|DELETE", "pathTemplate":"/api/{id}", "parameters":{ "id":"string", "limit":"number" }, "queryTemplate":{ "limit":{ "$param":"limit" } }, "auth":"inherit|none", "minimumBoardPermission":"view|modify|full", "trigger":"load|manual", "confirmation":{ "title":"...", "message":"...", "destructive":false }, "invalidates":[] }], "optionsSchema":{ "type":"object", "properties":{}, "additionalProperties":false }, "defaultOptions":{}, "template":"__HOMARR_TEMPLATE__" } Keep the result concise, accessible, responsive, and valid. Preserve unrelated fields when fixing an existing widget.`; @@ -33,7 +33,7 @@ export const CUSTOM_WIDGET_MCP_AUTHORING_PROMPT = `Author one Homarr Custom JSX 6. Use customWidget_secretSet only for a credential the user supplied and only with dedicated permission. Otherwise call customWidget_secretRequestUser and poll its completion status; never receive or repeat the plaintext. 7. Call customWidget_create only after validation, request tests, action simulation, and visual inspection pass. -Use inline sources, named queries/actions, restricted JSON Schema options, session-local typed state, declarative bind controls, and safe Mantine JSX. Use kind, not method, to distinguish reads from user-triggered changes. Never include credentials, imports, hooks, refs, callbacks, browser requests, arbitrary JavaScript, npm packages, polymorphic roots, or arbitrary portals. Make the widget responsive, accessible, loading-aware, empty-aware, and error-aware. The live schema and component resources are authoritative for the installed Homarr release.`; +Use inline sources, named queries/actions, restricted JSON Schema options, temporary declarative bind controls exposed through inputs, and safe Mantine JSX. Use kind, not method, to distinguish reads from user-triggered changes. Never include credentials, imports, hooks, refs, callbacks, browser requests, arbitrary JavaScript, npm packages, polymorphic roots, or arbitrary portals. Make the widget responsive, accessible, loading-aware, empty-aware, and error-aware. The live schema and component resources are authoritative for the installed Homarr release.`; export function buildCustomWidgetMcpPrompt(request?: string | null, documentationUrl?: string | null) { const sections = [CUSTOM_WIDGET_MCP_AUTHORING_PROMPT]; @@ -91,7 +91,7 @@ function redactValue(value: unknown, key = ""): unknown { ); } if (typeof value === "string") { - if (["sources", "requests", "optionsSchema", "defaultOptions", "stateSchema", "defaultState"].includes(key)) { + if (["sources", "requests", "optionsSchema", "defaultOptions"].includes(key)) { try { return redactValue(JSON.parse(value) as unknown); } catch { diff --git a/packages/custom-widgets/src/core/authoring-resources.ts b/packages/custom-widgets/src/core/authoring-resources.ts index 5e0691644..ab2b7da5d 100644 --- a/packages/custom-widgets/src/core/authoring-resources.ts +++ b/packages/custom-widgets/src/core/authoring-resources.ts @@ -7,12 +7,15 @@ import { customJsxExamples } from "./examples"; import { customJsxTablerIconNames } from "./tabler-icons"; export const CUSTOM_WIDGET_SKILL_VERSION = "2.0.0"; -export const CUSTOM_WIDGET_SKILL_DOWNLOAD_URL = "https://homarr.dev/downloads/homarr-custom-widget-2.0.0.zip"; -export const CUSTOM_WIDGET_SKILL_SHA256 = "5571b74b2faafe8a9e230231c1915193e889c7afa95acef38a1f70befe14a9e1"; +export const CUSTOM_WIDGET_SKILLS_SH_URL = "https://www.skills.sh/homarr-labs/homarr/homarr-custom-widget"; +export const CUSTOM_WIDGET_SKILL_SOURCE_URL = + "https://github.com/homarr-labs/homarr/tree/v2/.agents/skills/homarr-custom-widget"; +export const CUSTOM_WIDGET_SKILL_INSTALL_COMMAND = + "npx skills add https://github.com/homarr-labs/homarr --skill homarr-custom-widget"; export const CUSTOM_WIDGET_SKILL_MD = `--- name: homarr-custom-widget -description: Author, validate, preview, and install safe Homarr Custom JSX v2 widgets from API documentation. Use when creating or fixing Homarr custom widgets, including multi-source dashboards, queries, actions, options, local state, and Mantine JSX. +description: Author, validate, preview, and install safe Homarr Custom JSX v2 widgets from API documentation. Use when creating or fixing Homarr custom widgets, including multi-source dashboards, queries, actions, options, temporary inputs, and Mantine JSX. license: Apache-2.0 metadata: version: "2.0.0" @@ -35,7 +38,7 @@ Create one widget at a time. Repeat the workflow when the user asks for several ## Output rules -- Use inline sources, named requests, definition-owned options, session-local state, and one safe JSX template. +- Use inline sources, named requests, definition-owned options, temporary bound inputs, and one safe JSX template. - Use \`kind\`, not the HTTP method, to distinguish reads from user-triggered actions. - Never put a credential in JSX, options, headers, URLs, examples, exports, logs, or chat output. - Use declarative \`bind\` controls and Homarr request components; never emit imports, hooks, callbacks, browser requests, or arbitrary JavaScript. @@ -50,8 +53,9 @@ export function getCustomWidgetSkill() { name: "homarr-custom-widget", version: CUSTOM_WIDGET_SKILL_VERSION, skillMd: CUSTOM_WIDGET_SKILL_MD, - downloadUrl: CUSTOM_WIDGET_SKILL_DOWNLOAD_URL, - sha256: CUSTOM_WIDGET_SKILL_SHA256, + skillsShUrl: CUSTOM_WIDGET_SKILLS_SH_URL, + sourceUrl: CUSTOM_WIDGET_SKILL_SOURCE_URL, + installCommand: CUSTOM_WIDGET_SKILL_INSTALL_COMMAND, }; } diff --git a/packages/custom-widgets/src/core/bundled-widgets.ts b/packages/custom-widgets/src/core/bundled-widgets.ts new file mode 100644 index 000000000..0653bd04d --- /dev/null +++ b/packages/custom-widgets/src/core/bundled-widgets.ts @@ -0,0 +1,211 @@ +import type { HomarrCustomWidgetV2 } from "./custom-jsx-schema"; + +export interface BundledCustomWidget { + id: "seed-dog-facts" | "seed-currency-exchange" | "seed-jellyfin" | "seed-pokedex"; + widget: HomarrCustomWidgetV2; +} + +export const BUNDLED_CUSTOM_WIDGETS: readonly BundledCustomWidget[] = [ + { + id: "seed-dog-facts", + widget: { + $schema: "homarr-custom-widget-v2", + name: "Random Dog Fact", + description: "Displays a random fun fact about dogs.", + sources: [ + { + id: "default", + name: "Dog API", + baseUrl: "https://dogapi.dog", + networkScope: "public", + auth: { type: "none" }, + }, + ], + requests: [ + { + id: "fact", + sourceId: "default", + kind: "query", + method: "GET", + pathTemplate: "/api/v2/facts", + parameters: {}, + auth: "inherit", + minimumBoardPermission: "view", + trigger: "load", + cacheTtlSeconds: 30, + }, + ], + optionsSchema: { type: "object", properties: {}, additionalProperties: false }, + defaultOptions: {}, + template: ` + + Dog fact + + + {status.fact?.loading ? : + status.fact?.error ? {status.fact.error} : + {data.fact?.data?.[0]?.attributes?.body ?? "No dog fact was returned."}} +`, + }, + }, + { + id: "seed-currency-exchange", + widget: { + $schema: "homarr-custom-widget-v2", + name: "Currency Exchange", + description: "Converts an amount using European Central Bank exchange rates.", + sources: [ + { + id: "default", + name: "Frankfurter", + baseUrl: "https://api.frankfurter.dev", + networkScope: "public", + auth: { type: "none" }, + }, + ], + requests: [ + { + id: "rates", + sourceId: "default", + kind: "query", + method: "GET", + pathTemplate: "/v1/latest", + parameters: { from: "string", to: "string", amount: "number" }, + queryTemplate: { + from: { $param: "from" }, + to: { $param: "to" }, + amount: { $param: "amount" }, + }, + auth: "inherit", + minimumBoardPermission: "view", + trigger: "load", + cacheTtlSeconds: 300, + }, + ], + optionsSchema: { + type: "object", + properties: { + from: { type: "string", title: "From currency", minLength: 3, maxLength: 3 }, + to: { type: "string", title: "Target currencies", description: "Comma-separated currency codes" }, + amount: { type: "number", title: "Amount", minimum: 0 }, + }, + required: ["from", "to", "amount"], + additionalProperties: false, + }, + defaultOptions: { from: "JPY", to: "EUR,USD", amount: 50 }, + template: ` + {options.amount} {options.from}{data.rates?.date ?? "Latest"} + {status.rates?.loading ? : status.rates?.error ? {status.rates.error} : + + {Object.entries(data.rates?.rates ?? {}).map(entry => {entry[0]}{entry[1]})} + } +`, + }, + }, + { + id: "seed-jellyfin", + widget: { + $schema: "homarr-custom-widget-v2", + name: "Jellyfin Library", + description: "Counts movies, series, episodes, and songs in a Jellyfin library.", + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyfin.svg", + sources: [ + { + id: "default", + name: "Jellyfin", + baseUrl: "http://jellyfin.local", + networkScope: "private", + auth: { type: "apiKeyHeader", headerName: "X-Emby-Token" }, + }, + ], + requests: [ + { + id: "counts", + sourceId: "default", + kind: "query", + method: "GET", + pathTemplate: "/Items/Counts", + parameters: {}, + auth: "inherit", + minimumBoardPermission: "view", + trigger: "load", + cacheTtlSeconds: 60, + }, + ], + optionsSchema: { type: "object", properties: {}, additionalProperties: false }, + defaultOptions: {}, + template: ` + Jellyfin library + {status.counts?.loading ? : status.counts?.error ? {status.counts.error} : + + {[{ label: "Movies", value: data.counts?.MovieCount }, { label: "Series", value: data.counts?.SeriesCount }, { label: "Episodes", value: data.counts?.EpisodeCount }, { label: "Songs", value: data.counts?.SongCount }].map(item => {item.label}{item.value ?? 0})} + } +`, + }, + }, + { + id: "seed-pokedex", + widget: { + $schema: "homarr-custom-widget-v2", + name: "Pokédex", + description: "Browse Pokémon and open a responsive detail view.", + iconUrl: "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/items/poke-ball.png", + sources: [ + { + id: "default", + name: "PokeAPI", + baseUrl: "https://pokeapi.co", + networkScope: "public", + auth: { type: "none" }, + }, + ], + requests: [ + { + id: "pokemon", + sourceId: "default", + kind: "query", + method: "GET", + pathTemplate: "/api/v2/pokemon", + parameters: { limit: "number" }, + queryTemplate: { limit: { $param: "limit" }, offset: 0 }, + auth: "inherit", + minimumBoardPermission: "view", + trigger: "load", + cacheTtlSeconds: 300, + }, + { + id: "detail", + sourceId: "default", + kind: "query", + method: "GET", + pathTemplate: "/api/v2/pokemon/{name}", + parameters: { name: "string" }, + auth: "inherit", + minimumBoardPermission: "view", + trigger: "manual", + cacheTtlSeconds: 300, + }, + ], + optionsSchema: { + type: "object", + properties: { + limit: { + type: "number", + title: "Pokémon to load", + minimum: 1, + maximum: 100, + "x-homarr": { control: "number" }, + }, + }, + required: ["limit"], + additionalProperties: false, + }, + defaultOptions: { limit: 20 }, + template: ` + Pokédex{data.pokemon?.count ?? 0} Pokémon + {status.pokemon?.loading ? : status.pokemon?.error ? {status.pokemon.error} : + {(data.pokemon?.results ?? []).map(pokemon => {pokemon.name})}} +`, + }, + }, +] as const; diff --git a/packages/custom-widgets/src/core/component-registry.ts b/packages/custom-widgets/src/core/component-registry.ts index 444863baf..7cd261ab8 100644 --- a/packages/custom-widgets/src/core/component-registry.ts +++ b/packages/custom-widgets/src/core/component-registry.ts @@ -65,3 +65,24 @@ export const customJsxBindableComponentNames: ReadonlySet = new Set([ "YearPicker", "YearPickerInput", ]); + +export type CustomJsxBindingType = "string" | "number" | "boolean" | "string[]" | "number[]"; + +const booleanBindingComponents = new Set(["Checkbox", "HoverCard", "Menu", "Popover", "Switch"]); +const numberBindingComponents = new Set(["NumberInput", "Pagination", "Rating", "Slider", "Stepper"]); +const stringArrayBindingComponents = new Set(["Checkbox.Group", "MultiSelect", "Switch.Group", "TagsInput"]); + +export function getCustomJsxBindingType( + componentName: string, + props: Readonly> = {}, +): CustomJsxBindingType | null { + if (!customJsxBindableComponentNames.has(componentName)) return null; + if (booleanBindingComponents.has(componentName)) return "boolean"; + if (numberBindingComponents.has(componentName)) return "number"; + if (componentName === "RangeSlider") return "number[]"; + if (stringArrayBindingComponents.has(componentName)) return "string[]"; + if (["Calendar", "DatePicker"].includes(componentName) && ["multiple", "range"].includes(String(props.type))) + return "string[]"; + if (componentName === "Chip.Group" && props.multiple === true) return "string[]"; + return "string"; +} diff --git a/packages/custom-widgets/src/core/custom-jsx-schema.ts b/packages/custom-widgets/src/core/custom-jsx-schema.ts index 33fa0b090..edb5656de 100644 --- a/packages/custom-widgets/src/core/custom-jsx-schema.ts +++ b/packages/custom-widgets/src/core/custom-jsx-schema.ts @@ -1,7 +1,7 @@ import { z } from "zod/v4"; import { validateCustomJsxTemplate } from "../jsx/analyzer"; -import { isCredentialKeyName, validateCredentialFreeExport, validatePrototypeKeys } from "./definition-security"; +import { validateCredentialFreeExport, validatePrototypeKeys } from "./definition-security"; import { validateCustomWidgetOptions } from "./options"; import { customWidgetOptionsSchemaSchema } from "./options-schema"; import { customJsxRequestSchema, customWidgetIdentifierSchema, customWidgetSourceSchema } from "./request-schema"; @@ -12,35 +12,11 @@ export * from "./options-schema"; export * from "./request-schema"; export type CustomWidgetOptionsSchema = z.infer; -export const customWidgetStateTypes = [ - "string", - "number", - "boolean", - "date", - "string[]", - "number[]", - "date[]", -] as const; export const customWidgetBindingIdentifierSchema = z .string() .min(1) .max(64) .regex(/^[A-Za-z][A-Za-z0-9_-]*$/u, "Binding names must start with a letter and contain letters, numbers, - or _"); -export const customWidgetStateSchemaSchema = z - .record(customWidgetBindingIdentifierSchema, z.enum(customWidgetStateTypes)) - .superRefine((schema, ctx) => { - for (const key of Object.keys(schema)) { - if (isCredentialKeyName(key)) { - ctx.addIssue({ - code: "custom", - path: [key], - message: "Credentials must use source authentication, not local state", - }); - } - } - }); -export type CustomWidgetStateSchema = z.infer; - export const customJsxTemplateSchema = z .string() .min(1) @@ -93,10 +69,9 @@ export const customWidgetDefinitionSchema = z requests: z.array(customJsxRequestSchema).max(64), optionsSchema: customWidgetOptionsSchemaSchema, defaultOptions: z.record(z.string(), z.unknown()), - stateSchema: customWidgetStateSchemaSchema.optional(), - defaultState: z.record(z.string(), z.unknown()).optional(), template: customJsxTemplateSchema, }) + .strict() .superRefine((definition, ctx) => { for (const issue of validateCustomWidgetOptions(definition.optionsSchema, definition.defaultOptions)) { ctx.addIssue({ @@ -106,11 +81,10 @@ export const customWidgetDefinitionSchema = z }); } const sourceIds = validateSourceIds(definition.sources, ctx); - validateDefaultState(definition.stateSchema ?? {}, definition.defaultState ?? {}, ctx); const requests = validateRequests(definition, sourceIds, ctx); validateDynamicOptions(definition.optionsSchema, requests, ctx); validateTemplateRequests(definition.template, requests, ctx); - validateTemplateStateBindings(definition.template, definition.stateSchema ?? {}, ctx); + validateTemplateBindings(definition.template, ctx); validateCredentialFreeExport(definition, ctx); validatePrototypeKeys(definition, ctx); }); @@ -160,28 +134,6 @@ function validateDynamicOptions( } } -function validateDefaultState( - schema: NonNullable, - state: NonNullable, - ctx: z.RefinementCtx, -) { - for (const [key, value] of Object.entries(state)) { - const type = schema[key]; - if (!type) ctx.addIssue({ code: "custom", path: ["defaultState", key], message: `Unknown state key: ${key}` }); - else if (!matchesStateType(type, value)) { - ctx.addIssue({ code: "custom", path: ["defaultState", key], message: `Expected ${type}` }); - } - } -} - -function matchesStateType(type: CustomWidgetStateSchema[string], value: unknown) { - if (type === "date") return typeof value === "string"; - if (type === "string[]" || type === "date[]") - return Array.isArray(value) && value.every((entry) => typeof entry === "string"); - if (type === "number[]") return Array.isArray(value) && value.every((entry) => typeof entry === "number"); - return typeof value === type; -} - function validateRequests(definition: Definition, sourceIds: Set, ctx: z.RefinementCtx) { const requests = new Map(); definition.requests.forEach((request, index) => { @@ -259,14 +211,12 @@ function validateTemplateRequests(template: string, requests: Map]*?)?\bbind\s*=\s*(?:"([^"]+)"|'([^']+)'|\{)/gu)) { - const stateName = match[1] ?? match[2]; - if (!stateName) { - ctx.addIssue({ code: "custom", path: ["template"], message: "bind must use a literal state name" }); - } else if (!Object.hasOwn(stateSchema, stateName)) { - ctx.addIssue({ code: "custom", path: ["template"], message: `bind references unknown state '${stateName}'` }); - } + const inputName = match[1] ?? match[2]; + if (!inputName) ctx.addIssue({ code: "custom", path: ["template"], message: "bind must use a literal input name" }); + else if (!customWidgetBindingIdentifierSchema.safeParse(inputName).success) + ctx.addIssue({ code: "custom", path: ["template"], message: `Invalid bind input name '${inputName}'` }); } } @@ -280,7 +230,8 @@ export const customWidgetUpdateSchema = z .extend({ id: z.string().min(1), secrets: customWidgetSecretsInputSchema.optional(), - }); + }) + .strict(); export const customWidgetImportSchema = customWidgetDefinitionSchema; export type CustomWidgetCreateInput = z.infer; export type CustomWidgetUpdateInput = z.infer; diff --git a/packages/custom-widgets/src/core/definition-security.ts b/packages/custom-widgets/src/core/definition-security.ts index bb08e6b0c..c68cc0ce6 100644 --- a/packages/custom-widgets/src/core/definition-security.ts +++ b/packages/custom-widgets/src/core/definition-security.ts @@ -11,7 +11,6 @@ const blockedObjectKeys = new Set(["__proto__", "prototype", "constructor"]); interface SecurityDefinition { defaultOptions: Record; - defaultState?: Record; requests: CustomJsxRequest[]; template: string; iconUrl?: string; @@ -26,9 +25,6 @@ export function validateCredentialFreeExport(definition: SecurityDefinition, ctx if (containsCredentialLikeValue(definition.defaultOptions)) { ctx.addIssue({ code: "custom", path: ["defaultOptions"], message: "Default options cannot contain credentials" }); } - if (containsCredentialLikeValue(definition.defaultState)) { - ctx.addIssue({ code: "custom", path: ["defaultState"], message: "Default state cannot contain credentials" }); - } definition.requests.forEach((request, index) => { if ( containsCredentialLikeValue(request.queryTemplate) || @@ -68,7 +64,6 @@ export function validatePrototypeKeys(definition: SecurityDefinition, ctx: Refin const values: Array<[Array, unknown]> = [ [["optionsSchema"], definition.optionsSchema], [["defaultOptions"], definition.defaultOptions], - [["defaultState"], definition.defaultState], ]; definition.requests.forEach((request, index) => { values.push([["requests", index, "queryTemplate"], request.queryTemplate]); diff --git a/packages/custom-widgets/src/core/examples.ts b/packages/custom-widgets/src/core/examples.ts index 26aceade4..5d2ed0427 100644 --- a/packages/custom-widgets/src/core/examples.ts +++ b/packages/custom-widgets/src/core/examples.ts @@ -31,8 +31,6 @@ export const CUSTOM_WIDGET_STARTER: HomarrCustomWidgetV2 = { requests: [], optionsSchema: { type: "object", properties: {}, additionalProperties: false }, defaultOptions: {}, - stateSchema: {}, - defaultState: {}, template: CUSTOM_JSX_STARTER, }; @@ -68,8 +66,6 @@ export const customJsxExamples: readonly CustomJsxExample[] = [ ], optionsSchema: { type: "object", properties: {}, additionalProperties: false }, defaultOptions: {}, - stateSchema: {}, - defaultState: {}, template: ` {data.status.name ?? "Service"} diff --git a/packages/custom-widgets/src/core/import.ts b/packages/custom-widgets/src/core/import.ts index bb8033803..d49fbf6a7 100644 --- a/packages/custom-widgets/src/core/import.ts +++ b/packages/custom-widgets/src/core/import.ts @@ -1,3 +1,4 @@ +import type { HomarrCustomWidgetV2 } from "./custom-jsx-schema"; import { customWidgetImportSchema } from "./custom-jsx-schema"; export interface ImportReview { @@ -10,53 +11,201 @@ export interface ImportReview { hasActions: boolean; } -function extractFencedBlock(text: string, acceptedLanguages: readonly string[]): string | undefined { - let cursor = 0; - while (cursor < text.length) { - const fenceStart = text.indexOf("```", cursor); - if (fenceStart === -1) return undefined; - const headerStart = fenceStart + 3; - const headerEnd = text.indexOf("\n", headerStart); - if (headerEnd === -1) return undefined; - const contentStart = headerEnd + 1; - const fenceEnd = text.indexOf("```", contentStart); - if (fenceEnd === -1) return undefined; - const language = text.slice(headerStart, headerEnd).trim().toLowerCase(); - if (acceptedLanguages.includes(language)) return text.slice(contentStart, fenceEnd); - cursor = fenceEnd + 3; - } - return undefined; +export interface CustomWidgetImportIssue { + code: string; + path?: Array; + message: string; } -export function parseCustomWidgetClipboard(text: string): Record | null { +export type CustomWidgetParseResult = + | { success: true; widget: HomarrCustomWidgetV2; warnings: CustomWidgetImportIssue[] } + | { success: false; issues: CustomWidgetImportIssue[] }; + +interface FencedBlock { + language: string; + content: string; +} + +const MAX_IMPORT_ISSUES = 8; + +function extractFencedBlocks(text: string): FencedBlock[] { + const blocks: FencedBlock[] = []; + for (const match of text.matchAll(/```([^\n`]*)\n([\s\S]*?)```/gu)) { + blocks.push({ language: (match[1] ?? "").trim().toLowerCase(), content: match[2] ?? "" }); + } + return blocks; +} + +function jsonSyntaxIssue(error: unknown, source: string): CustomWidgetImportIssue { + const message = error instanceof Error ? error.message : "The response is not valid JSON"; + const position = /position\s+(\d+)/iu.exec(message)?.[1]; + if (!position) return { code: "INVALID_JSON", message }; + const offset = Number(position); + const prefix = source.slice(0, offset); + const line = prefix.split("\n").length; + const column = offset - prefix.lastIndexOf("\n"); + return { code: "INVALID_JSON", message: `${message} (line ${line}, column ${column})` }; +} + +function zodIssues(error: { issues: Array<{ path: PropertyKey[]; message: string; code: string }> }) { + return error.issues.slice(0, MAX_IMPORT_ISSUES).map((issue) => ({ + code: issue.code === "unrecognized_keys" ? "UNSUPPORTED_FIELD" : "INVALID_WIDGET", + path: issue.path.map((part) => (typeof part === "symbol" ? String(part) : part)), + message: issue.message, + })); +} + +function removedStateIssues(value: Record): CustomWidgetImportIssue[] { + return ["stateSchema", "defaultState"].flatMap((field) => + Object.hasOwn(value, field) + ? [ + { + code: "REMOVED_LOCAL_STATE", + path: [field], + message: `${field} is not supported. Use bind with inputs for temporary values or widget options for saved configuration.`, + }, + ] + : [], + ); +} + +function isTemplatePlaceholder(value: unknown) { + return typeof value === "string" && value.replace(/[\\*_`\s]/gu, "").toUpperCase() === "HOMARRTEMPLATE"; +} + +function normalizeAiRequestParameters(value: Record, warnings: CustomWidgetImportIssue[]) { + if (!Array.isArray(value.requests)) return; + value.requests = value.requests.map((candidate, requestIndex) => { + if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return candidate; + const request = { ...(candidate as Record) }; + if (!request.parameters || typeof request.parameters !== "object" || Array.isArray(request.parameters)) + return request; + const parameters = { ...(request.parameters as Record) }; + for (const [name, parameter] of Object.entries(parameters)) { + if (!parameter || typeof parameter !== "object" || Array.isArray(parameter)) continue; + const keys = Object.keys(parameter); + const type = (parameter as Record).type; + if (keys.length === 1 && ["string", "number", "boolean"].includes(String(type))) { + parameters[name] = type; + warnings.push({ + code: "NORMALIZED_PARAMETER_TYPE", + path: ["requests", requestIndex, "parameters", name], + message: `Converted { type: "${String(type)}" } to "${String(type)}".`, + }); + } + } + request.parameters = parameters; + return request; + }); +} + +type ParsedWidgetValue = + | { success: true; value: Record } + | { success: false; issue: CustomWidgetImportIssue }; + +function parseWidgetValue(source: string): ParsedWidgetValue { + try { + const parsed: unknown = JSON.parse(source); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { + success: false, + issue: { code: "INVALID_WIDGET", message: "The response must contain one widget JSON object." }, + }; + } + return { success: true, value: parsed as Record }; + } catch (error) { + return { success: false, issue: jsonSyntaxIssue(error, source) }; + } +} + +export function parseCustomWidgetClipboard(text: string): HomarrCustomWidgetV2 | null { const result = parseCustomWidgetClipboardDetailed(text); return result.success ? result.widget : null; } -export function parseCustomWidgetClipboardDetailed( - text: string, -): { success: true; widget: Record } | { success: false; error: string } { - try { - const jsonBlock = extractFencedBlock(text, ["", "json"]); - const jsxBlock = extractFencedBlock(text, ["jsx", "tsx"])?.trim(); - const value: unknown = JSON.parse(jsonBlock ?? text); - if (!value || typeof value !== "object" || Array.isArray(value)) { - return { success: false, error: "The response must contain one widget JSON object" }; - } - const widget = { ...(value as Record) }; - if (jsxBlock && (widget.template === "__HOMARR_TEMPLATE__" || !widget.template)) widget.template = jsxBlock; - const parsed = customWidgetImportSchema.safeParse(widget); - if (!parsed.success) { - const issue = parsed.error.issues[0]; - return { - success: false, - error: `${issue?.path.map(String).join(".") || "widget"}: ${issue?.message ?? "Invalid widget"}`, - }; - } - return { success: true, widget }; - } catch (error) { - return { success: false, error: error instanceof Error ? error.message : "The response is not valid JSON" }; +export function parseCustomWidgetClipboardDetailed(text: string): CustomWidgetParseResult { + const blocks = extractFencedBlocks(text); + const jsonBlock = blocks.find((block) => block.language === "json" || block.language === ""); + const jsxBlock = blocks.find((block) => block.language === "jsx" || block.language === "tsx"); + const source = jsonBlock?.content ?? text; + const parsedValue = parseWidgetValue(source); + if (!parsedValue.success) return { success: false, issues: [parsedValue.issue] }; + const widget = { ...parsedValue.value }; + if (jsonBlock && isTemplatePlaceholder(widget.template) && !jsxBlock) { + return { + success: false, + issues: [ + { + code: "MISSING_JSX_BLOCK", + path: ["template"], + message: "The manifest uses a template placeholder but no fenced JSX block was provided.", + }, + ], + }; } + if (jsxBlock && isTemplatePlaceholder(widget.template)) widget.template = jsxBlock.content.trim(); + const removedIssues = removedStateIssues(widget); + if (removedIssues.length > 0) return { success: false, issues: removedIssues }; + const parsed = customWidgetImportSchema.safeParse(widget); + if (!parsed.success) return { success: false, issues: zodIssues(parsed.error) }; + return { success: true, widget: parsed.data, warnings: [] }; +} + +export function parseCustomWidgetAiResponse(text: string): CustomWidgetParseResult { + const blocks = extractFencedBlocks(text); + const jsonBlocks = blocks.filter((block) => block.language === "json" || block.language === ""); + const jsxBlocks = blocks.filter((block) => block.language === "jsx" || block.language === "tsx"); + const formatIssues: CustomWidgetImportIssue[] = []; + if (jsonBlocks.length !== 1) + formatIssues.push({ code: "AI_JSON_BLOCK_REQUIRED", message: "Expected exactly one fenced json block." }); + if (jsxBlocks.length !== 1) + formatIssues.push({ code: "AI_JSX_BLOCK_REQUIRED", message: "Expected exactly one fenced jsx block." }); + const source = jsonBlocks[0]?.content ?? text; + const parsedValue = parseWidgetValue(source); + if (!parsedValue.success) { + if (/\\_/u.test(source)) + formatIssues.push({ + code: "INVALID_JSON_ESCAPE", + message: 'JSON does not allow \\_ escapes. The template placeholder must be "__HOMARR_TEMPLATE__".', + }); + if (/"(?:stateSchema|defaultState)"\s*:/u.test(source)) + formatIssues.push({ + code: "REMOVED_LOCAL_STATE", + message: "stateSchema and defaultState are not supported; use bind with inputs or widget options.", + }); + return { success: false, issues: [parsedValue.issue, ...formatIssues].slice(0, MAX_IMPORT_ISSUES) }; + } + const widget = { ...parsedValue.value }; + const removedIssues = removedStateIssues(widget); + if (removedIssues.length > 0) formatIssues.push(...removedIssues); + const warnings: CustomWidgetImportIssue[] = []; + normalizeAiRequestParameters(widget, warnings); + if (jsxBlocks[0]) { + if (isTemplatePlaceholder(widget.template) || !widget.template) widget.template = jsxBlocks[0].content.trim(); + else if (widget.template !== jsxBlocks[0].content.trim()) + formatIssues.push({ + code: "AI_TEMPLATE_PLACEHOLDER_REQUIRED", + path: ["template"], + message: 'The manifest template must be "__HOMARR_TEMPLATE__" when a separate JSX block is returned.', + }); + } + if (formatIssues.length > 0) return { success: false, issues: formatIssues.slice(0, MAX_IMPORT_ISSUES) }; + const parsed = customWidgetImportSchema.safeParse(widget); + if (!parsed.success) return { success: false, issues: zodIssues(parsed.error) }; + return { success: true, widget: parsed.data, warnings }; +} + +export function looksLikeCustomWidgetClipboard(text: string) { + return /homarr-custom-widget|```(?:json|jsx|tsx)?\s*\n|"\$schema"\s*:/iu.test(text); +} + +export function formatCustomWidgetImportIssues(issues: readonly CustomWidgetImportIssue[]) { + const visible = issues.slice(0, 3).map((issue) => { + const path = issue.path?.length ? `${issue.path.join(".")}: ` : ""; + return `${path}${issue.message}`; + }); + const remaining = issues.length - visible.length; + return `${visible.join(" ")}${remaining > 0 ? ` (+${remaining} more)` : ""}`; } export function getImportReview(value: Record | null): ImportReview | null { diff --git a/packages/custom-widgets/src/core/index.ts b/packages/custom-widgets/src/core/index.ts index 75e1595c3..336e1c808 100644 --- a/packages/custom-widgets/src/core/index.ts +++ b/packages/custom-widgets/src/core/index.ts @@ -1,8 +1,10 @@ export * from "./ai-prompt"; export * from "./authoring-resources"; +export * from "./bundled-widgets"; export * from "./component-registry"; export * from "./examples"; export * from "./import"; export * from "./options"; export * from "./schema"; +export * from "./schema-references"; export * from "./tabler-icons"; diff --git a/packages/custom-widgets/src/core/schema-references.ts b/packages/custom-widgets/src/core/schema-references.ts new file mode 100644 index 000000000..d9986c45d --- /dev/null +++ b/packages/custom-widgets/src/core/schema-references.ts @@ -0,0 +1,126 @@ +import { z } from "zod/v4"; + +import { customWidgetOptionsSchemaSchema } from "./options-schema"; +import { customJsxRequestSchema } from "./request-schema"; + +let requestsSchema: Record | undefined; +let optionsSchema: Record | undefined; +let defaultOptionsSchema: Record | undefined; + +export function getCustomWidgetRequestsJsonSchema() { + requestsSchema ??= { + ...z.toJSONSchema(customJsxRequestSchema.array()), + title: "Named Custom Widget queries and actions", + }; + return requestsSchema; +} + +export function getCustomWidgetOptionsJsonSchema() { + optionsSchema ??= { + ...z.toJSONSchema(customWidgetOptionsSchemaSchema), + title: "Custom Widget options schema", + }; + return optionsSchema; +} + +export function getCustomWidgetDefaultOptionsJsonSchema() { + defaultOptionsSchema ??= { + ...z.toJSONSchema(z.record(z.string(), z.unknown())), + title: "Custom Widget default option values", + description: "Values must validate against the widget options schema.", + }; + return defaultOptionsSchema; +} + +export const CUSTOM_WIDGET_REQUEST_EXAMPLES = { + minimal: [ + { + id: "data", + sourceId: "default", + kind: "query", + method: "GET", + pathTemplate: "/api/status", + parameters: {}, + auth: "inherit", + minimumBoardPermission: "view", + trigger: "load", + }, + ], + full: [ + { + id: "environments", + sourceId: "default", + kind: "query", + method: "GET", + pathTemplate: "/api/environments", + parameters: {}, + auth: "inherit", + minimumBoardPermission: "view", + trigger: "load", + cacheTtlSeconds: 60, + }, + { + id: "search", + sourceId: "default", + kind: "query", + method: "POST", + pathTemplate: "/api/search", + parameters: { query: "string", limit: "number" }, + queryTemplate: { limit: { $param: "limit" } }, + bodyTemplate: { term: { $param: "query" } }, + auth: "inherit", + minimumBoardPermission: "view", + trigger: "manual", + cacheTtlSeconds: 30, + }, + { + id: "restart", + sourceId: "default", + kind: "action", + method: "POST", + pathTemplate: "/api/containers/{id}/restart", + parameters: { id: "string" }, + auth: "inherit", + minimumBoardPermission: "modify", + trigger: "manual", + confirmation: { + title: "Restart container", + message: "Restart this container now?", + confirmLabel: "Restart", + }, + invalidates: ["search"], + }, + ], +} as const; + +export const CUSTOM_WIDGET_OPTIONS_EXAMPLES = { + minimal: { + schema: { type: "object", properties: {}, additionalProperties: false }, + defaults: {}, + }, + full: { + schema: { + type: "object", + properties: { + title: { type: "string", title: "Title", "x-homarr": { control: "text" } }, + limit: { type: "number", title: "Result limit", minimum: 1, maximum: 100 }, + environmentId: { + type: "number", + title: "Environment", + "x-homarr": { + control: "select", + optionsSource: { + requestId: "environments", + itemsPath: "items", + valuePath: "Id", + labelPath: "Name", + }, + }, + }, + }, + required: ["limit"], + additionalProperties: false, + }, + defaults: { title: "Overview", limit: 20 }, + }, +} as const; diff --git a/packages/custom-widgets/src/jsx/analyzer.ts b/packages/custom-widgets/src/jsx/analyzer.ts index df15e42b7..fd744343d 100644 --- a/packages/custom-widgets/src/jsx/analyzer.ts +++ b/packages/custom-widgets/src/jsx/analyzer.ts @@ -66,7 +66,7 @@ const rootBindings = new Set([ "String", "data", "options", - "state", + "inputs", "status", "decodeURIComponent", "encodeURIComponent", diff --git a/packages/custom-widgets/src/jsx/runtime-components.tsx b/packages/custom-widgets/src/jsx/runtime-components.tsx index aa32636a6..9c6e6fb94 100644 --- a/packages/custom-widgets/src/jsx/runtime-components.tsx +++ b/packages/custom-widgets/src/jsx/runtime-components.tsx @@ -4,7 +4,12 @@ import * as Core from "@mantine/core"; import * as Charts from "@mantine/charts"; import * as Dates from "@mantine/dates"; -import { customJsxBindableComponentNames, enabledCustomJsxComponents } from "../core/component-registry"; +import { + customJsxBindableComponentNames, + enabledCustomJsxComponents, + getCustomJsxBindingType, +} from "../core/component-registry"; +import type { CustomJsxBindingType } from "../core/component-registry"; import { ActionButton, ToggleSwitch } from "../runtime/actions"; import { SubData } from "../runtime/data"; import { RefreshButton } from "../runtime/refresh-button"; @@ -31,25 +36,28 @@ function safeUrl(value: unknown) { return typeof value === "string" && (/^https?:\/\//iu.test(value) || value.startsWith("/") || value.startsWith("#")); } -type WidgetStateValue = string | number | boolean | string[] | number[]; -interface CustomJsxStateContextValue { - state: Record; - stateSchema: Record; - setStateValue(name: string, value: WidgetStateValue): void; +export type WidgetInputValue = string | number | boolean | string[] | number[]; +export type WidgetInputType = CustomJsxBindingType; +interface CustomJsxInputsContextValue { + inputs: Record; + inputTypes: Record; + registerInput(name: string, type: WidgetInputType, initialValue: WidgetInputValue): void; + setInputValue(name: string, type: WidgetInputType, value: WidgetInputValue): void; } -const CustomJsxStateContext = createContext(null); +const CustomJsxInputsContext = createContext(null); -export function CustomJsxStateProvider({ +export function CustomJsxInputsProvider({ children, - state, - stateSchema, - setStateValue, -}: CustomJsxStateContextValue & { children: ReactNode }) { + inputs, + inputTypes, + registerInput, + setInputValue, +}: CustomJsxInputsContextValue & { children: ReactNode }) { return ( - + {children} - + ); } @@ -67,7 +75,7 @@ const popoverInputComponents = new Set([ ]); const buttonRootComponents = new Set(["ActionIcon", "Burger", "Button", "CloseButton", "UnstyledButton"]); -function extractEventValue(value: unknown, checked: boolean): WidgetStateValue | null { +function extractEventValue(value: unknown, checked: boolean): WidgetInputValue | null { if (value && typeof value === "object" && "currentTarget" in value) { const target = (value as { currentTarget?: { checked?: unknown; value?: unknown } }).currentTarget; const result = checked ? target?.checked : target?.value; @@ -85,37 +93,57 @@ function extractEventValue(value: unknown, checked: boolean): WidgetStateValue | function useBoundProps(componentName: string, props: Record): Record { const binding = typeof props.bind === "string" ? props.bind : undefined; - const context = useContext(CustomJsxStateContext); + const context = useContext(CustomJsxInputsContext); const sanitized = sanitizeCustomJsxProps(props); delete sanitized.bind; - if (!binding || !context || !customJsxBindableComponentNames.has(componentName)) return sanitized; - const stateType = context.stateSchema[binding]; - if (!stateType) return sanitized; + const inputType = getCustomJsxBindingType(componentName, sanitized); + const initialValue = getInitialInputValue(inputType, sanitized); + delete sanitized.defaultValue; + delete sanitized.defaultChecked; + useEffect(() => { + if (binding && context && inputType && customJsxBindableComponentNames.has(componentName)) + context.registerInput(binding, inputType, initialValue); + }, [binding, componentName, context, initialValue, inputType]); + if (!binding || !context || !inputType || !customJsxBindableComponentNames.has(componentName)) return sanitized; + const currentValue = context.inputs[binding] ?? initialValue; const update = (value: unknown, checked = false) => { const extracted = extractEventValue(value, checked); - context.setStateValue(binding, extracted ?? emptyStateValue(stateType)); + context.setInputValue(binding, inputType, extracted ?? emptyInputValue(inputType)); }; if (componentName === "Calendar") { - return { ...sanitized, date: context.state[binding], onDateChange: update }; + return { ...sanitized, date: currentValue, onDateChange: update }; } if (checkedComponents.has(componentName)) { return { ...sanitized, - checked: Boolean(context.state[binding]), + checked: Boolean(currentValue), onChange: (event: unknown) => update(event, true), }; } if (openedComponents.has(componentName)) { - return { ...sanitized, opened: Boolean(context.state[binding]), onChange: update, withinPortal: false }; + return { ...sanitized, opened: Boolean(currentValue), onChange: update, withinPortal: false }; } if (activeComponents.has(componentName)) { - return { ...sanitized, active: Number(context.state[binding] ?? 0), onStepClick: update }; + return { ...sanitized, active: Number(currentValue), onStepClick: update }; } - return { ...sanitized, value: context.state[binding], onChange: update }; + return { ...sanitized, value: currentValue, onChange: update }; } -function emptyStateValue(type: string): WidgetStateValue { +function getInitialInputValue(type: WidgetInputType | null, props: Record): WidgetInputValue { + if (!type) return ""; + const candidate = type === "boolean" ? props.defaultChecked : props.defaultValue; + if (type === "boolean" && typeof candidate === "boolean") return candidate; + if (type === "number" && typeof candidate === "number") return candidate; + if (type === "string" && typeof candidate === "string") return candidate; + if (type === "string[]" && Array.isArray(candidate) && candidate.every((item) => typeof item === "string")) + return candidate; + if (type === "number[]" && Array.isArray(candidate) && candidate.every((item) => typeof item === "number")) + return candidate; + return emptyInputValue(type); +} + +function emptyInputValue(type: WidgetInputType): WidgetInputValue { if (type.endsWith("[]")) return []; if (type === "number") return 0; if (type === "boolean") return false; diff --git a/packages/custom-widgets/src/runtime/custom-jsx-renderer.tsx b/packages/custom-widgets/src/runtime/custom-jsx-renderer.tsx index b753674dc..3a655ed1a 100644 --- a/packages/custom-widgets/src/runtime/custom-jsx-renderer.tsx +++ b/packages/custom-widgets/src/runtime/custom-jsx-renderer.tsx @@ -4,10 +4,10 @@ import { Alert, Badge, Box, Group, Popover, Stack, Text } from "@mantine/core"; import { IconAlertTriangle, IconNetwork } from "@tabler/icons-react"; import { renderSafeJsx } from "../jsx/interpreter"; -import { CustomJsxStateProvider } from "../jsx/runtime-components"; +import { CustomJsxInputsProvider } from "../jsx/runtime-components"; +import type { WidgetInputType, WidgetInputValue } from "../jsx/runtime-components"; import type { CustomJsxRequestCapability } from "./types"; -type CustomWidgetStateValue = string | number | boolean | string[] | number[]; const EMPTY_RECORD: Record = {}; const methodColors: Readonly> = { @@ -33,9 +33,6 @@ export interface CustomJsxRendererProps { data: unknown; status?: Record; options?: Record; - stateSchema?: Record; - defaultState?: Record; - onStateChange?(state: Record): void; requestCapabilities: unknown; components: Readonly>>; createBindings(data: unknown): Readonly>; @@ -110,34 +107,48 @@ export function CustomJsxRenderer({ data, status = EMPTY_RECORD, options = EMPTY_RECORD, - stateSchema = EMPTY_RECORD, - defaultState = EMPTY_RECORD, - onStateChange, requestCapabilities: rawCapabilities, components, createBindings, messages, }: CustomJsxRendererProps) { const [parseErrors, setParseErrors] = useState([]); - const [state, setState] = useState>(() => - normalizeState(stateSchema, defaultState), - ); - useEffect(() => { - const next = normalizeState(stateSchema, defaultState); - setState((current) => (sameWidgetState(current, next) ? current : next)); - }, [defaultState, stateSchema]); - const setStateValue = useCallback( - (name: string, value: CustomWidgetStateValue) => { - const type = stateSchema[name]; - if (!type || !matchesStateType(type, value)) return; - setState((current) => ({ ...current, [name]: value })); + const [bindingErrors, setBindingErrors] = useState([]); + const [inputs, setInputs] = useState>({}); + const [inputTypes, setInputTypes] = useState>({}); + const registerInput = useCallback((name: string, type: WidgetInputType, initialValue: WidgetInputValue) => { + setInputTypes((current) => { + const existing = current[name]; + if (existing === type) return current; + if (existing && existing !== type) { + setBindingErrors((errors) => { + const message = `BINDING_TYPE_CONFLICT: '${name}' is bound as both ${existing} and ${type}`; + return errors.includes(message) ? errors : [...errors.slice(0, 4), message]; + }); + return current; + } + return { ...current, [name]: type }; + }); + setInputs((current) => (Object.hasOwn(current, name) ? current : { ...current, [name]: initialValue })); + }, []); + const setInputValue = useCallback( + (name: string, type: WidgetInputType, value: WidgetInputValue) => { + const existing = inputTypes[name]; + if (existing && existing !== type) { + setBindingErrors((errors) => { + const message = `BINDING_TYPE_CONFLICT: '${name}' is bound as both ${existing} and ${type}`; + return errors.includes(message) ? errors : [...errors.slice(0, 4), message]; + }); + return; + } + setInputTypes((current) => (current[name] === type ? current : { ...current, [name]: type })); + setInputs((current) => (Object.is(current[name], value) ? current : { ...current, [name]: value })); }, - [stateSchema], + [inputTypes], ); - useEffect(() => onStateChange?.(state), [onStateChange, state]); const bindings = useMemo( - () => ({ ...createBindings(data), status, options, state }), - [createBindings, data, options, state, status], + () => ({ ...createBindings(data), status, options, inputs }), + [createBindings, data, inputs, options, status], ); const capabilities = useMemo(() => parseRequestCapabilities(rawCapabilities), [rawCapabilities]); const rendered = useMemo(() => { @@ -216,19 +227,24 @@ export function CustomJsxRenderer({ {rendered.error ? ( ) : ( - + {rendered.node} - + )} - {parseErrors.length > 0 && ( + {[...parseErrors, ...bindingErrors].length > 0 && ( - {messages.templateWarnings(parseErrors.length)} + {messages.templateWarnings(parseErrors.length + bindingErrors.length)} - {parseErrors.map((message) => ( + {[...parseErrors, ...bindingErrors].map((message) => ( {message} @@ -238,40 +254,3 @@ export function CustomJsxRenderer({ ); } - -function normalizeState(schema: Record, defaults: Record) { - const state: Record = {}; - for (const [name, type] of Object.entries(schema)) { - const value = defaults[name]; - state[name] = matchesStateType(type, value) - ? value - : type.endsWith("[]") - ? [] - : type === "number" - ? 0 - : type === "boolean" - ? false - : ""; - } - return state; -} - -function matchesStateType(type: string, value: unknown): value is CustomWidgetStateValue { - if (type === "date") return typeof value === "string"; - if (type === "string[]" || type === "date[]") - return Array.isArray(value) && value.every((entry) => typeof entry === "string"); - if (type === "number[]") return Array.isArray(value) && value.every((entry) => typeof entry === "number"); - return typeof value === type; -} - -function sameWidgetState(left: Record, right: Record) { - const leftEntries = Object.entries(left); - const rightEntries = Object.entries(right); - if (leftEntries.length !== rightEntries.length) return false; - return leftEntries.every(([key, value]) => { - const candidate = right[key]; - return Array.isArray(value) && Array.isArray(candidate) - ? value.length === candidate.length && value.every((entry, index) => Object.is(entry, candidate[index])) - : Object.is(value, candidate); - }); -} diff --git a/packages/custom-widgets/src/server/preview-sessions.ts b/packages/custom-widgets/src/server/preview-sessions.ts index fb442ca48..904f0df14 100644 --- a/packages/custom-widgets/src/server/preview-sessions.ts +++ b/packages/custom-widgets/src/server/preview-sessions.ts @@ -1,11 +1,6 @@ import { z } from "zod/v4"; -import { - customJsxRequestSchema, - customWidgetSecretKinds, - customWidgetSourceSchema, - customWidgetStateTypes, -} from "../core"; +import { customJsxRequestSchema, customWidgetSecretKinds, customWidgetSourceSchema } from "../core"; import type { CustomJsxRequest, CustomWidgetSource } from "../core"; import { CustomWidgetDomainError } from "./errors"; @@ -29,8 +24,6 @@ const sessionSchema = z.object({ template: z.string(), optionsSchema: z.record(z.string(), z.unknown()), defaultOptions: z.record(z.string(), z.unknown()), - stateSchema: z.record(z.string(), z.enum(customWidgetStateTypes)), - defaultState: z.record(z.string(), z.unknown()), definitionId: z.string().optional(), liveActions: z.boolean(), }); @@ -58,8 +51,6 @@ export interface CreatePreviewSessionInput { template: string; optionsSchema: Record; defaultOptions: Record; - stateSchema: Record; - defaultState: Record; definitionId?: string; } @@ -107,8 +98,6 @@ export class CustomWidgetPreviewSessionService { template: input.template, optionsSchema: input.optionsSchema, defaultOptions: input.defaultOptions, - stateSchema: input.stateSchema, - defaultState: input.defaultState, definitionId: input.definitionId, liveActions: false, }; diff --git a/packages/custom-widgets/src/styles.d.ts b/packages/custom-widgets/src/styles.d.ts new file mode 100644 index 000000000..e2b48bc71 --- /dev/null +++ b/packages/custom-widgets/src/styles.d.ts @@ -0,0 +1,4 @@ +declare module "*.module.css" { + const classes: Readonly>; + export default classes; +} diff --git a/packages/custom-widgets/src/test/authoring-resources.spec.ts b/packages/custom-widgets/src/test/authoring-resources.spec.ts index 497cb0994..2ea2d3231 100644 --- a/packages/custom-widgets/src/test/authoring-resources.spec.ts +++ b/packages/custom-widgets/src/test/authoring-resources.spec.ts @@ -1,15 +1,9 @@ -import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { describe, expect, test } from "vitest"; -import { - CUSTOM_WIDGET_SKILL_MD, - CUSTOM_WIDGET_SKILL_SHA256, - getCustomWidgetComponent, - getCustomWidgetSkill, -} from "../core/authoring-resources"; +import { CUSTOM_WIDGET_SKILL_MD, getCustomWidgetComponent, getCustomWidgetSkill } from "../core/authoring-resources"; const repositoryRoot = resolve(import.meta.dirname, "../../../.."); @@ -20,11 +14,15 @@ describe("custom widget Agent Skill", () => { expect(getCustomWidgetSkill().skillMd).toBe(source); }); - test("publishes the exact archive hash", async () => { - const archive = await readFile( - resolve(repositoryRoot, "apps/docs/static/downloads/homarr-custom-widget-2.0.0.zip"), + test("publishes repository-based skills.sh installation metadata", () => { + const skill = getCustomWidgetSkill(); + expect(skill.skillsShUrl).toBe("https://www.skills.sh/homarr-labs/homarr/homarr-custom-widget"); + expect(skill.sourceUrl).toContain("github.com/homarr-labs/homarr/tree/v2/.agents/skills/homarr-custom-widget"); + expect(skill.installCommand).toBe( + "npx skills add https://github.com/homarr-labs/homarr --skill homarr-custom-widget", ); - expect(createHash("sha256").update(archive).digest("hex")).toBe(CUSTOM_WIDGET_SKILL_SHA256); + expect(skill).not.toHaveProperty("sha256"); + expect(skill).not.toHaveProperty("downloadUrl"); }); test("publishes safe icon names with the Tabler icon component", () => { diff --git a/packages/custom-widgets/src/test/component-registry.spec.ts b/packages/custom-widgets/src/test/component-registry.spec.ts index 3fae04e56..f46825755 100644 --- a/packages/custom-widgets/src/test/component-registry.spec.ts +++ b/packages/custom-widgets/src/test/component-registry.spec.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from "vitest"; -import { customJsxComponentByName, customJsxComponentRegistry } from "../core/component-registry"; +import { + customJsxComponentByName, + customJsxComponentRegistry, + getCustomJsxBindingType, +} from "../core/component-registry"; const mantineCore941ComponentExports = [ "RemoveScroll", @@ -231,4 +235,25 @@ describe("customJsxComponentRegistry", () => { ])("classifies the Mantine 9.4.1 export %s", (name) => { expect(customJsxComponentByName.has(name), `${name} is not classified`).toBe(true); }); + + test.each([ + ["TextInput", "string"], + ["NumberInput", "number"], + ["Switch", "boolean"], + ["Select", "string"], + ["MultiSelect", "string[]"], + ["DateInput", "string"], + ["Calendar", "string"], + ["Tabs", "string"], + ["Popover", "boolean"], + ["RangeSlider", "number[]"], + ] as const)("infers %s bindings as %s", (component, type) => { + expect(getCustomJsxBindingType(component)).toBe(type); + }); + + test("infers range and multiple date controls from their authored props", () => { + expect(getCustomJsxBindingType("Calendar", { type: "range" })).toBe("string[]"); + expect(getCustomJsxBindingType("Chip.Group", { multiple: true })).toBe("string[]"); + expect(getCustomJsxBindingType("Text", {})).toBeNull(); + }); }); diff --git a/packages/custom-widgets/src/test/fixtures/pokedex-ai-response.md b/packages/custom-widgets/src/test/fixtures/pokedex-ai-response.md new file mode 100644 index 000000000..e4168ad2a --- /dev/null +++ b/packages/custom-widgets/src/test/fixtures/pokedex-ai-response.md @@ -0,0 +1,88 @@ +```json +{ + "$schema": "homarr-custom-widget-v2", + "name": "Pokédex", + "description": "Browse Pokémon from PokeAPI, view stats/types/abilities, and keep a list of favorites. Every section can be shown or hidden.", + "iconUrl": "https://raw.githubusercontent.com/PokeAPI/media/master/logo/pokeapi_256.png", + "sources": [ + { + "id": "default", + "name": "PokeAPI", + "baseUrl": "https://pokeapi.co/api/v2", + "networkScope": "public", + "auth": { "type": "none" } + } + ], + "requests": [ + { + "id": "list", + "sourceId": "default", + "kind": "query", + "method": "GET", + "pathTemplate": "/pokemon", + "parameters": { "pokemonLimit": { "type": "number" } }, + "queryTemplate": { "limit": { "$param": "pokemonLimit" }, "offset": 0 }, + "auth": "inherit", + "minimumBoardPermission": "view", + "trigger": "load", + "invalidates": [] + }, + { + "id": "detail", + "sourceId": "default", + "kind": "query", + "method": "GET", + "pathTemplate": "/pokemon/{name}", + "parameters": { "name": { "type": "string" } }, + "auth": "inherit", + "minimumBoardPermission": "view", + "trigger": "manual", + "invalidates": [] + } + ], + "optionsSchema": { + "type": "object", + "properties": { + "pokemonLimit": { + "type": "number", + "title": "Number of Pokémon to load", + "minimum": 1, + "maximum": 1000, + "x-homarr": { "control": "number" } + }, + "showSprite": { "type": "boolean", "title": "Show sprite image", "x-homarr": { "control": "switch" } }, + "showTypes": { "type": "boolean", "title": "Show type badges", "x-homarr": { "control": "switch" } }, + "showStats": { "type": "boolean", "title": "Show base stats", "x-homarr": { "control": "switch" } }, + "showAbilities": { "type": "boolean", "title": "Show abilities", "x-homarr": { "control": "switch" } }, + "showPhysical": { "type": "boolean", "title": "Show height and weight", "x-homarr": { "control": "switch" } }, + "showFavoritesBar": { + "type": "boolean", + "title": "Show favorites-only toggle", + "x-homarr": { "control": "switch" } + }, + "accentColor": { "type": "string", "title": "Accent color", "x-homarr": { "control": "color" } } + }, + "additionalProperties": false + }, + "defaultOptions": { + "pokemonLimit": 151, + "showSprite": true, + "showTypes": true, + "showStats": true, + "showAbilities": true, + "showPhysical": true, + "showFavoritesBar": true, + "accentColor": "#e3350d" + }, + "stateSchema": { + "type": "object", + "properties": { + "selectedPokemon": { "type": "string" }, + "favorites": { "type": "array", "items": { "type": "string" } }, + "favoritesOnly": { "type": "boolean" } + } + }, + "defaultState": { "selectedPokemon": "", "favorites": [], "favoritesOnly": false }, + "template": "**HOMARR\_TEMPLATE**" +} +``` diff --git a/packages/custom-widgets/src/test/import.spec.ts b/packages/custom-widgets/src/test/import.spec.ts index 882e46677..bcdbd1e3b 100644 --- a/packages/custom-widgets/src/test/import.spec.ts +++ b/packages/custom-widgets/src/test/import.spec.ts @@ -1,31 +1,89 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + import { describe, expect, it } from "vitest"; import { CUSTOM_WIDGET_STARTER } from "../core/examples"; -import { parseCustomWidgetClipboard, parseCustomWidgetClipboardDetailed } from "../core/import"; +import { + parseCustomWidgetAiResponse, + parseCustomWidgetClipboard, + parseCustomWidgetClipboardDetailed, +} from "../core/import"; -describe("parseCustomWidgetClipboard", () => { +describe("Custom Widget imports", () => { it("accepts the canonical raw JSON export", () => { expect(parseCustomWidgetClipboard(JSON.stringify(CUSTOM_WIDGET_STARTER))?.$schema).toBe("homarr-custom-widget-v2"); }); - it("combines widget.json and widget.jsx blocks", () => { + it("combines one widget.json block and one widget.jsx block", () => { const manifest = { ...CUSTOM_WIDGET_STARTER, template: "__HOMARR_TEMPLATE__" }; - const result = parseCustomWidgetClipboard( + const result = parseCustomWidgetAiResponse( `\`\`\`json\n${JSON.stringify(manifest)}\n\`\`\`\n\`\`\`jsx\n{options.title}\n\`\`\``, ); - expect(result?.template).toBe("{options.title}"); + expect(result).toMatchObject({ success: true, widget: { template: "{options.title}" } }); }); - it("rejects removed display types and old exports", () => { + it("normalizes common AI parameter objects but keeps strict imports strict", () => { + const request = { + id: "detail", + sourceId: "default", + kind: "query", + method: "GET", + pathTemplate: "/pokemon/{name}", + parameters: { name: { type: "string" } }, + auth: "inherit", + minimumBoardPermission: "view", + trigger: "manual", + }; + const manifest = { ...CUSTOM_WIDGET_STARTER, requests: [request], template: "__HOMARR_TEMPLATE__" }; + expect(parseCustomWidgetClipboardDetailed(JSON.stringify(manifest)).success).toBe(false); + const result = parseCustomWidgetAiResponse( + `\`\`\`json\n${JSON.stringify(manifest)}\n\`\`\`\n\`\`\`jsx\nPokemon\n\`\`\``, + ); + expect(result).toMatchObject({ + success: true, + widget: { requests: [{ parameters: { name: "string" } }] }, + warnings: [{ code: "NORMALIZED_PARAMETER_TYPE" }], + }); + }); + + it("returns multiple actionable issues for the supplied malformed Pokemon response", async () => { + const response = await readFile(resolve(import.meta.dirname, "fixtures/pokedex-ai-response.md"), "utf8"); + const result = parseCustomWidgetAiResponse(response); + expect(result).toMatchObject({ success: false }); + if (result.success) throw new Error("Expected malformed response to fail"); + expect(result.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: "INVALID_JSON" }), + expect.objectContaining({ code: "INVALID_JSON_ESCAPE" }), + expect.objectContaining({ code: "AI_JSX_BLOCK_REQUIRED" }), + expect.objectContaining({ code: "REMOVED_LOCAL_STATE" }), + ]), + ); + }); + + it("rejects removed fields, legacy displays, and missing JSX fences explicitly", () => { expect( parseCustomWidgetClipboard(JSON.stringify({ displayType: "raw", displayConfig: { type: "raw" } })), ).toBeNull(); + const result = parseCustomWidgetAiResponse( + `\`\`\`json\n${JSON.stringify({ ...CUSTOM_WIDGET_STARTER, stateSchema: {} })}\n\`\`\``, + ); + expect(result).toMatchObject({ success: false }); + if (result.success) throw new Error("Expected removed state to fail"); + expect(result.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: "REMOVED_LOCAL_STATE" }), + expect.objectContaining({ code: "AI_JSX_BLOCK_REQUIRED" }), + ]), + ); }); - it("returns an actionable validation issue for invalid AI output", () => { - expect(parseCustomWidgetClipboardDetailed(JSON.stringify({ $schema: "homarr-custom-widget-v2" }))).toEqual({ - success: false, - error: expect.stringMatching(/name|sources/u), - }); + it("returns all useful schema issues instead of a nullable failure", () => { + const result = parseCustomWidgetClipboardDetailed(JSON.stringify({ $schema: "homarr-custom-widget-v2" })); + expect(result).toMatchObject({ success: false }); + if (result.success) throw new Error("Expected invalid widget to fail"); + expect(result.issues.length).toBeGreaterThan(1); + expect(result.issues.some((issue) => issue.path?.includes("name"))).toBe(true); }); }); diff --git a/packages/custom-widgets/src/test/preview-sessions.spec.ts b/packages/custom-widgets/src/test/preview-sessions.spec.ts index 7ea60ca09..ad1c9c70e 100644 --- a/packages/custom-widgets/src/test/preview-sessions.spec.ts +++ b/packages/custom-widgets/src/test/preview-sessions.spec.ts @@ -37,8 +37,6 @@ const input: CreatePreviewSessionInput = { template: "Preview", optionsSchema: { type: "object", properties: {}, additionalProperties: false }, defaultOptions: {}, - stateSchema: {}, - defaultState: {}, }; describe("preview session service", () => { diff --git a/packages/custom-widgets/src/test/runtime.spec.tsx b/packages/custom-widgets/src/test/runtime.spec.tsx index 4b87cd777..27260b617 100644 --- a/packages/custom-widgets/src/test/runtime.spec.tsx +++ b/packages/custom-widgets/src/test/runtime.spec.tsx @@ -4,11 +4,12 @@ import { act } from "react"; import type { ReactNode } from "react"; import { createRoot } from "react-dom/client"; import type { Root } from "react-dom/client"; -import { MantineProvider, Text } from "@mantine/core"; +import { MantineProvider } from "@mantine/core"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ActionButton, CustomJsxRenderer, CustomWidgetRuntimeProvider, SubFetch, ToggleSwitch } from "../runtime"; +import { createCustomJsxComponents } from "../jsx"; import type { CustomJsxRequestCapability, CustomWidgetPublishedQueryState, @@ -105,35 +106,33 @@ async function settle() { } describe("Custom Widget runtime ports", () => { - it("does not republish local state when equivalent default objects are rendered", async () => { - const onStateChange = vi.fn(); + it("exposes temporary bindings through inputs without browser storage", async () => { + const setItem = vi.spyOn(Storage.prototype, "setItem"); const rendererMessages = { noTemplate: "No template", interactive: "Interactive", networkCapabilities: "Network capabilities", templateWarnings: (count: number) => `${count} warnings`, }; - const widget = () => ( + const components = createCustomJsxComponents({ + TablerIcon: (() => null) as never, + copyLabels: { copy: "Copy", copied: "Copied" }, + }); + const widget = ( {inputs.search}'} data={{}} - stateSchema={{ search: "string" }} - defaultState={{ search: "containers" }} - onStateChange={onStateChange} requestCapabilities={[]} - components={{ Text: Text as never }} + components={components} createBindings={() => ({})} messages={rendererMessages} /> ); - await render(widget(), createPort()); + await render(widget, createPort()); await settle(); - expect(onStateChange).toHaveBeenCalledTimes(1); - - await render(widget(), createPort()); - await settle(); - expect(onStateChange).toHaveBeenCalledTimes(1); + expect(host.textContent).toContain("containers"); + expect(setItem).not.toHaveBeenCalled(); }); it("runs an automatic query once and publishes data to the declarative data root", async () => { diff --git a/packages/custom-widgets/src/test/schema.spec.ts b/packages/custom-widgets/src/test/schema.spec.ts index 5208361fd..3f0cb7282 100644 --- a/packages/custom-widgets/src/test/schema.spec.ts +++ b/packages/custom-widgets/src/test/schema.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "vitest"; +import { BUNDLED_CUSTOM_WIDGETS } from "../core/bundled-widgets"; import { customJsxExamples, CUSTOM_WIDGET_STARTER } from "../core/examples"; import { customJsxRequestSchema, customWidgetCreateSchema, customWidgetDefinitionSchema } from "../core/schema"; import { validateCustomWidgetOptions } from "../core/options"; @@ -9,6 +10,23 @@ describe("Custom JSX v2 validation", () => { expect(() => customWidgetDefinitionSchema.parse(example.widget)).not.toThrow(); }); + test.each(BUNDLED_CUSTOM_WIDGETS)("validates bundled definition $id", ({ widget }) => { + const result = customWidgetDefinitionSchema.safeParse(widget); + expect(result.success, result.error?.issues.map((issue) => issue.message).join("; ")).toBe(true); + }); + + test("keeps the bundled seed set stable, disabled-ready, and credential-free", () => { + expect(BUNDLED_CUSTOM_WIDGETS.map(({ id }) => id)).toEqual([ + "seed-dog-facts", + "seed-currency-exchange", + "seed-jellyfin", + "seed-pokedex", + ]); + for (const { widget } of BUNDLED_CUSTOM_WIDGETS) { + expect(JSON.stringify(widget)).not.toMatch(/"(?:apiKey|password|secret|token)"\s*:/iu); + } + }); + test("rejects every legacy schema", () => { expect( customWidgetDefinitionSchema.safeParse({ ...CUSTOM_WIDGET_STARTER, $schema: "homarr-custom-widget-v3" }).success, @@ -105,7 +123,7 @@ describe("Custom JSX v2 validation", () => { ).toBe(false); }); - test("accepts multi-source actions, dynamic options, conditions, and typed state", () => { + test("accepts multi-source actions, dynamic options, conditions, and temporary inputs", () => { const result = customWidgetDefinitionSchema.safeParse({ ...CUSTOM_WIDGET_STARTER, sources: [ @@ -177,8 +195,6 @@ describe("Custom JSX v2 validation", () => { then: { properties: { advancedColor: { type: "string" } } }, }, defaultOptions: { start: "today" }, - stateSchema: { selectedDate: "date", search: "string", opened: "boolean", selectedEntities: "string[]" }, - defaultState: { selectedDate: "2026-07-17", search: "", opened: false, selectedEntities: ["light.office"] }, template: 'Monitor', }); @@ -225,13 +241,7 @@ describe("Custom JSX v2 validation", () => { }, }).success, ).toBe(false); - expect( - customWidgetDefinitionSchema.safeParse({ - ...CUSTOM_WIDGET_STARTER, - stateSchema: { accessToken: "string" }, - defaultState: {}, - }).success, - ).toBe(false); + expect(customWidgetDefinitionSchema.safeParse({ ...CUSTOM_WIDGET_STARTER, stateSchema: {} }).success).toBe(false); expect( customWidgetDefinitionSchema.safeParse({ ...CUSTOM_WIDGET_STARTER, @@ -253,13 +263,6 @@ describe("Custom JSX v2 validation", () => { staticHeaders: { "X-API-Key": "not-exportable" }, }).success, ).toBe(false); - expect( - customWidgetDefinitionSchema.safeParse({ - ...CUSTOM_WIDGET_STARTER, - stateSchema: { count: "number" }, - defaultState: { count: "wrong" }, - }).success, - ).toBe(false); expect( customWidgetDefinitionSchema.safeParse({ ...CUSTOM_WIDGET_STARTER, @@ -299,27 +302,23 @@ describe("Custom JSX v2 validation", () => { ).toBe(false); }); - test("requires literal bindings to reference declared camel-case state", () => { + test("requires literal temporary input bindings", () => { expect( customWidgetDefinitionSchema.safeParse({ ...CUSTOM_WIDGET_STARTER, - stateSchema: { selectedDate: "date" }, - defaultState: { selectedDate: "2026-07-17" }, template: '', }).success, ).toBe(true); expect( customWidgetDefinitionSchema.safeParse({ ...CUSTOM_WIDGET_STARTER, - stateSchema: { selectedDate: "date" }, - template: '', + template: "", }).success, ).toBe(false); expect( customWidgetDefinitionSchema.safeParse({ ...CUSTOM_WIDGET_STARTER, - stateSchema: { selectedDate: "date" }, - template: "", + template: "{state.search}", }).success, ).toBe(false); }); diff --git a/packages/custom-widgets/src/test/workbench.spec.ts b/packages/custom-widgets/src/test/workbench.spec.ts index fa8583eb8..5d3f616ca 100644 --- a/packages/custom-widgets/src/test/workbench.spec.ts +++ b/packages/custom-widgets/src/test/workbench.spec.ts @@ -12,6 +12,13 @@ import { renameCustomWidgetRequest, } from "../workbench"; import { CUSTOM_WIDGET_STARTER } from "../core/examples"; +import { + CUSTOM_WIDGET_OPTIONS_EXAMPLES, + CUSTOM_WIDGET_REQUEST_EXAMPLES, + getCustomWidgetDefaultOptionsJsonSchema, + getCustomWidgetOptionsJsonSchema, + getCustomWidgetRequestsJsonSchema, +} from "../core/schema-references"; describe("Custom Widget workbench contracts", () => { it("always provides a valid starter template", () => { @@ -41,6 +48,14 @@ describe("Custom Widget workbench contracts", () => { ); }); + it("generates schema references and examples from canonical validators", () => { + expect(getCustomWidgetRequestsJsonSchema()).toMatchObject({ type: "array" }); + expect(getCustomWidgetOptionsJsonSchema()).toMatchObject({ type: "object" }); + expect(getCustomWidgetDefaultOptionsJsonSchema()).toMatchObject({ type: "object" }); + expect(analyzeRequestManifest(JSON.stringify(CUSTOM_WIDGET_REQUEST_EXAMPLES.full))).toEqual([]); + expect(CUSTOM_WIDGET_OPTIONS_EXAMPLES.full.defaults).toMatchObject({ limit: 20 }); + }); + it("validates named request references and rejects inline network capabilities", () => { expect(analyzeJsxTemplate('', { requestIds: ["known"] })).toEqual([]); expect(analyzeJsxTemplate('', { requestIds: ["known"] })).toEqual( @@ -51,13 +66,13 @@ describe("Custom Widget workbench contracts", () => { ); }); - it("diagnoses dynamic and unknown local-state bindings", () => { - expect(analyzeJsxTemplate('', { stateKeys: ["selectedDate"] })).toEqual([]); - expect(analyzeJsxTemplate('', { stateKeys: ["selectedDate"] })).toEqual([ - expect.objectContaining({ code: "unknownStateBinding", value: "missing" }), - ]); - expect(analyzeJsxTemplate("", { stateKeys: ["selectedDate"] })).toEqual( - expect.arrayContaining([expect.objectContaining({ code: "dynamicStateBinding" })]), + it("accepts named temporary inputs and diagnoses dynamic bindings", () => { + expect(analyzeJsxTemplate('')).toEqual([]); + expect(analyzeJsxTemplate("")).toEqual( + expect.arrayContaining([expect.objectContaining({ code: "dynamicInputBinding" })]), + ); + expect(analyzeJsxTemplate('')).toEqual( + expect.arrayContaining([expect.objectContaining({ code: "bindingTypeConflict", value: "filter" })]), ); }); diff --git a/packages/custom-widgets/src/test/workshop-translations.spec.ts b/packages/custom-widgets/src/test/workshop-translations.spec.ts new file mode 100644 index 000000000..de44d3161 --- /dev/null +++ b/packages/custom-widgets/src/test/workshop-translations.spec.ts @@ -0,0 +1,20 @@ +import { readdir, readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +import { describe, expect, test } from "vitest"; + +const languageDirectory = resolve(import.meta.dirname, "../../../translation/src/lang"); + +describe("Workshop navigation translations", () => { + test("defines the navbar and breadcrumb label in every locale", async () => { + const files = (await readdir(languageDirectory)).filter((file) => file.endsWith(".json")); + for (const file of files) { + const messages = JSON.parse(await readFile(resolve(languageDirectory, file), "utf8")) as { + management?: { navbar?: { items?: { workshop?: unknown } } }; + navigationStructure?: { manage?: { workshop?: { label?: unknown } } }; + }; + expect(messages.management?.navbar?.items?.workshop, file).toBe("Workshop"); + expect(messages.navigationStructure?.manage?.workshop?.label, file).toBe("Workshop"); + } + }); +}); diff --git a/packages/custom-widgets/src/workbench/analyzer.ts b/packages/custom-widgets/src/workbench/analyzer.ts index a84cf8b9d..0457a33b1 100644 --- a/packages/custom-widgets/src/workbench/analyzer.ts +++ b/packages/custom-widgets/src/workbench/analyzer.ts @@ -1,16 +1,16 @@ -import { customJsxRequestSchema } from "../core"; +import { customJsxRequestSchema, getCustomJsxBindingType } from "../core"; import { CUSTOM_JSX_LIMITS, validateCustomJsxTemplate } from "../jsx"; export type EditorDiagnosticCode = | "ast" + | "bindingTypeConflict" | "dynamicRequestId" - | "dynamicStateBinding" + | "dynamicInputBinding" | "inlineRequestProps" | "invalidRequestManifest" | "missingRequestId" | "templateEmpty" | "templateTooLong" - | "unknownStateBinding" | "unknownRequest"; export interface EditorDiagnostic { @@ -24,16 +24,15 @@ export interface EditorDiagnostic { export interface AnalyzeJsxTemplateOptions { requestIds?: string[]; - stateKeys?: string[]; } const networkComponentPattern = /<(SubFetch|ActionButton|ToggleSwitch)\b([^>]*)>/giu; -const stateBindingPattern = /<[A-Z][A-Za-z0-9.]*(?:\s[^<>]*?)?\bbind\s*=\s*(?:"([^"]+)"|'([^']+)'|\{)/gu; +const inputBindingPattern = /<([A-Z][A-Za-z0-9.]*)(?:\s[^<>]*?)?\bbind\s*=\s*(?:"([^"]+)"|'([^']+)'|\{)/gu; const lineAt = (source: string, index: number) => source.slice(0, index).split("\n").length; export const CUSTOM_JSX_TEMPLATE_LIMIT = CUSTOM_JSX_LIMITS.templateLength; export function analyzeJsxTemplate( template: string, - { requestIds = [], stateKeys = [] }: AnalyzeJsxTemplateOptions = {}, + { requestIds = [] }: AnalyzeJsxTemplateOptions = {}, ): EditorDiagnostic[] { const diagnostics: EditorDiagnostic[] = []; if (!template.trim()) diagnostics.push({ code: "templateEmpty", severity: "error", line: 1, column: 1, index: 0 }); @@ -71,24 +70,31 @@ export function analyzeJsxTemplate( diagnostics.push({ code: "missingRequestId", severity: "error", line, index: match.index }); } } - const knownState = new Set(stateKeys); - for (const match of template.matchAll(stateBindingPattern)) { - const stateName = match[1] ?? match[2]; - if (!stateName) { + const inputTypes = new Map(); + for (const match of template.matchAll(inputBindingPattern)) { + const componentName = match[1] ?? ""; + const inputName = match[2] ?? match[3]; + if (!inputName) { diagnostics.push({ - code: "dynamicStateBinding", + code: "dynamicInputBinding", severity: "error", line: lineAt(template, match.index), index: match.index, }); - } else if (!knownState.has(stateName)) { + continue; + } + const type = getCustomJsxBindingType(componentName); + const existing = inputTypes.get(inputName); + if (type && existing && existing !== type) { diagnostics.push({ - code: "unknownStateBinding", + code: "bindingTypeConflict", severity: "error", line: lineAt(template, match.index), index: match.index, - value: stateName, + value: inputName, }); + } else if (type) { + inputTypes.set(inputName, type); } } return diagnostics; diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_code-editor-diagnostics.tsx b/packages/custom-widgets/src/workbench/code-editor-diagnostics.tsx similarity index 73% rename from apps/nextjs/src/app/[locale]/manage/custom-widgets/_code-editor-diagnostics.tsx rename to packages/custom-widgets/src/workbench/code-editor-diagnostics.tsx index 0da12af38..26c750e68 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_code-editor-diagnostics.tsx +++ b/packages/custom-widgets/src/workbench/code-editor-diagnostics.tsx @@ -2,26 +2,22 @@ import type { EditorView } from "@codemirror/view"; import { Alert, Stack, Text, UnstyledButton } from "@mantine/core"; import { IconAlertTriangle } from "@tabler/icons-react"; -import type { EditorDiagnostic } from "@homarr/custom-widgets/workbench"; +import type { EditorDiagnostic } from "./analyzer"; +import type { CustomWidgetEditorMessages } from "./code-editor-types"; +import classes from "./code-editor.module.css"; -import classes from "./_code-editor.module.css"; - -interface CodeEditorDiagnosticsProps { - diagnostics: EditorDiagnostic[]; - editorView: EditorView | null; - title: string; - formatDiagnostic: (diagnostic: EditorDiagnostic) => string; -} - -export function CodeEditorDiagnostics({ +export function EditorDiagnostics({ diagnostics, editorView, - title, - formatDiagnostic, -}: CodeEditorDiagnosticsProps) { + messages, +}: { + diagnostics: EditorDiagnostic[]; + editorView: EditorView | null; + messages: CustomWidgetEditorMessages; +}) { if (diagnostics.length === 0) return null; return ( - + {diagnostics.map((diagnostic, index) => (
  • } > - {formatDiagnostic(diagnostic)} + {messages.diagnostic(diagnostic)}
  • diff --git a/packages/custom-widgets/src/workbench/code-editor-toolbar.tsx b/packages/custom-widgets/src/workbench/code-editor-toolbar.tsx new file mode 100644 index 000000000..4286455c5 --- /dev/null +++ b/packages/custom-widgets/src/workbench/code-editor-toolbar.tsx @@ -0,0 +1,131 @@ +import { redo, undo } from "@codemirror/commands"; +import type { EditorView } from "@codemirror/view"; +import { Button, Group, Text } from "@mantine/core"; +import { + IconArrowBackUp, + IconArrowForwardUp, + IconCode, + IconCopy, + IconFileCode, + IconSchema, + IconWand, +} from "@tabler/icons-react"; + +import { ComponentReference } from "./component-reference"; +import type { CustomWidgetCodeEditorProps } from "./code-editor-types"; +import classes from "./code-editor.module.css"; + +interface CodeEditorToolbarProps { + props: CustomWidgetCodeEditorProps; + editorView: EditorView | null; + historyDepth: { undo: number; redo: number }; + formattedValue: string; + copied: boolean; + referenceOpened: boolean; + onReferenceToggle(): void; + onCopy(): void; +} + +export function CodeEditorToolbar({ + props, + editorView, + historyDepth, + formattedValue, + copied, + referenceOpened, + onReferenceToggle, + onCopy, +}: CodeEditorToolbarProps) { + const starter = props.starter; + return ( + + + + + {props.language === "jsx" ? props.messages.languageJsx : props.messages.languageJson} + + + + {!props.readOnly && ( + <> + + + + )} + {!props.readOnly && props.language === "jsx" && ( + + )} + {!props.readOnly && starter && props.value.trim().length === 0 && ( + + )} + {!props.readOnly && ( + + )} + {props.reference && ( + + )} + {props.readOnly && ( + + )} + + + ); +} diff --git a/packages/custom-widgets/src/workbench/code-editor-types.ts b/packages/custom-widgets/src/workbench/code-editor-types.ts new file mode 100644 index 000000000..0074e0ae0 --- /dev/null +++ b/packages/custom-widgets/src/workbench/code-editor-types.ts @@ -0,0 +1,58 @@ +import type { Completion } from "@codemirror/autocomplete"; +import type { ReactNode } from "react"; + +import type { EditorDiagnostic } from "./analyzer"; + +export interface CustomWidgetEditorMessages { + languageJsx: string; + languageJson: string; + undo: string; + redo: string; + components: string; + componentSearch: string; + componentEmpty: string; + componentCount(count: number): string; + insertStarter: string; + format: string; + copy: string; + copied: string; + schema: string; + schemaTab: string; + minimalTab: string; + fullTab: string; + errors(count: number): string; + warnings(count: number): string; + ready: string; + position(cursor: { line: number; column: number }): string; + characters(count: number, limit?: number): string; + diagnosticsTitle: string; + diagnostic(diagnostic: EditorDiagnostic): string; +} + +export interface CustomWidgetSchemaReferenceData { + schema: unknown; + minimal: unknown; + full: unknown; +} + +export interface CustomWidgetCodeEditorProps { + id: string; + value: string; + onChange(value: string): void; + label: string; + messages: CustomWidgetEditorMessages; + description?: string; + placeholder?: string; + language: "jsx" | "json"; + diagnostics?: EditorDiagnostic[]; + error?: ReactNode; + required?: boolean; + maxLength?: number; + starter?: string; + completions?: Completion[]; + revealText?: string; + revealKey?: number; + readOnly?: boolean; + reference?: CustomWidgetSchemaReferenceData; + height?: string; +} diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_code-editor.module.css b/packages/custom-widgets/src/workbench/code-editor.module.css similarity index 89% rename from apps/nextjs/src/app/[locale]/manage/custom-widgets/_code-editor.module.css rename to packages/custom-widgets/src/workbench/code-editor.module.css index d152dc61e..565976940 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_code-editor.module.css +++ b/packages/custom-widgets/src/workbench/code-editor.module.css @@ -21,60 +21,45 @@ .toolbar { border-bottom: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5)); } - .footer { border-top: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5)); } - .viewport { max-height: min(62vh, 680px); overflow: auto; } - .viewport :global(.cm-editor) { background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7)); color: light-dark(var(--mantine-color-black), var(--mantine-color-gray-0)); font-family: "Fira Code", "SFMono-Regular", Consolas, monospace; font-size: 12px; } - .viewport :global(.cm-editor.cm-focused) { outline: none; } - .viewport :global(.cm-scroller) { line-height: 1.65; overflow: auto; } - .viewport :global(.cm-gutters) { background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8)); border-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5)); } - .diagnosticButton { min-height: 44px; border-radius: var(--mantine-radius-sm); } - -.diagnosticButton:focus-visible { - outline: 2px solid var(--mantine-primary-color-filled); - outline-offset: 2px; +.reference { + border-bottom: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5)); + padding: 10px; } @media (max-width: 48em) { .viewport { max-height: 52vh; } - .toolbar, .footer { align-items: flex-start; } } - -@media (prefers-reduced-motion: reduce) { - .root { - scroll-behavior: auto; - } -} diff --git a/packages/custom-widgets/src/workbench/code-editor.tsx b/packages/custom-widgets/src/workbench/code-editor.tsx new file mode 100644 index 000000000..d5ef5569d --- /dev/null +++ b/packages/custom-widgets/src/workbench/code-editor.tsx @@ -0,0 +1,242 @@ +"use client"; + +import { autocompletion } from "@codemirror/autocomplete"; +import { redoDepth, undoDepth } from "@codemirror/commands"; +import { javascript } from "@codemirror/lang-javascript"; +import { json } from "@codemirror/lang-json"; +import { linter } from "@codemirror/lint"; +import type { EditorView as EditorViewType } from "@codemirror/view"; +import { EditorView } from "@codemirror/view"; +import { lazy, Suspense, useEffect, useMemo, useState } from "react"; +import { Badge, Collapse, Group, Input, Loader, Tabs, Text, useComputedColorScheme } from "@mantine/core"; +import { IconCheck } from "@tabler/icons-react"; + +import { createCustomJsxCompletionSource, customJsxComponentHover } from "./code-language"; +import { EditorDiagnostics } from "./code-editor-diagnostics"; +import { CodeEditorToolbar } from "./code-editor-toolbar"; +import type { + CustomWidgetCodeEditorProps, + CustomWidgetEditorMessages, + CustomWidgetSchemaReferenceData, +} from "./code-editor-types"; +import classes from "./code-editor.module.css"; + +export type { + CustomWidgetCodeEditorProps, + CustomWidgetEditorMessages, + CustomWidgetSchemaReferenceData, +} from "./code-editor-types"; + +const LazyCodeMirror = lazy(() => import("@uiw/react-codemirror").then((module) => ({ default: module.default }))); +const EMPTY_DIAGNOSTICS: NonNullable = []; + +export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) { + const diagnostics = props.diagnostics ?? EMPTY_DIAGNOSTICS; + const [mounted, setMounted] = useState(false); + const [copied, setCopied] = useState(false); + const [referenceOpened, setReferenceOpened] = useState(false); + const [cursor, setCursor] = useState({ line: 1, column: 1 }); + const [editorView, setEditorView] = useState(null); + const [historyDepth, setHistoryDepth] = useState({ undo: 0, redo: 0 }); + const colorScheme = useComputedColorScheme("light"); + useEffect(() => setMounted(true), []); + const extensions = useMemo(() => { + const diagnosticsExtension = linter((view) => + diagnostics.map((diagnostic) => { + const from = Math.min(diagnostic.index ?? 0, view.state.doc.length); + return { + from, + to: Math.min(from + 1, view.state.doc.length), + severity: diagnostic.severity, + message: props.messages.diagnostic(diagnostic), + }; + }), + ); + const accessibilityExtension = EditorView.contentAttributes.of({ id: props.id, "aria-label": props.label }); + const editable = props.readOnly + ? [EditorView.editable.of(false), EditorView.contentAttributes.of({ tabindex: "0" })] + : []; + return props.language === "jsx" + ? [ + javascript({ jsx: true }), + autocompletion({ override: [createCustomJsxCompletionSource(props.completions ?? [])] }), + customJsxComponentHover, + diagnosticsExtension, + accessibilityExtension, + ...editable, + ] + : [json(), diagnosticsExtension, accessibilityExtension, ...editable]; + }, [diagnostics, props.completions, props.id, props.label, props.language, props.messages, props.readOnly]); + useEffect(() => { + if (!editorView || !props.revealText) return; + const from = editorView.state.doc.toString().indexOf(props.revealText); + if (from < 0) return; + editorView.dispatch({ + selection: { anchor: from, head: from + props.revealText.length }, + effects: EditorView.scrollIntoView(from, { y: "center" }), + }); + editorView.focus(); + }, [editorView, props.revealKey, props.revealText]); + const formattedValue = useMemo(() => formatCode(props.value, props.language), [props.language, props.value]); + const errorCount = diagnostics.filter(({ severity }) => severity === "error").length; + const warningCount = diagnostics.length - errorCount; + const copy = async () => { + try { + await navigator.clipboard.writeText(props.value); + setCopied(true); + window.setTimeout(() => setCopied(false), 2_000); + } catch { + setCopied(false); + } + }; + return ( + +
    + setReferenceOpened((value) => !value)} + onCopy={() => void copy()} + /> + {props.reference && ( + + + + )} +
    + {mounted ? ( + }> + undefined : props.onChange} + placeholder={props.placeholder} + extensions={extensions} + theme={colorScheme} + height={props.height ?? (props.language === "jsx" ? "320px" : "220px")} + basicSetup={{ + autocompletion: props.language !== "jsx" && !props.readOnly, + bracketMatching: true, + closeBrackets: !props.readOnly, + foldGutter: true, + history: !props.readOnly, + highlightActiveLine: !props.readOnly, + highlightActiveLineGutter: !props.readOnly, + indentOnInput: !props.readOnly, + lineNumbers: true, + }} + onCreateEditor={setEditorView} + onUpdate={(update) => { + if (!update.selectionSet && !update.docChanged) return; + const head = update.state.selection.main.head; + const line = update.state.doc.lineAt(head); + setCursor({ line: line.number, column: head - line.from + 1 }); + setHistoryDepth({ undo: undoDepth(update.state), redo: redoDepth(update.state) }); + }} + /> + + ) : ( + + )} +
    + + + {errorCount > 0 ? ( + + {props.messages.errors(errorCount)} + + ) : warningCount > 0 ? ( + + {props.messages.warnings(warningCount)} + + ) : ( + + + + {props.messages.ready} + + + )} + + {props.messages.position(cursor)} + + + props.maxLength ? "red" : "dimmed"}> + {props.messages.characters(props.value.length, props.maxLength)} + + +
    + +
    + ); +} + +export function ReadOnlyCustomWidgetCode(props: Omit) { + return undefined} />; +} + +export function CustomWidgetSchemaReference({ + reference, + messages, +}: { + reference: CustomWidgetSchemaReferenceData; + messages: CustomWidgetEditorMessages; +}) { + const values = { schema: reference.schema, minimal: reference.minimal, full: reference.full }; + const labels = { schema: messages.schemaTab, minimal: messages.minimalTab, full: messages.fullTab }; + return ( + + + {messages.schemaTab} + {messages.minimalTab} + {messages.fullTab} + + {Object.entries(values).map(([key, value]) => ( + + + + ))} + + ); +} + +function EditorLoader() { + return ( + + + + ); +} +function formatCode(value: string, language: "jsx" | "json") { + if (language === "json") { + try { + return `${JSON.stringify(JSON.parse(value) as unknown, null, 2)}\n`; + } catch { + return value; + } + } + return value + .replace(/\r\n?/gu, "\n") + .split("\n") + .map((line) => line.trimEnd()) + .join("\n") + .trim(); +} diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-code-language.ts b/packages/custom-widgets/src/workbench/code-language.ts similarity index 93% rename from apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-code-language.ts rename to packages/custom-widgets/src/workbench/code-language.ts index a4f0d92fc..46250ddab 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-code-language.ts +++ b/packages/custom-widgets/src/workbench/code-language.ts @@ -1,11 +1,7 @@ import type { Completion, CompletionSource } from "@codemirror/autocomplete"; import { hoverTooltip } from "@codemirror/view"; -import { - customJsxComponentByName, - customJsxTablerIconNames, - enabledCustomJsxComponents, -} from "@homarr/custom-widgets/core"; +import { customJsxComponentByName, customJsxTablerIconNames, enabledCustomJsxComponents } from "../core"; const componentCompletions: Completion[] = enabledCustomJsxComponents.map((component) => ({ label: component.name, diff --git a/packages/custom-widgets/src/workbench/form-schema.ts b/packages/custom-widgets/src/workbench/form-schema.ts index 9c8530f76..357236461 100644 --- a/packages/custom-widgets/src/workbench/form-schema.ts +++ b/packages/custom-widgets/src/workbench/form-schema.ts @@ -27,8 +27,6 @@ export const customWidgetFormSchema = z.object({ requests: jsonArrayString, optionsSchema: jsonObjectString, defaultOptions: jsonObjectString, - stateSchema: jsonObjectString, - defaultState: jsonObjectString, template: z.string().min(1).max(50_000), secrets: z.array( z.object({ sourceId: z.string(), kind: z.string(), value: z.string(), hasValue: z.boolean().optional() }), @@ -58,8 +56,6 @@ export const DEFAULT_CUSTOM_WIDGET_FORM_VALUES: CustomWidgetFormValues = { requests: JSON.stringify(CUSTOM_WIDGET_STARTER.requests, null, 2), optionsSchema: JSON.stringify(CUSTOM_WIDGET_STARTER.optionsSchema, null, 2), defaultOptions: JSON.stringify(CUSTOM_WIDGET_STARTER.defaultOptions, null, 2), - stateSchema: JSON.stringify(CUSTOM_WIDGET_STARTER.stateSchema ?? {}, null, 2), - defaultState: JSON.stringify(CUSTOM_WIDGET_STARTER.defaultState ?? {}, null, 2), template: CUSTOM_WIDGET_STARTER.template, secrets: [], }; diff --git a/packages/custom-widgets/src/workbench/index.ts b/packages/custom-widgets/src/workbench/index.ts index 2cdbe6af1..9844db25f 100644 --- a/packages/custom-widgets/src/workbench/index.ts +++ b/packages/custom-widgets/src/workbench/index.ts @@ -3,6 +3,8 @@ export * from "./response-tree"; export * from "./import-review"; export * from "./ports"; export * from "./component-reference"; +export * from "./code-editor"; +export * from "./code-language"; export * from "./preview-error-boundary"; export * from "./preview-response-panel"; export * from "./preview-request-panel"; diff --git a/packages/db/migrations/mysql/0043_custom_widget_v2_reset.sql b/packages/db/migrations/mysql/0043_custom_widget_v2_reset.sql index 712dff295..3f80c777c 100644 --- a/packages/db/migrations/mysql/0043_custom_widget_v2_reset.sql +++ b/packages/db/migrations/mysql/0043_custom_widget_v2_reset.sql @@ -9,8 +9,6 @@ CREATE TABLE `custom_widget_definition` ( `requests` text NOT NULL, `options_schema` text NOT NULL, `default_options` text NOT NULL, - `state_schema` text, - `default_state` text, `template` text NOT NULL, `enabled` boolean DEFAULT true NOT NULL, `created_at` timestamp DEFAULT (now()) NOT NULL, diff --git a/packages/db/migrations/mysql/meta/0043_snapshot.json b/packages/db/migrations/mysql/meta/0043_snapshot.json index 84d295bb1..4c775df6d 100644 --- a/packages/db/migrations/mysql/meta/0043_snapshot.json +++ b/packages/db/migrations/mysql/meta/0043_snapshot.json @@ -593,20 +593,6 @@ "notNull": true, "autoincrement": false }, - "state_schema": { - "name": "state_schema", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "default_state": { - "name": "default_state", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, "template": { "name": "template", "type": "text", diff --git a/packages/db/migrations/postgresql/0009_custom_widget_v2_reset.sql b/packages/db/migrations/postgresql/0009_custom_widget_v2_reset.sql index 020808300..c9c523309 100644 --- a/packages/db/migrations/postgresql/0009_custom_widget_v2_reset.sql +++ b/packages/db/migrations/postgresql/0009_custom_widget_v2_reset.sql @@ -9,8 +9,6 @@ CREATE TABLE "custom_widget_definition" ( "requests" text NOT NULL, "options_schema" text NOT NULL, "default_options" text NOT NULL, - "state_schema" text, - "default_state" text, "template" text NOT NULL, "enabled" boolean DEFAULT true NOT NULL, "created_at" timestamp DEFAULT now() NOT NULL, diff --git a/packages/db/migrations/postgresql/meta/0009_snapshot.json b/packages/db/migrations/postgresql/meta/0009_snapshot.json index 936bb45b6..7b8a2bf32 100644 --- a/packages/db/migrations/postgresql/meta/0009_snapshot.json +++ b/packages/db/migrations/postgresql/meta/0009_snapshot.json @@ -550,18 +550,6 @@ "primaryKey": false, "notNull": true }, - "state_schema": { - "name": "state_schema", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "default_state": { - "name": "default_state", - "type": "text", - "primaryKey": false, - "notNull": false - }, "template": { "name": "template", "type": "text", diff --git a/packages/db/migrations/seed.ts b/packages/db/migrations/seed.ts index d491b6092..5f1ad3ba0 100644 --- a/packages/db/migrations/seed.ts +++ b/packages/db/migrations/seed.ts @@ -1,6 +1,7 @@ import SuperJSON from "superjson"; import { createId, objectKeys } from "@homarr/common"; +import { BUNDLED_CUSTOM_WIDGETS, customWidgetDefinitionSchema } from "@homarr/custom-widgets/core"; import { createDocumentationLink, credentialsAdminGroup, @@ -15,7 +16,7 @@ import type { WidgetKind } from "@homarr/definitions"; import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings"; import type { Database } from ".."; -import { eq } from ".."; +import { eq, inArray } from ".."; import { getMaxGroupPositionAsync, placeAllWidgetsAsync } from "../queries"; import { getServerSettingByKeyAsync, @@ -26,6 +27,7 @@ import { import { apps, boards, + customWidgetDefinitions, groupMembers, groupPermissions, groups, @@ -57,6 +59,7 @@ export const seedDataAsync = async (db: Database) => { await seedDefaultIntegrationsAsync(db); await seedDefaultAppsAsync(db); await seedDefaultBoardAsync(db); + await seedDefaultCustomWidgetsAsync(db); await seedBoardWidgetsAsync(db); if (isTruthyEnv(process.env.DEMO_MODE)) { @@ -552,6 +555,37 @@ const seedDemoUserAsync = async (db: Database) => { ); }; +const seedDefaultCustomWidgetsAsync = async (db: Database) => { + const seedIds = BUNDLED_CUSTOM_WIDGETS.map(({ id }) => id); + const existing = await db.query.customWidgetDefinitions.findMany({ + columns: { id: true }, + where: inArray(customWidgetDefinitions.id, seedIds), + }); + const existingIds = new Set(existing.map(({ id }) => id)); + const values = BUNDLED_CUSTOM_WIDGETS.filter(({ id }) => !existingIds.has(id)).map(({ id, widget }) => { + const definition = customWidgetDefinitionSchema.parse(widget); + return { + id, + name: definition.name, + description: definition.description ?? null, + iconUrl: definition.iconUrl ?? null, + sources: SuperJSON.stringify(definition.sources), + requests: SuperJSON.stringify(definition.requests), + optionsSchema: SuperJSON.stringify(definition.optionsSchema), + defaultOptions: SuperJSON.stringify(definition.defaultOptions), + template: definition.template, + enabled: false, + creatorId: null, + }; + }); + if (values.length === 0) { + console.log("Skipping seeding of bundled custom widgets because they already exist"); + return; + } + await db.insert(customWidgetDefinitions).values(values); + console.log(`Created ${values.length} bundled custom widgets through seeding process`); +}; + const seedBoardWidgetsAsync = async (db: Database) => { const existingItems = await db.$count(items); if (existingItems > 0) { diff --git a/packages/db/migrations/sqlite/0041_custom_widget_v2_reset.sql b/packages/db/migrations/sqlite/0041_custom_widget_v2_reset.sql index 44653dc24..e8053cb0c 100644 --- a/packages/db/migrations/sqlite/0041_custom_widget_v2_reset.sql +++ b/packages/db/migrations/sqlite/0041_custom_widget_v2_reset.sql @@ -10,8 +10,6 @@ CREATE TABLE `custom_widget_definition` ( `requests` text NOT NULL, `options_schema` text NOT NULL, `default_options` text NOT NULL, - `state_schema` text, - `default_state` text, `template` text NOT NULL, `enabled` integer DEFAULT true NOT NULL, `created_at` integer DEFAULT (unixepoch()) NOT NULL, diff --git a/packages/db/migrations/sqlite/meta/0041_snapshot.json b/packages/db/migrations/sqlite/meta/0041_snapshot.json index fb6f82ab4..586c6524b 100644 --- a/packages/db/migrations/sqlite/meta/0041_snapshot.json +++ b/packages/db/migrations/sqlite/meta/0041_snapshot.json @@ -574,20 +574,6 @@ "notNull": true, "autoincrement": false }, - "state_schema": { - "name": "state_schema", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "default_state": { - "name": "default_state", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, "template": { "name": "template", "type": "text", diff --git a/packages/db/schema/mysql.ts b/packages/db/schema/mysql.ts index ac6da528e..e7454123d 100644 --- a/packages/db/schema/mysql.ts +++ b/packages/db/schema/mysql.ts @@ -508,8 +508,6 @@ export const customWidgetDefinitions = mysqlTable("custom_widget_definition", { requests: text().notNull(), optionsSchema: text().notNull(), defaultOptions: text().notNull(), - stateSchema: text(), - defaultState: text(), template: text().notNull(), enabled: boolean().notNull().default(true), createdAt: timestamp({ mode: "date" }).notNull().defaultNow(), diff --git a/packages/db/schema/postgresql.ts b/packages/db/schema/postgresql.ts index d55e351f6..425ac633a 100644 --- a/packages/db/schema/postgresql.ts +++ b/packages/db/schema/postgresql.ts @@ -507,8 +507,6 @@ export const customWidgetDefinitions = pgTable("custom_widget_definition", { requests: text().notNull(), optionsSchema: text().notNull(), defaultOptions: text().notNull(), - stateSchema: text(), - defaultState: text(), template: text().notNull(), enabled: boolean().notNull().default(true), createdAt: timestamp({ mode: "date" }).notNull().defaultNow(), diff --git a/packages/db/schema/sqlite.ts b/packages/db/schema/sqlite.ts index cd06368cc..dba0efee4 100644 --- a/packages/db/schema/sqlite.ts +++ b/packages/db/schema/sqlite.ts @@ -493,8 +493,6 @@ export const customWidgetDefinitions = sqliteTable("custom_widget_definition", { requests: text().notNull(), optionsSchema: text().notNull(), defaultOptions: text().notNull(), - stateSchema: text(), - defaultState: text(), template: text().notNull(), enabled: int({ mode: "boolean" }).notNull().default(true), createdAt: int({ mode: "timestamp" }) diff --git a/packages/translation/src/lang/ca.json b/packages/translation/src/lang/ca.json index fa274e96e..4ab738a30 100644 --- a/packages/translation/src/lang/ca.json +++ b/packages/translation/src/lang/ca.json @@ -3966,6 +3966,7 @@ }, "navbar": { "items": { + "workshop": "Workshop", "home": "", "boards": "", "apps": "", @@ -5104,6 +5105,9 @@ }, "navigationStructure": { "manage": { + "workshop": { + "label": "Workshop" + }, "label": "", "boards": { "label": "" diff --git a/packages/translation/src/lang/cn.json b/packages/translation/src/lang/cn.json index c039ef9bc..bd5a4024d 100644 --- a/packages/translation/src/lang/cn.json +++ b/packages/translation/src/lang/cn.json @@ -3966,6 +3966,7 @@ }, "navbar": { "items": { + "workshop": "Workshop", "home": "首页", "boards": "面板", "apps": "应用", @@ -5104,6 +5105,9 @@ }, "navigationStructure": { "manage": { + "workshop": { + "label": "Workshop" + }, "label": "管理中心", "boards": { "label": "面板" diff --git a/packages/translation/src/lang/cr.json b/packages/translation/src/lang/cr.json index b7a69006d..265840a6f 100644 --- a/packages/translation/src/lang/cr.json +++ b/packages/translation/src/lang/cr.json @@ -4035,6 +4035,7 @@ }, "navbar": { "items": { + "workshop": "Workshop", "home": "crwdns1572:0crwdne1572:0", "boards": "crwdns1574:0crwdne1574:0", "apps": "crwdns1576:0crwdne1576:0", @@ -5174,6 +5175,9 @@ }, "navigationStructure": { "manage": { + "workshop": { + "label": "Workshop" + }, "label": "crwdns1994:0crwdne1994:0", "boards": { "label": "crwdns1996:0crwdne1996:0" diff --git a/packages/translation/src/lang/cs.json b/packages/translation/src/lang/cs.json index 845649a81..4db21096e 100644 --- a/packages/translation/src/lang/cs.json +++ b/packages/translation/src/lang/cs.json @@ -3966,6 +3966,7 @@ }, "navbar": { "items": { + "workshop": "Workshop", "home": "Domovská stránka", "boards": "Plochy", "apps": "Aplikace", @@ -5104,6 +5105,9 @@ }, "navigationStructure": { "manage": { + "workshop": { + "label": "Workshop" + }, "label": "Spravovat", "boards": { "label": "Plochy" diff --git a/packages/translation/src/lang/da.json b/packages/translation/src/lang/da.json index 3af4b5465..c35f1bd2f 100644 --- a/packages/translation/src/lang/da.json +++ b/packages/translation/src/lang/da.json @@ -3966,6 +3966,7 @@ }, "navbar": { "items": { + "workshop": "Workshop", "home": "Hjem", "boards": "Tavler", "apps": "Apps", @@ -5104,6 +5105,9 @@ }, "navigationStructure": { "manage": { + "workshop": { + "label": "Workshop" + }, "label": "Administrer", "boards": { "label": "Tavler" diff --git a/packages/translation/src/lang/de-CH.json b/packages/translation/src/lang/de-CH.json index a70ae604c..e91ca3970 100644 --- a/packages/translation/src/lang/de-CH.json +++ b/packages/translation/src/lang/de-CH.json @@ -3966,6 +3966,7 @@ }, "navbar": { "items": { + "workshop": "Workshop", "home": "Startseite", "boards": "", "apps": "", @@ -5104,6 +5105,9 @@ }, "navigationStructure": { "manage": { + "workshop": { + "label": "Workshop" + }, "label": "Verwalten", "boards": { "label": "" diff --git a/packages/translation/src/lang/de.json b/packages/translation/src/lang/de.json index 1de4ca274..73892b825 100644 --- a/packages/translation/src/lang/de.json +++ b/packages/translation/src/lang/de.json @@ -4035,6 +4035,7 @@ }, "navbar": { "items": { + "workshop": "Workshop", "home": "Startseite", "boards": "Boards", "apps": "Apps", @@ -5173,6 +5174,9 @@ }, "navigationStructure": { "manage": { + "workshop": { + "label": "Workshop" + }, "label": "Verwalten", "boards": { "label": "Boards" diff --git a/packages/translation/src/lang/el.json b/packages/translation/src/lang/el.json index 29e655012..d3d74966b 100644 --- a/packages/translation/src/lang/el.json +++ b/packages/translation/src/lang/el.json @@ -3966,6 +3966,7 @@ }, "navbar": { "items": { + "workshop": "Workshop", "home": "Αρχική", "boards": "Πίνακες", "apps": "Εφαρμογές", @@ -5104,6 +5105,9 @@ }, "navigationStructure": { "manage": { + "workshop": { + "label": "Workshop" + }, "label": "Διαχείριση", "boards": { "label": "Πίνακες" diff --git a/packages/translation/src/lang/en-gb.json b/packages/translation/src/lang/en-gb.json index 670a341eb..5159236bf 100644 --- a/packages/translation/src/lang/en-gb.json +++ b/packages/translation/src/lang/en-gb.json @@ -3997,6 +3997,7 @@ }, "navbar": { "items": { + "workshop": "Workshop", "home": "", "boards": "", "apps": "", @@ -5135,6 +5136,9 @@ }, "navigationStructure": { "manage": { + "workshop": { + "label": "Workshop" + }, "label": "", "boards": { "label": "" diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index e0c08d129..6b071323f 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -722,7 +722,7 @@ }, "create": { "title": "Create Custom Widget", - "subtitle": "Build a safe JSX widget with inline API sources, options, state, queries, and actions." + "subtitle": "Build a safe JSX widget with inline API sources, options, queries, and actions." }, "edit": { "title": "Edit Custom Widget" @@ -742,7 +742,7 @@ "enable": "Enable", "disable": "Disable", "menu": "Actions", - "downloadSkill": "Download Custom Widget Skill", + "downloadSkill": "View Custom Widget Skill", "publishWorkshop": "Publish to Workshop" }, "field": { @@ -764,7 +764,15 @@ "format": "Format", "insertStarter": "Insert starter", "undo": "Undo", - "redo": "Redo" + "redo": "Redo", + "copy": "Copy", + "copied": "Copied", + "schema": "Schema" + }, + "reference": { + "schema": "JSON Schema", + "minimal": "Minimal example", + "full": "Full example" }, "componentReference": { "search": "Search components", @@ -783,14 +791,14 @@ "title": "Template diagnostics", "line": "Line {line, number}", "ast": "{value}", + "bindingTypeConflict": "The input \"{value}\" is shared by controls with incompatible value types.", "dynamicRequestId": "A literal requestId is required. Dynamic request IDs are not accepted.", - "dynamicStateBinding": "A literal state name is required for bind.", + "dynamicInputBinding": "A literal input name is required for bind.", "inlineRequestProps": "Network components accept requestId and params; inline URL, method, header, and body props are blocked.", "invalidRequestManifest": "Named requests must be a valid request array. {value}", "missingRequestId": "This network component requires a literal requestId.", "templateEmpty": "Enter a JSX template.", "templateTooLong": "The template exceeds the 50,000 character limit.", - "unknownStateBinding": "No local state named \"{value}\" exists.", "unknownRequest": "No named request with ID \"{value}\" exists." } }, @@ -805,7 +813,6 @@ "sources": "API sources", "requests": "Requests", "options": "Options", - "state": "State", "jsx": "JSX", "preview": "Preview" }, @@ -834,12 +841,6 @@ "schema": "Options schema", "defaults": "Default option values" }, - "state": { - "title": "Local state", - "description": "State lives in this widget instance and browser session. Persisted choices belong in options.", - "schema": "State schema", - "defaults": "Default state" - }, "jsx": { "title": "JSX template", "label": "Template" @@ -886,7 +887,6 @@ "fixtureError": "Preview fixture error", "requestData": "Request data", "instanceOptions": "Instance options", - "localState": "Local state", "liveActions": "Run actions against the configured API", "liveActionsDescription": "Off is safe simulation. Turning this on affects the real service.", "noActions": "No actions are defined.", @@ -985,6 +985,7 @@ "description": "Community Custom JSX v2 widgets. Inspect the source and network capabilities before installing.", "securityNotice": "Workshop widgets can call their declared API sources and perform declared actions with credentials you configure locally. Secrets are never included.", "signIn": "Sign in with GitHub", + "signInError": "GitHub sign-in failed. Check that the Workshop OAuth provider is configured and that popups are allowed.", "signOut": "Sign out", "search": "Search widgets", "sort": "Sort", @@ -5853,6 +5854,9 @@ } } }, + "workshop": { + "label": "Workshop" + }, "users": { "label": "Users", "create": { diff --git a/packages/translation/src/lang/es.json b/packages/translation/src/lang/es.json index c281ed7fa..bebdd7edd 100644 --- a/packages/translation/src/lang/es.json +++ b/packages/translation/src/lang/es.json @@ -3966,6 +3966,7 @@ }, "navbar": { "items": { + "workshop": "Workshop", "home": "Inicio", "boards": "Tableros", "apps": "Aplicaciones", @@ -5104,6 +5105,9 @@ }, "navigationStructure": { "manage": { + "workshop": { + "label": "Workshop" + }, "label": "Administrar", "boards": { "label": "Tableros" diff --git a/packages/translation/src/lang/et.json b/packages/translation/src/lang/et.json index fe2798403..3f104e15d 100644 --- a/packages/translation/src/lang/et.json +++ b/packages/translation/src/lang/et.json @@ -3966,6 +3966,7 @@ }, "navbar": { "items": { + "workshop": "Workshop", "home": "", "boards": "", "apps": "", @@ -5104,6 +5105,9 @@ }, "navigationStructure": { "manage": { + "workshop": { + "label": "Workshop" + }, "label": "", "boards": { "label": "" diff --git a/packages/translation/src/lang/fi.json b/packages/translation/src/lang/fi.json index 0f72a89e0..d43af805e 100644 --- a/packages/translation/src/lang/fi.json +++ b/packages/translation/src/lang/fi.json @@ -3966,6 +3966,7 @@ }, "navbar": { "items": { + "workshop": "Workshop", "home": "Aloitus", "boards": "Paneelit", "apps": "Sovellukset", @@ -5104,6 +5105,9 @@ }, "navigationStructure": { "manage": { + "workshop": { + "label": "Workshop" + }, "label": "", "boards": { "label": "Paneelit" diff --git a/packages/translation/src/lang/fr.json b/packages/translation/src/lang/fr.json index 18964abf8..8a6f222db 100644 --- a/packages/translation/src/lang/fr.json +++ b/packages/translation/src/lang/fr.json @@ -4019,6 +4019,7 @@ }, "navbar": { "items": { + "workshop": "Workshop", "home": "Accueil", "boards": "Tableaux de bord", "apps": "Applications", @@ -5157,6 +5158,9 @@ }, "navigationStructure": { "manage": { + "workshop": { + "label": "Workshop" + }, "label": "Gérer", "boards": { "label": "Tableaux de bord" diff --git a/packages/translation/src/lang/he.json b/packages/translation/src/lang/he.json index 59ae93287..6a4202593 100644 --- a/packages/translation/src/lang/he.json +++ b/packages/translation/src/lang/he.json @@ -3966,6 +3966,7 @@ }, "navbar": { "items": { + "workshop": "Workshop", "home": "מסך הבית", "boards": "לוחות", "apps": "אפליקציות", @@ -5104,6 +5105,9 @@ }, "navigationStructure": { "manage": { + "workshop": { + "label": "Workshop" + }, "label": "ניהול", "boards": { "label": "לוחות" diff --git a/packages/translation/src/lang/hr.json b/packages/translation/src/lang/hr.json index a4ebbc435..33135f828 100644 --- a/packages/translation/src/lang/hr.json +++ b/packages/translation/src/lang/hr.json @@ -3966,6 +3966,7 @@ }, "navbar": { "items": { + "workshop": "Workshop", "home": "Dom", "boards": "Daske", "apps": "aplikacije", @@ -5104,6 +5105,9 @@ }, "navigationStructure": { "manage": { + "workshop": { + "label": "Workshop" + }, "label": "Upravljati", "boards": { "label": "Daske" diff --git a/packages/translation/src/lang/hu.json b/packages/translation/src/lang/hu.json index a7e03c19f..294218b4d 100644 --- a/packages/translation/src/lang/hu.json +++ b/packages/translation/src/lang/hu.json @@ -3966,6 +3966,7 @@ }, "navbar": { "items": { + "workshop": "Workshop", "home": "Nyitólap", "boards": "Táblák", "apps": "Alkalmazások", @@ -5104,6 +5105,9 @@ }, "navigationStructure": { "manage": { + "workshop": { + "label": "Workshop" + }, "label": "Kezelés", "boards": { "label": "Táblák" diff --git a/packages/translation/src/lang/it.json b/packages/translation/src/lang/it.json index ee70a9610..609f91f3a 100644 --- a/packages/translation/src/lang/it.json +++ b/packages/translation/src/lang/it.json @@ -4035,6 +4035,7 @@ }, "navbar": { "items": { + "workshop": "Workshop", "home": "Home", "boards": "Board", "apps": "Applicazioni", @@ -5174,6 +5175,9 @@ }, "navigationStructure": { "manage": { + "workshop": { + "label": "Workshop" + }, "label": "Gestisci", "boards": { "label": "Board" diff --git a/packages/translation/src/lang/ja.json b/packages/translation/src/lang/ja.json index 4fac59ceb..724a96fc8 100644 --- a/packages/translation/src/lang/ja.json +++ b/packages/translation/src/lang/ja.json @@ -3966,6 +3966,7 @@ }, "navbar": { "items": { + "workshop": "Workshop", "home": "ホーム", "boards": "ボード", "apps": "アプリ", @@ -5104,6 +5105,9 @@ }, "navigationStructure": { "manage": { + "workshop": { + "label": "Workshop" + }, "label": "管理", "boards": { "label": "ボード" diff --git a/packages/translation/src/lang/ko.json b/packages/translation/src/lang/ko.json index bf9deb26c..4b7f43624 100644 --- a/packages/translation/src/lang/ko.json +++ b/packages/translation/src/lang/ko.json @@ -4020,6 +4020,7 @@ }, "navbar": { "items": { + "workshop": "Workshop", "home": "홈", "boards": "보드", "apps": "앱", @@ -5158,6 +5159,9 @@ }, "navigationStructure": { "manage": { + "workshop": { + "label": "Workshop" + }, "label": "관리", "boards": { "label": "보드" diff --git a/packages/translation/src/lang/lt.json b/packages/translation/src/lang/lt.json index 30e2e1f2c..56fe6f657 100644 --- a/packages/translation/src/lang/lt.json +++ b/packages/translation/src/lang/lt.json @@ -3966,6 +3966,7 @@ }, "navbar": { "items": { + "workshop": "Workshop", "home": "Pagrindinis", "boards": "Lentos", "apps": "Programėlės", @@ -5104,6 +5105,9 @@ }, "navigationStructure": { "manage": { + "workshop": { + "label": "Workshop" + }, "label": "Valdyti", "boards": { "label": "Lentos" diff --git a/packages/translation/src/lang/lv.json b/packages/translation/src/lang/lv.json index 0449f9cbd..9eaa55f67 100644 --- a/packages/translation/src/lang/lv.json +++ b/packages/translation/src/lang/lv.json @@ -3976,6 +3976,7 @@ }, "navbar": { "items": { + "workshop": "Workshop", "home": "Sākums", "boards": "Dēļi", "apps": "Lietotnes", @@ -5114,6 +5115,9 @@ }, "navigationStructure": { "manage": { + "workshop": { + "label": "Workshop" + }, "label": "Pārvaldīt", "boards": { "label": "Dēļi" diff --git a/packages/translation/src/lang/nl.json b/packages/translation/src/lang/nl.json index 601b03641..7f2149d35 100644 --- a/packages/translation/src/lang/nl.json +++ b/packages/translation/src/lang/nl.json @@ -3966,6 +3966,7 @@ }, "navbar": { "items": { + "workshop": "Workshop", "home": "Home", "boards": "Borden", "apps": "Apps", @@ -5104,6 +5105,9 @@ }, "navigationStructure": { "manage": { + "workshop": { + "label": "Workshop" + }, "label": "Beheren", "boards": { "label": "Borden" diff --git a/packages/translation/src/lang/no.json b/packages/translation/src/lang/no.json index f0791bf7c..bc66ceaf0 100644 --- a/packages/translation/src/lang/no.json +++ b/packages/translation/src/lang/no.json @@ -3966,6 +3966,7 @@ }, "navbar": { "items": { + "workshop": "Workshop", "home": "Hjem", "boards": "Tavler", "apps": "Apper", @@ -5104,6 +5105,9 @@ }, "navigationStructure": { "manage": { + "workshop": { + "label": "Workshop" + }, "label": "Administrer", "boards": { "label": "Tavler" diff --git a/packages/translation/src/lang/pl.json b/packages/translation/src/lang/pl.json index b2f33e4a0..2bea3fb6b 100644 --- a/packages/translation/src/lang/pl.json +++ b/packages/translation/src/lang/pl.json @@ -4035,6 +4035,7 @@ }, "navbar": { "items": { + "workshop": "Workshop", "home": "Strona główna", "boards": "Tablice", "apps": "Aplikacje", @@ -5174,6 +5175,9 @@ }, "navigationStructure": { "manage": { + "workshop": { + "label": "Workshop" + }, "label": "Zarządzaj", "boards": { "label": "Tablice" diff --git a/packages/translation/src/lang/pt-br.json b/packages/translation/src/lang/pt-br.json index 063e31b4a..2ce10c43a 100644 --- a/packages/translation/src/lang/pt-br.json +++ b/packages/translation/src/lang/pt-br.json @@ -4018,6 +4018,7 @@ }, "navbar": { "items": { + "workshop": "Workshop", "home": "Início", "boards": "Placas", "apps": "Aplicativos", @@ -5156,6 +5157,9 @@ }, "navigationStructure": { "manage": { + "workshop": { + "label": "Workshop" + }, "label": "Gerenciar", "boards": { "label": "Placas" diff --git a/packages/translation/src/lang/pt.json b/packages/translation/src/lang/pt.json index 1f4d5157d..6aacc4a0a 100644 --- a/packages/translation/src/lang/pt.json +++ b/packages/translation/src/lang/pt.json @@ -3966,6 +3966,7 @@ }, "navbar": { "items": { + "workshop": "Workshop", "home": "", "boards": "", "apps": "", @@ -5104,6 +5105,9 @@ }, "navigationStructure": { "manage": { + "workshop": { + "label": "Workshop" + }, "label": "", "boards": { "label": "" diff --git a/packages/translation/src/lang/ro.json b/packages/translation/src/lang/ro.json index 5696ea647..8961afadf 100644 --- a/packages/translation/src/lang/ro.json +++ b/packages/translation/src/lang/ro.json @@ -3966,6 +3966,7 @@ }, "navbar": { "items": { + "workshop": "Workshop", "home": "Acasă", "boards": "Planșe", "apps": "Aplicații", @@ -5104,6 +5105,9 @@ }, "navigationStructure": { "manage": { + "workshop": { + "label": "Workshop" + }, "label": "Gestionați", "boards": { "label": "Planșe" diff --git a/packages/translation/src/lang/ru.json b/packages/translation/src/lang/ru.json index a76445a4d..1d2b49eee 100644 --- a/packages/translation/src/lang/ru.json +++ b/packages/translation/src/lang/ru.json @@ -3966,6 +3966,7 @@ }, "navbar": { "items": { + "workshop": "Workshop", "home": "Главная", "boards": "Панели", "apps": "Приложения", @@ -5104,6 +5105,9 @@ }, "navigationStructure": { "manage": { + "workshop": { + "label": "Workshop" + }, "label": "Управление", "boards": { "label": "Панели" diff --git a/packages/translation/src/lang/sk.json b/packages/translation/src/lang/sk.json index b636b5e64..06519bc04 100644 --- a/packages/translation/src/lang/sk.json +++ b/packages/translation/src/lang/sk.json @@ -3966,6 +3966,7 @@ }, "navbar": { "items": { + "workshop": "Workshop", "home": "Domovská stránka", "boards": "Dosky", "apps": "Aplikácie", @@ -5104,6 +5105,9 @@ }, "navigationStructure": { "manage": { + "workshop": { + "label": "Workshop" + }, "label": "Spravovať", "boards": { "label": "Dosky" diff --git a/packages/translation/src/lang/sl.json b/packages/translation/src/lang/sl.json index f4acb6d3b..cc234ae34 100644 --- a/packages/translation/src/lang/sl.json +++ b/packages/translation/src/lang/sl.json @@ -3966,6 +3966,7 @@ }, "navbar": { "items": { + "workshop": "Workshop", "home": "Domov", "boards": "Deske", "apps": "Aplikacije", @@ -5104,6 +5105,9 @@ }, "navigationStructure": { "manage": { + "workshop": { + "label": "Workshop" + }, "label": "Upravljanje", "boards": { "label": "Deske" diff --git a/packages/translation/src/lang/sv.json b/packages/translation/src/lang/sv.json index 0206b4f0a..0f363ccae 100644 --- a/packages/translation/src/lang/sv.json +++ b/packages/translation/src/lang/sv.json @@ -3966,6 +3966,7 @@ }, "navbar": { "items": { + "workshop": "Workshop", "home": "Hem", "boards": "Tavlor", "apps": "Applikationer", @@ -5104,6 +5105,9 @@ }, "navigationStructure": { "manage": { + "workshop": { + "label": "Workshop" + }, "label": "Hantera", "boards": { "label": "Tavlor" diff --git a/packages/translation/src/lang/tr.json b/packages/translation/src/lang/tr.json index 0d846dedf..ab4947763 100644 --- a/packages/translation/src/lang/tr.json +++ b/packages/translation/src/lang/tr.json @@ -3966,6 +3966,7 @@ }, "navbar": { "items": { + "workshop": "Workshop", "home": "Ana Sayfa", "boards": "Paneller", "apps": "Uygulamalar", @@ -5104,6 +5105,9 @@ }, "navigationStructure": { "manage": { + "workshop": { + "label": "Workshop" + }, "label": "Yönet", "boards": { "label": "Paneller" diff --git a/packages/translation/src/lang/uk.json b/packages/translation/src/lang/uk.json index d838ae76a..582fa1f34 100644 --- a/packages/translation/src/lang/uk.json +++ b/packages/translation/src/lang/uk.json @@ -3976,6 +3976,7 @@ }, "navbar": { "items": { + "workshop": "Workshop", "home": "Головна", "boards": "Дошки", "apps": "Додатки", @@ -5114,6 +5115,9 @@ }, "navigationStructure": { "manage": { + "workshop": { + "label": "Workshop" + }, "label": "Керувати", "boards": { "label": "Дошки" diff --git a/packages/translation/src/lang/vi.json b/packages/translation/src/lang/vi.json index 3c140fc8a..278751fa2 100644 --- a/packages/translation/src/lang/vi.json +++ b/packages/translation/src/lang/vi.json @@ -3966,6 +3966,7 @@ }, "navbar": { "items": { + "workshop": "Workshop", "home": "Trang chủ", "boards": "Bảng", "apps": "Ứng dụng", @@ -5104,6 +5105,9 @@ }, "navigationStructure": { "manage": { + "workshop": { + "label": "Workshop" + }, "label": "Quản lý", "boards": { "label": "Bảng" diff --git a/packages/translation/src/lang/zh.json b/packages/translation/src/lang/zh.json index 39522d531..23fe69389 100644 --- a/packages/translation/src/lang/zh.json +++ b/packages/translation/src/lang/zh.json @@ -3966,6 +3966,7 @@ }, "navbar": { "items": { + "workshop": "Workshop", "home": "首頁", "boards": "面板", "apps": "應用程式", @@ -5104,6 +5105,9 @@ }, "navigationStructure": { "manage": { + "workshop": { + "label": "Workshop" + }, "label": "管理", "boards": { "label": "面板" diff --git a/packages/widgets/src/_inputs/widget-custom-widget-select-input.tsx b/packages/widgets/src/_inputs/widget-custom-widget-select-input.tsx index 54d789e91..7427e4744 100644 --- a/packages/widgets/src/_inputs/widget-custom-widget-select-input.tsx +++ b/packages/widgets/src/_inputs/widget-custom-widget-select-input.tsx @@ -157,8 +157,6 @@ export const WidgetCustomWidgetSelectInput = ({ selected.requestCapabilities.map((request) => [request.id, { loading: true }]), ), options: selected.defaultOptions, - stateSchema: selected.stateSchema, - defaultState: selected.defaultState, requestCapabilities: selected.requestCapabilities, previewSessionId: "widget-picker", queriesDisabled: true, diff --git a/packages/widgets/src/custom-api/custom-jsx-display.tsx b/packages/widgets/src/custom-api/custom-jsx-display.tsx index 547ff88e6..ae376459c 100644 --- a/packages/widgets/src/custom-api/custom-jsx-display.tsx +++ b/packages/widgets/src/custom-api/custom-jsx-display.tsx @@ -44,9 +44,6 @@ export default function CustomJsxDisplay({ data }: { data: Record [id, value.status])), }} options={isRecord(data.options) ? data.options : {}} - stateSchema={isStringRecord(data.stateSchema) ? data.stateSchema : {}} - defaultState={isRecord(data.defaultState) ? data.defaultState : {}} - onStateChange={typeof data.onStateChange === "function" ? (data.onStateChange as never) : undefined} requestCapabilities={capabilities} components={components} createBindings={SAFE_BINDINGS} @@ -90,7 +87,3 @@ function sameStatus(left: CustomWidgetPublishedQueryState["status"], right: Cust function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } - -function isStringRecord(value: unknown): value is Record { - return isRecord(value) && Object.values(value).every((entry) => typeof entry === "string"); -} diff --git a/packages/workshop/src/client.ts b/packages/workshop/src/client.ts index 7b283e700..9ca4ef02f 100644 --- a/packages/workshop/src/client.ts +++ b/packages/workshop/src/client.ts @@ -112,9 +112,6 @@ export class WorkshopClient { public async signInWithGitHub() { try { - const methods = await this.pocketBase.collection("users").listAuthMethods(); - if (!methods.oauth2.providers.some((provider) => provider.name === "github")) - throw new WorkshopError("unavailable", "GitHub login is not configured"); await this.pocketBase .collection("users") .authWithOAuth2({ provider: "github", createData: { displayName: "Community member" } }); diff --git a/packages/workshop/src/schema.ts b/packages/workshop/src/schema.ts index b13c92058..eb4457e33 100644 --- a/packages/workshop/src/schema.ts +++ b/packages/workshop/src/schema.ts @@ -151,8 +151,7 @@ function containsCredentialLikeValue(widget: z.infer