@byline/admin 2.5.2 → 2.6.1

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