@prenta/admin 1.5.1 → 1.6.1
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/CHANGELOG.md +12 -0
- package/dist/__tests__/components/request-changes-dialog.render.test.d.ts +2 -0
- package/dist/__tests__/components/request-changes-dialog.render.test.d.ts.map +1 -0
- package/dist/__tests__/components/request-changes-dialog.render.test.js +12 -0
- package/dist/__tests__/components/request-changes-dialog.render.test.js.map +1 -0
- package/dist/__tests__/lib/page-audit-input.test.js +10 -0
- package/dist/__tests__/lib/page-audit-input.test.js.map +1 -1
- package/dist/__tests__/lib/page-editor-service-api.test.js +28 -15
- package/dist/__tests__/lib/page-editor-service-api.test.js.map +1 -1
- package/dist/__tests__/lib/post-editor-service-api.test.d.ts +2 -0
- package/dist/__tests__/lib/post-editor-service-api.test.d.ts.map +1 -0
- package/dist/__tests__/lib/post-editor-service-api.test.js +68 -0
- package/dist/__tests__/lib/post-editor-service-api.test.js.map +1 -0
- package/dist/__tests__/lib/post-editor-service.test.js +27 -6
- package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
- package/dist/__tests__/lib/version-history.test.js +27 -0
- package/dist/__tests__/lib/version-history.test.js.map +1 -1
- package/dist/__tests__/lib/workflow-chrome.test.d.ts +2 -0
- package/dist/__tests__/lib/workflow-chrome.test.d.ts.map +1 -0
- package/dist/__tests__/lib/workflow-chrome.test.js +108 -0
- package/dist/__tests__/lib/workflow-chrome.test.js.map +1 -0
- package/dist/__tests__/views/editor-top-bar.render.test.js +40 -33
- package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
- package/dist/__tests__/views/page-section-editor.test.js +8 -0
- package/dist/__tests__/views/page-section-editor.test.js.map +1 -1
- package/dist/__tests__/views/page-tab.render.test.js +2 -0
- package/dist/__tests__/views/page-tab.render.test.js.map +1 -1
- package/dist/__tests__/views/post-fields-modal.render.test.js +21 -0
- package/dist/__tests__/views/post-fields-modal.render.test.js.map +1 -1
- package/dist/__tests__/views/post-section-editor.test.js +3 -0
- package/dist/__tests__/views/post-section-editor.test.js.map +1 -1
- package/dist/__tests__/views/post-tab.render.test.js +3 -0
- package/dist/__tests__/views/post-tab.render.test.js.map +1 -1
- package/dist/components/RequestChangesDialog.d.ts +6 -0
- package/dist/components/RequestChangesDialog.d.ts.map +1 -0
- package/dist/components/RequestChangesDialog.js +15 -0
- package/dist/components/RequestChangesDialog.js.map +1 -0
- package/dist/lib/page-audit-input.js +1 -1
- package/dist/lib/page-audit-input.js.map +1 -1
- package/dist/lib/page-editor-service.d.ts +6 -2
- package/dist/lib/page-editor-service.d.ts.map +1 -1
- package/dist/lib/page-editor-service.js +16 -12
- package/dist/lib/page-editor-service.js.map +1 -1
- package/dist/lib/post-editor-service.d.ts +24 -4
- package/dist/lib/post-editor-service.d.ts.map +1 -1
- package/dist/lib/post-editor-service.js +49 -24
- package/dist/lib/post-editor-service.js.map +1 -1
- package/dist/lib/version-history.d.ts.map +1 -1
- package/dist/lib/version-history.js +3 -9
- package/dist/lib/version-history.js.map +1 -1
- package/dist/lib/workflow-chrome.d.ts +22 -0
- package/dist/lib/workflow-chrome.d.ts.map +1 -0
- package/dist/lib/workflow-chrome.js +80 -0
- package/dist/lib/workflow-chrome.js.map +1 -0
- package/dist/prenta-admin.css +1 -1
- package/dist/views/page-editor/EditorTopBar.d.ts +10 -5
- package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
- package/dist/views/page-editor/EditorTopBar.js +38 -28
- package/dist/views/page-editor/EditorTopBar.js.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.js +83 -62
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/dist/views/post-editor/PostFieldsForm.d.ts +3 -0
- package/dist/views/post-editor/PostFieldsForm.d.ts.map +1 -1
- package/dist/views/post-editor/PostFieldsForm.js +7 -2
- package/dist/views/post-editor/PostFieldsForm.js.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.js +15 -2
- package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +81 -65
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
- package/dist/views/post-editor/PostTab.d.ts.map +1 -1
- package/dist/views/post-editor/PostTab.js +1 -0
- package/dist/views/post-editor/PostTab.js.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/components/request-changes-dialog.render.test.tsx +11 -0
- package/src/__tests__/lib/page-audit-input.test.ts +11 -0
- package/src/__tests__/lib/page-editor-service-api.test.ts +37 -18
- package/src/__tests__/lib/post-editor-service-api.test.ts +74 -0
- package/src/__tests__/lib/post-editor-service.test.ts +28 -9
- package/src/__tests__/lib/version-history.test.ts +29 -0
- package/src/__tests__/lib/workflow-chrome.test.ts +125 -0
- package/src/__tests__/views/editor-top-bar.render.test.tsx +42 -45
- package/src/__tests__/views/page-section-editor.test.tsx +18 -0
- package/src/__tests__/views/page-tab.render.test.tsx +2 -0
- package/src/__tests__/views/post-fields-modal.render.test.tsx +41 -0
- package/src/__tests__/views/post-section-editor.test.tsx +3 -0
- package/src/__tests__/views/post-tab.render.test.tsx +3 -0
- package/src/components/RequestChangesDialog.tsx +56 -0
- package/src/lib/page-audit-input.ts +1 -1
- package/src/lib/page-editor-service.ts +26 -11
- package/src/lib/post-editor-service.ts +71 -20
- package/src/lib/version-history.ts +3 -7
- package/src/lib/workflow-chrome.ts +105 -0
- package/src/views/page-editor/EditorTopBar.tsx +110 -48
- package/src/views/page-editor/PageSectionEditor.tsx +121 -92
- package/src/views/post-editor/PostFieldsForm.tsx +14 -5
- package/src/views/post-editor/PostFieldsModal.tsx +15 -2
- package/src/views/post-editor/PostSectionEditor.tsx +114 -91
- package/src/views/post-editor/PostTab.tsx +1 -0
|
@@ -9,8 +9,14 @@ vi.mock('../../lib/api.js', () => ({
|
|
|
9
9
|
cmsApi: (...args: unknown[]) => cmsApi(...args),
|
|
10
10
|
}))
|
|
11
11
|
|
|
12
|
-
const {
|
|
13
|
-
|
|
12
|
+
const {
|
|
13
|
+
fetchPageForEditor,
|
|
14
|
+
savePageDraft,
|
|
15
|
+
publishPage,
|
|
16
|
+
transitionPageWorkflow,
|
|
17
|
+
missingPageFields,
|
|
18
|
+
emptyPage,
|
|
19
|
+
} = await import('../../lib/page-editor-service.js')
|
|
14
20
|
|
|
15
21
|
afterEach(() => {
|
|
16
22
|
cmsApi.mockReset()
|
|
@@ -188,18 +194,34 @@ describe('savePageDraft', () => {
|
|
|
188
194
|
})
|
|
189
195
|
})
|
|
190
196
|
|
|
191
|
-
describe('
|
|
192
|
-
it('
|
|
193
|
-
cmsApi.
|
|
194
|
-
data: {
|
|
197
|
+
describe('transitionPageWorkflow', () => {
|
|
198
|
+
it('transitionPageWorkflow POSTs stage to /workflow then reloads the page', async () => {
|
|
199
|
+
cmsApi.mockResolvedValueOnce({ data: { success: true }, status: 200 }).mockResolvedValueOnce({
|
|
200
|
+
data: {
|
|
201
|
+
id: 'p1',
|
|
202
|
+
title: 'About',
|
|
203
|
+
slug: 'about',
|
|
204
|
+
status: 'DRAFT',
|
|
205
|
+
workflowStage: 'IN_REVIEW',
|
|
206
|
+
reviewNote: null,
|
|
207
|
+
data: {},
|
|
208
|
+
},
|
|
195
209
|
status: 200,
|
|
196
210
|
})
|
|
197
211
|
|
|
198
|
-
await
|
|
212
|
+
const next = await transitionPageWorkflow(draft('p1'), 'IN_REVIEW')
|
|
213
|
+
expect(cmsApi.mock.calls[0]![0]).toBe('/collections/pages/p1/workflow')
|
|
214
|
+
expect(JSON.parse((cmsApi.mock.calls[0]![1] as RequestInit).body as string)).toEqual({
|
|
215
|
+
stage: 'IN_REVIEW',
|
|
216
|
+
})
|
|
217
|
+
expect(next.workflowStage).toBe('IN_REVIEW')
|
|
218
|
+
})
|
|
219
|
+
})
|
|
199
220
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
expect(
|
|
221
|
+
describe('publishPage', () => {
|
|
222
|
+
it('publishPage refuses to skip review', async () => {
|
|
223
|
+
await expect(publishPage(draft('p1'))).rejects.toThrow(/Submit for review before publishing/)
|
|
224
|
+
expect(cmsApi).not.toHaveBeenCalled()
|
|
203
225
|
})
|
|
204
226
|
})
|
|
205
227
|
|
|
@@ -282,13 +304,10 @@ describe('section settings survive a save', () => {
|
|
|
282
304
|
expect(body.sections[0].settings).toEqual(styled.settings)
|
|
283
305
|
})
|
|
284
306
|
|
|
285
|
-
it('
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
const body = JSON.parse((cmsApi.mock.calls[0]![1] as { body: string }).body)
|
|
291
|
-
expect(body.sections[0].settings).toEqual(styled.settings)
|
|
292
|
-
expect(body.status).toBe('PUBLISHED')
|
|
307
|
+
it('does not persist via publishPage (review gate)', async () => {
|
|
308
|
+
await expect(publishPage({ ...draft('p1'), sections: [styled] })).rejects.toThrow(
|
|
309
|
+
/Submit for review before publishing/,
|
|
310
|
+
)
|
|
311
|
+
expect(cmsApi).not.toHaveBeenCalled()
|
|
293
312
|
})
|
|
294
313
|
})
|
|
@@ -0,0 +1,74 @@
|
|
|
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 { transitionPostWorkflow, publishPost } = await import('../../lib/post-editor-service.js')
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
cmsApi.mockReset()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
function draft(id: string | null) {
|
|
19
|
+
return {
|
|
20
|
+
id,
|
|
21
|
+
postType: 'blog',
|
|
22
|
+
title: 'Hello',
|
|
23
|
+
slug: 'hello',
|
|
24
|
+
excerpt: '',
|
|
25
|
+
featuredImage: '',
|
|
26
|
+
featuredImageUrl: '',
|
|
27
|
+
body: '',
|
|
28
|
+
category: '',
|
|
29
|
+
tags: [],
|
|
30
|
+
status: 'DRAFT' as const,
|
|
31
|
+
workflowStage: 'DRAFT' as const,
|
|
32
|
+
reviewNote: null,
|
|
33
|
+
publishDate: null,
|
|
34
|
+
scheduledAt: null,
|
|
35
|
+
scheduledUnpublishAt: null,
|
|
36
|
+
sections: [],
|
|
37
|
+
seoTitle: '',
|
|
38
|
+
seoDescription: '',
|
|
39
|
+
publishedAt: null,
|
|
40
|
+
updatedAt: null,
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('transitionPostWorkflow', () => {
|
|
45
|
+
it('transitionPostWorkflow POSTs stage to /workflow then reloads the post', async () => {
|
|
46
|
+
cmsApi.mockResolvedValueOnce({ data: { success: true }, status: 200 }).mockResolvedValueOnce({
|
|
47
|
+
data: {
|
|
48
|
+
id: 'p1',
|
|
49
|
+
title: 'Hello',
|
|
50
|
+
slug: 'hello',
|
|
51
|
+
status: 'DRAFT',
|
|
52
|
+
workflowStage: 'IN_REVIEW',
|
|
53
|
+
reviewNote: null,
|
|
54
|
+
data: {},
|
|
55
|
+
},
|
|
56
|
+
status: 200,
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const next = await transitionPostWorkflow(draft('p1'), 'IN_REVIEW')
|
|
60
|
+
expect(cmsApi.mock.calls[0]![0]).toBe('/collections/blog/p1/workflow')
|
|
61
|
+
expect(JSON.parse((cmsApi.mock.calls[0]![1] as RequestInit).body as string)).toEqual({
|
|
62
|
+
stage: 'IN_REVIEW',
|
|
63
|
+
})
|
|
64
|
+
expect(cmsApi.mock.calls[1]![0]).toBe('/collections/blog/p1')
|
|
65
|
+
expect(next.workflowStage).toBe('IN_REVIEW')
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe('publishPost', () => {
|
|
70
|
+
it('publishPost refuses to skip review', async () => {
|
|
71
|
+
await expect(publishPost(draft('p1'))).rejects.toThrow(/Submit for review before publishing/)
|
|
72
|
+
expect(cmsApi).not.toHaveBeenCalled()
|
|
73
|
+
})
|
|
74
|
+
})
|
|
@@ -17,6 +17,7 @@ const {
|
|
|
17
17
|
missingPostFields,
|
|
18
18
|
savePostDraft,
|
|
19
19
|
parseFeaturedImage,
|
|
20
|
+
parseFeaturedImageRef,
|
|
20
21
|
} = await import('../../lib/post-editor-service.js')
|
|
21
22
|
|
|
22
23
|
type EditorPostT = Awaited<ReturnType<typeof emptyPostFromTemplate>>
|
|
@@ -29,10 +30,13 @@ function basePost(over: Partial<EditorPostT> = {}): EditorPostT {
|
|
|
29
30
|
slug: 'hello',
|
|
30
31
|
excerpt: '',
|
|
31
32
|
featuredImage: '',
|
|
33
|
+
featuredImageUrl: '',
|
|
32
34
|
body: '',
|
|
33
35
|
category: '',
|
|
34
36
|
tags: [],
|
|
35
37
|
status: 'DRAFT',
|
|
38
|
+
workflowStage: 'DRAFT',
|
|
39
|
+
reviewNote: null,
|
|
36
40
|
publishDate: null,
|
|
37
41
|
scheduledAt: null,
|
|
38
42
|
scheduledUnpublishAt: null,
|
|
@@ -492,21 +496,34 @@ describe('tags round-trip', () => {
|
|
|
492
496
|
expect(post.tags).toEqual(['AI', 'Nextjs'])
|
|
493
497
|
})
|
|
494
498
|
|
|
499
|
+
it('parseFeaturedImageRef splits persist id from display url', () => {
|
|
500
|
+
const mediaId = 'cmry1z9yr000004l832th7bf2'
|
|
501
|
+
const url = 'https://cdn.example.com/hero.webp'
|
|
502
|
+
expect(parseFeaturedImageRef({ id: mediaId, url, alt: 'Hero' })).toEqual({
|
|
503
|
+
id: mediaId,
|
|
504
|
+
url,
|
|
505
|
+
})
|
|
506
|
+
expect(parseFeaturedImageRef(mediaId)).toEqual({ id: mediaId, url: '' })
|
|
507
|
+
expect(parseFeaturedImageRef(url)).toEqual({ id: url, url })
|
|
508
|
+
expect(parseFeaturedImageRef({ publicUrl: url })).toEqual({ id: url, url })
|
|
509
|
+
expect(parseFeaturedImageRef({ src: '/uploads/hero.webp' })).toEqual({
|
|
510
|
+
id: '/uploads/hero.webp',
|
|
511
|
+
url: '/uploads/hero.webp',
|
|
512
|
+
})
|
|
513
|
+
})
|
|
514
|
+
|
|
495
515
|
it('parseFeaturedImage / fetchPostForEditor keep denormalized media objects (#671)', async () => {
|
|
496
516
|
const mediaId = 'cmry1z9yr000004l832th7bf2'
|
|
517
|
+
const url = 'https://cdn.example.com/hero.webp'
|
|
497
518
|
expect(
|
|
498
519
|
parseFeaturedImage({
|
|
499
520
|
id: mediaId,
|
|
500
|
-
url
|
|
521
|
+
url,
|
|
501
522
|
alt: 'Hero',
|
|
502
523
|
}),
|
|
503
524
|
).toBe(mediaId)
|
|
504
|
-
expect(parseFeaturedImage(
|
|
505
|
-
|
|
506
|
-
)
|
|
507
|
-
expect(parseFeaturedImage({ url: 'https://cdn.example.com/hero.webp' })).toBe(
|
|
508
|
-
'https://cdn.example.com/hero.webp',
|
|
509
|
-
)
|
|
525
|
+
expect(parseFeaturedImage(url)).toBe(url)
|
|
526
|
+
expect(parseFeaturedImage({ url })).toBe(url)
|
|
510
527
|
|
|
511
528
|
cmsApiMock.mockResolvedValueOnce({
|
|
512
529
|
data: {
|
|
@@ -517,7 +534,7 @@ describe('tags round-trip', () => {
|
|
|
517
534
|
data: {
|
|
518
535
|
featuredImage: {
|
|
519
536
|
id: mediaId,
|
|
520
|
-
url
|
|
537
|
+
url,
|
|
521
538
|
alt: 'Hero',
|
|
522
539
|
},
|
|
523
540
|
},
|
|
@@ -525,16 +542,18 @@ describe('tags round-trip', () => {
|
|
|
525
542
|
})
|
|
526
543
|
const post = await fetchPostForEditor('blog', 'p1')
|
|
527
544
|
expect(post.featuredImage).toBe(mediaId)
|
|
545
|
+
expect(post.featuredImageUrl).toBe(url)
|
|
528
546
|
|
|
529
547
|
cmsApiMock.mockResolvedValueOnce({
|
|
530
548
|
data: { id: 'p1', title: 'Hello', slug: 'hello', status: 'DRAFT', data: {} },
|
|
531
549
|
})
|
|
532
|
-
await savePostDraft(basePost({ id: 'p1', featuredImage: mediaId }))
|
|
550
|
+
await savePostDraft(basePost({ id: 'p1', featuredImage: mediaId, featuredImageUrl: url }))
|
|
533
551
|
const body = JSON.parse(cmsApiMock.mock.calls[1]![1].body as string) as {
|
|
534
552
|
featuredImage: string
|
|
535
553
|
}
|
|
536
554
|
expect(body.featuredImage).toBe(mediaId)
|
|
537
555
|
expect(body.featuredImage).not.toBe('')
|
|
556
|
+
expect(body).not.toHaveProperty('featuredImageUrl')
|
|
538
557
|
})
|
|
539
558
|
})
|
|
540
559
|
|
|
@@ -60,6 +60,35 @@ describe('toPageVersion', () => {
|
|
|
60
60
|
expect(mapped.body).toBe('<p>Snapshot body</p>')
|
|
61
61
|
})
|
|
62
62
|
|
|
63
|
+
it('does not treat a bare media id as a canvas preview URL', () => {
|
|
64
|
+
const mapped = toPageVersion({
|
|
65
|
+
id: 'v1',
|
|
66
|
+
changeType: 'UPDATE',
|
|
67
|
+
createdAt: '2026-08-02T12:00:00Z',
|
|
68
|
+
data: {
|
|
69
|
+
featuredImage: 'cmssbdfxo000004jrzphzt2bj',
|
|
70
|
+
sections: [],
|
|
71
|
+
},
|
|
72
|
+
})
|
|
73
|
+
expect(mapped.featuredImage).toBeUndefined()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('prefers the public URL over the media id on a denormalized snapshot', () => {
|
|
77
|
+
const mapped = toPageVersion({
|
|
78
|
+
id: 'v1',
|
|
79
|
+
changeType: 'UPDATE',
|
|
80
|
+
createdAt: '2026-08-02T12:00:00Z',
|
|
81
|
+
data: {
|
|
82
|
+
featuredImage: {
|
|
83
|
+
id: 'cmssbdfxo000004jrzphzt2bj',
|
|
84
|
+
url: 'https://cdn.example.com/hero.webp',
|
|
85
|
+
},
|
|
86
|
+
sections: [],
|
|
87
|
+
},
|
|
88
|
+
})
|
|
89
|
+
expect(mapped.featuredImage).toBe('https://cdn.example.com/hero.webp')
|
|
90
|
+
})
|
|
91
|
+
|
|
63
92
|
it('preserves an unmanaged section rather than dropping it from the snapshot', () => {
|
|
64
93
|
// Coercing a snapshot more strictly than the live document reads would
|
|
65
94
|
// make an externally-managed section look deleted in every version.
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { resolveWorkflowChrome } from '../../lib/workflow-chrome.js'
|
|
3
|
+
|
|
4
|
+
const draft = {
|
|
5
|
+
stage: 'DRAFT' as const,
|
|
6
|
+
status: 'DRAFT' as const,
|
|
7
|
+
canEdit: true,
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('resolveWorkflowChrome', () => {
|
|
11
|
+
it('author on draft sees Submit for review', () => {
|
|
12
|
+
expect(resolveWorkflowChrome({ ...draft, role: 'AUTHOR' })).toMatchObject({
|
|
13
|
+
primary: 'submit',
|
|
14
|
+
showRequestChanges: false,
|
|
15
|
+
showSchedule: false,
|
|
16
|
+
statusLabel: 'Draft',
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('editor on draft also submits — no one-click publish', () => {
|
|
21
|
+
expect(resolveWorkflowChrome({ ...draft, role: 'EDITOR' })).toMatchObject({
|
|
22
|
+
primary: 'submit',
|
|
23
|
+
showRequestChanges: false,
|
|
24
|
+
showSchedule: false,
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('author in review waits', () => {
|
|
29
|
+
const chrome = resolveWorkflowChrome({
|
|
30
|
+
stage: 'IN_REVIEW',
|
|
31
|
+
status: 'DRAFT',
|
|
32
|
+
role: 'AUTHOR',
|
|
33
|
+
canEdit: true,
|
|
34
|
+
})
|
|
35
|
+
expect(chrome.primary).toBe('waiting')
|
|
36
|
+
expect(chrome.waitingLabel).toBe('Waiting for review.')
|
|
37
|
+
expect(chrome.showRequestChanges).toBe(false)
|
|
38
|
+
expect(chrome.showSchedule).toBe(false)
|
|
39
|
+
expect(chrome.statusLabel).toBe('In review')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('editor in review can approve, request changes, and schedule', () => {
|
|
43
|
+
expect(
|
|
44
|
+
resolveWorkflowChrome({
|
|
45
|
+
stage: 'IN_REVIEW',
|
|
46
|
+
status: 'DRAFT',
|
|
47
|
+
role: 'ADMIN',
|
|
48
|
+
canEdit: true,
|
|
49
|
+
}),
|
|
50
|
+
).toMatchObject({
|
|
51
|
+
primary: 'approve',
|
|
52
|
+
showRequestChanges: true,
|
|
53
|
+
showSchedule: true,
|
|
54
|
+
statusLabel: 'In review',
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('does not stop on APPROVED — editor still approves', () => {
|
|
59
|
+
expect(
|
|
60
|
+
resolveWorkflowChrome({
|
|
61
|
+
stage: 'APPROVED',
|
|
62
|
+
status: 'DRAFT',
|
|
63
|
+
role: 'EDITOR',
|
|
64
|
+
canEdit: true,
|
|
65
|
+
}).primary,
|
|
66
|
+
).toBe('approve')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('treats status PUBLISHED as published even when stage is still DRAFT', () => {
|
|
70
|
+
const chrome = resolveWorkflowChrome({
|
|
71
|
+
stage: 'DRAFT',
|
|
72
|
+
status: 'PUBLISHED',
|
|
73
|
+
role: 'ADMIN',
|
|
74
|
+
canEdit: true,
|
|
75
|
+
})
|
|
76
|
+
expect(chrome.primary).toBeNull()
|
|
77
|
+
expect(chrome.showRequestChanges).toBe(false)
|
|
78
|
+
expect(chrome.showSchedule).toBe(false)
|
|
79
|
+
expect(chrome.statusLabel).toBe('Published')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('shows Scheduled when status is SCHEDULED', () => {
|
|
83
|
+
const chrome = resolveWorkflowChrome({
|
|
84
|
+
stage: 'IN_REVIEW',
|
|
85
|
+
status: 'SCHEDULED',
|
|
86
|
+
role: 'EDITOR',
|
|
87
|
+
canEdit: true,
|
|
88
|
+
})
|
|
89
|
+
expect(chrome.primary).toBeNull()
|
|
90
|
+
expect(chrome.showSchedule).toBe(true)
|
|
91
|
+
expect(chrome.showRequestChanges).toBe(true)
|
|
92
|
+
expect(chrome.statusLabel).toBe('Scheduled')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('scheduled reviewer actions do not require IN_REVIEW or APPROVED stage', () => {
|
|
96
|
+
expect(
|
|
97
|
+
resolveWorkflowChrome({
|
|
98
|
+
stage: 'DRAFT',
|
|
99
|
+
status: 'SCHEDULED',
|
|
100
|
+
role: 'EDITOR',
|
|
101
|
+
canEdit: true,
|
|
102
|
+
}),
|
|
103
|
+
).toMatchObject({
|
|
104
|
+
primary: null,
|
|
105
|
+
showRequestChanges: true,
|
|
106
|
+
showSchedule: true,
|
|
107
|
+
statusLabel: 'Scheduled',
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('hides mutations when canEdit is false', () => {
|
|
112
|
+
expect(
|
|
113
|
+
resolveWorkflowChrome({
|
|
114
|
+
stage: 'DRAFT',
|
|
115
|
+
status: 'DRAFT',
|
|
116
|
+
role: 'AUTHOR',
|
|
117
|
+
canEdit: false,
|
|
118
|
+
}),
|
|
119
|
+
).toMatchObject({
|
|
120
|
+
primary: null,
|
|
121
|
+
showRequestChanges: false,
|
|
122
|
+
showSchedule: false,
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
})
|
|
@@ -7,83 +7,80 @@ import { EditorTopBar } from '../../views/page-editor/EditorTopBar.js'
|
|
|
7
7
|
const baseProps = {
|
|
8
8
|
title: 'About',
|
|
9
9
|
status: 'DRAFT' as const,
|
|
10
|
+
workflowStage: 'DRAFT' as const,
|
|
11
|
+
role: 'EDITOR',
|
|
10
12
|
dirty: false,
|
|
11
13
|
saveState: 'idle' as const,
|
|
12
14
|
publishing: false,
|
|
13
15
|
canEdit: true,
|
|
14
|
-
canPublish: true,
|
|
15
16
|
viewport: 'wide' as const,
|
|
16
17
|
onViewport: vi.fn(),
|
|
17
18
|
onBack: vi.fn(),
|
|
18
19
|
onOpenSettings: vi.fn(),
|
|
19
20
|
onSaveDraft: vi.fn(),
|
|
20
|
-
|
|
21
|
+
onSubmitForReview: vi.fn(),
|
|
22
|
+
onApprove: vi.fn(),
|
|
23
|
+
onRequestChanges: vi.fn(),
|
|
21
24
|
}
|
|
22
25
|
|
|
23
|
-
describe('EditorTopBar
|
|
24
|
-
it('
|
|
25
|
-
render(
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
publishDisabledReason="Add at least one visible section before publishing."
|
|
30
|
-
/>,
|
|
31
|
-
)
|
|
32
|
-
const btn = screen.getByRole('button', { name: 'Publish' }) as HTMLButtonElement
|
|
33
|
-
expect(btn.disabled).toBe(true)
|
|
34
|
-
expect(btn.getAttribute('title')).toBe('Add at least one visible section before publishing.')
|
|
26
|
+
describe('EditorTopBar review chrome', () => {
|
|
27
|
+
it('shows Submit for review on a draft, not Publish', () => {
|
|
28
|
+
render(<EditorTopBar {...baseProps} />)
|
|
29
|
+
expect(screen.getByRole('button', { name: 'Submit for review' })).toBeTruthy()
|
|
30
|
+
expect(screen.queryByRole('button', { name: 'Publish' })).toBeNull()
|
|
31
|
+
expect(screen.queryByRole('button', { name: 'Approve' })).toBeNull()
|
|
35
32
|
})
|
|
36
33
|
|
|
37
|
-
it('
|
|
38
|
-
render(<EditorTopBar {...baseProps}
|
|
39
|
-
expect(
|
|
40
|
-
|
|
41
|
-
)
|
|
34
|
+
it('author in review sees Waiting for review and no Approve', () => {
|
|
35
|
+
render(<EditorTopBar {...baseProps} role="AUTHOR" workflowStage="IN_REVIEW" />)
|
|
36
|
+
expect(screen.getByText('Waiting for review.')).toBeTruthy()
|
|
37
|
+
expect(screen.queryByRole('button', { name: 'Approve' })).toBeNull()
|
|
38
|
+
expect(screen.getByLabelText('Status: In review')).toBeTruthy()
|
|
42
39
|
})
|
|
43
40
|
|
|
44
|
-
it('
|
|
41
|
+
it('editor in review can Approve; Request changes opens the note dialog', () => {
|
|
42
|
+
const onApprove = vi.fn()
|
|
43
|
+
const onRequestChanges = vi.fn()
|
|
45
44
|
render(
|
|
46
45
|
<EditorTopBar
|
|
47
46
|
{...baseProps}
|
|
48
|
-
|
|
47
|
+
workflowStage="IN_REVIEW"
|
|
49
48
|
publishReady
|
|
50
|
-
|
|
49
|
+
onApprove={onApprove}
|
|
50
|
+
onRequestChanges={onRequestChanges}
|
|
51
51
|
/>,
|
|
52
52
|
)
|
|
53
|
-
|
|
54
|
-
expect(
|
|
55
|
-
|
|
53
|
+
fireEvent.click(screen.getByRole('button', { name: 'Approve' }))
|
|
54
|
+
expect(onApprove).toHaveBeenCalledOnce()
|
|
55
|
+
fireEvent.click(screen.getByRole('button', { name: 'Request changes' }))
|
|
56
|
+
expect(screen.getByRole('dialog', { name: 'Request changes' })).toBeTruthy()
|
|
57
|
+
fireEvent.click(screen.getByRole('button', { name: 'Send back' }))
|
|
58
|
+
expect(onRequestChanges).toHaveBeenCalledWith('')
|
|
56
59
|
})
|
|
57
60
|
|
|
58
|
-
it('
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
expect(onPublish).toHaveBeenCalledOnce()
|
|
61
|
+
it('disables Request changes while publishing', () => {
|
|
62
|
+
render(<EditorTopBar {...baseProps} workflowStage="IN_REVIEW" publishing publishReady />)
|
|
63
|
+
const btn = screen.getByRole('button', { name: 'Request changes' }) as HTMLButtonElement
|
|
64
|
+
expect(btn.disabled).toBe(true)
|
|
63
65
|
})
|
|
64
66
|
|
|
65
|
-
it('
|
|
66
|
-
const onSchedule = vi.fn()
|
|
67
|
+
it('disables Approve when publishReady is false and shows the reason', () => {
|
|
67
68
|
render(
|
|
68
69
|
<EditorTopBar
|
|
69
70
|
{...baseProps}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
scheduledAt="2026-07-11T16:00:00.000Z"
|
|
71
|
+
workflowStage="IN_REVIEW"
|
|
72
|
+
publishReady={false}
|
|
73
|
+
publishDisabledReason="Add at least one visible section before publishing."
|
|
74
74
|
/>,
|
|
75
75
|
)
|
|
76
|
-
const btn = screen.getByRole('button', { name: '
|
|
77
|
-
expect(
|
|
78
|
-
|
|
79
|
-
expect(onSchedule).toHaveBeenCalledOnce()
|
|
76
|
+
const btn = screen.getByRole('button', { name: 'Approve' }) as HTMLButtonElement
|
|
77
|
+
expect(btn.disabled).toBe(true)
|
|
78
|
+
expect(btn.getAttribute('title')).toBe('Add at least one visible section before publishing.')
|
|
80
79
|
})
|
|
81
80
|
|
|
82
|
-
it('
|
|
83
|
-
render(<EditorTopBar {...baseProps}
|
|
84
|
-
expect(
|
|
85
|
-
(screen.getByRole('button', { name: 'Schedule publish' }) as HTMLButtonElement).disabled,
|
|
86
|
-
).toBe(true)
|
|
81
|
+
it('shows the reviewer note on a draft', () => {
|
|
82
|
+
render(<EditorTopBar {...baseProps} reviewNote="Need a stronger lede." />)
|
|
83
|
+
expect(screen.getByText('Reviewer note: Need a stronger lede.')).toBeTruthy()
|
|
87
84
|
})
|
|
88
85
|
})
|
|
89
86
|
|
|
@@ -103,6 +103,8 @@ function loadedPage(sections: Array<{ id: string; sectionType: string; name: str
|
|
|
103
103
|
slug: 'home',
|
|
104
104
|
path: '/home',
|
|
105
105
|
status: 'DRAFT',
|
|
106
|
+
workflowStage: 'DRAFT',
|
|
107
|
+
reviewNote: null,
|
|
106
108
|
sections: sections.map((s) => ({ ...s, visible: true, content: {}, settings: {} })),
|
|
107
109
|
seoTitle: '',
|
|
108
110
|
seoDescription: '',
|
|
@@ -181,6 +183,22 @@ describe('PageSectionEditor structure mode', () => {
|
|
|
181
183
|
await screen.findByText('Sections')
|
|
182
184
|
expect(screen.getByRole('button', { name: 'Version history' })).toBeTruthy()
|
|
183
185
|
})
|
|
186
|
+
|
|
187
|
+
it('does not show Publish for an author on a draft', async () => {
|
|
188
|
+
fetchPageForEditor.mockResolvedValue(
|
|
189
|
+
loadedPage([{ id: 's1', sectionType: 'hero', name: 'Hero' }]),
|
|
190
|
+
)
|
|
191
|
+
render(
|
|
192
|
+
<PageSectionEditor
|
|
193
|
+
documentId="p1"
|
|
194
|
+
config={validConfig}
|
|
195
|
+
onNavigate={() => {}}
|
|
196
|
+
session={{ user: { role: 'AUTHOR' } }}
|
|
197
|
+
/>,
|
|
198
|
+
)
|
|
199
|
+
await screen.findByText('Sections')
|
|
200
|
+
expect(screen.queryByRole('button', { name: 'Publish' })).toBeNull()
|
|
201
|
+
})
|
|
184
202
|
})
|
|
185
203
|
|
|
186
204
|
/**
|
|
@@ -39,10 +39,13 @@ function basePost(over: Partial<ModalProps['post']> = {}): ModalProps['post'] {
|
|
|
39
39
|
slug: 'hello',
|
|
40
40
|
excerpt: '',
|
|
41
41
|
featuredImage: '',
|
|
42
|
+
featuredImageUrl: '',
|
|
42
43
|
body: '',
|
|
43
44
|
category: '',
|
|
44
45
|
tags: [],
|
|
45
46
|
status: 'DRAFT',
|
|
47
|
+
workflowStage: 'DRAFT',
|
|
48
|
+
reviewNote: null,
|
|
46
49
|
publishDate: null,
|
|
47
50
|
scheduledAt: null,
|
|
48
51
|
scheduledUnpublishAt: null,
|
|
@@ -173,4 +176,42 @@ describe('PostFieldsModal', () => {
|
|
|
173
176
|
expect(onClose).toHaveBeenCalledTimes(1)
|
|
174
177
|
expect(screen.queryByRole('dialog', { name: 'Discard unsaved changes?' })).toBeNull()
|
|
175
178
|
})
|
|
179
|
+
|
|
180
|
+
it('previews the featured image URL, not the stored media id', () => {
|
|
181
|
+
const mediaId = 'cmssbdfxo000004jrzphzt2bj'
|
|
182
|
+
const url = 'https://cdn.example.com/hero.webp'
|
|
183
|
+
render(
|
|
184
|
+
<PostFieldsModal
|
|
185
|
+
open
|
|
186
|
+
post={basePost({ featuredImage: mediaId, featuredImageUrl: url })}
|
|
187
|
+
canEdit
|
|
188
|
+
onClose={() => {}}
|
|
189
|
+
onSave={() => {}}
|
|
190
|
+
/>,
|
|
191
|
+
)
|
|
192
|
+
const input = screen.getByLabelText('Featured image') as HTMLInputElement
|
|
193
|
+
expect(input.value).toBe(url)
|
|
194
|
+
expect(input.value).not.toBe(mediaId)
|
|
195
|
+
const preview = document.querySelector('img')
|
|
196
|
+
expect(preview?.getAttribute('src')).toBe(url)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('saves the media id when the input is showing the public URL', () => {
|
|
200
|
+
const onSave = vi.fn()
|
|
201
|
+
const mediaId = 'cmssbdfxo000004jrzphzt2bj'
|
|
202
|
+
const url = 'https://cdn.example.com/hero.webp'
|
|
203
|
+
render(
|
|
204
|
+
<PostFieldsModal
|
|
205
|
+
open
|
|
206
|
+
post={basePost({ featuredImage: mediaId, featuredImageUrl: url })}
|
|
207
|
+
canEdit
|
|
208
|
+
onClose={() => {}}
|
|
209
|
+
onSave={onSave}
|
|
210
|
+
/>,
|
|
211
|
+
)
|
|
212
|
+
fireEvent.click(screen.getByRole('button', { name: 'Done' }))
|
|
213
|
+
expect(onSave).toHaveBeenCalledWith(
|
|
214
|
+
expect.objectContaining({ featuredImage: mediaId, featuredImageUrl: url }),
|
|
215
|
+
)
|
|
216
|
+
})
|
|
176
217
|
})
|
|
@@ -49,10 +49,13 @@ function loadedPost(
|
|
|
49
49
|
slug: 'logan',
|
|
50
50
|
excerpt: '',
|
|
51
51
|
featuredImage: '',
|
|
52
|
+
featuredImageUrl: '',
|
|
52
53
|
body: '',
|
|
53
54
|
category: '',
|
|
54
55
|
tags: [],
|
|
55
56
|
status: 'DRAFT',
|
|
57
|
+
workflowStage: 'DRAFT',
|
|
58
|
+
reviewNote: null,
|
|
56
59
|
publishDate: null,
|
|
57
60
|
sections: sections.map((s) => ({ ...s, visible: true, content: {}, settings: {} })),
|
|
58
61
|
seoTitle: '',
|
|
@@ -27,10 +27,13 @@ function basePost(over: Partial<EditorPost> = {}): EditorPost {
|
|
|
27
27
|
slug: 'hello',
|
|
28
28
|
excerpt: 'A short summary.',
|
|
29
29
|
featuredImage: '',
|
|
30
|
+
featuredImageUrl: '',
|
|
30
31
|
body: '',
|
|
31
32
|
category: '',
|
|
32
33
|
tags: [],
|
|
33
34
|
status: 'DRAFT',
|
|
35
|
+
workflowStage: 'DRAFT',
|
|
36
|
+
reviewNote: null,
|
|
34
37
|
publishDate: null,
|
|
35
38
|
scheduledAt: null,
|
|
36
39
|
scheduledUnpublishAt: null,
|