Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ exports[`Page detail page > matches the page-head snapshot 1`] = `
</p>
</div>
<div
class="flex gap-2"
class="flex items-center gap-3"
>
<a
class="inline-flex items-center justify-center gap-[6px] whitespace-nowrap rounded-md font-display font-bold transition-shadow duration-[160ms] ease-brand focus-visible:outline-none focus-visible:shadow-focus disabled:pointer-events-none disabled:opacity-50 bg-paper-2 text-ink border border-border shadow-xs hover:bg-paper-3 hover:border-border-strong px-4 py-[9px] text-sm"
Expand All @@ -64,6 +64,7 @@ exports[`Page detail page > matches the page-head snapshot 1`] = `
</a>
<button
class="inline-flex items-center justify-center gap-[6px] whitespace-nowrap rounded-md font-display font-bold transition-shadow duration-[160ms] ease-brand focus-visible:outline-none focus-visible:shadow-focus disabled:pointer-events-none disabled:opacity-50 bg-emerald text-emerald-ink border border-emerald shadow-xs hover:bg-emerald-deep hover:text-paper hover:border-emerald-deep px-4 py-[9px] text-sm"
data-testid="page-save"
>
<svg
aria-hidden="true"
Expand Down
119 changes: 115 additions & 4 deletions apps/admin/src/app/(authenticated)/pages/[id]/page.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
/**
* Page detail tests — sibling of posts/[id]/page.test.tsx.
* Page detail tests — sibling of posts/[id]/page.test.tsx (issue #506).
*
* Pins:
* • Italic-accent headline ("Edit *page*.")
* • Inspector sidebar with the canonical panels
* • Save handler PATCHes the API with the right body and surfaces
* a success pip on 2xx / an inline error on ApiError.
*/
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import {
describe,
expect,
it,
vi,
beforeEach,
} from 'vitest';
import { render, screen, fireEvent, act } from '@testing-library/react';

vi.mock('next/navigation', () => ({
useParams: () => ({ id: 'about' }),
Expand All @@ -16,7 +28,27 @@ vi.mock('next/navigation', () => ({
useSearchParams: () => new URLSearchParams(),
}));

const apiPatchMock = vi.fn();
vi.mock('@/lib/api-client', async () => {
const actual =
await vi.importActual<typeof import('@/lib/api-client')>(
'@/lib/api-client',
);
return {
...actual,
api: {
...actual.api,
patch: (...args: unknown[]) => apiPatchMock(...args),
},
};
});

import PageDetailPage from './page';
import { ApiError } from '@/lib/api-client';

beforeEach(() => {
apiPatchMock.mockReset();
});

describe('Page detail page', () => {
it('renders the italic-accent headline', () => {
Expand All @@ -32,7 +64,9 @@ describe('Page detail page', () => {
screen.getByLabelText('Page metadata inspector'),
).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Status' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Metadata' })).toBeInTheDocument();
expect(
screen.getByRole('heading', { name: 'Metadata' }),
).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /SEO/i })).toBeInTheDocument();
});

Expand All @@ -42,6 +76,83 @@ describe('Page detail page', () => {
expect(back).toHaveAttribute('href', '/pages');
});

it('PATCHes /api/v1/posts/{id} with the metadata body on save', async () => {
apiPatchMock.mockResolvedValueOnce({});
render(<PageDetailPage />);

// Title input has a default of "Untitled page" — flip it so we
// can assert the body carries the operator's edit verbatim.
fireEvent.change(screen.getByLabelText(/^Title$/), {
target: { value: 'About Us' },
});

await act(async () => {
fireEvent.click(screen.getByTestId('page-save'));
});

// URL is /api/v1/posts/{encodedId} because pages live in the
// posts table and the dedicated /api/v1/pages mount isn't wired.
expect(apiPatchMock).toHaveBeenCalledWith(
'/api/v1/posts/about',
expect.objectContaining({
title: 'About Us',
// The default status is "draft" and the API speaks "draft" —
// no mapping needed for that arm, but explicit here so the
// contract is pinned.
status: 'draft',
}),
);

// Success indicator surfaces.
expect(screen.getByTestId('page-saved')).toBeInTheDocument();
});

