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