@byline/admin 5.1.2 → 5.1.4
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.
- package/dist/exports-parity.test.node.js +37 -0
- package/dist/fields/array/array-field.jsx +209 -0
- package/dist/fields/blocks/blocks-field.jsx +248 -0
- package/dist/fields/checkbox/checkbox-field.jsx +24 -0
- package/dist/fields/code/code-editor.jsx +211 -0
- package/dist/fields/code/code-field.jsx +109 -0
- package/dist/fields/column-formatter.jsx +25 -0
- package/dist/fields/date-time-formatter.jsx +18 -0
- package/dist/fields/datetime/datetime-field.jsx +22 -0
- package/dist/fields/draggable-context-menu.jsx +50 -0
- package/dist/fields/field-admin.test.node.js +40 -0
- package/dist/fields/field-renderer.jsx +136 -0
- package/dist/fields/field-services-context.jsx +17 -0
- package/dist/fields/file/file-field.jsx +176 -0
- package/dist/fields/file/file-upload-field.jsx +89 -0
- package/dist/fields/group/group-field.jsx +46 -0
- package/dist/fields/image/image-field.jsx +183 -0
- package/dist/fields/image/image-upload-field.jsx +157 -0
- package/dist/fields/local-date-time.jsx +69 -0
- package/dist/fields/locale-badge.jsx +19 -0
- package/dist/fields/numerical/numerical-field.jsx +93 -0
- package/dist/fields/relation/relation-column-formatter.jsx +74 -0
- package/dist/fields/relation/relation-display.jsx +118 -0
- package/dist/fields/relation/relation-field.jsx +108 -0
- package/dist/fields/relation/relation-many-field.jsx +142 -0
- package/dist/fields/relation/relation-picker.jsx +240 -0
- package/dist/fields/relation/relation-summary.jsx +45 -0
- package/dist/fields/select/select-field.jsx +27 -0
- package/dist/fields/sortable-item.jsx +54 -0
- package/dist/fields/text/text-field.jsx +79 -0
- package/dist/fields/text-area/text-area-field.jsx +79 -0
- package/dist/forms/available-locales-reconcile.test.node.js +43 -0
- package/dist/forms/available-locales-widget.jsx +63 -0
- package/dist/forms/document-actions.jsx +486 -0
- package/dist/forms/form-context.jsx +598 -0
- package/dist/forms/form-modals.jsx +128 -0
- package/dist/forms/form-renderer.jsx +532 -0
- package/dist/forms/form-status-display.jsx +69 -0
- package/dist/forms/navigation-guard.jsx +68 -0
- package/dist/forms/nested-path.test.node.js +115 -0
- package/dist/forms/path-widget.jsx +125 -0
- package/dist/forms/pending-uploads.test.node.js +19 -0
- package/dist/forms/repeating-items.test.node.js +29 -0
- package/dist/forms/scheduled-publication-control.jsx +389 -0
- package/dist/forms/scheduled-publication-state.test.node.js +121 -0
- package/dist/forms/scheduled-publication-time.test.node.js +70 -0
- package/dist/forms/status-transitions.test.node.js +76 -0
- package/dist/forms/tree-placement-widget.jsx +160 -0
- package/dist/forms/upload-executor.test.node.js +368 -0
- package/dist/forms/use-form-layout.test.node.js +69 -0
- package/dist/modules/admin-account/components/change-password.jsx +169 -0
- package/dist/modules/admin-account/components/container.jsx +164 -0
- package/dist/modules/admin-account/components/preferences.jsx +148 -0
- package/dist/modules/admin-account/components/theme-switch.jsx +52 -0
- package/dist/modules/admin-account/components/update.jsx +186 -0
- package/dist/modules/admin-permissions/components/inspector.jsx +200 -0
- package/dist/modules/admin-preferences/schemas.test.node.js +48 -0
- package/dist/modules/admin-roles/components/create.jsx +155 -0
- package/dist/modules/admin-roles/components/permissions.jsx +239 -0
- package/dist/modules/admin-roles/components/update.jsx +148 -0
- package/dist/modules/admin-users/components/create.jsx +206 -0
- package/dist/modules/admin-users/components/roles.jsx +126 -0
- package/dist/modules/admin-users/components/set-password.jsx +133 -0
- package/dist/modules/admin-users/components/update.jsx +215 -0
- package/dist/modules/analytics/components/dashboard.d.ts +13 -3
- package/dist/modules/analytics/components/dashboard.js +86 -68
- package/dist/modules/analytics/components/dashboard.jsx +208 -0
- package/dist/modules/analytics/components/dashboard.module.js +10 -1
- package/dist/modules/analytics/components/dashboard.test.node.js +162 -0
- package/dist/modules/analytics/components/dashboard_module.css +57 -0
- package/dist/modules/analytics/components/ranked-list-modal.d.ts +32 -0
- package/dist/modules/analytics/components/ranked-list-modal.js +145 -0
- package/dist/modules/analytics/components/ranking.d.ts +45 -0
- package/dist/modules/analytics/components/ranking.js +71 -0
- package/dist/modules/analytics/components/ranking.test.node.d.ts +8 -0
- package/dist/modules/analytics/components/timeseries.jsx +193 -0
- package/dist/modules/auth/components/sign-in-form.jsx +89 -0
- package/dist/modules/auth/safe-redirect.test.node.js +40 -0
- package/dist/modules/auth/sign-in-form-props.test.node.js +7 -0
- package/dist/presentation/group.jsx +26 -0
- package/dist/presentation/row.jsx +23 -0
- package/dist/presentation/tabs.jsx +39 -0
- package/dist/services/admin-services-context.jsx +17 -0
- package/dist/widgets/diff-viewer/diff-modal.jsx +110 -0
- package/dist/widgets/source-locale-badge/source-locale-badge.jsx +28 -0
- package/dist/widgets/status-badge/status-badge.jsx +46 -0
- package/package.json +7 -7
- package/src/modules/analytics/components/dashboard.module.css +69 -0
- package/src/modules/analytics/components/dashboard.tsx +105 -83
- package/src/modules/analytics/components/ranked-list-modal.test.tsx +154 -0
- package/src/modules/analytics/components/ranked-list-modal.tsx +159 -0
- package/src/modules/analytics/components/ranking.test.node.ts +43 -0
- package/src/modules/analytics/components/ranking.tsx +122 -0
|
@@ -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
|
+
};
|