@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.
Files changed (101) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/__tests__/components/request-changes-dialog.render.test.d.ts +2 -0
  3. package/dist/__tests__/components/request-changes-dialog.render.test.d.ts.map +1 -0
  4. package/dist/__tests__/components/request-changes-dialog.render.test.js +12 -0
  5. package/dist/__tests__/components/request-changes-dialog.render.test.js.map +1 -0
  6. package/dist/__tests__/lib/page-audit-input.test.js +10 -0
  7. package/dist/__tests__/lib/page-audit-input.test.js.map +1 -1
  8. package/dist/__tests__/lib/page-editor-service-api.test.js +28 -15
  9. package/dist/__tests__/lib/page-editor-service-api.test.js.map +1 -1
  10. package/dist/__tests__/lib/post-editor-service-api.test.d.ts +2 -0
  11. package/dist/__tests__/lib/post-editor-service-api.test.d.ts.map +1 -0
  12. package/dist/__tests__/lib/post-editor-service-api.test.js +68 -0
  13. package/dist/__tests__/lib/post-editor-service-api.test.js.map +1 -0
  14. package/dist/__tests__/lib/post-editor-service.test.js +27 -6
  15. package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
  16. package/dist/__tests__/lib/version-history.test.js +27 -0
  17. package/dist/__tests__/lib/version-history.test.js.map +1 -1
  18. package/dist/__tests__/lib/workflow-chrome.test.d.ts +2 -0
  19. package/dist/__tests__/lib/workflow-chrome.test.d.ts.map +1 -0
  20. package/dist/__tests__/lib/workflow-chrome.test.js +108 -0
  21. package/dist/__tests__/lib/workflow-chrome.test.js.map +1 -0
  22. package/dist/__tests__/views/editor-top-bar.render.test.js +40 -33
  23. package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
  24. package/dist/__tests__/views/page-section-editor.test.js +8 -0
  25. package/dist/__tests__/views/page-section-editor.test.js.map +1 -1
  26. package/dist/__tests__/views/page-tab.render.test.js +2 -0
  27. package/dist/__tests__/views/page-tab.render.test.js.map +1 -1
  28. package/dist/__tests__/views/post-fields-modal.render.test.js +21 -0
  29. package/dist/__tests__/views/post-fields-modal.render.test.js.map +1 -1
  30. package/dist/__tests__/views/post-section-editor.test.js +3 -0
  31. package/dist/__tests__/views/post-section-editor.test.js.map +1 -1
  32. package/dist/__tests__/views/post-tab.render.test.js +3 -0
  33. package/dist/__tests__/views/post-tab.render.test.js.map +1 -1
  34. package/dist/components/RequestChangesDialog.d.ts +6 -0
  35. package/dist/components/RequestChangesDialog.d.ts.map +1 -0
  36. package/dist/components/RequestChangesDialog.js +15 -0
  37. package/dist/components/RequestChangesDialog.js.map +1 -0
  38. package/dist/lib/page-audit-input.js +1 -1
  39. package/dist/lib/page-audit-input.js.map +1 -1
  40. package/dist/lib/page-editor-service.d.ts +6 -2
  41. package/dist/lib/page-editor-service.d.ts.map +1 -1
  42. package/dist/lib/page-editor-service.js +16 -12
  43. package/dist/lib/page-editor-service.js.map +1 -1
  44. package/dist/lib/post-editor-service.d.ts +24 -4
  45. package/dist/lib/post-editor-service.d.ts.map +1 -1
  46. package/dist/lib/post-editor-service.js +49 -24
  47. package/dist/lib/post-editor-service.js.map +1 -1
  48. package/dist/lib/version-history.d.ts.map +1 -1
  49. package/dist/lib/version-history.js +3 -9
  50. package/dist/lib/version-history.js.map +1 -1
  51. package/dist/lib/workflow-chrome.d.ts +22 -0
  52. package/dist/lib/workflow-chrome.d.ts.map +1 -0
  53. package/dist/lib/workflow-chrome.js +80 -0
  54. package/dist/lib/workflow-chrome.js.map +1 -0
  55. package/dist/prenta-admin.css +1 -1
  56. package/dist/views/page-editor/EditorTopBar.d.ts +10 -5
  57. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
  58. package/dist/views/page-editor/EditorTopBar.js +38 -28
  59. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  60. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  61. package/dist/views/page-editor/PageSectionEditor.js +83 -62
  62. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  63. package/dist/views/post-editor/PostFieldsForm.d.ts +3 -0
  64. package/dist/views/post-editor/PostFieldsForm.d.ts.map +1 -1
  65. package/dist/views/post-editor/PostFieldsForm.js +7 -2
  66. package/dist/views/post-editor/PostFieldsForm.js.map +1 -1
  67. package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
  68. package/dist/views/post-editor/PostFieldsModal.js +15 -2
  69. package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
  70. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  71. package/dist/views/post-editor/PostSectionEditor.js +81 -65
  72. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  73. package/dist/views/post-editor/PostTab.d.ts.map +1 -1
  74. package/dist/views/post-editor/PostTab.js +1 -0
  75. package/dist/views/post-editor/PostTab.js.map +1 -1
  76. package/package.json +4 -4
  77. package/src/__tests__/components/request-changes-dialog.render.test.tsx +11 -0
  78. package/src/__tests__/lib/page-audit-input.test.ts +11 -0
  79. package/src/__tests__/lib/page-editor-service-api.test.ts +37 -18
  80. package/src/__tests__/lib/post-editor-service-api.test.ts +74 -0
  81. package/src/__tests__/lib/post-editor-service.test.ts +28 -9
  82. package/src/__tests__/lib/version-history.test.ts +29 -0
  83. package/src/__tests__/lib/workflow-chrome.test.ts +125 -0
  84. package/src/__tests__/views/editor-top-bar.render.test.tsx +42 -45
  85. package/src/__tests__/views/page-section-editor.test.tsx +18 -0
  86. package/src/__tests__/views/page-tab.render.test.tsx +2 -0
  87. package/src/__tests__/views/post-fields-modal.render.test.tsx +41 -0
  88. package/src/__tests__/views/post-section-editor.test.tsx +3 -0
  89. package/src/__tests__/views/post-tab.render.test.tsx +3 -0
  90. package/src/components/RequestChangesDialog.tsx +56 -0
  91. package/src/lib/page-audit-input.ts +1 -1
  92. package/src/lib/page-editor-service.ts +26 -11
  93. package/src/lib/post-editor-service.ts +71 -20
  94. package/src/lib/version-history.ts +3 -7
  95. package/src/lib/workflow-chrome.ts +105 -0
  96. package/src/views/page-editor/EditorTopBar.tsx +110 -48
  97. package/src/views/page-editor/PageSectionEditor.tsx +121 -92
  98. package/src/views/post-editor/PostFieldsForm.tsx +14 -5
  99. package/src/views/post-editor/PostFieldsModal.tsx +15 -2
  100. package/src/views/post-editor/PostSectionEditor.tsx +114 -91
  101. 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 { fetchPageForEditor, savePageDraft, publishPage, missingPageFields, emptyPage } =
