fix(workshop): stack widget install flow

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-13 19:51:44 +02:00
1 parent 3efe64a062
commit 327e5c15ae
10 files changed
+269 -65

No files matched your search

@@ -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}");
});
});