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:
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user