@actuate-media/cms-admin 0.16.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/__tests__/lib/page-editor-service-api.test.d.ts +2 -0
  2. package/dist/__tests__/lib/page-editor-service-api.test.d.ts.map +1 -0
  3. package/dist/__tests__/lib/page-editor-service-api.test.js +155 -0
  4. package/dist/__tests__/lib/page-editor-service-api.test.js.map +1 -0
  5. package/dist/__tests__/lib/pages-service-fetch.test.d.ts +2 -0
  6. package/dist/__tests__/lib/pages-service-fetch.test.d.ts.map +1 -0
  7. package/dist/__tests__/lib/pages-service-fetch.test.js +125 -0
  8. package/dist/__tests__/lib/pages-service-fetch.test.js.map +1 -0
  9. package/dist/__tests__/lib/useApiData.test.js +28 -1
  10. package/dist/__tests__/lib/useApiData.test.js.map +1 -1
  11. package/dist/__tests__/views/page-section-editor.test.d.ts +2 -0
  12. package/dist/__tests__/views/page-section-editor.test.d.ts.map +1 -0
  13. package/dist/__tests__/views/page-section-editor.test.js +56 -0
  14. package/dist/__tests__/views/page-section-editor.test.js.map +1 -0
  15. package/dist/__tests__/views/pages-list-view.test.d.ts +2 -0
  16. package/dist/__tests__/views/pages-list-view.test.d.ts.map +1 -0
  17. package/dist/__tests__/views/pages-list-view.test.js +102 -0
  18. package/dist/__tests__/views/pages-list-view.test.js.map +1 -0
  19. package/dist/actuate-admin.css +1 -1
  20. package/dist/lib/page-editor-service.d.ts +1 -0
  21. package/dist/lib/page-editor-service.d.ts.map +1 -1
  22. package/dist/lib/page-editor-service.js +55 -0
  23. package/dist/lib/page-editor-service.js.map +1 -1
  24. package/dist/lib/pages-service.d.ts +26 -0
  25. package/dist/lib/pages-service.d.ts.map +1 -1
  26. package/dist/lib/pages-service.js +65 -38
  27. package/dist/lib/pages-service.js.map +1 -1
  28. package/dist/lib/useApiData.d.ts +11 -0
  29. package/dist/lib/useApiData.d.ts.map +1 -1
  30. package/dist/lib/useApiData.js +41 -7
  31. package/dist/lib/useApiData.js.map +1 -1
  32. package/dist/views/Dashboard.d.ts.map +1 -1
  33. package/dist/views/Dashboard.js +54 -48
  34. package/dist/views/Dashboard.js.map +1 -1
  35. package/dist/views/Pages/PagesListView.d.ts.map +1 -1
  36. package/dist/views/Pages/PagesListView.js +71 -46
  37. package/dist/views/Pages/PagesListView.js.map +1 -1
  38. package/dist/views/page-editor/PagePreview.d.ts.map +1 -1
  39. package/dist/views/page-editor/PagePreview.js +10 -1
  40. package/dist/views/page-editor/PagePreview.js.map +1 -1
  41. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  42. package/dist/views/page-editor/PageSectionEditor.js +22 -2
  43. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  44. package/package.json +2 -2
  45. package/src/__tests__/lib/page-editor-service-api.test.ts +180 -0
  46. package/src/__tests__/lib/pages-service-fetch.test.ts +160 -0
  47. package/src/__tests__/lib/useApiData.test.ts +36 -1
  48. package/src/__tests__/views/page-section-editor.test.tsx +65 -0
  49. package/src/__tests__/views/pages-list-view.test.tsx +119 -0
  50. package/src/lib/page-editor-service.ts +61 -0
  51. package/src/lib/pages-service.ts +88 -35
  52. package/src/lib/useApiData.ts +52 -7
  53. package/src/styles/theme.css +37 -0
  54. package/src/views/Dashboard.tsx +77 -61
  55. package/src/views/Pages/PagesListView.tsx +113 -82
  56. package/src/views/page-editor/PagePreview.tsx +10 -1
  57. package/src/views/page-editor/PageSectionEditor.tsx +25 -1
