@byline/admin 6.3.0 → 6.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/fields/array/array-field.js +29 -12
  2. package/dist/fields/blocks/blocks-field.js +20 -8
  3. package/dist/fields/checkbox/checkbox-field.js +1 -0
  4. package/dist/fields/datetime/datetime-field.js +1 -0
  5. package/dist/fields/file/file-field.js +3 -2
  6. package/dist/fields/group/group-field.js +5 -2
  7. package/dist/fields/image/image-field.js +3 -2
  8. package/dist/fields/relation/relation-field.js +5 -0
  9. package/dist/fields/relation/relation-many-field.js +9 -2
  10. package/dist/fields/select/select-field.js +1 -0
  11. package/dist/fields/text/text-field.js +4 -2
  12. package/dist/fields/text-area/text-area-field.js +4 -2
  13. package/dist/fields/use-field-change-handler.js +24 -6
  14. package/dist/fields/use-field-condition.d.ts +0 -15
  15. package/dist/fields/use-field-condition.js +10 -1
  16. package/dist/forms/form-context.d.ts +3 -12
  17. package/dist/forms/form-context.js +111 -129
  18. package/dist/forms/form-layout.js +7 -2
  19. package/dist/forms/form-page-chrome.js +14 -24
  20. package/dist/forms/form-renderer.d.ts +2 -1
  21. package/dist/forms/form-renderer.js +12 -56
  22. package/dist/forms/nested-path.d.ts +2 -0
  23. package/dist/forms/nested-path.js +26 -1
  24. package/dist/forms/use-document-reload-recovery.d.ts +22 -0
  25. package/dist/forms/use-document-reload-recovery.js +67 -0
  26. package/dist/forms/use-form-submission.d.ts +1 -1
  27. package/dist/forms/use-form-submission.js +45 -27
  28. package/dist/forms/use-form-tabs.d.ts +2 -2
  29. package/dist/forms/use-form-tabs.js +18 -11
  30. package/package.json +14 -14
  31. package/src/fields/array/array-field.tsx +24 -12
  32. package/src/fields/blocks/blocks-field.tsx +18 -8
  33. package/src/fields/checkbox/checkbox-field.tsx +1 -0
  34. package/src/fields/datetime/datetime-field.tsx +1 -0
  35. package/src/fields/file/file-field.tsx +3 -2
  36. package/src/fields/group/group-field.tsx +2 -2
  37. package/src/fields/image/image-field.tsx +3 -2
  38. package/src/fields/relation/relation-field.tsx +5 -0
  39. package/src/fields/relation/relation-many-field.tsx +16 -2
  40. package/src/fields/select/select-field.tsx +1 -0
  41. package/src/fields/text/text-field.tsx +3 -2
  42. package/src/fields/text-area/text-area-field.tsx +3 -2
  43. package/src/fields/use-field-change-handler.ts +43 -9
  44. package/src/fields/use-field-condition.ts +17 -1
  45. package/src/forms/form-context.tsx +178 -233
  46. package/src/forms/form-correctness.test.tsx +560 -0
  47. package/src/forms/form-layout.tsx +3 -2
  48. package/src/forms/form-page-chrome.tsx +17 -34
  49. package/src/forms/form-renderer-submit.test.tsx +36 -0
  50. package/src/forms/form-renderer.tsx +15 -64
  51. package/src/forms/nested-path.ts +35 -0
  52. package/src/forms/use-document-reload-recovery.ts +92 -0
  53. package/src/forms/use-form-submission.ts +56 -27
  54. package/src/forms/use-form-tabs.ts +25 -17
@@ -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)