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(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 (
{placeholder}
); }, })); vi.mock("../../src/components/RevisionHistory", () => ({ RevisionHistory: () =>
Revision History
, })); vi.mock("../../src/components/TaxonomySidebar", () => ({ TaxonomySidebar: () =>
Taxonomy
, })); vi.mock("../../src/components/MediaPickerModal", () => ({ MediaPickerModal: () => null, })); vi.mock("../../src/components/editor/DocumentOutline", () => ({ DocumentOutline: () =>
Outline
, })); vi.mock("@tanstack/react-router", async () => { const actual = await vi.importActual("@tanstack/react-router"); return { ...actual, Link: ({ children, ...props }: any) => {children}, }; }); 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 = { 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 { 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 = {}) { const defaultProps: ContentEditorProps = { collection: "posts", collectionLabel: "Post", fields: defaultFields, isNew: true, onSave: vi.fn(), ...props, }; return render(); } 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 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(); const titleInput = screen.getByLabelText("Title"); await titleInput.fill("Updated title"); await vi.advanceTimersByTimeAsync(2000); expect(onAutosave).toHaveBeenCalledTimes(1); await screen.rerender(); const autosavedItem = makeItem({ updatedAt: "2026-04-12T18:38:00Z", data: { title: "Updated title", body: "Some content" }, }); await screen.rerender( , ); 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 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 = { 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 ( ); } const screen = await render(); // 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(); 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 = { 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 ( ); } const screen = await render(); 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(); 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); }); }); });