@byline/admin 6.2.1 → 6.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/fields/array/array-field.js +29 -12
- package/dist/fields/blocks/blocks-field.js +20 -8
- package/dist/fields/checkbox/checkbox-field.js +4 -1
- package/dist/fields/code/code-field.js +2 -1
- package/dist/fields/datetime/datetime-field.js +5 -2
- package/dist/fields/field-renderer.js +2 -1
- package/dist/fields/field-services-types.d.ts +23 -0
- package/dist/fields/file/file-field.js +6 -4
- package/dist/fields/group/group-field.js +8 -3
- package/dist/fields/image/image-field.js +6 -4
- package/dist/fields/numerical/numerical-field.js +2 -1
- package/dist/fields/relation/relation-field.js +8 -2
- package/dist/fields/relation/relation-many-field.js +12 -4
- package/dist/fields/relation/relation-picker.js +38 -7
- package/dist/fields/relation/relation-picker.module.js +3 -0
- package/dist/fields/relation/relation-picker_module.css +20 -0
- package/dist/fields/select/select-field.js +4 -2
- package/dist/fields/text/text-field.js +6 -3
- package/dist/fields/text-area/text-area-field.js +6 -3
- package/dist/fields/use-field-change-handler.js +24 -6
- package/dist/fields/use-field-condition.d.ts +0 -15
- package/dist/fields/use-field-condition.js +10 -1
- package/dist/forms/form-context.d.ts +3 -12
- package/dist/forms/form-context.js +115 -130
- package/dist/forms/form-dom-scope.d.ts +34 -0
- package/dist/forms/form-dom-scope.js +20 -0
- package/dist/forms/form-layout.js +10 -3
- package/dist/forms/form-page-chrome.js +14 -24
- package/dist/forms/form-renderer.d.ts +2 -1
- package/dist/forms/form-renderer.js +13 -56
- package/dist/forms/nested-path.d.ts +2 -0
- package/dist/forms/nested-path.js +26 -1
- package/dist/forms/use-document-reload-recovery.d.ts +22 -0
- package/dist/forms/use-document-reload-recovery.js +67 -0
- package/dist/forms/use-form-submission.d.ts +1 -1
- package/dist/forms/use-form-submission.js +45 -27
- package/dist/forms/use-form-tabs.d.ts +2 -2
- package/dist/forms/use-form-tabs.js +18 -11
- package/dist/presentation/tabs.js +2 -1
- package/dist/react.d.ts +1 -1
- package/package.json +14 -14
- package/src/fields/array/array-field.tsx +24 -12
- package/src/fields/blocks/blocks-field.tsx +18 -8
- package/src/fields/checkbox/checkbox-field.tsx +4 -1
- package/src/fields/code/code-field.tsx +2 -1
- package/src/fields/datetime/datetime-field.tsx +5 -2
- package/src/fields/direct-widget-dom-ids.test.tsx +142 -0
- package/src/fields/field-dom-ids.test.tsx +293 -0
- package/src/fields/field-renderer.tsx +2 -1
- package/src/fields/field-services-types.ts +25 -0
- package/src/fields/file/file-field.tsx +6 -4
- package/src/fields/group/group-field.tsx +5 -3
- package/src/fields/image/image-field.tsx +6 -4
- package/src/fields/numerical/numerical-field.tsx +2 -1
- package/src/fields/relation/relation-field.tsx +8 -2
- package/src/fields/relation/relation-many-field.tsx +19 -4
- package/src/fields/relation/relation-picker-create.test.tsx +203 -0
- package/src/fields/relation/relation-picker-refresh.test.tsx +325 -0
- package/src/fields/relation/relation-picker.module.css +32 -0
- package/src/fields/relation/relation-picker.tsx +59 -1
- package/src/fields/select/select-field.tsx +4 -2
- package/src/fields/text/text-field.tsx +5 -3
- package/src/fields/text-area/text-area-field.tsx +5 -3
- package/src/fields/use-field-change-handler.ts +43 -9
- package/src/fields/use-field-condition.ts +17 -1
- package/src/forms/form-context.tsx +180 -234
- package/src/forms/form-correctness.test.tsx +560 -0
- package/src/forms/form-dom-scope.test.tsx +213 -0
- package/src/forms/form-dom-scope.tsx +70 -0
- package/src/forms/form-layout.tsx +10 -3
- package/src/forms/form-page-chrome.tsx +17 -34
- package/src/forms/form-renderer-submit.test.tsx +36 -0
- package/src/forms/form-renderer.tsx +26 -64
- package/src/forms/form-submit-isolation.test.tsx +247 -0
- package/src/forms/nested-path.ts +35 -0
- package/src/forms/use-document-reload-recovery.ts +92 -0
- package/src/forms/use-form-submission.ts +56 -27
- package/src/forms/use-form-tabs.ts +25 -17
- package/src/presentation/tab-dom-ids.test.tsx +153 -0
- package/src/presentation/tabs.test.tsx +49 -2
- package/src/presentation/tabs.tsx +11 -1
- package/src/react.ts +2 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx } from "react/jsx-runtime";
|
|
3
|
-
import { createContext, useCallback, useContext, useEffect, useRef,
|
|
4
|
-
import { normalizeHooks } from "@byline/core";
|
|
5
|
-
import {
|
|
3
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useSyncExternalStore } from "react";
|
|
4
|
+
import { normalizeHooks, validateDocumentFields } from "@byline/core";
|
|
5
|
+
import { FormDomScopeProvider } from "./form-dom-scope.js";
|
|
6
|
+
import { get, hasExistingIdTargets, withValue } from "./nested-path.js";
|
|
6
7
|
import { deletePendingUploadsUnderPath } from "./pending-uploads.js";
|
|
7
8
|
import { useTrackedSlot } from "./use-tracked-slot.js";
|
|
8
9
|
const sameLocaleSet = (a, b)=>{
|
|
@@ -25,7 +26,6 @@ const useFormContext = ()=>{
|
|
|
25
26
|
};
|
|
26
27
|
const FormProvider = ({ children, initialData = {}, documentId = null, collectionPath = null })=>{
|
|
27
28
|
const fieldValues = useRef(JSON.parse(JSON.stringify(initialData?.fields ?? initialData)));
|
|
28
|
-
const initialValues = useRef(initialData?.fields ?? initialData);
|
|
29
29
|
const errorsRef = useRef([]);
|
|
30
30
|
const dirtyFields = useRef(new Set());
|
|
31
31
|
const patchesRef = useRef([]);
|
|
@@ -93,10 +93,8 @@ const FormProvider = ({ children, initialData = {}, documentId = null, collectio
|
|
|
93
93
|
]
|
|
94
94
|
});
|
|
95
95
|
const updateFieldStoreInternal = useCallback((name, value)=>{
|
|
96
|
-
const newFieldValues =
|
|
97
|
-
|
|
98
|
-
};
|
|
99
|
-
if (!setWithResult(newFieldValues, name, value)) return false;
|
|
96
|
+
const newFieldValues = withValue(fieldValues.current, name, value);
|
|
97
|
+
if (!newFieldValues) return false;
|
|
100
98
|
fieldValues.current = newFieldValues;
|
|
101
99
|
dirtyFields.current.add(name);
|
|
102
100
|
notifyFieldListeners(name, value);
|
|
@@ -149,12 +147,7 @@ const FormProvider = ({ children, initialData = {}, documentId = null, collectio
|
|
|
149
147
|
}, [
|
|
150
148
|
notifyMetaListeners
|
|
151
149
|
]);
|
|
152
|
-
const getFieldValue = useCallback((name)=>
|
|
153
|
-
const dirty = dirtyFields.current.has(name);
|
|
154
|
-
const currentValue = get(fieldValues.current, name);
|
|
155
|
-
if (void 0 !== currentValue) return currentValue;
|
|
156
|
-
if (!dirty) return get(initialValues.current, name);
|
|
157
|
-
}, []);
|
|
150
|
+
const getFieldValue = useCallback((name)=>get(fieldValues.current, name), []);
|
|
158
151
|
const hasChanges = useCallback(()=>dirtyFields.current.size > 0, []);
|
|
159
152
|
const resetHasChanges = useCallback(()=>{
|
|
160
153
|
dirtyFields.current.clear();
|
|
@@ -253,58 +246,35 @@ const FormProvider = ({ children, initialData = {}, documentId = null, collectio
|
|
|
253
246
|
useEffect(()=>()=>{
|
|
254
247
|
for (const upload of pendingUploadsRef.current.values())URL.revokeObjectURL(upload.previewUrl);
|
|
255
248
|
}, []);
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
if (
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
});
|
|
266
|
-
if (null != value && '' !== value) switch(field.type){
|
|
267
|
-
case 'text':
|
|
268
|
-
if ('string' != typeof value) formErrors.push({
|
|
269
|
-
field: field.name,
|
|
270
|
-
message: `${field.label} must be text`
|
|
271
|
-
});
|
|
272
|
-
break;
|
|
273
|
-
case 'checkbox':
|
|
274
|
-
if ('boolean' != typeof value) formErrors.push({
|
|
275
|
-
field: field.name,
|
|
276
|
-
message: `${field.label} must be true or false`
|
|
277
|
-
});
|
|
278
|
-
break;
|
|
279
|
-
case 'select':
|
|
280
|
-
if ('options' in field && field.options) {
|
|
281
|
-
const validValues = field.options.map((opt)=>opt.value);
|
|
282
|
-
if (!validValues.includes(value)) formErrors.push({
|
|
283
|
-
field: field.name,
|
|
284
|
-
message: `${field.label} must be one of: ${validValues.join(', ')}`
|
|
285
|
-
});
|
|
286
|
-
}
|
|
287
|
-
break;
|
|
288
|
-
case 'datetime':
|
|
289
|
-
if (value instanceof Date === false && 'string' != typeof value) formErrors.push({
|
|
290
|
-
field: field.name,
|
|
291
|
-
message: `${field.label} must be a valid date`
|
|
292
|
-
});
|
|
293
|
-
break;
|
|
294
|
-
}
|
|
295
|
-
if (field.validate) {
|
|
296
|
-
const error = field.validate(value, data);
|
|
297
|
-
if (error) formErrors.push({
|
|
298
|
-
field: field.name,
|
|
299
|
-
message: error
|
|
300
|
-
});
|
|
249
|
+
const fieldChanges = useRef(new Set());
|
|
250
|
+
const fieldChangeWaiters = useRef(new Set());
|
|
251
|
+
const trackFieldChange = useCallback((pending)=>{
|
|
252
|
+
fieldChanges.current.add(pending);
|
|
253
|
+
const release = ()=>{
|
|
254
|
+
fieldChanges.current.delete(pending);
|
|
255
|
+
if (0 === fieldChanges.current.size) {
|
|
256
|
+
for (const resolve of fieldChangeWaiters.current)resolve();
|
|
257
|
+
fieldChangeWaiters.current.clear();
|
|
301
258
|
}
|
|
302
|
-
}
|
|
303
|
-
|
|
259
|
+
};
|
|
260
|
+
pending.then(release, release);
|
|
261
|
+
return release;
|
|
262
|
+
}, []);
|
|
263
|
+
const waitForFieldChanges = useCallback(async ()=>{
|
|
264
|
+
while(fieldChanges.current.size)await new Promise((resolve)=>fieldChangeWaiters.current.add(resolve));
|
|
265
|
+
}, []);
|
|
266
|
+
const validateForm = useCallback((fields, additionalErrors = [])=>{
|
|
267
|
+
const formErrors = validateDocumentFields(fields, getFieldValues(), {
|
|
268
|
+
respectConditions: true,
|
|
269
|
+
skip: (path)=>pendingUploadsRef.current.has(path)
|
|
270
|
+
});
|
|
271
|
+
errorsRef.current = [
|
|
272
|
+
...additionalErrors,
|
|
273
|
+
...formErrors
|
|
274
|
+
];
|
|
304
275
|
notifyErrorListeners();
|
|
305
|
-
return
|
|
276
|
+
return errorsRef.current;
|
|
306
277
|
}, [
|
|
307
|
-
getFieldValue,
|
|
308
278
|
getFieldValues,
|
|
309
279
|
notifyErrorListeners
|
|
310
280
|
]);
|
|
@@ -341,7 +311,13 @@ const FormProvider = ({ children, initialData = {}, documentId = null, collectio
|
|
|
341
311
|
for (const field of fields){
|
|
342
312
|
const fns = normalizeHooks(field.hooks?.beforeValidate);
|
|
343
313
|
if (0 === fns.length) continue;
|
|
344
|
-
if (field.condition
|
|
314
|
+
if (field.condition) {
|
|
315
|
+
let visible = true;
|
|
316
|
+
try {
|
|
317
|
+
visible = Boolean(field.condition(data, data));
|
|
318
|
+
} catch {}
|
|
319
|
+
if (!visible) continue;
|
|
320
|
+
}
|
|
345
321
|
const path = field.name;
|
|
346
322
|
const value = getFieldValue(path);
|
|
347
323
|
const ctx = {
|
|
@@ -387,8 +363,11 @@ const FormProvider = ({ children, initialData = {}, documentId = null, collectio
|
|
|
387
363
|
setFieldValue,
|
|
388
364
|
notifyErrorListeners
|
|
389
365
|
]);
|
|
390
|
-
|
|
391
|
-
|
|
366
|
+
const resetPatches = useCallback(()=>{
|
|
367
|
+
patchesRef.current = [];
|
|
368
|
+
}, []);
|
|
369
|
+
const getErrors = useCallback(()=>errorsRef.current, []);
|
|
370
|
+
const context = useMemo(()=>({
|
|
392
371
|
documentId,
|
|
393
372
|
collectionPath,
|
|
394
373
|
setFieldValue,
|
|
@@ -397,15 +376,17 @@ const FormProvider = ({ children, initialData = {}, documentId = null, collectio
|
|
|
397
376
|
getFieldValues,
|
|
398
377
|
getPatches,
|
|
399
378
|
appendPatch,
|
|
400
|
-
resetPatches
|
|
401
|
-
patchesRef.current = [];
|
|
402
|
-
},
|
|
379
|
+
resetPatches,
|
|
403
380
|
hasChanges,
|
|
404
381
|
resetHasChanges,
|
|
405
382
|
runFieldHooks,
|
|
406
383
|
validateForm,
|
|
407
|
-
errors
|
|
408
|
-
|
|
384
|
+
get errors () {
|
|
385
|
+
return errorsRef.current;
|
|
386
|
+
},
|
|
387
|
+
getErrors,
|
|
388
|
+
trackFieldChange,
|
|
389
|
+
waitForFieldChanges,
|
|
409
390
|
clearErrors,
|
|
410
391
|
setFieldError,
|
|
411
392
|
clearFieldError,
|
|
@@ -429,96 +410,100 @@ const FormProvider = ({ children, initialData = {}, documentId = null, collectio
|
|
|
429
410
|
getSystemAvailableLocales: availableLocalesSlot.get,
|
|
430
411
|
setSystemAvailableLocales: availableLocalesSlot.set,
|
|
431
412
|
subscribeSystemAvailableLocales: availableLocalesSlot.subscribe
|
|
432
|
-
},
|
|
433
|
-
|
|
413
|
+
}), [
|
|
414
|
+
documentId,
|
|
415
|
+
collectionPath,
|
|
416
|
+
setFieldValue,
|
|
417
|
+
setFieldStore,
|
|
418
|
+
getFieldValue,
|
|
419
|
+
getFieldValues,
|
|
420
|
+
getPatches,
|
|
421
|
+
appendPatch,
|
|
422
|
+
resetPatches,
|
|
423
|
+
hasChanges,
|
|
424
|
+
resetHasChanges,
|
|
425
|
+
runFieldHooks,
|
|
426
|
+
validateForm,
|
|
427
|
+
getErrors,
|
|
428
|
+
trackFieldChange,
|
|
429
|
+
waitForFieldChanges,
|
|
430
|
+
clearErrors,
|
|
431
|
+
setFieldError,
|
|
432
|
+
clearFieldError,
|
|
433
|
+
isDirty,
|
|
434
|
+
getDirtyBreakdown,
|
|
435
|
+
subscribeField,
|
|
436
|
+
subscribeErrors,
|
|
437
|
+
subscribeMeta,
|
|
438
|
+
addPendingUpload,
|
|
439
|
+
removePendingUpload,
|
|
440
|
+
removePendingUploadsUnder,
|
|
441
|
+
getPendingUploads,
|
|
442
|
+
hasPendingUploads,
|
|
443
|
+
clearPendingUploads,
|
|
444
|
+
setFieldUploading,
|
|
445
|
+
getIsFieldUploading,
|
|
446
|
+
subscribeFieldUploading,
|
|
447
|
+
pathSlot.get,
|
|
448
|
+
pathSlot.set,
|
|
449
|
+
pathSlot.subscribe,
|
|
450
|
+
availableLocalesSlot.get,
|
|
451
|
+
availableLocalesSlot.set,
|
|
452
|
+
availableLocalesSlot.subscribe
|
|
453
|
+
]);
|
|
454
|
+
return /*#__PURE__*/ jsx(FormContext.Provider, {
|
|
455
|
+
value: context,
|
|
456
|
+
children: /*#__PURE__*/ jsx(FormDomScopeProvider, {
|
|
457
|
+
children: children
|
|
458
|
+
})
|
|
434
459
|
});
|
|
435
460
|
};
|
|
436
461
|
const useSystemPath = ()=>{
|
|
437
462
|
const { getSystemPath, subscribeSystemPath } = useFormContext();
|
|
438
|
-
|
|
439
|
-
useEffect(()=>subscribeSystemPath((next)=>setValue(next)), [
|
|
440
|
-
subscribeSystemPath
|
|
441
|
-
]);
|
|
442
|
-
return value;
|
|
463
|
+
return useSyncExternalStore(subscribeSystemPath, getSystemPath, getSystemPath);
|
|
443
464
|
};
|
|
444
465
|
const useSystemAvailableLocales = ()=>{
|
|
445
466
|
const { getSystemAvailableLocales, subscribeSystemAvailableLocales } = useFormContext();
|
|
446
|
-
|
|
447
|
-
useEffect(()=>subscribeSystemAvailableLocales((next)=>setValue(next)), [
|
|
448
|
-
subscribeSystemAvailableLocales
|
|
449
|
-
]);
|
|
450
|
-
return value;
|
|
467
|
+
return useSyncExternalStore(subscribeSystemAvailableLocales, getSystemAvailableLocales, getSystemAvailableLocales);
|
|
451
468
|
};
|
|
452
469
|
const useFormStore = ()=>useFormContext();
|
|
453
470
|
const useFieldError = (name)=>{
|
|
454
471
|
const { getErrors, subscribeErrors } = useFormContext();
|
|
455
|
-
const
|
|
456
|
-
|
|
457
|
-
const unsubscribe = subscribeErrors((currentErrors)=>{
|
|
458
|
-
const fieldError = currentErrors.find((e)=>e.field === name);
|
|
459
|
-
setError(fieldError?.message);
|
|
460
|
-
});
|
|
461
|
-
return unsubscribe;
|
|
462
|
-
}, [
|
|
463
|
-
subscribeErrors,
|
|
464
|
-
name
|
|
465
|
-
]);
|
|
466
|
-
return error;
|
|
472
|
+
const errors = useSyncExternalStore(subscribeErrors, getErrors, getErrors);
|
|
473
|
+
return errors.find((error)=>error.field === name)?.message;
|
|
467
474
|
};
|
|
468
475
|
const useFormMeta = ()=>{
|
|
469
476
|
const { hasChanges, subscribeMeta } = useFormContext();
|
|
470
|
-
const [hasChangesValue, setHasChangesValue] = useState(hasChanges());
|
|
471
|
-
useEffect(()=>{
|
|
472
|
-
const unsubscribe = subscribeMeta(()=>{
|
|
473
|
-
setHasChangesValue(hasChanges());
|
|
474
|
-
});
|
|
475
|
-
return unsubscribe;
|
|
476
|
-
}, [
|
|
477
|
-
subscribeMeta,
|
|
478
|
-
hasChanges
|
|
479
|
-
]);
|
|
480
477
|
return {
|
|
481
|
-
hasChanges:
|
|
478
|
+
hasChanges: useSyncExternalStore(subscribeMeta, hasChanges, hasChanges)
|
|
482
479
|
};
|
|
483
480
|
};
|
|
484
481
|
const useIsDirty = (name)=>{
|
|
485
482
|
const { isDirty, subscribeMeta } = useFormContext();
|
|
486
|
-
const
|
|
487
|
-
useEffect(()=>{
|
|
488
|
-
const unsubscribe = subscribeMeta(()=>{
|
|
489
|
-
setDirty(isDirty(name));
|
|
490
|
-
});
|
|
491
|
-
return unsubscribe;
|
|
492
|
-
}, [
|
|
493
|
-
subscribeMeta,
|
|
483
|
+
const snapshot = useCallback(()=>isDirty(name), [
|
|
494
484
|
isDirty,
|
|
495
485
|
name
|
|
496
486
|
]);
|
|
497
|
-
return
|
|
487
|
+
return useSyncExternalStore(subscribeMeta, snapshot, snapshot);
|
|
498
488
|
};
|
|
499
489
|
const useFieldValue = (name)=>{
|
|
500
|
-
const { getFieldValue,
|
|
501
|
-
const
|
|
502
|
-
|
|
503
|
-
const unsubscribe = subscribeField(name, (nextValue)=>{
|
|
504
|
-
setValue(nextValue);
|
|
505
|
-
});
|
|
506
|
-
return unsubscribe;
|
|
507
|
-
}, [
|
|
508
|
-
subscribeField,
|
|
490
|
+
const { getFieldValue, subscribeMeta } = useFormContext();
|
|
491
|
+
const snapshot = useCallback(()=>getFieldValue(name), [
|
|
492
|
+
getFieldValue,
|
|
509
493
|
name
|
|
510
494
|
]);
|
|
511
|
-
return
|
|
495
|
+
return useSyncExternalStore(subscribeMeta, snapshot, snapshot);
|
|
512
496
|
};
|
|
513
497
|
const useIsFieldUploading = (fieldPath)=>{
|
|
514
498
|
const { getIsFieldUploading, subscribeFieldUploading } = useFormContext();
|
|
515
|
-
const
|
|
516
|
-
useEffect(()=>subscribeFieldUploading(fieldPath, (next)=>{
|
|
517
|
-
setUploading(next);
|
|
518
|
-
}), [
|
|
499
|
+
const subscribe = useCallback((notify)=>subscribeFieldUploading(fieldPath, notify), [
|
|
519
500
|
subscribeFieldUploading,
|
|
520
501
|
fieldPath
|
|
521
502
|
]);
|
|
522
|
-
|
|
503
|
+
const snapshot = useCallback(()=>getIsFieldUploading(fieldPath), [
|
|
504
|
+
getIsFieldUploading,
|
|
505
|
+
fieldPath
|
|
506
|
+
]);
|
|
507
|
+
return useSyncExternalStore(subscribe, snapshot, snapshot);
|
|
523
508
|
};
|
|
524
509
|
export { FormProvider, useFieldError, useFieldValue, useFormContext, useFormMeta, useFormStore, useIsDirty, useIsFieldUploading, useSystemAvailableLocales, useSystemPath };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import type { ReactNode } from 'react';
|
|
9
|
+
/**
|
|
10
|
+
* Establishes one DOM id scope. `FormProvider` wraps its children in this, so a
|
|
11
|
+
* scope exists wherever form state does and no caller has to opt in.
|
|
12
|
+
*/
|
|
13
|
+
export declare const FormDomScopeProvider: ({ children }: {
|
|
14
|
+
children: ReactNode;
|
|
15
|
+
}) => ReactNode;
|
|
16
|
+
/**
|
|
17
|
+
* The active scope, or `''` when rendered outside a form. Field widgets are also
|
|
18
|
+
* composed directly by the admin user and role screens, which have no
|
|
19
|
+
* `FormProvider`, so an absent scope degrades to the unprefixed id rather than
|
|
20
|
+
* throwing.
|
|
21
|
+
*/
|
|
22
|
+
export declare function useFormDomScope(): string;
|
|
23
|
+
/**
|
|
24
|
+
* Scope a derived DOM id.
|
|
25
|
+
*
|
|
26
|
+
* A caller-supplied `id` is returned untouched: it is the author's identifier, it
|
|
27
|
+
* may already be referenced from outside the form, and scoping it would break that
|
|
28
|
+
* reference as well as double-prefix ids that pass through here twice.
|
|
29
|
+
*
|
|
30
|
+
* The result is a valid HTML id and a valid `for` / `aria-*` reference, but React's
|
|
31
|
+
* generated scope contains characters that need escaping in a CSS selector. Resolve
|
|
32
|
+
* these with `document.getElementById`, not `querySelector('#…')`.
|
|
33
|
+
*/
|
|
34
|
+
export declare function useScopedDomId(base: string, explicit?: string): string;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx } from "react/jsx-runtime";
|
|
3
|
+
import { createContext, useContext, useId } from "react";
|
|
4
|
+
const FormDomScopeContext = /*#__PURE__*/ createContext(null);
|
|
5
|
+
const FormDomScopeProvider = ({ children })=>{
|
|
6
|
+
const scope = useId();
|
|
7
|
+
return /*#__PURE__*/ jsx(FormDomScopeContext.Provider, {
|
|
8
|
+
value: scope,
|
|
9
|
+
children: children
|
|
10
|
+
});
|
|
11
|
+
};
|
|
12
|
+
function useFormDomScope() {
|
|
13
|
+
return useContext(FormDomScopeContext) ?? '';
|
|
14
|
+
}
|
|
15
|
+
function useScopedDomId(base, explicit) {
|
|
16
|
+
const scope = useFormDomScope();
|
|
17
|
+
if (null != explicit) return explicit;
|
|
18
|
+
return '' === scope ? base : `${scope}${base}`;
|
|
19
|
+
}
|
|
20
|
+
export { FormDomScopeProvider, useFormDomScope, useScopedDomId };
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { Fragment as external_react_Fragment } from "react";
|
|
3
|
+
import { Fragment as external_react_Fragment, useMemo } from "react";
|
|
4
4
|
import clsx from "clsx";
|
|
5
5
|
import { sliceFieldAdmin } from "../fields/field-admin.js";
|
|
6
6
|
import { FieldRenderer } from "../fields/field-renderer.js";
|
|
7
7
|
import { AdminGroup } from "../presentation/group.js";
|
|
8
8
|
import { AdminRow } from "../presentation/row.js";
|
|
9
9
|
import { AdminTabs, tabPanelId, tabTriggerId } from "../presentation/tabs.js";
|
|
10
|
+
import { useFormDomScope } from "./form-dom-scope.js";
|
|
10
11
|
import form_renderer_module from "./form-renderer.module.js";
|
|
11
12
|
import { useFormLayout } from "./use-form-layout.js";
|
|
12
13
|
import { useFormTabs } from "./use-form-tabs.js";
|
|
@@ -16,7 +17,13 @@ const BOTH_REGIONS = [
|
|
|
16
17
|
];
|
|
17
18
|
const FormLayout = ({ fields, adminConfig, regions = BOTH_REGIONS, variant = 'split', activeLocale, initialData, activeTabBySet, onTabChange, sidebarSlot })=>{
|
|
18
19
|
const { fieldByName, tabSetByName, rowByName, groupByName, layout, fieldToTabPath } = useFormLayout(adminConfig, fields);
|
|
19
|
-
const
|
|
20
|
+
const tabSets = useMemo(()=>[
|
|
21
|
+
...tabSetByName.values()
|
|
22
|
+
], [
|
|
23
|
+
tabSetByName
|
|
24
|
+
]);
|
|
25
|
+
const tabs = useFormTabs(fieldToTabPath, activeTabBySet, tabSets);
|
|
26
|
+
const domScope = useFormDomScope();
|
|
20
27
|
const renderField = (fieldName)=>{
|
|
21
28
|
const field = fieldByName.get(fieldName);
|
|
22
29
|
if (!field) return null;
|
|
@@ -47,7 +54,7 @@ const FormLayout = ({ fields, adminConfig, regions = BOTH_REGIONS, variant = 'sp
|
|
|
47
54
|
}, `group:${group.name}`);
|
|
48
55
|
const renderTabSet = (set)=>{
|
|
49
56
|
const { visibleTabs, activeTabName, activeTab, errorCounts } = tabs.resolve(set);
|
|
50
|
-
const idBase =
|
|
57
|
+
const idBase = `${domScope}tabset:${set.name}`;
|
|
51
58
|
return /*#__PURE__*/ jsxs("div", {
|
|
52
59
|
className: clsx('byline-form-tabset', form_renderer_module.tabset),
|
|
53
60
|
children: [
|
|
@@ -23,6 +23,18 @@ const FormHeadingRow = ({ heading, headerSlot })=>/*#__PURE__*/ jsxs("div", {
|
|
|
23
23
|
const FormStatusBar = ({ disabled, initialData, workflowStatuses, publishedVersion, scheduling, hasChanges, isUploading, isSubmitting, onCancel, transitions, statusBusy, onStatusBusyChange, onStatusChange, onMutationError, isBlocked, onUnsavedChanges, onUnpublish, onDelete, onDuplicate, onCopyToLocale, onDeleteLocale, useAsTitle, contentLocale, contentLocales, defaultLocale })=>{
|
|
24
24
|
const { t } = useTranslation('byline-admin');
|
|
25
25
|
const { primaryStatus, secondaryStatuses, isTerminal } = transitions;
|
|
26
|
+
const runTransition = async (status)=>{
|
|
27
|
+
if (isBlocked() || !onStatusChange) return;
|
|
28
|
+
if (hasChanges) return void onUnsavedChanges();
|
|
29
|
+
onStatusBusyChange(true);
|
|
30
|
+
try {
|
|
31
|
+
await onStatusChange(status);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
onMutationError?.(error);
|
|
34
|
+
} finally{
|
|
35
|
+
onStatusBusyChange(false);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
26
38
|
return /*#__PURE__*/ jsxs("div", {
|
|
27
39
|
className: clsx('byline-form-status-bar', form_renderer_module["status-bar"]),
|
|
28
40
|
children: [
|
|
@@ -90,30 +102,8 @@ const FormStatusBar = ({ disabled, initialData, workflowStatuses, publishedVersi
|
|
|
90
102
|
type: "button",
|
|
91
103
|
intent: isTerminal ? 'info' : 'success',
|
|
92
104
|
disabled: disabled || statusBusy,
|
|
93
|
-
onOptionSelect:
|
|
94
|
-
|
|
95
|
-
if (hasChanges) return void onUnsavedChanges();
|
|
96
|
-
onStatusBusyChange(true);
|
|
97
|
-
try {
|
|
98
|
-
await onStatusChange(value);
|
|
99
|
-
} catch (error) {
|
|
100
|
-
onMutationError?.(error);
|
|
101
|
-
} finally{
|
|
102
|
-
onStatusBusyChange(false);
|
|
103
|
-
}
|
|
104
|
-
},
|
|
105
|
-
onButtonClick: isTerminal ? void 0 : async ()=>{
|
|
106
|
-
if (isBlocked()) return;
|
|
107
|
-
if (hasChanges) return void onUnsavedChanges();
|
|
108
|
-
onStatusBusyChange(true);
|
|
109
|
-
try {
|
|
110
|
-
await onStatusChange(primaryStatus.name);
|
|
111
|
-
} catch (error) {
|
|
112
|
-
onMutationError?.(error);
|
|
113
|
-
} finally{
|
|
114
|
-
onStatusBusyChange(false);
|
|
115
|
-
}
|
|
116
|
-
},
|
|
105
|
+
onOptionSelect: runTransition,
|
|
106
|
+
onButtonClick: isTerminal ? void 0 : ()=>runTransition(primaryStatus.name),
|
|
117
107
|
children: statusBusy ? '...' : isTerminal ? primaryStatus.label ?? primaryStatus.name : primaryStatus.verb ?? primaryStatus.label ?? primaryStatus.name
|
|
118
108
|
})
|
|
119
109
|
}),
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { type ReactNode } from 'react';
|
|
9
9
|
import type { AdminResourceConfig, Field, WorkflowStatus } from '@byline/core';
|
|
10
10
|
import { type ScheduledPublicationInfo, type SchedulePublicationInput } from './scheduled-publication-control.js';
|
|
11
|
+
import { type SystemFieldsSubmitPayload } from './use-form-submission.js';
|
|
11
12
|
import type { DocumentActionsLocaleOption } from './document-actions.js';
|
|
12
13
|
import type { UseNavigationGuard } from './navigation-guard.js';
|
|
13
14
|
export type { SystemFieldsSubmitPayload } from './use-form-submission.js';
|
|
@@ -32,7 +33,7 @@ export interface FormRendererProps {
|
|
|
32
33
|
onReloadDocument?: () => void | Promise<void>;
|
|
33
34
|
mode: 'create' | 'edit';
|
|
34
35
|
fields: Field[];
|
|
35
|
-
onSubmit: (data:
|
|
36
|
+
onSubmit: (data: SystemFieldsSubmitPayload) => void | Promise<void>;
|
|
36
37
|
onCancel: () => void;
|
|
37
38
|
onStatusChange?: (nextStatus: string) => Promise<void>;
|
|
38
39
|
onUnpublish?: () => Promise<void>;
|
|
@@ -13,15 +13,13 @@ import form_renderer_module from "./form-renderer.module.js";
|
|
|
13
13
|
import { useNavigationGuardAdapter } from "./navigation-guard.js";
|
|
14
14
|
import { useScheduledPublication } from "./scheduled-publication-control.js";
|
|
15
15
|
import { computeStatusTransitions } from "./status-transitions.js";
|
|
16
|
+
import { useDocumentReloadRecovery } from "./use-document-reload-recovery.js";
|
|
16
17
|
import { useFormSubmission } from "./use-form-submission.js";
|
|
17
18
|
const FormContent = ({ mode, mutationIssue, mutationsBlocked = false, observedRevision, onMutationError, onTreeMutationCommitted, scheduledPublicationsNeedReconfirmation = false, scheduledPublicationsHref, onReloadDocument, fields, onSubmit, onCancel, onStatusChange, onUnpublish, scheduledPublication, onSchedulePublication, onConfirmScheduledPublication, onCancelScheduledPublication, onDelete, onDuplicate, onCopyToLocale, onDeleteLocale, contentLocales, nextStatus, workflowStatuses, publishedVersion, initialData, adminConfig, useAsTitle, useAsPath, showPath = true, heading, advertiseLocales, tree, headingLabel, headerSlot, collectionPath, initialLocale, onLocaleChange, defaultLocale = 'en', useNavigationGuard: useNavigationGuardProp, restoreWarnings, _activeTabBySet, _onTabChange })=>{
|
|
18
19
|
const { hasChanges: hasChangesFn, subscribeMeta } = useFormContext();
|
|
19
20
|
const { t } = useTranslation('byline-admin');
|
|
20
21
|
const [hasChanges, setHasChanges] = useState(hasChangesFn());
|
|
21
22
|
const [statusBusy, setStatusBusy] = useState(false);
|
|
22
|
-
const formRef = useRef(null);
|
|
23
|
-
const focusBeforeBusyRef = useRef(null);
|
|
24
|
-
const restoreFocusAfterBusyRef = useRef(false);
|
|
25
23
|
const [showUnsavedModal, setShowUnsavedModal] = useState(false);
|
|
26
24
|
const [contentLocale, setContentLocale] = useState(initialLocale ?? defaultLocale);
|
|
27
25
|
const scheduling = useScheduledPublication({
|
|
@@ -79,76 +77,38 @@ const FormContent = ({ mode, mutationIssue, mutationsBlocked = false, observedRe
|
|
|
79
77
|
}) : 'create' === mode ? t('forms.heading.create') : t('forms.heading.edit'));
|
|
80
78
|
const guardFromContext = useNavigationGuardAdapter();
|
|
81
79
|
const useGuard = useNavigationGuardProp ?? guardFromContext;
|
|
82
|
-
const [discarding, setDiscarding] = useState(false);
|
|
83
|
-
const [reloadFailed, setReloadFailed] = useState(false);
|
|
84
|
-
const warningRef = useRef(null);
|
|
85
|
-
const mutationBlockedRef = useRef(mutationsBlocked);
|
|
86
|
-
mutationBlockedRef.current = mutationsBlocked || discarding;
|
|
87
|
-
const guard = useGuard(hasChanges && !discarding);
|
|
88
|
-
useEffect(()=>{
|
|
89
|
-
if (mutationIssue) warningRef.current?.focus();
|
|
90
|
-
}, [
|
|
91
|
-
mutationIssue
|
|
92
|
-
]);
|
|
93
|
-
useEffect(()=>{
|
|
94
|
-
if (!discarding) return;
|
|
95
|
-
Promise.resolve().then(()=>onReloadDocument ? onReloadDocument() : window.location.reload()).catch(()=>{
|
|
96
|
-
setDiscarding(false);
|
|
97
|
-
setReloadFailed(true);
|
|
98
|
-
});
|
|
99
|
-
}, [
|
|
100
|
-
discarding,
|
|
101
|
-
onReloadDocument
|
|
102
|
-
]);
|
|
103
80
|
const currentStatus = initialData?.status;
|
|
104
81
|
const { primaryStatus, secondaryStatuses, isTerminal } = computeStatusTransitions(currentStatus, workflowStatuses, nextStatus);
|
|
105
82
|
useEffect(()=>subscribeMeta(()=>setHasChanges(hasChangesFn())), [
|
|
106
83
|
subscribeMeta,
|
|
107
84
|
hasChangesFn
|
|
108
85
|
]);
|
|
109
|
-
const captureFocusBeforeBusy = useCallback(()=>{
|
|
110
|
-
if (null != focusBeforeBusyRef.current) return;
|
|
111
|
-
const activeElement = document.activeElement;
|
|
112
|
-
if (!(activeElement instanceof HTMLElement) || !formRef.current?.contains(activeElement)) return;
|
|
113
|
-
focusBeforeBusyRef.current = activeElement;
|
|
114
|
-
restoreFocusAfterBusyRef.current = true;
|
|
115
|
-
}, []);
|
|
116
86
|
const submission = useFormSubmission({
|
|
117
87
|
mode,
|
|
118
88
|
fields,
|
|
119
89
|
documentId: 'edit' === mode && 'string' == typeof initialData?.id ? initialData.id : void 0,
|
|
120
90
|
advertiseLocales,
|
|
121
91
|
onSubmit,
|
|
122
|
-
isBlocked: ()=>mutationBlockedRef.current,
|
|
123
|
-
onBeforeBusy: captureFocusBeforeBusy
|
|
92
|
+
isBlocked: ()=>recovery.mutationBlockedRef.current,
|
|
93
|
+
onBeforeBusy: ()=>recovery.captureFocusBeforeBusy()
|
|
124
94
|
});
|
|
95
|
+
const recovery = useDocumentReloadRecovery({
|
|
96
|
+
mutationsBlocked,
|
|
97
|
+
mutationIssue,
|
|
98
|
+
onReloadDocument,
|
|
99
|
+
isBusy: submission.isBusy
|
|
100
|
+
});
|
|
101
|
+
const { formRef, warningRef, mutationBlockedRef, discarding, reloadFailed, requestDiscard } = recovery;
|
|
102
|
+
const guard = useGuard(hasChanges && !discarding);
|
|
125
103
|
const isUploading = 'uploading' === submission.phase.kind;
|
|
126
104
|
const isSubmitting = 'submitting' === submission.phase.kind;
|
|
127
105
|
const isBusy = submission.isBusy;
|
|
128
106
|
const pendingSystemFieldsSubmit = 'confirmingSystemFields' === submission.phase.kind ? submission.phase.payload : null;
|
|
129
|
-
useEffect(()=>{
|
|
130
|
-
if (isBusy || !restoreFocusAfterBusyRef.current) return;
|
|
131
|
-
restoreFocusAfterBusyRef.current = false;
|
|
132
|
-
if (mutationIssue) {
|
|
133
|
-
warningRef.current?.focus();
|
|
134
|
-
focusBeforeBusyRef.current = null;
|
|
135
|
-
return;
|
|
136
|
-
}
|
|
137
|
-
const original = focusBeforeBusyRef.current;
|
|
138
|
-
focusBeforeBusyRef.current = null;
|
|
139
|
-
const originalCanReceiveFocus = original?.isConnected === true && !original.matches(':disabled, [aria-disabled="true"]');
|
|
140
|
-
const target = originalCanReceiveFocus ? original : formRef.current?.querySelector('input:not(:disabled), textarea:not(:disabled), select:not(:disabled), button:not(:disabled), [tabindex]:not([tabindex="-1"])');
|
|
141
|
-
target?.focus({
|
|
142
|
-
preventScroll: true
|
|
143
|
-
});
|
|
144
|
-
}, [
|
|
145
|
-
isBusy,
|
|
146
|
-
mutationIssue
|
|
147
|
-
]);
|
|
148
107
|
const handleCancel = ()=>{
|
|
149
108
|
if (onCancel && 'function' == typeof onCancel) onCancel();
|
|
150
109
|
};
|
|
151
110
|
const handleSubmit = (e)=>{
|
|
111
|
+
if (e.target !== e.currentTarget) return;
|
|
152
112
|
e.preventDefault();
|
|
153
113
|
if (mutationBlockedRef.current) return;
|
|
154
114
|
submission.submit();
|
|
@@ -215,10 +175,7 @@ const FormContent = ({ mode, mutationIssue, mutationsBlocked = false, observedRe
|
|
|
215
175
|
warningRef: warningRef,
|
|
216
176
|
discarding: discarding,
|
|
217
177
|
reloadFailed: reloadFailed,
|
|
218
|
-
onDiscardRequested:
|
|
219
|
-
setReloadFailed(false);
|
|
220
|
-
setDiscarding(true);
|
|
221
|
-
},
|
|
178
|
+
onDiscardRequested: requestDiscard,
|
|
222
179
|
scheduling: scheduling,
|
|
223
180
|
restoreWarnings: restoreWarnings
|
|
224
181
|
}),
|
|
@@ -26,3 +26,5 @@ export declare function hasExistingIdTargets(object: unknown, path: string): boo
|
|
|
26
26
|
/** Set a path and report whether all stable-id selectors resolved. */
|
|
27
27
|
export declare function setWithResult<T extends object>(object: T, path: string, value: unknown): boolean;
|
|
28
28
|
export declare function set<T extends object>(object: T, path: string, value: unknown): T;
|
|
29
|
+
/** Clone only the containers along a write path, preserving other snapshots. */
|
|
30
|
+
export declare function withValue<T extends object>(object: T, path: string, value: unknown): T | undefined;
|
|
@@ -95,4 +95,29 @@ function set(object, path, value) {
|
|
|
95
95
|
setWithResult(object, path, value);
|
|
96
96
|
return object;
|
|
97
97
|
}
|
|
98
|
-
|
|
98
|
+
function withValue(object, path, value) {
|
|
99
|
+
const parsed = parseInstancePath(path);
|
|
100
|
+
if (!parsed.ok || !hasExistingIdTargets(object, path)) return;
|
|
101
|
+
const result = Array.isArray(object) ? [
|
|
102
|
+
...object
|
|
103
|
+
] : {
|
|
104
|
+
...object
|
|
105
|
+
};
|
|
106
|
+
let current = result;
|
|
107
|
+
for (const [index, segment] of parsed.segments.entries()){
|
|
108
|
+
const key = 'field' === segment.kind ? segment.name : 'index' === segment.kind ? segment.index : 'id' === segment.kind ? selectId(current, segment.id) : void 0;
|
|
109
|
+
if (void 0 === key || -1 === key) break;
|
|
110
|
+
if (index === parsed.segments.length - 1) {
|
|
111
|
+
current[key] = value;
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
const existing = current[key];
|
|
115
|
+
current[key] = Array.isArray(existing) ? [
|
|
116
|
+
...existing
|
|
117
|
+
] : null != existing && 'object' == typeof existing ? {
|
|
118
|
+
...existing
|
|
119
|
+
} : newContainer(parsed.segments[index + 1]);
|
|
120
|
+
current = current[key];
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
export { get, hasExistingIdTargets, set, setWithResult, toPath, withValue };
|