@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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
+
import { useState, type ReactNode } from 'react'
|
|
3
4
|
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
|
4
5
|
import {
|
|
5
6
|
Calendar,
|
|
@@ -14,11 +15,20 @@ import {
|
|
|
14
15
|
} from 'lucide-react'
|
|
15
16
|
import { EditorSeoScoreBadge } from '../../components/seo/EditorSeoScoreBadge.js'
|
|
16
17
|
import { BreakpointPicker } from '../../components/BreakpointPicker.js'
|
|
18
|
+
import { RequestChangesDialog } from '../../components/RequestChangesDialog.js'
|
|
17
19
|
import { ZoomChip } from '../../components/ZoomChip.js'
|
|
18
20
|
import { adminPortalContainer } from '../../lib/portal-container.js'
|
|
19
21
|
import type { PageStatus } from '../../lib/page-editor-service.js'
|
|
20
22
|
import type { Zoom } from '../../lib/breakpoints.js'
|
|
21
23
|
import { resolveViewSiteChrome, type ViewSiteIntent } from '../../lib/view-site-chrome.js'
|
|
24
|
+
import {
|
|
25
|
+
APPROVE_LABEL,
|
|
26
|
+
REQUEST_CHANGES_LABEL,
|
|
27
|
+
REVIEWER_NOTE_PREFIX,
|
|
28
|
+
SUBMIT_FOR_REVIEW_LABEL,
|
|
29
|
+
resolveWorkflowChrome,
|
|
30
|
+
type WorkflowStage,
|
|
31
|
+
} from '../../lib/workflow-chrome.js'
|
|
22
32
|
import { type ViewportId } from './viewports.js'
|
|
23
33
|
|
|
24
34
|
export type SaveState = 'idle' | 'saving' | 'saved' | 'error'
|
|
@@ -26,14 +36,16 @@ export type SaveState = 'idle' | 'saving' | 'saved' | 'error'
|
|
|
26
36
|
interface EditorTopBarProps {
|
|
27
37
|
title: string
|
|
28
38
|
status: PageStatus
|
|
39
|
+
workflowStage: WorkflowStage
|
|
40
|
+
reviewNote?: string | null
|
|
41
|
+
role: string
|
|
29
42
|
dirty: boolean
|
|
30
43
|
saveState: SaveState
|
|
31
44
|
publishing: boolean
|
|
32
45
|
canEdit: boolean
|
|
33
|
-
|
|
34
|
-
/** When false, Publish stays disabled even if the user has publish permission. */
|
|
46
|
+
/** When false, Approve stays disabled even if the user can review. */
|
|
35
47
|
publishReady?: boolean
|
|
36
|
-
/** Shown on hover/focus when
|
|
48
|
+
/** Shown on hover/focus when approve is blocked by validation (not role). */
|
|
37
49
|
publishDisabledReason?: string
|
|
38
50
|
viewport: ViewportId
|
|
39
51
|
/** Canvas zoom mode; omit to hide the zoom chip. */
|
|
@@ -82,7 +94,9 @@ interface EditorTopBarProps {
|
|
|
82
94
|
/** Band for `seoScore`; omit to derive from the content-analysis scale. */
|
|
83
95
|
seoScoreBand?: 'good' | 'fair' | 'poor'
|
|
84
96
|
onSaveDraft: () => void
|
|
85
|
-
|
|
97
|
+
onSubmitForReview: () => void
|
|
98
|
+
onApprove: () => void
|
|
99
|
+
onRequestChanges: (note: string) => void
|
|
86
100
|
/** Open the schedule-publish dialog. Omitted when the document is unsaved. */
|
|
87
101
|
onSchedule?: () => void
|
|
88
102
|
/** When false, Schedule stays disabled (e.g. unsaved document). Defaults to true. */
|
|
@@ -97,21 +111,16 @@ interface EditorTopBarProps {
|
|
|
97
111
|
structuralTypes?: string[]
|
|
98
112
|
}
|
|
99
113
|
|
|
100
|
-
const STATUS_TEXT: Record<PageStatus, { label: string; className: string }> = {
|
|
101
|
-
DRAFT: { label: 'Draft', className: 'text-warning dark:text-amber-400' },
|
|
102
|
-
PUBLISHED: { label: 'Published', className: 'text-emerald-600 dark:text-emerald-400' },
|
|
103
|
-
SCHEDULED: { label: 'Scheduled', className: 'text-warning dark:text-amber-400' },
|
|
104
|
-
ARCHIVED: { label: 'Archived', className: 'text-muted-foreground' },
|
|
105
|
-
}
|
|
106
|
-
|
|
107
114
|
export function EditorTopBar({
|
|
108
115
|
title,
|
|
109
116
|
status,
|
|
117
|
+
workflowStage,
|
|
118
|
+
reviewNote,
|
|
119
|
+
role,
|
|
110
120
|
dirty,
|
|
111
121
|
saveState,
|
|
112
122
|
publishing,
|
|
113
123
|
canEdit,
|
|
114
|
-
canPublish,
|
|
115
124
|
publishReady = true,
|
|
116
125
|
publishDisabledReason,
|
|
117
126
|
viewport,
|
|
@@ -133,13 +142,16 @@ export function EditorTopBar({
|
|
|
133
142
|
seoScore,
|
|
134
143
|
seoScoreBand,
|
|
135
144
|
onSaveDraft,
|
|
136
|
-
|
|
145
|
+
onSubmitForReview,
|
|
146
|
+
onApprove,
|
|
147
|
+
onRequestChanges,
|
|
137
148
|
onSchedule,
|
|
138
149
|
scheduleReady = true,
|
|
139
150
|
scheduledAt = null,
|
|
140
151
|
scheduledUnpublishAt = null,
|
|
141
152
|
}: EditorTopBarProps) {
|
|
142
|
-
const
|
|
153
|
+
const [requestChangesOpen, setRequestChangesOpen] = useState(false)
|
|
154
|
+
const workflow = resolveWorkflowChrome({ stage: workflowStage, status, role, canEdit })
|
|
143
155
|
const chrome = onViewOnSite
|
|
144
156
|
? resolveViewSiteChrome({
|
|
145
157
|
status,
|
|
@@ -150,36 +162,74 @@ export function EditorTopBar({
|
|
|
150
162
|
})
|
|
151
163
|
: null
|
|
152
164
|
|
|
153
|
-
const
|
|
154
|
-
const
|
|
165
|
+
const approveBlocked = !publishReady || publishing || saveState === 'saving'
|
|
166
|
+
const approveTitle = publishing
|
|
155
167
|
? 'Publishing…'
|
|
156
168
|
: saveState === 'saving'
|
|
157
169
|
? 'Wait for the current save to finish'
|
|
158
|
-
:
|
|
170
|
+
: !publishReady && publishDisabledReason
|
|
159
171
|
? publishDisabledReason
|
|
160
|
-
:
|
|
161
|
-
? 'You do not have permission to publish'
|
|
162
|
-
: undefined
|
|
172
|
+
: undefined
|
|
163
173
|
|
|
164
|
-
const scheduleBlocked =
|
|
165
|
-
!onSchedule ||
|
|
166
|
-
!canPublish ||
|
|
167
|
-
!publishReady ||
|
|
168
|
-
!scheduleReady ||
|
|
169
|
-
publishing ||
|
|
170
|
-
saveState === 'saving'
|
|
174
|
+
const scheduleBlocked = !publishReady || !scheduleReady || publishing || saveState === 'saving'
|
|
171
175
|
const scheduleTitle =
|
|
172
176
|
saveState === 'saving'
|
|
173
177
|
? 'Wait for the current save to finish'
|
|
174
178
|
: !scheduleReady
|
|
175
179
|
? 'Save before scheduling'
|
|
176
|
-
:
|
|
180
|
+
: !publishReady && publishDisabledReason
|
|
177
181
|
? publishDisabledReason
|
|
178
|
-
:
|
|
179
|
-
?
|
|
180
|
-
:
|
|
181
|
-
|
|
182
|
-
|
|
182
|
+
: scheduledAt
|
|
183
|
+
? `Reschedule — currently publishes ${formatScheduleLabel(scheduledAt)}`
|
|
184
|
+
: 'Schedule a future publish date'
|
|
185
|
+
|
|
186
|
+
const submitBlocked = !canEdit || publishing || saveState === 'saving'
|
|
187
|
+
const requestChangesBlocked = publishing || saveState === 'saving'
|
|
188
|
+
let primaryAction: ReactNode
|
|
189
|
+
switch (workflow.primary) {
|
|
190
|
+
case 'submit':
|
|
191
|
+
primaryAction = (
|
|
192
|
+
<button
|
|
193
|
+
type="button"
|
|
194
|
+
onClick={onSubmitForReview}
|
|
195
|
+
disabled={submitBlocked}
|
|
196
|
+
className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
|
197
|
+
>
|
|
198
|
+
{publishing && <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />}
|
|
199
|
+
{SUBMIT_FOR_REVIEW_LABEL}
|
|
200
|
+
</button>
|
|
201
|
+
)
|
|
202
|
+
break
|
|
203
|
+
case 'approve':
|
|
204
|
+
primaryAction = (
|
|
205
|
+
<button
|
|
206
|
+
type="button"
|
|
207
|
+
onClick={onApprove}
|
|
208
|
+
disabled={approveBlocked}
|
|
209
|
+
title={approveTitle}
|
|
210
|
+
aria-disabled={approveBlocked}
|
|
211
|
+
className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
|
212
|
+
>
|
|
213
|
+
{publishing && <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />}
|
|
214
|
+
{APPROVE_LABEL}
|
|
215
|
+
</button>
|
|
216
|
+
)
|
|
217
|
+
break
|
|
218
|
+
case 'waiting':
|
|
219
|
+
primaryAction = (
|
|
220
|
+
<span className="text-muted-foreground text-sm" aria-live="polite">
|
|
221
|
+
{workflow.waitingLabel}
|
|
222
|
+
</span>
|
|
223
|
+
)
|
|
224
|
+
break
|
|
225
|
+
case null:
|
|
226
|
+
primaryAction = null
|
|
227
|
+
break
|
|
228
|
+
default: {
|
|
229
|
+
const _exhaustive: never = workflow.primary
|
|
230
|
+
primaryAction = _exhaustive
|
|
231
|
+
}
|
|
232
|
+
}
|
|
183
233
|
|
|
184
234
|
return (
|
|
185
235
|
<header className="prenta-editor-header bg-card border-border flex h-14 shrink-0 items-center justify-between gap-4 border-b px-4">
|
|
@@ -199,11 +249,16 @@ export function EditorTopBar({
|
|
|
199
249
|
<span className="text-foreground truncate font-medium">{title || 'Untitled page'}</span>
|
|
200
250
|
</nav>
|
|
201
251
|
<span
|
|
202
|
-
className={`ml-2 shrink-0 text-xs font-medium ${
|
|
203
|
-
aria-label={`Status: ${
|
|
252
|
+
className={`ml-2 shrink-0 text-xs font-medium ${workflow.statusClassName}`}
|
|
253
|
+
aria-label={`Status: ${workflow.statusLabel}`}
|
|
204
254
|
>
|
|
205
|
-
{
|
|
255
|
+
{workflow.statusLabel}
|
|
206
256
|
</span>
|
|
257
|
+
{reviewNote ? (
|
|
258
|
+
<span className="text-muted-foreground text-xs">
|
|
259
|
+
{REVIEWER_NOTE_PREFIX} {reviewNote}
|
|
260
|
+
</span>
|
|
261
|
+
) : null}
|
|
207
262
|
{scheduledAt && (
|
|
208
263
|
<span
|
|
209
264
|
className="text-muted-foreground ml-1 hidden shrink-0 text-xs sm:inline"
|
|
@@ -351,7 +406,7 @@ export function EditorTopBar({
|
|
|
351
406
|
{saveState === 'saving' ? 'Saving…' : 'Save Draft'}
|
|
352
407
|
</button>
|
|
353
408
|
|
|
354
|
-
{onSchedule && (
|
|
409
|
+
{onSchedule && workflow.showSchedule && (
|
|
355
410
|
<button
|
|
356
411
|
type="button"
|
|
357
412
|
onClick={onSchedule}
|
|
@@ -365,17 +420,24 @@ export function EditorTopBar({
|
|
|
365
420
|
</button>
|
|
366
421
|
)}
|
|
367
422
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
423
|
+
{primaryAction}
|
|
424
|
+
|
|
425
|
+
{workflow.showRequestChanges && (
|
|
426
|
+
<button
|
|
427
|
+
type="button"
|
|
428
|
+
onClick={() => setRequestChangesOpen(true)}
|
|
429
|
+
disabled={requestChangesBlocked}
|
|
430
|
+
className="border-border text-foreground hover:bg-accent rounded-lg border px-3 py-1.5 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
|
431
|
+
>
|
|
432
|
+
{REQUEST_CHANGES_LABEL}
|
|
433
|
+
</button>
|
|
434
|
+
)}
|
|
435
|
+
|
|
436
|
+
<RequestChangesDialog
|
|
437
|
+
open={requestChangesOpen}
|
|
438
|
+
onClose={() => setRequestChangesOpen(false)}
|
|
439
|
+
onConfirm={onRequestChanges}
|
|
440
|
+
/>
|
|
379
441
|
</div>
|
|
380
442
|
</header>
|
|
381
443
|
)
|
|
@@ -22,16 +22,17 @@ import {
|
|
|
22
22
|
emptyPage,
|
|
23
23
|
fetchPageForEditor,
|
|
24
24
|
missingPageFields,
|
|
25
|
-
publishPage,
|
|
26
25
|
PAGES_COLLECTION,
|
|
27
26
|
savePageDraft,
|
|
28
27
|
toggleSectionVisibility,
|
|
28
|
+
transitionPageWorkflow,
|
|
29
29
|
updateSectionContent,
|
|
30
30
|
updateSectionMeta,
|
|
31
31
|
validatePage,
|
|
32
32
|
type EditorPage,
|
|
33
33
|
type ValidationResult,
|
|
34
34
|
} from '../../lib/page-editor-service.js'
|
|
35
|
+
import { resolveWorkflowChrome, type WorkflowStage } from '../../lib/workflow-chrome.js'
|
|
35
36
|
import { SharePreviewLinkDialog } from '../../components/SharePreviewLinkDialog.js'
|
|
36
37
|
import { openPublicSiteTab } from '../../lib/open-public-site-tab.js'
|
|
37
38
|
import type { ViewSiteIntent } from '../../lib/view-site-chrome.js'
|
|
@@ -210,13 +211,13 @@ export function PageSectionEditor({
|
|
|
210
211
|
/** Index into `versions`; 0 is the newest, which is the current content. */
|
|
211
212
|
const [versionIndex, setVersionIndex] = useState(0)
|
|
212
213
|
const [restoring, setRestoring] = useState(false)
|
|
213
|
-
const [confirmPublish, setConfirmPublish] = useState(false)
|
|
214
214
|
const [scheduleOpen, setScheduleOpen] = useState(false)
|
|
215
215
|
const [shareOpen, setShareOpen] = useState(false)
|
|
216
216
|
const [leaveTarget, setLeaveTarget] = useState<string | null>(null)
|
|
217
217
|
const [validation, setValidation] = useState<ValidationResult | null>(null)
|
|
218
218
|
|
|
219
|
-
const { canEdit,
|
|
219
|
+
const { canEdit, canCreateRedirect } = useMemo(() => deriveCan(session), [session])
|
|
220
|
+
const sessionRole = String(session?.user?.role ?? '')
|
|
220
221
|
const customRenderers = useAdminSectionRenderers()
|
|
221
222
|
const {
|
|
222
223
|
siteUrl: publicSiteUrl,
|
|
@@ -535,43 +536,115 @@ export function PageSectionEditor({
|
|
|
535
536
|
[directives, reloadAudit],
|
|
536
537
|
)
|
|
537
538
|
|
|
538
|
-
const handleSaveDraft = useCallback(
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
539
|
+
const handleSaveDraft = useCallback(
|
|
540
|
+
async (options?: { navigate?: boolean }): Promise<EditorPage | false> => {
|
|
541
|
+
if (!page) return false
|
|
542
|
+
const result = validatePage(page, false)
|
|
543
|
+
if (result.errors.length > 0) {
|
|
544
|
+
setValidation(result)
|
|
545
|
+
toast.error(result.errors[0]!)
|
|
546
|
+
setSettingsOpen(true)
|
|
547
|
+
return false
|
|
548
|
+
}
|
|
549
|
+
setSaveState('saving')
|
|
550
|
+
try {
|
|
551
|
+
const saved = await savePageDraft(page)
|
|
552
|
+
if (saved.id) await saveDirectives(saved.id)
|
|
553
|
+
acceptPage(saved)
|
|
554
|
+
setDirty(false)
|
|
555
|
+
setSaveState('saved')
|
|
556
|
+
setValidation(null)
|
|
557
|
+
toast.success('Draft saved')
|
|
558
|
+
if ((options?.navigate ?? true) && !documentId && saved.id) {
|
|
559
|
+
onNavigate(`/pages/${saved.id}/edit`)
|
|
560
|
+
}
|
|
561
|
+
return saved
|
|
562
|
+
} catch (err) {
|
|
563
|
+
setSaveState('error')
|
|
564
|
+
toast.error(err instanceof Error ? err.message : 'Failed to save draft')
|
|
565
|
+
return false
|
|
566
|
+
}
|
|
567
|
+
},
|
|
568
|
+
[page, documentId, onNavigate, saveDirectives, acceptPage],
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
const runWorkflow = useCallback(
|
|
572
|
+
async (stage: WorkflowStage, note?: string, toastOk?: string) => {
|
|
573
|
+
if (!page) return
|
|
574
|
+
let current = page
|
|
575
|
+
if (dirty || !current.id) {
|
|
576
|
+
const saved = await handleSaveDraft({ navigate: false })
|
|
577
|
+
if (!saved) return
|
|
578
|
+
current = saved
|
|
579
|
+
}
|
|
580
|
+
setPublishing(true)
|
|
581
|
+
try {
|
|
582
|
+
// The move has to be read BEFORE the transition — `acceptPage` moves
|
|
583
|
+
// the published path to the new one, after which there is nothing to
|
|
584
|
+
// detect. Same block as the old `doPublish`.
|
|
585
|
+
const move =
|
|
586
|
+
stage === 'PUBLISHED' && redirectOnPublish && canCreateRedirect
|
|
587
|
+
? pendingSlugChange(publishedPath, current.path)
|
|
588
|
+
: null
|
|
589
|
+
|
|
590
|
+
const next = await transitionPageWorkflow(current, stage, note)
|
|
591
|
+
|
|
592
|
+
if (move) {
|
|
593
|
+
const { error } = await createPublishRedirect(move)
|
|
594
|
+
if (error) {
|
|
595
|
+
// Loud, and naming both paths: the page has already moved, so a
|
|
596
|
+
// silently-failed redirect leaves exactly the broken links the
|
|
597
|
+
// switch promised to prevent.
|
|
598
|
+
toast.error(`Published, but the redirect ${move.from} → ${move.to} failed: ${error}`)
|
|
599
|
+
} else {
|
|
600
|
+
toast.success(`Redirect created: ${move.from} → ${move.to}`)
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (stage === 'PUBLISHED' && next.id) await saveDirectives(next.id)
|
|
605
|
+
|
|
606
|
+
acceptPage(next)
|
|
607
|
+
if (!documentId && next.id) onNavigate(`/pages/${next.id}/edit`)
|
|
608
|
+
if (toastOk) toast.success(toastOk)
|
|
609
|
+
} catch (err) {
|
|
610
|
+
toast.error(err instanceof Error ? err.message : 'Transition failed')
|
|
611
|
+
} finally {
|
|
612
|
+
setPublishing(false)
|
|
613
|
+
}
|
|
614
|
+
},
|
|
615
|
+
[
|
|
616
|
+
page,
|
|
617
|
+
dirty,
|
|
618
|
+
documentId,
|
|
619
|
+
handleSaveDraft,
|
|
620
|
+
redirectOnPublish,
|
|
621
|
+
canCreateRedirect,
|
|
622
|
+
publishedPath,
|
|
623
|
+
acceptPage,
|
|
624
|
+
saveDirectives,
|
|
625
|
+
onNavigate,
|
|
626
|
+
],
|
|
627
|
+
)
|
|
564
628
|
|
|
565
|
-
const
|
|
629
|
+
const handleSubmitForReview = useCallback(
|
|
630
|
+
() => runWorkflow('IN_REVIEW', undefined, 'Submitted for review.'),
|
|
631
|
+
[runWorkflow],
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
const handleApprove = useCallback(() => {
|
|
566
635
|
if (!page) return
|
|
567
636
|
const result = validatePage(page, true)
|
|
568
|
-
setValidation(result.errors.length || result.warnings.length ? result : null)
|
|
569
637
|
if (result.errors.length > 0) {
|
|
570
638
|
toast.error('Resolve the listed issues before publishing')
|
|
571
639
|
return
|
|
572
640
|
}
|
|
573
|
-
|
|
574
|
-
}, [page])
|
|
641
|
+
void runWorkflow('PUBLISHED', undefined, 'Published.')
|
|
642
|
+
}, [page, runWorkflow])
|
|
643
|
+
|
|
644
|
+
const handleRequestChanges = useCallback(
|
|
645
|
+
(note: string) => runWorkflow('DRAFT', note || undefined, 'Sent back to draft.'),
|
|
646
|
+
[runWorkflow],
|
|
647
|
+
)
|
|
575
648
|
|
|
576
649
|
const requestSchedule = useCallback(async () => {
|
|
577
650
|
if (!page) return
|
|
@@ -589,8 +662,15 @@ export function PageSectionEditor({
|
|
|
589
662
|
const ok = await handleSaveDraft()
|
|
590
663
|
if (!ok) return
|
|
591
664
|
}
|
|
665
|
+
const chrome = resolveWorkflowChrome({
|
|
666
|
+
stage: page.workflowStage,
|
|
667
|
+
status: page.status,
|
|
668
|
+
role: sessionRole,
|
|
669
|
+
canEdit: effectiveCanEdit,
|
|
670
|
+
})
|
|
671
|
+
if (!chrome.showSchedule) return
|
|
592
672
|
setScheduleOpen(true)
|
|
593
|
-
}, [page, dirty, handleSaveDraft])
|
|
673
|
+
}, [page, dirty, handleSaveDraft, sessionRole, effectiveCanEdit])
|
|
594
674
|
|
|
595
675
|
const handleScheduled = useCallback(
|
|
596
676
|
(next: { status: string; scheduledAt: string | null; scheduledUnpublishAt: string | null }) => {
|
|
@@ -609,52 +689,6 @@ export function PageSectionEditor({
|
|
|
609
689
|
[],
|
|
610
690
|
)
|
|
611
691
|
|
|
612
|
-
const doPublish = useCallback(async () => {
|
|
613
|
-
if (!page) return
|
|
614
|
-
setPublishing(true)
|
|
615
|
-
try {
|
|
616
|
-
// The move has to be read BEFORE publishing — `acceptPage` moves the
|
|
617
|
-
// published path to the new one, after which there is nothing to detect.
|
|
618
|
-
const move =
|
|
619
|
-
redirectOnPublish && canCreateRedirect ? pendingSlugChange(publishedPath, page.path) : null
|
|
620
|
-
|
|
621
|
-
const published = await publishPage(page)
|
|
622
|
-
if (published.id) await saveDirectives(published.id)
|
|
623
|
-
|
|
624
|
-
if (move) {
|
|
625
|
-
const { error } = await createPublishRedirect(move)
|
|
626
|
-
if (error) {
|
|
627
|
-
// Loud, and naming both paths: the page has already moved, so a
|
|
628
|
-
// silently-failed redirect leaves exactly the broken links the switch
|
|
629
|
-
// promised to prevent.
|
|
630
|
-
toast.error(`Published, but the redirect ${move.from} → ${move.to} failed: ${error}`)
|
|
631
|
-
} else {
|
|
632
|
-
toast.success(`Redirect created: ${move.from} → ${move.to}`)
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
acceptPage(published)
|
|
637
|
-
setDirty(false)
|
|
638
|
-
setSaveState('saved')
|
|
639
|
-
setValidation(null)
|
|
640
|
-
toast.success('Page published')
|
|
641
|
-
if (!documentId && published.id) onNavigate(`/pages/${published.id}/edit`)
|
|
642
|
-
} catch (err) {
|
|
643
|
-
toast.error(err instanceof Error ? err.message : 'Failed to publish page')
|
|
644
|
-
} finally {
|
|
645
|
-
setPublishing(false)
|
|
646
|
-
}
|
|
647
|
-
}, [
|
|
648
|
-
page,
|
|
649
|
-
documentId,
|
|
650
|
-
onNavigate,
|
|
651
|
-
saveDirectives,
|
|
652
|
-
redirectOnPublish,
|
|
653
|
-
canCreateRedirect,
|
|
654
|
-
publishedPath,
|
|
655
|
-
acceptPage,
|
|
656
|
-
])
|
|
657
|
-
|
|
658
692
|
const handleDuplicate = useCallback(async () => {
|
|
659
693
|
if (!page?.id) return
|
|
660
694
|
const { id, error } = await duplicatePage(page.id)
|
|
@@ -754,7 +788,7 @@ export function PageSectionEditor({
|
|
|
754
788
|
path: page.path,
|
|
755
789
|
trailingSlash,
|
|
756
790
|
dirty,
|
|
757
|
-
saveDraft: handleSaveDraft,
|
|
791
|
+
saveDraft: async () => Boolean(await handleSaveDraft()),
|
|
758
792
|
}).catch((err) => {
|
|
759
793
|
toast.error(err instanceof Error ? err.message : 'Failed to open the site')
|
|
760
794
|
})
|
|
@@ -833,11 +867,13 @@ export function PageSectionEditor({
|
|
|
833
867
|
<EditorTopBar
|
|
834
868
|
title={page.title}
|
|
835
869
|
status={page.status}
|
|
870
|
+
workflowStage={page.workflowStage}
|
|
871
|
+
reviewNote={page.reviewNote}
|
|
872
|
+
role={sessionRole}
|
|
836
873
|
dirty={dirty}
|
|
837
874
|
saveState={saveState}
|
|
838
875
|
publishing={publishing}
|
|
839
876
|
canEdit={effectiveCanEdit}
|
|
840
|
-
canPublish={canPublish && effectiveCanEdit}
|
|
841
877
|
publishReady={publishReady}
|
|
842
878
|
publishDisabledReason={publishDisabledReason}
|
|
843
879
|
viewport={viewport}
|
|
@@ -863,7 +899,9 @@ export function PageSectionEditor({
|
|
|
863
899
|
typeof audit?.seo.score === 'number' ? scoreBand(audit.seo.score) : undefined
|
|
864
900
|
}
|
|
865
901
|
onSaveDraft={handleSaveDraft}
|
|
866
|
-
|
|
902
|
+
onSubmitForReview={handleSubmitForReview}
|
|
903
|
+
onApprove={handleApprove}
|
|
904
|
+
onRequestChanges={handleRequestChanges}
|
|
867
905
|
onSchedule={page.id ? requestSchedule : undefined}
|
|
868
906
|
scheduleReady={Boolean(page.id)}
|
|
869
907
|
scheduledAt={page.scheduledAt}
|
|
@@ -1037,15 +1075,6 @@ export function PageSectionEditor({
|
|
|
1037
1075
|
onSave={handlePageSettings}
|
|
1038
1076
|
/>
|
|
1039
1077
|
|
|
1040
|
-
<ConfirmDialog
|
|
1041
|
-
open={confirmPublish}
|
|
1042
|
-
onClose={() => setConfirmPublish(false)}
|
|
1043
|
-
onConfirm={doPublish}
|
|
1044
|
-
title="Publish this page?"
|
|
1045
|
-
description="The current content will go live and replace the published version."
|
|
1046
|
-
confirmLabel="Publish"
|
|
1047
|
-
/>
|
|
1048
|
-
|
|
1049
1078
|
{page.id && (
|
|
1050
1079
|
<SchedulePublishDialog
|
|
1051
1080
|
collectionSlug={PAGES_COLLECTION}
|