chore: import upstream snapshot with attribution
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { BlockKitMediaPickerField } from "../../src/components/BlockKitMediaPickerField";
|
||||
import { render } from "../utils/render";
|
||||
|
||||
// Stub MediaPickerModal as a test seam so tests can drive the picker selection
|
||||
// flow (local pick, URL pick, close) without spinning up the real modal.
|
||||
vi.mock("../../src/components/MediaPickerModal", () => ({
|
||||
MediaPickerModal: ({
|
||||
open,
|
||||
onSelect,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onSelect: (item: unknown) => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) =>
|
||||
open ? (
|
||||
<div data-testid="media-picker-modal">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onSelect({
|
||||
id: "m1",
|
||||
filename: "photo.png",
|
||||
mimeType: "image/png",
|
||||
url: "/media/photo.png",
|
||||
storageKey: "photo.png",
|
||||
provider: "local",
|
||||
size: 0,
|
||||
createdAt: "",
|
||||
})
|
||||
}
|
||||
>
|
||||
pick-local
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onSelect({
|
||||
id: "",
|
||||
filename: "ext.jpg",
|
||||
mimeType: "image/unknown",
|
||||
url: "https://cdn.example/ext.jpg",
|
||||
size: 0,
|
||||
createdAt: "",
|
||||
})
|
||||
}
|
||||
>
|
||||
pick-url
|
||||
</button>
|
||||
<button type="button" onClick={() => onOpenChange(false)}>
|
||||
close
|
||||
</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
async function renderField(
|
||||
props: Partial<React.ComponentProps<typeof BlockKitMediaPickerField>> = {},
|
||||
) {
|
||||
const onChange = props.onChange ?? vi.fn();
|
||||
const screen = await render(
|
||||
<BlockKitMediaPickerField
|
||||
actionId="hero"
|
||||
label="Hero"
|
||||
value=""
|
||||
onChange={onChange}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
return { screen, onChange };
|
||||
}
|
||||
|
||||
async function waitForImg(): Promise<HTMLImageElement> {
|
||||
let el: HTMLImageElement | null = null;
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
el = document.querySelector("img");
|
||||
expect(el).toBeTruthy();
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
return el!;
|
||||
}
|
||||
|
||||
describe("BlockKitMediaPickerField", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("empty state", () => {
|
||||
it("renders the default placeholder when no value is set", async () => {
|
||||
const { screen } = await renderField();
|
||||
await expect.element(screen.getByText("Select media")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the custom placeholder when provided", async () => {
|
||||
const { screen } = await renderField({ placeholder: "Pick a hero image" });
|
||||
await expect.element(screen.getByText("Pick a hero image")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the picker when the empty-state button is clicked", async () => {
|
||||
const { screen } = await renderField({ placeholder: "Pick a hero image" });
|
||||
const trigger = screen.getByText("Pick a hero image");
|
||||
(trigger.element() as HTMLElement).closest("button")!.click();
|
||||
await expect.element(screen.getByTestId("media-picker-modal")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("selection", () => {
|
||||
it("rewrites a local-provider item to the /_emdash/api/media/file/ URL", async () => {
|
||||
const onChange = vi.fn();
|
||||
const { screen } = await renderField({ placeholder: "open", onChange });
|
||||
(screen.getByText("open").element() as HTMLElement).closest("button")!.click();
|
||||
await expect.element(screen.getByTestId("media-picker-modal")).toBeInTheDocument();
|
||||
(screen.getByText("pick-local").element() as HTMLElement).click();
|
||||
expect(onChange).toHaveBeenCalledWith("hero", "/_emdash/api/media/file/photo.png");
|
||||
});
|
||||
|
||||
it("uses the raw URL for items inserted via the URL tab (no provider, no storageKey)", async () => {
|
||||
const onChange = vi.fn();
|
||||
const { screen } = await renderField({ placeholder: "open", onChange });
|
||||
(screen.getByText("open").element() as HTMLElement).closest("button")!.click();
|
||||
await expect.element(screen.getByTestId("media-picker-modal")).toBeInTheDocument();
|
||||
(screen.getByText("pick-url").element() as HTMLElement).click();
|
||||
expect(onChange).toHaveBeenCalledWith("hero", "https://cdn.example/ext.jpg");
|
||||
});
|
||||
});
|
||||
|
||||
describe("preview", () => {
|
||||
it("renders the image with no-referrer and lazy loading when value is a safe URL", async () => {
|
||||
await renderField({ value: "/_emdash/api/media/file/abc.png" });
|
||||
const img = await waitForImg();
|
||||
expect(img.getAttribute("src")).toBe("/_emdash/api/media/file/abc.png");
|
||||
expect(img.getAttribute("referrerpolicy")).toBe("no-referrer");
|
||||
expect(img.getAttribute("loading")).toBe("lazy");
|
||||
});
|
||||
|
||||
it("renders the image for safe external URLs", async () => {
|
||||
await renderField({ value: "https://cdn.example/img.png" });
|
||||
const img = await waitForImg();
|
||||
expect(img.getAttribute("src")).toBe("https://cdn.example/img.png");
|
||||
});
|
||||
|
||||
it("falls back to the placeholder for javascript: URLs", async () => {
|
||||
// Cast to any to bypass DOM-style typing on src; this string is what an
|
||||
// admin user could paste into a text_input-compatible value.
|
||||
const { screen } = await renderField({ value: "javascript:alert(1)" });
|
||||
await expect.element(screen.getByText("Select media")).toBeInTheDocument();
|
||||
expect(document.querySelector("img")).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to the placeholder for protocol-relative URLs", async () => {
|
||||
const { screen } = await renderField({ value: "//evil.example/img.png" });
|
||||
await expect.element(screen.getByText("Select media")).toBeInTheDocument();
|
||||
expect(document.querySelector("img")).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to the placeholder for data: URIs", async () => {
|
||||
const { screen } = await renderField({ value: "data:image/png;base64,iVBORw0KG" });
|
||||
await expect.element(screen.getByText("Select media")).toBeInTheDocument();
|
||||
expect(document.querySelector("img")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("broken image", () => {
|
||||
it("shows a broken-image placeholder when the img fails to load", async () => {
|
||||
const { screen } = await renderField({
|
||||
value: "/_emdash/api/media/file/missing.png",
|
||||
});
|
||||
|
||||
// Image should render initially
|
||||
const img = await waitForImg();
|
||||
expect(img.getAttribute("src")).toBe("/_emdash/api/media/file/missing.png");
|
||||
|
||||
// Simulate image load failure
|
||||
img.dispatchEvent(new Event("error"));
|
||||
|
||||
// Broken-image placeholder should appear
|
||||
await expect.element(screen.getByText("Image not found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps Change and Remove buttons accessible when the image is broken", async () => {
|
||||
const { screen } = await renderField({
|
||||
value: "/_emdash/api/media/file/missing.png",
|
||||
});
|
||||
|
||||
const img = await waitForImg();
|
||||
img.dispatchEvent(new Event("error"));
|
||||
|
||||
// Both action buttons must remain in the DOM and accessible
|
||||
await expect.element(screen.getByLabelText("Remove")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Change")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("maintains a minimum height so the container does not collapse", async () => {
|
||||
const { screen } = await renderField({
|
||||
value: "/_emdash/api/media/file/missing.png",
|
||||
});
|
||||
|
||||
const img = await waitForImg();
|
||||
img.dispatchEvent(new Event("error"));
|
||||
|
||||
// The broken-image placeholder should be visible (its presence confirms
|
||||
// the container didn't collapse — a zero-height container can't show text)
|
||||
await expect.element(screen.getByText("Image not found")).toBeInTheDocument();
|
||||
|
||||
// The remove button must remain accessible via label
|
||||
await expect.element(screen.getByLabelText("Remove")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("remove", () => {
|
||||
it("clears the value when Remove is clicked", async () => {
|
||||
const onChange = vi.fn();
|
||||
const { screen } = await renderField({
|
||||
value: "/_emdash/api/media/file/abc.png",
|
||||
onChange,
|
||||
});
|
||||
const removeBtn = screen.getByLabelText("Remove");
|
||||
await expect.element(removeBtn).toBeInTheDocument();
|
||||
(removeBtn.element() as HTMLElement).click();
|
||||
expect(onChange).toHaveBeenCalledWith("hero", "");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { CapabilityConsentDialog } from "../../src/components/CapabilityConsentDialog";
|
||||
import { render } from "../utils/render.tsx";
|
||||
|
||||
describe("CapabilityConsentDialog", () => {
|
||||
let onConfirm: ReturnType<typeof vi.fn>;
|
||||
let onCancel: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
onConfirm = vi.fn();
|
||||
onCancel = vi.fn();
|
||||
});
|
||||
|
||||
it("renders dialog with plugin name and capabilities", async () => {
|
||||
const screen = await render(
|
||||
<CapabilityConsentDialog
|
||||
pluginName="SEO Helper"
|
||||
capabilities={["read:content", "write:content"]}
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
);
|
||||
|
||||
await expect
|
||||
.element(screen.getByText("SEO Helper requires the following permissions:"))
|
||||
.toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Read your content")).toBeInTheDocument();
|
||||
await expect
|
||||
.element(screen.getByText("Create, update, and delete content"))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'Plugin Permissions' title for fresh install", async () => {
|
||||
const screen = await render(
|
||||
<CapabilityConsentDialog
|
||||
pluginName="Test"
|
||||
capabilities={["read:content"]}
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
);
|
||||
|
||||
await expect.element(screen.getByText("Plugin Permissions")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'Review New Permissions' title for update with new capabilities", async () => {
|
||||
const screen = await render(
|
||||
<CapabilityConsentDialog
|
||||
pluginName="Test"
|
||||
capabilities={["read:content", "write:content"]}
|
||||
newCapabilities={["write:content"]}
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
);
|
||||
|
||||
await expect.element(screen.getByText("Review New Permissions")).toBeInTheDocument();
|
||||
await expect
|
||||
.element(screen.getByText("Test is requesting additional permissions:"))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks new capabilities with NEW badge", async () => {
|
||||
const screen = await render(
|
||||
<CapabilityConsentDialog
|
||||
pluginName="Test"
|
||||
capabilities={["read:content", "write:content", "network:fetch"]}
|
||||
newCapabilities={["network:fetch"]}
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
);
|
||||
|
||||
// The NEW badge should appear for network:fetch (exact match to avoid matching "New" in header)
|
||||
const newBadges = screen.getByText("NEW", { exact: true }).all();
|
||||
expect(newBadges.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("shows 'Accept & Install' button for fresh install", async () => {
|
||||
const screen = await render(
|
||||
<CapabilityConsentDialog
|
||||
pluginName="Test"
|
||||
capabilities={["read:content"]}
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
);
|
||||
|
||||
await expect.element(screen.getByText("Accept & Install")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'Accept & Update' button for update", async () => {
|
||||
const screen = await render(
|
||||
<CapabilityConsentDialog
|
||||
pluginName="Test"
|
||||
capabilities={["read:content"]}
|
||||
newCapabilities={["read:content"]}
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
);
|
||||
|
||||
await expect.element(screen.getByText("Accept & Update")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onConfirm when confirm button is clicked", async () => {
|
||||
const screen = await render(
|
||||
<CapabilityConsentDialog
|
||||
pluginName="Test"
|
||||
capabilities={["read:content"]}
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.getByText("Accept & Install").click();
|
||||
expect(onConfirm).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("calls onCancel when cancel button is clicked", async () => {
|
||||
const screen = await render(
|
||||
<CapabilityConsentDialog
|
||||
pluginName="Test"
|
||||
capabilities={["read:content"]}
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.getByText("Cancel").click();
|
||||
expect(onCancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("shows warning banner for 'warn' audit verdict", async () => {
|
||||
const screen = await render(
|
||||
<CapabilityConsentDialog
|
||||
pluginName="Test"
|
||||
capabilities={["read:content"]}
|
||||
auditVerdict="warn"
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
);
|
||||
|
||||
await expect
|
||||
.element(screen.getByText("Security audit flagged potential concerns with this plugin."))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows danger banner for 'fail' audit verdict", async () => {
|
||||
const screen = await render(
|
||||
<CapabilityConsentDialog
|
||||
pluginName="Test"
|
||||
capabilities={["read:content"]}
|
||||
auditVerdict="fail"
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
);
|
||||
|
||||
await expect
|
||||
.element(screen.getByText("Security audit flagged this plugin as potentially unsafe."))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows no audit banner for 'pass' verdict", async () => {
|
||||
const screen = await render(
|
||||
<CapabilityConsentDialog
|
||||
pluginName="Test"
|
||||
capabilities={["read:content"]}
|
||||
auditVerdict="pass"
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
);
|
||||
|
||||
const warnText = screen.getByText(
|
||||
"Security audit flagged potential concerns with this plugin.",
|
||||
);
|
||||
await expect.element(warnText).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows pending state during install", async () => {
|
||||
const screen = await render(
|
||||
<CapabilityConsentDialog
|
||||
pluginName="Test"
|
||||
capabilities={["read:content"]}
|
||||
isPending={true}
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
);
|
||||
|
||||
await expect.element(screen.getByText("Installing...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows pending state during update", async () => {
|
||||
const screen = await render(
|
||||
<CapabilityConsentDialog
|
||||
pluginName="Test"
|
||||
capabilities={["read:content"]}
|
||||
newCapabilities={["read:content"]}
|
||||
isPending={true}
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
);
|
||||
|
||||
await expect.element(screen.getByText("Updating...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("appends allowed hosts for network:fetch", async () => {
|
||||
const screen = await render(
|
||||
<CapabilityConsentDialog
|
||||
pluginName="Test"
|
||||
capabilities={["network:fetch"]}
|
||||
allowedHosts={["api.example.com"]}
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
);
|
||||
|
||||
await expect
|
||||
.element(screen.getByText("Make network requests to: api.example.com"))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders raw capability string for unknown capabilities", async () => {
|
||||
const screen = await render(
|
||||
<CapabilityConsentDialog
|
||||
pluginName="Test"
|
||||
capabilities={["custom:magic"]}
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
);
|
||||
|
||||
await expect.element(screen.getByText("custom:magic")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has correct dialog role and aria attributes", async () => {
|
||||
const screen = await render(
|
||||
<CapabilityConsentDialog
|
||||
pluginName="Test"
|
||||
capabilities={["read:content"]}
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole("dialog");
|
||||
await expect.element(dialog).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { i18n } from "@lingui/core";
|
||||
import * as React from "react";
|
||||
import { beforeAll, describe, it, expect, vi } from "vitest";
|
||||
|
||||
import { CommentInbox } from "../../src/components/comments/CommentInbox";
|
||||
import type { AdminComment, CommentCounts } from "../../src/lib/api/comments.js";
|
||||
// @ts-ignore — compiled lingui catalog has no .d.ts
|
||||
import { messages as enMessages } from "../../src/locales/en/messages.mjs";
|
||||
import { render } from "../utils/render.tsx";
|
||||
|
||||
beforeAll(() => {
|
||||
i18n.loadAndActivate({ locale: "en", messages: enMessages });
|
||||
});
|
||||
|
||||
const sampleComment: AdminComment = {
|
||||
id: "01J000000000000000000000A1",
|
||||
collection: "posts",
|
||||
contentId: "01J000000000000000000000B1",
|
||||
parentId: null,
|
||||
authorName: "Author Dash",
|
||||
authorEmail: "authdash@example.com",
|
||||
authorUserId: null,
|
||||
body: "Lovely post!",
|
||||
status: "pending",
|
||||
ipHash: null,
|
||||
userAgent: null,
|
||||
moderationMetadata: null,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z").toISOString(),
|
||||
updatedAt: new Date("2026-01-01T00:00:00Z").toISOString(),
|
||||
};
|
||||
|
||||
const counts: CommentCounts = { pending: 1, approved: 0, spam: 0, trash: 0 };
|
||||
|
||||
const noopProps = {
|
||||
comments: [sampleComment],
|
||||
counts,
|
||||
isLoading: false,
|
||||
collections: { posts: { label: "Posts" } },
|
||||
activeStatus: "pending" as const,
|
||||
onStatusChange: vi.fn(),
|
||||
collectionFilter: "",
|
||||
onCollectionFilterChange: vi.fn(),
|
||||
searchQuery: "",
|
||||
onSearchChange: vi.fn(),
|
||||
onCommentStatusChange: vi.fn().mockResolvedValue(undefined),
|
||||
onCommentDelete: vi.fn().mockResolvedValue(undefined),
|
||||
onBulkAction: vi.fn().mockResolvedValue(undefined),
|
||||
onLoadMore: vi.fn(),
|
||||
isAdmin: true,
|
||||
isStatusPending: false,
|
||||
deleteError: null,
|
||||
onDeleteErrorReset: vi.fn(),
|
||||
};
|
||||
|
||||
describe("CommentInbox", () => {
|
||||
it("toggles selection when a row checkbox is clicked", async () => {
|
||||
const screen = await render(<CommentInbox {...noopProps} />);
|
||||
|
||||
const checkbox = screen.getByRole("checkbox", { name: "Select comment by Author Dash" });
|
||||
await expect.element(checkbox).toBeInTheDocument();
|
||||
|
||||
await checkbox.click();
|
||||
|
||||
// Bulk action bar appears only after at least one comment is selected.
|
||||
await expect.element(screen.getByText("1 selected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toggles all rows when the select-all checkbox is clicked", async () => {
|
||||
const screen = await render(<CommentInbox {...noopProps} />);
|
||||
|
||||
const selectAll = screen.getByRole("checkbox", { name: "Select all" });
|
||||
await selectAll.click();
|
||||
|
||||
await expect.element(screen.getByText("1 selected")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Enforces the ContentSettingsPanel memoization contract.
|
||||
*
|
||||
* The panel is React.memo'd and ContentEditor promises that every prop it
|
||||
* passes is referentially stable across editor keystrokes (see the useCallback
|
||||
* scaffolding in ContentEditor.tsx and router.tsx). One future inline arrow
|
||||
* among those props silently defeats the memo — the panel re-renders on every
|
||||
* keystroke with green CI. This suite pins the contract by counting probe
|
||||
* renders while typing.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import { ContentEditor, type ContentEditorProps } from "../../src/components/ContentEditor";
|
||||
import type { FieldDescriptor } from "../../src/components/ContentEditor";
|
||||
import type { ContentItem } from "../../src/lib/api";
|
||||
import { render } from "../utils/render.tsx";
|
||||
|
||||
let panelRenderCount = 0;
|
||||
|
||||
// Replace only the panel with a memoized render-counting probe; the action
|
||||
// bar and the rest of the module stay real. The probe must be memo'd exactly
|
||||
// like the real panel so a re-render means "a prop identity changed".
|
||||
vi.mock("../../src/components/ContentSettingsPanel", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../src/components/ContentSettingsPanel")>(
|
||||
"../../src/components/ContentSettingsPanel",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
ContentSettingsPanel: React.memo(function PanelProbe() {
|
||||
panelRenderCount++;
|
||||
return <div data-testid="panel-probe" />;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@tanstack/react-router", async () => {
|
||||
const actual = await vi.importActual("@tanstack/react-router");
|
||||
return {
|
||||
...actual,
|
||||
Link: ({ children, ...props }: any) => <a {...props}>{children}</a>,
|
||||
};
|
||||
});
|
||||
|
||||
const fields: Record<string, FieldDescriptor> = {
|
||||
title: { kind: "string", label: "Title", required: true },
|
||||
body: { kind: "string", label: "Body" },
|
||||
};
|
||||
|
||||
function makeItem(): ContentItem {
|
||||
return {
|
||||
id: "item-1",
|
||||
type: "posts",
|
||||
slug: "my-post",
|
||||
status: "draft",
|
||||
data: { title: "My Post", body: "Some content" },
|
||||
authorId: null,
|
||||
createdAt: "2025-01-15T10:30:00Z",
|
||||
updatedAt: "2025-01-15T10:30:00Z",
|
||||
publishedAt: null,
|
||||
scheduledAt: null,
|
||||
liveRevisionId: null,
|
||||
draftRevisionId: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ContentSettingsPanel memo contract", () => {
|
||||
it("does not re-render the panel while the user types in the editor", async () => {
|
||||
const props: ContentEditorProps = {
|
||||
collection: "posts",
|
||||
collectionLabel: "Post",
|
||||
fields,
|
||||
isNew: false,
|
||||
item: makeItem(),
|
||||
onSave: vi.fn(),
|
||||
onAutosave: vi.fn(),
|
||||
onPublish: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
};
|
||||
|
||||
const screen = await render(<ContentEditor {...props} />);
|
||||
await expect.element(screen.getByTestId("panel-probe")).toBeInTheDocument();
|
||||
|
||||
const baseline = panelRenderCount;
|
||||
expect(baseline).toBeGreaterThan(0);
|
||||
|
||||
const titleInput = screen.getByLabelText("Title");
|
||||
await titleInput.fill("A completely new title");
|
||||
const bodyInput = screen.getByLabelText("Body");
|
||||
await bodyInput.fill("More typing in another field");
|
||||
|
||||
// The editor itself re-rendered — the form is dirty and Save is live…
|
||||
await expect.element(screen.getByRole("button", { name: "Save" }).first()).toBeEnabled();
|
||||
|
||||
// …but no panel prop changed identity, so the memo held.
|
||||
expect(panelRenderCount).toBe(baseline);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,615 @@
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { ContentList } from "../../src/components/ContentList";
|
||||
import type { ContentItem, TrashedContentItem } from "../../src/lib/api";
|
||||
import { render } from "../utils/render.tsx";
|
||||
|
||||
const NO_RESULTS_PATTERN = /No results for/;
|
||||
const HAS_MORE_ITEMS_PATTERN = /21\+ items/;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const NO_POSTS_YET_REGEX = /No posts yet/;
|
||||
const MOVE_TO_TRASH_CONFIRMATION_REGEX = /Move "Post" to trash/;
|
||||
const PERMANENT_DELETE_CONFIRMATION_REGEX = /Permanently delete "Old Post"/;
|
||||
|
||||
vi.mock("@tanstack/react-router", async () => {
|
||||
const actual = await vi.importActual("@tanstack/react-router");
|
||||
return {
|
||||
...actual,
|
||||
Link: ({
|
||||
children,
|
||||
to,
|
||||
params: _params,
|
||||
...props
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
to?: string;
|
||||
params?: Record<string, string>;
|
||||
[key: string]: unknown;
|
||||
}) => (
|
||||
<a href={typeof to === "string" ? to : "#"} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
function makeItem(overrides: Partial<ContentItem> = {}): ContentItem {
|
||||
return {
|
||||
id: "item_01",
|
||||
type: "posts",
|
||||
slug: "hello-world",
|
||||
status: "draft",
|
||||
data: { title: "Hello World" },
|
||||
authorId: "user_01",
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
updatedAt: "2025-01-02T00:00:00Z",
|
||||
publishedAt: null,
|
||||
scheduledAt: null,
|
||||
liveRevisionId: null,
|
||||
draftRevisionId: "rev_01",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeTrashedItem(overrides: Partial<TrashedContentItem> = {}): TrashedContentItem {
|
||||
return {
|
||||
...makeItem(),
|
||||
deletedAt: "2025-01-03T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
collection: "posts",
|
||||
collectionLabel: "Posts",
|
||||
items: [] as ContentItem[],
|
||||
};
|
||||
|
||||
describe("ContentList", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("rendering items", () => {
|
||||
it("renders items in table with data.title", async () => {
|
||||
const items = [makeItem({ id: "1", data: { title: "My Post" } })];
|
||||
const screen = await render(<ContentList {...defaultProps} items={items} />);
|
||||
await expect.element(screen.getByText("My Post")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to data.name when title is missing", async () => {
|
||||
const items = [makeItem({ id: "1", data: { name: "Named Item" } })];
|
||||
const screen = await render(<ContentList {...defaultProps} items={items} />);
|
||||
await expect.element(screen.getByText("Named Item")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to slug when title and name are missing", async () => {
|
||||
const items = [makeItem({ id: "1", slug: "my-slug", data: {} })];
|
||||
const screen = await render(<ContentList {...defaultProps} items={items} />);
|
||||
await expect.element(screen.getByText("my-slug")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to id when title, name, and slug are missing", async () => {
|
||||
const items = [makeItem({ id: "item_xyz", slug: null, data: {} })];
|
||||
const screen = await render(<ContentList {...defaultProps} items={items} />);
|
||||
await expect.element(screen.getByText("item_xyz")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders multiple items", async () => {
|
||||
const items = [
|
||||
makeItem({ id: "1", data: { title: "First" } }),
|
||||
makeItem({ id: "2", data: { title: "Second" } }),
|
||||
makeItem({ id: "3", data: { title: "Third" } }),
|
||||
];
|
||||
const screen = await render(<ContentList {...defaultProps} items={items} />);
|
||||
await expect.element(screen.getByText("First")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Second")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Third")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("empty states", () => {
|
||||
it("shows empty message for All tab", async () => {
|
||||
const screen = await render(<ContentList {...defaultProps} items={[]} />);
|
||||
await expect.element(screen.getByText(NO_POSTS_YET_REGEX)).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Create your first one")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows empty trash message in Trash tab", async () => {
|
||||
const screen = await render(<ContentList {...defaultProps} items={[]} trashedItems={[]} />);
|
||||
// Switch to Trash tab
|
||||
await screen.getByText("Trash").click();
|
||||
await expect.element(screen.getByText("Trash is empty")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("tab switching", () => {
|
||||
it("defaults to All tab", async () => {
|
||||
const items = [makeItem()];
|
||||
const screen = await render(<ContentList {...defaultProps} items={items} />);
|
||||
// Items should be visible (All tab active)
|
||||
await expect.element(screen.getByText("Hello World")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches to Trash tab", async () => {
|
||||
const trashed = [
|
||||
makeTrashedItem({
|
||||
id: "t1",
|
||||
data: { title: "Deleted Post" },
|
||||
}),
|
||||
];
|
||||
const screen = await render(
|
||||
<ContentList {...defaultProps} items={[makeItem()]} trashedItems={trashed} />,
|
||||
);
|
||||
await screen.getByText("Trash").click();
|
||||
await expect.element(screen.getByText("Deleted Post")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows trash count badge when items are trashed", async () => {
|
||||
const screen = await render(
|
||||
<ContentList {...defaultProps} items={[]} trashedItems={[]} trashedCount={42} />,
|
||||
);
|
||||
await expect.element(screen.getByText("42")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("status badges", () => {
|
||||
it("shows draft status", async () => {
|
||||
const items = [makeItem({ id: "1", status: "draft" })];
|
||||
const screen = await render(<ContentList {...defaultProps} items={items} />);
|
||||
await expect.element(screen.getByText("draft")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows published status", async () => {
|
||||
const items = [makeItem({ id: "1", status: "published" })];
|
||||
const screen = await render(<ContentList {...defaultProps} items={items} />);
|
||||
await expect.element(screen.getByText("published")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows pending badge when draftRevisionId differs from liveRevisionId", async () => {
|
||||
const items = [
|
||||
makeItem({
|
||||
id: "1",
|
||||
status: "published",
|
||||
draftRevisionId: "rev_draft",
|
||||
liveRevisionId: "rev_live",
|
||||
}),
|
||||
];
|
||||
const screen = await render(<ContentList {...defaultProps} items={items} />);
|
||||
await expect.element(screen.getByText("pending")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show pending badge when revisions match", async () => {
|
||||
const items = [
|
||||
makeItem({
|
||||
id: "1",
|
||||
status: "published",
|
||||
draftRevisionId: "rev_same",
|
||||
liveRevisionId: "rev_same",
|
||||
}),
|
||||
];
|
||||
const screen = await render(<ContentList {...defaultProps} items={items} />);
|
||||
expect(screen.getByText("pending").query()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete confirmation", () => {
|
||||
it("shows delete confirmation dialog with item title", async () => {
|
||||
const onDelete = vi.fn();
|
||||
const items = [makeItem({ id: "item_1", data: { title: "Post" } })];
|
||||
const screen = await render(
|
||||
<ContentList {...defaultProps} items={items} onDelete={onDelete} />,
|
||||
);
|
||||
|
||||
// Click trash icon button to open the confirmation dialog
|
||||
await screen.getByRole("button", { name: "Move Post to trash" }).click();
|
||||
|
||||
// Dialog should appear with confirmation text
|
||||
await expect.element(screen.getByText("Move to Trash?")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText(MOVE_TO_TRASH_CONFIRMATION_REGEX)).toBeInTheDocument();
|
||||
// Confirm and Cancel buttons should be visible
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: "Move to Trash" }))
|
||||
.toBeInTheDocument();
|
||||
await expect.element(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("permanent delete", () => {
|
||||
it("shows permanent delete dialog with item title", async () => {
|
||||
const onPermanentDelete = vi.fn();
|
||||
const trashed = [
|
||||
makeTrashedItem({
|
||||
id: "t1",
|
||||
data: { title: "Old Post" },
|
||||
}),
|
||||
];
|
||||
const screen = await render(
|
||||
<ContentList
|
||||
{...defaultProps}
|
||||
items={[]}
|
||||
trashedItems={trashed}
|
||||
onPermanentDelete={onPermanentDelete}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Switch to trash tab
|
||||
await screen.getByText("Trash").click();
|
||||
|
||||
// Click permanent delete trigger button
|
||||
await screen.getByRole("button", { name: "Permanently delete Old Post" }).click();
|
||||
|
||||
// Dialog should appear with correct text
|
||||
await expect.element(screen.getByText("Delete Permanently?")).toBeInTheDocument();
|
||||
await expect
|
||||
.element(screen.getByText(PERMANENT_DELETE_CONFIRMATION_REGEX))
|
||||
.toBeInTheDocument();
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: "Delete Permanently" }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("restore", () => {
|
||||
it("calls onRestore when restore button is clicked", async () => {
|
||||
const onRestore = vi.fn();
|
||||
const trashed = [
|
||||
makeTrashedItem({
|
||||
id: "t1",
|
||||
data: { title: "Restorable" },
|
||||
}),
|
||||
];
|
||||
const screen = await render(
|
||||
<ContentList {...defaultProps} items={[]} trashedItems={trashed} onRestore={onRestore} />,
|
||||
);
|
||||
|
||||
await screen.getByText("Trash").click();
|
||||
await screen.getByRole("button", { name: "Restore Restorable" }).click();
|
||||
|
||||
expect(onRestore).toHaveBeenCalledWith("t1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("duplicate", () => {
|
||||
it("calls onDuplicate when duplicate button is clicked", async () => {
|
||||
const onDuplicate = vi.fn();
|
||||
const items = [makeItem({ id: "item_1", data: { title: "Copyable" } })];
|
||||
const screen = await render(
|
||||
<ContentList {...defaultProps} items={items} onDuplicate={onDuplicate} />,
|
||||
);
|
||||
|
||||
await screen.getByRole("button", { name: "Duplicate Copyable" }).click();
|
||||
|
||||
expect(onDuplicate).toHaveBeenCalledWith("item_1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("load more", () => {
|
||||
it("shows Load More button when hasMore is true", async () => {
|
||||
const onLoadMore = vi.fn();
|
||||
const items = [makeItem()];
|
||||
const screen = await render(
|
||||
<ContentList {...defaultProps} items={items} hasMore={true} onLoadMore={onLoadMore} />,
|
||||
);
|
||||
await expect.element(screen.getByRole("button", { name: "Load More" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show Load More when hasMore is false", async () => {
|
||||
const items = [makeItem()];
|
||||
const screen = await render(<ContentList {...defaultProps} items={items} hasMore={false} />);
|
||||
expect(screen.getByRole("button", { name: "Load More" }).query()).toBeNull();
|
||||
});
|
||||
|
||||
it("auto-fetches when user navigates to the last client-side page", async () => {
|
||||
const onLoadMore = vi.fn();
|
||||
// 21 items = 2 pages of 20; user starts on page 0 (not the last page)
|
||||
const items = Array.from({ length: 21 }, (_, i) => makeItem({ id: `item_${i}` }));
|
||||
const screen = await render(
|
||||
<ContentList {...defaultProps} items={items} hasMore={true} onLoadMore={onLoadMore} />,
|
||||
);
|
||||
|
||||
// On mount, page 0 is not the last page — no fetch yet
|
||||
expect(onLoadMore).not.toHaveBeenCalled();
|
||||
|
||||
// Navigate to page 2 (the last page)
|
||||
await screen.getByRole("button", { name: "Next page" }).click();
|
||||
|
||||
expect(onLoadMore).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not auto-fetch when a search query is active", async () => {
|
||||
const onLoadMore = vi.fn();
|
||||
// 21 items so pagination exists, but search will collapse to 1 result / 1 page
|
||||
const items = [
|
||||
...Array.from({ length: 20 }, (_, i) =>
|
||||
makeItem({ id: `item_${i}`, data: { title: `Post ${i}` } }),
|
||||
),
|
||||
makeItem({ id: "unique", data: { title: "Unique Title" } }),
|
||||
];
|
||||
const screen = await render(
|
||||
<ContentList {...defaultProps} items={items} hasMore={true} onLoadMore={onLoadMore} />,
|
||||
);
|
||||
|
||||
// No fetch on mount (page 0 is not the last page with 21 items)
|
||||
expect(onLoadMore).not.toHaveBeenCalled();
|
||||
|
||||
// Search collapses results to 1 item — totalPages becomes 1, but should NOT fetch
|
||||
await screen.getByRole("searchbox").fill("Unique Title");
|
||||
|
||||
expect(onLoadMore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows '+' suffix on item count when hasMore is true and no search is active", async () => {
|
||||
const items = Array.from({ length: 21 }, (_, i) => makeItem({ id: `item_${i}` }));
|
||||
const screen = await render(<ContentList {...defaultProps} items={items} hasMore={true} />);
|
||||
|
||||
await expect.element(screen.getByText(HAS_MORE_ITEMS_PATTERN)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onLoadMore when Load More is clicked", async () => {
|
||||
const onLoadMore = vi.fn();
|
||||
const items = [makeItem()];
|
||||
const screen = await render(
|
||||
<ContentList {...defaultProps} items={items} hasMore={true} onLoadMore={onLoadMore} />,
|
||||
);
|
||||
|
||||
// With 1 item and hasMore=true, the auto-fetch effect fires on mount
|
||||
// because page 0 is already the last client-side page.
|
||||
// The button click adds a second call on top of that.
|
||||
expect(onLoadMore).toHaveBeenCalledOnce();
|
||||
|
||||
await screen.getByRole("button", { name: "Load More" }).click();
|
||||
|
||||
expect(onLoadMore).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("header", () => {
|
||||
it("shows collection label as heading", async () => {
|
||||
const screen = await render(<ContentList {...defaultProps} collectionLabel="Articles" />);
|
||||
await expect.element(screen.getByRole("heading", { name: "Articles" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Add New link", async () => {
|
||||
const screen = await render(<ContentList {...defaultProps} />);
|
||||
await expect.element(screen.getByText("Add New")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("search", () => {
|
||||
it("shows search input when items exist", async () => {
|
||||
const items = [makeItem({ id: "1", data: { title: "Post" } })];
|
||||
const screen = await render(<ContentList {...defaultProps} items={items} />);
|
||||
await expect
|
||||
.element(screen.getByRole("searchbox", { name: "Search posts" }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides search input when no items", async () => {
|
||||
const screen = await render(<ContentList {...defaultProps} items={[]} />);
|
||||
expect(screen.getByRole("searchbox").query()).toBeNull();
|
||||
});
|
||||
|
||||
it("filters items by title", async () => {
|
||||
const items = [
|
||||
makeItem({ id: "1", data: { title: "Alpha post" } }),
|
||||
makeItem({ id: "2", data: { title: "Beta post" } }),
|
||||
makeItem({ id: "3", data: { title: "Gamma post" } }),
|
||||
];
|
||||
const screen = await render(<ContentList {...defaultProps} items={items} />);
|
||||
|
||||
await screen.getByRole("searchbox").fill("beta");
|
||||
|
||||
await expect.element(screen.getByText("Beta post")).toBeInTheDocument();
|
||||
expect(screen.getByText("Alpha post").query()).toBeNull();
|
||||
expect(screen.getByText("Gamma post").query()).toBeNull();
|
||||
});
|
||||
|
||||
it("shows no results message when search has no matches", async () => {
|
||||
const items = [makeItem({ id: "1", data: { title: "Hello" } })];
|
||||
const screen = await render(<ContentList {...defaultProps} items={items} />);
|
||||
|
||||
await screen.getByRole("searchbox").fill("zzzzz");
|
||||
|
||||
await expect.element(screen.getByText(NO_RESULTS_PATTERN)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// #1219: when the caller opts into server-side search it reports the
|
||||
// (debounced) query and must NOT also filter the loaded page client-side
|
||||
// (the server already returned the matching rows).
|
||||
it("reports the query upward and does not client-filter in server mode", async () => {
|
||||
const onSearchChange = vi.fn();
|
||||
const items = [
|
||||
makeItem({ id: "1", data: { title: "Alpha post" } }),
|
||||
makeItem({ id: "2", data: { title: "Beta post" } }),
|
||||
];
|
||||
const screen = await render(
|
||||
<ContentList {...defaultProps} items={items} onSearchChange={onSearchChange} />,
|
||||
);
|
||||
|
||||
await screen.getByRole("searchbox").fill("beta");
|
||||
|
||||
// Debounced callback fires with the typed term.
|
||||
await vi.waitFor(() => {
|
||||
expect(onSearchChange).toHaveBeenCalledWith("beta");
|
||||
});
|
||||
|
||||
// Server mode shows whatever `items` the caller supplied — it does not
|
||||
// hide "Alpha post" by filtering locally.
|
||||
await expect.element(screen.getByText("Alpha post")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Beta post")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// #1219: in server mode a zero-match query empties `items`. The search box
|
||||
// must stay mounted so the user can clear the query.
|
||||
it("keeps the search input mounted in server mode when there are no items", async () => {
|
||||
const onSearchChange = vi.fn();
|
||||
const screen = await render(
|
||||
<ContentList {...defaultProps} items={[]} onSearchChange={onSearchChange} />,
|
||||
);
|
||||
await expect
|
||||
.element(screen.getByRole("searchbox", { name: "Search posts" }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// #1219: a zero-match server search must not show "Create your first one"
|
||||
// (there is content, it just doesn't match), it must report the query.
|
||||
it("shows a no-results message, not the empty state, for a zero-match server search", async () => {
|
||||
const onSearchChange = vi.fn();
|
||||
const screen = await render(
|
||||
<ContentList {...defaultProps} items={[]} total={0} onSearchChange={onSearchChange} />,
|
||||
);
|
||||
|
||||
await screen.getByRole("searchbox").fill("zzzzz");
|
||||
|
||||
await expect.element(screen.getByText(NO_RESULTS_PATTERN)).toBeInTheDocument();
|
||||
expect(screen.getByText("Create your first one").query()).toBeNull();
|
||||
});
|
||||
|
||||
// #1219: the match count must come from the server `total`, not the loaded
|
||||
// page length, otherwise it undercounts when matches span multiple pages.
|
||||
it("counts server-search matches using total, not the loaded page", async () => {
|
||||
const onSearchChange = vi.fn();
|
||||
const items = Array.from({ length: 20 }, (_, i) =>
|
||||
makeItem({ id: `item_${i}`, data: { title: `Post ${i}` } }),
|
||||
);
|
||||
const screen = await render(
|
||||
<ContentList
|
||||
{...defaultProps}
|
||||
items={items}
|
||||
total={143}
|
||||
hasMore={true}
|
||||
onSearchChange={onSearchChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.getByRole("searchbox").fill("post");
|
||||
|
||||
await expect.element(screen.getByText(/143 items matching "post"/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("pagination", () => {
|
||||
it("shows pagination when items exceed page size", async () => {
|
||||
const items = Array.from({ length: 25 }, (_, i) =>
|
||||
makeItem({ id: `item_${i}`, data: { title: `Post ${i}` } }),
|
||||
);
|
||||
const screen = await render(<ContentList {...defaultProps} items={items} />);
|
||||
|
||||
await expect.element(screen.getByText("1 / 2")).toBeInTheDocument();
|
||||
await expect.element(screen.getByRole("button", { name: "Next page" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show pagination when items fit on one page", async () => {
|
||||
const items = [makeItem({ id: "1", data: { title: "Post" } })];
|
||||
const screen = await render(<ContentList {...defaultProps} items={items} />);
|
||||
expect(screen.getByRole("button", { name: "Next page" }).query()).toBeNull();
|
||||
});
|
||||
|
||||
it("navigates between pages", async () => {
|
||||
const items = Array.from({ length: 25 }, (_, i) =>
|
||||
makeItem({ id: `item_${i}`, data: { title: `Post ${i}` } }),
|
||||
);
|
||||
const screen = await render(<ContentList {...defaultProps} items={items} />);
|
||||
|
||||
// Page 1 should show Post 0
|
||||
await expect.element(screen.getByText("Post 0")).toBeInTheDocument();
|
||||
|
||||
// Go to page 2
|
||||
await screen.getByRole("button", { name: "Next page" }).click();
|
||||
|
||||
await expect.element(screen.getByText("2 / 2")).toBeInTheDocument();
|
||||
// Post 20 should be on page 2
|
||||
await expect.element(screen.getByText("Post 20")).toBeInTheDocument();
|
||||
// Post 0 should not be visible
|
||||
expect(screen.getByText("Post 0").query()).toBeNull();
|
||||
});
|
||||
|
||||
// Regression: before this change `totalPages` was derived only from
|
||||
// loaded items, so the denominator grew in increments of 5 (API
|
||||
// fetches 100, page size 20 → 5 client pages per fetch). When the
|
||||
// parent supplies an authoritative `total`, the denominator must
|
||||
// reflect it from the first render.
|
||||
it("uses `total` as a stable denominator instead of items.length", async () => {
|
||||
// Only the first 20 items have been loaded, but the server knows
|
||||
// there are 143 total.
|
||||
const items = Array.from({ length: 20 }, (_, i) =>
|
||||
makeItem({ id: `item_${i}`, data: { title: `Post ${i}` } }),
|
||||
);
|
||||
const screen = await render(
|
||||
<ContentList {...defaultProps} items={items} total={143} hasMore={true} />,
|
||||
);
|
||||
|
||||
// 143 / 20 = 8 pages. The denominator should read 8, not "/5".
|
||||
await expect.element(screen.getByText("1 / 8")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortable headers", () => {
|
||||
it("calls onSortChange when a header is clicked", async () => {
|
||||
const onSortChange = vi.fn();
|
||||
const items = [makeItem({ id: "1", data: { title: "Post" } })];
|
||||
const screen = await render(
|
||||
<ContentList
|
||||
{...defaultProps}
|
||||
items={items}
|
||||
sort={{ field: "updatedAt", direction: "desc" }}
|
||||
onSortChange={onSortChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.getByRole("button", { name: "Title" }).click();
|
||||
|
||||
expect(onSortChange).toHaveBeenCalledWith({ field: "title", direction: "desc" });
|
||||
});
|
||||
|
||||
it("toggles direction when clicking the active column", async () => {
|
||||
const onSortChange = vi.fn();
|
||||
const items = [makeItem({ id: "1", data: { title: "Post" } })];
|
||||
const screen = await render(
|
||||
<ContentList
|
||||
{...defaultProps}
|
||||
items={items}
|
||||
sort={{ field: "title", direction: "desc" }}
|
||||
onSortChange={onSortChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.getByRole("button", { name: "Title" }).click();
|
||||
|
||||
expect(onSortChange).toHaveBeenCalledWith({ field: "title", direction: "asc" });
|
||||
});
|
||||
|
||||
it("exposes sort state via aria-sort on the active header", async () => {
|
||||
const items = [makeItem({ id: "1", data: { title: "Post" } })];
|
||||
const screen = await render(
|
||||
<ContentList
|
||||
{...defaultProps}
|
||||
items={items}
|
||||
sort={{ field: "title", direction: "asc" }}
|
||||
onSortChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const titleHeader = screen.getByRole("columnheader", { name: "Title" });
|
||||
const statusHeader = screen.getByRole("columnheader", { name: "Status" });
|
||||
await expect.element(titleHeader).toHaveAttribute("aria-sort", "ascending");
|
||||
// Inactive columns explicitly advertise "none" so the header still
|
||||
// announces as sortable.
|
||||
await expect.element(statusHeader).toHaveAttribute("aria-sort", "none");
|
||||
});
|
||||
|
||||
it("falls back to static headers when onSortChange is not provided", async () => {
|
||||
const items = [makeItem({ id: "1", data: { title: "Post" } })];
|
||||
const screen = await render(<ContentList {...defaultProps} items={items} />);
|
||||
|
||||
// The header must not render as a button — it's just a label.
|
||||
expect(screen.getByRole("button", { name: "Title" }).query()).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import type { ContentEditorProps } from "../../src/components/ContentEditor";
|
||||
import {
|
||||
ContentSettingsPanel,
|
||||
SettingsActionBar,
|
||||
type ContentSettingsPanelProps,
|
||||
type SettingsActionBarProps,
|
||||
} from "../../src/components/ContentSettingsPanel";
|
||||
import type { BlockSidebarPanel } from "../../src/components/PortableTextEditor";
|
||||
import type { ContentItem } from "../../src/lib/api";
|
||||
import { render } from "../utils/render.tsx";
|
||||
|
||||
// Mock child components with their own data fetching so the panel tests
|
||||
// only assert section visibility, not child behaviour.
|
||||
vi.mock("../../src/components/RevisionHistory", () => ({
|
||||
RevisionHistory: () => <div data-testid="revision-history">Revision History</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../../src/components/TaxonomySidebar", () => ({
|
||||
TaxonomySidebar: () => <div data-testid="taxonomy-sidebar">Taxonomy</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../../src/components/editor/DocumentOutline", () => ({
|
||||
DocumentOutline: () => <div data-testid="doc-outline">Outline</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../../src/components/editor/ImageDetailPanel", () => ({
|
||||
ImageDetailPanel: () => <div data-testid="image-detail-panel">Image details</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../../src/components/SeoPanel", () => ({
|
||||
SeoPanel: () => <div data-testid="seo-panel">SEO fields</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-router", async () => {
|
||||
const actual = await vi.importActual("@tanstack/react-router");
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => vi.fn(),
|
||||
Link: ({ children, ...props }: any) => <a {...props}>{children}</a>,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../src/lib/api", async () => {
|
||||
const actual = await vi.importActual("../../src/lib/api");
|
||||
return {
|
||||
...actual,
|
||||
fetchBylines: vi.fn(async () => ({ items: [], nextCursor: null })),
|
||||
};
|
||||
});
|
||||
|
||||
function makeItem(overrides: Partial<ContentItem> = {}): ContentItem {
|
||||
return {
|
||||
id: "item-1",
|
||||
type: "posts",
|
||||
slug: "my-post",
|
||||
status: "draft",
|
||||
data: { title: "My Post" },
|
||||
authorId: null,
|
||||
createdAt: "2025-01-15T10:30:00Z",
|
||||
updatedAt: "2025-01-15T10:30:00Z",
|
||||
publishedAt: null,
|
||||
scheduledAt: null,
|
||||
liveRevisionId: null,
|
||||
draftRevisionId: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const EDITOR_ROLE: NonNullable<ContentEditorProps["currentUser"]> = { id: "u1", role: 40 };
|
||||
const AUTHOR_ROLE: NonNullable<ContentEditorProps["currentUser"]> = { id: "u2", role: 20 };
|
||||
const USERS = [
|
||||
{ id: "u1", name: "Editor One", email: "editor@example.com", role: 40 },
|
||||
] as ContentSettingsPanelProps["users"];
|
||||
|
||||
function makePanelProps(
|
||||
overrides: Partial<ContentSettingsPanelProps> = {},
|
||||
): ContentSettingsPanelProps {
|
||||
return {
|
||||
collection: "posts",
|
||||
item: makeItem(),
|
||||
isNew: false,
|
||||
slug: "my-post",
|
||||
onSlugChange: vi.fn(),
|
||||
status: "draft",
|
||||
supportsDrafts: true,
|
||||
isLive: false,
|
||||
hasPendingChanges: false,
|
||||
hasSchedule: false,
|
||||
supportsRevisions: true,
|
||||
canSchedule: false,
|
||||
onDelete: vi.fn(),
|
||||
currentUser: EDITOR_ROLE,
|
||||
users: USERS,
|
||||
onAuthorChange: vi.fn(),
|
||||
activeBylines: [],
|
||||
availableBylines: [],
|
||||
availableBylinesLoaded: true,
|
||||
onBylinesChange: vi.fn(),
|
||||
i18n: { defaultLocale: "en", locales: ["en", "ar"] },
|
||||
translations: [],
|
||||
onTranslate: vi.fn(),
|
||||
hasSeo: true,
|
||||
onSeoChange: vi.fn(),
|
||||
portableTextEditor: {} as Editor,
|
||||
blockSidebarPanel: null,
|
||||
onBlockSidebarClose: vi.fn(),
|
||||
onBlockSidebarDelete: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ContentSettingsPanel", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders all eight sections when every capability is enabled", async () => {
|
||||
const screen = await render(<ContentSettingsPanel {...makePanelProps()} />);
|
||||
|
||||
await expect.element(screen.getByRole("heading", { name: "Publish" })).toBeInTheDocument();
|
||||
await expect.element(screen.getByRole("heading", { name: "Ownership" })).toBeInTheDocument();
|
||||
await expect.element(screen.getByRole("heading", { name: "Bylines" })).toBeInTheDocument();
|
||||
await expect.element(screen.getByRole("heading", { name: "Translations" })).toBeInTheDocument();
|
||||
await expect.element(screen.getByTestId("taxonomy-sidebar")).toBeInTheDocument();
|
||||
await expect.element(screen.getByRole("heading", { name: "SEO" })).toBeInTheDocument();
|
||||
await expect.element(screen.getByTestId("doc-outline")).toBeInTheDocument();
|
||||
await expect.element(screen.getByTestId("revision-history")).toBeInTheDocument();
|
||||
await expect.element(screen.getByRole("button", { name: "Move to Trash" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides Ownership and Bylines for users below the editor role", async () => {
|
||||
const screen = await render(
|
||||
<ContentSettingsPanel {...makePanelProps({ currentUser: AUTHOR_ROLE })} />,
|
||||
);
|
||||
|
||||
await expect.element(screen.getByRole("heading", { name: "Publish" })).toBeInTheDocument();
|
||||
expect(screen.container.textContent).not.toContain("Ownership");
|
||||
expect(screen.container.textContent).not.toContain("Bylines");
|
||||
});
|
||||
|
||||
it("hides capability-gated sections when their flags are off", async () => {
|
||||
const screen = await render(
|
||||
<ContentSettingsPanel
|
||||
{...makePanelProps({
|
||||
hasSeo: false,
|
||||
supportsRevisions: false,
|
||||
portableTextEditor: null,
|
||||
i18n: undefined,
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
await expect.element(screen.getByRole("heading", { name: "Publish" })).toBeInTheDocument();
|
||||
expect(screen.container.querySelector('[data-testid="seo-panel"]')).toBeNull();
|
||||
expect(screen.container.querySelector('[data-testid="revision-history"]')).toBeNull();
|
||||
expect(screen.container.querySelector('[data-testid="doc-outline"]')).toBeNull();
|
||||
expect(screen.container.textContent).not.toContain("Translations");
|
||||
});
|
||||
|
||||
it("hides item-dependent sections for new entries", async () => {
|
||||
const screen = await render(
|
||||
<ContentSettingsPanel {...makePanelProps({ item: null, isNew: true })} />,
|
||||
);
|
||||
|
||||
await expect.element(screen.getByRole("heading", { name: "Publish" })).toBeInTheDocument();
|
||||
// No trash, no translations, no taxonomies, no SEO, no revisions for new items
|
||||
expect(screen.container.textContent).not.toContain("Move to Trash");
|
||||
expect(screen.container.textContent).not.toContain("Translations");
|
||||
expect(screen.container.querySelector('[data-testid="taxonomy-sidebar"]')).toBeNull();
|
||||
expect(screen.container.querySelector('[data-testid="seo-panel"]')).toBeNull();
|
||||
expect(screen.container.querySelector('[data-testid="revision-history"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the block detail panel instead of settings when a block requests the sidebar", async () => {
|
||||
const blockPanel: BlockSidebarPanel = {
|
||||
type: "image",
|
||||
attrs: {},
|
||||
onUpdate: vi.fn(),
|
||||
onReplace: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
};
|
||||
const screen = await render(
|
||||
<ContentSettingsPanel {...makePanelProps({ blockSidebarPanel: blockPanel })} />,
|
||||
);
|
||||
|
||||
await expect.element(screen.getByTestId("image-detail-panel")).toBeInTheDocument();
|
||||
expect(screen.container.textContent).not.toContain("Publish");
|
||||
expect(screen.container.textContent).not.toContain("Move to Trash");
|
||||
});
|
||||
|
||||
it("keeps Move to Trash as the last section", async () => {
|
||||
const screen = await render(<ContentSettingsPanel {...makePanelProps()} />);
|
||||
const root = screen.container.firstElementChild;
|
||||
const lastSection = root?.lastElementChild;
|
||||
expect(lastSection?.textContent).toContain("Move to Trash");
|
||||
});
|
||||
});
|
||||
|
||||
function makeBarProps(overrides: Partial<SettingsActionBarProps> = {}): SettingsActionBarProps {
|
||||
return {
|
||||
isNew: false,
|
||||
isDirty: false,
|
||||
isSaving: false,
|
||||
isLive: false,
|
||||
hasPendingChanges: false,
|
||||
liveViewUrl: null,
|
||||
supportsPreview: false,
|
||||
isLoadingPreview: false,
|
||||
onPreview: vi.fn(),
|
||||
onPublish: vi.fn(),
|
||||
onUnpublish: vi.fn(),
|
||||
announceSaveStatus: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("SettingsActionBar", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows Publish for an unpublished draft", async () => {
|
||||
const screen = await render(<SettingsActionBar {...makeBarProps()} />);
|
||||
|
||||
await expect.element(screen.getByRole("button", { name: "Publish" })).toBeInTheDocument();
|
||||
expect(screen.container.textContent).not.toContain("Unpublish");
|
||||
});
|
||||
|
||||
it("shows Publish changes for a live item with edits", async () => {
|
||||
const props = makeBarProps({ isLive: true, hasPendingChanges: true });
|
||||
const screen = await render(<SettingsActionBar {...props} />);
|
||||
|
||||
const publishChanges = screen.getByRole("button", { name: "Publish changes" });
|
||||
await expect.element(publishChanges).toBeInTheDocument();
|
||||
|
||||
await publishChanges.click();
|
||||
expect(props.onPublish).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows Unpublish for a clean live item", async () => {
|
||||
const props = makeBarProps({ isLive: true });
|
||||
const screen = await render(<SettingsActionBar {...props} />);
|
||||
|
||||
const unpublish = screen.getByRole("button", { name: "Unpublish" });
|
||||
await expect.element(unpublish).toBeInTheDocument();
|
||||
|
||||
await unpublish.click();
|
||||
expect(props.onUnpublish).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders Live View when a live URL is provided", async () => {
|
||||
const screen = await render(
|
||||
<SettingsActionBar {...makeBarProps({ liveViewUrl: "https://example.com/my-post" })} />,
|
||||
);
|
||||
const link = screen.getByRole("link", { name: /Live View/ });
|
||||
await expect.element(link).toBeInTheDocument();
|
||||
await expect.element(link).toHaveAttribute("href", "https://example.com/my-post");
|
||||
});
|
||||
|
||||
it("renders Preview when preview is supported", async () => {
|
||||
const props = makeBarProps({ supportsPreview: true, hasPendingChanges: true });
|
||||
const screen = await render(<SettingsActionBar {...props} />);
|
||||
|
||||
const preview = screen.getByRole("button", { name: "Preview draft" });
|
||||
await expect.element(preview).toBeInTheDocument();
|
||||
|
||||
await preview.click();
|
||||
expect(props.onPreview).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hides the publish cluster for new items", async () => {
|
||||
const screen = await render(<SettingsActionBar {...makeBarProps({ isNew: true })} />);
|
||||
|
||||
expect(screen.container.textContent).not.toContain("Publish");
|
||||
expect(screen.container.textContent).not.toContain("Unpublish");
|
||||
// Save is still available
|
||||
await expect.element(screen.getByRole("button", { name: /Save/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows autosave progress in the Save button", async () => {
|
||||
const screen = await render(
|
||||
<SettingsActionBar {...makeBarProps({ isDirty: true, isAutosaving: true })} />,
|
||||
);
|
||||
await expect.element(screen.getByRole("button", { name: "Saving..." })).toBeDisabled();
|
||||
expect(screen.getByRole("status").element().textContent).toBe("Saving...");
|
||||
});
|
||||
|
||||
it("can suppress its live region when another mounted copy announces status", async () => {
|
||||
const screen = await render(
|
||||
<SettingsActionBar {...makeBarProps({ announceSaveStatus: false })} />,
|
||||
);
|
||||
expect(screen.container.querySelector('span[role="status"][aria-live="polite"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("marks the Save button dirty state", async () => {
|
||||
const screen = await render(<SettingsActionBar {...makeBarProps({ isDirty: true })} />);
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: "Save", exact: true }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,576 @@
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import {
|
||||
ContentTypeEditor,
|
||||
type ContentTypeEditorProps,
|
||||
} from "../../src/components/ContentTypeEditor";
|
||||
import type { SchemaCollectionWithFields, SchemaField } from "../../src/lib/api";
|
||||
import { render } from "../utils/render";
|
||||
|
||||
// Regexes hoisted to module scope to avoid recompilation per call
|
||||
const EDIT_TITLE_RE = /Edit Title field/i;
|
||||
const EDIT_BODY_RE = /Edit Body field/i;
|
||||
const URL_PATTERN_SLUG_RE = /must include.*\{slug\}/i;
|
||||
|
||||
// Mock tanstack router — Link renders as <a>, useNavigate is a no-op
|
||||
vi.mock("@tanstack/react-router", async () => {
|
||||
const actual = await vi.importActual("@tanstack/react-router");
|
||||
return {
|
||||
...actual,
|
||||
Link: ({ children, ...props }: any) => <a {...props}>{children}</a>,
|
||||
useNavigate: () => vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock FieldEditor — just expose open state via data attribute
|
||||
vi.mock("../../src/components/FieldEditor", () => ({
|
||||
FieldEditor: ({ open }: { open: boolean }) =>
|
||||
open ? <div data-testid="field-editor-dialog">Field Editor</div> : null,
|
||||
}));
|
||||
|
||||
const DELETE_FIELD_BUTTON_PATTERN = /Delete Title field/i;
|
||||
|
||||
function makeField(overrides: Partial<SchemaField> = {}): SchemaField {
|
||||
return {
|
||||
id: "field-1",
|
||||
collectionId: "col-1",
|
||||
slug: "title",
|
||||
label: "Title",
|
||||
type: "string",
|
||||
columnType: "TEXT",
|
||||
required: false,
|
||||
unique: false,
|
||||
searchable: false,
|
||||
sortOrder: 0,
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeCollection(
|
||||
overrides: Partial<SchemaCollectionWithFields> = {},
|
||||
): SchemaCollectionWithFields {
|
||||
return {
|
||||
id: "col-1",
|
||||
slug: "posts",
|
||||
label: "Posts",
|
||||
labelSingular: "Post",
|
||||
description: "Blog posts",
|
||||
supports: ["drafts"],
|
||||
fields: [],
|
||||
hasSeo: false,
|
||||
commentsEnabled: false,
|
||||
commentsModeration: "first_time",
|
||||
commentsClosedAfterDays: 90,
|
||||
commentsAutoApproveUsers: true,
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
updatedAt: "2025-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
function defaultProps(overrides: Partial<ContentTypeEditorProps> = {}): ContentTypeEditorProps {
|
||||
return {
|
||||
onSave: noop,
|
||||
onAddField: noop,
|
||||
onUpdateField: noop,
|
||||
onDeleteField: noop,
|
||||
onReorderFields: noop,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const DRAFTS_CHECKBOX_REGEX = /Drafts/i;
|
||||
const REVISIONS_CHECKBOX_REGEX = /Revisions/i;
|
||||
const CREATE_CONTENT_TYPE_BUTTON_REGEX = /Create Content Type/i;
|
||||
const EDIT_FIELD_BUTTON_REGEX = /Edit .* field/i;
|
||||
const DELETE_FIELD_BUTTON_REGEX = /Delete .* field/i;
|
||||
const ADD_FIELD_BUTTON_REGEX = /Add Field/i;
|
||||
const CODE_DEFINED_MSG_REGEX = /This collection is defined in code/i;
|
||||
const SYSTEM_FIELDS_REGEX = /6 system \+ 2 custom fields/;
|
||||
|
||||
describe("ContentTypeEditor", () => {
|
||||
// ---- Title for new vs edit mode ----
|
||||
|
||||
it("shows 'New Content Type' title when isNew", async () => {
|
||||
const screen = await render(<ContentTypeEditor {...defaultProps()} isNew />);
|
||||
await expect.element(screen.getByText("New Content Type")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows collection label as title when editing", async () => {
|
||||
const collection = makeCollection({ label: "Articles" });
|
||||
const screen = await render(<ContentTypeEditor {...defaultProps()} collection={collection} />);
|
||||
await expect.element(screen.getByText("Articles")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ---- Auto-slug from label when isNew ----
|
||||
|
||||
it("auto-generates slug from label when isNew", async () => {
|
||||
const screen = await render(<ContentTypeEditor {...defaultProps()} isNew />);
|
||||
|
||||
const labelInput = screen.getByLabelText("Label (Plural)");
|
||||
await labelInput.fill("Blog Posts");
|
||||
|
||||
// The slug input should auto-populate from the label
|
||||
const slugInput = screen.getByLabelText("Slug");
|
||||
await expect.element(slugInput).toHaveValue("blog_posts");
|
||||
});
|
||||
|
||||
// ---- Slug disabled when editing ----
|
||||
|
||||
it("does not show slug input when editing existing collection", async () => {
|
||||
const collection = makeCollection();
|
||||
const screen = await render(<ContentTypeEditor {...defaultProps()} collection={collection} />);
|
||||
|
||||
// Slug input is only rendered when isNew, so it shouldn't exist
|
||||
const slugInput = screen.getByLabelText("Slug");
|
||||
await expect.element(slugInput).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ---- Supports checkboxes toggle correctly ----
|
||||
|
||||
it("toggles support checkboxes", async () => {
|
||||
const collection = makeCollection({ supports: ["drafts"] });
|
||||
const screen = await render(<ContentTypeEditor {...defaultProps()} collection={collection} />);
|
||||
|
||||
// "Drafts" should be checked initially
|
||||
const draftsCheckbox = screen.getByRole("checkbox", { name: DRAFTS_CHECKBOX_REGEX });
|
||||
await expect.element(draftsCheckbox).toBeChecked();
|
||||
|
||||
// "Revisions" should not be checked
|
||||
const revisionsCheckbox = screen.getByRole("checkbox", { name: REVISIONS_CHECKBOX_REGEX });
|
||||
await expect.element(revisionsCheckbox).not.toBeChecked();
|
||||
|
||||
// Toggle revisions on
|
||||
await revisionsCheckbox.click();
|
||||
await expect.element(revisionsCheckbox).toBeChecked();
|
||||
|
||||
// Toggle drafts off
|
||||
await draftsCheckbox.click();
|
||||
await expect.element(draftsCheckbox).not.toBeChecked();
|
||||
});
|
||||
|
||||
// ---- Save button disabled when no changes ----
|
||||
|
||||
it("save button is disabled when no changes have been made", async () => {
|
||||
const collection = makeCollection();
|
||||
const screen = await render(<ContentTypeEditor {...defaultProps()} collection={collection} />);
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: "Saved", exact: true }).last();
|
||||
await expect.element(saveButton).toBeDisabled();
|
||||
});
|
||||
|
||||
// ---- Save button enabled after changing a field ----
|
||||
|
||||
it("save button is enabled after changing label", async () => {
|
||||
const collection = makeCollection({ label: "Posts" });
|
||||
const screen = await render(<ContentTypeEditor {...defaultProps()} collection={collection} />);
|
||||
|
||||
const labelInput = screen.getByLabelText("Label (Plural)");
|
||||
await labelInput.fill("Articles");
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: "Save", exact: true }).last();
|
||||
await expect.element(saveButton).toBeEnabled();
|
||||
});
|
||||
|
||||
// ---- onSave called with correct input shape ----
|
||||
|
||||
it("calls onSave with correct input when creating new collection", async () => {
|
||||
const onSave = vi.fn();
|
||||
const screen = await render(<ContentTypeEditor {...defaultProps({ onSave })} isNew />);
|
||||
|
||||
await screen.getByLabelText("Label (Plural)").fill("Articles");
|
||||
await screen.getByLabelText("Label (Singular)").fill("Article");
|
||||
|
||||
const createButton = screen.getByRole("button", { name: CREATE_CONTENT_TYPE_BUTTON_REGEX });
|
||||
await createButton.click();
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith({
|
||||
slug: "articles",
|
||||
label: "Articles",
|
||||
labelSingular: "Article",
|
||||
description: undefined,
|
||||
urlPattern: undefined,
|
||||
supports: ["drafts", "revisions"], // default
|
||||
hasSeo: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("calls onSave with correct input when editing existing collection", async () => {
|
||||
const onSave = vi.fn();
|
||||
const collection = makeCollection({ label: "Posts", supports: ["drafts"] });
|
||||
const screen = await render(
|
||||
<ContentTypeEditor {...defaultProps({ onSave })} collection={collection} />,
|
||||
);
|
||||
|
||||
await screen.getByLabelText("Label (Plural)").fill("Articles");
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: "Save", exact: true }).last();
|
||||
await saveButton.click();
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith({
|
||||
label: "Articles",
|
||||
labelSingular: "Post",
|
||||
description: "Blog posts",
|
||||
urlPattern: undefined,
|
||||
supports: ["drafts"],
|
||||
hasSeo: false,
|
||||
commentsEnabled: false,
|
||||
commentsModeration: "first_time",
|
||||
commentsClosedAfterDays: 90,
|
||||
commentsAutoApproveUsers: true,
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Field list displays existing fields with type and badges ----
|
||||
|
||||
it("displays custom fields with type and badges", async () => {
|
||||
const fields: SchemaField[] = [
|
||||
makeField({ slug: "title", label: "Title", type: "string", required: true, unique: true }),
|
||||
makeField({
|
||||
id: "field-2",
|
||||
slug: "body",
|
||||
label: "Body",
|
||||
type: "portableText",
|
||||
searchable: true,
|
||||
}),
|
||||
];
|
||||
const collection = makeCollection({ fields });
|
||||
|
||||
const screen = await render(<ContentTypeEditor {...defaultProps()} collection={collection} />);
|
||||
|
||||
// Verify fields render by checking their edit buttons (unique aria-labels)
|
||||
await expect.element(screen.getByRole("button", { name: EDIT_TITLE_RE })).toBeInTheDocument();
|
||||
await expect.element(screen.getByRole("button", { name: EDIT_BODY_RE })).toBeInTheDocument();
|
||||
|
||||
// Badges — use exact: true to avoid matching system field descriptions like "Unique identifier"
|
||||
await expect.element(screen.getByText("Required", { exact: true })).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Unique", { exact: true })).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Searchable", { exact: true })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ---- System fields always shown ----
|
||||
|
||||
it("shows system fields section", async () => {
|
||||
const collection = makeCollection();
|
||||
const screen = await render(<ContentTypeEditor {...defaultProps()} collection={collection} />);
|
||||
|
||||
await expect.element(screen.getByText("System Fields")).toBeInTheDocument();
|
||||
// System fields show descriptions — use those as unambiguous locators
|
||||
await expect.element(screen.getByText("Unique identifier (ULID)")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("URL-friendly identifier")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("draft, published, or archived")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("When the entry was created")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("When the entry was last modified")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("When the entry was published")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ---- Add field button opens FieldEditor dialog ----
|
||||
|
||||
it("opens FieldEditor dialog when Add Field is clicked", async () => {
|
||||
const collection = makeCollection();
|
||||
const screen = await render(<ContentTypeEditor {...defaultProps()} collection={collection} />);
|
||||
|
||||
// Field editor should not be visible initially
|
||||
const dialog = screen.getByTestId("field-editor-dialog");
|
||||
await expect.element(dialog).not.toBeInTheDocument();
|
||||
|
||||
// Click Add Field
|
||||
const addButton = screen.getByRole("button", { name: ADD_FIELD_BUTTON_REGEX });
|
||||
await addButton.click();
|
||||
|
||||
// Dialog should now be visible
|
||||
await expect.element(screen.getByTestId("field-editor-dialog")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ---- Delete field with confirm dialog calls onDeleteField ----
|
||||
|
||||
it("calls onDeleteField when delete is confirmed via dialog", async () => {
|
||||
const onDeleteField = vi.fn();
|
||||
|
||||
const fields = [makeField({ slug: "title", label: "Title" })];
|
||||
const collection = makeCollection({ fields });
|
||||
|
||||
const screen = await render(
|
||||
<ContentTypeEditor {...defaultProps({ onDeleteField })} collection={collection} />,
|
||||
);
|
||||
|
||||
const deleteButton = screen.getByRole("button", { name: DELETE_FIELD_BUTTON_PATTERN });
|
||||
await deleteButton.click();
|
||||
|
||||
// ConfirmDialog should appear
|
||||
await expect.element(screen.getByText("Delete Field?")).toBeInTheDocument();
|
||||
|
||||
// Direct DOM click to bypass Base UI inert overlay
|
||||
screen.getByRole("button", { name: "Delete" }).element().click();
|
||||
|
||||
expect(onDeleteField).toHaveBeenCalledWith("title");
|
||||
});
|
||||
|
||||
it("does not call onDeleteField when delete dialog is cancelled", async () => {
|
||||
const onDeleteField = vi.fn();
|
||||
|
||||
const fields = [makeField({ slug: "title", label: "Title" })];
|
||||
const collection = makeCollection({ fields });
|
||||
|
||||
const screen = await render(
|
||||
<ContentTypeEditor {...defaultProps({ onDeleteField })} collection={collection} />,
|
||||
);
|
||||
|
||||
const deleteButton = screen.getByRole("button", { name: DELETE_FIELD_BUTTON_REGEX });
|
||||
await deleteButton.click();
|
||||
|
||||
// ConfirmDialog should appear
|
||||
await expect.element(screen.getByText("Delete Field?")).toBeInTheDocument();
|
||||
|
||||
// Direct DOM click to bypass Base UI inert overlay
|
||||
screen.getByRole("button", { name: "Cancel" }).element().click();
|
||||
|
||||
expect(onDeleteField).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ---- Code-source collections show disabled inputs and info banner ----
|
||||
|
||||
it("shows info banner and disables inputs for code-source collections", async () => {
|
||||
const collection = makeCollection({ source: "code" });
|
||||
const screen = await render(<ContentTypeEditor {...defaultProps()} collection={collection} />);
|
||||
|
||||
// Info banner text
|
||||
await expect.element(screen.getByText(CODE_DEFINED_MSG_REGEX)).toBeInTheDocument();
|
||||
|
||||
// Label inputs should be disabled
|
||||
const labelInput = screen.getByLabelText("Label (Plural)");
|
||||
await expect.element(labelInput).toBeDisabled();
|
||||
|
||||
const singularInput = screen.getByLabelText("Label (Singular)");
|
||||
await expect.element(singularInput).toBeDisabled();
|
||||
|
||||
// Description uses InputArea — locate via placeholder
|
||||
const descInput = screen.getByPlaceholder("A brief description of this content type");
|
||||
await expect.element(descInput).toBeDisabled();
|
||||
|
||||
// Save button should not exist for code-source collections
|
||||
const saveButton = screen.getByRole("button", { name: "Save", exact: true }).last();
|
||||
await expect.element(saveButton).not.toBeInTheDocument();
|
||||
|
||||
// Add Field button should not exist
|
||||
const addFieldButton = screen.getByRole("button", { name: ADD_FIELD_BUTTON_REGEX });
|
||||
await expect.element(addFieldButton).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides edit and delete buttons on fields for code-source collections", async () => {
|
||||
const fields = [makeField({ slug: "title", label: "Title" })];
|
||||
const collection = makeCollection({ source: "code", fields });
|
||||
const screen = await render(<ContentTypeEditor {...defaultProps()} collection={collection} />);
|
||||
|
||||
const editButton = screen.getByRole("button", { name: EDIT_FIELD_BUTTON_REGEX });
|
||||
await expect.element(editButton).not.toBeInTheDocument();
|
||||
|
||||
const deleteButton = screen.getByRole("button", { name: DELETE_FIELD_BUTTON_REGEX });
|
||||
await expect.element(deleteButton).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ---- Empty field list shows "No custom fields yet" ----
|
||||
|
||||
it("shows empty state when collection has no custom fields", async () => {
|
||||
const collection = makeCollection({ fields: [] });
|
||||
const screen = await render(<ContentTypeEditor {...defaultProps()} collection={collection} />);
|
||||
|
||||
await expect.element(screen.getByText("No custom fields yet")).toBeInTheDocument();
|
||||
await expect
|
||||
.element(screen.getByText("Add fields to define the structure of your content"))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ---- Fields section hidden for new collections ----
|
||||
|
||||
it("does not show fields section when creating new collection", async () => {
|
||||
const screen = await render(<ContentTypeEditor {...defaultProps()} isNew />);
|
||||
|
||||
const fieldsHeading = screen.getByRole("heading", { name: "Fields" });
|
||||
await expect.element(fieldsHeading).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ---- isSaving shows saving state ----
|
||||
|
||||
it("shows 'Saving...' when isSaving is true", async () => {
|
||||
const collection = makeCollection();
|
||||
const screen = await render(
|
||||
<ContentTypeEditor {...defaultProps()} collection={collection} isSaving />,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("status").element().textContent).toBe("Saving...");
|
||||
for (const button of screen.getByRole("button", { name: "Saving...", exact: true }).all()) {
|
||||
await expect.element(button).toBeDisabled();
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Sticky-header save (issue #233) ----
|
||||
|
||||
it("renders a sticky-header save button when editing existing collection", async () => {
|
||||
const collection = makeCollection();
|
||||
const screen = await render(<ContentTypeEditor {...defaultProps()} collection={collection} />);
|
||||
|
||||
// Both actions communicate their saved state directly. Saving remains
|
||||
// reachable from the sticky header and at the end of the form.
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: "Saved", exact: true }).first())
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByRole("status").element().textContent).toBe("Saved");
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: "Saved", exact: true }).last())
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sticky-header save flips to enabled 'Save' when fields change", async () => {
|
||||
const collection = makeCollection({ label: "Posts" });
|
||||
const screen = await render(<ContentTypeEditor {...defaultProps()} collection={collection} />);
|
||||
|
||||
// Initially clean -> disabled "Saved"
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: "Saved", exact: true }).first())
|
||||
.toBeDisabled();
|
||||
expect(screen.getByRole("status").element().textContent).toBe("Saved");
|
||||
|
||||
// Make a change -> sticky button remains "Save" and becomes enabled.
|
||||
await screen.getByLabelText("Label (Plural)").fill("Articles");
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: "Save", exact: true }).first())
|
||||
.toBeEnabled();
|
||||
});
|
||||
|
||||
it("sticky-header save submits the form (calls onSave)", async () => {
|
||||
const onSave = vi.fn();
|
||||
const collection = makeCollection({ label: "Posts" });
|
||||
const screen = await render(
|
||||
<ContentTypeEditor {...defaultProps({ onSave })} collection={collection} />,
|
||||
);
|
||||
|
||||
await screen.getByLabelText("Label (Plural)").fill("Articles");
|
||||
await screen.getByRole("button", { name: "Save", exact: true }).first().click();
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ label: "Articles" }));
|
||||
});
|
||||
|
||||
it("does not render sticky-header save for code-source collections", async () => {
|
||||
const collection = makeCollection({ source: "code" });
|
||||
const screen = await render(<ContentTypeEditor {...defaultProps()} collection={collection} />);
|
||||
|
||||
// Neither save action appears for code-source collections.
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: "Save", exact: true }).first())
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render sticky-header save when creating a new collection", async () => {
|
||||
const screen = await render(<ContentTypeEditor {...defaultProps()} isNew />);
|
||||
|
||||
// On the new flow only the bottom 'Create Content Type' button is expected.
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: "Save", exact: true }).first())
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ---- URL Pattern field ----
|
||||
|
||||
it("shows URL Pattern input", async () => {
|
||||
const collection = makeCollection();
|
||||
const screen = await render(<ContentTypeEditor {...defaultProps()} collection={collection} />);
|
||||
|
||||
const input = screen.getByLabelText("URL Pattern");
|
||||
await expect.element(input).toBeInTheDocument();
|
||||
await expect.element(input).toHaveValue("");
|
||||
});
|
||||
|
||||
it("populates URL Pattern from collection", async () => {
|
||||
const collection = makeCollection({ urlPattern: "/blog/{slug}" });
|
||||
const screen = await render(<ContentTypeEditor {...defaultProps()} collection={collection} />);
|
||||
|
||||
const input = screen.getByLabelText("URL Pattern");
|
||||
await expect.element(input).toHaveValue("/blog/{slug}");
|
||||
});
|
||||
|
||||
it("includes urlPattern in onSave when set", async () => {
|
||||
const onSave = vi.fn();
|
||||
const collection = makeCollection();
|
||||
const screen = await render(
|
||||
<ContentTypeEditor {...defaultProps({ onSave })} collection={collection} />,
|
||||
);
|
||||
|
||||
await screen.getByLabelText("URL Pattern").fill("/blog/{slug}");
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: "Save", exact: true }).last();
|
||||
await saveButton.click();
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ urlPattern: "/blog/{slug}" }));
|
||||
});
|
||||
|
||||
it("shows validation error when pattern lacks {slug}", async () => {
|
||||
const collection = makeCollection();
|
||||
const screen = await render(<ContentTypeEditor {...defaultProps()} collection={collection} />);
|
||||
|
||||
await screen.getByLabelText("URL Pattern").fill("/blog/broken");
|
||||
|
||||
await expect.element(screen.getByText(URL_PATTERN_SLUG_RE)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables save button when pattern lacks {slug}", async () => {
|
||||
const collection = makeCollection();
|
||||
const screen = await render(<ContentTypeEditor {...defaultProps()} collection={collection} />);
|
||||
|
||||
await screen.getByLabelText("URL Pattern").fill("/blog/broken");
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: "Save", exact: true }).last();
|
||||
await expect.element(saveButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("enables save button when pattern includes {slug}", async () => {
|
||||
const collection = makeCollection();
|
||||
const screen = await render(<ContentTypeEditor {...defaultProps()} collection={collection} />);
|
||||
|
||||
await screen.getByLabelText("URL Pattern").fill("/blog/{slug}");
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: "Save", exact: true }).last();
|
||||
await expect.element(saveButton).toBeEnabled();
|
||||
});
|
||||
|
||||
it("allows empty URL Pattern (field is optional)", async () => {
|
||||
const onSave = vi.fn();
|
||||
const collection = makeCollection({ label: "Posts" });
|
||||
const screen = await render(
|
||||
<ContentTypeEditor {...defaultProps({ onSave })} collection={collection} />,
|
||||
);
|
||||
|
||||
// Change label to enable save (urlPattern empty is fine)
|
||||
await screen.getByLabelText("Label (Plural)").fill("Articles");
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: "Save", exact: true }).last();
|
||||
await expect.element(saveButton).toBeEnabled();
|
||||
await saveButton.click();
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ urlPattern: undefined }));
|
||||
});
|
||||
|
||||
it("disables URL Pattern input for code-source collections", async () => {
|
||||
const collection = makeCollection({ source: "code", urlPattern: "/blog/{slug}" });
|
||||
const screen = await render(<ContentTypeEditor {...defaultProps()} collection={collection} />);
|
||||
|
||||
const input = screen.getByLabelText("URL Pattern");
|
||||
await expect.element(input).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows field count in header", async () => {
|
||||
const fields = [
|
||||
makeField({ slug: "title", label: "Title" }),
|
||||
makeField({ id: "field-2", slug: "body", label: "Body" }),
|
||||
];
|
||||
const collection = makeCollection({ fields });
|
||||
const screen = await render(<ContentTypeEditor {...defaultProps()} collection={collection} />);
|
||||
|
||||
// Should show "6 system + 2 custom fields"
|
||||
await expect.element(screen.getByText(SYSTEM_FIELDS_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { ContentTypeList } from "../../src/components/ContentTypeList";
|
||||
import type { SchemaCollection, OrphanedTable } from "../../src/lib/api";
|
||||
import { render } from "../utils/render.tsx";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const NO_CONTENT_TYPES_REGEX = /No content types yet/;
|
||||
|
||||
vi.mock("@tanstack/react-router", async () => {
|
||||
const actual = await vi.importActual("@tanstack/react-router");
|
||||
return {
|
||||
...actual,
|
||||
Link: ({
|
||||
children,
|
||||
to,
|
||||
params: _params,
|
||||
...props
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
to?: string;
|
||||
params?: Record<string, string>;
|
||||
[key: string]: unknown;
|
||||
}) => (
|
||||
<a href={typeof to === "string" ? to : "#"} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
function makeCollection(overrides: Partial<SchemaCollection> = {}): SchemaCollection {
|
||||
return {
|
||||
id: "col_01",
|
||||
slug: "posts",
|
||||
label: "Posts",
|
||||
labelSingular: "Post",
|
||||
supports: ["drafts", "revisions"],
|
||||
source: "dashboard",
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
updatedAt: "2025-01-02T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeOrphan(overrides: Partial<OrphanedTable> = {}): OrphanedTable {
|
||||
return {
|
||||
slug: "legacy_posts",
|
||||
tableName: "ec_legacy_posts",
|
||||
rowCount: 42,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ContentTypeList", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("rendering collections", () => {
|
||||
it("displays collections with labels and slugs", async () => {
|
||||
const collections = [
|
||||
makeCollection({ id: "1", slug: "articles", label: "Articles" }),
|
||||
makeCollection({ id: "2", slug: "landing_pages", label: "Landing Pages" }),
|
||||
];
|
||||
const screen = await render(<ContentTypeList collections={collections} />);
|
||||
await expect.element(screen.getByText("Articles", { exact: true })).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Landing Pages", { exact: true })).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("articles", { exact: true })).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("landing_pages", { exact: true })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'Code' badge for code-source collections", async () => {
|
||||
const collections = [makeCollection({ id: "1", source: "code" })];
|
||||
const screen = await render(<ContentTypeList collections={collections} />);
|
||||
await expect.element(screen.getByText("Code")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'Dashboard' badge for dashboard-source collections", async () => {
|
||||
const collections = [makeCollection({ id: "1", source: "dashboard" })];
|
||||
const screen = await render(<ContentTypeList collections={collections} />);
|
||||
await expect.element(screen.getByText("Dashboard")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows feature badges from supports array", async () => {
|
||||
const collections = [
|
||||
makeCollection({
|
||||
id: "1",
|
||||
supports: ["drafts", "revisions", "preview", "search"],
|
||||
}),
|
||||
];
|
||||
const screen = await render(<ContentTypeList collections={collections} />);
|
||||
await expect.element(screen.getByText("drafts")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("revisions")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("preview")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("search")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("navigation", () => {
|
||||
it("edit link navigates to /content-types/$slug", async () => {
|
||||
const collections = [makeCollection({ id: "1", slug: "articles", label: "Articles" })];
|
||||
const screen = await render(<ContentTypeList collections={collections} />);
|
||||
const editLink = screen.getByRole("link", { name: "Edit Articles" });
|
||||
await expect.element(editLink).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("'New Content Type' link is present", async () => {
|
||||
const screen = await render(<ContentTypeList collections={[]} />);
|
||||
await expect.element(screen.getByText("New Content Type")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete", () => {
|
||||
it("delete button only shown for non-code-source collections", async () => {
|
||||
const collections = [
|
||||
makeCollection({ id: "1", slug: "from-code", label: "From Code", source: "code" }),
|
||||
makeCollection({
|
||||
id: "2",
|
||||
slug: "from-dash",
|
||||
label: "From Dashboard",
|
||||
source: "dashboard",
|
||||
}),
|
||||
];
|
||||
const screen = await render(<ContentTypeList collections={collections} />);
|
||||
// Code-sourced should have no delete button
|
||||
expect(screen.getByRole("button", { name: "Delete From Code" }).query()).toBeNull();
|
||||
// Dashboard-sourced should have delete button
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: "Delete From Dashboard" }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens confirm dialog and calls onDelete after confirm", async () => {
|
||||
const onDelete = vi.fn();
|
||||
const collections = [
|
||||
makeCollection({ id: "1", slug: "posts", label: "Posts", source: "dashboard" }),
|
||||
];
|
||||
const screen = await render(
|
||||
<ContentTypeList collections={collections} onDelete={onDelete} />,
|
||||
);
|
||||
|
||||
await screen.getByRole("button", { name: "Delete Posts" }).click();
|
||||
|
||||
// ConfirmDialog should appear
|
||||
await expect.element(screen.getByText("Delete Content Type?")).toBeInTheDocument();
|
||||
|
||||
// Direct DOM click to bypass Base UI inert overlay
|
||||
screen.getByRole("button", { name: "Delete" }).element().click();
|
||||
|
||||
expect(onDelete).toHaveBeenCalledWith("posts");
|
||||
});
|
||||
|
||||
it("does not call onDelete when confirm dialog is cancelled", async () => {
|
||||
const onDelete = vi.fn();
|
||||
const collections = [
|
||||
makeCollection({ id: "1", slug: "posts", label: "Posts", source: "dashboard" }),
|
||||
];
|
||||
const screen = await render(
|
||||
<ContentTypeList collections={collections} onDelete={onDelete} />,
|
||||
);
|
||||
|
||||
await screen.getByRole("button", { name: "Delete Posts" }).click();
|
||||
|
||||
// ConfirmDialog should appear
|
||||
await expect.element(screen.getByText("Delete Content Type?")).toBeInTheDocument();
|
||||
|
||||
// Direct DOM click to bypass Base UI inert overlay
|
||||
screen.getByRole("button", { name: "Cancel" }).element().click();
|
||||
|
||||
expect(onDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("orphaned tables", () => {
|
||||
it("shows warning when orphanedTables has items", async () => {
|
||||
const orphans = [makeOrphan({ slug: "old_content", rowCount: 15 })];
|
||||
const screen = await render(<ContentTypeList collections={[]} orphanedTables={orphans} />);
|
||||
await expect
|
||||
.element(screen.getByText("Unregistered Content Tables Found"))
|
||||
.toBeInTheDocument();
|
||||
await expect.element(screen.getByText("old_content")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("(15 items)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("register button calls onRegisterOrphan", async () => {
|
||||
const onRegisterOrphan = vi.fn();
|
||||
const orphans = [makeOrphan({ slug: "legacy_data", rowCount: 5 })];
|
||||
const screen = await render(
|
||||
<ContentTypeList
|
||||
collections={[]}
|
||||
orphanedTables={orphans}
|
||||
onRegisterOrphan={onRegisterOrphan}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.getByRole("button", { name: "Register" }).click();
|
||||
|
||||
expect(onRegisterOrphan).toHaveBeenCalledWith("legacy_data");
|
||||
});
|
||||
|
||||
it("does not show orphan warning when orphanedTables is empty", async () => {
|
||||
const screen = await render(<ContentTypeList collections={[]} orphanedTables={[]} />);
|
||||
expect(screen.getByText("Unregistered Content Tables Found").query()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("empty state", () => {
|
||||
it("shows 'No content types yet' when no collections", async () => {
|
||||
const screen = await render(<ContentTypeList collections={[]} />);
|
||||
await expect.element(screen.getByText(NO_CONTENT_TYPES_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("loading state", () => {
|
||||
it("shows loading message when isLoading is true", async () => {
|
||||
const screen = await render(<ContentTypeList collections={[]} isLoading />);
|
||||
await expect.element(screen.getByText("Loading collections...")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import type { AdminManifest } from "../../src/lib/api";
|
||||
import type { DashboardStats } from "../../src/lib/api/dashboard";
|
||||
import { render } from "../utils/render.tsx";
|
||||
|
||||
vi.mock("@tanstack/react-router", async () => {
|
||||
const actual = await vi.importActual("@tanstack/react-router");
|
||||
return {
|
||||
...actual,
|
||||
Link: ({ children, to, params, search: _search, ...props }: any) => {
|
||||
let href = String(to ?? "");
|
||||
if (params && typeof params === "object") {
|
||||
for (const [key, value] of Object.entries(params as Record<string, unknown>)) {
|
||||
const paramValue =
|
||||
typeof value === "string" || typeof value === "number" || typeof value === "boolean"
|
||||
? String(value)
|
||||
: "";
|
||||
href = href.replace(`$${key}`, paramValue);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const mockFetchDashboardStats = vi.fn<() => Promise<DashboardStats>>();
|
||||
|
||||
vi.mock("../../src/lib/api/dashboard", async () => {
|
||||
const actual = await vi.importActual("../../src/lib/api/dashboard");
|
||||
return {
|
||||
...actual,
|
||||
fetchDashboardStats: () => mockFetchDashboardStats(),
|
||||
};
|
||||
});
|
||||
|
||||
const { Dashboard } = await import("../../src/components/Dashboard");
|
||||
|
||||
const manifest: AdminManifest = {
|
||||
version: "1.0.0",
|
||||
hash: "test",
|
||||
authMode: "passkey",
|
||||
collections: {
|
||||
pages: {
|
||||
label: "Pages",
|
||||
labelSingular: "Page",
|
||||
supports: [],
|
||||
hasSeo: false,
|
||||
fields: {},
|
||||
},
|
||||
},
|
||||
plugins: {},
|
||||
};
|
||||
|
||||
function makeStats(collections: DashboardStats["collections"]): DashboardStats {
|
||||
return {
|
||||
collections,
|
||||
mediaCount: 0,
|
||||
userCount: 0,
|
||||
recentItems: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("Dashboard", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows scheduled summary when collection stats include pending schedules", async () => {
|
||||
mockFetchDashboardStats.mockResolvedValue(
|
||||
makeStats([
|
||||
{ slug: "pages", label: "Pages", total: 5, published: 2, draft: 3, scheduled: 2 },
|
||||
]),
|
||||
);
|
||||
|
||||
const screen = await render(<Dashboard manifest={manifest} />);
|
||||
|
||||
await expect.element(screen.getByText("Scheduled")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits scheduled summary when only residual non-scheduled statuses exist", async () => {
|
||||
mockFetchDashboardStats.mockResolvedValue(
|
||||
makeStats([
|
||||
{ slug: "pages", label: "Pages", total: 6, published: 2, draft: 3, scheduled: 0 },
|
||||
]),
|
||||
);
|
||||
|
||||
const screen = await render(<Dashboard manifest={manifest} />);
|
||||
|
||||
await expect.element(screen.getByText("Media files")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Scheduled")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("links collection quick actions to new content forms", async () => {
|
||||
mockFetchDashboardStats.mockResolvedValue(makeStats([]));
|
||||
|
||||
const screen = await render(<Dashboard manifest={manifest} />);
|
||||
|
||||
await expect
|
||||
.element(screen.getByRole("link", { name: "Page" }))
|
||||
.toHaveAttribute("href", "/content/pages/new");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import * as React from "react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { EditorHeader } from "../../src/components/EditorHeader";
|
||||
import { render } from "../utils/render";
|
||||
|
||||
describe("EditorHeader", () => {
|
||||
it("renders title content", async () => {
|
||||
const screen = await render(
|
||||
<EditorHeader>
|
||||
<h1>Hello title</h1>
|
||||
</EditorHeader>,
|
||||
);
|
||||
await expect.element(screen.getByRole("heading", { name: "Hello title" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders leading content next to the title", async () => {
|
||||
const screen = await render(
|
||||
<EditorHeader leading={<button type="button">Back</button>}>
|
||||
<h1>Title</h1>
|
||||
</EditorHeader>,
|
||||
);
|
||||
await expect.element(screen.getByRole("button", { name: "Back" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders actions area for the primary save button", async () => {
|
||||
const screen = await render(
|
||||
<EditorHeader actions={<button type="submit">Save</button>}>
|
||||
<h1>Title</h1>
|
||||
</EditorHeader>,
|
||||
);
|
||||
await expect.element(screen.getByRole("button", { name: "Save" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits the actions area when no actions prop is provided", async () => {
|
||||
const screen = await render(
|
||||
<EditorHeader>
|
||||
<h1>Just a title</h1>
|
||||
</EditorHeader>,
|
||||
);
|
||||
// The actions container has flex + gap utility classes; assert it
|
||||
// isn't present in the header subtree. Looking at the underlying DOM
|
||||
// avoids relying on global queries that may be affected by leftover
|
||||
// nodes from earlier tests in the shared browser session.
|
||||
const wrapper = screen.getByText("Just a title").element().closest("[data-editor-header]");
|
||||
expect(wrapper).not.toBeNull();
|
||||
// Only one direct child div (the title group) — no actions div.
|
||||
const directChildren = wrapper!.children;
|
||||
expect(directChildren.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,390 @@
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { FieldEditor } from "../../src/components/FieldEditor";
|
||||
import type { SchemaField } from "../../src/lib/api";
|
||||
import { render } from "../utils/render.tsx";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FIELD_TYPE_REGEXES = [
|
||||
/Short Text/,
|
||||
/Long Text/,
|
||||
/Number Decimal number/,
|
||||
/Integer Whole number/,
|
||||
/Boolean True\/false toggle/,
|
||||
/Date & Time/,
|
||||
/^Select Single choice/,
|
||||
/Multi Select/,
|
||||
/Rich Text/,
|
||||
/Image/,
|
||||
/^File File from/,
|
||||
/Reference/,
|
||||
/JSON/,
|
||||
/Slug URL-friendly/,
|
||||
];
|
||||
|
||||
const SHORT_TEXT_REGEX = /Short Text/;
|
||||
const LONG_TEXT_REGEX = /Long Text/;
|
||||
const BOOLEAN_REGEX = /Boolean/;
|
||||
const RICH_TEXT_REGEX = /Rich Text Rich text editor/;
|
||||
|
||||
function makeField(overrides: Partial<SchemaField> = {}): SchemaField {
|
||||
return {
|
||||
id: "field_01",
|
||||
collectionId: "col_01",
|
||||
slug: "title",
|
||||
label: "Title",
|
||||
type: "string",
|
||||
columnType: "TEXT",
|
||||
required: true,
|
||||
unique: false,
|
||||
searchable: true,
|
||||
sortOrder: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// The kumo Dialog renders a `data-base-ui-inert` overlay that blocks pointer
|
||||
// events inside the dialog in Playwright's actionability checks. Assertions
|
||||
// (toBeInTheDocument, toHaveValue, etc.) work fine; only click() is blocked.
|
||||
//
|
||||
// Strategy:
|
||||
// - Type selection step: assert type buttons exist (no clicking needed)
|
||||
// - Config step: use edit mode (pass field prop) to go directly to config
|
||||
// - onSave/callbacks: use edit mode fields to test form submission
|
||||
|
||||
describe("FieldEditor", () => {
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
onOpenChange: vi.fn(),
|
||||
onSave: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("type selection step", () => {
|
||||
it("shows type selection grid when creating new field", async () => {
|
||||
const screen = await render(<FieldEditor {...defaultProps} />);
|
||||
await expect.element(screen.getByText("Add Field")).toBeInTheDocument();
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: SHORT_TEXT_REGEX }))
|
||||
.toBeInTheDocument();
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: LONG_TEXT_REGEX }))
|
||||
.toBeInTheDocument();
|
||||
await expect.element(screen.getByRole("button", { name: BOOLEAN_REGEX })).toBeInTheDocument();
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: RICH_TEXT_REGEX }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows all 14 field types as buttons", async () => {
|
||||
const screen = await render(<FieldEditor {...defaultProps} />);
|
||||
// Each type renders as a button with label and description
|
||||
for (const name of FIELD_TYPE_REGEXES) {
|
||||
await expect.element(screen.getByRole("button", { name })).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not show config form on initial render", async () => {
|
||||
const screen = await render(<FieldEditor {...defaultProps} />);
|
||||
// Label and Slug inputs should NOT be present in type selection step
|
||||
expect(screen.getByLabelText("Label").query()).toBeNull();
|
||||
expect(screen.getByLabelText("Slug").query()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("config step (string field)", () => {
|
||||
// Use a minimal string field to go directly to config step
|
||||
const stringField = makeField({
|
||||
slug: "",
|
||||
label: "",
|
||||
type: "string",
|
||||
required: false,
|
||||
unique: false,
|
||||
searchable: false,
|
||||
});
|
||||
|
||||
it("shows Configure Field title for string type", async () => {
|
||||
const screen = await render(
|
||||
<FieldEditor {...defaultProps} field={makeField({ type: "string" })} />,
|
||||
);
|
||||
await expect.element(screen.getByText("Edit Field")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows label and slug inputs", async () => {
|
||||
const screen = await render(<FieldEditor {...defaultProps} field={stringField} />);
|
||||
await expect.element(screen.getByLabelText("Label")).toBeInTheDocument();
|
||||
await expect.element(screen.getByLabelText("Slug")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows searchable checkbox for string type", async () => {
|
||||
const screen = await render(<FieldEditor {...defaultProps} field={stringField} />);
|
||||
await expect.element(screen.getByText("Searchable")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows min/max length validation for string type", async () => {
|
||||
const screen = await render(<FieldEditor {...defaultProps} field={stringField} />);
|
||||
await expect.element(screen.getByText("Validation")).toBeInTheDocument();
|
||||
await expect.element(screen.getByLabelText("Min Length")).toBeInTheDocument();
|
||||
await expect.element(screen.getByLabelText("Max Length")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows pattern input for string type", async () => {
|
||||
const screen = await render(<FieldEditor {...defaultProps} field={stringField} />);
|
||||
await expect.element(screen.getByLabelText("Pattern (Regex)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows required and unique checkboxes", async () => {
|
||||
const screen = await render(<FieldEditor {...defaultProps} field={stringField} />);
|
||||
await expect.element(screen.getByText("Required")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Unique")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("config step (number field)", () => {
|
||||
const numberField = makeField({
|
||||
slug: "",
|
||||
label: "",
|
||||
type: "number",
|
||||
required: false,
|
||||
unique: false,
|
||||
searchable: false,
|
||||
});
|
||||
|
||||
it("shows min/max value for number type", async () => {
|
||||
const screen = await render(<FieldEditor {...defaultProps} field={numberField} />);
|
||||
await expect.element(screen.getByLabelText("Min Value")).toBeInTheDocument();
|
||||
await expect.element(screen.getByLabelText("Max Value")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show searchable for number type", async () => {
|
||||
const screen = await render(<FieldEditor {...defaultProps} field={numberField} />);
|
||||
expect(screen.getByText("Searchable").query()).toBeNull();
|
||||
});
|
||||
|
||||
it("does not show pattern for number type", async () => {
|
||||
const screen = await render(<FieldEditor {...defaultProps} field={numberField} />);
|
||||
expect(screen.getByLabelText("Pattern (Regex)").query()).toBeNull();
|
||||
});
|
||||
|
||||
it("does not show min/max length for number type", async () => {
|
||||
const screen = await render(<FieldEditor {...defaultProps} field={numberField} />);
|
||||
expect(screen.getByLabelText("Min Length").query()).toBeNull();
|
||||
expect(screen.getByLabelText("Max Length").query()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("config step (text field)", () => {
|
||||
const textField = makeField({
|
||||
slug: "",
|
||||
label: "",
|
||||
type: "text",
|
||||
required: false,
|
||||
unique: false,
|
||||
searchable: false,
|
||||
});
|
||||
|
||||
it("shows min/max length but no pattern for text type", async () => {
|
||||
const screen = await render(<FieldEditor {...defaultProps} field={textField} />);
|
||||
await expect.element(screen.getByLabelText("Min Length")).toBeInTheDocument();
|
||||
await expect.element(screen.getByLabelText("Max Length")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Pattern (Regex)").query()).toBeNull();
|
||||
});
|
||||
|
||||
it("shows searchable checkbox for text type", async () => {
|
||||
const screen = await render(<FieldEditor {...defaultProps} field={textField} />);
|
||||
await expect.element(screen.getByText("Searchable")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("config step (select field)", () => {
|
||||
const selectField = makeField({
|
||||
slug: "",
|
||||
label: "",
|
||||
type: "select",
|
||||
required: false,
|
||||
unique: false,
|
||||
searchable: false,
|
||||
});
|
||||
|
||||
it("shows options textarea for select type", async () => {
|
||||
const screen = await render(<FieldEditor {...defaultProps} field={selectField} />);
|
||||
await expect.element(screen.getByText("Options (one per line)")).toBeInTheDocument();
|
||||
// Textarea should have the placeholder
|
||||
await expect.element(screen.getByPlaceholder("Option 1")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("config step (multiSelect field)", () => {
|
||||
const multiSelectField = makeField({
|
||||
slug: "",
|
||||
label: "",
|
||||
type: "multiSelect",
|
||||
required: false,
|
||||
unique: false,
|
||||
searchable: false,
|
||||
});
|
||||
|
||||
it("shows options textarea for multi-select type", async () => {
|
||||
const screen = await render(<FieldEditor {...defaultProps} field={multiSelectField} />);
|
||||
await expect.element(screen.getByText("Options (one per line)")).toBeInTheDocument();
|
||||
await expect.element(screen.getByPlaceholder("Option 1")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("edit mode", () => {
|
||||
const existingField = makeField({
|
||||
validation: { maxLength: 200 },
|
||||
});
|
||||
|
||||
it("skips type selection and shows config directly", async () => {
|
||||
const screen = await render(<FieldEditor {...defaultProps} field={existingField} />);
|
||||
await expect.element(screen.getByText("Edit Field")).toBeInTheDocument();
|
||||
await expect.element(screen.getByLabelText("Label")).toHaveValue("Title");
|
||||
});
|
||||
|
||||
it("disables slug input in edit mode", async () => {
|
||||
const screen = await render(<FieldEditor {...defaultProps} field={existingField} />);
|
||||
await expect.element(screen.getByLabelText("Slug")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows hint about slug immutability", async () => {
|
||||
const screen = await render(<FieldEditor {...defaultProps} field={existingField} />);
|
||||
await expect
|
||||
.element(screen.getByText("Field slugs cannot be changed after creation"))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show Change button in edit mode", async () => {
|
||||
const screen = await render(<FieldEditor {...defaultProps} field={existingField} />);
|
||||
expect(screen.getByRole("button", { name: "Change" }).query()).toBeNull();
|
||||
});
|
||||
|
||||
it("shows Update Field button instead of Add Field", async () => {
|
||||
const screen = await render(<FieldEditor {...defaultProps} field={existingField} />);
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: "Update Field" }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("pre-populates validation values", async () => {
|
||||
const screen = await render(<FieldEditor {...defaultProps} field={existingField} />);
|
||||
await expect.element(screen.getByLabelText("Max Length")).toHaveValue(200);
|
||||
});
|
||||
|
||||
it("pre-populates slug value", async () => {
|
||||
const screen = await render(<FieldEditor {...defaultProps} field={existingField} />);
|
||||
await expect.element(screen.getByLabelText("Slug")).toHaveValue("title");
|
||||
});
|
||||
|
||||
it("pre-populates required checkbox", async () => {
|
||||
const screen = await render(<FieldEditor {...defaultProps} field={existingField} />);
|
||||
// The Required checkbox text should be present (the field has required: true)
|
||||
await expect.element(screen.getByText("Required")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not auto-generate slug when editing label in edit mode", async () => {
|
||||
const screen = await render(<FieldEditor {...defaultProps} field={existingField} />);
|
||||
await screen.getByLabelText("Label").fill("New Label");
|
||||
// Slug should remain "title", not change to "new_label"
|
||||
await expect.element(screen.getByLabelText("Slug")).toHaveValue("title");
|
||||
});
|
||||
|
||||
it("shows type indicator with field type info", async () => {
|
||||
const screen = await render(<FieldEditor {...defaultProps} field={existingField} />);
|
||||
await expect.element(screen.getByText("Short Text")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Single line text input")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("saving state", () => {
|
||||
it("shows Saving... when isSaving is true", async () => {
|
||||
const field = makeField();
|
||||
const screen = await render(<FieldEditor {...defaultProps} isSaving={true} field={field} />);
|
||||
await expect.element(screen.getByText("Saving...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables cancel button when saving", async () => {
|
||||
const field = makeField();
|
||||
const screen = await render(<FieldEditor {...defaultProps} isSaving={true} field={field} />);
|
||||
await expect.element(screen.getByRole("button", { name: "Cancel" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("disables update button when saving", async () => {
|
||||
const field = makeField();
|
||||
const screen = await render(<FieldEditor {...defaultProps} isSaving={true} field={field} />);
|
||||
await expect.element(screen.getByRole("button", { name: "Saving..." })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("button state", () => {
|
||||
it("disables save button when label is empty", async () => {
|
||||
const field = makeField({ slug: "test", label: "" });
|
||||
const screen = await render(<FieldEditor {...defaultProps} field={field} />);
|
||||
await expect.element(screen.getByRole("button", { name: "Update Field" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("enables save button when label and slug are filled", async () => {
|
||||
const field = makeField({ slug: "test", label: "Test" });
|
||||
const screen = await render(<FieldEditor {...defaultProps} field={field} />);
|
||||
await expect.element(screen.getByRole("button", { name: "Update Field" })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("config step (file field)", () => {
|
||||
const fileField = makeField({
|
||||
slug: "attachment",
|
||||
label: "Attachment",
|
||||
type: "file",
|
||||
required: false,
|
||||
unique: false,
|
||||
searchable: false,
|
||||
});
|
||||
|
||||
it("shows AllowedTypesEditor for file type", async () => {
|
||||
const screen = await render(<FieldEditor {...defaultProps} field={fileField} />);
|
||||
await expect.element(screen.getByText("Allowed types")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows AllowedTypesEditor for image type", async () => {
|
||||
const imageField = makeField({
|
||||
slug: "cover",
|
||||
label: "Cover",
|
||||
type: "image",
|
||||
required: false,
|
||||
unique: false,
|
||||
searchable: false,
|
||||
});
|
||||
const screen = await render(<FieldEditor {...defaultProps} field={imageField} />);
|
||||
await expect.element(screen.getByText("Allowed types")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("pre-populates allowedMimeTypes from existing field validation", async () => {
|
||||
const fieldWithMimes = makeField({
|
||||
slug: "document",
|
||||
label: "Document",
|
||||
type: "file",
|
||||
required: false,
|
||||
unique: false,
|
||||
searchable: false,
|
||||
validation: { allowedMimeTypes: ["application/pdf"] },
|
||||
});
|
||||
const screen = await render(<FieldEditor {...defaultProps} field={fieldWithMimes} />);
|
||||
await expect.element(screen.getByText("application/pdf")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("dialog closed", () => {
|
||||
it("renders nothing visible when open is false", async () => {
|
||||
const screen = await render(<FieldEditor {...defaultProps} open={false} />);
|
||||
expect(screen.getByText("Add Field").query()).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Sidebar } from "@cloudflare/kumo";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { ThemeProvider } from "../../src/components/ThemeProvider";
|
||||
import { render } from "../utils/render.tsx";
|
||||
|
||||
// Mock router
|
||||
vi.mock("@tanstack/react-router", async () => {
|
||||
const actual = await vi.importActual("@tanstack/react-router");
|
||||
return {
|
||||
...actual,
|
||||
Link: ({ children, to, ...props }: any) => (
|
||||
<a href={to} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
useNavigate: () => vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock API
|
||||
vi.mock("../../src/lib/api/client", async () => {
|
||||
const actual = await vi.importActual("../../src/lib/api/client");
|
||||
return {
|
||||
...actual,
|
||||
apiFetch: vi.fn().mockImplementation((url: string) => {
|
||||
if (url.includes("/auth/me")) {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
id: "1",
|
||||
name: "Matt Kane",
|
||||
email: "matt@test.com",
|
||||
role: 50,
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
}
|
||||
return Promise.resolve(new Response(JSON.stringify({ data: {} }), { status: 200 }));
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// Import after mocks
|
||||
const { Header } = await import("../../src/components/Header");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const THEME_BUTTON_REGEX = /Toggle theme/;
|
||||
|
||||
function TestWrapper({ children }: { children: React.ReactNode }) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return (
|
||||
<QueryClientProvider client={qc}>
|
||||
<ThemeProvider defaultTheme="light">
|
||||
<Sidebar.Provider defaultOpen>{children}</Sidebar.Provider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("Header", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
document.documentElement.removeAttribute("data-mode");
|
||||
});
|
||||
|
||||
it("theme toggle button is present", async () => {
|
||||
const screen = await render(
|
||||
<TestWrapper>
|
||||
<Header />
|
||||
</TestWrapper>,
|
||||
);
|
||||
// ThemeToggle renders a button with aria-label "Toggle theme (current: …)"
|
||||
// (Kumo 2.x wraps `<Button title>` as a Tooltip popup, not a DOM title.)
|
||||
const themeButton = screen.getByLabelText(THEME_BUTTON_REGEX);
|
||||
await expect.element(themeButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays user name when loaded", async () => {
|
||||
const screen = await render(
|
||||
<TestWrapper>
|
||||
<Header />
|
||||
</TestWrapper>,
|
||||
);
|
||||
// User data loads async via react-query
|
||||
await expect.element(screen.getByText("Matt Kane")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("View Site link is present", async () => {
|
||||
const screen = await render(
|
||||
<TestWrapper>
|
||||
<Header />
|
||||
</TestWrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("View Site")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { render } from "../utils/render.tsx";
|
||||
|
||||
// Mock router
|
||||
vi.mock("@tanstack/react-router", async () => {
|
||||
const actual = await vi.importActual("@tanstack/react-router");
|
||||
return {
|
||||
...actual,
|
||||
Link: ({ children, to, ...props }: any) => (
|
||||
<a href={to} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
useNavigate: () => vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock API — keep a reference so tests can override fetchAuthMode
|
||||
const mockFetchAuthMode = vi.fn().mockResolvedValue({
|
||||
authMode: "passkey",
|
||||
});
|
||||
|
||||
vi.mock("../../src/lib/api", async () => {
|
||||
const actual = await vi.importActual("../../src/lib/api");
|
||||
return {
|
||||
...actual,
|
||||
fetchAuthMode: (...args: unknown[]) => mockFetchAuthMode(...args),
|
||||
apiFetch: vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response(JSON.stringify({ success: true }), { status: 200 })),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock WebAuthn APIs so PasskeyLogin doesn't bail out
|
||||
Object.defineProperty(window, "PublicKeyCredential", {
|
||||
value: function PublicKeyCredential() {},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// Import after mocks
|
||||
const { LoginPage } = await import("../../src/components/LoginPage");
|
||||
|
||||
function QueryWrapper({ children }: { children: React.ReactNode }) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
describe("LoginPage", () => {
|
||||
beforeEach(() => {
|
||||
// Clean URL params
|
||||
window.history.replaceState({}, "", window.location.pathname);
|
||||
});
|
||||
|
||||
it("shows passkey login button when authMode is passkey", async () => {
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<LoginPage />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Sign in with Passkey")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'Sign in with email link' button", async () => {
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<LoginPage />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Sign in with email link")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clicking email link button switches to magic link form", async () => {
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<LoginPage />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
const emailButton = screen.getByText("Sign in with email link");
|
||||
await emailButton.click();
|
||||
// Heading should change
|
||||
await expect.element(screen.getByText("Sign in with email")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("magic link form has email input and submit button", async () => {
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<LoginPage />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
// Switch to magic link
|
||||
await screen.getByText("Sign in with email link").click();
|
||||
// Check for email input (by placeholder)
|
||||
await expect.element(screen.getByPlaceholder("you@example.com")).toBeInTheDocument();
|
||||
// Check for submit button
|
||||
await expect.element(screen.getByText("Send magic link")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("'Back to login' from magic link returns to passkey view", async () => {
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<LoginPage />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
// Switch to magic link
|
||||
await screen.getByText("Sign in with email link").click();
|
||||
await expect.element(screen.getByText("Sign in with email")).toBeInTheDocument();
|
||||
// Click back
|
||||
await screen.getByText("Back to login").click();
|
||||
// Should see passkey button again
|
||||
await expect.element(screen.getByText("Sign in with Passkey")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides sign up link when signup is not enabled", async () => {
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<LoginPage />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
// Wait for manifest to load (passkey button appears)
|
||||
await expect.element(screen.getByText("Sign in with Passkey")).toBeInTheDocument();
|
||||
// Sign up link should NOT be present
|
||||
expect(screen.getByText("Sign up").query()).toBeNull();
|
||||
});
|
||||
|
||||
it("shows sign up link when signup is enabled", async () => {
|
||||
mockFetchAuthMode.mockResolvedValueOnce({
|
||||
authMode: "passkey",
|
||||
signupEnabled: true,
|
||||
});
|
||||
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<LoginPage />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Sign up")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import type {
|
||||
MarketplaceSearchResult,
|
||||
MarketplacePluginSummary,
|
||||
} from "../../src/lib/api/marketplace";
|
||||
import { render } from "../utils/render.tsx";
|
||||
|
||||
// Mock router
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock("@tanstack/react-router", async () => {
|
||||
const actual = await vi.importActual("@tanstack/react-router");
|
||||
return {
|
||||
...actual,
|
||||
Link: ({ children, to, params, ...props }: any) => {
|
||||
const href = params?.pluginId ? to.replace("$pluginId", params.pluginId) : to;
|
||||
return (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
useNavigate: () => mockNavigate,
|
||||
};
|
||||
});
|
||||
|
||||
const mockSearchMarketplace = vi.fn<() => Promise<MarketplaceSearchResult>>();
|
||||
|
||||
vi.mock("../../src/lib/api/marketplace", async () => {
|
||||
const actual = await vi.importActual("../../src/lib/api/marketplace");
|
||||
return {
|
||||
...actual,
|
||||
searchMarketplace: (...args: unknown[]) => mockSearchMarketplace(...(args as [])),
|
||||
};
|
||||
});
|
||||
|
||||
// Import after mocks
|
||||
const { MarketplaceBrowse } = await import("../../src/components/MarketplaceBrowse");
|
||||
|
||||
function makePlugin(overrides: Partial<MarketplacePluginSummary> = {}): MarketplacePluginSummary {
|
||||
return {
|
||||
id: "test-plugin",
|
||||
name: "Test Plugin",
|
||||
description: "A test plugin for testing",
|
||||
author: { name: "Test Author", verified: false },
|
||||
capabilities: ["read:content"],
|
||||
installCount: 1234,
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
updatedAt: "2025-02-01T00:00:00Z",
|
||||
latestVersion: {
|
||||
version: "1.0.0",
|
||||
audit: { verdict: "pass", riskScore: 10 },
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
describe("MarketplaceBrowse", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSearchMarketplace.mockResolvedValue({
|
||||
items: [
|
||||
makePlugin({
|
||||
id: "seo-helper",
|
||||
name: "SEO Helper",
|
||||
description: "Improve your SEO",
|
||||
author: { name: "Acme Inc", verified: true },
|
||||
installCount: 5200,
|
||||
capabilities: ["read:content", "write:content"],
|
||||
latestVersion: {
|
||||
version: "1.2.3",
|
||||
audit: { verdict: "pass", riskScore: 10 },
|
||||
},
|
||||
}),
|
||||
makePlugin({
|
||||
id: "analytics",
|
||||
name: "Analytics",
|
||||
description: "Track page views",
|
||||
author: { name: "DataCorp", verified: false },
|
||||
installCount: 890,
|
||||
capabilities: ["network:fetch"],
|
||||
latestVersion: {
|
||||
version: "2.0.0",
|
||||
audit: { verdict: "warn", riskScore: 45 },
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("renders marketplace header", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<MarketplaceBrowse />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Marketplace")).toBeInTheDocument();
|
||||
await expect
|
||||
.element(screen.getByText("Browse and install plugins to extend your site."))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays plugin cards with names and authors", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<MarketplaceBrowse />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("SEO Helper")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Acme Inc")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Analytics")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("DataCorp")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows plugin descriptions", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<MarketplaceBrowse />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Improve your SEO")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Track page views")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("formats install counts (K for thousands)", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<MarketplaceBrowse />
|
||||
</Wrapper>,
|
||||
);
|
||||
// 5200 → 5.2k
|
||||
await expect.element(screen.getByText("5.2k")).toBeInTheDocument();
|
||||
// 890 → 890
|
||||
await expect.element(screen.getByText("890")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows permission count", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<MarketplaceBrowse />
|
||||
</Wrapper>,
|
||||
);
|
||||
// SEO Helper has 2 capabilities
|
||||
await expect.element(screen.getByText("2 permissions")).toBeInTheDocument();
|
||||
// Analytics has 1 capability
|
||||
await expect.element(screen.getByText("1 permission")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows audit badges", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<MarketplaceBrowse />
|
||||
</Wrapper>,
|
||||
);
|
||||
// SEO Helper has "pass", Analytics has "warn"
|
||||
await expect.element(screen.getByText("Pass")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Warn")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'Installed' badge for installed plugins", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<MarketplaceBrowse installedPluginIds={new Set(["seo-helper"])} />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Installed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows empty state when no results", async () => {
|
||||
mockSearchMarketplace.mockResolvedValue({ items: [] });
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<MarketplaceBrowse />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("No plugins found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error state with retry button", async () => {
|
||||
mockSearchMarketplace.mockRejectedValue(new Error("Network timeout"));
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<MarketplaceBrowse />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Unable to reach marketplace")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Network timeout")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Retry")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has search input", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<MarketplaceBrowse />
|
||||
</Wrapper>,
|
||||
);
|
||||
const searchInput = screen.getByPlaceholder("Search plugins...");
|
||||
await expect.element(searchInput).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has sort dropdown with options", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<MarketplaceBrowse />
|
||||
</Wrapper>,
|
||||
);
|
||||
const sortSelect = screen.getByRole("combobox", { name: "Sort plugins" });
|
||||
await expect.element(sortSelect).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("plugin cards link to detail page", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<MarketplaceBrowse />
|
||||
</Wrapper>,
|
||||
);
|
||||
// Wait for cards to render
|
||||
await expect.element(screen.getByText("SEO Helper")).toBeInTheDocument();
|
||||
// The Link wraps the card, creating an <a> with the plugin detail path
|
||||
const links = screen.getByRole("link").all();
|
||||
const seoLink = links.find((l) => l.element().getAttribute("href")?.includes("seo-helper"));
|
||||
expect(seoLink).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows plugin avatar when no icon URL", async () => {
|
||||
mockSearchMarketplace.mockResolvedValue({
|
||||
items: [makePlugin({ id: "no-icon", name: "Zeta Plugin", iconUrl: undefined })],
|
||||
});
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<MarketplaceBrowse />
|
||||
</Wrapper>,
|
||||
);
|
||||
// The avatar shows the first letter — use exact match to avoid matching "Zeta Plugin"
|
||||
await expect.element(screen.getByText("Z", { exact: true })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows version numbers on cards", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<MarketplaceBrowse />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("v1.2.3")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("v2.0.0")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has capability filter dropdown", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<MarketplaceBrowse />
|
||||
</Wrapper>,
|
||||
);
|
||||
const capabilitySelect = screen.getByRole("combobox", { name: "Filter by capability" });
|
||||
await expect.element(capabilitySelect).toBeInTheDocument();
|
||||
// Default option
|
||||
await expect.element(screen.getByText("All capabilities")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("installed badge navigates to plugin manager on click", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<MarketplaceBrowse installedPluginIds={new Set(["seo-helper"])} />
|
||||
</Wrapper>,
|
||||
);
|
||||
const badge = screen.getByText("Installed");
|
||||
await expect.element(badge).toBeInTheDocument();
|
||||
await badge.click();
|
||||
expect(mockNavigate).toHaveBeenCalledWith({ to: "/plugins-manager" });
|
||||
});
|
||||
|
||||
it("shows 'Load more' button when there are more pages", async () => {
|
||||
mockSearchMarketplace.mockResolvedValue({
|
||||
items: [makePlugin({ id: "plugin-1", name: "Plugin One" })],
|
||||
nextCursor: "cursor-abc",
|
||||
});
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<MarketplaceBrowse />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Load more")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show 'Load more' when there are no more pages", async () => {
|
||||
mockSearchMarketplace.mockResolvedValue({
|
||||
items: [makePlugin({ id: "plugin-1", name: "Plugin One" })],
|
||||
});
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<MarketplaceBrowse />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Plugin One")).toBeInTheDocument();
|
||||
expect(screen.getByText("Load more").query()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,418 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import type { MarketplacePluginDetail as PluginDetailType } from "../../src/lib/api/marketplace";
|
||||
import { render } from "../utils/render.tsx";
|
||||
|
||||
const INSTALL_RE = /Install/;
|
||||
|
||||
// Mock router
|
||||
vi.mock("@tanstack/react-router", async () => {
|
||||
const actual = await vi.importActual("@tanstack/react-router");
|
||||
return {
|
||||
...actual,
|
||||
Link: ({ children, to, ...props }: any) => (
|
||||
<a href={to} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
useNavigate: () => vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockFetchMarketplacePlugin = vi.fn<() => Promise<PluginDetailType>>();
|
||||
const mockInstallMarketplacePlugin = vi.fn<() => Promise<void>>();
|
||||
|
||||
vi.mock("../../src/lib/api/marketplace", async () => {
|
||||
const actual = await vi.importActual("../../src/lib/api/marketplace");
|
||||
return {
|
||||
...actual,
|
||||
fetchMarketplacePlugin: (...args: unknown[]) => mockFetchMarketplacePlugin(...(args as [])),
|
||||
installMarketplacePlugin: (...args: unknown[]) => mockInstallMarketplacePlugin(...(args as [])),
|
||||
};
|
||||
});
|
||||
|
||||
// Import after mocks
|
||||
const { MarketplacePluginDetail } = await import("../../src/components/MarketplacePluginDetail");
|
||||
|
||||
function makePluginDetail(overrides: Partial<PluginDetailType> = {}): PluginDetailType {
|
||||
return {
|
||||
id: "seo-helper",
|
||||
name: "SEO Helper",
|
||||
description: "Improve your SEO with automatic meta tags",
|
||||
author: { name: "Acme Inc", verified: true },
|
||||
capabilities: ["read:content", "write:content"],
|
||||
keywords: ["seo", "meta", "optimization"],
|
||||
installCount: 5200,
|
||||
license: "MIT",
|
||||
repositoryUrl: "https://github.com/acme/seo-helper",
|
||||
homepageUrl: "https://seo-helper.example.com",
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
updatedAt: "2025-02-01T00:00:00Z",
|
||||
latestVersion: {
|
||||
version: "2.1.0",
|
||||
minEmDashVersion: "0.8.0",
|
||||
bundleSize: 15360,
|
||||
readme: "# SEO Helper\n\nThis plugin helps with SEO.",
|
||||
audit: { verdict: "pass", riskScore: 5 },
|
||||
publishedAt: "2025-02-01T00:00:00Z",
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
describe("MarketplacePluginDetail", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetchMarketplacePlugin.mockResolvedValue(makePluginDetail());
|
||||
mockInstallMarketplacePlugin.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("displays plugin name and description", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<MarketplacePluginDetail pluginId="seo-helper" />
|
||||
</Wrapper>,
|
||||
);
|
||||
// Name appears in header h1 and also in rendered README h1 — use first()
|
||||
await expect
|
||||
.element(screen.getByRole("heading", { name: "SEO Helper" }).first())
|
||||
.toBeInTheDocument();
|
||||
await expect
|
||||
.element(screen.getByText("Improve your SEO with automatic meta tags"))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows author name with verified badge", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<MarketplacePluginDetail pluginId="seo-helper" />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Acme Inc")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows version number", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<MarketplacePluginDetail pluginId="seo-helper" />
|
||||
</Wrapper>,
|
||||
);
|
||||
// Wait for data to load — version appears in both header and sidebar
|
||||
await expect.element(screen.getByText("Acme Inc")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("v2.1.0").first()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays install count", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<MarketplacePluginDetail pluginId="seo-helper" />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("5,200 installs")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows audit badge", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<MarketplacePluginDetail pluginId="seo-helper" />
|
||||
</Wrapper>,
|
||||
);
|
||||
// Wait for data to load, then check audit badge (appears in stats bar and sidebar)
|
||||
await expect.element(screen.getByText("Acme Inc")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Pass").first()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows license", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<MarketplacePluginDetail pluginId="seo-helper" />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("MIT")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows source and website links", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<MarketplacePluginDetail pluginId="seo-helper" />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Source")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Website")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders README content", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<MarketplacePluginDetail pluginId="seo-helper" />
|
||||
</Wrapper>,
|
||||
);
|
||||
// The markdown renderer should convert "# SEO Helper" and the paragraph
|
||||
await expect.element(screen.getByText("This plugin helps with SEO.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows permissions sidebar", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<MarketplacePluginDetail pluginId="seo-helper" />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Permissions")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Read your content")).toBeInTheDocument();
|
||||
await expect
|
||||
.element(screen.getByText("Create, update, and delete content"))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows keywords", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<MarketplacePluginDetail pluginId="seo-helper" />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Keywords")).toBeInTheDocument();
|
||||
// "seo" appears in multiple places (name, description, keyword) — use exact match
|
||||
await expect.element(screen.getByText("seo", { exact: true })).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("meta", { exact: true })).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("optimization")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows audit summary with risk score", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<MarketplacePluginDetail pluginId="seo-helper" />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Security Audit")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Risk score: 5/100")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows version info with min emdash version", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<MarketplacePluginDetail pluginId="seo-helper" />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Version")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Requires EmDash 0.8.0")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows bundle size", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<MarketplacePluginDetail pluginId="seo-helper" />
|
||||
</Wrapper>,
|
||||
);
|
||||
// 15360 bytes = 15.0 KB
|
||||
await expect.element(screen.getByText("15.0 KB")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Install button for non-installed plugin", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<MarketplacePluginDetail pluginId="seo-helper" />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByRole("button", { name: INSTALL_RE })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Installed badge for installed plugin", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<MarketplacePluginDetail
|
||||
pluginId="seo-helper"
|
||||
installedPluginIds={new Set(["seo-helper"])}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Installed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens consent dialog on Install click", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<MarketplacePluginDetail pluginId="seo-helper" />
|
||||
</Wrapper>,
|
||||
);
|
||||
// Wait for data to load, then click Install button
|
||||
const installBtn = screen.getByRole("button", { name: INSTALL_RE });
|
||||
await expect.element(installBtn).toBeInTheDocument();
|
||||
await installBtn.click();
|
||||
// Consent dialog should appear
|
||||
await expect.element(screen.getByText("Plugin Permissions")).toBeInTheDocument();
|
||||
await expect
|
||||
.element(screen.getByText("SEO Helper requires the following permissions:"))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error state when plugin fails to load", async () => {
|
||||
mockFetchMarketplacePlugin.mockRejectedValue(new Error("Plugin not found"));
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<MarketplacePluginDetail pluginId="nonexistent" />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Failed to load plugin")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Plugin not found")).toBeInTheDocument();
|
||||
// "Back to marketplace" appears multiple times (header + error) — just check one
|
||||
const backLinks = screen.getByText("Back to marketplace").all();
|
||||
expect(backLinks.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("shows 'No detailed description' when no README", async () => {
|
||||
mockFetchMarketplacePlugin.mockResolvedValue(
|
||||
makePluginDetail({
|
||||
latestVersion: {
|
||||
version: "1.0.0",
|
||||
bundleSize: 0,
|
||||
publishedAt: "2025-01-01T00:00:00Z",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<MarketplacePluginDetail pluginId="seo-helper" />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect
|
||||
.element(screen.getByText("No detailed description available."))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'no special permissions' when capabilities empty", async () => {
|
||||
mockFetchMarketplacePlugin.mockResolvedValue(makePluginDetail({ capabilities: [] }));
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<MarketplacePluginDetail pluginId="seo-helper" />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect
|
||||
.element(screen.getByText("This plugin requires no special permissions."))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders screenshots when present", async () => {
|
||||
mockFetchMarketplacePlugin.mockResolvedValue(
|
||||
makePluginDetail({
|
||||
latestVersion: {
|
||||
version: "2.1.0",
|
||||
bundleSize: 15360,
|
||||
screenshotUrls: [
|
||||
"https://example.com/screenshot1.png",
|
||||
"https://example.com/screenshot2.png",
|
||||
],
|
||||
audit: { verdict: "pass", riskScore: 5 },
|
||||
publishedAt: "2025-02-01T00:00:00Z",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<MarketplacePluginDetail pluginId="seo-helper" />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Screenshots")).toBeInTheDocument();
|
||||
// Two screenshot images
|
||||
const imgs = screen.getByRole("img").all();
|
||||
const screenshotImgs = imgs.filter((img) =>
|
||||
img.element().getAttribute("alt")?.startsWith("Screenshot"),
|
||||
);
|
||||
expect(screenshotImgs.length).toBe(2);
|
||||
});
|
||||
|
||||
it("has back link to marketplace", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<MarketplacePluginDetail pluginId="seo-helper" />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Back to marketplace")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows plugin avatar when no icon URL", async () => {
|
||||
mockFetchMarketplacePlugin.mockResolvedValue(makePluginDetail({ iconUrl: undefined }));
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<MarketplacePluginDetail pluginId="seo-helper" />
|
||||
</Wrapper>,
|
||||
);
|
||||
// First letter of "SEO Helper" is "S" — use exact match
|
||||
await expect.element(screen.getByText("S", { exact: true })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("XSS prevention in README rendering", () => {
|
||||
async function renderWithReadme(readme: string) {
|
||||
mockFetchMarketplacePlugin.mockResolvedValue(
|
||||
makePluginDetail({
|
||||
latestVersion: {
|
||||
version: "1.0.0",
|
||||
bundleSize: 0,
|
||||
readme,
|
||||
audit: { verdict: "pass", riskScore: 0 },
|
||||
publishedAt: "2025-01-01T00:00:00Z",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<MarketplacePluginDetail pluginId="seo-helper" />
|
||||
</Wrapper>,
|
||||
);
|
||||
// Wait for data to load
|
||||
await expect.element(screen.getByText("Acme Inc")).toBeInTheDocument();
|
||||
return screen;
|
||||
}
|
||||
|
||||
it("strips img tags with onerror handlers from link text", async () => {
|
||||
const screen = await renderWithReadme(
|
||||
"[<img src=x onerror=alert(document.cookie)>](https://example.com)",
|
||||
);
|
||||
// The prose div contains the rendered README markdown
|
||||
const prose = screen.container.querySelector(".prose")!;
|
||||
// No img element should exist in the rendered output
|
||||
expect(prose.querySelectorAll("img[onerror]").length).toBe(0);
|
||||
// The onerror text should be escaped (visible as text), not as an attribute
|
||||
expect(prose.querySelectorAll("[onerror]").length).toBe(0);
|
||||
});
|
||||
|
||||
it("strips attribute breakout via quotes in link text", async () => {
|
||||
const screen = await renderWithReadme('[a" onmouseover="alert(1)](https://example.com)');
|
||||
// The prose div contains the rendered README markdown
|
||||
const prose = screen.container.querySelector(".prose")!;
|
||||
// No element should have an onmouseover attribute
|
||||
expect(prose.querySelectorAll("[onmouseover]").length).toBe(0);
|
||||
});
|
||||
|
||||
it("strips raw script tags from README", async () => {
|
||||
const screen = await renderWithReadme("Hello\n\n<script>alert('xss')</script>\n\nWorld");
|
||||
expect(screen.container.querySelectorAll("script").length).toBe(0);
|
||||
expect(screen.container.innerHTML).not.toContain("<script");
|
||||
});
|
||||
|
||||
it("strips event handlers from raw HTML in README", async () => {
|
||||
const screen = await renderWithReadme('<div onload="alert(1)">test</div>');
|
||||
expect(screen.container.innerHTML).not.toContain("onload");
|
||||
});
|
||||
|
||||
it("renders safe markdown content correctly", async () => {
|
||||
const screen = await renderWithReadme(
|
||||
"# Title\n\nA paragraph with **bold** and [a link](https://example.com).",
|
||||
);
|
||||
// The link text should be rendered as plain text within an anchor
|
||||
await expect.element(screen.getByText("a link")).toBeInTheDocument();
|
||||
const link = screen.container.querySelector('a[href="https://example.com"]');
|
||||
expect(link).not.toBeNull();
|
||||
expect(link?.getAttribute("target")).toBe("_blank");
|
||||
expect(link?.getAttribute("rel")).toBe("noopener noreferrer");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,500 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { MediaDetailPanel } from "../../src/components/MediaDetailPanel";
|
||||
import type { MediaItem } from "../../src/lib/api";
|
||||
import { render } from "../utils/render.tsx";
|
||||
|
||||
vi.mock("../../src/lib/api", async () => {
|
||||
const actual = await vi.importActual("../../src/lib/api");
|
||||
return {
|
||||
...actual,
|
||||
updateMedia: vi.fn().mockResolvedValue({}),
|
||||
deleteMedia: vi.fn().mockResolvedValue({}),
|
||||
deleteFromProvider: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
});
|
||||
|
||||
// Import the mocked functions for assertions
|
||||
import { updateMedia, deleteMedia, deleteFromProvider } from "../../src/lib/api";
|
||||
|
||||
function QueryWrapper({ children }: { children: React.ReactNode }) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
function makeImageItem(overrides: Partial<MediaItem> = {}): MediaItem {
|
||||
return {
|
||||
id: "media-1",
|
||||
filename: "photo.jpg",
|
||||
mimeType: "image/jpeg",
|
||||
url: "https://example.com/photo.jpg",
|
||||
size: 204800,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
alt: "A nice photo",
|
||||
caption: "Photo caption",
|
||||
createdAt: "2025-01-15T10:30:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makePdfItem(overrides: Partial<MediaItem> = {}): MediaItem {
|
||||
return {
|
||||
id: "media-2",
|
||||
filename: "document.pdf",
|
||||
mimeType: "application/pdf",
|
||||
url: "https://example.com/document.pdf",
|
||||
size: 1048576,
|
||||
createdAt: "2025-01-15T10:30:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderPanel(props: Partial<React.ComponentProps<typeof MediaDetailPanel>> = {}) {
|
||||
const defaultProps: React.ComponentProps<typeof MediaDetailPanel> = {
|
||||
open: true,
|
||||
item: makeImageItem(),
|
||||
onClose: vi.fn(),
|
||||
onDeleted: vi.fn(),
|
||||
...props,
|
||||
};
|
||||
return render(
|
||||
<QueryWrapper>
|
||||
<MediaDetailPanel {...defaultProps} />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("MediaDetailPanel", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("does not render dialog contents when closed", async () => {
|
||||
const screen = await renderPanel({ open: false });
|
||||
await expect
|
||||
.element(screen.getByText("Media Details"), { timeout: 100 })
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays filename and file size", async () => {
|
||||
const item = makeImageItem({ size: 204800 });
|
||||
const screen = await renderPanel({ item });
|
||||
// Filename is in a disabled input
|
||||
const filenameInput = screen.getByLabelText("Filename");
|
||||
await expect.element(filenameInput).toHaveValue("photo.jpg");
|
||||
// 204800 bytes = 200 KB
|
||||
await expect.element(screen.getByText("200 KB")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays dimensions for images", async () => {
|
||||
const item = makeImageItem({ width: 1920, height: 1080 });
|
||||
const screen = await renderPanel({ item });
|
||||
await expect.element(screen.getByText("1920 × 1080")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the responsive two-column viewport-bounded dialog layout", async () => {
|
||||
const screen = await renderPanel();
|
||||
const dialog = screen.getByRole("dialog", { name: "Media Details" }).element();
|
||||
const header = screen.getByTestId("media-detail-dialog-header").element();
|
||||
const body = screen.getByTestId("media-detail-dialog-body").element();
|
||||
const previewColumn = screen.getByTestId("media-detail-dialog-preview-column").element();
|
||||
const detailsColumn = screen.getByTestId("media-detail-dialog-details-column").element();
|
||||
const fileFacts = screen.getByTestId("media-detail-dialog-file-facts").element();
|
||||
const footer = screen.getByTestId("media-detail-dialog-footer").element();
|
||||
|
||||
expect(body.className).toContain("grid-cols-1");
|
||||
expect(body.className).toContain("md:grid-cols-2");
|
||||
expect(dialog.style.height).toBe("");
|
||||
expect(dialog.style.maxHeight).toBe("min(88dvh, 48rem)");
|
||||
expect(dialog.className).toContain("data-starting-style:scale-90");
|
||||
expect(dialog.className).toContain("data-starting-style:opacity-0");
|
||||
expect(dialog.style.transitionProperty).toBe("scale, opacity");
|
||||
expect(header.style.padding).toBe("1.25rem 2rem");
|
||||
expect(previewColumn.contains(fileFacts)).toBe(true);
|
||||
expect(detailsColumn.contains(fileFacts)).toBe(false);
|
||||
expect(previewColumn.className).toContain("md:p-8");
|
||||
expect(detailsColumn.className).toContain("md:p-8");
|
||||
// Columns only constrain/scroll at md+; on mobile the body scrolls as one
|
||||
// column so collapsed columns can't compress and overlap their content.
|
||||
expect(body.className).toContain("overflow-y-auto");
|
||||
expect(body.className).toContain("md:overflow-hidden");
|
||||
expect(previewColumn.className).toContain("md:min-h-0");
|
||||
expect(previewColumn.className).toContain("md:overflow-y-auto");
|
||||
expect(previewColumn.className).not.toContain(" min-h-0");
|
||||
expect(detailsColumn.className).toContain("md:min-h-0");
|
||||
expect(detailsColumn.className).toContain("md:overflow-y-auto");
|
||||
expect(detailsColumn.className).not.toContain(" min-h-0");
|
||||
expect(fileFacts.className).toContain("space-y-3");
|
||||
expect(footer.style.padding).toBe("1.25rem 2rem");
|
||||
});
|
||||
|
||||
it("shows image preview for image mimeTypes", async () => {
|
||||
const item = makeImageItem();
|
||||
const screen = await renderPanel({ item });
|
||||
const img = screen.getByAltText("A nice photo");
|
||||
await expect.element(img).toBeInTheDocument();
|
||||
await expect.element(img).toHaveAttribute("src", item.url);
|
||||
});
|
||||
|
||||
it("does not show image preview for non-image mimeTypes", async () => {
|
||||
const item = makePdfItem();
|
||||
const screen = await renderPanel({ item });
|
||||
// Should show the mime type text instead of img
|
||||
await expect.element(screen.getByText("application/pdf")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("alt text input is editable", async () => {
|
||||
const item = makeImageItem({ alt: "Initial alt" });
|
||||
const screen = await renderPanel({ item });
|
||||
const altInput = screen.getByLabelText("Alt Text");
|
||||
await expect.element(altInput).toBeInTheDocument();
|
||||
expect(altInput.element().className).toContain("w-full");
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: "Why is this important?" }))
|
||||
.toBeInTheDocument();
|
||||
await altInput.fill("New alt text");
|
||||
await expect.element(altInput).toHaveValue("New alt text");
|
||||
});
|
||||
|
||||
it("shows caption textarea only for images", async () => {
|
||||
const imageItem = makeImageItem();
|
||||
const screen = await renderPanel({ item: imageItem });
|
||||
// Caption textarea should exist for images - find by placeholder
|
||||
const captionArea = screen.getByPlaceholder("Optional caption for display");
|
||||
await expect.element(captionArea).toBeInTheDocument();
|
||||
await expect.element(captionArea).toHaveValue("Photo caption");
|
||||
});
|
||||
|
||||
it("hides caption textarea for non-images", async () => {
|
||||
const pdfItem = makePdfItem();
|
||||
const screen = await renderPanel({ item: pdfItem });
|
||||
await expect
|
||||
.element(screen.getByPlaceholder("Optional caption for display"), { timeout: 100 })
|
||||
.not.toBeInTheDocument();
|
||||
await expect
|
||||
.element(screen.getByLabelText("Caption"), { timeout: 100 })
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filename input is disabled with tooltip help", async () => {
|
||||
const item = makeImageItem();
|
||||
const screen = await renderPanel({ item });
|
||||
const filenameInput = screen.getByLabelText("Filename");
|
||||
await expect.element(filenameInput).toBeDisabled();
|
||||
expect(filenameInput.element().className).toContain("bg-kumo-tint");
|
||||
expect(filenameInput.element().className).toContain("w-full");
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: "Why can't this be changed?" }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("save button is disabled when no changes", async () => {
|
||||
const item = makeImageItem();
|
||||
const screen = await renderPanel({ item });
|
||||
const saveBtn = screen.getByRole("button", { name: "Save" });
|
||||
await expect.element(saveBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
it("save button is enabled after changing alt text", async () => {
|
||||
const item = makeImageItem({ alt: "Original" });
|
||||
const screen = await renderPanel({ item });
|
||||
const altInput = screen.getByLabelText("Alt Text");
|
||||
await altInput.fill("Changed alt text");
|
||||
const saveBtn = screen.getByRole("button", { name: "Save" });
|
||||
await expect.element(saveBtn).toBeEnabled();
|
||||
});
|
||||
|
||||
it("save calls updateMedia with correct payload", async () => {
|
||||
const onClose = vi.fn();
|
||||
const item = makeImageItem({ alt: "Old alt", caption: "Old caption" });
|
||||
const screen = await renderPanel({ item, onClose });
|
||||
|
||||
const altInput = screen.getByLabelText("Alt Text");
|
||||
await altInput.fill("New alt");
|
||||
|
||||
const saveBtn = screen.getByRole("button", { name: "Save" });
|
||||
await expect.element(saveBtn).toBeEnabled();
|
||||
saveBtn.element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(updateMedia).toHaveBeenCalledWith("media-1", {
|
||||
alt: "New alt",
|
||||
caption: "Old caption",
|
||||
});
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("saves empty strings when clearing alt text and caption", async () => {
|
||||
const item = makeImageItem({ alt: "Old alt", caption: "Old caption" });
|
||||
const screen = await renderPanel({ item });
|
||||
|
||||
await screen.getByLabelText("Alt Text").fill("");
|
||||
await screen.getByLabelText("Caption").fill("");
|
||||
screen.getByRole("button", { name: "Save" }).element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(updateMedia).toHaveBeenCalledWith("media-1", {
|
||||
alt: "",
|
||||
caption: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("disables editing and closing while a save is pending", async () => {
|
||||
let resolveUpdate!: (item: MediaItem) => void;
|
||||
vi.mocked(updateMedia).mockImplementationOnce(
|
||||
() => new Promise<MediaItem>((resolve) => (resolveUpdate = resolve)),
|
||||
);
|
||||
const onClose = vi.fn();
|
||||
const item = makeImageItem({ alt: "Old alt", caption: "Old caption" });
|
||||
const screen = await renderPanel({ item, onClose });
|
||||
|
||||
const altInput = screen.getByLabelText("Alt Text");
|
||||
await altInput.fill("New alt");
|
||||
screen.getByRole("button", { name: "Save" }).element().click();
|
||||
|
||||
await expect.element(altInput).toBeDisabled();
|
||||
await expect.element(screen.getByRole("button", { name: "Saving..." })).toBeDisabled();
|
||||
await expect.element(screen.getByRole("button", { name: "Close" })).toBeDisabled();
|
||||
await expect.element(screen.getByRole("button", { name: "Cancel" })).toBeDisabled();
|
||||
|
||||
screen.getByRole("button", { name: "Close" }).element().click();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
resolveUpdate({ ...item, alt: "New alt" });
|
||||
await vi.waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("saves dirty metadata with the keyboard shortcut", async () => {
|
||||
const onClose = vi.fn();
|
||||
const item = makeImageItem({ alt: "Old alt", caption: "Old caption" });
|
||||
const screen = await renderPanel({ item, onClose });
|
||||
|
||||
await screen.getByLabelText("Alt Text").fill("Shortcut alt");
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { key: "s", ctrlKey: true }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(updateMedia).toHaveBeenCalledWith("media-1", {
|
||||
alt: "Shortcut alt",
|
||||
caption: "Old caption",
|
||||
});
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not consume the keyboard save shortcut when nothing can be saved", async () => {
|
||||
await renderPanel({
|
||||
item: makeImageItem({ provider: "cloudflare-images" }),
|
||||
providerName: "Cloudflare Images",
|
||||
});
|
||||
|
||||
const event = new KeyboardEvent("keydown", { key: "s", ctrlKey: true, cancelable: true });
|
||||
window.dispatchEvent(event);
|
||||
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
expect(updateMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("delete with confirm calls deleteMedia and onClose + onDeleted", async () => {
|
||||
const onClose = vi.fn();
|
||||
const onDeleted = vi.fn();
|
||||
const item = makeImageItem();
|
||||
|
||||
const screen = await renderPanel({ item, onClose, onDeleted });
|
||||
const deleteBtn = screen.getByRole("button", { name: "Delete" });
|
||||
deleteBtn.element().click();
|
||||
|
||||
// ConfirmDialog should appear
|
||||
await expect.element(screen.getByText("Delete Media?")).toBeInTheDocument();
|
||||
|
||||
// Direct DOM click to bypass Base UI inert overlay
|
||||
const allDeleteBtns = screen.getByRole("button", { name: "Delete" }).all();
|
||||
allDeleteBtns.at(-1)!.element().click();
|
||||
|
||||
// Wait for mutation to complete
|
||||
await vi.waitFor(() => {
|
||||
expect(deleteMedia).toHaveBeenCalledWith("media-1");
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
expect(onDeleted).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes provider assets through the provider API when deletion is supported", async () => {
|
||||
const onDeleted = vi.fn();
|
||||
const screen = await renderPanel({
|
||||
item: makeImageItem({ id: "provider-1", provider: "cloudflare-images" }),
|
||||
providerName: "Cloudflare Images",
|
||||
canDelete: true,
|
||||
onDeleted,
|
||||
});
|
||||
|
||||
screen.getByRole("button", { name: "Delete" }).element().click();
|
||||
await expect.element(screen.getByText("Delete Media?")).toBeInTheDocument();
|
||||
screen.getByRole("button", { name: "Delete" }).all().at(-1)!.element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(deleteFromProvider).toHaveBeenCalledWith("cloudflare-images", "provider-1");
|
||||
expect(deleteMedia).not.toHaveBeenCalled();
|
||||
expect(onDeleted).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("delete cancelled does not call deleteMedia", async () => {
|
||||
const item = makeImageItem();
|
||||
|
||||
const screen = await renderPanel({ item });
|
||||
const deleteBtn = screen.getByRole("button", { name: "Delete" });
|
||||
deleteBtn.element().click();
|
||||
|
||||
// ConfirmDialog should appear
|
||||
await expect.element(screen.getByText("Delete Media?")).toBeInTheDocument();
|
||||
|
||||
// Direct DOM click to bypass Base UI inert overlay
|
||||
screen.getByRole("button", { name: "Cancel" }).all().at(-1)!.element().click();
|
||||
|
||||
expect(deleteMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("close button calls onClose when clean", async () => {
|
||||
const onClose = vi.fn();
|
||||
const item = makeImageItem();
|
||||
const screen = await renderPanel({ item, onClose });
|
||||
|
||||
screen.getByRole("button", { name: "Close" }).element().click();
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("close button opens discard confirmation when dirty", async () => {
|
||||
const onClose = vi.fn();
|
||||
const item = makeImageItem({ alt: "Original" });
|
||||
const screen = await renderPanel({ item, onClose });
|
||||
|
||||
await screen.getByLabelText("Alt Text").fill("Changed alt");
|
||||
screen.getByRole("button", { name: "Close" }).element().click();
|
||||
|
||||
await expect.element(screen.getByText("Discard changes?")).toBeInTheDocument();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
screen.getByRole("button", { name: "Discard" }).element().click();
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancels the close fallback when reopened before the fallback timer fires", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const onClose = vi.fn();
|
||||
const onClosed = vi.fn();
|
||||
const firstItem = makeImageItem({ id: "media-1", filename: "first.jpg" });
|
||||
const secondItem = makeImageItem({ id: "media-2", filename: "second.jpg" });
|
||||
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<MediaDetailPanel
|
||||
open
|
||||
item={firstItem}
|
||||
onClose={onClose}
|
||||
onClosed={onClosed}
|
||||
onDeleted={vi.fn()}
|
||||
/>
|
||||
</QueryWrapper>,
|
||||
);
|
||||
|
||||
screen.getByRole("button", { name: "Close" }).element().click();
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
|
||||
await screen.rerender(
|
||||
<QueryWrapper>
|
||||
<MediaDetailPanel
|
||||
open
|
||||
item={secondItem}
|
||||
onClose={onClose}
|
||||
onClosed={onClosed}
|
||||
onDeleted={vi.fn()}
|
||||
/>
|
||||
</QueryWrapper>,
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
|
||||
expect(onClosed).not.toHaveBeenCalled();
|
||||
await expect.element(screen.getByLabelText("Filename")).toHaveValue("second.jpg");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("form fields reset when item prop changes", async () => {
|
||||
const item1 = makeImageItem({ id: "m1", alt: "Alt one", caption: "Cap one" });
|
||||
const item2 = makeImageItem({ id: "m2", alt: "Alt two", caption: "Cap two" });
|
||||
|
||||
const screen = await renderPanel({ item: item1 });
|
||||
|
||||
// Verify item1 alt is shown
|
||||
const altInput = screen.getByLabelText("Alt Text");
|
||||
await expect.element(altInput).toHaveValue("Alt one");
|
||||
|
||||
// Rerender with item2
|
||||
await screen.rerender(
|
||||
<QueryWrapper>
|
||||
<MediaDetailPanel open item={item2} onClose={vi.fn()} onDeleted={vi.fn()} />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
|
||||
// The alt text should now show item2's alt
|
||||
await expect.element(screen.getByLabelText("Alt Text")).toHaveValue("Alt two");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MediaDetailPanel file URL", () => {
|
||||
it("shows the absolute file URL with a Copy URL action", async () => {
|
||||
const screen = await renderPanel({
|
||||
item: makeImageItem({ url: "/_emdash/api/media/file/01ABC.jpg" }),
|
||||
});
|
||||
|
||||
// Relative local-storage URLs are shown as absolute (origin-resolved)
|
||||
// so they can be pasted anywhere. The Kumo ClipboardText component
|
||||
// renders the value as text and a copy button (labelled "Copy URL").
|
||||
const absolute = new URL("/_emdash/api/media/file/01ABC.jpg", window.location.origin).href;
|
||||
await expect.element(screen.getByText(absolute)).toBeVisible();
|
||||
await expect.element(screen.getByRole("button", { name: /Copy URL/ })).toBeVisible();
|
||||
});
|
||||
|
||||
it("does not expose provider preview URLs as public URLs", async () => {
|
||||
const screen = await renderPanel({
|
||||
item: makeImageItem({ provider: "cloudflare-images", url: "https://preview.example/image" }),
|
||||
providerName: "Cloudflare Images",
|
||||
});
|
||||
|
||||
await expect.element(screen.getByText("Managed by Cloudflare Images")).toBeVisible();
|
||||
await expect.element(screen.getByText("No public URL available")).toBeVisible();
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: /Copy URL/ }), { timeout: 100 })
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders provider assets read-only", async () => {
|
||||
const screen = await renderPanel({
|
||||
item: makeImageItem({ provider: "cloudflare-images" }),
|
||||
providerName: "Cloudflare Images",
|
||||
});
|
||||
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: "Delete" }), { timeout: 100 })
|
||||
.not.toBeInTheDocument();
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: "Save" }), { timeout: 100 })
|
||||
.not.toBeInTheDocument();
|
||||
await expect
|
||||
.element(screen.getByLabelText("Alt Text"), { timeout: 100 })
|
||||
.not.toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Uploaded:"), { timeout: 100 }).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,391 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { MediaLibrary } from "../../src/components/MediaLibrary";
|
||||
import type { MediaItem } from "../../src/lib/api";
|
||||
import { deleteMedia } from "../../src/lib/api";
|
||||
import { render } from "../utils/render.tsx";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const UPLOAD_CTA_PATTERN = /Upload images, videos, and documents to keep reusable assets/;
|
||||
const UPLOAD_TO_LIBRARY_PATTERN = /Upload to Library/;
|
||||
const UPLOAD_FILES_PATTERN = /Upload Files/;
|
||||
|
||||
vi.mock("../../src/lib/api", async () => {
|
||||
const actual = await vi.importActual("../../src/lib/api");
|
||||
return {
|
||||
...actual,
|
||||
fetchMediaProviders: vi.fn().mockResolvedValue([]),
|
||||
fetchProviderMedia: vi.fn().mockResolvedValue({ items: [] }),
|
||||
uploadToProvider: vi.fn().mockResolvedValue({}),
|
||||
updateMedia: vi.fn().mockResolvedValue({}),
|
||||
deleteMedia: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
});
|
||||
|
||||
function QueryWrapper({ children }: { children: React.ReactNode }) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
function renderLibrary(props: Partial<React.ComponentProps<typeof MediaLibrary>> = {}) {
|
||||
const defaultProps: React.ComponentProps<typeof MediaLibrary> = {
|
||||
items: [],
|
||||
isLoading: false,
|
||||
onUpload: vi.fn(),
|
||||
onSelect: vi.fn(),
|
||||
onItemUpdated: vi.fn(),
|
||||
...props,
|
||||
};
|
||||
return render(
|
||||
<QueryWrapper>
|
||||
<MediaLibrary {...defaultProps} />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
}
|
||||
|
||||
function makeMediaItem(overrides: Partial<MediaItem> = {}): MediaItem {
|
||||
return {
|
||||
id: "media_01",
|
||||
filename: "photo.jpg",
|
||||
mimeType: "image/jpeg",
|
||||
url: "https://example.com/photo.jpg",
|
||||
size: 102400,
|
||||
width: 800,
|
||||
height: 600,
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("MediaLibrary", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("rendering items", () => {
|
||||
it("displays media items in grid view by default", async () => {
|
||||
const items = [
|
||||
makeMediaItem({ id: "1", filename: "image1.jpg" }),
|
||||
makeMediaItem({ id: "2", filename: "image2.jpg" }),
|
||||
];
|
||||
const screen = await renderLibrary({ items });
|
||||
// Grid view is default — items render as buttons with alt text
|
||||
await expect.element(screen.getByRole("tab", { name: "Grid view" })).toBeInTheDocument();
|
||||
// Images should be present via their img alt attributes
|
||||
await expect.element(screen.getByAltText("image1.jpg")).toBeInTheDocument();
|
||||
await expect.element(screen.getByAltText("image2.jpg")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("grid items show image thumbnails for image mimeTypes", async () => {
|
||||
const items = [makeMediaItem({ id: "1", filename: "pic.jpg", mimeType: "image/jpeg" })];
|
||||
const screen = await renderLibrary({ items });
|
||||
const img = screen.getByAltText("pic.jpg");
|
||||
await expect.element(img).toBeInTheDocument();
|
||||
await expect.element(img).toHaveAttribute("src", "https://example.com/photo.jpg");
|
||||
});
|
||||
});
|
||||
|
||||
describe("view mode toggle", () => {
|
||||
it("switches between grid and list view", async () => {
|
||||
const items = [makeMediaItem({ id: "1", filename: "test.jpg" })];
|
||||
const screen = await renderLibrary({ items });
|
||||
|
||||
// Default is grid
|
||||
const listBtn = screen.getByRole("tab", { name: "List view" });
|
||||
await listBtn.click();
|
||||
|
||||
// In list view, filename appears in table cell
|
||||
await expect.element(screen.getByText("test.jpg")).toBeInTheDocument();
|
||||
// Table headers should be visible
|
||||
await expect.element(screen.getByText("Filename")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Type", { exact: true })).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Size")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("upload", () => {
|
||||
it("upload button triggers file input", async () => {
|
||||
const screen = await renderLibrary();
|
||||
// The upload button should be present
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: UPLOAD_TO_LIBRARY_PATTERN }))
|
||||
.toBeInTheDocument();
|
||||
// Hidden file input should exist
|
||||
const fileInput = screen.getByLabelText("Upload files");
|
||||
await expect.element(fileInput).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("item selection", () => {
|
||||
it("clicking an item opens detail dialog", async () => {
|
||||
const items = [makeMediaItem({ id: "1", filename: "photo.jpg", alt: "A photo" })];
|
||||
const screen = await renderLibrary({ items });
|
||||
|
||||
// Click the grid item button
|
||||
await screen.getByRole("button", { name: "photo.jpg" }).click();
|
||||
|
||||
// MediaDetailPanel should open showing the item details
|
||||
await expect.element(screen.getByText("Media Details")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the detail dialog on an animation frame so Kumo entry animation runs", async () => {
|
||||
let openFrame: FrameRequestCallback | undefined;
|
||||
const requestAnimationFrameSpy = vi
|
||||
.spyOn(window, "requestAnimationFrame")
|
||||
.mockImplementation((callback) => {
|
||||
openFrame = callback;
|
||||
return 1;
|
||||
});
|
||||
const cancelAnimationFrameSpy = vi
|
||||
.spyOn(window, "cancelAnimationFrame")
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
const items = [makeMediaItem({ id: "1", filename: "photo.jpg", alt: "A photo" })];
|
||||
const screen = await renderLibrary({ items });
|
||||
|
||||
await screen.getByRole("button", { name: "photo.jpg" }).click();
|
||||
|
||||
expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1);
|
||||
await expect
|
||||
.element(screen.getByText("Media Details"), { timeout: 100 })
|
||||
.not.toBeInTheDocument();
|
||||
|
||||
openFrame?.(performance.now());
|
||||
|
||||
await expect.element(screen.getByText("Media Details")).toBeInTheDocument();
|
||||
} finally {
|
||||
requestAnimationFrameSpy.mockRestore();
|
||||
cancelAnimationFrameSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves unsaved alt text when the media list refetches the same item", async () => {
|
||||
const item = makeMediaItem({ id: "1", filename: "photo.jpg", alt: "Server alt" });
|
||||
const screen = await renderLibrary({ items: [item] });
|
||||
|
||||
await screen.getByRole("button", { name: "photo.jpg" }).click();
|
||||
await screen.getByLabelText("Alt Text").fill("Unsaved alt");
|
||||
|
||||
await screen.rerender(
|
||||
<QueryWrapper>
|
||||
<MediaLibrary
|
||||
items={[makeMediaItem({ id: "1", filename: "photo.jpg", alt: "Refetched alt" })]}
|
||||
isLoading={false}
|
||||
/>
|
||||
</QueryWrapper>,
|
||||
);
|
||||
|
||||
await expect.element(screen.getByLabelText("Alt Text")).toHaveValue("Unsaved alt");
|
||||
});
|
||||
|
||||
it("deletes from the detail dialog with one local delete call", async () => {
|
||||
const onItemUpdated = vi.fn();
|
||||
const items = [makeMediaItem({ id: "1", filename: "photo.jpg" })];
|
||||
const screen = await renderLibrary({ items, onItemUpdated });
|
||||
|
||||
await screen.getByRole("button", { name: "photo.jpg" }).click();
|
||||
screen.getByRole("button", { name: "Delete" }).element().click();
|
||||
await expect.element(screen.getByText("Delete Media?")).toBeInTheDocument();
|
||||
screen.getByRole("button", { name: "Delete" }).all().at(-1)!.element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(deleteMedia).toHaveBeenCalledTimes(1);
|
||||
expect(deleteMedia).toHaveBeenCalledWith("1");
|
||||
expect(onItemUpdated).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("focuses the persistent heading after deleting the last asset", async () => {
|
||||
function Harness() {
|
||||
const [items, setItems] = React.useState([
|
||||
makeMediaItem({ id: "1", filename: "photo.jpg" }),
|
||||
]);
|
||||
return <MediaLibrary items={items} isLoading={false} onItemUpdated={() => setItems([])} />;
|
||||
}
|
||||
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<Harness />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
|
||||
await screen.getByRole("button", { name: "photo.jpg" }).click();
|
||||
screen.getByRole("button", { name: "Delete" }).element().click();
|
||||
await expect.element(screen.getByText("Delete Media?")).toBeInTheDocument();
|
||||
screen.getByRole("button", { name: "Delete" }).all().at(-1)!.element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.activeElement).toBe(
|
||||
screen.getByRole("heading", { name: "Media Library", exact: true }).element(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("empty state", () => {
|
||||
it("shows upload CTA when no items", async () => {
|
||||
const screen = await renderLibrary({ items: [] });
|
||||
await expect.element(screen.getByText("Your media library is empty")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText(UPLOAD_CTA_PATTERN)).toBeInTheDocument();
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: UPLOAD_FILES_PATTERN }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("loading state", () => {
|
||||
it("displays loading state", async () => {
|
||||
const screen = await renderLibrary({ isLoading: true });
|
||||
// When loading, neither empty state nor items are shown
|
||||
expect(screen.getByText("Your media library is empty").query()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("list view details", () => {
|
||||
it("list view shows table with filename and details", async () => {
|
||||
const items = [
|
||||
makeMediaItem({
|
||||
id: "1",
|
||||
filename: "document.pdf",
|
||||
mimeType: "application/pdf",
|
||||
size: 1048576,
|
||||
}),
|
||||
];
|
||||
const screen = await renderLibrary({ items });
|
||||
|
||||
// Switch to list view
|
||||
await screen.getByRole("tab", { name: "List view" }).click();
|
||||
|
||||
await expect.element(screen.getByText("document.pdf")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("application/pdf")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("1 MB")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("header", () => {
|
||||
it("shows Media Library heading", async () => {
|
||||
const screen = await renderLibrary();
|
||||
await expect
|
||||
.element(screen.getByRole("heading", { name: "Media Library", exact: true }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("load more pagination", () => {
|
||||
it("renders Load More button when hasMore is true", async () => {
|
||||
const items = [makeMediaItem({ id: "1", filename: "a.jpg" })];
|
||||
const screen = await renderLibrary({ items, hasMore: true, onLoadMore: vi.fn() });
|
||||
await expect.element(screen.getByRole("button", { name: "Load More" })).toBeInTheDocument();
|
||||
expect(screen.getByText("1 item").query()).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render Load More button when hasMore is false", async () => {
|
||||
const items = [makeMediaItem({ id: "1", filename: "a.jpg" })];
|
||||
const screen = await renderLibrary({ items, hasMore: false, onLoadMore: vi.fn() });
|
||||
expect(screen.getByRole("button", { name: "Load More" }).query()).toBeNull();
|
||||
});
|
||||
|
||||
it("invokes onLoadMore when Load More button is clicked", async () => {
|
||||
const onLoadMore = vi.fn();
|
||||
const items = [makeMediaItem({ id: "1", filename: "a.jpg" })];
|
||||
const screen = await renderLibrary({ items, hasMore: true, onLoadMore });
|
||||
await screen.getByRole("button", { name: "Load More" }).click();
|
||||
expect(onLoadMore).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps already-loaded items visible while fetching the next page (isLoading=true with items)", async () => {
|
||||
// Reproduces the Copilot review concern: when isLoading flips true
|
||||
// during a Load-More fetch, the grid must not be blanked out into a
|
||||
// centered spinner — already-rendered items should remain visible.
|
||||
const items = [makeMediaItem({ id: "1", filename: "first-page.jpg" })];
|
||||
const screen = await renderLibrary({
|
||||
items,
|
||||
isLoading: true,
|
||||
hasMore: true,
|
||||
onLoadMore: vi.fn(),
|
||||
});
|
||||
await expect.element(screen.getByAltText("first-page.jpg")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// #1221: the local library gained filename search + a type filter.
|
||||
describe("local search and filter", () => {
|
||||
it("reports the debounced filename query upward", async () => {
|
||||
const onLocalSearchChange = vi.fn();
|
||||
const items = [makeMediaItem({ id: "1", filename: "a.jpg" })];
|
||||
const screen = await renderLibrary({ items, onLocalSearchChange });
|
||||
|
||||
await screen.getByRole("searchbox", { name: "Search media" }).fill("vacation");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(onLocalSearchChange).toHaveBeenCalledWith("vacation");
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a MIME filter when a type is chosen", async () => {
|
||||
const onLocalMimeFilterChange = vi.fn();
|
||||
const items = [makeMediaItem({ id: "1", filename: "a.jpg" })];
|
||||
const screen = await renderLibrary({ items, onLocalMimeFilterChange });
|
||||
|
||||
// Open the type filter and choose Images.
|
||||
await screen.getByRole("combobox", { name: "Filter by type" }).click();
|
||||
await screen.getByRole("option", { name: "Images" }).click();
|
||||
|
||||
expect(onLocalMimeFilterChange).toHaveBeenCalledWith("image/");
|
||||
});
|
||||
|
||||
it("does not flash the empty-library state while clearing a zero-result search", async () => {
|
||||
function Harness() {
|
||||
const [search, setSearch] = React.useState("");
|
||||
const items = search ? [] : [makeMediaItem({ id: "1", filename: "restored.jpg" })];
|
||||
|
||||
return <MediaLibrary items={items} onLocalSearchChange={setSearch} isLoading={false} />;
|
||||
}
|
||||
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<Harness />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
|
||||
await screen.getByRole("searchbox", { name: "Search media" }).fill("missing");
|
||||
await expect.element(screen.getByText("No matching media")).toBeInTheDocument();
|
||||
|
||||
await screen.getByRole("searchbox", { name: "Search media" }).fill("");
|
||||
|
||||
expect(screen.getByText("Your media library is empty").query()).toBeNull();
|
||||
await expect.element(screen.getByAltText("restored.jpg")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not keep the local filter toolbar visible on empty provider tabs", async () => {
|
||||
const api = await import("../../src/lib/api");
|
||||
(api.fetchMediaProviders as any).mockResolvedValueOnce([
|
||||
{
|
||||
id: "cloudflare-images",
|
||||
name: "Cloudflare Images",
|
||||
capabilities: { browse: true, search: false, upload: false, delete: false },
|
||||
},
|
||||
]);
|
||||
|
||||
const screen = await renderLibrary({
|
||||
items: [makeMediaItem({ id: "1", filename: "a.jpg" })],
|
||||
});
|
||||
|
||||
await screen.getByRole("combobox", { name: "Filter by type" }).click();
|
||||
await screen.getByRole("option", { name: "Images" }).click();
|
||||
await screen.getByRole("tab", { name: "Cloudflare Images" }).click();
|
||||
|
||||
await expect.element(screen.getByText("No media found")).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Grid view" }).query()).toBeNull();
|
||||
expect(screen.getByRole("tab", { name: "List view" }).query()).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,526 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { MediaPickerModal } from "../../src/components/MediaPickerModal";
|
||||
import { render } from "../utils/render.tsx";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Anchored to the button's exact accessible name. Without anchors this also
|
||||
// matches the hidden file `<input aria-label="Upload file">` and trips
|
||||
// playwright's strict-mode "resolved to N elements" guard.
|
||||
const UPLOAD_BUTTON_REGEX = /^Upload$/;
|
||||
|
||||
vi.mock("../../src/lib/api", async () => {
|
||||
const actual = await vi.importActual("../../src/lib/api");
|
||||
return {
|
||||
...actual,
|
||||
fetchMediaList: vi.fn().mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: "m1",
|
||||
filename: "photo.jpg",
|
||||
mimeType: "image/jpeg",
|
||||
url: "/media/photo.jpg",
|
||||
size: 1024,
|
||||
width: 800,
|
||||
height: 600,
|
||||
createdAt: "2024-01-01",
|
||||
},
|
||||
{
|
||||
id: "m2",
|
||||
filename: "landscape.png",
|
||||
mimeType: "image/png",
|
||||
url: "/media/landscape.png",
|
||||
size: 2048,
|
||||
width: 1200,
|
||||
height: 800,
|
||||
createdAt: "2024-01-02",
|
||||
},
|
||||
],
|
||||
}),
|
||||
fetchMediaProviders: vi.fn().mockResolvedValue([]),
|
||||
fetchProviderMedia: vi.fn().mockResolvedValue({ items: [] }),
|
||||
uploadMedia: vi.fn().mockResolvedValue({ id: "m3", filename: "new.jpg" }),
|
||||
uploadToProvider: vi.fn().mockResolvedValue({}),
|
||||
updateMedia: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
});
|
||||
|
||||
function QueryWrapper({ children }: { children: React.ReactNode }) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
function renderModal(props: Partial<React.ComponentProps<typeof MediaPickerModal>> = {}) {
|
||||
const defaultProps: React.ComponentProps<typeof MediaPickerModal> = {
|
||||
open: true,
|
||||
onOpenChange: vi.fn(),
|
||||
onSelect: vi.fn(),
|
||||
...props,
|
||||
};
|
||||
return render(
|
||||
<QueryWrapper>
|
||||
<MediaPickerModal {...defaultProps} />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("MediaPickerModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("displaying items", () => {
|
||||
it("shows media items when open", async () => {
|
||||
const screen = await renderModal({ open: true });
|
||||
await expect.element(screen.getByRole("option", { name: "photo.jpg" })).toBeInTheDocument();
|
||||
await expect
|
||||
.element(screen.getByRole("option", { name: "landscape.png" }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the modal title", async () => {
|
||||
const screen = await renderModal({ title: "Pick an Image" });
|
||||
await expect.element(screen.getByText("Pick an Image")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("selection", () => {
|
||||
it("single click selects item (highlighted)", async () => {
|
||||
const screen = await renderModal();
|
||||
const option = screen.getByRole("option", { name: "photo.jpg" });
|
||||
await expect.element(option).toBeInTheDocument();
|
||||
|
||||
// Direct DOM click to bypass inert overlay
|
||||
const btn = option.element().querySelector("button")!;
|
||||
btn.click();
|
||||
|
||||
// Should show selected state via aria-selected
|
||||
await expect.element(option).toHaveAttribute("aria-selected", "true");
|
||||
// Footer should show selected filename in a <strong> tag
|
||||
await expect.element(screen.getByRole("strong")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("double click selects and calls onSelect", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const screen = await renderModal({ onSelect });
|
||||
|
||||
const option = screen.getByRole("option", { name: "photo.jpg" });
|
||||
await expect.element(option).toBeInTheDocument();
|
||||
|
||||
// Use direct DOM dblclick to bypass inert overlay
|
||||
const btn = option.element().querySelector("button")!;
|
||||
btn.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "m1", filename: "photo.jpg" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("Insert button disabled when nothing selected", async () => {
|
||||
await renderModal();
|
||||
// There are two Insert buttons — URL section and footer.
|
||||
// The footer Insert is the last one and should be disabled.
|
||||
await vi.waitFor(() => {
|
||||
const allInsertBtns = document.querySelectorAll("button");
|
||||
const insertBtns = [...allInsertBtns].filter((b) => b.textContent?.trim() === "Insert");
|
||||
// The footer Insert (last one) should be disabled
|
||||
const lastInsert = insertBtns.at(-1);
|
||||
expect(lastInsert?.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("Insert button enabled when item selected, calls onSelect", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const screen = await renderModal({ onSelect });
|
||||
|
||||
// Select an item via direct DOM click
|
||||
const option = screen.getByRole("option", { name: "photo.jpg" });
|
||||
await expect.element(option).toBeInTheDocument();
|
||||
const itemBtn = option.element().querySelector("button")!;
|
||||
itemBtn.click();
|
||||
|
||||
// Wait for selection to register
|
||||
await expect.element(option).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
// Click the footer Insert button (last Insert button)
|
||||
await vi.waitFor(() => {
|
||||
const allInsertBtns = document.querySelectorAll("button");
|
||||
const insertBtns = [...allInsertBtns].filter((b) => b.textContent?.trim() === "Insert");
|
||||
const lastInsert = insertBtns.at(-1)!;
|
||||
expect(lastInsert.disabled).toBe(false);
|
||||
lastInsert.click();
|
||||
});
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "m1", filename: "photo.jpg" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("URL input", () => {
|
||||
it("invalid URL shows error", async () => {
|
||||
const screen = await renderModal();
|
||||
|
||||
// The URL input has aria-label "Image URL"
|
||||
const urlInput = screen.getByLabelText("Image URL");
|
||||
await expect.element(urlInput).toBeInTheDocument();
|
||||
|
||||
// Type an invalid URL — use direct DOM since we're inside a dialog
|
||||
const inputEl = urlInput.element() as HTMLInputElement;
|
||||
// Manually set value and trigger change
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
"value",
|
||||
)!.set!;
|
||||
nativeInputValueSetter.call(inputEl, "not-a-url");
|
||||
inputEl.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
inputEl.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
|
||||
// Click the URL Insert button (first Insert button)
|
||||
await vi.waitFor(() => {
|
||||
const urlInsert = [...document.querySelectorAll("button")].find(
|
||||
(b) => b.textContent?.trim() === "Insert",
|
||||
)!;
|
||||
expect(urlInsert.disabled).toBe(false);
|
||||
urlInsert.click();
|
||||
});
|
||||
|
||||
await expect.element(screen.getByText("Please enter a valid URL")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("URL input: typing a URL and submitting triggers probe", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const screen = await renderModal({ onSelect });
|
||||
|
||||
const urlInput = screen.getByLabelText("Image URL");
|
||||
const inputEl = urlInput.element() as HTMLInputElement;
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
"value",
|
||||
)!.set!;
|
||||
nativeInputValueSetter.call(inputEl, "https://example.com/test.jpg");
|
||||
inputEl.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
inputEl.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
|
||||
// Click URL Insert button
|
||||
await vi.waitFor(() => {
|
||||
const urlInsert = [...document.querySelectorAll("button")].find(
|
||||
(b) => b.textContent?.trim() === "Insert",
|
||||
)!;
|
||||
urlInsert.click();
|
||||
});
|
||||
|
||||
// Image probe will fail in test env, so either onSelect called or error shown
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
const called = onSelect.mock.calls.length > 0;
|
||||
const hasError =
|
||||
document.body.textContent?.includes("Could not load image from URL") ?? false;
|
||||
expect(called || hasError).toBe(true);
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
});
|
||||
|
||||
it("hideUrlInput hides the URL input section (for non-image pickers)", async () => {
|
||||
const screen = await renderModal({ hideUrlInput: true });
|
||||
|
||||
// "Insert from URL" label should not appear when hidden
|
||||
await expect.element(screen.getByText("Select Image")).toBeInTheDocument();
|
||||
expect(document.body.textContent).not.toContain("Insert from URL");
|
||||
expect(document.body.textContent).not.toContain("or choose from library");
|
||||
|
||||
// The URL input itself should not be in the DOM
|
||||
const urlInput = document.querySelector('input[aria-label="Image URL"]');
|
||||
expect(urlInput).toBeNull();
|
||||
});
|
||||
|
||||
it("localOnly hides the URL input section", async () => {
|
||||
// `localOnly` is for fields whose storage model only persists a local
|
||||
// mediaId (e.g. site `logo`, `favicon`, `seo.defaultOgImage`). Selecting
|
||||
// an external URL would return an item the server cannot resolve later.
|
||||
const screen = await renderModal({ localOnly: true });
|
||||
|
||||
await expect.element(screen.getByText("Select Image")).toBeInTheDocument();
|
||||
expect(document.body.textContent).not.toContain("Insert from URL");
|
||||
|
||||
const urlInput = document.querySelector('input[aria-label="Image URL"]');
|
||||
expect(urlInput).toBeNull();
|
||||
});
|
||||
|
||||
it("renders external provider tabs by default (control for localOnly)", async () => {
|
||||
// Establishes that providers DO appear without `localOnly`. Without
|
||||
// this control assertion, the suppression test below could pass
|
||||
// purely because the providers query hadn't resolved yet.
|
||||
const api = await import("../../src/lib/api");
|
||||
(api.fetchMediaProviders as any).mockResolvedValueOnce([
|
||||
{
|
||||
id: "cloudflare-images",
|
||||
name: "Cloudflare Images",
|
||||
capabilities: { upload: true, search: false },
|
||||
},
|
||||
]);
|
||||
|
||||
const screen = await renderModal();
|
||||
await expect.element(screen.getByText("Cloudflare Images")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("localOnly suppresses external provider tabs and skips the providers fetch", async () => {
|
||||
const api = await import("../../src/lib/api");
|
||||
(api.fetchMediaProviders as any).mockResolvedValueOnce([
|
||||
{
|
||||
id: "cloudflare-images",
|
||||
name: "Cloudflare Images",
|
||||
capabilities: { upload: true, search: false },
|
||||
},
|
||||
{
|
||||
id: "unsplash",
|
||||
name: "Unsplash",
|
||||
capabilities: { upload: false, search: true },
|
||||
},
|
||||
]);
|
||||
|
||||
const screen = await renderModal({ localOnly: true });
|
||||
|
||||
await expect.element(screen.getByText("Select Image")).toBeInTheDocument();
|
||||
// External providers must not be reachable through any tab when
|
||||
// localOnly is set, even if the API would report them.
|
||||
expect(document.body.textContent).not.toContain("Cloudflare Images");
|
||||
expect(document.body.textContent).not.toContain("Unsplash");
|
||||
// `enabled: open && !localOnly` short-circuits the query, so the
|
||||
// fetch should never have been issued. This proves the assertion
|
||||
// above isn't just racing the resolve.
|
||||
expect(api.fetchMediaProviders).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mediaKind", () => {
|
||||
it("uses file-specific copy when mediaKind is 'file'", async () => {
|
||||
// Use an empty media list so the empty state copy renders.
|
||||
const api = await import("../../src/lib/api");
|
||||
(api.fetchMediaList as any).mockResolvedValueOnce({ items: [] });
|
||||
|
||||
const screen = await renderModal({ mediaKind: "file", hideUrlInput: true });
|
||||
|
||||
// Default title should be "Select File", not "Select Image"
|
||||
await expect.element(screen.getByText("Select File")).toBeInTheDocument();
|
||||
expect(document.body.textContent).not.toContain("Select Image");
|
||||
|
||||
// Empty-state hint and CTA should reference files, not images
|
||||
await expect.element(screen.getByText("Upload a file to get started")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Upload File")).toBeInTheDocument();
|
||||
expect(document.body.textContent).not.toContain("Upload an image to get started");
|
||||
expect(document.body.textContent).not.toContain("Upload Image");
|
||||
});
|
||||
|
||||
it("defaults to image-specific copy when mediaKind is unset", async () => {
|
||||
const api = await import("../../src/lib/api");
|
||||
(api.fetchMediaList as any).mockResolvedValueOnce({ items: [] });
|
||||
|
||||
const screen = await renderModal();
|
||||
|
||||
await expect.element(screen.getByText("Select Image")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Upload an image to get started")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancel and close", () => {
|
||||
it("Cancel closes modal", async () => {
|
||||
const onOpenChange = vi.fn();
|
||||
const screen = await renderModal({ onOpenChange });
|
||||
|
||||
await expect.element(screen.getByText("Select Image")).toBeInTheDocument();
|
||||
// Direct DOM click to bypass inert overlay
|
||||
const cancelEl = screen.getByText("Cancel").element();
|
||||
const cancelBtn = cancelEl.closest("button")!;
|
||||
cancelBtn.click();
|
||||
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("state reset", () => {
|
||||
it("state resets when modal reopens", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const onOpenChange = vi.fn();
|
||||
const screen = await renderModal({ open: true, onSelect, onOpenChange });
|
||||
|
||||
// Select an item
|
||||
const option = screen.getByRole("option", { name: "photo.jpg" });
|
||||
await expect.element(option).toBeInTheDocument();
|
||||
const btn = option.element().querySelector("button")!;
|
||||
btn.click();
|
||||
|
||||
// Verify selection
|
||||
await expect.element(option).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
// Close modal
|
||||
await screen.rerender(
|
||||
<QueryWrapper>
|
||||
<MediaPickerModal open={false} onOpenChange={onOpenChange} onSelect={onSelect} />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
|
||||
// Reopen modal
|
||||
await screen.rerender(
|
||||
<QueryWrapper>
|
||||
<MediaPickerModal open={true} onOpenChange={onOpenChange} onSelect={onSelect} />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
|
||||
// Footer Insert should be disabled (no selection after reset)
|
||||
await vi.waitFor(() => {
|
||||
const allInsertBtns = document.querySelectorAll("button");
|
||||
const insertBtns = [...allInsertBtns].filter((b) => b.textContent?.trim() === "Insert");
|
||||
const lastInsert = insertBtns.at(-1);
|
||||
expect(lastInsert?.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("upload", () => {
|
||||
it("upload button and file input are present", async () => {
|
||||
const screen = await renderModal();
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: UPLOAD_BUTTON_REGEX }))
|
||||
.toBeInTheDocument();
|
||||
await expect.element(screen.getByLabelText("Upload file")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("load more pagination", () => {
|
||||
it("renders Load More button when first page returns nextCursor", async () => {
|
||||
const api = await import("../../src/lib/api");
|
||||
(api.fetchMediaList as any).mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
id: "p1",
|
||||
filename: "page1.jpg",
|
||||
mimeType: "image/jpeg",
|
||||
url: "/media/page1.jpg",
|
||||
size: 1024,
|
||||
width: 800,
|
||||
height: 600,
|
||||
createdAt: "2024-01-01",
|
||||
},
|
||||
],
|
||||
nextCursor: "cursor-2",
|
||||
});
|
||||
|
||||
const screen = await renderModal();
|
||||
await expect.element(screen.getByRole("option", { name: "page1.jpg" })).toBeInTheDocument();
|
||||
await expect.element(screen.getByRole("button", { name: "Load More" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render Load More button when no nextCursor", async () => {
|
||||
const screen = await renderModal();
|
||||
// Default mock returns 2 items with no nextCursor → button should be absent
|
||||
await expect.element(screen.getByRole("option", { name: "photo.jpg" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Load More" }).query()).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps already-loaded items visible while fetching the next page", async () => {
|
||||
// Reproduces the Copilot review concern: when the next-page fetch is
|
||||
// in flight, the picker grid must not blank out into a centered
|
||||
// loader — the user's prior selection / scroll context would be lost.
|
||||
const api = await import("../../src/lib/api");
|
||||
const mock = api.fetchMediaList as any;
|
||||
mock.mockReset();
|
||||
let resolveSecond: (value: unknown) => void = () => {};
|
||||
const secondPagePromise = new Promise((resolve) => {
|
||||
resolveSecond = resolve;
|
||||
});
|
||||
mock
|
||||
.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
id: "p1",
|
||||
filename: "page1.jpg",
|
||||
mimeType: "image/jpeg",
|
||||
url: "/media/page1.jpg",
|
||||
size: 1024,
|
||||
width: 800,
|
||||
height: 600,
|
||||
createdAt: "2024-01-01",
|
||||
},
|
||||
],
|
||||
nextCursor: "cursor-2",
|
||||
})
|
||||
.mockReturnValueOnce(secondPagePromise);
|
||||
|
||||
const screen = await renderModal();
|
||||
await expect.element(screen.getByRole("option", { name: "page1.jpg" })).toBeInTheDocument();
|
||||
|
||||
const loadMoreBtn = [...document.querySelectorAll("button")].find(
|
||||
(b) => b.textContent?.trim() === "Load More",
|
||||
)!;
|
||||
loadMoreBtn.click();
|
||||
|
||||
// While the second page is still pending, the first-page item must
|
||||
// stay in the DOM (not be replaced by a centered loader).
|
||||
await expect.element(screen.getByRole("option", { name: "page1.jpg" })).toBeInTheDocument();
|
||||
|
||||
resolveSecond({ items: [] });
|
||||
});
|
||||
|
||||
it("Load More click fetches the next page with the previous cursor", async () => {
|
||||
const api = await import("../../src/lib/api");
|
||||
const mock = api.fetchMediaList as any;
|
||||
mock.mockReset();
|
||||
mock
|
||||
.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
id: "p1",
|
||||
filename: "page1.jpg",
|
||||
mimeType: "image/jpeg",
|
||||
url: "/media/page1.jpg",
|
||||
size: 1024,
|
||||
width: 800,
|
||||
height: 600,
|
||||
createdAt: "2024-01-01",
|
||||
},
|
||||
],
|
||||
nextCursor: "cursor-2",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
id: "p2",
|
||||
filename: "page2.jpg",
|
||||
mimeType: "image/jpeg",
|
||||
url: "/media/page2.jpg",
|
||||
size: 1024,
|
||||
width: 800,
|
||||
height: 600,
|
||||
createdAt: "2024-01-02",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const screen = await renderModal();
|
||||
await expect.element(screen.getByRole("option", { name: "page1.jpg" })).toBeInTheDocument();
|
||||
|
||||
// Direct DOM click to bypass the dialog's inert overlay
|
||||
const loadMoreBtn = [...document.querySelectorAll("button")].find(
|
||||
(b) => b.textContent?.trim() === "Load More",
|
||||
)!;
|
||||
loadMoreBtn.click();
|
||||
|
||||
await expect.element(screen.getByRole("option", { name: "page2.jpg" })).toBeInTheDocument();
|
||||
|
||||
// Second call should have been made with the previous response's cursor.
|
||||
expect(mock).toHaveBeenCalledTimes(2);
|
||||
expect(mock.mock.calls[1][0]).toEqual(
|
||||
expect.objectContaining({ cursor: "cursor-2", limit: 100 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,368 @@
|
||||
import { Toasty } from "@cloudflare/kumo";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { MenuEditor } from "../../src/components/MenuEditor";
|
||||
import { render } from "../utils/render.tsx";
|
||||
|
||||
vi.mock("@tanstack/react-router", async () => {
|
||||
const actual = await vi.importActual("@tanstack/react-router");
|
||||
return {
|
||||
...actual,
|
||||
Link: ({
|
||||
children,
|
||||
to,
|
||||
...props
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
to?: string;
|
||||
[key: string]: unknown;
|
||||
}) => (
|
||||
<a href={typeof to === "string" ? to : "#"} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
useParams: () => ({ name: "main-menu" }),
|
||||
useSearch: () => ({}),
|
||||
useNavigate: () => vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../src/lib/api", async () => {
|
||||
const actual = await vi.importActual("../../src/lib/api");
|
||||
return {
|
||||
...actual,
|
||||
fetchMenu: vi.fn(),
|
||||
createMenuItem: vi.fn().mockResolvedValue({ id: "3" }),
|
||||
deleteMenuItem: vi.fn().mockResolvedValue(undefined),
|
||||
updateMenuItem: vi.fn().mockResolvedValue({}),
|
||||
reorderMenuItems: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
});
|
||||
|
||||
import * as api from "../../src/lib/api";
|
||||
|
||||
const ADD_CUSTOM_LINK_REGEX = /Add Custom Link/;
|
||||
|
||||
const defaultMenu = {
|
||||
id: "menu1",
|
||||
name: "main-menu",
|
||||
label: "Main Menu",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
locale: "en",
|
||||
translationGroup: "menu1",
|
||||
items: [
|
||||
{
|
||||
id: "1",
|
||||
menuId: "menu1",
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
type: "custom",
|
||||
referenceCollection: null,
|
||||
referenceId: null,
|
||||
customUrl: "/",
|
||||
label: "Home",
|
||||
titleAttr: null,
|
||||
target: "_self",
|
||||
cssClasses: null,
|
||||
createdAt: "",
|
||||
locale: "en",
|
||||
translationGroup: "1",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
menuId: "menu1",
|
||||
parentId: null,
|
||||
sortOrder: 1,
|
||||
type: "custom",
|
||||
referenceCollection: null,
|
||||
referenceId: null,
|
||||
customUrl: "/about",
|
||||
label: "About",
|
||||
titleAttr: null,
|
||||
target: "_self",
|
||||
cssClasses: null,
|
||||
createdAt: "",
|
||||
locale: "en",
|
||||
translationGroup: "2",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return (
|
||||
<Toasty>
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
</Toasty>
|
||||
);
|
||||
}
|
||||
|
||||
describe("MenuEditor", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(api.fetchMenu).mockResolvedValue(defaultMenu);
|
||||
});
|
||||
|
||||
it("displays menu items in order", async () => {
|
||||
const screen = await render(<MenuEditor />, { wrapper: Wrapper });
|
||||
|
||||
// Use exact: true to avoid matching "/about" which contains "About"
|
||||
await expect.element(screen.getByText("Home")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("About", { exact: true })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("add item button opens dialog with label and URL inputs", async () => {
|
||||
const screen = await render(<MenuEditor />, { wrapper: Wrapper });
|
||||
|
||||
await expect.element(screen.getByRole("heading", { name: "Main Menu" })).toBeInTheDocument();
|
||||
await screen.getByRole("button", { name: ADD_CUSTOM_LINK_REGEX }).click();
|
||||
|
||||
await expect.element(screen.getByLabelText("Label")).toBeInTheDocument();
|
||||
await expect.element(screen.getByLabelText("URL")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("edit item opens dialog", async () => {
|
||||
const screen = await render(<MenuEditor />, { wrapper: Wrapper });
|
||||
|
||||
await expect.element(screen.getByText("Home")).toBeInTheDocument();
|
||||
|
||||
const editButtons = screen.getByRole("button", { name: "Edit" });
|
||||
await editButtons.first().click();
|
||||
|
||||
await expect
|
||||
.element(screen.getByRole("heading", { name: "Edit Menu Item" }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("delete item fires immediately without confirmation dialog", async () => {
|
||||
const screen = await render(<MenuEditor />, { wrapper: Wrapper });
|
||||
|
||||
await expect.element(screen.getByText("Home")).toBeInTheDocument();
|
||||
|
||||
// Delete buttons have aria-label="Delete"
|
||||
const deleteBtn = screen.getByRole("button", { name: "Delete" });
|
||||
await deleteBtn.first().click();
|
||||
|
||||
// No confirmation dialog should appear
|
||||
expect(screen.getByText("Are you sure").query()).toBeNull();
|
||||
expect(screen.getByText("Confirm").query()).toBeNull();
|
||||
});
|
||||
|
||||
it("up/down reorder buttons — first item up disabled, last item down disabled", async () => {
|
||||
const screen = await render(<MenuEditor />, { wrapper: Wrapper });
|
||||
|
||||
await expect.element(screen.getByText("Home")).toBeInTheDocument();
|
||||
|
||||
const disabledButtons = document.querySelectorAll("button[disabled]");
|
||||
// At least 2: first item's up + last item's down
|
||||
expect(disabledButtons.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("empty state when no items", async () => {
|
||||
vi.mocked(api.fetchMenu).mockResolvedValue({
|
||||
...defaultMenu,
|
||||
items: [],
|
||||
});
|
||||
|
||||
const screen = await render(<MenuEditor />, { wrapper: Wrapper });
|
||||
|
||||
await expect.element(screen.getByText("No menu items yet")).toBeInTheDocument();
|
||||
await expect
|
||||
.element(screen.getByText("Add links to build your navigation menu"))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows menu label as heading", async () => {
|
||||
const screen = await render(<MenuEditor />, { wrapper: Wrapper });
|
||||
|
||||
await expect.element(screen.getByRole("heading", { name: "Main Menu" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows custom URLs for custom link items", async () => {
|
||||
const screen = await render(<MenuEditor />, { wrapper: Wrapper });
|
||||
|
||||
await expect.element(screen.getByText("Home")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("/about")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("URL input accepts relative paths", async () => {
|
||||
const screen = await render(<MenuEditor />, { wrapper: Wrapper });
|
||||
|
||||
await screen.getByRole("button", { name: ADD_CUSTOM_LINK_REGEX }).click();
|
||||
|
||||
const urlInput = screen.getByLabelText("URL");
|
||||
await urlInput.fill("/about");
|
||||
|
||||
// The input should accept the value without browser validation errors
|
||||
const inputEl = urlInput.element() as HTMLInputElement;
|
||||
expect(inputEl.validity.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("URL input accepts absolute https URLs", async () => {
|
||||
const screen = await render(<MenuEditor />, { wrapper: Wrapper });
|
||||
|
||||
await screen.getByRole("button", { name: ADD_CUSTOM_LINK_REGEX }).click();
|
||||
|
||||
const urlInput = screen.getByLabelText("URL");
|
||||
await urlInput.fill("https://example.com");
|
||||
|
||||
const inputEl = urlInput.element() as HTMLInputElement;
|
||||
expect(inputEl.validity.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("URL input rejects bare domains without scheme", async () => {
|
||||
const screen = await render(<MenuEditor />, { wrapper: Wrapper });
|
||||
|
||||
await screen.getByRole("button", { name: ADD_CUSTOM_LINK_REGEX }).click();
|
||||
|
||||
const urlInput = screen.getByLabelText("URL");
|
||||
await urlInput.fill("example.com");
|
||||
|
||||
const inputEl = urlInput.element() as HTMLInputElement;
|
||||
expect(inputEl.validity.valid).toBe(false);
|
||||
});
|
||||
|
||||
it("edit dialog shows a Parent select that excludes the item itself", async () => {
|
||||
const screen = await render(<MenuEditor />, { wrapper: Wrapper });
|
||||
|
||||
await expect.element(screen.getByText("Home")).toBeInTheDocument();
|
||||
await screen.getByRole("button", { name: "Edit" }).first().click();
|
||||
|
||||
await expect
|
||||
.element(screen.getByRole("heading", { name: "Edit Menu Item" }))
|
||||
.toBeInTheDocument();
|
||||
// Use a native DOM click rather than a Playwright locator click: this
|
||||
// env doesn't load Tailwind CSS, so the Dialog's `fixed` positioning
|
||||
// class has no effect and Base UI's outside-click-capture layer (styled
|
||||
// via inline `position: fixed`) stacks on top per normal paint order,
|
||||
// failing Playwright's pointer-event hit test on the trigger.
|
||||
screen.getByRole("combobox", { name: "Parent" }).element().click();
|
||||
|
||||
await expect.element(screen.getByRole("option", { name: "About" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "Home", exact: true }).query()).toBeNull();
|
||||
});
|
||||
|
||||
it("edit dialog's Parent select excludes descendants to prevent cycles", async () => {
|
||||
vi.mocked(api.fetchMenu).mockResolvedValue({
|
||||
...defaultMenu,
|
||||
items: [
|
||||
...defaultMenu.items,
|
||||
{
|
||||
id: "3",
|
||||
menuId: "menu1",
|
||||
parentId: "1",
|
||||
sortOrder: 0,
|
||||
type: "custom",
|
||||
referenceCollection: null,
|
||||
referenceId: null,
|
||||
customUrl: "/services",
|
||||
label: "Services",
|
||||
titleAttr: null,
|
||||
target: "_self",
|
||||
cssClasses: null,
|
||||
createdAt: "",
|
||||
locale: "en",
|
||||
translationGroup: "3",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const screen = await render(<MenuEditor />, { wrapper: Wrapper });
|
||||
|
||||
await expect.element(screen.getByText("Home")).toBeInTheDocument();
|
||||
// Home is the first row; editing it must exclude its own child (Services)
|
||||
// from the Parent options, not just itself.
|
||||
await screen.getByRole("button", { name: "Edit" }).first().click();
|
||||
|
||||
await expect
|
||||
.element(screen.getByRole("heading", { name: "Edit Menu Item" }))
|
||||
.toBeInTheDocument();
|
||||
screen.getByRole("combobox", { name: "Parent" }).element().click();
|
||||
|
||||
await expect.element(screen.getByRole("option", { name: "About" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "Home", exact: true }).query()).toBeNull();
|
||||
expect(screen.getByRole("option", { name: "Services", exact: true }).query()).toBeNull();
|
||||
});
|
||||
|
||||
it("renders nested items indented under their parent", async () => {
|
||||
vi.mocked(api.fetchMenu).mockResolvedValue({
|
||||
...defaultMenu,
|
||||
items: [
|
||||
...defaultMenu.items,
|
||||
{
|
||||
id: "3",
|
||||
menuId: "menu1",
|
||||
parentId: "1",
|
||||
sortOrder: 0,
|
||||
type: "custom",
|
||||
referenceCollection: null,
|
||||
referenceId: null,
|
||||
customUrl: "/services",
|
||||
label: "Services",
|
||||
titleAttr: null,
|
||||
target: "_self",
|
||||
cssClasses: null,
|
||||
createdAt: "",
|
||||
locale: "en",
|
||||
translationGroup: "3",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const screen = await render(<MenuEditor />, { wrapper: Wrapper });
|
||||
|
||||
await expect.element(screen.getByText("Services", { exact: true })).toBeInTheDocument();
|
||||
const servicesRow = screen
|
||||
.getByText("Services", { exact: true })
|
||||
.element()
|
||||
.closest("div.border");
|
||||
expect((servicesRow as HTMLElement | null)?.style.marginInlineStart).toBe("1.5rem");
|
||||
});
|
||||
|
||||
it("reorder buttons only compare against siblings, not the whole flat list", async () => {
|
||||
vi.mocked(api.fetchMenu).mockResolvedValue({
|
||||
...defaultMenu,
|
||||
items: [
|
||||
...defaultMenu.items,
|
||||
{
|
||||
id: "3",
|
||||
menuId: "menu1",
|
||||
parentId: "1",
|
||||
sortOrder: 0,
|
||||
type: "custom",
|
||||
referenceCollection: null,
|
||||
referenceId: null,
|
||||
customUrl: "/services",
|
||||
label: "Services",
|
||||
titleAttr: null,
|
||||
target: "_self",
|
||||
cssClasses: null,
|
||||
createdAt: "",
|
||||
locale: "en",
|
||||
translationGroup: "3",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const screen = await render(<MenuEditor />, { wrapper: Wrapper });
|
||||
|
||||
await expect.element(screen.getByText("Services", { exact: true })).toBeInTheDocument();
|
||||
// "Services" is Home's only child, so both its up and down buttons are
|
||||
// disabled even though it isn't the first/last item in the flat list.
|
||||
const servicesRow = screen
|
||||
.getByText("Services", { exact: true })
|
||||
.element()
|
||||
.closest("div.border") as HTMLElement;
|
||||
const upBtn = servicesRow.querySelector('button[aria-label="Move up"]') as HTMLButtonElement;
|
||||
const downBtn = servicesRow.querySelector(
|
||||
'button[aria-label="Move down"]',
|
||||
) as HTMLButtonElement;
|
||||
expect(upBtn.disabled).toBe(true);
|
||||
expect(downBtn.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { Toasty } from "@cloudflare/kumo";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { MenuList } from "../../src/components/MenuList";
|
||||
import { render } from "../utils/render.tsx";
|
||||
|
||||
vi.mock("@tanstack/react-router", async () => {
|
||||
const actual = await vi.importActual("@tanstack/react-router");
|
||||
return {
|
||||
...actual,
|
||||
Link: ({
|
||||
children,
|
||||
to,
|
||||
...props
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
to?: string;
|
||||
[key: string]: unknown;
|
||||
}) => (
|
||||
<a href={typeof to === "string" ? to : "#"} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
useNavigate: () => vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../src/lib/api", async () => {
|
||||
const actual = await vi.importActual("../../src/lib/api");
|
||||
return {
|
||||
...actual,
|
||||
fetchMenus: vi.fn(),
|
||||
createMenu: vi.fn().mockResolvedValue({ name: "new-menu", label: "New Menu" }),
|
||||
deleteMenu: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
import * as api from "../../src/lib/api";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MAIN_MENU_ITEMS_REGEX = /main.*3 items/;
|
||||
const FOOTER_MENU_ITEMS_REGEX = /footer.*1 item(?!s)/;
|
||||
const DELETE_MENU_CONFIRMATION_REGEX = /Are you sure you want to delete this menu/;
|
||||
const CREATE_MENU_REGEX = /Create Menu/;
|
||||
|
||||
function mockMenus() {
|
||||
vi.mocked(api.fetchMenus).mockResolvedValue([
|
||||
{
|
||||
id: "m1",
|
||||
name: "main",
|
||||
label: "Main Menu",
|
||||
itemCount: 3,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
},
|
||||
{
|
||||
id: "m2",
|
||||
name: "footer",
|
||||
label: "Footer Menu",
|
||||
itemCount: 1,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return (
|
||||
<Toasty>
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
</Toasty>
|
||||
);
|
||||
}
|
||||
|
||||
describe("MenuList", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockMenus();
|
||||
});
|
||||
|
||||
it("displays list of menus with labels and item counts", async () => {
|
||||
const screen = await render(<MenuList />, { wrapper: Wrapper });
|
||||
|
||||
await expect.element(screen.getByRole("heading", { name: "Main Menu" })).toBeInTheDocument();
|
||||
await expect.element(screen.getByText(MAIN_MENU_ITEMS_REGEX)).toBeInTheDocument();
|
||||
await expect.element(screen.getByRole("heading", { name: "Footer Menu" })).toBeInTheDocument();
|
||||
await expect.element(screen.getByText(FOOTER_MENU_ITEMS_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Create Menu button opens dialog", async () => {
|
||||
const screen = await render(<MenuList />, { wrapper: Wrapper });
|
||||
|
||||
await screen.getByRole("button", { name: CREATE_MENU_REGEX }).click();
|
||||
await expect.element(screen.getByText("Create New Menu")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("create dialog has name and label inputs", async () => {
|
||||
const screen = await render(<MenuList />, { wrapper: Wrapper });
|
||||
|
||||
await screen.getByRole("button", { name: CREATE_MENU_REGEX }).click();
|
||||
|
||||
await expect.element(screen.getByLabelText("Name")).toBeInTheDocument();
|
||||
await expect.element(screen.getByLabelText("Label")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("delete button opens confirmation dialog", async () => {
|
||||
const screen = await render(<MenuList />, { wrapper: Wrapper });
|
||||
|
||||
await expect.element(screen.getByRole("heading", { name: "Main Menu" })).toBeInTheDocument();
|
||||
|
||||
await screen.getByRole("button", { name: "Delete main menu" }).click();
|
||||
|
||||
await expect.element(screen.getByRole("heading", { name: "Delete Menu" })).toBeInTheDocument();
|
||||
await expect.element(screen.getByText(DELETE_MENU_CONFIRMATION_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows empty state when no menus", async () => {
|
||||
vi.mocked(api.fetchMenus).mockResolvedValue([]);
|
||||
|
||||
const screen = await render(<MenuList />, { wrapper: Wrapper });
|
||||
|
||||
await expect.element(screen.getByText("No menus yet")).toBeInTheDocument();
|
||||
await expect
|
||||
.element(screen.getByText("Create your first navigation menu to get started"))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("each menu has an Edit link", async () => {
|
||||
const screen = await render(<MenuList />, { wrapper: Wrapper });
|
||||
|
||||
await expect.element(screen.getByRole("heading", { name: "Main Menu" })).toBeInTheDocument();
|
||||
|
||||
const editLinks = screen.getByText("Edit");
|
||||
await expect.element(editLinks.first()).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,392 @@
|
||||
import { Toasty } from "@cloudflare/kumo";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import type { PluginInfo, AdminManifest } from "../../src/lib/api";
|
||||
import type { PluginUpdateInfo } from "../../src/lib/api/marketplace";
|
||||
import { render } from "../utils/render.tsx";
|
||||
|
||||
// Mock router
|
||||
vi.mock("@tanstack/react-router", async () => {
|
||||
const actual = await vi.importActual("@tanstack/react-router");
|
||||
return {
|
||||
...actual,
|
||||
Link: ({ children, to, params, ...props }: any) => {
|
||||
let href = String(to ?? "");
|
||||
if (params && typeof params === "object") {
|
||||
for (const [key, value] of Object.entries(params as Record<string, unknown>)) {
|
||||
const stringified =
|
||||
value == null
|
||||
? ""
|
||||
: typeof value === "string" || typeof value === "number"
|
||||
? String(value)
|
||||
: "";
|
||||
if (key === "_splat") {
|
||||
href = href.replace("$", stringified);
|
||||
} else {
|
||||
href = href.replace(`$${key}`, stringified);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
useNavigate: () => vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockFetchPlugins = vi.fn<() => Promise<PluginInfo[]>>();
|
||||
const mockEnablePlugin = vi.fn();
|
||||
const mockDisablePlugin = vi.fn();
|
||||
|
||||
vi.mock("../../src/lib/api", async () => {
|
||||
const actual = await vi.importActual("../../src/lib/api");
|
||||
return {
|
||||
...actual,
|
||||
fetchPlugins: (...args: unknown[]) => mockFetchPlugins(...(args as [])),
|
||||
enablePlugin: (...args: unknown[]) => mockEnablePlugin(...(args as [])),
|
||||
disablePlugin: (...args: unknown[]) => mockDisablePlugin(...(args as [])),
|
||||
};
|
||||
});
|
||||
|
||||
const mockCheckPluginUpdates = vi.fn<() => Promise<PluginUpdateInfo[]>>();
|
||||
const mockUpdateMarketplacePlugin = vi.fn<() => Promise<void>>();
|
||||
const mockUninstallMarketplacePlugin = vi.fn<() => Promise<void>>();
|
||||
|
||||
vi.mock("../../src/lib/api/marketplace", async () => {
|
||||
const actual = await vi.importActual("../../src/lib/api/marketplace");
|
||||
return {
|
||||
...actual,
|
||||
checkPluginUpdates: (...args: unknown[]) => mockCheckPluginUpdates(...(args as [])),
|
||||
updateMarketplacePlugin: (...args: unknown[]) => mockUpdateMarketplacePlugin(...(args as [])),
|
||||
uninstallMarketplacePlugin: (...args: unknown[]) =>
|
||||
mockUninstallMarketplacePlugin(...(args as [])),
|
||||
};
|
||||
});
|
||||
|
||||
// Import after mocks
|
||||
const { PluginManager } = await import("../../src/components/PluginManager");
|
||||
|
||||
function makePlugin(overrides: Partial<PluginInfo> = {}): PluginInfo {
|
||||
return {
|
||||
id: "test-plugin",
|
||||
name: "Test Plugin",
|
||||
version: "1.0.0",
|
||||
enabled: true,
|
||||
status: "active",
|
||||
capabilities: ["hooks"],
|
||||
hasAdminPages: false,
|
||||
hasDashboardWidgets: false,
|
||||
hasHooks: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeManifest(overrides: Partial<AdminManifest> = {}): AdminManifest {
|
||||
return {
|
||||
version: "1.0.0",
|
||||
hash: "abc",
|
||||
collections: {},
|
||||
plugins: {},
|
||||
taxonomies: [],
|
||||
authMode: "passkey",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return (
|
||||
<QueryClientProvider client={qc}>
|
||||
<Toasty>{children}</Toasty>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("PluginManager", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetchPlugins.mockResolvedValue([
|
||||
makePlugin({
|
||||
id: "audit-log",
|
||||
name: "Audit Log",
|
||||
version: "1.0.0",
|
||||
enabled: true,
|
||||
hasAdminPages: true,
|
||||
capabilities: ["hooks", "pages"],
|
||||
}),
|
||||
makePlugin({
|
||||
id: "seo",
|
||||
name: "SEO Helper",
|
||||
version: "2.0.0",
|
||||
enabled: false,
|
||||
status: "inactive",
|
||||
hasAdminPages: false,
|
||||
capabilities: ["hooks"],
|
||||
}),
|
||||
]);
|
||||
mockEnablePlugin.mockResolvedValue({});
|
||||
mockDisablePlugin.mockResolvedValue({});
|
||||
mockCheckPluginUpdates.mockResolvedValue([]);
|
||||
mockUpdateMarketplacePlugin.mockResolvedValue(undefined);
|
||||
mockUninstallMarketplacePlugin.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("displays plugin list with names and versions", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<PluginManager />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Audit Log")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("v1.0.0")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("SEO Helper")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("v2.0.0")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("enabled plugins show toggle in on state", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<PluginManager />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Audit Log")).toBeInTheDocument();
|
||||
const enableToggle = screen.getByRole("switch", { name: "Disable plugin" });
|
||||
await expect.element(enableToggle).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disabled plugins show toggle in off state", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<PluginManager />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("SEO Helper")).toBeInTheDocument();
|
||||
const disableToggle = screen.getByRole("switch", { name: "Enable plugin" });
|
||||
await expect.element(disableToggle).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("settings link shown only for enabled plugins with admin pages", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<PluginManager />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Audit Log")).toBeInTheDocument();
|
||||
const settingsLinks = screen.getByRole("link", { name: "Settings" }).all();
|
||||
expect(settingsLinks.length).toBe(1);
|
||||
});
|
||||
|
||||
it("settings link points to the plugin root, not a /settings sub-path", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<PluginManager />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Audit Log")).toBeInTheDocument();
|
||||
const settingsLink = screen.getByRole("link", { name: "Settings" });
|
||||
await expect.element(settingsLink).toBeInTheDocument();
|
||||
const anchor = settingsLink.element() as HTMLAnchorElement;
|
||||
// Plugins are not required to expose a `/settings` sub-page; the gear
|
||||
// icon should land on the plugin's primary admin page.
|
||||
expect(anchor.getAttribute("href")).toMatch(/^\/plugins\/audit-log\/?$/);
|
||||
});
|
||||
|
||||
it("expand/collapse shows plugin details", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<PluginManager />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Audit Log")).toBeInTheDocument();
|
||||
const expandButtons = screen.getByRole("button", { name: "Expand details" }).all();
|
||||
expect(expandButtons.length).toBeGreaterThan(0);
|
||||
await expandButtons[0]!.click();
|
||||
await expect.element(screen.getByText("Capabilities")).toBeInTheDocument();
|
||||
await vi.waitFor(() => {
|
||||
const badges = document.querySelectorAll(".inline-flex.items-center.rounded-md.bg-kumo-tint");
|
||||
expect(badges.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("empty state when no plugins", async () => {
|
||||
mockFetchPlugins.mockResolvedValue([]);
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<PluginManager />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("No plugins configured")).toBeInTheDocument();
|
||||
await expect
|
||||
.element(
|
||||
screen.getByText("Add plugins to your astro.config.mjs to extend EmDash functionality."),
|
||||
)
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Marketplace features
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it("shows Marketplace link when manifest has marketplace URL", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<PluginManager
|
||||
manifest={makeManifest({ marketplace: "https://marketplace.emdashcms.com" })}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Audit Log")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Marketplace")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides Marketplace link when no marketplace configured", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<PluginManager manifest={makeManifest()} />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Audit Log")).toBeInTheDocument();
|
||||
const marketplaceLink = screen.getByText("Marketplace");
|
||||
await expect.element(marketplaceLink).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Marketplace badge on marketplace-installed plugins", async () => {
|
||||
mockFetchPlugins.mockResolvedValue([
|
||||
makePlugin({
|
||||
id: "mp-plugin",
|
||||
name: "Marketplace Plugin",
|
||||
source: "marketplace",
|
||||
marketplaceVersion: "1.2.0",
|
||||
}),
|
||||
]);
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<PluginManager />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Marketplace Plugin")).toBeInTheDocument();
|
||||
// Look for the "Marketplace" badge
|
||||
const badges = screen.getByText("Marketplace").all();
|
||||
// At least one should be the source badge on the card (not the nav link)
|
||||
expect(badges.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("shows 'Check for updates' button when marketplace plugins exist", async () => {
|
||||
mockFetchPlugins.mockResolvedValue([
|
||||
makePlugin({
|
||||
id: "mp-plugin",
|
||||
name: "MP Plugin",
|
||||
source: "marketplace",
|
||||
}),
|
||||
]);
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<PluginManager />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Check for updates")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides 'Check for updates' button when no marketplace plugins", async () => {
|
||||
mockFetchPlugins.mockResolvedValue([
|
||||
makePlugin({ id: "config-plugin", name: "Config Plugin", source: "config" }),
|
||||
]);
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<PluginManager />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Config Plugin")).toBeInTheDocument();
|
||||
const checkBtn = screen.getByText("Check for updates");
|
||||
await expect.element(checkBtn).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows marketplace source in expanded details", async () => {
|
||||
mockFetchPlugins.mockResolvedValue([
|
||||
makePlugin({
|
||||
id: "mp-plugin",
|
||||
name: "MP Plugin",
|
||||
source: "marketplace",
|
||||
marketplaceVersion: "1.5.0",
|
||||
}),
|
||||
]);
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<PluginManager />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("MP Plugin")).toBeInTheDocument();
|
||||
// Expand
|
||||
const expandBtn = screen.getByRole("button", { name: "Expand details" });
|
||||
await expandBtn.click();
|
||||
await expect
|
||||
.element(screen.getByText("Installed from marketplace (v1.5.0)"))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows uninstall button for marketplace plugins in expanded details", async () => {
|
||||
mockFetchPlugins.mockResolvedValue([
|
||||
makePlugin({
|
||||
id: "mp-plugin",
|
||||
name: "MP Plugin",
|
||||
source: "marketplace",
|
||||
}),
|
||||
]);
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<PluginManager />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("MP Plugin")).toBeInTheDocument();
|
||||
// Expand
|
||||
const expandBtn = screen.getByRole("button", { name: "Expand details" });
|
||||
await expandBtn.click();
|
||||
await expect.element(screen.getByText("Uninstall")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uninstall button opens confirmation dialog", async () => {
|
||||
mockFetchPlugins.mockResolvedValue([
|
||||
makePlugin({
|
||||
id: "mp-plugin",
|
||||
name: "MP Plugin",
|
||||
source: "marketplace",
|
||||
}),
|
||||
]);
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<PluginManager />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("MP Plugin")).toBeInTheDocument();
|
||||
const expandBtn = screen.getByRole("button", { name: "Expand details" });
|
||||
await expandBtn.click();
|
||||
await screen.getByText("Uninstall").click();
|
||||
// Confirm dialog
|
||||
await expect.element(screen.getByText("Uninstall MP Plugin?")).toBeInTheDocument();
|
||||
await expect
|
||||
.element(screen.getByText("This will remove the plugin and its bundle from your site."))
|
||||
.toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Also delete plugin storage data")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("empty state mentions marketplace when configured", async () => {
|
||||
mockFetchPlugins.mockResolvedValue([]);
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<PluginManager
|
||||
manifest={makeManifest({ marketplace: "https://marketplace.emdashcms.com" })}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("No plugins configured")).toBeInTheDocument();
|
||||
// The empty state links to the marketplace
|
||||
await expect.element(screen.getByText("marketplace", { exact: true })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,374 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
_portableTextToProsemirror,
|
||||
_prosemirrorToPortableText,
|
||||
} from "../../src/components/PortableTextEditor";
|
||||
|
||||
type ListBlock = {
|
||||
_type: "block";
|
||||
style: "normal";
|
||||
listItem: "bullet" | "number";
|
||||
level: number;
|
||||
children: Array<{ _type: "span"; text: string }>;
|
||||
};
|
||||
|
||||
function isListBlock(b: unknown): b is ListBlock {
|
||||
return (
|
||||
typeof b === "object" &&
|
||||
b !== null &&
|
||||
(b as { _type?: unknown })._type === "block" &&
|
||||
"listItem" in (b as Record<string, unknown>)
|
||||
);
|
||||
}
|
||||
|
||||
describe("ProseMirror → PortableText: nested list level", () => {
|
||||
it("emits level=1 for a single-level bullet list", () => {
|
||||
const pmDoc = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "bulletList",
|
||||
content: [
|
||||
{
|
||||
type: "listItem",
|
||||
content: [{ type: "paragraph", content: [{ type: "text", text: "Item one" }] }],
|
||||
},
|
||||
{
|
||||
type: "listItem",
|
||||
content: [{ type: "paragraph", content: [{ type: "text", text: "Item two" }] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = _prosemirrorToPortableText(pmDoc).filter(isListBlock);
|
||||
|
||||
expect(result.map((b) => [b.listItem, b.level, b.children[0]?.text])).toEqual([
|
||||
["bullet", 1, "Item one"],
|
||||
["bullet", 1, "Item two"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits level=2 for bullets nested inside a parent bullet", () => {
|
||||
const pmDoc = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "bulletList",
|
||||
content: [
|
||||
{
|
||||
type: "listItem",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "Parent" }] },
|
||||
{
|
||||
type: "bulletList",
|
||||
content: [
|
||||
{
|
||||
type: "listItem",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "Child" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = _prosemirrorToPortableText(pmDoc).filter(isListBlock);
|
||||
|
||||
expect(result.map((b) => [b.listItem, b.level, b.children[0]?.text])).toEqual([
|
||||
["bullet", 1, "Parent"],
|
||||
["bullet", 2, "Child"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves listItem type when an ordered list nests inside a bullet", () => {
|
||||
const pmDoc = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "bulletList",
|
||||
content: [
|
||||
{
|
||||
type: "listItem",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "Bullet top" }] },
|
||||
{
|
||||
type: "orderedList",
|
||||
content: [
|
||||
{
|
||||
type: "listItem",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "Numbered child" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = _prosemirrorToPortableText(pmDoc).filter(isListBlock);
|
||||
|
||||
expect(result.map((b) => [b.listItem, b.level, b.children[0]?.text])).toEqual([
|
||||
["bullet", 1, "Bullet top"],
|
||||
["number", 2, "Numbered child"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles three-level nesting", () => {
|
||||
const pmDoc = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "bulletList",
|
||||
content: [
|
||||
{
|
||||
type: "listItem",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "L1" }] },
|
||||
{
|
||||
type: "bulletList",
|
||||
content: [
|
||||
{
|
||||
type: "listItem",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "L2" }] },
|
||||
{
|
||||
type: "bulletList",
|
||||
content: [
|
||||
{
|
||||
type: "listItem",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "L3" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = _prosemirrorToPortableText(pmDoc).filter(isListBlock);
|
||||
|
||||
expect(result.map((b) => [b.level, b.children[0]?.text])).toEqual([
|
||||
[1, "L1"],
|
||||
[2, "L2"],
|
||||
[3, "L3"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
type PMList = {
|
||||
type: "bulletList" | "orderedList";
|
||||
content: Array<{
|
||||
type: "listItem";
|
||||
content: Array<{ type: string; content?: unknown[] }>;
|
||||
}>;
|
||||
};
|
||||
|
||||
function findFirstList(node: { content?: unknown[] }): PMList | null {
|
||||
if (!node.content) return null;
|
||||
for (const child of node.content as Array<{ type?: string }>) {
|
||||
if (child.type === "bulletList" || child.type === "orderedList") return child as PMList;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getParagraphText(listItem: { content?: unknown[] }): string | undefined {
|
||||
if (!listItem.content) return undefined;
|
||||
const para = (listItem.content as Array<{ type?: string; content?: unknown[] }>).find(
|
||||
(c) => c.type === "paragraph",
|
||||
);
|
||||
const text = (para?.content as Array<{ type?: string; text?: string }> | undefined)?.find(
|
||||
(c) => c.type === "text",
|
||||
);
|
||||
return text?.text;
|
||||
}
|
||||
|
||||
function getNestedList(listItem: { content?: unknown[] }): PMList | undefined {
|
||||
return (listItem.content as Array<{ type?: string }> | undefined)?.find(
|
||||
(c) => c.type === "bulletList" || c.type === "orderedList",
|
||||
) as PMList | undefined;
|
||||
}
|
||||
|
||||
function pt(
|
||||
listItem: "bullet" | "number",
|
||||
level: number,
|
||||
text: string,
|
||||
): {
|
||||
_type: "block";
|
||||
_key: string;
|
||||
style: "normal";
|
||||
listItem: "bullet" | "number";
|
||||
level: number;
|
||||
children: Array<{ _type: "span"; _key: string; text: string }>;
|
||||
} {
|
||||
return {
|
||||
_type: "block",
|
||||
_key: `b${level}-${text}`,
|
||||
style: "normal",
|
||||
listItem,
|
||||
level,
|
||||
children: [{ _type: "span", _key: `s-${text}`, text }],
|
||||
};
|
||||
}
|
||||
|
||||
describe("PortableText → ProseMirror: nested list level", () => {
|
||||
it("nests level=2 bullets inside their parent listItem", () => {
|
||||
const result = _portableTextToProsemirror([
|
||||
pt("bullet", 1, "Parent"),
|
||||
pt("bullet", 2, "Child"),
|
||||
]);
|
||||
const list = findFirstList(result);
|
||||
expect(list).toBeTruthy();
|
||||
expect(list!.type).toBe("bulletList");
|
||||
expect(list!.content).toHaveLength(1);
|
||||
expect(getParagraphText(list!.content[0]!)).toBe("Parent");
|
||||
|
||||
const nested = getNestedList(list!.content[0]!);
|
||||
expect(nested?.type).toBe("bulletList");
|
||||
expect(nested?.content).toHaveLength(1);
|
||||
expect(getParagraphText(nested!.content[0]!)).toBe("Child");
|
||||
});
|
||||
|
||||
it("preserves listType when an ordered list nests inside a bullet", () => {
|
||||
const result = _portableTextToProsemirror([
|
||||
pt("bullet", 1, "Bullet top"),
|
||||
pt("number", 2, "Numbered child"),
|
||||
]);
|
||||
const outer = findFirstList(result);
|
||||
expect(outer?.type).toBe("bulletList");
|
||||
expect(outer?.content).toHaveLength(1);
|
||||
|
||||
const inner = getNestedList(outer!.content[0]!);
|
||||
expect(inner?.type).toBe("orderedList");
|
||||
expect(inner?.content).toHaveLength(1);
|
||||
expect(getParagraphText(inner!.content[0]!)).toBe("Numbered child");
|
||||
});
|
||||
|
||||
it("does not flatten level=2 siblings into a top-level number list", () => {
|
||||
// Regression for the outer-loop run grouping: a number block at level=2
|
||||
// must be folded into its parent bullet's run, not start a new top-level
|
||||
// orderedList.
|
||||
const result = _portableTextToProsemirror([
|
||||
pt("bullet", 1, "Parent"),
|
||||
pt("number", 2, "Numbered child"),
|
||||
pt("bullet", 1, "Sibling"),
|
||||
]);
|
||||
const lists = (result.content as Array<{ type?: string }>).filter(
|
||||
(c) => c.type === "bulletList" || c.type === "orderedList",
|
||||
) as PMList[];
|
||||
expect(lists).toHaveLength(1);
|
||||
expect(lists[0]!.type).toBe("bulletList");
|
||||
expect(lists[0]!.content).toHaveLength(2);
|
||||
expect(getParagraphText(lists[0]!.content[0]!)).toBe("Parent");
|
||||
expect(getParagraphText(lists[0]!.content[1]!)).toBe("Sibling");
|
||||
expect(getNestedList(lists[0]!.content[0]!)?.type).toBe("orderedList");
|
||||
});
|
||||
|
||||
it("handles three-level nesting", () => {
|
||||
const result = _portableTextToProsemirror([
|
||||
pt("bullet", 1, "L1"),
|
||||
pt("bullet", 2, "L2"),
|
||||
pt("bullet", 3, "L3"),
|
||||
]);
|
||||
const l1 = findFirstList(result);
|
||||
expect(getParagraphText(l1!.content[0]!)).toBe("L1");
|
||||
|
||||
const l2 = getNestedList(l1!.content[0]!);
|
||||
expect(l2?.type).toBe("bulletList");
|
||||
expect(getParagraphText(l2!.content[0]!)).toBe("L2");
|
||||
|
||||
const l3 = getNestedList(l2!.content[0]!);
|
||||
expect(l3?.type).toBe("bulletList");
|
||||
expect(getParagraphText(l3!.content[0]!)).toBe("L3");
|
||||
});
|
||||
|
||||
it("keeps deeper nesting under its true parent for mixed-type 3-level trees", () => {
|
||||
// Regression for convertPTListItem's nested grouping: it used to
|
||||
// break the group on every `listItem` change regardless of depth,
|
||||
// so a level-3 block ended up as a sibling sub-list under the
|
||||
// level-1 item instead of nesting under the matching level-2 item
|
||||
// — and the round-trip would degrade level-3 to level-2.
|
||||
const original = [
|
||||
pt("bullet", 1, "A"),
|
||||
pt("number", 2, "B"),
|
||||
pt("bullet", 3, "C"),
|
||||
pt("number", 2, "D"),
|
||||
];
|
||||
const pm = _portableTextToProsemirror(original);
|
||||
|
||||
const outer = findFirstList(pm);
|
||||
expect(outer?.type).toBe("bulletList");
|
||||
expect(outer?.content).toHaveLength(1);
|
||||
expect(getParagraphText(outer!.content[0]!)).toBe("A");
|
||||
|
||||
const numbered = getNestedList(outer!.content[0]!);
|
||||
expect(numbered?.type).toBe("orderedList");
|
||||
expect(numbered?.content).toHaveLength(2);
|
||||
expect(getParagraphText(numbered!.content[0]!)).toBe("B");
|
||||
expect(getParagraphText(numbered!.content[1]!)).toBe("D");
|
||||
|
||||
const cInBullets = getNestedList(numbered!.content[0]!);
|
||||
expect(cInBullets?.type).toBe("bulletList");
|
||||
expect(getParagraphText(cInBullets!.content[0]!)).toBe("C");
|
||||
|
||||
// Round-trip must keep C at level 3, not collapse it to level 2.
|
||||
const roundTripped = _prosemirrorToPortableText(pm).filter(
|
||||
(b): b is (typeof original)[number] =>
|
||||
typeof b === "object" && b !== null && (b as { _type?: string })._type === "block",
|
||||
);
|
||||
expect(roundTripped.map((b) => [b.listItem, b.level, b.children[0]?.text])).toEqual([
|
||||
["bullet", 1, "A"],
|
||||
["number", 2, "B"],
|
||||
["bullet", 3, "C"],
|
||||
["number", 2, "D"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Round-trip: PT → PM → PT preserves nested list level", () => {
|
||||
it("keeps level and listItem for a 2-level bullet → number tree", () => {
|
||||
const original = [
|
||||
pt("bullet", 1, "Top"),
|
||||
pt("number", 2, "Nested"),
|
||||
pt("bullet", 1, "Sibling"),
|
||||
];
|
||||
const pm = _portableTextToProsemirror(original);
|
||||
const roundTripped = _prosemirrorToPortableText(pm).filter(
|
||||
(b): b is (typeof original)[number] =>
|
||||
typeof b === "object" && b !== null && (b as { _type?: string })._type === "block",
|
||||
);
|
||||
expect(roundTripped.map((b) => [b.listItem, b.level, b.children[0]?.text])).toEqual([
|
||||
["bullet", 1, "Top"],
|
||||
["number", 2, "Nested"],
|
||||
["bullet", 1, "Sibling"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,401 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
_portableTextToProsemirror,
|
||||
_prosemirrorToPortableText,
|
||||
} from "../../src/components/PortableTextEditor";
|
||||
|
||||
describe("Table conversion: PortableText ↔ ProseMirror", () => {
|
||||
describe("PortableText → ProseMirror", () => {
|
||||
it("converts simple table with text", () => {
|
||||
const ptBlocks = [
|
||||
{
|
||||
_type: "table",
|
||||
_key: "t1",
|
||||
hasHeaderRow: true,
|
||||
rows: [
|
||||
{
|
||||
_type: "tableRow",
|
||||
_key: "r1",
|
||||
cells: [
|
||||
{
|
||||
_type: "tableCell",
|
||||
_key: "c1",
|
||||
isHeader: true,
|
||||
content: [{ _type: "span", _key: "s1", text: "Header 1" }],
|
||||
},
|
||||
{
|
||||
_type: "tableCell",
|
||||
_key: "c2",
|
||||
isHeader: true,
|
||||
content: [{ _type: "span", _key: "s2", text: "Header 2" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
_type: "tableRow",
|
||||
_key: "r2",
|
||||
cells: [
|
||||
{
|
||||
_type: "tableCell",
|
||||
_key: "c3",
|
||||
content: [{ _type: "span", _key: "s3", text: "Cell 1" }],
|
||||
},
|
||||
{
|
||||
_type: "tableCell",
|
||||
_key: "c4",
|
||||
content: [{ _type: "span", _key: "s4", text: "Cell 2" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = _portableTextToProsemirror(ptBlocks);
|
||||
|
||||
expect(result.type).toBe("doc");
|
||||
expect(result.content).toHaveLength(1);
|
||||
|
||||
const table = result.content[0];
|
||||
expect(table.type).toBe("table");
|
||||
expect(table.content).toHaveLength(2);
|
||||
|
||||
const headerRow = table.content[0];
|
||||
expect(headerRow.type).toBe("tableRow");
|
||||
expect(headerRow.content[0].type).toBe("tableHeader");
|
||||
expect(headerRow.content[1].type).toBe("tableHeader");
|
||||
|
||||
const dataRow = table.content[1];
|
||||
expect(dataRow.type).toBe("tableRow");
|
||||
expect(dataRow.content[0].type).toBe("tableCell");
|
||||
});
|
||||
|
||||
it("converts table with text formatting marks", () => {
|
||||
const ptBlocks = [
|
||||
{
|
||||
_type: "table",
|
||||
_key: "t1",
|
||||
rows: [
|
||||
{
|
||||
_type: "tableRow",
|
||||
_key: "r1",
|
||||
cells: [
|
||||
{
|
||||
_type: "tableCell",
|
||||
_key: "c1",
|
||||
content: [{ _type: "span", _key: "s1", text: "Bold", marks: ["strong"] }],
|
||||
},
|
||||
{
|
||||
_type: "tableCell",
|
||||
_key: "c2",
|
||||
content: [{ _type: "span", _key: "s2", text: "Italic", marks: ["em"] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = _portableTextToProsemirror(ptBlocks);
|
||||
|
||||
const table = result.content[0];
|
||||
const cell1 = table.content[0].content[0];
|
||||
const cell1Para = cell1.content[0];
|
||||
const cell1Text = cell1Para.content[0];
|
||||
|
||||
expect(cell1Text.text).toBe("Bold");
|
||||
expect(cell1Text.marks).toContainEqual({ type: "bold" });
|
||||
|
||||
const cell2 = table.content[0].content[1];
|
||||
const cell2Para = cell2.content[0];
|
||||
const cell2Text = cell2Para.content[0];
|
||||
|
||||
expect(cell2Text.text).toBe("Italic");
|
||||
expect(cell2Text.marks).toContainEqual({ type: "italic" });
|
||||
});
|
||||
|
||||
it("converts table with links (markDefs)", () => {
|
||||
const ptBlocks = [
|
||||
{
|
||||
_type: "table",
|
||||
_key: "t1",
|
||||
markDefs: [
|
||||
{
|
||||
_type: "link",
|
||||
_key: "link1",
|
||||
href: "https://example.com",
|
||||
blank: true,
|
||||
},
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
_type: "tableRow",
|
||||
_key: "r1",
|
||||
cells: [
|
||||
{
|
||||
_type: "tableCell",
|
||||
_key: "c1",
|
||||
content: [{ _type: "span", _key: "s1", text: "Click here", marks: ["link1"] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = _portableTextToProsemirror(ptBlocks);
|
||||
|
||||
const table = result.content[0];
|
||||
const cell = table.content[0].content[0];
|
||||
const para = cell.content[0];
|
||||
const text = para.content[0];
|
||||
|
||||
expect(text.text).toBe("Click here");
|
||||
expect(text.marks).toContainEqual({
|
||||
type: "link",
|
||||
attrs: { href: "https://example.com", target: "_blank" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProseMirror → PortableText", () => {
|
||||
it("converts simple table to PT", () => {
|
||||
const pmDoc = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "table",
|
||||
content: [
|
||||
{
|
||||
type: "tableRow",
|
||||
content: [
|
||||
{
|
||||
type: "tableHeader",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "Header 1" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "tableHeader",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "Header 2" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "tableRow",
|
||||
content: [
|
||||
{
|
||||
type: "tableCell",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "Cell 1" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "tableCell",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "Cell 2" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = _prosemirrorToPortableText(pmDoc);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
const table = result[0] as {
|
||||
_type: string;
|
||||
rows: Array<{
|
||||
cells: Array<{ content: Array<{ text: string }>; isHeader: boolean }>;
|
||||
}>;
|
||||
hasHeaderRow: boolean;
|
||||
};
|
||||
|
||||
expect(table._type).toBe("table");
|
||||
expect(table.hasHeaderRow).toBe(true);
|
||||
expect(table.rows).toHaveLength(2);
|
||||
expect(table.rows[0].cells[0].isHeader).toBe(true);
|
||||
expect(table.rows[0].cells[0].content[0].text).toBe("Header 1");
|
||||
expect(table.rows[1].cells[0].isHeader).toBe(false);
|
||||
expect(table.rows[1].cells[0].content[0].text).toBe("Cell 1");
|
||||
});
|
||||
|
||||
it("converts table with text marks to PT", () => {
|
||||
const pmDoc = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "table",
|
||||
content: [
|
||||
{
|
||||
type: "tableRow",
|
||||
content: [
|
||||
{
|
||||
type: "tableCell",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Bold text",
|
||||
marks: [{ type: "bold" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = _prosemirrorToPortableText(pmDoc);
|
||||
|
||||
const table = result[0] as {
|
||||
rows: Array<{
|
||||
cells: Array<{ content: Array<{ text: string; marks?: string[] }> }>;
|
||||
}>;
|
||||
};
|
||||
|
||||
expect(table.rows[0].cells[0].content[0].text).toBe("Bold text");
|
||||
expect(table.rows[0].cells[0].content[0].marks).toContain("strong");
|
||||
});
|
||||
|
||||
it("converts table with links to PT (preserves markDefs)", () => {
|
||||
const pmDoc = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "table",
|
||||
content: [
|
||||
{
|
||||
type: "tableRow",
|
||||
content: [
|
||||
{
|
||||
type: "tableCell",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Link text",
|
||||
marks: [
|
||||
{
|
||||
type: "link",
|
||||
attrs: {
|
||||
href: "https://example.com",
|
||||
target: "_blank",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = _prosemirrorToPortableText(pmDoc);
|
||||
|
||||
const table = result[0] as {
|
||||
rows: Array<{
|
||||
cells: Array<{
|
||||
content: Array<{ text: string; marks?: string[] }>;
|
||||
markDefs?: Array<{ _key: string; _type: string; href: string; blank?: boolean }>;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
|
||||
const cell = table.rows[0].cells[0];
|
||||
expect(cell.markDefs).toBeDefined();
|
||||
expect(cell.markDefs).toHaveLength(1);
|
||||
expect(cell.markDefs![0]._type).toBe("link");
|
||||
expect(cell.markDefs![0].href).toBe("https://example.com");
|
||||
|
||||
const linkMarkKey = cell.markDefs![0]._key;
|
||||
expect(cell.content[0].marks).toContain(linkMarkKey);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Round-trip conversion", () => {
|
||||
it("PT → PM → PT preserves table structure", () => {
|
||||
const original = [
|
||||
{
|
||||
_type: "table",
|
||||
_key: "t1",
|
||||
hasHeaderRow: true,
|
||||
rows: [
|
||||
{
|
||||
_type: "tableRow",
|
||||
_key: "r1",
|
||||
cells: [
|
||||
{
|
||||
_type: "tableCell",
|
||||
_key: "c1",
|
||||
isHeader: true,
|
||||
content: [{ _type: "span", _key: "s1", text: "Header" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
_type: "tableRow",
|
||||
_key: "r2",
|
||||
cells: [
|
||||
{
|
||||
_type: "tableCell",
|
||||
_key: "c2",
|
||||
content: [{ _type: "span", _key: "s2", text: "Data" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const pm = _portableTextToProsemirror(original);
|
||||
const roundTripped = _prosemirrorToPortableText(pm);
|
||||
|
||||
const table = roundTripped[0] as {
|
||||
_type: string;
|
||||
hasHeaderRow: boolean;
|
||||
rows: Array<{
|
||||
cells: Array<{ content: Array<{ text: string }>; isHeader: boolean }>;
|
||||
}>;
|
||||
};
|
||||
|
||||
expect(table._type).toBe("table");
|
||||
expect(table.hasHeaderRow).toBe(true);
|
||||
expect(table.rows[0].cells[0].isHeader).toBe(true);
|
||||
expect(table.rows[0].cells[0].content[0].text).toBe("Header");
|
||||
expect(table.rows[1].cells[0].isHeader).toBe(false);
|
||||
expect(table.rows[1].cells[0].content[0].text).toBe("Data");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type {
|
||||
RegistryClientConfig,
|
||||
RegistryPackageView,
|
||||
RegistryReleaseView,
|
||||
} from "../../src/lib/api/registry";
|
||||
import { render } from "../utils/render.tsx";
|
||||
|
||||
vi.mock("@tanstack/react-router", async () => {
|
||||
const actual = await vi.importActual("@tanstack/react-router");
|
||||
return {
|
||||
...actual,
|
||||
Link: ({ children, to, ...props }: any) => (
|
||||
<a href={to} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
useNavigate: () => vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockGetRegistryPackage = vi.fn();
|
||||
const mockResolveRegistryPackage = vi.fn();
|
||||
const mockListRegistryReleases = vi.fn();
|
||||
|
||||
vi.mock("../../src/lib/api/registry", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../src/lib/api/registry")>(
|
||||
"../../src/lib/api/registry",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
getRegistryPackage: (...a: unknown[]) => mockGetRegistryPackage(...a),
|
||||
resolveRegistryPackage: (...a: unknown[]) => mockResolveRegistryPackage(...a),
|
||||
listRegistryReleases: (...a: unknown[]) => mockListRegistryReleases(...a),
|
||||
resolveDidToHandle: vi.fn(async () => ({ status: "ok", handle: "acme.dev" })),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../src/lib/api/client", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../src/lib/api/client")>(
|
||||
"../../src/lib/api/client",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
fetchManifest: vi.fn(async () => ({ version: "1.0.0", astroVersion: "5.0.0" })),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../src/lib/api/plugins", () => ({
|
||||
fetchPlugins: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
const { RegistryPluginDetail } = await import("../../src/components/RegistryPluginDetail");
|
||||
|
||||
const CONFIG: RegistryClientConfig = { aggregatorUrl: "https://aggregator.test" };
|
||||
|
||||
interface PkgOverrides {
|
||||
sections?: Record<string, unknown>;
|
||||
lastUpdated?: string;
|
||||
labels?: { val?: string; src?: string }[];
|
||||
}
|
||||
|
||||
function makePackage(overrides: PkgOverrides = {}): RegistryPackageView {
|
||||
return {
|
||||
did: "did:plc:acme",
|
||||
handle: "acme.dev",
|
||||
slug: "myplugin",
|
||||
labels: overrides.labels ?? [],
|
||||
profile: {
|
||||
name: "My Plugin",
|
||||
description: "A short description.",
|
||||
license: "MIT",
|
||||
authors: [{ name: "Acme" }],
|
||||
security: [],
|
||||
keywords: [],
|
||||
sections: overrides.sections,
|
||||
lastUpdated: overrides.lastUpdated,
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test fixture cast to the validated view shape
|
||||
} as any;
|
||||
}
|
||||
|
||||
interface ReleaseOverrides {
|
||||
sbom?: { format?: string; url?: string; checksum?: string };
|
||||
extensions?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function makeRelease(overrides: ReleaseOverrides = {}): RegistryReleaseView {
|
||||
return {
|
||||
version: "1.2.3",
|
||||
indexedAt: "2025-03-01T00:00:00Z",
|
||||
labels: [],
|
||||
release: {
|
||||
sbom: overrides.sbom,
|
||||
extensions: overrides.extensions,
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test fixture cast to the validated view shape
|
||||
} as any;
|
||||
}
|
||||
|
||||
const RELEASE_EXTENSION_NSID = "com.emdashcms.experimental.package.releaseExtension";
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
function setup(pkg: RegistryPackageView, releases: RegistryReleaseView[]) {
|
||||
mockGetRegistryPackage.mockResolvedValue(pkg);
|
||||
mockResolveRegistryPackage.mockResolvedValue(pkg);
|
||||
mockListRegistryReleases.mockResolvedValue({ releases });
|
||||
}
|
||||
|
||||
describe("RegistryPluginDetail sections", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders one pane per non-empty section and suppresses empty ones", async () => {
|
||||
setup(
|
||||
makePackage({
|
||||
sections: {
|
||||
description: "Description body text.",
|
||||
installation: "Installation body text.",
|
||||
faq: " ",
|
||||
security: "",
|
||||
},
|
||||
}),
|
||||
[makeRelease()],
|
||||
);
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<RegistryPluginDetail pluginId="acme.dev/myplugin" config={CONFIG} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
// Tabs for present sections.
|
||||
await expect.element(screen.getByRole("tab", { name: "Description" })).toBeInTheDocument();
|
||||
await expect.element(screen.getByRole("tab", { name: "Installation" })).toBeInTheDocument();
|
||||
// Empty/whitespace sections produce no tab.
|
||||
expect(screen.getByRole("tab", { name: "FAQ" }).query()).toBeNull();
|
||||
expect(screen.getByRole("tab", { name: "Security" }).query()).toBeNull();
|
||||
// Default pane is the first present section (description).
|
||||
await expect.element(screen.getByText("Description body text.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders sanitized markdown — a <script> in a section never reaches the DOM", async () => {
|
||||
setup(
|
||||
makePackage({
|
||||
sections: {
|
||||
description: "Safe paragraph.\n\n<script>window.__pwned = true</script>",
|
||||
},
|
||||
}),
|
||||
[makeRelease()],
|
||||
);
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<RegistryPluginDetail pluginId="acme.dev/myplugin" config={CONFIG} />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Safe paragraph.")).toBeInTheDocument();
|
||||
expect(screen.container.querySelector("script")).toBeNull();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- probe for the XSS side-effect
|
||||
expect((window as any).__pwned).toBeUndefined();
|
||||
});
|
||||
|
||||
it("renders nothing (no tab bar) when there are no sections", async () => {
|
||||
setup(makePackage({ sections: undefined }), [makeRelease()]);
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<RegistryPluginDetail pluginId="acme.dev/myplugin" config={CONFIG} />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByRole("heading", { name: "My Plugin" })).toBeInTheDocument();
|
||||
expect(screen.container.querySelector('[role="tab"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RegistryPluginDetail SBOM", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows the SBOM badge and a download link for an https url", async () => {
|
||||
setup(makePackage(), [
|
||||
makeRelease({ sbom: { format: "cyclonedx", url: "https://x/sbom.json" } }),
|
||||
]);
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<RegistryPluginDetail pluginId="acme.dev/myplugin" config={CONFIG} />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("SBOM · cyclonedx")).toBeInTheDocument();
|
||||
const link = screen.getByRole("link", { name: "Download SBOM" });
|
||||
await expect.element(link).toBeInTheDocument();
|
||||
await expect.element(link).toHaveAttribute("href", "https://x/sbom.json");
|
||||
});
|
||||
|
||||
it("renders the badge but no download link for an unsafe (javascript:) url", async () => {
|
||||
setup(makePackage(), [makeRelease({ sbom: { format: "spdx", url: "javascript:alert(1)" } })]);
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<RegistryPluginDetail pluginId="acme.dev/myplugin" config={CONFIG} />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("SBOM · spdx")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "Download SBOM" }).query()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RegistryPluginDetail declared permissions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("derives the consent list faithfully from declaredAccess, including hook facets", async () => {
|
||||
// declaredAccess carries the hook facets; the consent list must show the
|
||||
// canonical capability strings the install handler enforces, derived via
|
||||
// the shared converter rather than a component-local flattener.
|
||||
setup(makePackage(), [
|
||||
makeRelease({
|
||||
extensions: {
|
||||
[RELEASE_EXTENSION_NSID]: {
|
||||
declaredAccess: {
|
||||
network: { request: { allowedHosts: ["api.cloudflare.com"] } },
|
||||
email: { transport: {}, events: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<RegistryPluginDetail pluginId="acme.dev/myplugin" config={CONFIG} />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("hooks.email-transport:register")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("hooks.email-events:register")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("network:request")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RegistryPluginDetail lastUpdated + verified tooltip", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders the publisher lastUpdated label when present", async () => {
|
||||
setup(makePackage({ lastUpdated: "2025-02-15T00:00:00Z" }), [makeRelease()]);
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<RegistryPluginDetail pluginId="acme.dev/myplugin" config={CONFIG} />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Updated")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Indexed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("exposes the labeller DID through the verified shield trigger", async () => {
|
||||
setup(makePackage({ labels: [{ val: "verified", src: "did:plc:labeller" }] }), [makeRelease()]);
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<RegistryPluginDetail pluginId="acme.dev/myplugin" config={CONFIG} />
|
||||
</Wrapper>,
|
||||
);
|
||||
// The shield trigger is a focusable button whose accessible name names the labeller.
|
||||
const trigger = screen.getByRole("button", { name: /Verified publisher/ });
|
||||
await expect.element(trigger).toBeInTheDocument();
|
||||
await expect.element(trigger).toHaveAccessibleName(/did:plc:labeller/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import { RepeaterField } from "../../src/components/RepeaterField";
|
||||
import { render } from "../utils/render.tsx";
|
||||
|
||||
describe("RepeaterField", () => {
|
||||
describe("datetime sub-field", () => {
|
||||
it("displays a stored ISO datetime in the datetime-local input", async () => {
|
||||
// Mirrors the top-level datetime widget contract: full ISO 8601
|
||||
// values must round-trip through `<input type="datetime-local">`,
|
||||
// which only accepts `YYYY-MM-DDTHH:mm`.
|
||||
const screen = await render(
|
||||
<RepeaterField
|
||||
label="Recalls"
|
||||
id="recalls"
|
||||
value={[{ recall_date: "2026-02-26T09:30:00.000Z" }]}
|
||||
onChange={vi.fn()}
|
||||
subFields={[{ slug: "recall_date", type: "datetime", label: "Recall date" }]}
|
||||
/>,
|
||||
);
|
||||
const input = screen.getByLabelText("Recall date");
|
||||
await expect.element(input).toHaveValue("2026-02-26T09:30");
|
||||
});
|
||||
|
||||
it("emits a full ISO 8601 value with Z and milliseconds on change", async () => {
|
||||
const onChange = vi.fn();
|
||||
const screen = await render(
|
||||
<RepeaterField
|
||||
label="Recalls"
|
||||
id="recalls"
|
||||
value={[{ recall_date: "" }]}
|
||||
onChange={onChange}
|
||||
subFields={[{ slug: "recall_date", type: "datetime", label: "Recall date" }]}
|
||||
/>,
|
||||
);
|
||||
const input = screen.getByLabelText("Recall date");
|
||||
await input.fill("2026-02-26T09:30");
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith([
|
||||
expect.objectContaining({ recall_date: "2026-02-26T09:30:00.000Z" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Image sub-field support (issue #1424): rows render the media picker
|
||||
* (ImageFieldRenderer) instead of falling through to a plain text input.
|
||||
*/
|
||||
describe("RepeaterField sub-field types", () => {
|
||||
it("renders the media picker for image sub-fields", async () => {
|
||||
const screen = await render(
|
||||
<RepeaterField
|
||||
label="Gallery"
|
||||
id="gallery"
|
||||
value={[{ image: null, caption: "" }]}
|
||||
onChange={vi.fn()}
|
||||
subFields={[
|
||||
{ slug: "image", type: "image", label: "Image" },
|
||||
{ slug: "caption", type: "string", label: "Caption" },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Image sub-field → picker button, not a text input.
|
||||
await expect.element(screen.getByRole("button", { name: /Select image/ })).toBeVisible();
|
||||
// Scalar sub-fields keep their plain inputs.
|
||||
await expect.element(screen.getByRole("textbox", { name: "Caption" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("shows the existing image preview for media values", async () => {
|
||||
const screen = await render(
|
||||
<RepeaterField
|
||||
label="Gallery"
|
||||
id="gallery"
|
||||
value={[
|
||||
{
|
||||
image: {
|
||||
id: "m1",
|
||||
provider: "local",
|
||||
alt: "",
|
||||
meta: { storageKey: "01ABC.png" },
|
||||
},
|
||||
},
|
||||
]}
|
||||
onChange={vi.fn()}
|
||||
subFields={[{ slug: "image", type: "image", label: "Image" }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
// MediaValue with a storageKey renders the local-media preview image.
|
||||
await expect
|
||||
.element(screen.container.querySelector('img[src="/_emdash/api/media/file/01ABC.png"]'))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("initializes image sub-fields as null when adding an item", async () => {
|
||||
const onChange = vi.fn();
|
||||
const screen = await render(
|
||||
<RepeaterField
|
||||
label="Gallery"
|
||||
id="gallery"
|
||||
value={[]}
|
||||
onChange={onChange}
|
||||
subFields={[
|
||||
{ slug: "image", type: "image", label: "Image" },
|
||||
{ slug: "caption", type: "string", label: "Caption" },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.getByRole("button", { name: /Add First Item/ }).click();
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([{ image: null, caption: "" }]);
|
||||
});
|
||||
|
||||
it("renders a searchable combobox for select sub-fields", async () => {
|
||||
const screen = await render(
|
||||
<RepeaterField
|
||||
label="Services"
|
||||
id="services"
|
||||
value={[{ service: "CT" }]}
|
||||
onChange={vi.fn()}
|
||||
subFields={[
|
||||
{ slug: "service", type: "select", label: "Service", options: ["MRI", "CT", "PET"] },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Select sub-field → searchable typeahead input (role "combobox"),
|
||||
// not a plain native select, and it reflects the stored value.
|
||||
const input = screen.getByRole("combobox");
|
||||
await expect.element(input).toBeVisible();
|
||||
await expect.element(input).toHaveValue("CT");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,486 @@
|
||||
import { Toasty } from "@cloudflare/kumo";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { userEvent } from "@vitest/browser/context";
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from "vitest";
|
||||
|
||||
import { RevisionHistory } from "../../src/components/RevisionHistory";
|
||||
import type { Revision, RevisionListResponse } from "../../src/lib/api";
|
||||
import { render } from "../utils/render.tsx";
|
||||
|
||||
// Mock the API module
|
||||
vi.mock("../../src/lib/api", async () => {
|
||||
const actual = await vi.importActual("../../src/lib/api");
|
||||
return {
|
||||
...actual,
|
||||
fetchRevisions: vi.fn(),
|
||||
restoreRevision: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Import mocked functions for test control
|
||||
import { fetchRevisions, restoreRevision } from "../../src/lib/api";
|
||||
|
||||
const mockFetchRevisions = fetchRevisions as Mock;
|
||||
const mockRestoreRevision = restoreRevision as Mock;
|
||||
|
||||
const REVISIONS_BUTTON_REGEX = /Revisions/i;
|
||||
const RESTORE_BUTTON_REGEX = /Restore this version/i;
|
||||
const TIME_REGEX_5_MINS = /5 mins ago/;
|
||||
const TIME_REGEX_3_HOURS = /3 hours ago/;
|
||||
|
||||
function QueryWrapper({ children }: { children: React.ReactNode }) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return (
|
||||
<Toasty>
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
</Toasty>
|
||||
);
|
||||
}
|
||||
|
||||
function makeRevision(overrides: Partial<Revision> = {}): Revision {
|
||||
return {
|
||||
id: "rev-1",
|
||||
collection: "posts",
|
||||
entryId: "entry-1",
|
||||
data: { title: "Hello World", body: "Content here" },
|
||||
authorId: "user-1",
|
||||
createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), // 2 hours ago
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeRevisionList(revisions: Revision[]): RevisionListResponse {
|
||||
return { items: revisions, total: revisions.length };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("RevisionHistory", () => {
|
||||
// ---- Starts collapsed ----
|
||||
|
||||
it("starts collapsed with only header visible", async () => {
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<RevisionHistory collection="posts" entryId="entry-1" />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
|
||||
await expect.element(screen.getByText("Revisions")).toBeInTheDocument();
|
||||
// The expanded content should not be visible
|
||||
const noRevisionsText = screen.getByText("No revisions yet");
|
||||
await expect.element(noRevisionsText).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ---- Query only fires when expanded ----
|
||||
|
||||
it("does not fetch revisions until expanded", async () => {
|
||||
await render(
|
||||
<QueryWrapper>
|
||||
<RevisionHistory collection="posts" entryId="entry-1" />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
|
||||
// Should not have called fetchRevisions while collapsed
|
||||
expect(mockFetchRevisions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fetches revisions when header is clicked to expand", async () => {
|
||||
mockFetchRevisions.mockResolvedValue(makeRevisionList([]));
|
||||
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<RevisionHistory collection="posts" entryId="entry-1" />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
|
||||
// Click header to expand
|
||||
const header = screen.getByText("Revisions");
|
||||
await header.click();
|
||||
|
||||
expect(mockFetchRevisions).toHaveBeenCalledWith("posts", "entry-1", { limit: 20 });
|
||||
});
|
||||
|
||||
// ---- Shows loading state ----
|
||||
|
||||
it("shows loading state when expanded and fetching", async () => {
|
||||
// Never resolve — keeps it in loading state
|
||||
mockFetchRevisions.mockReturnValue(new Promise(() => {}));
|
||||
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<RevisionHistory collection="posts" entryId="entry-1" />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
|
||||
await screen.getByRole("button", { name: REVISIONS_BUTTON_REGEX }).click();
|
||||
|
||||
// Loader renders an SVG spinner — verify the loading container is present
|
||||
// and that none of the "loaded" states are showing
|
||||
const emptyText = screen.getByText("No revisions yet");
|
||||
await expect.element(emptyText).not.toBeInTheDocument();
|
||||
const errorText = screen.getByText("Failed to load revisions");
|
||||
await expect.element(errorText).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ---- Shows revision list with relative times ----
|
||||
|
||||
it("shows revision list with relative times", async () => {
|
||||
const revisions = [
|
||||
makeRevision({
|
||||
id: "rev-1",
|
||||
createdAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(), // 5 mins ago
|
||||
}),
|
||||
makeRevision({
|
||||
id: "rev-2",
|
||||
createdAt: new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(), // 3 hours ago
|
||||
}),
|
||||
];
|
||||
mockFetchRevisions.mockResolvedValue(makeRevisionList(revisions));
|
||||
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<RevisionHistory collection="posts" entryId="entry-1" />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
|
||||
await screen.getByText("Revisions").click();
|
||||
|
||||
await expect.element(screen.getByText(TIME_REGEX_5_MINS)).toBeInTheDocument();
|
||||
await expect.element(screen.getByText(TIME_REGEX_3_HOURS)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ---- First revision has "Current" badge ----
|
||||
|
||||
it("shows 'Current' badge on the first (latest) revision", async () => {
|
||||
const revisions = [
|
||||
makeRevision({ id: "rev-1" }),
|
||||
makeRevision({
|
||||
id: "rev-2",
|
||||
createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
|
||||
}),
|
||||
];
|
||||
mockFetchRevisions.mockResolvedValue(makeRevisionList(revisions));
|
||||
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<RevisionHistory collection="posts" entryId="entry-1" />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
|
||||
await screen.getByText("Revisions").click();
|
||||
|
||||
await expect.element(screen.getByText("Current")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ---- First revision does NOT have restore button ----
|
||||
|
||||
it("does not show restore button on the latest revision", async () => {
|
||||
const revisions = [makeRevision({ id: "rev-1" })];
|
||||
mockFetchRevisions.mockResolvedValue(makeRevisionList(revisions));
|
||||
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<RevisionHistory collection="posts" entryId="entry-1" />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
|
||||
await screen.getByText("Revisions").click();
|
||||
|
||||
// Wait for revision to appear
|
||||
await expect.element(screen.getByText("Current")).toBeInTheDocument();
|
||||
|
||||
// The restore button has title "Restore this version" — should not exist for latest
|
||||
const restoreButton = screen.getByRole("button", { name: RESTORE_BUTTON_REGEX });
|
||||
await expect.element(restoreButton).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ---- Non-latest revisions have restore button ----
|
||||
|
||||
it("shows restore button on non-latest revisions", async () => {
|
||||
const revisions = [
|
||||
makeRevision({ id: "rev-1" }),
|
||||
makeRevision({
|
||||
id: "rev-2",
|
||||
createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
|
||||
}),
|
||||
];
|
||||
mockFetchRevisions.mockResolvedValue(makeRevisionList(revisions));
|
||||
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<RevisionHistory collection="posts" entryId="entry-1" />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
|
||||
await screen.getByText("Revisions").click();
|
||||
|
||||
// Wait for revisions to load
|
||||
await expect.element(screen.getByText("Current")).toBeInTheDocument();
|
||||
|
||||
// There should be a restore button for the non-latest revision
|
||||
const restoreButton = screen.getByRole("button", { name: RESTORE_BUTTON_REGEX });
|
||||
await expect.element(restoreButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ---- Clicking revision toggles selection (shows content snapshot) ----
|
||||
|
||||
it("toggles content snapshot when clicking a revision", async () => {
|
||||
const revisions = [makeRevision({ id: "rev-1", data: { title: "Latest content" } })];
|
||||
mockFetchRevisions.mockResolvedValue(makeRevisionList(revisions));
|
||||
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<RevisionHistory collection="posts" entryId="entry-1" />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
|
||||
await screen.getByText("Revisions").click();
|
||||
await expect.element(screen.getByText("Current")).toBeInTheDocument();
|
||||
|
||||
// Content snapshot should not be visible initially
|
||||
const snapshotLabel = screen.getByText("Content snapshot:");
|
||||
await expect.element(snapshotLabel).not.toBeInTheDocument();
|
||||
|
||||
// Click the revision to select it
|
||||
const revisionButton = screen.getByText("Current").element().closest("button")!;
|
||||
await userEvent.click(revisionButton);
|
||||
|
||||
// Content snapshot should now be visible
|
||||
await expect.element(screen.getByText("Content snapshot:")).toBeInTheDocument();
|
||||
|
||||
// Click again to deselect
|
||||
await userEvent.click(revisionButton);
|
||||
|
||||
await expect.element(screen.getByText("Content snapshot:")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ---- Restore calls confirm() then restoreRevision ----
|
||||
|
||||
it("opens confirm dialog then restoreRevision when confirmed", async () => {
|
||||
mockRestoreRevision.mockResolvedValue({});
|
||||
const onRestored = vi.fn();
|
||||
|
||||
const revisions = [
|
||||
makeRevision({ id: "rev-1" }),
|
||||
makeRevision({
|
||||
id: "rev-2",
|
||||
createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
|
||||
}),
|
||||
];
|
||||
mockFetchRevisions.mockResolvedValue(makeRevisionList(revisions));
|
||||
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<RevisionHistory collection="posts" entryId="entry-1" onRestored={onRestored} />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
|
||||
await screen.getByText("Revisions").click();
|
||||
await expect.element(screen.getByText("Current")).toBeInTheDocument();
|
||||
|
||||
// Click the restore button on the second revision
|
||||
const restoreButton = screen.getByRole("button", { name: RESTORE_BUTTON_REGEX });
|
||||
await restoreButton.click();
|
||||
|
||||
// ConfirmDialog should appear
|
||||
await expect.element(screen.getByText("Restore Revision?")).toBeInTheDocument();
|
||||
|
||||
// Direct DOM click to bypass Base UI inert overlay
|
||||
screen.getByRole("button", { name: "Restore" }).element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockRestoreRevision).toHaveBeenCalledWith("rev-2");
|
||||
});
|
||||
});
|
||||
|
||||
it("does not call restoreRevision when confirm dialog is cancelled", async () => {
|
||||
const revisions = [
|
||||
makeRevision({ id: "rev-1" }),
|
||||
makeRevision({
|
||||
id: "rev-2",
|
||||
createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
|
||||
}),
|
||||
];
|
||||
mockFetchRevisions.mockResolvedValue(makeRevisionList(revisions));
|
||||
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<RevisionHistory collection="posts" entryId="entry-1" />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
|
||||
await screen.getByText("Revisions").click();
|
||||
await expect.element(screen.getByText("Current")).toBeInTheDocument();
|
||||
|
||||
const restoreButton = screen.getByRole("button", { name: RESTORE_BUTTON_REGEX });
|
||||
await restoreButton.click();
|
||||
|
||||
// ConfirmDialog should appear
|
||||
await expect.element(screen.getByText("Restore Revision?")).toBeInTheDocument();
|
||||
|
||||
// Direct DOM click to bypass Base UI inert overlay
|
||||
screen.getByRole("button", { name: "Cancel" }).element().click();
|
||||
|
||||
expect(mockRestoreRevision).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ---- Error state ----
|
||||
|
||||
it("shows error message when fetching revisions fails", async () => {
|
||||
mockFetchRevisions.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<RevisionHistory collection="posts" entryId="entry-1" />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
|
||||
await screen.getByText("Revisions").click();
|
||||
|
||||
await expect.element(screen.getByText("Failed to load revisions")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ---- Empty state ----
|
||||
|
||||
it("shows empty state when no revisions exist", async () => {
|
||||
mockFetchRevisions.mockResolvedValue(makeRevisionList([]));
|
||||
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<RevisionHistory collection="posts" entryId="entry-1" />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
|
||||
await screen.getByText("Revisions").click();
|
||||
|
||||
await expect.element(screen.getByText("No revisions yet")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ---- Collapse hides revision content ----
|
||||
|
||||
it("hides revision content when collapsed after expanding", async () => {
|
||||
mockFetchRevisions.mockResolvedValue(makeRevisionList([]));
|
||||
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<RevisionHistory collection="posts" entryId="entry-1" />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
|
||||
const headerButton = screen.getByRole("button", { name: REVISIONS_BUTTON_REGEX });
|
||||
|
||||
// Expand
|
||||
await headerButton.click();
|
||||
await expect.element(screen.getByText("No revisions yet")).toBeInTheDocument();
|
||||
|
||||
// Collapse
|
||||
await headerButton.click();
|
||||
|
||||
// Content should be hidden
|
||||
await expect.element(screen.getByText("No revisions yet")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ---- Shows total count in header ----
|
||||
|
||||
it("shows total count in header when revisions exist", async () => {
|
||||
const revisions = [
|
||||
makeRevision({ id: "rev-1" }),
|
||||
makeRevision({
|
||||
id: "rev-2",
|
||||
createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
|
||||
}),
|
||||
makeRevision({
|
||||
id: "rev-3",
|
||||
createdAt: new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString(),
|
||||
}),
|
||||
];
|
||||
mockFetchRevisions.mockResolvedValue(makeRevisionList(revisions));
|
||||
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<RevisionHistory collection="posts" entryId="entry-1" />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
|
||||
await screen.getByText("Revisions").click();
|
||||
|
||||
// Should show (3) next to "Revisions"
|
||||
await expect.element(screen.getByText("(3)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ---- Visual diff view ----
|
||||
|
||||
it("shows visual diff when selecting a non-latest revision", async () => {
|
||||
const revisions = [
|
||||
makeRevision({
|
||||
id: "rev-1",
|
||||
data: { title: "Updated Title", body: "Same body", newField: "added" },
|
||||
createdAt: new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString(),
|
||||
}),
|
||||
makeRevision({
|
||||
id: "rev-2",
|
||||
data: { title: "Original Title", body: "Same body", removed: "gone" },
|
||||
createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
|
||||
}),
|
||||
];
|
||||
mockFetchRevisions.mockResolvedValue(makeRevisionList(revisions));
|
||||
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<RevisionHistory collection="posts" entryId="entry-1" />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
|
||||
await screen.getByText("Revisions").click();
|
||||
await expect.element(screen.getByText("Current")).toBeInTheDocument();
|
||||
|
||||
// Click the second (non-latest) revision
|
||||
const revisionButtons = screen.getByText("1 day ago").element().closest("button")!;
|
||||
await userEvent.click(revisionButtons);
|
||||
|
||||
// Should show diff, not raw snapshot
|
||||
await expect
|
||||
.element(screen.getByText("from next revision", { exact: false }))
|
||||
.toBeInTheDocument();
|
||||
|
||||
// Changed field values should appear in the diff
|
||||
await expect.element(screen.getByText("Original Title")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Updated Title")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows raw snapshot for latest revision (no diff target)", async () => {
|
||||
const revisions = [
|
||||
makeRevision({
|
||||
id: "rev-1",
|
||||
data: { title: "Latest" },
|
||||
createdAt: new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString(),
|
||||
}),
|
||||
makeRevision({
|
||||
id: "rev-2",
|
||||
data: { title: "Older" },
|
||||
createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
|
||||
}),
|
||||
];
|
||||
mockFetchRevisions.mockResolvedValue(makeRevisionList(revisions));
|
||||
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<RevisionHistory collection="posts" entryId="entry-1" />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
|
||||
await screen.getByText("Revisions").click();
|
||||
|
||||
// Click the latest revision
|
||||
const latestButton = screen.getByText("Current").element().closest("button")!;
|
||||
await userEvent.click(latestButton);
|
||||
|
||||
// Should show raw JSON snapshot, not diff
|
||||
await expect.element(screen.getByText("Content snapshot:")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import * as React from "react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { SaveButton } from "../../src/components/SaveButton";
|
||||
import { render } from "../utils/render.tsx";
|
||||
|
||||
describe("SaveButton", () => {
|
||||
it("shows 'Save' when dirty and not saving", async () => {
|
||||
const screen = await render(<SaveButton isDirty={true} isSaving={false} />);
|
||||
await expect.element(screen.getByRole("button", { name: "Save" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("transitions to Saving while save progress is active", async () => {
|
||||
const screen = await render(<SaveButton isDirty={true} isSaving={true} />);
|
||||
const button = screen.getByRole("button", { name: "Saving..." });
|
||||
await expect.element(button).toBeDisabled();
|
||||
await expect.element(button).toHaveAttribute("aria-busy", "true");
|
||||
expect(screen.getByRole("status").element().textContent).toBe("Saving...");
|
||||
});
|
||||
|
||||
it("transitions to Saved when clean", async () => {
|
||||
const screen = await render(<SaveButton isDirty={false} isSaving={false} />);
|
||||
const button = screen.getByRole("button", { name: "Saved" });
|
||||
await expect.element(button).toBeDisabled();
|
||||
expect(screen.getByRole("status").element().textContent).toBe("Saved");
|
||||
});
|
||||
|
||||
it("has aria-busy when saving", async () => {
|
||||
const screen = await render(<SaveButton isDirty={true} isSaving={true} />);
|
||||
await expect.element(screen.getByRole("button")).toHaveAttribute("aria-busy", "true");
|
||||
});
|
||||
|
||||
it("does not have aria-busy when not saving", async () => {
|
||||
const screen = await render(<SaveButton isDirty={true} isSaving={false} />);
|
||||
await expect.element(screen.getByRole("button")).toHaveAttribute("aria-busy", "false");
|
||||
});
|
||||
|
||||
it("can render without a second live region", async () => {
|
||||
const screen = await render(<SaveButton isDirty={false} isSaving={false} announce={false} />);
|
||||
await expect.element(screen.getByRole("button", { name: "Saved" })).toBeInTheDocument();
|
||||
expect(screen.container.querySelector('span[role="status"][aria-live="polite"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps one fixed-width button across Save, Saving, and Saved states", async () => {
|
||||
const screen = await render(<SaveButton isDirty={true} isSaving={false} />);
|
||||
const getLabelWidth = () =>
|
||||
screen.container
|
||||
.querySelector<HTMLElement>("[data-save-button-labels]")!
|
||||
.getBoundingClientRect().width;
|
||||
const dirtyWidth = getLabelWidth();
|
||||
|
||||
await screen.rerender(<SaveButton isDirty={true} isSaving={true} />);
|
||||
const savingWidth = getLabelWidth();
|
||||
await screen.rerender(<SaveButton isDirty={false} isSaving={false} />);
|
||||
const savedWidth = getLabelWidth();
|
||||
|
||||
expect(dirtyWidth).toBeGreaterThan(0);
|
||||
expect(savingWidth).toBeCloseTo(dirtyWidth);
|
||||
expect(savedWidth).toBeCloseTo(dirtyWidth);
|
||||
});
|
||||
|
||||
it("runs the exit, content swap, and enter sequence", async () => {
|
||||
const screen = await render(<SaveButton isDirty={true} isSaving={false} />);
|
||||
const states = screen.container.querySelectorAll("[data-save-button-state]");
|
||||
expect(states).toHaveLength(3);
|
||||
const visibleState = () =>
|
||||
screen.container.querySelector<HTMLElement>("[data-save-button-visible-state]")!;
|
||||
expect(visibleState()).toHaveAttribute("data-save-button-visible-state", "save");
|
||||
|
||||
await screen.rerender(<SaveButton isDirty={true} isSaving={true} />);
|
||||
await expect.element(screen.getByRole("button", { name: "Saving..." })).toBeDisabled();
|
||||
await expect.element(visibleState()).toHaveClass("is-exit");
|
||||
expect(visibleState()).toHaveAttribute("data-save-button-visible-state", "save");
|
||||
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 200));
|
||||
expect(visibleState()).toHaveAttribute("data-save-button-visible-state", "saving");
|
||||
expect(visibleState()).not.toHaveClass("is-exit");
|
||||
expect(visibleState()).not.toHaveClass("is-enter-start");
|
||||
});
|
||||
|
||||
it("respects external disabled prop", async () => {
|
||||
const screen = await render(<SaveButton isDirty={true} isSaving={false} disabled={true} />);
|
||||
await expect.element(screen.getByRole("button")).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { Toasty } from "@cloudflare/kumo";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import type { Section } from "../../src/lib/api";
|
||||
import { render } from "../utils/render.tsx";
|
||||
|
||||
// Capture props passed to PortableTextEditor so the test can invoke the
|
||||
// block-sidebar callbacks the way the image node view does at runtime.
|
||||
const portableTextProps: { current: Record<string, unknown> | null } = { current: null };
|
||||
|
||||
vi.mock("../../src/components/PortableTextEditor", () => ({
|
||||
PortableTextEditor: (props: Record<string, unknown>) => {
|
||||
portableTextProps.current = props;
|
||||
return <div data-testid="portable-text-editor" />;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../src/components/MediaPickerModal", () => ({
|
||||
MediaPickerModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-router", async () => {
|
||||
const actual = await vi.importActual("@tanstack/react-router");
|
||||
return {
|
||||
...(actual as Record<string, unknown>),
|
||||
Link: ({ children, to, ...props }: { children: React.ReactNode; to?: string }) => (
|
||||
<a href={String(to ?? "")} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
useParams: () => ({ slug: "footer" }),
|
||||
useNavigate: () => vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockFetchSection = vi.fn<() => Promise<Section>>();
|
||||
const mockUpdateSection = vi.fn();
|
||||
|
||||
vi.mock("../../src/lib/api", async () => {
|
||||
const actual = await vi.importActual("../../src/lib/api");
|
||||
return {
|
||||
...(actual as Record<string, unknown>),
|
||||
fetchSection: (...args: unknown[]) => mockFetchSection(...(args as [])),
|
||||
updateSection: (...args: unknown[]) => mockUpdateSection(...(args as [])),
|
||||
};
|
||||
});
|
||||
|
||||
// Import after mocks so the module under test picks up the mocked deps.
|
||||
const { SectionEditor } = await import("../../src/components/SectionEditor");
|
||||
|
||||
function makeSection(overrides: Partial<Section> = {}): Section {
|
||||
return {
|
||||
id: "sec_footer",
|
||||
slug: "footer",
|
||||
title: "Footer",
|
||||
description: "All page footer",
|
||||
keywords: [],
|
||||
content: [],
|
||||
source: "user",
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
updatedAt: "2025-01-02T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return (
|
||||
<QueryClientProvider client={qc}>
|
||||
<Toasty>{children}</Toasty>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("SectionEditor", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
portableTextProps.current = null;
|
||||
mockFetchSection.mockResolvedValue(makeSection());
|
||||
});
|
||||
|
||||
it("opens the image settings panel when a block requests the sidebar", async () => {
|
||||
const screen = await render(<SectionEditor />, { wrapper: Wrapper });
|
||||
|
||||
// Editor must mount (and capture its props) before we can simulate.
|
||||
await expect.element(screen.getByTestId("portable-text-editor")).toBeInTheDocument();
|
||||
|
||||
const onBlockSidebarOpen = portableTextProps.current?.onBlockSidebarOpen as
|
||||
| ((panel: unknown) => void)
|
||||
| undefined;
|
||||
const onBlockSidebarClose = portableTextProps.current?.onBlockSidebarClose as
|
||||
| (() => void)
|
||||
| undefined;
|
||||
|
||||
// Both callbacks must be wired — without them, clicking the image-settings
|
||||
// icon in the editor is a silent no-op (#845).
|
||||
expect(typeof onBlockSidebarOpen).toBe("function");
|
||||
expect(typeof onBlockSidebarClose).toBe("function");
|
||||
|
||||
// Simulate the image node view asking for sidebar space, the same shape
|
||||
// it sends from ImageNode.openSidebar(). expect.element below polls until
|
||||
// React flushes the state update, so we don't need an explicit act wrapper.
|
||||
onBlockSidebarOpen!({
|
||||
type: "image",
|
||||
attrs: {
|
||||
src: "https://example.com/logo.png",
|
||||
alt: "Logo",
|
||||
mediaId: "media-1",
|
||||
width: 400,
|
||||
height: 200,
|
||||
},
|
||||
onUpdate: vi.fn(),
|
||||
onReplace: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
});
|
||||
|
||||
// The Image Settings panel should now be rendered in the sidebar slot.
|
||||
// The panel renders the image preview using the src we passed.
|
||||
await expect.element(screen.getByRole("img", { name: "Logo" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows both clean save actions as Saved", async () => {
|
||||
const screen = await render(<SectionEditor />, { wrapper: Wrapper });
|
||||
|
||||
await expect.element(screen.getByTestId("portable-text-editor")).toBeInTheDocument();
|
||||
const saveButtons = screen.getByRole("button", { name: "Saved", exact: true }).all();
|
||||
expect(saveButtons).toHaveLength(2);
|
||||
for (const button of saveButtons) await expect.element(button).toBeDisabled();
|
||||
expect(screen.getByRole("status").element().textContent).toBe("Saved");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import type { Section, SectionCategory, SectionsResult } from "../../src/lib/api";
|
||||
import { render } from "../utils/render.tsx";
|
||||
|
||||
const mockFetchSections = vi.fn<() => Promise<SectionsResult>>();
|
||||
const mockFetchSectionCategories = vi.fn<() => Promise<SectionCategory[]>>();
|
||||
|
||||
vi.mock("../../src/lib/api", async () => {
|
||||
const actual = await vi.importActual("../../src/lib/api");
|
||||
return {
|
||||
...actual,
|
||||
fetchSections: (...args: unknown[]) => mockFetchSections(...(args as [])),
|
||||
fetchSectionCategories: (...args: unknown[]) => mockFetchSectionCategories(...(args as [])),
|
||||
};
|
||||
});
|
||||
|
||||
// Import after mocks
|
||||
const { SectionPickerModal } = await import("../../src/components/SectionPickerModal");
|
||||
|
||||
function makeSection(overrides: Partial<Section> = {}): Section {
|
||||
return {
|
||||
id: "sec_01",
|
||||
slug: "hero",
|
||||
title: "Hero Section",
|
||||
description: "Main hero",
|
||||
keywords: [],
|
||||
content: [],
|
||||
source: "theme",
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
updatedAt: "2025-01-02T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
describe("SectionPickerModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetchSections.mockResolvedValue({
|
||||
items: [
|
||||
makeSection({
|
||||
id: "sec_01",
|
||||
slug: "hero",
|
||||
title: "Hero Section",
|
||||
description: "Main hero",
|
||||
source: "theme",
|
||||
}),
|
||||
makeSection({
|
||||
id: "sec_02",
|
||||
slug: "cta",
|
||||
title: "Call to Action",
|
||||
description: "CTA block",
|
||||
source: "user",
|
||||
}),
|
||||
],
|
||||
});
|
||||
mockFetchSectionCategories.mockResolvedValue([
|
||||
{ id: "cat_1", slug: "layout", label: "Layout", sortOrder: 0 },
|
||||
{ id: "cat_2", slug: "marketing", label: "Marketing", sortOrder: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows sections when open", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<SectionPickerModal open={true} onOpenChange={vi.fn()} onSelect={vi.fn()} />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Hero Section")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Call to Action")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clicking a section calls onSelect and closes modal", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const onOpenChange = vi.fn();
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<SectionPickerModal open={true} onOpenChange={onOpenChange} onSelect={onSelect} />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Hero Section")).toBeInTheDocument();
|
||||
// The Base UI dialog puts an inert overlay. Use direct DOM click to bypass it.
|
||||
const heroEl = screen.getByText("Hero Section").element();
|
||||
const button = heroEl.closest("button");
|
||||
button!.click();
|
||||
expect(onSelect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ slug: "hero", title: "Hero Section" }),
|
||||
);
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("search input filters results", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<SectionPickerModal open={true} onOpenChange={vi.fn()} onSelect={vi.fn()} />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Hero Section")).toBeInTheDocument();
|
||||
const searchInput = screen.getByPlaceholder("Search sections...");
|
||||
await searchInput.fill("cta");
|
||||
// Search is debounced — wait for the query to fire
|
||||
await vi.waitFor(() => {
|
||||
expect(mockFetchSections).toHaveBeenCalledWith(expect.objectContaining({ search: "cta" }));
|
||||
});
|
||||
});
|
||||
|
||||
it("cancel button closes modal", async () => {
|
||||
const onOpenChange = vi.fn();
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<SectionPickerModal open={true} onOpenChange={onOpenChange} onSelect={vi.fn()} />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Insert Section")).toBeInTheDocument();
|
||||
// Direct DOM click to bypass the inert overlay
|
||||
const cancelEl = screen.getByText("Cancel").element();
|
||||
cancelEl.click();
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("queries do not fire when closed", async () => {
|
||||
await render(
|
||||
<Wrapper>
|
||||
<SectionPickerModal open={false} onOpenChange={vi.fn()} onSelect={vi.fn()} />
|
||||
</Wrapper>,
|
||||
);
|
||||
// Wait a tick to let any queries that might fire settle
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(mockFetchSections).not.toHaveBeenCalled();
|
||||
expect(mockFetchSectionCategories).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("state resets when modal reopens", async () => {
|
||||
const onOpenChange = vi.fn();
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<SectionPickerModal open={true} onOpenChange={onOpenChange} onSelect={vi.fn()} />
|
||||
</Wrapper>,
|
||||
);
|
||||
// Type something in search
|
||||
const searchInput = screen.getByPlaceholder("Search sections...");
|
||||
await searchInput.fill("test");
|
||||
// Close and reopen
|
||||
await screen.rerender(
|
||||
<Wrapper>
|
||||
<SectionPickerModal open={false} onOpenChange={onOpenChange} onSelect={vi.fn()} />
|
||||
</Wrapper>,
|
||||
);
|
||||
await screen.rerender(
|
||||
<Wrapper>
|
||||
<SectionPickerModal open={true} onOpenChange={onOpenChange} onSelect={vi.fn()} />
|
||||
</Wrapper>,
|
||||
);
|
||||
// Search should be reset — the input should have an empty value
|
||||
await vi.waitFor(() => {
|
||||
const input = document.querySelector(
|
||||
'input[placeholder="Search sections..."]',
|
||||
) as HTMLInputElement | null;
|
||||
expect(input?.value ?? "").toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows empty state messaging when no sections match", async () => {
|
||||
mockFetchSections.mockResolvedValue({ items: [] });
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<SectionPickerModal open={true} onOpenChange={vi.fn()} onSelect={vi.fn()} />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("No sections available")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows filtered empty state when search has no results", async () => {
|
||||
mockFetchSections.mockResolvedValue({ items: [] });
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<SectionPickerModal open={true} onOpenChange={vi.fn()} onSelect={vi.fn()} />
|
||||
</Wrapper>,
|
||||
);
|
||||
const searchInput = screen.getByPlaceholder("Search sections...");
|
||||
await searchInput.fill("nonexistent");
|
||||
await expect.element(screen.getByText("No sections found")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import { Toasty } from "@cloudflare/kumo";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import type { Section, SectionsResult } from "../../src/lib/api";
|
||||
import { render } from "../utils/render.tsx";
|
||||
|
||||
// Mock router
|
||||
vi.mock("@tanstack/react-router", async () => {
|
||||
const actual = await vi.importActual("@tanstack/react-router");
|
||||
return {
|
||||
...actual,
|
||||
Link: ({ children, to, ...props }: any) => (
|
||||
<a href={to} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
useNavigate: () => vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockFetchSections = vi.fn<() => Promise<SectionsResult>>();
|
||||
const mockCreateSection = vi.fn();
|
||||
const mockDeleteSection = vi.fn();
|
||||
|
||||
vi.mock("../../src/lib/api", async () => {
|
||||
const actual = await vi.importActual("../../src/lib/api");
|
||||
return {
|
||||
...actual,
|
||||
fetchSections: (...args: unknown[]) => mockFetchSections(...(args as [])),
|
||||
createSection: (...args: unknown[]) => mockCreateSection(...(args as [])),
|
||||
deleteSection: (...args: unknown[]) => mockDeleteSection(...(args as [])),
|
||||
};
|
||||
});
|
||||
|
||||
// Import after mocks
|
||||
const { Sections } = await import("../../src/components/Sections");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DELETE_SECTION_MSG_REGEX = /This will permanently delete/;
|
||||
|
||||
function makeSection(overrides: Partial<Section> = {}): Section {
|
||||
return {
|
||||
id: "sec_01",
|
||||
slug: "hero",
|
||||
title: "Hero Section",
|
||||
description: "Main hero",
|
||||
keywords: [],
|
||||
content: [],
|
||||
source: "theme",
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
updatedAt: "2025-01-02T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return (
|
||||
<QueryClientProvider client={qc}>
|
||||
<Toasty>{children}</Toasty>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("Sections", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetchSections.mockResolvedValue({
|
||||
items: [
|
||||
makeSection({
|
||||
id: "sec_01",
|
||||
slug: "hero",
|
||||
title: "Hero Section",
|
||||
description: "Main hero",
|
||||
source: "theme",
|
||||
}),
|
||||
makeSection({
|
||||
id: "sec_02",
|
||||
slug: "cta",
|
||||
title: "Call to Action",
|
||||
description: "CTA block",
|
||||
source: "user",
|
||||
}),
|
||||
],
|
||||
});
|
||||
mockCreateSection.mockResolvedValue(makeSection({ slug: "new-section" }));
|
||||
mockDeleteSection.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("displays sections with titles and descriptions", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<Sections />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Hero Section")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Call to Action")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Main hero")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("CTA block")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("create button opens dialog with title/slug form", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<Sections />
|
||||
</Wrapper>,
|
||||
);
|
||||
await screen.getByText("New Section").click();
|
||||
await expect.element(screen.getByText("Create Section")).toBeInTheDocument();
|
||||
// Check form fields exist — InputArea uses label prop but may not be associated via aria
|
||||
await expect.element(screen.getByLabelText("Title")).toBeInTheDocument();
|
||||
await expect.element(screen.getByLabelText("Slug")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("auto-generates slug from title in create dialog", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<Sections />
|
||||
</Wrapper>,
|
||||
);
|
||||
await screen.getByText("New Section").click();
|
||||
const titleInput = screen.getByLabelText("Title");
|
||||
await titleInput.fill("My Great Section");
|
||||
// Slug should be auto-generated
|
||||
await expect.element(screen.getByLabelText("Slug")).toHaveValue("my-great-section");
|
||||
});
|
||||
|
||||
it("search input filters sections", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<Sections />
|
||||
</Wrapper>,
|
||||
);
|
||||
const searchInput = screen.getByPlaceholder("Search sections...");
|
||||
await searchInput.fill("hero");
|
||||
// fetchSections will be called again with search param
|
||||
expect(mockFetchSections).toHaveBeenCalledWith(expect.objectContaining({ search: "hero" }));
|
||||
});
|
||||
|
||||
it("delete button opens confirmation dialog", async () => {
|
||||
mockFetchSections.mockResolvedValue({
|
||||
items: [
|
||||
makeSection({
|
||||
id: "sec_02",
|
||||
slug: "cta",
|
||||
title: "Call to Action",
|
||||
description: "CTA block",
|
||||
source: "user",
|
||||
}),
|
||||
],
|
||||
});
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<Sections />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Call to Action")).toBeInTheDocument();
|
||||
// Click delete on the user section. Kumo 2.x wraps `<Button title>` as
|
||||
// a Tooltip popup rather than a DOM `title` attribute, so we locate the
|
||||
// button by its aria-label instead.
|
||||
const deleteButton = screen.getByLabelText("Delete Call to Action");
|
||||
await deleteButton.click();
|
||||
await expect.element(screen.getByText("Delete Section?")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText(DELETE_SECTION_MSG_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("theme sections have disabled delete button", async () => {
|
||||
mockFetchSections.mockResolvedValue({
|
||||
items: [
|
||||
makeSection({
|
||||
id: "sec_01",
|
||||
slug: "hero",
|
||||
title: "Hero Section",
|
||||
source: "theme",
|
||||
}),
|
||||
],
|
||||
});
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<Sections />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Hero Section")).toBeInTheDocument();
|
||||
// Kumo 2.x's `<Button title>` is rendered as a Tooltip popup, not a DOM
|
||||
// `title`. Locate the button by aria-label and assert the disabled state
|
||||
// directly; the "Cannot delete theme sections" copy is now in the
|
||||
// hover Tooltip rather than a queryable attribute.
|
||||
const deleteButton = screen.getByLabelText("Delete Hero Section");
|
||||
await expect.element(deleteButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("each section has an edit button", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<Sections />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Hero Section")).toBeInTheDocument();
|
||||
const editButtons = screen.getByText("Edit").all();
|
||||
expect(editButtons.length).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { userEvent } from "vitest/browser";
|
||||
|
||||
import { SeoPanel } from "../../src/components/SeoPanel";
|
||||
import { render } from "../utils/render";
|
||||
|
||||
// SeoPanel renders SeoImageField, which uses MediaPickerModal -- that hooks
|
||||
// into react-query. Tests need a QueryClient in scope even when the modal is
|
||||
// not opened, because the hook is called on the initial render.
|
||||
function QueryWrapper({ children }: { children: React.ReactNode }) {
|
||||
const queryClient = React.useMemo(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
}),
|
||||
[],
|
||||
);
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
describe("SeoPanel", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("renders an OG image control regardless of collection fields", async () => {
|
||||
// The OG image picker used to live next to a `featured_image` content
|
||||
// field, so collections without that field (e.g. typical Pages) had no
|
||||
// way to set it. It now lives inside SeoPanel itself.
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<SeoPanel
|
||||
contentKey="page-1"
|
||||
seo={{ title: "", description: null, image: null, canonical: null, noIndex: false }}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</QueryWrapper>,
|
||||
);
|
||||
|
||||
screen.getByText("OG Image");
|
||||
screen.getByRole("button", { name: "Select OG image" });
|
||||
});
|
||||
|
||||
it("associates visible SEO labels with their fields", async () => {
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<SeoPanel
|
||||
contentKey="page-1"
|
||||
seo={{ title: "", description: null, image: null, canonical: null, noIndex: false }}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</QueryWrapper>,
|
||||
);
|
||||
|
||||
for (const label of ["SEO Title", "Meta Description", "Canonical URL"]) {
|
||||
await userEvent.click(screen.getByText(label));
|
||||
expect(document.activeElement).toBe(screen.getByLabelText(label).element());
|
||||
}
|
||||
});
|
||||
|
||||
it("renders the existing OG image preview when set", async () => {
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<SeoPanel
|
||||
contentKey="page-1"
|
||||
seo={{
|
||||
title: null,
|
||||
description: null,
|
||||
image: "https://example.com/og.jpg",
|
||||
canonical: null,
|
||||
noIndex: false,
|
||||
}}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</QueryWrapper>,
|
||||
);
|
||||
|
||||
// The decorative <img> has alt="" so it doesn't have role="img" --
|
||||
// query the DOM directly after confirming the panel rendered.
|
||||
screen.getByText("OG Image");
|
||||
const img = document.querySelector("img");
|
||||
expect(img).not.toBeNull();
|
||||
expect(img?.getAttribute("src")).toBe("https://example.com/og.jpg");
|
||||
});
|
||||
|
||||
it("debounces text field saves", async () => {
|
||||
const onChange = vi.fn();
|
||||
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<SeoPanel
|
||||
contentKey="post-1"
|
||||
seo={{ title: "", description: null, image: null, canonical: null, noIndex: false }}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</QueryWrapper>,
|
||||
);
|
||||
|
||||
const titleInput = screen.getByLabelText("SEO Title");
|
||||
await userEvent.type(titleInput, "SEO title");
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
{ timeout: 1500 },
|
||||
);
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
title: "SEO title",
|
||||
description: null,
|
||||
canonical: null,
|
||||
noIndex: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("saves noindex changes immediately", async () => {
|
||||
const onChange = vi.fn();
|
||||
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<SeoPanel
|
||||
contentKey="post-1"
|
||||
seo={{ title: "", description: null, image: null, canonical: null, noIndex: false }}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</QueryWrapper>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole("switch"));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
title: null,
|
||||
description: null,
|
||||
canonical: null,
|
||||
noIndex: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("does not overwrite newer local text when stale props arrive", async () => {
|
||||
function Host() {
|
||||
const [seo, setSeo] = React.useState({
|
||||
title: "Original",
|
||||
description: null,
|
||||
image: null,
|
||||
canonical: null,
|
||||
noIndex: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<SeoPanel contentKey="post-1" seo={seo} onChange={() => {}} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSeo({
|
||||
title: "Older save",
|
||||
description: null,
|
||||
image: null,
|
||||
canonical: null,
|
||||
noIndex: false,
|
||||
})
|
||||
}
|
||||
>
|
||||
Apply stale props
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<Host />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
const titleInput = screen.getByLabelText("SEO Title");
|
||||
await userEvent.clear(titleInput);
|
||||
await userEvent.type(titleInput, "Newest local value");
|
||||
await vi.waitFor(() => {
|
||||
expect((titleInput.element() as HTMLInputElement).value).toBe("Newest local value");
|
||||
});
|
||||
|
||||
const stalePropsButton = screen.getByRole("button", { name: "Apply stale props" });
|
||||
await userEvent.click(stalePropsButton);
|
||||
|
||||
expect((titleInput.element() as HTMLInputElement).value).toBe("Newest local value");
|
||||
});
|
||||
|
||||
it("resets when switching to a different content item", async () => {
|
||||
const onChange = vi.fn();
|
||||
|
||||
function Host() {
|
||||
const [contentKey, setContentKey] = React.useState("post-1");
|
||||
const [seo, setSeo] = React.useState({
|
||||
title: "First post",
|
||||
description: null,
|
||||
image: null,
|
||||
canonical: null,
|
||||
noIndex: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<SeoPanel contentKey={contentKey} seo={seo} onChange={onChange} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setContentKey("post-2");
|
||||
setSeo({
|
||||
title: "Second post",
|
||||
description: "Fresh content",
|
||||
image: null,
|
||||
canonical: null,
|
||||
noIndex: false,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Switch content
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<Host />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
const titleInput = screen.getByLabelText("SEO Title");
|
||||
await userEvent.clear(titleInput);
|
||||
await userEvent.type(titleInput, "Unsaved local edit");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Switch content" }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
title: "Unsaved local edit",
|
||||
description: null,
|
||||
canonical: null,
|
||||
noIndex: false,
|
||||
});
|
||||
expect((titleInput.element() as HTMLInputElement).value).toBe("Second post");
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 700));
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("flushes pending text changes on unmount", async () => {
|
||||
const onChange = vi.fn();
|
||||
|
||||
function Host() {
|
||||
const [isVisible, setIsVisible] = React.useState(true);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isVisible ? (
|
||||
<SeoPanel
|
||||
contentKey="post-1"
|
||||
seo={{ title: "", description: null, image: null, canonical: null, noIndex: false }}
|
||||
onChange={onChange}
|
||||
/>
|
||||
) : null}
|
||||
<button type="button" onClick={() => setIsVisible(false)}>
|
||||
Hide panel
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<Host />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
const titleInput = screen.getByLabelText("SEO Title");
|
||||
await userEvent.type(titleInput, "SEO title");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Hide panel" }));
|
||||
|
||||
const expectedSeo = {
|
||||
title: "SEO title",
|
||||
description: null,
|
||||
canonical: null,
|
||||
noIndex: false,
|
||||
};
|
||||
expect(onChange).toHaveBeenCalledWith(expectedSeo);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 700));
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange.mock.lastCall?.[0]).toEqual(expectedSeo);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import type { AdminManifest } from "../../src/lib/api";
|
||||
import { render } from "../utils/render.tsx";
|
||||
|
||||
// Mock router
|
||||
vi.mock("@tanstack/react-router", async () => {
|
||||
const actual = await vi.importActual("@tanstack/react-router");
|
||||
return {
|
||||
...actual,
|
||||
Link: ({ children, to, ...props }: any) => (
|
||||
<a href={to} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
useNavigate: () => vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockFetchManifest = vi.fn<() => Promise<AdminManifest>>();
|
||||
|
||||
vi.mock("../../src/lib/api", async () => {
|
||||
const actual = await vi.importActual("../../src/lib/api");
|
||||
return {
|
||||
...actual,
|
||||
fetchManifest: (...args: unknown[]) => mockFetchManifest(...(args as [])),
|
||||
};
|
||||
});
|
||||
|
||||
// Import after mocks
|
||||
const { Settings } = await import("../../src/components/Settings");
|
||||
|
||||
const defaultManifest: AdminManifest = {
|
||||
authMode: "passkey",
|
||||
collections: {},
|
||||
plugins: {},
|
||||
taxonomies: [],
|
||||
version: "1",
|
||||
hash: "",
|
||||
};
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
describe("Settings", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetchManifest.mockResolvedValue(defaultManifest);
|
||||
});
|
||||
|
||||
it("displays settings heading", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<Settings />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByRole("heading", { name: "Settings" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows links to General, Social, and SEO sub-pages", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<Settings />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("General")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Social Links")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("SEO")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows links to API Tokens and Email sub-pages", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<Settings />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("API Tokens")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Email", { exact: true })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("security link shown when authMode is passkey", async () => {
|
||||
mockFetchManifest.mockResolvedValue(defaultManifest);
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<Settings />
|
||||
</Wrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Security")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Self-Signup Domains")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("security link hidden when authMode is not passkey", async () => {
|
||||
mockFetchManifest.mockResolvedValue({
|
||||
...defaultManifest,
|
||||
authMode: "cloudflare-access",
|
||||
});
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<Settings />
|
||||
</Wrapper>,
|
||||
);
|
||||
// Wait for the page to render by checking a link that's always visible
|
||||
await expect.element(screen.getByText("General")).toBeInTheDocument();
|
||||
expect(screen.getByText("Security").query()).toBeNull();
|
||||
expect(screen.getByText("Self-Signup Domains").query()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { render } from "../utils/render.tsx";
|
||||
|
||||
// Mock API
|
||||
let mockSeedInfo: any = null;
|
||||
|
||||
vi.mock("../../src/lib/api/client", async () => {
|
||||
const actual = await vi.importActual("../../src/lib/api/client");
|
||||
return {
|
||||
...actual,
|
||||
apiFetch: vi.fn().mockImplementation((url: string) => {
|
||||
if (url.includes("/setup/status")) {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
needsSetup: true,
|
||||
authMode: "passkey",
|
||||
...(mockSeedInfo ? { seedInfo: mockSeedInfo } : {}),
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
if (url.includes("/setup/admin")) {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ data: { success: true } }), { status: 200 }),
|
||||
);
|
||||
}
|
||||
if (url.includes("/setup") && !url.includes("status")) {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ data: { success: true } }), { status: 200 }),
|
||||
);
|
||||
}
|
||||
return Promise.resolve(new Response(JSON.stringify({ data: {} }), { status: 200 }));
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock WebAuthn so PasskeyRegistration doesn't bail out
|
||||
Object.defineProperty(window, "PublicKeyCredential", {
|
||||
value: function PublicKeyCredential() {},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// Import after mocks
|
||||
const { SetupWizard } = await import("../../src/components/SetupWizard");
|
||||
|
||||
function QueryWrapper({ children }: { children: React.ReactNode }) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
describe("SetupWizard", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSeedInfo = null;
|
||||
});
|
||||
|
||||
it("shows site setup step first with title input", async () => {
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<SetupWizard />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Set up your site")).toBeInTheDocument();
|
||||
await expect.element(screen.getByPlaceholder("My Awesome Blog")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("empty title prevents advancing and shows validation error", async () => {
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<SetupWizard />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
// Wait for setup status to load
|
||||
await expect.element(screen.getByText("Set up your site")).toBeInTheDocument();
|
||||
// Click Continue without filling title
|
||||
await screen.getByText("Continue →").click();
|
||||
await expect.element(screen.getByText("Site title is required")).toBeInTheDocument();
|
||||
// Should still be on site step
|
||||
await expect.element(screen.getByText("Set up your site")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filling title and clicking Next advances to admin step", async () => {
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<SetupWizard />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Set up your site")).toBeInTheDocument();
|
||||
// Fill in the title
|
||||
await screen.getByPlaceholder("My Awesome Blog").fill("Test Site");
|
||||
// Click continue
|
||||
await screen.getByText("Continue →").click();
|
||||
// Should advance to admin step
|
||||
await expect.element(screen.getByText("Create your account")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("admin step shows email input", async () => {
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<SetupWizard />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Set up your site")).toBeInTheDocument();
|
||||
// Fill title and advance
|
||||
await screen.getByPlaceholder("My Awesome Blog").fill("Test Site");
|
||||
await screen.getByText("Continue →").click();
|
||||
// Should see email input on admin step
|
||||
await expect.element(screen.getByText("Create your account")).toBeInTheDocument();
|
||||
await expect.element(screen.getByPlaceholder("you@example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("empty email prevents advancing on admin step", async () => {
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<SetupWizard />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Set up your site")).toBeInTheDocument();
|
||||
// Fill title and advance
|
||||
await screen.getByPlaceholder("My Awesome Blog").fill("Test Site");
|
||||
await screen.getByText("Continue →").click();
|
||||
await expect.element(screen.getByText("Create your account")).toBeInTheDocument();
|
||||
// Click Continue without filling email
|
||||
await screen.getByText("Continue →").click();
|
||||
// Should show validation error
|
||||
await expect.element(screen.getByText("Email is required")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("step indicator shows three steps", async () => {
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<SetupWizard />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Set up your site")).toBeInTheDocument();
|
||||
// Step indicator labels - use exact matching via role
|
||||
await expect.element(screen.getByText("Account")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Sign In")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("prefills title and tagline from seedInfo", async () => {
|
||||
mockSeedInfo = {
|
||||
name: "Blog Template",
|
||||
description: "A blog template",
|
||||
collections: 2,
|
||||
hasContent: true,
|
||||
title: "My Awesome Blog",
|
||||
tagline: "Thoughts and tutorials",
|
||||
};
|
||||
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<SetupWizard />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
|
||||
await expect.element(screen.getByText("Set up your site")).toBeInTheDocument();
|
||||
|
||||
const titleInput = screen.getByPlaceholder("My Awesome Blog");
|
||||
const taglineInput = screen.getByPlaceholder("Thoughts, tutorials, and more");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect((titleInput.element() as HTMLInputElement).value).toBe("My Awesome Blog");
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect((taglineInput.element() as HTMLInputElement).value).toBe("Thoughts and tutorials");
|
||||
});
|
||||
});
|
||||
|
||||
it("uses empty string for title and tagline when not in seedInfo", async () => {
|
||||
mockSeedInfo = {
|
||||
name: "Blank Template",
|
||||
description: "A blank template",
|
||||
collections: 0,
|
||||
hasContent: false,
|
||||
// title and tagline not provided
|
||||
};
|
||||
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<SetupWizard />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
|
||||
await expect.element(screen.getByText("Set up your site")).toBeInTheDocument();
|
||||
|
||||
const titleInput = screen.getByPlaceholder("My Awesome Blog");
|
||||
const taglineInput = screen.getByPlaceholder("Thoughts, tutorials, and more");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect((titleInput.element() as HTMLInputElement).value).toBe("");
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect((taglineInput.element() as HTMLInputElement).value).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
it("prefilled title can be edited and submitted", async () => {
|
||||
mockSeedInfo = {
|
||||
name: "Blog Template",
|
||||
description: "A blog template",
|
||||
collections: 2,
|
||||
hasContent: true,
|
||||
title: "My Awesome Blog",
|
||||
tagline: "Thoughts and tutorials",
|
||||
};
|
||||
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<SetupWizard />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
|
||||
await expect.element(screen.getByText("Set up your site")).toBeInTheDocument();
|
||||
|
||||
const titleInput = screen.getByPlaceholder("My Awesome Blog");
|
||||
await vi.waitFor(() => {
|
||||
expect((titleInput.element() as HTMLInputElement).value).toBe("My Awesome Blog");
|
||||
});
|
||||
|
||||
// Edit the title
|
||||
await titleInput.fill("My Custom Blog");
|
||||
await vi.waitFor(() => {
|
||||
expect((screen.getByPlaceholder("My Awesome Blog").element() as HTMLInputElement).value).toBe(
|
||||
"My Custom Blog",
|
||||
);
|
||||
});
|
||||
|
||||
// Should be able to advance with the edited value
|
||||
await screen.getByText("Continue →").click();
|
||||
await expect.element(screen.getByText("Create your account")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Sidebar nav invariants — Phase 5 visibility regression guard for the
|
||||
* "Byline Schema" entry (Discussion #1174).
|
||||
*
|
||||
* AC: "Admin sees the 'Byline Schema' sidebar entry; Editor does not."
|
||||
*
|
||||
* The full SidebarNav component is hard to test against because Kumo's
|
||||
* Sidebar primitive portals its rendered content to `document.body`,
|
||||
* applies collapse-state CSS that hides labels at narrow viewports
|
||||
* (the vitest-browser-react default), and runs Radix-style provider
|
||||
* choreography that doesn't surface anchors via `screen.container`.
|
||||
* Mounting tests against it produced inconsistent results across role
|
||||
* cases that should have been symmetric.
|
||||
*
|
||||
* Instead, the source `Sidebar.tsx` exports two pure artefacts:
|
||||
*
|
||||
* - `BYLINE_SCHEMA_NAV_ITEM` — the route + minRole pairing used
|
||||
* verbatim inside the runtime `adminItems` array.
|
||||
* - `filterNavItemsByRole` — the pure role filter applied to every
|
||||
* nav group.
|
||||
*
|
||||
* Together they cover the AC without DOM coupling: the constant pins
|
||||
* the contract, the filter pins the gate.
|
||||
*/
|
||||
|
||||
import { PuzzlePiece, Gear, Trophy, ClockCounterClockwise } from "@phosphor-icons/react";
|
||||
import * as React from "react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
BYLINE_SCHEMA_NAV_ITEM,
|
||||
filterNavItemsByRole,
|
||||
resolveNavIcon,
|
||||
toPhosphorIconName,
|
||||
} from "../../src/components/Sidebar";
|
||||
import { render } from "../utils/render.tsx";
|
||||
|
||||
// Mirror @emdash-cms/auth Role levels. Kept inline (matching Sidebar.tsx)
|
||||
// to avoid a runtime dependency just to read two numeric constants.
|
||||
const ROLE_SUBSCRIBER = 10;
|
||||
const ROLE_CONTRIBUTOR = 20;
|
||||
const ROLE_AUTHOR = 30;
|
||||
const ROLE_EDITOR = 40;
|
||||
const ROLE_ADMIN = 50;
|
||||
|
||||
describe("BYLINE_SCHEMA_NAV_ITEM invariants", () => {
|
||||
it("points to the /byline-schema route", () => {
|
||||
expect(BYLINE_SCHEMA_NAV_ITEM.to).toBe("/byline-schema");
|
||||
});
|
||||
|
||||
it("gates on ROLE_ADMIN — editors and below must not see it", () => {
|
||||
// If anyone drops this to ROLE_EDITOR (40), editors gain
|
||||
// access to admin-only schema management via the sidebar.
|
||||
// Keep this asserting the literal 50 (not the constant) so a
|
||||
// rename like `ROLE_ADMIN = 40` would also fail the test.
|
||||
expect(BYLINE_SCHEMA_NAV_ITEM.minRole).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterNavItemsByRole", () => {
|
||||
const items = [
|
||||
{ to: "/", minRole: undefined },
|
||||
{ to: "/bylines", minRole: ROLE_EDITOR },
|
||||
{ to: "/byline-schema", minRole: ROLE_ADMIN },
|
||||
];
|
||||
|
||||
it("passes items without minRole at every role", () => {
|
||||
for (const role of [ROLE_SUBSCRIBER, ROLE_CONTRIBUTOR, ROLE_AUTHOR, ROLE_EDITOR, ROLE_ADMIN]) {
|
||||
expect(filterNavItemsByRole(items, role).map((i) => i.to)).toContain("/");
|
||||
}
|
||||
});
|
||||
|
||||
it("excludes /byline-schema for EDITOR", () => {
|
||||
// Direct check of the AC: an Editor must not see the entry.
|
||||
const visible = filterNavItemsByRole(items, ROLE_EDITOR).map((i) => i.to);
|
||||
expect(visible).not.toContain("/byline-schema");
|
||||
});
|
||||
|
||||
it("excludes /byline-schema for AUTHOR, CONTRIBUTOR, SUBSCRIBER", () => {
|
||||
for (const role of [ROLE_SUBSCRIBER, ROLE_CONTRIBUTOR, ROLE_AUTHOR]) {
|
||||
const visible = filterNavItemsByRole(items, role).map((i) => i.to);
|
||||
expect(visible).not.toContain("/byline-schema");
|
||||
}
|
||||
});
|
||||
|
||||
it("includes /byline-schema for ADMIN", () => {
|
||||
const visible = filterNavItemsByRole(items, ROLE_ADMIN).map((i) => i.to);
|
||||
expect(visible).toContain("/byline-schema");
|
||||
});
|
||||
|
||||
it("treats role=0 (unauthenticated / pre-fetch) as below every gate", () => {
|
||||
// SidebarNav falls back to `userRole ?? 0` during the brief
|
||||
// load window before `useCurrentUser` resolves. The filter
|
||||
// must strip every gated entry at role=0.
|
||||
const visible = filterNavItemsByRole(items, 0).map((i) => i.to);
|
||||
expect(visible).toEqual(["/"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toPhosphorIconName", () => {
|
||||
it("converts kebab/snake/space names to PascalCase (the lazy-path key)", () => {
|
||||
// Any Phosphor icon is reachable by its own kebab name.
|
||||
expect(toPhosphorIconName("chart-bar")).toBe("ChartBar");
|
||||
expect(toPhosphorIconName("clock-counter-clockwise")).toBe("ClockCounterClockwise");
|
||||
expect(toPhosphorIconName("magnifying-glass")).toBe("MagnifyingGlass");
|
||||
expect(toPhosphorIconName("heart")).toBe("Heart");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveNavIcon", () => {
|
||||
it("falls back to PuzzlePiece when no icon is provided", () => {
|
||||
// `icon` is optional on adminPages; an omitted value is the
|
||||
// common case and must resolve synchronously to the default
|
||||
// (no Suspense boundary needed for the icon-less page).
|
||||
expect(resolveNavIcon(undefined)).toBe(PuzzlePiece);
|
||||
expect(resolveNavIcon("")).toBe(PuzzlePiece);
|
||||
});
|
||||
|
||||
it("resolves common/documented names synchronously from the static map", () => {
|
||||
// These ship in the main bundle and must NOT be lazy — the
|
||||
// everyday case never loads the full Phosphor set. Includes a
|
||||
// lucide-style alias (`settings` → Gear) and a Phosphor-named one.
|
||||
expect(resolveNavIcon("settings")).toBe(Gear);
|
||||
expect(resolveNavIcon("trophy")).toBe(Trophy);
|
||||
expect(resolveNavIcon("history")).toBe(ClockCounterClockwise);
|
||||
});
|
||||
|
||||
it("returns a stable (memoized) lazy component for a name outside the map", () => {
|
||||
// `heart` isn't in the static map, so it takes the lazy path.
|
||||
// React.lazy identity must be stable across renders, otherwise the
|
||||
// icon remounts and re-suspends — repeated calls return the same ref.
|
||||
const first = resolveNavIcon("heart");
|
||||
const second = resolveNavIcon("heart");
|
||||
expect(first).toBe(second);
|
||||
expect(first).not.toBe(PuzzlePiece);
|
||||
expect((first as { $$typeof?: symbol }).$$typeof).toBe(Symbol.for("react.lazy"));
|
||||
});
|
||||
|
||||
it("renders the PuzzlePiece fallback for a name that doesn't exist in Phosphor", async () => {
|
||||
// The lazy path resolves `mod[componentName] ?? PuzzlePiece`. Drive
|
||||
// it through a real render (not the Kumo Sidebar — just the icon) and
|
||||
// confirm the rendered glyph IS PuzzlePiece by comparing the SVG body
|
||||
// against a directly-rendered reference.
|
||||
const Unknown = resolveNavIcon("definitely-not-a-real-icon-xyz");
|
||||
const screen = await render(
|
||||
<React.Suspense fallback={<span>loading</span>}>
|
||||
<Unknown data-testid="resolved" />
|
||||
<PuzzlePiece data-testid="expected" />
|
||||
</React.Suspense>,
|
||||
);
|
||||
const resolved = screen.getByTestId("resolved");
|
||||
await expect.element(resolved).toBeInTheDocument();
|
||||
expect(resolved.element().innerHTML).toBe(screen.getByTestId("expected").element().innerHTML);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { render } from "../utils/render.tsx";
|
||||
|
||||
// Mock router
|
||||
vi.mock("@tanstack/react-router", async () => {
|
||||
const actual = await vi.importActual("@tanstack/react-router");
|
||||
return {
|
||||
...actual,
|
||||
Link: ({ children, to, ...props }: any) => (
|
||||
<a href={to} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
useNavigate: () => vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock API
|
||||
const mockRequestSignup = vi.fn().mockResolvedValue({ success: true });
|
||||
const mockVerifySignupToken = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ email: "test@example.com", role: 30, roleName: "Author" });
|
||||
|
||||
vi.mock("../../src/lib/api", async () => {
|
||||
const actual = await vi.importActual("../../src/lib/api");
|
||||
return {
|
||||
...actual,
|
||||
requestSignup: (...args: unknown[]) => mockRequestSignup(...args),
|
||||
verifySignupToken: (...args: unknown[]) => mockVerifySignupToken(...args),
|
||||
hasAllowedDomains: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock WebAuthn so PasskeyRegistration doesn't bail out
|
||||
Object.defineProperty(window, "PublicKeyCredential", {
|
||||
value: function PublicKeyCredential() {},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// Import after mocks
|
||||
const { SignupPage } = await import("../../src/components/SignupPage");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const RESEND_COOLDOWN_REGEX = /Resend in \d+s/;
|
||||
|
||||
describe("SignupPage", () => {
|
||||
beforeEach(() => {
|
||||
mockRequestSignup.mockClear();
|
||||
mockVerifySignupToken.mockClear();
|
||||
// Clean URL params
|
||||
window.history.replaceState({}, "", window.location.pathname);
|
||||
});
|
||||
|
||||
it("shows email input initially", async () => {
|
||||
const screen = await render(<SignupPage />);
|
||||
await expect.element(screen.getByText("Create an account")).toBeInTheDocument();
|
||||
await expect.element(screen.getByPlaceholder("you@company.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("submit empty email shows validation error", async () => {
|
||||
const screen = await render(<SignupPage />);
|
||||
await screen.getByText("Continue").click();
|
||||
await expect.element(screen.getByText("Email is required")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("submit invalid email (no dot) shows validation error", async () => {
|
||||
const screen = await render(<SignupPage />);
|
||||
const input = screen.getByPlaceholder("you@company.com");
|
||||
// Use an email with @ but no dot - passes browser validation but fails component validation
|
||||
await input.fill("test@nodot");
|
||||
await screen.getByText("Continue").click();
|
||||
await expect
|
||||
.element(screen.getByText("Please enter a valid email address"))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("submit valid email advances to check-email step", async () => {
|
||||
const screen = await render(<SignupPage />);
|
||||
const input = screen.getByPlaceholder("you@company.com");
|
||||
await input.fill("test@example.com");
|
||||
await screen.getByText("Continue").click();
|
||||
// Should advance to check-email: the h1 heading and the card copy.
|
||||
await expect
|
||||
.element(screen.getByRole("heading", { level: 1, name: "Check your email" }))
|
||||
.toBeInTheDocument();
|
||||
await expect.element(screen.getByText("We've sent a verification link to")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("check-email step shows correct email", async () => {
|
||||
const screen = await render(<SignupPage />);
|
||||
await screen.getByPlaceholder("you@company.com").fill("test@example.com");
|
||||
await screen.getByText("Continue").click();
|
||||
await expect.element(screen.getByText("test@example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("resend button has cooldown timer", async () => {
|
||||
mockRequestSignup.mockResolvedValue({ success: true });
|
||||
const screen = await render(<SignupPage />);
|
||||
await screen.getByPlaceholder("you@company.com").fill("test@example.com");
|
||||
await screen.getByText("Continue").click();
|
||||
// Should see resend button
|
||||
await expect.element(screen.getByText("Resend email")).toBeInTheDocument();
|
||||
// Click resend
|
||||
await screen.getByText("Resend email").click();
|
||||
// Should show cooldown text
|
||||
await expect.element(screen.getByText(RESEND_COOLDOWN_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("error step shows correct heading for token_expired", async () => {
|
||||
mockVerifySignupToken.mockRejectedValue(
|
||||
Object.assign(new Error("This link has expired"), { code: "token_expired" }),
|
||||
);
|
||||
// Navigate with token in URL
|
||||
window.history.replaceState({}, "", "?token=expired-token");
|
||||
const screen = await render(<SignupPage />);
|
||||
await expect.element(screen.getByText("Link expired")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("error step shows correct heading for invalid_token", async () => {
|
||||
mockVerifySignupToken.mockRejectedValue(
|
||||
Object.assign(new Error("Invalid token"), { code: "invalid_token" }),
|
||||
);
|
||||
window.history.replaceState({}, "", "?token=bad-token");
|
||||
const screen = await render(<SignupPage />);
|
||||
await expect.element(screen.getByText("Invalid link")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("error step shows correct heading for user_exists", async () => {
|
||||
mockVerifySignupToken.mockRejectedValue(
|
||||
Object.assign(new Error("Account already exists"), { code: "user_exists" }),
|
||||
);
|
||||
window.history.replaceState({}, "", "?token=exists-token");
|
||||
const screen = await render(<SignupPage />);
|
||||
await expect.element(screen.getByText("Account exists")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import { Toasty } from "@cloudflare/kumo";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { TaxonomyManager } from "../../src/components/TaxonomyManager";
|
||||
import { render } from "../utils/render.tsx";
|
||||
|
||||
const taxonomyResponse = JSON.stringify({
|
||||
data: {
|
||||
taxonomies: [
|
||||
{
|
||||
id: "t1",
|
||||
name: "categories",
|
||||
label: "Categories",
|
||||
labelSingular: "Category",
|
||||
hierarchical: true,
|
||||
collections: ["posts"],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const termsResponse = JSON.stringify({
|
||||
data: {
|
||||
terms: [
|
||||
{
|
||||
id: "1",
|
||||
name: "tech",
|
||||
slug: "tech",
|
||||
label: "Technology",
|
||||
parentId: null,
|
||||
children: [],
|
||||
count: 5,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "science",
|
||||
slug: "science",
|
||||
label: "Science",
|
||||
parentId: null,
|
||||
children: [],
|
||||
count: 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
vi.mock("../../src/lib/api/client.js", async () => {
|
||||
const actual = await vi.importActual("../../src/lib/api/client.js");
|
||||
return {
|
||||
...actual,
|
||||
apiFetch: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { apiFetch } from "../../src/lib/api/client.js";
|
||||
|
||||
function mockApiFetch(overrideTerms?: string) {
|
||||
vi.mocked(apiFetch).mockImplementation((url: string, init?: RequestInit) => {
|
||||
const urlStr = typeof url === "string" ? url : "";
|
||||
if (urlStr.includes("/terms") && (!init || !init.method || init.method === "GET")) {
|
||||
return Promise.resolve(
|
||||
new Response(overrideTerms ?? termsResponse, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (urlStr.includes("/taxonomies") && (!init || !init.method || init.method === "GET")) {
|
||||
return Promise.resolve(
|
||||
new Response(taxonomyResponse, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ data: { success: true } }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return (
|
||||
<Toasty>
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
</Toasty>
|
||||
);
|
||||
}
|
||||
|
||||
const ADD_CATEGORY_BUTTON_REGEX = /Add Category/;
|
||||
const ADD_CATEGORY_HEADING_REGEX = /Add Category/;
|
||||
const EDIT_CATEGORY_HEADING_REGEX = /Edit Category/;
|
||||
const PARENT_SELECTOR_REGEX = /Parent/;
|
||||
const NO_CATEGORIES_REGEX = /No categories yet/;
|
||||
const DELETE_CATEGORY_HEADING_REGEX = /Delete Category/i;
|
||||
const DELETE_TECHNOLOGY_DESC_REGEX = /permanently delete "Technology"/;
|
||||
|
||||
describe("TaxonomyManager", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockApiFetch();
|
||||
});
|
||||
|
||||
it("displays taxonomy name as heading", async () => {
|
||||
const screen = await render(<TaxonomyManager taxonomyName="categories" />, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
await expect.element(screen.getByRole("heading", { name: "Categories" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows list of terms with labels", async () => {
|
||||
const screen = await render(<TaxonomyManager taxonomyName="categories" />, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
// Use locators that target the specific label spans (font-medium class)
|
||||
await expect.element(screen.getByText("Technology", { exact: true })).toBeInTheDocument();
|
||||
// "Science" also appears in "(science)" slug, so target the font-medium span
|
||||
await expect.element(screen.getByText("(science)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows term slugs in parentheses", async () => {
|
||||
const screen = await render(<TaxonomyManager taxonomyName="categories" />, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
await expect.element(screen.getByText("(tech)")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("(science)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("add button opens create dialog", async () => {
|
||||
const screen = await render(<TaxonomyManager taxonomyName="categories" />, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
// Wait for content to load, then click the button
|
||||
await expect.element(screen.getByRole("heading", { name: "Categories" })).toBeInTheDocument();
|
||||
|
||||
await screen.getByRole("button", { name: ADD_CATEGORY_BUTTON_REGEX }).click();
|
||||
|
||||
// Verify the dialog heading opened
|
||||
await expect
|
||||
.element(screen.getByRole("heading", { name: ADD_CATEGORY_HEADING_REGEX }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("create dialog has name, slug, and description inputs", async () => {
|
||||
const screen = await render(<TaxonomyManager taxonomyName="categories" />, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
await expect.element(screen.getByRole("heading", { name: "Categories" })).toBeInTheDocument();
|
||||
|
||||
await screen.getByRole("button", { name: ADD_CATEGORY_BUTTON_REGEX }).click();
|
||||
|
||||
await expect.element(screen.getByLabelText("Name")).toBeInTheDocument();
|
||||
await expect.element(screen.getByLabelText("Slug")).toBeInTheDocument();
|
||||
// The InputArea uses "Description (optional)" as label
|
||||
await expect.element(screen.getByText("Description (optional)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows parent selector for hierarchical taxonomies", async () => {
|
||||
const screen = await render(<TaxonomyManager taxonomyName="categories" />, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
await expect.element(screen.getByRole("heading", { name: "Categories" })).toBeInTheDocument();
|
||||
|
||||
await screen.getByRole("button", { name: ADD_CATEGORY_BUTTON_REGEX }).click();
|
||||
|
||||
await expect.element(screen.getByLabelText(PARENT_SELECTOR_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("edit button opens dialog", async () => {
|
||||
const screen = await render(<TaxonomyManager taxonomyName="categories" />, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
await expect.element(screen.getByText("Technology", { exact: true })).toBeInTheDocument();
|
||||
|
||||
await screen.getByRole("button", { name: "Edit Technology" }).click();
|
||||
|
||||
// Should open the edit dialog with "Edit Category" heading
|
||||
await expect
|
||||
.element(screen.getByRole("heading", { name: EDIT_CATEGORY_HEADING_REGEX }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("delete button opens confirm dialog", async () => {
|
||||
const screen = await render(<TaxonomyManager taxonomyName="categories" />, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
await expect.element(screen.getByText("Technology", { exact: true })).toBeInTheDocument();
|
||||
|
||||
await screen.getByRole("button", { name: "Delete Technology" }).click();
|
||||
|
||||
// Should open a ConfirmDialog (not window.confirm)
|
||||
await expect
|
||||
.element(screen.getByRole("heading", { name: DELETE_CATEGORY_HEADING_REGEX }))
|
||||
.toBeInTheDocument();
|
||||
await expect.element(screen.getByText(DELETE_TECHNOLOGY_DESC_REGEX)).toBeInTheDocument();
|
||||
await expect.element(screen.getByRole("button", { name: "Delete" })).toBeInTheDocument();
|
||||
await expect.element(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows empty state when no terms", async () => {
|
||||
mockApiFetch(JSON.stringify({ data: { terms: [] } }));
|
||||
|
||||
const screen = await render(<TaxonomyManager taxonomyName="categories" />, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
await expect.element(screen.getByText(NO_CATEGORIES_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import { Toasty } from "@cloudflare/kumo";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { TaxonomySidebar } from "../../src/components/TaxonomySidebar";
|
||||
import { render } from "../utils/render.tsx";
|
||||
|
||||
vi.mock("../../src/lib/api/client.js", async () => {
|
||||
const actual = await vi.importActual("../../src/lib/api/client.js");
|
||||
return {
|
||||
...actual,
|
||||
apiFetch: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { apiFetch } from "../../src/lib/api/client.js";
|
||||
|
||||
interface TestTaxonomy {
|
||||
id: string;
|
||||
name: string;
|
||||
label: string;
|
||||
labelSingular?: string;
|
||||
hierarchical: boolean;
|
||||
collections: string[];
|
||||
}
|
||||
|
||||
interface TestTerm {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
label: string;
|
||||
parentId?: string | null;
|
||||
children: TestTerm[];
|
||||
}
|
||||
|
||||
const tagsTaxonomy: TestTaxonomy = {
|
||||
id: "tax_tags",
|
||||
name: "tags",
|
||||
label: "Tags",
|
||||
labelSingular: "Tag",
|
||||
hierarchical: false,
|
||||
collections: ["products"],
|
||||
};
|
||||
|
||||
const categoriesTaxonomy: TestTaxonomy = {
|
||||
id: "tax_categories",
|
||||
name: "categories",
|
||||
label: "Categories",
|
||||
labelSingular: "Category",
|
||||
hierarchical: true,
|
||||
collections: ["products"],
|
||||
};
|
||||
|
||||
const alphaTerm = makeTerm("term_alpha", "Alpha");
|
||||
const betaTerm = makeTerm("term_beta", "Beta");
|
||||
|
||||
function makeTerm(id: string, label: string): TestTerm {
|
||||
return {
|
||||
id,
|
||||
name: label.toLowerCase(),
|
||||
slug: label.toLowerCase(),
|
||||
label,
|
||||
parentId: null,
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
function dataResponse(data: unknown) {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ data }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function mockApiFetch({
|
||||
taxonomies = [tagsTaxonomy],
|
||||
terms = [alphaTerm, betaTerm],
|
||||
entryTerms = [],
|
||||
}: {
|
||||
taxonomies?: TestTaxonomy[];
|
||||
terms?: TestTerm[];
|
||||
entryTerms?: TestTerm[];
|
||||
} = {}) {
|
||||
vi.mocked(apiFetch).mockImplementation((url: string | URL | Request, init?: RequestInit) => {
|
||||
const urlString = typeof url === "string" ? url : url instanceof URL ? url.toString() : url.url;
|
||||
const method = init?.method ?? "GET";
|
||||
|
||||
if (method === "GET" && urlString === "/_emdash/api/taxonomies") {
|
||||
return dataResponse({ taxonomies });
|
||||
}
|
||||
|
||||
if (method === "GET" && urlString === "/_emdash/api/taxonomies/tags/terms") {
|
||||
return dataResponse({ terms });
|
||||
}
|
||||
|
||||
if (method === "GET" && urlString === "/_emdash/api/taxonomies/categories/terms") {
|
||||
return dataResponse({ terms });
|
||||
}
|
||||
|
||||
if (method === "GET" && urlString === "/_emdash/api/content/products/entry_1/terms/tags") {
|
||||
return dataResponse({ terms: entryTerms });
|
||||
}
|
||||
|
||||
return dataResponse({});
|
||||
});
|
||||
}
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
const queryClient = React.useMemo(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Toasty>{children}</Toasty>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("TaxonomySidebar", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockApiFetch();
|
||||
});
|
||||
|
||||
it("shows existing flat taxonomy terms when the tag picker receives focus", async () => {
|
||||
const screen = await render(<TaxonomySidebar collection="products" />, { wrapper: Wrapper });
|
||||
|
||||
await expect.element(screen.getByLabelText("Add Tags")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /^Alpha$/ }).query()).toBeNull();
|
||||
|
||||
await screen.getByLabelText("Add Tags").click();
|
||||
|
||||
await expect.element(screen.getByRole("button", { name: /^Alpha$/ })).toBeInTheDocument();
|
||||
await expect.element(screen.getByRole("button", { name: /^Beta$/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filters flat taxonomy terms while preserving the create option for new input", async () => {
|
||||
const screen = await render(<TaxonomySidebar collection="products" />, { wrapper: Wrapper });
|
||||
|
||||
const input = screen.getByLabelText("Add Tags");
|
||||
await input.fill("Alp");
|
||||
|
||||
await expect.element(screen.getByRole("button", { name: /^Alpha$/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /^Beta$/ }).query()).toBeNull();
|
||||
await expect.element(screen.getByText('Create "Alp"')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not suggest terms already assigned to the entry", async () => {
|
||||
mockApiFetch({ entryTerms: [alphaTerm] });
|
||||
|
||||
const screen = await render(<TaxonomySidebar collection="products" entryId="entry_1" />, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
await expect.element(screen.getByLabelText("Remove Alpha")).toBeInTheDocument();
|
||||
await screen.getByLabelText("Add Tags").click();
|
||||
|
||||
expect(screen.getByRole("button", { name: /^Alpha$/ }).query()).toBeNull();
|
||||
await expect.element(screen.getByRole("button", { name: /^Beta$/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the create prompt available when no flat taxonomy terms exist", async () => {
|
||||
mockApiFetch({ terms: [] });
|
||||
|
||||
const screen = await render(<TaxonomySidebar collection="products" />, { wrapper: Wrapper });
|
||||
|
||||
const input = screen.getByLabelText("Add Tags");
|
||||
await input.click();
|
||||
|
||||
expect(screen.getByText('Create "Gamma"').query()).toBeNull();
|
||||
|
||||
await input.fill("Gamma");
|
||||
|
||||
await expect.element(screen.getByText('Create "Gamma"')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("continues to render hierarchical taxonomies as a checkbox tree", async () => {
|
||||
mockApiFetch({ taxonomies: [categoriesTaxonomy], terms: [alphaTerm] });
|
||||
|
||||
const screen = await render(<TaxonomySidebar collection="products" />, { wrapper: Wrapper });
|
||||
|
||||
await expect.element(screen.getByText("Categories")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Alpha")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Add Categories").query()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
|
||||
import { ThemeProvider } from "../../src/components/ThemeProvider";
|
||||
import { ThemeToggle } from "../../src/components/ThemeToggle";
|
||||
import { render } from "../utils/render.tsx";
|
||||
|
||||
function TestThemeToggle({ defaultTheme = "system" as "system" | "light" | "dark" }) {
|
||||
return (
|
||||
<ThemeProvider defaultTheme={defaultTheme}>
|
||||
<ThemeToggle />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("ThemeToggle", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
});
|
||||
|
||||
// Kumo 2.x's <Button title="..."> wraps the button in a Tooltip popup
|
||||
// rather than setting the native `title` attribute. The current theme is
|
||||
// also exposed in `aria-label`, which is what these assertions read.
|
||||
|
||||
it("renders with system theme by default", async () => {
|
||||
const screen = await render(<TestThemeToggle />);
|
||||
const button = screen.getByRole("button");
|
||||
await expect.element(button).toBeInTheDocument();
|
||||
await expect.element(button).toHaveAttribute("aria-label", expect.stringContaining("System"));
|
||||
});
|
||||
|
||||
it("cycles from system to light on click", async () => {
|
||||
const screen = await render(<TestThemeToggle />);
|
||||
const button = screen.getByRole("button");
|
||||
await button.click();
|
||||
await expect.element(button).toHaveAttribute("aria-label", expect.stringContaining("Light"));
|
||||
});
|
||||
|
||||
it("cycles through system -> light -> dark -> system", async () => {
|
||||
const screen = await render(<TestThemeToggle />);
|
||||
const button = screen.getByRole("button");
|
||||
|
||||
// Start: system
|
||||
await expect.element(button).toHaveAttribute("aria-label", expect.stringContaining("System"));
|
||||
|
||||
// Click 1: light
|
||||
await button.click();
|
||||
await expect.element(button).toHaveAttribute("aria-label", expect.stringContaining("Light"));
|
||||
|
||||
// Click 2: dark
|
||||
await button.click();
|
||||
await expect.element(button).toHaveAttribute("aria-label", expect.stringContaining("Dark"));
|
||||
|
||||
// Click 3: back to system
|
||||
await button.click();
|
||||
await expect.element(button).toHaveAttribute("aria-label", expect.stringContaining("System"));
|
||||
});
|
||||
|
||||
it("persists theme to localStorage", async () => {
|
||||
const screen = await render(<TestThemeToggle />);
|
||||
const button = screen.getByRole("button");
|
||||
await button.click(); // system -> light
|
||||
expect(localStorage.getItem("emdash-theme")).toBe("light");
|
||||
});
|
||||
|
||||
it("starts with light theme when defaultTheme is light", async () => {
|
||||
const screen = await render(<TestThemeToggle defaultTheme="light" />);
|
||||
const button = screen.getByRole("button");
|
||||
await expect.element(button).toHaveAttribute("aria-label", expect.stringContaining("Light"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import { WelcomeModal } from "../../src/components/WelcomeModal";
|
||||
import { render } from "../utils/render";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const WELCOME_MESSAGE_REGEX = /Welcome to EmDash, Alice!/;
|
||||
const ADMIN_TEXT_REGEX = /As an administrator, you can invite other users/;
|
||||
|
||||
vi.mock("../../src/lib/api/client", async () => {
|
||||
const actual = await vi.importActual("../../src/lib/api/client");
|
||||
return {
|
||||
...actual,
|
||||
apiFetch: vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response(JSON.stringify({ success: true }), { status: 200 })),
|
||||
};
|
||||
});
|
||||
|
||||
function QueryWrapper({ children }: { children: React.ReactNode }) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
describe("WelcomeModal", () => {
|
||||
it("shows 'Administrator' for role >= 50", async () => {
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<WelcomeModal open={true} onClose={noop} userRole={50} />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Administrator", { exact: true })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'Editor' for role >= 40", async () => {
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<WelcomeModal open={true} onClose={noop} userRole={40} />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Editor")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'Author' for role >= 30", async () => {
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<WelcomeModal open={true} onClose={noop} userRole={30} />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Author")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'Contributor' for role >= 20", async () => {
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<WelcomeModal open={true} onClose={noop} userRole={20} />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Contributor")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'Subscriber' for role < 20", async () => {
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<WelcomeModal open={true} onClose={noop} userRole={10} />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Subscriber")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows first name from userName", async () => {
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<WelcomeModal open={true} onClose={noop} userName="Alice Smith" userRole={30} />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText(WELCOME_MESSAGE_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("'Get Started' button triggers dismiss mutation and calls onClose", async () => {
|
||||
const onClose = vi.fn();
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<WelcomeModal open={true} onClose={onClose} userRole={30} />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
const button = screen.getByText("Get Started").element().closest("button")!;
|
||||
button.click();
|
||||
// The mutation resolves and calls onClose
|
||||
await vi.waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("admin text shown only for role >= 50", async () => {
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<WelcomeModal open={true} onClose={noop} userRole={50} />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText(ADMIN_TEXT_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("admin text not shown for role < 50", async () => {
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<WelcomeModal open={true} onClose={noop} userRole={40} />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
// Check that the admin invite text is NOT present
|
||||
expect(screen.container.textContent).not.toContain(
|
||||
"As an administrator, you can invite other users",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import { Toasty } from "@cloudflare/kumo";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { Widgets } from "../../src/components/Widgets";
|
||||
import { render } from "../utils/render";
|
||||
|
||||
vi.mock("../../src/lib/api", async () => {
|
||||
const actual = await vi.importActual("../../src/lib/api");
|
||||
return {
|
||||
...actual,
|
||||
fetchWidgetAreas: vi.fn(),
|
||||
fetchWidgetComponents: vi.fn(),
|
||||
fetchMenus: vi.fn().mockResolvedValue([]),
|
||||
createWidgetArea: vi.fn().mockResolvedValue({}),
|
||||
deleteWidgetArea: vi.fn().mockResolvedValue(undefined),
|
||||
deleteWidget: vi.fn().mockResolvedValue(undefined),
|
||||
updateWidget: vi.fn().mockResolvedValue({}),
|
||||
reorderWidgets: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
import * as api from "../../src/lib/api";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DELETE_WIDGET_AREA_MSG_REGEX = /This will delete the widget area and all its widgets/;
|
||||
const ADD_WIDGET_AREA_REGEX = /Add Widget Area/;
|
||||
|
||||
function mockDefaults() {
|
||||
vi.mocked(api.fetchWidgetAreas).mockResolvedValue([
|
||||
{
|
||||
id: "a1",
|
||||
name: "sidebar",
|
||||
label: "Sidebar",
|
||||
description: "Main sidebar",
|
||||
widgets: [
|
||||
{ id: "w1", type: "content", title: "Recent Posts", sort_order: 0 },
|
||||
{ id: "w2", type: "menu", title: "Quick Links", sort_order: 1 },
|
||||
],
|
||||
},
|
||||
]);
|
||||
vi.mocked(api.fetchWidgetComponents).mockResolvedValue([
|
||||
{
|
||||
id: "recent-posts",
|
||||
label: "Recent Posts Widget",
|
||||
description: "Shows recent posts",
|
||||
props: {},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return (
|
||||
<Toasty>
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
</Toasty>
|
||||
);
|
||||
}
|
||||
|
||||
describe("Widgets", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockDefaults();
|
||||
});
|
||||
|
||||
it("displays widget areas with labels", async () => {
|
||||
const screen = await render(<Widgets />, { wrapper: Wrapper });
|
||||
|
||||
await expect.element(screen.getByRole("heading", { name: "Sidebar" })).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Main sidebar")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows widgets within each area", async () => {
|
||||
const screen = await render(<Widgets />, { wrapper: Wrapper });
|
||||
|
||||
// Widget titles are rendered in <span> elements. Use exact match to avoid
|
||||
// matching the "Recent Posts Widget" component in the available widgets list.
|
||||
await expect.element(screen.getByText("Quick Links")).toBeInTheDocument();
|
||||
// Verify widget type badges
|
||||
await expect.element(screen.getByText("(content)")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("(menu)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("create area button opens dialog with name/label/description form", async () => {
|
||||
const screen = await render(<Widgets />, { wrapper: Wrapper });
|
||||
|
||||
await screen.getByRole("button", { name: ADD_WIDGET_AREA_REGEX }).click();
|
||||
|
||||
await expect
|
||||
.element(screen.getByRole("heading", { name: "Create Widget Area" }))
|
||||
.toBeInTheDocument();
|
||||
await expect.element(screen.getByLabelText("Name")).toBeInTheDocument();
|
||||
await expect.element(screen.getByLabelText("Label")).toBeInTheDocument();
|
||||
await expect.element(screen.getByLabelText("Description")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("delete area shows confirmation dialog", async () => {
|
||||
const screen = await render(<Widgets />, { wrapper: Wrapper });
|
||||
|
||||
await expect.element(screen.getByRole("heading", { name: "Sidebar" })).toBeInTheDocument();
|
||||
|
||||
// The area header has a delete button next to the area label.
|
||||
// The WidgetAreaPanel header is a .p-4.border-b div with the label and a button.
|
||||
// Find the button inside the panel header (the div that contains the heading "Sidebar")
|
||||
const sidebarHeading = document.querySelector("h3");
|
||||
expect(sidebarHeading).not.toBeNull();
|
||||
// The delete button is a sibling of the div containing h3, within the .border-b parent
|
||||
const headerContainer = sidebarHeading!.closest(".border-b");
|
||||
expect(headerContainer).not.toBeNull();
|
||||
const deleteBtn = headerContainer!.querySelector("button");
|
||||
expect(deleteBtn).not.toBeNull();
|
||||
(deleteBtn as HTMLButtonElement).click();
|
||||
|
||||
await expect
|
||||
.element(screen.getByRole("heading", { name: "Delete Widget Area?" }))
|
||||
.toBeInTheDocument();
|
||||
await expect.element(screen.getByText(DELETE_WIDGET_AREA_MSG_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("widget expand/collapse toggles editor form", async () => {
|
||||
const screen = await render(<Widgets />, { wrapper: Wrapper });
|
||||
|
||||
await expect.element(screen.getByText("Quick Links")).toBeInTheDocument();
|
||||
|
||||
// Initially collapsed — editor form should not be visible
|
||||
expect(screen.getByText("Save").query()).toBeNull();
|
||||
|
||||
// Click the widget title area to expand (it's a <button> wrapping the title)
|
||||
const expandButtons = document.querySelectorAll("button.text-start");
|
||||
expect(expandButtons.length).toBeGreaterThanOrEqual(1);
|
||||
(expandButtons[0] as HTMLButtonElement).click();
|
||||
|
||||
// Now the editor should be visible with a Title field and Save button
|
||||
await expect.element(screen.getByLabelText("Title")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Save")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("content widget editor shows portable text editor", async () => {
|
||||
const screen = await render(<Widgets />, { wrapper: Wrapper });
|
||||
|
||||
// Wait for widget type badge to render — indicates widgets are loaded
|
||||
await expect.element(screen.getByText("(content)")).toBeInTheDocument();
|
||||
|
||||
// Expand the first widget (content type — "Recent Posts")
|
||||
const expandButtons = document.querySelectorAll("button.text-start");
|
||||
expect(expandButtons.length).toBeGreaterThanOrEqual(1);
|
||||
(expandButtons[0] as HTMLButtonElement).click();
|
||||
|
||||
// Content widget should show the Save button and Title input in the editor
|
||||
await expect.element(screen.getByText("Save")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("menu widget editor shows menu select", async () => {
|
||||
vi.mocked(api.fetchMenus).mockResolvedValue([
|
||||
{
|
||||
id: "m1",
|
||||
name: "main-nav",
|
||||
label: "Main Navigation",
|
||||
itemCount: 3,
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
updated_at: "2025-01-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "m2",
|
||||
name: "footer",
|
||||
label: "Footer Menu",
|
||||
itemCount: 2,
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
updated_at: "2025-01-01T00:00:00Z",
|
||||
},
|
||||
]);
|
||||
|
||||
const screen = await render(<Widgets />, { wrapper: Wrapper });
|
||||
|
||||
// Expand the second widget (menu type — "Quick Links")
|
||||
await expect.element(screen.getByText("Quick Links")).toBeInTheDocument();
|
||||
const expandButtons = document.querySelectorAll("button.text-start");
|
||||
expect(expandButtons.length).toBeGreaterThanOrEqual(2);
|
||||
(expandButtons[1] as HTMLButtonElement).click();
|
||||
|
||||
// Menu widget should show the Menu select
|
||||
await expect.element(screen.getByLabelText("Menu")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("empty state when no widget areas", async () => {
|
||||
vi.mocked(api.fetchWidgetAreas).mockResolvedValue([]);
|
||||
|
||||
const screen = await render(<Widgets />, { wrapper: Wrapper });
|
||||
|
||||
await expect
|
||||
.element(screen.getByText("No widget areas yet. Create one to get started."))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows available widget components panel", async () => {
|
||||
const screen = await render(<Widgets />, { wrapper: Wrapper });
|
||||
|
||||
await expect
|
||||
.element(screen.getByRole("heading", { name: "Available Widgets" }))
|
||||
.toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Content Block")).toBeInTheDocument();
|
||||
// Use exact text to avoid matching the widget type "(menu)" badge
|
||||
await expect.element(screen.getByText("Display a navigation menu")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Shows recent posts")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import { Toasty } from "@cloudflare/kumo";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { userEvent } from "@vitest/browser/context";
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { AllowedDomainsSettings } from "../../../src/components/settings/AllowedDomainsSettings";
|
||||
import { render } from "../../utils/render";
|
||||
|
||||
vi.mock("@tanstack/react-router", async () => {
|
||||
const actual = await vi.importActual("@tanstack/react-router");
|
||||
return {
|
||||
...actual,
|
||||
Link: ({ children, ...props }: any) => <a {...props}>{children}</a>,
|
||||
};
|
||||
});
|
||||
|
||||
const EXTERNAL_PROVIDER_MSG_REGEX = /User access is managed by an external provider/;
|
||||
const NO_DOMAINS_CONFIGURED_REGEX = /No domains configured/;
|
||||
|
||||
const mockFetchManifest = vi.fn();
|
||||
const mockFetchAllowedDomains = vi.fn();
|
||||
const mockCreateAllowedDomain = vi.fn();
|
||||
const mockUpdateAllowedDomain = vi.fn();
|
||||
const mockDeleteAllowedDomain = vi.fn();
|
||||
|
||||
vi.mock("../../../src/lib/api", async () => {
|
||||
const actual = await vi.importActual("../../../src/lib/api");
|
||||
return {
|
||||
...actual,
|
||||
fetchManifest: (...args: unknown[]) => mockFetchManifest(...args),
|
||||
fetchAllowedDomains: (...args: unknown[]) => mockFetchAllowedDomains(...args),
|
||||
createAllowedDomain: (...args: unknown[]) => mockCreateAllowedDomain(...args),
|
||||
updateAllowedDomain: (...args: unknown[]) => mockUpdateAllowedDomain(...args),
|
||||
deleteAllowedDomain: (...args: unknown[]) => mockDeleteAllowedDomain(...args),
|
||||
};
|
||||
});
|
||||
|
||||
function QueryWrapper({ children }: { children: React.ReactNode }) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
return (
|
||||
<Toasty>
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
</Toasty>
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetchManifest.mockResolvedValue({
|
||||
authMode: "passkey",
|
||||
collections: {},
|
||||
plugins: {},
|
||||
version: "1",
|
||||
hash: "",
|
||||
});
|
||||
mockFetchAllowedDomains.mockResolvedValue([]);
|
||||
mockCreateAllowedDomain.mockResolvedValue({});
|
||||
mockUpdateAllowedDomain.mockResolvedValue({});
|
||||
mockDeleteAllowedDomain.mockResolvedValue({});
|
||||
});
|
||||
|
||||
describe("AllowedDomainsSettings", () => {
|
||||
it("shows domain management when authMode is passkey", async () => {
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<AllowedDomainsSettings />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText("Self-Signup Domains")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Allowed Domains")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows info message when authMode is not passkey", async () => {
|
||||
mockFetchManifest.mockResolvedValue({
|
||||
authMode: "cloudflare-access",
|
||||
collections: {},
|
||||
plugins: {},
|
||||
version: "1",
|
||||
hash: "",
|
||||
});
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<AllowedDomainsSettings />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText(EXTERNAL_PROVIDER_MSG_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("empty state shows 'No domains configured'", async () => {
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<AllowedDomainsSettings />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
await expect.element(screen.getByText(NO_DOMAINS_CONFIGURED_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("add domain form: toggles open, has domain input and role select", async () => {
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<AllowedDomainsSettings />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
// Wait for data to load
|
||||
await expect.element(screen.getByText("Add Domain")).toBeInTheDocument();
|
||||
// Click Add Domain to show the form
|
||||
const addButton = screen.getByText("Add Domain").element().closest("button")!;
|
||||
await userEvent.click(addButton);
|
||||
await expect.element(screen.getByLabelText("Domain")).toBeInTheDocument();
|
||||
await expect.element(screen.getByLabelText("Default Role")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("add domain: submitting calls createAllowedDomain", async () => {
|
||||
mockCreateAllowedDomain.mockResolvedValue({
|
||||
domain: "example.com",
|
||||
defaultRole: 30,
|
||||
roleName: "Author",
|
||||
enabled: true,
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
});
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<AllowedDomainsSettings />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
// Open add form
|
||||
await expect.element(screen.getByText("Add Domain")).toBeInTheDocument();
|
||||
const addButton = screen.getByText("Add Domain").element().closest("button")!;
|
||||
await userEvent.click(addButton);
|
||||
// Fill in domain
|
||||
const domainInput = screen.getByLabelText("Domain");
|
||||
await userEvent.type(domainInput, "example.com");
|
||||
// The form submit button also says "Add Domain" — click it
|
||||
await expect.element(screen.getByText("Add Domain")).toBeInTheDocument();
|
||||
const submitButton = screen.getByText("Add Domain").element().closest("button")!;
|
||||
await userEvent.click(submitButton);
|
||||
await vi.waitFor(() => {
|
||||
expect(mockCreateAllowedDomain).toHaveBeenCalled();
|
||||
});
|
||||
await expect.element(screen.getByText("Domain added successfully")).toBeInTheDocument();
|
||||
expect(mockCreateAllowedDomain.mock.calls[0]![0]).toEqual({
|
||||
domain: "example.com",
|
||||
defaultRole: 30,
|
||||
});
|
||||
});
|
||||
|
||||
it("add domain: failed submit shows an error toast", async () => {
|
||||
mockCreateAllowedDomain.mockRejectedValue(new Error("Domain is already allowed"));
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<AllowedDomainsSettings />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
|
||||
await expect.element(screen.getByText("Add Domain")).toBeInTheDocument();
|
||||
const addButton = screen.getByText("Add Domain").element().closest("button")!;
|
||||
await userEvent.click(addButton);
|
||||
|
||||
const domainInput = screen.getByLabelText("Domain");
|
||||
await userEvent.type(domainInput, "example.com");
|
||||
const submitButton = screen.getByText("Add Domain").element().closest("button")!;
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
await expect.element(screen.getByText("Failed to add domain")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Domain is already allowed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("delete domain: confirmation dialog, confirm calls deleteAllowedDomain", async () => {
|
||||
mockFetchAllowedDomains.mockResolvedValue([
|
||||
{
|
||||
domain: "test.com",
|
||||
defaultRole: 30,
|
||||
roleName: "Author",
|
||||
enabled: true,
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
},
|
||||
]);
|
||||
mockDeleteAllowedDomain.mockResolvedValue({});
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<AllowedDomainsSettings />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
// Wait for the domain to appear
|
||||
await expect.element(screen.getByText("test.com")).toBeInTheDocument();
|
||||
// Click delete button
|
||||
const deleteButton = screen.getByLabelText("Delete test.com");
|
||||
await deleteButton.click();
|
||||
// Confirmation dialog should appear
|
||||
await expect.element(screen.getByText("Remove Domain?")).toBeInTheDocument();
|
||||
// Confirm deletion - Base UI overlays block pointer events, so click the element directly
|
||||
const confirmButton = screen
|
||||
.getByRole("button", { name: "Remove Domain" })
|
||||
.element() as HTMLButtonElement;
|
||||
confirmButton.click();
|
||||
await vi.waitFor(() => {
|
||||
expect(mockDeleteAllowedDomain).toHaveBeenCalled();
|
||||
});
|
||||
expect(mockDeleteAllowedDomain.mock.calls[0]![0]).toBe("test.com");
|
||||
});
|
||||
|
||||
it("toggle enable/disable calls updateAllowedDomain", async () => {
|
||||
mockFetchAllowedDomains.mockResolvedValue([
|
||||
{
|
||||
domain: "test.com",
|
||||
defaultRole: 30,
|
||||
roleName: "Author",
|
||||
enabled: true,
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
},
|
||||
]);
|
||||
mockUpdateAllowedDomain.mockResolvedValue({});
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<AllowedDomainsSettings />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
// Wait for the domain to appear
|
||||
await expect.element(screen.getByText("test.com")).toBeInTheDocument();
|
||||
// Find and click the switch toggle
|
||||
const switchEl = screen.getByRole("switch");
|
||||
await switchEl.click();
|
||||
expect(mockUpdateAllowedDomain).toHaveBeenCalledWith("test.com", { enabled: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Toasty } from "@cloudflare/kumo";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { userEvent } from "vitest/browser";
|
||||
|
||||
import { SecuritySettings } from "../../../src/components/settings/SecuritySettings";
|
||||
import { render } from "../../utils/render";
|
||||
|
||||
const mockFetchManifest = vi.fn();
|
||||
const mockFetchPasskeys = vi.fn();
|
||||
const mockRenamePasskey = vi.fn();
|
||||
const mockDeletePasskey = vi.fn();
|
||||
|
||||
vi.mock("@tanstack/react-router", async () => {
|
||||
const actual = await vi.importActual("@tanstack/react-router");
|
||||
return {
|
||||
...actual,
|
||||
Link: ({ children, ...props }: any) => <a {...props}>{children}</a>,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../src/lib/api", async () => {
|
||||
const actual = await vi.importActual("../../../src/lib/api");
|
||||
return {
|
||||
...actual,
|
||||
fetchManifest: (...args: unknown[]) => mockFetchManifest(...args),
|
||||
fetchPasskeys: (...args: unknown[]) => mockFetchPasskeys(...args),
|
||||
renamePasskey: (...args: unknown[]) => mockRenamePasskey(...args),
|
||||
deletePasskey: (...args: unknown[]) => mockDeletePasskey(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../src/components/auth/PasskeyRegistration", () => ({
|
||||
PasskeyRegistration: ({ onError }: { onError?: (error: Error) => void }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onError?.(new Error("Authenticator rejected registration"))}
|
||||
>
|
||||
Simulate registration error
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
function QueryWrapper({ children }: { children: React.ReactNode }) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
return (
|
||||
<Toasty>
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
</Toasty>
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetchManifest.mockResolvedValue({
|
||||
authMode: "passkey",
|
||||
collections: {},
|
||||
plugins: {},
|
||||
version: "1",
|
||||
hash: "",
|
||||
});
|
||||
mockFetchPasskeys.mockResolvedValue([]);
|
||||
mockRenamePasskey.mockResolvedValue({});
|
||||
mockDeletePasskey.mockResolvedValue({});
|
||||
});
|
||||
|
||||
describe("SecuritySettings", () => {
|
||||
it("passkey registration errors show a stable title and detail toast", async () => {
|
||||
const screen = await render(
|
||||
<QueryWrapper>
|
||||
<SecuritySettings />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
|
||||
await expect.element(screen.getByText("Add Passkey")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByText("Add Passkey"));
|
||||
await userEvent.click(screen.getByText("Simulate registration error"));
|
||||
|
||||
await expect.element(screen.getByText("Failed to add passkey")).toBeInTheDocument();
|
||||
await expect
|
||||
.element(screen.getByText("Authenticator rejected registration"))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Regression test for https://github.com/emdash-cms/emdash/issues/845
|
||||
*
|
||||
* Several admin forms use `<input pattern="...">` for slug-style identifiers.
|
||||
* Modern browsers compile that attribute as a regex with the `v`
|
||||
* (unicode-sets) flag, where unescaped/dangling `-` inside a character class
|
||||
* is a syntax error. The original `[a-z0-9-]+` therefore failed with
|
||||
* `Invalid character class` and disabled HTML form validation entirely on
|
||||
* the affected inputs.
|
||||
*
|
||||
* This test asserts that every slug-style `pattern` attribute in the admin
|
||||
* is a valid `v`-flag regex.
|
||||
*/
|
||||
|
||||
import { Toasty } from "@cloudflare/kumo";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { render } from "../utils/render.tsx";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Router mock (shared across components under test)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock("@tanstack/react-router", async () => {
|
||||
const actual = await vi.importActual("@tanstack/react-router");
|
||||
return {
|
||||
...actual,
|
||||
Link: ({
|
||||
children,
|
||||
to,
|
||||
...props
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
to?: string;
|
||||
[key: string]: unknown;
|
||||
}) => (
|
||||
<a href={typeof to === "string" ? to : "#"} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
useNavigate: () => vi.fn(),
|
||||
useParams: () => ({}),
|
||||
useSearch: () => ({}),
|
||||
};
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock("../../src/lib/api", async () => {
|
||||
const actual = await vi.importActual("../../src/lib/api");
|
||||
return {
|
||||
...actual,
|
||||
fetchSections: vi.fn().mockResolvedValue({ items: [] }),
|
||||
createSection: vi.fn(),
|
||||
deleteSection: vi.fn(),
|
||||
fetchMenus: vi.fn().mockResolvedValue([]),
|
||||
createMenu: vi.fn(),
|
||||
deleteMenu: vi.fn(),
|
||||
fetchWidgetAreas: vi.fn().mockResolvedValue([]),
|
||||
fetchWidgetComponents: vi.fn().mockResolvedValue([]),
|
||||
createWidgetArea: vi.fn(),
|
||||
deleteWidgetArea: vi.fn(),
|
||||
updateWidget: vi.fn(),
|
||||
deleteWidget: vi.fn(),
|
||||
reorderWidgets: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Imported lazily so the mocks above are in place
|
||||
const { Sections } = await import("../../src/components/Sections");
|
||||
const { MenuList } = await import("../../src/components/MenuList");
|
||||
const { Widgets } = await import("../../src/components/Widgets");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return (
|
||||
<QueryClientProvider client={qc}>
|
||||
<Toasty>{children}</Toasty>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that every `<input pattern="...">` currently in the document is a
|
||||
* valid regex when compiled with the `v` flag — which is what the browser
|
||||
* does for HTML form validation.
|
||||
*/
|
||||
function expectAllPatternsValidV() {
|
||||
const inputs = [...document.querySelectorAll<HTMLInputElement>("input[pattern]")];
|
||||
expect(inputs.length).toBeGreaterThan(0);
|
||||
for (const input of inputs) {
|
||||
const pattern = input.getAttribute("pattern");
|
||||
expect(pattern, "input pattern attribute should be set").toBeTruthy();
|
||||
expect(
|
||||
() => new RegExp(pattern as string, "v"),
|
||||
`pattern ${JSON.stringify(pattern)} on input name=${JSON.stringify(input.name)} must compile with the 'v' flag`,
|
||||
).not.toThrow();
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("slug pattern attributes are valid v-flag regexes (issue #845)", () => {
|
||||
it("Sections create dialog slug input pattern is valid", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<Sections />
|
||||
</Wrapper>,
|
||||
);
|
||||
await screen.getByText("New Section").click();
|
||||
// Wait for the dialog to render
|
||||
await expect.element(screen.getByLabelText("Slug")).toBeInTheDocument();
|
||||
expectAllPatternsValidV();
|
||||
});
|
||||
|
||||
it("MenuList create dialog name input pattern is valid", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<MenuList />
|
||||
</Wrapper>,
|
||||
);
|
||||
// "Create Menu" button opens the create dialog. With empty menus, two
|
||||
// buttons render (header trigger + empty state CTA). Click the first.
|
||||
await screen.getByText("Create Menu").first().click();
|
||||
await expect.element(screen.getByLabelText("Name")).toBeInTheDocument();
|
||||
expectAllPatternsValidV();
|
||||
});
|
||||
|
||||
it("Widgets create-area dialog name input pattern is valid", async () => {
|
||||
const screen = await render(
|
||||
<Wrapper>
|
||||
<Widgets />
|
||||
</Wrapper>,
|
||||
);
|
||||
await screen.getByText("Add Widget Area").click();
|
||||
await expect.element(screen.getByLabelText("Name")).toBeInTheDocument();
|
||||
expectAllPatternsValidV();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { userEvent } from "@vitest/browser/context";
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import { InviteUserModal } from "../../../src/components/users/InviteUserModal";
|
||||
import { render } from "../../utils/render";
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
describe("InviteUserModal", () => {
|
||||
it("shows email input and role select when open", async () => {
|
||||
const screen = await render(
|
||||
<InviteUserModal open={true} onOpenChange={noop} onInvite={noop} />,
|
||||
);
|
||||
await expect.element(screen.getByLabelText("Email address")).toBeInTheDocument();
|
||||
await expect.element(screen.getByLabelText("Role")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("default role is Author (30)", async () => {
|
||||
const screen = await render(
|
||||
<InviteUserModal open={true} onOpenChange={noop} onInvite={noop} />,
|
||||
);
|
||||
// The select should display "Author" as the selected value
|
||||
await expect.element(screen.getByText("Author")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("submit calls onInvite with email and role", async () => {
|
||||
const onInvite = vi.fn();
|
||||
const screen = await render(
|
||||
<InviteUserModal open={true} onOpenChange={noop} onInvite={onInvite} />,
|
||||
);
|
||||
const emailInput = screen.getByLabelText("Email address");
|
||||
await userEvent.type(emailInput, "new@example.com");
|
||||
const submitButton = screen.getByText("Send Invite").element().closest("button")!;
|
||||
submitButton.click();
|
||||
expect(onInvite).toHaveBeenCalledWith("new@example.com", 30);
|
||||
});
|
||||
|
||||
it("submit button disabled when email is empty", async () => {
|
||||
const screen = await render(
|
||||
<InviteUserModal open={true} onOpenChange={noop} onInvite={noop} />,
|
||||
);
|
||||
const submitButton = screen.getByText("Send Invite").element().closest("button")!;
|
||||
expect(submitButton.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("submit button disabled when isSending", async () => {
|
||||
const screen = await render(
|
||||
<InviteUserModal open={true} isSending={true} onOpenChange={noop} onInvite={noop} />,
|
||||
);
|
||||
const submitButton = screen.getByText("Sending...").element().closest("button")!;
|
||||
expect(submitButton.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("shows error message when error prop provided", async () => {
|
||||
const screen = await render(
|
||||
<InviteUserModal
|
||||
open={true}
|
||||
error="Email already exists"
|
||||
onOpenChange={noop}
|
||||
onInvite={noop}
|
||||
/>,
|
||||
);
|
||||
await expect.element(screen.getByText("Email already exists")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("form resets when modal opens", async () => {
|
||||
const result = await render(
|
||||
<InviteUserModal open={false} onOpenChange={noop} onInvite={noop} />,
|
||||
);
|
||||
// Open the modal - the effect should reset email to "" and role to 30
|
||||
await result.rerender(<InviteUserModal open={true} onOpenChange={noop} onInvite={noop} />);
|
||||
await expect.element(result.getByLabelText("Email address")).toHaveValue("");
|
||||
});
|
||||
|
||||
it("cancel button closes modal", async () => {
|
||||
const onOpenChange = vi.fn();
|
||||
const screen = await render(
|
||||
<InviteUserModal open={true} onOpenChange={onOpenChange} onInvite={noop} />,
|
||||
);
|
||||
const cancelButton = screen.getByText("Cancel").element().closest("button")!;
|
||||
cancelButton.click();
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,304 @@
|
||||
import { userEvent } from "@vitest/browser/context";
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import { UserDetail } from "../../../src/components/users/UserDetail";
|
||||
import type { UserDetail as UserDetailType } from "../../../src/lib/api";
|
||||
import { render } from "../../utils/render";
|
||||
|
||||
function makeUser(overrides: Partial<UserDetailType> = {}): UserDetailType {
|
||||
return {
|
||||
id: "user-1",
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
avatarUrl: null,
|
||||
role: 30,
|
||||
emailVerified: true,
|
||||
disabled: false,
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
updatedAt: "2025-01-02T00:00:00Z",
|
||||
lastLogin: "2025-01-02T00:00:00Z",
|
||||
credentialCount: 1,
|
||||
oauthProviders: [],
|
||||
credentials: [
|
||||
{
|
||||
id: "cred-1",
|
||||
name: "My Passkey",
|
||||
deviceType: "multiDevice",
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
lastUsedAt: "2025-01-02T00:00:00Z",
|
||||
},
|
||||
],
|
||||
oauthAccounts: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
describe("UserDetail", () => {
|
||||
it("hides dialog when not open", async () => {
|
||||
await render(
|
||||
<UserDetail
|
||||
user={makeUser()}
|
||||
isOpen={false}
|
||||
onClose={noop}
|
||||
onSave={noop}
|
||||
onDisable={noop}
|
||||
onEnable={noop}
|
||||
/>,
|
||||
);
|
||||
// Dialog.Portal renders outside the container; the popup should be hidden
|
||||
const popup = document.querySelector("[role='dialog']") as HTMLElement;
|
||||
expect(popup?.hidden ?? true).toBe(true);
|
||||
});
|
||||
|
||||
it("shows loading skeleton when isLoading", async () => {
|
||||
const screen = await render(
|
||||
<UserDetail
|
||||
user={null}
|
||||
isLoading={true}
|
||||
isOpen={true}
|
||||
onClose={noop}
|
||||
onSave={noop}
|
||||
onDisable={noop}
|
||||
onEnable={noop}
|
||||
/>,
|
||||
);
|
||||
// Skeleton has the animate-pulse class; portal renders outside screen.container
|
||||
await expect.element(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
expect(document.querySelector(".animate-pulse")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("shows 'User not found' when not loading and no user", async () => {
|
||||
const screen = await render(
|
||||
<UserDetail
|
||||
user={null}
|
||||
isLoading={false}
|
||||
isOpen={true}
|
||||
onClose={noop}
|
||||
onSave={noop}
|
||||
onDisable={noop}
|
||||
onEnable={noop}
|
||||
/>,
|
||||
);
|
||||
await expect.element(screen.getByText("User not found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays user name, email, and role correctly", async () => {
|
||||
const user = makeUser({ name: "Alice Smith", email: "alice@example.com", role: 40 });
|
||||
const screen = await render(
|
||||
<UserDetail
|
||||
user={user}
|
||||
isOpen={true}
|
||||
onClose={noop}
|
||||
onSave={noop}
|
||||
onDisable={noop}
|
||||
onEnable={noop}
|
||||
/>,
|
||||
);
|
||||
// Name input
|
||||
await expect.element(screen.getByLabelText("Name")).toHaveValue("Alice Smith");
|
||||
// Email input
|
||||
await expect.element(screen.getByLabelText("Email")).toHaveValue("alice@example.com");
|
||||
});
|
||||
|
||||
it("escape key calls onClose", async () => {
|
||||
const onClose = vi.fn();
|
||||
await render(
|
||||
<UserDetail
|
||||
user={makeUser()}
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
onSave={noop}
|
||||
onDisable={noop}
|
||||
onEnable={noop}
|
||||
/>,
|
||||
);
|
||||
await userEvent.keyboard("{Escape}");
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("backdrop click calls onClose", async () => {
|
||||
const onClose = vi.fn();
|
||||
await render(
|
||||
<UserDetail
|
||||
user={makeUser()}
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
onSave={noop}
|
||||
onDisable={noop}
|
||||
onEnable={noop}
|
||||
/>,
|
||||
);
|
||||
// Dialog.Portal renders outside screen.container
|
||||
const backdrop = document.querySelector("[role='presentation']") as HTMLElement;
|
||||
expect(backdrop).not.toBeNull();
|
||||
backdrop.click();
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("save button disabled when no changes", async () => {
|
||||
const screen = await render(
|
||||
<UserDetail
|
||||
user={makeUser()}
|
||||
isOpen={true}
|
||||
onClose={noop}
|
||||
onSave={noop}
|
||||
onDisable={noop}
|
||||
onEnable={noop}
|
||||
/>,
|
||||
);
|
||||
await expect.element(screen.getByText("Save Changes")).toBeInTheDocument();
|
||||
const saveButton = screen.getByText("Save Changes").element().closest("button")!;
|
||||
expect(saveButton.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("changing name enables save", async () => {
|
||||
const screen = await render(
|
||||
<UserDetail
|
||||
user={makeUser()}
|
||||
isOpen={true}
|
||||
onClose={noop}
|
||||
onSave={noop}
|
||||
onDisable={noop}
|
||||
onEnable={noop}
|
||||
/>,
|
||||
);
|
||||
const nameInput = screen.getByLabelText("Name");
|
||||
await userEvent.clear(nameInput);
|
||||
await userEvent.type(nameInput, "New Name");
|
||||
const saveButton = screen.getByText("Save Changes").element().closest("button")!;
|
||||
expect(saveButton.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("changing email enables save", async () => {
|
||||
const screen = await render(
|
||||
<UserDetail
|
||||
user={makeUser()}
|
||||
isOpen={true}
|
||||
onClose={noop}
|
||||
onSave={noop}
|
||||
onDisable={noop}
|
||||
onEnable={noop}
|
||||
/>,
|
||||
);
|
||||
const emailInput = screen.getByLabelText("Email");
|
||||
await userEvent.clear(emailInput);
|
||||
await userEvent.type(emailInput, "new@example.com");
|
||||
const saveButton = screen.getByText("Save Changes").element().closest("button")!;
|
||||
expect(saveButton.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("onSave only includes changed fields", async () => {
|
||||
const onSave = vi.fn();
|
||||
const screen = await render(
|
||||
<UserDetail
|
||||
user={makeUser({ name: "Original" })}
|
||||
isOpen={true}
|
||||
onClose={noop}
|
||||
onSave={onSave}
|
||||
onDisable={noop}
|
||||
onEnable={noop}
|
||||
/>,
|
||||
);
|
||||
const nameInput = screen.getByLabelText("Name");
|
||||
await userEvent.clear(nameInput);
|
||||
await userEvent.type(nameInput, "Changed");
|
||||
// Submit the form -- use native click to bypass data-base-ui-inert overlay
|
||||
const saveButton = screen.getByText("Save Changes").element().closest("button")!;
|
||||
saveButton.click();
|
||||
expect(onSave).toHaveBeenCalledWith({ name: "Changed" });
|
||||
});
|
||||
|
||||
it("self-user: role selector is disabled", async () => {
|
||||
const user = makeUser({ id: "me" });
|
||||
const screen = await render(
|
||||
<UserDetail
|
||||
user={user}
|
||||
isOpen={true}
|
||||
currentUserId="me"
|
||||
onClose={noop}
|
||||
onSave={noop}
|
||||
onDisable={noop}
|
||||
onEnable={noop}
|
||||
/>,
|
||||
);
|
||||
await expect.element(screen.getByText("You cannot change your own role")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("self-user: disable button not shown", async () => {
|
||||
const user = makeUser({ id: "me" });
|
||||
await render(
|
||||
<UserDetail
|
||||
user={user}
|
||||
isOpen={true}
|
||||
currentUserId="me"
|
||||
onClose={noop}
|
||||
onSave={noop}
|
||||
onDisable={noop}
|
||||
onEnable={noop}
|
||||
/>,
|
||||
);
|
||||
// Dialog.Portal renders outside screen.container; query the dialog popup directly
|
||||
const dialog = document.querySelector("[role='dialog']")!;
|
||||
const buttons = dialog.querySelectorAll("button");
|
||||
const disableButton = [...buttons].find((b) => b.textContent?.includes("Disable"));
|
||||
expect(disableButton).toBeUndefined();
|
||||
});
|
||||
|
||||
it("non-self: disable button shown and calls onDisable", async () => {
|
||||
const onDisable = vi.fn();
|
||||
const screen = await render(
|
||||
<UserDetail
|
||||
user={makeUser({ id: "other" })}
|
||||
isOpen={true}
|
||||
currentUserId="me"
|
||||
onClose={noop}
|
||||
onSave={noop}
|
||||
onDisable={onDisable}
|
||||
onEnable={noop}
|
||||
/>,
|
||||
);
|
||||
// Use native click to bypass data-base-ui-inert overlay
|
||||
const disableButton = screen.getByText("Disable").element().closest("button")!;
|
||||
disableButton.click();
|
||||
expect(onDisable).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("enable button shown for disabled users and calls onEnable", async () => {
|
||||
const onEnable = vi.fn();
|
||||
const screen = await render(
|
||||
<UserDetail
|
||||
user={makeUser({ id: "other", disabled: true })}
|
||||
isOpen={true}
|
||||
currentUserId="me"
|
||||
onClose={noop}
|
||||
onSave={noop}
|
||||
onDisable={noop}
|
||||
onEnable={onEnable}
|
||||
/>,
|
||||
);
|
||||
// Use native click to bypass data-base-ui-inert overlay
|
||||
const enableButton = screen.getByText("Enable").element().closest("button")!;
|
||||
enableButton.click();
|
||||
expect(onEnable).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("close button calls onClose", async () => {
|
||||
const onClose = vi.fn();
|
||||
const screen = await render(
|
||||
<UserDetail
|
||||
user={makeUser()}
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
onSave={noop}
|
||||
onDisable={noop}
|
||||
onEnable={noop}
|
||||
/>,
|
||||
);
|
||||
// Use native click to bypass data-base-ui-inert overlay
|
||||
screen.getByLabelText("Close panel").element().click();
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user