@byline/admin 5.1.2 → 5.1.3

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 (78) hide show
  1. package/dist/exports-parity.test.node.js +37 -0
  2. package/dist/fields/array/array-field.jsx +209 -0
  3. package/dist/fields/blocks/blocks-field.jsx +248 -0
  4. package/dist/fields/checkbox/checkbox-field.jsx +24 -0
  5. package/dist/fields/code/code-editor.jsx +211 -0
  6. package/dist/fields/code/code-field.jsx +109 -0
  7. package/dist/fields/column-formatter.jsx +25 -0
  8. package/dist/fields/date-time-formatter.jsx +18 -0
  9. package/dist/fields/datetime/datetime-field.jsx +22 -0
  10. package/dist/fields/draggable-context-menu.jsx +50 -0
  11. package/dist/fields/field-admin.test.node.js +40 -0
  12. package/dist/fields/field-renderer.jsx +136 -0
  13. package/dist/fields/field-services-context.jsx +17 -0
  14. package/dist/fields/file/file-field.jsx +176 -0
  15. package/dist/fields/file/file-upload-field.jsx +89 -0
  16. package/dist/fields/group/group-field.jsx +46 -0
  17. package/dist/fields/image/image-field.jsx +183 -0
  18. package/dist/fields/image/image-upload-field.jsx +157 -0
  19. package/dist/fields/local-date-time.jsx +69 -0
  20. package/dist/fields/locale-badge.jsx +19 -0
  21. package/dist/fields/numerical/numerical-field.jsx +93 -0
  22. package/dist/fields/relation/relation-column-formatter.jsx +74 -0
  23. package/dist/fields/relation/relation-display.jsx +118 -0
  24. package/dist/fields/relation/relation-field.jsx +108 -0
  25. package/dist/fields/relation/relation-many-field.jsx +142 -0
  26. package/dist/fields/relation/relation-picker.jsx +240 -0
  27. package/dist/fields/relation/relation-summary.jsx +45 -0
  28. package/dist/fields/select/select-field.jsx +27 -0
  29. package/dist/fields/sortable-item.jsx +54 -0
  30. package/dist/fields/text/text-field.jsx +79 -0
  31. package/dist/fields/text-area/text-area-field.jsx +79 -0
  32. package/dist/forms/available-locales-reconcile.test.node.js +43 -0
  33. package/dist/forms/available-locales-widget.jsx +63 -0
  34. package/dist/forms/document-actions.jsx +486 -0
  35. package/dist/forms/form-context.jsx +598 -0
  36. package/dist/forms/form-modals.jsx +128 -0
  37. package/dist/forms/form-renderer.jsx +532 -0
  38. package/dist/forms/form-status-display.jsx +69 -0
  39. package/dist/forms/navigation-guard.jsx +68 -0
  40. package/dist/forms/nested-path.test.node.js +115 -0
  41. package/dist/forms/path-widget.jsx +125 -0
  42. package/dist/forms/pending-uploads.test.node.js +19 -0
  43. package/dist/forms/repeating-items.test.node.js +29 -0
  44. package/dist/forms/scheduled-publication-control.jsx +389 -0
  45. package/dist/forms/scheduled-publication-state.test.node.js +121 -0
  46. package/dist/forms/scheduled-publication-time.test.node.js +70 -0
  47. package/dist/forms/status-transitions.test.node.js +76 -0
  48. package/dist/forms/tree-placement-widget.jsx +160 -0
  49. package/dist/forms/upload-executor.test.node.js +368 -0
  50. package/dist/forms/use-form-layout.test.node.js +69 -0
  51. package/dist/modules/admin-account/components/change-password.jsx +169 -0
  52. package/dist/modules/admin-account/components/container.jsx +164 -0
  53. package/dist/modules/admin-account/components/preferences.jsx +148 -0
  54. package/dist/modules/admin-account/components/theme-switch.jsx +52 -0
  55. package/dist/modules/admin-account/components/update.jsx +186 -0
  56. package/dist/modules/admin-permissions/components/inspector.jsx +200 -0
  57. package/dist/modules/admin-preferences/schemas.test.node.js +48 -0
  58. package/dist/modules/admin-roles/components/create.jsx +155 -0
  59. package/dist/modules/admin-roles/components/permissions.jsx +239 -0
  60. package/dist/modules/admin-roles/components/update.jsx +148 -0
  61. package/dist/modules/admin-users/components/create.jsx +206 -0
  62. package/dist/modules/admin-users/components/roles.jsx +126 -0
  63. package/dist/modules/admin-users/components/set-password.jsx +133 -0
  64. package/dist/modules/admin-users/components/update.jsx +215 -0
  65. package/dist/modules/analytics/components/dashboard.jsx +208 -0
  66. package/dist/modules/analytics/components/dashboard.test.node.js +162 -0
  67. package/dist/modules/analytics/components/timeseries.jsx +193 -0
  68. package/dist/modules/auth/components/sign-in-form.jsx +89 -0
  69. package/dist/modules/auth/safe-redirect.test.node.js +40 -0
  70. package/dist/modules/auth/sign-in-form-props.test.node.js +7 -0
  71. package/dist/presentation/group.jsx +26 -0
  72. package/dist/presentation/row.jsx +23 -0
  73. package/dist/presentation/tabs.jsx +39 -0
  74. package/dist/services/admin-services-context.jsx +17 -0
  75. package/dist/widgets/diff-viewer/diff-modal.jsx +110 -0
  76. package/dist/widgets/source-locale-badge/source-locale-badge.jsx +28 -0
  77. package/dist/widgets/status-badge/status-badge.jsx +46 -0
  78. package/package.json +7 -7
