@byline/ui 2.5.1 → 2.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 (211) hide show
  1. package/dist/dnd/draggable-sortable/demo/draggable-list-demo.js +1 -1
  2. package/dist/react.d.ts +18 -54
  3. package/dist/react.js +0 -35
  4. package/dist/uikit.d.ts +1 -0
  5. package/dist/uikit.js +1 -0
  6. package/package.json +2 -8
  7. package/src/dnd/draggable-sortable/demo/draggable-list-demo.tsx +1 -1
  8. package/src/react.ts +20 -68
  9. package/src/uikit.ts +1 -0
  10. package/dist/admin/group.d.ts +0 -27
  11. package/dist/admin/group.js +0 -14
  12. package/dist/admin/group.module.js +0 -6
  13. package/dist/admin/group_module.css +0 -19
  14. package/dist/admin/row.d.ts +0 -25
  15. package/dist/admin/row.js +0 -8
  16. package/dist/admin/row.module.js +0 -5
  17. package/dist/admin/row_module.css +0 -18
  18. package/dist/admin/tabs.d.ts +0 -25
  19. package/dist/admin/tabs.js +0 -35
  20. package/dist/admin/tabs.module.js +0 -10
  21. package/dist/admin/tabs_module.css +0 -68
  22. package/dist/fields/array/array-field.d.ts +0 -14
  23. package/dist/fields/array/array-field.js +0 -176
  24. package/dist/fields/array/array-field.module.js +0 -11
  25. package/dist/fields/array/array-field_module.css +0 -32
  26. package/dist/fields/blocks/blocks-field.d.ts +0 -13
  27. package/dist/fields/blocks/blocks-field.js +0 -244
  28. package/dist/fields/blocks/blocks-field.module.js +0 -26
  29. package/dist/fields/blocks/blocks-field_module.css +0 -107
  30. package/dist/fields/checkbox/checkbox-field.d.ts +0 -16
  31. package/dist/fields/checkbox/checkbox-field.js +0 -28
  32. package/dist/fields/checkbox/checkbox-field.module.js +0 -6
  33. package/dist/fields/checkbox/checkbox-field_module.css +0 -4
  34. package/dist/fields/column-formatter.d.ts +0 -20
  35. package/dist/fields/column-formatter.js +0 -15
  36. package/dist/fields/date-time-formatter.d.ts +0 -16
  37. package/dist/fields/date-time-formatter.js +0 -8
  38. package/dist/fields/datetime/datetime-field.d.ts +0 -16
  39. package/dist/fields/datetime/datetime-field.js +0 -37
  40. package/dist/fields/datetime/datetime-field.module.js +0 -5
  41. package/dist/fields/datetime/datetime-field_module.css +0 -4
  42. package/dist/fields/draggable-context-menu.d.ts +0 -6
  43. package/dist/fields/draggable-context-menu.js +0 -83
  44. package/dist/fields/draggable-context-menu.module.js +0 -15
  45. package/dist/fields/draggable-context-menu_module.css +0 -91
  46. package/dist/fields/field-helpers.d.ts +0 -26
  47. package/dist/fields/field-helpers.js +0 -50
  48. package/dist/fields/field-renderer.d.ts +0 -37
  49. package/dist/fields/field-renderer.js +0 -206
  50. package/dist/fields/field-renderer.module.js +0 -8
  51. package/dist/fields/field-renderer_module.css +0 -11
  52. package/dist/fields/file/file-field.d.ts +0 -19
  53. package/dist/fields/file/file-field.js +0 -226
  54. package/dist/fields/file/file-field.module.js +0 -18
  55. package/dist/fields/file/file-field_module.css +0 -131
  56. package/dist/fields/file/file-upload-field.d.ts +0 -21
  57. package/dist/fields/file/file-upload-field.js +0 -128
  58. package/dist/fields/file/file-upload-field.module.js +0 -15
  59. package/dist/fields/file/file-upload-field_module.css +0 -74
  60. package/dist/fields/group/group-field.d.ts +0 -15
  61. package/dist/fields/group/group-field.js +0 -59
  62. package/dist/fields/group/group-field.module.js +0 -9
  63. package/dist/fields/group/group-field_module.css +0 -27
  64. package/dist/fields/image/image-field.d.ts +0 -19
  65. package/dist/fields/image/image-field.js +0 -242
  66. package/dist/fields/image/image-field.module.js +0 -22
  67. package/dist/fields/image/image-field_module.css +0 -121
  68. package/dist/fields/image/image-upload-field.d.ts +0 -21
  69. package/dist/fields/image/image-upload-field.js +0 -187
  70. package/dist/fields/image/image-upload-field.module.js +0 -19
  71. package/dist/fields/image/image-upload-field_module.css +0 -92
  72. package/dist/fields/local-date-time.d.ts +0 -27
  73. package/dist/fields/local-date-time.js +0 -49
  74. package/dist/fields/locale-badge.d.ts +0 -18
  75. package/dist/fields/locale-badge.js +0 -10
  76. package/dist/fields/locale-badge.module.js +0 -5
  77. package/dist/fields/locale-badge_module.css +0 -27
  78. package/dist/fields/numerical/numerical-field.d.ts +0 -18
  79. package/dist/fields/numerical/numerical-field.js +0 -74
  80. package/dist/fields/relation/relation-display.d.ts +0 -40
  81. package/dist/fields/relation/relation-display.js +0 -58
  82. package/dist/fields/relation/relation-display.module.js +0 -9
  83. package/dist/fields/relation/relation-display_module.css +0 -21
  84. package/dist/fields/relation/relation-field.d.ts +0 -18
  85. package/dist/fields/relation/relation-field.js +0 -146
  86. package/dist/fields/relation/relation-field.module.js +0 -13
  87. package/dist/fields/relation/relation-field_module.css +0 -62
  88. package/dist/fields/relation/relation-picker.d.ts +0 -49
  89. package/dist/fields/relation/relation-picker.js +0 -233
  90. package/dist/fields/relation/relation-picker.module.js +0 -26
  91. package/dist/fields/relation/relation-picker_module.css +0 -124
  92. package/dist/fields/relation/relation-summary.d.ts +0 -31
  93. package/dist/fields/relation/relation-summary.js +0 -50
  94. package/dist/fields/relation/relation-summary.module.js +0 -11
  95. package/dist/fields/relation/relation-summary_module.css +0 -37
  96. package/dist/fields/select/select-field.d.ts +0 -16
  97. package/dist/fields/select/select-field.js +0 -50
  98. package/dist/fields/select/select-field.module.js +0 -5
  99. package/dist/fields/select/select-field_module.css +0 -4
  100. package/dist/fields/sortable-item.d.ts +0 -15
  101. package/dist/fields/sortable-item.js +0 -80
  102. package/dist/fields/sortable-item.module.js +0 -22
  103. package/dist/fields/sortable-item_module.css +0 -124
  104. package/dist/fields/text/text-field.d.ts +0 -20
  105. package/dist/fields/text/text-field.js +0 -104
  106. package/dist/fields/text/text-field.module.js +0 -6
  107. package/dist/fields/text/text-field_module.css +0 -5
  108. package/dist/fields/text-area/text-area-field.d.ts +0 -20
  109. package/dist/fields/text-area/text-area-field.js +0 -105
  110. package/dist/fields/text-area/text-area-field.module.js +0 -6
  111. package/dist/fields/text-area/text-area-field_module.css +0 -5
  112. package/dist/fields/use-field-change-handler.d.ts +0 -23
  113. package/dist/fields/use-field-change-handler.js +0 -52
  114. package/dist/forms/document-actions.d.ts +0 -48
  115. package/dist/forms/document-actions.js +0 -469
  116. package/dist/forms/document-actions.module.js +0 -34
  117. package/dist/forms/document-actions_module.css +0 -118
  118. package/dist/forms/form-context.d.ts +0 -89
  119. package/dist/forms/form-context.js +0 -466
  120. package/dist/forms/form-renderer.d.ts +0 -98
  121. package/dist/forms/form-renderer.js +0 -591
  122. package/dist/forms/form-renderer.module.js +0 -46
  123. package/dist/forms/form-renderer_module.css +0 -245
  124. package/dist/forms/navigation-guard.d.ts +0 -54
  125. package/dist/forms/navigation-guard.js +0 -22
  126. package/dist/forms/path-widget.d.ts +0 -36
  127. package/dist/forms/path-widget.js +0 -107
  128. package/dist/forms/path-widget.module.js +0 -8
  129. package/dist/forms/path-widget_module.css +0 -29
  130. package/dist/forms/upload-executor.d.ts +0 -57
  131. package/dist/forms/upload-executor.js +0 -92
  132. package/dist/services/field-services-context.d.ts +0 -16
  133. package/dist/services/field-services-context.js +0 -13
  134. package/dist/services/field-services-types.d.ts +0 -63
  135. package/dist/services/field-services-types.js +0 -1
  136. package/dist/widgets/diff-viewer/diff-modal.d.ts +0 -22
  137. package/dist/widgets/diff-viewer/diff-modal.js +0 -146
  138. package/dist/widgets/diff-viewer/diff-modal.module.js +0 -14
  139. package/dist/widgets/diff-viewer/diff-modal_module.css +0 -56
  140. package/dist/widgets/status-badge/status-badge.d.ts +0 -25
  141. package/dist/widgets/status-badge/status-badge.js +0 -35
  142. package/dist/widgets/status-badge/status-badge.module.js +0 -7
  143. package/dist/widgets/status-badge/status-badge_module.css +0 -20
  144. package/src/admin/group.module.css +0 -41
  145. package/src/admin/group.tsx +0 -40
  146. package/src/admin/row.module.css +0 -32
  147. package/src/admin/row.tsx +0 -33
  148. package/src/admin/tabs.module.css +0 -107
  149. package/src/admin/tabs.tsx +0 -82
  150. package/src/fields/array/array-field.module.css +0 -48
  151. package/src/fields/array/array-field.tsx +0 -266
  152. package/src/fields/blocks/blocks-field.module.css +0 -148
  153. package/src/fields/blocks/blocks-field.tsx +0 -312
  154. package/src/fields/checkbox/checkbox-field.module.css +0 -4
  155. package/src/fields/checkbox/checkbox-field.tsx +0 -54
  156. package/src/fields/column-formatter.tsx +0 -31
  157. package/src/fields/date-time-formatter.tsx +0 -22
  158. package/src/fields/datetime/datetime-field.module.css +0 -13
  159. package/src/fields/datetime/datetime-field.tsx +0 -54
  160. package/src/fields/draggable-context-menu.module.css +0 -127
  161. package/src/fields/draggable-context-menu.tsx +0 -85
  162. package/src/fields/field-helpers.ts +0 -69
  163. package/src/fields/field-renderer.module.css +0 -22
  164. package/src/fields/field-renderer.tsx +0 -288
  165. package/src/fields/file/file-field.module.css +0 -153
  166. package/src/fields/file/file-field.tsx +0 -271
  167. package/src/fields/file/file-upload-field.module.css +0 -101
  168. package/src/fields/file/file-upload-field.tsx +0 -183
  169. package/src/fields/group/group-field.module.css +0 -43
  170. package/src/fields/group/group-field.tsx +0 -84
  171. package/src/fields/image/image-field.module.css +0 -155
  172. package/src/fields/image/image-field.tsx +0 -291
  173. package/src/fields/image/image-upload-field.module.css +0 -123
  174. package/src/fields/image/image-upload-field.tsx +0 -270
  175. package/src/fields/local-date-time.tsx +0 -88
  176. package/src/fields/locale-badge.module.css +0 -37
  177. package/src/fields/locale-badge.tsx +0 -32
  178. package/src/fields/numerical/numerical-field.tsx +0 -114
  179. package/src/fields/relation/relation-display.module.css +0 -36
  180. package/src/fields/relation/relation-display.tsx +0 -130
  181. package/src/fields/relation/relation-field.module.css +0 -83
  182. package/src/fields/relation/relation-field.tsx +0 -206
  183. package/src/fields/relation/relation-picker.module.css +0 -168
  184. package/src/fields/relation/relation-picker.tsx +0 -325
  185. package/src/fields/relation/relation-summary.module.css +0 -55
  186. package/src/fields/relation/relation-summary.tsx +0 -123
  187. package/src/fields/select/select-field.module.css +0 -13
  188. package/src/fields/select/select-field.tsx +0 -61
  189. package/src/fields/sortable-item.module.css +0 -167
  190. package/src/fields/sortable-item.tsx +0 -101
  191. package/src/fields/text/text-field.module.css +0 -13
  192. package/src/fields/text/text-field.tsx +0 -146
  193. package/src/fields/text-area/text-area-field.module.css +0 -13
  194. package/src/fields/text-area/text-area-field.tsx +0 -147
  195. package/src/fields/use-field-change-handler.ts +0 -112
  196. package/src/forms/document-actions.module.css +0 -160
  197. package/src/forms/document-actions.tsx +0 -487
  198. package/src/forms/form-context.tsx +0 -704
  199. package/src/forms/form-renderer.module.css +0 -321
  200. package/src/forms/form-renderer.tsx +0 -888
  201. package/src/forms/navigation-guard.tsx +0 -98
  202. package/src/forms/path-widget.module.css +0 -41
  203. package/src/forms/path-widget.test.tsx +0 -217
  204. package/src/forms/path-widget.tsx +0 -181
  205. package/src/forms/upload-executor.ts +0 -190
  206. package/src/services/field-services-context.tsx +0 -35
  207. package/src/services/field-services-types.ts +0 -68
  208. package/src/widgets/diff-viewer/diff-modal.module.css +0 -79
  209. package/src/widgets/diff-viewer/diff-modal.tsx +0 -184
  210. package/src/widgets/status-badge/status-badge.module.css +0 -31
  211. package/src/widgets/status-badge/status-badge.tsx +0 -69