13
- await import('../../lib/page-editor-service.js')
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('publishPage', () => {
192
- it('includes status: PUBLISHED in the payload', async () => {
193
- cmsApi.mockResolvedValue({
194
- data: { id: 'p1', title: 'About', slug: 'about', status: 'PUBLISHED', 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 publishPage(draft('p1'))
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
- const [, opts] = cmsApi.mock.calls[0] as [string, RequestInit]
201
- const body = JSON.parse(opts.body as string)
202
- expect(body.status).toBe('PUBLISHED')
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('round-trips them through publish too', async () => {
286
- cmsApi.mockResolvedValue({ data: { id: 'p1', data: {} } })
287
-
288
- await publishPage({ ...draft('p1'), sections: [styled] })
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: 'https://cdn.example.com/hero.webp',
521
+ url,
501
522
  alt: 'Hero',
502
523
  }),
503
524
  ).toBe(mediaId)
504
- expect(parseFeaturedImage('https://cdn.example.com/hero.webp')).toBe(
505
- 'https://cdn.example.com/hero.webp',
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: 'https://cdn.example.com/hero.webp',
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
- onPublish: vi.fn(),
21
+ onSubmitForReview: vi.fn(),
22
+ onApprove: vi.fn(),
23
+ onRequestChanges: vi.fn(),
21
24
  }
22
25
 
23
- describe('EditorTopBar publish gating', () => {
24
- it('disables Publish when publishReady is false and shows the blocking reason', () => {
25
- render(
26
- <EditorTopBar
27
- {...baseProps}
28
- publishReady={false}
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('enables Publish when the user can publish and the document is ready', () => {
38
- render(<EditorTopBar {...baseProps} publishReady />)
39
- expect((screen.getByRole('button', { name: 'Publish' }) as HTMLButtonElement).disabled).toBe(
40
- false,
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('stays disabled without publish permission even when publishReady', () => {
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
- canPublish={false}
47
+ workflowStage="IN_REVIEW"
49
48
  publishReady
50
- publishDisabledReason="Should not show for role block"
49
+ onApprove={onApprove}
50
+ onRequestChanges={onRequestChanges}
51
51
  />,
52
52
  )
53
- const btn = screen.getByRole('button', { name: 'Publish' }) as HTMLButtonElement
54
- expect(btn.disabled).toBe(true)
55
- expect(btn.getAttribute('title')).toBe('You do not have permission to publish')
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('calls onPublish when enabled and clicked', () => {
59
- const onPublish = vi.fn()
60
- render(<EditorTopBar {...baseProps} publishReady onPublish={onPublish} />)
61
- fireEvent.click(screen.getByRole('button', { name: 'Publish' }))
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('shows Schedule when onSchedule is provided and calls it when ready', () => {
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
- publishReady
71
- scheduleReady
72
- onSchedule={onSchedule}
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: 'Reschedule publish' })
77
- expect((btn as HTMLButtonElement).disabled).toBe(false)
78
- fireEvent.click(btn)
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('disables Schedule when scheduleReady is false', () => {
83
- render(<EditorTopBar {...baseProps} publishReady scheduleReady={false} onSchedule={vi.fn()} />)
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
  /**
@@ -11,6 +11,8 @@ const page: EditorPage = {
11
11
  slug: 'about',
12
12
  path: '/about',
13
13
  status: 'PUBLISHED',
14
+ workflowStage: 'PUBLISHED',
15
+ reviewNote: null,
14
16
  parentPageId: null,
15
17
  sections: [],
16
18
  seoTitle: '',
@@ -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,