@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.
- package/dist/__tests__/lib/page-editor-service-api.test.d.ts +2 -0
- package/dist/__tests__/lib/page-editor-service-api.test.d.ts.map +1 -0
- package/dist/__tests__/lib/page-editor-service-api.test.js +155 -0
- package/dist/__tests__/lib/page-editor-service-api.test.js.map +1 -0
- package/dist/__tests__/lib/pages-service-fetch.test.d.ts +2 -0
- package/dist/__tests__/lib/pages-service-fetch.test.d.ts.map +1 -0
- package/dist/__tests__/lib/pages-service-fetch.test.js +125 -0
- package/dist/__tests__/lib/pages-service-fetch.test.js.map +1 -0
- package/dist/__tests__/lib/useApiData.test.js +28 -1
- package/dist/__tests__/lib/useApiData.test.js.map +1 -1
- package/dist/__tests__/views/page-section-editor.test.d.ts +2 -0
- package/dist/__tests__/views/page-section-editor.test.d.ts.map +1 -0
- package/dist/__tests__/views/page-section-editor.test.js +56 -0
- package/dist/__tests__/views/page-section-editor.test.js.map +1 -0
- package/dist/__tests__/views/pages-list-view.test.d.ts +2 -0
- package/dist/__tests__/views/pages-list-view.test.d.ts.map +1 -0
- package/dist/__tests__/views/pages-list-view.test.js +102 -0
- package/dist/__tests__/views/pages-list-view.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/lib/page-editor-service.d.ts +1 -0
- package/dist/lib/page-editor-service.d.ts.map +1 -1
- package/dist/lib/page-editor-service.js +55 -0
- package/dist/lib/page-editor-service.js.map +1 -1
- package/dist/lib/pages-service.d.ts +26 -0
- package/dist/lib/pages-service.d.ts.map +1 -1
- package/dist/lib/pages-service.js +65 -38
- package/dist/lib/pages-service.js.map +1 -1
- package/dist/lib/useApiData.d.ts +11 -0
- package/dist/lib/useApiData.d.ts.map +1 -1
- package/dist/lib/useApiData.js +41 -7
- package/dist/lib/useApiData.js.map +1 -1
- package/dist/views/Dashboard.d.ts.map +1 -1
- package/dist/views/Dashboard.js +54 -48
- package/dist/views/Dashboard.js.map +1 -1
- package/dist/views/Pages/PagesListView.d.ts.map +1 -1
- package/dist/views/Pages/PagesListView.js +71 -46
- package/dist/views/Pages/PagesListView.js.map +1 -1
- package/dist/views/page-editor/PagePreview.d.ts.map +1 -1
- package/dist/views/page-editor/PagePreview.js +10 -1
- package/dist/views/page-editor/PagePreview.js.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.js +22 -2
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/lib/page-editor-service-api.test.ts +180 -0
- package/src/__tests__/lib/pages-service-fetch.test.ts +160 -0
- package/src/__tests__/lib/useApiData.test.ts +36 -1
- package/src/__tests__/views/page-section-editor.test.tsx +65 -0
- package/src/__tests__/views/pages-list-view.test.tsx +119 -0
- package/src/lib/page-editor-service.ts +61 -0
- package/src/lib/pages-service.ts +88 -35
- package/src/lib/useApiData.ts +52 -7
- package/src/styles/theme.css +37 -0
- package/src/views/Dashboard.tsx +77 -61
- package/src/views/Pages/PagesListView.tsx +113 -82
- package/src/views/page-editor/PagePreview.tsx +10 -1
- 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,
|