@@ -0,0 +1,180 @@
1
+ // @vitest-environment node
2
+ import { afterEach, describe, expect, it, vi } from 'vitest'
3
+
4
+ // Mock the network layer so we can assert on request shape (endpoint,
5
+ // method, body) and drive responses. This is the class of bug — response
6
+ // unwrapping and write-payload drift — these tests exist to catch.
7
+ const cmsApi = vi.fn()
8
+ vi.mock('../../lib/api.js', () => ({
9
+ cmsApi: (...args: unknown[]) => cmsApi(...args),
10
+ }))
11
+
12
+ const { fetchPageForEditor, savePageDraft, publishPage, missingPageFields, emptyPage } =
13
+ await import('../../lib/page-editor-service.js')
14
+
15
+ afterEach(() => {
16
+ cmsApi.mockReset()
17
+ })
18
+
19
+ function draft(id: string | null) {
20
+ return {
21
+ ...emptyPage(),
22
+ id,
23
+ title: 'About',
24
+ slug: 'about',
25
+ path: '/about',
26
+ seoTitle: 'About us',
27
+ seoDescription: 'Our story',
28
+ sections: [],
29
+ }
30
+ }
31
+
32
+ describe('fetchPageForEditor', () => {
33
+ it('maps a document row into an EditorPage', async () => {
34
+ cmsApi.mockResolvedValue({
35
+ data: {
36
+ id: 'p1',
37
+ title: 'About',
38
+ slug: 'about',
39
+ status: 'PUBLISHED',
40
+ publishedAt: '2026-01-02T00:00:00Z',
41
+ updatedAt: '2026-01-03T00:00:00Z',
42
+ data: { path: '/about', sections: [], metaTitle: 'T', metaDescription: 'D' },
43
+ },
44
+ status: 200,
45
+ })
46
+
47
+ const page = await fetchPageForEditor('p1')
48
+
49
+ expect(cmsApi).toHaveBeenCalledWith('/collections/pages/p1')
50
+ expect(page).toMatchObject({
51
+ id: 'p1',
52
+ title: 'About',
53
+ slug: 'about',
54
+ path: '/about',
55
+ status: 'PUBLISHED',
56
+ seoTitle: 'T',
57
+ seoDescription: 'D',
58
+ })
59
+ })
60
+
61
+ it('falls back to /slug for path when data.path is absent', async () => {
62
+ cmsApi.mockResolvedValue({
63
+ data: { id: 'p2', title: 'Home', slug: 'home', status: 'DRAFT', data: {} },
64
+ status: 200,
65
+ })
66
+ const page = await fetchPageForEditor('p2')
67
+ expect(page.path).toBe('/home')
68
+ })
69
+
70
+ it('throws the server error message on failure', async () => {
71
+ cmsApi.mockResolvedValue({ error: 'Document not found', status: 404 })
72
+ await expect(fetchPageForEditor('missing')).rejects.toThrow('Document not found')
73
+ })
74
+ })
75
+
76
+ describe('savePageDraft', () => {
77
+ it('PUTs sections + SEO without a status change for an existing page', async () => {
78
+ cmsApi.mockResolvedValue({
79
+ data: { id: 'p1', title: 'About', slug: 'about', status: 'DRAFT', data: { sections: [] } },
80
+ status: 200,
81
+ })
82
+
83
+ await savePageDraft(draft('p1'))
84
+
85
+ const [endpoint, opts] = cmsApi.mock.calls[0] as [string, RequestInit]
86
+ expect(endpoint).toBe('/collections/pages/p1')
87
+ expect(opts.method).toBe('PUT')
88
+ const body = JSON.parse(opts.body as string)
89
+ expect(body).toMatchObject({
90
+ title: 'About',
91
+ slug: 'about',
92
+ path: '/about',
93
+ sections: [],
94
+ metaTitle: 'About us',
95
+ metaDescription: 'Our story',
96
+ })
97
+ // Draft save must never flip status.
98
+ expect('status' in body).toBe(false)
99
+ })
100
+
101
+ it('creates (POST) a new page when there is no id', async () => {
102
+ cmsApi.mockResolvedValue({
103
+ data: { id: 'new1', title: 'About', slug: 'about', status: 'DRAFT', data: {} },
104
+ status: 201,
105
+ })
106
+
107
+ const saved = await savePageDraft(draft(null))
108
+
109
+ const [endpoint, opts] = cmsApi.mock.calls[0] as [string, RequestInit]
110
+ expect(endpoint).toBe('/collections/pages')
111
+ expect(opts.method).toBe('POST')
112
+ expect(saved.id).toBe('new1')
113
+ })
114
+
115
+ it('throws when the server rejects the write', async () => {
116
+ cmsApi.mockResolvedValue({ error: 'Forbidden', status: 403 })
117
+ await expect(savePageDraft(draft('p1'))).rejects.toThrow('Forbidden')
118
+ })
119
+ })
120
+
121
+ describe('publishPage', () => {
122
+ it('includes status: PUBLISHED in the payload', async () => {
123
+ cmsApi.mockResolvedValue({
124
+ data: { id: 'p1', title: 'About', slug: 'about', status: 'PUBLISHED', data: {} },
125
+ status: 200,
126
+ })
127
+
128
+ await publishPage(draft('p1'))
129
+
130
+ const [, opts] = cmsApi.mock.calls[0] as [string, RequestInit]
131
+ const body = JSON.parse(opts.body as string)
132
+ expect(body.status).toBe('PUBLISHED')
133
+ })
134
+ })
135
+
136
+ describe('missingPageFields', () => {
137
+ const fullFields = {
138
+ title: { type: 'text' },
139
+ slug: { type: 'slug' },
140
+ path: { type: 'text' },
141
+ sections: { type: 'json' },
142
+ metaTitle: { type: 'text' },
143
+ metaDescription: { type: 'text' },
144
+ }
145
+
146
+ it('returns [] when no config is provided', () => {
147
+ expect(missingPageFields(undefined)).toEqual([])
148
+ expect(missingPageFields(null)).toEqual([])
149
+ expect(missingPageFields({})).toEqual([])
150
+ })
151
+
152
+ it('returns [] when the pages collection declares every required field (record form)', () => {
153
+ const config = { collections: { pages: { slug: 'pages', fields: fullFields } } }
154
+ expect(missingPageFields(config)).toEqual([])
155
+ })
156
+
157
+ it('reports the missing required fields', () => {
158
+ const config = {
159
+ collections: {
160
+ pages: { slug: 'pages', fields: { title: { type: 'text' }, slug: { type: 'slug' } } },
161
+ },
162
+ }
163
+ expect(missingPageFields(config)).toEqual(['sections', 'path', 'metaTitle', 'metaDescription'])
164
+ })
165
+
166
+ it('supports the array form of field definitions', () => {
167
+ const config = {
168
+ collections: [
169
+ { slug: 'pages', fields: Object.entries(fullFields).map(([name, f]) => ({ name, ...f })) },
170
+ ],
171
+ }
172
+ expect(missingPageFields(config)).toEqual([])
173
+ })
174
+
175
+ it('flags a pages collection that omits sections (data-loss guard)', () => {
176
+ const { sections: _omitted, ...withoutSections } = fullFields
177
+ const config = { collections: { pages: { slug: 'pages', fields: withoutSections } } }
178
+ expect(missingPageFields(config)).toEqual(['sections'])
179
+ })
180
+ })
@@ -0,0 +1,160 @@
1
+ // @vitest-environment node
2
+ import { afterEach, describe, expect, it, vi } from 'vitest'
3
+
4
+ const cmsApi = vi.fn()
5
+ vi.mock('../../lib/api.js', () => ({
6
+ cmsApi: (...args: unknown[]) => cmsApi(...args),
7
+ }))
8
+
9
+ const { fetchPages, fetchPagesDataset, bulkUpdatePages, bulkChangePageTags } =
10
+ await import('../../lib/pages-service.js')
11
+
12
+ afterEach(() => {
13
+ cmsApi.mockReset()
14
+ })
15
+
16
+ function pageDoc(
17
+ id: string,
18
+ over: Record<string, unknown> = {},
19
+ data: Record<string, unknown> = {},
20
+ ) {
21
+ return {
22
+ id,
23
+ collection: 'pages',
24
+ title: id,
25
+ slug: id,
26
+ status: 'PUBLISHED',
27
+ createdById: 'u1',
28
+ updatedById: 'u1',
29
+ publishedAt: '2026-01-01T00:00:00Z',
30
+ scheduledAt: null,
31
+ createdAt: '2026-01-01T00:00:00Z',
32
+ updatedAt: '2026-01-01T00:00:00Z',
33
+ data: { path: `/${id}`, ...data },
34
+ ...over,
35
+ }
36
+ }
37
+
38
+ // Route the three reads fetchPagesDataset performs (users, tags, pages).
39
+ function routeReads(pages: unknown[], tags: unknown[] = []) {
40
+ cmsApi.mockImplementation((endpoint: string) => {
41
+ if (endpoint.startsWith('/users')) {
42
+ return Promise.resolve({
43
+ data: { users: [{ id: 'u1', name: 'Jane Doe', email: 'jane@example.com' }] },
44
+ status: 200,
45
+ })
46
+ }
47
+ if (endpoint.startsWith('/collections/page-tags')) {
48
+ return Promise.resolve({ data: { docs: tags, total: tags.length }, status: 200 })
49
+ }
50
+ if (endpoint.startsWith('/collections/pages')) {
51
+ return Promise.resolve({ data: { docs: pages, total: pages.length }, status: 200 })
52
+ }
53
+ return Promise.resolve({ data: null, status: 200 })
54
+ })
55
+ }
56
+
57
+ describe('fetchPagesDataset', () => {
58
+ it('returns every page plus tag counts derived from the full set', async () => {
59
+ routeReads(
60
+ [
61
+ pageDoc('p1', { status: 'DRAFT' }, { tags: ['a'] }),
62
+ pageDoc('p2', {}, { tags: ['a', 'b'] }),
63
+ pageDoc('p3'), // untagged
64
+ ],
65
+ [
66
+ { id: 't1', slug: 'a', title: 'Alpha', data: { color: 'green', sortOrder: 0 } },
67
+ { id: 't2', slug: 'b', title: 'Beta', data: { sortOrder: 1 } },
68
+ ],
69
+ )
70
+
71
+ const ds = await fetchPagesDataset()
72
+ expect(ds.pages).toHaveLength(3)
73
+ expect(ds.tags.find((t) => t.slug === 'a')?.pageCount).toBe(2)
74
+ expect(ds.tags.find((t) => t.slug === 'b')?.pageCount).toBe(1)
75
+ expect(ds.authors).toHaveLength(1)
76
+ })
77
+ })
78
+
79
+ describe('fetchPages counts', () => {
80
+ it('reports grandTotal as the unfiltered count, independent of tag membership', async () => {
81
+ // p3 is untagged: a tag-sum total would undercount (the I-1 bug). The
82
+ // grandTotal must reflect all three pages.
83
+ routeReads(
84
+ [pageDoc('p1', {}, { tags: ['a'] }), pageDoc('p2', {}, { tags: ['a'] }), pageDoc('p3')],
85
+ [{ id: 't1', slug: 'a', title: 'Alpha', data: {} }],
86
+ )
87
+
88
+ const res = await fetchPages()
89
+ expect(res.grandTotal).toBe(3)
90
+ expect(res.total).toBe(3)
91
+ })
92
+
93
+ it('paginates the flat view and keeps total/grandTotal stable', async () => {
94
+ routeReads([pageDoc('p1'), pageDoc('p2'), pageDoc('p3')])
95
+
96
+ const first = await fetchPages({ page: 1, pageSize: 2 })
97
+ expect(first.pages).toHaveLength(2)
98
+ expect(first.total).toBe(3)
99
+ expect(first.grandTotal).toBe(3)
100
+
101
+ const second = await fetchPages({ page: 2, pageSize: 2 })
102
+ expect(second.pages).toHaveLength(1)
103
+ expect(second.total).toBe(3)
104
+ expect(second.grandTotal).toBe(3)
105
+ })
106
+
107
+ it('total reflects the filter while grandTotal does not', async () => {
108
+ routeReads([
109
+ pageDoc('p1', { status: 'DRAFT' }),
110
+ pageDoc('p2', { status: 'PUBLISHED' }),
111
+ pageDoc('p3', { status: 'PUBLISHED' }),
112
+ ])
113
+
114
+ const res = await fetchPages({ status: 'DRAFT' })
115
+ expect(res.total).toBe(1)
116
+ expect(res.grandTotal).toBe(3)
117
+ })
118
+ })
119
+
120
+ describe('bulk operations', () => {
121
+ it('counts ok/failed across a batch and issues one request per id', async () => {
122
+ cmsApi.mockImplementation((endpoint: string) =>
123
+ Promise.resolve(
124
+ endpoint.includes('bad') ? { error: 'nope', status: 500 } : { data: {}, status: 200 },
125
+ ),
126
+ )
127
+
128
+ const result = await bulkUpdatePages(['a', 'bad', 'c'], { status: 'PUBLISHED' })
129
+ expect(result).toEqual({ ok: 2, failed: 1 })
130
+ expect(cmsApi).toHaveBeenCalledTimes(3)
131
+ })
132
+
133
+ it('does not drop or double-process work above the concurrency limit', async () => {
134
+ cmsApi.mockResolvedValue({ data: {}, status: 200 })
135
+ const ids = Array.from({ length: 12 }, (_, i) => `p${i}`)
136
+ const result = await bulkUpdatePages(ids, { status: 'DRAFT' })
137
+ expect(result).toEqual({ ok: 12, failed: 0 })
138
+ expect(cmsApi).toHaveBeenCalledTimes(12)
139
+ })
140
+
141
+ it('treats a thrown request as a failure rather than aborting the batch', async () => {
142
+ let call = 0
143
+ cmsApi.mockImplementation(() => {
144
+ call++
145
+ if (call === 2) return Promise.reject(new Error('network'))
146
+ return Promise.resolve({ data: {}, status: 200 })
147
+ })
148
+
149
+ const result = await bulkChangePageTags(
150
+ [
151
+ { id: 'a', tags: [], primaryTagId: null },
152
+ { id: 'b', tags: [], primaryTagId: null },
153
+ { id: 'c', tags: [], primaryTagId: null },
154
+ ],
155
+ { add: ['x'] },
156
+ )
157
+ expect(result.ok).toBe(2)
158
+ expect(result.failed).toBe(1)
159
+ })
160
+ })
@@ -10,10 +10,11 @@ vi.mock('../../lib/api.js', () => ({
10
10
  cmsApi: (...args: unknown[]) => cmsApiMock(...args),
11
11
  }))
