fix(custom-widgets): address v2 merge review (#6446)
This commit is contained in:
1 parent
6aceb2fd1f
commit
df967751f6
123 files changed
+8632
-1477
No files matched your search
@@ -61,7 +61,7 @@ import { CodeBlock, DeleteConfirmButton, DetailSkeleton, ScreenshotGallery } fro
|
||||
import { formatRelativeTime } from "./format";
|
||||
import { ScreenshotEditor } from "./ScreenshotEditor";
|
||||
import { WorkshopErrorBoundary } from "./WorkshopErrorBoundary";
|
||||
import { downloadSubmissionJson, voteDelta } from "./workshop-utils";
|
||||
import { downloadSubmissionJson, voteAndReconcile } from "./workshop-utils";
|
||||
|
||||
const typeLabels: Record<SubmissionType, string> = { customCss: "CSS", customWidget: "Widget" };
|
||||
const typeDotColors: Record<SubmissionType, string> = { customCss: "bg-blue-500", customWidget: "bg-yellow-500" };
|
||||
@@ -305,28 +305,16 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
|
||||
if (!submission || voting.current) return;
|
||||
voting.current = true;
|
||||
|
||||
const userId = await requireUserId("vote");
|
||||
if (!userId) {
|
||||
if (!(await requireUserId("vote"))) {
|
||||
voting.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const prev = userVote;
|
||||
const isToggleOff = prev?.value === value;
|
||||
const [upD, downD] = voteDelta(prev?.value, value);
|
||||
|
||||
setUserVote(
|
||||
isToggleOff
|
||||
? undefined
|
||||
: ({ ...(prev ?? { id: "", submission: submission.id, user: userId }), value } as WorkshopVote),
|
||||
);
|
||||
setSubmission((s) => (s ? { ...s, upvotes: s.upvotes + upD, downvotes: s.downvotes + downD } : s));
|
||||
|
||||
try {
|
||||
setUserVote((await backend.vote(submission.id, value)) ?? undefined);
|
||||
const reconciled = await voteAndReconcile(backend, submission.id, value);
|
||||
setSubmission(reconciled.submission);
|
||||
setUserVote(reconciled.votes.find((vote) => vote.submission === submission.id));
|
||||
} catch (caught) {
|
||||
setUserVote(prev);
|
||||
setSubmission((s) => (s ? { ...s, upvotes: s.upvotes - upD, downvotes: s.downvotes - downD } : s));
|
||||
setError(errorMessage(caught, "Failed to register your vote"));
|
||||
} finally {
|
||||
voting.current = false;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ScreenshotGallery } from "./DetailSections";
|
||||
|
||||
vi.mock("@docusaurus/theme-common", () => ({ useColorMode: () => ({ colorMode: "light" }) }));
|
||||
|
||||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(() => root.unmount());
|
||||
host.remove();
|
||||
});
|
||||
|
||||
async function render(urls: string[]) {
|
||||
await act(async () => root.render(<ScreenshotGallery urls={urls} title="Gallery" />));
|
||||
}
|
||||
|
||||
describe("ScreenshotGallery", () => {
|
||||
it("resets an out-of-range selection when screenshots shrink", async () => {
|
||||
await render(["/one.png", "/two.png", "/three.png"]);
|
||||
const thirdThumbnail = host.querySelector<HTMLButtonElement>('button[aria-label="Show screenshot 3"]');
|
||||
if (!thirdThumbnail) throw new Error("Third screenshot thumbnail was not rendered");
|
||||
await act(async () => thirdThumbnail.click());
|
||||
expect(host.querySelector<HTMLImageElement>('img[alt="Gallery screenshot 3"]')?.src).toContain("/three.png");
|
||||
|
||||
await render(["/one.png"]);
|
||||
expect(host.querySelector<HTMLImageElement>('img[alt="Gallery screenshot 1"]')?.src).toContain("/one.png");
|
||||
expect(host.querySelector('img[alt="Gallery screenshot 3"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useColorMode } from "@docusaurus/theme-common";
|
||||
import { IconChevronLeft, IconChevronRight, IconTrash } from "@tabler/icons-react";
|
||||
import { Highlight, themes } from "prism-react-renderer";
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@site/src/lib/utils";
|
||||
import { clampScreenshotIndex } from "./workshop-utils";
|
||||
|
||||
const prismThemes = { light: themes.github, dark: themes.dracula } as const;
|
||||
const colorModeKeys = { dark: "dark", light: "light" } as const;
|
||||
@@ -44,11 +45,19 @@ export const DetailSkeleton = () => (
|
||||
|
||||
export const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: string }) => {
|
||||
const [idx, setIdx] = useState(0);
|
||||
const selectedIndex = clampScreenshotIndex(idx, urls.length);
|
||||
useEffect(() => setIdx((current) => clampScreenshotIndex(current, urls.length)), [urls.length]);
|
||||
|
||||
if (urls.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="group/gallery w-full">
|
||||
<div className="relative aspect-video overflow-hidden rounded-lg border border-border bg-muted">
|
||||
<img className="h-full w-full object-contain" src={urls[idx]} alt={`${title} screenshot ${idx + 1}`} />
|
||||
<img
|
||||
className="h-full w-full object-contain"
|
||||
src={urls[selectedIndex]}
|
||||
alt={`${title} screenshot ${selectedIndex + 1}`}
|
||||
/>
|
||||
{urls.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
@@ -75,7 +84,7 @@ export const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: stri
|
||||
<legend className="sr-only">Screenshot previews</legend>
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
{urls.map((url, i) => {
|
||||
const selected = i === idx;
|
||||
const selected = i === selectedIndex;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -31,6 +31,7 @@ import { cn, errorMessage } from "@/lib/utils";
|
||||
|
||||
import type { SubmitInput } from "./useWorkshop";
|
||||
import { ScreenshotEditor } from "./ScreenshotEditor";
|
||||
import { getJsonImportSubmissionType } from "./submission-import";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
@@ -104,7 +105,7 @@ export const SubmitForm = ({ onClose, onSubmit }: Props) => {
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener("load", (e) => {
|
||||
const text = e.target?.result as string;
|
||||
if (!type) setType("customWidget");
|
||||
setType(getJsonImportSubmissionType(type));
|
||||
setContentAndAutofill(text, title, description);
|
||||
if (step === 0) goTo(1);
|
||||
});
|
||||
@@ -400,6 +401,7 @@ const StepType = ({
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
aria-label="Choose a widget JSON file"
|
||||
accept=".json,application/json"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getJsonImportSubmissionType } from "./submission-import";
|
||||
|
||||
describe("Workshop JSON import classification", () => {
|
||||
it("switches an existing Custom CSS draft to Custom Widget", () => {
|
||||
expect(getJsonImportSubmissionType("customCss")).toBe("customWidget");
|
||||
});
|
||||
|
||||
it("classifies JSON before a submission type is selected", () => {
|
||||
expect(getJsonImportSubmissionType(null)).toBe("customWidget");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { SubmissionType } from "@site/src/lib/workshop-schema";
|
||||
|
||||
export const getJsonImportSubmissionType = (_currentType: SubmissionType | null): SubmissionType => "customWidget";
|
||||
@@ -7,7 +7,7 @@ import type { SubmissionType } from "@site/src/lib/workshop-schema";
|
||||
import { validateSubmissionContent } from "@site/src/lib/workshop-schema";
|
||||
import { errorMessage, oauthErrorMessage } from "@site/src/lib/utils";
|
||||
|
||||
import { voteDelta } from "./workshop-utils";
|
||||
import { voteAndReconcile } from "./workshop-utils";
|
||||
|
||||
export type SortKey = "top" | "new" | "recent" | "discussed";
|
||||
export type TypeFilter = "all" | "yours" | SubmissionType;
|
||||
@@ -131,58 +131,32 @@ export const useWorkshop = (workshopUrl: string) => {
|
||||
if (votingIds.current.has(submissionId)) return;
|
||||
votingIds.current.add(submissionId);
|
||||
|
||||
const userId = await requireUserId("vote");
|
||||
if (!userId) {
|
||||
if (!(await requireUserId("vote"))) {
|
||||
votingIds.current.delete(submissionId);
|
||||
return;
|
||||
}
|
||||
|
||||
const prev = votes[submissionId];
|
||||
const isToggleOff = prev?.value === value;
|
||||
const [upD, downD] = voteDelta(prev?.value, value);
|
||||
|
||||
setVotes((v) => {
|
||||
const next = { ...v };
|
||||
if (isToggleOff) delete next[submissionId];
|
||||
else
|
||||
next[submissionId] = {
|
||||
...(prev ?? { id: "", submission: submissionId, user: userId, created: "", updated: "" }),
|
||||
value,
|
||||
} as WorkshopVote;
|
||||
return next;
|
||||
});
|
||||
setSubmissions((s) =>
|
||||
s.map((sub) =>
|
||||
sub.id === submissionId ? { ...sub, upvotes: sub.upvotes + upD, downvotes: sub.downvotes + downD } : sub,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
const saved = await backend.vote(submissionId, value);
|
||||
setVotes((current) => {
|
||||
const next = { ...current };
|
||||
if (saved) next[submissionId] = saved;
|
||||
else delete next[submissionId];
|
||||
return next;
|
||||
});
|
||||
} catch (caught) {
|
||||
setVotes((v) => {
|
||||
const next = { ...v };
|
||||
if (prev) next[submissionId] = prev;
|
||||
else delete next[submissionId];
|
||||
return next;
|
||||
});
|
||||
setSubmissions((s) =>
|
||||
s.map((sub) =>
|
||||
sub.id === submissionId ? { ...sub, upvotes: sub.upvotes - upD, downvotes: sub.downvotes - downD } : sub,
|
||||
const reconciled = await voteAndReconcile(backend, submissionId, value);
|
||||
setVotes(Object.fromEntries(reconciled.votes.map((row) => [row.submission, row])));
|
||||
setSubmissions((current) =>
|
||||
current.map((submission) =>
|
||||
submission.id === submissionId
|
||||
? {
|
||||
...submission,
|
||||
upvotes: reconciled.submission.upvotes,
|
||||
downvotes: reconciled.submission.downvotes,
|
||||
}
|
||||
: submission,
|
||||
),
|
||||
);
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught, "Failed to register your vote"));
|
||||
} finally {
|
||||
votingIds.current.delete(submissionId);
|
||||
}
|
||||
},
|
||||
[backend, votes, requireUserId],
|
||||
[backend, requireUserId],
|
||||
);
|
||||
|
||||
const report = useCallback(
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { WorkshopSubmission, WorkshopVote } from "@site/src/lib/pocketbase";
|
||||
|
||||
import { clampScreenshotIndex, voteAndReconcile } from "./workshop-utils";
|
||||
|
||||
describe("Workshop state reconciliation", () => {
|
||||
it("reloads both the submission counts and authenticated vote after voting", async () => {
|
||||
const submission = { id: "submission-1", upvotes: 8, downvotes: 2 } as WorkshopSubmission;
|
||||
const vote = { id: "vote-1", submission: "submission-1", value: 1 } as WorkshopVote;
|
||||
const backend = {
|
||||
vote: vi.fn().mockResolvedValue(vote),
|
||||
get: vi.fn().mockResolvedValue(submission),
|
||||
listVotesForCurrentUser: vi.fn().mockResolvedValue([vote]),
|
||||
};
|
||||
|
||||
await expect(voteAndReconcile(backend, submission.id, 1)).resolves.toEqual({ submission, votes: [vote] });
|
||||
expect(backend.vote).toHaveBeenCalledWith(submission.id, 1);
|
||||
expect(backend.get).toHaveBeenCalledWith(submission.id);
|
||||
expect(backend.listVotesForCurrentUser).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("clamps a selected screenshot when the gallery shrinks", () => {
|
||||
expect(clampScreenshotIndex(3, 2)).toBe(1);
|
||||
expect(clampScreenshotIndex(2, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { WorkshopSubmission } from "@site/src/lib/pocketbase";
|
||||
import type { WorkshopSubmission, WorkshopVote } from "@site/src/lib/pocketbase";
|
||||
|
||||
export const downloadSubmissionJson = (submission: WorkshopSubmission) => {
|
||||
const url = URL.createObjectURL(new Blob([submission.content], { type: "application/json" }));
|
||||
@@ -9,8 +9,17 @@ export const downloadSubmissionJson = (submission: WorkshopSubmission) => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
export const voteDelta = (prev: 1 | -1 | undefined, next: 1 | -1): [up: number, down: number] => {
|
||||
if (!prev) return next === 1 ? [1, 0] : [0, 1];
|
||||
if (prev === next) return next === 1 ? [-1, 0] : [0, -1];
|
||||
return next === 1 ? [1, -1] : [-1, 1];
|
||||
interface WorkshopVoteBackend {
|
||||
vote(submissionId: string, value: 1 | -1): Promise<WorkshopVote | null>;
|
||||
get(submissionId: string): Promise<WorkshopSubmission>;
|
||||
listVotesForCurrentUser(): Promise<WorkshopVote[]>;
|
||||
}
|
||||
|
||||
export const voteAndReconcile = async (backend: WorkshopVoteBackend, submissionId: string, value: 1 | -1) => {
|
||||
await backend.vote(submissionId, value);
|
||||
const [submission, votes] = await Promise.all([backend.get(submissionId), backend.listVotesForCurrentUser()]);
|
||||
return { submission, votes };
|
||||
};
|
||||
|
||||
export const clampScreenshotIndex = (index: number, screenshotCount: number) =>
|
||||
Math.min(Math.max(0, index), Math.max(0, screenshotCount - 1));
|
||||
Reference in new issue
Block a user