@prenta/admin 1.4.0 → 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 (119) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +5 -0
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/__tests__/components/request-changes-dialog.render.test.d.ts +2 -0
  6. package/dist/__tests__/components/request-changes-dialog.render.test.d.ts.map +1 -0
  7. package/dist/__tests__/components/request-changes-dialog.render.test.js +12 -0
  8. package/dist/__tests__/components/request-changes-dialog.render.test.js.map +1 -0
  9. package/dist/__tests__/layout/sidebar-posts.test.js +1 -0
  10. package/dist/__tests__/layout/sidebar-posts.test.js.map +1 -1
  11. package/dist/__tests__/lib/page-editor-service-api.test.js +28 -15
  12. package/dist/__tests__/lib/page-editor-service-api.test.js.map +1 -1
  13. package/dist/__tests__/lib/post-editor-service-api.test.d.ts +2 -0
  14. package/dist/__tests__/lib/post-editor-service-api.test.d.ts.map +1 -0
  15. package/dist/__tests__/lib/post-editor-service-api.test.js +67 -0
  16. package/dist/__tests__/lib/post-editor-service-api.test.js.map +1 -0
  17. package/dist/__tests__/lib/post-editor-service.test.js +2 -0
  18. package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
  19. package/dist/__tests__/lib/workflow-chrome.test.d.ts +2 -0
  20. package/dist/__tests__/lib/workflow-chrome.test.d.ts.map +1 -0
  21. package/dist/__tests__/lib/workflow-chrome.test.js +108 -0
  22. package/dist/__tests__/lib/workflow-chrome.test.js.map +1 -0
  23. package/dist/__tests__/router/admin-routes.test.js +1 -0
  24. package/dist/__tests__/router/admin-routes.test.js.map +1 -1
  25. package/dist/__tests__/views/editor-top-bar.render.test.js +40 -33
  26. package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
  27. package/dist/__tests__/views/page-section-editor.test.js +8 -0
  28. package/dist/__tests__/views/page-section-editor.test.js.map +1 -1
  29. package/dist/__tests__/views/page-tab.render.test.js +2 -0
  30. package/dist/__tests__/views/page-tab.render.test.js.map +1 -1
  31. package/dist/__tests__/views/post-fields-modal.render.test.js +2 -0
  32. package/dist/__tests__/views/post-fields-modal.render.test.js.map +1 -1
  33. package/dist/__tests__/views/post-section-editor.test.js +2 -0
  34. package/dist/__tests__/views/post-section-editor.test.js.map +1 -1
  35. package/dist/__tests__/views/post-tab.render.test.js +2 -0
  36. package/dist/__tests__/views/post-tab.render.test.js.map +1 -1
  37. package/dist/__tests__/views/trash.render.test.d.ts +2 -0
  38. package/dist/__tests__/views/trash.render.test.d.ts.map +1 -0
  39. package/dist/__tests__/views/trash.render.test.js +55 -0
  40. package/dist/__tests__/views/trash.render.test.js.map +1 -0
  41. package/dist/components/Breadcrumbs.d.ts.map +1 -1
  42. package/dist/components/Breadcrumbs.js +1 -0
  43. package/dist/components/Breadcrumbs.js.map +1 -1
  44. package/dist/components/RequestChangesDialog.d.ts +6 -0
  45. package/dist/components/RequestChangesDialog.d.ts.map +1 -0
  46. package/dist/components/RequestChangesDialog.js +15 -0
  47. package/dist/components/RequestChangesDialog.js.map +1 -0
  48. package/dist/layout/Sidebar.d.ts.map +1 -1
  49. package/dist/layout/Sidebar.js +3 -2
  50. package/dist/layout/Sidebar.js.map +1 -1
  51. package/dist/lib/page-editor-service.d.ts +6 -2
  52. package/dist/lib/page-editor-service.d.ts.map +1 -1
  53. package/dist/lib/page-editor-service.js +16 -12
  54. package/dist/lib/page-editor-service.js.map +1 -1
  55. package/dist/lib/post-editor-service.d.ts +6 -1
  56. package/dist/lib/post-editor-service.d.ts.map +1 -1
  57. package/dist/lib/post-editor-service.js +16 -11
  58. package/dist/lib/post-editor-service.js.map +1 -1
  59. package/dist/lib/workflow-chrome.d.ts +22 -0
  60. package/dist/lib/workflow-chrome.d.ts.map +1 -0
  61. package/dist/lib/workflow-chrome.js +80 -0
  62. package/dist/lib/workflow-chrome.js.map +1 -0
  63. package/dist/prenta-admin.css +1 -1
  64. package/dist/router/admin-routes.d.ts.map +1 -1
  65. package/dist/router/admin-routes.js +2 -0
  66. package/dist/router/admin-routes.js.map +1 -1
  67. package/dist/views/ApiKeys.js +1 -5
  68. package/dist/views/ApiKeys.js.map +1 -1
  69. package/dist/views/SEO.d.ts.map +1 -1
  70. package/dist/views/SEO.js +1 -1
  71. package/dist/views/SEO.js.map +1 -1
  72. package/dist/views/Trash.d.ts +18 -0
  73. package/dist/views/Trash.d.ts.map +1 -0
  74. package/dist/views/Trash.js +104 -0
  75. package/dist/views/Trash.js.map +1 -0
  76. package/dist/views/page-editor/EditorTopBar.d.ts +10 -5
  77. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
  78. package/dist/views/page-editor/EditorTopBar.js +38 -28
  79. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  80. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  81. package/dist/views/page-editor/PageSectionEditor.js +83 -62
  82. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  83. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  84. package/dist/views/post-editor/PostSectionEditor.js +78 -62
  85. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  86. package/dist/views/seo/OverviewTab.d.ts +1 -2
  87. package/dist/views/seo/OverviewTab.d.ts.map +1 -1
  88. package/dist/views/seo/OverviewTab.js +3 -3
  89. package/dist/views/seo/OverviewTab.js.map +1 -1
  90. package/package.json +4 -4
  91. package/src/AdminRoot.tsx +6 -0
  92. package/src/__tests__/components/request-changes-dialog.render.test.tsx +11 -0
  93. package/src/__tests__/layout/sidebar-posts.test.ts +1 -0
  94. package/src/__tests__/lib/page-editor-service-api.test.ts +37 -18
  95. package/src/__tests__/lib/post-editor-service-api.test.ts +73 -0
  96. package/src/__tests__/lib/post-editor-service.test.ts +2 -0
  97. package/src/__tests__/lib/workflow-chrome.test.ts +125 -0
  98. package/src/__tests__/router/admin-routes.test.ts +1 -0
  99. package/src/__tests__/views/editor-top-bar.render.test.tsx +42 -45
  100. package/src/__tests__/views/page-section-editor.test.tsx +18 -0
  101. package/src/__tests__/views/page-tab.render.test.tsx +2 -0
  102. package/src/__tests__/views/post-fields-modal.render.test.tsx +2 -0
  103. package/src/__tests__/views/post-section-editor.test.tsx +2 -0
  104. package/src/__tests__/views/post-tab.render.test.tsx +2 -0
  105. package/src/__tests__/views/trash.render.test.tsx +61 -0
  106. package/src/components/Breadcrumbs.tsx +1 -0
  107. package/src/components/RequestChangesDialog.tsx +56 -0
  108. package/src/layout/Sidebar.tsx +3 -0
  109. package/src/lib/page-editor-service.ts +26 -11
  110. package/src/lib/post-editor-service.ts +26 -10
  111. package/src/lib/workflow-chrome.ts +105 -0
  112. package/src/router/admin-routes.ts +2 -0
  113. package/src/views/ApiKeys.tsx +0 -13
  114. package/src/views/SEO.tsx +0 -5
  115. package/src/views/Trash.tsx +207 -0
  116. package/src/views/page-editor/EditorTopBar.tsx +110 -48
  117. package/src/views/page-editor/PageSectionEditor.tsx +121 -92
  118. package/src/views/post-editor/PostSectionEditor.tsx +111 -88
  119. package/src/views/seo/OverviewTab.tsx +1 -11