12
12
 
13
- const { useApiData } = await import('../../lib/useApiData.js')
13
+ const { useApiData, __clearApiDataCache } = await import('../../lib/useApiData.js')
14
14
 
15
15
  beforeEach(() => {
16
16
  cmsApiMock.mockReset()
17
+ __clearApiDataCache()
17
18
  })
18
19
 
19
20
  afterEach(() => {
@@ -102,4 +103,38 @@ describe('useApiData', () => {
102
103
  expect(result.current.exhausted).toBe(false)
103
104
  expect(result.current.error).toBeNull()
104
105
  })
106
+
107
+ it('serves a warm cache on remount without refetching when cacheMs is set', async () => {
108
+ cmsApiMock.mockResolvedValue({ data: { v: 1 }, status: 200 })
109
+
110
+ const first = renderHook(() => useApiData<{ v: number }>('/cached', null, { cacheMs: 10_000 }))
111
+ await waitFor(() => expect(first.result.current.loading).toBe(false))
112
+ expect(first.result.current.data).toEqual({ v: 1 })
113
+ expect(cmsApiMock).toHaveBeenCalledTimes(1)
114
+ first.unmount()
115
+
116
+ // A fresh mount within the TTL reads the cache synchronously — data is
117
+ // present immediately, loading never flips true, and no new request fires.
118
+ const second = renderHook(() => useApiData<{ v: number }>('/cached', null, { cacheMs: 10_000 }))
119
+ expect(second.result.current.data).toEqual({ v: 1 })
120
+ expect(second.result.current.loading).toBe(false)
121
+ expect(cmsApiMock).toHaveBeenCalledTimes(1)
122
+ })
123
+
124
+ it('refetch bypasses the cache and updates it', async () => {
125
+ cmsApiMock.mockResolvedValue({ data: { v: 1 }, status: 200 })
126
+
127
+ const { result } = renderHook(() =>
128
+ useApiData<{ v: number }>('/cached2', null, { cacheMs: 10_000 }),
129
+ )
130
+ await waitFor(() => expect(result.current.loading).toBe(false))
131
+ expect(cmsApiMock).toHaveBeenCalledTimes(1)
132
+
133
+ cmsApiMock.mockResolvedValue({ data: { v: 2 }, status: 200 })
134
+ await act(async () => {
135
+ result.current.refetch()
136
+ })
137
+ await waitFor(() => expect(result.current.data).toEqual({ v: 2 }))
138
+ expect(cmsApiMock).toHaveBeenCalledTimes(2)
139
+ })
105
140
  })
