@prenta/admin 1.5.1 → 1.6.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 (75) hide show
  1. package/CHANGELOG.md +6 -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-editor-service-api.test.js +28 -15
  7. package/dist/__tests__/lib/page-editor-service-api.test.js.map +1 -1
  8. package/dist/__tests__/lib/post-editor-service-api.test.d.ts +2 -0
  9. package/dist/__tests__/lib/post-editor-service-api.test.d.ts.map +1 -0
  10. package/dist/__tests__/lib/post-editor-service-api.test.js +67 -0
  11. package/dist/__tests__/lib/post-editor-service-api.test.js.map +1 -0
  12. package/dist/__tests__/lib/post-editor-service.test.js +2 -0
  13. package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
  14. package/dist/__tests__/lib/workflow-chrome.test.d.ts +2 -0
  15. package/dist/__tests__/lib/workflow-chrome.test.d.ts.map +1 -0
  16. package/dist/__tests__/lib/workflow-chrome.test.js +108 -0
  17. package/dist/__tests__/lib/workflow-chrome.test.js.map +1 -0
  18. package/dist/__tests__/views/editor-top-bar.render.test.js +40 -33
  19. package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
  20. package/dist/__tests__/views/page-section-editor.test.js +8 -0
  21. package/dist/__tests__/views/page-section-editor.test.js.map +1 -1
  22. package/dist/__tests__/views/page-tab.render.test.js +2 -0
  23. package/dist/__tests__/views/page-tab.render.test.js.map +1 -1
  24. package/dist/__tests__/views/post-fields-modal.render.test.js +2 -0
  25. package/dist/__tests__/views/post-fields-modal.render.test.js.map +1 -1
  26. package/dist/__tests__/views/post-section-editor.test.js +2 -0
  27. package/dist/__tests__/views/post-section-editor.test.js.map +1 -1
  28. package/dist/__tests__/views/post-tab.render.test.js +2 -0
  29. package/dist/__tests__/views/post-tab.render.test.js.map +1 -1
  30. package/dist/components/RequestChangesDialog.d.ts +6 -0
  31. package/dist/components/RequestChangesDialog.d.ts.map +1 -0
  32. package/dist/components/RequestChangesDialog.js +15 -0
  33. package/dist/components/RequestChangesDialog.js.map +1 -0
  34. package/dist/lib/page-editor-service.d.ts +6 -2
  35. package/dist/lib/page-editor-service.d.ts.map +1 -1
  36. package/dist/lib/page-editor-service.js +16 -12
  37. package/dist/lib/page-editor-service.js.map +1 -1
  38. package/dist/lib/post-editor-service.d.ts +6 -1
  39. package/dist/lib/post-editor-service.d.ts.map +1 -1
  40. package/dist/lib/post-editor-service.js +16 -11
  41. package/dist/lib/post-editor-service.js.map +1 -1
  42. package/dist/lib/workflow-chrome.d.ts +22 -0
  43. package/dist/lib/workflow-chrome.d.ts.map +1 -0
  44. package/dist/lib/workflow-chrome.js +80 -0
  45. package/dist/lib/workflow-chrome.js.map +1 -0
  46. package/dist/prenta-admin.css +1 -1
  47. package/dist/views/page-editor/EditorTopBar.d.ts +10 -5
  48. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
  49. package/dist/views/page-editor/EditorTopBar.js +38 -28
  50. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  51. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  52. package/dist/views/page-editor/PageSectionEditor.js +83 -62
  53. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  54. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  55. package/dist/views/post-editor/PostSectionEditor.js +78 -62
  56. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  57. package/package.json +3 -3
  58. package/src/__tests__/components/request-changes-dialog.render.test.tsx +11 -0
  59. package/src/__tests__/lib/page-editor-service-api.test.ts +37 -18
  60. package/src/__tests__/lib/post-editor-service-api.test.ts +73 -0
  61. package/src/__tests__/lib/post-editor-service.test.ts +2 -0
  62. package/src/__tests__/lib/workflow-chrome.test.ts +125 -0
  63. package/src/__tests__/views/editor-top-bar.render.test.tsx +42 -45
  64. package/src/__tests__/views/page-section-editor.test.tsx +18 -0
  65. package/src/__tests__/views/page-tab.render.test.tsx +2 -0
  66. package/src/__tests__/views/post-fields-modal.render.test.tsx +2 -0
  67. package/src/__tests__/views/post-section-editor.test.tsx +2 -0
  68. package/src/__tests__/views/post-tab.render.test.tsx +2 -0
  69. package/src/components/RequestChangesDialog.tsx +56 -0
  70. package/src/lib/page-editor-service.ts +26 -11
  71. package/src/lib/post-editor-service.ts +26 -10
  72. package/src/lib/workflow-chrome.ts +105 -0
  73. package/src/views/page-editor/EditorTopBar.tsx +110 -48
  74. package/src/views/page-editor/PageSectionEditor.tsx +121 -92
  75. package/src/views/post-editor/PostSectionEditor.tsx +111 -88
