Merge commit '22e955529' into feat/widget-modernization
This commit is contained in:
13 files changed
+234
-61
No files matched your search
@@ -35,7 +35,7 @@ Back up your instance before installing untrusted content.
|
||||
- Sign in with **GitHub** to upvote/downvote, submit your own creations, or report a submission.
|
||||
- Each submission shows its title, description, linked GitHub author, revision, optional screenshots, comments, and full content. Widget pages also summarize API sources, actions, options, network access, and required credentials before installation.
|
||||
- A new submission receives one real upvote owned by its author. Votes remain unique per user and submission.
|
||||
- Open report counts are public on submission cards so visitors can make an informed installation decision. Report explanations and reporter identities remain visible only to Workshop moderators.
|
||||
- Open report counts are public on submission cards so visitors can make an informed installation decision. The submission author and Workshop moderators can read each report category and explanation; reporter identities remain moderator-only.
|
||||
- Signed-in moderators see a **Moderation** button in the Workshop header. The moderation queue is sorted by open report count and lets moderators inspect every report, dismiss it, or delete the submission. Reports do not automatically remove a submission.
|
||||
- The manual **Outdated** badge warns visitors that a submission may need an update.
|
||||
|
||||
@@ -53,6 +53,9 @@ Comments are publicly readable and appear in chronological conversation bubbles
|
||||
3. Review the widget JSON or CSS in the syntax-aware editor, then add a title, description, optional screenshots, and changelog.
|
||||
4. Submit the entry.
|
||||
|
||||
Custom widget JSON is normalized to readable multi-line formatting when it is published, and the Workshop detail page
|
||||
also formats older compact submissions before displaying them.
|
||||
|
||||
Community members can report submissions as outdated or no longer working, malicious, spam, copyright-infringing,
|
||||
inappropriate, or another concern. An outdated report does not automatically mark or remove the submission.
|
||||
|
||||
@@ -80,8 +83,7 @@ You can install workshop entries without leaving Homarr:
|
||||
|
||||
- **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.
|
||||
- **Custom CSS from Workshop (Homarr administrators only)** — open a board's **Settings → Custom CSS**, click **Import from Workshop**, choose a theme, review its full CSS in the second same-size modal step, and click **Import CSS** once. The board setting is still saved separately.
|
||||
|
||||
Alternatively, open a submission in the Workshop and use **Download widget JSON** or **Copy CSS**. The submission page shows the matching manual installation steps before the source code.
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import {
|
||||
githubAvatarUrl,
|
||||
githubProfileUrl,
|
||||
formatWorkshopContent,
|
||||
MAX_WORKSHOP_SCREENSHOTS,
|
||||
MAX_WORKSHOP_SCREENSHOT_BYTES,
|
||||
WORKSHOP_SCREENSHOT_MIME_TYPES,
|
||||
@@ -534,6 +535,7 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
|
||||
const requests = widgetDefinition ? Object.entries(widgetDefinition.requests) : [];
|
||||
const options = widgetDefinition ? Object.entries(widgetDefinition.options) : [];
|
||||
const protectedSources = sources.filter(([, source]) => sourceNeedsSecret(source.auth));
|
||||
const displayedContent = formatWorkshopContent(submission.type, submission.content);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[90rem] px-4 pb-20 pt-8 sm:px-6 lg:px-8">
|
||||
@@ -697,12 +699,12 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
|
||||
<CustomWidgetCodeExample
|
||||
id={`workshop-${submission.id}`}
|
||||
label="widget.json"
|
||||
code={submission.content}
|
||||
code={displayedContent}
|
||||
language="json"
|
||||
height="min(58vh, 640px)"
|
||||
/>
|
||||
) : (
|
||||
<CodeBlock content={submission.content} language={contentLanguages[submission.type]} />
|
||||
<CodeBlock content={displayedContent} language={contentLanguages[submission.type]} />
|
||||
)}
|
||||
</section>
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -9,7 +9,8 @@ version and digest together, then run the Workshop integration and production-im
|
||||
|
||||
The internal PocketBase field `users.isAdmin` means **Workshop moderator**. It does not grant Homarr administrator
|
||||
access. Signed-in moderators can open `/workshop/admin` from the visible **Moderation** button, review private report
|
||||
details, dismiss reports, and remove submissions or comments. Public submission cards show only the open report count.
|
||||
details, dismiss reports, and remove submissions or comments. Public submission cards show only the open report count;
|
||||
the submission author can read the report category and explanation without receiving the reporter identity.
|
||||
|
||||
## Run locally
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
const reportReaderRule = "@request.auth.id != '' && (author = @request.auth.id || @request.auth.isAdmin = true)";
|
||||
|
||||
migrate(
|
||||
(app) => {
|
||||
const summaries = new Collection({
|
||||
type: "view",
|
||||
name: "workshop_report_summaries",
|
||||
listRule: reportReaderRule,
|
||||
viewRule: reportReaderRule,
|
||||
viewQuery: `
|
||||
SELECT r.id, r.submission, s.author, r.category, r.explanation, r.created
|
||||
FROM reports r
|
||||
INNER JOIN submissions s ON s.id = r.submission
|
||||
WHERE r.status = 'open'
|
||||
`,
|
||||
});
|
||||
app.save(summaries);
|
||||
},
|
||||
(app) => {
|
||||
app.delete(app.findCollectionByNameOrId("workshop_report_summaries"));
|
||||
},
|
||||
);
|
||||
@@ -28,7 +28,14 @@ const root = await request("/api/collections/_superusers/auth-with-password", {
|
||||
const rootHeaders = { authorization: `Bearer ${root.token}` };
|
||||
const collections = await request("/api/collections?perPage=200", { headers: rootHeaders });
|
||||
const collectionNames = new Set(collections.items.map((collection) => collection.name));
|
||||
for (const required of ["submissions", "votes", "comments", "reports", "workshop_listings"]) {
|
||||
for (const required of [
|
||||
"submissions",
|
||||
"votes",
|
||||
"comments",
|
||||
"reports",
|
||||
"workshop_listings",
|
||||
"workshop_report_summaries",
|
||||
]) {
|
||||
if (!collectionNames.has(required)) throw new Error(`Missing Workshop collection: ${required}`);
|
||||
}
|
||||
for (const removed of ["workshop_admin_actions", "workshop_admins"]) {
|
||||
@@ -278,6 +285,23 @@ await expectStatus(`/api/collections/reports/records/${report.id}`, { headers: v
|
||||
await expectStatus("/api/collections/reports/records", { headers: visitorSession.headers }, 200);
|
||||
const publicReports = await request("/api/collections/reports/records", { headers: visitorSession.headers });
|
||||
if (publicReports.items.length !== 0) throw new Error("Report details must remain private to Workshop moderators");
|
||||
const visitorReportSummaries = await request("/api/collections/workshop_report_summaries/records", {
|
||||
headers: visitorSession.headers,
|
||||
});
|
||||
if (visitorReportSummaries.items.length !== 0) {
|
||||
throw new Error("Report explanations must remain private from unrelated Workshop users");
|
||||
}
|
||||
const authorReportSummaries = await request("/api/collections/workshop_report_summaries/records", {
|
||||
headers: authorSession.headers,
|
||||
});
|
||||
if (
|
||||
authorReportSummaries.items.length !== 1 ||
|
||||
authorReportSummaries.items[0].submission !== submission.id ||
|
||||
authorReportSummaries.items[0].explanation !== "Runtime moderation test" ||
|
||||
"reporter" in authorReportSummaries.items[0]
|
||||
) {
|
||||
throw new Error("Submission authors must see report reasons without exposing reporter identities");
|
||||
}
|
||||
await expectStatus(
|
||||
`/api/collections/reports/records/${report.id}`,
|
||||
{ method: "DELETE", headers: visitorSession.headers },
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import type {
|
||||
WorkshopComment,
|
||||
WorkshopReport,
|
||||
WorkshopReportSummary,
|
||||
WorkshopSubmissionDetail,
|
||||
WorkshopSubmissionInput,
|
||||
WorkshopSubmissionSummary,
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
WORKSHOP_SCHEMA_BY_TYPE,
|
||||
workshopCommentSchema,
|
||||
workshopReportSchema,
|
||||
workshopReportSummarySchema,
|
||||
workshopSubmissionDetailSchema,
|
||||
workshopSubmissionSummarySchema,
|
||||
workshopSubmissionInputSchema,
|
||||
@@ -122,6 +124,13 @@ export interface WorkshopReportRecord extends WorkshopBaseRecord {
|
||||
expand?: { reporter?: WorkshopUserRecord; submission?: WorkshopSubmissionRecord };
|
||||
}
|
||||
|
||||
export interface WorkshopReportSummaryRecord extends WorkshopBaseRecord {
|
||||
submission: string;
|
||||
category: WorkshopReportSummary["category"];
|
||||
explanation: string;
|
||||
created: string;
|
||||
}
|
||||
|
||||
export interface WorkshopCommentRecord extends WorkshopBaseRecord {
|
||||
submission: string;
|
||||
author: string;
|
||||
@@ -137,6 +146,7 @@ export interface TypedWorkshopPocketBase extends PocketBase {
|
||||
collection(idOrName: "workshop_listings"): RecordService<WorkshopListingRecord>;
|
||||
collection(idOrName: "votes"): RecordService<WorkshopVoteRecord>;
|
||||
collection(idOrName: "reports"): RecordService<WorkshopReportRecord>;
|
||||
collection(idOrName: "workshop_report_summaries"): RecordService<WorkshopReportSummaryRecord>;
|
||||
collection(idOrName: "comments"): RecordService<WorkshopCommentRecord>;
|
||||
collection(idOrName: string): RecordService;
|
||||
}
|
||||
@@ -508,6 +518,18 @@ export class WorkshopBackend {
|
||||
}
|
||||
}
|
||||
|
||||
public async listReportSummaries(submission: string): Promise<WorkshopReportSummary[]> {
|
||||
try {
|
||||
const filter = this.pocketBase.filter("submission = {:submission}", { submission });
|
||||
const items = await this.pocketBase
|
||||
.collection("workshop_report_summaries")
|
||||
.getFullList({ batch: 100, filter, sort: "-created" });
|
||||
return items.map((item) => workshopReportSummarySchema.parse(item));
|
||||
} catch (error) {
|
||||
throw workshopError(error, "Failed to load report details");
|
||||
}
|
||||
}
|
||||
|
||||
public async dismissReport(id: string) {
|
||||
try {
|
||||
await this.pocketBase.collection("reports").update(id, { status: "dismissed" });
|
||||
@@ -599,6 +621,14 @@ export const workshopQueryKeys = {
|
||||
details: (baseUrl?: string) => [...workshopQueryKeys.all, "detail", baseUrl] as const,
|
||||
detail: (backend: WorkshopBackend, id: string) => [...workshopQueryKeys.details(backend.baseUrl), id] as const,
|
||||
reports: (backend: WorkshopBackend) => [...workshopQueryKeys.all, "reports", backend.baseUrl] as const,
|
||||
reportSummaries: (backend: WorkshopBackend, submission: string) =>
|
||||
[
|
||||
...workshopQueryKeys.all,
|
||||
"report-summaries",
|
||||
backend.baseUrl,
|
||||
submission,
|
||||
backend.currentUser?.id ?? "anonymous",
|
||||
] as const,
|
||||
comments: (backend: WorkshopBackend, submission: string) =>
|
||||
[...workshopQueryKeys.all, "comments", backend.baseUrl, submission] as const,
|
||||
};
|
||||
@@ -620,6 +650,13 @@ export const workshopSubmissionQueryOptions = (backend: WorkshopBackend, id: str
|
||||
export const workshopReportsQueryOptions = (backend: WorkshopBackend) =>
|
||||
queryOptions({ queryKey: workshopQueryKeys.reports(backend), queryFn: () => backend.listReports() });
|
||||
|
||||
export const workshopReportSummariesQueryOptions = (backend: WorkshopBackend, submission: string) =>
|
||||
queryOptions({
|
||||
queryKey: workshopQueryKeys.reportSummaries(backend, submission),
|
||||
queryFn: () => backend.listReportSummaries(submission),
|
||||
enabled: submission.length > 0,
|
||||
});
|
||||
|
||||
export const workshopCommentsQueryOptions = (backend: WorkshopBackend, submission: string) =>
|
||||
queryOptions({
|
||||
queryKey: workshopQueryKeys.comments(backend, submission),
|
||||
@@ -700,6 +737,10 @@ export function useWorkshopReportsQuery(backend: WorkshopBackend) {
|
||||
return useQuery(workshopReportsQueryOptions(backend));
|
||||
}
|
||||
|
||||
export function useWorkshopReportSummariesQuery(backend: WorkshopBackend, submission: string) {
|
||||
return useQuery(workshopReportSummariesQueryOptions(backend, submission));
|
||||
}
|
||||
|
||||
export function useWorkshopCommentsQuery(backend: WorkshopBackend, submission: string) {
|
||||
return useQuery(workshopCommentsQueryOptions(backend, submission));
|
||||
}
|
||||
@@ -712,6 +753,8 @@ function useWorkshopInvalidation(backend: WorkshopBackend) {
|
||||
detail: (id: string) => queryClient.invalidateQueries({ queryKey: workshopQueryKeys.detail(backend, id) }),
|
||||
removeDetail: (id: string) => queryClient.removeQueries({ queryKey: workshopQueryKeys.detail(backend, id) }),
|
||||
reports: () => queryClient.invalidateQueries({ queryKey: workshopQueryKeys.reports(backend) }),
|
||||
reportSummaries: (submission: string) =>
|
||||
queryClient.invalidateQueries({ queryKey: workshopQueryKeys.reportSummaries(backend, submission) }),
|
||||
comments: (submission: string) =>
|
||||
queryClient.invalidateQueries({ queryKey: workshopQueryKeys.comments(backend, submission) }),
|
||||
};
|
||||
@@ -753,7 +796,13 @@ export function useWorkshopReportMutation(backend: WorkshopBackend) {
|
||||
const invalidation = useWorkshopInvalidation(backend);
|
||||
return useMutation({
|
||||
...workshopReportMutationOptions(backend),
|
||||
onSuccess: async () => Promise.all([invalidation.lists(), invalidation.details(), invalidation.reports()]),
|
||||
onSuccess: async (_report, input) =>
|
||||
Promise.all([
|
||||
invalidation.lists(),
|
||||
invalidation.details(),
|
||||
invalidation.reports(),
|
||||
invalidation.reportSummaries(input.submission),
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,17 @@ describe("Workshop widget validation", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("normalizes widget submissions to readable multi-line JSON", () => {
|
||||
const parsed = workshopSubmissionInputSchema.parse({
|
||||
type: "customWidget",
|
||||
title: "Starter widget",
|
||||
description: "",
|
||||
content: JSON.stringify(CUSTOM_WIDGET_STARTER),
|
||||
});
|
||||
expect(parsed.content).toContain('\n "$schema":');
|
||||
expect(JSON.parse(parsed.content)).toEqual(CUSTOM_WIDGET_STARTER);
|
||||
});
|
||||
|
||||
test("accepts Custom CSS without treating it as widget JSON", () => {
|
||||
expect(
|
||||
workshopSubmissionInputSchema.safeParse({
|
||||
|
||||
@@ -156,7 +156,8 @@ export const workshopSubmissionInputSchema = z
|
||||
.superRefine((input, context) => {
|
||||
const result = validateWorkshopContent(input.type, input.content);
|
||||
if (!result.success) context.addIssue({ code: "custom", path: ["content"], message: result.error });
|
||||
});
|
||||
})
|
||||
.transform((input) => ({ ...input, content: formatWorkshopContent(input.type, input.content) }));
|
||||
export type WorkshopSubmissionInput = z.input<typeof workshopSubmissionInputSchema>;
|
||||
|
||||
export const workshopScreenshotsSchema = z
|
||||
@@ -190,6 +191,15 @@ export const workshopReportSchema = z.object({
|
||||
});
|
||||
export type WorkshopReport = z.infer<typeof workshopReportSchema>;
|
||||
|
||||
export const workshopReportSummarySchema = z.object({
|
||||
id: z.string(),
|
||||
submission: z.string(),
|
||||
category: workshopReportCategorySchema,
|
||||
explanation: z.string(),
|
||||
created: z.string(),
|
||||
});
|
||||
export type WorkshopReportSummary = z.infer<typeof workshopReportSummarySchema>;
|
||||
|
||||
export const workshopCommentSchema = z.object({
|
||||
id: z.string(),
|
||||
submission: z.string(),
|
||||
@@ -219,6 +229,15 @@ export function validateWorkshopContent(type: WorkshopSubmissionType, content: s
|
||||
return validateWorkshopWidget(content);
|
||||
}
|
||||
|
||||
export function formatWorkshopContent(type: WorkshopSubmissionType, content: string): string {
|
||||
if (type !== "customWidget") return content;
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(content) as unknown, null, 2);
|
||||
} catch {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
export function validateWorkshopWidget(content: string): WorkshopWidgetValidationResult {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(content);
|
||||
|
||||
Reference in new issue
Block a user