fix(custom-widgets): restore bundled authoring

This commit is contained in:
Thomas Camlong committed 2026-07-20 16:48:00 +02:00
1 parent c39ddc4285
commit 4c69ad90df
16 files changed
+180 -58

No files matched your search

@@ -0,0 +1,9 @@
{
"label": "Custom Widgets",
"collapsed": true,
"position": 8,
"link": {
"type": "doc",
"id": "management/custom-widgets/index"
}
}
@@ -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
@@ -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.
@@ -1,4 +1,6 @@
---
title: Custom Widgets
sidebar_label: Overview
tags: [Custom Widgets, API, JSX, MCP]
---
@@ -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 (
<Button
type="button"
variant="light"
leftSection={<IconSparkles size={16} />}
onClick={() => void handleCopy()}
fullWidth
size="sm"
>
{t("action.copyAiPrompt")}
</Button>
<Button.Group w="100%">
<Button
type="button"
variant="light"
leftSection={<IconSparkles size={16} />}
onClick={() => void handleCopy()}
fullWidth
size="sm"
>
{t("action.copyAiPrompt")}
</Button>
<Menu position="bottom-end" withinPortal={false}>
<Menu.Target>
<Button type="button" variant="light" px="xs" aria-label={t("action.copyAiPromptWithSkill")}>
<IconChevronDown size={16} />
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item leftSection={<IconSparkles size={16} />} onClick={() => void handleCopy()}>
{t("action.copyAiPrompt")}
</Menu.Item>
<Menu.Item leftSection={<IconFileText size={16} />} onClick={() => void handleCopy(true)}>
{t("action.copyAiPromptWithSkill")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Button.Group>
);
};
@@ -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");
}
@@ -48,10 +48,14 @@ export function buildCustomWidgetAiPrompt(
currentConfig?: Partial<HomarrCustomWidgetV2> | Record<string, unknown> | 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\`\`\``);
@@ -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";
@@ -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,
@@ -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",
);
@@ -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<CustomWidgetCodeEditorProps["diagnostics"]> = [];
export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) {
const diagnostics = props.diagnostics ?? EMPTY_DIAGNOSTICS;
const [mounted, setMounted] = useState(false);
const [CodeMirror, setCodeMirror] = useState<ComponentType<ReactCodeMirrorProps> | null>(null);
const [copied, setCopied] = useState(false);
const [referenceOpened, setReferenceOpened] = useState(false);
const [cursor, setCursor] = useState({ line: 1, column: 1 });
const [editorView, setEditorView] = useState<EditorViewType | null>(null);
const [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) {
</Collapse>
)}
<div className={classes.viewport}>
{mounted ? (
<Suspense fallback={<EditorLoader />}>
<LazyCodeMirror
id={`${props.id}-root`}
value={props.value}
onChange={props.readOnly ? () => undefined : props.onChange}
placeholder={props.placeholder}
extensions={extensions}
theme={colorScheme}
height={props.height ?? (props.language === "jsx" ? "320px" : "220px")}
basicSetup={{
autocompletion: props.language !== "jsx" && !props.readOnly,
bracketMatching: true,
closeBrackets: !props.readOnly,
foldGutter: true,
history: !props.readOnly,
highlightActiveLine: !props.readOnly,
highlightActiveLineGutter: !props.readOnly,
indentOnInput: !props.readOnly,
lineNumbers: true,
}}
onCreateEditor={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) });
}}
/>
</Suspense>
{CodeMirror ? (
<CodeMirror
id={`${props.id}-root`}
value={props.value}
onChange={props.readOnly ? () => undefined : props.onChange}
placeholder={props.placeholder}
extensions={extensions}
theme={colorScheme}
height={props.height ?? (props.language === "jsx" ? "320px" : "220px")}
basicSetup={{
autocompletion: props.language !== "jsx" && !props.readOnly,
bracketMatching: true,
closeBrackets: !props.readOnly,
foldGutter: true,
history: !props.readOnly,
highlightActiveLine: !props.readOnly,
highlightActiveLineGutter: !props.readOnly,
indentOnInput: !props.readOnly,
lineNumbers: true,
}}
onCreateEditor={handleCreateEditor}
onUpdate={(update) => {
if (!editorView) return;
if (!update.selectionSet && !update.docChanged) return;
const head = update.state.selection.main.head;
const line = update.state.doc.lineAt(head);
setCursor({ line: line.number, column: head - line.from + 1 });
setHistoryDepth({ undo: undoDepth(update.state), redo: redoDepth(update.state) });
}}
/>
) : (
<EditorLoader />
)}
+9 -1
View File
@@ -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();
@@ -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();
+27
View File
@@ -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();
});
+1
View File
@@ -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",
+10 -1
View File
@@ -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/**"],
},
},
{