@byline/admin 6.3.0 → 6.5.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.
Files changed (62) hide show
  1. package/dist/fields/array/array-field.js +29 -12
  2. package/dist/fields/blocks/blocks-field.js +20 -8
  3. package/dist/fields/checkbox/checkbox-field.js +1 -0
  4. package/dist/fields/datetime/datetime-field.js +1 -0
  5. package/dist/fields/file/file-field.js +3 -2
  6. package/dist/fields/group/group-field.js +5 -2
  7. package/dist/fields/image/image-field.js +3 -2
  8. package/dist/fields/relation/relation-field.js +5 -0
  9. package/dist/fields/relation/relation-many-field.js +9 -2
  10. package/dist/fields/select/select-field.js +1 -0
  11. package/dist/fields/text/text-field.js +4 -2
  12. package/dist/fields/text-area/text-area-field.js +4 -2
  13. package/dist/fields/use-field-change-handler.js +24 -6
  14. package/dist/fields/use-field-condition.d.ts +0 -15
  15. package/dist/fields/use-field-condition.js +10 -1
  16. package/dist/forms/form-context.d.ts +3 -12
  17. package/dist/forms/form-context.js +111 -129
  18. package/dist/forms/form-layout.js +7 -2
  19. package/dist/forms/form-page-chrome.js +14 -24
  20. package/dist/forms/form-renderer.d.ts +2 -1
  21. package/dist/forms/form-renderer.js +12 -56
  22. package/dist/forms/nested-path.d.ts +2 -0
  23. package/dist/forms/nested-path.js +26 -1
  24. package/dist/forms/use-document-reload-recovery.d.ts +22 -0
  25. package/dist/forms/use-document-reload-recovery.js +67 -0
  26. package/dist/forms/use-form-submission.d.ts +1 -1
  27. package/dist/forms/use-form-submission.js +45 -27
  28. package/dist/forms/use-form-tabs.d.ts +2 -2
  29. package/dist/forms/use-form-tabs.js +18 -11
  30. package/dist/modules/admin-account/components/change-password.js +6 -0
  31. package/dist/modules/admin-users/components/create.js +4 -3
  32. package/dist/modules/admin-users/components/set-password.js +4 -0
  33. package/dist/modules/auth/components/sign-in-form.js +4 -3
  34. package/package.json +16 -16
  35. package/src/fields/array/array-field.tsx +24 -12
  36. package/src/fields/blocks/blocks-field.tsx +18 -8
  37. package/src/fields/checkbox/checkbox-field.tsx +1 -0
  38. package/src/fields/datetime/datetime-field.tsx +1 -0
  39. package/src/fields/file/file-field.tsx +3 -2
  40. package/src/fields/group/group-field.tsx +2 -2
  41. package/src/fields/image/image-field.tsx +3 -2
  42. package/src/fields/relation/relation-field.tsx +5 -0
  43. package/src/fields/relation/relation-many-field.tsx +16 -2
  44. package/src/fields/select/select-field.tsx +1 -0
  45. package/src/fields/text/text-field.tsx +3 -2
  46. package/src/fields/text-area/text-area-field.tsx +3 -2
  47. package/src/fields/use-field-change-handler.ts +43 -9
  48. package/src/fields/use-field-condition.ts +17 -1
  49. package/src/forms/form-context.tsx +178 -233
  50. package/src/forms/form-correctness.test.tsx +560 -0
  51. package/src/forms/form-layout.tsx +3 -2
  52. package/src/forms/form-page-chrome.tsx +17 -34
  53. package/src/forms/form-renderer-submit.test.tsx +36 -0
  54. package/src/forms/form-renderer.tsx +15 -64
  55. package/src/forms/nested-path.ts +35 -0
  56. package/src/forms/use-document-reload-recovery.ts +92 -0
  57. package/src/forms/use-form-submission.ts +56 -27
  58. package/src/forms/use-form-tabs.ts +25 -17
  59. package/src/modules/admin-account/components/change-password.tsx +6 -0
  60. package/src/modules/admin-users/components/create.tsx +4 -3
  61. package/src/modules/admin-users/components/set-password.tsx +4 -0
  62. package/src/modules/auth/components/sign-in-form.tsx +4 -3
