@actuate-media/cms-admin 0.17.0 → 0.19.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 (117) 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/post-editor-service.test.js +75 -23
  10. package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
  11. package/dist/__tests__/lib/posts-service.test.js +132 -142
  12. package/dist/__tests__/lib/posts-service.test.js.map +1 -1
  13. package/dist/__tests__/views/page-section-editor.test.d.ts +2 -0
  14. package/dist/__tests__/views/page-section-editor.test.d.ts.map +1 -0
  15. package/dist/__tests__/views/page-section-editor.test.js +56 -0
  16. package/dist/__tests__/views/page-section-editor.test.js.map +1 -0
  17. package/dist/__tests__/views/pages-list-view.test.d.ts +2 -0
  18. package/dist/__tests__/views/pages-list-view.test.d.ts.map +1 -0
  19. package/dist/__tests__/views/pages-list-view.test.js +102 -0
  20. package/dist/__tests__/views/pages-list-view.test.js.map +1 -0
  21. package/dist/__tests__/views/posts-list-view.render.test.d.ts +2 -0
  22. package/dist/__tests__/views/posts-list-view.render.test.d.ts.map +1 -0
  23. package/dist/__tests__/views/posts-list-view.render.test.js +73 -0
  24. package/dist/__tests__/views/posts-list-view.render.test.js.map +1 -0
  25. package/dist/actuate-admin.css +1 -1
  26. package/dist/index.d.ts +0 -2
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +0 -2
  29. package/dist/index.js.map +1 -1
  30. package/dist/lib/page-editor-service.d.ts +1 -0
  31. package/dist/lib/page-editor-service.d.ts.map +1 -1
  32. package/dist/lib/page-editor-service.js +55 -0
  33. package/dist/lib/page-editor-service.js.map +1 -1
  34. package/dist/lib/pages-service.d.ts +26 -0
  35. package/dist/lib/pages-service.d.ts.map +1 -1
  36. package/dist/lib/pages-service.js +65 -38
  37. package/dist/lib/pages-service.js.map +1 -1
  38. package/dist/lib/post-editor-service.d.ts +6 -4
  39. package/dist/lib/post-editor-service.d.ts.map +1 -1
  40. package/dist/lib/post-editor-service.js +32 -37
  41. package/dist/lib/post-editor-service.js.map +1 -1
  42. package/dist/lib/posts-service.d.ts.map +1 -1
  43. package/dist/lib/posts-service.js +91 -160
  44. package/dist/lib/posts-service.js.map +1 -1
  45. package/dist/views/Pages/PagesListView.d.ts.map +1 -1
  46. package/dist/views/Pages/PagesListView.js +71 -46
  47. package/dist/views/Pages/PagesListView.js.map +1 -1
  48. package/dist/views/Posts/NewPostPicker.d.ts +5 -4
  49. package/dist/views/Posts/NewPostPicker.d.ts.map +1 -1
  50. package/dist/views/Posts/NewPostPicker.js +5 -4
  51. package/dist/views/Posts/NewPostPicker.js.map +1 -1
  52. package/dist/views/Posts/PostTypesView.d.ts +5 -4
  53. package/dist/views/Posts/PostTypesView.d.ts.map +1 -1
  54. package/dist/views/Posts/PostTypesView.js.map +1 -1
  55. package/dist/views/Posts/PostsListView.d.ts.map +1 -1
  56. package/dist/views/Posts/PostsListView.js +8 -0
  57. package/dist/views/Posts/PostsListView.js.map +1 -1
  58. package/dist/views/page-editor/PagePreview.d.ts.map +1 -1
  59. package/dist/views/page-editor/PagePreview.js +10 -1
  60. package/dist/views/page-editor/PagePreview.js.map +1 -1
  61. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  62. package/dist/views/page-editor/PageSectionEditor.js +22 -2
  63. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  64. package/dist/views/page-editor/sections/ArticleBodySection.d.ts +6 -2
  65. package/dist/views/page-editor/sections/ArticleBodySection.d.ts.map +1 -1
  66. package/dist/views/page-editor/sections/ArticleBodySection.js +8 -8
  67. package/dist/views/page-editor/sections/ArticleBodySection.js.map +1 -1
  68. package/dist/views/post-editor/PostEditorCanvas.js +7 -1
  69. package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
  70. package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
  71. package/dist/views/post-editor/PostFieldsModal.js +10 -4
  72. package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
  73. package/dist/views/post-editor/PostHeader.d.ts.map +1 -1
  74. package/dist/views/post-editor/PostHeader.js +4 -3
  75. package/dist/views/post-editor/PostHeader.js.map +1 -1
  76. package/dist/views/post-editor/PostPreview.d.ts.map +1 -1
  77. package/dist/views/post-editor/PostPreview.js +11 -1
  78. package/dist/views/post-editor/PostPreview.js.map +1 -1
  79. package/dist/views/post-editor/PostSectionEditor.d.ts +22 -2
  80. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  81. package/dist/views/post-editor/PostSectionEditor.js +4 -1
  82. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  83. package/package.json +2 -2
  84. package/src/__tests__/lib/page-editor-service-api.test.ts +180 -0
  85. package/src/__tests__/lib/pages-service-fetch.test.ts +160 -0
  86. package/src/__tests__/lib/post-editor-service.test.ts +78 -23
  87. package/src/__tests__/lib/posts-service.test.ts +134 -163
  88. package/src/__tests__/views/page-section-editor.test.tsx +65 -0
  89. package/src/__tests__/views/pages-list-view.test.tsx +119 -0
  90. package/src/__tests__/views/posts-list-view.render.test.tsx +84 -0
  91. package/src/index.ts +0 -2
  92. package/src/lib/page-editor-service.ts +61 -0
  93. package/src/lib/pages-service.ts +88 -35
  94. package/src/lib/post-editor-service.ts +41 -40
  95. package/src/lib/posts-service.ts +109 -180
  96. package/src/views/Pages/PagesListView.tsx +113 -82
  97. package/src/views/Posts/NewPostPicker.tsx +5 -4
  98. package/src/views/Posts/PostTypesView.tsx +5 -4
  99. package/src/views/Posts/PostsListView.tsx +9 -0
  100. package/src/views/page-editor/PagePreview.tsx +10 -1
  101. package/src/views/page-editor/PageSectionEditor.tsx +25 -1
  102. package/src/views/page-editor/sections/ArticleBodySection.tsx +12 -16
  103. package/src/views/post-editor/PostEditorCanvas.tsx +7 -1
  104. package/src/views/post-editor/PostFieldsModal.tsx +36 -21
  105. package/src/views/post-editor/PostHeader.tsx +4 -4
  106. package/src/views/post-editor/PostPreview.tsx +12 -1
  107. package/src/views/post-editor/PostSectionEditor.tsx +22 -5
  108. package/dist/views/PostEditor.d.ts +0 -6
  109. package/dist/views/PostEditor.d.ts.map +0 -1
  110. package/dist/views/PostEditor.js +0 -79
  111. package/dist/views/PostEditor.js.map +0 -1
  112. package/dist/views/Posts.d.ts +0 -5
  113. package/dist/views/Posts.d.ts.map +0 -1
  114. package/dist/views/Posts.js +0 -73
  115. package/dist/views/Posts.js.map +0 -1
  116. package/src/views/PostEditor.tsx +0 -257
  117. package/src/views/Posts.tsx +0 -375