@@ -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: '',
@@ -43,6 +43,8 @@ function basePost(over: Partial<ModalProps['post']> = {}): ModalProps['post'] {
43
43
  category: '',
44
44
  tags: [],
45
45
  status: 'DRAFT',
46
+ workflowStage: 'DRAFT',
47
+ reviewNote: null,
46
48
  publishDate: null,
47
49
  scheduledAt: null,
48
50
  scheduledUnpublishAt: null,
@@ -53,6 +53,8 @@ function loadedPost(
53
53
  category: '',
54
54
  tags: [],
55
55
  status: 'DRAFT',
56
+ workflowStage: 'DRAFT',
57
+ reviewNote: null,
56
58
  publishDate: null,
57
59
  sections: sections.map((s) => ({ ...s, visible: true, content: {}, settings: {} })),
58
60
  seoTitle: '',
@@ -31,6 +31,8 @@ function basePost(over: Partial<EditorPost> = {}): EditorPost {
31
31
  category: '',
32
32
  tags: [],
33
33
  status: 'DRAFT',
34
+ workflowStage: 'DRAFT',
35
+ reviewNote: null,
34
36
  publishDate: null,
35
37
  scheduledAt: null,
36
38
  scheduledUnpublishAt: null,
@@ -0,0 +1,56 @@
1
+ 'use client'
2
+
3
+ import { useRef, useState } from 'react'
4
+ import { Modal } from './ui/Modal.js'
5
+ import { Button } from './ui/Button.js'
6
+
7
+ export function RequestChangesDialog({
8
+ open,
9
+ onClose,
10
+ onConfirm,
11
+ }: {
12
+ open: boolean
13
+ onClose: () => void
14
+ onConfirm: (note: string) => void
15
+ }) {
16
+ const [note, setNote] = useState('')
17
+ const inputRef = useRef<HTMLTextAreaElement>(null)
18
+ return (
19
+ <Modal
20
+ open={open}
21
+ onClose={onClose}
22
+ title="Request changes"
23
+ description="Optional note for the author. The page returns to draft."
24
+ initialFocusRef={inputRef}
25
+ actions={
26
+ <>
27
+ <Button variant="ghost" onClick={onClose}>
28
+ Cancel
29
+ </Button>
30
+ <Button
31
+ variant="primary"
32
+ onClick={() => {
33
+ onConfirm(note.trim())
34
+ setNote('')
35
+ onClose()
36
+ }}
37
+ >
38
+ Send back
39
+ </Button>
40
+ </>
41
+ }
42
+ >
43
+ <label className="text-foreground block text-sm font-medium" htmlFor="review-note">
44
+ Note
45
+ </label>
46
+ <textarea
47
+ id="review-note"
48
+ ref={inputRef}
49
+ value={note}
50
+ onChange={(e) => setNote(e.target.value)}
51
+ rows={4}
52
+ className="border-border bg-input-background mt-1 w-full rounded-md border px-3 py-2 text-sm"
53
+ />
54
+ </Modal>
55
+ )
56
+ }
@@ -39,6 +39,7 @@ import {
39
39
  import { validateSlug } from '@prenta/core/client'
40
40
 
41
41
  import { missingCollectionFields } from './collection-schema.js'
42
+ import { SUBMIT_BEFORE_PUBLISH_ERROR, type WorkflowStage } from './workflow-chrome.js'
42
43
 
43
44
  export type { PageSection } from '@prenta/core/page-sections'
44
45
 
@@ -68,6 +69,8 @@ export interface EditorPage {
68
69
  slug: string
69
70
  path: string
70
71
  status: PageStatus
72
+ workflowStage: WorkflowStage
73
+ reviewNote: string | null
71
74
  /**
72
75
  * Parent page for the tree hierarchy (`data.parentPageId`), or null for
73
76
  * a top-level page. Preselected when arriving via "Add sub-page"
@@ -186,6 +189,8 @@ interface DocumentApiRow {
186
189
  title: string | null
187
190
  slug: string | null
188
191
  status: PageStatus
192
+ workflowStage?: WorkflowStage
193
+ reviewNote?: string | null
189
194
  publishedAt: string | null
190
195
  scheduledAt?: string | null
191
196
  scheduledUnpublishAt?: string | null
@@ -211,6 +216,8 @@ function rowToEditorPage(doc: DocumentApiRow): EditorPage {
211
216
  slug,
212
217
  path: typeof data.path === 'string' && data.path ? data.path : slug ? `/${slug}` : '/',
213
218
  status: doc.status ?? 'DRAFT',
219
+ workflowStage: (doc.workflowStage as WorkflowStage | undefined) ?? 'DRAFT',
220
+ reviewNote: typeof doc.reviewNote === 'string' ? doc.reviewNote : null,
214
221
  parentPageId:
215
222
  typeof data.parentPageId === 'string' && data.parentPageId ? data.parentPageId : null,
216
223
  // Preserve unknown (externally-managed) sections so the editor can show
@@ -290,17 +297,23 @@ export async function savePageDraft(page: EditorPage): Promise<EditorPage> {
290
297
  return rowToEditorPage(res.data)
291
298
  }
292
299
 
293
- /** Publish: persist the latest content and flip status to PUBLISHED. */
294
- export async function publishPage(page: EditorPage): Promise<EditorPage> {
295
- const endpoint = page.id
296
- ? `/collections/${PAGES_COLLECTION}/${page.id}`
297
- : `/collections/${PAGES_COLLECTION}`
298
- const res = await cmsApi<DocumentApiRow>(endpoint, {
299
- method: page.id ? 'PUT' : 'POST',
300
- body: toWriteBody(page, { status: 'PUBLISHED' }),
301
- })
302
- if (res.error || !res.data) throw new Error(res.error ?? 'Failed to publish page')
303
- return rowToEditorPage(res.data)
300
+ export async function transitionPageWorkflow(
301
+ page: EditorPage,
302
+ stage: WorkflowStage,
303
+ note?: string,
304
+ ): Promise<EditorPage> {
305
+ if (!page.id) throw new Error('Save the page before changing review state')
306
+ const res = await cmsApi<{ success: boolean }>(
307
+ `/collections/${PAGES_COLLECTION}/${page.id}/workflow`,
308
+ { method: 'POST', body: JSON.stringify({ stage, note }) },
309
+ )
310
+ if (res.error) throw new Error(res.error)
311
+ return fetchPageForEditor(page.id)
312
+ }
313
+
314
+ /** Leftover callers must not skip review — use `transitionPageWorkflow`. */
315
+ export async function publishPage(_page: EditorPage): Promise<EditorPage> {
316
+ throw new Error(SUBMIT_BEFORE_PUBLISH_ERROR)
304
317
  }
305
318
 
306
319
  /**
@@ -314,6 +327,8 @@ export function emptyPage(parentPageId: string | null = null): EditorPage {
314
327
  slug: '',
315
328
  path: '',
316
329
  status: 'DRAFT',
330
+ workflowStage: 'DRAFT',
331
+ reviewNote: null,
317
332
  parentPageId,
318
333
  sections: [],
319
334
  seoTitle: '',
@@ -21,6 +21,7 @@
21
21
  */
22
22
  import { cmsApi } from './api.js'
23
23
  import { missingCollectionFields } from './collection-schema.js'
24
+ import { SUBMIT_BEFORE_PUBLISH_ERROR, type WorkflowStage } from './workflow-chrome.js'
24
25
  import {
25
26
  getSectionType,
26
27
  generateSectionId,
@@ -79,6 +80,8 @@ export interface EditorPost {
79
80
  /** Editorial tags as plain strings (stored as `[{ tag }]` rows, see {@link parseTags}). */
80
81
  tags: string[]
81
82
  status: PostStatus
83
+ workflowStage: WorkflowStage
84
+ reviewNote: string | null
82
85
  publishDate: string | null
83
86
  /** When set and status is SCHEDULED, the cron publishes at this time. */
84
87
  scheduledAt: string | null
@@ -199,6 +202,8 @@ interface DocumentApiRow {
199
202
  title: string | null
200
203
  slug: string | null
201
204
  status: PostStatus
205
+ workflowStage?: WorkflowStage
206
+ reviewNote?: string | null
202
207
  publishedAt: string | null
203
208
  scheduledAt?: string | null
204
209
  scheduledUnpublishAt?: string | null
@@ -263,6 +268,8 @@ function rowToEditorPost(postType: string, doc: DocumentApiRow): EditorPost {
263
268
  category: dataStr(data, 'category'),
264
269
  tags: parseTags(data.tags),
265
270
  status: doc.status ?? 'DRAFT',
271
+ workflowStage: (doc.workflowStage as WorkflowStage | undefined) ?? 'DRAFT',
272
+ reviewNote: typeof doc.reviewNote === 'string' ? doc.reviewNote : null,
266
273
  publishDate: (data.publishDate as string) ?? doc.scheduledAt ?? null,
267
274
  scheduledAt: doc.scheduledAt ?? null,
268
275
  scheduledUnpublishAt: doc.scheduledUnpublishAt ?? null,
@@ -410,16 +417,23 @@ export async function savePostDraft(post: EditorPost): Promise<EditorPost> {
410
417
  return rowToEditorPost(post.postType, res.data)
411
418
  }
412
419
 
413
- export async function publishPost(post: EditorPost): Promise<EditorPost> {
414
- const endpoint = post.id
415
- ? `/collections/${encodeURIComponent(post.postType)}/${post.id}`
416
- : `/collections/${encodeURIComponent(post.postType)}`
417
- const res = await cmsApi<DocumentApiRow>(endpoint, {
418
- method: post.id ? 'PUT' : 'POST',
419
- body: toWriteBody(post, { status: 'PUBLISHED' }),
420
- })
421
- if (res.error || !res.data) throw new Error(res.error ?? 'Failed to publish post')
422
- return rowToEditorPost(post.postType, res.data)
420
+ export async function transitionPostWorkflow(
421
+ post: EditorPost,
422
+ stage: WorkflowStage,
423
+ note?: string,
424
+ ): Promise<EditorPost> {
425
+ if (!post.id) throw new Error('Save the post before changing review state')
426
+ const res = await cmsApi<{ success: boolean }>(
427
+ `/collections/${encodeURIComponent(post.postType)}/${post.id}/workflow`,
428
+ { method: 'POST', body: JSON.stringify({ stage, note }) },
429
+ )
430
+ if (res.error) throw new Error(res.error)
431
+ return fetchPostForEditor(post.postType, post.id)
432
+ }
433
+
434
+ /** Leftover callers must not skip review — use `transitionPostWorkflow`. */
435
+ export async function publishPost(_post: EditorPost): Promise<EditorPost> {
436
+ throw new Error(SUBMIT_BEFORE_PUBLISH_ERROR)
423
437
  }
424
438
 
425
439
  /** A blank post scaffold seeded with the post type's template sections. */
@@ -447,6 +461,8 @@ export async function emptyPostFromTemplate(postType: string): Promise<EditorPos
447
461
  category: '',
448
462
  tags: [],
449
463
  status: 'DRAFT',
464
+ workflowStage: 'DRAFT',
465
+ reviewNote: null,
450
466
  publishDate: null,
451
467
  scheduledAt: null,
452
468
  scheduledUnpublishAt: null,
@@ -0,0 +1,105 @@
1
+ export type WorkflowStage = 'DRAFT' | 'IN_REVIEW' | 'APPROVED' | 'ARCHIVED' | 'PUBLISHED'
2
+
3
+ export type WorkflowChromeAction = 'submit' | 'approve' | 'waiting'
4
+
5
+ export const SUBMIT_FOR_REVIEW_LABEL = 'Submit for review'
6
+ export const APPROVE_LABEL = 'Approve'
7
+ export const REQUEST_CHANGES_LABEL = 'Request changes'
8
+ export const WAITING_FOR_REVIEW_LABEL = 'Waiting for review.'
9
+ export const REVIEWER_NOTE_PREFIX = 'Reviewer note:'
10
+ export const SUBMIT_BEFORE_PUBLISH_ERROR = 'Submit for review before publishing.'
11
+
12
+ const REVIEWER_ROLES = new Set(['ADMIN', 'EDITOR'])
13
+ const SUBMIT_ROLES = new Set(['ADMIN', 'EDITOR', 'AUTHOR'])
14
+
15
+ const STATUS_PILL: Record<WorkflowStage | 'SCHEDULED', { label: string; className: string }> = {
16
+ DRAFT: { label: 'Draft', className: 'text-warning' },
17
+ IN_REVIEW: { label: 'In review', className: 'text-warning' },
18
+ APPROVED: { label: 'Approved', className: 'text-warning' },
19
+ PUBLISHED: { label: 'Published', className: 'text-success' },
20
+ ARCHIVED: { label: 'Archived', className: 'text-muted-foreground' },
21
+ SCHEDULED: { label: 'Scheduled', className: 'text-warning' },
22
+ }
23
+
24
+ export function resolveWorkflowChrome(input: {
25
+ stage: WorkflowStage
26
+ status: 'DRAFT' | 'PUBLISHED' | 'SCHEDULED' | 'ARCHIVED'
27
+ role: string
28
+ canEdit: boolean
29
+ }): {
30
+ primary: WorkflowChromeAction | null
31
+ showRequestChanges: boolean
32
+ showSchedule: boolean
33
+ waitingLabel?: string
34
+ statusLabel: string
35
+ statusClassName: string
36
+ } {
37
+ const role = input.role.toUpperCase()
38
+ const published = input.status === 'PUBLISHED'
39
+ const scheduled = input.status === 'SCHEDULED'
40
+ const pillKey = published ? 'PUBLISHED' : scheduled ? 'SCHEDULED' : input.stage
41
+ const pill = STATUS_PILL[pillKey]
42
+
43
+ if (!input.canEdit || published) {
44
+ return {
45
+ primary: null,
46
+ showRequestChanges: false,
47
+ showSchedule: false,
48
+ statusLabel: pill.label,
49
+ statusClassName: pill.className,
50
+ }
51
+ }
52
+
53
+ const reviewer = REVIEWER_ROLES.has(role)
54
+ const canSubmit = SUBMIT_ROLES.has(role)
55
+ const inReview = input.stage === 'IN_REVIEW' || input.stage === 'APPROVED'
56
+
57
+ if (scheduled) {
58
+ return {
59
+ primary: null,
60
+ showRequestChanges: reviewer,
61
+ showSchedule: reviewer,
62
+ statusLabel: pill.label,
63
+ statusClassName: pill.className,
64
+ }
65
+ }
66
+
67
+ if (input.stage === 'DRAFT' && canSubmit) {
68
+ return {
69
+ primary: 'submit',
70
+ showRequestChanges: false,
71
+ showSchedule: false,
72
+ statusLabel: pill.label,
73
+ statusClassName: pill.className,
74
+ }
75
+ }
76
+
77
+ if (inReview && reviewer) {
78
+ return {
79
+ primary: 'approve',
80
+ showRequestChanges: true,
81
+ showSchedule: true,
82
+ statusLabel: pill.label,
83
+ statusClassName: pill.className,
84
+ }
85
+ }
86
+
87
+ if (inReview && canSubmit) {
88
+ return {
89
+ primary: 'waiting',
90
+ showRequestChanges: false,
91
+ showSchedule: false,
92
+ waitingLabel: WAITING_FOR_REVIEW_LABEL,
93
+ statusLabel: pill.label,
94
+ statusClassName: pill.className,
95
+ }
96
+ }
97
+
98
+ return {
99
+ primary: null,
100
+ showRequestChanges: false,
101
+ showSchedule: false,
102
+ statusLabel: pill.label,
103
+ statusClassName: pill.className,
104
+ }
105
+ }