feat(workshop): rebuild install and publish as pages
Replace the stacked install modals (browse -> details -> import review) with page-based flows so the primary action is always reachable: - /manage/custom-widgets/workshop browses submissions as a card grid with large previews, sitting in a tab next to the installed widgets - /manage/custom-widgets/workshop/[id] merges the listing, security review and source setup onto one page with a sticky install footer - /manage/custom-widgets/publish/[id] replaces the publish modal - /manage/workshop redirects; its navbar entry is gone - the Custom CSS picker collapses from three modals to one Extract useCustomWidgetImport and ImportReviewContent so the Workshop page and the file/clipboard dialog share one import pipeline. Votes now show the viewer's own choice filled Reddit-style, backed by a new user-votes query, and screenshots get a zoomable lightbox. Rework the installed list onto ManageCollectionItem: show the widget description, replace the cryptic "JSX - 2" badge with plain request and origin facts, and swap the toggle and bare pencil for a status badge, a labelled Edit button and a menu entry for enable/disable. Fixes found while reviewing: - importing could run twice and duplicate a widget; guard on pending and succeeded, and return to the installed list instead of the heavy edit form - a background refetch of the submission (window focus, or the invalidation a vote triggers) reset the source setup and silently wiped part-typed credentials; key the reset on the sources, not the object - breadcrumbs raised MISSING_MESSAGE for unmapped dynamic segments; fall back to the raw segment via t.has - publishing had no in-flight guard and could post duplicate listings - tooltips on disabled vote and report controls never fired, making the sign-in hint unreachable
This commit is contained in:
38 files changed
+2303
-1700
No files matched your search
@@ -6,17 +6,42 @@ import { describe, expect, test } from "vitest";
|
||||
const languageDirectory = resolve(import.meta.dirname, "../../../translation/src/lang");
|
||||
|
||||
describe("Workshop navigation translations", () => {
|
||||
test("defines the navbar and breadcrumb label in the source locale", async () => {
|
||||
test("defines the breadcrumb labels for the Custom Widgets sub-routes", async () => {
|
||||
const messages = JSON.parse(await readFile(resolve(languageDirectory, "en.json"), "utf8")) as {
|
||||
management?: { navbar?: { items?: { workshop?: unknown } } };
|
||||
navigationStructure?: { manage?: { workshop?: { label?: unknown } } };
|
||||
navigationStructure?: {
|
||||
manage?: {
|
||||
workshop?: unknown;
|
||||
"custom-widgets"?: {
|
||||
workshop?: { label?: unknown };
|
||||
publish?: { label?: unknown };
|
||||
};
|
||||
};
|
||||
};
|
||||
customWidget?: { page?: { tabs?: Record<string, unknown> } };
|
||||
workshop?: Record<string, unknown>;
|
||||
};
|
||||
expect(messages.management?.navbar?.items?.workshop).toBe("Workshop");
|
||||
expect(messages.navigationStructure?.manage?.workshop?.label).toBe("Workshop");
|
||||
|
||||
// The Workshop is reached through the Custom Widgets tabs, not its own navbar entry.
|
||||
expect(messages.management?.navbar?.items?.workshop).toBeUndefined();
|
||||
expect(messages.navigationStructure?.manage?.workshop).toBeUndefined();
|
||||
expect(messages.navigationStructure?.manage?.["custom-widgets"]?.workshop?.label).toBe("Workshop");
|
||||
expect(messages.navigationStructure?.manage?.["custom-widgets"]?.publish?.label).toBe("Publish to Workshop");
|
||||
expect(messages.customWidget?.page?.tabs).toMatchObject({
|
||||
installed: "Installed",
|
||||
workshop: "Workshop",
|
||||
ariaLabel: expect.any(String),
|
||||
});
|
||||
|
||||
expect(messages.workshop).toMatchObject({
|
||||
communityMember: "Community member",
|
||||
outdated: "Outdated",
|
||||
install: "Install",
|
||||
back: expect.any(String),
|
||||
listAriaLabel: expect.any(String),
|
||||
installReviewTitle: expect.any(String),
|
||||
emptyFilteredDescription: expect.any(String),
|
||||
importCssConfirm: expect.any(String),
|
||||
reportCount: expect.any(String),
|
||||
reportWarning: expect.any(String),
|
||||
reportVisibility: expect.any(String),
|
||||
@@ -31,7 +56,19 @@ describe("Workshop navigation translations", () => {
|
||||
inappropriate: expect.any(String),
|
||||
other: expect.any(String),
|
||||
}),
|
||||
publish: expect.objectContaining({ invalidScreenshot: expect.any(String) }),
|
||||
publish: expect.objectContaining({
|
||||
invalidScreenshot: expect.any(String),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test("drops the message keys that only existed for the stacked install modals", async () => {
|
||||
const messages = JSON.parse(await readFile(resolve(languageDirectory, "en.json"), "utf8")) as {
|
||||
workshop?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
for (const key of ["installDialog", "continueInstall", "confirmInstallTitle", "backToWidget", "inspectCss"]) {
|
||||
expect(messages.workshop).not.toHaveProperty(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,24 +1,11 @@
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Code,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { Alert, Badge, Box, Button, Code, Group, Modal, Paper, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import type { ModalProps } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
import type { ImportReview } from "../core/import";
|
||||
|
||||
export interface ImportReviewMessages {
|
||||
title: string;
|
||||
export interface ImportReviewContentMessages {
|
||||
description: string;
|
||||
name: string;
|
||||
origin: string;
|
||||
@@ -28,9 +15,13 @@ export interface ImportReviewMessages {
|
||||
permissions: string;
|
||||
actionWarningTitle: string;
|
||||
actionWarningDescription: string;
|
||||
permission(permission: string): string;
|
||||
}
|
||||
|
||||
export interface ImportReviewMessages extends ImportReviewContentMessages {
|
||||
title: string;
|
||||
cancel: string;
|
||||
confirm: string;
|
||||
permission(permission: string): string;
|
||||
}
|
||||
|
||||
export interface ImportReviewDialogProps {
|
||||
@@ -75,6 +66,71 @@ function ImportFact({ label, value }: { label: string; value: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export interface ImportReviewContentProps {
|
||||
review: ImportReview;
|
||||
messages: ImportReviewContentMessages;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The security facts a user has to review before a custom widget is installed.
|
||||
* Shared by the import modal and the Workshop install page so both read identically.
|
||||
*/
|
||||
export function ImportReviewContent({ review, messages, children }: ImportReviewContentProps) {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{messages.description}
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="xs">
|
||||
<ImportFact label={messages.name} value={review.name} />
|
||||
<ImportFact label={messages.origin} value={review.origins.join(", ")} />
|
||||
<ImportFact label={messages.authentication} value={review.authTypes.join(", ")} />
|
||||
<ImportFact label={messages.networkScope} value={review.networkScopes.join(", ")} />
|
||||
</SimpleGrid>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb={6}>
|
||||
{messages.methods}
|
||||
</Text>
|
||||
<Group gap={6}>
|
||||
{review.methods.map((method) => (
|
||||
<Badge
|
||||
key={method}
|
||||
color={method === "DELETE" ? "red" : method === "GET" ? "blue" : "orange"}
|
||||
variant="light"
|
||||
>
|
||||
{method}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb={6}>
|
||||
{messages.permissions}
|
||||
</Text>
|
||||
<Group gap={6}>
|
||||
{review.permissions.map((permission) => (
|
||||
<Badge key={permission} color="gray" variant="light">
|
||||
{messages.permission(permission)}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</div>
|
||||
</SimpleGrid>
|
||||
{review.hasActions && (
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
|
||||
<Text size="sm" fw={600}>
|
||||
{messages.actionWarningTitle}
|
||||
</Text>
|
||||
<Text size="sm">{messages.actionWarningDescription}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImportReviewDialog({
|
||||
opened,
|
||||
review,
|
||||
@@ -103,56 +159,9 @@ export function ImportReviewDialog({
|
||||
{review && (
|
||||
<>
|
||||
<Box p="lg" style={{ flex: 1, minHeight: 0, overflowY: "auto" }}>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{messages.description}
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="xs">
|
||||
<ImportFact label={messages.name} value={review.name} />
|
||||
<ImportFact label={messages.origin} value={review.origins.join(", ")} />
|
||||
<ImportFact label={messages.authentication} value={review.authTypes.join(", ")} />
|
||||
<ImportFact label={messages.networkScope} value={review.networkScopes.join(", ")} />
|
||||
</SimpleGrid>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb={6}>
|
||||
{messages.methods}
|
||||
</Text>
|
||||
<Group gap={6}>
|
||||
{review.methods.map((method) => (
|
||||
<Badge
|
||||
key={method}
|
||||
color={method === "DELETE" ? "red" : method === "GET" ? "blue" : "orange"}
|
||||
variant="light"
|
||||
>
|
||||
{method}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb={6}>
|
||||
{messages.permissions}
|
||||
</Text>
|
||||
<Group gap={6}>
|
||||
{review.permissions.map((permission) => (
|
||||
<Badge key={permission} color="gray" variant="light">
|
||||
{messages.permission(permission)}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</div>
|
||||
</SimpleGrid>
|
||||
{review.hasActions && (
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
|
||||
<Text size="sm" fw={600}>
|
||||
{messages.actionWarningTitle}
|
||||
</Text>
|
||||
<Text size="sm">{messages.actionWarningDescription}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
<ImportReviewContent review={review} messages={messages}>
|
||||
{children}
|
||||
</Stack>
|
||||
</ImportReviewContent>
|
||||
</Box>
|
||||
<Box
|
||||
px="lg"
|
||||
|
||||
Reference in new issue
Block a user