@@ -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
+ })
@@ -135,26 +135,21 @@ describe('validateTemplate', () => {
135
135
 
136
136
  describe('emptyPostFromTemplate', () => {
137
137
  it('seeds sections from the type template with fresh ids', async () => {
138
- // fetchTemplateForEditor lists the post-templates collection.
138
+ // fetchTemplateForEditor reads the dedicated /post-templates/:type endpoint,
139
+ // whose `{ data }` envelope is unwrapped by cmsApi to { postType, docId, header, sections }.
139
140
  cmsApiMock.mockResolvedValueOnce({
140
141
  data: {
141
- docs: [
142
+ postType: 'blog',
143
+ docId: 'tmpl-1',
144
+ header: { layout: 'left' },
145
+ sections: [
142
146
  {
143
- id: 'tmpl-1',
144
- slug: 'blog',
145
- data: {
146
- header: { layout: 'left' },
147
- sections: [
148
- {
149
- id: 'orig',
150
- sectionType: 'article-body',
151
- name: 'Body',
152
- visible: true,
153
- content: {},
154
- settings: {},
155
- },
156
- ],
157
- },
147
+ id: 'orig',
148
+ sectionType: 'article-body',
149
+ name: 'Body',
150
+ visible: true,
151
+ content: {},
152
+ settings: {},
158
153
  },
159
154
  ],
160
155
  },
@@ -166,22 +161,82 @@ describe('emptyPostFromTemplate', () => {
166
161
  expect(post.sections[0]!.sectionType).toBe('article-body')
167
162
  // cloned: a fresh id, not the template's section id
168
163
  expect(post.sections[0]!.id).not.toBe('orig')
164
+ // the endpoint was addressed by post type, not the raw collection API
165
+ expect(cmsApiMock.mock.calls[0]![0]).toBe('/post-templates/blog')
169
166
  })
170
167
 
171
- it('starts empty when the type has no template', async () => {
172
- cmsApiMock.mockResolvedValueOnce({ data: { docs: [] } })
168
+ it('seeds the default starting template when the type has no saved template', async () => {
169
+ // Unsaved type endpoint returns docId null + empty sections; the service
170
+ // backfills the default (article-body + related-posts) so the post is never blank.
171
+ cmsApiMock.mockResolvedValueOnce({ data: { postType: 'news', docId: null, sections: [] } })
173
172
  const post = await emptyPostFromTemplate('news')
174
- expect(post.sections).toEqual([])
173
+ expect(post.sections.map((s) => s.sectionType)).toEqual(['article-body', 'related-posts'])
174
+ // cloned with fresh, distinct ids
175
+ expect(post.sections[0]!.id).not.toBe(post.sections[1]!.id)
175
176
  })
176
177
  })
177
178
 
178
179
  describe('fetchTemplateForEditor', () => {
179
- it('returns an unsaved default when no template document matches', async () => {
180
- cmsApiMock.mockResolvedValueOnce({ data: { docs: [] } })
180
+ it('maps a saved template from the dedicated endpoint', async () => {
181
+ cmsApiMock.mockResolvedValueOnce({
182
+ data: {
183
+ postType: 'blog',
184
+ docId: 'tmpl-9',
185
+ header: { layout: 'overlay' },
186
+ sections: [
187
+ { id: 's1', sectionType: 'quote', name: 'Q', visible: true, content: {}, settings: {} },
188
+ ],
189
+ },
190
+ })
191
+ const t = await fetchTemplateForEditor('blog')
192
+ expect(t.docId).toBe('tmpl-9')
193
+ expect(t.header.layout).toBe('overlay')
194
+ expect(t.sections).toHaveLength(1)
195
+ })
196
+
197
+ it('seeds the default starting template when the type has no saved template', async () => {
198
+ cmsApiMock.mockResolvedValueOnce({ data: { postType: 'blog', docId: null, sections: [] } })
181
199
  const t = await fetchTemplateForEditor('blog')
182
200
  expect(t.docId).toBeNull()
183
201
  expect(t.header.layout).toBe('centered')
184
- expect(t.sections).toEqual([])
202
+ expect(t.sections.map((s) => s.sectionType)).toEqual(['article-body', 'related-posts'])
203
+ })
204
+
205
+ it('falls back to the default template when the request fails', async () => {
206
+ cmsApiMock.mockResolvedValueOnce({ error: 'network down' })
207
+ const t = await fetchTemplateForEditor('blog')
208
+ expect(t.docId).toBeNull()
209
+ expect(t.sections.map((s) => s.sectionType)).toEqual(['article-body', 'related-posts'])
210
+ })
211
+ })
212
+
213
+ describe('saveTemplate', () => {
214
+ it('PUTs header + sections to the dedicated endpoint', async () => {
215
+ cmsApiMock.mockResolvedValueOnce({
216
+ data: { postType: 'blog', docId: 'tmpl-1', header: { layout: 'left' }, sections: [] },
217
+ })
218
+ const { saveTemplate } = await import('../../lib/post-editor-service.js')
219
+ const out = await saveTemplate({
220
+ docId: null,
221
+ postType: 'blog',
222
+ header: {
223
+ layout: 'left',
224
+ showFeaturedImage: true,
225
+ showExcerpt: true,
226
+ showAuthor: true,
227
+ showDate: true,
228
+ showCategory: true,
229
+ showReadingTime: false,
230
+ },
231
+ sections: [],
232
+ })
233
+ const [endpoint, init] = cmsApiMock.mock.calls[0]!
234
+ expect(endpoint).toBe('/post-templates/blog')
235
+ expect(init.method).toBe('PUT')
236
+ const body = JSON.parse(init.body as string)
237
+ expect(body.header.layout).toBe('left')
238
+ expect(Array.isArray(body.sections)).toBe(true)
239
+ expect(out.docId).toBe('tmpl-1')
185
240
  })
186
241
  })
187
242
 
@@ -10,37 +10,6 @@ vi.mock('../../lib/api.js', () => ({
10
10
 
11
11
  const { fetchPosts, fetchPostTypes } = await import('../../lib/posts-service.js')
12
12
 
13
- interface DocFixture {
14
- id: string
15
- collection: string
16
- title: string
17
- slug: string
18
- status: 'PUBLISHED' | 'DRAFT' | 'SCHEDULED'
19
- createdById: string
20
- updatedById: string
21
- publishedAt: string | null
22
- scheduledAt: string | null
23
- createdAt: string
24
- updatedAt: string
25
- data: Record<string, unknown> | null
26
- }
27
-
28
- function doc(
29
- o: Partial<DocFixture> & Pick<DocFixture, 'id' | 'collection' | 'title' | 'slug'>,
30
- ): DocFixture {
31
- return {
32
- status: 'PUBLISHED',
33
- createdById: 'u-david',
34
- updatedById: 'u-david',
35
- publishedAt: '2026-05-10T00:00:00.000Z',
36
- scheduledAt: null,
37
- createdAt: '2026-05-10T00:00:00.000Z',
38
- updatedAt: '2026-05-10T00:00:00.000Z',
39
- data: { seoScore: 80 },
40
- ...o,
41
- }
42
- }
43
-
44
13
  const COLLECTIONS_RESPONSE = [
45
14
  {
46
15
  slug: 'blog',
@@ -54,30 +23,6 @@ const COLLECTIONS_RESPONSE = [
54
23
  hidden: false,
55
24
  fieldCount: 12,
56
25
  },
57
- {
58
- slug: 'case-studies',
59
- labels: { singular: 'Case Study', plural: 'Case Studies' },
60
- type: 'post',
61
- urlPrefix: 'case-studies',
62
- description: 'Case studies',
63
- icon: 'briefcase',
64
- color: 'green',
65
- group: 'Posts',
66
- hidden: false,
67
- fieldCount: 13,
68
- },
69
- {
70
- slug: 'news',
71
- labels: { singular: 'News Item', plural: 'News' },
72
- type: 'post',
73
- urlPrefix: 'news',
74
- description: 'News',
75
- icon: 'newspaper',
76
- color: 'orange',
77
- group: 'Posts',
78
- hidden: false,
79
- fieldCount: 11,
80
- },
81
26
  {
82
27
  slug: 'pages',
83
28
  labels: { singular: 'Page', plural: 'Pages' },
@@ -92,84 +37,95 @@ const COLLECTIONS_RESPONSE = [
92
37
  },
93
38
  ]
94
39
 
95
- const USERS_RESPONSE = {
96
- users: [
97
- { id: 'u-sarah', name: 'Sarah Morgan', email: 'sarah@example.com' },
98
- { id: 'u-maria', name: 'Maria K', email: 'maria@example.com' },
99
- { id: 'u-david', name: 'David Carlson', email: 'david@example.com' },
100
- ],
101
- }
102
-
103
- const DOCS: Record<string, DocFixture[]> = {
104
- blog: [
105
- doc({
40
+ // Canned response from the server-side `GET /posts` aggregate. The server now
41
+ // owns filtering/sorting/pagination, so the client tests assert the mapping
42
+ // from this envelope onto the Post / PostType / PostAuthor shapes and that the
43
+ // query params are forwarded.
44
+ const POSTS_RESPONSE = {
45
+ posts: [
46
+ {
47
+ id: 'n1',
48
+ collection: 'news',
49
+ title: 'Q2 2026 Product Roadmap Update',
50
+ slug: 'q2-roadmap',
51
+ status: 'SCHEDULED' as const,
52
+ publishedAt: null,
53
+ scheduledAt: '2026-06-01T00:00:00.000Z',
54
+ createdAt: '2026-05-10T00:00:00.000Z',
55
+ updatedAt: '2026-05-24T00:00:00.000Z',
56
+ seoScore: 62,
57
+ author: { id: 'u-sarah', name: 'Sarah Morgan' },
58
+ },
59
+ {
106
60
  id: 'b1',
107
61
  collection: 'blog',
108
62
  title: 'The Future of AI Content Marketing',
109
63
  slug: 'ai-content-marketing',
110
- createdById: 'u-maria',
111
- updatedById: 'u-maria',
64
+ status: 'PUBLISHED' as const,
65
+ publishedAt: '2026-05-10T00:00:00.000Z',
66
+ scheduledAt: null,
67
+ createdAt: '2026-05-10T00:00:00.000Z',
112
68
  updatedAt: '2026-05-18T00:00:00.000Z',
113
- data: { seoScore: 88 },
114
- }),
115
- doc({
116
- id: 'b2',
117
- collection: 'blog',
118
- title: 'ActuateCMS vs Contentful',
119
- slug: 'actuatecms-vs-contentful',
120
- updatedAt: '2026-05-15T00:00:00.000Z',
121
- data: { seoScore: 92 },
122
- }),
123
- doc({
69
+ seoScore: 88,
70
+ author: { id: 'u-maria', name: 'Maria K' },
71
+ },
72
+ {
124
73
  id: 'b3',
125
74
  collection: 'blog',
126
75
  title: 'Mid-Year Review',
127
76
  slug: 'mid-year-cms-trends',
128
- status: 'DRAFT',
129
- createdById: 'u-sarah',
130
- updatedById: 'u-sarah',
77
+ status: 'DRAFT' as const,
131
78
  publishedAt: null,
79
+ scheduledAt: null,
80
+ createdAt: '2026-05-10T00:00:00.000Z',
132
81
  updatedAt: '2026-05-22T00:00:00.000Z',
133
- data: { seoScore: null },
134
- }),
82
+ seoScore: null,
83
+ author: null,
84
+ },
135
85
  ],
136
- 'case-studies': [
137
- doc({
138
- id: 'c1',
139
- collection: 'case-studies',
140
- title: 'Healthcare Client SEO Results',
141
- slug: 'healthcare-seo',
142
- status: 'DRAFT',
143
- publishedAt: null,
144
- updatedAt: '2026-05-19T00:00:00.000Z',
145
- data: { seoScore: 38 },
146
- }),
86
+ total: 5,
87
+ page: 1,
88
+ pageSize: 10,
89
+ types: [
90
+ {
91
+ slug: 'blog',
92
+ singular: 'Blog Post',
93
+ plural: 'Blog',
94
+ description: 'Blog posts',
95
+ urlPrefix: 'blog',
96
+ color: 'purple',
97
+ icon: 'file-text',
98
+ fieldCount: 12,
99
+ count: 3,
100
+ },
101
+ {
102
+ slug: 'news',
103
+ singular: 'News Item',
104
+ plural: 'News',
105
+ description: 'News',
106
+ urlPrefix: 'news',
107
+ color: 'orange',
108
+ icon: 'newspaper',
109
+ fieldCount: 11,
110
+ count: 1,
111
+ },
147
112
  ],
148
- news: [
149
- doc({
150
- id: 'n1',
151
- collection: 'news',
152
- title: 'Q2 2026 Product Roadmap Update',
153
- slug: 'q2-roadmap',
154
- status: 'SCHEDULED',
155
- createdById: 'u-sarah',
156
- updatedById: 'u-sarah',
157
- publishedAt: null,
158
- scheduledAt: '2026-06-01T00:00:00.000Z',
159
- updatedAt: '2026-05-24T00:00:00.000Z',
160
- data: { seoScore: 62 },
161
- }),
113
+ authors: [
114
+ { id: 'u-sarah', name: 'Sarah Morgan' },
115
+ { id: 'u-maria', name: 'Maria K' },
162
116
  ],
163
117
  }
164
118
 
119
+ /** Records the last `/posts` URL the service requested. */
120
+ let lastPostsUrl = ''
121
+
165
122
  function installDefaultMocks() {
123
+ lastPostsUrl = ''
166
124
  cmsApiMock.mockImplementation((url: string) => {
167
125
  if (url === '/collections') return { data: COLLECTIONS_RESPONSE }
168
- if (url.startsWith('/users')) return { data: USERS_RESPONSE }
169
- const m = url.match(/^\/collections\/([^/?]+)/)
170
- if (m && m[1]) {
171
- const slug = decodeURIComponent(m[1])
172
- return { data: { docs: DOCS[slug] ?? [], total: DOCS[slug]?.length ?? 0 } }
126
+ if (url.startsWith('/posts')) {
127
+ lastPostsUrl = url
128
+ return { data: POSTS_RESPONSE }
173
129
  }
174
130
  return { error: `unexpected url: ${url}` }
175
131
  })
@@ -183,7 +139,7 @@ describe('posts-service.fetchPostTypes', () => {
183
139
 
184
140
  it('returns only post-typed collections (filters out type: page)', async () => {
185
141
  const types = await fetchPostTypes()
186
- expect(types.map((t) => t.slug).sort()).toEqual(['blog', 'case-studies', 'news'])
142
+ expect(types.map((t) => t.slug).sort()).toEqual(['blog'])
187
143
  })
188
144
 
189
145
  it('maps collection metadata onto the PostType shape', async () => {
@@ -212,68 +168,83 @@ describe('posts-service.fetchPosts', () => {
212
168
  installDefaultMocks()
213
169
  })
214
170
 
215
- it('aggregates posts across all post-typed collections', async () => {
216
- const res = await fetchPosts({ pageSize: 20 })
171
+ it('maps the server posts onto the Post shape', async () => {
172
+ const res = await fetchPosts({ pageSize: 10 })
217
173
  expect(res.total).toBe(5)
218
- expect(res.posts.map((p) => p.id).sort()).toEqual(['b1', 'b2', 'b3', 'c1', 'n1'])
219
- })
220
-
221
- it('joins authors via /users', async () => {
222
- const res = await fetchPosts({ pageSize: 20 })
223
- const ai = res.posts.find((p) => p.id === 'b1')
224
- expect(ai?.authorName).toBe('Maria K')
225
- expect(ai?.authorInitials).toBe('MK')
226
- })
227
-
228
- it('sorts by updated date desc by default and surfaces scheduled date for SCHEDULED posts', async () => {
229
- const res = await fetchPosts({ pageSize: 20 })
230
- // The SCHEDULED news post should win the "date" sort because its
231
- // scheduledAt is the most recent date in the fixture set.
232
- expect(res.posts[0]?.id).toBe('n1')
233
- expect(res.posts[0]?.publishDate).toBe('2026-06-01T00:00:00.000Z')
174
+ expect(res.posts.map((p) => p.id)).toEqual(['n1', 'b1', 'b3'])
175
+ const blogPost = res.posts.find((p) => p.id === 'b1')
176
+ expect(blogPost).toMatchObject({
177
+ title: 'The Future of AI Content Marketing',
178
+ postTypeId: 'blog',
179
+ postTypeName: 'Blog Post',
180
+ postTypeAccentColor: 'purple',
181
+ authorId: 'u-maria',
182
+ authorName: 'Maria K',
183
+ authorInitials: 'MK',
184
+ status: 'PUBLISHED',
185
+ seoScore: 88,
186
+ })
234
187
  })
235
188
 
236
- it('filters by postType', async () => {
237
- const res = await fetchPosts({ postType: 'blog', pageSize: 20 })
238
- expect(res.total).toBe(3)
239
- expect(res.posts.every((p) => p.postTypeId === 'blog')).toBe(true)
189
+ it('surfaces the scheduled date for SCHEDULED posts as the display date', async () => {
190
+ const res = await fetchPosts({})
191
+ const news = res.posts.find((p) => p.id === 'n1')
192
+ expect(news?.publishDate).toBe('2026-06-01T00:00:00.000Z')
193
+ expect(news?.updatedDate).toBe('2026-06-01T00:00:00.000Z')
240
194
  })
241
195
 
242
- it('filters by status', async () => {
243
- const res = await fetchPosts({ status: 'DRAFT', pageSize: 20 })
244
- expect(res.posts.map((p) => p.id).sort()).toEqual(['b3', 'c1'])
196
+ it('falls back gracefully when a post has no author', async () => {
197
+ const res = await fetchPosts({})
198
+ const orphan = res.posts.find((p) => p.id === 'b3')
199
+ expect(orphan?.authorName).toBe('Unknown')
200
+ expect(orphan?.authorInitials).toBe('??')
201
+ expect(orphan?.authorId).toBe('')
245
202
  })
246
203
 
247
- it('filters by author', async () => {
248
- const res = await fetchPosts({ author: 'u-sarah', pageSize: 20 })
249
- expect(res.posts.map((p) => p.id).sort()).toEqual(['b3', 'n1'])
204
+ it('maps the dataset-wide tab counts from the endpoint', async () => {
205
+ const res = await fetchPosts({})
206
+ expect(res.types.find((t) => t.slug === 'blog')?.postCount).toBe(3)
207
+ expect(res.types.find((t) => t.slug === 'news')?.postCount).toBe(1)
250
208
  })
251
209
 
252
- it('searches across title, slug, author name, and post type', async () => {
253
- const res = await fetchPosts({ search: 'roadmap' })
254
- expect(res.posts).toHaveLength(1)
255
- expect(res.posts[0]?.id).toBe('n1')
210
+ it('returns the contributor list sorted by name', async () => {
211
+ const res = await fetchPosts({})
212
+ expect(res.authors.map((a) => a.name)).toEqual(['Maria K', 'Sarah Morgan'])
256
213
  })
257
214
 
258
- it('paginates and exposes the unfiltered tab counts', async () => {
259
- const res = await fetchPosts({ pageSize: 2 })
260
- expect(res.posts).toHaveLength(2)
261
- expect(res.total).toBe(5)
262
- const blogTab = res.types.find((t) => t.slug === 'blog')
263
- expect(blogTab?.postCount).toBe(3)
264
- const newsTab = res.types.find((t) => t.slug === 'news')
265
- expect(newsTab?.postCount).toBe(1)
215
+ it('forwards filter / sort / pagination params to the endpoint', async () => {
216
+ await fetchPosts({
217
+ search: 'roadmap',
218
+ postType: 'news',
219
+ status: 'SCHEDULED',
220
+ author: 'u-sarah',
221
+ sortBy: 'title',
222
+ sortDirection: 'asc',
223
+ page: 2,
224
+ pageSize: 25,
225
+ })
226
+ const params = new URLSearchParams(lastPostsUrl.split('?')[1] ?? '')
227
+ expect(params.get('search')).toBe('roadmap')
228
+ expect(params.get('postType')).toBe('news')
229
+ expect(params.get('status')).toBe('SCHEDULED')
230
+ expect(params.get('author')).toBe('u-sarah')
231
+ expect(params.get('sortBy')).toBe('title')
232
+ expect(params.get('sortDirection')).toBe('asc')
233
+ expect(params.get('page')).toBe('2')
234
+ expect(params.get('pageSize')).toBe('25')
266
235
  })
267
236
 
268
- it('returns only authors actually present on posts', async () => {
269
- const res = await fetchPosts({ pageSize: 20 })
270
- expect(res.authors.map((a) => a.id).sort()).toEqual(['u-david', 'u-maria', 'u-sarah'])
237
+ it('omits "all" filters from the query string', async () => {
238
+ await fetchPosts({ postType: 'all', status: 'all', author: 'all' })
239
+ const params = new URLSearchParams(lastPostsUrl.split('?')[1] ?? '')
240
+ expect(params.has('postType')).toBe(false)
241
+ expect(params.has('status')).toBe(false)
242
+ expect(params.has('author')).toBe(false)
271
243
  })
272
244
 
273
- it('uses the postType filter to scope the result set without touching the tab counts', async () => {
274
- const res = await fetchPosts({ postType: 'news', pageSize: 20 })
275
- expect(res.total).toBe(1)
276
- // Tab counts still reflect the full corpus.
277
- expect(res.types.find((t) => t.slug === 'blog')?.postCount).toBe(3)
245
+ it('throws when the endpoint returns an error', async () => {
246
+ cmsApiMock.mockReset()
247
+ cmsApiMock.mockImplementation(() => ({ error: 'boom' }))
248
+ await expect(fetchPosts({})).rejects.toThrow('boom')
278
249
  })
279
250
  })