@actuate-media/cms-admin 0.18.0 → 0.20.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 (167) hide show
  1. package/dist/AdminRoot.d.ts.map +1 -1
  2. package/dist/AdminRoot.js +9 -5
  3. package/dist/AdminRoot.js.map +1 -1
  4. package/dist/__tests__/lib/post-editor-service.test.js +75 -23
  5. package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
  6. package/dist/__tests__/lib/posts-service.test.js +132 -142
  7. package/dist/__tests__/lib/posts-service.test.js.map +1 -1
  8. package/dist/__tests__/views/posts-list-view.render.test.d.ts +2 -0
  9. package/dist/__tests__/views/posts-list-view.render.test.d.ts.map +1 -0
  10. package/dist/__tests__/views/posts-list-view.render.test.js +73 -0
  11. package/dist/__tests__/views/posts-list-view.render.test.js.map +1 -0
  12. package/dist/actuate-admin.css +1 -1
  13. package/dist/components/seo/Drawer.d.ts +11 -0
  14. package/dist/components/seo/Drawer.d.ts.map +1 -0
  15. package/dist/components/seo/Drawer.js +13 -0
  16. package/dist/components/seo/Drawer.js.map +1 -0
  17. package/dist/components/seo/RedirectEditorDrawer.d.ts +9 -0
  18. package/dist/components/seo/RedirectEditorDrawer.d.ts.map +1 -0
  19. package/dist/components/seo/RedirectEditorDrawer.js +94 -0
  20. package/dist/components/seo/RedirectEditorDrawer.js.map +1 -0
  21. package/dist/components/seo/SeoCopilotDrawer.d.ts +10 -0
  22. package/dist/components/seo/SeoCopilotDrawer.d.ts.map +1 -0
  23. package/dist/components/seo/SeoCopilotDrawer.js +34 -0
  24. package/dist/components/seo/SeoCopilotDrawer.js.map +1 -0
  25. package/dist/components/seo/SeoEditorDrawer.d.ts +8 -0
  26. package/dist/components/seo/SeoEditorDrawer.d.ts.map +1 -0
  27. package/dist/components/seo/SeoEditorDrawer.js +111 -0
  28. package/dist/components/seo/SeoEditorDrawer.js.map +1 -0
  29. package/dist/components/seo/SeoIssueFixDrawer.d.ts +8 -0
  30. package/dist/components/seo/SeoIssueFixDrawer.d.ts.map +1 -0
  31. package/dist/components/seo/SeoIssueFixDrawer.js +111 -0
  32. package/dist/components/seo/SeoIssueFixDrawer.js.map +1 -0
  33. package/dist/components/seo/primitives.d.ts +53 -0
  34. package/dist/components/seo/primitives.d.ts.map +1 -0
  35. package/dist/components/seo/primitives.js +100 -0
  36. package/dist/components/seo/primitives.js.map +1 -0
  37. package/dist/index.d.ts +0 -2
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +0 -2
  40. package/dist/index.js.map +1 -1
  41. package/dist/layout/Sidebar.d.ts.map +1 -1
  42. package/dist/layout/Sidebar.js +3 -0
  43. package/dist/layout/Sidebar.js.map +1 -1
  44. package/dist/lib/post-editor-service.d.ts +6 -4
  45. package/dist/lib/post-editor-service.d.ts.map +1 -1
  46. package/dist/lib/post-editor-service.js +32 -37
  47. package/dist/lib/post-editor-service.js.map +1 -1
  48. package/dist/lib/posts-service.d.ts.map +1 -1
  49. package/dist/lib/posts-service.js +91 -160
  50. package/dist/lib/posts-service.js.map +1 -1
  51. package/dist/lib/seo-service.d.ts +322 -0
  52. package/dist/lib/seo-service.d.ts.map +1 -0
  53. package/dist/lib/seo-service.js +295 -0
  54. package/dist/lib/seo-service.js.map +1 -0
  55. package/dist/router/index.d.ts.map +1 -1
  56. package/dist/router/index.js +4 -1
  57. package/dist/router/index.js.map +1 -1
  58. package/dist/views/MediaBrowser.d.ts.map +1 -1
  59. package/dist/views/MediaBrowser.js +36 -25
  60. package/dist/views/MediaBrowser.js.map +1 -1
  61. package/dist/views/Posts/NewPostPicker.d.ts +5 -4
  62. package/dist/views/Posts/NewPostPicker.d.ts.map +1 -1
  63. package/dist/views/Posts/NewPostPicker.js +5 -4
  64. package/dist/views/Posts/NewPostPicker.js.map +1 -1
  65. package/dist/views/Posts/NewPostTypeInstructions.js +2 -2
  66. package/dist/views/Posts/PostTypesView.d.ts +5 -4
  67. package/dist/views/Posts/PostTypesView.d.ts.map +1 -1
  68. package/dist/views/Posts/PostTypesView.js.map +1 -1
  69. package/dist/views/Posts/PostsListView.d.ts.map +1 -1
  70. package/dist/views/Posts/PostsListView.js +8 -0
  71. package/dist/views/Posts/PostsListView.js.map +1 -1
  72. package/dist/views/Posts/post-type-presets.d.ts +5 -0
  73. package/dist/views/Posts/post-type-presets.d.ts.map +1 -1
  74. package/dist/views/Posts/post-type-presets.js +10 -64
  75. package/dist/views/Posts/post-type-presets.js.map +1 -1
  76. package/dist/views/SEO.d.ts.map +1 -1
  77. package/dist/views/SEO.js +89 -132
  78. package/dist/views/SEO.js.map +1 -1
  79. package/dist/views/page-editor/sections/ArticleBodySection.d.ts +6 -2
  80. package/dist/views/page-editor/sections/ArticleBodySection.d.ts.map +1 -1
  81. package/dist/views/page-editor/sections/ArticleBodySection.js +8 -8
  82. package/dist/views/page-editor/sections/ArticleBodySection.js.map +1 -1
  83. package/dist/views/post-editor/PostEditorCanvas.js +7 -1
  84. package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
  85. package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
  86. package/dist/views/post-editor/PostFieldsModal.js +10 -4
  87. package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
  88. package/dist/views/post-editor/PostHeader.d.ts.map +1 -1
  89. package/dist/views/post-editor/PostHeader.js +4 -3
  90. package/dist/views/post-editor/PostHeader.js.map +1 -1
  91. package/dist/views/post-editor/PostPreview.d.ts.map +1 -1
  92. package/dist/views/post-editor/PostPreview.js +11 -1
  93. package/dist/views/post-editor/PostPreview.js.map +1 -1
  94. package/dist/views/post-editor/PostSectionEditor.d.ts +22 -2
  95. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  96. package/dist/views/post-editor/PostSectionEditor.js +4 -1
  97. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  98. package/dist/views/seo/AuditTab.d.ts +6 -0
  99. package/dist/views/seo/AuditTab.d.ts.map +1 -0
  100. package/dist/views/seo/AuditTab.js +57 -0
  101. package/dist/views/seo/AuditTab.js.map +1 -0
  102. package/dist/views/seo/ContentTab.d.ts +5 -0
  103. package/dist/views/seo/ContentTab.d.ts.map +1 -0
  104. package/dist/views/seo/ContentTab.js +27 -0
  105. package/dist/views/seo/ContentTab.js.map +1 -0
  106. package/dist/views/seo/OverviewTab.d.ts +8 -0
  107. package/dist/views/seo/OverviewTab.d.ts.map +1 -0
  108. package/dist/views/seo/OverviewTab.js +35 -0
  109. package/dist/views/seo/OverviewTab.js.map +1 -0
  110. package/dist/views/seo/RedirectsTab.d.ts +2 -0
  111. package/dist/views/seo/RedirectsTab.d.ts.map +1 -0
  112. package/dist/views/seo/RedirectsTab.js +124 -0
  113. package/dist/views/seo/RedirectsTab.js.map +1 -0
  114. package/dist/views/seo/TechnicalTab.d.ts +2 -0
  115. package/dist/views/seo/TechnicalTab.d.ts.map +1 -0
  116. package/dist/views/seo/TechnicalTab.js +102 -0
  117. package/dist/views/seo/TechnicalTab.js.map +1 -0
  118. package/dist/views/seo/useSeoResource.d.ts +8 -0
  119. package/dist/views/seo/useSeoResource.d.ts.map +1 -0
  120. package/dist/views/seo/useSeoResource.js +42 -0
  121. package/dist/views/seo/useSeoResource.js.map +1 -0
  122. package/package.json +2 -2
  123. package/src/AdminRoot.tsx +9 -5
  124. package/src/__tests__/lib/post-editor-service.test.ts +78 -23
  125. package/src/__tests__/lib/posts-service.test.ts +134 -163
  126. package/src/__tests__/views/posts-list-view.render.test.tsx +84 -0
  127. package/src/components/seo/Drawer.tsx +61 -0
  128. package/src/components/seo/RedirectEditorDrawer.tsx +235 -0
  129. package/src/components/seo/SeoCopilotDrawer.tsx +76 -0
  130. package/src/components/seo/SeoEditorDrawer.tsx +347 -0
  131. package/src/components/seo/SeoIssueFixDrawer.tsx +244 -0
  132. package/src/components/seo/primitives.tsx +237 -0
  133. package/src/index.ts +0 -2
  134. package/src/layout/Sidebar.tsx +3 -0
  135. package/src/lib/post-editor-service.ts +41 -40
  136. package/src/lib/posts-service.ts +109 -180
  137. package/src/lib/seo-service.ts +623 -0
  138. package/src/router/index.ts +4 -1
  139. package/src/views/MediaBrowser.tsx +89 -67
  140. package/src/views/Posts/NewPostPicker.tsx +5 -4
  141. package/src/views/Posts/NewPostTypeInstructions.tsx +2 -2
  142. package/src/views/Posts/PostTypesView.tsx +5 -4
  143. package/src/views/Posts/PostsListView.tsx +9 -0
  144. package/src/views/Posts/post-type-presets.ts +15 -64
  145. package/src/views/SEO.tsx +186 -863
  146. package/src/views/page-editor/sections/ArticleBodySection.tsx +12 -16
  147. package/src/views/post-editor/PostEditorCanvas.tsx +7 -1
  148. package/src/views/post-editor/PostFieldsModal.tsx +36 -21
  149. package/src/views/post-editor/PostHeader.tsx +4 -4
  150. package/src/views/post-editor/PostPreview.tsx +12 -1
  151. package/src/views/post-editor/PostSectionEditor.tsx +22 -5
  152. package/src/views/seo/AuditTab.tsx +229 -0
  153. package/src/views/seo/ContentTab.tsx +164 -0
  154. package/src/views/seo/OverviewTab.tsx +257 -0
  155. package/src/views/seo/RedirectsTab.tsx +392 -0
  156. package/src/views/seo/TechnicalTab.tsx +303 -0
  157. package/src/views/seo/useSeoResource.ts +52 -0
  158. package/dist/views/PostEditor.d.ts +0 -6
  159. package/dist/views/PostEditor.d.ts.map +0 -1
  160. package/dist/views/PostEditor.js +0 -79
  161. package/dist/views/PostEditor.js.map +0 -1
  162. package/dist/views/Posts.d.ts +0 -5
  163. package/dist/views/Posts.d.ts.map +0 -1
  164. package/dist/views/Posts.js +0 -73
  165. package/dist/views/Posts.js.map +0 -1
  166. package/src/views/PostEditor.tsx +0 -257
  167. package/src/views/Posts.tsx +0 -375