@@ -1,888 +0,0 @@
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 ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
12
-
13
- import type {
14
- CollectionAdminConfig,
15
- Field,
16
- GroupDefinition,
17
- RowDefinition,
18
- TabSetDefinition,
19
- WorkflowStatus,
20
- } from '@byline/core'
21
- import cx from 'classnames'
22
-
23
- import { AdminGroup } from '../admin/group'
24
- import { AdminRow } from '../admin/row'
25
- import { AdminTabs } from '../admin/tabs'
26
- import { FieldRenderer } from '../fields/field-renderer'
27
- import { LocalDateTime } from '../fields/local-date-time'
28
- import { useBylineFieldServices } from '../services/field-services-context'
29
- import { Alert, Button, ComboButton, Modal } from '../uikit.js'
30
- import { DocumentActions, type DocumentActionsLocaleOption } from './document-actions'
31
- import { FormProvider, useFieldValue, useFormContext } from './form-context'
32
- import styles from './form-renderer.module.css'
33
- import { useNavigationGuardAdapter } from './navigation-guard'
34
- import { PathWidget } from './path-widget'
35
- import { executeUploadsWithProgress } from './upload-executor'
36
- import type { UseNavigationGuard } from './navigation-guard'
37
-
38
- /** Metadata about a previously published version that is still live. */
39
- export interface PublishedVersionInfo {
40
- id: string
41
- versionId: string
42
- status: string
43
- createdAt: string | Date
44
- updatedAt: string | Date
45
- }
46
-
47
- /** Props shared by both the public FormRenderer and its internal FormContent component. */
48
- export interface FormRendererProps {
49
- mode: 'create' | 'edit'
50
- fields: Field[]
51
- onSubmit: (data: any) => void
52
- onCancel: () => void
53
- onStatusChange?: (nextStatus: string) => Promise<void>
54
- onUnpublish?: () => Promise<void>
55
- onDelete?: () => Promise<void>
56
- /**
57
- * Called when the editor confirms the duplicate modal in
58
- * `DocumentActions`. Edit views provide a handler that invokes the
59
- * `duplicateCollectionDocument` server fn and navigates to the new doc.
60
- * When omitted, the Duplicate menu item is hidden.
61
- */
62
- onDuplicate?: () => Promise<void>
63
- /**
64
- * Called when the editor confirms the Copy-to-Locale modal in
65
- * `DocumentActions`. Edit views provide a handler that invokes the
66
- * `copyDocumentToLocale` server fn and navigates to the target-locale
67
- * view. When omitted (or when fewer than two `contentLocales` are
68
- * configured), the Copy-to-Locale menu item is hidden.
69
- */
70
- onCopyToLocale?: (args: { targetLocale: string; overwrite: boolean }) => Promise<void>
71
- /**
72
- * All configured content locales (code + display label) — required for
73
- * the Copy-to-Locale modal's target Select. Threaded as an opaque list
74
- * through to `DocumentActions`.
75
- */
76
- contentLocales?: ReadonlyArray<DocumentActionsLocaleOption>
77
- nextStatus?: WorkflowStatus
78
- workflowStatuses?: WorkflowStatus[]
79
- publishedVersion?: PublishedVersionInfo | null
80
- initialData?: Record<string, any>
81
- adminConfig?: CollectionAdminConfig
82
- /**
83
- * Name of the schema field to render as the live form heading.
84
- * Sourced from `CollectionDefinition.useAsTitle` by the caller.
85
- */
86
- useAsTitle?: string
87
- /**
88
- * Name of the schema field that initialises the system path.
89
- * Sourced from `CollectionDefinition.useAsPath` by the caller. When
90
- * present the path widget renders in the sidebar.
91
- */
92
- useAsPath?: string
93
- headingLabel?: string
94
- headerSlot?: ReactNode
95
- /** Collection path forwarded to upload-capable fields (e.g. `'media'`). */
96
- collectionPath?: string
97
- /** The active content locale — initialised from the route query string. */
98
- initialLocale?: string
99
- /** Called when the user picks a different content locale. */
100
- onLocaleChange?: (locale: string) => void
101
- /**
102
- * Schema-mismatch warnings produced by a "best-effort" reconstruction
103
- * of the document (`findById({ lenient: true })`). When present, the
104
- * form renders an inline Alert telling the editor that fields from a
105
- * previous schema have been dropped — saving the form will overwrite
106
- * them with the new shape.
107
- */
108
- restoreWarnings?: string[]
109
- /**
110
- * Default content locale used when no `initialLocale` is supplied and as the
111
- * fallback inside `PathWidget`. Hosts typically pass their app-wide
112
- * `i18n.content.defaultLocale`. Defaults to `'en'`.
113
- */
114
- defaultLocale?: string
115
- /**
116
- * Framework-specific navigation guard hook.
117
- * When provided, this overrides the adapter from `NavigationGuardProvider` context.
118
- * If neither is set, a no-op `beforeunload`-only guard is used.
119
- */
120
- useNavigationGuard?: UseNavigationGuard
121
- }
122
-
123
- const FormStatusDisplay = ({
124
- initialData,
125
- workflowStatuses,
126
- publishedVersion,
127
- onUnpublish,
128
- }: {
129
- initialData?: Record<string, any>
130
- workflowStatuses?: WorkflowStatus[]
131
- publishedVersion?: PublishedVersionInfo | null
132
- onUnpublish?: () => Promise<void>
133
- }) => {
134
- const statusCode = initialData?.status
135
- const statusLabel = workflowStatuses?.find((s) => s.name === statusCode)?.label ?? statusCode
136
- // Single-status workflows (e.g. lookups) have no editorial lifecycle —
137
- // suppress the "Status: …" cell since there is nothing meaningful to convey.
138
- const showStatusCell = (workflowStatuses?.length ?? 0) > 1
139
-
140
- return (
141
- <div className={cx('byline-form-status', styles.status)}>
142
- <div className={cx('byline-form-status-meta', styles['status-meta'])}>
143
- {showStatusCell && (
144
- <div className={cx('byline-form-status-cell', styles['status-cell'])}>
145
- <span className={cx('byline-form-status-muted', styles['status-muted'])}>Status:</span>
146
- <span className={cx('byline-form-status-trunc', styles['status-trunc'])}>
147
- {statusLabel}
148
- </span>
149
- </div>
150
- )}
151
-
152
- {initialData?.updatedAt != null && (
153
- <div className={cx('byline-form-status-cell', styles['status-cell'])}>
154
- <span className={cx('byline-form-status-muted', styles['status-muted'])}>
155
- Last modified:
156
- </span>
157
- <span className={cx('byline-form-status-trunc', styles['status-trunc'])}>
158
- <LocalDateTime value={initialData.updatedAt} />
159
- </span>
160
- </div>
161
- )}
162
-
163
- {initialData?.createdAt != null && (
164
- <div className={cx('byline-form-status-cell', styles['status-cell'])}>
165
- <span className={cx('byline-form-status-muted', styles['status-muted'])}>Created:</span>
166
- <span className={cx('byline-form-status-trunc', styles['status-trunc'])}>
167
- <LocalDateTime value={initialData.createdAt} />
168
- </span>
169
- </div>
170
- )}
171
- </div>
172
-
173
- {publishedVersion != null && (
174
- <div className={cx('byline-form-status-published', styles['status-published'])}>
175
- <span className={cx('byline-form-status-muted', styles['status-muted'])}>
176
- A published version is currently live.{' '}
177
- {publishedVersion.updatedAt ? (
178
- <span>
179
- Published on <LocalDateTime value={publishedVersion.updatedAt} />
180
- </span>
181
- ) : (
182
- ''
183
- )}
184
- </span>
185
- {onUnpublish && (
186
- <>
187
- {' '}
188
- <button
189
- type="button"
190
- onClick={onUnpublish}
191
- className={cx('byline-form-status-unpublish', styles['status-unpublish'])}
192
- >
193
- Unpublish
194
- </button>
195
- </>
196
- )}
197
- </div>
198
- )}
199
- </div>
200
- )
201
- }
202
-
203
- /**
204
- * Compute the primary and secondary status transitions for the ComboButton.
205
- * - Primary: the main action (forward step), or the current status itself
206
- * when the document has reached the final workflow step (terminal state).
207
- * - Secondary: other available transitions to show as dropdown options.
208
- * - isTerminal: true when the document is at the final workflow status —
209
- * the primary button renders as a non-actionable indicator and all
210
- * back-steps move into the dropdown.
211
- */
212
- function computeStatusTransitions(
213
- currentStatus: string | undefined,
214
- workflowStatuses: WorkflowStatus[] | undefined,
215
- nextStatus: WorkflowStatus | undefined
216
- ): {
217
- primaryStatus: WorkflowStatus | undefined
218
- secondaryStatuses: WorkflowStatus[]
219
- isTerminal: boolean
220
- } {
221
- if (!workflowStatuses || workflowStatuses.length === 0 || !currentStatus) {
222
- return { primaryStatus: nextStatus, secondaryStatuses: [], isTerminal: false }
223
- }
224
-
225
- // Single-status workflows (e.g. SINGLE_STATUS_WORKFLOW for lookups) have
226
- // no transitions — short-circuit so the form shows only Close / Save.
227
- if (workflowStatuses.length <= 1) {
228
- return { primaryStatus: undefined, secondaryStatuses: [], isTerminal: false }
229
- }
230
-
231
- const currentIndex = workflowStatuses.findIndex((s) => s.name === currentStatus)
232
- if (currentIndex === -1) {
233
- return { primaryStatus: nextStatus, secondaryStatuses: [], isTerminal: false }
234
- }
235
-
236
- const isAtEnd = currentIndex === workflowStatuses.length - 1
237
- const isAtStart = currentIndex === 0
238
-
239
- // Collect all available target statuses
240
- const availableTargets: WorkflowStatus[] = []
241
-
242
- // Reset to first (if not at first)
243
- if (!isAtStart && workflowStatuses[0]) {
244
- availableTargets.push(workflowStatuses[0])
245
- }
246
-
247
- // Back one step (if not at start and the previous is not already the first)
248
- if (currentIndex > 1 && workflowStatuses[currentIndex - 1]) {
249
- availableTargets.push(workflowStatuses[currentIndex - 1])
250
- }
251
-
252
- // Forward one step (if not at end) - this is the nextStatus
253
- if (!isAtEnd && workflowStatuses[currentIndex + 1]) {
254
- availableTargets.push(workflowStatuses[currentIndex + 1])
255
- }
256
-
257
- if (isAtEnd) {
258
- // Terminal state: the primary button is a non-actionable indicator of the
259
- // current status; both back-steps (revert to previous / reset to first)
260
- // are surfaced in the dropdown.
261
- return {
262
- primaryStatus: workflowStatuses[currentIndex],
263
- secondaryStatuses: availableTargets,
264
- isTerminal: true,
265
- }
266
- }
267
-
268
- // Not at end: primary is the forward step (nextStatus)
269
- return {
270
- primaryStatus: nextStatus,
271
- secondaryStatuses: availableTargets.filter((s) => s.name !== nextStatus?.name),
272
- isTerminal: false,
273
- }
274
- }
275
-
276
- const FormContent = ({
277
- mode,
278
- fields,
279
- onSubmit,
280
- onCancel,
281
- onStatusChange,
282
- onUnpublish,
283
- onDelete,
284
- onDuplicate,
285
- onCopyToLocale,
286
- contentLocales,
287
- nextStatus,
288
- workflowStatuses,
289
- publishedVersion,
290
- initialData,
291
- adminConfig,
292
- useAsTitle,
293
- useAsPath,
294
- headingLabel,
295
- headerSlot,
296
- collectionPath,
297
- initialLocale,
298
- onLocaleChange,
299
- defaultLocale = 'en',
300
- useNavigationGuard: useNavigationGuardProp,
301
- restoreWarnings,
302
- _activeTabBySet,
303
- _onTabChange,
304
- }: FormRendererProps & {
305
- /** Lifted active-tab-per-set map from FormRenderer — preserves tab choices across locale-change remounts. */
306
- _activeTabBySet?: Record<string, string>
307
- _onTabChange?: (tabSetName: string, tabName: string) => void
308
- }) => {
309
- const {
310
- getFieldValues,
311
- runFieldHooks,
312
- validateForm,
313
- errors: initialErrors,
314
- hasChanges: hasChangesFn,
315
- resetHasChanges,
316
- getPatches,
317
- getSystemPath,
318
- subscribeErrors,
319
- subscribeMeta,
320
- setFieldValue,
321
- setFieldError,
322
- getPendingUploads,
323
- clearPendingUploads,
324
- setFieldUploading,
325
- } = useFormContext()
326
-
327
- const [errors, setErrors] = useState(initialErrors)
328
- const [hasChanges, setHasChanges] = useState(hasChangesFn())
329
- const [statusBusy, setStatusBusy] = useState(false)
330
- const [isUploading, setIsUploading] = useState(false)
331
- const [contentLocale, setContentLocale] = useState(initialLocale ?? defaultLocale)
332
- const { uploadField } = useBylineFieldServices()
333
-
334
- // Sync contentLocale when the route re-fetches with a different locale.
335
- useEffect(() => {
336
- if (initialLocale) setContentLocale(initialLocale)
337
- }, [initialLocale])
338
-
339
- // ---------------------------------------------------------------------
340
- // Layout primitives + lookup tables.
341
- //
342
- // Built once per render from `adminConfig`. The validator at startup
343
- // guarantees every reachable name resolves and every schema field is
344
- // placed at most once, so render-time lookups are unguarded.
345
- // ---------------------------------------------------------------------
346
-
347
- const fieldByName = useMemo(() => {
348
- const map = new Map<string, Field>()
349
- for (const field of fields) {
350
- if ('name' in field) map.set(field.name, field)
351
- }
352
- return map
353
- }, [fields])
354
-
355
- const tabSetByName = useMemo(() => {
356
- const map = new Map<string, TabSetDefinition>()
357
- for (const set of adminConfig?.tabSets ?? []) map.set(set.name, set)
358
- return map
359
- }, [adminConfig])
360
-
361
- const rowByName = useMemo(() => {
362
- const map = new Map<string, RowDefinition>()
363
- for (const row of adminConfig?.rows ?? []) map.set(row.name, row)
364
- return map
365
- }, [adminConfig])
366
-
367
- const groupByName = useMemo(() => {
368
- const map = new Map<string, GroupDefinition>()
369
- for (const group of adminConfig?.groups ?? []) map.set(group.name, group)
370
- return map
371
- }, [adminConfig])
372
-
373
- // When `layout` is omitted, synthesise main = all schema fields in order.
374
- const layout = useMemo(() => {
375
- if (adminConfig?.layout) return adminConfig.layout
376
- return { main: fields.filter((f) => 'name' in f).map((f) => (f as { name: string }).name) }
377
- }, [adminConfig, fields])
378
-
379
- // Reverse index: schema field name → which tab set + tab it lives in.
380
- // Powers per-tab-set error badge counts. Fields not under any tab set
381
- // (e.g. raw-field placement directly in `layout.main`) are absent from
382
- // this map.
383
- const fieldToTabPath = useMemo(() => {
384
- const map = new Map<string, { tabSetName: string; tabName: string }>()
385
- const visit = (
386
- names: readonly string[],
387
- tabSetName: string,
388
- tabName: string,
389
- seen: Set<string>
390
- ) => {
391
- for (const name of names) {
392
- if (fieldByName.has(name)) {
393
- map.set(name, { tabSetName, tabName })
394
- } else if (seen.has(name)) {
395
- } else if (rowByName.has(name)) {
396
- const row = rowByName.get(name)!
397
- const next = new Set(seen).add(name)
398
- visit(row.fields, tabSetName, tabName, next)
399
- } else if (groupByName.has(name)) {
400
- const group = groupByName.get(name)!
401
- const next = new Set(seen).add(name)
402
- visit(group.fields, tabSetName, tabName, next)
403
- }
404
- }
405
- }
406
- for (const set of adminConfig?.tabSets ?? []) {
407
- for (const tab of set.tabs) {
408
- visit(tab.fields, set.name, tab.name, new Set())
409
- }
410
- }
411
- return map
412
- }, [adminConfig, fieldByName, rowByName, groupByName])
413
-
414
- // ---------------------------------------------------------------------
415
- // Active-tab state — one tab name per declared tab set.
416
- // Lifted into FormRenderer via `_activeTabBySet` / `_onTabChange` so the
417
- // user's tab choices survive the locale-change remount triggered by
418
- // FormProvider's `key` prop.
419
- // ---------------------------------------------------------------------
420
-
421
- const tabSets = adminConfig?.tabSets ?? []
422
-
423
- const initialActiveTabBySet = useMemo<Record<string, string>>(() => {
424
- const result: Record<string, string> = {}
425
- for (const set of tabSets) {
426
- const saved = _activeTabBySet?.[set.name]
427
- if (saved && set.tabs.some((t) => t.name === saved)) {
428
- result[set.name] = saved
429
- } else {
430
- result[set.name] = set.tabs[0]?.name ?? ''
431
- }
432
- }
433
- return result
434
- // initial-only; subsequent updates flow through setActiveTabBySet.
435
- // eslint-disable-next-line react-hooks/exhaustive-deps
436
- }, [tabSets, _activeTabBySet])
437
-
438
- const [activeTabBySet, setActiveTabBySet] =
439
- useState<Record<string, string>>(initialActiveTabBySet)
440
-
441
- const handleTabChange = useCallback(
442
- (tabSetName: string, tabName: string) => {
443
- setActiveTabBySet((prev) => ({ ...prev, [tabSetName]: tabName }))
444
- _onTabChange?.(tabSetName, tabName)
445
- },
446
- [_onTabChange]
447
- )
448
-
449
- // Track live form data so TabDefinition.condition functions can react to
450
- // field changes. Re-evaluated per keystroke via the meta-subscribe loop.
451
- const [formData, setFormData] = useState<Record<string, any>>(() => getFieldValues())
452
-
453
- // Live document heading — tracks the useAsTitle field as the user types
454
- const liveTitle = useFieldValue<string>(useAsTitle ?? '')
455
- const heading =
456
- liveTitle ||
457
- (headingLabel
458
- ? `${mode === 'create' ? 'Create' : 'Edit'} ${headingLabel}`
459
- : mode === 'create'
460
- ? 'Create'
461
- : 'Edit')
462
-
463
- // Navigation guard — block router navigation and browser unload when dirty.
464
- // The guard hook is injected by the consuming framework (prop > context > no-op fallback).
465
- const guardFromContext = useNavigationGuardAdapter()
466
- const useGuard = useNavigationGuardProp ?? guardFromContext
467
- const guard = useGuard(hasChanges)
468
-
469
- // Compute available status transitions
470
- const currentStatus = initialData?.status
471
- const { primaryStatus, secondaryStatuses, isTerminal } = computeStatusTransitions(
472
- currentStatus,
473
- workflowStatuses,
474
- nextStatus
475
- )
476
-
477
- useEffect(() => {
478
- return subscribeErrors((newErrors) => setErrors(newErrors))
479
- }, [subscribeErrors])
480
-
481
- useEffect(() => {
482
- return subscribeMeta(() => setHasChanges(hasChangesFn()))
483
- }, [subscribeMeta, hasChangesFn])
484
-
485
- // Keep formData in sync for evaluating TabDefinition.condition functions
486
- useEffect(() => {
487
- return subscribeMeta(() => setFormData(getFieldValues()))
488
- }, [subscribeMeta, getFieldValues])
489
-
490
- const handleCancel = () => {
491
- if (onCancel && typeof onCancel === 'function') {
492
- onCancel()
493
- }
494
- }
495
-
496
- const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
497
- e.preventDefault()
498
-
499
- // Run field-level beforeValidate hooks (submit-time), then validate
500
- void (async () => {
501
- const hookErrors = await runFieldHooks(fields)
502
- const formErrors = validateForm(fields)
503
- const allErrors = [...hookErrors, ...formErrors]
504
-
505
- if (allErrors.length > 0) {
506
- console.error('Form validation failed:', allErrors)
507
- return
508
- }
509
-
510
- // Execute any pending uploads before submitting
511
- const pendingUploads = getPendingUploads()
512
- if (pendingUploads.size > 0) {
513
- setIsUploading(true)
514
- try {
515
- const uploadResult = await executeUploadsWithProgress(
516
- pendingUploads,
517
- uploadField,
518
- ({ fieldPath, status }) => {
519
- setFieldUploading(fieldPath, status === 'uploading')
520
- }
521
- )
522
-
523
- // Check for upload errors
524
- if (!uploadResult.allSucceeded) {
525
- // Set field-level errors for failed uploads
526
- for (const [fieldPath, errorMessage] of uploadResult.errors.entries()) {
527
- setFieldError(fieldPath, `Upload failed: ${errorMessage}`)
528
- }
529
- console.error('One or more uploads failed:', uploadResult.errors)
530
- setIsUploading(false)
531
- return
532
- }
533
-
534
- // Replace pending StoredFileValues with real ones in form data
535
- for (const [fieldPath, storedFile] of uploadResult.successful.entries()) {
536
- setFieldValue(fieldPath, storedFile)
537
- }
538
-
539
- // Clear pending uploads (blob URLs already revoked by clearPendingUploads)
540
- clearPendingUploads()
541
- } catch (err) {
542
- console.error('Upload execution error:', err)
543
- setIsUploading(false)
544
- return
545
- }
546
- setIsUploading(false)
547
- }
548
-
549
- const data = getFieldValues()
550
- const patches = getPatches()
551
- const systemPath = getSystemPath()
552
-
553
- if (onSubmit && typeof onSubmit === 'function') {
554
- onSubmit({ data, patches, systemPath })
555
- resetHasChanges()
556
- }
557
- })()
558
- }
559
-
560
- // Per-tab-set error counts: { [tabSetName]: { [tabName]: count } }.
561
- // Each <Tabs> bar consumes its own slice.
562
- const tabErrorCountsBySet = useMemo<Record<string, Record<string, number>>>(() => {
563
- const result: Record<string, Record<string, number>> = {}
564
- for (const err of errors) {
565
- const path = fieldToTabPath.get(err.field)
566
- if (!path) continue
567
- result[path.tabSetName] ??= {}
568
- result[path.tabSetName]![path.tabName] = (result[path.tabSetName]?.[path.tabName] ?? 0) + 1
569
- }
570
- return result
571
- }, [errors, fieldToTabPath])
572
-
573
- // -------------------------------------------------------------------
574
- // Layout walk — recursively dispatches each name in a region to the
575
- // appropriate primitive renderer or to <FieldRenderer>.
576
- // -------------------------------------------------------------------
577
-
578
- const renderField = (fieldName: string): ReactNode => {
579
- const field = fieldByName.get(fieldName)
580
- if (!field) return null
581
- return (
582
- <FieldRenderer
583
- key={field.name}
584
- field={field}
585
- defaultValue={initialData?.fields?.[field.name]}
586
- collectionPath={collectionPath}
587
- contentLocale={contentLocale}
588
- components={adminConfig?.fields?.[field.name]?.components}
589
- editor={adminConfig?.fields?.[field.name]?.editor}
590
- />
591
- )
592
- }
593
-
594
- const renderItem = (name: string): ReactNode => {
595
- const tabSet = tabSetByName.get(name)
596
- if (tabSet) return renderTabSet(tabSet)
597
-
598
- const group = groupByName.get(name)
599
- if (group) return renderGroup(group)
600
-
601
- const row = rowByName.get(name)
602
- if (row) return renderRow(row)
603
-
604
- return renderField(name)
605
- }
606
-
607
- const renderRow = (row: RowDefinition): ReactNode => (
608
- <AdminRow key={`row:${row.name}`}>{row.fields.map((name) => renderField(name))}</AdminRow>
609
- )
610
-
611
- const renderGroup = (group: GroupDefinition): ReactNode => (
612
- <AdminGroup key={`group:${group.name}`} label={group.label}>
613
- {group.fields.map((name) => renderItem(name))}
614
- </AdminGroup>
615
- )
616
-
617
- const renderTabSet = (set: TabSetDefinition): ReactNode => {
618
- const visibleTabs = set.tabs.filter((tab) => !tab.condition || tab.condition(formData))
619
- const requested = activeTabBySet[set.name] ?? ''
620
- const resolvedActive =
621
- visibleTabs.length > 0 && !visibleTabs.some((t) => t.name === requested)
622
- ? (visibleTabs[0]?.name ?? requested)
623
- : requested
624
- const activeTab = visibleTabs.find((t) => t.name === resolvedActive)
625
-
626
- return (
627
- <div key={`tabset:${set.name}`} className={cx('byline-form-tabset', styles.tabset)}>
628
- {visibleTabs.length > 0 && (
629
- <AdminTabs
630
- tabs={visibleTabs}
631
- activeTab={resolvedActive}
632
- onChange={(tabName) => handleTabChange(set.name, tabName)}
633
- errorCounts={tabErrorCountsBySet[set.name]}
634
- className={cx('byline-form-tabset-tabs', styles['tabset-tabs'])}
635
- />
636
- )}
637
- {activeTab && (
638
- <div className={cx('byline-form-tabset-fields', styles['tabset-fields'])}>
639
- {activeTab.fields.map((name) => renderItem(name))}
640
- </div>
641
- )}
642
- </div>
643
- )
644
- }
645
-
646
- return (
647
- <form noValidate onSubmit={handleSubmit} className={cx('byline-form', styles.form)}>
648
- <div className={cx('byline-form-heading-row', styles['heading-row'])}>
649
- <h1 className={cx('byline-form-heading', styles.heading)}>{heading}</h1>
650
- {headerSlot}
651
- </div>
652
- <div className={cx('byline-form-status-bar', styles['status-bar'])}>
653
- <FormStatusDisplay
654
- initialData={initialData}
655
- workflowStatuses={workflowStatuses}
656
- publishedVersion={publishedVersion}
657
- onUnpublish={onUnpublish}
658
- />
659
- <div className={cx('byline-form-actions', styles.actions)}>
660
- <Button
661
- className={cx('byline-form-actions-button', styles['actions-button'])}
662
- size="sm"
663
- intent="noeffect"
664
- type="button"
665
- onClick={handleCancel}
666
- >
667
- {hasChanges === false ? 'Close' : 'Cancel'}
668
- </Button>
669
- <Button
670
- className={cx('byline-form-actions-button', styles['actions-button'])}
671
- size="sm"
672
- type="submit"
673
- disabled={hasChanges === false || isUploading}
674
- >
675
- {isUploading ? 'Uploading…' : 'Save'}
676
- </Button>
677
- {primaryStatus && onStatusChange && (
678
- <div className={cx('byline-form-actions-status-wrap', styles['actions-status-wrap'])}>
679
- <ComboButton
680
- buttonClassName={cx(
681
- 'byline-form-actions-combo-button',
682
- styles['actions-combo-button']
683
- )}
684
- triggerClassName={cx(
685
- 'byline-form-actions-combo-trigger',
686
- styles['actions-combo-trigger']
687
- )}
688
- options={secondaryStatuses.map((s) => ({
689
- label: isTerminal
690
- ? `Revert to ${s.label ?? s.name}`
691
- : (s.verb ?? s.label ?? s.name),
692
- value: s.name,
693
- }))}
694
- sideOffset={5}
695
- size="sm"
696
- type="button"
697
- intent={isTerminal ? 'info' : 'success'}
698
- disabled={statusBusy}
699
- onOptionSelect={async (value: string) => {
700
- setStatusBusy(true)
701
- try {
702
- await onStatusChange(value)
703
- } finally {
704
- setStatusBusy(false)
705
- }
706
- }}
707
- onButtonClick={
708
- isTerminal
709
- ? undefined
710
- : async () => {
711
- setStatusBusy(true)
712
- try {
713
- await onStatusChange(primaryStatus.name)
714
- } finally {
715
- setStatusBusy(false)
716
- }
717
- }
718
- }
719
- >
720
- {statusBusy
721
- ? '...'
722
- : isTerminal
723
- ? (primaryStatus.label ?? primaryStatus.name)
724
- : (primaryStatus.verb ?? primaryStatus.label ?? primaryStatus.name)}
725
- </ComboButton>
726
- </div>
727
- )}
728
- <DocumentActions
729
- publishedVersion={publishedVersion}
730
- onUnpublish={onUnpublish}
731
- onDelete={onDelete}
732
- onDuplicate={onDuplicate}
733
- sourceTitle={
734
- useAsTitle != null && initialData != null
735
- ? ((initialData as Record<string, unknown>)[useAsTitle] as
736
- | string
737
- | null
738
- | undefined)
739
- : null
740
- }
741
- onCopyToLocale={onCopyToLocale}
742
- sourceLocale={contentLocale}
743
- contentLocales={contentLocales}
744
- />
745
- </div>
746
- </div>
747
- {restoreWarnings && restoreWarnings.length > 0 && (
748
- <Alert
749
- className="m-0 mt-4"
750
- intent="warning"
751
- icon={true}
752
- close={false}
753
- title="This document was loaded with a best-effort reconstruction"
754
- >
755
- <p>
756
- The collection schema has changed since this document was last saved, and{' '}
757
- {restoreWarnings.length === 1
758
- ? '1 field could not be restored against the current shape.'
759
- : `${restoreWarnings.length} fields could not be restored against the current shape.`}{' '}
760
- The form below shows only the fields that match the new schema. Saving will overwrite
761
- the document with the new shape — any data that did not match will be lost. To preserve
762
- it, copy what you need before saving, or delete this document and recreate it. Errors:
763
- </p>
764
- <ul>
765
- {restoreWarnings.map((w) => (
766
- <li key={w}>{w}</li>
767
- ))}
768
- </ul>
769
- </Alert>
770
- )}
771
- <div className={cx('byline-form-layout', styles.layout)}>
772
- <div className={cx('byline-form-content', styles.content)}>
773
- {layout.main.map((name) => renderItem(name))}
774
- </div>
775
- <div className={cx('byline-form-sidebar', styles.sidebar)}>
776
- {(useAsPath ||
777
- (typeof initialData?.path === 'string' && initialData.path.length > 0)) && (
778
- <PathWidget
779
- useAsPath={useAsPath}
780
- collectionPath={collectionPath ?? ''}
781
- defaultLocale={defaultLocale}
782
- activeLocale={contentLocale}
783
- mode={mode}
784
- />
785
- )}
786
- {(layout.sidebar ?? []).map((name) => renderItem(name))}
787
- </div>
788
- </div>
789
- {guard.isBlocked && (
790
- <Modal isOpen={true} closeOnOverlayClick={false} onDismiss={guard.stay}>
791
- <Modal.Container style={{ maxWidth: '460px' }}>
792
- <Modal.Header
793
- className={cx('byline-form-guard-modal-head', styles['guard-modal-head'])}
794
- >
795
- <h3 className={cx('byline-form-guard-modal-title', styles['guard-modal-title'])}>
796
- Leave without saving?
797
- </h3>
798
- </Modal.Header>
799
- <Modal.Content>
800
- <p className={cx('byline-form-guard-modal-text', styles['guard-modal-text'])}>
801
- Your changes have not been saved. If you leave now, you will lose your changes.
802
- </p>
803
- </Modal.Content>
804
- <Modal.Actions>
805
- <Button size="sm" intent="noeffect" type="button" onClick={guard.stay}>
806
- Stay on this page
807
- </Button>
808
- <Button size="sm" intent="danger" type="button" onClick={guard.proceed}>
809
- Leave anyway
810
- </Button>
811
- </Modal.Actions>
812
- </Modal.Container>
813
- </Modal>
814
- )}
815
- </form>
816
- )
817
- }
818
-
819
- export const FormRenderer = ({
820
- mode,
821
- fields,
822
- onSubmit,
823
- onCancel,
824
- onStatusChange,
825
- onUnpublish,
826
- onDelete,
827
- onDuplicate,
828
- onCopyToLocale,
829
- contentLocales,
830
- nextStatus,
831
- workflowStatuses,
832
- publishedVersion,
833
- initialData,
834
- adminConfig,
835
- useAsTitle,
836
- useAsPath,
837
- headingLabel,
838
- headerSlot,
839
- collectionPath,
840
- initialLocale,
841
- onLocaleChange,
842
- defaultLocale,
843
- useNavigationGuard,
844
- restoreWarnings,
845
- }: FormRendererProps) => {
846
- // Persists per-tab-set active tab across locale-change remounts of FormContent.
847
- // useRef so mutations never trigger a re-render of FormRenderer itself.
848
- const savedTabsRef = useRef<Record<string, string>>({})
849
-
850
- return (
851
- <FormProvider
852
- key={`${initialLocale ?? 'default'}-${initialData?.versionId ?? ''}`}
853
- initialData={initialData}
854
- >
855
- <FormContent
856
- mode={mode}
857
- fields={fields}
858
- onSubmit={onSubmit}
859
- onCancel={onCancel}
860
- onStatusChange={onStatusChange}
861
- onUnpublish={onUnpublish}
862
- onDelete={onDelete}
863
- onDuplicate={onDuplicate}
864
- onCopyToLocale={onCopyToLocale}
865
- contentLocales={contentLocales}
866
- nextStatus={nextStatus}
867
- workflowStatuses={workflowStatuses}
868
- publishedVersion={publishedVersion}
869
- initialData={initialData}
870
- adminConfig={adminConfig}
871
- useAsTitle={useAsTitle}
872
- useAsPath={useAsPath}
873
- headingLabel={headingLabel}
874
- headerSlot={headerSlot}
875
- collectionPath={collectionPath}
876
- initialLocale={initialLocale}
877
- onLocaleChange={onLocaleChange}
878
- defaultLocale={defaultLocale}
879
- useNavigationGuard={useNavigationGuard}
880
- restoreWarnings={restoreWarnings}
881
- _activeTabBySet={savedTabsRef.current}
882
- _onTabChange={(tabSetName, tabName) => {
883
- savedTabsRef.current = { ...savedTabsRef.current, [tabSetName]: tabName }
884
- }}
885
- />
886
- </FormProvider>
887
- )
888
- }