@byline/admin 3.12.1 → 3.13.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 (35) hide show
  1. package/dist/fields/field-services-types.d.ts +50 -0
  2. package/dist/forms/form-context.js +30 -60
  3. package/dist/forms/form-modals.d.ts +30 -0
  4. package/dist/forms/form-modals.js +189 -0
  5. package/dist/forms/form-renderer.d.ts +8 -1
  6. package/dist/forms/form-renderer.js +31 -365
  7. package/dist/forms/form-status-display.d.ts +21 -0
  8. package/dist/forms/form-status-display.js +94 -0
  9. package/dist/forms/status-transitions.d.ts +23 -0
  10. package/dist/forms/status-transitions.js +37 -0
  11. package/dist/forms/status-transitions.test.node.d.ts +8 -0
  12. package/dist/forms/tree-placement-widget.d.ts +23 -0
  13. package/dist/forms/tree-placement-widget.js +176 -0
  14. package/dist/forms/tree-placement-widget.module.js +15 -0
  15. package/dist/forms/tree-placement-widget_module.css +69 -0
  16. package/dist/forms/use-form-layout.d.ts +35 -0
  17. package/dist/forms/use-form-layout.js +77 -0
  18. package/dist/forms/use-form-layout.test.node.d.ts +8 -0
  19. package/dist/forms/use-tracked-slot.d.ts +45 -0
  20. package/dist/forms/use-tracked-slot.js +46 -0
  21. package/dist/react.d.ts +1 -1
  22. package/package.json +5 -5
  23. package/src/fields/field-services-types.ts +50 -0
  24. package/src/forms/form-context.tsx +36 -92
  25. package/src/forms/form-modals.tsx +186 -0
  26. package/src/forms/form-renderer.tsx +42 -387
  27. package/src/forms/form-status-display.tsx +108 -0
  28. package/src/forms/status-transitions.test.node.ts +87 -0
  29. package/src/forms/status-transitions.ts +86 -0
  30. package/src/forms/tree-placement-widget.module.css +87 -0
  31. package/src/forms/tree-placement-widget.tsx +202 -0
  32. package/src/forms/use-form-layout.test.node.ts +82 -0
  33. package/src/forms/use-form-layout.ts +134 -0
  34. package/src/forms/use-tracked-slot.ts +101 -0
  35. package/src/react.ts +6 -0
@@ -20,22 +20,26 @@ 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'
40
+ import { TreePlacementWidget } from './tree-placement-widget'
38
41
  import { executeUploadsWithProgress } from './upload-executor'
42
+ import { useFormLayout } from './use-form-layout'
39
43
  import type { UseNavigationGuard } from './navigation-guard'
40
44
 
41
45
  /** Metadata about a previously published version that is still live. */
@@ -126,6 +130,13 @@ export interface FormRendererProps {
126
130
  * against the document's `_availableVersionLocales` ledger fact.
127
131
  */
128
132
  advertiseLocales?: boolean
133
+ /**
134
+ * Opts the document-tree placement widget into the sidebar (above the
135
+ * available-locales widget). Sourced from `CollectionDefinition.tree` by the
136
+ * caller. Renders only in edit mode (placement needs a persisted document)
137
+ * and only when the host wires the tree services. See docs/DOCUMENT-TREE.md.
138
+ */
139
+ tree?: boolean
129
140
  headingLabel?: string
130
141
  headerSlot?: ReactNode
131
142
  /** Collection path forwarded to upload-capable fields (e.g. `'media'`). */
@@ -156,166 +167,6 @@ export interface FormRendererProps {
156
167
  useNavigationGuard?: UseNavigationGuard
157
168
  }
158
169
 
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
170
  const FormContent = ({
320
171
  mode,
321
172
  fields,
@@ -336,6 +187,7 @@ const FormContent = ({
336
187
  useAsTitle,
337
188
  useAsPath,
338
189
  advertiseLocales,
190
+ tree,
339
191
  headingLabel,
340
192
  headerSlot,
341
193
  collectionPath,
@@ -394,80 +246,12 @@ const FormContent = ({
394
246
  if (initialLocale) setContentLocale(initialLocale)
395
247
  }, [initialLocale])
396
248
 
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])
249
+ // Layout primitives + lookup tables — pure derivations of `adminConfig` +
250
+ // `fields`. The validator at startup guarantees every reachable name
251
+ // resolves and every schema field is placed at most once, so the render-time
252
+ // lookups below are unguarded. See ./use-form-layout.
253
+ const { fieldByName, tabSetByName, rowByName, groupByName, layout, fieldToTabPath } =
254
+ useFormLayout(adminConfig, fields)
471
255
 
472
256
  // ---------------------------------------------------------------------
473
257
  // Active-tab state — one tab name per declared tab set.
@@ -890,6 +674,13 @@ const FormContent = ({
890
674
  mode={mode}
891
675
  />
892
676
  )}
677
+ {tree && mode === 'edit' && typeof initialData?.id === 'string' && (
678
+ <TreePlacementWidget
679
+ collectionPath={collectionPath ?? ''}
680
+ documentId={initialData.id as string}
681
+ useAsTitle={useAsTitle}
682
+ />
683
+ )}
893
684
  {advertiseLocales && (
894
685
  <AvailableLocalesWidget
895
686
  contentLocales={contentLocales ?? []}
@@ -901,159 +692,21 @@ const FormContent = ({
901
692
  {(layout.sidebar ?? []).map((name) => renderItem(name))}
902
693
  </div>
903
694
  </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
- )}
695
+ {showUnsavedModal && <UnsavedChangesModal onClose={() => setShowUnsavedModal(false)} />}
937
696
  {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>
697
+ <SystemFieldsConfirmModal
698
+ contentDirty={pendingSystemFieldsSubmit.contentDirty}
699
+ pathDirty={pendingSystemFieldsSubmit.pathDirty}
700
+ availableLocalesDirty={pendingSystemFieldsSubmit.availableLocalesDirty}
701
+ onCancel={() => setPendingSystemFieldsSubmit(null)}
702
+ onConfirm={() => {
703
+ const payload = pendingSystemFieldsSubmit
704
+ setPendingSystemFieldsSubmit(null)
705
+ submitPayload(payload)
706
+ }}
707
+ />
1056
708
  )}
709
+ {guard.isBlocked && <NavigationGuardModal onStay={guard.stay} onProceed={guard.proceed} />}
1057
710
  </form>
1058
711
  )
1059
712
  }
@@ -1078,6 +731,7 @@ export const FormRenderer = ({
1078
731
  useAsTitle,
1079
732
  useAsPath,
1080
733
  advertiseLocales,
734
+ tree,
1081
735
  headingLabel,
1082
736
  headerSlot,
1083
737
  collectionPath,
@@ -1116,6 +770,7 @@ export const FormRenderer = ({
1116
770
  useAsTitle={useAsTitle}
1117
771
  useAsPath={useAsPath}
1118
772
  advertiseLocales={advertiseLocales}
773
+ tree={tree}
1119
774
  headingLabel={headingLabel}
1120
775
  headerSlot={headerSlot}
1121
776
  collectionPath={collectionPath}
@@ -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
+ }