Emdash source with visual editor image upload fix
Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
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("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", "");
|
||||
});
|
||||
});
|
||||
});
|
||||
256
packages/admin/tests/components/CapabilityConsentDialog.test.tsx
Normal file
256
packages/admin/tests/components/CapabilityConsentDialog.test.tsx
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
838
packages/admin/tests/components/ContentEditor.test.tsx
Normal file
838
packages/admin/tests/components/ContentEditor.test.tsx
Normal file
@@ -0,0 +1,838 @@
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import {
|
||||
ContentEditor,
|
||||
type FieldDescriptor,
|
||||
type ContentEditorProps,
|
||||
} from "../../src/components/ContentEditor";
|
||||
import type { ContentItem } from "../../src/lib/api";
|
||||
import { render } from "../utils/render.tsx";
|
||||
|
||||
// Mock child components that have complex dependencies.
|
||||
// The mock simulates the real editor's behaviour of freezing initial content on mount:
|
||||
// it captures `value` once via useState initializer and never re-reads it.
|
||||
// This is what makes the translation-switch bug observable in tests — the displayed
|
||||
// content stays stale unless the component is forced to remount via a fresh `key`.
|
||||
//
|
||||
// It also mirrors the real component's onEditorReady contract: called with a stub
|
||||
// editor on mount and with `null` on unmount, so consumers can clear stale refs
|
||||
// before the next instance mounts.
|
||||
let portableTextMountCount = 0;
|
||||
type EditorReadyCall = { mockId: number | null };
|
||||
let onEditorReadyCalls: EditorReadyCall[] = [];
|
||||
vi.mock("../../src/components/PortableTextEditor", () => ({
|
||||
PortableTextEditor: ({ value, placeholder, onEditorReady }: any) => {
|
||||
// Mirror the real component: capture initial value once, never update.
|
||||
const [initialValue] = React.useState(() => value);
|
||||
const mountIdRef = React.useRef<number>(0);
|
||||
React.useEffect(() => {
|
||||
portableTextMountCount++;
|
||||
mountIdRef.current = portableTextMountCount;
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
if (onEditorReady) {
|
||||
const id = mountIdRef.current || portableTextMountCount + 1;
|
||||
const stubEditor = { __mockId: id } as unknown;
|
||||
onEditorReadyCalls.push({ mockId: id });
|
||||
onEditorReady(stubEditor);
|
||||
return () => {
|
||||
onEditorReadyCalls.push({ mockId: null });
|
||||
onEditorReady(null);
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}, [onEditorReady]);
|
||||
const text = Array.isArray(initialValue)
|
||||
? initialValue
|
||||
.map((b: any) => b?.children?.map((c: any) => c?.text ?? "").join("") ?? "")
|
||||
.join("\n")
|
||||
: "";
|
||||
return (
|
||||
<div data-testid="portable-text-editor" data-content={text}>
|
||||
{placeholder}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
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/MediaPickerModal", () => ({
|
||||
MediaPickerModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../../src/components/editor/DocumentOutline", () => ({
|
||||
DocumentOutline: () => <div data-testid="doc-outline">Outline</div>,
|
||||
}));
|
||||
|
||||
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,
|
||||
getPreviewUrl: vi.fn().mockResolvedValue({ url: "https://example.com/preview" }),
|
||||
};
|
||||
});
|
||||
|
||||
const defaultFields: Record<string, FieldDescriptor> = {
|
||||
title: { kind: "string", label: "Title", required: true },
|
||||
body: { kind: "string", label: "Body" },
|
||||
};
|
||||
|
||||
const MOVE_TO_TRASH_PATTERN = /Move to Trash/i;
|
||||
|
||||
function makeItem(overrides: Partial<ContentItem> = {}): 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,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderEditor(props: Partial<ContentEditorProps> = {}) {
|
||||
const defaultProps: ContentEditorProps = {
|
||||
collection: "posts",
|
||||
collectionLabel: "Post",
|
||||
fields: defaultFields,
|
||||
isNew: true,
|
||||
onSave: vi.fn(),
|
||||
...props,
|
||||
};
|
||||
return render(<ContentEditor {...defaultProps} />);
|
||||
}
|
||||
|
||||
describe("ContentEditor", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
portableTextMountCount = 0;
|
||||
onEditorReadyCalls = [];
|
||||
});
|
||||
|
||||
describe("slug generation", () => {
|
||||
it("auto-generates slug from title for new items", async () => {
|
||||
const screen = await renderEditor({ isNew: true });
|
||||
const titleInput = screen.getByLabelText("Title");
|
||||
await titleInput.fill("Hello World Post");
|
||||
|
||||
const slugInput = screen.getByLabelText("Slug");
|
||||
await expect.element(slugInput).toHaveValue("hello-world-post");
|
||||
});
|
||||
|
||||
it("slug accepts manual override", async () => {
|
||||
const screen = await renderEditor({ isNew: true });
|
||||
const slugInput = screen.getByLabelText("Slug");
|
||||
await slugInput.fill("custom-slug");
|
||||
await expect.element(slugInput).toHaveValue("custom-slug");
|
||||
|
||||
// After manual edit, typing in title should NOT update slug
|
||||
const titleInput = screen.getByLabelText("Title");
|
||||
await titleInput.fill("New Title");
|
||||
await expect.element(slugInput).toHaveValue("custom-slug");
|
||||
});
|
||||
|
||||
it("slug is editable for new items", async () => {
|
||||
const screen = await renderEditor({ isNew: true });
|
||||
const slugInput = screen.getByLabelText("Slug");
|
||||
await expect.element(slugInput).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("field rendering", () => {
|
||||
it("renders string fields as text inputs", async () => {
|
||||
const screen = await renderEditor({
|
||||
fields: { title: { kind: "string", label: "Title" } },
|
||||
});
|
||||
const input = screen.getByLabelText("Title");
|
||||
await expect.element(input).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders boolean fields as switches", async () => {
|
||||
const screen = await renderEditor({
|
||||
fields: { featured: { kind: "boolean", label: "Featured" } },
|
||||
});
|
||||
const toggle = screen.getByRole("switch");
|
||||
await expect.element(toggle).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders number fields as number inputs", async () => {
|
||||
const screen = await renderEditor({
|
||||
fields: { order: { kind: "number", label: "Order" } },
|
||||
});
|
||||
const input = screen.getByLabelText("Order");
|
||||
await expect.element(input).toHaveAttribute("type", "number");
|
||||
});
|
||||
|
||||
it("renders select fields as select dropdowns", async () => {
|
||||
const screen = await renderEditor({
|
||||
fields: {
|
||||
color: {
|
||||
kind: "select",
|
||||
label: "Color",
|
||||
options: [
|
||||
{ value: "red", label: "Red" },
|
||||
{ value: "blue", label: "Blue" },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
// Select renders a combobox role
|
||||
const select = screen.getByRole("combobox");
|
||||
await expect.element(select).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders multiSelect fields as checkbox group", async () => {
|
||||
const screen = await renderEditor({
|
||||
fields: {
|
||||
tags: {
|
||||
kind: "multiSelect",
|
||||
label: "Tags",
|
||||
options: [
|
||||
{ value: "news", label: "News" },
|
||||
{ value: "tech", label: "Tech" },
|
||||
{ value: "sports", label: "Sports" },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const checkboxes = screen.getByRole("checkbox", { exact: false });
|
||||
await expect.element(checkboxes.first()).toBeInTheDocument();
|
||||
// All option labels should be present
|
||||
await expect.element(screen.getByText("News")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Tech")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Sports")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toggling a multiSelect checkbox updates the saved value", async () => {
|
||||
const onSave = vi.fn();
|
||||
const item = makeItem({
|
||||
data: { title: "Test", tags: ["news", "sports"] },
|
||||
});
|
||||
const screen = await renderEditor({
|
||||
isNew: false,
|
||||
item,
|
||||
onSave,
|
||||
fields: {
|
||||
title: { kind: "string", label: "Title", required: true },
|
||||
tags: {
|
||||
kind: "multiSelect",
|
||||
label: "Tags",
|
||||
options: [
|
||||
{ value: "news", label: "News" },
|
||||
{ value: "tech", label: "Tech" },
|
||||
{ value: "sports", label: "Sports" },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const checkboxes = screen.getByRole("checkbox", { exact: false });
|
||||
const all = checkboxes.all();
|
||||
|
||||
// Uncheck "sports" (index 2, currently checked)
|
||||
await all[2]!.click();
|
||||
await expect.element(all[2]!).not.toBeChecked();
|
||||
|
||||
// Check "tech" (index 1, currently unchecked)
|
||||
await all[1]!.click();
|
||||
await expect.element(all[1]!).toBeChecked();
|
||||
|
||||
// Save and verify the data sent to onSave
|
||||
const saveBtn = screen.getByRole("button", { name: "Save" });
|
||||
await saveBtn.click();
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
tags: ["news", "tech"],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("multiSelect checkboxes reflect existing values", async () => {
|
||||
const item = makeItem({
|
||||
data: { title: "Test", tags: ["news", "sports"] },
|
||||
});
|
||||
const screen = await renderEditor({
|
||||
isNew: false,
|
||||
item,
|
||||
fields: {
|
||||
title: { kind: "string", label: "Title", required: true },
|
||||
tags: {
|
||||
kind: "multiSelect",
|
||||
label: "Tags",
|
||||
options: [
|
||||
{ value: "news", label: "News" },
|
||||
{ value: "tech", label: "Tech" },
|
||||
{ value: "sports", label: "Sports" },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
// Verify the checkbox group renders with correct checked state via aria
|
||||
const checkboxes = screen.getByRole("checkbox", { exact: false });
|
||||
const all = checkboxes.all();
|
||||
// Should have 3 checkboxes
|
||||
expect(all).toHaveLength(3);
|
||||
// news (checked), tech (unchecked), sports (checked)
|
||||
await expect.element(all[0]!).toBeChecked();
|
||||
await expect.element(all[1]!).not.toBeChecked();
|
||||
await expect.element(all[2]!).toBeChecked();
|
||||
});
|
||||
|
||||
it("renders datetime fields as datetime-local inputs", async () => {
|
||||
const screen = await renderEditor({
|
||||
fields: { recall_date: { kind: "datetime", label: "Recall date" } },
|
||||
});
|
||||
const input = screen.getByLabelText("Recall date");
|
||||
await expect.element(input).toHaveAttribute("type", "datetime-local");
|
||||
});
|
||||
|
||||
it("displays a stored ISO datetime in the datetime-local input", async () => {
|
||||
// The validator stores datetimes as full ISO 8601 with "Z" + millis,
|
||||
// but <input type="datetime-local"> only accepts "YYYY-MM-DDTHH:mm".
|
||||
// Without conversion the browser silently renders an empty input.
|
||||
const item = makeItem({
|
||||
data: { title: "Recall", recall_date: "2026-02-26T09:30:00.000Z" },
|
||||
});
|
||||
const screen = await renderEditor({
|
||||
isNew: false,
|
||||
item,
|
||||
fields: {
|
||||
title: { kind: "string", label: "Title", required: true },
|
||||
recall_date: { kind: "datetime", label: "Recall date" },
|
||||
},
|
||||
});
|
||||
const input = screen.getByLabelText("Recall date");
|
||||
await expect.element(input).toHaveValue("2026-02-26T09:30");
|
||||
});
|
||||
|
||||
it("saves datetime fields back as full ISO 8601 with Z and milliseconds", async () => {
|
||||
// datetime-local emits "YYYY-MM-DDTHH:mm" which the field's
|
||||
// `z.string().datetime().or(z.string().date())` schema rejects.
|
||||
// The widget must round-trip the value back to a validator-accepted shape.
|
||||
const onSave = vi.fn();
|
||||
const screen = await renderEditor({
|
||||
isNew: true,
|
||||
onSave,
|
||||
fields: {
|
||||
title: { kind: "string", label: "Title", required: true },
|
||||
recall_date: { kind: "datetime", label: "Recall date" },
|
||||
},
|
||||
});
|
||||
|
||||
const titleInput = screen.getByLabelText("Title");
|
||||
await titleInput.fill("Recall");
|
||||
|
||||
const dtInput = screen.getByLabelText("Recall date");
|
||||
await dtInput.fill("2026-02-26T09:30");
|
||||
|
||||
const saveBtn = screen.getByRole("button", { name: "Save" });
|
||||
await saveBtn.click();
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
recall_date: "2026-02-26T09:30:00.000Z",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders json fields as a textarea", async () => {
|
||||
const screen = await renderEditor({
|
||||
fields: { metadata: { kind: "json", label: "Metadata" } },
|
||||
isNew: true,
|
||||
});
|
||||
const textarea = screen.getByLabelText("Metadata");
|
||||
await expect.element(textarea).toBeInTheDocument();
|
||||
// JSON field uses a textarea element
|
||||
expect(textarea.element().tagName).toBe("TEXTAREA");
|
||||
});
|
||||
|
||||
it("renders json fields with object values as formatted JSON", async () => {
|
||||
const jsonData = { foo: "bar", num: 42 };
|
||||
const screen = await renderEditor({
|
||||
fields: { metadata: { kind: "json", label: "Metadata" } },
|
||||
item: makeItem({ data: { title: "Test", body: "", metadata: jsonData } }),
|
||||
});
|
||||
const textarea = screen.getByLabelText("Metadata");
|
||||
await expect.element(textarea).toHaveValue(JSON.stringify(jsonData, null, 2));
|
||||
});
|
||||
});
|
||||
|
||||
describe("saving", () => {
|
||||
it("save form calls onSave with formData including slug", async () => {
|
||||
const onSave = vi.fn();
|
||||
const screen = await renderEditor({ isNew: true, onSave });
|
||||
|
||||
const titleInput = screen.getByLabelText("Title");
|
||||
await titleInput.fill("Test Title");
|
||||
|
||||
const saveBtn = screen.getByRole("button", { name: "Save" });
|
||||
await saveBtn.click();
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ title: "Test Title" }),
|
||||
slug: "test-title",
|
||||
bylines: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("SaveButton shows correct dirty state for new items", async () => {
|
||||
const screen = await renderEditor({ isNew: true });
|
||||
// New items are always dirty
|
||||
const saveBtn = screen.getByRole("button", { name: "Save" });
|
||||
await expect.element(saveBtn).toBeEnabled();
|
||||
});
|
||||
|
||||
it("SaveButton is disabled (Saved) for existing item with no changes", async () => {
|
||||
const item = makeItem();
|
||||
const screen = await renderEditor({ isNew: false, item });
|
||||
const savedBtn = screen.getByRole("button", { name: "Saved" });
|
||||
await expect.element(savedBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
it("keeps edited values after autosave completes without queuing another autosave", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
try {
|
||||
const item = makeItem();
|
||||
const onAutosave = vi.fn();
|
||||
const props: ContentEditorProps = {
|
||||
collection: "posts",
|
||||
collectionLabel: "Post",
|
||||
fields: defaultFields,
|
||||
isNew: false,
|
||||
item,
|
||||
onSave: vi.fn(),
|
||||
onAutosave,
|
||||
isAutosaving: false,
|
||||
lastAutosaveAt: null,
|
||||
};
|
||||
|
||||
const screen = await render(<ContentEditor {...props} />);
|
||||
const titleInput = screen.getByLabelText("Title");
|
||||
await titleInput.fill("Updated title");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(onAutosave).toHaveBeenCalledTimes(1);
|
||||
|
||||
await screen.rerender(<ContentEditor {...props} isAutosaving={true} />);
|
||||
const autosavedItem = makeItem({
|
||||
updatedAt: "2026-04-12T18:38:00Z",
|
||||
data: { title: "Updated title", body: "Some content" },
|
||||
});
|
||||
await screen.rerender(
|
||||
<ContentEditor
|
||||
{...props}
|
||||
item={autosavedItem}
|
||||
isAutosaving={false}
|
||||
lastAutosaveAt={new Date("2026-04-12T18:38:00Z")}
|
||||
/>,
|
||||
);
|
||||
|
||||
await expect.element(screen.getByLabelText("Title")).toHaveValue("Updated title");
|
||||
await vi.advanceTimersByTimeAsync(2500);
|
||||
expect(onAutosave).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete", () => {
|
||||
it("shows delete button for existing items", async () => {
|
||||
const item = makeItem();
|
||||
const onDelete = vi.fn();
|
||||
const screen = await renderEditor({ isNew: false, item, onDelete });
|
||||
const deleteBtn = screen.getByRole("button", { name: MOVE_TO_TRASH_PATTERN });
|
||||
await expect.element(deleteBtn).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("delete button opens confirmation dialog and confirming calls onDelete", async () => {
|
||||
const item = makeItem();
|
||||
const onDelete = vi.fn();
|
||||
const screen = await renderEditor({ isNew: false, item, onDelete });
|
||||
|
||||
// Click the delete trigger button
|
||||
const deleteBtn = screen.getByRole("button", { name: MOVE_TO_TRASH_PATTERN });
|
||||
await deleteBtn.click();
|
||||
|
||||
// Dialog should appear with "Move to Trash?" title
|
||||
await expect.element(screen.getByText("Move to Trash?")).toBeInTheDocument();
|
||||
|
||||
// There are multiple "Move to Trash" buttons - click the last one (the dialog confirm)
|
||||
const allBtns = document.querySelectorAll("button");
|
||||
const trashBtns = [...allBtns].filter((b) => b.textContent?.trim() === "Move to Trash");
|
||||
if (trashBtns[1]) {
|
||||
trashBtns[1].click();
|
||||
}
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(onDelete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not show delete button for new items", async () => {
|
||||
const screen = await renderEditor({ isNew: true });
|
||||
await expect
|
||||
.element(screen.getByText("Move to Trash"), { timeout: 100 })
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("publish actions", () => {
|
||||
it("shows Publish button for draft items", async () => {
|
||||
const item = makeItem({ status: "draft" });
|
||||
const onPublish = vi.fn();
|
||||
const screen = await renderEditor({ isNew: false, item, onPublish });
|
||||
const publishBtn = screen.getByRole("button", { name: "Publish" });
|
||||
await expect.element(publishBtn).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("publish button calls onPublish", async () => {
|
||||
const item = makeItem({ status: "draft" });
|
||||
const onPublish = vi.fn();
|
||||
const screen = await renderEditor({ isNew: false, item, onPublish });
|
||||
const publishBtn = screen.getByRole("button", { name: "Publish" });
|
||||
await publishBtn.click();
|
||||
expect(onPublish).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows Unpublish for published items with supportsDrafts", async () => {
|
||||
const item = makeItem({
|
||||
status: "published",
|
||||
liveRevisionId: "rev-1",
|
||||
draftRevisionId: "rev-1",
|
||||
});
|
||||
const onUnpublish = vi.fn();
|
||||
const screen = await renderEditor({
|
||||
isNew: false,
|
||||
item,
|
||||
onUnpublish,
|
||||
supportsDrafts: true,
|
||||
});
|
||||
const unpublishBtn = screen.getByRole("button", { name: "Unpublish" });
|
||||
await expect.element(unpublishBtn).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("unpublish button calls onUnpublish", async () => {
|
||||
const item = makeItem({
|
||||
status: "published",
|
||||
liveRevisionId: "rev-1",
|
||||
draftRevisionId: "rev-1",
|
||||
});
|
||||
const onUnpublish = vi.fn();
|
||||
const screen = await renderEditor({
|
||||
isNew: false,
|
||||
item,
|
||||
onUnpublish,
|
||||
supportsDrafts: true,
|
||||
});
|
||||
const unpublishBtn = screen.getByRole("button", { name: "Unpublish" });
|
||||
await unpublishBtn.click();
|
||||
expect(onUnpublish).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("distraction-free mode", () => {
|
||||
it("toggle adds fixed class for distraction-free mode", async () => {
|
||||
const screen = await renderEditor({ isNew: true });
|
||||
const enterBtn = screen.getByRole("button", { name: "Enter distraction-free mode" });
|
||||
await enterBtn.click();
|
||||
|
||||
// The form should now have the fixed inset-0 class
|
||||
const form = document.querySelector("form");
|
||||
expect(form?.classList.toString()).toContain("fixed");
|
||||
});
|
||||
|
||||
it("escape exits distraction-free mode", async () => {
|
||||
const screen = await renderEditor({ isNew: true });
|
||||
const enterBtn = screen.getByRole("button", { name: "Enter distraction-free mode" });
|
||||
await enterBtn.click();
|
||||
|
||||
// Verify we're in distraction-free mode
|
||||
let form = document.querySelector("form");
|
||||
expect(form?.classList.toString()).toContain("fixed");
|
||||
|
||||
// Press Escape
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||
|
||||
// Wait for the state to update
|
||||
await vi.waitFor(() => {
|
||||
form = document.querySelector("form");
|
||||
expect(form?.classList.toString()).not.toContain("fixed");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("scheduler", () => {
|
||||
it("shows scheduler when Schedule for later is clicked", async () => {
|
||||
const item = makeItem({ status: "draft" });
|
||||
const onSchedule = vi.fn();
|
||||
const screen = await renderEditor({ isNew: false, item, onSchedule });
|
||||
|
||||
const scheduleBtn = screen.getByRole("button", { name: "Schedule for later" });
|
||||
await scheduleBtn.click();
|
||||
|
||||
// Should now show the datetime input
|
||||
await expect.element(screen.getByLabelText("Schedule for")).toBeInTheDocument();
|
||||
// And a Schedule submit button
|
||||
await expect.element(screen.getByRole("button", { name: "Schedule" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Publish button for scheduled items", async () => {
|
||||
const item = makeItem({ status: "scheduled", scheduledAt: "2026-06-01T12:00:00Z" });
|
||||
const onPublish = vi.fn();
|
||||
const screen = await renderEditor({ isNew: false, item, onPublish });
|
||||
|
||||
const publishBtn = screen.getByRole("button", { name: "Publish" });
|
||||
await expect.element(publishBtn).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("publish button on scheduled item calls onPublish", async () => {
|
||||
const item = makeItem({ status: "scheduled", scheduledAt: "2026-06-01T12:00:00Z" });
|
||||
const onPublish = vi.fn();
|
||||
const screen = await renderEditor({ isNew: false, item, onPublish });
|
||||
|
||||
const publishBtn = screen.getByRole("button", { name: "Publish" });
|
||||
await publishBtn.click();
|
||||
expect(onPublish).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows Unschedule button in sidebar for scheduled items", async () => {
|
||||
const item = makeItem({ status: "scheduled", scheduledAt: "2026-06-01T12:00:00Z" });
|
||||
const onUnschedule = vi.fn();
|
||||
const screen = await renderEditor({ isNew: false, item, onUnschedule });
|
||||
|
||||
// Unschedule should be in the sidebar, not in the header
|
||||
const unscheduleBtn = screen.getByRole("button", { name: "Unschedule" });
|
||||
await expect.element(unscheduleBtn).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("unschedule button calls onUnschedule", async () => {
|
||||
const item = makeItem({ status: "scheduled", scheduledAt: "2026-06-01T12:00:00Z" });
|
||||
const onUnschedule = vi.fn();
|
||||
const screen = await renderEditor({ isNew: false, item, onUnschedule });
|
||||
|
||||
const unscheduleBtn = screen.getByRole("button", { name: "Unschedule" });
|
||||
await unscheduleBtn.click();
|
||||
expect(onUnschedule).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("heading", () => {
|
||||
it("shows 'New Post' heading for new items", async () => {
|
||||
const screen = await renderEditor({ isNew: true, collectionLabel: "Post" });
|
||||
await expect.element(screen.getByText("New Post")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'Edit Post' heading for existing items", async () => {
|
||||
const item = makeItem();
|
||||
const screen = await renderEditor({ isNew: false, item, collectionLabel: "Post" });
|
||||
await expect.element(screen.getByText("Edit Post")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bug: translation switch leaves stale content in PortableTextEditor.
|
||||
//
|
||||
// When navigating between translations of the same content (e.g. /en post ->
|
||||
// /fr post), TanStack Router keeps ContentEditor mounted and only the `item`
|
||||
// prop changes. The PortableTextEditor (TipTap) freezes its content via
|
||||
// useMemo([], ...) on mount and has no effect to reconcile incoming `value`
|
||||
// changes, so it keeps showing the previous locale's body.
|
||||
//
|
||||
// Worse: any subsequent edit fires onUpdate with the stale content, silently
|
||||
// overwriting the new translation's body in formData.
|
||||
//
|
||||
// Fix: key <FieldRenderer> by `${name}:${item?.id ?? "new"}` so all field
|
||||
// editors remount cleanly when the underlying content item changes.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("translation / item switch", () => {
|
||||
function buildPtItem(id: string, text: string): ContentItem {
|
||||
return makeItem({
|
||||
id,
|
||||
slug: id,
|
||||
data: {
|
||||
title: text,
|
||||
body: [
|
||||
{
|
||||
_type: "block",
|
||||
_key: `block-${id}`,
|
||||
style: "normal",
|
||||
children: [{ _type: "span", _key: `span-${id}`, text, marks: [] }],
|
||||
markDefs: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const ptFields: Record<string, FieldDescriptor> = {
|
||||
title: { kind: "string", label: "Title", required: true },
|
||||
body: { kind: "portableText", label: "Body" },
|
||||
};
|
||||
|
||||
it("remounts the portable text editor when item.id changes (translation switch)", async () => {
|
||||
const itemEn = buildPtItem("post-en", "English body");
|
||||
const itemFr = buildPtItem("post-fr", "French body");
|
||||
|
||||
// Use a wrapper so we can swap items without unmounting ContentEditor.
|
||||
function Switcher({ item }: { item: ContentItem }) {
|
||||
return (
|
||||
<ContentEditor
|
||||
collection="posts"
|
||||
collectionLabel="Post"
|
||||
fields={ptFields}
|
||||
isNew={false}
|
||||
item={item}
|
||||
onSave={vi.fn()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const screen = await render(<Switcher item={itemEn} />);
|
||||
|
||||
// Initial mount: editor shows the English body.
|
||||
const editor = screen.getByTestId("portable-text-editor");
|
||||
await expect.element(editor).toHaveAttribute("data-content", "English body");
|
||||
expect(portableTextMountCount).toBe(1);
|
||||
|
||||
// Simulate translation switch by rerendering with a different item id.
|
||||
// The fix (keying FieldRenderer by item.id) must force a fresh mount
|
||||
// so the editor reads the new locale's body.
|
||||
await screen.rerender(<Switcher item={itemFr} />);
|
||||
|
||||
const editorAfter = screen.getByTestId("portable-text-editor");
|
||||
await expect.element(editorAfter).toHaveAttribute("data-content", "French body");
|
||||
|
||||
// A new mount means the FieldRenderer was keyed by id and remounted.
|
||||
// Without the fix, mountCount stays at 1 and content stays stale.
|
||||
expect(portableTextMountCount).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("wires onEditorReady through for the 'content' field so DocumentOutline tracks remounts", async () => {
|
||||
// ContentEditor only wires `onEditorReady` to its `setPortableTextEditor`
|
||||
// slot when the field name is exactly "content" (see ContentEditor.tsx,
|
||||
// where the conditional onEditorReady prop is set). On a translation
|
||||
// switch, the FieldRenderer is keyed by item.id so the editor remounts;
|
||||
// the corresponding cleanup call flows through, clearing the stale ref
|
||||
// in the parent before the new instance mounts.
|
||||
//
|
||||
// The actual cleanup behaviour of PortableTextEditor (calling
|
||||
// onEditorReady(null) on unmount) is exercised against the real
|
||||
// component in tests/editor/PortableTextEditor.test.tsx — this test
|
||||
// only verifies that ContentEditor wires the callback in the first place.
|
||||
const ptFieldsForContent: Record<string, FieldDescriptor> = {
|
||||
title: { kind: "string", label: "Title", required: true },
|
||||
// "content" is the magic field name that wires onEditorReady through
|
||||
// to ContentEditor's setPortableTextEditor (see ContentEditor.tsx).
|
||||
content: { kind: "portableText", label: "Body" },
|
||||
};
|
||||
|
||||
const itemEn = makeItem({
|
||||
id: "post-en",
|
||||
slug: "post-en",
|
||||
data: {
|
||||
title: "EN",
|
||||
content: [
|
||||
{
|
||||
_type: "block",
|
||||
_key: "block-en",
|
||||
style: "normal",
|
||||
children: [{ _type: "span", _key: "span-en", text: "English", marks: [] }],
|
||||
markDefs: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const itemFr = makeItem({
|
||||
id: "post-fr",
|
||||
slug: "post-fr",
|
||||
data: {
|
||||
title: "FR",
|
||||
content: [
|
||||
{
|
||||
_type: "block",
|
||||
_key: "block-fr",
|
||||
style: "normal",
|
||||
children: [{ _type: "span", _key: "span-fr", text: "French", marks: [] }],
|
||||
markDefs: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
function Switcher({ item }: { item: ContentItem }) {
|
||||
return (
|
||||
<ContentEditor
|
||||
collection="posts"
|
||||
collectionLabel="Post"
|
||||
fields={ptFieldsForContent}
|
||||
isNew={false}
|
||||
item={item}
|
||||
onSave={vi.fn()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const screen = await render(<Switcher item={itemEn} />);
|
||||
await expect
|
||||
.element(screen.getByTestId("portable-text-editor"))
|
||||
.toHaveAttribute("data-content", "English");
|
||||
|
||||
// Initial mount fired exactly one onEditorReady call with a non-null editor.
|
||||
expect(onEditorReadyCalls).toHaveLength(1);
|
||||
expect(onEditorReadyCalls[0]?.mockId).not.toBeNull();
|
||||
|
||||
await screen.rerender(<Switcher item={itemFr} />);
|
||||
await expect
|
||||
.element(screen.getByTestId("portable-text-editor"))
|
||||
.toHaveAttribute("data-content", "French");
|
||||
|
||||
// After the switch we expect the call sequence:
|
||||
// 1. mount (en) -> non-null
|
||||
// 2. cleanup (en) -> null <-- the M1 fix
|
||||
// 3. mount (fr) -> non-null
|
||||
// Without the cleanup in PortableTextEditor's onEditorReady effect,
|
||||
// step 2 is missing and the stale en-editor reference lingers in
|
||||
// ContentEditor's state during the remount window.
|
||||
const nullCallIndex = onEditorReadyCalls.findIndex((c) => c.mockId === null);
|
||||
expect(nullCallIndex).toBeGreaterThan(-1);
|
||||
|
||||
// The null call must come before the final mount (otherwise the slot
|
||||
// would end up null after a fresh editor was reported ready).
|
||||
const lastCall = onEditorReadyCalls.at(-1);
|
||||
expect(lastCall?.mockId).not.toBeNull();
|
||||
expect(nullCallIndex).toBeLessThan(onEditorReadyCalls.length - 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
522
packages/admin/tests/components/ContentList.test.tsx
Normal file
522
packages/admin/tests/components/ContentList.test.tsx
Normal file
@@ -0,0 +1,522 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
589
packages/admin/tests/components/ContentTypeEditor.test.tsx
Normal file
589
packages/admin/tests/components/ContentTypeEditor.test.tsx
Normal file
@@ -0,0 +1,589 @@
|
||||
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 SAVE_CHANGES_BUTTON_REGEX = /Save Changes/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 SAVING_BUTTON_REGEX = /Saving/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: SAVE_CHANGES_BUTTON_REGEX });
|
||||
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_CHANGES_BUTTON_REGEX });
|
||||
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_CHANGES_BUTTON_REGEX });
|
||||
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_CHANGES_BUTTON_REGEX });
|
||||
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 />,
|
||||
);
|
||||
|
||||
// Both the sticky-header SaveButton and the in-form "Save Changes"
|
||||
// button render their saving label when isSaving is true; we render
|
||||
// two save buttons by design (top-right sticky for sighted/pointer
|
||||
// users + bottom-of-form for keyboard / screen-reader users following
|
||||
// DOM order). Assert at least one matches.
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: SAVING_BUTTON_REGEX }).first())
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ---- 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} />);
|
||||
|
||||
// The sticky save button uses the SaveButton component, which renders
|
||||
// "Saved" when there are no unsaved changes. The bottom-of-form button
|
||||
// renders "Save Changes". Both should be present so users have a
|
||||
// visible save action regardless of where they are on the page.
|
||||
// Use exact: true so "Save Changes" doesn't shadow the sticky "Saved"
|
||||
// match (Playwright role-name matching is partial by default).
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: "Saved", exact: true }))
|
||||
.toBeInTheDocument();
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: SAVE_CHANGES_BUTTON_REGEX }))
|
||||
.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 -> "Saved" disabled
|
||||
await expect.element(screen.getByRole("button", { name: "Saved", exact: true })).toBeDisabled();
|
||||
|
||||
// Make a change -> sticky button should now read "Save" and be enabled.
|
||||
// exact:true so it doesn't also match "Save Changes".
|
||||
await screen.getByLabelText("Label (Plural)").fill("Articles");
|
||||
await expect.element(screen.getByRole("button", { name: "Save", exact: true })).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 }).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 sticky variant should appear (and the bottom Save Changes
|
||||
// button is also hidden for code-source collections).
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: "Saved", exact: true }))
|
||||
.not.toBeInTheDocument();
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: "Save", exact: true }))
|
||||
.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. No SaveButton ('Save'/'Saved') should render at the top.
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: "Saved", exact: true }))
|
||||
.not.toBeInTheDocument();
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: "Save", exact: true }))
|
||||
.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_CHANGES_BUTTON_REGEX });
|
||||
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_CHANGES_BUTTON_REGEX });
|
||||
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_CHANGES_BUTTON_REGEX });
|
||||
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_CHANGES_BUTTON_REGEX });
|
||||
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();
|
||||
});
|
||||
});
|
||||
225
packages/admin/tests/components/ContentTypeList.test.tsx
Normal file
225
packages/admin/tests/components/ContentTypeList.test.tsx
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
75
packages/admin/tests/components/EditorHeader.test.tsx
Normal file
75
packages/admin/tests/components/EditorHeader.test.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
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("applies sticky utility classes by default", async () => {
|
||||
const screen = await render(
|
||||
<EditorHeader actions={<button type="submit">Save</button>}>
|
||||
<h1>Title</h1>
|
||||
</EditorHeader>,
|
||||
);
|
||||
// Locate the wrapper via the data attribute on the root.
|
||||
const wrapper = screen.getByText("Title").element().closest("[data-editor-header]");
|
||||
expect(wrapper).not.toBeNull();
|
||||
expect(wrapper?.classList.contains("sticky")).toBe(true);
|
||||
expect(wrapper?.classList.contains("top-0")).toBe(true);
|
||||
});
|
||||
|
||||
it("omits sticky classes when sticky=false", async () => {
|
||||
const screen = await render(
|
||||
<EditorHeader sticky={false} actions={<button type="submit">Save</button>}>
|
||||
<h1>Title</h1>
|
||||
</EditorHeader>,
|
||||
);
|
||||
const wrapper = screen.getByText("Title").element().closest("[data-editor-header]");
|
||||
expect(wrapper).not.toBeNull();
|
||||
expect(wrapper?.classList.contains("sticky")).toBe(false);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
347
packages/admin/tests/components/FieldEditor.test.tsx
Normal file
347
packages/admin/tests/components/FieldEditor.test.tsx
Normal file
@@ -0,0 +1,347 @@
|
||||
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("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();
|
||||
});
|
||||
});
|
||||
});
|
||||
106
packages/admin/tests/components/Header.test.tsx
Normal file
106
packages/admin/tests/components/Header.test.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
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 = /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 title containing "Theme:"
|
||||
const themeButton = screen.getByTitle(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();
|
||||
});
|
||||
});
|
||||
143
packages/admin/tests/components/LoginPage.test.tsx
Normal file
143
packages/admin/tests/components/LoginPage.test.tsx
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
306
packages/admin/tests/components/MarketplaceBrowse.test.tsx
Normal file
306
packages/admin/tests/components/MarketplaceBrowse.test.tsx
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
418
packages/admin/tests/components/MarketplacePluginDetail.test.tsx
Normal file
418
packages/admin/tests/components/MarketplacePluginDetail.test.tsx
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
260
packages/admin/tests/components/MediaDetailPanel.test.tsx
Normal file
260
packages/admin/tests/components/MediaDetailPanel.test.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
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({}),
|
||||
};
|
||||
});
|
||||
|
||||
// Import the mocked functions for assertions
|
||||
import { updateMedia, deleteMedia } 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 renderPanel(props: Partial<React.ComponentProps<typeof MediaDetailPanel>> = {}) {
|
||||
const defaultProps = {
|
||||
item: null as MediaItem | null,
|
||||
onClose: vi.fn(),
|
||||
onDeleted: vi.fn(),
|
||||
...props,
|
||||
};
|
||||
return render(
|
||||
<QueryWrapper>
|
||||
<MediaDetailPanel {...defaultProps} />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
describe("MediaDetailPanel", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders nothing when item is null", async () => {
|
||||
const screen = await renderPanel({ item: null });
|
||||
// The component returns null, so "Media Details" heading shouldn't exist
|
||||
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("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();
|
||||
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();
|
||||
});
|
||||
|
||||
it("hides caption textarea for non-images", async () => {
|
||||
const pdfItem = makePdfItem();
|
||||
const screen = await renderPanel({ item: pdfItem });
|
||||
await expect
|
||||
.element(screen.getByLabelText("Caption"), { timeout: 100 })
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filename input is disabled", async () => {
|
||||
const item = makeImageItem();
|
||||
const screen = await renderPanel({ item });
|
||||
const filenameInput = screen.getByLabelText("Filename");
|
||||
await expect.element(filenameInput).toBeDisabled();
|
||||
});
|
||||
|
||||
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 item = makeImageItem({ alt: "Old alt", caption: "Old caption" });
|
||||
const screen = await renderPanel({ item });
|
||||
|
||||
const altInput = screen.getByLabelText("Alt Text");
|
||||
await altInput.fill("New alt");
|
||||
|
||||
const saveBtn = screen.getByRole("button", { name: "Save" });
|
||||
await saveBtn.click();
|
||||
|
||||
expect(updateMedia).toHaveBeenCalledWith("media-1", {
|
||||
alt: "New alt",
|
||||
caption: "Old caption",
|
||||
});
|
||||
});
|
||||
|
||||
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" });
|
||||
await deleteBtn.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("delete cancelled does not call deleteMedia", async () => {
|
||||
const item = makeImageItem();
|
||||
|
||||
const screen = await renderPanel({ item });
|
||||
const deleteBtn = screen.getByRole("button", { name: "Delete" });
|
||||
await deleteBtn.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" }).element().click();
|
||||
|
||||
expect(deleteMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("escape key calls onClose", async () => {
|
||||
const onClose = vi.fn();
|
||||
const item = makeImageItem();
|
||||
await renderPanel({ item, onClose });
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
||||
resolve();
|
||||
});
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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 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");
|
||||
});
|
||||
});
|
||||
185
packages/admin/tests/components/MediaLibrary.test.tsx
Normal file
185
packages/admin/tests/components/MediaLibrary.test.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
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 { render } from "../utils/render.tsx";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const UPLOAD_CTA_PATTERN = /Upload images, videos, and documents/;
|
||||
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(),
|
||||
onDelete: 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("button", { 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("button", { 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")).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 panel", 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();
|
||||
});
|
||||
});
|
||||
|
||||
describe("empty state", () => {
|
||||
it("shows upload CTA when no items", async () => {
|
||||
const screen = await renderLibrary({ items: [] });
|
||||
await expect.element(screen.getByText("No media yet")).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("No media yet").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("button", { 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.getByText("Media Library")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
293
packages/admin/tests/components/MediaPickerModal.test.tsx
Normal file
293
packages/admin/tests/components/MediaPickerModal.test.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
222
packages/admin/tests/components/MenuEditor.test.tsx
Normal file
222
packages/admin/tests/components/MenuEditor.test.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
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" }),
|
||||
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",
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
items: [
|
||||
{
|
||||
id: "1",
|
||||
menu_id: "menu1",
|
||||
parent_id: null,
|
||||
sort_order: 0,
|
||||
type: "custom",
|
||||
reference_collection: null,
|
||||
reference_id: null,
|
||||
custom_url: "/",
|
||||
label: "Home",
|
||||
title_attr: null,
|
||||
target: "_self",
|
||||
css_classes: null,
|
||||
created_at: "",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
menu_id: "menu1",
|
||||
parent_id: null,
|
||||
sort_order: 1,
|
||||
type: "custom",
|
||||
reference_collection: null,
|
||||
reference_id: null,
|
||||
custom_url: "/about",
|
||||
label: "About",
|
||||
title_attr: null,
|
||||
target: "_self",
|
||||
css_classes: null,
|
||||
created_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("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);
|
||||
});
|
||||
});
|
||||
144
packages/admin/tests/components/MenuList.test.tsx
Normal file
144
packages/admin/tests/components/MenuList.test.tsx
Normal file
@@ -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 items/;
|
||||
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();
|
||||
});
|
||||
});
|
||||
393
packages/admin/tests/components/PluginManager.test.tsx
Normal file
393
packages/admin/tests/components/PluginManager.test.tsx
Normal file
@@ -0,0 +1,393 @@
|
||||
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 settingsButtons = screen.getByRole("button", { name: "Settings" }).all();
|
||||
expect(settingsButtons.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 settingsButton = screen.getByRole("button", { name: "Settings" });
|
||||
await expect.element(settingsButton).toBeInTheDocument();
|
||||
const anchor = settingsButton.element().closest("a");
|
||||
expect(anchor).not.toBeNull();
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
45
packages/admin/tests/components/RepeaterField.test.tsx
Normal file
45
packages/admin/tests/components/RepeaterField.test.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
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" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
486
packages/admin/tests/components/RevisionHistory.test.tsx
Normal file
486
packages/admin/tests/components/RevisionHistory.test.tsx
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
45
packages/admin/tests/components/SaveButton.test.tsx
Normal file
45
packages/admin/tests/components/SaveButton.test.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
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.getByText("Save")).toBeInTheDocument();
|
||||
await expect.element(screen.getByRole("button")).toBeEnabled();
|
||||
});
|
||||
|
||||
it("shows 'Saving...' when saving", async () => {
|
||||
const screen = await render(<SaveButton isDirty={true} isSaving={true} />);
|
||||
await expect.element(screen.getByText("Saving...")).toBeInTheDocument();
|
||||
await expect.element(screen.getByRole("button")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows 'Saved' when not dirty and not saving", async () => {
|
||||
const screen = await render(<SaveButton isDirty={false} isSaving={false} />);
|
||||
await expect.element(screen.getByText("Saved")).toBeInTheDocument();
|
||||
await expect.element(screen.getByRole("button")).toBeDisabled();
|
||||
});
|
||||
|
||||
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("has aria-live polite", async () => {
|
||||
const screen = await render(<SaveButton isDirty={true} isSaving={false} />);
|
||||
await expect.element(screen.getByRole("button")).toHaveAttribute("aria-live", "polite");
|
||||
});
|
||||
|
||||
it("respects external disabled prop", async () => {
|
||||
const screen = await render(<SaveButton isDirty={true} isSaving={false} disabled={true} />);
|
||||
await expect.element(screen.getByRole("button")).toBeDisabled();
|
||||
});
|
||||
});
|
||||
126
packages/admin/tests/components/SectionEditor.test.tsx
Normal file
126
packages/admin/tests/components/SectionEditor.test.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
193
packages/admin/tests/components/SectionPickerModal.test.tsx
Normal file
193
packages/admin/tests/components/SectionPickerModal.test.tsx
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
203
packages/admin/tests/components/Sections.test.tsx
Normal file
203
packages/admin/tests/components/Sections.test.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
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
|
||||
const deleteButton = screen.getByTitle("Delete");
|
||||
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();
|
||||
const deleteButton = screen.getByTitle("Cannot delete theme sections");
|
||||
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);
|
||||
});
|
||||
});
|
||||
204
packages/admin/tests/components/SeoPanel.test.tsx
Normal file
204
packages/admin/tests/components/SeoPanel.test.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
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";
|
||||
|
||||
describe("SeoPanel", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("debounces text field saves", async () => {
|
||||
const onChange = vi.fn();
|
||||
|
||||
const screen = await render(
|
||||
<SeoPanel
|
||||
contentKey="post-1"
|
||||
seo={{ title: "", description: null, image: null, canonical: null, noIndex: false }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<SeoPanel
|
||||
contentKey="post-1"
|
||||
seo={{ title: "", description: null, image: null, canonical: null, noIndex: false }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(<Host />);
|
||||
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(<Host />);
|
||||
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(<Host />);
|
||||
const titleInput = screen.getByLabelText("SEO Title");
|
||||
await userEvent.type(titleInput, "SEO title");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Hide panel" }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
title: "SEO title",
|
||||
description: null,
|
||||
canonical: null,
|
||||
noIndex: false,
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 700));
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
113
packages/admin/tests/components/Settings.test.tsx
Normal file
113
packages/admin/tests/components/Settings.test.tsx
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
244
packages/admin/tests/components/SetupWizard.test.tsx
Normal file
244
packages/admin/tests/components/SetupWizard.test.tsx
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
156
packages/admin/tests/components/SignupPage.test.tsx
Normal file
156
packages/admin/tests/components/SignupPage.test.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
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 - use the h1 heading to disambiguate
|
||||
await expect
|
||||
.element(screen.getByRole("heading", { level: 1, name: "Check your email" }))
|
||||
.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("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 - h2 inside the card is unique to this step
|
||||
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();
|
||||
});
|
||||
});
|
||||
225
packages/admin/tests/components/TaxonomyManager.test.tsx
Normal file
225
packages/admin/tests/components/TaxonomyManager.test.tsx
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
69
packages/admin/tests/components/ThemeToggle.test.tsx
Normal file
69
packages/admin/tests/components/ThemeToggle.test.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
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");
|
||||
});
|
||||
|
||||
it("renders with system theme by default", async () => {
|
||||
const screen = await render(<TestThemeToggle />);
|
||||
const button = screen.getByRole("button");
|
||||
await expect.element(button).toBeInTheDocument();
|
||||
// System theme shows Monitor icon - check the title
|
||||
await expect.element(button).toHaveAttribute("title", 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("title", 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("title", expect.stringContaining("System"));
|
||||
|
||||
// Click 1: light
|
||||
await button.click();
|
||||
await expect.element(button).toHaveAttribute("title", expect.stringContaining("Light"));
|
||||
|
||||
// Click 2: dark
|
||||
await button.click();
|
||||
await expect.element(button).toHaveAttribute("title", expect.stringContaining("Dark"));
|
||||
|
||||
// Click 3: back to system
|
||||
await button.click();
|
||||
await expect.element(button).toHaveAttribute("title", 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("title", expect.stringContaining("Light"));
|
||||
});
|
||||
});
|
||||
127
packages/admin/tests/components/WelcomeModal.test.tsx
Normal file
127
packages/admin/tests/components/WelcomeModal.test.tsx
Normal file
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
213
packages/admin/tests/components/Widgets.test.tsx
Normal file
213
packages/admin/tests/components/Widgets.test.tsx
Normal file
@@ -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,204 @@
|
||||
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 <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
expect(mockCreateAllowedDomain.mock.calls[0]![0]).toEqual({
|
||||
domain: "example.com",
|
||||
defaultRole: 30,
|
||||
});
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
});
|
||||
151
packages/admin/tests/components/slugPatternRegression.test.tsx
Normal file
151
packages/admin/tests/components/slugPatternRegression.test.tsx
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
304
packages/admin/tests/components/users/UserDetail.test.tsx
Normal file
304
packages/admin/tests/components/users/UserDetail.test.tsx
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
120
packages/admin/tests/editor-focus.test.tsx
Normal file
120
packages/admin/tests/editor-focus.test.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import Focus from "@tiptap/extension-focus";
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import { render } from "./utils/render.js";
|
||||
|
||||
// Test wrapper to render editor with Focus extension
|
||||
function TestEditor({ spotlightMode = false }: { spotlightMode?: boolean }) {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Focus.configure({
|
||||
className: "has-focus",
|
||||
mode: "all",
|
||||
}),
|
||||
],
|
||||
content: `
|
||||
<p>First paragraph</p>
|
||||
<p>Second paragraph</p>
|
||||
<p>Third paragraph</p>
|
||||
`,
|
||||
immediatelyRender: true,
|
||||
});
|
||||
|
||||
if (!editor) return <div data-testid="loading">Loading...</div>;
|
||||
|
||||
return (
|
||||
<div className={spotlightMode ? "spotlight-mode" : ""} data-testid="editor-wrapper">
|
||||
<style>{`
|
||||
.spotlight-mode .ProseMirror > *:not(.has-focus) {
|
||||
opacity: 0.3;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.spotlight-mode .ProseMirror > .has-focus {
|
||||
opacity: 1;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
`}</style>
|
||||
<EditorContent editor={editor} data-testid="editor-content" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe("Editor Focus Mode", () => {
|
||||
it("Focus extension is configured correctly", async () => {
|
||||
void render(<TestEditor />);
|
||||
|
||||
// Wait for editor to initialize (not just loading state)
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
const wrapper = screen.queryByTestId("editor-wrapper");
|
||||
expect(wrapper).toBeTruthy();
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
|
||||
const editorContent = screen.getByTestId("editor-content");
|
||||
expect(editorContent).toBeDefined();
|
||||
|
||||
// The editor should be rendered with ProseMirror
|
||||
const proseMirror = editorContent.querySelector(".ProseMirror");
|
||||
expect(proseMirror).toBeTruthy();
|
||||
|
||||
// Verify the editor has the correct structure (3 paragraphs)
|
||||
const paragraphs = proseMirror?.querySelectorAll("p");
|
||||
expect(paragraphs?.length).toBe(3);
|
||||
});
|
||||
|
||||
it("spotlight mode applies CSS class to editor wrapper", async () => {
|
||||
void render(<TestEditor spotlightMode={true} />);
|
||||
|
||||
// Wait for editor to initialize
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
const wrapper = screen.queryByTestId("editor-wrapper");
|
||||
expect(wrapper).toBeTruthy();
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
|
||||
const wrapper = screen.getByTestId("editor-wrapper");
|
||||
expect(wrapper.classList.contains("spotlight-mode")).toBe(true);
|
||||
});
|
||||
|
||||
it("non-spotlight mode does not have spotlight-mode class", async () => {
|
||||
void render(<TestEditor spotlightMode={false} />);
|
||||
|
||||
// Wait for editor to initialize
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
const wrapper = screen.queryByTestId("editor-wrapper");
|
||||
expect(wrapper).toBeTruthy();
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
|
||||
const wrapper = screen.getByTestId("editor-wrapper");
|
||||
expect(wrapper.classList.contains("spotlight-mode")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Distraction-free mode state", () => {
|
||||
it("can toggle between focus modes", () => {
|
||||
// Simple state test - verifies the type and state pattern works
|
||||
type FocusMode = "normal" | "spotlight";
|
||||
|
||||
let focusMode: FocusMode = "normal";
|
||||
|
||||
// Toggle to spotlight
|
||||
focusMode = "spotlight";
|
||||
expect(focusMode).toBe("spotlight");
|
||||
|
||||
// Toggle back to normal
|
||||
focusMode = "normal";
|
||||
expect(focusMode).toBe("normal");
|
||||
});
|
||||
});
|
||||
161
packages/admin/tests/editor/DocumentOutline.test.tsx
Normal file
161
packages/admin/tests/editor/DocumentOutline.test.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
extractHeadings,
|
||||
findCurrentHeading,
|
||||
type HeadingItem,
|
||||
} from "../../src/components/editor/DocumentOutline";
|
||||
|
||||
/**
|
||||
* Create a mock editor with a document containing the specified headings
|
||||
*/
|
||||
function createMockEditor(headings: Array<{ level: number; text: string; pos: number }>) {
|
||||
const mockDoc = {
|
||||
descendants: (
|
||||
callback: (
|
||||
node: { type: { name: string }; attrs: { level: number }; textContent: string },
|
||||
pos: number,
|
||||
) => void,
|
||||
) => {
|
||||
for (const heading of headings) {
|
||||
callback(
|
||||
{
|
||||
type: { name: "heading" },
|
||||
attrs: { level: heading.level },
|
||||
textContent: heading.text,
|
||||
},
|
||||
heading.pos,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
state: {
|
||||
doc: mockDoc,
|
||||
},
|
||||
} as unknown as Parameters<typeof extractHeadings>[0];
|
||||
}
|
||||
|
||||
describe("DocumentOutline", () => {
|
||||
describe("extractHeadings", () => {
|
||||
it("returns empty array when editor is null", () => {
|
||||
const result = extractHeadings(null);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("extracts headings from editor document", () => {
|
||||
const editor = createMockEditor([
|
||||
{ level: 1, text: "Main Title", pos: 0 },
|
||||
{ level: 2, text: "Section One", pos: 50 },
|
||||
{ level: 3, text: "Subsection", pos: 100 },
|
||||
]);
|
||||
|
||||
const result = extractHeadings(editor);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0]).toMatchObject({
|
||||
level: 1,
|
||||
text: "Main Title",
|
||||
pos: 0,
|
||||
});
|
||||
expect(result[1]).toMatchObject({
|
||||
level: 2,
|
||||
text: "Section One",
|
||||
pos: 50,
|
||||
});
|
||||
expect(result[2]).toMatchObject({
|
||||
level: 3,
|
||||
text: "Subsection",
|
||||
pos: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it("skips headings with empty text", () => {
|
||||
const editor = createMockEditor([
|
||||
{ level: 1, text: "Title", pos: 0 },
|
||||
{ level: 2, text: "", pos: 50 },
|
||||
{ level: 2, text: " ", pos: 100 },
|
||||
{ level: 2, text: "Valid", pos: 150 },
|
||||
]);
|
||||
|
||||
const result = extractHeadings(editor);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]?.text).toBe("Title");
|
||||
expect(result[1]?.text).toBe("Valid");
|
||||
});
|
||||
|
||||
it("assigns unique keys to headings", () => {
|
||||
const editor = createMockEditor([
|
||||
{ level: 1, text: "Title", pos: 0 },
|
||||
{ level: 2, text: "Section", pos: 50 },
|
||||
]);
|
||||
|
||||
const result = extractHeadings(editor);
|
||||
|
||||
expect(result[0]?.key).toBeDefined();
|
||||
expect(result[1]?.key).toBeDefined();
|
||||
expect(result[0]?.key).not.toBe(result[1]?.key);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findCurrentHeading", () => {
|
||||
const headings: HeadingItem[] = [
|
||||
{ level: 1, text: "Title", pos: 0, key: "h1" },
|
||||
{ level: 2, text: "Section One", pos: 100, key: "h2" },
|
||||
{ level: 2, text: "Section Two", pos: 200, key: "h3" },
|
||||
{ level: 3, text: "Subsection", pos: 300, key: "h4" },
|
||||
];
|
||||
|
||||
it("returns null for empty headings array", () => {
|
||||
const result = findCurrentHeading([], 50);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when cursor is before first heading", () => {
|
||||
const headingsWithOffset: HeadingItem[] = [{ level: 1, text: "Title", pos: 100, key: "h1" }];
|
||||
const result = findCurrentHeading(headingsWithOffset, 50);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns first heading when cursor is at its position", () => {
|
||||
const result = findCurrentHeading(headings, 0);
|
||||
expect(result?.key).toBe("h1");
|
||||
});
|
||||
|
||||
it("returns heading that contains cursor position", () => {
|
||||
const result = findCurrentHeading(headings, 150);
|
||||
expect(result?.key).toBe("h2");
|
||||
});
|
||||
|
||||
it("returns last heading when cursor is past all headings", () => {
|
||||
const result = findCurrentHeading(headings, 500);
|
||||
expect(result?.key).toBe("h4");
|
||||
});
|
||||
|
||||
it("returns heading when cursor is exactly at heading position", () => {
|
||||
const result = findCurrentHeading(headings, 200);
|
||||
expect(result?.key).toBe("h3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("indentation", () => {
|
||||
it("correctly structures heading levels", () => {
|
||||
const editor = createMockEditor([
|
||||
{ level: 1, text: "H1", pos: 0 },
|
||||
{ level: 2, text: "H2", pos: 50 },
|
||||
{ level: 3, text: "H3", pos: 100 },
|
||||
]);
|
||||
|
||||
const result = extractHeadings(editor);
|
||||
|
||||
// H1 should be at root level
|
||||
expect(result[0]?.level).toBe(1);
|
||||
// H2 should be indented (level 2)
|
||||
expect(result[1]?.level).toBe(2);
|
||||
// H3 should be further indented (level 3)
|
||||
expect(result[2]?.level).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
956
packages/admin/tests/editor/PortableTextEditor.test.tsx
Normal file
956
packages/admin/tests/editor/PortableTextEditor.test.tsx
Normal file
@@ -0,0 +1,956 @@
|
||||
/**
|
||||
* PortableTextEditor component tests.
|
||||
*
|
||||
* Tests the TipTap-based rich text editor in vitest browser mode,
|
||||
* covering Portable Text ↔ ProseMirror round-trip conversion,
|
||||
* toolbar behaviour, focus modes, and editor lifecycle.
|
||||
*/
|
||||
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import type { PluginBlockDef } from "../../src/components/PortableTextEditor";
|
||||
import {
|
||||
_buildPluginBlockFormValues,
|
||||
_hasPluginBlockFormData,
|
||||
PortableTextEditor,
|
||||
} from "../../src/components/PortableTextEditor";
|
||||
import { render } from "../utils/render";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks — heavy components that need network / Astro context
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock("../../src/components/MediaPickerModal", () => ({
|
||||
MediaPickerModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../../src/components/SectionPickerModal", () => ({
|
||||
SectionPickerModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../../src/components/editor/DragHandleWrapper", () => ({
|
||||
DragHandleWrapper: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../../src/components/editor/ImageNode", async () => {
|
||||
const { Node } = await import("@tiptap/core");
|
||||
|
||||
const ImageExtension = Node.create({
|
||||
name: "image",
|
||||
group: "block",
|
||||
atom: true,
|
||||
addAttributes() {
|
||||
return {
|
||||
src: { default: null },
|
||||
alt: { default: "" },
|
||||
title: { default: "" },
|
||||
caption: { default: "" },
|
||||
mediaId: { default: null },
|
||||
provider: { default: "local" },
|
||||
width: { default: null },
|
||||
height: { default: null },
|
||||
displayWidth: { default: null },
|
||||
displayHeight: { default: null },
|
||||
};
|
||||
},
|
||||
parseHTML() {
|
||||
return [{ tag: "img[src]" }];
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["img", HTMLAttributes];
|
||||
},
|
||||
});
|
||||
|
||||
return { ImageExtension };
|
||||
});
|
||||
|
||||
vi.mock("../../src/components/editor/PluginBlockNode", async () => {
|
||||
const { Node } = await import("@tiptap/core");
|
||||
|
||||
const PluginBlockExtension = Node.create({
|
||||
name: "pluginBlock",
|
||||
group: "block",
|
||||
atom: true,
|
||||
addAttributes() {
|
||||
return {
|
||||
blockType: { default: "embed" },
|
||||
id: { default: "" },
|
||||
data: { default: {} },
|
||||
};
|
||||
},
|
||||
parseHTML() {
|
||||
return [{ tag: "div[data-plugin-block]" }];
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["div", { ...HTMLAttributes, "data-plugin-block": "" }];
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
PluginBlockExtension,
|
||||
getEmbedMeta: () => ({ label: "Embed", Icon: () => null }),
|
||||
registerPluginBlocks: () => {},
|
||||
resolveIcon: () => () => null,
|
||||
};
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SPOTLIGHT_MODE_PATTERN = /Spotlight Mode/i;
|
||||
|
||||
/** Wait for the ProseMirror editor to mount inside the container */
|
||||
async function waitForEditor(): Promise<HTMLElement> {
|
||||
let pm: HTMLElement | null = null;
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
pm = document.querySelector(".ProseMirror") as HTMLElement | null;
|
||||
expect(pm).toBeTruthy();
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
return pm!;
|
||||
}
|
||||
|
||||
/** Focus the ProseMirror contenteditable and wait for it to be focused */
|
||||
async function focusEditor(pm: HTMLElement) {
|
||||
pm.focus();
|
||||
await vi.waitFor(() => expect(document.activeElement).toBe(pm), { timeout: 1000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the editor, wait for it to initialize, and return the Editor instance.
|
||||
* Useful for tests that need to type or manipulate content programmatically.
|
||||
*/
|
||||
async function renderAndGetEditor(props: Partial<Parameters<typeof PortableTextEditor>[0]> = {}) {
|
||||
let capturedEditor: Editor | null = null;
|
||||
const screen = await render(
|
||||
<PortableTextEditor
|
||||
onEditorReady={(editor) => {
|
||||
capturedEditor = editor;
|
||||
}}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
const pm = await waitForEditor();
|
||||
await vi.waitFor(() => expect(capturedEditor).toBeTruthy(), { timeout: 2000 });
|
||||
return { screen, editor: capturedEditor!, pm };
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate typing text into the editor via TipTap's API.
|
||||
* This avoids browser keyboard API issues and is more reliable in tests.
|
||||
*/
|
||||
function typeIntoEditor(editor: Editor, text: string) {
|
||||
editor.chain().focus().insertContent(text).run();
|
||||
}
|
||||
|
||||
// Shorthand block builders
|
||||
function textBlock(
|
||||
text: string,
|
||||
opts: {
|
||||
style?: "normal" | "h1" | "h2" | "h3" | "blockquote";
|
||||
marks?: string[];
|
||||
listItem?: "bullet" | "number";
|
||||
level?: number;
|
||||
markDefs?: Array<{ _type: string; _key: string; [k: string]: unknown }>;
|
||||
} = {},
|
||||
) {
|
||||
return {
|
||||
_type: "block" as const,
|
||||
_key: Math.random().toString(36).slice(2, 9),
|
||||
style: opts.style ?? "normal",
|
||||
...(opts.listItem ? { listItem: opts.listItem, level: opts.level ?? 1 } : {}),
|
||||
children: [
|
||||
{
|
||||
_type: "span" as const,
|
||||
_key: Math.random().toString(36).slice(2, 9),
|
||||
text,
|
||||
marks: opts.marks,
|
||||
},
|
||||
],
|
||||
markDefs: opts.markDefs,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 1. Plugin block helpers
|
||||
// =============================================================================
|
||||
|
||||
describe("plugin block helpers", () => {
|
||||
it("builds form state from field initial_value defaults", () => {
|
||||
const block: PluginBlockDef = {
|
||||
type: "readingTime",
|
||||
pluginId: "reading-time",
|
||||
label: "Reading Time",
|
||||
fields: [
|
||||
{
|
||||
type: "select",
|
||||
action_id: "variant",
|
||||
label: "Style",
|
||||
options: [
|
||||
{ label: "Inline", value: "inline" },
|
||||
{ label: "Compact", value: "compact" },
|
||||
],
|
||||
initial_value: "inline",
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
action_id: "includeHeadings",
|
||||
label: "Include headings",
|
||||
initial_value: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(_buildPluginBlockFormValues(block)).toEqual({
|
||||
variant: "inline",
|
||||
includeHeadings: true,
|
||||
});
|
||||
expect(_hasPluginBlockFormData(_buildPluginBlockFormValues(block))).toBe(true);
|
||||
});
|
||||
|
||||
it("merges existing block data over defaults when editing", () => {
|
||||
const block: PluginBlockDef = {
|
||||
type: "readingTime",
|
||||
pluginId: "reading-time",
|
||||
label: "Reading Time",
|
||||
fields: [
|
||||
{
|
||||
type: "select",
|
||||
action_id: "variant",
|
||||
label: "Style",
|
||||
options: [
|
||||
{ label: "Inline", value: "inline" },
|
||||
{ label: "Compact", value: "compact" },
|
||||
],
|
||||
initial_value: "inline",
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
action_id: "includeHeadings",
|
||||
label: "Include headings",
|
||||
initial_value: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(
|
||||
_buildPluginBlockFormValues(block, {
|
||||
variant: "compact",
|
||||
customLabel: "Custom label",
|
||||
includeHeadings: false,
|
||||
}),
|
||||
).toEqual({
|
||||
variant: "compact",
|
||||
customLabel: "Custom label",
|
||||
includeHeadings: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps explicit existing values over defaults", () => {
|
||||
const block: PluginBlockDef = {
|
||||
type: "readingTime",
|
||||
pluginId: "reading-time",
|
||||
label: "Reading Time",
|
||||
fields: [
|
||||
{
|
||||
type: "number_input",
|
||||
action_id: "minutes",
|
||||
label: "Minutes",
|
||||
initial_value: 5,
|
||||
},
|
||||
{
|
||||
type: "text_input",
|
||||
action_id: "label",
|
||||
label: "Label",
|
||||
initial_value: "Default label",
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
action_id: "includeHeadings",
|
||||
label: "Include headings",
|
||||
initial_value: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(
|
||||
_buildPluginBlockFormValues(block, {
|
||||
minutes: 0,
|
||||
label: "",
|
||||
includeHeadings: false,
|
||||
}),
|
||||
).toEqual({
|
||||
minutes: 0,
|
||||
label: "",
|
||||
includeHeadings: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// 2. Portable Text ↔ ProseMirror Conversion (via component)
|
||||
// =============================================================================
|
||||
|
||||
describe("Portable Text ↔ ProseMirror conversion", () => {
|
||||
it("renders a paragraph from PT value", async () => {
|
||||
await render(<PortableTextEditor value={[textBlock("Hello world")]} />);
|
||||
const pm = await waitForEditor();
|
||||
const p = pm.querySelector("p");
|
||||
expect(p).toBeTruthy();
|
||||
expect(p!.textContent).toBe("Hello world");
|
||||
});
|
||||
|
||||
it("renders an h1 heading", async () => {
|
||||
await render(<PortableTextEditor value={[textBlock("Title", { style: "h1" })]} />);
|
||||
const pm = await waitForEditor();
|
||||
const h1 = pm.querySelector("h1");
|
||||
expect(h1).toBeTruthy();
|
||||
expect(h1!.textContent).toBe("Title");
|
||||
});
|
||||
|
||||
it("renders bold text", async () => {
|
||||
await render(<PortableTextEditor value={[textBlock("Bold text", { marks: ["strong"] })]} />);
|
||||
const pm = await waitForEditor();
|
||||
const strong = pm.querySelector("strong");
|
||||
expect(strong).toBeTruthy();
|
||||
expect(strong!.textContent).toBe("Bold text");
|
||||
});
|
||||
|
||||
it("renders a link from markDef", async () => {
|
||||
const linkKey = "lnk1";
|
||||
await render(
|
||||
<PortableTextEditor
|
||||
value={[
|
||||
textBlock("Click me", {
|
||||
marks: [linkKey],
|
||||
markDefs: [{ _type: "link", _key: linkKey, href: "https://example.com" }],
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
const pm = await waitForEditor();
|
||||
const anchor = pm.querySelector("a");
|
||||
expect(anchor).toBeTruthy();
|
||||
expect(anchor!.textContent).toBe("Click me");
|
||||
expect(anchor!.getAttribute("href")).toBe("https://example.com");
|
||||
});
|
||||
|
||||
it("renders a bullet list", async () => {
|
||||
await render(
|
||||
<PortableTextEditor
|
||||
value={[
|
||||
textBlock("Item one", { listItem: "bullet" }),
|
||||
textBlock("Item two", { listItem: "bullet" }),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
const pm = await waitForEditor();
|
||||
const ul = pm.querySelector("ul");
|
||||
expect(ul).toBeTruthy();
|
||||
const items = ul!.querySelectorAll("li");
|
||||
expect(items.length).toBe(2);
|
||||
expect(items[0]!.textContent).toBe("Item one");
|
||||
expect(items[1]!.textContent).toBe("Item two");
|
||||
});
|
||||
|
||||
it("renders an ordered list", async () => {
|
||||
await render(
|
||||
<PortableTextEditor
|
||||
value={[
|
||||
textBlock("First", { listItem: "number" }),
|
||||
textBlock("Second", { listItem: "number" }),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
const pm = await waitForEditor();
|
||||
const ol = pm.querySelector("ol");
|
||||
expect(ol).toBeTruthy();
|
||||
const items = ol!.querySelectorAll("li");
|
||||
expect(items.length).toBe(2);
|
||||
});
|
||||
|
||||
it("renders a blockquote", async () => {
|
||||
await render(
|
||||
<PortableTextEditor value={[textBlock("A wise quote", { style: "blockquote" })]} />,
|
||||
);
|
||||
const pm = await waitForEditor();
|
||||
const bq = pm.querySelector("blockquote");
|
||||
expect(bq).toBeTruthy();
|
||||
expect(bq!.textContent).toBe("A wise quote");
|
||||
});
|
||||
|
||||
it("renders a code block", async () => {
|
||||
await render(
|
||||
<PortableTextEditor
|
||||
value={[{ _type: "code", _key: "c1", code: "const x = 1", language: "js" }]}
|
||||
/>,
|
||||
);
|
||||
const pm = await waitForEditor();
|
||||
const pre = pm.querySelector("pre");
|
||||
expect(pre).toBeTruthy();
|
||||
expect(pre!.textContent).toContain("const x = 1");
|
||||
});
|
||||
|
||||
it("renders an image block", async () => {
|
||||
await render(
|
||||
<PortableTextEditor
|
||||
value={[
|
||||
{
|
||||
_type: "image",
|
||||
_key: "img1",
|
||||
asset: { _ref: "img-1", url: "/test.jpg" },
|
||||
alt: "Test image",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
const pm = await waitForEditor();
|
||||
// The mock ImageExtension renders as <img>
|
||||
const img = pm.querySelector("img");
|
||||
expect(img).toBeTruthy();
|
||||
expect(img!.getAttribute("src")).toBe("/test.jpg");
|
||||
});
|
||||
|
||||
it("renders a horizontal rule", async () => {
|
||||
await render(
|
||||
<PortableTextEditor
|
||||
value={[
|
||||
textBlock("Above"),
|
||||
{ _type: "break", _key: "hr1", style: "lineBreak" },
|
||||
textBlock("Below"),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
const pm = await waitForEditor();
|
||||
const hr = pm.querySelector("hr");
|
||||
expect(hr).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders empty editor when value is empty array", async () => {
|
||||
await render(<PortableTextEditor value={[]} placeholder="Write here..." />);
|
||||
const pm = await waitForEditor();
|
||||
// Empty editor should have a single empty paragraph
|
||||
const paragraphs = pm.querySelectorAll("p");
|
||||
expect(paragraphs.length).toBeGreaterThanOrEqual(1);
|
||||
// Placeholder should appear
|
||||
expect(pm.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("renders empty editor when value is undefined", async () => {
|
||||
await render(<PortableTextEditor placeholder="Start..." />);
|
||||
const pm = await waitForEditor();
|
||||
expect(pm).toBeTruthy();
|
||||
// Empty editor — no meaningful text
|
||||
const textContent = pm.textContent ?? "";
|
||||
expect(textContent.trim()).toBe("");
|
||||
});
|
||||
|
||||
it("renders bold+italic text with multiple marks", async () => {
|
||||
await render(
|
||||
<PortableTextEditor value={[textBlock("Bold italic", { marks: ["strong", "em"] })]} />,
|
||||
);
|
||||
const pm = await waitForEditor();
|
||||
const strong = pm.querySelector("strong");
|
||||
const em = pm.querySelector("em");
|
||||
expect(strong).toBeTruthy();
|
||||
expect(em).toBeTruthy();
|
||||
// The text is wrapped in both marks
|
||||
expect(pm.textContent).toContain("Bold italic");
|
||||
});
|
||||
|
||||
it("fires onChange with valid PT blocks when typing", async () => {
|
||||
const onChange = vi.fn();
|
||||
const { editor } = await renderAndGetEditor({ onChange });
|
||||
|
||||
typeIntoEditor(editor, "Hello");
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
|
||||
const lastCall = onChange.mock.calls.at(-1)!;
|
||||
const blocks = lastCall[0] as Array<{ _type: string }>;
|
||||
expect(blocks.length).toBeGreaterThan(0);
|
||||
expect(blocks[0]!._type).toBe("block");
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// 2. Editor Component Behaviour
|
||||
// =============================================================================
|
||||
|
||||
describe("Editor component behaviour", () => {
|
||||
it("shows placeholder text in empty editor", async () => {
|
||||
await render(<PortableTextEditor placeholder="Write something..." />);
|
||||
const pm = await waitForEditor();
|
||||
// TipTap sets placeholder via data-placeholder or a .is-empty class
|
||||
// Check for the placeholder content in a before pseudo-element or attribute
|
||||
const placeholderEl = pm.querySelector("[data-placeholder]");
|
||||
if (placeholderEl) {
|
||||
expect(placeholderEl.getAttribute("data-placeholder")).toBe("Write something...");
|
||||
} else {
|
||||
// Fallback: check the class-based placeholder
|
||||
const emptyNode = pm.querySelector(".is-empty, .is-editor-empty");
|
||||
expect(emptyNode).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("sets contenteditable=false when editable is false", async () => {
|
||||
await render(<PortableTextEditor editable={false} value={[textBlock("Read only")]} />);
|
||||
const pm = await waitForEditor();
|
||||
expect(pm.getAttribute("contenteditable")).toBe("false");
|
||||
});
|
||||
|
||||
it("sets contenteditable=true by default", async () => {
|
||||
await render(<PortableTextEditor value={[textBlock("Editable")]} />);
|
||||
const pm = await waitForEditor();
|
||||
expect(pm.getAttribute("contenteditable")).toBe("true");
|
||||
});
|
||||
|
||||
it("applies spotlight-mode class when focusMode is spotlight", async () => {
|
||||
await render(<PortableTextEditor focusMode="spotlight" value={[textBlock("Focused")]} />);
|
||||
await waitForEditor();
|
||||
const wrapper = document.querySelector(".spotlight-mode");
|
||||
expect(wrapper).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not apply spotlight-mode class when focusMode is normal", async () => {
|
||||
await render(<PortableTextEditor focusMode="normal" value={[textBlock("Normal")]} />);
|
||||
await waitForEditor();
|
||||
const wrapper = document.querySelector(".spotlight-mode");
|
||||
expect(wrapper).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onFocusModeChange when spotlight button is clicked", async () => {
|
||||
const onFocusModeChange = vi.fn();
|
||||
const screen = await render(
|
||||
<PortableTextEditor
|
||||
focusMode="normal"
|
||||
onFocusModeChange={onFocusModeChange}
|
||||
value={[textBlock("Test")]}
|
||||
/>,
|
||||
);
|
||||
await waitForEditor();
|
||||
|
||||
// The spotlight button has aria-label containing "Spotlight Mode"
|
||||
const spotlightBtn = screen.getByRole("button", { name: SPOTLIGHT_MODE_PATTERN });
|
||||
await spotlightBtn.click();
|
||||
expect(onFocusModeChange).toHaveBeenCalledWith("spotlight");
|
||||
});
|
||||
|
||||
it("hides toolbar and footer in minimal mode", async () => {
|
||||
await render(<PortableTextEditor minimal={true} value={[textBlock("Minimal")]} />);
|
||||
await waitForEditor();
|
||||
// Toolbar has role="toolbar" — should not exist
|
||||
const toolbar = document.querySelector('[role="toolbar"]');
|
||||
expect(toolbar).toBeNull();
|
||||
// Footer shows word count — should not exist
|
||||
const footer = document.querySelector(".border-t");
|
||||
expect(footer).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onEditorReady with Editor instance", async () => {
|
||||
const onEditorReady = vi.fn();
|
||||
await render(<PortableTextEditor onEditorReady={onEditorReady} value={[textBlock("Ready")]} />);
|
||||
await waitForEditor();
|
||||
|
||||
await vi.waitFor(() => expect(onEditorReady).toHaveBeenCalledTimes(1), { timeout: 2000 });
|
||||
|
||||
const editorArg = onEditorReady.mock.calls[0]![0] as Editor;
|
||||
expect(editorArg).toBeTruthy();
|
||||
expect(typeof editorArg.getJSON).toBe("function");
|
||||
expect(typeof editorArg.chain).toBe("function");
|
||||
});
|
||||
|
||||
it("calls onEditorReady with null on unmount so consumers can clear stale references", async () => {
|
||||
// Without this cleanup, ContentEditor's `portableTextEditor` slot keeps
|
||||
// pointing at a destroyed TipTap instance during the brief remount window
|
||||
// when switching translations (FieldRenderer is re-keyed by item.id),
|
||||
// causing DocumentOutline to render against a destroyed editor.
|
||||
const onEditorReady = vi.fn();
|
||||
const screen = await render(
|
||||
<PortableTextEditor onEditorReady={onEditorReady} value={[textBlock("Mount/unmount")]} />,
|
||||
);
|
||||
await waitForEditor();
|
||||
|
||||
await vi.waitFor(() => expect(onEditorReady).toHaveBeenCalledTimes(1), { timeout: 2000 });
|
||||
expect(onEditorReady.mock.calls[0]![0]).toBeTruthy();
|
||||
|
||||
// Unmount and verify the cleanup fires onEditorReady(null).
|
||||
await screen.unmount();
|
||||
|
||||
await vi.waitFor(() => expect(onEditorReady).toHaveBeenCalledTimes(2), { timeout: 2000 });
|
||||
expect(onEditorReady.mock.calls[1]![0]).toBeNull();
|
||||
});
|
||||
|
||||
it("shows word count and character count in footer", async () => {
|
||||
await render(<PortableTextEditor value={[textBlock("One two three")]} />);
|
||||
await waitForEditor();
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
const text = document.body.textContent ?? "";
|
||||
expect(text).toContain("words");
|
||||
expect(text).toContain("characters");
|
||||
expect(text).toContain("min read");
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// 3. Toolbar
|
||||
// =============================================================================
|
||||
|
||||
describe("Toolbar", () => {
|
||||
async function renderWithToolbar() {
|
||||
const screen = await render(<PortableTextEditor value={[textBlock("Toolbar test")]} />);
|
||||
await waitForEditor();
|
||||
return screen;
|
||||
}
|
||||
|
||||
it("renders a toolbar with text formatting aria-label", async () => {
|
||||
const screen = await renderWithToolbar();
|
||||
const toolbar = screen.getByRole("toolbar");
|
||||
await expect.element(toolbar).toHaveAttribute("aria-label", "Text formatting");
|
||||
});
|
||||
|
||||
it("has inline formatting buttons", async () => {
|
||||
const screen = await renderWithToolbar();
|
||||
await expect.element(screen.getByRole("button", { name: "Bold" })).toBeInTheDocument();
|
||||
await expect.element(screen.getByRole("button", { name: "Italic" })).toBeInTheDocument();
|
||||
await expect.element(screen.getByRole("button", { name: "Underline" })).toBeInTheDocument();
|
||||
await expect.element(screen.getByRole("button", { name: "Strikethrough" })).toBeInTheDocument();
|
||||
await expect.element(screen.getByRole("button", { name: "Inline Code" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has heading buttons", async () => {
|
||||
const screen = await renderWithToolbar();
|
||||
await expect.element(screen.getByRole("button", { name: "Heading 1" })).toBeInTheDocument();
|
||||
await expect.element(screen.getByRole("button", { name: "Heading 2" })).toBeInTheDocument();
|
||||
await expect.element(screen.getByRole("button", { name: "Heading 3" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has list buttons", async () => {
|
||||
const screen = await renderWithToolbar();
|
||||
await expect.element(screen.getByRole("button", { name: "Bullet List" })).toBeInTheDocument();
|
||||
await expect.element(screen.getByRole("button", { name: "Numbered List" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has block buttons", async () => {
|
||||
const screen = await renderWithToolbar();
|
||||
await expect.element(screen.getByRole("button", { name: "Quote" })).toBeInTheDocument();
|
||||
await expect.element(screen.getByRole("button", { name: "Code Block" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has alignment buttons", async () => {
|
||||
const screen = await renderWithToolbar();
|
||||
await expect.element(screen.getByRole("button", { name: "Align Left" })).toBeInTheDocument();
|
||||
await expect.element(screen.getByRole("button", { name: "Align Center" })).toBeInTheDocument();
|
||||
await expect.element(screen.getByRole("button", { name: "Align Right" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has insert buttons", async () => {
|
||||
const screen = await renderWithToolbar();
|
||||
await expect.element(screen.getByRole("button", { name: "Insert Link" })).toBeInTheDocument();
|
||||
await expect.element(screen.getByRole("button", { name: "Insert Image" })).toBeInTheDocument();
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: "Insert Horizontal Rule" }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has history buttons (initially disabled)", async () => {
|
||||
const screen = await renderWithToolbar();
|
||||
const undoBtn = screen.getByRole("button", { name: "Undo" });
|
||||
const redoBtn = screen.getByRole("button", { name: "Redo" });
|
||||
await expect.element(undoBtn).toBeInTheDocument();
|
||||
await expect.element(redoBtn).toBeInTheDocument();
|
||||
await expect.element(undoBtn).toBeDisabled();
|
||||
await expect.element(redoBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
it("has spotlight mode button", async () => {
|
||||
const screen = await renderWithToolbar();
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: SPOTLIGHT_MODE_PATTERN }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toggles bold aria-pressed when clicked", async () => {
|
||||
const screen = await renderWithToolbar();
|
||||
const pm = document.querySelector(".ProseMirror") as HTMLElement;
|
||||
await focusEditor(pm);
|
||||
|
||||
const boldBtn = screen.getByRole("button", { name: "Bold" });
|
||||
await expect.element(boldBtn).toHaveAttribute("aria-pressed", "false");
|
||||
|
||||
await boldBtn.click();
|
||||
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
await expect.element(boldBtn).toHaveAttribute("aria-pressed", "true");
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
});
|
||||
|
||||
it("toggles italic aria-pressed when clicked", async () => {
|
||||
const screen = await renderWithToolbar();
|
||||
const pm = document.querySelector(".ProseMirror") as HTMLElement;
|
||||
await focusEditor(pm);
|
||||
|
||||
const italicBtn = screen.getByRole("button", { name: "Italic" });
|
||||
await expect.element(italicBtn).toHaveAttribute("aria-pressed", "false");
|
||||
|
||||
await italicBtn.click();
|
||||
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
await expect.element(italicBtn).toHaveAttribute("aria-pressed", "true");
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
});
|
||||
|
||||
it("toggles Heading 1 aria-pressed when clicked", async () => {
|
||||
const screen = await renderWithToolbar();
|
||||
const pm = document.querySelector(".ProseMirror") as HTMLElement;
|
||||
await focusEditor(pm);
|
||||
|
||||
const h1Btn = screen.getByRole("button", { name: "Heading 1" });
|
||||
await expect.element(h1Btn).toHaveAttribute("aria-pressed", "false");
|
||||
|
||||
await h1Btn.click();
|
||||
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
await expect.element(h1Btn).toHaveAttribute("aria-pressed", "true");
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
});
|
||||
|
||||
it("enables Undo after typing and Redo after undoing", async () => {
|
||||
let editorRef: Editor | null = null;
|
||||
const screen = await render(
|
||||
<PortableTextEditor
|
||||
value={[textBlock("Toolbar test")]}
|
||||
onEditorReady={(editor) => {
|
||||
editorRef = editor;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
await waitForEditor();
|
||||
await vi.waitFor(() => expect(editorRef).toBeTruthy(), { timeout: 2000 });
|
||||
|
||||
const undoBtn = screen.getByRole("button", { name: "Undo" });
|
||||
const redoBtn = screen.getByRole("button", { name: "Redo" });
|
||||
|
||||
// Initially both disabled
|
||||
await expect.element(undoBtn).toBeDisabled();
|
||||
await expect.element(redoBtn).toBeDisabled();
|
||||
|
||||
// Type something via editor API
|
||||
typeIntoEditor(editorRef!, "Some text");
|
||||
|
||||
// Undo should become enabled
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
await expect.element(undoBtn).toBeEnabled();
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
|
||||
// Click undo
|
||||
await undoBtn.click();
|
||||
|
||||
// Redo should become enabled
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
await expect.element(redoBtn).toBeEnabled();
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
});
|
||||
|
||||
it("toggles spotlight mode button aria-pressed", async () => {
|
||||
const onFocusModeChange = vi.fn();
|
||||
const screen = await render(
|
||||
<PortableTextEditor
|
||||
focusMode="normal"
|
||||
onFocusModeChange={onFocusModeChange}
|
||||
value={[textBlock("Test")]}
|
||||
/>,
|
||||
);
|
||||
await waitForEditor();
|
||||
|
||||
const btn = screen.getByRole("button", { name: SPOTLIGHT_MODE_PATTERN });
|
||||
await expect.element(btn).toHaveAttribute("aria-pressed", "false");
|
||||
});
|
||||
|
||||
it("spotlight button shows pressed when focusMode is spotlight", async () => {
|
||||
const screen = await render(
|
||||
<PortableTextEditor
|
||||
focusMode="spotlight"
|
||||
onFocusModeChange={() => {}}
|
||||
value={[textBlock("Focused")]}
|
||||
/>,
|
||||
);
|
||||
await waitForEditor();
|
||||
|
||||
const btn = screen.getByRole("button", { name: SPOTLIGHT_MODE_PATTERN });
|
||||
await expect.element(btn).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
|
||||
it("toolbar not present in minimal mode", async () => {
|
||||
await render(<PortableTextEditor minimal={true} value={[textBlock("Minimal")]} />);
|
||||
await waitForEditor();
|
||||
const toolbar = document.querySelector('[role="toolbar"]');
|
||||
expect(toolbar).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// 4. Slash Commands
|
||||
// =============================================================================
|
||||
|
||||
describe("Slash commands", () => {
|
||||
it("renders without errors with default commands", async () => {
|
||||
await render(<PortableTextEditor value={[textBlock("Slash test")]} />);
|
||||
const pm = await waitForEditor();
|
||||
expect(pm).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders without errors with pluginBlocks prop", async () => {
|
||||
const pluginBlocks: PluginBlockDef[] = [
|
||||
{ type: "youtube", pluginId: "embeds", label: "YouTube Video" },
|
||||
{ type: "tweet", pluginId: "social", label: "Tweet" },
|
||||
];
|
||||
await render(
|
||||
<PortableTextEditor value={[textBlock("Plugin test")]} pluginBlocks={pluginBlocks} />,
|
||||
);
|
||||
const pm = await waitForEditor();
|
||||
expect(pm).toBeTruthy();
|
||||
});
|
||||
|
||||
it("editor accepts pluginBlocks without crashing when typing", async () => {
|
||||
const pluginBlocks: PluginBlockDef[] = [
|
||||
{ type: "youtube", pluginId: "embeds", label: "YouTube Video" },
|
||||
];
|
||||
const onChange = vi.fn();
|
||||
const { editor } = await renderAndGetEditor({
|
||||
pluginBlocks,
|
||||
onChange,
|
||||
});
|
||||
|
||||
typeIntoEditor(editor, "Hello");
|
||||
|
||||
await vi.waitFor(() => expect(onChange).toHaveBeenCalled(), { timeout: 2000 });
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// 5. Round-trip: onChange output shape
|
||||
// =============================================================================
|
||||
|
||||
describe("onChange output shape", () => {
|
||||
it("onChange returns blocks with _type and _key", async () => {
|
||||
const onChange = vi.fn();
|
||||
const { editor } = await renderAndGetEditor({ onChange });
|
||||
|
||||
typeIntoEditor(editor, "Test");
|
||||
|
||||
await vi.waitFor(() => expect(onChange).toHaveBeenCalled(), { timeout: 2000 });
|
||||
|
||||
const blocks = onChange.mock.calls.at(-1)![0] as Array<{
|
||||
_type: string;
|
||||
_key: string;
|
||||
children?: Array<{ _type: string; text: string }>;
|
||||
}>;
|
||||
expect(blocks.length).toBeGreaterThan(0);
|
||||
const block = blocks[0]!;
|
||||
expect(block._type).toBe("block");
|
||||
expect(typeof block._key).toBe("string");
|
||||
expect(block.children).toBeDefined();
|
||||
expect(block.children!.length).toBeGreaterThan(0);
|
||||
expect(block.children![0]!._type).toBe("span");
|
||||
expect(block.children![0]!.text).toContain("Test");
|
||||
});
|
||||
|
||||
it("heading value roundtrips through onEditorReady", async () => {
|
||||
let capturedEditor: Editor | null = null;
|
||||
const value = [textBlock("My Heading", { style: "h1" })];
|
||||
|
||||
await render(
|
||||
<PortableTextEditor
|
||||
value={value}
|
||||
onEditorReady={(editor) => {
|
||||
capturedEditor = editor;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
await waitForEditor();
|
||||
|
||||
await vi.waitFor(() => expect(capturedEditor).toBeTruthy(), { timeout: 2000 });
|
||||
|
||||
// Verify the editor has a heading node
|
||||
const json = capturedEditor!.getJSON();
|
||||
const headingNode = json.content?.find((n: { type: string }) => n.type === "heading");
|
||||
expect(headingNode).toBeTruthy();
|
||||
expect((headingNode as { attrs?: { level?: number } }).attrs?.level).toBe(1);
|
||||
});
|
||||
|
||||
it("code block value roundtrips through onEditorReady", async () => {
|
||||
let capturedEditor: Editor | null = null;
|
||||
const value = [{ _type: "code" as const, _key: "c1", code: "let a = 1;", language: "js" }];
|
||||
|
||||
await render(
|
||||
<PortableTextEditor
|
||||
value={value}
|
||||
onEditorReady={(editor) => {
|
||||
capturedEditor = editor;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
await waitForEditor();
|
||||
|
||||
await vi.waitFor(() => expect(capturedEditor).toBeTruthy(), { timeout: 2000 });
|
||||
|
||||
const json = capturedEditor!.getJSON();
|
||||
const codeNode = json.content?.find((n: { type: string }) => n.type === "codeBlock");
|
||||
expect(codeNode).toBeTruthy();
|
||||
});
|
||||
|
||||
it("list value roundtrips through onEditorReady", async () => {
|
||||
let capturedEditor: Editor | null = null;
|
||||
const value = [
|
||||
textBlock("Alpha", { listItem: "bullet" }),
|
||||
textBlock("Beta", { listItem: "bullet" }),
|
||||
];
|
||||
|
||||
await render(
|
||||
<PortableTextEditor
|
||||
value={value}
|
||||
onEditorReady={(editor) => {
|
||||
capturedEditor = editor;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
await waitForEditor();
|
||||
|
||||
await vi.waitFor(() => expect(capturedEditor).toBeTruthy(), { timeout: 2000 });
|
||||
|
||||
const json = capturedEditor!.getJSON();
|
||||
const listNode = json.content?.find((n: { type: string }) => n.type === "bulletList");
|
||||
expect(listNode).toBeTruthy();
|
||||
});
|
||||
});
|
||||
484
packages/admin/tests/editor/block-menu.test.tsx
Normal file
484
packages/admin/tests/editor/block-menu.test.tsx
Normal file
@@ -0,0 +1,484 @@
|
||||
/**
|
||||
* BlockMenu component tests.
|
||||
*
|
||||
* Tests the floating block-level context menu that appears when clicking
|
||||
* a drag handle. Covers the main menu (Turn into, Duplicate, Delete),
|
||||
* the "Turn into" submenu with block transforms, Escape to close,
|
||||
* and click-outside dismissal.
|
||||
*
|
||||
* BlockMenu is a standalone component that takes an editor instance,
|
||||
* an anchor element, and open/close callbacks. It renders via
|
||||
* createPortal to document.body.
|
||||
*/
|
||||
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { userEvent } from "@vitest/browser/context";
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import { BlockMenu } from "../../src/components/editor/BlockMenu";
|
||||
import { PortableTextEditor } from "../../src/components/PortableTextEditor";
|
||||
import { render } from "../utils/render";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks — same as other editor tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock("../../src/components/MediaPickerModal", () => ({
|
||||
MediaPickerModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../../src/components/SectionPickerModal", () => ({
|
||||
SectionPickerModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../../src/components/editor/DragHandleWrapper", () => ({
|
||||
DragHandleWrapper: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../../src/components/editor/ImageNode", async () => {
|
||||
const { Node } = await import("@tiptap/core");
|
||||
const ImageExtension = Node.create({
|
||||
name: "image",
|
||||
group: "block",
|
||||
atom: true,
|
||||
addAttributes() {
|
||||
return {
|
||||
src: { default: null },
|
||||
alt: { default: "" },
|
||||
title: { default: "" },
|
||||
caption: { default: "" },
|
||||
mediaId: { default: null },
|
||||
provider: { default: "local" },
|
||||
width: { default: null },
|
||||
height: { default: null },
|
||||
displayWidth: { default: null },
|
||||
displayHeight: { default: null },
|
||||
};
|
||||
},
|
||||
parseHTML() {
|
||||
return [{ tag: "img[src]" }];
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["img", HTMLAttributes];
|
||||
},
|
||||
});
|
||||
return { ImageExtension };
|
||||
});
|
||||
|
||||
vi.mock("../../src/components/editor/PluginBlockNode", async () => {
|
||||
const { Node } = await import("@tiptap/core");
|
||||
const PluginBlockExtension = Node.create({
|
||||
name: "pluginBlock",
|
||||
group: "block",
|
||||
atom: true,
|
||||
addAttributes() {
|
||||
return {
|
||||
blockType: { default: "embed" },
|
||||
id: { default: "" },
|
||||
data: { default: {} },
|
||||
};
|
||||
},
|
||||
parseHTML() {
|
||||
return [{ tag: "div[data-plugin-block]" }];
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["div", { ...HTMLAttributes, "data-plugin-block": "" }];
|
||||
},
|
||||
});
|
||||
return {
|
||||
PluginBlockExtension,
|
||||
getEmbedMeta: () => ({ label: "Embed", Icon: () => null }),
|
||||
registerPluginBlocks: () => {},
|
||||
resolveIcon: () => () => null,
|
||||
};
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const defaultValue = [
|
||||
{
|
||||
_type: "block" as const,
|
||||
_key: "1",
|
||||
style: "normal" as const,
|
||||
children: [{ _type: "span" as const, _key: "s1", text: "First paragraph" }],
|
||||
},
|
||||
{
|
||||
_type: "block" as const,
|
||||
_key: "2",
|
||||
style: "normal" as const,
|
||||
children: [{ _type: "span" as const, _key: "s2", text: "Second paragraph" }],
|
||||
},
|
||||
];
|
||||
|
||||
/** Render the full editor to get a real TipTap Editor instance */
|
||||
async function getEditor() {
|
||||
let editorInstance: Editor | null = null;
|
||||
|
||||
await render(
|
||||
<PortableTextEditor
|
||||
value={defaultValue}
|
||||
onEditorReady={(editor) => {
|
||||
editorInstance = editor;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(document.querySelector(".ProseMirror")).toBeTruthy();
|
||||
expect(editorInstance).toBeTruthy();
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
|
||||
const pm = document.querySelector(".ProseMirror") as HTMLElement;
|
||||
return { editor: editorInstance!, pm };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper component that renders BlockMenu with an anchor element.
|
||||
* This is needed because BlockMenu uses useFloating which needs a real DOM element.
|
||||
*/
|
||||
function BlockMenuTestWrapper({
|
||||
editor,
|
||||
isOpen,
|
||||
onClose,
|
||||
}: {
|
||||
editor: Editor;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const anchorRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={anchorRef} data-testid="anchor" style={{ width: 100, height: 20 }}>
|
||||
Anchor
|
||||
</div>
|
||||
<BlockMenu
|
||||
editor={editor}
|
||||
anchorElement={anchorRef.current}
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Get the block menu portal element */
|
||||
function getBlockMenu(): HTMLElement | null {
|
||||
const portals = document.querySelectorAll("body > div");
|
||||
for (const el of portals) {
|
||||
// The block menu has "Turn into", "Duplicate", "Delete" buttons
|
||||
if (el.textContent?.includes("Turn into") || el.textContent?.includes("Back")) {
|
||||
return el as HTMLElement;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Get all text buttons in the menu */
|
||||
function getMenuButtons(menu: HTMLElement): HTMLButtonElement[] {
|
||||
return [...menu.querySelectorAll("button")];
|
||||
}
|
||||
|
||||
/** Find a button by its text content */
|
||||
function findButtonByText(menu: HTMLElement, text: string): HTMLButtonElement | null {
|
||||
const buttons = getMenuButtons(menu);
|
||||
return buttons.find((btn) => btn.textContent?.includes(text)) ?? null;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// BlockMenu — Main Menu
|
||||
// =============================================================================
|
||||
|
||||
describe("BlockMenu", () => {
|
||||
it("renders nothing when isOpen is false", async () => {
|
||||
const { editor } = await getEditor();
|
||||
const onClose = vi.fn();
|
||||
|
||||
await render(<BlockMenuTestWrapper editor={editor} isOpen={false} onClose={onClose} />);
|
||||
|
||||
expect(getBlockMenu()).toBeNull();
|
||||
});
|
||||
|
||||
it("renders main menu with Turn into, Duplicate, Delete when open", async () => {
|
||||
const { editor } = await getEditor();
|
||||
const onClose = vi.fn();
|
||||
|
||||
await render(<BlockMenuTestWrapper editor={editor} isOpen={true} onClose={onClose} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const menu = getBlockMenu();
|
||||
expect(menu).toBeTruthy();
|
||||
});
|
||||
|
||||
const menu = getBlockMenu()!;
|
||||
expect(findButtonByText(menu, "Turn into")).toBeTruthy();
|
||||
expect(findButtonByText(menu, "Duplicate")).toBeTruthy();
|
||||
expect(findButtonByText(menu, "Delete")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows Turn into submenu when Turn into is clicked", async () => {
|
||||
const { editor } = await getEditor();
|
||||
const onClose = vi.fn();
|
||||
|
||||
await render(<BlockMenuTestWrapper editor={editor} isOpen={true} onClose={onClose} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getBlockMenu()).toBeTruthy();
|
||||
});
|
||||
|
||||
const menu = getBlockMenu()!;
|
||||
findButtonByText(menu, "Turn into")!.click();
|
||||
|
||||
// Should show transform options
|
||||
await vi.waitFor(() => {
|
||||
const updatedMenu = getBlockMenu()!;
|
||||
expect(findButtonByText(updatedMenu, "Back")).toBeTruthy();
|
||||
expect(findButtonByText(updatedMenu, "Paragraph")).toBeTruthy();
|
||||
expect(findButtonByText(updatedMenu, "Heading 1")).toBeTruthy();
|
||||
expect(findButtonByText(updatedMenu, "Heading 2")).toBeTruthy();
|
||||
expect(findButtonByText(updatedMenu, "Heading 3")).toBeTruthy();
|
||||
expect(findButtonByText(updatedMenu, "Quote")).toBeTruthy();
|
||||
expect(findButtonByText(updatedMenu, "Code Block")).toBeTruthy();
|
||||
expect(findButtonByText(updatedMenu, "Bullet List")).toBeTruthy();
|
||||
expect(findButtonByText(updatedMenu, "Numbered List")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("returns to main menu when Back is clicked in transform submenu", async () => {
|
||||
const { editor } = await getEditor();
|
||||
const onClose = vi.fn();
|
||||
|
||||
await render(<BlockMenuTestWrapper editor={editor} isOpen={true} onClose={onClose} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getBlockMenu()).toBeTruthy();
|
||||
});
|
||||
|
||||
const menu = getBlockMenu()!;
|
||||
findButtonByText(menu, "Turn into")!.click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(findButtonByText(getBlockMenu()!, "Back")).toBeTruthy();
|
||||
});
|
||||
|
||||
findButtonByText(getBlockMenu()!, "Back")!.click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const mainMenu = getBlockMenu()!;
|
||||
expect(findButtonByText(mainMenu, "Turn into")).toBeTruthy();
|
||||
expect(findButtonByText(mainMenu, "Duplicate")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("transforms block to heading when Heading 1 is selected", async () => {
|
||||
const { editor, pm } = await getEditor();
|
||||
const onClose = vi.fn();
|
||||
|
||||
// Focus editor on first paragraph
|
||||
editor.commands.focus("start");
|
||||
|
||||
await render(<BlockMenuTestWrapper editor={editor} isOpen={true} onClose={onClose} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getBlockMenu()).toBeTruthy();
|
||||
});
|
||||
|
||||
// Open transforms
|
||||
findButtonByText(getBlockMenu()!, "Turn into")!.click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(findButtonByText(getBlockMenu()!, "Heading 1")).toBeTruthy();
|
||||
});
|
||||
|
||||
findButtonByText(getBlockMenu()!, "Heading 1")!.click();
|
||||
|
||||
// Should close menu and transform block
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(pm.querySelector("h1")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("transforms block to blockquote", async () => {
|
||||
const { editor, pm } = await getEditor();
|
||||
const onClose = vi.fn();
|
||||
|
||||
editor.commands.focus("start");
|
||||
|
||||
await render(<BlockMenuTestWrapper editor={editor} isOpen={true} onClose={onClose} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getBlockMenu()).toBeTruthy();
|
||||
});
|
||||
|
||||
findButtonByText(getBlockMenu()!, "Turn into")!.click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(findButtonByText(getBlockMenu()!, "Quote")).toBeTruthy();
|
||||
});
|
||||
|
||||
findButtonByText(getBlockMenu()!, "Quote")!.click();
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(pm.querySelector("blockquote")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("transforms block to code block", async () => {
|
||||
const { editor, pm } = await getEditor();
|
||||
const onClose = vi.fn();
|
||||
|
||||
editor.commands.focus("start");
|
||||
|
||||
await render(<BlockMenuTestWrapper editor={editor} isOpen={true} onClose={onClose} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getBlockMenu()).toBeTruthy();
|
||||
});
|
||||
|
||||
findButtonByText(getBlockMenu()!, "Turn into")!.click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(findButtonByText(getBlockMenu()!, "Code Block")).toBeTruthy();
|
||||
});
|
||||
|
||||
findButtonByText(getBlockMenu()!, "Code Block")!.click();
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(pm.querySelector("pre")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("transforms block to bullet list", async () => {
|
||||
const { editor, pm } = await getEditor();
|
||||
const onClose = vi.fn();
|
||||
|
||||
editor.commands.focus("start");
|
||||
|
||||
await render(<BlockMenuTestWrapper editor={editor} isOpen={true} onClose={onClose} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getBlockMenu()).toBeTruthy();
|
||||
});
|
||||
|
||||
findButtonByText(getBlockMenu()!, "Turn into")!.click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(findButtonByText(getBlockMenu()!, "Bullet List")).toBeTruthy();
|
||||
});
|
||||
|
||||
findButtonByText(getBlockMenu()!, "Bullet List")!.click();
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(pm.querySelector("ul")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes the current block when Delete is clicked", async () => {
|
||||
const { editor, pm } = await getEditor();
|
||||
const onClose = vi.fn();
|
||||
|
||||
// Focus on first paragraph
|
||||
editor.commands.focus("start");
|
||||
|
||||
// Count initial paragraphs
|
||||
const initialParagraphs = pm.querySelectorAll("p").length;
|
||||
|
||||
await render(<BlockMenuTestWrapper editor={editor} isOpen={true} onClose={onClose} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getBlockMenu()).toBeTruthy();
|
||||
});
|
||||
|
||||
findButtonByText(getBlockMenu()!, "Delete")!.click();
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
|
||||
// Should have one fewer paragraph
|
||||
await vi.waitFor(() => {
|
||||
const newParagraphs = pm.querySelectorAll("p").length;
|
||||
expect(newParagraphs).toBeLessThan(initialParagraphs);
|
||||
});
|
||||
});
|
||||
|
||||
it("duplicates the current block when Duplicate is clicked", async () => {
|
||||
const { editor, pm } = await getEditor();
|
||||
const onClose = vi.fn();
|
||||
|
||||
editor.commands.focus("start");
|
||||
|
||||
const initialParagraphs = pm.querySelectorAll("p").length;
|
||||
|
||||
await render(<BlockMenuTestWrapper editor={editor} isOpen={true} onClose={onClose} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getBlockMenu()).toBeTruthy();
|
||||
});
|
||||
|
||||
findButtonByText(getBlockMenu()!, "Duplicate")!.click();
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const newParagraphs = pm.querySelectorAll("p").length;
|
||||
expect(newParagraphs).toBe(initialParagraphs + 1);
|
||||
});
|
||||
});
|
||||
|
||||
it("closes on Escape key", async () => {
|
||||
const { editor } = await getEditor();
|
||||
const onClose = vi.fn();
|
||||
|
||||
await render(<BlockMenuTestWrapper editor={editor} isOpen={true} onClose={onClose} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getBlockMenu()).toBeTruthy();
|
||||
});
|
||||
|
||||
await userEvent.keyboard("{Escape}");
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes transform submenu on Escape (returns to main, not full close)", async () => {
|
||||
const { editor } = await getEditor();
|
||||
const onClose = vi.fn();
|
||||
|
||||
await render(<BlockMenuTestWrapper editor={editor} isOpen={true} onClose={onClose} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getBlockMenu()).toBeTruthy();
|
||||
});
|
||||
|
||||
// Open transforms
|
||||
findButtonByText(getBlockMenu()!, "Turn into")!.click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(findButtonByText(getBlockMenu()!, "Back")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Escape should close submenu, not the whole menu
|
||||
await userEvent.keyboard("{Escape}");
|
||||
|
||||
// onClose should NOT have been called — submenu should just close
|
||||
// (The component resets showTransforms on Escape in submenu)
|
||||
await vi.waitFor(() => {
|
||||
const menu = getBlockMenu()!;
|
||||
// Should be back to main menu
|
||||
expect(findButtonByText(menu, "Turn into")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
566
packages/admin/tests/editor/bubble-menu.test.tsx
Normal file
566
packages/admin/tests/editor/bubble-menu.test.tsx
Normal file
@@ -0,0 +1,566 @@
|
||||
/**
|
||||
* Bubble menu tests.
|
||||
*
|
||||
* Tests the inline formatting bubble menu that appears when text is selected.
|
||||
* Covers formatting buttons (bold, italic, underline, strikethrough, code),
|
||||
* link insertion/editing, and menu visibility.
|
||||
*
|
||||
* The bubble menu uses TipTap's BubbleMenu component and only appears
|
||||
* when there's a text selection in the editor.
|
||||
*/
|
||||
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { userEvent } from "@vitest/browser/context";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import type { PortableTextEditorProps } from "../../src/components/PortableTextEditor";
|
||||
import { PortableTextEditor } from "../../src/components/PortableTextEditor";
|
||||
import { render } from "../utils/render.tsx";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock("../../src/components/MediaPickerModal", () => ({
|
||||
MediaPickerModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../../src/components/SectionPickerModal", () => ({
|
||||
SectionPickerModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../../src/components/editor/DragHandleWrapper", () => ({
|
||||
DragHandleWrapper: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../../src/components/editor/ImageNode", async () => {
|
||||
const { Node } = await import("@tiptap/core");
|
||||
const ImageExtension = Node.create({
|
||||
name: "image",
|
||||
group: "block",
|
||||
atom: true,
|
||||
addAttributes() {
|
||||
return {
|
||||
src: { default: null },
|
||||
alt: { default: "" },
|
||||
title: { default: "" },
|
||||
caption: { default: "" },
|
||||
mediaId: { default: null },
|
||||
provider: { default: "local" },
|
||||
width: { default: null },
|
||||
height: { default: null },
|
||||
displayWidth: { default: null },
|
||||
displayHeight: { default: null },
|
||||
};
|
||||
},
|
||||
parseHTML() {
|
||||
return [{ tag: "img[src]" }];
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["img", HTMLAttributes];
|
||||
},
|
||||
});
|
||||
return { ImageExtension };
|
||||
});
|
||||
|
||||
vi.mock("../../src/components/editor/PluginBlockNode", async () => {
|
||||
const { Node } = await import("@tiptap/core");
|
||||
const PluginBlockExtension = Node.create({
|
||||
name: "pluginBlock",
|
||||
group: "block",
|
||||
atom: true,
|
||||
addAttributes() {
|
||||
return {
|
||||
blockType: { default: "embed" },
|
||||
id: { default: "" },
|
||||
data: { default: {} },
|
||||
};
|
||||
},
|
||||
parseHTML() {
|
||||
return [{ tag: "div[data-plugin-block]" }];
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["div", { ...HTMLAttributes, "data-plugin-block": "" }];
|
||||
},
|
||||
});
|
||||
return {
|
||||
PluginBlockExtension,
|
||||
getEmbedMeta: () => ({ label: "Embed", Icon: () => null }),
|
||||
registerPluginBlocks: () => {},
|
||||
resolveIcon: () => () => null,
|
||||
};
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const defaultValue = [
|
||||
{
|
||||
_type: "block" as const,
|
||||
_key: "1",
|
||||
style: "normal" as const,
|
||||
children: [{ _type: "span" as const, _key: "s1", text: "Hello world" }],
|
||||
},
|
||||
];
|
||||
|
||||
async function renderEditor(props: Partial<PortableTextEditorProps> = {}) {
|
||||
let editorInstance: Editor | null = null;
|
||||
|
||||
const screen = await render(
|
||||
<PortableTextEditor
|
||||
value={defaultValue}
|
||||
onEditorReady={(editor) => {
|
||||
editorInstance = editor;
|
||||
}}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(document.querySelector(".ProseMirror")).toBeTruthy();
|
||||
expect(editorInstance).toBeTruthy();
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
|
||||
const pm = document.querySelector(".ProseMirror") as HTMLElement;
|
||||
return { screen, editor: editorInstance!, pm };
|
||||
}
|
||||
|
||||
/** Focus the editor and select all text using TipTap commands */
|
||||
async function focusAndSelectAll(editor: Editor, pm: HTMLElement) {
|
||||
pm.focus();
|
||||
await vi.waitFor(() => expect(document.activeElement).toBe(pm), { timeout: 1000 });
|
||||
editor.commands.focus();
|
||||
editor.commands.selectAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the bubble menu element.
|
||||
* TipTap's BubbleMenu renders as a div with role=presentation (tippy.js).
|
||||
* Our menu has the class "bg-kumo-base" and contains aria-label buttons.
|
||||
*/
|
||||
function getBubbleMenu(): HTMLElement | null {
|
||||
// The BubbleMenu from @tiptap/react/menus renders inline.
|
||||
// Look for the container with our known class pattern.
|
||||
const candidates = document.querySelectorAll('[class*="bg-kumo-base"]');
|
||||
for (const el of candidates) {
|
||||
// Bubble menu has formatting buttons with specific aria-labels
|
||||
if (el.querySelector('[aria-label="Bold"]') && el.querySelector('[aria-label="Italic"]')) {
|
||||
return el as HTMLElement;
|
||||
}
|
||||
}
|
||||
// Also check for link input mode (has Apply link button)
|
||||
for (const el of candidates) {
|
||||
if (el.querySelector('[aria-label="Apply link"]')) {
|
||||
return el as HTMLElement;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Wait for bubble menu to appear */
|
||||
async function waitForBubbleMenu(): Promise<HTMLElement> {
|
||||
let menu: HTMLElement | null = null;
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
menu = getBubbleMenu();
|
||||
expect(menu).toBeTruthy();
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
return menu!;
|
||||
}
|
||||
|
||||
/** Get a bubble menu button by aria-label */
|
||||
function getBubbleButton(menu: HTMLElement, label: string): HTMLButtonElement | null {
|
||||
return menu.querySelector(`[aria-label="${label}"]`);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Bubble Menu
|
||||
// =============================================================================
|
||||
|
||||
describe("Bubble Menu", () => {
|
||||
it("appears when text is selected", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusAndSelectAll(editor, pm);
|
||||
|
||||
const menu = await waitForBubbleMenu();
|
||||
expect(menu).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows formatting buttons: Bold, Italic, Underline, Strikethrough, Code", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusAndSelectAll(editor, pm);
|
||||
|
||||
const menu = await waitForBubbleMenu();
|
||||
|
||||
expect(getBubbleButton(menu, "Bold")).toBeTruthy();
|
||||
expect(getBubbleButton(menu, "Italic")).toBeTruthy();
|
||||
expect(getBubbleButton(menu, "Underline")).toBeTruthy();
|
||||
expect(getBubbleButton(menu, "Strikethrough")).toBeTruthy();
|
||||
expect(getBubbleButton(menu, "Code")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows Add link button", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusAndSelectAll(editor, pm);
|
||||
|
||||
const menu = await waitForBubbleMenu();
|
||||
expect(getBubbleButton(menu, "Add link")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("toggles bold when Bold button is clicked", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusAndSelectAll(editor, pm);
|
||||
|
||||
const menu = await waitForBubbleMenu();
|
||||
const boldBtn = getBubbleButton(menu, "Bold")!;
|
||||
|
||||
boldBtn.click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(editor.isActive("bold")).toBe(true);
|
||||
});
|
||||
|
||||
// Verify the text is wrapped in <strong>
|
||||
expect(pm.querySelector("strong")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("toggles italic when Italic button is clicked", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusAndSelectAll(editor, pm);
|
||||
|
||||
const menu = await waitForBubbleMenu();
|
||||
const italicBtn = getBubbleButton(menu, "Italic")!;
|
||||
|
||||
italicBtn.click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(editor.isActive("italic")).toBe(true);
|
||||
});
|
||||
|
||||
expect(pm.querySelector("em")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("toggles underline when Underline button is clicked", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusAndSelectAll(editor, pm);
|
||||
|
||||
const menu = await waitForBubbleMenu();
|
||||
const underlineBtn = getBubbleButton(menu, "Underline")!;
|
||||
|
||||
underlineBtn.click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(editor.isActive("underline")).toBe(true);
|
||||
});
|
||||
|
||||
expect(pm.querySelector("u")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("toggles strikethrough when Strikethrough button is clicked", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusAndSelectAll(editor, pm);
|
||||
|
||||
const menu = await waitForBubbleMenu();
|
||||
const strikeBtn = getBubbleButton(menu, "Strikethrough")!;
|
||||
|
||||
strikeBtn.click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(editor.isActive("strike")).toBe(true);
|
||||
});
|
||||
|
||||
expect(pm.querySelector("s")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("toggles inline code when Code button is clicked", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusAndSelectAll(editor, pm);
|
||||
|
||||
const menu = await waitForBubbleMenu();
|
||||
const codeBtn = getBubbleButton(menu, "Code")!;
|
||||
|
||||
codeBtn.click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(editor.isActive("code")).toBe(true);
|
||||
});
|
||||
|
||||
expect(pm.querySelector("code")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows link input when Add link button is clicked", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusAndSelectAll(editor, pm);
|
||||
|
||||
const menu = await waitForBubbleMenu();
|
||||
const linkBtn = getBubbleButton(menu, "Add link")!;
|
||||
|
||||
linkBtn.click();
|
||||
|
||||
// The bubble menu should now show the link input
|
||||
await vi.waitFor(() => {
|
||||
const applyBtn = getBubbleButton(menu, "Apply link");
|
||||
expect(applyBtn).toBeTruthy();
|
||||
});
|
||||
|
||||
// Should have a URL input with placeholder
|
||||
const input = menu.querySelector('input[type="url"]');
|
||||
expect(input).toBeTruthy();
|
||||
});
|
||||
|
||||
it("applies link URL when Apply button is clicked", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusAndSelectAll(editor, pm);
|
||||
|
||||
const menu = await waitForBubbleMenu();
|
||||
const linkBtn = getBubbleButton(menu, "Add link")!;
|
||||
linkBtn.click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(menu.querySelector('input[type="url"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Type a URL into the input
|
||||
const input = menu.querySelector('input[type="url"]') as HTMLInputElement;
|
||||
input.focus();
|
||||
// Use native value setter + input event for React controlled input
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
"value",
|
||||
)!.set!;
|
||||
nativeInputValueSetter.call(input, "https://example.com");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
|
||||
// Click Apply
|
||||
const applyBtn = getBubbleButton(menu, "Apply link")!;
|
||||
applyBtn.click();
|
||||
|
||||
// The editor should now have a link
|
||||
await vi.waitFor(() => {
|
||||
const link = pm.querySelector("a");
|
||||
expect(link).toBeTruthy();
|
||||
expect(link!.getAttribute("href")).toBe("https://example.com");
|
||||
});
|
||||
});
|
||||
|
||||
it("applies link on Enter key in URL input", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusAndSelectAll(editor, pm);
|
||||
|
||||
const menu = await waitForBubbleMenu();
|
||||
getBubbleButton(menu, "Add link")!.click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(menu.querySelector('input[type="url"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
const input = menu.querySelector('input[type="url"]') as HTMLInputElement;
|
||||
input.focus();
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
"value",
|
||||
)!.set!;
|
||||
nativeInputValueSetter.call(input, "https://test.org");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
|
||||
// Press Enter
|
||||
await userEvent.keyboard("{Enter}");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const link = pm.querySelector("a");
|
||||
expect(link).toBeTruthy();
|
||||
expect(link!.getAttribute("href")).toBe("https://test.org");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Edit link and Remove link buttons when cursor is on a link", async () => {
|
||||
const linkValue = [
|
||||
{
|
||||
_type: "block" as const,
|
||||
_key: "1",
|
||||
style: "normal" as const,
|
||||
children: [
|
||||
{
|
||||
_type: "span" as const,
|
||||
_key: "s1",
|
||||
text: "Click here",
|
||||
marks: ["link1"],
|
||||
},
|
||||
],
|
||||
markDefs: [{ _type: "link", _key: "link1", href: "https://example.com" }],
|
||||
},
|
||||
];
|
||||
|
||||
const { editor, pm } = await renderEditor({ value: linkValue });
|
||||
await focusAndSelectAll(editor, pm);
|
||||
|
||||
const menu = await waitForBubbleMenu();
|
||||
|
||||
// Should show "Edit link" instead of "Add link"
|
||||
expect(getBubbleButton(menu, "Edit link")).toBeTruthy();
|
||||
|
||||
// Click Edit link to show input
|
||||
getBubbleButton(menu, "Edit link")!.click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getBubbleButton(menu, "Remove link")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("removes link when Remove link button is clicked", async () => {
|
||||
const linkValue = [
|
||||
{
|
||||
_type: "block" as const,
|
||||
_key: "1",
|
||||
style: "normal" as const,
|
||||
children: [
|
||||
{
|
||||
_type: "span" as const,
|
||||
_key: "s1",
|
||||
text: "Click here",
|
||||
marks: ["link1"],
|
||||
},
|
||||
],
|
||||
markDefs: [{ _type: "link", _key: "link1", href: "https://example.com" }],
|
||||
},
|
||||
];
|
||||
|
||||
const { editor, pm } = await renderEditor({ value: linkValue });
|
||||
await focusAndSelectAll(editor, pm);
|
||||
|
||||
const menu = await waitForBubbleMenu();
|
||||
|
||||
// Click Edit link to open link input mode
|
||||
getBubbleButton(menu, "Edit link")!.click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getBubbleButton(menu, "Remove link")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click Remove link
|
||||
getBubbleButton(menu, "Remove link")!.click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(pm.querySelector("a")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("closes link input on Escape and returns focus to editor", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusAndSelectAll(editor, pm);
|
||||
|
||||
const menu = await waitForBubbleMenu();
|
||||
getBubbleButton(menu, "Add link")!.click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(menu.querySelector('input[type="url"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
const input = menu.querySelector('input[type="url"]') as HTMLInputElement;
|
||||
input.focus();
|
||||
|
||||
// Press Escape
|
||||
await userEvent.keyboard("{Escape}");
|
||||
|
||||
// Should return to the formatting buttons view
|
||||
await vi.waitFor(() => {
|
||||
expect(getBubbleButton(menu, "Bold")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("unsets link when Apply is clicked with empty URL", async () => {
|
||||
const linkValue = [
|
||||
{
|
||||
_type: "block" as const,
|
||||
_key: "1",
|
||||
style: "normal" as const,
|
||||
children: [
|
||||
{
|
||||
_type: "span" as const,
|
||||
_key: "s1",
|
||||
text: "Click here",
|
||||
marks: ["link1"],
|
||||
},
|
||||
],
|
||||
markDefs: [{ _type: "link", _key: "link1", href: "https://example.com" }],
|
||||
},
|
||||
];
|
||||
|
||||
const { editor, pm } = await renderEditor({ value: linkValue });
|
||||
await focusAndSelectAll(editor, pm);
|
||||
|
||||
const menu = await waitForBubbleMenu();
|
||||
getBubbleButton(menu, "Edit link")!.click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(menu.querySelector('input[type="url"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Clear the input
|
||||
const input = menu.querySelector('input[type="url"]') as HTMLInputElement;
|
||||
input.focus();
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
"value",
|
||||
)!.set!;
|
||||
nativeInputValueSetter.call(input, "");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
|
||||
// Click Apply
|
||||
getBubbleButton(menu, "Apply link")!.click();
|
||||
|
||||
// Link should be removed
|
||||
await vi.waitFor(() => {
|
||||
expect(pm.querySelector("a")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("can apply multiple formatting marks", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusAndSelectAll(editor, pm);
|
||||
|
||||
const menu = await waitForBubbleMenu();
|
||||
|
||||
// Apply bold + italic
|
||||
getBubbleButton(menu, "Bold")!.click();
|
||||
getBubbleButton(menu, "Italic")!.click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(editor.isActive("bold")).toBe(true);
|
||||
expect(editor.isActive("italic")).toBe(true);
|
||||
});
|
||||
|
||||
expect(pm.querySelector("strong")).toBeTruthy();
|
||||
expect(pm.querySelector("em")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("toggles off formatting when clicked twice", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusAndSelectAll(editor, pm);
|
||||
|
||||
const menu = await waitForBubbleMenu();
|
||||
const boldBtn = getBubbleButton(menu, "Bold")!;
|
||||
|
||||
// Apply bold
|
||||
boldBtn.click();
|
||||
await vi.waitFor(() => expect(editor.isActive("bold")).toBe(true));
|
||||
|
||||
// Re-select (bold toggle may deselect)
|
||||
editor.commands.selectAll();
|
||||
|
||||
// Remove bold
|
||||
boldBtn.click();
|
||||
await vi.waitFor(() => expect(editor.isActive("bold")).toBe(false));
|
||||
|
||||
expect(pm.querySelector("strong")).toBeNull();
|
||||
});
|
||||
});
|
||||
226
packages/admin/tests/editor/input-rules.test.ts
Normal file
226
packages/admin/tests/editor/input-rules.test.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Input Rules Tests for TipTap Editor
|
||||
*
|
||||
* Tests that Markdown-style shortcuts work correctly in the editor.
|
||||
*
|
||||
* TipTap input rules are triggered by actual text input, not insertContent().
|
||||
* In headless tests, we simulate this by using the inputRules extension's
|
||||
* run function or by testing the resulting transformations.
|
||||
*
|
||||
* For integration testing, we verify the editor has the correct extensions
|
||||
* configured and that the expected node/mark types exist.
|
||||
*/
|
||||
|
||||
import { Editor } from "@tiptap/core";
|
||||
import Typography from "@tiptap/extension-typography";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
describe("Editor Input Rules", () => {
|
||||
let editor: Editor;
|
||||
|
||||
beforeEach(() => {
|
||||
editor = new Editor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2, 3] },
|
||||
}),
|
||||
Typography,
|
||||
],
|
||||
content: "",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
describe("Editor extension configuration", () => {
|
||||
it("has heading extension with levels 1-3", () => {
|
||||
const headingExtension = editor.extensionManager.extensions.find(
|
||||
(ext) => ext.name === "heading",
|
||||
);
|
||||
expect(headingExtension).toBeDefined();
|
||||
});
|
||||
|
||||
it("has bulletList extension", () => {
|
||||
const extension = editor.extensionManager.extensions.find((ext) => ext.name === "bulletList");
|
||||
expect(extension).toBeDefined();
|
||||
});
|
||||
|
||||
it("has orderedList extension", () => {
|
||||
const extension = editor.extensionManager.extensions.find(
|
||||
(ext) => ext.name === "orderedList",
|
||||
);
|
||||
expect(extension).toBeDefined();
|
||||
});
|
||||
|
||||
it("has blockquote extension", () => {
|
||||
const extension = editor.extensionManager.extensions.find((ext) => ext.name === "blockquote");
|
||||
expect(extension).toBeDefined();
|
||||
});
|
||||
|
||||
it("has codeBlock extension", () => {
|
||||
const extension = editor.extensionManager.extensions.find((ext) => ext.name === "codeBlock");
|
||||
expect(extension).toBeDefined();
|
||||
});
|
||||
|
||||
it("has horizontalRule extension", () => {
|
||||
const extension = editor.extensionManager.extensions.find(
|
||||
(ext) => ext.name === "horizontalRule",
|
||||
);
|
||||
expect(extension).toBeDefined();
|
||||
});
|
||||
|
||||
it("has bold extension", () => {
|
||||
const extension = editor.extensionManager.extensions.find((ext) => ext.name === "bold");
|
||||
expect(extension).toBeDefined();
|
||||
});
|
||||
|
||||
it("has italic extension", () => {
|
||||
const extension = editor.extensionManager.extensions.find((ext) => ext.name === "italic");
|
||||
expect(extension).toBeDefined();
|
||||
});
|
||||
|
||||
it("has code extension", () => {
|
||||
const extension = editor.extensionManager.extensions.find((ext) => ext.name === "code");
|
||||
expect(extension).toBeDefined();
|
||||
});
|
||||
|
||||
it("has strike extension", () => {
|
||||
const extension = editor.extensionManager.extensions.find((ext) => ext.name === "strike");
|
||||
expect(extension).toBeDefined();
|
||||
});
|
||||
|
||||
it("has typography extension", () => {
|
||||
const extension = editor.extensionManager.extensions.find((ext) => ext.name === "typography");
|
||||
expect(extension).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Commands work correctly", () => {
|
||||
it("can toggle heading 1", () => {
|
||||
editor.commands.setHeading({ level: 1 });
|
||||
expect(editor.isActive("heading", { level: 1 })).toBe(true);
|
||||
});
|
||||
|
||||
it("can toggle heading 2", () => {
|
||||
editor.commands.setHeading({ level: 2 });
|
||||
expect(editor.isActive("heading", { level: 2 })).toBe(true);
|
||||
});
|
||||
|
||||
it("can toggle heading 3", () => {
|
||||
editor.commands.setHeading({ level: 3 });
|
||||
expect(editor.isActive("heading", { level: 3 })).toBe(true);
|
||||
});
|
||||
|
||||
it("can toggle bullet list", () => {
|
||||
editor.commands.toggleBulletList();
|
||||
expect(editor.isActive("bulletList")).toBe(true);
|
||||
});
|
||||
|
||||
it("can toggle ordered list", () => {
|
||||
editor.commands.toggleOrderedList();
|
||||
expect(editor.isActive("orderedList")).toBe(true);
|
||||
});
|
||||
|
||||
it("can toggle blockquote", () => {
|
||||
editor.commands.toggleBlockquote();
|
||||
expect(editor.isActive("blockquote")).toBe(true);
|
||||
});
|
||||
|
||||
it("can toggle code block", () => {
|
||||
editor.commands.toggleCodeBlock();
|
||||
expect(editor.isActive("codeBlock")).toBe(true);
|
||||
});
|
||||
|
||||
it("can insert horizontal rule", () => {
|
||||
editor.commands.setHorizontalRule();
|
||||
const json = editor.getJSON();
|
||||
const hasHR = json.content?.some((node) => node.type === "horizontalRule");
|
||||
expect(hasHR).toBe(true);
|
||||
});
|
||||
|
||||
it("can toggle bold", () => {
|
||||
editor.commands.insertContent("test");
|
||||
editor.commands.selectAll();
|
||||
editor.commands.toggleBold();
|
||||
expect(editor.isActive("bold")).toBe(true);
|
||||
});
|
||||
|
||||
it("can toggle italic", () => {
|
||||
editor.commands.insertContent("test");
|
||||
editor.commands.selectAll();
|
||||
editor.commands.toggleItalic();
|
||||
expect(editor.isActive("italic")).toBe(true);
|
||||
});
|
||||
|
||||
it("can toggle code", () => {
|
||||
editor.commands.insertContent("test");
|
||||
editor.commands.selectAll();
|
||||
editor.commands.toggleCode();
|
||||
expect(editor.isActive("code")).toBe(true);
|
||||
});
|
||||
|
||||
it("can toggle strike", () => {
|
||||
editor.commands.insertContent("test");
|
||||
editor.commands.selectAll();
|
||||
editor.commands.toggleStrike();
|
||||
expect(editor.isActive("strike")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Schema has correct node types", () => {
|
||||
it("has heading node type", () => {
|
||||
expect(editor.schema.nodes.heading).toBeDefined();
|
||||
});
|
||||
|
||||
it("has bulletList node type", () => {
|
||||
expect(editor.schema.nodes.bulletList).toBeDefined();
|
||||
});
|
||||
|
||||
it("has orderedList node type", () => {
|
||||
expect(editor.schema.nodes.orderedList).toBeDefined();
|
||||
});
|
||||
|
||||
it("has blockquote node type", () => {
|
||||
expect(editor.schema.nodes.blockquote).toBeDefined();
|
||||
});
|
||||
|
||||
it("has codeBlock node type", () => {
|
||||
expect(editor.schema.nodes.codeBlock).toBeDefined();
|
||||
});
|
||||
|
||||
it("has horizontalRule node type", () => {
|
||||
expect(editor.schema.nodes.horizontalRule).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Schema has correct mark types", () => {
|
||||
it("has bold mark type", () => {
|
||||
expect(editor.schema.marks.bold).toBeDefined();
|
||||
});
|
||||
|
||||
it("has italic mark type", () => {
|
||||
expect(editor.schema.marks.italic).toBeDefined();
|
||||
});
|
||||
|
||||
it("has code mark type", () => {
|
||||
expect(editor.schema.marks.code).toBeDefined();
|
||||
});
|
||||
|
||||
it("has strike mark type", () => {
|
||||
expect(editor.schema.marks.strike).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Input rules are registered", () => {
|
||||
it("has input rules plugin", () => {
|
||||
// StarterKit registers input rules through individual extensions
|
||||
// We verify by checking extensions have inputRules defined
|
||||
const extensions = editor.extensionManager.extensions;
|
||||
const headingExt = extensions.find((e) => e.name === "heading");
|
||||
expect(headingExt).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
303
packages/admin/tests/editor/markdown-link.test.ts
Normal file
303
packages/admin/tests/editor/markdown-link.test.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* Markdown Link Extension Tests
|
||||
*
|
||||
* Tests the MarkdownLinkExtension which converts [text](url) syntax
|
||||
* into proper link marks:
|
||||
* - Extension registration and input/paste rule presence
|
||||
* - Input rule converts typed [text](url) to a linked text node
|
||||
* - Paste rule converts pasted markdown links inline
|
||||
* - Disallowed protocols (javascript:) are rejected
|
||||
* - Edge cases: empty text, empty href, whitespace trimming
|
||||
* - No conflict with StarterKit's Link extension commands
|
||||
*/
|
||||
|
||||
import { Editor } from "@tiptap/core";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { MarkdownLinkExtension } from "../../src/components/editor/MarkdownLinkExtension";
|
||||
|
||||
/**
|
||||
* Simulate typing text character-by-character into the editor.
|
||||
*
|
||||
* TipTap InputRules only fire on the `handleTextInput` view prop,
|
||||
* which requires dispatching through the EditorView — insertContent()
|
||||
* bypasses it. We call `view.someProp("handleTextInput", ...)` for
|
||||
* each character, matching how ProseMirror's input rules plugin works.
|
||||
*/
|
||||
function simulateTyping(editor: Editor, text: string) {
|
||||
for (const char of text) {
|
||||
const { from, to } = editor.state.selection;
|
||||
const deflt = () => editor.state.tr.insertText(char, from, to);
|
||||
const handled = editor.view.someProp("handleTextInput", (f) =>
|
||||
f(editor.view, from, to, char, deflt),
|
||||
);
|
||||
if (!handled) {
|
||||
// If no input rule consumed it, insert as plain text
|
||||
editor.view.dispatch(deflt());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate pasting plain text into the editor.
|
||||
*
|
||||
* Uses ProseMirror's `pasteText()` which runs through the full paste
|
||||
* pipeline including TipTap's PasteRule handlers.
|
||||
*/
|
||||
function simulatePaste(editor: Editor, text: string) {
|
||||
editor.view.pasteText(text);
|
||||
}
|
||||
|
||||
/** Extract all link marks from the editor doc for assertions. */
|
||||
function extractLinks(editor: Editor): Array<{ text: string; href: string }> {
|
||||
const links: Array<{ text: string; href: string }> = [];
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (node.isText) {
|
||||
const linkMark = node.marks.find((m) => m.type.name === "link");
|
||||
if (linkMark) {
|
||||
links.push({ text: node.text || "", href: linkMark.attrs.href });
|
||||
}
|
||||
}
|
||||
});
|
||||
return links;
|
||||
}
|
||||
|
||||
describe("Markdown Link Extension", () => {
|
||||
let editor: Editor;
|
||||
|
||||
beforeEach(() => {
|
||||
editor = new Editor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
link: {
|
||||
openOnClick: false,
|
||||
enableClickSelection: true,
|
||||
},
|
||||
}),
|
||||
MarkdownLinkExtension,
|
||||
],
|
||||
content: "",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
describe("Extension registration", () => {
|
||||
it("registers the markdownLink extension", () => {
|
||||
const ext = editor.extensionManager.extensions.find((e) => e.name === "markdownLink");
|
||||
expect(ext).toBeDefined();
|
||||
});
|
||||
|
||||
it("has the link mark type from StarterKit", () => {
|
||||
expect(editor.schema.marks.link).toBeDefined();
|
||||
});
|
||||
|
||||
it("registers input rules", () => {
|
||||
const ext = editor.extensionManager.extensions.find((e) => e.name === "markdownLink");
|
||||
expect(ext).toBeDefined();
|
||||
// The extension defines addInputRules, verify it produced rules
|
||||
const plugins = editor.state.plugins;
|
||||
const inputRulesPlugin = plugins.find(
|
||||
(p) => (p.spec as Record<string, unknown>).isInputRules,
|
||||
);
|
||||
expect(inputRulesPlugin).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Input rule — typed markdown links", () => {
|
||||
it("converts [text](url) to a link mark on closing paren", () => {
|
||||
editor.commands.focus();
|
||||
simulateTyping(editor, "[Example](https://example.com)");
|
||||
|
||||
const links = extractLinks(editor);
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0]).toEqual({ text: "Example", href: "https://example.com" });
|
||||
});
|
||||
|
||||
it("converts [text](url) with preceding text", () => {
|
||||
editor.commands.focus();
|
||||
simulateTyping(editor, "Visit [Example](https://example.com)");
|
||||
|
||||
const links = extractLinks(editor);
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0]).toEqual({ text: "Example", href: "https://example.com" });
|
||||
|
||||
// Preceding text should still be present as plain text
|
||||
const plainText = editor.getText();
|
||||
expect(plainText).toContain("Visit");
|
||||
expect(plainText).toContain("Example");
|
||||
});
|
||||
|
||||
it("trims whitespace from href", () => {
|
||||
editor.commands.focus();
|
||||
simulateTyping(editor, "[Docs]( https://docs.example.com )");
|
||||
|
||||
const links = extractLinks(editor);
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0]!.href).toBe("https://docs.example.com");
|
||||
});
|
||||
|
||||
it("preserves link text with spaces", () => {
|
||||
editor.commands.focus();
|
||||
simulateTyping(editor, "[My Cool Link](https://example.com)");
|
||||
|
||||
const links = extractLinks(editor);
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0]!.text).toBe("My Cool Link");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Paste rule — pasted markdown links", () => {
|
||||
it("converts a pasted [text](url) to a link mark", () => {
|
||||
editor.commands.focus();
|
||||
simulatePaste(editor, "[Example](https://example.com)");
|
||||
|
||||
const links = extractLinks(editor);
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0]).toEqual({ text: "Example", href: "https://example.com" });
|
||||
});
|
||||
|
||||
it("converts multiple markdown links in pasted text", () => {
|
||||
editor.commands.focus();
|
||||
simulatePaste(editor, "See [Foo](https://foo.com) and [Bar](https://bar.com).");
|
||||
|
||||
const links = extractLinks(editor);
|
||||
expect(links).toHaveLength(2);
|
||||
expect(links[0]).toEqual({ text: "Foo", href: "https://foo.com" });
|
||||
expect(links[1]).toEqual({ text: "Bar", href: "https://bar.com" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Protocol allowlist — rejects disallowed URIs", () => {
|
||||
it("rejects javascript: protocol", () => {
|
||||
editor.commands.focus();
|
||||
simulateTyping(editor, "[click me](javascript:alert(1))");
|
||||
|
||||
const links = extractLinks(editor);
|
||||
expect(links).toHaveLength(0);
|
||||
// The raw markdown syntax should remain as literal text
|
||||
expect(editor.getText()).toContain("[click me](javascript:alert(1))");
|
||||
});
|
||||
|
||||
it("rejects data: protocol", () => {
|
||||
editor.commands.focus();
|
||||
simulateTyping(editor, "[click me](data:text/html,<script>alert(1)</script>)");
|
||||
|
||||
const links = extractLinks(editor);
|
||||
expect(links).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("allows https: protocol", () => {
|
||||
editor.commands.focus();
|
||||
simulateTyping(editor, "[safe](https://example.com)");
|
||||
|
||||
const links = extractLinks(editor);
|
||||
expect(links).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("allows http: protocol", () => {
|
||||
editor.commands.focus();
|
||||
simulateTyping(editor, "[safe](http://example.com)");
|
||||
|
||||
const links = extractLinks(editor);
|
||||
expect(links).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("allows relative paths", () => {
|
||||
editor.commands.focus();
|
||||
simulateTyping(editor, "[page](/about)");
|
||||
|
||||
const links = extractLinks(editor);
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0]!.href).toBe("/about");
|
||||
});
|
||||
|
||||
it("allows mailto: protocol", () => {
|
||||
editor.commands.focus();
|
||||
simulateTyping(editor, "[email](mailto:user@example.com)");
|
||||
|
||||
const links = extractLinks(editor);
|
||||
expect(links).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge cases", () => {
|
||||
it("does not convert when link text is empty", () => {
|
||||
editor.commands.focus();
|
||||
simulateTyping(editor, "[](https://example.com)");
|
||||
|
||||
const links = extractLinks(editor);
|
||||
expect(links).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not convert when href is empty", () => {
|
||||
editor.commands.focus();
|
||||
simulateTyping(editor, "[text]()");
|
||||
|
||||
const links = extractLinks(editor);
|
||||
expect(links).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles special characters in link text", () => {
|
||||
editor.commands.focus();
|
||||
simulateTyping(editor, "[it's a <test> & more](https://example.com)");
|
||||
|
||||
const links = extractLinks(editor);
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0]!.text).toBe("it's a <test> & more");
|
||||
});
|
||||
|
||||
it("handles query strings and fragments in URL", () => {
|
||||
editor.commands.focus();
|
||||
simulateTyping(editor, "[search](https://example.com/path?q=hello&lang=en#section)");
|
||||
|
||||
const links = extractLinks(editor);
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0]!.href).toBe("https://example.com/path?q=hello&lang=en#section");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Coexistence with StarterKit Link extension", () => {
|
||||
it("can still set links via the Link command API", () => {
|
||||
editor.commands.insertContent("click here");
|
||||
editor.commands.selectAll();
|
||||
editor.commands.setLink({ href: "https://example.com" });
|
||||
|
||||
expect(editor.isActive("link")).toBe(true);
|
||||
const links = extractLinks(editor);
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0]).toEqual({ text: "click here", href: "https://example.com" });
|
||||
});
|
||||
|
||||
it("can unset links via the Link command API", () => {
|
||||
editor.commands.insertContent("click here");
|
||||
editor.commands.selectAll();
|
||||
editor.commands.setLink({ href: "https://example.com" });
|
||||
expect(editor.isActive("link")).toBe(true);
|
||||
|
||||
editor.commands.unsetLink();
|
||||
expect(editor.isActive("link")).toBe(false);
|
||||
const links = extractLinks(editor);
|
||||
expect(links).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("markdown link input does not break subsequent link commands", () => {
|
||||
// First, create a link via markdown syntax
|
||||
editor.commands.focus();
|
||||
simulateTyping(editor, "[md link](https://md.example.com) ");
|
||||
|
||||
// Then create a link via command API
|
||||
editor.commands.insertContent("cmd link");
|
||||
// Select just the "cmd link" text
|
||||
const docSize = editor.state.doc.content.size;
|
||||
editor.commands.setTextSelection({ from: docSize - 8, to: docSize - 1 });
|
||||
editor.commands.setLink({ href: "https://cmd.example.com" });
|
||||
|
||||
const links = extractLinks(editor);
|
||||
expect(links).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
40
packages/admin/tests/editor/metrics.test.tsx
Normal file
40
packages/admin/tests/editor/metrics.test.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { calculateReadingTime } from "../../src/components/PortableTextEditor";
|
||||
|
||||
describe("Editor Metrics", () => {
|
||||
describe("calculateReadingTime", () => {
|
||||
it("returns 0 minutes for empty document", () => {
|
||||
expect(calculateReadingTime(0)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 1 minute for less than 200 words", () => {
|
||||
expect(calculateReadingTime(1)).toBe(1);
|
||||
expect(calculateReadingTime(100)).toBe(1);
|
||||
expect(calculateReadingTime(199)).toBe(1);
|
||||
});
|
||||
|
||||
it("returns 1 minute for exactly 200 words", () => {
|
||||
expect(calculateReadingTime(200)).toBe(1);
|
||||
});
|
||||
|
||||
it("returns 2 minutes for 201-400 words", () => {
|
||||
expect(calculateReadingTime(201)).toBe(2);
|
||||
expect(calculateReadingTime(300)).toBe(2);
|
||||
expect(calculateReadingTime(400)).toBe(2);
|
||||
});
|
||||
|
||||
it("returns correct reading time for larger documents", () => {
|
||||
expect(calculateReadingTime(1000)).toBe(5);
|
||||
expect(calculateReadingTime(1001)).toBe(6);
|
||||
expect(calculateReadingTime(2000)).toBe(10);
|
||||
});
|
||||
|
||||
it("always rounds up (ceil)", () => {
|
||||
// 201 / 200 = 1.005, ceil = 2
|
||||
expect(calculateReadingTime(201)).toBe(2);
|
||||
// 401 / 200 = 2.005, ceil = 3
|
||||
expect(calculateReadingTime(401)).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
275
packages/admin/tests/editor/plugin-block-conversion.test.ts
Normal file
275
packages/admin/tests/editor/plugin-block-conversion.test.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Plugin Block Conversion Tests
|
||||
*
|
||||
* Tests the Portable Text ↔ ProseMirror conversion for plugin blocks (embeds).
|
||||
* Covers round-trip fidelity, data isolation, and edge cases that caused
|
||||
* bugs in the initial implementation.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
_prosemirrorToPortableText as prosemirrorToPortableText,
|
||||
_portableTextToProsemirror as portableTextToProsemirror,
|
||||
} from "../../src/components/PortableTextEditor";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Build a ProseMirror doc with the given content nodes */
|
||||
function pmDoc(...content: unknown[]) {
|
||||
return { type: "doc", content };
|
||||
}
|
||||
|
||||
/** Build a ProseMirror pluginBlock node */
|
||||
function pmPluginBlock(blockType: string, id: string, data: Record<string, unknown> = {}) {
|
||||
return {
|
||||
type: "pluginBlock",
|
||||
attrs: { blockType, id, data },
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a Portable Text plugin block (unknown _type → embed) */
|
||||
function ptPluginBlock(type: string, id: string, extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
_type: type,
|
||||
_key: "k1",
|
||||
id,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ProseMirror → Portable Text (convertPMNode pluginBlock case)
|
||||
// =============================================================================
|
||||
|
||||
describe("PM → PT: plugin blocks", () => {
|
||||
it("converts a basic plugin block", () => {
|
||||
const doc = pmDoc(pmPluginBlock("youtube", "https://youtu.be/abc"));
|
||||
const blocks = prosemirrorToPortableText(doc);
|
||||
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect(blocks[0]).toMatchObject({
|
||||
_type: "youtube",
|
||||
id: "https://youtu.be/abc",
|
||||
});
|
||||
expect(blocks[0]!._key).toBeTruthy();
|
||||
});
|
||||
|
||||
it("spreads data fields into the PT block", () => {
|
||||
const doc = pmDoc(pmPluginBlock("chart", "chart-1", { color: "red", size: 42 }));
|
||||
const blocks = prosemirrorToPortableText(doc);
|
||||
|
||||
expect(blocks[0]).toMatchObject({
|
||||
_type: "chart",
|
||||
id: "chart-1",
|
||||
color: "red",
|
||||
size: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it("data fields cannot overwrite _type", () => {
|
||||
const doc = pmDoc(pmPluginBlock("youtube", "vid-1", { _type: "malicious" }));
|
||||
const blocks = prosemirrorToPortableText(doc);
|
||||
|
||||
expect(blocks[0]!._type).toBe("youtube");
|
||||
});
|
||||
|
||||
it("data fields cannot overwrite _key", () => {
|
||||
const doc = pmDoc(pmPluginBlock("youtube", "vid-1", { _key: "evil" }));
|
||||
const blocks = prosemirrorToPortableText(doc);
|
||||
|
||||
expect(blocks[0]!._key).not.toBe("evil");
|
||||
});
|
||||
|
||||
it("handles empty data gracefully", () => {
|
||||
const doc = pmDoc(pmPluginBlock("tweet", "https://twitter.com/x/status/1"));
|
||||
const blocks = prosemirrorToPortableText(doc);
|
||||
|
||||
expect(blocks[0]).toMatchObject({
|
||||
_type: "tweet",
|
||||
id: "https://twitter.com/x/status/1",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back blockType to 'embed' when missing", () => {
|
||||
const doc = pmDoc({
|
||||
type: "pluginBlock",
|
||||
attrs: { blockType: null, id: "url", data: {} },
|
||||
});
|
||||
const blocks = prosemirrorToPortableText(doc);
|
||||
|
||||
expect(blocks[0]!._type).toBe("embed");
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Portable Text → ProseMirror (convertPTBlock default case)
|
||||
// =============================================================================
|
||||
|
||||
describe("PT → PM: plugin blocks", () => {
|
||||
it("converts an unknown block type with id to pluginBlock", () => {
|
||||
const pm = portableTextToProsemirror([ptPluginBlock("youtube", "https://youtu.be/abc")]);
|
||||
const node = pm.content?.[0] as { type: string; attrs: Record<string, unknown> };
|
||||
|
||||
expect(node.type).toBe("pluginBlock");
|
||||
expect(node.attrs.blockType).toBe("youtube");
|
||||
expect(node.attrs.id).toBe("https://youtu.be/abc");
|
||||
});
|
||||
|
||||
it("stores extra fields as data", () => {
|
||||
const pm = portableTextToProsemirror([
|
||||
ptPluginBlock("chart", "chart-1", { color: "red", size: 42 }),
|
||||
]);
|
||||
const node = pm.content?.[0] as { type: string; attrs: { data: Record<string, unknown> } };
|
||||
|
||||
expect(node.attrs.data).toEqual({ color: "red", size: 42 });
|
||||
});
|
||||
|
||||
it("filters _-prefixed keys from data", () => {
|
||||
const pm = portableTextToProsemirror([
|
||||
ptPluginBlock("youtube", "vid-1", {
|
||||
_internal: "should-be-stripped",
|
||||
_foo: "also-stripped",
|
||||
caption: "keep-this",
|
||||
}),
|
||||
]);
|
||||
const node = pm.content?.[0] as { type: string; attrs: { data: Record<string, unknown> } };
|
||||
|
||||
expect(node.attrs.data).toEqual({ caption: "keep-this" });
|
||||
expect(node.attrs.data).not.toHaveProperty("_internal");
|
||||
expect(node.attrs.data).not.toHaveProperty("_foo");
|
||||
});
|
||||
|
||||
it("handles url field as fallback for id", () => {
|
||||
const block = { _type: "embed", _key: "k1", url: "https://example.com" };
|
||||
const pm = portableTextToProsemirror([block]);
|
||||
const node = pm.content?.[0] as { type: string; attrs: Record<string, unknown> };
|
||||
|
||||
expect(node.type).toBe("pluginBlock");
|
||||
expect(node.attrs.id).toBe("https://example.com");
|
||||
});
|
||||
|
||||
it("treats blocks without id, url, or data as unknown (paragraph fallback)", () => {
|
||||
const block = { _type: "mystery", _key: "k1" };
|
||||
const pm = portableTextToProsemirror([block]);
|
||||
const node = pm.content?.[0] as { type: string };
|
||||
|
||||
expect(node.type).toBe("paragraph");
|
||||
});
|
||||
|
||||
it("converts blocks with field data but no id/url to pluginBlock", () => {
|
||||
const block = { _type: "emdash-form", _key: "k1", formId: "abc-123" };
|
||||
const pm = portableTextToProsemirror([block]);
|
||||
const node = pm.content?.[0] as {
|
||||
type: string;
|
||||
attrs: { blockType: string; id: string; data: Record<string, unknown> };
|
||||
};
|
||||
|
||||
expect(node.type).toBe("pluginBlock");
|
||||
expect(node.attrs.blockType).toBe("emdash-form");
|
||||
expect(node.attrs.id).toBe("");
|
||||
expect(node.attrs.data).toEqual({ formId: "abc-123" });
|
||||
});
|
||||
|
||||
it("converts blocks with empty id and field data to pluginBlock", () => {
|
||||
const block = { _type: "emdash-form", _key: "k1", id: "", formId: "abc-123" };
|
||||
const pm = portableTextToProsemirror([block]);
|
||||
const node = pm.content?.[0] as {
|
||||
type: string;
|
||||
attrs: { blockType: string; id: string; data: Record<string, unknown> };
|
||||
};
|
||||
|
||||
expect(node.type).toBe("pluginBlock");
|
||||
expect(node.attrs.blockType).toBe("emdash-form");
|
||||
expect(node.attrs.id).toBe("");
|
||||
expect(node.attrs.data).toEqual({ formId: "abc-123" });
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Round-trip: PT → PM → PT
|
||||
// =============================================================================
|
||||
|
||||
describe("Plugin block round-trip", () => {
|
||||
it("basic plugin block survives round-trip", () => {
|
||||
const original = [ptPluginBlock("youtube", "https://youtu.be/abc")];
|
||||
const pm = portableTextToProsemirror(original);
|
||||
const roundTripped = prosemirrorToPortableText(pm);
|
||||
|
||||
expect(roundTripped).toHaveLength(1);
|
||||
expect(roundTripped[0]).toMatchObject({
|
||||
_type: "youtube",
|
||||
id: "https://youtu.be/abc",
|
||||
});
|
||||
});
|
||||
|
||||
it("plugin block with data survives round-trip", () => {
|
||||
const original = [ptPluginBlock("chart", "chart-1", { color: "red", size: 42 })];
|
||||
const pm = portableTextToProsemirror(original);
|
||||
const roundTripped = prosemirrorToPortableText(pm);
|
||||
|
||||
expect(roundTripped[0]).toMatchObject({
|
||||
_type: "chart",
|
||||
id: "chart-1",
|
||||
color: "red",
|
||||
size: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it("_-prefixed keys do not accumulate across round-trips", () => {
|
||||
// Simulate a block that somehow has _-prefixed keys in data
|
||||
const withLeakyKeys = [
|
||||
ptPluginBlock("youtube", "vid-1", {
|
||||
_createdAt: "2024-01-01",
|
||||
caption: "test",
|
||||
}),
|
||||
];
|
||||
|
||||
// First round-trip should strip _-prefixed keys
|
||||
const pm1 = portableTextToProsemirror(withLeakyKeys);
|
||||
const rt1 = prosemirrorToPortableText(pm1);
|
||||
|
||||
expect(rt1[0]).toMatchObject({ _type: "youtube", id: "vid-1", caption: "test" });
|
||||
expect(rt1[0]).not.toHaveProperty("_createdAt");
|
||||
|
||||
// Second round-trip should be stable
|
||||
const pm2 = portableTextToProsemirror(rt1);
|
||||
const rt2 = prosemirrorToPortableText(pm2);
|
||||
|
||||
expect(rt2[0]).toMatchObject({ _type: "youtube", id: "vid-1", caption: "test" });
|
||||
expect(Object.keys(rt2[0]!).filter((k) => k.startsWith("_"))).toEqual(["_type", "_key"]);
|
||||
});
|
||||
|
||||
it("field-data block (no id) survives round-trip", () => {
|
||||
const original = [{ _type: "emdash-form", _key: "k1", formId: "abc-123" }];
|
||||
const pm = portableTextToProsemirror(original);
|
||||
const roundTripped = prosemirrorToPortableText(pm);
|
||||
|
||||
expect(roundTripped).toHaveLength(1);
|
||||
expect(roundTripped[0]).toMatchObject({
|
||||
_type: "emdash-form",
|
||||
id: "",
|
||||
formId: "abc-123",
|
||||
});
|
||||
});
|
||||
|
||||
it("data with _type/_key fields cannot overwrite block identity after round-trip", () => {
|
||||
// Start from PM where data contains _type/_key as data fields
|
||||
const doc = pmDoc(
|
||||
pmPluginBlock("youtube", "vid-1", { _type: "evil", _key: "evil", caption: "test" }),
|
||||
);
|
||||
|
||||
// PM → PT: fix 5 ensures _type/_key are set after data spread
|
||||
const pt = prosemirrorToPortableText(doc);
|
||||
expect(pt[0]!._type).toBe("youtube");
|
||||
expect(pt[0]!._key).not.toBe("evil");
|
||||
|
||||
// PT → PM → PT: fix 6 strips _-prefixed keys, so they don't persist
|
||||
const pm2 = portableTextToProsemirror(pt);
|
||||
const rt = prosemirrorToPortableText(pm2);
|
||||
expect(rt[0]!._type).toBe("youtube");
|
||||
expect(rt[0]).toMatchObject({ caption: "test" });
|
||||
});
|
||||
});
|
||||
618
packages/admin/tests/editor/slash-menu.test.tsx
Normal file
618
packages/admin/tests/editor/slash-menu.test.tsx
Normal file
@@ -0,0 +1,618 @@
|
||||
/**
|
||||
* Slash command menu tests.
|
||||
*
|
||||
* Tests the "/" trigger, command filtering, keyboard navigation,
|
||||
* command execution, and menu dismissal via Escape.
|
||||
*
|
||||
* The slash menu is internal to PortableTextEditor and driven by
|
||||
* TipTap's Suggestion plugin. We test it through the full editor
|
||||
* since there's no standalone export.
|
||||
*/
|
||||
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { userEvent } from "@vitest/browser/context";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import type { PortableTextEditorProps } from "../../src/components/PortableTextEditor";
|
||||
import { PortableTextEditor } from "../../src/components/PortableTextEditor";
|
||||
import { render } from "../utils/render";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock("../../src/components/MediaPickerModal", () => ({
|
||||
MediaPickerModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../../src/components/SectionPickerModal", () => ({
|
||||
SectionPickerModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../../src/components/editor/DragHandleWrapper", () => ({
|
||||
DragHandleWrapper: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../../src/components/editor/ImageNode", async () => {
|
||||
const { Node } = await import("@tiptap/core");
|
||||
const ImageExtension = Node.create({
|
||||
name: "image",
|
||||
group: "block",
|
||||
atom: true,
|
||||
addAttributes() {
|
||||
return {
|
||||
src: { default: null },
|
||||
alt: { default: "" },
|
||||
title: { default: "" },
|
||||
caption: { default: "" },
|
||||
mediaId: { default: null },
|
||||
provider: { default: "local" },
|
||||
width: { default: null },
|
||||
height: { default: null },
|
||||
displayWidth: { default: null },
|
||||
displayHeight: { default: null },
|
||||
};
|
||||
},
|
||||
parseHTML() {
|
||||
return [{ tag: "img[src]" }];
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["img", HTMLAttributes];
|
||||
},
|
||||
});
|
||||
return { ImageExtension };
|
||||
});
|
||||
|
||||
vi.mock("../../src/components/editor/PluginBlockNode", async () => {
|
||||
const { Node } = await import("@tiptap/core");
|
||||
const PluginBlockExtension = Node.create({
|
||||
name: "pluginBlock",
|
||||
group: "block",
|
||||
atom: true,
|
||||
addAttributes() {
|
||||
return {
|
||||
blockType: { default: "embed" },
|
||||
id: { default: "" },
|
||||
data: { default: {} },
|
||||
};
|
||||
},
|
||||
parseHTML() {
|
||||
return [{ tag: "div[data-plugin-block]" }];
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["div", { ...HTMLAttributes, "data-plugin-block": "" }];
|
||||
},
|
||||
});
|
||||
const embedMeta: Record<string, { label: string }> = {
|
||||
youtube: { label: "YouTube Video" },
|
||||
vimeo: { label: "Vimeo" },
|
||||
tweet: { label: "Tweet" },
|
||||
};
|
||||
return {
|
||||
PluginBlockExtension,
|
||||
getEmbedMeta: (type: string) => ({
|
||||
label: embedMeta[type]?.label ?? "Embed",
|
||||
Icon: () => null,
|
||||
}),
|
||||
registerPluginBlocks: () => {},
|
||||
resolveIcon: () => () => null,
|
||||
};
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const WHITESPACE_SPLIT_REGEX = /\s+/;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Render the editor, wait for TipTap, return editor instance + ProseMirror element */
|
||||
async function renderEditor(props: Partial<PortableTextEditorProps> = {}) {
|
||||
let editorInstance: Editor | null = null;
|
||||
|
||||
const screen = await render(
|
||||
<PortableTextEditor
|
||||
onEditorReady={(editor) => {
|
||||
editorInstance = editor;
|
||||
}}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(document.querySelector(".ProseMirror")).toBeTruthy();
|
||||
expect(editorInstance).toBeTruthy();
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
|
||||
const pm = document.querySelector(".ProseMirror") as HTMLElement;
|
||||
return { screen, editor: editorInstance!, pm };
|
||||
}
|
||||
|
||||
/** Focus the editor */
|
||||
async function focusEditor(pm: HTMLElement) {
|
||||
pm.focus();
|
||||
await vi.waitFor(() => expect(document.activeElement).toBe(pm), { timeout: 1000 });
|
||||
}
|
||||
|
||||
/** Get the slash menu portal element from document.body */
|
||||
function getSlashMenu(): HTMLElement | null {
|
||||
const portals = document.querySelectorAll("body > div");
|
||||
for (const el of portals) {
|
||||
if (el.querySelector("[data-index]") || el.textContent?.includes("No results")) {
|
||||
return el as HTMLElement;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Wait for the slash menu to appear */
|
||||
async function waitForSlashMenu(): Promise<HTMLElement> {
|
||||
let menu: HTMLElement | null = null;
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
menu = getSlashMenu();
|
||||
expect(menu).toBeTruthy();
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
return menu!;
|
||||
}
|
||||
|
||||
/** Wait for the slash menu to disappear */
|
||||
async function waitForSlashMenuClosed() {
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(getSlashMenu()).toBeNull();
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
}
|
||||
|
||||
/** Get visible items in the slash menu */
|
||||
function getSlashMenuItems(menu: HTMLElement): HTMLButtonElement[] {
|
||||
return [...menu.querySelectorAll("button[data-index]")];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an item is the selected/highlighted item.
|
||||
* Selected items use "bg-kumo-tint text-kumo-default" (space-separated).
|
||||
* Non-selected items use "hover:bg-kumo-tint/50".
|
||||
*/
|
||||
function isItemSelected(el: HTMLElement): boolean {
|
||||
// Split className by spaces and check for exact "bg-kumo-tint" token
|
||||
return el.className.split(WHITESPACE_SPLIT_REGEX).includes("bg-kumo-tint");
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Slash Command Menu
|
||||
// =============================================================================
|
||||
|
||||
describe("Slash Command Menu", () => {
|
||||
it("opens when typing / at the start of an empty line", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusEditor(pm);
|
||||
editor.commands.insertContent("/");
|
||||
|
||||
const menu = await waitForSlashMenu();
|
||||
const items = getSlashMenuItems(menu);
|
||||
|
||||
// Default commands: heading1-3, bullet/numbered list, quote, code block, divider, image, section
|
||||
expect(items.length).toBeGreaterThanOrEqual(8);
|
||||
});
|
||||
|
||||
it("shows default block type commands", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusEditor(pm);
|
||||
editor.commands.insertContent("/");
|
||||
|
||||
const menu = await waitForSlashMenu();
|
||||
const items = getSlashMenuItems(menu);
|
||||
const titles = items.map((btn) => btn.querySelector(".font-medium")?.textContent ?? "");
|
||||
|
||||
expect(titles).toContain("Heading 1");
|
||||
expect(titles).toContain("Heading 2");
|
||||
expect(titles).toContain("Heading 3");
|
||||
expect(titles).toContain("Bullet List");
|
||||
expect(titles).toContain("Numbered List");
|
||||
expect(titles).toContain("Quote");
|
||||
expect(titles).toContain("Code Block");
|
||||
expect(titles).toContain("Divider");
|
||||
});
|
||||
|
||||
it("shows descriptions for each command", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusEditor(pm);
|
||||
editor.commands.insertContent("/");
|
||||
|
||||
const menu = await waitForSlashMenu();
|
||||
const items = getSlashMenuItems(menu);
|
||||
|
||||
for (const item of items) {
|
||||
const description = item.querySelector(".text-xs");
|
||||
expect(description).toBeTruthy();
|
||||
expect(description!.textContent!.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("filters commands by query text", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusEditor(pm);
|
||||
editor.commands.insertContent("/");
|
||||
await waitForSlashMenu();
|
||||
|
||||
// Type filter text — Suggestion plugin watches text after "/"
|
||||
await userEvent.keyboard("head");
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
const menu = getSlashMenu();
|
||||
expect(menu).toBeTruthy();
|
||||
const items = getSlashMenuItems(menu!);
|
||||
const titles = items.map((btn) => btn.querySelector(".font-medium")?.textContent ?? "");
|
||||
expect(titles.length).toBeGreaterThanOrEqual(1);
|
||||
expect(titles.every((t) => t.toLowerCase().includes("heading"))).toBe(true);
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
});
|
||||
|
||||
it("shows No results when no commands match", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusEditor(pm);
|
||||
editor.commands.insertContent("/");
|
||||
await waitForSlashMenu();
|
||||
|
||||
await userEvent.keyboard("xyznonexistent");
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
const menu = getSlashMenu();
|
||||
expect(menu).toBeTruthy();
|
||||
expect(menu!.textContent).toContain("No results");
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
});
|
||||
|
||||
it("highlights the first item by default", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusEditor(pm);
|
||||
editor.commands.insertContent("/");
|
||||
|
||||
const menu = await waitForSlashMenu();
|
||||
const items = getSlashMenuItems(menu);
|
||||
|
||||
expect(isItemSelected(items[0]!)).toBe(true);
|
||||
});
|
||||
|
||||
it("moves selection down with ArrowDown", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusEditor(pm);
|
||||
editor.commands.insertContent("/");
|
||||
await waitForSlashMenu();
|
||||
|
||||
await userEvent.keyboard("{ArrowDown}");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const menu = getSlashMenu()!;
|
||||
const items = getSlashMenuItems(menu);
|
||||
expect(isItemSelected(items[1]!)).toBe(true);
|
||||
expect(isItemSelected(items[0]!)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("moves selection up with ArrowUp from second item", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusEditor(pm);
|
||||
editor.commands.insertContent("/");
|
||||
await waitForSlashMenu();
|
||||
|
||||
// Move down, then back up
|
||||
await userEvent.keyboard("{ArrowDown}");
|
||||
await vi.waitFor(() => {
|
||||
const items = getSlashMenuItems(getSlashMenu()!);
|
||||
expect(isItemSelected(items[1]!)).toBe(true);
|
||||
});
|
||||
|
||||
await userEvent.keyboard("{ArrowUp}");
|
||||
await vi.waitFor(() => {
|
||||
const items = getSlashMenuItems(getSlashMenu()!);
|
||||
expect(isItemSelected(items[0]!)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("wraps selection around when pressing ArrowUp from first item", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusEditor(pm);
|
||||
editor.commands.insertContent("/");
|
||||
await waitForSlashMenu();
|
||||
|
||||
await userEvent.keyboard("{ArrowUp}");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const menu = getSlashMenu()!;
|
||||
const items = getSlashMenuItems(menu);
|
||||
const lastItem = items.at(-1)!;
|
||||
expect(isItemSelected(lastItem)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("executes selected command on Enter and converts to heading", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusEditor(pm);
|
||||
editor.commands.insertContent("/");
|
||||
await waitForSlashMenu();
|
||||
|
||||
// First item is "Heading 1"
|
||||
await userEvent.keyboard("{Enter}");
|
||||
|
||||
await waitForSlashMenuClosed();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(pm.querySelector("h1")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("closes menu on Escape without executing", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusEditor(pm);
|
||||
editor.commands.insertContent("/");
|
||||
await waitForSlashMenu();
|
||||
|
||||
await userEvent.keyboard("{Escape}");
|
||||
|
||||
await waitForSlashMenuClosed();
|
||||
|
||||
// Should still be a paragraph
|
||||
expect(pm.querySelector("h1")).toBeNull();
|
||||
});
|
||||
|
||||
it("executes command when clicking an item", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusEditor(pm);
|
||||
editor.commands.insertContent("/");
|
||||
|
||||
const menu = await waitForSlashMenu();
|
||||
const items = getSlashMenuItems(menu);
|
||||
const quoteBtn = items.find(
|
||||
(btn) => btn.querySelector(".font-medium")?.textContent === "Quote",
|
||||
);
|
||||
expect(quoteBtn).toBeTruthy();
|
||||
quoteBtn!.click();
|
||||
|
||||
await waitForSlashMenuClosed();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(pm.querySelector("blockquote")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("inserts a code block via slash command", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusEditor(pm);
|
||||
editor.commands.insertContent("/");
|
||||
|
||||
const menu = await waitForSlashMenu();
|
||||
const items = getSlashMenuItems(menu);
|
||||
const codeBlockBtn = items.find(
|
||||
(btn) => btn.querySelector(".font-medium")?.textContent === "Code Block",
|
||||
);
|
||||
expect(codeBlockBtn).toBeTruthy();
|
||||
codeBlockBtn!.click();
|
||||
|
||||
await waitForSlashMenuClosed();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(pm.querySelector("pre")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("inserts a horizontal rule via slash command", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusEditor(pm);
|
||||
editor.commands.insertContent("/");
|
||||
|
||||
const menu = await waitForSlashMenu();
|
||||
const items = getSlashMenuItems(menu);
|
||||
const dividerBtn = items.find(
|
||||
(btn) => btn.querySelector(".font-medium")?.textContent === "Divider",
|
||||
);
|
||||
expect(dividerBtn).toBeTruthy();
|
||||
dividerBtn!.click();
|
||||
|
||||
await waitForSlashMenuClosed();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(pm.querySelector("hr")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("inserts bullet list via slash command", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusEditor(pm);
|
||||
editor.commands.insertContent("/");
|
||||
|
||||
const menu = await waitForSlashMenu();
|
||||
const items = getSlashMenuItems(menu);
|
||||
const bulletBtn = items.find(
|
||||
(btn) => btn.querySelector(".font-medium")?.textContent === "Bullet List",
|
||||
);
|
||||
expect(bulletBtn).toBeTruthy();
|
||||
bulletBtn!.click();
|
||||
|
||||
await waitForSlashMenuClosed();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(pm.querySelector("ul")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("inserts numbered list via slash command", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusEditor(pm);
|
||||
editor.commands.insertContent("/");
|
||||
|
||||
const menu = await waitForSlashMenu();
|
||||
const items = getSlashMenuItems(menu);
|
||||
const numberedBtn = items.find(
|
||||
(btn) => btn.querySelector(".font-medium")?.textContent === "Numbered List",
|
||||
);
|
||||
expect(numberedBtn).toBeTruthy();
|
||||
numberedBtn!.click();
|
||||
|
||||
await waitForSlashMenuClosed();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(pm.querySelector("ol")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("highlights item on mouse hover", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusEditor(pm);
|
||||
editor.commands.insertContent("/");
|
||||
|
||||
const menu = await waitForSlashMenu();
|
||||
const items = getSlashMenuItems(menu);
|
||||
|
||||
// React listens for pointerenter/mouseenter on the element.
|
||||
// Use userEvent.hover which properly dispatches pointer + mouse events.
|
||||
await userEvent.hover(items[2]!);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const freshItems = getSlashMenuItems(menu);
|
||||
expect(isItemSelected(freshItems[2]!)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("filters by alias (typing /h1 shows Heading 1)", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusEditor(pm);
|
||||
editor.commands.insertContent("/");
|
||||
await waitForSlashMenu();
|
||||
|
||||
await userEvent.keyboard("h1");
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
const menu = getSlashMenu();
|
||||
expect(menu).toBeTruthy();
|
||||
const items = getSlashMenuItems(menu!);
|
||||
expect(items.length).toBeGreaterThanOrEqual(1);
|
||||
const titles = items.map((btn) => btn.querySelector(".font-medium")?.textContent ?? "");
|
||||
expect(titles).toContain("Heading 1");
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
});
|
||||
|
||||
it("includes Image and Section commands", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusEditor(pm);
|
||||
editor.commands.insertContent("/");
|
||||
|
||||
const menu = await waitForSlashMenu();
|
||||
const items = getSlashMenuItems(menu);
|
||||
const titles = items.map((btn) => btn.querySelector(".font-medium")?.textContent ?? "");
|
||||
|
||||
expect(titles).toContain("Image");
|
||||
expect(titles).toContain("Section");
|
||||
});
|
||||
|
||||
it("prioritises title matches over description matches when filtering", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusEditor(pm);
|
||||
editor.commands.insertContent("/");
|
||||
await waitForSlashMenu();
|
||||
|
||||
// "sec" matches "Section" by title and headings by description ("section heading")
|
||||
await userEvent.keyboard("sec");
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
const menu = getSlashMenu();
|
||||
expect(menu).toBeTruthy();
|
||||
const items = getSlashMenuItems(menu!);
|
||||
const titles = items.map((btn) => btn.querySelector(".font-medium")?.textContent ?? "");
|
||||
expect(titles.length).toBeGreaterThan(1);
|
||||
expect(titles[0]).toBe("Section");
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
});
|
||||
|
||||
it("includes plugin block commands when provided", async () => {
|
||||
const { editor, pm } = await renderEditor({
|
||||
pluginBlocks: [
|
||||
{
|
||||
pluginId: "test-plugin",
|
||||
type: "youtube",
|
||||
label: "YouTube Video",
|
||||
},
|
||||
],
|
||||
});
|
||||
await focusEditor(pm);
|
||||
editor.commands.insertContent("/");
|
||||
|
||||
const menu = await waitForSlashMenu();
|
||||
const items = getSlashMenuItems(menu);
|
||||
const titles = items.map((btn) => btn.querySelector(".font-medium")?.textContent ?? "");
|
||||
|
||||
expect(titles).toContain("YouTube Video");
|
||||
});
|
||||
|
||||
it("renders plugin block commands with a custom category override", async () => {
|
||||
// A plugin block that opts into the "Sections" category instead of the
|
||||
// default "Embeds". The category itself isn't currently surfaced in the
|
||||
// rendered DOM (the slash menu doesn't group by category), but providing
|
||||
// it must not break rendering and the block must still be selectable.
|
||||
const { editor, pm } = await renderEditor({
|
||||
pluginBlocks: [
|
||||
{
|
||||
pluginId: "marketing-blocks",
|
||||
type: "marketing.hero",
|
||||
label: "Hero",
|
||||
category: "Sections",
|
||||
},
|
||||
],
|
||||
});
|
||||
await focusEditor(pm);
|
||||
editor.commands.insertContent("/");
|
||||
|
||||
const menu = await waitForSlashMenu();
|
||||
const items = getSlashMenuItems(menu);
|
||||
const titles = items.map((btn) => btn.querySelector(".font-medium")?.textContent ?? "");
|
||||
|
||||
expect(titles).toContain("Hero");
|
||||
});
|
||||
|
||||
it("renders plugin block commands without a category (default Embeds)", async () => {
|
||||
// Existing plugins that omit `category` must continue to render under
|
||||
// the default category. This guards against regressions in the type
|
||||
// widening / fallback behaviour.
|
||||
const { editor, pm } = await renderEditor({
|
||||
pluginBlocks: [
|
||||
{
|
||||
pluginId: "test-plugin",
|
||||
type: "vimeo",
|
||||
label: "Vimeo",
|
||||
// no category provided
|
||||
},
|
||||
],
|
||||
});
|
||||
await focusEditor(pm);
|
||||
editor.commands.insertContent("/");
|
||||
|
||||
const menu = await waitForSlashMenu();
|
||||
const items = getSlashMenuItems(menu);
|
||||
const titles = items.map((btn) => btn.querySelector(".font-medium")?.textContent ?? "");
|
||||
|
||||
expect(titles).toContain("Vimeo");
|
||||
});
|
||||
});
|
||||
823
packages/admin/tests/editor/toolbar.test.tsx
Normal file
823
packages/admin/tests/editor/toolbar.test.tsx
Normal file
@@ -0,0 +1,823 @@
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import { userEvent } from "@vitest/browser/context";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import {
|
||||
PortableTextEditor,
|
||||
type PortableTextEditorProps,
|
||||
} from "../../src/components/PortableTextEditor";
|
||||
import { render } from "../utils/render.tsx";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks — heavy components that need network / Astro context
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock("../../src/components/MediaPickerModal", () => ({
|
||||
MediaPickerModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../../src/components/SectionPickerModal", () => ({
|
||||
SectionPickerModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../../src/components/editor/DragHandleWrapper", () => ({
|
||||
DragHandleWrapper: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../../src/components/editor/ImageNode", async () => {
|
||||
const { Node } = await import("@tiptap/core");
|
||||
const ImageExtension = Node.create({
|
||||
name: "image",
|
||||
group: "block",
|
||||
atom: true,
|
||||
addAttributes() {
|
||||
return {
|
||||
src: { default: null },
|
||||
alt: { default: "" },
|
||||
title: { default: "" },
|
||||
caption: { default: "" },
|
||||
mediaId: { default: null },
|
||||
provider: { default: "local" },
|
||||
width: { default: null },
|
||||
height: { default: null },
|
||||
displayWidth: { default: null },
|
||||
displayHeight: { default: null },
|
||||
};
|
||||
},
|
||||
parseHTML() {
|
||||
return [{ tag: "img[src]" }];
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["img", HTMLAttributes];
|
||||
},
|
||||
});
|
||||
return { ImageExtension };
|
||||
});
|
||||
|
||||
vi.mock("../../src/components/editor/PluginBlockNode", async () => {
|
||||
const { Node } = await import("@tiptap/core");
|
||||
const PluginBlockExtension = Node.create({
|
||||
name: "pluginBlock",
|
||||
group: "block",
|
||||
atom: true,
|
||||
addAttributes() {
|
||||
return {
|
||||
blockType: { default: "embed" },
|
||||
id: { default: "" },
|
||||
data: { default: {} },
|
||||
};
|
||||
},
|
||||
parseHTML() {
|
||||
return [{ tag: "div[data-plugin-block]" }];
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["div", { ...HTMLAttributes, "data-plugin-block": "" }];
|
||||
},
|
||||
});
|
||||
return {
|
||||
PluginBlockExtension,
|
||||
getEmbedMeta: () => ({ label: "Embed", Icon: () => null }),
|
||||
registerPluginBlocks: () => {},
|
||||
resolveIcon: () => () => null,
|
||||
};
|
||||
});
|
||||
|
||||
const defaultValue = [
|
||||
{
|
||||
_type: "block" as const,
|
||||
_key: "1",
|
||||
style: "normal" as const,
|
||||
children: [{ _type: "span" as const, _key: "s1", text: "Hello world" }],
|
||||
},
|
||||
];
|
||||
|
||||
async function renderEditor(props: Partial<PortableTextEditorProps> = {}) {
|
||||
let editorInstance: Editor | null = null;
|
||||
const onEditorReady = (editor: Editor) => {
|
||||
editorInstance = editor;
|
||||
};
|
||||
|
||||
const screen = await render(
|
||||
<PortableTextEditor value={defaultValue} onEditorReady={onEditorReady} {...props} />,
|
||||
);
|
||||
|
||||
// Wait for TipTap to initialize
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(document.querySelector(".ProseMirror")).toBeTruthy();
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
|
||||
return { screen, editor: editorInstance! };
|
||||
}
|
||||
|
||||
/** Focus the ProseMirror editor and select all text */
|
||||
async function focusAndSelectAll(screen: Awaited<ReturnType<typeof render>>) {
|
||||
const prosemirror = screen.container.querySelector(".ProseMirror") as HTMLElement;
|
||||
prosemirror.focus();
|
||||
await vi.waitFor(() => expect(document.activeElement).toBe(prosemirror), { timeout: 1000 });
|
||||
// Use Control on Linux CI, Meta on macOS
|
||||
const mod = navigator.platform.includes("Mac") ? "{Meta>}" : "{Control>}";
|
||||
const modUp = navigator.platform.includes("Mac") ? "{/Meta}" : "{/Control}";
|
||||
await userEvent.keyboard(`${mod}{a}${modUp}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a locator scoped to the editor toolbar.
|
||||
*
|
||||
* The bubble menu (which appears when text is selected) renders buttons with
|
||||
* the same accessible names as some toolbar buttons (Bold, Italic, Underline,
|
||||
* Strikethrough). An unscoped `getByRole("button", { name: "Bold" })` after
|
||||
* selecting text races with the bubble menu and produces a strict-mode
|
||||
* violation in CI. Scoping to the toolbar via its aria-label avoids the race.
|
||||
*/
|
||||
function getToolbarButton(screen: Awaited<ReturnType<typeof render>>, name: string) {
|
||||
return screen.getByRole("toolbar", { name: "Text formatting" }).getByRole("button", { name });
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 1. Toolbar Presence and Structure
|
||||
// =============================================================================
|
||||
|
||||
describe("Toolbar Presence and Structure", () => {
|
||||
it("has role='toolbar' with correct aria-label", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
const toolbar = screen.getByRole("toolbar");
|
||||
await expect.element(toolbar).toHaveAttribute("aria-label", "Text formatting");
|
||||
});
|
||||
|
||||
it("has all formatting buttons", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
await expect.element(screen.getByRole("button", { name: "Bold" })).toBeVisible();
|
||||
await expect.element(screen.getByRole("button", { name: "Italic" })).toBeVisible();
|
||||
await expect.element(screen.getByRole("button", { name: "Underline" })).toBeVisible();
|
||||
await expect.element(screen.getByRole("button", { name: "Strikethrough" })).toBeVisible();
|
||||
await expect.element(screen.getByRole("button", { name: "Inline Code" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("has all heading buttons", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
await expect.element(screen.getByRole("button", { name: "Heading 1" })).toBeVisible();
|
||||
await expect.element(screen.getByRole("button", { name: "Heading 2" })).toBeVisible();
|
||||
await expect.element(screen.getByRole("button", { name: "Heading 3" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("has all list buttons", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
await expect.element(screen.getByRole("button", { name: "Bullet List" })).toBeVisible();
|
||||
await expect.element(screen.getByRole("button", { name: "Numbered List" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("has all block buttons", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
await expect.element(screen.getByRole("button", { name: "Quote" })).toBeVisible();
|
||||
await expect.element(screen.getByRole("button", { name: "Code Block" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("has all alignment buttons", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
await expect.element(screen.getByRole("button", { name: "Align Left" })).toBeVisible();
|
||||
await expect.element(screen.getByRole("button", { name: "Align Center" })).toBeVisible();
|
||||
await expect.element(screen.getByRole("button", { name: "Align Right" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("has all insert buttons", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
await expect.element(screen.getByRole("button", { name: "Insert Link" })).toBeVisible();
|
||||
await expect.element(screen.getByRole("button", { name: "Insert Image" })).toBeVisible();
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: "Insert Horizontal Rule" }))
|
||||
.toBeVisible();
|
||||
});
|
||||
|
||||
it("has history buttons", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
await expect.element(screen.getByRole("button", { name: "Undo" })).toBeVisible();
|
||||
await expect.element(screen.getByRole("button", { name: "Redo" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("has Spotlight Mode button", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
await expect.element(screen.getByRole("button", { name: "Spotlight Mode" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("hides toolbar when minimal={true}", async () => {
|
||||
const { screen } = await renderEditor({ minimal: true });
|
||||
const toolbar = screen.container.querySelector('[role="toolbar"]');
|
||||
expect(toolbar).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// 2. Formatting Button Toggle States
|
||||
// =============================================================================
|
||||
|
||||
describe("Formatting Button Toggle States", () => {
|
||||
it("Bold: click toggles aria-pressed to true", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
await focusAndSelectAll(screen);
|
||||
|
||||
const btn = getToolbarButton(screen, "Bold");
|
||||
await expect.element(btn).toHaveAttribute("aria-pressed", "false");
|
||||
btn.element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(btn.element().getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
it("Italic: click toggles aria-pressed to true", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
await focusAndSelectAll(screen);
|
||||
|
||||
const btn = getToolbarButton(screen, "Italic");
|
||||
await expect.element(btn).toHaveAttribute("aria-pressed", "false");
|
||||
btn.element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(btn.element().getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
it("Underline: click toggles aria-pressed to true", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
await focusAndSelectAll(screen);
|
||||
|
||||
const btn = getToolbarButton(screen, "Underline");
|
||||
await expect.element(btn).toHaveAttribute("aria-pressed", "false");
|
||||
btn.element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(btn.element().getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
it("Strikethrough: click toggles aria-pressed to true", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
await focusAndSelectAll(screen);
|
||||
|
||||
const btn = getToolbarButton(screen, "Strikethrough");
|
||||
await expect.element(btn).toHaveAttribute("aria-pressed", "false");
|
||||
btn.element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(btn.element().getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
it("Inline Code: click toggles aria-pressed to true", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
await focusAndSelectAll(screen);
|
||||
|
||||
const btn = getToolbarButton(screen, "Inline Code");
|
||||
await expect.element(btn).toHaveAttribute("aria-pressed", "false");
|
||||
btn.element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(btn.element().getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
it("Heading 1: click toggles aria-pressed to true and changes to h1", async () => {
|
||||
const { screen, editor } = await renderEditor();
|
||||
// Focus editor and place cursor (block commands need cursor in a paragraph)
|
||||
editor.commands.focus();
|
||||
|
||||
const btn = screen.getByRole("button", { name: "Heading 1" });
|
||||
await expect.element(btn).toHaveAttribute("aria-pressed", "false");
|
||||
btn.element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(btn.element().getAttribute("aria-pressed")).toBe("true");
|
||||
expect(editor.isActive("heading", { level: 1 })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("Heading 2: click toggles aria-pressed to true", async () => {
|
||||
const { screen, editor } = await renderEditor();
|
||||
editor.commands.focus();
|
||||
|
||||
const btn = screen.getByRole("button", { name: "Heading 2" });
|
||||
btn.element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(btn.element().getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
it("Heading 3: click toggles aria-pressed to true", async () => {
|
||||
const { screen, editor } = await renderEditor();
|
||||
editor.commands.focus();
|
||||
|
||||
const btn = screen.getByRole("button", { name: "Heading 3" });
|
||||
btn.element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(btn.element().getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
it("Bullet List: click toggles aria-pressed to true", async () => {
|
||||
const { screen, editor } = await renderEditor();
|
||||
editor.commands.focus();
|
||||
|
||||
const btn = screen.getByRole("button", { name: "Bullet List" });
|
||||
btn.element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(btn.element().getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
it("Numbered List: click toggles aria-pressed to true", async () => {
|
||||
const { screen, editor } = await renderEditor();
|
||||
editor.commands.focus();
|
||||
|
||||
const btn = screen.getByRole("button", { name: "Numbered List" });
|
||||
btn.element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(btn.element().getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
it("Quote: click toggles aria-pressed to true", async () => {
|
||||
const { screen, editor } = await renderEditor();
|
||||
editor.commands.focus();
|
||||
|
||||
const btn = screen.getByRole("button", { name: "Quote" });
|
||||
btn.element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(btn.element().getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
it("Code Block: click toggles aria-pressed to true", async () => {
|
||||
const { screen, editor } = await renderEditor();
|
||||
editor.commands.focus();
|
||||
|
||||
const btn = screen.getByRole("button", { name: "Code Block" });
|
||||
btn.element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(btn.element().getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
it("Toggle off: clicking Bold twice returns aria-pressed to false", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
await focusAndSelectAll(screen);
|
||||
|
||||
const btn = getToolbarButton(screen, "Bold");
|
||||
|
||||
// First click: on
|
||||
btn.element().click();
|
||||
await vi.waitFor(() => {
|
||||
expect(btn.element().getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
|
||||
// Second click: off
|
||||
btn.element().click();
|
||||
await vi.waitFor(() => {
|
||||
expect(btn.element().getAttribute("aria-pressed")).toBe("false");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// 3. Text Alignment
|
||||
// =============================================================================
|
||||
|
||||
describe("Text Alignment", () => {
|
||||
it("Align Center becomes pressed, Align Left becomes unpressed", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
await focusAndSelectAll(screen);
|
||||
|
||||
const alignLeft = screen.getByRole("button", { name: "Align Left" });
|
||||
const alignCenter = screen.getByRole("button", { name: "Align Center" });
|
||||
|
||||
alignCenter.element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(alignCenter.element().getAttribute("aria-pressed")).toBe("true");
|
||||
expect(alignLeft.element().getAttribute("aria-pressed")).toBe("false");
|
||||
});
|
||||
});
|
||||
|
||||
it("Align Right becomes pressed, others unpressed", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
await focusAndSelectAll(screen);
|
||||
|
||||
const alignLeft = screen.getByRole("button", { name: "Align Left" });
|
||||
const alignCenter = screen.getByRole("button", { name: "Align Center" });
|
||||
const alignRight = screen.getByRole("button", { name: "Align Right" });
|
||||
|
||||
alignRight.element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(alignRight.element().getAttribute("aria-pressed")).toBe("true");
|
||||
expect(alignLeft.element().getAttribute("aria-pressed")).toBe("false");
|
||||
expect(alignCenter.element().getAttribute("aria-pressed")).toBe("false");
|
||||
});
|
||||
});
|
||||
|
||||
it("Align Left becomes pressed after switching from another alignment", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
await focusAndSelectAll(screen);
|
||||
|
||||
const alignLeft = screen.getByRole("button", { name: "Align Left" });
|
||||
const alignRight = screen.getByRole("button", { name: "Align Right" });
|
||||
|
||||
// First switch to right
|
||||
alignRight.element().click();
|
||||
await vi.waitFor(() => {
|
||||
expect(alignRight.element().getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
|
||||
// Then switch back to left
|
||||
alignLeft.element().click();
|
||||
await vi.waitFor(() => {
|
||||
expect(alignLeft.element().getAttribute("aria-pressed")).toBe("true");
|
||||
expect(alignRight.element().getAttribute("aria-pressed")).toBe("false");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// 4. Undo/Redo
|
||||
// =============================================================================
|
||||
|
||||
describe("Undo/Redo", () => {
|
||||
it("initially Undo and Redo are disabled", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
|
||||
const undo = screen.getByRole("button", { name: "Undo" });
|
||||
const redo = screen.getByRole("button", { name: "Redo" });
|
||||
|
||||
await expect.element(undo).toBeDisabled();
|
||||
await expect.element(redo).toBeDisabled();
|
||||
});
|
||||
|
||||
it("after making a change, Undo becomes enabled", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
await focusAndSelectAll(screen);
|
||||
|
||||
// Make a change - toggle bold
|
||||
getToolbarButton(screen, "Bold").element().click();
|
||||
|
||||
const undo = getToolbarButton(screen, "Undo");
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(undo.element().disabled).toBe(false);
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
});
|
||||
|
||||
it("after undo, Redo becomes enabled", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
await focusAndSelectAll(screen);
|
||||
|
||||
// Make a change
|
||||
getToolbarButton(screen, "Bold").element().click();
|
||||
|
||||
const undo = getToolbarButton(screen, "Undo");
|
||||
const redo = getToolbarButton(screen, "Redo");
|
||||
|
||||
// Wait for undo to be enabled, then click it
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(undo.element().disabled).toBe(false);
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
undo.element().click();
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(redo.element().disabled).toBe(false);
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
});
|
||||
|
||||
it("after redo, Undo is enabled and Redo is disabled", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
await focusAndSelectAll(screen);
|
||||
|
||||
// Make a change
|
||||
getToolbarButton(screen, "Bold").element().click();
|
||||
|
||||
const undo = getToolbarButton(screen, "Undo");
|
||||
const redo = getToolbarButton(screen, "Redo");
|
||||
|
||||
// Undo
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(undo.element().disabled).toBe(false);
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
undo.element().click();
|
||||
|
||||
// Redo
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(redo.element().disabled).toBe(false);
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
redo.element().click();
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(undo.element().disabled).toBe(false);
|
||||
expect(redo.element().disabled).toBe(true);
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// 5. Link Insertion (Toolbar Popover)
|
||||
// =============================================================================
|
||||
|
||||
describe("Link Insertion", () => {
|
||||
it("clicking Insert Link opens a popover with URL input", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
await focusAndSelectAll(screen);
|
||||
|
||||
const linkBtn = screen.getByRole("button", { name: "Insert Link" });
|
||||
linkBtn.element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const input = screen.container.querySelector('input[type="url"]');
|
||||
expect(input).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("popover has Cancel and Apply buttons", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
await focusAndSelectAll(screen);
|
||||
|
||||
screen.getByRole("button", { name: "Insert Link" }).element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Cancel" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Apply" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("typing URL and clicking Apply sets the link", async () => {
|
||||
const { screen, editor } = await renderEditor();
|
||||
await focusAndSelectAll(screen);
|
||||
|
||||
screen.getByRole("button", { name: "Insert Link" }).element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.container.querySelector('input[type="url"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
const input = screen.container.querySelector('input[type="url"]') as HTMLInputElement;
|
||||
// Focus input and type URL
|
||||
input.focus();
|
||||
// Use native input value setter to trigger React's onChange
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
"value",
|
||||
)!.set!;
|
||||
nativeInputValueSetter.call(input, "https://example.com");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
|
||||
screen.getByRole("button", { name: "Apply" }).element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(editor.isActive("link")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("clicking Cancel closes the popover", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
await focusAndSelectAll(screen);
|
||||
|
||||
screen.getByRole("button", { name: "Insert Link" }).element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.container.querySelector('input[type="url"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
screen.getByRole("button", { name: "Cancel" }).element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.container.querySelector('input[type="url"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("Remove button appears when link already exists", async () => {
|
||||
const { screen, editor } = await renderEditor();
|
||||
await focusAndSelectAll(screen);
|
||||
|
||||
// Set a link programmatically
|
||||
editor.chain().focus().setLink({ href: "https://example.com" }).run();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(editor.isActive("link")).toBe(true);
|
||||
});
|
||||
|
||||
// Re-select all to ensure cursor is in the link
|
||||
const mod = navigator.platform.includes("Mac") ? "{Meta>}" : "{Control>}";
|
||||
const modUp = navigator.platform.includes("Mac") ? "{/Meta}" : "{/Control}";
|
||||
await userEvent.keyboard(`${mod}{a}${modUp}`);
|
||||
|
||||
screen.getByRole("button", { name: "Insert Link" }).element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Remove" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// 6. Focus Mode Toggle
|
||||
// =============================================================================
|
||||
|
||||
describe("Focus Mode Toggle", () => {
|
||||
it("initially Spotlight Mode aria-pressed is false", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
const btn = screen.getByRole("button", { name: "Spotlight Mode" });
|
||||
await expect.element(btn).toHaveAttribute("aria-pressed", "false");
|
||||
});
|
||||
|
||||
it("clicking Spotlight Mode toggles aria-pressed to true and adds class", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
const btn = screen.getByRole("button", { name: "Spotlight Mode" });
|
||||
|
||||
btn.element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(btn.element().getAttribute("aria-pressed")).toBe("true");
|
||||
// The wrapper div should have the spotlight-mode class
|
||||
const wrapper = screen.container.querySelector(".spotlight-mode");
|
||||
expect(wrapper).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("clicking Spotlight Mode again toggles back to false and removes class", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
const btn = screen.getByRole("button", { name: "Spotlight Mode" });
|
||||
|
||||
// Toggle on
|
||||
btn.element().click();
|
||||
await vi.waitFor(() => {
|
||||
expect(btn.element().getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
|
||||
// Toggle off
|
||||
btn.element().click();
|
||||
await vi.waitFor(() => {
|
||||
expect(btn.element().getAttribute("aria-pressed")).toBe("false");
|
||||
expect(screen.container.querySelector(".spotlight-mode")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("with controlled focusMode prop, reflects external state", async () => {
|
||||
const { screen } = await renderEditor({ focusMode: "spotlight" });
|
||||
|
||||
// The button title changes to "Exit Spotlight Mode" when active
|
||||
const btn = screen.getByRole("button", { name: "Exit Spotlight Mode" });
|
||||
await expect.element(btn).toHaveAttribute("aria-pressed", "true");
|
||||
|
||||
const wrapper = screen.container.querySelector(".spotlight-mode");
|
||||
expect(wrapper).toBeTruthy();
|
||||
});
|
||||
|
||||
it("with onFocusModeChange callback, fires with correct mode", async () => {
|
||||
const onFocusModeChange = vi.fn();
|
||||
const { screen } = await renderEditor({
|
||||
focusMode: "normal",
|
||||
onFocusModeChange,
|
||||
});
|
||||
|
||||
const btn = screen.getByRole("button", { name: "Spotlight Mode" });
|
||||
btn.element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(onFocusModeChange).toHaveBeenCalledWith("spotlight");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// 7. WAI-ARIA Keyboard Navigation
|
||||
// =============================================================================
|
||||
|
||||
describe("WAI-ARIA Keyboard Navigation", () => {
|
||||
it("ArrowRight from Bold moves focus to Italic", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
|
||||
const bold = screen.getByRole("button", { name: "Bold" });
|
||||
const italic = screen.getByRole("button", { name: "Italic" });
|
||||
|
||||
// Focus the Bold button
|
||||
bold.element().focus();
|
||||
expect(document.activeElement).toBe(bold.element());
|
||||
|
||||
// Press ArrowRight
|
||||
await userEvent.keyboard("{ArrowRight}");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.activeElement).toBe(italic.element());
|
||||
});
|
||||
});
|
||||
|
||||
it("ArrowLeft from Italic moves focus to Bold", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
|
||||
const bold = screen.getByRole("button", { name: "Bold" });
|
||||
const italic = screen.getByRole("button", { name: "Italic" });
|
||||
|
||||
// Focus the Italic button
|
||||
italic.element().focus();
|
||||
expect(document.activeElement).toBe(italic.element());
|
||||
|
||||
// Press ArrowLeft
|
||||
await userEvent.keyboard("{ArrowLeft}");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.activeElement).toBe(bold.element());
|
||||
});
|
||||
});
|
||||
|
||||
it("Home moves focus to first button", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
|
||||
const bold = screen.getByRole("button", { name: "Bold" });
|
||||
const alignCenter = screen.getByRole("button", { name: "Align Center" });
|
||||
|
||||
// Focus a button in the middle
|
||||
alignCenter.element().focus();
|
||||
|
||||
// Press Home
|
||||
await userEvent.keyboard("{Home}");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.activeElement).toBe(bold.element());
|
||||
});
|
||||
});
|
||||
|
||||
it("End moves focus to last button", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
|
||||
const bold = screen.getByRole("button", { name: "Bold" });
|
||||
|
||||
// Focus the first button
|
||||
bold.element().focus();
|
||||
|
||||
// Press End — last button is Spotlight Mode (or Exit Spotlight Mode)
|
||||
await userEvent.keyboard("{End}");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const active = document.activeElement as HTMLElement;
|
||||
// Last button in the toolbar — its aria-label should be "Spotlight Mode"
|
||||
expect(active.getAttribute("aria-label")).toBe("Spotlight Mode");
|
||||
});
|
||||
});
|
||||
|
||||
it("ArrowRight wraps from last to first button", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
|
||||
const spotlightBtn = screen.getByRole("button", { name: "Spotlight Mode" });
|
||||
const bold = screen.getByRole("button", { name: "Bold" });
|
||||
|
||||
// Focus the last button
|
||||
spotlightBtn.element().focus();
|
||||
|
||||
// Press ArrowRight - should wrap to first
|
||||
await userEvent.keyboard("{ArrowRight}");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.activeElement).toBe(bold.element());
|
||||
});
|
||||
});
|
||||
|
||||
it("ArrowLeft wraps from first to last button", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
|
||||
const bold = screen.getByRole("button", { name: "Bold" });
|
||||
|
||||
// Focus the first button
|
||||
bold.element().focus();
|
||||
|
||||
// Press ArrowLeft - should wrap to last
|
||||
await userEvent.keyboard("{ArrowLeft}");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const active = document.activeElement as HTMLElement;
|
||||
expect(active.getAttribute("aria-label")).toBe("Spotlight Mode");
|
||||
});
|
||||
});
|
||||
});
|
||||
324
packages/admin/tests/editor/transforms.test.ts
Normal file
324
packages/admin/tests/editor/transforms.test.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* Block Transform Tests
|
||||
*
|
||||
* Tests that block transformations work correctly:
|
||||
* - Transform paragraph to headings (H1, H2, H3)
|
||||
* - Transform to blockquote, code block
|
||||
* - Transform to bullet and ordered lists
|
||||
* - Duplicate block preserves content
|
||||
* - Delete block removes content
|
||||
*
|
||||
* These transformations are used by the BlockMenu component.
|
||||
*/
|
||||
|
||||
import { Editor } from "@tiptap/core";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { blockTransforms } from "../../src/components/editor/BlockMenu";
|
||||
|
||||
describe("Block Transforms", () => {
|
||||
let editor: Editor;
|
||||
|
||||
beforeEach(() => {
|
||||
editor = new Editor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2, 3] },
|
||||
}),
|
||||
],
|
||||
content: "<p>Test content</p>",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
describe("Transform to Paragraph", () => {
|
||||
it("transforms heading to paragraph", () => {
|
||||
editor.commands.setHeading({ level: 1 });
|
||||
expect(editor.isActive("heading", { level: 1 })).toBe(true);
|
||||
|
||||
const transform = blockTransforms.find((t) => t.id === "paragraph");
|
||||
transform?.transform(editor);
|
||||
|
||||
expect(editor.isActive("heading")).toBe(false);
|
||||
expect(editor.isActive("paragraph")).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves text content when transforming to paragraph", () => {
|
||||
editor.commands.setHeading({ level: 2 });
|
||||
const transform = blockTransforms.find((t) => t.id === "paragraph");
|
||||
transform?.transform(editor);
|
||||
|
||||
expect(editor.getText().trim()).toBe("Test content");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Transform to Heading", () => {
|
||||
it("transforms paragraph to heading 1", () => {
|
||||
const transform = blockTransforms.find((t) => t.id === "heading1");
|
||||
transform?.transform(editor);
|
||||
|
||||
expect(editor.isActive("heading", { level: 1 })).toBe(true);
|
||||
});
|
||||
|
||||
it("transforms paragraph to heading 2", () => {
|
||||
const transform = blockTransforms.find((t) => t.id === "heading2");
|
||||
transform?.transform(editor);
|
||||
|
||||
expect(editor.isActive("heading", { level: 2 })).toBe(true);
|
||||
});
|
||||
|
||||
it("transforms paragraph to heading 3", () => {
|
||||
const transform = blockTransforms.find((t) => t.id === "heading3");
|
||||
transform?.transform(editor);
|
||||
|
||||
expect(editor.isActive("heading", { level: 3 })).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves text content when transforming to heading", () => {
|
||||
const transform = blockTransforms.find((t) => t.id === "heading1");
|
||||
transform?.transform(editor);
|
||||
|
||||
expect(editor.getText().trim()).toBe("Test content");
|
||||
});
|
||||
|
||||
it("can change heading level", () => {
|
||||
const h1Transform = blockTransforms.find((t) => t.id === "heading1");
|
||||
h1Transform?.transform(editor);
|
||||
expect(editor.isActive("heading", { level: 1 })).toBe(true);
|
||||
|
||||
const h2Transform = blockTransforms.find((t) => t.id === "heading2");
|
||||
h2Transform?.transform(editor);
|
||||
expect(editor.isActive("heading", { level: 2 })).toBe(true);
|
||||
expect(editor.isActive("heading", { level: 1 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Transform to Blockquote", () => {
|
||||
it("transforms paragraph to blockquote", () => {
|
||||
const transform = blockTransforms.find((t) => t.id === "blockquote");
|
||||
transform?.transform(editor);
|
||||
|
||||
expect(editor.isActive("blockquote")).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves text content when transforming to blockquote", () => {
|
||||
const transform = blockTransforms.find((t) => t.id === "blockquote");
|
||||
transform?.transform(editor);
|
||||
|
||||
expect(editor.getText().trim()).toBe("Test content");
|
||||
});
|
||||
|
||||
it("toggles blockquote off when already active", () => {
|
||||
const transform = blockTransforms.find((t) => t.id === "blockquote");
|
||||
transform?.transform(editor);
|
||||
expect(editor.isActive("blockquote")).toBe(true);
|
||||
|
||||
transform?.transform(editor);
|
||||
expect(editor.isActive("blockquote")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Transform to Code Block", () => {
|
||||
it("transforms paragraph to code block", () => {
|
||||
const transform = blockTransforms.find((t) => t.id === "codeBlock");
|
||||
transform?.transform(editor);
|
||||
|
||||
expect(editor.isActive("codeBlock")).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves text content when transforming to code block", () => {
|
||||
const transform = blockTransforms.find((t) => t.id === "codeBlock");
|
||||
transform?.transform(editor);
|
||||
|
||||
expect(editor.getText().trim()).toBe("Test content");
|
||||
});
|
||||
|
||||
it("toggles code block off when already active", () => {
|
||||
const transform = blockTransforms.find((t) => t.id === "codeBlock");
|
||||
transform?.transform(editor);
|
||||
expect(editor.isActive("codeBlock")).toBe(true);
|
||||
|
||||
transform?.transform(editor);
|
||||
expect(editor.isActive("codeBlock")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Transform to Bullet List", () => {
|
||||
it("transforms paragraph to bullet list", () => {
|
||||
const transform = blockTransforms.find((t) => t.id === "bulletList");
|
||||
transform?.transform(editor);
|
||||
|
||||
expect(editor.isActive("bulletList")).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves text content when transforming to bullet list", () => {
|
||||
const transform = blockTransforms.find((t) => t.id === "bulletList");
|
||||
transform?.transform(editor);
|
||||
|
||||
expect(editor.getText().trim()).toBe("Test content");
|
||||
});
|
||||
|
||||
it("toggles bullet list off when already active", () => {
|
||||
const transform = blockTransforms.find((t) => t.id === "bulletList");
|
||||
transform?.transform(editor);
|
||||
expect(editor.isActive("bulletList")).toBe(true);
|
||||
|
||||
transform?.transform(editor);
|
||||
expect(editor.isActive("bulletList")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Transform to Ordered List", () => {
|
||||
it("transforms paragraph to ordered list", () => {
|
||||
const transform = blockTransforms.find((t) => t.id === "orderedList");
|
||||
transform?.transform(editor);
|
||||
|
||||
expect(editor.isActive("orderedList")).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves text content when transforming to ordered list", () => {
|
||||
const transform = blockTransforms.find((t) => t.id === "orderedList");
|
||||
transform?.transform(editor);
|
||||
|
||||
expect(editor.getText().trim()).toBe("Test content");
|
||||
});
|
||||
|
||||
it("can switch between bullet and ordered list", () => {
|
||||
const bulletTransform = blockTransforms.find((t) => t.id === "bulletList");
|
||||
bulletTransform?.transform(editor);
|
||||
expect(editor.isActive("bulletList")).toBe(true);
|
||||
|
||||
const orderedTransform = blockTransforms.find((t) => t.id === "orderedList");
|
||||
orderedTransform?.transform(editor);
|
||||
expect(editor.isActive("orderedList")).toBe(true);
|
||||
expect(editor.isActive("bulletList")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Transform metadata", () => {
|
||||
it("has all required transform definitions", () => {
|
||||
const expectedIds = [
|
||||
"paragraph",
|
||||
"heading1",
|
||||
"heading2",
|
||||
"heading3",
|
||||
"blockquote",
|
||||
"codeBlock",
|
||||
"bulletList",
|
||||
"orderedList",
|
||||
];
|
||||
|
||||
for (const id of expectedIds) {
|
||||
const transform = blockTransforms.find((t) => t.id === id);
|
||||
expect(transform, `Transform "${id}" should exist`).toBeDefined();
|
||||
expect(transform?.label, `Transform "${id}" should have a label`).toBeTruthy();
|
||||
expect(transform?.icon, `Transform "${id}" should have an icon`).toBeDefined();
|
||||
expect(
|
||||
typeof transform?.transform,
|
||||
`Transform "${id}" should have a transform function`,
|
||||
).toBe("function");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Block Duplicate", () => {
|
||||
let editor: Editor;
|
||||
|
||||
beforeEach(() => {
|
||||
editor = new Editor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2, 3] },
|
||||
}),
|
||||
],
|
||||
content: "<p>First paragraph</p><p>Second paragraph</p>",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("duplicates block and preserves content", () => {
|
||||
// Position cursor in first paragraph
|
||||
editor.commands.setTextSelection(1);
|
||||
|
||||
const { selection } = editor.state;
|
||||
const { $from, $to } = selection;
|
||||
|
||||
// Get the block node at current position
|
||||
const blockStart = $from.start($from.depth);
|
||||
const blockEnd = $to.end($to.depth);
|
||||
|
||||
// Get the content to duplicate
|
||||
const slice = editor.state.doc.slice(blockStart, blockEnd);
|
||||
|
||||
// Insert after current block
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.command(({ tr }) => {
|
||||
tr.insert(blockEnd + 1, slice.content);
|
||||
return true;
|
||||
})
|
||||
.run();
|
||||
|
||||
const json = editor.getJSON();
|
||||
expect(json.content?.length).toBe(3); // Now 3 paragraphs
|
||||
|
||||
// Check content
|
||||
const texts =
|
||||
json.content?.map((block) => {
|
||||
if (block.type === "paragraph" && block.content?.[0]) {
|
||||
return (block.content[0] as { text?: string }).text;
|
||||
}
|
||||
return "";
|
||||
}) ?? [];
|
||||
|
||||
expect(texts[0]).toBe("First paragraph");
|
||||
expect(texts[1]).toBe("First paragraph"); // Duplicated
|
||||
expect(texts[2]).toBe("Second paragraph");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Block Delete", () => {
|
||||
let editor: Editor;
|
||||
|
||||
beforeEach(() => {
|
||||
editor = new Editor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2, 3] },
|
||||
}),
|
||||
],
|
||||
content: "<p>First paragraph</p><p>Second paragraph</p>",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("deletes block at cursor position", () => {
|
||||
// Position cursor in first paragraph
|
||||
editor.commands.setTextSelection(1);
|
||||
|
||||
editor.commands.deleteNode("paragraph");
|
||||
|
||||
const json = editor.getJSON();
|
||||
expect(json.content?.length).toBe(1); // Now 1 paragraph
|
||||
|
||||
// Check remaining content
|
||||
const text =
|
||||
json.content?.[0]?.type === "paragraph" && json.content[0].content?.[0]
|
||||
? (json.content[0].content[0] as { text?: string }).text
|
||||
: "";
|
||||
|
||||
expect(text).toBe("Second paragraph");
|
||||
});
|
||||
});
|
||||
71
packages/admin/tests/lib/api-client.test.ts
Normal file
71
packages/admin/tests/lib/api-client.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { apiFetch, fetchManifest } from "../../src/lib/api/client";
|
||||
|
||||
describe("apiFetch", () => {
|
||||
let fetchSpy: ReturnType<typeof vi.fn>;
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.fn().mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
|
||||
globalThis.fetch = fetchSpy;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("adds X-EmDash-Request header", async () => {
|
||||
await apiFetch("/test");
|
||||
expect(fetchSpy).toHaveBeenCalledOnce();
|
||||
const [, init] = fetchSpy.mock.calls[0]!;
|
||||
const headers = new Headers(init.headers);
|
||||
expect(headers.get("X-EmDash-Request")).toBe("1");
|
||||
});
|
||||
|
||||
it("preserves existing headers", async () => {
|
||||
await apiFetch("/test", { headers: { "Content-Type": "application/json" } });
|
||||
const [, init] = fetchSpy.mock.calls[0]!;
|
||||
const headers = new Headers(init.headers);
|
||||
expect(headers.get("Content-Type")).toBe("application/json");
|
||||
expect(headers.get("X-EmDash-Request")).toBe("1");
|
||||
});
|
||||
|
||||
it("passes through other init options", async () => {
|
||||
await apiFetch("/test", { method: "POST", body: "data" });
|
||||
const [, init] = fetchSpy.mock.calls[0]!;
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.body).toBe("data");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchManifest", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("returns parsed manifest on success", async () => {
|
||||
const manifest = {
|
||||
version: "1.0",
|
||||
collections: {},
|
||||
plugins: {},
|
||||
authMode: "passkey",
|
||||
hash: "abc",
|
||||
};
|
||||
globalThis.fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response(JSON.stringify({ data: manifest }), { status: 200 }));
|
||||
const result = await fetchManifest();
|
||||
expect(result.version).toBe("1.0");
|
||||
expect(result.authMode).toBe("passkey");
|
||||
});
|
||||
|
||||
it("throws on non-ok response", async () => {
|
||||
globalThis.fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response("", { status: 500, statusText: "Internal Server Error" }));
|
||||
await expect(fetchManifest()).rejects.toThrow("Failed to fetch manifest");
|
||||
});
|
||||
});
|
||||
22
packages/admin/tests/lib/api-token-scopes-contract.test.ts
Normal file
22
packages/admin/tests/lib/api-token-scopes-contract.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// Import source (not package `dist`) so the contract tracks edits without a prior `auth` build.
|
||||
import { VALID_SCOPES } from "../../../auth/src/tokens.js";
|
||||
import { API_TOKEN_SCOPE_FORM_SCOPES } from "../../src/components/settings/ApiTokenSettings.js";
|
||||
import { API_TOKEN_SCOPES } from "../../src/lib/api/api-tokens.js";
|
||||
|
||||
function sortedUnique(values: readonly string[]): string[] {
|
||||
return [...new Set(values)].toSorted((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
describe("API token scope drift guard", () => {
|
||||
it("admin wire constants match server VALID_SCOPES", () => {
|
||||
expect(sortedUnique(Object.values(API_TOKEN_SCOPES))).toEqual(sortedUnique(VALID_SCOPES));
|
||||
});
|
||||
|
||||
it("create-token UI lists the same scopes as API_TOKEN_SCOPES", () => {
|
||||
expect(sortedUnique(API_TOKEN_SCOPE_FORM_SCOPES)).toEqual(
|
||||
sortedUnique(Object.values(API_TOKEN_SCOPES)),
|
||||
);
|
||||
});
|
||||
});
|
||||
42
packages/admin/tests/lib/datetime-local.test.ts
Normal file
42
packages/admin/tests/lib/datetime-local.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
fromDatetimeLocalInputValue,
|
||||
toDatetimeLocalInputValue,
|
||||
} from "../../src/lib/datetime-local";
|
||||
|
||||
describe("toDatetimeLocalInputValue", () => {
|
||||
it("returns empty for non-string and empty values", () => {
|
||||
expect(toDatetimeLocalInputValue(undefined)).toBe("");
|
||||
expect(toDatetimeLocalInputValue(null)).toBe("");
|
||||
expect(toDatetimeLocalInputValue(0)).toBe("");
|
||||
expect(toDatetimeLocalInputValue("")).toBe("");
|
||||
});
|
||||
|
||||
it("strips seconds/ms/Z from full ISO 8601", () => {
|
||||
expect(toDatetimeLocalInputValue("2026-02-26T09:30:00.000Z")).toBe("2026-02-26T09:30");
|
||||
});
|
||||
|
||||
it("pads date-only values to UTC midnight", () => {
|
||||
expect(toDatetimeLocalInputValue("2026-02-26")).toBe("2026-02-26T00:00");
|
||||
});
|
||||
|
||||
it("preserves a value already in datetime-local shape", () => {
|
||||
expect(toDatetimeLocalInputValue("2026-02-26T09:30")).toBe("2026-02-26T09:30");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fromDatetimeLocalInputValue", () => {
|
||||
it("returns empty for empty input", () => {
|
||||
expect(fromDatetimeLocalInputValue("")).toBe("");
|
||||
});
|
||||
|
||||
it("appends seconds/ms/Z so the value matches the validator's ISO shape", () => {
|
||||
expect(fromDatetimeLocalInputValue("2026-02-26T09:30")).toBe("2026-02-26T09:30:00.000Z");
|
||||
});
|
||||
|
||||
it("round-trips a stored ISO value without drift", () => {
|
||||
const stored = "2026-02-26T09:30:00.000Z";
|
||||
expect(fromDatetimeLocalInputValue(toDatetimeLocalInputValue(stored))).toBe(stored);
|
||||
});
|
||||
});
|
||||
77
packages/admin/tests/lib/hooks.test.tsx
Normal file
77
packages/admin/tests/lib/hooks.test.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { userEvent } from "@vitest/browser/context";
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import { useStableCallback } from "../../src/lib/hooks";
|
||||
import { render } from "../utils/render.tsx";
|
||||
|
||||
/**
|
||||
* Test component that attaches a keydown listener using useStableCallback.
|
||||
* Verifies that re-renders with new callback identities don't cause
|
||||
* listener churn, and the latest callback is always invoked.
|
||||
*/
|
||||
function KeydownListener({ onEscape }: { onEscape: () => void }) {
|
||||
const stableOnEscape = useStableCallback(onEscape);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") stableOnEscape();
|
||||
};
|
||||
document.addEventListener("keydown", handler);
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, [stableOnEscape]);
|
||||
|
||||
return <div data-testid="listener">Listening</div>;
|
||||
}
|
||||
|
||||
describe("useStableCallback", () => {
|
||||
it("calls the latest callback after re-render with new identity", async () => {
|
||||
const first = vi.fn();
|
||||
const second = vi.fn();
|
||||
|
||||
const screen = await render(<KeydownListener onEscape={first} />);
|
||||
await expect.element(screen.getByTestId("listener")).toBeInTheDocument();
|
||||
|
||||
// First callback should work
|
||||
await userEvent.keyboard("{Escape}");
|
||||
expect(first).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Re-render with a new callback identity
|
||||
await screen.rerender(<KeydownListener onEscape={second} />);
|
||||
|
||||
// Second callback should be called, not the first
|
||||
await userEvent.keyboard("{Escape}");
|
||||
expect(second).toHaveBeenCalledTimes(1);
|
||||
expect(first).toHaveBeenCalledTimes(1); // still just the one from before
|
||||
});
|
||||
|
||||
it("does not add/remove listeners on re-render", async () => {
|
||||
const addSpy = vi.spyOn(document, "addEventListener");
|
||||
const removeSpy = vi.spyOn(document, "removeEventListener");
|
||||
|
||||
const screen = await render(<KeydownListener onEscape={vi.fn()} />);
|
||||
await expect.element(screen.getByTestId("listener")).toBeInTheDocument();
|
||||
|
||||
const addCountAfterMount = addSpy.mock.calls.filter(([type]) => type === "keydown").length;
|
||||
const removeCountAfterMount = removeSpy.mock.calls.filter(
|
||||
([type]) => type === "keydown",
|
||||
).length;
|
||||
|
||||
// Re-render 3 times with different callback identities
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await screen.rerender(<KeydownListener onEscape={vi.fn()} />);
|
||||
}
|
||||
|
||||
const addCountAfterRerenders = addSpy.mock.calls.filter(([type]) => type === "keydown").length;
|
||||
const removeCountAfterRerenders = removeSpy.mock.calls.filter(
|
||||
([type]) => type === "keydown",
|
||||
).length;
|
||||
|
||||
// No new addEventListener/removeEventListener calls for keydown
|
||||
expect(addCountAfterRerenders).toBe(addCountAfterMount);
|
||||
expect(removeCountAfterRerenders).toBe(removeCountAfterMount);
|
||||
|
||||
addSpy.mockRestore();
|
||||
removeSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
150
packages/admin/tests/lib/locales.test.ts
Normal file
150
packages/admin/tests/lib/locales.test.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
getLocaleDir,
|
||||
loadMessages,
|
||||
resolveLocale,
|
||||
SUPPORTED_LOCALES,
|
||||
} from "../../src/locales/index.js";
|
||||
|
||||
for (const { code } of SUPPORTED_LOCALES) {
|
||||
test(`loadMessages resolves catalog for supported locale "${code}"`, async () => {
|
||||
const messages = await loadMessages(code);
|
||||
expect(messages).toBeDefined();
|
||||
expect(typeof messages).toBe("object");
|
||||
expect(Object.keys(messages).length).toBeGreaterThan(0);
|
||||
});
|
||||
}
|
||||
|
||||
test("loadMessages falls back to English for unknown locale", async () => {
|
||||
const [fallback, english] = await Promise.all([loadMessages("xx"), loadMessages("en")]);
|
||||
expect(fallback).toEqual(english);
|
||||
});
|
||||
|
||||
// -- getLocaleDir ----------------------------------------------------------
|
||||
|
||||
describe("getLocaleDir", () => {
|
||||
test("returns 'rtl' for Arabic", () => {
|
||||
expect(getLocaleDir("ar")).toBe("rtl");
|
||||
});
|
||||
|
||||
test("returns 'ltr' for English", () => {
|
||||
expect(getLocaleDir("en")).toBe("ltr");
|
||||
});
|
||||
|
||||
test("returns 'ltr' for locales without explicit dir", () => {
|
||||
expect(getLocaleDir("de")).toBe("ltr");
|
||||
expect(getLocaleDir("fr")).toBe("ltr");
|
||||
expect(getLocaleDir("pt-BR")).toBe("ltr");
|
||||
});
|
||||
|
||||
test("returns 'ltr' for unknown locale", () => {
|
||||
expect(getLocaleDir("xx")).toBe("ltr");
|
||||
});
|
||||
});
|
||||
|
||||
// -- resolveLocale ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a Request with the given headers. Browser environments silently
|
||||
* strip the `cookie` header (forbidden header name) from Request/Headers,
|
||||
* so we override `.get()` to inject it for testing purposes.
|
||||
*/
|
||||
function makeRequest(headers: Record<string, string> = {}): Request {
|
||||
const { cookie, ...rest } = headers;
|
||||
const req = new Request("http://localhost/", { headers: rest });
|
||||
if (cookie) {
|
||||
const original = req.headers.get.bind(req.headers);
|
||||
req.headers.get = (name: string) => (name.toLowerCase() === "cookie" ? cookie : original(name));
|
||||
}
|
||||
return req;
|
||||
}
|
||||
|
||||
describe("resolveLocale", () => {
|
||||
test("returns DEFAULT_LOCALE when no cookie or accept-language", () => {
|
||||
expect(resolveLocale(makeRequest())).toBe(DEFAULT_LOCALE);
|
||||
});
|
||||
|
||||
// Cookie precedence
|
||||
test("returns locale from emdash-locale cookie", () => {
|
||||
expect(resolveLocale(makeRequest({ cookie: "emdash-locale=de" }))).toBe("de");
|
||||
});
|
||||
|
||||
test("ignores cookie with unsupported locale", () => {
|
||||
expect(resolveLocale(makeRequest({ cookie: "emdash-locale=xx" }))).toBe(DEFAULT_LOCALE);
|
||||
});
|
||||
|
||||
test("cookie takes precedence over accept-language", () => {
|
||||
expect(
|
||||
resolveLocale(
|
||||
makeRequest({
|
||||
cookie: "emdash-locale=de",
|
||||
"accept-language": "fr",
|
||||
}),
|
||||
),
|
||||
).toBe("de");
|
||||
});
|
||||
|
||||
// Accept-Language exact match
|
||||
test("matches exact accept-language tag", () => {
|
||||
expect(resolveLocale(makeRequest({ "accept-language": "de" }))).toBe("de");
|
||||
});
|
||||
|
||||
test("matches accept-language with region (pt-BR)", () => {
|
||||
expect(resolveLocale(makeRequest({ "accept-language": "pt-BR" }))).toBe("pt-BR");
|
||||
});
|
||||
|
||||
// Accept-Language case insensitivity (fix for Copilot review #4)
|
||||
test("matches accept-language case-insensitively (pt-br -> pt-BR)", () => {
|
||||
expect(resolveLocale(makeRequest({ "accept-language": "pt-br" }))).toBe("pt-BR");
|
||||
});
|
||||
|
||||
test("matches accept-language case-insensitively (ZH-CN -> zh-CN)", () => {
|
||||
expect(resolveLocale(makeRequest({ "accept-language": "ZH-CN" }))).toBe("zh-CN");
|
||||
});
|
||||
|
||||
test("matches accept-language case-insensitively (DE -> de)", () => {
|
||||
expect(resolveLocale(makeRequest({ "accept-language": "DE" }))).toBe("de");
|
||||
});
|
||||
|
||||
// Accept-Language base language fallback
|
||||
test("falls back to base language (pt-PT -> pt-BR)", () => {
|
||||
expect(resolveLocale(makeRequest({ "accept-language": "pt-PT" }))).toBe("pt-BR");
|
||||
});
|
||||
|
||||
test("matches exact accept-language tag with region (zh-TW)", () => {
|
||||
expect(resolveLocale(makeRequest({ "accept-language": "zh-TW" }))).toBe("zh-TW");
|
||||
});
|
||||
|
||||
test("matches Traditional Chinese script tag (zh-Hant -> zh-TW)", () => {
|
||||
expect(resolveLocale(makeRequest({ "accept-language": "zh-Hant" }))).toBe("zh-TW");
|
||||
});
|
||||
|
||||
test("matches Traditional Chinese script+region tag (zh-Hant-TW -> zh-TW)", () => {
|
||||
expect(resolveLocale(makeRequest({ "accept-language": "zh-Hant-TW" }))).toBe("zh-TW");
|
||||
});
|
||||
|
||||
test("matches Simplified Chinese script tag (zh-Hans -> zh-CN)", () => {
|
||||
expect(resolveLocale(makeRequest({ "accept-language": "zh-Hans" }))).toBe("zh-CN");
|
||||
});
|
||||
// Accept-Language with quality weights
|
||||
test("respects order in accept-language list", () => {
|
||||
expect(resolveLocale(makeRequest({ "accept-language": "fr;q=0.9, de;q=1.0" }))).toBe("fr");
|
||||
});
|
||||
|
||||
test("skips unsupported languages in accept-language list", () => {
|
||||
expect(resolveLocale(makeRequest({ "accept-language": "xx, yy, de" }))).toBe("de");
|
||||
});
|
||||
|
||||
// Malformed input
|
||||
test("handles empty accept-language gracefully", () => {
|
||||
expect(resolveLocale(makeRequest({ "accept-language": "" }))).toBe(DEFAULT_LOCALE);
|
||||
});
|
||||
|
||||
test("handles garbage accept-language gracefully", () => {
|
||||
expect(resolveLocale(makeRequest({ "accept-language": "not-a-real-locale-tag!!!" }))).toBe(
|
||||
DEFAULT_LOCALE,
|
||||
);
|
||||
});
|
||||
});
|
||||
277
packages/admin/tests/lib/marketplace.test.ts
Normal file
277
packages/admin/tests/lib/marketplace.test.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import {
|
||||
searchMarketplace,
|
||||
fetchMarketplacePlugin,
|
||||
installMarketplacePlugin,
|
||||
updateMarketplacePlugin,
|
||||
uninstallMarketplacePlugin,
|
||||
checkPluginUpdates,
|
||||
describeCapability,
|
||||
CAPABILITY_LABELS,
|
||||
} from "../../src/lib/api/marketplace";
|
||||
|
||||
describe("marketplace API client", () => {
|
||||
let fetchSpy: ReturnType<typeof vi.fn>;
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.fn();
|
||||
globalThis.fetch = fetchSpy as typeof globalThis.fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// searchMarketplace
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("searchMarketplace", () => {
|
||||
it("calls correct URL with no params", async () => {
|
||||
fetchSpy.mockResolvedValue(
|
||||
new Response(JSON.stringify({ data: { items: [], nextCursor: undefined } }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
await searchMarketplace();
|
||||
const [url] = fetchSpy.mock.calls[0]!;
|
||||
expect(url).toBe("/_emdash/api/admin/plugins/marketplace");
|
||||
});
|
||||
|
||||
it("appends query params when provided", async () => {
|
||||
fetchSpy.mockResolvedValue(
|
||||
new Response(JSON.stringify({ data: { items: [] } }), { status: 200 }),
|
||||
);
|
||||
await searchMarketplace({ q: "seo", sort: "installs", limit: 10, cursor: "abc" });
|
||||
const [url] = fetchSpy.mock.calls[0]!;
|
||||
expect(url).toContain("q=seo");
|
||||
expect(url).toContain("sort=installs");
|
||||
expect(url).toContain("limit=10");
|
||||
expect(url).toContain("cursor=abc");
|
||||
});
|
||||
|
||||
it("omits undefined params", async () => {
|
||||
fetchSpy.mockResolvedValue(
|
||||
new Response(JSON.stringify({ data: { items: [] } }), { status: 200 }),
|
||||
);
|
||||
await searchMarketplace({ q: "test" });
|
||||
const [url] = fetchSpy.mock.calls[0]!;
|
||||
expect(url).toContain("q=test");
|
||||
expect(url).not.toContain("sort=");
|
||||
expect(url).not.toContain("cursor=");
|
||||
expect(url).not.toContain("limit=");
|
||||
});
|
||||
|
||||
it("includes CSRF header", async () => {
|
||||
fetchSpy.mockResolvedValue(
|
||||
new Response(JSON.stringify({ data: { items: [] } }), { status: 200 }),
|
||||
);
|
||||
await searchMarketplace();
|
||||
const [, init] = fetchSpy.mock.calls[0]!;
|
||||
const headers = new Headers(init.headers);
|
||||
expect(headers.get("X-EmDash-Request")).toBe("1");
|
||||
});
|
||||
|
||||
it("throws on non-ok response", async () => {
|
||||
fetchSpy.mockResolvedValue(
|
||||
new Response("", { status: 503, statusText: "Service Unavailable" }),
|
||||
);
|
||||
await expect(searchMarketplace()).rejects.toThrow("Marketplace search failed");
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// fetchMarketplacePlugin
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("fetchMarketplacePlugin", () => {
|
||||
it("calls correct URL with encoded ID", async () => {
|
||||
fetchSpy.mockResolvedValue(
|
||||
new Response(JSON.stringify({ data: { id: "my-plugin", name: "My Plugin" } }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
await fetchMarketplacePlugin("my-plugin");
|
||||
const [url] = fetchSpy.mock.calls[0]!;
|
||||
expect(url).toBe("/_emdash/api/admin/plugins/marketplace/my-plugin");
|
||||
});
|
||||
|
||||
it("encodes special characters in ID", async () => {
|
||||
fetchSpy.mockResolvedValue(
|
||||
new Response(JSON.stringify({ data: { id: "a/b" } }), { status: 200 }),
|
||||
);
|
||||
await fetchMarketplacePlugin("a/b");
|
||||
const [url] = fetchSpy.mock.calls[0]!;
|
||||
expect(url).toBe("/_emdash/api/admin/plugins/marketplace/a%2Fb");
|
||||
});
|
||||
|
||||
it("throws specific message on 404", async () => {
|
||||
fetchSpy.mockResolvedValue(new Response("", { status: 404 }));
|
||||
await expect(fetchMarketplacePlugin("nonexistent")).rejects.toThrow(
|
||||
'Plugin "nonexistent" not found in marketplace',
|
||||
);
|
||||
});
|
||||
|
||||
it("throws generic message on other errors", async () => {
|
||||
fetchSpy.mockResolvedValue(
|
||||
new Response("", { status: 500, statusText: "Internal Server Error" }),
|
||||
);
|
||||
await expect(fetchMarketplacePlugin("broken")).rejects.toThrow("Failed to fetch plugin");
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// installMarketplacePlugin
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("installMarketplacePlugin", () => {
|
||||
it("POSTs to correct URL", async () => {
|
||||
fetchSpy.mockResolvedValue(new Response("{}", { status: 200 }));
|
||||
await installMarketplacePlugin("my-plugin", { version: "1.0.0" });
|
||||
const [url, init] = fetchSpy.mock.calls[0]!;
|
||||
expect(url).toBe("/_emdash/api/admin/plugins/marketplace/my-plugin/install");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(JSON.parse(init.body)).toEqual({ version: "1.0.0" });
|
||||
});
|
||||
|
||||
it("throws error message from response body", async () => {
|
||||
fetchSpy.mockResolvedValue(
|
||||
new Response(JSON.stringify({ error: { message: "Version conflict" } }), { status: 409 }),
|
||||
);
|
||||
await expect(installMarketplacePlugin("my-plugin")).rejects.toThrow("Version conflict");
|
||||
});
|
||||
|
||||
it("falls back to statusText when body has no message", async () => {
|
||||
fetchSpy.mockResolvedValue(
|
||||
new Response("not json", { status: 500, statusText: "Server Error" }),
|
||||
);
|
||||
await expect(installMarketplacePlugin("my-plugin")).rejects.toThrow(
|
||||
"Failed to install plugin: Server Error",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// updateMarketplacePlugin
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("updateMarketplacePlugin", () => {
|
||||
it("POSTs to plugin update endpoint (not marketplace proxy)", async () => {
|
||||
fetchSpy.mockResolvedValue(new Response("{}", { status: 200 }));
|
||||
await updateMarketplacePlugin("my-plugin", { confirmCapabilities: true });
|
||||
const [url, init] = fetchSpy.mock.calls[0]!;
|
||||
expect(url).toBe("/_emdash/api/admin/plugins/my-plugin/update");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(JSON.parse(init.body)).toEqual({ confirmCapabilities: true });
|
||||
});
|
||||
|
||||
it("throws error message from response body", async () => {
|
||||
fetchSpy.mockResolvedValue(
|
||||
new Response(JSON.stringify({ error: { message: "Capability mismatch" } }), {
|
||||
status: 400,
|
||||
}),
|
||||
);
|
||||
await expect(updateMarketplacePlugin("x")).rejects.toThrow("Capability mismatch");
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// uninstallMarketplacePlugin
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("uninstallMarketplacePlugin", () => {
|
||||
it("POSTs to the uninstall endpoint", async () => {
|
||||
fetchSpy.mockResolvedValue(new Response("{}", { status: 200 }));
|
||||
await uninstallMarketplacePlugin("my-plugin", { deleteData: true });
|
||||
const [url, init] = fetchSpy.mock.calls[0]!;
|
||||
expect(url).toBe("/_emdash/api/admin/plugins/my-plugin/uninstall");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(JSON.parse(init.body)).toEqual({ deleteData: true });
|
||||
});
|
||||
|
||||
it("defaults to empty opts", async () => {
|
||||
fetchSpy.mockResolvedValue(new Response("{}", { status: 200 }));
|
||||
await uninstallMarketplacePlugin("my-plugin");
|
||||
const [, init] = fetchSpy.mock.calls[0]!;
|
||||
expect(JSON.parse(init.body)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// checkPluginUpdates
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("checkPluginUpdates", () => {
|
||||
it("GETs the updates endpoint and returns items", async () => {
|
||||
const updates = [
|
||||
{ pluginId: "a", installed: "1.0.0", latest: "2.0.0", hasCapabilityChanges: true },
|
||||
];
|
||||
fetchSpy.mockResolvedValue(
|
||||
new Response(JSON.stringify({ data: { items: updates } }), { status: 200 }),
|
||||
);
|
||||
const result = await checkPluginUpdates();
|
||||
expect(result).toEqual(updates);
|
||||
const [url] = fetchSpy.mock.calls[0]!;
|
||||
expect(url).toBe("/_emdash/api/admin/plugins/updates");
|
||||
});
|
||||
|
||||
it("throws on non-ok response", async () => {
|
||||
fetchSpy.mockResolvedValue(new Response("", { status: 500, statusText: "Server Error" }));
|
||||
await expect(checkPluginUpdates()).rejects.toThrow("Failed to check for updates");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// describeCapability helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("describeCapability", () => {
|
||||
it("returns known capability label", () => {
|
||||
expect(describeCapability("read:content")).toBe("Read your content");
|
||||
expect(describeCapability("write:media")).toBe("Upload and manage media");
|
||||
});
|
||||
|
||||
it("returns raw capability string for unknown capabilities", () => {
|
||||
expect(describeCapability("custom:something")).toBe("custom:something");
|
||||
});
|
||||
|
||||
it("appends allowed hosts for network:fetch", () => {
|
||||
const result = describeCapability("network:fetch", ["api.example.com", "cdn.example.com"]);
|
||||
expect(result).toBe("Make network requests to: api.example.com, cdn.example.com");
|
||||
});
|
||||
|
||||
it("ignores empty allowed hosts for network:fetch", () => {
|
||||
expect(describeCapability("network:fetch", [])).toBe("Make network requests");
|
||||
expect(describeCapability("network:fetch")).toBe("Make network requests");
|
||||
});
|
||||
|
||||
it("ignores allowed hosts for non-fetch capabilities", () => {
|
||||
expect(describeCapability("read:content", ["example.com"])).toBe("Read your content");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CAPABILITY_LABELS", () => {
|
||||
it("has entries for all known capabilities", () => {
|
||||
expect(Object.keys(CAPABILITY_LABELS)).toEqual([
|
||||
// Canonical
|
||||
"content:read",
|
||||
"content:write",
|
||||
"media:read",
|
||||
"media:write",
|
||||
"users:read",
|
||||
"network:request",
|
||||
"network:request:unrestricted",
|
||||
// Legacy aliases
|
||||
"read:content",
|
||||
"write:content",
|
||||
"read:media",
|
||||
"write:media",
|
||||
"read:users",
|
||||
"network:fetch",
|
||||
"network:fetch:any",
|
||||
]);
|
||||
});
|
||||
});
|
||||
98
packages/admin/tests/lib/taxonomy-match.test.ts
Normal file
98
packages/admin/tests/lib/taxonomy-match.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
foldForMatch,
|
||||
termExactMatches,
|
||||
termMatches,
|
||||
type MatchableTerm,
|
||||
} from "../../src/lib/taxonomy-match.js";
|
||||
|
||||
/**
|
||||
* Tests for the admin picker matcher. The matcher is the sole gate between
|
||||
* an editor's typed input and the "Create new term" escape hatch, so every
|
||||
* branch has real user impact:
|
||||
*
|
||||
* - accent-fold branch: `"Mexico"` must find `"México"` or editors create
|
||||
* a duplicate term and fragment the taxonomy.
|
||||
* - no-match branch: genuinely new terms must still offer Create.
|
||||
* - exact-match branch: governs whether Create is suppressed, so an
|
||||
* accent-insensitive exact match must count.
|
||||
*/
|
||||
|
||||
const mexico: MatchableTerm = { label: "México" };
|
||||
const hongKong: MatchableTerm = { label: "Hong Kong" };
|
||||
|
||||
describe("foldForMatch", () => {
|
||||
it("folds diacritics and case to the same key", () => {
|
||||
expect(foldForMatch("México")).toBe("mexico");
|
||||
expect(foldForMatch("MEXICO")).toBe("mexico");
|
||||
expect(foldForMatch("méxico")).toBe("mexico");
|
||||
});
|
||||
|
||||
it("handles empty input", () => {
|
||||
expect(foldForMatch("")).toBe("");
|
||||
});
|
||||
|
||||
it("leaves non-accented characters unchanged", () => {
|
||||
expect(foldForMatch("USA")).toBe("usa");
|
||||
expect(foldForMatch("Hong Kong")).toBe("hong kong");
|
||||
});
|
||||
|
||||
it("handles precomposed and decomposed forms identically", () => {
|
||||
// Precomposed U+00E9 vs decomposed U+0065 U+0301 both fold to "e".
|
||||
expect(foldForMatch("caf\u00e9")).toBe("cafe");
|
||||
expect(foldForMatch("cafe\u0301")).toBe("cafe");
|
||||
});
|
||||
});
|
||||
|
||||
describe("termMatches", () => {
|
||||
it("matches across the diacritic boundary (regression: Mexico/México)", () => {
|
||||
expect(termMatches(mexico, "Mexico")).toBe(true);
|
||||
expect(termMatches(mexico, "mexico")).toBe(true);
|
||||
expect(termMatches(mexico, "MEX")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches an accented term against an accented query too", () => {
|
||||
expect(termMatches(mexico, "México")).toBe(true);
|
||||
expect(termMatches(mexico, "méx")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match genuinely unrelated input (Create button must still appear)", () => {
|
||||
expect(termMatches(mexico, "Japan")).toBe(false);
|
||||
expect(termMatches(hongKong, "Canada")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for empty input so the dropdown stays closed", () => {
|
||||
expect(termMatches(mexico, "")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects whitespace-only input even when term labels contain spaces", () => {
|
||||
// Regression guard: the label `"Hong Kong"` contains a space,
|
||||
// so a naive `includes(needle)` without a whitespace guard would
|
||||
// match a needle of `" "` and surface every multi-word term.
|
||||
expect(termMatches(hongKong, " ")).toBe(false);
|
||||
expect(termMatches(hongKong, " ")).toBe(false);
|
||||
});
|
||||
|
||||
it("tolerates terms carrying unknown keys without crashing", () => {
|
||||
const term = { label: "Canadá", extra: 42 } as unknown as MatchableTerm;
|
||||
expect(termMatches(term, "Canada")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("termExactMatches", () => {
|
||||
it("treats diacritic-only differences as equal (so Create stays hidden)", () => {
|
||||
expect(termExactMatches(mexico, "Mexico")).toBe(true);
|
||||
expect(termExactMatches(mexico, "México")).toBe(true);
|
||||
});
|
||||
|
||||
it("is stricter than termMatches — substrings do not count as exact", () => {
|
||||
expect(termExactMatches(mexico, "Mex")).toBe(false);
|
||||
expect(termExactMatches(hongKong, "Hong")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for empty or whitespace-only input", () => {
|
||||
expect(termExactMatches(mexico, "")).toBe(false);
|
||||
expect(termExactMatches(mexico, " ")).toBe(false);
|
||||
});
|
||||
});
|
||||
59
packages/admin/tests/lib/url.test.ts
Normal file
59
packages/admin/tests/lib/url.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { sanitizeRedirectUrl } from "../../src/lib/url";
|
||||
|
||||
describe("sanitizeRedirectUrl", () => {
|
||||
it("allows simple relative paths", () => {
|
||||
expect(sanitizeRedirectUrl("/_emdash/admin")).toBe("/_emdash/admin");
|
||||
});
|
||||
|
||||
it("allows deep relative paths", () => {
|
||||
expect(sanitizeRedirectUrl("/_emdash/admin/content/posts")).toBe(
|
||||
"/_emdash/admin/content/posts",
|
||||
);
|
||||
});
|
||||
|
||||
it("allows root path", () => {
|
||||
expect(sanitizeRedirectUrl("/")).toBe("/");
|
||||
});
|
||||
|
||||
it("allows paths with query strings", () => {
|
||||
expect(sanitizeRedirectUrl("/_emdash/admin?tab=settings")).toBe("/_emdash/admin?tab=settings");
|
||||
});
|
||||
|
||||
it("allows paths with hash fragments", () => {
|
||||
expect(sanitizeRedirectUrl("/_emdash/admin#section")).toBe("/_emdash/admin#section");
|
||||
});
|
||||
|
||||
it("rejects absolute http URLs (open redirect)", () => {
|
||||
expect(sanitizeRedirectUrl("https://evil.com/phishing")).toBe("/_emdash/admin");
|
||||
});
|
||||
|
||||
it("rejects absolute http URLs without TLS", () => {
|
||||
expect(sanitizeRedirectUrl("http://evil.com")).toBe("/_emdash/admin");
|
||||
});
|
||||
|
||||
it("rejects protocol-relative URLs (//evil.com)", () => {
|
||||
expect(sanitizeRedirectUrl("//evil.com/phishing")).toBe("/_emdash/admin");
|
||||
});
|
||||
|
||||
it("rejects javascript: scheme (DOM XSS)", () => {
|
||||
expect(sanitizeRedirectUrl("javascript:alert(document.cookie)")).toBe("/_emdash/admin");
|
||||
});
|
||||
|
||||
it("rejects data: scheme", () => {
|
||||
expect(sanitizeRedirectUrl("data:text/html,<script>alert(1)</script>")).toBe("/_emdash/admin");
|
||||
});
|
||||
|
||||
it("rejects backslash trick (/\\evil.com)", () => {
|
||||
expect(sanitizeRedirectUrl("/\\evil.com")).toBe("/_emdash/admin");
|
||||
});
|
||||
|
||||
it("rejects empty string", () => {
|
||||
expect(sanitizeRedirectUrl("")).toBe("/_emdash/admin");
|
||||
});
|
||||
|
||||
it("rejects bare domain", () => {
|
||||
expect(sanitizeRedirectUrl("evil.com")).toBe("/_emdash/admin");
|
||||
});
|
||||
});
|
||||
64
packages/admin/tests/lib/utils.test.ts
Normal file
64
packages/admin/tests/lib/utils.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { cn, slugify } from "../../src/lib/utils";
|
||||
|
||||
describe("slugify", () => {
|
||||
it("converts basic text to slug", () => {
|
||||
expect(slugify("Hello World")).toBe("hello-world");
|
||||
});
|
||||
|
||||
it("handles unicode and diacritics", () => {
|
||||
expect(slugify("café résumé")).toBe("cafe-resume");
|
||||
});
|
||||
|
||||
it("strips special characters", () => {
|
||||
expect(slugify("hello! @world# $")).toBe("hello-world");
|
||||
});
|
||||
|
||||
it("collapses multiple hyphens", () => {
|
||||
expect(slugify("hello---world")).toBe("hello-world");
|
||||
});
|
||||
|
||||
it("trims leading/trailing hyphens", () => {
|
||||
expect(slugify("-hello-world-")).toBe("hello-world");
|
||||
});
|
||||
|
||||
it("handles underscores as separators", () => {
|
||||
expect(slugify("hello_world")).toBe("hello-world");
|
||||
});
|
||||
|
||||
it("returns empty string for empty input", () => {
|
||||
expect(slugify("")).toBe("");
|
||||
});
|
||||
|
||||
it("handles all special characters", () => {
|
||||
expect(slugify("!@#$%")).toBe("");
|
||||
});
|
||||
|
||||
it("handles mixed case", () => {
|
||||
expect(slugify("HeLLo WoRLD")).toBe("hello-world");
|
||||
});
|
||||
|
||||
it("handles multiple spaces", () => {
|
||||
expect(slugify("hello world")).toBe("hello-world");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cn", () => {
|
||||
it("merges class names", () => {
|
||||
expect(cn("foo", "bar")).toBe("foo bar");
|
||||
});
|
||||
|
||||
it("handles conditional classes", () => {
|
||||
const condition = false;
|
||||
expect(cn("foo", condition && "bar", "baz")).toBe("foo baz");
|
||||
});
|
||||
|
||||
it("merges conflicting tailwind classes", () => {
|
||||
expect(cn("p-4", "p-2")).toBe("p-2");
|
||||
});
|
||||
|
||||
it("handles undefined and null", () => {
|
||||
expect(cn("foo", undefined, null, "bar")).toBe("foo bar");
|
||||
});
|
||||
});
|
||||
54
packages/admin/tests/lib/webauthn-environment.test.ts
Normal file
54
packages/admin/tests/lib/webauthn-environment.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
|
||||
import {
|
||||
isPasskeyEnvironmentUsable,
|
||||
isPublicKeyCredentialConstructorAvailable,
|
||||
isWebAuthnSecureContext,
|
||||
} from "../../src/lib/webauthn-environment";
|
||||
|
||||
describe("webauthn-environment", () => {
|
||||
const origPk = globalThis.window.PublicKeyCredential;
|
||||
const desc = Object.getOwnPropertyDescriptor(globalThis.window, "isSecureContext");
|
||||
|
||||
afterEach(() => {
|
||||
if (origPk === undefined) {
|
||||
delete (globalThis.window as { PublicKeyCredential?: unknown }).PublicKeyCredential;
|
||||
} else {
|
||||
Object.defineProperty(globalThis.window, "PublicKeyCredential", {
|
||||
value: origPk,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
if (desc) Object.defineProperty(globalThis.window, "isSecureContext", desc);
|
||||
});
|
||||
|
||||
it("is usable only when secure context and PublicKeyCredential constructor exist", () => {
|
||||
Object.defineProperty(globalThis.window, "isSecureContext", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis.window, "PublicKeyCredential", {
|
||||
value: function PublicKeyCredential() {},
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
expect(isWebAuthnSecureContext()).toBe(true);
|
||||
expect(isPublicKeyCredentialConstructorAvailable()).toBe(true);
|
||||
expect(isPasskeyEnvironmentUsable()).toBe(true);
|
||||
});
|
||||
|
||||
it("is not usable in an insecure context even if PublicKeyCredential is defined", () => {
|
||||
Object.defineProperty(globalThis.window, "isSecureContext", {
|
||||
value: false,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis.window, "PublicKeyCredential", {
|
||||
value: function PublicKeyCredential() {},
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
expect(isWebAuthnSecureContext()).toBe(false);
|
||||
expect(isPasskeyEnvironmentUsable()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { userEvent } from "vitest/browser";
|
||||
|
||||
import { LocaleDirectionProvider } from "../../src/locales/LocaleDirectionProvider.js";
|
||||
import { useLocale } from "../../src/locales/useLocale.js";
|
||||
|
||||
const expectHTMLAttr = (attr: "lang" | "dir", expected: string | null) => {
|
||||
expect(document.documentElement.getAttribute(attr)).toBe(expected);
|
||||
};
|
||||
|
||||
describe("LocaleDirectionProvider", () => {
|
||||
beforeEach(() => {
|
||||
document.documentElement.removeAttribute("dir");
|
||||
document.documentElement.removeAttribute("lang");
|
||||
});
|
||||
|
||||
test("throws error when used without I18nProvider", () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
expect(() => {
|
||||
render(
|
||||
<LocaleDirectionProvider>
|
||||
<div>test</div>
|
||||
</LocaleDirectionProvider>,
|
||||
);
|
||||
}).toThrow();
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("works correctly when wrapped by I18nProvider", () => {
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
|
||||
expect(() => {
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<LocaleDirectionProvider>
|
||||
<div data-testid="content">test</div>
|
||||
</LocaleDirectionProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}).not.toThrow();
|
||||
|
||||
expect(screen.getByTestId("content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("updates document.documentElement.lang attribute and dir attribute for RTL locale", () => {
|
||||
i18n.loadAndActivate({ locale: "ar", messages: {} });
|
||||
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<LocaleDirectionProvider>
|
||||
<div>test</div>
|
||||
</LocaleDirectionProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expectHTMLAttr("lang", "ar");
|
||||
expectHTMLAttr("dir", "rtl");
|
||||
});
|
||||
test("updates document.documentElement.lang and dir attributes when locale changes", async () => {
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
|
||||
const LocaleButton = () => {
|
||||
const { setLocale } = useLocale();
|
||||
|
||||
return (
|
||||
<button type="button" data-testid="locale-button" onClick={() => setLocale("ar")}>
|
||||
Dashing into Arabic
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<LocaleDirectionProvider>
|
||||
<LocaleButton />
|
||||
</LocaleDirectionProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expectHTMLAttr("dir", "ltr");
|
||||
expectHTMLAttr("lang", "en");
|
||||
|
||||
await userEvent.click(screen.getByTestId("locale-button"));
|
||||
|
||||
await waitFor(() => {
|
||||
expectHTMLAttr("dir", "rtl");
|
||||
expectHTMLAttr("lang", "ar");
|
||||
});
|
||||
});
|
||||
});
|
||||
418
packages/admin/tests/router.test.tsx
Normal file
418
packages/admin/tests/router.test.tsx
Normal file
@@ -0,0 +1,418 @@
|
||||
/**
|
||||
* Tests for admin router page components.
|
||||
*
|
||||
* Bug: content created in the wrong locale when using the locale switcher.
|
||||
*
|
||||
* Root cause (two parts):
|
||||
* 1. ContentListPage renders ContentList with `activeLocale` but the "Add New"
|
||||
* <Link> in ContentList does NOT forward `search={{ locale: activeLocale }}` to
|
||||
* the new-content route. The locale is silently dropped on navigation.
|
||||
* 2. ContentNewPage (router.tsx:380) has no `validateSearch` and never reads the
|
||||
* locale from URL search params, so `createContent` is always called without a
|
||||
* locale, defaulting to English regardless of what is configured.
|
||||
*
|
||||
* Fix required in:
|
||||
* packages/admin/src/components/ContentList.tsx – forward locale on Add-New links
|
||||
* packages/admin/src/router.tsx (ContentNewPage) – read locale from search params
|
||||
* and pass it to createContent
|
||||
*/
|
||||
|
||||
import { Toasty } from "@cloudflare/kumo";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider } from "@tanstack/react-router";
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import type { AdminManifest } from "../src/lib/api";
|
||||
import { createAdminRouter } from "../src/router";
|
||||
import { render } from "./utils/render.tsx";
|
||||
import { createTestQueryClient, createMockFetch, waitFor } from "./utils/test-helpers";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component mocks – keep layout plumbing out of these tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock("../src/components/Shell", () => ({
|
||||
Shell: ({ children }: { children: React.ReactNode }) => <div data-testid="shell">{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../src/components/AdminCommandPalette", () => ({
|
||||
AdminCommandPalette: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../src/components/ContentEditor", () => ({
|
||||
ContentEditor: ({
|
||||
item,
|
||||
onSave,
|
||||
onAutosave,
|
||||
}: {
|
||||
item?: { data?: { title?: string }; slug?: string | null };
|
||||
onSave?: (payload: { data: Record<string, unknown> }) => void;
|
||||
onAutosave?: (payload: { data: Record<string, unknown>; slug?: string }) => void;
|
||||
}) => (
|
||||
<div data-testid="content-editor">
|
||||
<div data-testid="mock-title">{item?.data?.title ?? ""}</div>
|
||||
<div data-testid="mock-slug">{item?.slug ?? ""}</div>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSave?.({ data: { title: "Test Post" } });
|
||||
}}
|
||||
>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onAutosave?.({
|
||||
data: { title: "Autosaved Title" },
|
||||
slug: "autosaved-title",
|
||||
})
|
||||
}
|
||||
>
|
||||
Trigger Draft Sync
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MANIFEST: AdminManifest = {
|
||||
version: "1.0.0",
|
||||
hash: "abc123",
|
||||
authMode: "passkey",
|
||||
collections: {
|
||||
posts: {
|
||||
label: "Posts",
|
||||
labelSingular: "Post",
|
||||
supports: ["drafts"],
|
||||
hasSeo: false,
|
||||
fields: {
|
||||
title: { kind: "string", label: "Title" },
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {},
|
||||
taxonomies: [],
|
||||
i18n: {
|
||||
defaultLocale: "fr",
|
||||
locales: ["fr", "en", "de"],
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildRouter() {
|
||||
const queryClient = createTestQueryClient();
|
||||
const router = createAdminRouter(queryClient);
|
||||
if (!i18n.locale) {
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
}
|
||||
// Toasty and I18nProvider are provided by App.tsx in production.
|
||||
// Mirror that here so useLingui() and Toast.useToastManager() work inside page components.
|
||||
function TestApp() {
|
||||
return (
|
||||
<I18nProvider i18n={i18n}>
|
||||
<Toasty>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</Toasty>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
return { router, queryClient, TestApp };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: ContentListPage – locale forwarded to "Add New" link
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ContentListPage – locale forwarding to the new-content route", () => {
|
||||
let mockFetch: ReturnType<typeof createMockFetch>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch = createMockFetch();
|
||||
|
||||
mockFetch
|
||||
.on("GET", "/_emdash/api/manifest", { data: MANIFEST })
|
||||
.on("GET", "/_emdash/api/auth/me", {
|
||||
data: { id: "user_01", role: 60 },
|
||||
})
|
||||
.on("GET", "/_emdash/api/content/posts", {
|
||||
data: { items: [], nextCursor: undefined },
|
||||
})
|
||||
.on("GET", "/_emdash/api/content/posts/trashed", {
|
||||
data: { items: [] },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockFetch.restore();
|
||||
});
|
||||
|
||||
it("Add New link includes the active locale when a non-default locale (de) is selected", async () => {
|
||||
// Navigate to the content list with locale=de selected in the switcher.
|
||||
// The default locale is fr, so de is a non-default locale.
|
||||
// The "Add New" <Link> must carry ?locale=de so that ContentNewPage
|
||||
// receives it and creates content in German, not the default French.
|
||||
const { router, TestApp } = buildRouter();
|
||||
|
||||
await router.navigate({
|
||||
to: "/content/$collection",
|
||||
params: { collection: "posts" },
|
||||
search: { locale: "de" },
|
||||
});
|
||||
|
||||
const screen = await render(<TestApp />);
|
||||
|
||||
const addNewLink = screen.getByRole("link", { name: /add new/i });
|
||||
await expect.element(addNewLink).toBeInTheDocument();
|
||||
|
||||
const href = addNewLink.element().getAttribute("href") ?? "";
|
||||
expect(href).toContain("locale=de");
|
||||
});
|
||||
|
||||
it("Add New link uses the default locale (fr) when no locale is set in the URL", async () => {
|
||||
// Navigate to the content list without an explicit locale param.
|
||||
// activeLocale falls back to the configured defaultLocale ("fr").
|
||||
// The "Add New" <Link> must carry ?locale=fr so that ContentNewPage
|
||||
// creates content in the correct default language.
|
||||
const { router, TestApp } = buildRouter();
|
||||
|
||||
await router.navigate({
|
||||
to: "/content/$collection",
|
||||
params: { collection: "posts" },
|
||||
});
|
||||
|
||||
const screen = await render(<TestApp />);
|
||||
|
||||
const addNewLink = screen.getByRole("link", { name: /add new/i });
|
||||
await expect.element(addNewLink).toBeInTheDocument();
|
||||
|
||||
const href = addNewLink.element().getAttribute("href") ?? "";
|
||||
expect(href).toContain("locale=fr");
|
||||
});
|
||||
|
||||
it("Add New link does not include a locale param when i18n is not configured", async () => {
|
||||
const manifestWithoutI18n: AdminManifest = { ...MANIFEST, i18n: undefined };
|
||||
mockFetch.on("GET", "/_emdash/api/manifest", { data: manifestWithoutI18n });
|
||||
|
||||
const { router, TestApp } = buildRouter();
|
||||
|
||||
await router.navigate({
|
||||
to: "/content/$collection",
|
||||
params: { collection: "posts" },
|
||||
});
|
||||
|
||||
const screen = await render(<TestApp />);
|
||||
|
||||
const addNewLink = screen.getByRole("link", { name: /add new/i });
|
||||
await expect.element(addNewLink).toBeInTheDocument();
|
||||
|
||||
const href = addNewLink.element().getAttribute("href") ?? "";
|
||||
expect(href).not.toContain("locale=");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: ContentNewPage – locale passed to createContent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ContentNewPage – locale passed to createContent", () => {
|
||||
let mockFetch: ReturnType<typeof createMockFetch>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch = createMockFetch();
|
||||
|
||||
mockFetch
|
||||
.on("GET", "/_emdash/api/manifest", { data: MANIFEST })
|
||||
.on("GET", "/_emdash/api/auth/me", {
|
||||
data: { id: "user_01", role: 60 },
|
||||
})
|
||||
.on("GET", "/_emdash/api/bylines", { data: { items: [] } })
|
||||
.on("POST", "/_emdash/api/content/posts", {
|
||||
data: {
|
||||
item: {
|
||||
id: "new_01",
|
||||
type: "posts",
|
||||
slug: null,
|
||||
status: "draft",
|
||||
locale: "de",
|
||||
translationGroup: null,
|
||||
data: { title: "Test Post" },
|
||||
authorId: null,
|
||||
primaryBylineId: null,
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
updatedAt: "2025-01-01T00:00:00Z",
|
||||
publishedAt: null,
|
||||
scheduledAt: null,
|
||||
liveRevisionId: null,
|
||||
draftRevisionId: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockFetch.restore();
|
||||
});
|
||||
|
||||
it("passes locale=de to the API when ?locale=de is in the URL", async () => {
|
||||
// The default locale is fr; navigating with ?locale=de tests that the
|
||||
// non-default locale is read from search params and forwarded to createContent.
|
||||
const { router, TestApp } = buildRouter();
|
||||
|
||||
await router.navigate({
|
||||
to: "/content/$collection/new",
|
||||
params: { collection: "posts" },
|
||||
search: { locale: "de" },
|
||||
});
|
||||
|
||||
const screen = await render(<TestApp />);
|
||||
|
||||
// Wait for the editor to appear (manifest must have loaded)
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: "Save", exact: true }))
|
||||
.toBeInTheDocument();
|
||||
|
||||
// Capture outgoing requests
|
||||
const requests: { url: string; body: unknown }[] = [];
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (input, init) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("/content/posts") && init?.method === "POST") {
|
||||
const body = init.body ? JSON.parse(init.body as string) : null;
|
||||
requests.push({ url, body });
|
||||
}
|
||||
return origFetch(input, init);
|
||||
};
|
||||
|
||||
await screen.getByRole("button", { name: "Save", exact: true }).click();
|
||||
|
||||
globalThis.fetch = origFetch;
|
||||
|
||||
// After the fix: the POST body must include locale: "de"
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(requests[0]!.body).toMatchObject({ locale: "de" });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: ContentEditPage – autosave cache stays in sync
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ContentEditPage – autosave cache patching", () => {
|
||||
let mockFetch: ReturnType<typeof createMockFetch>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch = createMockFetch();
|
||||
|
||||
const manifestWithRevisions: AdminManifest = {
|
||||
...MANIFEST,
|
||||
i18n: undefined,
|
||||
collections: {
|
||||
posts: {
|
||||
...MANIFEST.collections.posts,
|
||||
supports: ["drafts", "revisions"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
mockFetch
|
||||
.on("GET", "/_emdash/api/manifest", { data: manifestWithRevisions })
|
||||
.on("GET", "/_emdash/api/auth/me", {
|
||||
data: { id: "user_01", role: 30 },
|
||||
})
|
||||
.on("GET", "/_emdash/api/bylines", { data: { items: [] } })
|
||||
.on("GET", "/_emdash/api/content/posts/post_1", {
|
||||
data: {
|
||||
item: {
|
||||
id: "post_1",
|
||||
type: "posts",
|
||||
slug: "published-slug",
|
||||
status: "draft",
|
||||
locale: "en",
|
||||
translationGroup: null,
|
||||
data: { title: "Published Title" },
|
||||
authorId: null,
|
||||
primaryBylineId: null,
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
updatedAt: "2025-01-01T00:00:00Z",
|
||||
publishedAt: "2025-01-01T00:00:00Z",
|
||||
scheduledAt: null,
|
||||
liveRevisionId: "rev_live",
|
||||
draftRevisionId: "rev_draft",
|
||||
},
|
||||
},
|
||||
})
|
||||
.on("GET", "/_emdash/api/revisions/rev_draft", {
|
||||
data: {
|
||||
item: {
|
||||
id: "rev_draft",
|
||||
collection: "posts",
|
||||
entryId: "post_1",
|
||||
data: { title: "Draft Title", _slug: "draft-slug" },
|
||||
authorId: null,
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
})
|
||||
.on("PUT", "/_emdash/api/content/posts/post_1", {
|
||||
data: {
|
||||
item: {
|
||||
id: "post_1",
|
||||
type: "posts",
|
||||
slug: "published-slug",
|
||||
status: "draft",
|
||||
locale: "en",
|
||||
translationGroup: null,
|
||||
data: { title: "Published Title" },
|
||||
authorId: null,
|
||||
primaryBylineId: null,
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
updatedAt: "2025-01-02T00:00:00Z",
|
||||
publishedAt: "2025-01-01T00:00:00Z",
|
||||
scheduledAt: null,
|
||||
liveRevisionId: "rev_live",
|
||||
draftRevisionId: "rev_draft",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockFetch.restore();
|
||||
});
|
||||
|
||||
it("keeps the edited draft title and slug after autosave completes", async () => {
|
||||
const { router, TestApp } = buildRouter();
|
||||
|
||||
await router.navigate({
|
||||
to: "/content/$collection/$id",
|
||||
params: { collection: "posts", id: "post_1" },
|
||||
});
|
||||
|
||||
const screen = await render(<TestApp />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mock-title").element().textContent).toBe("Draft Title");
|
||||
expect(screen.getByTestId("mock-slug").element().textContent).toBe("draft-slug");
|
||||
});
|
||||
|
||||
await screen.getByRole("button", { name: "Trigger Draft Sync" }).click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mock-title").element().textContent).toBe("Autosaved Title");
|
||||
expect(screen.getByTestId("mock-slug").element().textContent).toBe("autosaved-title");
|
||||
});
|
||||
});
|
||||
});
|
||||
5
packages/admin/tests/setup.ts
Normal file
5
packages/admin/tests/setup.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import "vitest-browser-react";
|
||||
import { i18n } from "@lingui/core";
|
||||
|
||||
// Initialize i18n for browser tests with empty English messages
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
18
packages/admin/tests/utils/render.tsx
Normal file
18
packages/admin/tests/utils/render.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
import * as React from "react";
|
||||
import { render as baseRender, type ComponentRenderOptions } from "vitest-browser-react";
|
||||
|
||||
type RenderWrapper = ComponentRenderOptions["wrapper"];
|
||||
|
||||
const I18nWrapper = (InnerWrapper: RenderWrapper = React.Fragment) => {
|
||||
return ({ children }: React.PropsWithChildren) => (
|
||||
<I18nProvider i18n={i18n}>
|
||||
<InnerWrapper>{children}</InnerWrapper>
|
||||
</I18nProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const render: typeof baseRender = (ui, { wrapper: UserWrapper, ...options } = {}) => {
|
||||
return baseRender(ui, { ...options, wrapper: I18nWrapper(UserWrapper) });
|
||||
};
|
||||
141
packages/admin/tests/utils/test-helpers.tsx
Normal file
141
packages/admin/tests/utils/test-helpers.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Shared test utilities for admin component tests.
|
||||
*
|
||||
* Provides wrapper components, mock factories, and helpers
|
||||
* for vitest browser mode with React.
|
||||
*/
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
|
||||
import { ThemeProvider } from "../../src/components/ThemeProvider";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const HTTP_METHOD_PREFIX_REGEX = /^(GET|POST|PUT|DELETE|PATCH|ANY) /;
|
||||
|
||||
/**
|
||||
* Create a fresh QueryClient configured for testing.
|
||||
* Disables retries and gcTime for deterministic tests.
|
||||
*/
|
||||
export function createTestQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
gcTime: 0,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper that provides QueryClient + ThemeProvider.
|
||||
* Use with `render(<Comp />, { wrapper: TestWrapper })`.
|
||||
*/
|
||||
export function TestWrapper({ children }: { children: React.ReactNode }) {
|
||||
const queryClient = React.useMemo(() => createTestQueryClient(), []);
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider defaultTheme="light">{children}</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper that provides just QueryClient (no theme).
|
||||
*/
|
||||
export function QueryWrapper({ children }: { children: React.ReactNode }) {
|
||||
const queryClient = React.useMemo(() => createTestQueryClient(), []);
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock fetch interceptor for testing API calls.
|
||||
*
|
||||
* Usage:
|
||||
* const mockFetch = createMockFetch();
|
||||
* mockFetch.on("GET", "/_emdash/api/manifest", { version: "1.0" });
|
||||
* // ... render component that fetches manifest ...
|
||||
* mockFetch.restore();
|
||||
*/
|
||||
export function createMockFetch() {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const handlers = new Map<string, { status: number; body: unknown }>();
|
||||
|
||||
function mockFetchFn(input: string | URL | Request, init?: RequestInit): Promise<Response> {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
const method = init?.method ?? "GET";
|
||||
const key = `${method.toUpperCase()} ${url}`;
|
||||
|
||||
// Check for exact match first
|
||||
let handler = handlers.get(key);
|
||||
|
||||
// Then check for URL-only match (any method)
|
||||
if (!handler) {
|
||||
handler = handlers.get(`ANY ${url}`);
|
||||
}
|
||||
|
||||
// Check for prefix matches
|
||||
if (!handler) {
|
||||
for (const [pattern, h] of handlers) {
|
||||
const patternUrl = pattern.replace(HTTP_METHOD_PREFIX_REGEX, "");
|
||||
if (url.startsWith(patternUrl)) {
|
||||
handler = h;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (handler) {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(handler.body), {
|
||||
status: handler.status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Fall through to real fetch for unmatched
|
||||
return originalFetch(input, init);
|
||||
}
|
||||
|
||||
globalThis.fetch = mockFetchFn as typeof fetch;
|
||||
|
||||
return {
|
||||
on(method: string, url: string, body: unknown, status = 200) {
|
||||
handlers.set(`${method.toUpperCase()} ${url}`, { status, body });
|
||||
return this;
|
||||
},
|
||||
restore() {
|
||||
globalThis.fetch = originalFetch;
|
||||
},
|
||||
clear() {
|
||||
handlers.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a condition to be true, with retry.
|
||||
*/
|
||||
export async function waitFor(fn: () => boolean | void, timeout = 2000): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeout) {
|
||||
try {
|
||||
const result = fn();
|
||||
if (result !== false) return;
|
||||
} catch {
|
||||
// retry
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
// Final attempt that throws
|
||||
fn();
|
||||
}
|
||||
Reference in New Issue
Block a user