@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.
Files changed (82) 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 +4 -1
  4. package/dist/fields/code/code-field.js +2 -1
  5. package/dist/fields/datetime/datetime-field.js +5 -2
  6. package/dist/fields/field-renderer.js +2 -1
  7. package/dist/fields/field-services-types.d.ts +23 -0
  8. package/dist/fields/file/file-field.js +6 -4
  9. package/dist/fields/group/group-field.js +8 -3
  10. package/dist/fields/image/image-field.js +6 -4
  11. package/dist/fields/numerical/numerical-field.js +2 -1
  12. package/dist/fields/relation/relation-field.js +8 -2
  13. package/dist/fields/relation/relation-many-field.js +12 -4
  14. package/dist/fields/relation/relation-picker.js +38 -7
  15. package/dist/fields/relation/relation-picker.module.js +3 -0
  16. package/dist/fields/relation/relation-picker_module.css +20 -0
  17. package/dist/fields/select/select-field.js +4 -2
  18. package/dist/fields/text/text-field.js +6 -3
  19. package/dist/fields/text-area/text-area-field.js +6 -3
  20. package/dist/fields/use-field-change-handler.js +24 -6
  21. package/dist/fields/use-field-condition.d.ts +0 -15
  22. package/dist/fields/use-field-condition.js +10 -1
  23. package/dist/forms/form-context.d.ts +3 -12
  24. package/dist/forms/form-context.js +115 -130
  25. package/dist/forms/form-dom-scope.d.ts +34 -0
  26. package/dist/forms/form-dom-scope.js +20 -0
  27. package/dist/forms/form-layout.js +10 -3
  28. package/dist/forms/form-page-chrome.js +14 -24
  29. package/dist/forms/form-renderer.d.ts +2 -1
  30. package/dist/forms/form-renderer.js +13 -56
  31. package/dist/forms/nested-path.d.ts +2 -0
  32. package/dist/forms/nested-path.js +26 -1
  33. package/dist/forms/use-document-reload-recovery.d.ts +22 -0
  34. package/dist/forms/use-document-reload-recovery.js +67 -0
  35. package/dist/forms/use-form-submission.d.ts +1 -1
  36. package/dist/forms/use-form-submission.js +45 -27
  37. package/dist/forms/use-form-tabs.d.ts +2 -2
  38. package/dist/forms/use-form-tabs.js +18 -11
  39. package/dist/presentation/tabs.js +2 -1
  40. package/dist/react.d.ts +1 -1
  41. package/package.json +14 -14
  42. package/src/fields/array/array-field.tsx +24 -12
  43. package/src/fields/blocks/blocks-field.tsx +18 -8
  44. package/src/fields/checkbox/checkbox-field.tsx +4 -1
  45. package/src/fields/code/code-field.tsx +2 -1
  46. package/src/fields/datetime/datetime-field.tsx +5 -2
  47. package/src/fields/direct-widget-dom-ids.test.tsx +142 -0
  48. package/src/fields/field-dom-ids.test.tsx +293 -0
  49. package/src/fields/field-renderer.tsx +2 -1
  50. package/src/fields/field-services-types.ts +25 -0
  51. package/src/fields/file/file-field.tsx +6 -4
  52. package/src/fields/group/group-field.tsx +5 -3
  53. package/src/fields/image/image-field.tsx +6 -4
  54. package/src/fields/numerical/numerical-field.tsx +2 -1
  55. package/src/fields/relation/relation-field.tsx +8 -2
  56. package/src/fields/relation/relation-many-field.tsx +19 -4
  57. package/src/fields/relation/relation-picker-create.test.tsx +203 -0
  58. package/src/fields/relation/relation-picker-refresh.test.tsx +325 -0
  59. package/src/fields/relation/relation-picker.module.css +32 -0
  60. package/src/fields/relation/relation-picker.tsx +59 -1
  61. package/src/fields/select/select-field.tsx +4 -2
  62. package/src/fields/text/text-field.tsx +5 -3
  63. package/src/fields/text-area/text-area-field.tsx +5 -3
  64. package/src/fields/use-field-change-handler.ts +43 -9
  65. package/src/fields/use-field-condition.ts +17 -1
  66. package/src/forms/form-context.tsx +180 -234
  67. package/src/forms/form-correctness.test.tsx +560 -0
  68. package/src/forms/form-dom-scope.test.tsx +213 -0
  69. package/src/forms/form-dom-scope.tsx +70 -0
  70. package/src/forms/form-layout.tsx +10 -3
  71. package/src/forms/form-page-chrome.tsx +17 -34
  72. package/src/forms/form-renderer-submit.test.tsx +36 -0
  73. package/src/forms/form-renderer.tsx +26 -64
  74. package/src/forms/form-submit-isolation.test.tsx +247 -0
  75. package/src/forms/nested-path.ts +35 -0
  76. package/src/forms/use-document-reload-recovery.ts +92 -0
  77. package/src/forms/use-form-submission.ts +56 -27
  78. package/src/forms/use-form-tabs.ts +25 -17
  79. package/src/presentation/tab-dom-ids.test.tsx +153 -0
  80. package/src/presentation/tabs.test.tsx +49 -2
  81. package/src/presentation/tabs.tsx +11 -1
  82. 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, useState } from "react";
4
- import { normalizeHooks } from "@byline/core";
5
- import { get, hasExistingIdTargets, setWithResult } from "./nested-path.js";
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
- ...fieldValues.current
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 validateForm = useCallback((fields)=>{
257
- const formErrors = [];
258
- const data = getFieldValues();
259
- for (const field of fields){
260
- if (field.condition && !field.condition(data, data)) continue;
261
- const value = getFieldValue(field.name);
262
- if (!field.optional && (null == value || '' === value)) formErrors.push({
263
- field: field.name,
264
- message: `${field.label} is required`
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
- 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
+ ];
304
275
  notifyErrorListeners();
305
- return formErrors;
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 && !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
+ }
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
- return /*#__PURE__*/ jsx(FormContext.Provider, {
391
- value: {
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: errorsRef.current,
408
- getErrors: ()=>errorsRef.current,
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
- children: children
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
- const [value, setValue] = useState(()=>getSystemPath());
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
- const [value, setValue] = useState(()=>getSystemAvailableLocales());
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 [error, setError] = useState(()=>getErrors().find((e)=>e.field === name)?.message);
456
- useEffect(()=>{
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: hasChangesValue
478
+ hasChanges: useSyncExternalStore(subscribeMeta, hasChanges, hasChanges)
482
479
  };
483
480
  };
484
481
  const useIsDirty = (name)=>{
485
482
  const { isDirty, subscribeMeta } = useFormContext();
486
- const [dirty, setDirty] = useState(isDirty(name));
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 dirty;
487
+ return useSyncExternalStore(subscribeMeta, snapshot, snapshot);
498
488
  };
499
489
  const useFieldValue = (name)=>{
500
- const { getFieldValue, subscribeField } = useFormContext();
501
- const [value, setValue] = useState(()=>getFieldValue(name));
502
- useEffect(()=>{
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 value;
495
+ return useSyncExternalStore(subscribeMeta, snapshot, snapshot);
512
496
  };
513
497
  const useIsFieldUploading = (fieldPath)=>{
514
498
  const { getIsFieldUploading, subscribeFieldUploading } = useFormContext();
515
- const [uploading, setUploading] = useState(()=>getIsFieldUploading(fieldPath));
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
- return uploading;
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 tabs = useFormTabs(fieldToTabPath, activeTabBySet);
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 = `tabset:${set.name}`;
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: 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,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
- 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 };