it('normalises the publish status to the API "published" label', async () => {
apiPatchMock.mockResolvedValueOnce({});
render(<PageDetailPage />);

fireEvent.change(screen.getByLabelText(/Change to/i), {
target: { value: 'publish' },
});
await act(async () => {
fireEvent.click(screen.getByTestId('page-save'));
});

expect(apiPatchMock).toHaveBeenCalledWith(
'/api/v1/posts/about',
expect.objectContaining({ status: 'published' }),
);
});

it('renders the ApiError detail when the save rejects', async () => {
apiPatchMock.mockRejectedValueOnce(
new ApiError(412, 'Precondition Failed', null),
);
render(<PageDetailPage />);

await act(async () => {
fireEvent.click(screen.getByTestId('page-save'));
});

const banner = screen.getByTestId('page-save-error');
expect(banner).toHaveTextContent(/HTTP 412/);
// No success pip on error.
expect(screen.queryByTestId('page-saved')).not.toBeInTheDocument();
});

it('renders a generic error message when the rejection is not an ApiError', async () => {
apiPatchMock.mockRejectedValueOnce(new Error('network down'));
render(<PageDetailPage />);

await act(async () => {
fireEvent.click(screen.getByTestId('page-save'));
});

expect(screen.getByTestId('page-save-error')).toHaveTextContent(
/network down/,
);
});

