@byline/admin 3.12.0 → 3.12.2

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.
@@ -20,22 +20,25 @@ import type {
20
20
  } from '@byline/core'
21
21
  import type { DocumentPatch } from '@byline/core/patches'
22
22
  import { useTranslation } from '@byline/i18n/react'
23
- import { Alert, Button, CloseIcon, ComboButton, IconButton, Modal } from '@byline/ui/react'
23
+ import { Alert, Button, ComboButton } from '@byline/ui/react'
24
24
  import cx from 'classnames'
25
25
 
26
26
  import { FieldRenderer } from '../fields/field-renderer'
27
27
  import { useBylineFieldServices } from '../fields/field-services-context'
28
- import { LocalDateTime } from '../fields/local-date-time'
29
28
  import { AdminGroup } from '../presentation/group'
30
29
  import { AdminRow } from '../presentation/row'
31
30
  import { AdminTabs } from '../presentation/tabs'
32
31
  import { AvailableLocalesWidget } from './available-locales-widget'
33
32
  import { DocumentActions, type DocumentActionsLocaleOption } from './document-actions'
34
33
  import { FormProvider, useFieldValue, useFormContext } from './form-context'
34
+ import { NavigationGuardModal, SystemFieldsConfirmModal, UnsavedChangesModal } from './form-modals'
35
35
  import styles from './form-renderer.module.css'
36
+ import { FormStatusDisplay } from './form-status-display'
36
37
  import { useNavigationGuardAdapter } from './navigation-guard'
37
38
  import { PathWidget } from './path-widget'
39
+ import { computeStatusTransitions } from './status-transitions'
38
40
  import { executeUploadsWithProgress } from './upload-executor'
41
+ import { useFormLayout } from './use-form-layout'
39
42
  import type { UseNavigationGuard } from './navigation-guard'
40
43
 
41
44
  /** Metadata about a previously published version that is still live. */
@@ -156,166 +159,6 @@ export interface FormRendererProps {
156
159
  useNavigationGuard?: UseNavigationGuard
157
160
  }
158
161
 
