Merge commit '22e955529' into feat/widget-modernization

This commit is contained in:
Thomas Camlong committed 2026-08-10 18:48:22 +02:00
commit d04eec39b6
13 files changed
+234 -61

No files matched your search

@@ -43,6 +43,7 @@ import type { CustomWidgetEditorMessages } from "@homarr/custom-widgets/workbenc
import {
useWorkshopCssImportMutation,
useWorkshopQuery,
useWorkshopReportSummariesQuery,
useWorkshopReportMutation,
useWorkshopSubmissionQuery,
useWorkshopVoteMutation,
@@ -108,7 +109,6 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss, mo
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;
@@ -131,6 +131,7 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss, mo
const list = useWorkshopQuery(client, { page, perPage: 12, type, sort, search: debouncedSearch });
const detail = useWorkshopSubmissionQuery(client, selectedId ?? "");
const reportSummaries = useWorkshopReportSummariesQuery(client, selectedId ?? "");
const detailValidation = useMemo(() => {
if (!detail.data) return null;
return type === "customCss"
@@ -154,10 +155,7 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss, mo
.finally(() => setLoginPending(false));
};
const install = useWorkshopWidgetImportMutation(onInstall);
const useCss = useWorkshopCssImportMutation(onUseCss, () => {
setCssAwaitingConfirmation(null);
setSelectedId(null);
});
const useCss = useWorkshopCssImportMutation(onUseCss, () => setSelectedId(null));
const vote = useWorkshopVoteMutation(client);
const report = useWorkshopReportMutation(client);
const submitReport = () =>
@@ -372,11 +370,28 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss, mo
</Alert>
)}
{detail.data.reportCount > 0 && (
<Alert color="red" icon={<IconAlertTriangle size={18} />}>
This submission has {detail.data.reportCount} open community report
{detail.data.reportCount === 1 ? "" : "s"}. It remains installable, but you should review its content
first.
</Alert>
<Stack gap="xs">
<Alert color="red" icon={<IconAlertTriangle size={18} />}>
This submission has {detail.data.reportCount} open community report
{detail.data.reportCount === 1 ? "" : "s"}. It remains installable, but you should review its content
first.
</Alert>
{reportSummaries.data?.map((summary) => (
<Card key={summary.id} withBorder p="sm">
<Badge color="red" variant="light" mb="xs">
{summary.category}
</Badge>
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
{summary.explanation}
</Text>
</Card>
))}
{reportSummaries.data?.length === 0 && (
<Text size="xs" c="dimmed">
Report details are visible only to this submission's author and Workshop admins.
</Text>
)}
</Stack>
)}
{detail.data.screenshots.length > 0 && (
<SimpleGrid cols={{ base: 1, sm: 2 }}>
@@ -457,11 +472,12 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss, mo
)}
{onUseCss && (
<Button
loading={useCss.isPending}
disabled={!detailCompatible}
onClick={() =>
detailValidation?.success &&
typeof detailValidation.data === "string" &&
setCssAwaitingConfirmation(detailValidation.data)
useCss.mutate(detailValidation.data)
}
>
{t("useCss")}
@@ -507,31 +523,6 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss, mo
</Button>
</Stack>
</Modal>
<Modal
opened={cssAwaitingConfirmation !== null}
onClose={() => setCssAwaitingConfirmation(null)}
title="Import this Custom CSS?"
>
<Stack>
<Alert color="yellow" icon={<IconAlertTriangle size={18} />}>
Review community CSS before applying it. Importing replaces the current Custom CSS editor value, but nothing
is saved until you save the board settings.
</Alert>
<Group justify="flex-end">
<Button variant="default" onClick={() => setCssAwaitingConfirmation(null)}>
Cancel
</Button>
<Button
loading={useCss.isPending}
onClick={() => {
if (cssAwaitingConfirmation !== null) useCss.mutate(cssAwaitingConfirmation);
}}
>
Import CSS
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
}
@@ -1,30 +1,60 @@
"use client";
import { useState } from "react";
import { Button, Modal } from "@mantine/core";
import { Button, Modal, useModalsStack } from "@mantine/core";
import type { ModalProps } from "@mantine/core";
import { IconDownload } from "@tabler/icons-react";
import { modalSizeSelect } from "@homarr/modals";
import { WorkshopBrowser } from "./workshop-browser";
const cssInstallModalProps = {
size: "90%",
styles: {
content: { display: "flex", flexDirection: "column", height: "min(85dvh, 900px)" },
body: { flex: 1, overflowY: "auto" },
},
} satisfies Pick<ModalProps, "size" | "styles">;
export function WorkshopCssImportButton({ onImport }: { onImport(css: string): void }) {
const [opened, setOpened] = useState(false);
const stack = useModalsStack(["workshop", "details", "report"]);
const detailsModal = stack.register("details");
const reportModal = stack.register("report");
return (
<>
<Button type="button" variant="light" leftSection={<IconDownload size={16} />} onClick={() => setOpened(true)}>
<Button
type="button"
variant="light"
leftSection={<IconDownload size={16} />}
onClick={() => stack.open("workshop")}
>
Import from Workshop
</Button>
<Modal opened={opened} onClose={() => setOpened(false)} title="Import Custom CSS" size={modalSizeSelect}>
<WorkshopBrowser
type="customCss"
onUseCss={(css) => {
onImport(css);
setOpened(false);
}}
/>
</Modal>
<Modal.Stack>
<Modal {...stack.register("workshop")} {...cssInstallModalProps} title="Import Custom CSS">
<WorkshopBrowser
type="customCss"
modalStack={{
modalProps: cssInstallModalProps,
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"),
},
}}
onUseCss={(css) => {
onImport(css);
stack.closeAll();
}}
/>
</Modal>
</Modal.Stack>
</>
);
}
@@ -31,4 +31,18 @@ describe("Workshop widget install flow", () => {
expect(browser).toContain("ReadOnlyCustomWidgetCode");
expect(browser).not.toContain("{item.widgetSchema}");
});
it("uses a two-step modal stack for Custom CSS", () => {
const cssButton = readFileSync(
`${process.cwd()}/apps/nextjs/src/components/workshop/workshop-css-import-button.tsx`,
"utf8",
);
const browser = readFileSync(`${process.cwd()}/apps/nextjs/src/components/workshop/workshop-browser.tsx`, "utf8");
expect(cssButton).toContain('useModalsStack(["workshop", "details", "report"])');
expect(cssButton).toContain("<Modal.Stack>");
expect(cssButton).toContain('height: "min(85dvh, 900px)"');
expect(browser).not.toContain("cssAwaitingConfirmation");
expect(browser).toContain("useWorkshopReportSummariesQuery");
});
});
@@ -26,6 +26,12 @@ describe("Workshop publish definition inspection", () => {
expect(workshopDefinitionChanged(definition, { ...definition, name: "Updated" })).toBe(true);
});
it("publishes readable multi-line JSON", () => {
const serialized = serializeWorkshopDefinition(definition);
expect(serialized).toContain('\n "name": "Status"');
expect(JSON.parse(serialized)).toEqual(definition);
});
it("blocks publishing when the definition changes after inspection", async () => {
const publish = vi.fn(async () => undefined);
await expect(
@@ -3,7 +3,7 @@ interface PublishableWorkshopDefinition {
}
export const serializeWorkshopDefinition = (definition: unknown): string => {
const serialized = JSON.stringify(definition);
const serialized = JSON.stringify(definition, null, 2);
if (serialized === undefined) throw new Error("Workshop definition is not serializable");
return serialized;
};