fix(workshop): stack widget install flow
This commit is contained in:
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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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}");
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,8 @@ export interface ImportReviewDialogProps {
|
||||
children?: ReactNode;
|
||||
stackId?: string;
|
||||
zIndex?: ModalProps["zIndex"];
|
||||
size?: ModalProps["size"];
|
||||
styles?: ModalProps["styles"];
|
||||
onClose(): void;
|
||||
onConfirm(): void;
|
||||
}
|
||||
@@ -54,6 +56,8 @@ export function ImportReviewDialog({
|
||||
children,
|
||||
stackId,
|
||||
zIndex,
|
||||
size,
|
||||
styles,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: ImportReviewDialogProps) {
|
||||
@@ -63,9 +67,10 @@ export function ImportReviewDialog({
|
||||
onClose={onClose}
|
||||
title={messages.title}
|
||||
centered
|
||||
size="lg"
|
||||
stackId={stackId}
|
||||
zIndex={zIndex}
|
||||
size={size ?? "lg"}
|
||||
styles={styles}
|
||||
>
|
||||
{review && (
|
||||
<Stack gap="md">
|
||||
|
||||
@@ -1130,7 +1130,8 @@
|
||||
"installError": "Could not install widget",
|
||||
"installErrorDescription": "The Workshop submission is not a valid Custom JSX v2 widget.",
|
||||
"title": "Workshop",
|
||||
"openCommunity": "Open public Workshop",
|
||||
"importFromWorkshop": "Import from Workshop",
|
||||
"openCommunity": "Open Workshop",
|
||||
"description": "Community Custom JSX v2 widgets. Inspect the source and network capabilities before installing.",
|
||||
"descriptionCss": "Community Custom CSS themes. Preview every rule before importing it into a board.",
|
||||
"securityNotice": "Workshop widgets can call their declared API sources and perform declared actions with credentials you configure locally. Secrets are never included.",
|
||||
@@ -1150,9 +1151,8 @@
|
||||
"emptyCss": "No CSS themes match this search.",
|
||||
"noDescription": "No description",
|
||||
"author": "by {name}",
|
||||
"inspect": "Inspect widget",
|
||||
"inspectCss": "Inspect CSS",
|
||||
"details": "Widget details",
|
||||
"sourceCode": "Workshop entry",
|
||||
"loadError": "This submission could not be loaded.",
|
||||
"sources": "Declared API sources",
|
||||
"requests": "Declared queries and actions",
|
||||
@@ -1160,6 +1160,10 @@
|
||||
"export": "Export",
|
||||
"report": "Report",
|
||||
"install": "Install widget",
|
||||
"continueInstall": "Continue installation",
|
||||
"confirmInstallTitle": "Confirm widget installation",
|
||||
"confirmInstallDescription": "Confirm where this widget connects and add any credentials required by your Homarr installation.",
|
||||
"backToWidget": "Back to widget",
|
||||
"useCss": "Import CSS",
|
||||
"signInHint": "Sign in to vote or report. Installing does not require a Workshop account.",
|
||||
"reportTitle": "Report submission",
|
||||
|
||||
Reference in new issue
Block a user