@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
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
Card,
|
|
21
21
|
CloseIcon,
|
|
22
22
|
DraggableSortable,
|
|
23
|
+
ErrorText,
|
|
23
24
|
IconButton,
|
|
24
25
|
Modal,
|
|
25
26
|
moveItem,
|
|
@@ -29,8 +30,9 @@ import cx from 'clsx'
|
|
|
29
30
|
|
|
30
31
|
import { defaultScalarForField } from '../../fields/field-helpers'
|
|
31
32
|
import { GroupField } from '../../fields/group/group-field'
|
|
32
|
-
import { SortableItem } from '../../fields/sortable-item'
|
|
33
|
-
import { useFormContext } from '../../forms/form-context'
|
|
33
|
+
import { SortableItem, StaticItem } from '../../fields/sortable-item'
|
|
34
|
+
import { useFieldError, useFormContext } from '../../forms/form-context'
|
|
35
|
+
import { useScopedDomId } from '../../forms/form-dom-scope'
|
|
34
36
|
import { hasExistingIdTargets } from '../../forms/nested-path'
|
|
35
37
|
import { moveRepeatingItems, repeatingItemId, repeatingItemPath } from '../../forms/repeating-items'
|
|
36
38
|
import styles from './blocks-field.module.css'
|
|
@@ -59,6 +61,8 @@ export const BlocksField = ({
|
|
|
59
61
|
const { appendPatch, getFieldValue, getFieldValues, removePendingUploadsUnder, setFieldStore } =
|
|
60
62
|
useFormContext()
|
|
61
63
|
const { t } = useTranslation('byline-admin')
|
|
64
|
+
const fieldError = useFieldError(path)
|
|
65
|
+
const htmlId = useScopedDomId(path)
|
|
62
66
|
const [items, setItems] = useState<{ id: string; data: any }[]>([])
|
|
63
67
|
const [showAddBlockModal, setShowAddBlockModal] = useState(false)
|
|
64
68
|
const [pendingInsertIndex, setPendingInsertIndex] = useState<number | null>(null)
|
|
@@ -121,6 +125,7 @@ export const BlocksField = ({
|
|
|
121
125
|
moveFromIndex: number
|
|
122
126
|
moveToIndex: number
|
|
123
127
|
}) => {
|
|
128
|
+
if (field.readOnly) return
|
|
124
129
|
const currentArray = (getFieldValue(path) ?? defaultValue) as any[]
|
|
125
130
|
if (!Array.isArray(currentArray)) return
|
|
126
131
|
|
|
@@ -138,6 +143,7 @@ export const BlocksField = ({
|
|
|
138
143
|
}
|
|
139
144
|
|
|
140
145
|
const handleAddItem = async (forcedVariantName?: string, atIndex?: number) => {
|
|
146
|
+
if (field.readOnly) return
|
|
141
147
|
setShowAddBlockModal(false)
|
|
142
148
|
setPendingInsertIndex(null)
|
|
143
149
|
|
|
@@ -184,6 +190,7 @@ export const BlocksField = ({
|
|
|
184
190
|
}
|
|
185
191
|
|
|
186
192
|
const handleRemoveItem = (index: number) => {
|
|
193
|
+
if (field.readOnly) return
|
|
187
194
|
const currentArray = (getFieldValue(path) ?? defaultValue) as any[]
|
|
188
195
|
if (!Array.isArray(currentArray) || index < 0 || index >= currentArray.length) return
|
|
189
196
|
|
|
@@ -241,6 +248,7 @@ export const BlocksField = ({
|
|
|
241
248
|
type: 'group',
|
|
242
249
|
name: subField.blockType,
|
|
243
250
|
fields: subField.fields,
|
|
251
|
+
readOnly: field.readOnly,
|
|
244
252
|
label: undefined,
|
|
245
253
|
} as GroupFieldType
|
|
246
254
|
}
|
|
@@ -256,16 +264,17 @@ export const BlocksField = ({
|
|
|
256
264
|
/>
|
|
257
265
|
)
|
|
258
266
|
|
|
267
|
+
const Item = field.readOnly ? StaticItem : SortableItem
|
|
259
268
|
return (
|
|
260
|
-
<
|
|
269
|
+
<Item
|
|
261
270
|
key={itemWrapper.id}
|
|
262
271
|
id={itemWrapper.id}
|
|
263
272
|
label={label ?? subField.blockType}
|
|
264
|
-
onAddBelow={() => handleInsertBelow(index)}
|
|
265
|
-
onRemove={() => handleRemoveItem(index)}
|
|
273
|
+
onAddBelow={field.readOnly ? undefined : () => handleInsertBelow(index)}
|
|
274
|
+
onRemove={field.readOnly ? undefined : () => handleRemoveItem(index)}
|
|
266
275
|
>
|
|
267
276
|
{body}
|
|
268
|
-
</
|
|
277
|
+
</Item>
|
|
269
278
|
)
|
|
270
279
|
}
|
|
271
280
|
|
|
@@ -286,7 +295,7 @@ export const BlocksField = ({
|
|
|
286
295
|
setPendingInsertIndex(null)
|
|
287
296
|
setShowAddBlockModal(true)
|
|
288
297
|
}}
|
|
289
|
-
disabled={!selectedBlockName}
|
|
298
|
+
disabled={field.readOnly || !selectedBlockName}
|
|
290
299
|
aria-label={t('fields.blocks.addBlockAriaLabel')}
|
|
291
300
|
variant="outlined"
|
|
292
301
|
>
|
|
@@ -298,7 +307,7 @@ export const BlocksField = ({
|
|
|
298
307
|
<button
|
|
299
308
|
type="button"
|
|
300
309
|
tabIndex={-1}
|
|
301
|
-
disabled={!selectedBlockName}
|
|
310
|
+
disabled={field.readOnly || !selectedBlockName}
|
|
302
311
|
onClick={() => {
|
|
303
312
|
setPendingInsertIndex(null)
|
|
304
313
|
setShowAddBlockModal(true)
|
|
@@ -368,6 +377,7 @@ export const BlocksField = ({
|
|
|
368
377
|
</Modal.Content>
|
|
369
378
|
</Modal.Container>
|
|
370
379
|
</Modal>
|
|
380
|
+
{fieldError && <ErrorText id={`${htmlId}-error`} text={fieldError} />}
|
|
371
381
|
</div>
|
|
372
382
|
)
|
|
373
383
|
}
|
|
@@ -110,6 +110,7 @@ export const FileField = ({
|
|
|
110
110
|
const uploadGated = field.upload?.requireSavedDocument === true && documentId == null
|
|
111
111
|
|
|
112
112
|
const handleRemove = () => {
|
|
113
|
+
if (field.readOnly) return
|
|
113
114
|
if (isPending) {
|
|
114
115
|
removePendingUpload(fieldPath)
|
|
115
116
|
}
|
|
@@ -146,7 +147,7 @@ export const FileField = ({
|
|
|
146
147
|
>
|
|
147
148
|
{t('fields.upload.requireSavedDocument')}
|
|
148
149
|
</div>
|
|
149
|
-
) : collectionPath ? (
|
|
150
|
+
) : collectionPath && !field.readOnly ? (
|
|
150
151
|
<FileUploadField
|
|
151
152
|
field={field}
|
|
152
153
|
collectionPath={collectionPath}
|
|
@@ -195,7 +196,7 @@ export const FileField = ({
|
|
|
195
196
|
intent="noeffect"
|
|
196
197
|
onClick={handleRemove}
|
|
197
198
|
size="xs"
|
|
198
|
-
disabled={isUploading}
|
|
199
|
+
disabled={isUploading || field.readOnly}
|
|
199
200
|
aria-label={t('fields.file.removeAriaLabel')}
|
|
200
201
|
>
|
|
201
202
|
<CloseIcon width="15px" height="15px" />
|
|
@@ -72,7 +72,7 @@ export const GroupField = ({
|
|
|
72
72
|
fieldAdmin,
|
|
73
73
|
}: GroupFieldProps) => {
|
|
74
74
|
const htmlId = useScopedDomId(path ?? field.name)
|
|
75
|
-
const fieldError = useFieldError(field.name)
|
|
75
|
+
const fieldError = useFieldError(path ?? field.name)
|
|
76
76
|
// Default value for a group field is a plain object: { rating: 5, comment: '...' }
|
|
77
77
|
// Normalize to a plain object if not already one.
|
|
78
78
|
const groupData = useMemo(() => {
|
|
@@ -107,7 +107,7 @@ export const GroupField = ({
|
|
|
107
107
|
return (
|
|
108
108
|
<FieldRenderer
|
|
109
109
|
key={innerField.name}
|
|
110
|
-
field={innerField}
|
|
110
|
+
field={field.readOnly ? { ...innerField, readOnly: true } : innerField}
|
|
111
111
|
defaultValue={groupData[innerField.name]}
|
|
112
112
|
basePath={path}
|
|
113
113
|
disableSorting={disableSorting}
|
|
@@ -104,6 +104,7 @@ export const ImageField = ({
|
|
|
104
104
|
|
|
105
105
|
// Handle remove, including cleanup of pending uploads
|
|
106
106
|
const handleRemove = () => {
|
|
107
|
+
if (field.readOnly) return
|
|
107
108
|
if (isPending) {
|
|
108
109
|
removePendingUpload(fieldPath)
|
|
109
110
|
}
|
|
@@ -137,7 +138,7 @@ export const ImageField = ({
|
|
|
137
138
|
>
|
|
138
139
|
{t('fields.upload.requireSavedDocument')}
|
|
139
140
|
</div>
|
|
140
|
-
) : collectionPath ? (
|
|
141
|
+
) : collectionPath && !field.readOnly ? (
|
|
141
142
|
<ImageUploadField
|
|
142
143
|
field={field}
|
|
143
144
|
collectionPath={collectionPath}
|
|
@@ -170,7 +171,7 @@ export const ImageField = ({
|
|
|
170
171
|
intent="noeffect"
|
|
171
172
|
onClick={handleRemove}
|
|
172
173
|
size="xs"
|
|
173
|
-
disabled={isUploading}
|
|
174
|
+
disabled={isUploading || field.readOnly}
|
|
174
175
|
aria-label={t('fields.image.removeAriaLabel')}
|
|
175
176
|
>
|
|
176
177
|
<CloseIcon width="15px" height="15px" />
|
|
@@ -104,6 +104,7 @@ export const RelationField = ({
|
|
|
104
104
|
targetCollectionId: string
|
|
105
105
|
record?: Record<string, any>
|
|
106
106
|
}) => {
|
|
107
|
+
if (field.readOnly) return
|
|
107
108
|
setPickerOpen(false)
|
|
108
109
|
if (selection.record) {
|
|
109
110
|
setPickedRecord({ id: selection.targetDocumentId, record: selection.record })
|
|
@@ -117,6 +118,7 @@ export const RelationField = ({
|
|
|
117
118
|
}
|
|
118
119
|
|
|
119
120
|
const handleRemove = () => {
|
|
121
|
+
if (field.readOnly) return
|
|
120
122
|
setPickedRecord(null)
|
|
121
123
|
onChange?.(null)
|
|
122
124
|
}
|
|
@@ -168,6 +170,7 @@ export const RelationField = ({
|
|
|
168
170
|
/>
|
|
169
171
|
<div className={cx('byline-field-relation-actions', styles.actions)}>
|
|
170
172
|
<IconButton
|
|
173
|
+
disabled={field.readOnly}
|
|
171
174
|
id={htmlId}
|
|
172
175
|
type="button"
|
|
173
176
|
intent="noeffect"
|
|
@@ -180,6 +183,7 @@ export const RelationField = ({
|
|
|
180
183
|
<EditIcon width="15px" height="15px" />
|
|
181
184
|
</IconButton>
|
|
182
185
|
<IconButton
|
|
186
|
+
disabled={field.readOnly}
|
|
183
187
|
type="button"
|
|
184
188
|
intent="noeffect"
|
|
185
189
|
size="xs"
|
|
@@ -194,6 +198,7 @@ export const RelationField = ({
|
|
|
194
198
|
</div>
|
|
195
199
|
) : (
|
|
196
200
|
<Button
|
|
201
|
+
disabled={field.readOnly}
|
|
197
202
|
id={htmlId}
|
|
198
203
|
size="xs"
|
|
199
204
|
variant="outlined"
|
|
@@ -61,12 +61,20 @@ interface RelationManyTileProps {
|
|
|
61
61
|
id: string
|
|
62
62
|
children: ReactNode
|
|
63
63
|
onRemove: () => void
|
|
64
|
+
readOnly?: boolean
|
|
64
65
|
removeAriaLabel: string
|
|
65
66
|
}
|
|
66
67
|
|
|
67
|
-
const RelationManyTile = ({
|
|
68
|
+
const RelationManyTile = ({
|
|
69
|
+
id,
|
|
70
|
+
children,
|
|
71
|
+
onRemove,
|
|
72
|
+
removeAriaLabel,
|
|
73
|
+
readOnly,
|
|
74
|
+
}: RelationManyTileProps) => {
|
|
68
75
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
|
69
76
|
id,
|
|
77
|
+
disabled: readOnly,
|
|
70
78
|
transition: { duration: 250, easing: 'cubic-bezier(0, 0.2, 0.2, 1)' },
|
|
71
79
|
})
|
|
72
80
|
|
|
@@ -87,6 +95,7 @@ const RelationManyTile = ({ id, children, onRemove, removeAriaLabel }: RelationM
|
|
|
87
95
|
)}
|
|
88
96
|
>
|
|
89
97
|
<button
|
|
98
|
+
disabled={readOnly}
|
|
90
99
|
type="button"
|
|
91
100
|
className={cx('byline-field-relation-many-grip', styles['many-grip'])}
|
|
92
101
|
{...attributes}
|
|
@@ -96,6 +105,7 @@ const RelationManyTile = ({ id, children, onRemove, removeAriaLabel }: RelationM
|
|
|
96
105
|
</button>
|
|
97
106
|
<div className={cx('byline-field-relation-many-body', styles['many-body'])}>{children}</div>
|
|
98
107
|
<IconButton
|
|
108
|
+
disabled={readOnly}
|
|
99
109
|
type="button"
|
|
100
110
|
size="xs"
|
|
101
111
|
intent="noeffect"
|
|
@@ -152,6 +162,7 @@ export const RelationManyField = ({ field, defaultValue, id, path }: RelationMan
|
|
|
152
162
|
}
|
|
153
163
|
|
|
154
164
|
const handleAddMany = (selections: RelationPickerSelection[]) => {
|
|
165
|
+
if (field.readOnly) return
|
|
155
166
|
setPickerOpen(false)
|
|
156
167
|
const current = currentArray()
|
|
157
168
|
// Dedup the batch against the current array — a target may appear at most
|
|
@@ -178,6 +189,7 @@ export const RelationManyField = ({ field, defaultValue, id, path }: RelationMan
|
|
|
178
189
|
}
|
|
179
190
|
|
|
180
191
|
const handleRemove = (targetDocumentId: string) => {
|
|
192
|
+
if (field.readOnly) return
|
|
181
193
|
setFieldValue(
|
|
182
194
|
fieldPath,
|
|
183
195
|
currentArray().filter((v) => v.targetDocumentId !== targetDocumentId)
|
|
@@ -191,7 +203,7 @@ export const RelationManyField = ({ field, defaultValue, id, path }: RelationMan
|
|
|
191
203
|
moveFromIndex: number
|
|
192
204
|
moveToIndex: number
|
|
193
205
|
}) => {
|
|
194
|
-
if (moveFromIndex === moveToIndex) return
|
|
206
|
+
if (field.readOnly || moveFromIndex === moveToIndex) return
|
|
195
207
|
setFieldValue(fieldPath, moveItem(currentArray(), moveFromIndex, moveToIndex))
|
|
196
208
|
}
|
|
197
209
|
|
|
@@ -235,6 +247,7 @@ export const RelationManyField = ({ field, defaultValue, id, path }: RelationMan
|
|
|
235
247
|
>
|
|
236
248
|
{items.map((value) => (
|
|
237
249
|
<RelationManyTile
|
|
250
|
+
readOnly={field.readOnly}
|
|
238
251
|
key={value.targetDocumentId}
|
|
239
252
|
id={value.targetDocumentId}
|
|
240
253
|
onRemove={() => handleRemove(value.targetDocumentId)}
|
|
@@ -254,6 +267,7 @@ export const RelationManyField = ({ field, defaultValue, id, path }: RelationMan
|
|
|
254
267
|
|
|
255
268
|
<div className={cx('byline-field-relation-many-add', styles['many-add'])}>
|
|
256
269
|
<Button
|
|
270
|
+
disabled={field.readOnly}
|
|
257
271
|
id={htmlId}
|
|
258
272
|
size="xs"
|
|
259
273
|
variant="outlined"
|
|
@@ -46,11 +46,11 @@ export const TextField = ({
|
|
|
46
46
|
|
|
47
47
|
const handleChange = useCallback(
|
|
48
48
|
(value: string) => {
|
|
49
|
-
if (onChange) {
|
|
49
|
+
if (onChange && !field.readOnly) {
|
|
50
50
|
onChange(value)
|
|
51
51
|
}
|
|
52
52
|
},
|
|
53
|
-
[onChange]
|
|
53
|
+
[onChange, field.readOnly]
|
|
54
54
|
)
|
|
55
55
|
|
|
56
56
|
// Custom component slots (from admin config)
|
|
@@ -122,6 +122,7 @@ export const TextField = ({
|
|
|
122
122
|
}
|
|
123
123
|
return (
|
|
124
124
|
<Input
|
|
125
|
+
readOnly={field.readOnly}
|
|
125
126
|
id={htmlId}
|
|
126
127
|
name={field.name}
|
|
127
128
|
label={suppressInputLabel ? undefined : field.label}
|
|
@@ -46,11 +46,11 @@ export const TextAreaField = ({
|
|
|
46
46
|
|
|
47
47
|
const handleChange = useCallback(
|
|
48
48
|
(value: string) => {
|
|
49
|
-
if (onChange) {
|
|
49
|
+
if (onChange && !field.readOnly) {
|
|
50
50
|
onChange(value)
|
|
51
51
|
}
|
|
52
52
|
},
|
|
53
|
-
[onChange]
|
|
53
|
+
[onChange, field.readOnly]
|
|
54
54
|
)
|
|
55
55
|
|
|
56
56
|
// Custom component slots (from admin config)
|
|
@@ -122,6 +122,7 @@ export const TextAreaField = ({
|
|
|
122
122
|
}
|
|
123
123
|
return (
|
|
124
124
|
<TextArea
|
|
125
|
+
readOnly={field.readOnly}
|
|
125
126
|
id={htmlId}
|
|
126
127
|
name={field.name}
|
|
127
128
|
label={suppressInputLabel ? undefined : field.label}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { useCallback } from 'react'
|
|
9
|
+
import { useCallback, useEffect, useRef } from 'react'
|
|
10
10
|
|
|
11
11
|
import type { Field, FieldBeforeChangeResult, FieldHookContext } from '@byline/core'
|
|
12
12
|
import { normalizeHooks } from '@byline/core'
|
|
@@ -28,11 +28,31 @@ import { useFormContext } from '../forms/form-context'
|
|
|
28
28
|
* to `setFieldValue` (no promises, no extra allocations).
|
|
29
29
|
*/
|
|
30
30
|
export function useFieldChangeHandler(field: Field, path: string) {
|
|
31
|
-
const {
|
|
32
|
-
|
|
31
|
+
const {
|
|
32
|
+
setFieldValue,
|
|
33
|
+
getFieldValue,
|
|
34
|
+
getFieldValues,
|
|
35
|
+
setFieldError,
|
|
36
|
+
clearFieldError,
|
|
37
|
+
trackFieldChange,
|
|
38
|
+
} = useFormContext()
|
|
39
|
+
|
|
40
|
+
const generation = useRef(0)
|
|
41
|
+
const releasePending = useRef<(() => void) | undefined>(undefined)
|
|
42
|
+
useEffect(
|
|
43
|
+
() => () => {
|
|
44
|
+
generation.current++
|
|
45
|
+
releasePending.current?.()
|
|
46
|
+
},
|
|
47
|
+
[]
|
|
48
|
+
)
|
|
33
49
|
|
|
34
50
|
return useCallback(
|
|
35
51
|
(value: any) => {
|
|
52
|
+
releasePending.current?.()
|
|
53
|
+
const invocation = ++generation.current
|
|
54
|
+
if (field.readOnly) return
|
|
55
|
+
const current = () => invocation === generation.current
|
|
36
56
|
const hooks = field.hooks
|
|
37
57
|
|
|
38
58
|
// ── fast path: no hooks defined ────────────────────────────
|
|
@@ -56,7 +76,9 @@ export function useFieldChangeHandler(field: Field, path: string) {
|
|
|
56
76
|
// Raw store write for cross-field behaviour (e.g. mutual exclusivity
|
|
57
77
|
// across array items). Deliberately does not run the target field's
|
|
58
78
|
// own hooks — see FieldHookContext.setFieldValue.
|
|
59
|
-
setFieldValue,
|
|
79
|
+
setFieldValue: (target, next) => {
|
|
80
|
+
if (current()) setFieldValue(target, next)
|
|
81
|
+
},
|
|
60
82
|
}
|
|
61
83
|
|
|
62
84
|
clearFieldError(path)
|
|
@@ -70,12 +92,13 @@ export function useFieldChangeHandler(field: Field, path: string) {
|
|
|
70
92
|
setFieldValue(path, value)
|
|
71
93
|
}
|
|
72
94
|
|
|
73
|
-
|
|
95
|
+
const pending = (async () => {
|
|
74
96
|
try {
|
|
75
97
|
// 1. beforeValidate (advisory — value is always committed)
|
|
76
98
|
let advisoryError: string | undefined
|
|
77
99
|
for (const fn of validateFns) {
|
|
78
100
|
const result = (await fn(ctx)) as FieldBeforeChangeResult | undefined
|
|
101
|
+
if (!current()) return
|
|
79
102
|
if (result?.error) {
|
|
80
103
|
advisoryError = result.error
|
|
81
104
|
}
|
|
@@ -87,6 +110,7 @@ export function useFieldChangeHandler(field: Field, path: string) {
|
|
|
87
110
|
// 2. beforeChange
|
|
88
111
|
for (const fn of changeFns) {
|
|
89
112
|
const result = (await fn(ctx)) as FieldBeforeChangeResult | undefined
|
|
113
|
+
if (!current()) return
|
|
90
114
|
if (result?.error) {
|
|
91
115
|
setFieldError(path, result.error)
|
|
92
116
|
return // block the change
|
|
@@ -97,20 +121,30 @@ export function useFieldChangeHandler(field: Field, path: string) {
|
|
|
97
121
|
}
|
|
98
122
|
|
|
99
123
|
// 3. commit the (possibly transformed) value and surface any advisory error.
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
setFieldValue(path, ctx.value)
|
|
124
|
+
if (!current()) return
|
|
125
|
+
if (changeFns.length > 0 || !Object.is(ctx.value, value)) setFieldValue(path, ctx.value)
|
|
103
126
|
if (advisoryError) {
|
|
104
127
|
setFieldError(path, advisoryError)
|
|
105
128
|
}
|
|
106
129
|
} catch (err) {
|
|
130
|
+
if (!current()) return
|
|
107
131
|
// Surface unexpected hook errors as field errors rather than crashing
|
|
108
132
|
const message = err instanceof Error ? err.message : 'Unexpected hook error'
|
|
109
133
|
setFieldError(path, message)
|
|
110
134
|
}
|
|
111
135
|
})()
|
|
136
|
+
releasePending.current = trackFieldChange(pending)
|
|
112
137
|
},
|
|
113
138
|
// field reference is stable per render cycle; path is derived from props
|
|
114
|
-
[
|
|
139
|
+
[
|
|
140
|
+
field,
|
|
141
|
+
path,
|
|
142
|
+
setFieldValue,
|
|
143
|
+
getFieldValue,
|
|
144
|
+
getFieldValues,
|
|
145
|
+
setFieldError,
|
|
146
|
+
clearFieldError,
|
|
147
|
+
trackFieldChange,
|
|
148
|
+
]
|
|
115
149
|
)
|
|
116
150
|
}
|
|
@@ -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)
|