159
- const FormStatusDisplay = ({
160
- initialData,
161
- workflowStatuses,
162
- publishedVersion,
163
- onUnpublish,
164
- }: {
165
- initialData?: Record<string, any>
166
- workflowStatuses?: WorkflowStatus[]
167
- publishedVersion?: PublishedVersionInfo | null
168
- onUnpublish?: () => Promise<void>
169
- }) => {
170
- const { t } = useTranslation('byline-admin')
171
- const statusCode = initialData?.status
172
- const statusLabel = workflowStatuses?.find((s) => s.name === statusCode)?.label ?? statusCode
173
- // Single-status workflows (e.g. lookups) have no editorial lifecycle —
174
- // suppress the "Status: …" cell since there is nothing meaningful to convey.
175
- const showStatusCell = (workflowStatuses?.length ?? 0) > 1
176
-
177
- return (
178
- <div className={cx('byline-form-status', styles.status)}>
179
- <div className={cx('byline-form-status-meta', styles['status-meta'])}>
180
- {showStatusCell && (
181
- <div className={cx('byline-form-status-cell', styles['status-cell'])}>
182
- <span className={cx('byline-form-status-muted', styles['status-muted'])}>
183
- {t('forms.status.label')}
184
- </span>
185
- <span className={cx('byline-form-status-trunc', styles['status-trunc'])}>
186
- {statusLabel}
187
- </span>
188
- </div>
189
- )}
190
-
191
- {initialData?.updatedAt != null && (
192
- <div className={cx('byline-form-status-cell', styles['status-cell'])}>
193
- <span className={cx('byline-form-status-muted', styles['status-muted'])}>
194
- {t('forms.status.lastModified')}
195
- </span>
196
- <span className={cx('byline-form-status-trunc', styles['status-trunc'])}>
197
- <LocalDateTime value={initialData.updatedAt} />
198
- </span>
199
- </div>
200
- )}
201
-
202
- {initialData?.createdAt != null && (
203
- <div className={cx('byline-form-status-cell', styles['status-cell'])}>
204
- <span className={cx('byline-form-status-muted', styles['status-muted'])}>
205
- {t('forms.status.created')}
206
- </span>
207
- <span className={cx('byline-form-status-trunc', styles['status-trunc'])}>
208
- <LocalDateTime value={initialData.createdAt} />
209
- </span>
210
- </div>
211
- )}
212
- </div>
213
-
214
- {publishedVersion != null && (
215
- <div className={cx('byline-form-status-published', styles['status-published'])}>
216
- <span className={cx('byline-form-status-muted', styles['status-muted'])}>
217
- {t('forms.status.publishedLive')}{' '}
218
- {publishedVersion.updatedAt ? (
219
- <span>
220
- {t('forms.status.publishedOn', { date: new Date(publishedVersion.updatedAt) })}
221
- </span>
222
- ) : (
223
- ''
224
- )}
225
- </span>
226
- {onUnpublish && (
227
- <>
228
- {' '}
229
- <button
230
- type="button"
231
- onClick={onUnpublish}
232
- className={cx('byline-form-status-unpublish', styles['status-unpublish'])}
233
- >
234
- {t('common.actions.unpublish')}
235
- </button>
236
- </>
237
- )}
238
- </div>
239
- )}
240
- </div>
241
- )
242
- }
243
-
244
- /**
245
- * Compute the primary and secondary status transitions for the ComboButton.
246
- * - Primary: the main action (forward step), or the current status itself
247
- * when the document has reached the final workflow step (terminal state).
248
- * - Secondary: other available transitions to show as dropdown options.
249
- * - isTerminal: true when the document is at the final workflow status —
250
- * the primary button renders as a non-actionable indicator and all
251
- * back-steps move into the dropdown.
252
- */
253
- function computeStatusTransitions(
254
- currentStatus: string | undefined,
255
- workflowStatuses: WorkflowStatus[] | undefined,
256
- nextStatus: WorkflowStatus | undefined
257
- ): {
258
- primaryStatus: WorkflowStatus | undefined
259
- secondaryStatuses: WorkflowStatus[]
260
- isTerminal: boolean
261
- } {
262
- if (!workflowStatuses || workflowStatuses.length === 0 || !currentStatus) {
263
- return { primaryStatus: nextStatus, secondaryStatuses: [], isTerminal: false }
264
- }
265
-
266
- // Single-status workflows (e.g. SINGLE_STATUS_WORKFLOW for lookups) have
267
- // no transitions — short-circuit so the form shows only Close / Save.
268
- if (workflowStatuses.length <= 1) {
269
- return { primaryStatus: undefined, secondaryStatuses: [], isTerminal: false }
270
- }
271
-
272
- const currentIndex = workflowStatuses.findIndex((s) => s.name === currentStatus)
273
- if (currentIndex === -1) {
274
- return { primaryStatus: nextStatus, secondaryStatuses: [], isTerminal: false }
275
- }
276
-
277
- const isAtEnd = currentIndex === workflowStatuses.length - 1
278
- const isAtStart = currentIndex === 0
279
-
280
- // Collect all available target statuses
281
- const availableTargets: WorkflowStatus[] = []
282
-
283
- // Reset to first (if not at first)
284
- if (!isAtStart && workflowStatuses[0]) {
285
- availableTargets.push(workflowStatuses[0])
286
- }
287
-
288
- // Back one step (if not at start and the previous is not already the first)
289
- const prev = workflowStatuses[currentIndex - 1]
290
- if (currentIndex > 1 && prev) {
291
- availableTargets.push(prev)
292
- }
293
-
294
- // Forward one step (if not at end) - this is the nextStatus
295
- const next = workflowStatuses[currentIndex + 1]
296
- if (!isAtEnd && next) {
297
- availableTargets.push(next)
298
- }
299
-
300
- if (isAtEnd) {
301
- // Terminal state: the primary button is a non-actionable indicator of the
302
- // current status; both back-steps (revert to previous / reset to first)
303
- // are surfaced in the dropdown.
304
- return {
305
- primaryStatus: workflowStatuses[currentIndex],
306
- secondaryStatuses: availableTargets,
307
- isTerminal: true,
308
- }
309
- }
310
-
311
- // Not at end: primary is the forward step (nextStatus)
312
- return {
313
- primaryStatus: nextStatus,
314
- secondaryStatuses: availableTargets.filter((s) => s.name !== nextStatus?.name),
315
- isTerminal: false,
316
- }
317
- }
318
-
319
162
  const FormContent = ({
320
163
  mode,
321
164
  fields,
@@ -394,80 +237,12 @@ const FormContent = ({
394
237
  if (initialLocale) setContentLocale(initialLocale)
395
238
  }, [initialLocale])
396
239
 
397
- // ---------------------------------------------------------------------
398
- // Layout primitives + lookup tables.
399
- //
400
- // Built once per render from `adminConfig`. The validator at startup
401
- // guarantees every reachable name resolves and every schema field is
402
- // placed at most once, so render-time lookups are unguarded.
403
- // ---------------------------------------------------------------------
404
-
405
- const fieldByName = useMemo(() => {
406
- const map = new Map<string, Field>()
407
- for (const field of fields) {
408
- if ('name' in field) map.set(field.name, field)
409
- }
410
- return map
411
- }, [fields])
412
-
413
- const tabSetByName = useMemo(() => {
414
- const map = new Map<string, TabSetDefinition>()
415
- for (const set of adminConfig?.tabSets ?? []) map.set(set.name, set)
416
- return map
417
- }, [adminConfig])
418
-
419
- const rowByName = useMemo(() => {
420
- const map = new Map<string, RowDefinition>()
421
- for (const row of adminConfig?.rows ?? []) map.set(row.name, row)
422
- return map
423
- }, [adminConfig])
424
-
425
- const groupByName = useMemo(() => {
426
- const map = new Map<string, GroupDefinition>()
427
- for (const group of adminConfig?.groups ?? []) map.set(group.name, group)
428
- return map
429
- }, [adminConfig])
430
-
431
- // When `layout` is omitted, synthesise main = all schema fields in order.
432
- const layout = useMemo(() => {
433
- if (adminConfig?.layout) return adminConfig.layout
434
- return { main: fields.filter((f) => 'name' in f).map((f) => (f as { name: string }).name) }
435
- }, [adminConfig, fields])
436
-
437
- // Reverse index: schema field name → which tab set + tab it lives in.
438
- // Powers per-tab-set error badge counts. Fields not under any tab set
439
- // (e.g. raw-field placement directly in `layout.main`) are absent from
440
- // this map.
441
- const fieldToTabPath = useMemo(() => {
442
- const map = new Map<string, { tabSetName: string; tabName: string }>()
443
- const visit = (
444
- names: readonly string[],
445
- tabSetName: string,
446
- tabName: string,
447
- seen: Set<string>
448
- ) => {
449
- for (const name of names) {
450
- if (fieldByName.has(name)) {
451
- map.set(name, { tabSetName, tabName })
452
- } else if (seen.has(name)) {
453
- } else if (rowByName.has(name)) {
454
- const row = rowByName.get(name)!
455
- const next = new Set(seen).add(name)
456
- visit(row.fields, tabSetName, tabName, next)
457
- } else if (groupByName.has(name)) {
458
- const group = groupByName.get(name)!
459
- const next = new Set(seen).add(name)
460
- visit(group.fields, tabSetName, tabName, next)
461
- }
462
- }
463
- }
464
- for (const set of adminConfig?.tabSets ?? []) {
465
- for (const tab of set.tabs) {
466
- visit(tab.fields, set.name, tab.name, new Set())
467
- }
468
- }
469
- return map
470
- }, [adminConfig, fieldByName, rowByName, groupByName])
240
+ // Layout primitives + lookup tables — pure derivations of `adminConfig` +
241
+ // `fields`. The validator at startup guarantees every reachable name
242
+ // resolves and every schema field is placed at most once, so the render-time
243
+ // lookups below are unguarded. See ./use-form-layout.
244
+ const { fieldByName, tabSetByName, rowByName, groupByName, layout, fieldToTabPath } =
245
+ useFormLayout(adminConfig, fields)
471
246
 
472
247
  // ---------------------------------------------------------------------
473
248
  // Active-tab state — one tab name per declared tab set.
@@ -901,159 +676,21 @@ const FormContent = ({
901
676
  {(layout.sidebar ?? []).map((name) => renderItem(name))}
902
677
  </div>
903
678
  </div>
904
- {showUnsavedModal && (
905
- <Modal
906
- isOpen={true}
907
- closeOnOverlayClick={true}
908
- onDismiss={() => setShowUnsavedModal(false)}
909
- >
910
- <Modal.Container style={{ maxWidth: '460px' }}>
911
- <Modal.Header
912
- className={cx('byline-form-guard-modal-head', styles['guard-modal-head'])}
913
- >
914
- <h3 className={cx('byline-form-guard-modal-title', styles['guard-modal-title'])}>
915
- {t('forms.unsavedChanges.title')}
916
- </h3>
917
- </Modal.Header>
918
- <Modal.Content>
919
- <p className={cx('byline-form-guard-modal-text', styles['guard-modal-text'])}>
920
- {t('forms.unsavedChanges.message')}
921
- </p>
922
- </Modal.Content>
923
- <Modal.Actions>
924
- <Button
925
- size="sm"
926
- style={{ minWidth: '60px' }}
927
- intent="primary"
928
- type="button"
929
- onClick={() => setShowUnsavedModal(false)}
930
- >
931
- {t('forms.unsavedChanges.okButton')}
932
- </Button>
933
- </Modal.Actions>
934
- </Modal.Container>
935
- </Modal>
936
- )}
679
+ {showUnsavedModal && <UnsavedChangesModal onClose={() => setShowUnsavedModal(false)} />}
937
680
  {pendingSystemFieldsSubmit != null && (
938
- <Modal
939
- isOpen={true}
940
- closeOnOverlayClick={true}
941
- onDismiss={() => setPendingSystemFieldsSubmit(null)}
942
- >
943
- <Modal.Container style={{ maxWidth: '520px' }}>
944
- <Modal.Header
945
- className={cx('byline-form-guard-modal-head', styles['guard-modal-head'])}
946
- >
947
- <h3 className={cx('byline-form-guard-modal-title', styles['guard-modal-title'])}>
948
- {pendingSystemFieldsSubmit.contentDirty
949
- ? t('forms.systemFieldsConfirm.bothTitle')
950
- : t('forms.systemFieldsConfirm.title')}
951
- </h3>
952
- <IconButton
953
- aria-label={t('common.actions.close')}
954
- size="xs"
955
- onClick={() => setPendingSystemFieldsSubmit(null)}
956
- >
957
- <CloseIcon width="16px" height="16px" svgClassName="white-icon" />
958
- </IconButton>
959
- </Modal.Header>
960
- <Modal.Content className="prose">
961
- {/* Lead with reassurance: content edits follow the normal
962
- revision + publish workflow. The immediate, document-level
963
- system-field write is explained below the divider. */}
964
- {pendingSystemFieldsSubmit.contentDirty && (
965
- <p className={cx('byline-form-system-fields-content-note', 'm-0 mt-2')}>
966
- {t('forms.systemFieldsConfirm.contentNote')}
967
- </p>
968
- )}
969
- <p
970
- className="m-0 mt-2"
971
- style={
972
- pendingSystemFieldsSubmit.contentDirty
973
- ? {
974
- marginTop: 'var(--spacing-8)',
975
- paddingTop: 'var(--spacing-12)',
976
- borderTop: '1px solid var(--border-color)',
977
- }
978
- : undefined
979
- }
980
- >
981
- {t('forms.systemFieldsConfirm.intro')}
982
- </p>
983
- <ul
984
- className={cx('byline-form-system-fields-list', styles['guard-modal-text'], 'm-0')}
985
- >
986
- {pendingSystemFieldsSubmit.pathDirty && (
987
- <li>{t('forms.systemFieldsConfirm.bulletPath')}</li>
988
- )}
989
- {pendingSystemFieldsSubmit.availableLocalesDirty && (
990
- <li>{t('forms.systemFieldsConfirm.bulletLocales')}</li>
991
- )}
992
- </ul>
993
- <p
994
- className={cx('byline-form-system-fields-effect', styles['guard-modal-text'])}
995
- style={{
996
- marginTop: 'var(--spacing-4)',
997
- marginBottom: 0,
998
- color: 'var(--text-subtle)',
999
- }}
1000
- >
1001
- {t('forms.systemFieldsConfirm.effectLine')}
1002
- </p>
1003
- </Modal.Content>
1004
- <Modal.Actions>
1005
- <Button
1006
- size="sm"
1007
- style={{ minWidth: '80px' }}
1008
- intent="noeffect"
1009
- type="button"
1010
- onClick={() => setPendingSystemFieldsSubmit(null)}
1011
- >
1012
- {t('common.actions.cancel')}
1013
- </Button>
1014
- <Button
1015
- size="sm"
1016
- style={{ minWidth: '80px' }}
1017
- intent="primary"
1018
- type="button"
1019
- onClick={() => {
1020
- const payload = pendingSystemFieldsSubmit
1021
- setPendingSystemFieldsSubmit(null)
1022
- submitPayload(payload)
1023
- }}
1024
- >
1025
- {t('forms.systemFieldsConfirm.confirmButton')}
1026
- </Button>
1027
- </Modal.Actions>
1028
- </Modal.Container>
1029
- </Modal>
1030
- )}
1031
- {guard.isBlocked && (
1032
- <Modal isOpen={true} closeOnOverlayClick={false} onDismiss={guard.stay}>
1033
- <Modal.Container style={{ maxWidth: '460px' }}>
1034
- <Modal.Header
1035
- className={cx('byline-form-guard-modal-head', styles['guard-modal-head'])}
1036
- >
1037
- <h3 className={cx('byline-form-guard-modal-title', styles['guard-modal-title'])}>
1038
- {t('forms.navigationGuard.title')}
1039
- </h3>
1040
- </Modal.Header>
1041
- <Modal.Content>
1042
- <p className={cx('byline-form-guard-modal-text', styles['guard-modal-text'])}>
1043
- {t('forms.navigationGuard.message')}
1044
- </p>
1045
- </Modal.Content>
1046
- <Modal.Actions>
1047
- <Button size="sm" intent="noeffect" type="button" onClick={guard.stay}>
1048
- {t('forms.navigationGuard.stayButton')}
1049
- </Button>
1050
- <Button size="sm" intent="danger" type="button" onClick={guard.proceed}>
1051
- {t('forms.navigationGuard.leaveButton')}
1052
- </Button>
1053
- </Modal.Actions>
1054
- </Modal.Container>
1055
- </Modal>
681
+ <SystemFieldsConfirmModal
682
+ contentDirty={pendingSystemFieldsSubmit.contentDirty}
683
+ pathDirty={pendingSystemFieldsSubmit.pathDirty}
684
+ availableLocalesDirty={pendingSystemFieldsSubmit.availableLocalesDirty}
685
+ onCancel={() => setPendingSystemFieldsSubmit(null)}
686
+ onConfirm={() => {
687
+ const payload = pendingSystemFieldsSubmit
688
+ setPendingSystemFieldsSubmit(null)
689
+ submitPayload(payload)
690
+ }}
691
+ />
1056
692
  )}
693
+ {guard.isBlocked && <NavigationGuardModal onStay={guard.stay} onProceed={guard.proceed} />}
1057
694
  </form>
1058
695
  )
1059
696
  }
@@ -0,0 +1,108 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * This Source Code is subject to the terms of the Mozilla Public
5
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
7
+ *
8
+ * Copyright (c) Infonomic Company Limited
9
+ */
10
+
11
+ import type { WorkflowStatus } from '@byline/core'
12
+ import { useTranslation } from '@byline/i18n/react'
13
+ import cx from 'classnames'
14
+
15
+ import { LocalDateTime } from '../fields/local-date-time'
16
+ import styles from './form-renderer.module.css'
17
+ import type { PublishedVersionInfo } from './form-renderer'
18
+
19
+ /**
20
+ * The status / metadata strip at the top of a document form — current
21
+ * workflow status (suppressed for single-status workflows), last-modified and
22
+ * created timestamps, and a "published live" indicator with an inline
23
+ * Unpublish action when a previously-published version is still live.
24
+ */
25
+ export const FormStatusDisplay = ({
26
+ initialData,
27
+ workflowStatuses,
28
+ publishedVersion,
29
+ onUnpublish,
30
+ }: {
31
+ initialData?: Record<string, any>
32
+ workflowStatuses?: WorkflowStatus[]
33
+ publishedVersion?: PublishedVersionInfo | null
34
+ onUnpublish?: () => Promise<void>
35
+ }) => {
36
+ const { t } = useTranslation('byline-admin')
37
+ const statusCode = initialData?.status
38
+ const statusLabel = workflowStatuses?.find((s) => s.name === statusCode)?.label ?? statusCode
39
+ // Single-status workflows (e.g. lookups) have no editorial lifecycle —
40
+ // suppress the "Status: …" cell since there is nothing meaningful to convey.
41
+ const showStatusCell = (workflowStatuses?.length ?? 0) > 1
42
+
43
+ return (
44
+ <div className={cx('byline-form-status', styles.status)}>
45
+ <div className={cx('byline-form-status-meta', styles['status-meta'])}>
46
+ {showStatusCell && (
47
+ <div className={cx('byline-form-status-cell', styles['status-cell'])}>
48
+ <span className={cx('byline-form-status-muted', styles['status-muted'])}>
49
+ {t('forms.status.label')}
50
+ </span>
51
+ <span className={cx('byline-form-status-trunc', styles['status-trunc'])}>
52
+ {statusLabel}
53
+ </span>
54
+ </div>
55
+ )}
56
+
57
+ {initialData?.updatedAt != null && (
58
+ <div className={cx('byline-form-status-cell', styles['status-cell'])}>
59
+ <span className={cx('byline-form-status-muted', styles['status-muted'])}>
60
+ {t('forms.status.lastModified')}
61
+ </span>
62
+ <span className={cx('byline-form-status-trunc', styles['status-trunc'])}>
63
+ <LocalDateTime value={initialData.updatedAt} />
64
+ </span>
65
+ </div>
66
+ )}
67
+
68
+ {initialData?.createdAt != null && (
69
+ <div className={cx('byline-form-status-cell', styles['status-cell'])}>
70
+ <span className={cx('byline-form-status-muted', styles['status-muted'])}>
71
+ {t('forms.status.created')}
72
+ </span>
73
+ <span className={cx('byline-form-status-trunc', styles['status-trunc'])}>
74
+ <LocalDateTime value={initialData.createdAt} />
75
+ </span>
76
+ </div>
77
+ )}
78
+ </div>
79
+
80
+ {publishedVersion != null && (
81
+ <div className={cx('byline-form-status-published', styles['status-published'])}>
82
+ <span className={cx('byline-form-status-muted', styles['status-muted'])}>
83
+ {t('forms.status.publishedLive')}{' '}
84
+ {publishedVersion.updatedAt ? (
85
+ <span>
86
+ {t('forms.status.publishedOn', { date: new Date(publishedVersion.updatedAt) })}
87
+ </span>
88
+ ) : (
89
+ ''
90
+ )}
91
+ </span>
92
+ {onUnpublish && (
93
+ <>
94
+ {' '}
95
+ <button
96
+ type="button"
97
+ onClick={onUnpublish}
98
+ className={cx('byline-form-status-unpublish', styles['status-unpublish'])}
99
+ >
100
+ {t('common.actions.unpublish')}
101
+ </button>
102
+ </>
103
+ )}
104
+ </div>
105
+ )}
106
+ </div>
107
+ )
108
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import type { WorkflowStatus } from '@byline/core'
10
+ import { describe, expect, it } from 'vitest'
11
+
12
+ import { computeStatusTransitions } from './status-transitions'
13
+
14
+ const draft: WorkflowStatus = { name: 'draft', label: 'Draft' }
15
+ const review: WorkflowStatus = { name: 'review', label: 'Review' }
16
+ const published: WorkflowStatus = { name: 'published', label: 'Published' }
17
+ const flow = [draft, review, published]
18
+
19
+ describe('computeStatusTransitions', () => {
20
+ it('falls back to nextStatus when there is no workflow or no current status', () => {
21
+ expect(computeStatusTransitions(undefined, undefined, published)).toEqual({
22
+ primaryStatus: published,
23
+ secondaryStatuses: [],
24
+ isTerminal: false,
25
+ })
26
+ expect(computeStatusTransitions('draft', [], published)).toEqual({
27
+ primaryStatus: published,
28
+ secondaryStatuses: [],
29
+ isTerminal: false,
30
+ })
31
+ expect(computeStatusTransitions(undefined, flow, published)).toEqual({
32
+ primaryStatus: published,
33
+ secondaryStatuses: [],
34
+ isTerminal: false,
35
+ })
36
+ })
37
+
38
+ it('exposes no transitions for a single-status workflow', () => {
39
+ expect(computeStatusTransitions('draft', [draft], undefined)).toEqual({
40
+ primaryStatus: undefined,
41
+ secondaryStatuses: [],
42
+ isTerminal: false,
43
+ })
44
+ })
45
+
46
+ it('falls back to nextStatus when the current status is not in the workflow', () => {
47
+ expect(computeStatusTransitions('archived', flow, review)).toEqual({
48
+ primaryStatus: review,
49
+ secondaryStatuses: [],
50
+ isTerminal: false,
51
+ })
52
+ })
53
+
54
+ it('at the first step: primary is the forward step, no back-steps', () => {
55
+ const result = computeStatusTransitions('draft', flow, review)
56
+ expect(result.primaryStatus).toBe(review)
57
+ expect(result.isTerminal).toBe(false)
58
+ // Forward step is the primary, so it is filtered out of the dropdown.
59
+ expect(result.secondaryStatuses).toEqual([])
60
+ })
61
+
62
+ it('in the middle: primary is forward, dropdown offers reset-to-first', () => {
63
+ const result = computeStatusTransitions('review', flow, published)
64
+ expect(result.primaryStatus).toBe(published)
65
+ expect(result.isTerminal).toBe(false)
66
+ // Reset-to-first (draft) is surfaced; the forward step (published) is the
67
+ // primary and filtered out. The "back one step" is draft itself here, so
68
+ // it is not duplicated.
69
+ expect(result.secondaryStatuses).toEqual([draft])
70
+ })
71
+
72
+ it('at the terminal step: primary is the current status, back-steps in dropdown', () => {
73
+ const result = computeStatusTransitions('published', flow, undefined)
74
+ expect(result.primaryStatus).toBe(published)
75
+ expect(result.isTerminal).toBe(true)
76
+ // Both reset-to-first (draft) and back-one-step (review) are offered.
77
+ expect(result.secondaryStatuses).toEqual([draft, review])
78
+ })
79
+
80
+ it('terminal back-one-step is omitted when the previous is already the first', () => {
81
+ const twoStep = [draft, published]
82
+ const result = computeStatusTransitions('published', twoStep, undefined)
83
+ expect(result.isTerminal).toBe(true)
84
+ // Only reset-to-first (draft); no separate back-one-step since prev === first.
85
+ expect(result.secondaryStatuses).toEqual([draft])
86
+ })
87
+ })