@byline/admin 6.3.0 → 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 (54) 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/package.json +14 -14
  31. package/src/fields/array/array-field.tsx +24 -12
  32. package/src/fields/blocks/blocks-field.tsx +18 -8
  33. package/src/fields/checkbox/checkbox-field.tsx +1 -0
  34. package/src/fields/datetime/datetime-field.tsx +1 -0
  35. package/src/fields/file/file-field.tsx +3 -2
  36. package/src/fields/group/group-field.tsx +2 -2
  37. package/src/fields/image/image-field.tsx +3 -2
  38. package/src/fields/relation/relation-field.tsx +5 -0
  39. package/src/fields/relation/relation-many-field.tsx +16 -2
  40. package/src/fields/select/select-field.tsx +1 -0
  41. package/src/fields/text/text-field.tsx +3 -2
  42. package/src/fields/text-area/text-area-field.tsx +3 -2
  43. package/src/fields/use-field-change-handler.ts +43 -9
  44. package/src/fields/use-field-condition.ts +17 -1
  45. package/src/forms/form-context.tsx +178 -233
  46. package/src/forms/form-correctness.test.tsx +560 -0
  47. package/src/forms/form-layout.tsx +3 -2
  48. package/src/forms/form-page-chrome.tsx +17 -34
  49. package/src/forms/form-renderer-submit.test.tsx +36 -0
  50. package/src/forms/form-renderer.tsx +15 -64
  51. package/src/forms/nested-path.ts +35 -0
  52. package/src/forms/use-document-reload-recovery.ts +92 -0
  53. package/src/forms/use-form-submission.ts +56 -27
  54. package/src/forms/use-form-tabs.ts +25 -17
@@ -9,10 +9,18 @@
9
9
  */
10
10
 
11
11
  import type React from 'react'
12
- import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
12
+ import {
13
+ createContext,
14
+ useCallback,
15
+ useContext,
16
+ useEffect,
17
+ useMemo,
18
+ useRef,
19
+ useSyncExternalStore,
20
+ } from 'react'
13
21
 
14
22
  import type { Field, FieldBeforeChangeResult, FieldHookContext } from '@byline/core'
15
- import { normalizeHooks } from '@byline/core'
23
+ import { normalizeHooks, validateDocumentFields } from '@byline/core'
16
24
  import type { DocumentPatch, FieldSetPatch } from '@byline/core/patches'
17
25
 
18
26
  import { FormDomScopeProvider } from './form-dom-scope'
@@ -20,11 +28,7 @@ import { FormDomScopeProvider } from './form-dom-scope'
20
28
  // outright. A bare `from 'lodash-es'` import otherwise pools into a single
21
29
  // ~85KB chunk that leaks onto the public frontend bundle (form-context is
22
30
  // reachable from the layout graph).
23
- import {
24
- get as getNestedValue,
25
- hasExistingIdTargets,
26
- setWithResult as setNestedValue,
27
- } from './nested-path'
31
+ import { get as getNestedValue, hasExistingIdTargets, withValue } from './nested-path'
28
32
  import { deletePendingUploadsUnderPath } from './pending-uploads'
29
33
  import { useTrackedSlot } from './use-tracked-slot'
30
34
 