@@ -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,61 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, describe, expect, it, vi } from 'vitest'
3
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
4
+
5
+ const cmsApi = vi.fn()
6
+
7
+ vi.mock('../../lib/api.js', () => ({
8
+ cmsApi: (...args: unknown[]) => cmsApi(...args),
9
+ }))
10
+
11
+ const { Trash } = await import('../../views/Trash.js')
12
+
13
+ afterEach(() => {
14
+ cleanup()
15
+ cmsApi.mockReset()
16
+ })
17
+
18
+ describe('Trash', () => {
19
+ it('shows an empty state when nothing is deleted', async () => {
20
+ cmsApi.mockResolvedValue({ data: { docs: [] }, status: 200 })
21
+ render(<Trash onNavigate={vi.fn()} />)
22
+ expect(await screen.findByText('Trash is empty')).toBeTruthy()
23
+ })
24
+
25
+ it('lists a deleted document and restores it', async () => {
26
+ cmsApi.mockImplementation(async (endpoint: string, options?: { method?: string }) => {
27
+ if (options?.method === 'POST' && String(endpoint).includes('/restore')) {
28
+ return {
29
+ data: { document: { id: 'p1' }, slugConflict: false, previousSlug: 'about' },
30
+ status: 200,
31
+ }
32
+ }
33
+ if (String(endpoint).includes('/collections/pages')) {
34
+ return {
35
+ data: {
36
+ docs: [
37
+ {
38
+ id: 'p1',
39
+ title: 'About',
40
+ slug: null,
41
+ deletedAt: '2026-08-01T00:00:00.000Z',
42
+ data: { slug: 'about' },
43
+ },
44
+ ],
45
+ },
46
+ status: 200,
47
+ }
48
+ }
49
+ return { data: { docs: [] }, status: 200 }
50
+ })
51
+
52
+ const onNavigate = vi.fn()
53
+ render(<Trash onNavigate={onNavigate} />)
54
+ expect(await screen.findByText('About')).toBeTruthy()
55
+
56
+ fireEvent.click(screen.getByRole('button', { name: 'Restore About' }))
57
+ await waitFor(() => {
58
+ expect(onNavigate).toHaveBeenCalledWith('/pages/p1')
59
+ })
60
+ })
61
+ })
@@ -14,6 +14,7 @@ const LABEL_MAP: Record<string, string> = {
14
14
  users: 'Users',
15
15
  'script-tags': 'Script Tags',
16
16
  settings: 'Settings',
17
+ trash: 'Trash',
17
18
  profile: 'Your Profile',
18
19
  collections: 'Collections',
19
20
  submissions: 'Submissions',
@@ -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
+ }
@@ -31,6 +31,7 @@ import {
31
31
  Menu as MenuIcon,
32
32
  PanelLeftClose,
33
33
  PanelLeftOpen,
34
+ Trash2,
34
35
  } from 'lucide-react'
