@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.
- package/CHANGELOG.md +6 -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-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 +67 -0
- package/dist/__tests__/lib/post-editor-service-api.test.js.map +1 -0
- package/dist/__tests__/lib/post-editor-service.test.js +2 -0
- package/dist/__tests__/lib/post-editor-service.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 +2 -0
- package/dist/__tests__/views/post-fields-modal.render.test.js.map +1 -1
- package/dist/__tests__/views/post-section-editor.test.js +2 -0
- package/dist/__tests__/views/post-section-editor.test.js.map +1 -1
- package/dist/__tests__/views/post-tab.render.test.js +2 -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-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 +6 -1
- package/dist/lib/post-editor-service.d.ts.map +1 -1
- package/dist/lib/post-editor-service.js +16 -11
- package/dist/lib/post-editor-service.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/PostSectionEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +78 -62
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/components/request-changes-dialog.render.test.tsx +11 -0
- package/src/__tests__/lib/page-editor-service-api.test.ts +37 -18
- package/src/__tests__/lib/post-editor-service-api.test.ts +73 -0
- package/src/__tests__/lib/post-editor-service.test.ts +2 -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 +2 -0
- package/src/__tests__/views/post-section-editor.test.tsx +2 -0
- package/src/__tests__/views/post-tab.render.test.tsx +2 -0
- package/src/components/RequestChangesDialog.tsx +56 -0
- package/src/lib/page-editor-service.ts +26 -11
- package/src/lib/post-editor-service.ts +26 -10
- 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/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
|
-
|
|
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
|
/**
|
|
@@ -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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
+
}
|