From 4c69ad90dfc3d5da2b78c2cd3cdfc36c71391ff1 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 20 Jul 2026 16:48:00 +0200 Subject: [PATCH] fix(custom-widgets): restore bundled authoring --- .../management/custom-widgets/_category_.json | 9 ++ .../custom-widgets/agent-authoring.mdx | 2 +- .../management/custom-widgets/authoring.mdx | 2 +- .../docs/management/custom-widgets/index.mdx | 2 + .../custom-widgets/_copy-ai-prompt-button.tsx | 54 ++++++++---- .../scripts/check-architecture.mjs | 2 +- packages/custom-widgets/src/core/ai-prompt.ts | 4 + .../src/core/authoring-resources.ts | 2 +- .../custom-widgets/src/test/ai-prompt.spec.ts | 17 ++++ .../src/test/authoring-resources.spec.ts | 2 +- .../src/workbench/code-editor.tsx | 83 +++++++++++-------- packages/db/test/mysql-migration.spec.ts | 10 ++- packages/db/test/postgresql-migration.spec.ts | 10 ++- packages/db/test/sqlite-migration.spec.ts | 27 ++++++ packages/translation/src/lang/en.json | 1 + vitest.config.mts | 11 ++- 16 files changed, 180 insertions(+), 58 deletions(-) create mode 100644 apps/docs/docs/management/custom-widgets/_category_.json create mode 100644 packages/db/test/sqlite-migration.spec.ts diff --git a/apps/docs/docs/management/custom-widgets/_category_.json b/apps/docs/docs/management/custom-widgets/_category_.json new file mode 100644 index 000000000..23e215294 --- /dev/null +++ b/apps/docs/docs/management/custom-widgets/_category_.json @@ -0,0 +1,9 @@ +{ + "label": "Custom Widgets", + "collapsed": true, + "position": 8, + "link": { + "type": "doc", + "id": "management/custom-widgets/index" + } +} diff --git a/apps/docs/docs/management/custom-widgets/agent-authoring.mdx b/apps/docs/docs/management/custom-widgets/agent-authoring.mdx index 507042ebf..9d2ad7479 100644 --- a/apps/docs/docs/management/custom-widgets/agent-authoring.mdx +++ b/apps/docs/docs/management/custom-widgets/agent-authoring.mdx @@ -12,7 +12,7 @@ Install the Agent Skill directly from the verified Homarr repository: npx skills add https://github.com/homarr-labs/homarr --skill homarr-custom-widget ``` -The source lives at [`.agents/skills/homarr-custom-widget`](https://github.com/homarr-labs/homarr/tree/v2/.agents/skills/homarr-custom-widget). There is no ZIP archive. After the skill exists on the public `v2` branch, run the command once to register installation activity, verify the skills.sh page, and request official maker attribution for `homarr-labs` through skills.sh. Repository code cannot grant that attribution itself. +The source lives at [`.agents/skills/homarr-custom-widget`](https://github.com/homarr-labs/homarr/tree/dev/.agents/skills/homarr-custom-widget). There is no ZIP archive. After the skill exists on the repository's default branch, run the command once to register installation activity, verify the skills.sh page, and request official maker attribution for `homarr-labs` through skills.sh. Repository code cannot grant that attribution itself. ## MCP discovery diff --git a/apps/docs/docs/management/custom-widgets/authoring.mdx b/apps/docs/docs/management/custom-widgets/authoring.mdx index a0408eacc..e061ee08c 100644 --- a/apps/docs/docs/management/custom-widgets/authoring.mdx +++ b/apps/docs/docs/management/custom-widgets/authoring.mdx @@ -20,7 +20,7 @@ The **Schema** action beside Undo, Redo, and Format expands the generated JSON S ## ChatGPT copy/paste -Enter a short request and optional API documentation URL, then select **Copy AI prompt**. Paste it into ChatGPT or another model. The model must return exactly: +Enter a short request and optional API documentation URL, then select **Copy AI prompt**. Its dropdown also offers a self-contained version with the Custom Widget skill embedded for models that cannot discover skills or MCP resources. Paste either prompt into ChatGPT or another model. The model must return exactly: 1. A fenced `json` block with one complete widget and `template: "__HOMARR_TEMPLATE__"`. 2. A fenced `jsx` block with the readable template. diff --git a/apps/docs/docs/management/custom-widgets/index.mdx b/apps/docs/docs/management/custom-widgets/index.mdx index 1c115dadb..1fb51cf83 100644 --- a/apps/docs/docs/management/custom-widgets/index.mdx +++ b/apps/docs/docs/management/custom-widgets/index.mdx @@ -1,4 +1,6 @@ --- +title: Custom Widgets +sidebar_label: Overview tags: [Custom Widgets, API, JSX, MCP] --- diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx index 66da3bd98..46139e3f3 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx @@ -1,9 +1,9 @@ "use client"; -import { Button } from "@mantine/core"; -import { IconSparkles } from "@tabler/icons-react"; +import { Button, Menu } from "@mantine/core"; +import { IconChevronDown, IconFileText, IconSparkles } from "@tabler/icons-react"; -import { buildCustomWidgetAiPrompt } from "@homarr/custom-widgets/core"; +import { buildCustomWidgetAiPrompt, CUSTOM_WIDGET_SKILL_MD } from "@homarr/custom-widgets/core"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useScopedI18n } from "@homarr/translation/client"; @@ -22,10 +22,17 @@ export const CopyAiPromptButton = ({ }: CopyAiPromptButtonProps) => { const t = useScopedI18n("customWidget"); - const handleCopy = async () => { + const handleCopy = async (includeSkill = false) => { try { await navigator.clipboard.writeText( - buildCustomWidgetAiPrompt(undefined, rawResponse, currentConfig, request, documentationUrl), + buildCustomWidgetAiPrompt( + undefined, + rawResponse, + currentConfig, + request, + documentationUrl, + includeSkill ? CUSTOM_WIDGET_SKILL_MD : undefined, + ), ); showSuccessNotification({ title: t("action.copyAiPrompt"), message: t("notification.aiPromptCopied") }); } catch { @@ -34,15 +41,32 @@ export const CopyAiPromptButton = ({ }; return ( - + + + + + + + + } onClick={() => void handleCopy()}> + {t("action.copyAiPrompt")} + + } onClick={() => void handleCopy(true)}> + {t("action.copyAiPromptWithSkill")} + + + + ); }; diff --git a/packages/custom-widgets/scripts/check-architecture.mjs b/packages/custom-widgets/scripts/check-architecture.mjs index 2d7489963..ec9563d9d 100644 --- a/packages/custom-widgets/scripts/check-architecture.mjs +++ b/packages/custom-widgets/scripts/check-architecture.mjs @@ -86,7 +86,7 @@ const editorAdapter = await readFile( join(repositoryRoot, "packages/custom-widgets/src/workbench/code-editor.tsx"), "utf8", ); -if (!/lazy\(\(\)\s*=>\s*import\(["']@uiw\/react-codemirror["']\)/u.test(editorAdapter)) { +if (!/useEffect\(\(\)\s*=>[\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 d5f1a9ce9..d69cb39d0 100644 --- a/packages/custom-widgets/src/core/ai-prompt.ts +++ b/packages/custom-widgets/src/core/ai-prompt.ts @@ -48,10 +48,14 @@ export function buildCustomWidgetAiPrompt( currentConfig?: Partial | Record | null, request?: string | null, documentationUrl?: string | null, + embeddedSkill?: string, ) { const sections = [AUTHORING_PROMPT]; if (documentationUrl) sections.push(`API documentation: ${redactUrl(documentationUrl)}`); sections.push(`Requested widget: ${redactText(request?.trim() || "Describe the widget you want to create.")}`); + if (embeddedSkill) { + sections.push(`Use this embedded Homarr Custom Widget skill while authoring:\n\n${embeddedSkill}`); + } if (rawResponse) sections.push(`Sample API response:\n\n\`\`\`json\n${redactResponse(rawResponse)}\n\`\`\``); if (currentConfig) { sections.push(`Current widget:\n\n\`\`\`json\n${JSON.stringify(redactValue(currentConfig), null, 2)}\n\`\`\``); diff --git a/packages/custom-widgets/src/core/authoring-resources.ts b/packages/custom-widgets/src/core/authoring-resources.ts index ab2b7da5d..a4a1c7278 100644 --- a/packages/custom-widgets/src/core/authoring-resources.ts +++ b/packages/custom-widgets/src/core/authoring-resources.ts @@ -9,7 +9,7 @@ import { customJsxTablerIconNames } from "./tabler-icons"; export const CUSTOM_WIDGET_SKILL_VERSION = "2.0.0"; 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"; + "https://github.com/homarr-labs/homarr/tree/dev/.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"; diff --git a/packages/custom-widgets/src/test/ai-prompt.spec.ts b/packages/custom-widgets/src/test/ai-prompt.spec.ts index 9a114863a..31bdb82fb 100644 --- a/packages/custom-widgets/src/test/ai-prompt.spec.ts +++ b/packages/custom-widgets/src/test/ai-prompt.spec.ts @@ -1,9 +1,26 @@ import { describe, expect, test } from "vitest"; import { buildCustomWidgetAiPrompt, buildCustomWidgetFixPrompt, buildCustomWidgetMcpPrompt } from "../core/ai-prompt"; +import { CUSTOM_WIDGET_SKILL_MD } from "../core/authoring-resources"; import { CUSTOM_WIDGET_STARTER } from "../core/examples"; describe("buildCustomWidgetAiPrompt", () => { + test("can embed the portable skill for standalone chat models", () => { + const prompt = buildCustomWidgetAiPrompt( + undefined, + undefined, + undefined, + "Create a status widget", + undefined, + CUSTOM_WIDGET_SKILL_MD, + ); + + expect(prompt).toContain("Use this embedded Homarr Custom Widget skill while authoring:"); + expect(prompt).toContain("# Homarr Custom Widget"); + expect(prompt).toContain("installed Mantine components can differ by Homarr release."); + expect(prompt.length).toBeLessThanOrEqual(8_000); + }); + test("is concise and describes the one-widget v2 contract", () => { const prompt = buildCustomWidgetAiPrompt( undefined, diff --git a/packages/custom-widgets/src/test/authoring-resources.spec.ts b/packages/custom-widgets/src/test/authoring-resources.spec.ts index 2ea2d3231..7a15c493a 100644 --- a/packages/custom-widgets/src/test/authoring-resources.spec.ts +++ b/packages/custom-widgets/src/test/authoring-resources.spec.ts @@ -17,7 +17,7 @@ describe("custom widget Agent Skill", () => { 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.sourceUrl).toBe("https://github.com/homarr-labs/homarr/tree/dev/.agents/skills/homarr-custom-widget"); expect(skill.installCommand).toBe( "npx skills add https://github.com/homarr-labs/homarr --skill homarr-custom-widget", ); diff --git a/packages/custom-widgets/src/workbench/code-editor.tsx b/packages/custom-widgets/src/workbench/code-editor.tsx index d5ef5569d..2485953cd 100644 --- a/packages/custom-widgets/src/workbench/code-editor.tsx +++ b/packages/custom-widgets/src/workbench/code-editor.tsx @@ -7,7 +7,9 @@ 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 type { ComponentType } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { ReactCodeMirrorProps } from "@uiw/react-codemirror"; import { Badge, Collapse, Group, Input, Loader, Tabs, Text, useComputedColorScheme } from "@mantine/core"; import { IconCheck } from "@tabler/icons-react"; @@ -27,19 +29,26 @@ export type { 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 [CodeMirror, setCodeMirror] = useState | null>(null); 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), []); + useEffect(() => { + let cancelled = false; + void import("@uiw/react-codemirror").then((module) => { + if (!cancelled) setCodeMirror(() => module.default); + }); + return () => { + cancelled = true; + }; + }, []); const extensions = useMemo(() => { const diagnosticsExtension = linter((view) => diagnostics.map((diagnostic) => { @@ -77,6 +86,11 @@ export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) { }); editorView.focus(); }, [editorView, props.revealKey, props.revealText]); + const handleCreateEditor = useCallback((view: EditorViewType) => { + // @uiw invokes this while constructing CodeMirror. Deferring the parent update avoids + // re-entering React's renderer during initialisation in Next.js and Docusaurus. + window.setTimeout(() => setEditorView(view), 0); + }, []); const formattedValue = useMemo(() => formatCode(props.value, props.language), [props.language, props.value]); const errorCount = diagnostics.filter(({ severity }) => severity === "error").length; const warningCount = diagnostics.length - errorCount; @@ -115,37 +129,36 @@ export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) { )}
- {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) }); - }} - /> - + {CodeMirror ? ( + undefined : props.onChange} + placeholder={props.placeholder} + extensions={extensions} + theme={colorScheme} + height={props.height ?? (props.language === "jsx" ? "320px" : "220px")} + basicSetup={{ + autocompletion: props.language !== "jsx" && !props.readOnly, + bracketMatching: true, + closeBrackets: !props.readOnly, + foldGutter: true, + history: !props.readOnly, + highlightActiveLine: !props.readOnly, + highlightActiveLineGutter: !props.readOnly, + indentOnInput: !props.readOnly, + lineNumbers: true, + }} + onCreateEditor={handleCreateEditor} + onUpdate={(update) => { + if (!editorView) return; + if (!update.selectionSet && !update.docChanged) return; + const head = update.state.selection.main.head; + const line = update.state.doc.lineAt(head); + setCursor({ line: line.number, column: head - line.from + 1 }); + setHistoryDepth({ undo: undoDepth(update.state), redo: redoDepth(update.state) }); + }} + /> ) : ( )} diff --git a/packages/db/test/mysql-migration.spec.ts b/packages/db/test/mysql-migration.spec.ts index a235f00fc..8b9cd880e 100644 --- a/packages/db/test/mysql-migration.spec.ts +++ b/packages/db/test/mysql-migration.spec.ts @@ -3,11 +3,13 @@ import { MySqlContainer } from "@testcontainers/mysql"; import { drizzle } from "drizzle-orm/mysql2"; import { migrate } from "drizzle-orm/mysql2/migrator"; import mysql from "mysql2"; -import { describe, test } from "vitest"; +import { describe, expect, test } from "vitest"; import { DB_CASING } from "@homarr/core/infrastructure/db/constants"; import * as mysqlSchema from "../schema/mysql"; +import type { Database } from ".."; +import { seedDataAsync } from "../migrations/seed"; describe("Mysql Migration", () => { test("should add all tables and keys specified in migration files", async () => { @@ -31,9 +33,15 @@ describe("Mysql Migration", () => { await migrate(database, { migrationsFolder: path.join(__dirname, "..", "migrations", "mysql"), }); + await seedDataAsync(database as unknown as Database); // Check if users table exists await database.query.users.findMany(); + const bundledWidgets = await database.query.customWidgetDefinitions.findMany({ + where: (table, { like }) => like(table.id, "seed-%"), + }); + expect(bundledWidgets).toHaveLength(4); + expect(bundledWidgets.every(({ enabled }) => !enabled)).toBe(true); connection.end(); await mysqlContainer.stop(); diff --git a/packages/db/test/postgresql-migration.spec.ts b/packages/db/test/postgresql-migration.spec.ts index c7d4ce4ee..d8f7cc1e9 100644 --- a/packages/db/test/postgresql-migration.spec.ts +++ b/packages/db/test/postgresql-migration.spec.ts @@ -3,11 +3,13 @@ import { PostgreSqlContainer } from "@testcontainers/postgresql"; import { drizzle } from "drizzle-orm/node-postgres"; import { migrate } from "drizzle-orm/node-postgres/migrator"; import { Pool } from "pg"; -import { describe, test } from "vitest"; +import { describe, expect, test } from "vitest"; import { DB_CASING } from "@homarr/core/infrastructure/db/constants"; import * as pgSchema from "../schema/postgresql"; +import type { Database } from ".."; +import { seedDataAsync } from "../migrations/seed"; describe("PostgreSql Migration", () => { test("should add all tables and keys specified in migration files", async () => { @@ -36,9 +38,15 @@ describe("PostgreSql Migration", () => { await migrate(database, { migrationsFolder: path.join(__dirname, "..", "migrations", "postgresql"), }); + await seedDataAsync(database as unknown as Database); // Check if users table exists await database.query.users.findMany(); + const bundledWidgets = await database.query.customWidgetDefinitions.findMany({ + where: (table, { like }) => like(table.id, "seed-%"), + }); + expect(bundledWidgets).toHaveLength(4); + expect(bundledWidgets.every(({ enabled }) => !enabled)).toBe(true); // Close the pool to release resources await pool.end(); diff --git a/packages/db/test/sqlite-migration.spec.ts b/packages/db/test/sqlite-migration.spec.ts new file mode 100644 index 000000000..103d45bfd --- /dev/null +++ b/packages/db/test/sqlite-migration.spec.ts @@ -0,0 +1,27 @@ +import path from "path"; +import BetterSqlite3 from "better-sqlite3"; +import { drizzle } from "drizzle-orm/better-sqlite3"; +import { migrate } from "drizzle-orm/better-sqlite3/migrator"; +import { expect, test } from "vitest"; + +import { DB_CASING } from "@homarr/core/infrastructure/db/constants"; + +import type { Database } from ".."; +import { seedDataAsync } from "../migrations/seed"; +import * as sqliteSchema from "../schema/sqlite"; + +test("SQLite migrations seed the four disabled bundled custom widgets", async () => { + const connection = new BetterSqlite3(":memory:"); + const database = drizzle(connection, { schema: sqliteSchema, casing: DB_CASING }); + + migrate(database, { migrationsFolder: path.join(__dirname, "..", "migrations", "sqlite") }); + await seedDataAsync(database as unknown as Database); + + const bundledWidgets = await database.query.customWidgetDefinitions.findMany({ + where: (table, { like }) => like(table.id, "seed-%"), + }); + expect(bundledWidgets).toHaveLength(4); + expect(bundledWidgets.every(({ enabled }) => !enabled)).toBe(true); + + connection.close(); +}); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index f08e9aa0a..335556d3c 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -743,6 +743,7 @@ "edit": "Edit", "duplicate": "Duplicate", "copyAiPrompt": "Copy AI prompt", + "copyAiPromptWithSkill": "Copy AI prompt with embedded skill", "enable": "Enable", "disable": "Disable", "menu": "Actions", diff --git a/vitest.config.mts b/vitest.config.mts index 6f40c2b20..600fb91bc 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -26,13 +26,22 @@ export default defineConfig({ include: ["packages/api/**/*.spec.ts"], }, }, + { + extends: true, + test: { + name: "db-node", + environment: "node", + setupFiles: ["./vitest.setup.ts", "./vitest.setup.node.ts"], + include: ["packages/db/test/**/*.spec.ts"], + }, + }, { extends: true, test: { name: "dom", environment: "jsdom", include: ["**/*.spec.{ts,tsx}"], - exclude: [...configDefaults.exclude, "apps/nextjs/.next", "packages/api/**", "e2e/**"], + exclude: [...configDefaults.exclude, "apps/nextjs/.next", "packages/api/**", "packages/db/**", "e2e/**"], }, }, {