35
36
  import type { LucideIcon } from 'lucide-react'
36
37
  import { PrentaBrandLogo } from '../assets/prenta-logo.js'
@@ -206,6 +207,7 @@ const defaultNavItems: NavItem[] = [
206
207
  { path: '/forms', label: 'Forms', icon: ClipboardList, group: 'Content' },
207
208
  { path: '/seo', label: 'SEO', icon: SearchIcon, group: 'Content' },
208
209
  { path: '/script-tags', label: 'Script Tags', icon: Code2, group: 'Content' },
210
+ { path: '/trash', label: 'Trash', icon: Trash2, group: 'Content' },
209
211
  { path: '/settings', label: 'Settings', icon: Settings, group: 'Settings' },
210
212
  ]
211
213
 
@@ -825,6 +827,7 @@ export function buildNavItems(config: any): NavItem[] {
825
827
  { path: '/forms', label: 'Forms', icon: ClipboardList, group: 'Content' },
826
828
  { path: '/seo', label: 'SEO', icon: SearchIcon, group: 'Content' },
827
829
  { path: '/script-tags', label: 'Script Tags', icon: Code2, group: 'Content' },
830
+ { path: '/trash', label: 'Trash', icon: Trash2, group: 'Content' },
828
831
  )
829
832
 
830
833
  // Plugin-contributed custom nav links (PluginDefinition.adminNavItems,
@@ -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
+ }
@@ -59,6 +59,7 @@ export const ADMIN_STATIC_ROUTE_PATTERNS: readonly string[] = [
59
59
  '/script-tags/:id',
60
60
  '/script-tags/new',
61
61
  '/settings',
62
+ '/trash',
62
63
  '/users',
63
64
  '/widgets',
64
65
  ]
@@ -76,6 +77,7 @@ export const RESERVED_COLLECTION_SLUGS: ReadonlySet<string> = new Set([
76
77
  'saved-sections',
77
78
  'page-templates',
78
79
  'widgets',
80
+ 'trash',
79
81
  'users',
80
82
  'settings',
81
83
  'profile',