@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.
Files changed (62) 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/dist/modules/admin-account/components/change-password.js +6 -0
  31. package/dist/modules/admin-users/components/create.js +4 -3
  32. package/dist/modules/admin-users/components/set-password.js +4 -0
  33. package/dist/modules/auth/components/sign-in-form.js +4 -3
  34. package/package.json +16 -16
  35. package/src/fields/array/array-field.tsx +24 -12
  36. package/src/fields/blocks/blocks-field.tsx +18 -8
  37. package/src/fields/checkbox/checkbox-field.tsx +1 -0
  38. package/src/fields/datetime/datetime-field.tsx +1 -0
  39. package/src/fields/file/file-field.tsx +3 -2
  40. package/src/fields/group/group-field.tsx +2 -2
  41. package/src/fields/image/image-field.tsx +3 -2
  42. package/src/fields/relation/relation-field.tsx +5 -0
  43. package/src/fields/relation/relation-many-field.tsx +16 -2
  44. package/src/fields/select/select-field.tsx +1 -0
  45. package/src/fields/text/text-field.tsx +3 -2
  46. package/src/fields/text-area/text-area-field.tsx +3 -2
  47. package/src/fields/use-field-change-handler.ts +43 -9
  48. package/src/fields/use-field-condition.ts +17 -1
  49. package/src/forms/form-context.tsx +178 -233
  50. package/src/forms/form-correctness.test.tsx +560 -0
  51. package/src/forms/form-layout.tsx +3 -2
  52. package/src/forms/form-page-chrome.tsx +17 -34
  53. package/src/forms/form-renderer-submit.test.tsx +36 -0
  54. package/src/forms/form-renderer.tsx +15 -64
  55. package/src/forms/nested-path.ts +35 -0
  56. package/src/forms/use-document-reload-recovery.ts +92 -0
  57. package/src/forms/use-form-submission.ts +56 -27
  58. package/src/forms/use-form-tabs.ts +25 -17
  59. package/src/modules/admin-account/components/change-password.tsx +6 -0
  60. package/src/modules/admin-users/components/create.tsx +4 -3
  61. package/src/modules/admin-users/components/set-password.tsx +4 -0
  62. 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
- <SortableItem
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
- </SortableItem>
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
  }
@@ -37,6 +37,7 @@ export const CheckboxField = ({
37
37
  return (
38
38
  <div className={`byline-field-checkbox ${field.name} ${styles['field-checkbox']}`}>
39
39
  <Checkbox
40
+ disabled={field.readOnly}
40
41
  id={htmlId}
41
42
  name={field.name}
42
43
  label={field.label}
@@ -39,6 +39,7 @@ export const DateTimeField = ({
39
39
  return (
40
40
  <div className={`byline-field-datetime ${field.name}`}>
41
41
  <DatePicker
42
+ disabled={field.readOnly}
42
43
  id={htmlId}
43
44
  name={field.name}
44
45
  label={field.label}
@@ -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 = ({ id, children, onRemove, removeAriaLabel }: RelationManyTileProps) => {
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"
@@ -48,6 +48,7 @@ export const SelectField = ({
48
48
  />
49
49
  )}
50
50
  <Select<string>
51
+ disabled={field.readOnly}
51
52
  size="xs"
52
53
  variant="outlined"
53
54
  id={htmlId}
@@ -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 { setFieldValue, getFieldValue, getFieldValues, setFieldError, clearFieldError } =
32
- useFormContext()
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
- void (async () => {
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
- // If there were no beforeChange hooks we already committed synchronously above;
101
- // this call is a no-op if the value hasn't been altered by a hook.
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
- [field, path, setFieldValue, getFieldValue, getFieldValues, setFieldError, clearFieldError]
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
- return Boolean(field.condition(data, siblingData))
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)