it('matches the page-head snapshot', () => {
const { container } = render(<PageDetailPage />);
const head = container.querySelector('[data-testid="page-detail"] > div');
Expand Down
128 changes: 109 additions & 19 deletions apps/admin/src/app/(authenticated)/pages/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,38 @@
/**
* Page detail / edit-metadata — sibling of posts/[id].
* Page detail / edit-metadata — sibling of posts/[id] (issue #506).
*
* Pages share the post-type infrastructure (docs/05-admin-api.md §3.1)
* but the metadata surface trims the bits that don't apply to
* evergreen content (no scheduling-as-publication, no category
* taxonomy by default). The brand surface stays identical so the IA
* is predictable.
* evergreen content (no scheduling-as-publication by default, no
* category taxonomy).
*
* The block editor for pages opens via the same per-resource route
* (/pages/[id]/edit); this page is intentionally the metadata-only
* view so editors can quickly toggle visibility, change the URL, or
* tweak SEO without entering edit mode.
* Save path: PATCH /api/v1/posts/{id} with `title`, `slug`, `status`.
* Pages are stored as posts with post_type='page' in the same table,
* so the canonical CRUD endpoint is `/api/v1/posts/{id}` — there is
* no separate `/api/v1/pages/{id}` mount yet.
*
* The block editor is the next iteration (the "Block editor — coming
* soon" button stays disabled until the dedicated edit route ships).
* This screen owns the title/slug/status/excerpt write — enough to
* make the Pages module functional for operators.
*/
'use client';

import type { ReactElement } from 'react';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import {
Calendar,
ChevronLeft,
Check,
Eye,
Globe,
Loader2,
Save,
User,
} from 'lucide-react';
import { ApiError, api } from '@/lib/api-client';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Headline } from '@/components/ui/headline';
Expand All @@ -34,13 +41,74 @@ import { Label } from '@/components/ui/label';

type PageStatus = 'draft' | 'publish' | 'private';

/**
* Wire body for the PATCH. The API speaks `published` (past tense)
* not `publish` — we normalise at the boundary so the UI keeps the
* WP-classic label set the rest of the admin uses. The field set is
* the minimum the metadata surface owns; content_blocks lives on the
* dedicated editor route (not built yet).
*/
interface UpdatePageBody {
title?: string;
slug?: string;
status?: 'draft' | 'published' | 'private';
excerpt?: string;
}

/** Map the local UI status onto the API's published/draft/private. */
function toApiStatus(s: PageStatus): UpdatePageBody['status'] {
if (s === 'publish') return 'published';
if (s === 'private') return 'private';
return 'draft';
}

export default function PageDetailPage(): ReactElement {
const params = useParams<{ id: string }>();
const pageId = params?.id ?? 'new';

const [title, setTitle] = useState('Untitled page');
const [slug, setSlug] = useState('/untitled-page');
const [status, setStatus] = useState<PageStatus>('draft');
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [savedAt, setSavedAt] = useState<number | null>(null);

// The "Saved" pip auto-clears after a few seconds so it doesn't
// shout at the user forever. A timer ref isn't necessary because
// re-saves replace `savedAt` and the effect cleanup cancels the
// pending timeout cleanly.
useEffect(() => {
if (savedAt === null) return;
const handle = setTimeout(() => setSavedAt(null), 3000);
return () => clearTimeout(handle);
}, [savedAt]);

const onSave = async (): Promise<void> => {
setSaving(true);
setSaveError(null);
try {
const body: UpdatePageBody = {
title,
slug,
status: toApiStatus(status),
};
await api.patch<unknown>(
`/api/v1/posts/${encodeURIComponent(pageId)}`,
body,
);
setSavedAt(Date.now());
} catch (err) {
if (err instanceof ApiError) {
setSaveError(
`Couldn't save (HTTP ${err.status} ${err.statusText}).`,
);
} else {
setSaveError(err instanceof Error ? err.message : 'Save failed.');
}
} finally {
setSaving(false);
}
};

return (
<section data-testid="page-detail" className="flex flex-col gap-6">
Expand All @@ -62,22 +130,44 @@ export default function PageDetailPage(): ReactElement {
<span className="font-mono text-xs">#{pageId}</span>
</p>
</div>
<div className="flex gap-2">
<div className="flex items-center gap-3">
{savedAt !== null ? (
<span
role="status"
data-testid="page-saved"
className="inline-flex items-center gap-1 text-xs font-medium text-emerald-deep"
>
<Check aria-hidden="true" width={13} height={13} />
Saved
</span>
) : null}
<Button variant="default" asChild>
<Link href="/pages">Cancel</Link>
</Button>
<Button
variant="emerald"
onClick={() => {
// eslint-disable-next-line no-console
console.log('[page-detail] save', { pageId, title, slug, status });
}}
onClick={() => void onSave()}
disabled={saving}
data-testid="page-save"
>
<Save aria-hidden="true" width={14} height={14} />
Save changes
{saving ? (
<Loader2 aria-hidden="true" width={14} height={14} className="animate-spin" />
) : (
<Save aria-hidden="true" width={14} height={14} />
)}
{saving ? 'Saving…' : 'Save changes'}
</Button>
</div>
</div>
{saveError ? (
<p
role="alert"
data-testid="page-save-error"
className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900"
>
{saveError}
</p>
) : null}
</div>

<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_320px]">
Expand Down Expand Up @@ -185,7 +275,7 @@ export default function PageDetailPage(): ReactElement {
Created
</span>
<span className="font-mono text-xs text-ink-soft">
2026-04-02 11:30
</span>
</li>
<li className="flex items-center justify-between">
Expand All @@ -194,7 +284,7 @@ export default function PageDetailPage(): ReactElement {
Updated
</span>
<span className="font-mono text-xs text-ink-soft">
3 days ago
</span>
</li>
<li className="flex items-center justify-between">
Expand All @@ -209,7 +299,7 @@ export default function PageDetailPage(): ReactElement {
<User aria-hidden="true" width={13} height={13} />
Author
</span>
<span className="text-xs text-ink-soft">Mara Wills</span>
<span className="text-xs text-ink-soft"></span>
</li>
</ul>
</div>
Expand Down
Loading