@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.
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +9 -5
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/lib/post-editor-service.test.js +75 -23
- package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
- package/dist/__tests__/lib/posts-service.test.js +132 -142
- package/dist/__tests__/lib/posts-service.test.js.map +1 -1
- package/dist/__tests__/views/posts-list-view.render.test.d.ts +2 -0
- package/dist/__tests__/views/posts-list-view.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/posts-list-view.render.test.js +73 -0
- package/dist/__tests__/views/posts-list-view.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/seo/Drawer.d.ts +11 -0
- package/dist/components/seo/Drawer.d.ts.map +1 -0
- package/dist/components/seo/Drawer.js +13 -0
- package/dist/components/seo/Drawer.js.map +1 -0
- package/dist/components/seo/RedirectEditorDrawer.d.ts +9 -0
- package/dist/components/seo/RedirectEditorDrawer.d.ts.map +1 -0
- package/dist/components/seo/RedirectEditorDrawer.js +94 -0
- package/dist/components/seo/RedirectEditorDrawer.js.map +1 -0
- package/dist/components/seo/SeoCopilotDrawer.d.ts +10 -0
- package/dist/components/seo/SeoCopilotDrawer.d.ts.map +1 -0
- package/dist/components/seo/SeoCopilotDrawer.js +34 -0
- package/dist/components/seo/SeoCopilotDrawer.js.map +1 -0
- package/dist/components/seo/SeoEditorDrawer.d.ts +8 -0
- package/dist/components/seo/SeoEditorDrawer.d.ts.map +1 -0
- package/dist/components/seo/SeoEditorDrawer.js +111 -0
- package/dist/components/seo/SeoEditorDrawer.js.map +1 -0
- package/dist/components/seo/SeoIssueFixDrawer.d.ts +8 -0
- package/dist/components/seo/SeoIssueFixDrawer.d.ts.map +1 -0
- package/dist/components/seo/SeoIssueFixDrawer.js +111 -0
- package/dist/components/seo/SeoIssueFixDrawer.js.map +1 -0
- package/dist/components/seo/primitives.d.ts +53 -0
- package/dist/components/seo/primitives.d.ts.map +1 -0
- package/dist/components/seo/primitives.js +100 -0
- package/dist/components/seo/primitives.js.map +1 -0
- package/dist/index.d.ts +0 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -2
- package/dist/index.js.map +1 -1
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +3 -0
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/lib/post-editor-service.d.ts +6 -4
- package/dist/lib/post-editor-service.d.ts.map +1 -1
- package/dist/lib/post-editor-service.js +32 -37
- package/dist/lib/post-editor-service.js.map +1 -1
- package/dist/lib/posts-service.d.ts.map +1 -1
- package/dist/lib/posts-service.js +91 -160
- package/dist/lib/posts-service.js.map +1 -1
- package/dist/lib/seo-service.d.ts +322 -0
- package/dist/lib/seo-service.d.ts.map +1 -0
- package/dist/lib/seo-service.js +295 -0
- package/dist/lib/seo-service.js.map +1 -0
- package/dist/router/index.d.ts.map +1 -1
- package/dist/router/index.js +4 -1
- package/dist/router/index.js.map +1 -1
- package/dist/views/MediaBrowser.d.ts.map +1 -1
- package/dist/views/MediaBrowser.js +36 -25
- package/dist/views/MediaBrowser.js.map +1 -1
- package/dist/views/Posts/NewPostPicker.d.ts +5 -4
- package/dist/views/Posts/NewPostPicker.d.ts.map +1 -1
- package/dist/views/Posts/NewPostPicker.js +5 -4
- package/dist/views/Posts/NewPostPicker.js.map +1 -1
- package/dist/views/Posts/NewPostTypeInstructions.js +2 -2
- package/dist/views/Posts/PostTypesView.d.ts +5 -4
- package/dist/views/Posts/PostTypesView.d.ts.map +1 -1
- package/dist/views/Posts/PostTypesView.js.map +1 -1
- package/dist/views/Posts/PostsListView.d.ts.map +1 -1
- package/dist/views/Posts/PostsListView.js +8 -0
- package/dist/views/Posts/PostsListView.js.map +1 -1
- package/dist/views/Posts/post-type-presets.d.ts +5 -0
- package/dist/views/Posts/post-type-presets.d.ts.map +1 -1
- package/dist/views/Posts/post-type-presets.js +10 -64
- package/dist/views/Posts/post-type-presets.js.map +1 -1
- package/dist/views/SEO.d.ts.map +1 -1
- package/dist/views/SEO.js +89 -132
- package/dist/views/SEO.js.map +1 -1
- package/dist/views/page-editor/sections/ArticleBodySection.d.ts +6 -2
- package/dist/views/page-editor/sections/ArticleBodySection.d.ts.map +1 -1
- package/dist/views/page-editor/sections/ArticleBodySection.js +8 -8
- package/dist/views/page-editor/sections/ArticleBodySection.js.map +1 -1
- package/dist/views/post-editor/PostEditorCanvas.js +7 -1
- package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.js +10 -4
- package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
- package/dist/views/post-editor/PostHeader.d.ts.map +1 -1
- package/dist/views/post-editor/PostHeader.js +4 -3
- package/dist/views/post-editor/PostHeader.js.map +1 -1
- package/dist/views/post-editor/PostPreview.d.ts.map +1 -1
- package/dist/views/post-editor/PostPreview.js +11 -1
- package/dist/views/post-editor/PostPreview.js.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.d.ts +22 -2
- package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +4 -1
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
- package/dist/views/seo/AuditTab.d.ts +6 -0
- package/dist/views/seo/AuditTab.d.ts.map +1 -0
- package/dist/views/seo/AuditTab.js +57 -0
- package/dist/views/seo/AuditTab.js.map +1 -0
- package/dist/views/seo/ContentTab.d.ts +5 -0
- package/dist/views/seo/ContentTab.d.ts.map +1 -0
- package/dist/views/seo/ContentTab.js +27 -0
- package/dist/views/seo/ContentTab.js.map +1 -0
- package/dist/views/seo/OverviewTab.d.ts +8 -0
- package/dist/views/seo/OverviewTab.d.ts.map +1 -0
- package/dist/views/seo/OverviewTab.js +35 -0
- package/dist/views/seo/OverviewTab.js.map +1 -0
- package/dist/views/seo/RedirectsTab.d.ts +2 -0
- package/dist/views/seo/RedirectsTab.d.ts.map +1 -0
- package/dist/views/seo/RedirectsTab.js +124 -0
- package/dist/views/seo/RedirectsTab.js.map +1 -0
- package/dist/views/seo/TechnicalTab.d.ts +2 -0
- package/dist/views/seo/TechnicalTab.d.ts.map +1 -0
- package/dist/views/seo/TechnicalTab.js +102 -0
- package/dist/views/seo/TechnicalTab.js.map +1 -0
- package/dist/views/seo/useSeoResource.d.ts +8 -0
- package/dist/views/seo/useSeoResource.d.ts.map +1 -0
- package/dist/views/seo/useSeoResource.js +42 -0
- package/dist/views/seo/useSeoResource.js.map +1 -0
- package/package.json +2 -2
- package/src/AdminRoot.tsx +9 -5
- package/src/__tests__/lib/post-editor-service.test.ts +78 -23
- package/src/__tests__/lib/posts-service.test.ts +134 -163
- package/src/__tests__/views/posts-list-view.render.test.tsx +84 -0
- package/src/components/seo/Drawer.tsx +61 -0
- package/src/components/seo/RedirectEditorDrawer.tsx +235 -0
- package/src/components/seo/SeoCopilotDrawer.tsx +76 -0
- package/src/components/seo/SeoEditorDrawer.tsx +347 -0
- package/src/components/seo/SeoIssueFixDrawer.tsx +244 -0
- package/src/components/seo/primitives.tsx +237 -0
- package/src/index.ts +0 -2
- package/src/layout/Sidebar.tsx +3 -0
- package/src/lib/post-editor-service.ts +41 -40
- package/src/lib/posts-service.ts +109 -180
- package/src/lib/seo-service.ts +623 -0
- package/src/router/index.ts +4 -1
- package/src/views/MediaBrowser.tsx +89 -67
- package/src/views/Posts/NewPostPicker.tsx +5 -4
- package/src/views/Posts/NewPostTypeInstructions.tsx +2 -2
- package/src/views/Posts/PostTypesView.tsx +5 -4
- package/src/views/Posts/PostsListView.tsx +9 -0
- package/src/views/Posts/post-type-presets.ts +15 -64
- package/src/views/SEO.tsx +186 -863
- package/src/views/page-editor/sections/ArticleBodySection.tsx +12 -16
- package/src/views/post-editor/PostEditorCanvas.tsx +7 -1
- package/src/views/post-editor/PostFieldsModal.tsx +36 -21
- package/src/views/post-editor/PostHeader.tsx +4 -4
- package/src/views/post-editor/PostPreview.tsx +12 -1
- package/src/views/post-editor/PostSectionEditor.tsx +22 -5
- package/src/views/seo/AuditTab.tsx +229 -0
- package/src/views/seo/ContentTab.tsx +164 -0
- package/src/views/seo/OverviewTab.tsx +257 -0
- package/src/views/seo/RedirectsTab.tsx +392 -0
- package/src/views/seo/TechnicalTab.tsx +303 -0
- package/src/views/seo/useSeoResource.ts +52 -0
- package/dist/views/PostEditor.d.ts +0 -6
- package/dist/views/PostEditor.d.ts.map +0 -1
- package/dist/views/PostEditor.js +0 -79
- package/dist/views/PostEditor.js.map +0 -1
- package/dist/views/Posts.d.ts +0 -5
- package/dist/views/Posts.d.ts.map +0 -1
- package/dist/views/Posts.js +0 -73
- package/dist/views/Posts.js.map +0 -1
- package/src/views/PostEditor.tsx +0 -257
- 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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
134
|
-
|
|
82
|
+
seoScore: null,
|
|
83
|
+
author: null,
|
|
84
|
+
},
|
|
135
85
|
],
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
slug: '
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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('/
|
|
169
|
-
|
|
170
|
-
|
|
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'
|
|
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('
|
|
216
|
-
const res = await fetchPosts({ pageSize:
|
|
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)
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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('
|
|
237
|
-
const res = await fetchPosts({
|
|
238
|
-
|
|
239
|
-
expect(
|
|
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('
|
|
243
|
-
const res = await fetchPosts({
|
|
244
|
-
|
|
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('
|
|
248
|
-
const res = await fetchPosts({
|
|
249
|
-
expect(res.
|
|
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('
|
|
253
|
-
const res = await fetchPosts({
|
|
254
|
-
expect(res.
|
|
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('
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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('
|
|
269
|
-
|
|
270
|
-
|
|
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('
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
+
}
|