@@ -0,0 +1,65 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, describe, expect, it, vi } from 'vitest'
3
+ import { render, screen } from '@testing-library/react'
4
+
5
+ const fetchPageForEditor = vi.fn()
6
+ vi.mock('../../lib/page-editor-service.js', async (importOriginal) => {
7
+ const actual = await importOriginal<typeof import('../../lib/page-editor-service.js')>()
8
+ return {
9
+ ...actual,
10
+ fetchPageForEditor: (...args: unknown[]) => fetchPageForEditor(...args),
11
+ }
12
+ })
13
+
14
+ const { PageSectionEditor } = await import('../../views/page-editor/PageSectionEditor.js')
15
+
16
+ const validConfig = {
17
+ collections: {
18
+ pages: {
19
+ slug: 'pages',
20
+ fields: {
21
+ title: { type: 'text' },
22
+ slug: { type: 'slug' },
23
+ path: { type: 'text' },
24
+ sections: { type: 'json' },
25
+ metaTitle: { type: 'text' },
26
+ metaDescription: { type: 'text' },
27
+ },
28
+ },
29
+ },
30
+ }
31
+
32
+ afterEach(() => {
33
+ fetchPageForEditor.mockReset()
34
+ })
35
+
36
+ describe('PageSectionEditor schema guard', () => {
37
+ it('refuses to operate when the pages collection is missing the sections field', async () => {
38
+ const badConfig = {
39
+ collections: { pages: { slug: 'pages', fields: { title: { type: 'text' } } } },
40
+ }
41
+
42
+ render(
43
+ <PageSectionEditor documentId="p1" config={badConfig} onNavigate={() => {}} session={{}} />,
44
+ )
45
+
46
+ expect(await screen.findByText(/missing required field/i)).toBeTruthy()
47
+ expect(screen.getByText(/sections/)).toBeTruthy()
48
+ // The guard must short-circuit before any network read.
49
+ expect(fetchPageForEditor).not.toHaveBeenCalled()
50
+ })
51
+
52
+ it('does not block when no config is provided (embedding/test contexts)', async () => {
53
+ render(<PageSectionEditor config={undefined} onNavigate={() => {}} session={{}} />)
54
+ // New page (no documentId) → empty editor shell renders.
55
+ expect(await screen.findByText('Sections')).toBeTruthy()
56
+ })
57
+ })
58
+
59
+ describe('PageSectionEditor new page', () => {
60
+ it('renders the editor shell without fetching for /pages/new', async () => {
61
+ render(<PageSectionEditor config={validConfig} onNavigate={() => {}} session={{}} />)
62
+ expect(await screen.findByText('Sections')).toBeTruthy()
63
+ expect(fetchPageForEditor).not.toHaveBeenCalled()
64
+ })
65
+ })
@@ -0,0 +1,119 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, describe, expect, it, vi } from 'vitest'
3
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react'
4
+
5
+ // Keep the pure shaping helpers (applyPagesQuery/groupPages) real — the
6
+ // list view relies on them for in-memory filtering — and stub only the
7
+ // network read + bulk mutations.
8
+ const fetchPagesDataset = vi.fn()
9
+ vi.mock('../../lib/pages-service.js', async (importOriginal) => {
10
+ const actual = await importOriginal<typeof import('../../lib/pages-service.js')>()
11
+ return {
12
+ ...actual,
13
+ fetchPagesDataset: () => fetchPagesDataset(),
14
+ }
15
+ })
16
+
17
+ const { PagesListView } = await import('../../views/Pages/PagesListView.js')
18
+ import type { Page, PageTag, PagesDataset } from '../../lib/pages-service.js'
19
+
20
+ afterEach(() => {
21
+ fetchPagesDataset.mockReset()
22
+ })
23
+
24
+ function page(id: string, over: Partial<Page> = {}): Page {
25
+ return {
26
+ id,
27
+ title: id,
28
+ path: `/${id}`,
29
+ slug: id,
30
+ parentPageId: null,
31
+ tags: [],
32
+ primaryTagId: null,
33
+ authorId: 'u1',
34
+ authorName: 'Jane Doe',
35
+ authorInitials: 'JD',
36
+ status: 'PUBLISHED',
37
+ publishDate: null,
38
+ scheduledDate: null,
39
+ lastModified: '2026-01-01T00:00:00Z',
40
+ seoScore: 80,
41
+ seoTitle: null,
42
+ seoDescription: null,
43
+ templateId: null,
44
+ isHomepage: false,
45
+ isSystemPage: false,
46
+ createdAt: '2026-01-01T00:00:00Z',
47
+ updatedAt: '2026-01-01T00:00:00Z',
48
+ ...over,
49
+ }
50
+ }
51
+
52
+ function tag(slug: string, pageCount: number): PageTag {
53
+ return {
54
+ id: slug,
55
+ docId: `doc-${slug}`,
56
+ name: slug.toUpperCase(),
57
+ slug,
58
+ description: null,
59
+ color: 'green',
60
+ pageCount,
61
+ sortOrder: 0,
62
+ active: true,
63
+ createdAt: null,
64
+ updatedAt: null,
65
+ }
66
+ }
67
+
68
+ function dataset(over: Partial<PagesDataset> = {}): PagesDataset {
69
+ return { pages: [], tags: [], authors: [], ...over }
70
+ }
71
+
72
+ describe('PagesListView', () => {
73
+ it('counts untagged pages in the grand total (I-1 regression)', async () => {
74
+ // Two pages tagged "a", one untagged. A tag-sum total would report 2;
75
+ // the grand total must be 3.
76
+ fetchPagesDataset.mockResolvedValue(
77
+ dataset({
78
+ pages: [
79
+ page('p1', { tags: ['a'], primaryTagId: 'a' }),
80
+ page('p2', { tags: ['a'], primaryTagId: 'a' }),
81
+ page('p3'),
82
+ ],
83
+ tags: [tag('a', 2)],
84
+ }),
85
+ )
86
+
87
+ render(<PagesListView onNavigate={() => {}} />)
88
+
89
+ expect(await screen.findByText(/3 pages · organized by tags/)).toBeTruthy()
90
+ expect(screen.getByText('3 of 3')).toBeTruthy()
91
+ })
92
+
93
+ it('renders the empty state when there are no pages', async () => {
94
+ fetchPagesDataset.mockResolvedValue(dataset())
95
+ render(<PagesListView onNavigate={() => {}} />)
96
+ expect(await screen.findByText('No pages yet')).toBeTruthy()
97
+ })
98
+
99
+ it('shows an error with a retry that refetches', async () => {
100
+ fetchPagesDataset.mockRejectedValueOnce(new Error('Boom'))
101
+ render(<PagesListView onNavigate={() => {}} />)
102
+
103
+ expect(await screen.findByText('Boom')).toBeTruthy()
104
+
105
+ fetchPagesDataset.mockResolvedValueOnce(dataset({ pages: [page('p1')] }))
106
+ fireEvent.click(screen.getByText('Try again'))
107
+
108
+ await waitFor(() => expect(screen.getByText('1 of 1')).toBeTruthy())
109
+ })
110
+
111
+ it('navigates to the section editor when a row is opened', async () => {
112
+ const onNavigate = vi.fn()
113
+ fetchPagesDataset.mockResolvedValue(dataset({ pages: [page('about', { title: 'About' })] }))
114
+ render(<PagesListView onNavigate={onNavigate} />)
115
+
116
+ fireEvent.click(await screen.findByText('About'))
117
+ expect(onNavigate).toHaveBeenCalledWith('/pages/about/edit')
118
+ })
119
+ })
@@ -123,6 +123,62 @@ export function validatePage(page: EditorPage, forPublish = false): ValidationRe
123
123
  return { errors, warnings }
