From cf3c4b415274f258b3ff7b4949482f6ef0d2c401 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 10 Aug 2026 18:19:31 +0200 Subject: [PATCH] fix(workshop): stack widget install flow --- .../docs/management/custom-widgets/index.mdx | 10 +- apps/docs/docs/management/workshop/index.mdx | 4 +- apps/nextjs/src/app/[locale]/layout.tsx | 1 + .../custom-widget-import-dialog.tsx | 16 +- .../components/workshop/workshop-browser.tsx | 198 ++++++++++++++---- .../components/workshop/workshop-client.ts | 11 +- .../workshop/workshop-install-button.tsx | 43 +++- .../workshop/workshop-install-flow.spec.ts | 34 +++ .../src/workbench/import-review.tsx | 7 +- packages/translation/src/lang/en.json | 10 +- 10 files changed, 269 insertions(+), 65 deletions(-) create mode 100644 apps/nextjs/src/components/workshop/workshop-install-flow.spec.ts diff --git a/apps/docs/docs/management/custom-widgets/index.mdx b/apps/docs/docs/management/custom-widgets/index.mdx index 508dde70c..3490a5d41 100644 --- a/apps/docs/docs/management/custom-widgets/index.mdx +++ b/apps/docs/docs/management/custom-widgets/index.mdx @@ -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. diff --git a/apps/docs/docs/management/workshop/index.mdx b/apps/docs/docs/management/workshop/index.mdx index 1d18f2c97..a8c3e153c 100644 --- a/apps/docs/docs/management/workshop/index.mdx +++ b/apps/docs/docs/management/workshop/index.mdx @@ -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. diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx index 7be6cafa8..5c5171d63 100644 --- a/apps/nextjs/src/app/[locale]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/layout.tsx @@ -160,6 +160,7 @@ export default async function Layout(props: { + diff --git a/apps/nextjs/src/components/custom-widgets/custom-widget-import-dialog.tsx b/apps/nextjs/src/components/custom-widgets/custom-widget-import-dialog.tsx index f406a99be..af28e22e5 100644 --- a/apps/nextjs/src/components/custom-widgets/custom-widget-import-dialog.tsx +++ b/apps/nextjs/src/components/custom-widgets/custom-widget-import-dialog.tsx @@ -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; + 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), }} > diff --git a/apps/nextjs/src/components/workshop/workshop-browser.tsx b/apps/nextjs/src/components/workshop/workshop-browser.tsx index cdc042dd5..548c4c7d2 100644 --- a/apps/nextjs/src/components/workshop/workshop-browser.tsx +++ b/apps/nextjs/src/components/workshop/workshop-browser.tsx @@ -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; onUseCss?(css: string): void; + modalStack?: { + modalProps: Pick; + 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(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(null); - const [reportOpened, reportControls] = useDisclosure(false); + const [internalReportOpened, reportControls] = useDisclosure(false); const [reportCategory, setReportCategory] = useState("other"); const [reportExplanation, setReportExplanation] = useState(""); const [loginPending, setLoginPending] = useState(false); const [loginError, setLoginError] = useState(null); const [cssAwaitingConfirmation, setCssAwaitingConfirmation] = useState(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 }: - {user ? ( - - {user.name} - - - ) : ( - - )} + {user ? ( + <> + {user.name} + + + ) : ( + + )} + }> @@ -260,9 +303,6 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }: {item.upvotes} · {item.downvotes} - - {item.widgetSchema} - {item.outdated && Outdated} {item.reportCount > 0 && ( @@ -277,8 +317,8 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }: {item.description || t("noDescription")} - @@ -294,21 +334,38 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }: )} 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 ? ( ) : detail.isError || !detail.data ? ( {detail.error instanceof Error ? detail.error.message : t("loadError")} ) : ( - - {detail.data.description} - - {t("author", { name: detail.data.authorName || "Community member" })} - + + + + {detail.data.title} + + {t("author", { name: detail.data.authorName || "Community member" })} + + + + + {detail.data.description && {detail.data.description}} {detail.data.outdated && ( }> 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")} )} - - {detail.data.content} - + @@ -390,13 +445,14 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }: )} {onUseCss && ( @@ -424,7 +480,13 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }: )} - +