@@ -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
  })
@@ -0,0 +1,84 @@
1
+ // @vitest-environment happy-dom
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
4
+
5
+ // Drive the list purely through a mocked posts-service so the test can
6
+ // control what each page returns. fetchPosts echoes the requested page so
7
+ // we can assert page-scoped selection behaviour without a real backend.
8
+ function makePost(id: string, title: string) {
9
+ return {
10
+ id,
11
+ postTypeId: 'articles',
12
+ postTypeName: 'Article',
13
+ postTypeAccentColor: 'blue',
14
+ title,
15
+ slug: id,
16
+ authorName: 'Author',
17
+ authorInitials: 'AU',
18
+ status: 'PUBLISHED',
19
+ updatedDate: '2026-01-01T00:00:00.000Z',
20
+ seoScore: 80,
21
+ }
22
+ }
23
+
24
+ const PAGE_ONE = [makePost('a1', 'Alpha'), makePost('a2', 'Alpha Two')]
25
+ const PAGE_TWO = [makePost('b1', 'Beta'), makePost('b2', 'Beta Two')]
26
+
27
+ const fetchPosts = vi.fn(async ({ page }: { page: number }) => ({
28
+ posts: page === 1 ? PAGE_ONE : PAGE_TWO,
29
+ total: 20,
30
+ types: [{ slug: 'articles', name: 'Article', postCount: 20 }],
31
+ authors: [],
32
+ }))
33
+ const bulkUpdatePosts = vi.fn(async () => ({ ok: 0, failed: 0 }))
34
+ const bulkDeletePosts = vi.fn(async () => ({ ok: 0, failed: 0 }))
35
+
36
+ vi.mock('../../lib/posts-service.js', () => ({
37
+ fetchPosts: (args: { page: number }) => fetchPosts(args),
38
+ bulkUpdatePosts: (...args: unknown[]) => bulkUpdatePosts(...(args as [])),
39
+ bulkDeletePosts: (...args: unknown[]) => bulkDeletePosts(...(args as [])),
40
+ }))
41
+
42
+ const { PostsListView } = await import('../../views/Posts/PostsListView.js')
43
+
44
+ beforeEach(() => {
45
+ fetchPosts.mockClear()
46
+ bulkUpdatePosts.mockClear()
47
+ bulkDeletePosts.mockClear()
48
+ })
49
+
50
+ describe('PostsListView — page-scoped selection', () => {
51
+ it('clears the selection when navigating to another page so the bulk count stays honest', async () => {
52
+ render(<PostsListView onNavigate={() => {}} />)
53
+
54
+ // Wait for page 1 to load, then select a row.
55
+ const alpha = await screen.findByLabelText('Select "Alpha"')
56
+ fireEvent.click(alpha)
57
+ expect(screen.getByText('1 selected')).toBeTruthy()
58
+
59
+ // Navigate to page 2.
60
+ fireEvent.click(screen.getByLabelText('Next page'))
61
+
62
+ // Page 2 rows render and the stale selection is gone — no bulk bar,
63
+ // so a bulk publish/delete can never silently no-op against page 1.
64
+ await screen.findByLabelText('Select "Beta"')
65
+ expect(screen.queryByText('1 selected')).toBeNull()
66
+ expect(screen.queryByText(/selected$/)).toBeNull()
67
+ })
68
+
69
+ it('keeps the bulk count equal to the rows a bulk action will touch', async () => {
70
+ render(<PostsListView onNavigate={() => {}} />)
71
+
72
+ const alpha = await screen.findByLabelText('Select "Alpha"')
73
+ const alphaTwo = await screen.findByLabelText('Select "Alpha Two"')
74
+ fireEvent.click(alpha)
75
+ fireEvent.click(alphaTwo)
76
+ expect(screen.getByText('2 selected')).toBeTruthy()
77
+
78
+ fireEvent.click(screen.getByText('Publish'))
79
+ await waitFor(() => expect(bulkUpdatePosts).toHaveBeenCalledTimes(1))
80
+ // The handler received exactly the two rows the bar advertised.
81
+ const ids = (bulkUpdatePosts.mock.calls[0] as unknown as [unknown[]])[0]
82
+ expect(ids).toHaveLength(2)
83
+ })
84
+ })
@@ -0,0 +1,61 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Right-side drawer built on Radix Dialog — focus-trapped, escape-closable,
5
+ * token-only styling. Shared by the SEO editor / fix / copilot drawers.
6
+ */
7
+ import * as Dialog from '@radix-ui/react-dialog'
8
+ import { X } from 'lucide-react'
9
+ import type { ReactNode } from 'react'
10
+ import { btnGhostIcon } from './primitives.js'
11
+
12
+ export function Drawer({
13
+ open,
14
+ onOpenChange,
15
+ title,
16
+ description,
17
+ footer,
18
+ children,
19
+ width = 'max-w-xl',
20
+ }: {
21
+ open: boolean
22
+ onOpenChange: (open: boolean) => void
23
+ title: string
24
+ description?: string
25
+ footer?: ReactNode
26
+ children: ReactNode
27
+ width?: string
28
+ }) {
29
+ return (
30
+ <Dialog.Root open={open} onOpenChange={onOpenChange}>
31
+ <Dialog.Portal>
32
+ <Dialog.Overlay className="bg-foreground/40 motion-safe:animate-in motion-safe:fade-in fixed inset-0 z-50" />
33
+ <Dialog.Content
34
+ className={`bg-card text-foreground fixed inset-y-0 right-0 z-50 flex w-full ${width} motion-safe:animate-in motion-safe:slide-in-from-right flex-col shadow-xl outline-none`}
35
+ >
36
+ <header className="border-border flex items-start justify-between gap-3 border-b px-5 py-4">
37
+ <div className="min-w-0">
38
+ <Dialog.Title className="text-foreground text-lg font-medium">{title}</Dialog.Title>
39
+ {description && (
40
+ <Dialog.Description className="text-muted-foreground mt-0.5 text-sm">
41
+ {description}
42
+ </Dialog.Description>
43
+ )}
44
+ </div>
45
+ <Dialog.Close asChild>
46
+ <button className={btnGhostIcon} aria-label="Close">
47
+ <X size={20} aria-hidden />
48
+ </button>
49
+ </Dialog.Close>
50
+ </header>
51
+ <div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">{children}</div>
52
+ {footer && (
53
+ <footer className="border-border bg-card flex items-center justify-end gap-2 border-t px-5 py-4">
54
+ {footer}
55
+ </footer>
56
+ )}
57
+ </Dialog.Content>
58
+ </Dialog.Portal>
59
+ </Dialog.Root>
60
+ )
61
+ }