124
124
  }
125
125
 
126
+ // ─── Schema guard ────────────────────────────────────────────────────
127
+
128
+ /**
129
+ * The Page Editor persists `sections` / `path` / `metaTitle` /
130
+ * `metaDescription` as top-level keys on a `pages` document. The server's
131
+ * field-access layer only writes keys that are *declared as fields* on the
132
+ * collection — any undeclared key is silently dropped on save. A `pages`
133
+ * collection missing the `sections` field would therefore discard all page
134
+ * content with no error.
135
+ *
136
+ * `missingPageFields` returns the required field keys absent from the
137
+ * `pages` collection so the editor can refuse to operate (and explain why)
138
+ * rather than corrupt data. When `config` (or its collections) is not
139
+ * provided — some embedding/test contexts don't pass it — we can't check,
140
+ * so we return `[]` and trust the consumer.
141
+ */
142
+ const REQUIRED_PAGE_FIELDS = ['sections', 'path', 'metaTitle', 'metaDescription'] as const
143
+
144
+ export function missingPageFields(config: unknown): string[] {
145
+ const pages = findPagesCollection(config)
146
+ if (!pages) return []
147
+ const declared = declaredFieldNames(pages.fields)
148
+ if (declared === null) return []
149
+ return REQUIRED_PAGE_FIELDS.filter((k) => !declared.has(k))
150
+ }
151
+
152
+ function findPagesCollection(config: unknown): { fields?: unknown } | null {
153
+ const collections = (config as { collections?: unknown } | null | undefined)?.collections
154
+ if (!collections) return null
155
+ if (Array.isArray(collections)) {
156
+ return (
157
+ (collections as Array<{ slug?: string; fields?: unknown }>).find(
158
+ (c) => c?.slug === PAGES_COLLECTION,
159
+ ) ?? null
160
+ )
161
+ }
162
+ if (typeof collections === 'object') {
163
+ return (collections as Record<string, { fields?: unknown }>)[PAGES_COLLECTION] ?? null
164
+ }
165
+ return null
166
+ }
167
+
168
+ /** Field definitions may be a record (keyed by name) or an array of `{ name }`. */
169
+ function declaredFieldNames(fields: unknown): Set<string> | null {
170
+ if (!fields) return null
171
+ if (Array.isArray(fields)) {
172
+ return new Set(
173
+ (fields as Array<{ name?: string }>)
174
+ .map((f) => f?.name)
175
+ .filter((n): n is string => typeof n === 'string'),
176
+ )
177
+ }
178
+ if (typeof fields === 'object') return new Set(Object.keys(fields as Record<string, unknown>))
179
+ return null
180
+ }
181
+
126
182
  // ─── API row mapping ─────────────────────────────────────────────────
127
183
 
128
184
  interface DocumentApiRow {
@@ -169,6 +225,11 @@ interface PageWritePayload {
169
225
  metaDescription: string
170
226
  }
171
227
 
228
+ // NOTE: `slug` may be empty for a brand-new draft. The `pages` collection
229
+ // declares `slug` as a `type: 'slug'` field with `from: 'title'`, so the
230
+ // server derives the slug from the title on create when none is supplied;
231
+ // the derived value comes back in the response and populates `EditorPage`.
232
+ // This is why `validatePage` requires a title but not a slug.
172
233
  function toWriteBody(page: EditorPage, extra?: Record<string, unknown>): string {
173
234
  const payload: PageWritePayload & Record<string, unknown> = {
174
235
  title: page.title,