@@ -1,9 +1,9 @@
1
1
  "use client";
2
2
  import { jsx } from "react/jsx-runtime";
3
- import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
4
- import { normalizeHooks } from "@byline/core";
3
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useSyncExternalStore } from "react";
4
+ import { normalizeHooks, validateDocumentFields } from "@byline/core";
5
5
  import { FormDomScopeProvider } from "./form-dom-scope.js";
6
- import { get, hasExistingIdTargets, setWithResult } from "./nested-path.js";
6
+ import { get, hasExistingIdTargets, withValue } from "./nested-path.js";
7
7
  import { deletePendingUploadsUnderPath } from "./pending-uploads.js";
8
8
  import { useTrackedSlot } from "./use-tracked-slot.js";
9
9
  const sameLocaleSet = (a, b)=>{
@@ -26,7 +26,6 @@ const useFormContext = ()=>{
26
26
  };
27
27
  const FormProvider = ({ children, initialData = {}, documentId = null, collectionPath = null })=>{
28
28
  const fieldValues = useRef(JSON.parse(JSON.stringify(initialData?.fields ?? initialData)));
29
- const initialValues = useRef(initialData?.fields ?? initialData);
30
29
  const errorsRef = useRef([]);
31
30
  const dirtyFields = useRef(new Set());
32
31
  const patchesRef = useRef([]);
@@ -94,10 +93,8 @@ const FormProvider = ({ children, initialData = {}, documentId = null, collectio
94
93
  ]
95
94
  });
96
95
  const updateFieldStoreInternal = useCallback((name, value)=>{
97
- const newFieldValues = {
98
- ...fieldValues.current
99
- };
100
- if (!setWithResult(newFieldValues, name, value)) return false;
96
+ const newFieldValues = withValue(fieldValues.current, name, value);
97
+ if (!newFieldValues) return false;
101
98
  fieldValues.current = newFieldValues;
102
99
  dirtyFields.current.add(name);
103
100
  notifyFieldListeners(name, value);
@@ -150,12 +147,7 @@ const FormProvider = ({ children, initialData = {}, documentId = null, collectio
150
147
  }, [
151
148
  notifyMetaListeners
152
149
  ]);
153
- const getFieldValue = useCallback((name)=>{
154
- const dirty = dirtyFields.current.has(name);
155
- const currentValue = get(fieldValues.current, name);
156
- if (void 0 !== currentValue) return currentValue;
157
- if (!dirty) return get(initialValues.current, name);
158
- }, []);
150
+ const getFieldValue = useCallback((name)=>get(fieldValues.current, name), []);
159
151
  const hasChanges = useCallback(()=>dirtyFields.current.size > 0, []);
160
152
  const resetHasChanges = useCallback(()=>{
161
153
  dirtyFields.current.clear();
@@ -254,58 +246,35 @@ const FormProvider = ({ children, initialData = {}, documentId = null, collectio
254
246
  useEffect(()=>()=>{
255
247
  for (const upload of pendingUploadsRef.current.values())URL.revokeObjectURL(upload.previewUrl);
256
248
  }, []);
257
- const validateForm = useCallback((fields)=>{
258
- const formErrors = [];
259
- const data = getFieldValues();
260
- for (const field of fields){
261
- if (field.condition && !field.condition(data, data)) continue;
262
- const value = getFieldValue(field.name);
263
- if (!field.optional && (null == value || '' === value)) formErrors.push({
264
- field: field.name,
265
- message: `${field.label} is required`
266
- });
267
- if (null != value && '' !== value) switch(field.type){
268
- case 'text':
269
- if ('string' != typeof value) formErrors.push({
270
- field: field.name,
271
- message: `${field.label} must be text`
272
- });
273
- break;
274
- case 'checkbox':
275
- if ('boolean' != typeof value) formErrors.push({
276
- field: field.name,
277
- message: `${field.label} must be true or false`
278
- });
279
- break;
280
- case 'select':
281
- if ('options' in field && field.options) {
282
- const validValues = field.options.map((opt)=>opt.value);
283
- if (!validValues.includes(value)) formErrors.push({
284
- field: field.name,
285
- message: `${field.label} must be one of: ${validValues.join(', ')}`
286
- });
287
- }
288
- break;
289
- case 'datetime':
290
- if (value instanceof Date === false && 'string' != typeof value) formErrors.push({
291
- field: field.name,
292
- message: `${field.label} must be a valid date`
293
- });
294
- break;
295
- }
296
- if (field.validate) {
297
- const error = field.validate(value, data);
298
- if (error) formErrors.push({
299
- field: field.name,
300
- message: error
301
- });
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();
302
258
  }
303
- }
304
- errorsRef.current = formErrors;
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
+ ];
305
275
  notifyErrorListeners();
306
- return formErrors;
276
+ return errorsRef.current;
307
277
  }, [
308
- getFieldValue,
309
278
  getFieldValues,
310
279
  notifyErrorListeners
311
280
  ]);
@@ -342,7 +311,13 @@ const FormProvider = ({ children, initialData = {}, documentId = null, collectio
342
311
  for (const field of fields){
343
312
  const fns = normalizeHooks(field.hooks?.beforeValidate);
344
313
  if (0 === fns.length) continue;
345
- if (field.condition && !field.condition(data, data)) continue;
314
+ if (field.condition) {
315
+ let visible = true;
316
+ try {
317
+ visible = Boolean(field.condition(data, data));
318
+ } catch {}
319
+ if (!visible) continue;
320
+ }
346
321
  const path = field.name;
347
322
  const value = getFieldValue(path);
348
323
  const ctx = {
@@ -388,8 +363,11 @@ const FormProvider = ({ children, initialData = {}, documentId = null, collectio
388
363
  setFieldValue,
389
364
  notifyErrorListeners
390
365
  ]);
391
- return /*#__PURE__*/ jsx(FormContext.Provider, {
392
- value: {
366
+ const resetPatches = useCallback(()=>{
367
+ patchesRef.current = [];
368
+ }, []);
369
+ const getErrors = useCallback(()=>errorsRef.current, []);
370
+ const context = useMemo(()=>({
393
371
  documentId,
394
372
  collectionPath,
395
373
  setFieldValue,
@@ -398,15 +376,17 @@ const FormProvider = ({ children, initialData = {}, documentId = null, collectio
398
376
  getFieldValues,
399
377
  getPatches,
400
378
  appendPatch,
401
- resetPatches: ()=>{
402
- patchesRef.current = [];
403
- },
379
+ resetPatches,
404
380
  hasChanges,
405
381
  resetHasChanges,
406
382
  runFieldHooks,
407
383
  validateForm,
408
- errors: errorsRef.current,
409
- getErrors: ()=>errorsRef.current,
384
+ get errors () {
385
+ return errorsRef.current;
386
+ },
387
+ getErrors,
388
+ trackFieldChange,
389
+ waitForFieldChanges,
410
390
  clearErrors,
411
391
  setFieldError,
412
392
  clearFieldError,
@@ -430,7 +410,49 @@ const FormProvider = ({ children, initialData = {}, documentId = null, collectio
430
410
  getSystemAvailableLocales: availableLocalesSlot.get,
431
411
  setSystemAvailableLocales: availableLocalesSlot.set,
432
412
  subscribeSystemAvailableLocales: availableLocalesSlot.subscribe
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,
434
456
  children: /*#__PURE__*/ jsx(FormDomScopeProvider, {
435
457
  children: children
436
458
  })
@@ -438,90 +460,50 @@ const FormProvider = ({ children, initialData = {}, documentId = null, collectio
438
460
  };
439
461
  const useSystemPath = ()=>{
440
462
  const { getSystemPath, subscribeSystemPath } = useFormContext();
441
- const [value, setValue] = useState(()=>getSystemPath());
442
- useEffect(()=>subscribeSystemPath((next)=>setValue(next)), [
443
- subscribeSystemPath
444
- ]);
445
- return value;
463
+ return useSyncExternalStore(subscribeSystemPath, getSystemPath, getSystemPath);
446
464
  };
447
465
  const useSystemAvailableLocales = ()=>{
448
466
  const { getSystemAvailableLocales, subscribeSystemAvailableLocales } = useFormContext();
449
- const [value, setValue] = useState(()=>getSystemAvailableLocales());
450
- useEffect(()=>subscribeSystemAvailableLocales((next)=>setValue(next)), [
451
- subscribeSystemAvailableLocales
452
- ]);
453
- return value;
467
+ return useSyncExternalStore(subscribeSystemAvailableLocales, getSystemAvailableLocales, getSystemAvailableLocales);
454
468
  };
455
469
  const useFormStore = ()=>useFormContext();
456
470
  const useFieldError = (name)=>{
457
471
  const { getErrors, subscribeErrors } = useFormContext();
458
- const [error, setError] = useState(()=>getErrors().find((e)=>e.field === name)?.message);
459
- useEffect(()=>{
460
- const unsubscribe = subscribeErrors((currentErrors)=>{
461
- const fieldError = currentErrors.find((e)=>e.field === name);
462
- setError(fieldError?.message);
463
- });
464
- return unsubscribe;
465
- }, [
466
- subscribeErrors,
467
- name
468
- ]);
469
- return error;
472
+ const errors = useSyncExternalStore(subscribeErrors, getErrors, getErrors);
473
+ return errors.find((error)=>error.field === name)?.message;
470
474
  };
471
475
  const useFormMeta = ()=>{
472
476
  const { hasChanges, subscribeMeta } = useFormContext();
473
- const [hasChangesValue, setHasChangesValue] = useState(hasChanges());
474
- useEffect(()=>{
475
- const unsubscribe = subscribeMeta(()=>{
476
- setHasChangesValue(hasChanges());
477
- });
478
- return unsubscribe;
479
- }, [
480
- subscribeMeta,
481
- hasChanges
482
- ]);
483
477
  return {
484
- hasChanges: hasChangesValue
478
+ hasChanges: useSyncExternalStore(subscribeMeta, hasChanges, hasChanges)
485
479
  };
486
480
  };
487
481
  const useIsDirty = (name)=>{
488
482
  const { isDirty, subscribeMeta } = useFormContext();
489
- const [dirty, setDirty] = useState(isDirty(name));
490
- useEffect(()=>{
491
- const unsubscribe = subscribeMeta(()=>{
492
- setDirty(isDirty(name));
493
- });
494
- return unsubscribe;
495
- }, [
496
- subscribeMeta,
483
+ const snapshot = useCallback(()=>isDirty(name), [
497
484
  isDirty,
498
485
  name
499
486
  ]);
500
- return dirty;
487
+ return useSyncExternalStore(subscribeMeta, snapshot, snapshot);
501
488
  };
502
489
  const useFieldValue = (name)=>{
503
- const { getFieldValue, subscribeField } = useFormContext();
504
- const [value, setValue] = useState(()=>getFieldValue(name));
505
- useEffect(()=>{
506
- const unsubscribe = subscribeField(name, (nextValue)=>{
507
- setValue(nextValue);
508
- });
509
- return unsubscribe;
510
- }, [
511
- subscribeField,
490
+ const { getFieldValue, subscribeMeta } = useFormContext();
491
+ const snapshot = useCallback(()=>getFieldValue(name), [
492
+ getFieldValue,
512
493
  name
513
494
  ]);
514
- return value;
495
+ return useSyncExternalStore(subscribeMeta, snapshot, snapshot);
515
496
  };
516
497
  const useIsFieldUploading = (fieldPath)=>{
517
498
  const { getIsFieldUploading, subscribeFieldUploading } = useFormContext();
518
- const [uploading, setUploading] = useState(()=>getIsFieldUploading(fieldPath));
519
- useEffect(()=>subscribeFieldUploading(fieldPath, (next)=>{
520
- setUploading(next);
521
- }), [
499
+ const subscribe = useCallback((notify)=>subscribeFieldUploading(fieldPath, notify), [
522
500
  subscribeFieldUploading,
523
501
  fieldPath
524
502
  ]);
525
- return uploading;
503
+ const snapshot = useCallback(()=>getIsFieldUploading(fieldPath), [
504
+ getIsFieldUploading,
505
+ fieldPath
506
+ ]);
507
+ return useSyncExternalStore(subscribe, snapshot, snapshot);
526
508
  };
527
509
  export { FormProvider, useFieldError, useFieldValue, useFormContext, useFormMeta, useFormStore, useIsDirty, useIsFieldUploading, useSystemAvailableLocales, useSystemPath };
@@ -1,6 +1,6 @@
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";
@@ -17,7 +17,12 @@ const BOTH_REGIONS = [
17
17
  ];
18
18
  const FormLayout = ({ fields, adminConfig, regions = BOTH_REGIONS, variant = 'split', activeLocale, initialData, activeTabBySet, onTabChange, sidebarSlot })=>{
19
19
  const { fieldByName, tabSetByName, rowByName, groupByName, layout, fieldToTabPath } = useFormLayout(adminConfig, fields);
20
- const tabs = useFormTabs(fieldToTabPath, activeTabBySet);
20
+ const tabSets = useMemo(()=>[
21
+ ...tabSetByName.values()
22
+ ], [
23
+ tabSetByName
24
+ ]);
25
+ const tabs = useFormTabs(fieldToTabPath, activeTabBySet, tabSets);
21
26
  const domScope = useFormDomScope();
22
27
  const renderField = (fieldName)=>{
23
28
  const field = fieldByName.get(fieldName);
@@ -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: async (value)=>{
94
- if (isBlocked()) return;
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: any) => void | Promise<void>;
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,72 +77,33 @@ 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
  };
@@ -216,10 +175,7 @@ const FormContent = ({ mode, mutationIssue, mutationsBlocked = false, observedRe
216
175
  warningRef: warningRef,
217
176
  discarding: discarding,
218
177
  reloadFailed: reloadFailed,
219
- onDiscardRequested: ()=>{
220
- setReloadFailed(false);
221
- setDiscarding(true);
222
- },
178
+ onDiscardRequested: requestDiscard,
223
179
  scheduling: scheduling,
224
180
  restoreWarnings: restoreWarnings
225
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
- export { get, hasExistingIdTargets, set, setWithResult, toPath };
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 };
@@ -0,0 +1,22 @@
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
+ /** Reload admission and focus recovery remain synchronous at event boundaries. */
9
+ export declare function useDocumentReloadRecovery({ mutationsBlocked, mutationIssue, onReloadDocument, isBusy, }: {
10
+ mutationsBlocked: boolean;
11
+ mutationIssue?: string | null;
12
+ onReloadDocument?: () => void | Promise<void>;
13
+ isBusy: boolean;
14
+ }): {
15
+ formRef: import("react").RefObject<HTMLFormElement | null>;
16
+ warningRef: import("react").RefObject<HTMLDivElement | null>;
17
+ mutationBlockedRef: import("react").RefObject<boolean>;
18
+ discarding: boolean;
19
+ reloadFailed: boolean;
20
+ requestDiscard: () => void;
21
+ captureFocusBeforeBusy: () => void;
22
+ };