@@ -119,7 +123,9 @@ interface FormContextType {
119
123
  hasChanges: () => boolean
120
124
  resetHasChanges: () => void
121
125
  runFieldHooks: (fields: Field[]) => Promise<FormError[]>
122
- validateForm: (fields: Field[]) => FormError[]
126
+ validateForm: (fields: Field[], additionalErrors?: FormError[]) => FormError[]
127
+ trackFieldChange: (pending: Promise<void>) => () => void
128
+ waitForFieldChanges: () => Promise<void>
123
129
  errors: FormError[]
124
130
  getErrors: () => FormError[]
125
131
  clearErrors: () => void
@@ -197,7 +203,6 @@ export const FormProvider = ({
197
203
  const fieldValues = useRef<Record<string, any>>(
198
204
  JSON.parse(JSON.stringify(initialData?.fields ?? initialData))
199
205
  )
200
- const initialValues = useRef<Record<string, any>>(initialData?.fields ?? initialData)
201
206
  const errorsRef = useRef<FormError[]>([])
202
207
  const dirtyFields = useRef<Set<string>>(new Set())
203
208
  const patchesRef = useRef<DocumentPatch[]>([])
@@ -288,10 +293,8 @@ export const FormProvider = ({
288
293
 
289
294
  const updateFieldStoreInternal = useCallback(
290
295
  (name: string, value: any) => {
291
- const newFieldValues = { ...fieldValues.current }
292
-
293
- // Keep nested path values up to date for generic usage and patches.
294
- if (!setNestedValue(newFieldValues, name, value)) return false
296
+ const newFieldValues = withValue(fieldValues.current, name, value)
297
+ if (!newFieldValues) return false
295
298
 
296
299
  fieldValues.current = newFieldValues
297
300
  dirtyFields.current.add(name)
@@ -366,18 +369,9 @@ export const FormProvider = ({
366
369
  [notifyMetaListeners]
367
370
  )
368
371
 
369
- const getFieldValue = useCallback((name: string) => {
370
- const dirty = dirtyFields.current.has(name)
371
- const currentValue = getNestedValue(fieldValues.current, name)
372
-
373
- if (currentValue !== undefined) {
374
- return currentValue
375
- }
376
- if (!dirty) {
377
- return getNestedValue(initialValues.current, name)
378
- }
379
- return undefined
380
- }, [])
372
+ // The store starts with the complete initial snapshot. Falling back to that
373
+ // snapshot after an ancestor is cleared would resurrect removed descendants.
374
+ const getFieldValue = useCallback((name: string) => getNestedValue(fieldValues.current, name), [])
381
375
 
382
376
  const hasChanges = useCallback(() => {
383
377
  return dirtyFields.current.size > 0
@@ -539,83 +533,36 @@ export const FormProvider = ({
539
533
  }
540
534
  }, [])
541
535
 
542
- const validateForm = useCallback(
543
- (fields: Field[]): FormError[] => {
544
- const formErrors: FormError[] = []
545
- const data = getFieldValues()
546
-
547
- for (const field of fields) {
548
- // Condition-hidden fields are exempt from client-side validation — a
549
- // field the editor cannot currently see must not block submit. Only
550
- // top-level fields flow through this walk, and a root-level field's
551
- // sibling scope is the form data itself (see FieldCondition).
552
- if (field.condition && !field.condition(data, data)) continue
553
-
554
- const value = getFieldValue(field.name)
555
-
556
- // Required field validation
557
- if (!field.optional && (value == null || value === '')) {
558
- formErrors.push({
559
- field: field.name,
560
- message: `${field.label} is required`,
561
- })
562
- }
563
-
564
- // Type-specific validation
565
- if (value != null && value !== '') {
566
- switch (field.type) {
567
- case 'text':
568
- if (typeof value !== 'string') {
569
- formErrors.push({
570
- field: field.name,
571
- message: `${field.label} must be text`,
572
- })
573
- }
574
- break
575
- case 'checkbox':
576
- if (typeof value !== 'boolean') {
577
- formErrors.push({
578
- field: field.name,
579
- message: `${field.label} must be true or false`,
580
- })
581
- }
582
- break
583
- case 'select':
584
- if ('options' in field && field.options) {
585
- const validValues = field.options.map((opt) => opt.value)
586
- if (!validValues.includes(value)) {
587
- formErrors.push({
588
- field: field.name,
589
- message: `${field.label} must be one of: ${validValues.join(', ')}`,
590
- })
591
- }
592
- }
593
- break
594
- case 'datetime':
595
- if (value instanceof Date === false && typeof value !== 'string') {
596
- formErrors.push({
597
- field: field.name,
598
- message: `${field.label} must be a valid date`,
599
- })
600
- }
601
- break
602
- }
603
- }
604
-
605
- // Custom validate function — applies to all field types including structure fields.
606
- if (field.validate) {
607
- const error = field.validate(value, data)
608
- if (error) {
609
- formErrors.push({ field: field.name, message: error })
610
- }
611
- }
536
+ const fieldChanges = useRef(new Set<Promise<void>>())
537
+ const fieldChangeWaiters = useRef(new Set<() => void>())
538
+ const trackFieldChange = useCallback((pending: Promise<void>) => {
539
+ fieldChanges.current.add(pending)
540
+ const release = () => {
541
+ fieldChanges.current.delete(pending)
542
+ if (fieldChanges.current.size === 0) {
543
+ for (const resolve of fieldChangeWaiters.current) resolve()
544
+ fieldChangeWaiters.current.clear()
612
545
  }
546
+ }
547
+ void pending.then(release, release)
548
+ return release
549
+ }, [])
550
+ const waitForFieldChanges = useCallback(async () => {
551
+ while (fieldChanges.current.size)
552
+ await new Promise<void>((resolve) => fieldChangeWaiters.current.add(resolve))
553
+ }, [])
613
554
 
614
- errorsRef.current = formErrors
555
+ const validateForm = useCallback(
556
+ (fields: Field[], additionalErrors: FormError[] = []): FormError[] => {
557
+ const formErrors = validateDocumentFields(fields, getFieldValues(), {
558
+ respectConditions: true,
559
+ skip: (path) => pendingUploadsRef.current.has(path),
560
+ })
561
+ errorsRef.current = [...additionalErrors, ...formErrors]
615
562
  notifyErrorListeners()
616
- return formErrors
563
+ return errorsRef.current
617
564
  },
618
- [getFieldValue, getFieldValues, notifyErrorListeners]
565
+ [getFieldValues, notifyErrorListeners]
619
566
  )
620
567
 
621
568
  const clearErrors = useCallback(() => {
@@ -659,8 +606,20 @@ export const FormProvider = ({
659
606
  if (fns.length === 0) continue
660
607
 
661
608
  // Condition-hidden fields skip submit-time hooks, mirroring their
662
- // exemption from validateForm below.
663
- if (field.condition && !field.condition(data, data)) continue
609
+ // exemption from validateForm below. A predicate that throws leaves the
610
+ // field visible, matching the render hook and the validation walker: the
611
+ // hooks run, and validateForm reports the failure as a field error
612
+ // rather than the exception rejecting submission before anything can be
613
+ // shown.
614
+ if (field.condition) {
615
+ let visible = true
616
+ try {
617
+ visible = Boolean(field.condition(data, data))
618
+ } catch {
619
+ // Reported by validateForm, which evaluates the same predicate.
620
+ }
621
+ if (!visible) continue
622
+ }
664
623
 
665
624
  const path = field.name
666
625
  const value = getFieldValue(path)
@@ -705,51 +664,100 @@ export const FormProvider = ({
705
664
  [getFieldValue, setFieldValue, notifyErrorListeners]
706
665
  )
707
666
 
667
+ const resetPatches = useCallback(() => {
668
+ patchesRef.current = []
669
+ }, [])
670
+ const getErrors = useCallback(() => errorsRef.current, [])
671
+ const context = useMemo(
672
+ () => ({
673
+ documentId,
674
+ collectionPath,
675
+ setFieldValue,
676
+ setFieldStore,
677
+ getFieldValue,
678
+ getFieldValues,
679
+ getPatches,
680
+ appendPatch,
681
+ resetPatches,
682
+ hasChanges,
683
+ resetHasChanges,
684
+ runFieldHooks,
685
+ validateForm,
686
+ get errors() {
687
+ return errorsRef.current
688
+ },
689
+ getErrors,
690
+ trackFieldChange,
691
+ waitForFieldChanges,
692
+ clearErrors,
693
+ setFieldError,
694
+ clearFieldError,
695
+ isDirty,
696
+ getDirtyBreakdown,
697
+ subscribeField,
698
+ subscribeErrors,
699
+ subscribeMeta,
700
+ addPendingUpload,
701
+ removePendingUpload,
702
+ removePendingUploadsUnder,
703
+ getPendingUploads,
704
+ hasPendingUploads,
705
+ clearPendingUploads,
706
+ setFieldUploading,
707
+ getIsFieldUploading,
708
+ subscribeFieldUploading,
709
+ getSystemPath: pathSlot.get,
710
+ setSystemPath: pathSlot.set,
711
+ subscribeSystemPath: pathSlot.subscribe,
712
+ getSystemAvailableLocales: availableLocalesSlot.get,
713
+ setSystemAvailableLocales: availableLocalesSlot.set,
714
+ subscribeSystemAvailableLocales: availableLocalesSlot.subscribe,
715
+ }),
716
+ [
717
+ documentId,
718
+ collectionPath,
719
+ setFieldValue,
720
+ setFieldStore,
721
+ getFieldValue,
722
+ getFieldValues,
723
+ getPatches,
724
+ appendPatch,
725
+ resetPatches,
726
+ hasChanges,
727
+ resetHasChanges,
728
+ runFieldHooks,
729
+ validateForm,
730
+ getErrors,
731
+ trackFieldChange,
732
+ waitForFieldChanges,
733
+ clearErrors,
734
+ setFieldError,
735
+ clearFieldError,
736
+ isDirty,
737
+ getDirtyBreakdown,
738
+ subscribeField,
739
+ subscribeErrors,
740
+ subscribeMeta,
741
+ addPendingUpload,
742
+ removePendingUpload,
743
+ removePendingUploadsUnder,
744
+ getPendingUploads,
745
+ hasPendingUploads,
746
+ clearPendingUploads,
747
+ setFieldUploading,
748
+ getIsFieldUploading,
749
+ subscribeFieldUploading,
750
+ pathSlot.get,
751
+ pathSlot.set,
752
+ pathSlot.subscribe,
753
+ availableLocalesSlot.get,
754
+ availableLocalesSlot.set,
755
+ availableLocalesSlot.subscribe,
756
+ ]
757
+ )
758
+
708
759
  return (
709
- <FormContext.Provider
710
- value={{
711
- documentId,
712
- collectionPath,
713
- setFieldValue,
714
- setFieldStore,
715
- getFieldValue,
716
- getFieldValues,
717
- getPatches,
718
- appendPatch,
719
- resetPatches: () => {
720
- patchesRef.current = []
721
- },
722
- hasChanges,
723
- resetHasChanges,
724
- runFieldHooks,
725
- validateForm,
726
- errors: errorsRef.current,
727
- getErrors: () => errorsRef.current,
728
- clearErrors,
729
- setFieldError,
730
- clearFieldError,
731
- isDirty,
732
- getDirtyBreakdown,
733
- subscribeField,
734
- subscribeErrors,
735
- subscribeMeta,
736
- addPendingUpload,
737
- removePendingUpload,
738
- removePendingUploadsUnder,
739
- getPendingUploads,
740
- hasPendingUploads,
741
- clearPendingUploads,
742
- setFieldUploading,
743
- getIsFieldUploading,
744
- subscribeFieldUploading,
745
- getSystemPath: pathSlot.get,
746
- setSystemPath: pathSlot.set,
747
- subscribeSystemPath: pathSlot.subscribe,
748
- getSystemAvailableLocales: availableLocalesSlot.get,
749
- setSystemAvailableLocales: availableLocalesSlot.set,
750
- subscribeSystemAvailableLocales: availableLocalesSlot.subscribe,
751
- }}
752
- >
760
+ <FormContext.Provider value={context}>
753
761
  <FormDomScopeProvider>{children}</FormDomScopeProvider>
754
762
  </FormContext.Provider>
755
763
  )
@@ -761,117 +769,54 @@ export const FormProvider = ({
761
769
  */
762
770
  export const useSystemPath = (): string | null => {
763
771
  const { getSystemPath, subscribeSystemPath } = useFormContext()
764
- const [value, setValue] = useState<string | null>(() => getSystemPath())
765
-
766
- useEffect(() => {
767
- return subscribeSystemPath((next) => setValue(next))
768
- }, [subscribeSystemPath])
769
-
770
- return value
772
+ return useSyncExternalStore(subscribeSystemPath, getSystemPath, getSystemPath)
771
773
  }
772
774
 
773
- /**
774
- * Subscribe to the system `availableLocales` slot edited by the
775
- * available-locales widget. Returns the current advertised set (or `[]` when
776
- * nothing is advertised / not yet surfaced).
777
- */
778
775
  export const useSystemAvailableLocales = (): string[] => {
779
776
  const { getSystemAvailableLocales, subscribeSystemAvailableLocales } = useFormContext()
780
- const [value, setValue] = useState<string[]>(() => getSystemAvailableLocales())
781
-
782
- useEffect(() => {
783
- return subscribeSystemAvailableLocales((next) => setValue(next))
784
- }, [subscribeSystemAvailableLocales])
785
-
786
- return value
777
+ return useSyncExternalStore(
778
+ subscribeSystemAvailableLocales,
779
+ getSystemAvailableLocales,
780
+ getSystemAvailableLocales
781
+ )
787
782
  }
788
783
 
789
- export const useFormStore = () => {
790
- return useFormContext()
791
- }
784
+ export const useFormStore = () => useFormContext()
792
785
 
793
786
  export const useFieldError = (name: string) => {
794
787
  const { getErrors, subscribeErrors } = useFormContext()
795
- // Seed from the live errors ref via getErrors() rather than the context's
796
- // `errors` snapshot — the snapshot is bound at FormProvider's first render
797
- // and goes stale as soon as validateForm replaces errorsRef.current. Fields
798
- // mounted after validation has already run (e.g. switching to a tab whose
799
- // error badge is non-zero) would otherwise initialise to undefined and miss
800
- // the existing error until something else fires notifyErrorListeners.
801
- const [error, setError] = useState<string | undefined>(
802
- () => getErrors().find((e) => e.field === name)?.message
803
- )
804
-
805
- useEffect(() => {
806
- const unsubscribe = subscribeErrors((currentErrors) => {
807
- const fieldError = currentErrors.find((e) => e.field === name)
808
- setError(fieldError?.message)
809
- })
810
- return unsubscribe
811
- }, [subscribeErrors, name])
812
-
813
- return error
788
+ const errors = useSyncExternalStore(subscribeErrors, getErrors, getErrors)
789
+ return errors.find((error) => error.field === name)?.message
814
790
  }
815
791
 
816
792
  export const useFormMeta = () => {
817
793
  const { hasChanges, subscribeMeta } = useFormContext()
818
- const [hasChangesValue, setHasChangesValue] = useState(hasChanges())
819
-
820
- useEffect(() => {
821
- const unsubscribe = subscribeMeta(() => {
822
- setHasChangesValue(hasChanges())
823
- })
824
- return unsubscribe
825
- }, [subscribeMeta, hasChanges])
826
-
827
- return {
828
- hasChanges: hasChangesValue,
829
- }
794
+ return { hasChanges: useSyncExternalStore(subscribeMeta, hasChanges, hasChanges) }
830
795
  }
831
796
 
832
797
  export const useIsDirty = (name: string) => {
833
798
  const { isDirty, subscribeMeta } = useFormContext()
834
- const [dirty, setDirty] = useState(isDirty(name))
835
-
836
- useEffect(() => {
837
- const unsubscribe = subscribeMeta(() => {
838
- setDirty(isDirty(name))
839
- })
840
- return unsubscribe
841
- }, [subscribeMeta, isDirty, name])
842
-
843
- return dirty
799
+ const snapshot = useCallback(() => isDirty(name), [isDirty, name])
800
+ return useSyncExternalStore(subscribeMeta, snapshot, snapshot)
844
801
  }
845
802
 
846
803
  export const useFieldValue = <T = any>(name: string): T | undefined => {
847
- const { getFieldValue, subscribeField } = useFormContext()
848
- const [value, setValue] = useState<T | undefined>(() => getFieldValue(name))
849
-
850
- useEffect(() => {
851
- const unsubscribe = subscribeField(name, (nextValue) => {
852
- setValue(nextValue)
853
- })
854
- return unsubscribe
855
- }, [subscribeField, name])
856
-
857
- return value
804
+ const { getFieldValue, subscribeMeta } = useFormContext()
805
+ const snapshot = useCallback(() => getFieldValue(name), [getFieldValue, name])
806
+ // Ancestor replacement and descendant edits both affect a composite value.
807
+ // Structural sharing keeps unrelated field snapshots referentially stable.
808
+ return useSyncExternalStore(subscribeMeta, snapshot, snapshot)
858
809
  }
859
810
 
860
- /**
861
- * Subscribe to a single field's upload-in-flight state. Returns `true` while
862
- * the form orchestrator is actively transporting this field's pending upload
863
- * (between the `setFieldUploading(path, true)` and the matching `false`
864
- * emitted by the upload executor's progress callback).
865
- */
866
811
  export const useIsFieldUploading = (fieldPath: string): boolean => {
867
812
  const { getIsFieldUploading, subscribeFieldUploading } = useFormContext()
868
- const [uploading, setUploading] = useState<boolean>(() => getIsFieldUploading(fieldPath))
869
-
870
- useEffect(() => {
871
- return subscribeFieldUploading(fieldPath, (next) => {
872
- setUploading(next)
873
- })
874
- }, [subscribeFieldUploading, fieldPath])
875
-
876
- return uploading
813
+ const subscribe = useCallback(
814
+ (notify: () => void) => subscribeFieldUploading(fieldPath, notify),
815
+ [subscribeFieldUploading, fieldPath]
816
+ )
817
+ const snapshot = useCallback(
818
+ () => getIsFieldUploading(fieldPath),
819
+ [getIsFieldUploading, fieldPath]
820
+ )
821
+ return useSyncExternalStore(subscribe, snapshot, snapshot)
877
822
  }