Merge commit '22e955529' into feat/widget-modernization
This commit is contained in:
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;
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user