@@ -0,0 +1,598 @@
1
+ 'use client';
2
+ import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
3
+ import { normalizeHooks } from '@byline/core';
4
+ // Vendored nested get/set (see ./nested-path) — removes the lodash-es dep
5
+ // outright. A bare `from 'lodash-es'` import otherwise pools into a single
6
+ // ~85KB chunk that leaks onto the public frontend bundle (form-context is
7
+ // reachable from the layout graph).
8
+ import { get as getNestedValue, hasExistingIdTargets, setWithResult as setNestedValue, } from './nested-path';
9
+ import { deletePendingUploadsUnderPath } from './pending-uploads';
10
+ import { useTrackedSlot } from './use-tracked-slot';
11
+ /**
12
+ * Order-insensitive set equality for the advertised-locale slot. The slot
13
+ * holds an array, so a fresh array reference is never `===` its initial — dirty
14
+ * tracking must compare membership, not identity.
15
+ */
16
+ const sameLocaleSet = (a, b) => {
17
+ if (a.length !== b.length)
18
+ return false;
19
+ const sa = [...a].sort();
20
+ const sb = [...b].sort();
21
+ return sa.every((v, i) => v === sb[i]);
22
+ };
23
+ /** Dirty-tracking keys for the two system-managed, document-grain slots. */
24
+ const SYSTEM_PATH_DIRTY_KEY = '__systemPath__';
25
+ const SYSTEM_AVAILABLE_LOCALES_DIRTY_KEY = '__systemAvailableLocales__';
26
+ const FormContext = createContext(null);
27
+ export const useFormContext = () => {
28
+ const context = useContext(FormContext);
29
+ if (context == null) {
30
+ throw new Error('useFormContext must be used within a FormProvider');
31
+ }
32
+ return context;
33
+ };
34
+ export const FormProvider = ({ children, initialData = {}, documentId = null, collectionPath = null, }) => {
35
+ const fieldValues = useRef(JSON.parse(JSON.stringify(initialData?.fields ?? initialData)));
36
+ const initialValues = useRef(initialData?.fields ?? initialData);
37
+ const errorsRef = useRef([]);
38
+ const dirtyFields = useRef(new Set());
39
+ const patchesRef = useRef([]);
40
+ const pendingUploadsRef = useRef(new Map());
41
+ const uploadingFieldsRef = useRef(new Set());
42
+ const uploadingListenersRef = useRef(new Map());
43
+ const fieldListeners = useRef(new Map());
44
+ const errorListeners = useRef(new Set());
45
+ const metaListeners = useRef(new Set());
46
+ const subscribeField = useCallback((name, listener) => {
47
+ if (!fieldListeners.current.has(name)) {
48
+ fieldListeners.current.set(name, new Set());
49
+ }
50
+ fieldListeners.current.get(name)?.add(listener);
51
+ return () => {
52
+ const listeners = fieldListeners.current.get(name);
53
+ if (listeners) {
54
+ listeners.delete(listener);
55
+ if (listeners.size === 0) {
56
+ fieldListeners.current.delete(name);
57
+ }
58
+ }
59
+ };
60
+ }, []);
61
+ const subscribeErrors = useCallback((listener) => {
62
+ errorListeners.current.add(listener);
63
+ return () => {
64
+ errorListeners.current.delete(listener);
65
+ };
66
+ }, []);
67
+ const subscribeMeta = useCallback((listener) => {
68
+ metaListeners.current.add(listener);
69
+ return () => {
70
+ metaListeners.current.delete(listener);
71
+ };
72
+ }, []);
73
+ const notifyFieldListeners = useCallback((name, value) => {
74
+ const listeners = fieldListeners.current.get(name);
75
+ if (listeners) {
76
+ listeners.forEach((listener) => {
77
+ listener(value);
78
+ });
79
+ }
80
+ }, []);
81
+ const notifyErrorListeners = useCallback(() => {
82
+ errorListeners.current.forEach((listener) => {
83
+ listener(errorsRef.current);
84
+ });
85
+ }, []);
86
+ const notifyMetaListeners = useCallback(() => {
87
+ metaListeners.current.forEach((listener) => {
88
+ listener();
89
+ });
90
+ }, []);
91
+ // Document-grain system-field slots — dirty-tracked, ref-backed, each with
92
+ // its own listener set. The `path` slot is initialised from the loaded
93
+ // version's top-level `path` (edit) or `null` (create); the available-locales
94
+ // slot from `availableLocales` (edit) or `[]`. Edits toggle the slot's dirty
95
+ // key so the single Save button can branch. See ./use-tracked-slot.
96
+ const pathSlot = useTrackedSlot({
97
+ initial: typeof initialData?.path === 'string' && initialData.path.length > 0
98
+ ? initialData.path
99
+ : null,
100
+ dirtyKey: SYSTEM_PATH_DIRTY_KEY,
101
+ dirtyFields,
102
+ notifyMeta: notifyMetaListeners,
103
+ });
104
+ const availableLocalesSlot = useTrackedSlot({
105
+ initial: Array.isArray(initialData?.availableLocales) ? [...initialData.availableLocales] : [],
106
+ dirtyKey: SYSTEM_AVAILABLE_LOCALES_DIRTY_KEY,
107
+ dirtyFields,
108
+ notifyMeta: notifyMetaListeners,
109
+ // The slot holds an array; a fresh reference is never `===` its baseline,
110
+ // so dirty tracking compares membership, not identity. Stored as a copy.
111
+ isEqual: sameLocaleSet,
112
+ clone: (value) => [...value],
113
+ });
114
+ const updateFieldStoreInternal = useCallback((name, value) => {
115
+ const newFieldValues = { ...fieldValues.current };
116
+ // Keep nested path values up to date for generic usage and patches.
117
+ if (!setNestedValue(newFieldValues, name, value))
118
+ return false;
119
+ fieldValues.current = newFieldValues;
120
+ dirtyFields.current.add(name);
121
+ notifyFieldListeners(name, value);
122
+ notifyMetaListeners();
123
+ return true;
124
+ }, [notifyFieldListeners, notifyMetaListeners]);
125
+ const setFieldStore = useCallback((name, value) => {
126
+ updateFieldStoreInternal(name, value);
127
+ }, [updateFieldStoreInternal]);
128
+ const setFieldValue = useCallback((name, value) => {
129
+ if (!updateFieldStoreInternal(name, value))
130
+ return;
131
+ const patch = {
132
+ kind: 'field.set',
133
+ path: name,
134
+ value,
135
+ };
136
+ // Optimization: Coalesce consecutive field.set patches for the same path
137
+ const lastPatch = patchesRef.current[patchesRef.current.length - 1];
138
+ if (lastPatch && lastPatch.kind === 'field.set' && lastPatch.path === name) {
139
+ const newPatches = [...patchesRef.current];
140
+ newPatches[newPatches.length - 1] = patch;
141
+ patchesRef.current = newPatches;
142
+ }
143
+ else {
144
+ patchesRef.current = [...patchesRef.current, patch];
145
+ }
146
+ // Clear field-specific errors when value changes
147
+ if (errorsRef.current.some((error) => error.field === name)) {
148
+ errorsRef.current = errorsRef.current.filter((error) => error.field !== name);
149
+ notifyErrorListeners();
150
+ }
151
+ }, [updateFieldStoreInternal, notifyErrorListeners]);
152
+ const getFieldValues = useCallback(() => fieldValues.current, []);
153
+ const getPatches = useCallback(() => patchesRef.current, []);
154
+ const appendPatch = useCallback((patch) => {
155
+ // Snapshot the patch at append time. Structural patches (array.insert,
156
+ // block add) carry item objects that are ALSO placed into the form
157
+ // store — and `setNestedValue` mutates store nodes in place, so a
158
+ // later nested write inside the item (e.g. adding an array item to a
159
+ // block added this session) would silently rewrite the queued patch.
160
+ // Serialized at save time, the block insert would then already contain
161
+ // the array items AND the array.insert patches would re-add them —
162
+ // duplicating items server-side (caught by e2e/array-in-block.spec.ts).
163
+ patchesRef.current = [...patchesRef.current, structuredClone(patch)];
164
+ // Mark a generic dirty flag so hasChanges() becomes true even
165
+ // for patches that don't correspond to a specific field.set.
166
+ dirtyFields.current.add('__patch__');
167
+ notifyMetaListeners();
168
+ // Dev-time patch tracing — uncomment when debugging the patch stream.
169
+ // if (process.env.NODE_ENV !== 'production') {
170
+ // // eslint-disable-next-line no-console
171
+ // console.debug('FormContext.appendPatch', { patch, dirtyCount: dirtyFields.current.size })
172
+ // }
173
+ }, [notifyMetaListeners]);
174
+ const getFieldValue = useCallback((name) => {
175
+ const dirty = dirtyFields.current.has(name);
176
+ const currentValue = getNestedValue(fieldValues.current, name);
177
+ if (currentValue !== undefined) {
178
+ return currentValue;
179
+ }
180
+ if (!dirty) {
181
+ return getNestedValue(initialValues.current, name);
182
+ }
183
+ return undefined;
184
+ }, []);
185
+ const hasChanges = useCallback(() => {
186
+ return dirtyFields.current.size > 0;
187
+ }, []);
188
+ const resetHasChanges = useCallback(() => {
189
+ dirtyFields.current.clear();
190
+ patchesRef.current = [];
191
+ pathSlot.commitInitial();
192
+ availableLocalesSlot.commitInitial();
193
+ notifyMetaListeners();
194
+ }, [notifyMetaListeners, pathSlot.commitInitial, availableLocalesSlot.commitInitial]);
195
+ const isDirty = useCallback((fieldName) => {
196
+ return dirtyFields.current.has(fieldName);
197
+ }, []);
198
+ // Partition the current dirty set by write semantics so the single Save
199
+ // button can route each piece correctly: content → versioned write; the
200
+ // document-grain system fields (path / advertised locales) → immediate,
201
+ // non-versioned direct write that leaves workflow status untouched.
202
+ // See docs/08-internationalization/index.md.
203
+ const getDirtyBreakdown = useCallback(() => {
204
+ const keys = dirtyFields.current;
205
+ const pathDirty = keys.has(SYSTEM_PATH_DIRTY_KEY);
206
+ const availableLocalesDirty = keys.has(SYSTEM_AVAILABLE_LOCALES_DIRTY_KEY);
207
+ let contentDirty = false;
208
+ for (const key of keys) {
209
+ if (key !== SYSTEM_PATH_DIRTY_KEY && key !== SYSTEM_AVAILABLE_LOCALES_DIRTY_KEY) {
210
+ contentDirty = true;
211
+ break;
212
+ }
213
+ }
214
+ const directWrite = pathDirty || availableLocalesDirty;
215
+ const reason = contentDirty && directWrite
216
+ ? 'both'
217
+ : contentDirty
218
+ ? 'content'
219
+ : directWrite
220
+ ? 'direct-write'
221
+ : 'none';
222
+ return { reason, contentDirty, pathDirty, availableLocalesDirty };
223
+ }, []);
224
+ // ---------------------------------------------------------------------------
225
+ // Pending uploads (deferred until save)
226
+ // ---------------------------------------------------------------------------
227
+ const addPendingUpload = useCallback((fieldPath, upload) => {
228
+ // Image metadata extraction is asynchronous. If its containing item was
229
+ // removed while decoding, discard the late registration rather than
230
+ // allowing submit to recreate or overwrite an item through a stale path.
231
+ if (!hasExistingIdTargets(fieldValues.current, fieldPath)) {
232
+ URL.revokeObjectURL(upload.previewUrl);
233
+ return false;
234
+ }
235
+ // If there's an existing pending upload for this path, revoke its blob URL
236
+ const existing = pendingUploadsRef.current.get(fieldPath);
237
+ if (existing) {
238
+ URL.revokeObjectURL(existing.previewUrl);
239
+ }
240
+ pendingUploadsRef.current.set(fieldPath, upload);
241
+ dirtyFields.current.add(fieldPath);
242
+ notifyMetaListeners();
243
+ return true;
244
+ }, [notifyMetaListeners]);
245
+ const removePendingUpload = useCallback((fieldPath) => {
246
+ const existing = pendingUploadsRef.current.get(fieldPath);
247
+ if (existing) {
248
+ URL.revokeObjectURL(existing.previewUrl);
249
+ pendingUploadsRef.current.delete(fieldPath);
250
+ notifyMetaListeners();
251
+ }
252
+ }, [notifyMetaListeners]);
253
+ const removePendingUploadsUnder = useCallback((itemPath) => {
254
+ const deleted = deletePendingUploadsUnderPath(pendingUploadsRef.current, itemPath, (url) => URL.revokeObjectURL(url));
255
+ if (deleted)
256
+ notifyMetaListeners();
257
+ }, [notifyMetaListeners]);
258
+ const getPendingUploads = useCallback(() => {
259
+ return new Map(pendingUploadsRef.current);
260
+ }, []);
261
+ const hasPendingUploads = useCallback(() => {
262
+ return pendingUploadsRef.current.size > 0;
263
+ }, []);
264
+ const clearPendingUploads = useCallback(() => {
265
+ // Revoke all blob URLs to prevent memory leaks
266
+ for (const upload of pendingUploadsRef.current.values()) {
267
+ URL.revokeObjectURL(upload.previewUrl);
268
+ }
269
+ pendingUploadsRef.current.clear();
270
+ }, []);
271
+ // ---------------------------------------------------------------------------
272
+ // Per-field upload-in-flight tracking
273
+ // ---------------------------------------------------------------------------
274
+ const setFieldUploading = useCallback((fieldPath, uploading) => {
275
+ if (uploading) {
276
+ if (uploadingFieldsRef.current.has(fieldPath))
277
+ return;
278
+ uploadingFieldsRef.current.add(fieldPath);
279
+ }
280
+ else {
281
+ if (!uploadingFieldsRef.current.has(fieldPath))
282
+ return;
283
+ uploadingFieldsRef.current.delete(fieldPath);
284
+ }
285
+ uploadingListenersRef.current.get(fieldPath)?.forEach((listener) => {
286
+ listener(uploading);
287
+ });
288
+ }, []);
289
+ const getIsFieldUploading = useCallback((fieldPath) => {
290
+ return uploadingFieldsRef.current.has(fieldPath);
291
+ }, []);
292
+ const subscribeFieldUploading = useCallback((fieldPath, listener) => {
293
+ let listeners = uploadingListenersRef.current.get(fieldPath);
294
+ if (!listeners) {
295
+ listeners = new Set();
296
+ uploadingListenersRef.current.set(fieldPath, listeners);
297
+ }
298
+ listeners.add(listener);
299
+ return () => {
300
+ const set = uploadingListenersRef.current.get(fieldPath);
301
+ if (set) {
302
+ set.delete(listener);
303
+ if (set.size === 0) {
304
+ uploadingListenersRef.current.delete(fieldPath);
305
+ }
306
+ }
307
+ };
308
+ }, []);
309
+ // Cleanup blob URLs on unmount
310
+ useEffect(() => {
311
+ return () => {
312
+ for (const upload of pendingUploadsRef.current.values()) {
313
+ URL.revokeObjectURL(upload.previewUrl);
314
+ }
315
+ };
316
+ }, []);
317
+ const validateForm = useCallback((fields) => {
318
+ const formErrors = [];
319
+ const data = getFieldValues();
320
+ for (const field of fields) {
321
+ // Condition-hidden fields are exempt from client-side validation — a
322
+ // field the editor cannot currently see must not block submit. Only
323
+ // top-level fields flow through this walk, and a root-level field's
324
+ // sibling scope is the form data itself (see FieldCondition).
325
+ if (field.condition && !field.condition(data, data))
326
+ continue;
327
+ const value = getFieldValue(field.name);
328
+ // Required field validation
329
+ if (!field.optional && (value == null || value === '')) {
330
+ formErrors.push({
331
+ field: field.name,
332
+ message: `${field.label} is required`,
333
+ });
334
+ }
335
+ // Type-specific validation
336
+ if (value != null && value !== '') {
337
+ switch (field.type) {
338
+ case 'text':
339
+ if (typeof value !== 'string') {
340
+ formErrors.push({
341
+ field: field.name,
342
+ message: `${field.label} must be text`,
343
+ });
344
+ }
345
+ break;
346
+ case 'checkbox':
347
+ if (typeof value !== 'boolean') {
348
+ formErrors.push({
349
+ field: field.name,
350
+ message: `${field.label} must be true or false`,
351
+ });
352
+ }
353
+ break;
354
+ case 'select':
355
+ if ('options' in field && field.options) {
356
+ const validValues = field.options.map((opt) => opt.value);
357
+ if (!validValues.includes(value)) {
358
+ formErrors.push({
359
+ field: field.name,
360
+ message: `${field.label} must be one of: ${validValues.join(', ')}`,
361
+ });
362
+ }
363
+ }
364
+ break;
365
+ case 'datetime':
366
+ if (value instanceof Date === false && typeof value !== 'string') {
367
+ formErrors.push({
368
+ field: field.name,
369
+ message: `${field.label} must be a valid date`,
370
+ });
371
+ }
372
+ break;
373
+ }
374
+ }
375
+ // Custom validate function — applies to all field types including structure fields.
376
+ if (field.validate) {
377
+ const error = field.validate(value, data);
378
+ if (error) {
379
+ formErrors.push({ field: field.name, message: error });
380
+ }
381
+ }
382
+ }
383
+ errorsRef.current = formErrors;
384
+ notifyErrorListeners();
385
+ return formErrors;
386
+ }, [getFieldValue, getFieldValues, notifyErrorListeners]);
387
+ const clearErrors = useCallback(() => {
388
+ errorsRef.current = [];
389
+ notifyErrorListeners();
390
+ }, [notifyErrorListeners]);
391
+ const setFieldError = useCallback((field, message) => {
392
+ // Replace any existing error for this field, or add a new one
393
+ const filtered = errorsRef.current.filter((e) => e.field !== field);
394
+ filtered.push({ field, message });
395
+ errorsRef.current = filtered;
396
+ notifyErrorListeners();
397
+ }, [notifyErrorListeners]);
398
+ const clearFieldError = useCallback((field) => {
399
+ if (errorsRef.current.some((e) => e.field === field)) {
400
+ errorsRef.current = errorsRef.current.filter((e) => e.field !== field);
401
+ notifyErrorListeners();
402
+ }
403
+ }, [notifyErrorListeners]);
404
+ /**
405
+ * Run `beforeValidate` hooks for every top-level field that defines one.
406
+ * Called at submit time, before `validateForm()`. Hooks may return
407
+ * `{ value }` to auto-populate a field, or `{ error }` to block submit.
408
+ */
409
+ const runFieldHooks = useCallback(async (fields) => {
410
+ const hookErrors = [];
411
+ const data = { ...fieldValues.current };
412
+ for (const field of fields) {
413
+ const fns = normalizeHooks(field.hooks?.beforeValidate);
414
+ if (fns.length === 0)
415
+ continue;
416
+ // Condition-hidden fields skip submit-time hooks, mirroring their
417
+ // exemption from validateForm below.
418
+ if (field.condition && !field.condition(data, data))
419
+ continue;
420
+ const path = field.name;
421
+ const value = getFieldValue(path);
422
+ const ctx = {
423
+ value,
424
+ previousValue: value,
425
+ data,
426
+ path,
427
+ field,
428
+ operation: 'submit',
429
+ setFieldValue,
430
+ };
431
+ try {
432
+ for (const fn of fns) {
433
+ const result = (await fn(ctx));
434
+ if (result?.error) {
435
+ hookErrors.push({ field: path, message: result.error });
436
+ }
437
+ if (result?.value !== undefined) {
438
+ // Auto-populate: write the derived value into the store
439
+ setFieldValue(path, result.value);
440
+ // Keep ctx and data snapshot in sync for subsequent hooks
441
+ ctx.value = result.value;
442
+ data[path] = result.value;
443
+ }
444
+ }
445
+ }
446
+ catch (err) {
447
+ const message = err instanceof Error ? err.message : 'Unexpected hook error';
448
+ hookErrors.push({ field: path, message });
449
+ }
450
+ }
451
+ if (hookErrors.length > 0) {
452
+ errorsRef.current = [...errorsRef.current, ...hookErrors];
453
+ notifyErrorListeners();
454
+ }
455
+ return hookErrors;
456
+ }, [getFieldValue, setFieldValue, notifyErrorListeners]);
457
+ return (<FormContext.Provider value={{
458
+ documentId,
459
+ collectionPath,
460
+ setFieldValue,
461
+ setFieldStore,
462
+ getFieldValue,
463
+ getFieldValues,
464
+ getPatches,
465
+ appendPatch,
466
+ resetPatches: () => {
467
+ patchesRef.current = [];
468
+ },
469
+ hasChanges,
470
+ resetHasChanges,
471
+ runFieldHooks,
472
+ validateForm,
473
+ errors: errorsRef.current,
474
+ getErrors: () => errorsRef.current,
475
+ clearErrors,
476
+ setFieldError,
477
+ clearFieldError,
478
+ isDirty,
479
+ getDirtyBreakdown,
480
+ subscribeField,
481
+ subscribeErrors,
482
+ subscribeMeta,
483
+ addPendingUpload,
484
+ removePendingUpload,
485
+ removePendingUploadsUnder,
486
+ getPendingUploads,
487
+ hasPendingUploads,
488
+ clearPendingUploads,
489
+ setFieldUploading,
490
+ getIsFieldUploading,
491
+ subscribeFieldUploading,
492
+ getSystemPath: pathSlot.get,
493
+ setSystemPath: pathSlot.set,
494
+ subscribeSystemPath: pathSlot.subscribe,
495
+ getSystemAvailableLocales: availableLocalesSlot.get,
496
+ setSystemAvailableLocales: availableLocalesSlot.set,
497
+ subscribeSystemAvailableLocales: availableLocalesSlot.subscribe,
498
+ }}>
499
+ {children}
500
+ </FormContext.Provider>);
501
+ };
502
+ /**
503
+ * Subscribe to the system `path` slot edited by the path widget.
504
+ * Returns the current value (or `null` when no override is set).
505
+ */
506
+ export const useSystemPath = () => {
507
+ const { getSystemPath, subscribeSystemPath } = useFormContext();
508
+ const [value, setValue] = useState(() => getSystemPath());
509
+ useEffect(() => {
510
+ return subscribeSystemPath((next) => setValue(next));
511
+ }, [subscribeSystemPath]);
512
+ return value;
513
+ };
514
+ /**
515
+ * Subscribe to the system `availableLocales` slot edited by the
516
+ * available-locales widget. Returns the current advertised set (or `[]` when
517
+ * nothing is advertised / not yet surfaced).
518
+ */
519
+ export const useSystemAvailableLocales = () => {
520
+ const { getSystemAvailableLocales, subscribeSystemAvailableLocales } = useFormContext();
521
+ const [value, setValue] = useState(() => getSystemAvailableLocales());
522
+ useEffect(() => {
523
+ return subscribeSystemAvailableLocales((next) => setValue(next));
524
+ }, [subscribeSystemAvailableLocales]);
525
+ return value;
526
+ };
527
+ export const useFormStore = () => {
528
+ return useFormContext();
529
+ };
530
+ export const useFieldError = (name) => {
531
+ const { getErrors, subscribeErrors } = useFormContext();
532
+ // Seed from the live errors ref via getErrors() rather than the context's
533
+ // `errors` snapshot — the snapshot is bound at FormProvider's first render
534
+ // and goes stale as soon as validateForm replaces errorsRef.current. Fields
535
+ // mounted after validation has already run (e.g. switching to a tab whose
536
+ // error badge is non-zero) would otherwise initialise to undefined and miss
537
+ // the existing error until something else fires notifyErrorListeners.
538
+ const [error, setError] = useState(() => getErrors().find((e) => e.field === name)?.message);
539
+ useEffect(() => {
540
+ const unsubscribe = subscribeErrors((currentErrors) => {
541
+ const fieldError = currentErrors.find((e) => e.field === name);
542
+ setError(fieldError?.message);
543
+ });
544
+ return unsubscribe;
545
+ }, [subscribeErrors, name]);
546
+ return error;
547
+ };
548
+ export const useFormMeta = () => {
549
+ const { hasChanges, subscribeMeta } = useFormContext();
550
+ const [hasChangesValue, setHasChangesValue] = useState(hasChanges());
551
+ useEffect(() => {
552
+ const unsubscribe = subscribeMeta(() => {
553
+ setHasChangesValue(hasChanges());
554
+ });
555
+ return unsubscribe;
556
+ }, [subscribeMeta, hasChanges]);
557
+ return {
558
+ hasChanges: hasChangesValue,
559
+ };
560
+ };
561
+ export const useIsDirty = (name) => {
562
+ const { isDirty, subscribeMeta } = useFormContext();
563
+ const [dirty, setDirty] = useState(isDirty(name));
564
+ useEffect(() => {
565
+ const unsubscribe = subscribeMeta(() => {
566
+ setDirty(isDirty(name));
567
+ });
568
+ return unsubscribe;
569
+ }, [subscribeMeta, isDirty, name]);
570
+ return dirty;
571
+ };
572
+ export const useFieldValue = (name) => {
573
+ const { getFieldValue, subscribeField } = useFormContext();
574
+ const [value, setValue] = useState(() => getFieldValue(name));
575
+ useEffect(() => {
576
+ const unsubscribe = subscribeField(name, (nextValue) => {
577
+ setValue(nextValue);
578
+ });
579
+ return unsubscribe;
580
+ }, [subscribeField, name]);
581
+ return value;
582
+ };
583
+ /**
584
+ * Subscribe to a single field's upload-in-flight state. Returns `true` while
585
+ * the form orchestrator is actively transporting this field's pending upload
586
+ * (between the `setFieldUploading(path, true)` and the matching `false`
587
+ * emitted by the upload executor's progress callback).
588
+ */
589
+ export const useIsFieldUploading = (fieldPath) => {
590
+ const { getIsFieldUploading, subscribeFieldUploading } = useFormContext();
591
+ const [uploading, setUploading] = useState(() => getIsFieldUploading(fieldPath));
592
+ useEffect(() => {
593
+ return subscribeFieldUploading(fieldPath, (next) => {
594
+ setUploading(next);
595
+ });
596
+ }, [subscribeFieldUploading, fieldPath]);
597
+ return uploading;
598
+ };