@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.
- 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/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 +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
|
@@ -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)
|