@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
@@ -1,19 +1,22 @@
1
1
  import { jsx, jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useState } from "react";
3
3
  import { useTranslation } from "@byline/i18n/react";
4
- import { DraggableSortable, IconButton, PlusIcon, moveItem } from "@byline/ui/react";
4
+ import { DraggableSortable, ErrorText, IconButton, PlusIcon, moveItem } from "@byline/ui/react";
5
5
  import clsx from "clsx";
6
6
  import { sliceFieldAdmin } from "../field-admin.js";
7
7
  import { defaultScalarForField } from "../field-helpers.js";
8
8
  import { FieldRenderer } from "../field-renderer.js";
9
9
  import { SortableItem, StaticItem } from "../sortable-item.js";
10
- import { useFormContext } from "../../forms/form-context.js";
10
+ import { useFieldError, useFormContext } from "../../forms/form-context.js";
11
+ import { useScopedDomId } from "../../forms/form-dom-scope.js";
11
12
  import { hasExistingIdTargets } from "../../forms/nested-path.js";
12
13
  import { moveRepeatingItems, repeatingItemId, repeatingItemPath } from "../../forms/repeating-items.js";
13
14
  import array_field_module from "./array-field.module.js";
14
15
  const ArrayField = ({ field, defaultValue, path, disableSorting = false, contentLocale, fieldAdmin })=>{
15
16
  const { appendPatch, getFieldValue, getFieldValues, removePendingUploadsUnder, setFieldStore } = useFormContext();
16
17
  const { t } = useTranslation('byline-admin');
18
+ const fieldError = useFieldError(path);
19
+ const htmlId = useScopedDomId(path);
17
20
  const [items, setItems] = useState([]);
18
21
  useEffect(()=>{
19
22
  const storeValue = getFieldValue(path);
@@ -28,6 +31,7 @@ const ArrayField = ({ field, defaultValue, path, disableSorting = false, content
28
31
  path
29
32
  ]);
30
33
  const handleDragEnd = ({ moveFromIndex, moveToIndex })=>{
34
+ if (field.readOnly) return;
31
35
  const currentArray = getFieldValue(path) ?? defaultValue;
32
36
  if (!Array.isArray(currentArray)) return;
33
37
  const move = moveRepeatingItems(currentArray, moveFromIndex, moveToIndex);
@@ -42,6 +46,7 @@ const ArrayField = ({ field, defaultValue, path, disableSorting = false, content
42
46
  });
43
47
  };
44
48
  const handleAddItem = async (atIndex)=>{
49
+ if (field.readOnly) return;
45
50
  const childFields = field.fields ?? [];
46
51
  if (0 === childFields.length) return;
47
52
  const newId = crypto.randomUUID();
@@ -80,6 +85,7 @@ const ArrayField = ({ field, defaultValue, path, disableSorting = false, content
80
85
  setFieldStore(path, newArrayValue);
81
86
  };
82
87
  const handleRemoveItem = (index)=>{
88
+ if (field.readOnly) return;
83
89
  const currentArray = getFieldValue(path) ?? defaultValue;
84
90
  if (!Array.isArray(currentArray) || index < 0 || index >= currentArray.length) return;
85
91
  const item = currentArray[index];
@@ -98,6 +104,7 @@ const ArrayField = ({ field, defaultValue, path, disableSorting = false, content
98
104
  setFieldStore(path, newArrayValue);
99
105
  };
100
106
  const handleInsertBelow = (index)=>{
107
+ if (field.readOnly) return;
101
108
  handleAddItem(index + 1);
102
109
  };
103
110
  const renderItem = (itemWrapper, index)=>{
@@ -119,7 +126,10 @@ const ArrayField = ({ field, defaultValue, path, disableSorting = false, content
119
126
  children: childField.label
120
127
  }),
121
128
  childField.fields.map((innerField)=>/*#__PURE__*/ jsx(FieldRenderer, {
122
- field: innerField,
129
+ field: field.readOnly || childField.readOnly ? {
130
+ ...innerField,
131
+ readOnly: true
132
+ } : innerField,
123
133
  defaultValue: groupData[innerField.name],
124
134
  basePath: `${arrayElementPath}.${childField.name}`,
125
135
  disableSorting: true,
@@ -132,7 +142,10 @@ const ArrayField = ({ field, defaultValue, path, disableSorting = false, content
132
142
  }, childField.name);
133
143
  }
134
144
  return /*#__PURE__*/ jsx(FieldRenderer, {
135
- field: childField,
145
+ field: field.readOnly ? {
146
+ ...childField,
147
+ readOnly: true
148
+ } : childField,
136
149
  defaultValue: initial,
137
150
  basePath: arrayElementPath,
138
151
  disableSorting: true,
@@ -143,10 +156,10 @@ const ArrayField = ({ field, defaultValue, path, disableSorting = false, content
143
156
  }, childField.name);
144
157
  });
145
158
  const itemLabel = `${field.label ?? field.name} ${index + 1}`;
146
- if (disableSorting) return /*#__PURE__*/ jsx(StaticItem, {
159
+ if (disableSorting || field.readOnly) return /*#__PURE__*/ jsx(StaticItem, {
147
160
  label: itemLabel,
148
- onAddBelow: ()=>handleInsertBelow(index),
149
- onRemove: ()=>handleRemoveItem(index),
161
+ onAddBelow: field.readOnly ? void 0 : ()=>handleInsertBelow(index),
162
+ onRemove: field.readOnly ? void 0 : ()=>handleRemoveItem(index),
150
163
  children: /*#__PURE__*/ jsx("div", {
151
164
  className: clsx('byline-field-array-group-fields', array_field_module["group-fields"]),
152
165
  children: innerBody
@@ -155,8 +168,8 @@ const ArrayField = ({ field, defaultValue, path, disableSorting = false, content
155
168
  return /*#__PURE__*/ jsx(SortableItem, {
156
169
  id: itemWrapper.id,
157
170
  label: itemLabel,
158
- onAddBelow: ()=>handleInsertBelow(index),
159
- onRemove: ()=>handleRemoveItem(index),
171
+ onAddBelow: field.readOnly ? void 0 : ()=>handleInsertBelow(index),
172
+ onRemove: field.readOnly ? void 0 : ()=>handleRemoveItem(index),
160
173
  children: /*#__PURE__*/ jsx("div", {
161
174
  className: clsx('byline-field-array-group-fields', array_field_module["group-fields"]),
162
175
  children: innerBody
@@ -191,11 +204,11 @@ const ArrayField = ({ field, defaultValue, path, disableSorting = false, content
191
204
  className: clsx('byline-field-array-title', array_field_module.title),
192
205
  children: field.label
193
206
  }),
194
- disableSorting ? /*#__PURE__*/ jsxs("div", {
207
+ disableSorting || field.readOnly ? /*#__PURE__*/ jsxs("div", {
195
208
  className: clsx('byline-field-array-stack', array_field_module.stack),
196
209
  children: [
197
210
  items.map((item, index)=>renderItem(item, index)),
198
- addRow
211
+ !field.readOnly && addRow
199
212
  ]
200
213
  }) : /*#__PURE__*/ jsxs(DraggableSortable, {
201
214
  ids: items.map((i)=>i.id),
@@ -203,8 +216,12 @@ const ArrayField = ({ field, defaultValue, path, disableSorting = false, content
203
216
  className: clsx('byline-field-array-stack', array_field_module.stack),
204
217
  children: [
205
218
  items.map((item, index)=>renderItem(item, index)),
206
- addRow
219
+ !field.readOnly && addRow
207
220
  ]
221
+ }),
222
+ fieldError && /*#__PURE__*/ jsx(ErrorText, {
223
+ id: `${htmlId}-error`,
224
+ text: fieldError
208
225
  })
209
226
  ]
210
227
  });
@@ -2,18 +2,21 @@ import { jsx, jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useMemo, useState } from "react";
3
3
  import { getAdminConfig } from "@byline/core";
4
4
  import { useTranslation } from "@byline/i18n/react";
5
- import { Card, CloseIcon, DraggableSortable, IconButton, Modal, PlusIcon, moveItem } from "@byline/ui/react";
5
+ import { Card, CloseIcon, DraggableSortable, ErrorText, IconButton, Modal, PlusIcon, moveItem } from "@byline/ui/react";
6
6
  import clsx from "clsx";
7
7
  import { defaultScalarForField } from "../field-helpers.js";
8
8
  import { GroupField } from "../group/group-field.js";
9
- import { SortableItem } from "../sortable-item.js";
10
- import { useFormContext } from "../../forms/form-context.js";
9
+ import { SortableItem, StaticItem } from "../sortable-item.js";
10
+ import { useFieldError, useFormContext } from "../../forms/form-context.js";
11
+ import { useScopedDomId } from "../../forms/form-dom-scope.js";
11
12
  import { hasExistingIdTargets } from "../../forms/nested-path.js";
12
13
  import { moveRepeatingItems, repeatingItemId, repeatingItemPath } from "../../forms/repeating-items.js";
13
14
  import blocks_field_module from "./blocks-field.module.js";
14
15
  const BlocksField = ({ field, defaultValue, path, contentLocale })=>{
15
16
  const { appendPatch, getFieldValue, getFieldValues, removePendingUploadsUnder, setFieldStore } = useFormContext();
16
17
  const { t } = useTranslation('byline-admin');
18
+ const fieldError = useFieldError(path);
19
+ const htmlId = useScopedDomId(path);
17
20
  const [items, setItems] = useState([]);
18
21
  const [showAddBlockModal, setShowAddBlockModal] = useState(false);
19
22
  const [pendingInsertIndex, setPendingInsertIndex] = useState(null);
@@ -45,6 +48,7 @@ const BlocksField = ({ field, defaultValue, path, contentLocale })=>{
45
48
  path
46
49
  ]);
47
50
  const handleDragEnd = ({ moveFromIndex, moveToIndex })=>{
51
+ if (field.readOnly) return;
48
52
  const currentArray = getFieldValue(path) ?? defaultValue;
49
53
  if (!Array.isArray(currentArray)) return;
50
54
  const move = moveRepeatingItems(currentArray, moveFromIndex, moveToIndex);
@@ -59,6 +63,7 @@ const BlocksField = ({ field, defaultValue, path, contentLocale })=>{
59
63
  });
60
64
  };
61
65
  const handleAddItem = async (forcedVariantName, atIndex)=>{
66
+ if (field.readOnly) return;
62
67
  setShowAddBlockModal(false);
63
68
  setPendingInsertIndex(null);
64
69
  const variant = (null != forcedVariantName ? availableBlocks.find((v)=>v.blockType === forcedVariantName) : void 0) ?? availableBlocks[0];
@@ -97,6 +102,7 @@ const BlocksField = ({ field, defaultValue, path, contentLocale })=>{
97
102
  setFieldStore(path, newArrayValue);
98
103
  };
99
104
  const handleRemoveItem = (index)=>{
105
+ if (field.readOnly) return;
100
106
  const currentArray = getFieldValue(path) ?? defaultValue;
101
107
  if (!Array.isArray(currentArray) || index < 0 || index >= currentArray.length) return;
102
108
  const item = currentArray[index];
@@ -134,6 +140,7 @@ const BlocksField = ({ field, defaultValue, path, contentLocale })=>{
134
140
  type: 'group',
135
141
  name: subField.blockType,
136
142
  fields: subField.fields,
143
+ readOnly: field.readOnly,
137
144
  label: void 0
138
145
  },
139
146
  defaultValue: fieldData,
@@ -142,11 +149,12 @@ const BlocksField = ({ field, defaultValue, path, contentLocale })=>{
142
149
  contentLocale: contentLocale,
143
150
  fieldAdmin: blockAdminByType.get(item._type)?.fields
144
151
  }, subField.blockType);
145
- return /*#__PURE__*/ jsx(SortableItem, {
152
+ const Item = field.readOnly ? StaticItem : SortableItem;
153
+ return /*#__PURE__*/ jsx(Item, {
146
154
  id: itemWrapper.id,
147
155
  label: label ?? subField.blockType,
148
- onAddBelow: ()=>handleInsertBelow(index),
149
- onRemove: ()=>handleRemoveItem(index),
156
+ onAddBelow: field.readOnly ? void 0 : ()=>handleInsertBelow(index),
157
+ onRemove: field.readOnly ? void 0 : ()=>handleRemoveItem(index),
150
158
  children: body
151
159
  }, itemWrapper.id);
152
160
  };
@@ -171,7 +179,7 @@ const BlocksField = ({ field, defaultValue, path, contentLocale })=>{
171
179
  setPendingInsertIndex(null);
172
180
  setShowAddBlockModal(true);
173
181
  },
174
- disabled: !selectedBlockName,
182
+ disabled: field.readOnly || !selectedBlockName,
175
183
  "aria-label": t('fields.blocks.addBlockAriaLabel'),
176
184
  variant: "outlined",
177
185
  children: /*#__PURE__*/ jsx(PlusIcon, {})
@@ -179,7 +187,7 @@ const BlocksField = ({ field, defaultValue, path, contentLocale })=>{
179
187
  /*#__PURE__*/ jsx("button", {
180
188
  type: "button",
181
189
  tabIndex: -1,
182
- disabled: !selectedBlockName,
190
+ disabled: field.readOnly || !selectedBlockName,
183
191
  onClick: ()=>{
184
192
  setPendingInsertIndex(null);
185
193
  setShowAddBlockModal(true);
@@ -267,6 +275,10 @@ const BlocksField = ({ field, defaultValue, path, contentLocale })=>{
267
275
  })
268
276
  ]
269
277
  })
278
+ }),
279
+ fieldError && /*#__PURE__*/ jsx(ErrorText, {
280
+ id: `${htmlId}-error`,
281
+ text: fieldError
270
282
  })
271
283
  ]
272
284
  });
@@ -12,6 +12,7 @@ const CheckboxField = ({ field, value, defaultValue, onChange, id, path })=>{
12
12
  return /*#__PURE__*/ jsx("div", {
13
13
  className: `byline-field-checkbox ${field.name} ${checkbox_field_module["field-checkbox"]}`,
14
14
  children: /*#__PURE__*/ jsx(Checkbox, {
15
+ disabled: field.readOnly,
15
16
  id: htmlId,
16
17
  name: field.name,
17
18
  label: field.label,
@@ -15,6 +15,7 @@ const DateTimeField = ({ field, value, defaultValue, onChange, id, path })=>{
15
15
  className: `byline-field-datetime ${field.name}`,
16
16
  children: [
17
17
  /*#__PURE__*/ jsx(DatePicker, {
18
+ disabled: field.readOnly,
18
19
  id: htmlId,
19
20
  name: field.name,
20
21
  label: field.label,
@@ -38,6 +38,7 @@ const FileField = ({ field, value, defaultValue, onChange: _onChange, path })=>{
38
38
  const showUploadWidget = null == incomingValue || isOldPlaceholder(incomingValue);
39
39
  const uploadGated = field.upload?.requireSavedDocument === true && null == documentId;
40
40
  const handleRemove = ()=>{
41
+ if (field.readOnly) return;
41
42
  if (isPending) removePendingUpload(fieldPath);
42
43
  handleChange(null);
43
44
  };
@@ -61,7 +62,7 @@ const FileField = ({ field, value, defaultValue, onChange: _onChange, path })=>{
61
62
  role: "note",
62
63
  "data-testid": "upload-require-saved-document",
63
64
  children: t('fields.upload.requireSavedDocument')
64
- }) : collectionPath ? /*#__PURE__*/ jsx(FileUploadField, {
65
+ }) : collectionPath && !field.readOnly ? /*#__PURE__*/ jsx(FileUploadField, {
65
66
  field: field,
66
67
  collectionPath: collectionPath,
67
68
  fieldPath: fieldPath,
@@ -100,7 +101,7 @@ const FileField = ({ field, value, defaultValue, onChange: _onChange, path })=>{
100
101
  intent: "noeffect",
101
102
  onClick: handleRemove,
102
103
  size: "xs",
103
- disabled: isUploading,
104
+ disabled: isUploading || field.readOnly,
104
105
  "aria-label": t('fields.file.removeAriaLabel'),
105
106
  children: /*#__PURE__*/ jsx(CloseIcon, {
106
107
  width: "15px",
@@ -10,7 +10,7 @@ import { useScopedDomId } from "../../forms/form-dom-scope.js";
10
10
  import group_field_module from "./group-field.module.js";
11
11
  const GroupField = ({ field, defaultValue, path, disableSorting = true, contentLocale, fieldAdmin })=>{
12
12
  const htmlId = useScopedDomId(path ?? field.name);
13
- const fieldError = useFieldError(field.name);
13
+ const fieldError = useFieldError(path ?? field.name);
14
14
  const groupData = useMemo(()=>{
15
15
  if (defaultValue && 'object' == typeof defaultValue && !Array.isArray(defaultValue)) return defaultValue;
16
16
  const placeholder = {};
@@ -46,7 +46,10 @@ const GroupField = ({ field, defaultValue, path, disableSorting = true, contentL
46
46
  /*#__PURE__*/ jsx("div", {
47
47
  className: clsx('byline-field-group-body', group_field_module.body),
48
48
  children: field.fields.map((innerField)=>/*#__PURE__*/ jsx(FieldRenderer, {
49
- field: innerField,
49
+ field: field.readOnly ? {
50
+ ...innerField,
51
+ readOnly: true
52
+ } : innerField,
50
53
  defaultValue: groupData[innerField.name],
51
54
  basePath: path,
52
55
  disableSorting: disableSorting,
@@ -30,6 +30,7 @@ const ImageField = ({ field, value, defaultValue, onChange: _onChange, path })=>
30
30
  const thumbVariant = incomingValue && !isPendingStoredFileValue(incomingValue) ? incomingValue.variants?.find((v)=>'thumbnail' === v.name) : void 0;
31
31
  const previewUrl = thumbVariant?.storageUrl ?? incomingValue?.storageUrl;
32
32
  const handleRemove = ()=>{
33
+ if (field.readOnly) return;
33
34
  if (isPending) removePendingUpload(fieldPath);
34
35
  handleChange(null);
35
36
  };
@@ -53,7 +54,7 @@ const ImageField = ({ field, value, defaultValue, onChange: _onChange, path })=>
53
54
  role: "note",
54
55
  "data-testid": "upload-require-saved-document",
55
56
  children: t('fields.upload.requireSavedDocument')
56
- }) : collectionPath ? /*#__PURE__*/ jsx(ImageUploadField, {
57
+ }) : collectionPath && !field.readOnly ? /*#__PURE__*/ jsx(ImageUploadField, {
57
58
  field: field,
58
59
  collectionPath: collectionPath,
59
60
  fieldPath: fieldPath,
@@ -79,7 +80,7 @@ const ImageField = ({ field, value, defaultValue, onChange: _onChange, path })=>
79
80
  intent: "noeffect",
80
81
  onClick: handleRemove,
81
82
  size: "xs",
82
- disabled: isUploading,
83
+ disabled: isUploading || field.readOnly,
83
84
  "aria-label": t('fields.image.removeAriaLabel'),
84
85
  children: /*#__PURE__*/ jsx(CloseIcon, {
85
86
  width: "15px",
@@ -22,6 +22,7 @@ const RelationField = ({ field, value, defaultValue, onChange, id, path })=>{
22
22
  const [pickerOpen, setPickerOpen] = useState(false);
23
23
  const [pickedRecord, setPickedRecord] = useState(null);
24
24
  const handleSelect = (selection)=>{
25
+ if (field.readOnly) return;
25
26
  setPickerOpen(false);
26
27
  selection.record ? setPickedRecord({
27
28
  id: selection.targetDocumentId,
@@ -33,6 +34,7 @@ const RelationField = ({ field, value, defaultValue, onChange, id, path })=>{
33
34
  });
34
35
  };
35
36
  const handleRemove = ()=>{
37
+ if (field.readOnly) return;
36
38
  setPickedRecord(null);
37
39
  onChange?.(null);
38
40
  };
@@ -80,6 +82,7 @@ const RelationField = ({ field, value, defaultValue, onChange, id, path })=>{
80
82
  className: clsx('byline-field-relation-actions', relation_field_module.actions),
81
83
  children: [
82
84
  /*#__PURE__*/ jsx(IconButton, {
85
+ disabled: field.readOnly,
83
86
  id: htmlId,
84
87
  type: "button",
85
88
  intent: "noeffect",
@@ -94,6 +97,7 @@ const RelationField = ({ field, value, defaultValue, onChange, id, path })=>{
94
97
  })
95
98
  }),
96
99
  /*#__PURE__*/ jsx(IconButton, {
100
+ disabled: field.readOnly,
97
101
  type: "button",
98
102
  intent: "noeffect",
99
103
  size: "xs",
@@ -110,6 +114,7 @@ const RelationField = ({ field, value, defaultValue, onChange, id, path })=>{
110
114
  })
111
115
  ]
112
116
  }) : /*#__PURE__*/ jsx(Button, {
117
+ disabled: field.readOnly,
113
118
  id: htmlId,
114
119
  size: "xs",
115
120
  variant: "outlined",
@@ -9,9 +9,10 @@ import { useScopedDomId } from "../../forms/form-dom-scope.js";
9
9
  import relation_field_module from "./relation-field.module.js";
10
10
  import { RelationPicker } from "./relation-picker.js";
11
11
  import { RelationSummary } from "./relation-summary.js";
12
- const RelationManyTile = ({ id, children, onRemove, removeAriaLabel })=>{
12
+ const RelationManyTile = ({ id, children, onRemove, removeAriaLabel, readOnly })=>{
13
13
  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
14
14
  id,
15
+ disabled: readOnly,
15
16
  transition: {
16
17
  duration: 250,
17
18
  easing: 'cubic-bezier(0, 0.2, 0.2, 1)'
@@ -31,6 +32,7 @@ const RelationManyTile = ({ id, children, onRemove, removeAriaLabel })=>{
31
32
  ]),
32
33
  children: [
33
34
  /*#__PURE__*/ jsx("button", {
35
+ disabled: readOnly,
34
36
  type: "button",
35
37
  className: clsx('byline-field-relation-many-grip', relation_field_module["many-grip"]),
36
38
  ...attributes,
@@ -42,6 +44,7 @@ const RelationManyTile = ({ id, children, onRemove, removeAriaLabel })=>{
42
44
  children: children
43
45
  }),
44
46
  /*#__PURE__*/ jsx(IconButton, {
47
+ disabled: readOnly,
45
48
  type: "button",
46
49
  size: "xs",
47
50
  intent: "noeffect",
@@ -77,6 +80,7 @@ const RelationManyField = ({ field, defaultValue, id, path })=>{
77
80
  return Array.isArray(defaultValue) ? defaultValue : [];
78
81
  };
79
82
  const handleAddMany = (selections)=>{
83
+ if (field.readOnly) return;
80
84
  setPickerOpen(false);
81
85
  const current = currentArray();
82
86
  const existing = new Set(current.map((v)=>v.targetDocumentId));
@@ -99,10 +103,11 @@ const RelationManyField = ({ field, defaultValue, id, path })=>{
99
103
  ]);
100
104
  };
101
105
  const handleRemove = (targetDocumentId)=>{
106
+ if (field.readOnly) return;
102
107
  setFieldValue(fieldPath, currentArray().filter((v)=>v.targetDocumentId !== targetDocumentId));
103
108
  };
104
109
  const handleDragEnd = ({ moveFromIndex, moveToIndex })=>{
105
- if (moveFromIndex === moveToIndex) return;
110
+ if (field.readOnly || moveFromIndex === moveToIndex) return;
106
111
  setFieldValue(fieldPath, moveItem(currentArray(), moveFromIndex, moveToIndex));
107
112
  };
108
113
  return /*#__PURE__*/ jsxs("div", {
@@ -146,6 +151,7 @@ const RelationManyField = ({ field, defaultValue, id, path })=>{
146
151
  onDragEnd: handleDragEnd,
147
152
  className: clsx('byline-field-relation-many', relation_field_module.many),
148
153
  children: items.map((value)=>/*#__PURE__*/ jsx(RelationManyTile, {
154
+ readOnly: field.readOnly,
149
155
  id: value.targetDocumentId,
150
156
  onRemove: ()=>handleRemove(value.targetDocumentId),
151
157
  removeAriaLabel: t('fields.relation.removeAriaLabel', {
@@ -163,6 +169,7 @@ const RelationManyField = ({ field, defaultValue, id, path })=>{
163
169
  /*#__PURE__*/ jsx("div", {
164
170
  className: clsx('byline-field-relation-many-add', relation_field_module["many-add"]),
165
171
  children: /*#__PURE__*/ jsxs(Button, {
172
+ disabled: field.readOnly,
166
173
  id: htmlId,
167
174
  size: "xs",
168
175
  variant: "outlined",
@@ -22,6 +22,7 @@ const SelectField = ({ field, value, defaultValue, onChange, id, path })=>{
22
22
  className: clsx('byline-field-select-label', select_field_module.label)
23
23
  }),
24
24
  /*#__PURE__*/ jsx(Select, {
25
+ disabled: field.readOnly,
25
26
  size: "xs",
26
27
  variant: "outlined",
27
28
  id: htmlId,
@@ -13,9 +13,10 @@ const TextField = ({ field, value, defaultValue, onChange, id, path, locale, com
13
13
  const incomingValue = value ?? fieldValue ?? defaultValue ?? '';
14
14
  const htmlId = useScopedDomId(fieldPath, id);
15
15
  const handleChange = useCallback((value)=>{
16
- if (onChange) onChange(value);
16
+ if (onChange && !field.readOnly) onChange(value);
17
17
  }, [
18
- onChange
18
+ onChange,
19
+ field.readOnly
19
20
  ]);
20
21
  const slots = components;
21
22
  const CustomLabel = slots?.Label;
@@ -73,6 +74,7 @@ const TextField = ({ field, value, defaultValue, onChange, id, path, locale, com
73
74
  placeholder: field.placeholder
74
75
  });
75
76
  return /*#__PURE__*/ jsx(Input, {
77
+ readOnly: field.readOnly,
76
78
  id: htmlId,
77
79
  name: field.name,
78
80
  label: suppressInputLabel ? void 0 : field.label,
@@ -13,9 +13,10 @@ const TextAreaField = ({ field, value, defaultValue, onChange, id, path, locale,
13
13
  const incomingValue = value ?? fieldValue ?? defaultValue ?? '';
14
14
  const htmlId = useScopedDomId(fieldPath, id);
15
15
  const handleChange = useCallback((value)=>{
16
- if (onChange) onChange(value);
16
+ if (onChange && !field.readOnly) onChange(value);
17
17
  }, [
18
- onChange
18
+ onChange,
19
+ field.readOnly
19
20
  ]);
20
21
  const slots = components;
21
22
  const CustomLabel = slots?.Label;
@@ -73,6 +74,7 @@ const TextAreaField = ({ field, value, defaultValue, onChange, id, path, locale,
73
74
  placeholder: field.placeholder
74
75
  });
75
76
  return /*#__PURE__*/ jsx(TextArea, {
77
+ readOnly: field.readOnly,
76
78
  id: htmlId,
77
79
  name: field.name,
78
80
  label: suppressInputLabel ? void 0 : field.label,
@@ -1,9 +1,19 @@
1
- import { useCallback } from "react";
1
+ import { useCallback, useEffect, useRef } from "react";
2
2
  import { normalizeHooks } from "@byline/core";
3
3
  import { useFormContext } from "../forms/form-context.js";
4
4
  function useFieldChangeHandler(field, path) {
5
- const { setFieldValue, getFieldValue, getFieldValues, setFieldError, clearFieldError } = useFormContext();
5
+ const { setFieldValue, getFieldValue, getFieldValues, setFieldError, clearFieldError, trackFieldChange } = useFormContext();
6
+ const generation = useRef(0);
7
+ const releasePending = useRef(void 0);
8
+ useEffect(()=>()=>{
9
+ generation.current++;
10
+ releasePending.current?.();
11
+ }, []);
6
12
  return useCallback((value)=>{
13
+ releasePending.current?.();
14
+ const invocation = ++generation.current;
15
+ if (field.readOnly) return;
16
+ const current = ()=>invocation === generation.current;
7
17
  const hooks = field.hooks;
8
18
  const validateFns = normalizeHooks(hooks?.beforeValidate);
9
19
  const changeFns = normalizeHooks(hooks?.beforeChange);
@@ -16,30 +26,37 @@ function useFieldChangeHandler(field, path) {
16
26
  path,
17
27
  field,
18
28
  operation: 'change',
19
- setFieldValue
29
+ setFieldValue: (target, next)=>{
30
+ if (current()) setFieldValue(target, next);
31
+ }
20
32
  };
21
33
  clearFieldError(path);
22
34
  if (0 === changeFns.length) setFieldValue(path, value);
23
- (async ()=>{
35
+ const pending = (async ()=>{
24
36
  try {
25
37
  let advisoryError;
26
38
  for (const fn of validateFns){
27
39
  const result = await fn(ctx);
40
+ if (!current()) return;
28
41
  if (result?.error) advisoryError = result.error;
29
42
  if (result?.value !== void 0) ctx.value = result.value;
30
43
  }
31
44
  for (const fn of changeFns){
32
45
  const result = await fn(ctx);
46
+ if (!current()) return;
33
47
  if (result?.error) return void setFieldError(path, result.error);
34
48
  if (result?.value !== void 0) ctx.value = result.value;
35
49
  }
36
- setFieldValue(path, ctx.value);
50
+ if (!current()) return;
51
+ if (changeFns.length > 0 || !Object.is(ctx.value, value)) setFieldValue(path, ctx.value);
37
52
  if (advisoryError) setFieldError(path, advisoryError);
38
53
  } catch (err) {
54
+ if (!current()) return;
39
55
  const message = err instanceof Error ? err.message : 'Unexpected hook error';
40
56
  setFieldError(path, message);
41
57
  }
42
58
  })();
59
+ releasePending.current = trackFieldChange(pending);
43
60
  }, [
44
61
  field,
45
62
  path,
@@ -47,7 +64,8 @@ function useFieldChangeHandler(field, path) {
47
64
  getFieldValue,
48
65
  getFieldValues,
49
66
  setFieldError,
50
- clearFieldError
67
+ clearFieldError,
68
+ trackFieldChange
51
69
  ]);
52
70
  }
53
71
  export { useFieldChangeHandler };
@@ -6,19 +6,4 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
  import type { Field } from '@byline/core';
9
- /**
10
- * Evaluate a field's visibility `condition` against live form data.
11
- *
12
- * Subscribes to the form's meta listeners — fired on every field commit, the
13
- * same loop `TabDefinition.condition` rides — and recomputes the predicate.
14
- * The consumer only re-renders when the boolean actually flips, so a stable
15
- * condition costs one function call per form edit, no reconciliation.
16
- *
17
- * `basePath` is the field's sibling scope: the enclosing group / array item
18
- * (e.g. `files[0].filesGroup` for `files[0].filesGroup.thumbnailPage`).
19
- * Root-level fields pass no basePath and receive the full form data as their
20
- * sibling scope, mirroring `FieldCondition`'s contract.
21
- *
22
- * Fields without a condition are always visible and no subscription is made.
23
- */
24
9
  export declare const useFieldCondition: (field: Field, basePath?: string) => boolean;
@@ -1,13 +1,22 @@
1
1
  import { useCallback, useEffect, useState } from "react";
2
2
  import { useFormContext } from "../forms/form-context.js";
3
3
  import { get } from "../forms/nested-path.js";
4
+ const reportedConditionFailures = new WeakSet();
4
5
  const useFieldCondition = (field, basePath)=>{
5
6
  const { getFieldValues, subscribeMeta } = useFormContext();
6
7
  const evaluate = useCallback(()=>{
7
8
  if (!field.condition) return true;
8
9
  const data = getFieldValues();
9
10
  const siblingData = basePath ? get(data, basePath) ?? {} : data;
10
- return Boolean(field.condition(data, siblingData));
11
+ try {
12
+ return Boolean(field.condition(data, siblingData));
13
+ } catch (error) {
14
+ if (!reportedConditionFailures.has(field)) {
15
+ reportedConditionFailures.add(field);
16
+ console.error(`Field condition threw for '${field.name}'; keeping the field visible`, error);
17
+ }
18
+ return true;
19
+ }
11
20
  }, [
12
21
  field,
13
22
  basePath,
@@ -78,7 +78,9 @@ interface FormContextType {
78
78
  hasChanges: () => boolean;
79
79
  resetHasChanges: () => void;
80
80
  runFieldHooks: (fields: Field[]) => Promise<FormError[]>;
81
- validateForm: (fields: Field[]) => FormError[];
81
+ validateForm: (fields: Field[], additionalErrors?: FormError[]) => FormError[];
82
+ trackFieldChange: (pending: Promise<void>) => () => void;
83
+ waitForFieldChanges: () => Promise<void>;
82
84
  errors: FormError[];
83
85
  getErrors: () => FormError[];
84
86
  clearErrors: () => void;
@@ -131,11 +133,6 @@ export declare const FormProvider: ({ children, initialData, documentId, collect
131
133
  * Returns the current value (or `null` when no override is set).
132
134
  */
133
135
  export declare const useSystemPath: () => string | null;
134
- /**
135
- * Subscribe to the system `availableLocales` slot edited by the
136
- * available-locales widget. Returns the current advertised set (or `[]` when
137
- * nothing is advertised / not yet surfaced).
138
- */
139
136
  export declare const useSystemAvailableLocales: () => string[];
140
137
  export declare const useFormStore: () => FormContextType;
141
138
  export declare const useFieldError: (name: string) => string | undefined;
@@ -144,11 +141,5 @@ export declare const useFormMeta: () => {
144
141
  };
145
142
  export declare const useIsDirty: (name: string) => boolean;
146
143
  export declare const useFieldValue: <T = any>(name: string) => T | undefined;
147
- /**
148
- * Subscribe to a single field's upload-in-flight state. Returns `true` while
149
- * the form orchestrator is actively transporting this field's pending upload
150
- * (between the `setFieldUploading(path, true)` and the matching `false`
151
- * emitted by the upload executor's progress callback).
152
- */
153
144
  export declare const useIsFieldUploading: (fieldPath: string) => boolean;
154
145
  export {};