@byline/admin 6.2.1 → 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 (82) 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 +4 -1
  4. package/dist/fields/code/code-field.js +2 -1
  5. package/dist/fields/datetime/datetime-field.js +5 -2
  6. package/dist/fields/field-renderer.js +2 -1
  7. package/dist/fields/field-services-types.d.ts +23 -0
  8. package/dist/fields/file/file-field.js +6 -4
  9. package/dist/fields/group/group-field.js +8 -3
  10. package/dist/fields/image/image-field.js +6 -4
  11. package/dist/fields/numerical/numerical-field.js +2 -1
  12. package/dist/fields/relation/relation-field.js +8 -2
  13. package/dist/fields/relation/relation-many-field.js +12 -4
  14. package/dist/fields/relation/relation-picker.js +38 -7
  15. package/dist/fields/relation/relation-picker.module.js +3 -0
  16. package/dist/fields/relation/relation-picker_module.css +20 -0
  17. package/dist/fields/select/select-field.js +4 -2
  18. package/dist/fields/text/text-field.js +6 -3
  19. package/dist/fields/text-area/text-area-field.js +6 -3
  20. package/dist/fields/use-field-change-handler.js +24 -6
  21. package/dist/fields/use-field-condition.d.ts +0 -15
  22. package/dist/fields/use-field-condition.js +10 -1
  23. package/dist/forms/form-context.d.ts +3 -12
  24. package/dist/forms/form-context.js +115 -130
  25. package/dist/forms/form-dom-scope.d.ts +34 -0
  26. package/dist/forms/form-dom-scope.js +20 -0
  27. package/dist/forms/form-layout.js +10 -3
  28. package/dist/forms/form-page-chrome.js +14 -24
  29. package/dist/forms/form-renderer.d.ts +2 -1
  30. package/dist/forms/form-renderer.js +13 -56
  31. package/dist/forms/nested-path.d.ts +2 -0
  32. package/dist/forms/nested-path.js +26 -1
  33. package/dist/forms/use-document-reload-recovery.d.ts +22 -0
  34. package/dist/forms/use-document-reload-recovery.js +67 -0
  35. package/dist/forms/use-form-submission.d.ts +1 -1
  36. package/dist/forms/use-form-submission.js +45 -27
  37. package/dist/forms/use-form-tabs.d.ts +2 -2
  38. package/dist/forms/use-form-tabs.js +18 -11
  39. package/dist/presentation/tabs.js +2 -1
  40. package/dist/react.d.ts +1 -1
  41. package/package.json +14 -14
  42. package/src/fields/array/array-field.tsx +24 -12
  43. package/src/fields/blocks/blocks-field.tsx +18 -8
  44. package/src/fields/checkbox/checkbox-field.tsx +4 -1
  45. package/src/fields/code/code-field.tsx +2 -1
  46. package/src/fields/datetime/datetime-field.tsx +5 -2
  47. package/src/fields/direct-widget-dom-ids.test.tsx +142 -0
  48. package/src/fields/field-dom-ids.test.tsx +293 -0
  49. package/src/fields/field-renderer.tsx +2 -1
  50. package/src/fields/field-services-types.ts +25 -0
  51. package/src/fields/file/file-field.tsx +6 -4
  52. package/src/fields/group/group-field.tsx +5 -3
  53. package/src/fields/image/image-field.tsx +6 -4
  54. package/src/fields/numerical/numerical-field.tsx +2 -1
  55. package/src/fields/relation/relation-field.tsx +8 -2
  56. package/src/fields/relation/relation-many-field.tsx +19 -4
  57. package/src/fields/relation/relation-picker-create.test.tsx +203 -0
  58. package/src/fields/relation/relation-picker-refresh.test.tsx +325 -0
  59. package/src/fields/relation/relation-picker.module.css +32 -0
  60. package/src/fields/relation/relation-picker.tsx +59 -1
  61. package/src/fields/select/select-field.tsx +4 -2
  62. package/src/fields/text/text-field.tsx +5 -3
  63. package/src/fields/text-area/text-area-field.tsx +5 -3
  64. package/src/fields/use-field-change-handler.ts +43 -9
  65. package/src/fields/use-field-condition.ts +17 -1
  66. package/src/forms/form-context.tsx +180 -234
  67. package/src/forms/form-correctness.test.tsx +560 -0
  68. package/src/forms/form-dom-scope.test.tsx +213 -0
  69. package/src/forms/form-dom-scope.tsx +70 -0
  70. package/src/forms/form-layout.tsx +10 -3
  71. package/src/forms/form-page-chrome.tsx +17 -34
  72. package/src/forms/form-renderer-submit.test.tsx +36 -0
  73. package/src/forms/form-renderer.tsx +26 -64
  74. package/src/forms/form-submit-isolation.test.tsx +247 -0
  75. package/src/forms/nested-path.ts +35 -0
  76. package/src/forms/use-document-reload-recovery.ts +92 -0
  77. package/src/forms/use-form-submission.ts +56 -27
  78. package/src/forms/use-form-tabs.ts +25 -17
  79. package/src/presentation/tab-dom-ids.test.tsx +153 -0
  80. package/src/presentation/tabs.test.tsx +49 -2
  81. package/src/presentation/tabs.tsx +11 -1
  82. package/src/react.ts +2 -0
