@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,704 @@
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 React from 'react'
12
+ import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
13
+
14
+ import type { Field, FieldBeforeChangeResult, FieldHookContext } from '@byline/core'
15
+ import { normalizeHooks } from '@byline/core'
16
+ import type { DocumentPatch, FieldSetPatch } from '@byline/core/patches'
17
+ import { get as getNestedValue, set as setNestedValue } from 'lodash-es'
18
+
19
+ interface FormError {
20
+ field: string
21
+ message: string
22
+ }
23
+
24
+ /**
25
+ * Represents a file that has been selected but not yet uploaded.
26
+ * The file is held locally until form submission.
27
+ */
28
+ export interface PendingUpload {
29
+ /** The actual File object to upload */
30
+ file: File
31
+ /** Blob URL for local preview (must be revoked on cleanup) */
32
+ previewUrl: string
33
+ /** The collection path for the upload endpoint */
34
+ collectionPath: string
35
+ }
36
+
37
+ type FieldListener = (value: any) => void
38
+ type ErrorsListener = (errors: FormError[]) => void
39
+ type MetaListener = () => void
40
+ type SystemPathListener = (value: string | null) => void
41
+ type FieldUploadingListener = (uploading: boolean) => void
42
+
43
+ interface FormContextType {
44
+ setFieldValue: (name: string, value: any) => void
45
+ setFieldStore: (name: string, value: any) => void
46
+ getFieldValue: (name: string) => any
47
+ getFieldValues: () => Record<string, any>
48
+ getPatches: () => DocumentPatch[]
49
+ appendPatch: (patch: DocumentPatch) => void
50
+ resetPatches: () => void
51
+ hasChanges: () => boolean
52
+ resetHasChanges: () => void
53
+ runFieldHooks: (fields: Field[]) => Promise<FormError[]>
54
+ validateForm: (fields: Field[]) => FormError[]
55
+ errors: FormError[]
56
+ getErrors: () => FormError[]
57
+ clearErrors: () => void
58
+ setFieldError: (field: string, message: string) => void
59
+ clearFieldError: (field: string) => void
60
+ isDirty: (fieldName: string) => boolean
61
+ subscribeField: (name: string, listener: FieldListener) => () => void
62
+ subscribeErrors: (listener: ErrorsListener) => () => void
63
+ subscribeMeta: (listener: MetaListener) => () => void
64
+ // Pending uploads (deferred until save)
65
+ addPendingUpload: (fieldPath: string, upload: PendingUpload) => void
66
+ removePendingUpload: (fieldPath: string) => void
67
+ getPendingUploads: () => Map<string, PendingUpload>
68
+ hasPendingUploads: () => boolean
69
+ clearPendingUploads: () => void
70
+ // Per-field upload-in-flight tracking. Mirrors the pending-uploads map but
71
+ // for the window during which the upload-executor is actively transporting
72
+ // a given fieldPath, so widgets can render a localised spinner/overlay.
73
+ setFieldUploading: (fieldPath: string, uploading: boolean) => void
74
+ getIsFieldUploading: (fieldPath: string) => boolean
75
+ subscribeFieldUploading: (fieldPath: string, listener: FieldUploadingListener) => () => void
76
+ // System-managed `path` slot (persisted in `byline_document_paths`),
77
+ // edited by the path widget. `null` means the widget will fall back
78
+ // to live-derived preview / the server-side default; a non-null value
79
+ // is sent verbatim to the server.
80
+ getSystemPath: () => string | null
81
+ setSystemPath: (value: string | null) => void
82
+ subscribeSystemPath: (listener: SystemPathListener) => () => void
83
+ }
84
+
85
+ const FormContext = createContext<FormContextType | null>(null)
86
+
87
+ export const useFormContext = () => {
88
+ const context = useContext(FormContext)
89
+ if (context == null) {
90
+ throw new Error('useFormContext must be used within a FormProvider')
91
+ }
92
+ return context
93
+ }
94
+
95
+ export const FormProvider = ({
96
+ children,
97
+ initialData = {},
98
+ }: {
99
+ children: React.ReactNode
100
+ initialData?: Record<string, any>
101
+ }) => {
102
+ const fieldValues = useRef<Record<string, any>>(
103
+ JSON.parse(JSON.stringify(initialData?.fields ?? initialData))
104
+ )
105
+ const initialValues = useRef<Record<string, any>>(initialData?.fields ?? initialData)
106
+ const errorsRef = useRef<FormError[]>([])
107
+ const dirtyFields = useRef<Set<string>>(new Set())
108
+ const patchesRef = useRef<DocumentPatch[]>([])
109
+ const pendingUploadsRef = useRef<Map<string, PendingUpload>>(new Map())
110
+ const uploadingFieldsRef = useRef<Set<string>>(new Set())
111
+ const uploadingListenersRef = useRef<Map<string, Set<FieldUploadingListener>>>(new Map())
112
+
113
+ const fieldListeners = useRef<Map<string, Set<FieldListener>>>(new Map())
114
+ const errorListeners = useRef<Set<ErrorsListener>>(new Set())
115
+ const metaListeners = useRef<Set<MetaListener>>(new Set())
116
+
117
+ // System path slot — initialised from the loaded version's top-level
118
+ // `path` (edit mode) or `null` (create mode). Edits via `setSystemPath`
119
+ // mark the form dirty so the Save button enables.
120
+ const systemPathRef = useRef<string | null>(
121
+ typeof initialData?.path === 'string' && (initialData.path as string).length > 0
122
+ ? (initialData.path as string)
123
+ : null
124
+ )
125
+ const initialSystemPath = useRef<string | null>(systemPathRef.current)
126
+ const systemPathListeners = useRef<Set<SystemPathListener>>(new Set())
127
+
128
+ const subscribeField = useCallback((name: string, listener: FieldListener) => {
129
+ if (!fieldListeners.current.has(name)) {
130
+ fieldListeners.current.set(name, new Set())
131
+ }
132
+ fieldListeners.current.get(name)?.add(listener)
133
+ return () => {
134
+ const listeners = fieldListeners.current.get(name)
135
+ if (listeners) {
136
+ listeners.delete(listener)
137
+ if (listeners.size === 0) {
138
+ fieldListeners.current.delete(name)
139
+ }
140
+ }
141
+ }
142
+ }, [])
143
+
144
+ const subscribeErrors = useCallback((listener: ErrorsListener) => {
145
+ errorListeners.current.add(listener)
146
+ return () => {
147
+ errorListeners.current.delete(listener)
148
+ }
149
+ }, [])
150
+
151
+ const subscribeMeta = useCallback((listener: MetaListener) => {
152
+ metaListeners.current.add(listener)
153
+ return () => {
154
+ metaListeners.current.delete(listener)
155
+ }
156
+ }, [])
157
+
158
+ const notifyFieldListeners = useCallback((name: string, value: any) => {
159
+ const listeners = fieldListeners.current.get(name)
160
+ if (listeners) {
161
+ listeners.forEach((listener) => {
162
+ listener(value)
163
+ })
164
+ }
165
+ }, [])
166
+
167
+ const notifyErrorListeners = useCallback(() => {
168
+ errorListeners.current.forEach((listener) => {
169
+ listener(errorsRef.current)
170
+ })
171
+ }, [])
172
+
173
+ const notifyMetaListeners = useCallback(() => {
174
+ metaListeners.current.forEach((listener) => {
175
+ listener()
176
+ })
177
+ }, [])
178
+
179
+ const updateFieldStoreInternal = useCallback(
180
+ (name: string, value: any) => {
181
+ const newFieldValues = { ...fieldValues.current }
182
+
183
+ // Keep nested path values up to date for generic usage and patches.
184
+ setNestedValue(newFieldValues, name, value)
185
+
186
+ fieldValues.current = newFieldValues
187
+ dirtyFields.current.add(name)
188
+
189
+ notifyFieldListeners(name, value)
190
+ notifyMetaListeners()
191
+ },
192
+ [notifyFieldListeners, notifyMetaListeners]
193
+ )
194
+
195
+ const setFieldStore = useCallback(
196
+ (name: string, value: any) => {
197
+ updateFieldStoreInternal(name, value)
198
+ },
199
+ [updateFieldStoreInternal]
200
+ )
201
+
202
+ const setFieldValue = useCallback(
203
+ (name: string, value: any) => {
204
+ updateFieldStoreInternal(name, value)
205
+
206
+ const patch: FieldSetPatch = {
207
+ kind: 'field.set',
208
+ path: name,
209
+ value,
210
+ }
211
+
212
+ // Optimization: Coalesce consecutive field.set patches for the same path
213
+ const lastPatch = patchesRef.current[patchesRef.current.length - 1]
214
+ if (lastPatch && lastPatch.kind === 'field.set' && lastPatch.path === name) {
215
+ const newPatches = [...patchesRef.current]
216
+ newPatches[newPatches.length - 1] = patch
217
+ patchesRef.current = newPatches
218
+ } else {
219
+ patchesRef.current = [...patchesRef.current, patch]
220
+ }
221
+
222
+ // Clear field-specific errors when value changes
223
+ if (errorsRef.current.some((error) => error.field === name)) {
224
+ errorsRef.current = errorsRef.current.filter((error) => error.field !== name)
225
+ notifyErrorListeners()
226
+ }
227
+ },
228
+ [updateFieldStoreInternal, notifyErrorListeners]
229
+ )
230
+
231
+ const getFieldValues = useCallback(() => fieldValues.current, [])
232
+
233
+ const getPatches = useCallback(() => patchesRef.current, [])
234
+ const appendPatch = useCallback(
235
+ (patch: DocumentPatch) => {
236
+ patchesRef.current = [...patchesRef.current, patch]
237
+ // Mark a generic dirty flag so hasChanges() becomes true even
238
+ // for patches that don't correspond to a specific field.set.
239
+ dirtyFields.current.add('__patch__')
240
+ notifyMetaListeners()
241
+ if (process.env.NODE_ENV !== 'production') {
242
+ // eslint-disable-next-line no-console
243
+ console.debug('FormContext.appendPatch', { patch, dirtyCount: dirtyFields.current.size })
244
+ }
245
+ },
246
+ [notifyMetaListeners]
247
+ )
248
+
249
+ const getFieldValue = useCallback((name: string) => {
250
+ const dirty = dirtyFields.current.has(name)
251
+ const currentValue = getNestedValue(fieldValues.current, name)
252
+
253
+ if (currentValue !== undefined) {
254
+ return currentValue
255
+ }
256
+ if (!dirty) {
257
+ return getNestedValue(initialValues.current, name)
258
+ }
259
+ return undefined
260
+ }, [])
261
+
262
+ const hasChanges = useCallback(() => {
263
+ return dirtyFields.current.size > 0
264
+ }, [])
265
+
266
+ const resetHasChanges = useCallback(() => {
267
+ dirtyFields.current.clear()
268
+ patchesRef.current = []
269
+ initialSystemPath.current = systemPathRef.current
270
+ notifyMetaListeners()
271
+ }, [notifyMetaListeners])
272
+
273
+ const isDirty = useCallback((fieldName: string) => {
274
+ return dirtyFields.current.has(fieldName)
275
+ }, [])
276
+
277
+ // -------------------------------------------------------------------------
278
+ // System path slot
279
+ // -------------------------------------------------------------------------
280
+
281
+ const getSystemPath = useCallback(() => systemPathRef.current, [])
282
+
283
+ const setSystemPath = useCallback(
284
+ (value: string | null) => {
285
+ systemPathRef.current = value
286
+ if (value !== initialSystemPath.current) {
287
+ dirtyFields.current.add('__systemPath__')
288
+ } else {
289
+ dirtyFields.current.delete('__systemPath__')
290
+ }
291
+ systemPathListeners.current.forEach((listener) => {
292
+ listener(value)
293
+ })
294
+ notifyMetaListeners()
295
+ },
296
+ [notifyMetaListeners]
297
+ )
298
+
299
+ const subscribeSystemPath = useCallback((listener: SystemPathListener) => {
300
+ systemPathListeners.current.add(listener)
301
+ return () => {
302
+ systemPathListeners.current.delete(listener)
303
+ }
304
+ }, [])
305
+
306
+ // ---------------------------------------------------------------------------
307
+ // Pending uploads (deferred until save)
308
+ // ---------------------------------------------------------------------------
309
+
310
+ const addPendingUpload = useCallback(
311
+ (fieldPath: string, upload: PendingUpload) => {
312
+ // If there's an existing pending upload for this path, revoke its blob URL
313
+ const existing = pendingUploadsRef.current.get(fieldPath)
314
+ if (existing) {
315
+ URL.revokeObjectURL(existing.previewUrl)
316
+ }
317
+ pendingUploadsRef.current.set(fieldPath, upload)
318
+ dirtyFields.current.add(fieldPath)
319
+ notifyMetaListeners()
320
+ },
321
+ [notifyMetaListeners]
322
+ )
323
+
324
+ const removePendingUpload = useCallback(
325
+ (fieldPath: string) => {
326
+ const existing = pendingUploadsRef.current.get(fieldPath)
327
+ if (existing) {
328
+ URL.revokeObjectURL(existing.previewUrl)
329
+ pendingUploadsRef.current.delete(fieldPath)
330
+ notifyMetaListeners()
331
+ }
332
+ },
333
+ [notifyMetaListeners]
334
+ )
335
+
336
+ const getPendingUploads = useCallback(() => {
337
+ return new Map(pendingUploadsRef.current)
338
+ }, [])
339
+
340
+ const hasPendingUploads = useCallback(() => {
341
+ return pendingUploadsRef.current.size > 0
342
+ }, [])
343
+
344
+ const clearPendingUploads = useCallback(() => {
345
+ // Revoke all blob URLs to prevent memory leaks
346
+ for (const upload of pendingUploadsRef.current.values()) {
347
+ URL.revokeObjectURL(upload.previewUrl)
348
+ }
349
+ pendingUploadsRef.current.clear()
350
+ }, [])
351
+
352
+ // ---------------------------------------------------------------------------
353
+ // Per-field upload-in-flight tracking
354
+ // ---------------------------------------------------------------------------
355
+
356
+ const setFieldUploading = useCallback((fieldPath: string, uploading: boolean) => {
357
+ if (uploading) {
358
+ if (uploadingFieldsRef.current.has(fieldPath)) return
359
+ uploadingFieldsRef.current.add(fieldPath)
360
+ } else {
361
+ if (!uploadingFieldsRef.current.has(fieldPath)) return
362
+ uploadingFieldsRef.current.delete(fieldPath)
363
+ }
364
+ uploadingListenersRef.current.get(fieldPath)?.forEach((listener) => {
365
+ listener(uploading)
366
+ })
367
+ }, [])
368
+
369
+ const getIsFieldUploading = useCallback((fieldPath: string) => {
370
+ return uploadingFieldsRef.current.has(fieldPath)
371
+ }, [])
372
+
373
+ const subscribeFieldUploading = useCallback(
374
+ (fieldPath: string, listener: FieldUploadingListener) => {
375
+ let listeners = uploadingListenersRef.current.get(fieldPath)
376
+ if (!listeners) {
377
+ listeners = new Set()
378
+ uploadingListenersRef.current.set(fieldPath, listeners)
379
+ }
380
+ listeners.add(listener)
381
+ return () => {
382
+ const set = uploadingListenersRef.current.get(fieldPath)
383
+ if (set) {
384
+ set.delete(listener)
385
+ if (set.size === 0) {
386
+ uploadingListenersRef.current.delete(fieldPath)
387
+ }
388
+ }
389
+ }
390
+ },
391
+ []
392
+ )
393
+
394
+ // Cleanup blob URLs on unmount
395
+ useEffect(() => {
396
+ return () => {
397
+ for (const upload of pendingUploadsRef.current.values()) {
398
+ URL.revokeObjectURL(upload.previewUrl)
399
+ }
400
+ }
401
+ }, [])
402
+
403
+ const validateForm = useCallback(
404
+ (fields: Field[]): FormError[] => {
405
+ const formErrors: FormError[] = []
406
+ const data = getFieldValues()
407
+
408
+ for (const field of fields) {
409
+ const value = getFieldValue(field.name)
410
+
411
+ // Required field validation
412
+ if (!field.optional && (value == null || value === '')) {
413
+ formErrors.push({
414
+ field: field.name,
415
+ message: `${field.label} is required`,
416
+ })
417
+ }
418
+
419
+ // Type-specific validation
420
+ if (value != null && value !== '') {
421
+ switch (field.type) {
422
+ case 'text':
423
+ if (typeof value !== 'string') {
424
+ formErrors.push({
425
+ field: field.name,
426
+ message: `${field.label} must be text`,
427
+ })
428
+ }
429
+ break
430
+ case 'checkbox':
431
+ if (typeof value !== 'boolean') {
432
+ formErrors.push({
433
+ field: field.name,
434
+ message: `${field.label} must be true or false`,
435
+ })
436
+ }
437
+ break
438
+ case 'select':
439
+ if ('options' in field && field.options) {
440
+ const validValues = field.options.map((opt) => opt.value)
441
+ if (!validValues.includes(value)) {
442
+ formErrors.push({
443
+ field: field.name,
444
+ message: `${field.label} must be one of: ${validValues.join(', ')}`,
445
+ })
446
+ }
447
+ }
448
+ break
449
+ case 'datetime':
450
+ if (value instanceof Date === false && typeof value !== 'string') {
451
+ formErrors.push({
452
+ field: field.name,
453
+ message: `${field.label} must be a valid date`,
454
+ })
455
+ }
456
+ break
457
+ }
458
+ }
459
+
460
+ // Custom validate function — applies to all field types including structure fields.
461
+ if (field.validate) {
462
+ const error = field.validate(value, data)
463
+ if (error) {
464
+ formErrors.push({ field: field.name, message: error })
465
+ }
466
+ }
467
+ }
468
+
469
+ errorsRef.current = formErrors
470
+ notifyErrorListeners()
471
+ return formErrors
472
+ },
473
+ [getFieldValue, getFieldValues, notifyErrorListeners]
474
+ )
475
+
476
+ const clearErrors = useCallback(() => {
477
+ errorsRef.current = []
478
+ notifyErrorListeners()
479
+ }, [notifyErrorListeners])
480
+
481
+ const setFieldError = useCallback(
482
+ (field: string, message: string) => {
483
+ // Replace any existing error for this field, or add a new one
484
+ const filtered = errorsRef.current.filter((e) => e.field !== field)
485
+ filtered.push({ field, message })
486
+ errorsRef.current = filtered
487
+ notifyErrorListeners()
488
+ },
489
+ [notifyErrorListeners]
490
+ )
491
+
492
+ const clearFieldError = useCallback(
493
+ (field: string) => {
494
+ if (errorsRef.current.some((e) => e.field === field)) {
495
+ errorsRef.current = errorsRef.current.filter((e) => e.field !== field)
496
+ notifyErrorListeners()
497
+ }
498
+ },
499
+ [notifyErrorListeners]
500
+ )
501
+
502
+ /**
503
+ * Run `beforeValidate` hooks for every top-level field that defines one.
504
+ * Called at submit time, before `validateForm()`. Hooks may return
505
+ * `{ value }` to auto-populate a field, or `{ error }` to block submit.
506
+ */
507
+ const runFieldHooks = useCallback(
508
+ async (fields: Field[]): Promise<FormError[]> => {
509
+ const hookErrors: FormError[] = []
510
+ const data = { ...fieldValues.current }
511
+
512
+ for (const field of fields) {
513
+ const fns = normalizeHooks(field.hooks?.beforeValidate)
514
+ if (fns.length === 0) continue
515
+
516
+ const path = field.name
517
+ const value = getFieldValue(path)
518
+
519
+ const ctx: FieldHookContext = {
520
+ value,
521
+ previousValue: value,
522
+ data,
523
+ path,
524
+ field,
525
+ operation: 'submit',
526
+ }
527
+
528
+ try {
529
+ for (const fn of fns) {
530
+ const result = (await fn(ctx)) as FieldBeforeChangeResult | undefined
531
+ if (result?.error) {
532
+ hookErrors.push({ field: path, message: result.error })
533
+ }
534
+ if (result?.value !== undefined) {
535
+ // Auto-populate: write the derived value into the store
536
+ setFieldValue(path, result.value)
537
+ // Keep ctx and data snapshot in sync for subsequent hooks
538
+ ctx.value = result.value
539
+ data[path] = result.value
540
+ }
541
+ }
542
+ } catch (err) {
543
+ const message = err instanceof Error ? err.message : 'Unexpected hook error'
544
+ hookErrors.push({ field: path, message })
545
+ }
546
+ }
547
+
548
+ if (hookErrors.length > 0) {
549
+ errorsRef.current = [...errorsRef.current, ...hookErrors]
550
+ notifyErrorListeners()
551
+ }
552
+
553
+ return hookErrors
554
+ },
555
+ [getFieldValue, setFieldValue, notifyErrorListeners]
556
+ )
557
+
558
+ return (
559
+ <FormContext.Provider
560
+ value={{
561
+ setFieldValue,
562
+ setFieldStore,
563
+ getFieldValue,
564
+ getFieldValues,
565
+ getPatches,
566
+ appendPatch,
567
+ resetPatches: () => {
568
+ patchesRef.current = []
569
+ },
570
+ hasChanges,
571
+ resetHasChanges,
572
+ runFieldHooks,
573
+ validateForm,
574
+ errors: errorsRef.current,
575
+ getErrors: () => errorsRef.current,
576
+ clearErrors,
577
+ setFieldError,
578
+ clearFieldError,
579
+ isDirty,
580
+ subscribeField,
581
+ subscribeErrors,
582
+ subscribeMeta,
583
+ addPendingUpload,
584
+ removePendingUpload,
585
+ getPendingUploads,
586
+ hasPendingUploads,
587
+ clearPendingUploads,
588
+ setFieldUploading,
589
+ getIsFieldUploading,
590
+ subscribeFieldUploading,
591
+ getSystemPath,
592
+ setSystemPath,
593
+ subscribeSystemPath,
594
+ }}
595
+ >
596
+ {children}
597
+ </FormContext.Provider>
598
+ )
599
+ }
600
+
601
+ /**
602
+ * Subscribe to the system `path` slot edited by the path widget.
603
+ * Returns the current value (or `null` when no override is set).
604
+ */
605
+ export const useSystemPath = (): string | null => {
606
+ const { getSystemPath, subscribeSystemPath } = useFormContext()
607
+ const [value, setValue] = useState<string | null>(() => getSystemPath())
608
+
609
+ useEffect(() => {
610
+ return subscribeSystemPath((next) => setValue(next))
611
+ }, [subscribeSystemPath])
612
+
613
+ return value
614
+ }
615
+
616
+ export const useFormStore = () => {
617
+ return useFormContext()
618
+ }
619
+
620
+ export const useFieldError = (name: string) => {
621
+ const { getErrors, subscribeErrors } = useFormContext()
622
+ // Seed from the live errors ref via getErrors() rather than the context's
623
+ // `errors` snapshot — the snapshot is bound at FormProvider's first render
624
+ // and goes stale as soon as validateForm replaces errorsRef.current. Fields
625
+ // mounted after validation has already run (e.g. switching to a tab whose
626
+ // error badge is non-zero) would otherwise initialise to undefined and miss
627
+ // the existing error until something else fires notifyErrorListeners.
628
+ const [error, setError] = useState<string | undefined>(
629
+ () => getErrors().find((e) => e.field === name)?.message
630
+ )
631
+
632
+ useEffect(() => {
633
+ const unsubscribe = subscribeErrors((currentErrors) => {
634
+ const fieldError = currentErrors.find((e) => e.field === name)
635
+ setError(fieldError?.message)
636
+ })
637
+ return unsubscribe
638
+ }, [subscribeErrors, name])
639
+
640
+ return error
641
+ }
642
+
643
+ export const useFormMeta = () => {
644
+ const { hasChanges, subscribeMeta } = useFormContext()
645
+ const [hasChangesValue, setHasChangesValue] = useState(hasChanges())
646
+
647
+ useEffect(() => {
648
+ const unsubscribe = subscribeMeta(() => {
649
+ setHasChangesValue(hasChanges())
650
+ })
651
+ return unsubscribe
652
+ }, [subscribeMeta, hasChanges])
653
+
654
+ return {
655
+ hasChanges: hasChangesValue,
656
+ }
657
+ }
658
+
659
+ export const useIsDirty = (name: string) => {
660
+ const { isDirty, subscribeMeta } = useFormContext()
661
+ const [dirty, setDirty] = useState(isDirty(name))
662
+
663
+ useEffect(() => {
664
+ const unsubscribe = subscribeMeta(() => {
665
+ setDirty(isDirty(name))
666
+ })
667
+ return unsubscribe
668
+ }, [subscribeMeta, isDirty, name])
669
+
670
+ return dirty
671
+ }
672
+
673
+ export const useFieldValue = <T = any>(name: string): T | undefined => {
674
+ const { getFieldValue, subscribeField } = useFormContext()
675
+ const [value, setValue] = useState<T | undefined>(() => getFieldValue(name))
676
+
677
+ useEffect(() => {
678
+ const unsubscribe = subscribeField(name, (nextValue) => {
679
+ setValue(nextValue)
680
+ })
681
+ return unsubscribe
682
+ }, [subscribeField, name])
683
+
684
+ return value
685
+ }
686
+
687
+ /**
688
+ * Subscribe to a single field's upload-in-flight state. Returns `true` while
689
+ * the form orchestrator is actively transporting this field's pending upload
690
+ * (between the `setFieldUploading(path, true)` and the matching `false`
691
+ * emitted by the upload executor's progress callback).
692
+ */
693
+ export const useIsFieldUploading = (fieldPath: string): boolean => {
694
+ const { getIsFieldUploading, subscribeFieldUploading } = useFormContext()
695
+ const [uploading, setUploading] = useState<boolean>(() => getIsFieldUploading(fieldPath))
696
+
697
+ useEffect(() => {
698
+ return subscribeFieldUploading(fieldPath, (next) => {
699
+ setUploading(next)
700
+ })
701
+ }, [subscribeFieldUploading, fieldPath])
702
+
703
+ return uploading
704
+ }