streamline AI import and Workshop errors
This commit is contained in:
1 parent
7a2d6009f7
commit
944c674939
21 files changed
+149
-109
No files matched your search
@@ -27,11 +27,12 @@ Every option has a default. Supported controls include text, textarea, number, s
|
||||
|
||||
Enter the desired widget and optional API documentation URL, then select **Copy AI prompt**. The single self-contained prompt is below 12,000 characters, starts with your request, includes the lean contract and two polished examples, and never claims that an offline chat has MCP tools.
|
||||
|
||||
The model must return exactly:
|
||||
The model must return one fenced `json` block containing the complete widget, including the JSX in its `template`
|
||||
field. Copy that single block and select **Paste AI response**. Homarr fills the visual builders and the JSX editor
|
||||
without any placeholder replacement or second paste.
|
||||
|
||||
1. One fenced `json` block with `template: "__HOMARR_TEMPLATE__"`.
|
||||
2. One fenced `jsx` block with the readable template.
|
||||
|
||||
Select **Paste AI response**. Homarr fills the visual builders and shows all actionable schema or JSX errors inline. Enter credentials separately. The prompt deliberately recommends a focused Mantine component set, visual hierarchy, responsive layout, theme compatibility, and loading, empty, failure, and success states.
|
||||
Homarr shows all actionable schema or JSX errors inline. Enter credentials separately. The prompt deliberately
|
||||
recommends a focused Mantine component set, visual hierarchy, responsive layout, theme compatibility, and loading,
|
||||
empty, failure, and success states.
|
||||
|
||||
Connected agents should use the skills.sh skill and retrieve the live schema and individual component metadata only as needed.
|
||||
@@ -78,8 +78,9 @@ The workbench keeps one continuous layout with visual source, request, and optio
|
||||
Exports, imports, the official Workshop client, and MCP validate the same schema. PocketBase stores community
|
||||
submissions without interpreting their content, and Homarr validates them before installation. Widgets saved in the v1
|
||||
format remain visible to administrators with a **Migration required** badge after upgrading. Use **Copy migration
|
||||
prompt**, give the redacted prompt to an LLM, then import its v2 JSON from that widget's action menu. The prompt keeps
|
||||
safe request metadata but deliberately omits the v1 display configuration because legacy free-text and JSX can contain
|
||||
prompt**, give the redacted prompt to an LLM, copy its single complete JSON block, then select **Paste migrated widget**
|
||||
from that widget's action menu. Homarr reads the `template` value into the JSX editor automatically. The prompt keeps safe
|
||||
request metadata but deliberately omits the v1 display configuration because legacy free-text and JSX can contain
|
||||
hard-coded credentials. Add a credential-free description of the widget's visible layout and behavior when sending the
|
||||
prompt.
|
||||
|
||||
|
||||
@@ -6,9 +6,10 @@ The definition does not exist. If it was deleted, remove the unavailable tile fr
|
||||
|
||||
If the Custom Widgets page shows the definition with a **Migration required** badge, an administrator can open its
|
||||
action menu and select **Copy migration prompt**. Give that redacted prompt to an LLM, then import the resulting v2 JSON
|
||||
from the same menu. Because arbitrary v1 display text or JSX may contain credentials, the prompt omits that display
|
||||
configuration; describe the old widget's visible layout and behavior without including secrets. Other viewers see
|
||||
**Contact an administrator** instead of a migration link.
|
||||
from the same menu. The LLM returns one complete JSON block; copy it once and select **Paste migrated widget**. Homarr
|
||||
places its `template` value into the JSX editor automatically. Because arbitrary v1 display text or JSX may contain
|
||||
credentials, the prompt omits that display configuration; describe the old widget's visible layout and behavior without
|
||||
including secrets. Other viewers see **Contact an administrator** instead of a migration link.
|
||||
|
||||
Homarr keeps the v1 definition and encrypted secret after migration. The validated v2 replacement uses the same ID and
|
||||
takes precedence; the archived v1 record reappears if the v2 replacement is deleted. There is no v1 permanent-delete
|
||||
|
||||
@@ -84,7 +84,6 @@ export function BundledCustomWidgetGalleryClient() {
|
||||
const [selectedId, setSelectedId] = useState(BUNDLED_CUSTOM_WIDGETS[0]?.id ?? null);
|
||||
const selected = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === selectedId) ?? BUNDLED_CUSTOM_WIDGETS[0];
|
||||
if (!selected) return null;
|
||||
const manifest = { ...selected.widget, template: "__HOMARR_TEMPLATE__" };
|
||||
|
||||
return (
|
||||
<MantineProvider forceColorScheme={colorMode}>
|
||||
@@ -103,19 +102,11 @@ export function BundledCustomWidgetGalleryClient() {
|
||||
</Text>
|
||||
<ReadOnlyCustomWidgetCode
|
||||
id={`${selected.id}-manifest`}
|
||||
label="widget.json"
|
||||
label="Complete widget.json"
|
||||
language="json"
|
||||
value={JSON.stringify(manifest, null, 2)}
|
||||
value={JSON.stringify(selected.widget, null, 2)}
|
||||
messages={messages}
|
||||
height="340px"
|
||||
/>
|
||||
<ReadOnlyCustomWidgetCode
|
||||
id={`${selected.id}-template`}
|
||||
label="widget.jsx"
|
||||
language="jsx"
|
||||
value={selected.widget.template}
|
||||
messages={messages}
|
||||
height="340px"
|
||||
height="680px"
|
||||
/>
|
||||
</Stack>
|
||||
</MantineProvider>
|
||||
|
||||
@@ -18,7 +18,7 @@ describe("Copy AI prompt", () => {
|
||||
const prompt = buildCustomWidgetAiPrompt(undefined, null, null, "Create a Pokédex");
|
||||
expect(prompt.length).toBeLessThanOrEqual(12_000);
|
||||
expect(prompt).toContain("exactly one complete `json` fenced block");
|
||||
expect(prompt).toContain("one complete `jsx` fenced block");
|
||||
expect(prompt).toContain("Put the complete JSX source directly in the `template` string");
|
||||
});
|
||||
|
||||
it("keeps AI actions and section navigation readable on narrow screens", () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ActionIcon, Menu } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import {
|
||||
IconBuildingStore,
|
||||
IconClipboard,
|
||||
IconCopy,
|
||||
IconDots,
|
||||
IconDownload,
|
||||
@@ -63,22 +64,37 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const queueMigratedWidget = (text: string) => {
|
||||
const result = parseCustomWidgetClipboardDetailed(text);
|
||||
if (!result.success) {
|
||||
showErrorNotification({
|
||||
title: t("action.migrate"),
|
||||
message: formatCustomWidgetImportIssues(result.issues),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setMigratedWidget(result.widget);
|
||||
migrationControls.open();
|
||||
};
|
||||
|
||||
const pasteMigratedWidget = async () => {
|
||||
try {
|
||||
queueMigratedWidget(await navigator.clipboard.readText());
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("action.migrate"),
|
||||
message: t("notification.migrationError"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMigrationFile = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener("load", (loadEvent) => {
|
||||
try {
|
||||
const result = parseCustomWidgetClipboardDetailed(loadEvent.target?.result as string);
|
||||
if (!result.success) {
|
||||
showErrorNotification({
|
||||
title: t("action.migrate"),
|
||||
message: formatCustomWidgetImportIssues(result.issues),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setMigratedWidget(result.widget);
|
||||
migrationControls.open();
|
||||
queueMigratedWidget(loadEvent.target?.result as string);
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("action.migrate"),
|
||||
@@ -186,6 +202,9 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
<Menu.Item onClick={() => void copyMigrationPrompt()} leftSection={<IconSparkles {...iconProps} />}>
|
||||
{t("action.copyMigrationPrompt")}
|
||||
</Menu.Item>
|
||||
<Menu.Item onClick={() => void pasteMigratedWidget()} leftSection={<IconClipboard {...iconProps} />}>
|
||||
{t("action.pasteMigration")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={() => migrationFileInputRef.current?.click()}
|
||||
leftSection={<IconUpload {...iconProps} />}
|
||||
|
||||
@@ -26,6 +26,9 @@ export function CustomWidgetAiCard(props: AiCardProps) {
|
||||
<IconSparkles size={18} />
|
||||
<Text fw={600}>{t("title")}</Text>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("description")}
|
||||
</Text>
|
||||
<Textarea
|
||||
label={t("request")}
|
||||
value={props.request}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
|
||||
|
||||
import {
|
||||
applyDefinition,
|
||||
filterSecretsForSourceAuthentication,
|
||||
getChangedSecrets,
|
||||
getCustomWidgetPreviewOptionIssues,
|
||||
@@ -23,6 +24,28 @@ const definition = customWidgetDefinitionSchema.parse({
|
||||
});
|
||||
|
||||
describe("Custom Widget workbench preview options", () => {
|
||||
it("loads a complete pasted widget template into the JSX editor field", () => {
|
||||
const setValues = vi.fn();
|
||||
applyDefinition(
|
||||
{
|
||||
values: {
|
||||
name: "",
|
||||
description: "",
|
||||
iconUrl: "",
|
||||
sources: "{}",
|
||||
requests: "{}",
|
||||
options: "{}",
|
||||
template: "",
|
||||
secrets: [],
|
||||
},
|
||||
setValues,
|
||||
} as never,
|
||||
definition,
|
||||
);
|
||||
|
||||
expect(setValues).toHaveBeenCalledWith(expect.objectContaining({ template: "<Text>Preview</Text>" }));
|
||||
});
|
||||
|
||||
it("shows the default source first regardless of manifest key order", () => {
|
||||
expect(
|
||||
parseSources(
|
||||
|
||||
+2
@@ -12,6 +12,8 @@ describe("legacy custom widget migration UI", () => {
|
||||
expect(list).toContain('t("page.list.migrationInstructions")');
|
||||
expect(actions).toContain("legacyMigrationPrompt.fetch");
|
||||
expect(actions).toContain("navigator.clipboard.writeText(result.prompt)");
|
||||
expect(actions).toContain("navigator.clipboard.readText()");
|
||||
expect(actions).toContain('t("action.pasteMigration")');
|
||||
expect(actions).toContain("parseCustomWidgetClipboardDetailed");
|
||||
expect(actions).toContain("legacyId={widget.id}");
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
IconBuildingStore,
|
||||
IconDownload,
|
||||
IconFlag,
|
||||
IconRefresh,
|
||||
IconSearch,
|
||||
IconThumbDown,
|
||||
IconThumbUp,
|
||||
@@ -217,7 +218,20 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }:
|
||||
<Skeleton h={280} />
|
||||
</SimpleGrid>
|
||||
) : list.isError ? (
|
||||
<Alert color="red">{list.error instanceof Error ? list.error.message : t("unavailable")}</Alert>
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={18} />} title={t("unavailableTitle")}>
|
||||
<Stack gap="sm" align="flex-start">
|
||||
<Text size="sm">{t("unavailable")}</Text>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<IconRefresh size={14} />}
|
||||
loading={list.isFetching}
|
||||
onClick={() => void list.refetch()}
|
||||
>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
) : list.data?.items.length ? (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
|
||||
{list.data.items.map((item) => (
|
||||
|
||||
@@ -377,12 +377,9 @@ describe("Custom JSX v2 workbench", () => {
|
||||
expect(prompt).not.toContain("nested-secret");
|
||||
expect(prompt).not.toContain("private-filter");
|
||||
|
||||
await page.evaluate((widget) => navigator.clipboard.writeText(JSON.stringify(widget)), migratedWidget);
|
||||
await legacyCard.getByRole("button", { name: "Actions" }).click();
|
||||
await page.getByLabel("Import migrated v2 JSON").setInputFiles({
|
||||
name: "legacy-widget-v2.json",
|
||||
mimeType: "application/json",
|
||||
buffer: Buffer.from(JSON.stringify(migratedWidget)),
|
||||
});
|
||||
await page.getByRole("menuitem", { name: "Paste migrated widget" }).click();
|
||||
const migrationDialog = page.getByRole("dialog", { name: "Review migrated v2 replacement" });
|
||||
await migrationDialog.getByRole("button", { name: "Confirm v2 replacement" }).click();
|
||||
await expect(
|
||||
|
||||
@@ -267,15 +267,7 @@ export function parseJudgeResult(raw: string): CustomWidgetJudgeResult {
|
||||
}
|
||||
|
||||
async function writeWidgetFiles(directory: string, widget: HomarrCustomWidgetV2, basename: string) {
|
||||
const { template, ...manifest } = widget;
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
path.join(directory, `${basename}.widget.json`),
|
||||
JSON.stringify({ ...manifest, template: "__HOMARR_TEMPLATE__" }, null, 2),
|
||||
"utf8",
|
||||
),
|
||||
writeFile(path.join(directory, `${basename}.widget.jsx`), template, "utf8"),
|
||||
]);
|
||||
await writeFile(path.join(directory, `${basename}.widget.json`), JSON.stringify(widget, null, 2), "utf8");
|
||||
}
|
||||
|
||||
async function callOpenRouter(args: {
|
||||
|
||||
@@ -11,7 +11,7 @@ export const CUSTOM_WIDGET_MANTINE_VERSION = customJsxAuthoringCatalog.mantineVe
|
||||
const CUSTOM_WIDGET_AI_PROMPT_LIMIT = 12_000;
|
||||
|
||||
export const CUSTOM_WIDGET_FINAL_OUTPUT_INSTRUCTION =
|
||||
"Return exactly one complete `json` fenced block followed by one complete `jsx` fenced block. Do not include prose outside those blocks.";
|
||||
"Return exactly one complete `json` fenced block containing the entire widget. Put the complete JSX source directly in the `template` string and JSON-escape it correctly. Do not include prose or additional code blocks.";
|
||||
|
||||
const RECOMMENDED_COMPONENTS = [
|
||||
"Stack",
|
||||
@@ -81,14 +81,13 @@ const leanShape = `{
|
||||
"category": { "label": "Category", "control": "text", "default": "all" },
|
||||
"limit": { "label": "Result limit", "control": "number", "default": 20, "min": 1, "max": 100 }
|
||||
},
|
||||
"template": "__HOMARR_TEMPLATE__"
|
||||
"template": "<Stack><Text>{data.items?.name}</Text></Stack>"
|
||||
}`;
|
||||
|
||||
function compactExample(index: number) {
|
||||
const example = customJsxExamples[index];
|
||||
if (!example) return "";
|
||||
const { template, ...manifest } = example.widget;
|
||||
return `Example — ${example.title}:\n\n\`\`\`json\n${JSON.stringify({ ...manifest, template: "__HOMARR_TEMPLATE__" }, null, 2)}\n\`\`\`\n\n\`\`\`jsx\n${template}\n\`\`\``;
|
||||
return `Example — ${example.title}:\n\n\`\`\`json\n${JSON.stringify(example.widget, null, 2)}\n\`\`\``;
|
||||
}
|
||||
|
||||
const AUTHORING_PROMPT = `You are writing one safe Homarr Custom JSX v2 dashboard widget for Mantine ${CUSTOM_WIDGET_MANTINE_VERSION}.
|
||||
@@ -122,7 +121,7 @@ ${compactExample(0)}
|
||||
|
||||
${compactExample(1)}
|
||||
|
||||
Output the manifest with template set exactly to "__HOMARR_TEMPLATE__", then put the complete template in the JSX block.`;
|
||||
Output one complete JSON manifest. Put the complete JSX directly in its template string so the user can copy one code block and paste it into Homarr once.`;
|
||||
|
||||
export const CUSTOM_WIDGET_AUTHORING_PROMPT = AUTHORING_PROMPT;
|
||||
|
||||
|
||||
@@ -69,10 +69,6 @@ function removedStateIssues(value: Record<string, unknown>): CustomWidgetImportI
|
||||
);
|
||||
}
|
||||
|
||||
function isTemplatePlaceholder(value: unknown) {
|
||||
return typeof value === "string" && value.replace(/[\\*_`\s]/gu, "").toUpperCase() === "HOMARRTEMPLATE";
|
||||
}
|
||||
|
||||
type ParsedWidgetValue =
|
||||
| { success: true; value: Record<string, unknown> }
|
||||
| { success: false; issue: CustomWidgetImportIssue };
|
||||
@@ -100,24 +96,10 @@ export function parseCustomWidgetClipboard(text: string): HomarrCustomWidgetV2 |
|
||||
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);
|
||||
@@ -128,20 +110,17 @@ export function parseCustomWidgetClipboardDetailed(text: string): CustomWidgetPa
|
||||
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." });
|
||||
if (blocks.length !== jsonBlocks.length)
|
||||
formatIssues.push({
|
||||
code: "AI_SINGLE_JSON_BLOCK_REQUIRED",
|
||||
message: "Expected one JSON block only. Put the complete JSX directly in its template field.",
|
||||
});
|
||||
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",
|
||||
@@ -153,15 +132,6 @@ export function parseCustomWidgetAiResponse(text: string): CustomWidgetParseResu
|
||||
const removedIssues = removedStateIssues(widget);
|
||||
if (removedIssues.length > 0) formatIssues.push(...removedIssues);
|
||||
const warnings: CustomWidgetImportIssue[] = [];
|
||||
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) };
|
||||
|
||||
@@ -18,7 +18,9 @@ describe("AI prompt", () => {
|
||||
expect(prompt).toContain("Example — Service dashboard");
|
||||
expect(prompt).toContain("Example — Search and action");
|
||||
expect(prompt).toContain("visual hierarchy");
|
||||
expect(prompt).toContain('template set exactly to "__HOMARR_TEMPLATE__"');
|
||||
expect(prompt).toContain("Put the complete JSX directly in its template string");
|
||||
expect(prompt).toContain("copy one code block and paste it into Homarr once");
|
||||
expect(prompt).not.toContain("fenced block followed by");
|
||||
expect(prompt).toContain('trigger="manual"');
|
||||
expect(prompt).toContain("never write `=> {` anywhere");
|
||||
expect(prompt).toContain('"choicesFrom"');
|
||||
|
||||
@@ -7,12 +7,11 @@ import {
|
||||
parseCustomWidgetClipboardDetailed,
|
||||
} from "../core";
|
||||
|
||||
const response = (manifest: unknown, jsx = "<Text>Ready</Text>") =>
|
||||
`\`\`\`json\n${JSON.stringify(manifest, null, 2)}\n\`\`\`\n\`\`\`jsx\n${jsx}\n\`\`\``;
|
||||
const response = (manifest: unknown) => `\`\`\`json\n${JSON.stringify(manifest, null, 2)}\n\`\`\``;
|
||||
|
||||
describe("Custom Widget imports", () => {
|
||||
it("accepts exactly one lean manifest and JSX block", () => {
|
||||
const result = parseCustomWidgetAiResponse(response({ ...CUSTOM_WIDGET_STARTER, template: "__HOMARR_TEMPLATE__" }));
|
||||
it("accepts one complete JSON block and loads its template", () => {
|
||||
const result = parseCustomWidgetAiResponse(response({ ...CUSTOM_WIDGET_STARTER, template: "<Text>Ready</Text>" }));
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) expect(result.widget.template).toBe("<Text>Ready</Text>");
|
||||
});
|
||||
@@ -25,7 +24,7 @@ describe("Custom Widget imports", () => {
|
||||
requests: [],
|
||||
optionsSchema: {},
|
||||
defaultOptions: {},
|
||||
template: "__HOMARR_TEMPLATE__",
|
||||
template: "<Text>Ready</Text>",
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
@@ -34,12 +33,20 @@ describe("Custom Widget imports", () => {
|
||||
it("reports missing fences and removed local state", () => {
|
||||
expect(parseCustomWidgetAiResponse(JSON.stringify(CUSTOM_WIDGET_STARTER)).success).toBe(false);
|
||||
const result = parseCustomWidgetAiResponse(
|
||||
response({ ...CUSTOM_WIDGET_STARTER, stateSchema: {}, template: "__HOMARR_TEMPLATE__" }),
|
||||
response({ ...CUSTOM_WIDGET_STARTER, stateSchema: {}, template: "<Text>Ready</Text>" }),
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) expect(result.issues.some(({ code }) => code === "REMOVED_LOCAL_STATE")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects additional code blocks", () => {
|
||||
const result = parseCustomWidgetAiResponse(
|
||||
`${response({ ...CUSTOM_WIDGET_STARTER, template: "<Text>Ready</Text>" })}\n\`\`\`jsx\n<Text>Old format</Text>\n\`\`\``,
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) expect(result.issues.some(({ code }) => code === "AI_SINGLE_JSON_BLOCK_REQUIRED")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns a capability review from keyed records", () => {
|
||||
const review = getImportReview(CUSTOM_WIDGET_STARTER);
|
||||
expect(review).toMatchObject({ origins: ["https://example.com"], authTypes: ["none"], hasActions: false });
|
||||
|
||||
@@ -13,15 +13,12 @@ describe("reference widget capabilities", () => {
|
||||
expect(parsed.requests.detail?.trigger).toBe("manual");
|
||||
});
|
||||
|
||||
it("round-trips the full Pokédex through the exact two-block AI paste format", () => {
|
||||
it("round-trips the full Pokédex through the single-block AI paste format", () => {
|
||||
const pokedex = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-pokedex")?.widget;
|
||||
if (!pokedex) throw new Error("Pokédex seed was not found");
|
||||
const { template, ...manifest } = pokedex;
|
||||
const parsed = parseCustomWidgetAiResponse(
|
||||
`\`\`\`json\n${JSON.stringify({ ...manifest, template: "__HOMARR_TEMPLATE__" }, null, 2)}\n\`\`\`\n\n\`\`\`jsx\n${template}\n\`\`\``,
|
||||
);
|
||||
const parsed = parseCustomWidgetAiResponse(`\`\`\`json\n${JSON.stringify(pokedex, null, 2)}\n\`\`\``);
|
||||
expect(parsed.success).toBe(true);
|
||||
if (parsed.success) expect(parsed.widget.template).toBe(template);
|
||||
if (parsed.success) expect(parsed.widget.template).toBe(pokedex.template);
|
||||
});
|
||||
|
||||
it("validates a polished Portainer dashboard with start, stop, and restart actions", () => {
|
||||
@@ -38,10 +35,10 @@ describe("reference widget capabilities", () => {
|
||||
});
|
||||
|
||||
it("round-trips the Portainer dashboard through AI paste", () => {
|
||||
const { template, ...manifest } = PORTAINER_REFERENCE_WIDGET;
|
||||
const parsed = parseCustomWidgetAiResponse(
|
||||
`\`\`\`json\n${JSON.stringify({ ...manifest, template: "__HOMARR_TEMPLATE__" }, null, 2)}\n\`\`\`\n\n\`\`\`jsx\n${template}\n\`\`\``,
|
||||
`\`\`\`json\n${JSON.stringify(PORTAINER_REFERENCE_WIDGET, null, 2)}\n\`\`\``,
|
||||
);
|
||||
expect(parsed.success).toBe(true);
|
||||
if (parsed.success) expect(parsed.widget.template).toBe(PORTAINER_REFERENCE_WIDGET.template);
|
||||
});
|
||||
});
|
||||
@@ -739,7 +739,7 @@
|
||||
"invalidDefinitionDescription": "This stored widget no longer matches the current Custom Widget schema. Delete it and recreate or import a valid definition.",
|
||||
"migrationRequired": "Migration required",
|
||||
"migrationDescription": "This v1 custom widget is preserved but cannot run until it is migrated to Custom JSX v2.",
|
||||
"migrationInstructions": "Open Actions, copy the redacted migration prompt into an LLM, then import the v2 JSON it produces. Existing board placements keep working after migration.",
|
||||
"migrationInstructions": "Open Actions, copy the redacted migration prompt into an LLM, then copy its single JSON response and choose Paste migrated widget. Existing board placements keep working after migration.",
|
||||
"missingCredentials": "{count, plural, one {Credential missing} other {# credentials missing}}",
|
||||
"missingCredentialsDescription": "{count, plural, one {This widget still needs one credential.} other {This widget still needs # credentials.}} Edit it to configure the encrypted values."
|
||||
},
|
||||
@@ -769,7 +769,8 @@
|
||||
"publishWorkshop": "Publish to Workshop",
|
||||
"migrate": "Migrate Custom Widget",
|
||||
"copyMigrationPrompt": "Copy LLM migration prompt",
|
||||
"importMigration": "Import migrated v2 JSON"
|
||||
"pasteMigration": "Paste migrated widget",
|
||||
"importMigration": "Import migrated JSON file"
|
||||
},
|
||||
"field": {
|
||||
"name": "Name",
|
||||
@@ -976,6 +977,7 @@
|
||||
},
|
||||
"ai": {
|
||||
"title": "Create or fix with any AI",
|
||||
"description": "Copy one prompt, then paste the AI's single JSON block. Homarr loads its JSX into the editor automatically.",
|
||||
"request": "What should this widget do?",
|
||||
"documentationUrl": "API documentation URL (optional)",
|
||||
"paste": "Paste AI response",
|
||||
@@ -1076,7 +1078,7 @@
|
||||
"imported": "Widget imported successfully.",
|
||||
"migrated": "Legacy widget migrated successfully. Existing board placements now use the v2 definition.",
|
||||
"migrationError": "Failed to migrate the legacy widget. Ensure the file uses the homarr-custom-widget-v2 schema.",
|
||||
"migrationPromptCopied": "A redacted migration prompt was copied. Paste it into an LLM, save the returned v2 JSON, then import it here.",
|
||||
"migrationPromptCopied": "A redacted migration prompt was copied. Paste it into an LLM, copy its single JSON response, then choose Paste migrated widget here.",
|
||||
"migrationPromptCopyError": "Failed to copy the migration prompt.",
|
||||
"aiPromptCopied": "AI prompt copied to clipboard",
|
||||
"aiPromptCopiedWithCount": "AI prompt copied to clipboard ({count, number} characters).",
|
||||
@@ -1151,7 +1153,9 @@
|
||||
"sort": "Sort",
|
||||
"sortTop": "Top rated",
|
||||
"sortNewest": "Newest",
|
||||
"unavailable": "Workshop is unavailable. Your installed widgets are unaffected.",
|
||||
"unavailableTitle": "Homarr Workshop seems to be unavailable",
|
||||
"unavailable": "The community service may be temporarily down. Try again in a moment. Your installed widgets are unaffected.",
|
||||
"retry": "Try again",
|
||||
"empty": "No widgets match this search.",
|
||||
"emptyCss": "No CSS themes match this search.",
|
||||
"noDescription": "No description",
|
||||
@@ -4199,7 +4203,7 @@
|
||||
"sources": "{count, plural, one {# source} other {# sources}}",
|
||||
"requests": "{count, plural, one {# request} other {# requests}}",
|
||||
"migrationRequired": "Migration required",
|
||||
"migrationDescription": "This placed v1 widget cannot run yet. Copy its redacted prompt to an LLM and import the returned v2 JSON.",
|
||||
"migrationDescription": "This placed v1 widget cannot run yet. Copy its redacted prompt to an LLM, then paste the single JSON response into Homarr.",
|
||||
"manageMigration": "Open Manage custom widgets",
|
||||
"contactAdmin": "Contact an administrator."
|
||||
},
|
||||
|
||||
@@ -75,7 +75,7 @@ vi.mock("@homarr/translation/client", () => ({
|
||||
useScopedI18n: () => (key: string) =>
|
||||
({
|
||||
migrationRequired: "Migration required",
|
||||
migrationDescription: "Copy its redacted prompt to an LLM and import the returned v2 JSON.",
|
||||
migrationDescription: "Copy its redacted prompt to an LLM, then paste the single JSON response into Homarr.",
|
||||
manageMigration: "Open Manage custom widgets",
|
||||
contactAdmin: "Contact an administrator.",
|
||||
})[key] ?? key,
|
||||
|
||||
@@ -257,6 +257,13 @@ function workshopError(error: unknown, fallback: string) {
|
||||
/invalid|missing|not found|failed/iu.test(rawMessage)
|
||||
)
|
||||
return new Error("GitHub sign-in is not configured correctly on the Workshop server.", { cause: error });
|
||||
if (
|
||||
(error instanceof ClientResponseError && error.status === 0) ||
|
||||
/networkerror|failed to fetch|fetch failed|load failed|connection (?:refused|reset)|econn(?:refused|reset)|offline/iu.test(
|
||||
rawMessage,
|
||||
)
|
||||
)
|
||||
return new Error("Homarr Workshop seems to be unavailable right now. Try again in a moment.", { cause: error });
|
||||
if (error instanceof ClientResponseError) {
|
||||
const sdkMessage = error.data?.message || error.message;
|
||||
const originalMessage =
|
||||
|
||||
@@ -247,6 +247,16 @@ describe("WorkshopBackend", () => {
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(mocks.getFullList).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("replaces low-level network errors with an actionable Workshop outage message", async () => {
|
||||
mocks.getList.mockRejectedValueOnce(new Error("NetworkError when attempting to fetch resource."));
|
||||
|
||||
const client = new WorkshopBackend("https://workshop.example.com");
|
||||
|
||||
await expect(client.list({ type: "customWidget" })).rejects.toThrow(
|
||||
"Homarr Workshop seems to be unavailable right now. Try again in a moment.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function listingRecord() {
|
||||
|
||||
Reference in new issue
Block a user