@@ -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
  });
@@ -1,16 +1,19 @@
1
1
  import { jsx } from "react/jsx-runtime";
2
2
  import { Checkbox } from "@byline/ui/react";
3
3
  import { useFieldError, useFieldValue } from "../../forms/form-context.js";
4
+ import { useScopedDomId } from "../../forms/form-dom-scope.js";
4
5
  import checkbox_field_module from "./checkbox-field.module.js";
5
6
  const CheckboxField = ({ field, value, defaultValue, onChange, id, path })=>{
6
7
  const fieldPath = path ?? field.name;
8
+ const htmlId = useScopedDomId(fieldPath, id);
7
9
  const fieldError = useFieldError(fieldPath);
8
10
  const fieldValue = useFieldValue(fieldPath);
9
11
  const checked = value ?? fieldValue ?? defaultValue ?? false;
10
12
  return /*#__PURE__*/ jsx("div", {
11
13
  className: `byline-field-checkbox ${field.name} ${checkbox_field_module["field-checkbox"]}`,
12
14
  children: /*#__PURE__*/ jsx(Checkbox, {
13
- id: id ?? fieldPath,
15
+ disabled: field.readOnly,
16
+ id: htmlId,
14
17
  name: field.name,
15
18
  label: field.label,
16
19
  checked: checked,
@@ -3,6 +3,7 @@ import react, { Suspense, useCallback } from "react";
3
3
  import { ErrorText, HelpText, Label } from "@byline/ui/react";
4
4
  import clsx from "clsx";
5
5
  import { useFieldError, useFieldValue } from "../../forms/form-context.js";
6
+ import { useScopedDomId } from "../../forms/form-dom-scope.js";
6
7
  import { LocaleBadge } from "../locale-badge.js";
7
8
  import code_field_module from "./code-field.module.js";
8
9
  const CodeEditor = /*#__PURE__*/ react.lazy(()=>import("./code-editor.js"));
@@ -15,7 +16,7 @@ const CodeField = ({ field, value, defaultValue, onChange, id, path, locale, com
15
16
  const fieldError = useFieldError(fieldPath);
16
17
  const fieldValue = useFieldValue(fieldPath);
17
18
  const incomingValue = value ?? fieldValue ?? defaultValue ?? '';
18
- const htmlId = id ?? fieldPath;
19
+ const htmlId = useScopedDomId(fieldPath, id);
19
20
  const siblingPath = field.languageField ? siblingFieldPath(fieldPath, field.languageField) : fieldPath;
20
21
  const siblingLanguage = useFieldValue(siblingPath);
21
22
  const effectiveLanguage = (field.languageField ? siblingLanguage : void 0) || field.language;
@@ -2,9 +2,11 @@ import { jsx, jsxs } from "react/jsx-runtime";
2
2
  import { DatePicker, ErrorText } from "@byline/ui/react";
3
3
  import clsx from "clsx";
4
4
  import { useFieldError, useFieldValue, useIsDirty } from "../../forms/form-context.js";
5
+ import { useScopedDomId } from "../../forms/form-dom-scope.js";
5
6
  import datetime_field_module from "./datetime-field.module.js";
6
7
  const DateTimeField = ({ field, value, defaultValue, onChange, id, path })=>{
7
8
  const fieldPath = path ?? field.name;
9
+ const htmlId = useScopedDomId(fieldPath, id);
8
10
  const fieldError = useFieldError(fieldPath);
9
11
  const isDirty = useIsDirty(fieldPath);
10
12
  const fieldValue = useFieldValue(fieldPath);
@@ -13,7 +15,8 @@ const DateTimeField = ({ field, value, defaultValue, onChange, id, path })=>{
13
15
  className: `byline-field-datetime ${field.name}`,
14
16
  children: [
15
17
  /*#__PURE__*/ jsx(DatePicker, {
16
- id: id ?? fieldPath,
18
+ disabled: field.readOnly,
19
+ id: htmlId,
17
20
  name: field.name,
18
21
  label: field.label,
19
22
  required: !field.optional,
@@ -28,7 +31,7 @@ const DateTimeField = ({ field, value, defaultValue, onChange, id, path })=>{
28
31
  ])
29
32
  }),
30
33
  fieldError && /*#__PURE__*/ jsx(ErrorText, {
31
- id: `${field.name}-error`,
34
+ id: `${htmlId}-error`,
32
35
  text: fieldError
33
36
  })
34
37
  ]
@@ -2,6 +2,7 @@ import { jsx, jsxs } from "react/jsx-runtime";
2
2
  import { getAdminConfig } from "@byline/core";
3
3
  import clsx from "clsx";
4
4
  import { useFormContext } from "../forms/form-context.js";
5
+ import { useScopedDomId } from "../forms/form-dom-scope.js";
5
6
  import { ArrayField } from "./array/array-field.js";
6
7
  import { BlocksField } from "./blocks/blocks-field.js";
7
8
  import { CheckboxField } from "./checkbox/checkbox-field.js";
@@ -22,7 +23,7 @@ import { useFieldChangeHandler } from "./use-field-change-handler.js";
22
23
  import { useFieldCondition } from "./use-field-condition.js";
23
24
  const FieldRenderer = ({ field, defaultValue: initialDefault, basePath, disableSorting, hideLabel, contentLocale, components, editor, fieldAdmin })=>{
24
25
  const path = basePath ? `${basePath}.${field.name}` : field.name;
25
- const htmlId = path.replace(/[[\].]/g, '-');
26
+ const htmlId = useScopedDomId(path.replace(/[[\].]/g, '-'));
26
27
  const handleChange = useFieldChangeHandler(field, path);
27
28
  const { getFieldValue } = useFormContext();
28
29
  const visible = useFieldCondition(field, basePath);
@@ -101,6 +101,10 @@ export type GetTreeParentFn = (input: {
101
101
  placed: boolean;
102
102
  parentDocumentId: string | null;
103
103
  }>;
104
+ /** See `BylineFieldServices.canCreateInCollection`. */
105
+ export type CanCreateInCollectionFn = (collectionPath: string) => boolean;
106
+ /** See `BylineFieldServices.getCreateDocumentUrl`. */
107
+ export type GetCreateDocumentUrlFn = (collectionPath: string) => string;
104
108
  export interface BylineFieldServices {
105
109
  getCollectionDocuments: GetCollectionDocumentsFn;
106
110
  uploadField: UploadFieldFn;
@@ -113,4 +117,23 @@ export interface BylineFieldServices {
113
117
  removeFromTree?: RemoveFromTreeFn;
114
118
  getTreeAncestors?: GetTreeAncestorsFn;
115
119
  getTreeParent?: GetTreeParentFn;
120
+ /**
121
+ * Whether the viewer may create documents in a collection, used to decide
122
+ * whether to offer a create affordance in the relation picker.
123
+ *
124
+ * Cosmetic only: the create view enforces the ability server-side regardless
125
+ * of what this returns. Optional, like the tree operations above — a host that
126
+ * wires neither this nor `getCreateDocumentUrl` simply gets no affordance, and
127
+ * the widget guards on their presence.
128
+ */
129
+ canCreateInCollection?: CanCreateInCollectionFn;
130
+ /**
131
+ * Root-relative URL of a collection's create view, built from the host's
132
+ * configured admin path.
133
+ *
134
+ * Root-relative rather than absolute because no origin is available during the
135
+ * server render, and the admin base path is host configuration —
136
+ * `@byline/admin` must not assume `/admin`.
137
+ */
138
+ getCreateDocumentUrl?: GetCreateDocumentUrlFn;
116
139
  }
@@ -4,6 +4,7 @@ import { useTranslation } from "@byline/i18n/react";
4
4
  import { CloseIcon, DocumentIcon, DownloadIcon, ErrorText, HelpText, IconButton, Label, LoaderRing, VideoIcon } from "@byline/ui/react";
5
5
  import clsx from "clsx";
6
6
  import { useFieldError, useFieldValue, useFormContext, useIsDirty, useIsFieldUploading } from "../../forms/form-context.js";
7
+ import { useScopedDomId } from "../../forms/form-dom-scope.js";
7
8
  import { useFieldChangeHandler } from "../use-field-change-handler.js";
8
9
  import file_field_module from "./file-field.module.js";
9
10
  import { FileUploadField } from "./file-upload-field.js";
@@ -37,12 +38,13 @@ const FileField = ({ field, value, defaultValue, onChange: _onChange, path })=>{
37
38
  const showUploadWidget = null == incomingValue || isOldPlaceholder(incomingValue);
38
39
  const uploadGated = field.upload?.requireSavedDocument === true && null == documentId;
39
40
  const handleRemove = ()=>{
41
+ if (field.readOnly) return;
40
42
  if (isPending) removePendingUpload(fieldPath);
41
43
  handleChange(null);
42
44
  };
43
45
  const isVideo = incomingValue?.mimeType?.startsWith('video/') === true;
44
46
  const FileGlyph = isVideo ? VideoIcon : DocumentIcon;
45
- const htmlId = fieldPath;
47
+ const htmlId = useScopedDomId(fieldPath);
46
48
  return /*#__PURE__*/ jsxs("div", {
47
49
  className: `byline-field-file ${field.name}`,
48
50
  children: [
@@ -60,7 +62,7 @@ const FileField = ({ field, value, defaultValue, onChange: _onChange, path })=>{
60
62
  role: "note",
61
63
  "data-testid": "upload-require-saved-document",
62
64
  children: t('fields.upload.requireSavedDocument')
63
- }) : collectionPath ? /*#__PURE__*/ jsx(FileUploadField, {
65
+ }) : collectionPath && !field.readOnly ? /*#__PURE__*/ jsx(FileUploadField, {
64
66
  field: field,
65
67
  collectionPath: collectionPath,
66
68
  fieldPath: fieldPath,
@@ -99,7 +101,7 @@ const FileField = ({ field, value, defaultValue, onChange: _onChange, path })=>{
99
101
  intent: "noeffect",
100
102
  onClick: handleRemove,
101
103
  size: "xs",
102
- disabled: isUploading,
104
+ disabled: isUploading || field.readOnly,
103
105
  "aria-label": t('fields.file.removeAriaLabel'),
104
106
  children: /*#__PURE__*/ jsx(CloseIcon, {
105
107
  width: "15px",
@@ -222,7 +224,7 @@ const FileField = ({ field, value, defaultValue, onChange: _onChange, path })=>{
222
224
  text: field.helpText
223
225
  }),
224
226
  fieldError && /*#__PURE__*/ jsx(ErrorText, {
225
- id: `${field.name}-error`,
227
+ id: `${htmlId}-error`,
226
228
  text: fieldError
227
229
  })
228
230
  ]
@@ -6,9 +6,11 @@ import { sliceFieldAdmin } from "../field-admin.js";
6
6
  import { placeholderForField } from "../field-helpers.js";
7
7
  import { FieldRenderer } from "../field-renderer.js";
8
8
  import { useFieldError } from "../../forms/form-context.js";
9
+ import { useScopedDomId } from "../../forms/form-dom-scope.js";
9
10
  import group_field_module from "./group-field.module.js";
10
11
  const GroupField = ({ field, defaultValue, path, disableSorting = true, contentLocale, fieldAdmin })=>{
11
- const fieldError = useFieldError(field.name);
12
+ const htmlId = useScopedDomId(path ?? field.name);
13
+ const fieldError = useFieldError(path ?? field.name);
12
14
  const groupData = useMemo(()=>{
13
15
  if (defaultValue && 'object' == typeof defaultValue && !Array.isArray(defaultValue)) return defaultValue;
14
16
  const placeholder = {};
@@ -44,7 +46,10 @@ const GroupField = ({ field, defaultValue, path, disableSorting = true, contentL
44
46
  /*#__PURE__*/ jsx("div", {
45
47
  className: clsx('byline-field-group-body', group_field_module.body),
46
48
  children: field.fields.map((innerField)=>/*#__PURE__*/ jsx(FieldRenderer, {
47
- field: innerField,
49
+ field: field.readOnly ? {
50
+ ...innerField,
51
+ readOnly: true
52
+ } : innerField,
48
53
  defaultValue: groupData[innerField.name],
49
54
  basePath: path,
50
55
  disableSorting: disableSorting,
@@ -55,7 +60,7 @@ const GroupField = ({ field, defaultValue, path, disableSorting = true, contentL
55
60
  }, innerField.name))
56
61
  }),
57
62
  fieldError && /*#__PURE__*/ jsx(ErrorText, {
58
- id: `${field.name}-error`,
63
+ id: `${htmlId}-error`,
59
64
  text: fieldError
60
65
  })
61
66
  ]
@@ -5,6 +5,7 @@ import { useTranslation } from "@byline/i18n/react";
5
5
  import { CloseIcon, ErrorText, HelpText, IconButton, ImageLightbox, Label, LoaderRing } from "@byline/ui/react";
6
6
  import clsx from "clsx";
7
7
  import { useFieldError, useFieldValue, useFormContext, useIsDirty, useIsFieldUploading } from "../../forms/form-context.js";
8
+ import { useScopedDomId } from "../../forms/form-dom-scope.js";
8
9
  import { useFieldChangeHandler } from "../use-field-change-handler.js";
9
10
  import image_field_module from "./image-field.module.js";
10
11
  import { ImageUploadField } from "./image-upload-field.js";
@@ -29,12 +30,13 @@ const ImageField = ({ field, value, defaultValue, onChange: _onChange, path })=>
29
30
  const thumbVariant = incomingValue && !isPendingStoredFileValue(incomingValue) ? incomingValue.variants?.find((v)=>'thumbnail' === v.name) : void 0;
30
31
  const previewUrl = thumbVariant?.storageUrl ?? incomingValue?.storageUrl;
31
32
  const handleRemove = ()=>{
33
+ if (field.readOnly) return;
32
34
  if (isPending) removePendingUpload(fieldPath);
33
35
  handleChange(null);
34
36
  };
35
37
  const [lightboxOpen, setLightboxOpen] = useState(false);
36
38
  const canOpenLightbox = !isPending && !!incomingValue?.storageUrl;
37
- const htmlId = fieldPath;
39
+ const htmlId = useScopedDomId(fieldPath);
38
40
  return /*#__PURE__*/ jsxs("div", {
39
41
  className: `byline-field-image ${field.name}`,
40
42
  children: [
@@ -52,7 +54,7 @@ const ImageField = ({ field, value, defaultValue, onChange: _onChange, path })=>
52
54
  role: "note",
53
55
  "data-testid": "upload-require-saved-document",
54
56
  children: t('fields.upload.requireSavedDocument')
55
- }) : collectionPath ? /*#__PURE__*/ jsx(ImageUploadField, {
57
+ }) : collectionPath && !field.readOnly ? /*#__PURE__*/ jsx(ImageUploadField, {
56
58
  field: field,
57
59
  collectionPath: collectionPath,
58
60
  fieldPath: fieldPath,
@@ -78,7 +80,7 @@ const ImageField = ({ field, value, defaultValue, onChange: _onChange, path })=>
78
80
  intent: "noeffect",
79
81
  onClick: handleRemove,
80
82
  size: "xs",
81
- disabled: isUploading,
83
+ disabled: isUploading || field.readOnly,
82
84
  "aria-label": t('fields.image.removeAriaLabel'),
83
85
  children: /*#__PURE__*/ jsx(CloseIcon, {
84
86
  width: "15px",
@@ -224,7 +226,7 @@ const ImageField = ({ field, value, defaultValue, onChange: _onChange, path })=>
224
226
  text: field.helpText
225
227
  }),
226
228
  fieldError && /*#__PURE__*/ jsx(ErrorText, {
227
- id: `${field.name}-error`,
229
+ id: `${htmlId}-error`,
228
230
  text: fieldError
229
231
  }),
230
232
  canOpenLightbox && incomingValue?.storageUrl && /*#__PURE__*/ jsx(ImageLightbox, {
@@ -3,13 +3,14 @@ import { useEffect, useState } from "react";
3
3
  import { normalizeNumericValue } from "@byline/core";
4
4
  import { Input } from "@byline/ui/react";
5
5
  import { useFieldError, useFieldValue } from "../../forms/form-context.js";
6
+ import { useScopedDomId } from "../../forms/form-dom-scope.js";
6
7
  const COMPLETE_NUMERIC_RE = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
7
8
  const NumericalField = ({ field, value, defaultValue, onChange, id, path, components })=>{
8
9
  const fieldPath = path ?? field.name;
9
10
  const fieldError = useFieldError(fieldPath);
10
11
  const fieldValue = useFieldValue(fieldPath);
11
12
  const incomingValue = void 0 !== value ? value : void 0 !== fieldValue ? fieldValue : defaultValue ?? null;
12
- const htmlId = id ?? fieldPath;
13
+ const htmlId = useScopedDomId(fieldPath, id);
13
14
  const canonicalDisplay = null == incomingValue ? '' : String(incomingValue);
14
15
  const [displayValue, setDisplayValue] = useState(canonicalDisplay);
15
16
  const [isEditing, setIsEditing] = useState(false);
@@ -5,12 +5,13 @@ import { useTranslation } from "@byline/i18n/react";
5
5
  import { Button, CloseIcon, EditIcon, ErrorText, HelpText, IconButton, Label } from "@byline/ui/react";
6
6
  import clsx from "clsx";
7
7
  import { useFieldError, useFieldValue } from "../../forms/form-context.js";
8
+ import { useScopedDomId } from "../../forms/form-dom-scope.js";
8
9
  import relation_field_module from "./relation-field.module.js";
9
10
  import { RelationPicker } from "./relation-picker.js";
10
11
  import { RelationSummary } from "./relation-summary.js";
11
12
  const RelationField = ({ field, value, defaultValue, onChange, id, path })=>{
12
13
  const fieldPath = path ?? field.name;
13
- const htmlId = id ?? fieldPath;
14
+ const htmlId = useScopedDomId(fieldPath, id);
14
15
  const fieldError = useFieldError(fieldPath);
15
16
  const fieldValue = useFieldValue(fieldPath);
16
17
  const incomingValue = void 0 !== fieldValue ? fieldValue ?? null : value ?? defaultValue ?? null;
@@ -21,6 +22,7 @@ const RelationField = ({ field, value, defaultValue, onChange, id, path })=>{
21
22
  const [pickerOpen, setPickerOpen] = useState(false);
22
23
  const [pickedRecord, setPickedRecord] = useState(null);
23
24
  const handleSelect = (selection)=>{
25
+ if (field.readOnly) return;
24
26
  setPickerOpen(false);
25
27
  selection.record ? setPickedRecord({
26
28
  id: selection.targetDocumentId,
@@ -32,6 +34,7 @@ const RelationField = ({ field, value, defaultValue, onChange, id, path })=>{
32
34
  });
33
35
  };
34
36
  const handleRemove = ()=>{
37
+ if (field.readOnly) return;
35
38
  setPickedRecord(null);
36
39
  onChange?.(null);
37
40
  };
@@ -79,6 +82,7 @@ const RelationField = ({ field, value, defaultValue, onChange, id, path })=>{
79
82
  className: clsx('byline-field-relation-actions', relation_field_module.actions),
80
83
  children: [
81
84
  /*#__PURE__*/ jsx(IconButton, {
85
+ disabled: field.readOnly,
82
86
  id: htmlId,
83
87
  type: "button",
84
88
  intent: "noeffect",
@@ -93,6 +97,7 @@ const RelationField = ({ field, value, defaultValue, onChange, id, path })=>{
93
97
  })
94
98
  }),
95
99
  /*#__PURE__*/ jsx(IconButton, {
100
+ disabled: field.readOnly,
96
101
  type: "button",
97
102
  intent: "noeffect",
98
103
  size: "xs",
@@ -109,6 +114,7 @@ const RelationField = ({ field, value, defaultValue, onChange, id, path })=>{
109
114
  })
110
115
  ]
111
116
  }) : /*#__PURE__*/ jsx(Button, {
117
+ disabled: field.readOnly,
112
118
  id: htmlId,
113
119
  size: "xs",
114
120
  variant: "outlined",
@@ -124,7 +130,7 @@ const RelationField = ({ field, value, defaultValue, onChange, id, path })=>{
124
130
  text: field.helpText
125
131
  }),
126
132
  fieldError && /*#__PURE__*/ jsx(ErrorText, {
127
- id: `${field.name}-error`,
133
+ id: `${htmlId}-error`,
128
134
  text: fieldError
129
135
  }),
130
136
  !isUnknown && /*#__PURE__*/ jsx(RelationPicker, {