@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.
- 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 +1 -0
- package/dist/fields/datetime/datetime-field.js +1 -0
- package/dist/fields/file/file-field.js +3 -2
- package/dist/fields/group/group-field.js +5 -2
- package/dist/fields/image/image-field.js +3 -2
- package/dist/fields/relation/relation-field.js +5 -0
- package/dist/fields/relation/relation-many-field.js +9 -2
- package/dist/fields/select/select-field.js +1 -0
- package/dist/fields/text/text-field.js +4 -2
- package/dist/fields/text-area/text-area-field.js +4 -2
- 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 +111 -129
- package/dist/forms/form-layout.js +7 -2
- package/dist/forms/form-page-chrome.js +14 -24
- package/dist/forms/form-renderer.d.ts +2 -1
- package/dist/forms/form-renderer.js +12 -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/modules/admin-account/components/change-password.js +6 -0
- package/dist/modules/admin-users/components/create.js +4 -3
- package/dist/modules/admin-users/components/set-password.js +4 -0
- package/dist/modules/auth/components/sign-in-form.js +4 -3
- package/package.json +16 -16
- 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 +1 -0
- package/src/fields/datetime/datetime-field.tsx +1 -0
- package/src/fields/file/file-field.tsx +3 -2
- package/src/fields/group/group-field.tsx +2 -2
- package/src/fields/image/image-field.tsx +3 -2
- package/src/fields/relation/relation-field.tsx +5 -0
- package/src/fields/relation/relation-many-field.tsx +16 -2
- package/src/fields/select/select-field.tsx +1 -0
- package/src/fields/text/text-field.tsx +3 -2
- package/src/fields/text-area/text-area-field.tsx +3 -2
- 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 +178 -233
- package/src/forms/form-correctness.test.tsx +560 -0
- package/src/forms/form-layout.tsx +3 -2
- 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 +15 -64
- 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/modules/admin-account/components/change-password.tsx +6 -0
- package/src/modules/admin-users/components/create.tsx +4 -3
- package/src/modules/admin-users/components/set-password.tsx +4 -0
- package/src/modules/auth/components/sign-in-form.tsx +4 -3
|
@@ -9,10 +9,18 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type React from 'react'
|
|
12
|
-
import {
|
|
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 =
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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
|
-
|
|
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
|
|
563
|
+
return errorsRef.current
|
|
617
564
|
},
|
|
618
|
-
[
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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
|
-
|
|
796
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
848
|
-
const
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
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
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
return
|
|
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
|
}
|