@byline/admin 6.2.1 → 6.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/fields/array/array-field.js +29 -12
- package/dist/fields/blocks/blocks-field.js +20 -8
- package/dist/fields/checkbox/checkbox-field.js +4 -1
- package/dist/fields/code/code-field.js +2 -1
- package/dist/fields/datetime/datetime-field.js +5 -2
- package/dist/fields/field-renderer.js +2 -1
- package/dist/fields/field-services-types.d.ts +23 -0
- package/dist/fields/file/file-field.js +6 -4
- package/dist/fields/group/group-field.js +8 -3
- package/dist/fields/image/image-field.js +6 -4
- package/dist/fields/numerical/numerical-field.js +2 -1
- package/dist/fields/relation/relation-field.js +8 -2
- package/dist/fields/relation/relation-many-field.js +12 -4
- package/dist/fields/relation/relation-picker.js +38 -7
- package/dist/fields/relation/relation-picker.module.js +3 -0
- package/dist/fields/relation/relation-picker_module.css +20 -0
- package/dist/fields/select/select-field.js +4 -2
- package/dist/fields/text/text-field.js +6 -3
- package/dist/fields/text-area/text-area-field.js +6 -3
- package/dist/fields/use-field-change-handler.js +24 -6
- package/dist/fields/use-field-condition.d.ts +0 -15
- package/dist/fields/use-field-condition.js +10 -1
- package/dist/forms/form-context.d.ts +3 -12
- package/dist/forms/form-context.js +115 -130
- package/dist/forms/form-dom-scope.d.ts +34 -0
- package/dist/forms/form-dom-scope.js +20 -0
- package/dist/forms/form-layout.js +10 -3
- package/dist/forms/form-page-chrome.js +14 -24
- package/dist/forms/form-renderer.d.ts +2 -1
- package/dist/forms/form-renderer.js +13 -56
- package/dist/forms/nested-path.d.ts +2 -0
- package/dist/forms/nested-path.js +26 -1
- package/dist/forms/use-document-reload-recovery.d.ts +22 -0
- package/dist/forms/use-document-reload-recovery.js +67 -0
- package/dist/forms/use-form-submission.d.ts +1 -1
- package/dist/forms/use-form-submission.js +45 -27
- package/dist/forms/use-form-tabs.d.ts +2 -2
- package/dist/forms/use-form-tabs.js +18 -11
- package/dist/presentation/tabs.js +2 -1
- package/dist/react.d.ts +1 -1
- package/package.json +14 -14
- package/src/fields/array/array-field.tsx +24 -12
- package/src/fields/blocks/blocks-field.tsx +18 -8
- package/src/fields/checkbox/checkbox-field.tsx +4 -1
- package/src/fields/code/code-field.tsx +2 -1
- package/src/fields/datetime/datetime-field.tsx +5 -2
- package/src/fields/direct-widget-dom-ids.test.tsx +142 -0
- package/src/fields/field-dom-ids.test.tsx +293 -0
- package/src/fields/field-renderer.tsx +2 -1
- package/src/fields/field-services-types.ts +25 -0
- package/src/fields/file/file-field.tsx +6 -4
- package/src/fields/group/group-field.tsx +5 -3
- package/src/fields/image/image-field.tsx +6 -4
- package/src/fields/numerical/numerical-field.tsx +2 -1
- package/src/fields/relation/relation-field.tsx +8 -2
- package/src/fields/relation/relation-many-field.tsx +19 -4
- package/src/fields/relation/relation-picker-create.test.tsx +203 -0
- package/src/fields/relation/relation-picker-refresh.test.tsx +325 -0
- package/src/fields/relation/relation-picker.module.css +32 -0
- package/src/fields/relation/relation-picker.tsx +59 -1
- package/src/fields/select/select-field.tsx +4 -2
- package/src/fields/text/text-field.tsx +5 -3
- package/src/fields/text-area/text-area-field.tsx +5 -3
- package/src/fields/use-field-change-handler.ts +43 -9
- package/src/fields/use-field-condition.ts +17 -1
- package/src/forms/form-context.tsx +180 -234
- package/src/forms/form-correctness.test.tsx +560 -0
- package/src/forms/form-dom-scope.test.tsx +213 -0
- package/src/forms/form-dom-scope.tsx +70 -0
- package/src/forms/form-layout.tsx +10 -3
- package/src/forms/form-page-chrome.tsx +17 -34
- package/src/forms/form-renderer-submit.test.tsx +36 -0
- package/src/forms/form-renderer.tsx +26 -64
- package/src/forms/form-submit-isolation.test.tsx +247 -0
- package/src/forms/nested-path.ts +35 -0
- package/src/forms/use-document-reload-recovery.ts +92 -0
- package/src/forms/use-form-submission.ts +56 -27
- package/src/forms/use-form-tabs.ts +25 -17
- package/src/presentation/tab-dom-ids.test.tsx +153 -0
- package/src/presentation/tabs.test.tsx +49 -2
- package/src/presentation/tabs.tsx +11 -1
- package/src/react.ts +2 -0
|
@@ -27,7 +27,15 @@ import { get as getNestedValue } from '../forms/nested-path'
|
|
|
27
27
|
* sibling scope, mirroring `FieldCondition`'s contract.
|
|
28
28
|
*
|
|
29
29
|
* Fields without a condition are always visible and no subscription is made.
|
|
30
|
+
*
|
|
31
|
+
* A condition that throws leaves the field visible. It runs on every form edit
|
|
32
|
+
* against live, partly-filled data, so a predicate that assumes a shape the
|
|
33
|
+
* editor has not reached yet would otherwise take the render down. Failing
|
|
34
|
+
* closed keeps the field editable and matches the validation walker, which
|
|
35
|
+
* treats the same failure as visible-and-invalid. The misconfiguration is
|
|
36
|
+
* reported once per field rather than on every keystroke.
|
|
30
37
|
*/
|
|
38
|
+
const reportedConditionFailures = new WeakSet<Field>()
|
|
31
39
|
export const useFieldCondition = (field: Field, basePath?: string): boolean => {
|
|
32
40
|
const { getFieldValues, subscribeMeta } = useFormContext()
|
|
33
41
|
|
|
@@ -35,7 +43,15 @@ export const useFieldCondition = (field: Field, basePath?: string): boolean => {
|
|
|
35
43
|
if (!field.condition) return true
|
|
36
44
|
const data = getFieldValues()
|
|
37
45
|
const siblingData = basePath ? (getNestedValue(data, basePath) ?? {}) : data
|
|
38
|
-
|
|
46
|
+
try {
|
|
47
|
+
return Boolean(field.condition(data, siblingData))
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (!reportedConditionFailures.has(field)) {
|
|
50
|
+
reportedConditionFailures.add(field)
|
|
51
|
+
console.error(`Field condition threw for '${field.name}'; keeping the field visible`, error)
|
|
52
|
+
}
|
|
53
|
+
return true
|
|
54
|
+
}
|
|
39
55
|
}, [field, basePath, getFieldValues])
|
|
40
56
|
|
|
41
57
|
const [visible, setVisible] = useState<boolean>(evaluate)
|
|
@@ -9,21 +9,26 @@
|
|
|
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
|
|
|
26
|
+
import { FormDomScopeProvider } from './form-dom-scope'
|
|
18
27
|
// Vendored nested get/set (see ./nested-path) — removes the lodash-es dep
|
|
19
28
|
// outright. A bare `from 'lodash-es'` import otherwise pools into a single
|
|
20
29
|
// ~85KB chunk that leaks onto the public frontend bundle (form-context is
|
|
21
30
|
// reachable from the layout graph).
|
|
22
|
-
import {
|
|
23
|
-
get as getNestedValue,
|
|
24
|
-
hasExistingIdTargets,
|
|
25
|
-
setWithResult as setNestedValue,
|
|
26
|
-
} from './nested-path'
|
|
31
|
+
import { get as getNestedValue, hasExistingIdTargets, withValue } from './nested-path'
|
|
27
32
|
import { deletePendingUploadsUnderPath } from './pending-uploads'
|
|
28
33
|
import { useTrackedSlot } from './use-tracked-slot'
|
|
29
34
|
|
|
@@ -118,7 +123,9 @@ interface FormContextType {
|
|
|
118
123
|
hasChanges: () => boolean
|
|
119
124
|
resetHasChanges: () => void
|
|
120
125
|
runFieldHooks: (fields: Field[]) => Promise<FormError[]>
|
|
121
|
-
validateForm: (fields: Field[]) => FormError[]
|
|
126
|
+
validateForm: (fields: Field[], additionalErrors?: FormError[]) => FormError[]
|
|
127
|
+
trackFieldChange: (pending: Promise<void>) => () => void
|
|
128
|
+
waitForFieldChanges: () => Promise<void>
|
|
122
129
|
errors: FormError[]
|
|
123
130
|
getErrors: () => FormError[]
|
|
124
131
|
clearErrors: () => void
|
|
@@ -196,7 +203,6 @@ export const FormProvider = ({
|
|
|
196
203
|
const fieldValues = useRef<Record<string, any>>(
|
|
197
204
|
JSON.parse(JSON.stringify(initialData?.fields ?? initialData))
|
|
198
205
|
)
|
|
199
|
-
const initialValues = useRef<Record<string, any>>(initialData?.fields ?? initialData)
|
|
200
206
|
const errorsRef = useRef<FormError[]>([])
|
|
201
207
|
const dirtyFields = useRef<Set<string>>(new Set())
|
|
202
208
|
const patchesRef = useRef<DocumentPatch[]>([])
|
|
@@ -287,10 +293,8 @@ export const FormProvider = ({
|
|
|
287
293
|
|
|
288
294
|
const updateFieldStoreInternal = useCallback(
|
|
289
295
|
(name: string, value: any) => {
|
|
290
|
-
const newFieldValues =
|
|
291
|
-
|
|
292
|
-
// Keep nested path values up to date for generic usage and patches.
|
|
293
|
-
if (!setNestedValue(newFieldValues, name, value)) return false
|
|
296
|
+
const newFieldValues = withValue(fieldValues.current, name, value)
|
|
297
|
+
if (!newFieldValues) return false
|
|
294
298
|
|
|
295
299
|
fieldValues.current = newFieldValues
|
|
296
300
|
dirtyFields.current.add(name)
|
|
@@ -365,18 +369,9 @@ export const FormProvider = ({
|
|
|
365
369
|
[notifyMetaListeners]
|
|
366
370
|
)
|
|
367
371
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
if (currentValue !== undefined) {
|
|
373
|
-
return currentValue
|
|
374
|
-
}
|
|
375
|
-
if (!dirty) {
|
|
376
|
-
return getNestedValue(initialValues.current, name)
|
|
377
|
-
}
|
|
378
|
-
return undefined
|
|
379
|
-
}, [])
|
|
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), [])
|
|
380
375
|
|
|
381
376
|
const hasChanges = useCallback(() => {
|
|
382
377
|
return dirtyFields.current.size > 0
|
|
@@ -538,83 +533,36 @@ export const FormProvider = ({
|
|
|
538
533
|
}
|
|
539
534
|
}, [])
|
|
540
535
|
|
|
541
|
-
const
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
// sibling scope is the form data itself (see FieldCondition).
|
|
551
|
-
if (field.condition && !field.condition(data, data)) continue
|
|
552
|
-
|
|
553
|
-
const value = getFieldValue(field.name)
|
|
554
|
-
|
|
555
|
-
// Required field validation
|
|
556
|
-
if (!field.optional && (value == null || value === '')) {
|
|
557
|
-
formErrors.push({
|
|
558
|
-
field: field.name,
|
|
559
|
-
message: `${field.label} is required`,
|
|
560
|
-
})
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
// Type-specific validation
|
|
564
|
-
if (value != null && value !== '') {
|
|
565
|
-
switch (field.type) {
|
|
566
|
-
case 'text':
|
|
567
|
-
if (typeof value !== 'string') {
|
|
568
|
-
formErrors.push({
|
|
569
|
-
field: field.name,
|
|
570
|
-
message: `${field.label} must be text`,
|
|
571
|
-
})
|
|
572
|
-
}
|
|
573
|
-
break
|
|
574
|
-
case 'checkbox':
|
|
575
|
-
if (typeof value !== 'boolean') {
|
|
576
|
-
formErrors.push({
|
|
577
|
-
field: field.name,
|
|
578
|
-
message: `${field.label} must be true or false`,
|
|
579
|
-
})
|
|
580
|
-
}
|
|
581
|
-
break
|
|
582
|
-
case 'select':
|
|
583
|
-
if ('options' in field && field.options) {
|
|
584
|
-
const validValues = field.options.map((opt) => opt.value)
|
|
585
|
-
if (!validValues.includes(value)) {
|
|
586
|
-
formErrors.push({
|
|
587
|
-
field: field.name,
|
|
588
|
-
message: `${field.label} must be one of: ${validValues.join(', ')}`,
|
|
589
|
-
})
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
break
|
|
593
|
-
case 'datetime':
|
|
594
|
-
if (value instanceof Date === false && typeof value !== 'string') {
|
|
595
|
-
formErrors.push({
|
|
596
|
-
field: field.name,
|
|
597
|
-
message: `${field.label} must be a valid date`,
|
|
598
|
-
})
|
|
599
|
-
}
|
|
600
|
-
break
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
// Custom validate function — applies to all field types including structure fields.
|
|
605
|
-
if (field.validate) {
|
|
606
|
-
const error = field.validate(value, data)
|
|
607
|
-
if (error) {
|
|
608
|
-
formErrors.push({ field: field.name, message: error })
|
|
609
|
-
}
|
|
610
|
-
}
|
|
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()
|
|
611
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
|
+
}, [])
|
|
612
554
|
|
|
613
|
-
|
|
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]
|
|
614
562
|
notifyErrorListeners()
|
|
615
|
-
return
|
|
563
|
+
return errorsRef.current
|
|
616
564
|
},
|
|
617
|
-
[
|
|
565
|
+
[getFieldValues, notifyErrorListeners]
|
|
618
566
|
)
|
|
619
567
|
|
|
620
568
|
const clearErrors = useCallback(() => {
|
|
@@ -658,8 +606,20 @@ export const FormProvider = ({
|
|
|
658
606
|
if (fns.length === 0) continue
|
|
659
607
|
|
|
660
608
|
// Condition-hidden fields skip submit-time hooks, mirroring their
|
|
661
|
-
// exemption from validateForm below.
|
|
662
|
-
|
|
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
|
+
}
|
|
663
623
|
|
|
664
624
|
const path = field.name
|
|
665
625
|
const value = getFieldValue(path)
|
|
@@ -704,52 +664,101 @@ export const FormProvider = ({
|
|
|
704
664
|
[getFieldValue, setFieldValue, notifyErrorListeners]
|
|
705
665
|
)
|
|
706
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
|
+
|
|
707
759
|
return (
|
|
708
|
-
<FormContext.Provider
|
|
709
|
-
|
|
710
|
-
documentId,
|
|
711
|
-
collectionPath,
|
|
712
|
-
setFieldValue,
|
|
713
|
-
setFieldStore,
|
|
714
|
-
getFieldValue,
|
|
715
|
-
getFieldValues,
|
|
716
|
-
getPatches,
|
|
717
|
-
appendPatch,
|
|
718
|
-
resetPatches: () => {
|
|
719
|
-
patchesRef.current = []
|
|
720
|
-
},
|
|
721
|
-
hasChanges,
|
|
722
|
-
resetHasChanges,
|
|
723
|
-
runFieldHooks,
|
|
724
|
-
validateForm,
|
|
725
|
-
errors: errorsRef.current,
|
|
726
|
-
getErrors: () => errorsRef.current,
|
|
727
|
-
clearErrors,
|
|
728
|
-
setFieldError,
|
|
729
|
-
clearFieldError,
|
|
730
|
-
isDirty,
|
|
731
|
-
getDirtyBreakdown,
|
|
732
|
-
subscribeField,
|
|
733
|
-
subscribeErrors,
|
|
734
|
-
subscribeMeta,
|
|
735
|
-
addPendingUpload,
|
|
736
|
-
removePendingUpload,
|
|
737
|
-
removePendingUploadsUnder,
|
|
738
|
-
getPendingUploads,
|
|
739
|
-
hasPendingUploads,
|
|
740
|
-
clearPendingUploads,
|
|
741
|
-
setFieldUploading,
|
|
742
|
-
getIsFieldUploading,
|
|
743
|
-
subscribeFieldUploading,
|
|
744
|
-
getSystemPath: pathSlot.get,
|
|
745
|
-
setSystemPath: pathSlot.set,
|
|
746
|
-
subscribeSystemPath: pathSlot.subscribe,
|
|
747
|
-
getSystemAvailableLocales: availableLocalesSlot.get,
|
|
748
|
-
setSystemAvailableLocales: availableLocalesSlot.set,
|
|
749
|
-
subscribeSystemAvailableLocales: availableLocalesSlot.subscribe,
|
|
750
|
-
}}
|
|
751
|
-
>
|
|
752
|
-
{children}
|
|
760
|
+
<FormContext.Provider value={context}>
|
|
761
|
+
<FormDomScopeProvider>{children}</FormDomScopeProvider>
|
|
753
762
|
</FormContext.Provider>
|
|
754
763
|
)
|
|
755
764
|
}
|
|
@@ -760,117 +769,54 @@ export const FormProvider = ({
|
|
|
760
769
|
*/
|
|
761
770
|
export const useSystemPath = (): string | null => {
|
|
762
771
|
const { getSystemPath, subscribeSystemPath } = useFormContext()
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
useEffect(() => {
|
|
766
|
-
return subscribeSystemPath((next) => setValue(next))
|
|
767
|
-
}, [subscribeSystemPath])
|
|
768
|
-
|
|
769
|
-
return value
|
|
772
|
+
return useSyncExternalStore(subscribeSystemPath, getSystemPath, getSystemPath)
|
|
770
773
|
}
|
|
771
774
|
|
|
772
|
-
/**
|
|
773
|
-
* Subscribe to the system `availableLocales` slot edited by the
|
|
774
|
-
* available-locales widget. Returns the current advertised set (or `[]` when
|
|
775
|
-
* nothing is advertised / not yet surfaced).
|
|
776
|
-
*/
|
|
777
775
|
export const useSystemAvailableLocales = (): string[] => {
|
|
778
776
|
const { getSystemAvailableLocales, subscribeSystemAvailableLocales } = useFormContext()
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
return value
|
|
777
|
+
return useSyncExternalStore(
|
|
778
|
+
subscribeSystemAvailableLocales,
|
|
779
|
+
getSystemAvailableLocales,
|
|
780
|
+
getSystemAvailableLocales
|
|
781
|
+
)
|
|
786
782
|
}
|
|
787
783
|
|
|
788
|
-
export const useFormStore = () =>
|
|
789
|
-
return useFormContext()
|
|
790
|
-
}
|
|
784
|
+
export const useFormStore = () => useFormContext()
|
|
791
785
|
|
|
792
786
|
export const useFieldError = (name: string) => {
|
|
793
787
|
const { getErrors, subscribeErrors } = useFormContext()
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
// and goes stale as soon as validateForm replaces errorsRef.current. Fields
|
|
797
|
-
// mounted after validation has already run (e.g. switching to a tab whose
|
|
798
|
-
// error badge is non-zero) would otherwise initialise to undefined and miss
|
|
799
|
-
// the existing error until something else fires notifyErrorListeners.
|
|
800
|
-
const [error, setError] = useState<string | undefined>(
|
|
801
|
-
() => getErrors().find((e) => e.field === name)?.message
|
|
802
|
-
)
|
|
803
|
-
|
|
804
|
-
useEffect(() => {
|
|
805
|
-
const unsubscribe = subscribeErrors((currentErrors) => {
|
|
806
|
-
const fieldError = currentErrors.find((e) => e.field === name)
|
|
807
|
-
setError(fieldError?.message)
|
|
808
|
-
})
|
|
809
|
-
return unsubscribe
|
|
810
|
-
}, [subscribeErrors, name])
|
|
811
|
-
|
|
812
|
-
return error
|
|
788
|
+
const errors = useSyncExternalStore(subscribeErrors, getErrors, getErrors)
|
|
789
|
+
return errors.find((error) => error.field === name)?.message
|
|
813
790
|
}
|
|
814
791
|
|
|
815
792
|
export const useFormMeta = () => {
|
|
816
793
|
const { hasChanges, subscribeMeta } = useFormContext()
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
useEffect(() => {
|
|
820
|
-
const unsubscribe = subscribeMeta(() => {
|
|
821
|
-
setHasChangesValue(hasChanges())
|
|
822
|
-
})
|
|
823
|
-
return unsubscribe
|
|
824
|
-
}, [subscribeMeta, hasChanges])
|
|
825
|
-
|
|
826
|
-
return {
|
|
827
|
-
hasChanges: hasChangesValue,
|
|
828
|
-
}
|
|
794
|
+
return { hasChanges: useSyncExternalStore(subscribeMeta, hasChanges, hasChanges) }
|
|
829
795
|
}
|
|
830
796
|
|
|
831
797
|
export const useIsDirty = (name: string) => {
|
|
832
798
|
const { isDirty, subscribeMeta } = useFormContext()
|
|
833
|
-
const
|
|
834
|
-
|
|
835
|
-
useEffect(() => {
|
|
836
|
-
const unsubscribe = subscribeMeta(() => {
|
|
837
|
-
setDirty(isDirty(name))
|
|
838
|
-
})
|
|
839
|
-
return unsubscribe
|
|
840
|
-
}, [subscribeMeta, isDirty, name])
|
|
841
|
-
|
|
842
|
-
return dirty
|
|
799
|
+
const snapshot = useCallback(() => isDirty(name), [isDirty, name])
|
|
800
|
+
return useSyncExternalStore(subscribeMeta, snapshot, snapshot)
|
|
843
801
|
}
|
|
844
802
|
|
|
845
803
|
export const useFieldValue = <T = any>(name: string): T | undefined => {
|
|
846
|
-
const { getFieldValue,
|
|
847
|
-
const
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
setValue(nextValue)
|
|
852
|
-
})
|
|
853
|
-
return unsubscribe
|
|
854
|
-
}, [subscribeField, name])
|
|
855
|
-
|
|
856
|
-
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)
|
|
857
809
|
}
|
|
858
810
|
|
|
859
|
-
/**
|
|
860
|
-
* Subscribe to a single field's upload-in-flight state. Returns `true` while
|
|
861
|
-
* the form orchestrator is actively transporting this field's pending upload
|
|
862
|
-
* (between the `setFieldUploading(path, true)` and the matching `false`
|
|
863
|
-
* emitted by the upload executor's progress callback).
|
|
864
|
-
*/
|
|
865
811
|
export const useIsFieldUploading = (fieldPath: string): boolean => {
|
|
866
812
|
const { getIsFieldUploading, subscribeFieldUploading } = useFormContext()
|
|
867
|
-
const
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
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)
|
|
876
822
|
}
|