fix(workshop): stack widget install flow

This commit is contained in:
Thomas Camlong committed 2026-08-10 18:19:31 +02:00
1 parent 378bc0cd28
commit cf3c4b4152
10 files changed
+269 -65

No files matched your search

@@ -90,15 +90,17 @@ while the replacement exists; deleting the v2 record surfaces the v1 entry as mi
legacy deletion is intentionally unavailable during v2 stabilization. Credentials are never included in prompts,
logs, or exports.
For administrators adding widgets while editing a board, the Custom Widgets section includes **Install from
Workshop**. Non-administrators do not see the Custom Widget picker or Workshop actions. The Workshop and API source
review open above the widget picker without closing it. After import, the new definition appears in the picker. Its tile
For administrators adding widgets while editing a board, the Custom Widgets section includes **Import from
Workshop**. Non-administrators do not see the Custom Widget picker or Workshop actions. Selecting **Install widget**
opens a two-step modal flow: first review the Workshop entry and its complete source in the read-only editor, then
confirm API sources and credentials. The steps keep the same dimensions and the previous step remains available when
you go back. After import, the new definition appears in the picker. Its tile
waits for the board to be saved before server requests run and loads automatically after a successful save; no page
reload is required.
## Import and Workshop
Import review shows origins, authentication modes, network scopes, methods, actions, and permissions. Every source has a suggested URL, but private and loopback sources must be confirmed or replaced for the current Homarr installation. The same screen accepts API keys, tokens, usernames, and passwords; credentials remain optional at install time and missing values stay visible on the Custom Widgets page.
The install flow always links to the corresponding public Workshop entry using `WORKSHOP_WEB_URL`. Import confirmation shows origins, authentication modes, network scopes, methods, actions, and permissions. Every source has a suggested URL, but private and loopback sources must be confirmed or replaced for the current Homarr installation. The same screen accepts API keys, tokens, usernames, and passwords; credentials remain optional at install time and missing values stay visible on the Custom Widgets page.
For example, installing **Tautulli Activity** asks for both the user's Tautulli URL and API key. A stable public source such as PokéAPI keeps its published URL without creating unnecessary setup work.
+2 -2
View File
@@ -78,8 +78,8 @@ that primarily promotes unrelated products or services.
You can install workshop entries without leaving Homarr:
- **Custom widgets (Homarr administrators only)** — go to **Manage → Custom Widgets**, or use **Install from Workshop** in the
board widget picker. Pick an entry, review its capabilities, and enter any required local secrets before import.
- **Custom widgets (Homarr administrators only)** — go to **Manage → Custom Widgets**, or use **Import from Workshop** in the
board widget picker. Choose **Install widget**, review the complete source and capabilities, then confirm API sources and any local secrets in the second modal step.
- **Custom CSS from Workshop (Homarr administrators only)** — open a board's **Settings → Custom CSS**, click **Install from Workshop**, and
pick a theme to load it into the editor before saving.
+1
View File
@@ -160,6 +160,7 @@ export default async function Layout(props: {
<head>
<meta name="homarr-website-url" content={publicUrls.homarrWebsiteUrl} />
<meta name="homarr-workshop-api-url" content={publicUrls.workshopApiUrl} />
<meta name="homarr-workshop-web-url" content={publicUrls.workshopWebUrl} />
<SearchEngineOptimization />
<CrowdinLiveTranslation locale={locale} />
</head>
@@ -2,6 +2,7 @@
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import type { ModalProps } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
@@ -27,6 +28,8 @@ interface CustomWidgetImportDialogProps {
legacyId?: string;
stackId?: string;
zIndex?: number;
modalProps?: Pick<ModalProps, "size" | "styles">;
labels?: { title?: string; description?: string; cancel?: string; confirm?: string };
onClose(): void;
onImported?(result: { id: string }): void;
}
@@ -37,6 +40,8 @@ export function CustomWidgetImportDialog({
legacyId,
stackId,
zIndex,
modalProps,
labels,
onClose,
onImported,
}: CustomWidgetImportDialogProps) {
@@ -103,14 +108,17 @@ export function CustomWidgetImportDialog({
opened={opened}
stackId={stackId}
zIndex={zIndex}
{...modalProps}
review={review}
pending={pending}
confirmDisabled={!isCustomWidgetSourceSetupReady(setups, values)}
onClose={onClose}
onConfirm={importWidget}
messages={{
title: legacyId ? t("importReview.migrationTitle") : t("importReview.title"),
description: legacyId ? t("importReview.migrationDescription") : t("importReview.description"),
title: legacyId ? t("importReview.migrationTitle") : (labels?.title ?? t("importReview.title")),
description: legacyId
? t("importReview.migrationDescription")
: (labels?.description ?? t("importReview.description")),
name: t("importReview.name"),
origin: t("importReview.origin"),
authentication: t("importReview.authentication"),
@@ -119,8 +127,8 @@ export function CustomWidgetImportDialog({
permissions: t("importReview.permissions"),
actionWarningTitle: t("importReview.actionWarning.title"),
actionWarningDescription: t("importReview.actionWarning.description"),
cancel: t("importReview.cancel"),
confirm: legacyId ? t("importReview.confirmMigration") : t("importReview.confirm"),
cancel: labels?.cancel ?? t("importReview.cancel"),
confirm: legacyId ? t("importReview.confirmMigration") : (labels?.confirm ?? t("importReview.confirm")),
permission: (permission) => t(`preview.request.permission.${permission}` as never),
}}
>
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useId, useMemo, useState } from "react";
import {
Alert,
Badge,
@@ -22,11 +22,14 @@ import {
TextInput,
Title,
} from "@mantine/core";
import type { ModalProps } from "@mantine/core";
import { useDebouncedValue, useDisclosure } from "@mantine/hooks";
import {
IconAlertTriangle,
IconArrowRight,
IconBuildingStore,
IconDownload,
IconExternalLink,
IconFlag,
IconRefresh,
IconSearch,
@@ -35,6 +38,8 @@ import {
} from "@tabler/icons-react";
import { CUSTOM_WIDGET_SCHEMA } from "@homarr/custom-widgets/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { ReadOnlyCustomWidgetCode } from "@homarr/custom-widgets/workbench";
import type { CustomWidgetEditorMessages } from "@homarr/custom-widgets/workbench";
import {
useWorkshopCssImportMutation,
useWorkshopQuery,
@@ -58,7 +63,7 @@ import {
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { createWorkshopClient } from "./workshop-client";
import { createWorkshopClient, getWorkshopWebUrl } from "./workshop-client";
function downloadWorkshopSubmission(submission: WorkshopSubmissionDetail) {
const url = URL.createObjectURL(
@@ -75,9 +80,21 @@ interface WorkshopBrowserProps {
type?: WorkshopSubmissionType;
onInstall?(widget: HomarrCustomWidgetV2): Promise<void>;
onUseCss?(css: string): void;
modalStack?: {
modalProps: Pick<ModalProps, "size" | "styles">;
details: WorkshopModalStep;
report: WorkshopModalStep;
};
}
export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }: WorkshopBrowserProps) {
interface WorkshopModalStep {
opened: boolean;
stackId?: string;
open(): void;
close(): void;
}
export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss, modalStack }: WorkshopBrowserProps) {
const t = useScopedI18n("workshop");
const client = useMemo(createWorkshopClient, []);
const [user, setUser] = useState<WorkshopUser | null>(null);
@@ -86,12 +103,25 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }:
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 250);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [reportOpened, reportControls] = useDisclosure(false);
const [internalReportOpened, reportControls] = useDisclosure(false);
const [reportCategory, setReportCategory] = useState<WorkshopReport["category"]>("other");
const [reportExplanation, setReportExplanation] = useState("");
const [loginPending, setLoginPending] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const [cssAwaitingConfirmation, setCssAwaitingConfirmation] = useState<string | null>(null);
const workshopWebUrl = useMemo(getWorkshopWebUrl, []);
const detailsOpened = modalStack?.details.opened ?? selectedId !== null;
const reportOpened = modalStack?.report.opened ?? internalReportOpened;
const openDetails = (id: string) => {
setSelectedId(id);
modalStack?.details.open();
};
const closeDetails = () => {
modalStack?.details.close();
setSelectedId(null);
};
const openReport = modalStack?.report.open ?? reportControls.open;
const closeReport = modalStack?.report.close ?? reportControls.close;
useEffect(() => {
const unsubscribe = client.subscribeToAuth(setUser);
@@ -123,7 +153,7 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }:
})
.finally(() => setLoginPending(false));
};
const install = useWorkshopWidgetImportMutation(onInstall, () => setSelectedId(null));
const install = useWorkshopWidgetImportMutation(onInstall);
const useCss = useWorkshopCssImportMutation(onUseCss, () => {
setCssAwaitingConfirmation(null);
setSelectedId(null);
@@ -135,7 +165,7 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }:
{ submission: selectedId ?? "", category: reportCategory, explanation: reportExplanation },
{
onSuccess: () => {
reportControls.close();
closeReport();
setReportExplanation("");
showSuccessNotification({ title: t("reportSent"), message: t("reportSentDescription") });
},
@@ -161,25 +191,38 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }:
</Text>
</Box>
</Group>
{user ? (
<Group gap="xs">
<Badge variant="light">{user.name}</Badge>
<Button
size="xs"
variant="subtle"
onClick={() => {
client.signOut();
setUser(null);
}}
>
{t("signOut")}
</Button>
</Group>
) : (
<Button size="xs" loading={loginPending} onClick={signIn}>
{t("signIn")}
<Group gap="xs">
<Button
component="a"
href={workshopWebUrl}
target="_blank"
rel="noopener noreferrer"
size="xs"
variant="default"
leftSection={<IconExternalLink size={14} />}
>
{t("openCommunity")}
</Button>
)}
{user ? (
<>
<Badge variant="light">{user.name}</Badge>
<Button
size="xs"
variant="subtle"
onClick={() => {
client.signOut();
setUser(null);
}}
>
{t("signOut")}
</Button>
</>
) : (
<Button size="xs" loading={loginPending} onClick={signIn}>
{t("signIn")}
</Button>
)}
</Group>
</Group>
</Card>
<Alert color="yellow" icon={<IconAlertTriangle size={18} />}>
@@ -260,9 +303,6 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }:
<IconThumbUp size={13} /> {item.upvotes} · <IconThumbDown size={13} /> {item.downvotes}
</Text>
</Group>
<Badge color={item.widgetSchema === expectedSchema ? "green" : "red"} variant="light">
{item.widgetSchema}
</Badge>
<Group gap="xs">
{item.outdated && <Badge color="yellow">Outdated</Badge>}
{item.reportCount > 0 && (
@@ -277,8 +317,8 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }:
<Text size="sm" c="dimmed" lineClamp={2}>
{item.description || t("noDescription")}
</Text>
<Button variant="light" onClick={() => setSelectedId(item.id)}>
{type === "customCss" ? t("inspectCss") : t("inspect")}
<Button variant="light" onClick={() => openDetails(item.id)}>
{type === "customCss" ? t("inspectCss") : t("install")}
</Button>
</Stack>
</Card>
@@ -294,21 +334,38 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }:
)}
<Modal
opened={selectedId !== null}
onClose={() => setSelectedId(null)}
title={detail.data?.title ?? t("details")}
size="xl"
{...modalStack?.modalProps}
opened={detailsOpened}
onClose={closeDetails}
stackId={modalStack?.details.stackId}
title={type === "customCss" ? t("inspectCss") : t("install")}
size={modalStack?.modalProps.size ?? "xl"}
>
{detail.isPending ? (
<Skeleton h={400} />
) : detail.isError || !detail.data ? (
<Alert color="red">{detail.error instanceof Error ? detail.error.message : t("loadError")}</Alert>
) : (
<Stack>
<Text>{detail.data.description}</Text>
<Text size="sm" c="dimmed">
{t("author", { name: detail.data.authorName || "Community member" })}
</Text>
<Stack gap="lg">
<Group justify="space-between" align="flex-start">
<Box>
<Title order={3}>{detail.data.title}</Title>
<Text size="sm" c="dimmed">
{t("author", { name: detail.data.authorName || "Community member" })}
</Text>
</Box>
<Button
component="a"
href={getWorkshopWebUrl(detail.data.id)}
target="_blank"
rel="noopener noreferrer"
variant="default"
leftSection={<IconExternalLink size={16} />}
>
{t("openCommunity")}
</Button>
</Group>
{detail.data.description && <Text>{detail.data.description}</Text>}
{detail.data.outdated && (
<Alert color="yellow" icon={<IconAlertTriangle size={18} />}>
The author marked this submission as outdated. Review it carefully before importing.
@@ -346,9 +403,7 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }:
{t("installErrorDescription")}
</Alert>
)}
<Code block mah={360} style={{ overflow: "auto", whiteSpace: "pre" }}>
{detail.data.content}
</Code>
<WorkshopCodeViewer value={detail.data.content} language={type === "customCss" ? "css" : "json"} />
<Group justify="space-between">
<Group gap="xs">
<Button
@@ -381,7 +436,7 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }:
color="red"
leftSection={<IconFlag size={16} />}
disabled={!user}
onClick={reportControls.open}
onClick={openReport}
>
{t("report")}
</Button>
@@ -390,13 +445,14 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }:
<Button
loading={install.isPending}
disabled={!detailCompatible}
rightSection={<IconArrowRight size={16} />}
onClick={() =>
detailValidation?.success &&
typeof detailValidation.data !== "string" &&
install.mutate(detailValidation.data)
}
>
{t("install")}
{t("continueInstall")}
</Button>
)}
{onUseCss && (
@@ -424,7 +480,13 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }:
)}
</Modal>
<Modal opened={reportOpened} onClose={reportControls.close} title={t("reportTitle")}>
<Modal
{...modalStack?.modalProps}
opened={reportOpened}
onClose={closeReport}
stackId={modalStack?.report.stackId}
title={t("reportTitle")}
>
<Stack>
<Select
label={t("reportReason")}
@@ -474,6 +536,54 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }:
);
}
function WorkshopCodeViewer({ value, language }: { value: string; language: "json" | "css" }) {
const id = useId();
const t = useScopedI18n("customWidget.editor");
const workshopT = useScopedI18n("workshop");
const messages: CustomWidgetEditorMessages = {
languageJsx: t("language.jsx"),
languageJson: t("language.json"),
undo: t("action.undo"),
redo: t("action.redo"),
components: t("action.components"),
componentSearch: t("componentReference.search"),
componentEmpty: t("componentReference.empty"),
componentCount: (count) => 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.value ?? diagnostic.code,
};
let displayValue = value;
if (language === "json") {
try {
displayValue = JSON.stringify(JSON.parse(value) as unknown, null, 2);
} catch {}
}
return (
<ReadOnlyCustomWidgetCode
id={`workshop-source-${id}`}
label={workshopT("sourceCode")}
language={language}
value={displayValue}
messages={messages}
height="360px"
/>
);
}
function CapabilitySummary({ widget }: { widget: HomarrCustomWidgetV2 }) {
const t = useScopedI18n("workshop");
return (
@@ -1,5 +1,5 @@
import { WorkshopBackend } from "@homarr/workshop/backend";
import { WORKSHOP_API_URL } from "@homarr/workshop/schema";
import { WORKSHOP_API_URL, WORKSHOP_WEB_URL } from "@homarr/workshop/schema";
let browserClient: WorkshopBackend | undefined;
@@ -12,3 +12,12 @@ export function createWorkshopClient() {
browserClient = new WorkshopBackend(runtimeUrl || WORKSHOP_API_URL);
return browserClient;
}
export function getWorkshopWebUrl(submissionId?: string) {
const runtimeUrl =
typeof document === "undefined"
? undefined
: document.querySelector<HTMLMetaElement>('meta[name="homarr-workshop-web-url"]')?.content;
const workshopUrl = (runtimeUrl || WORKSHOP_WEB_URL).replace(/\/+$/u, "");
return submissionId ? `${workshopUrl}/${encodeURIComponent(submissionId)}` : workshopUrl;
}
@@ -2,7 +2,8 @@
import type { ReactNode } from "react";
import { useState } from "react";
import { Button, getDefaultZIndex, Modal, useModalsStack } from "@mantine/core";
import { Button, Modal, useModalsStack } from "@mantine/core";
import type { ModalProps } from "@mantine/core";
import { IconBuildingStore } from "@tabler/icons-react";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
@@ -16,11 +17,20 @@ interface WorkshopInstallButtonProps {
fullWidth?: boolean;
}
const installFlowModalProps = {
size: "90%",
styles: {
content: { display: "flex", flexDirection: "column", height: "min(85dvh, 900px)" },
body: { flex: 1, overflowY: "auto" },
},
} satisfies Pick<ModalProps, "size" | "styles">;
export function WorkshopInstallButton({ children, fullWidth }: WorkshopInstallButtonProps = {}) {
const t = useScopedI18n("workshop");
const stack = useModalsStack(["workshop", "review"]);
const stack = useModalsStack(["workshop", "details", "report", "review"]);
const [pendingWidget, setPendingWidget] = useState<HomarrCustomWidgetV2 | null>(null);
const zIndex = getDefaultZIndex("modal") + 10;
const detailsModal = stack.register("details");
const reportModal = stack.register("report");
const closeReview = () => {
stack.close("review");
@@ -35,11 +45,26 @@ export function WorkshopInstallButton({ children, fullWidth }: WorkshopInstallBu
onClick={() => stack.open("workshop")}
fullWidth={fullWidth}
>
{children ?? t("title")}
{children ?? t("importFromWorkshop")}
</Button>
<Modal.Stack>
<Modal {...stack.register("workshop")} title={t("installDialog")} size="90%" zIndex={zIndex}>
<Modal {...stack.register("workshop")} {...installFlowModalProps} title={t("installDialog")}>
<WorkshopBrowser
modalStack={{
modalProps: installFlowModalProps,
details: {
opened: detailsModal.opened,
stackId: detailsModal.stackId,
open: () => stack.open("details"),
close: () => stack.close("details"),
},
report: {
opened: reportModal.opened,
stackId: reportModal.stackId,
open: () => stack.open("report"),
close: () => stack.close("report"),
},
}}
onInstall={async (widget) => {
setPendingWidget(widget);
stack.open("review");
@@ -49,7 +74,13 @@ export function WorkshopInstallButton({ children, fullWidth }: WorkshopInstallBu
<CustomWidgetImportDialog
{...stack.register("review")}
widget={pendingWidget}
zIndex={zIndex + 1}
modalProps={installFlowModalProps}
labels={{
title: t("confirmInstallTitle"),
description: t("confirmInstallDescription"),
cancel: t("backToWidget"),
confirm: t("install"),
}}
onClose={closeReview}
onImported={() => {
stack.closeAll();
@@ -0,0 +1,34 @@
// @vitest-environment jsdom
import { readFileSync } from "node:fs";
import { afterEach, describe, expect, it } from "vitest";
import { getWorkshopWebUrl } from "./workshop-client";
describe("Workshop widget install flow", () => {
afterEach(() => {
document.head.innerHTML = "";
});
it("links to the runtime-configured Workshop entry", () => {
document.head.innerHTML = '<meta name="homarr-workshop-web-url" content="https://preview.example/workshop/">';
expect(getWorkshopWebUrl()).toBe("https://preview.example/workshop");
expect(getWorkshopWebUrl("widget/id")).toBe("https://preview.example/workshop/widget%2Fid");
});
it("uses one registered modal stack for browsing, details, reports, and confirmation", () => {
const installButton = readFileSync(
`${process.cwd()}/apps/nextjs/src/components/workshop/workshop-install-button.tsx`,
"utf8",
);
const browser = readFileSync(`${process.cwd()}/apps/nextjs/src/components/workshop/workshop-browser.tsx`, "utf8");
expect(installButton).toContain('useModalsStack(["workshop", "details", "report", "review"])');
expect(installButton).toContain("<Modal.Stack>");
expect(installButton).toContain('height: "min(85dvh, 900px)"');
expect(installButton).not.toContain("getDefaultZIndex");
expect(browser).toContain("ReadOnlyCustomWidgetCode");
expect(browser).not.toContain("{item.widgetSchema}");
});
});