import type { Editor } from "@tiptap/react"; import * as React from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import type { ContentEditorProps } from "../../src/components/ContentEditor"; import { ContentSettingsPanel, SettingsActionBar, type ContentSettingsPanelProps, type SettingsActionBarProps, } from "../../src/components/ContentSettingsPanel"; import type { BlockSidebarPanel } from "../../src/components/PortableTextEditor"; import type { ContentItem } from "../../src/lib/api"; import { render } from "../utils/render.tsx"; // Mock child components with their own data fetching so the panel tests // only assert section visibility, not child behaviour. vi.mock("../../src/components/RevisionHistory", () => ({ RevisionHistory: () =>
Revision History
, })); vi.mock("../../src/components/TaxonomySidebar", () => ({ TaxonomySidebar: () =>
Taxonomy
, })); vi.mock("../../src/components/editor/DocumentOutline", () => ({ DocumentOutline: () =>
Outline
, })); vi.mock("../../src/components/editor/ImageDetailPanel", () => ({ ImageDetailPanel: () =>
Image details
, })); vi.mock("../../src/components/SeoPanel", () => ({ SeoPanel: () =>
SEO fields
, })); vi.mock("@tanstack/react-router", async () => { const actual = await vi.importActual("@tanstack/react-router"); return { ...actual, useNavigate: () => vi.fn(), Link: ({ children, ...props }: any) => {children}, }; }); vi.mock("../../src/lib/api", async () => { const actual = await vi.importActual("../../src/lib/api"); return { ...actual, fetchBylines: vi.fn(async () => ({ items: [], nextCursor: null })), }; }); function makeItem(overrides: Partial = {}): ContentItem { return { id: "item-1", type: "posts", slug: "my-post", status: "draft", data: { title: "My Post" }, authorId: null, createdAt: "2025-01-15T10:30:00Z", updatedAt: "2025-01-15T10:30:00Z", publishedAt: null, scheduledAt: null, liveRevisionId: null, draftRevisionId: null, ...overrides, }; } const EDITOR_ROLE: NonNullable = { id: "u1", role: 40 }; const AUTHOR_ROLE: NonNullable = { id: "u2", role: 20 }; const USERS = [ { id: "u1", name: "Editor One", email: "editor@example.com", role: 40 }, ] as ContentSettingsPanelProps["users"]; function makePanelProps( overrides: Partial = {}, ): ContentSettingsPanelProps { return { collection: "posts", item: makeItem(), isNew: false, slug: "my-post", onSlugChange: vi.fn(), status: "draft", supportsDrafts: true, isLive: false, hasPendingChanges: false, hasSchedule: false, supportsRevisions: true, canSchedule: false, onDelete: vi.fn(), currentUser: EDITOR_ROLE, users: USERS, onAuthorChange: vi.fn(), activeBylines: [], availableBylines: [], availableBylinesLoaded: true, onBylinesChange: vi.fn(), i18n: { defaultLocale: "en", locales: ["en", "ar"] }, translations: [], onTranslate: vi.fn(), hasSeo: true, onSeoChange: vi.fn(), portableTextEditor: {} as Editor, blockSidebarPanel: null, onBlockSidebarClose: vi.fn(), onBlockSidebarDelete: vi.fn(), ...overrides, }; } describe("ContentSettingsPanel", () => { beforeEach(() => { vi.clearAllMocks(); }); it("renders all eight sections when every capability is enabled", async () => { const screen = await render(); await expect.element(screen.getByRole("heading", { name: "Publish" })).toBeInTheDocument(); await expect.element(screen.getByRole("heading", { name: "Ownership" })).toBeInTheDocument(); await expect.element(screen.getByRole("heading", { name: "Bylines" })).toBeInTheDocument(); await expect.element(screen.getByRole("heading", { name: "Translations" })).toBeInTheDocument(); await expect.element(screen.getByTestId("taxonomy-sidebar")).toBeInTheDocument(); await expect.element(screen.getByRole("heading", { name: "SEO" })).toBeInTheDocument(); await expect.element(screen.getByTestId("doc-outline")).toBeInTheDocument(); await expect.element(screen.getByTestId("revision-history")).toBeInTheDocument(); await expect.element(screen.getByRole("button", { name: "Move to Trash" })).toBeInTheDocument(); }); it("hides Ownership and Bylines for users below the editor role", async () => { const screen = await render( , ); await expect.element(screen.getByRole("heading", { name: "Publish" })).toBeInTheDocument(); expect(screen.container.textContent).not.toContain("Ownership"); expect(screen.container.textContent).not.toContain("Bylines"); }); it("hides capability-gated sections when their flags are off", async () => { const screen = await render( , ); await expect.element(screen.getByRole("heading", { name: "Publish" })).toBeInTheDocument(); expect(screen.container.querySelector('[data-testid="seo-panel"]')).toBeNull(); expect(screen.container.querySelector('[data-testid="revision-history"]')).toBeNull(); expect(screen.container.querySelector('[data-testid="doc-outline"]')).toBeNull(); expect(screen.container.textContent).not.toContain("Translations"); }); it("hides item-dependent sections for new entries", async () => { const screen = await render( , ); await expect.element(screen.getByRole("heading", { name: "Publish" })).toBeInTheDocument(); // No trash, no translations, no taxonomies, no SEO, no revisions for new items expect(screen.container.textContent).not.toContain("Move to Trash"); expect(screen.container.textContent).not.toContain("Translations"); expect(screen.container.querySelector('[data-testid="taxonomy-sidebar"]')).toBeNull(); expect(screen.container.querySelector('[data-testid="seo-panel"]')).toBeNull(); expect(screen.container.querySelector('[data-testid="revision-history"]')).toBeNull(); }); it("renders the block detail panel instead of settings when a block requests the sidebar", async () => { const blockPanel: BlockSidebarPanel = { type: "image", attrs: {}, onUpdate: vi.fn(), onReplace: vi.fn(), onDelete: vi.fn(), onClose: vi.fn(), }; const screen = await render( , ); await expect.element(screen.getByTestId("image-detail-panel")).toBeInTheDocument(); expect(screen.container.textContent).not.toContain("Publish"); expect(screen.container.textContent).not.toContain("Move to Trash"); }); it("keeps Move to Trash as the last section", async () => { const screen = await render(); const root = screen.container.firstElementChild; const lastSection = root?.lastElementChild; expect(lastSection?.textContent).toContain("Move to Trash"); }); }); function makeBarProps(overrides: Partial = {}): SettingsActionBarProps { return { isNew: false, isDirty: false, isSaving: false, isLive: false, hasPendingChanges: false, liveViewUrl: null, supportsPreview: false, isLoadingPreview: false, onPreview: vi.fn(), onPublish: vi.fn(), onUnpublish: vi.fn(), announceSaveStatus: true, ...overrides, }; } describe("SettingsActionBar", () => { beforeEach(() => { vi.clearAllMocks(); }); it("shows Publish for an unpublished draft", async () => { const screen = await render(); await expect.element(screen.getByRole("button", { name: "Publish" })).toBeInTheDocument(); expect(screen.container.textContent).not.toContain("Unpublish"); }); it("shows Publish changes for a live item with edits", async () => { const props = makeBarProps({ isLive: true, hasPendingChanges: true }); const screen = await render(); const publishChanges = screen.getByRole("button", { name: "Publish changes" }); await expect.element(publishChanges).toBeInTheDocument(); await publishChanges.click(); expect(props.onPublish).toHaveBeenCalled(); }); it("shows Unpublish for a clean live item", async () => { const props = makeBarProps({ isLive: true }); const screen = await render(); const unpublish = screen.getByRole("button", { name: "Unpublish" }); await expect.element(unpublish).toBeInTheDocument(); await unpublish.click(); expect(props.onUnpublish).toHaveBeenCalled(); }); it("renders Live View when a live URL is provided", async () => { const screen = await render( , ); const link = screen.getByRole("link", { name: /Live View/ }); await expect.element(link).toBeInTheDocument(); await expect.element(link).toHaveAttribute("href", "https://example.com/my-post"); }); it("renders Preview when preview is supported", async () => { const props = makeBarProps({ supportsPreview: true, hasPendingChanges: true }); const screen = await render(); const preview = screen.getByRole("button", { name: "Preview draft" }); await expect.element(preview).toBeInTheDocument(); await preview.click(); expect(props.onPreview).toHaveBeenCalled(); }); it("hides the publish cluster for new items", async () => { const screen = await render(); expect(screen.container.textContent).not.toContain("Publish"); expect(screen.container.textContent).not.toContain("Unpublish"); // Save is still available await expect.element(screen.getByRole("button", { name: /Save/ })).toBeInTheDocument(); }); it("shows autosave progress in the Save button", async () => { const screen = await render( , ); await expect.element(screen.getByRole("button", { name: "Saving..." })).toBeDisabled(); expect(screen.getByRole("status").element().textContent).toBe("Saving..."); }); it("can suppress its live region when another mounted copy announces status", async () => { const screen = await render( , ); expect(screen.container.querySelector('span[role="status"][aria-live="polite"]')).toBeNull(); }); it("marks the Save button dirty state", async () => { const screen = await render(); await expect .element(screen.getByRole("button", { name: "Save", exact: true })) .toBeInTheDocument(); }); });