@byline/admin 4.3.0 → 4.4.1

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 (47) hide show
  1. package/dist/fields/array/array-field.d.ts +1 -8
  2. package/dist/fields/array/array-field.js +21 -27
  3. package/dist/fields/blocks/blocks-field.js +19 -17
  4. package/dist/fields/field-renderer.d.ts +1 -3
  5. package/dist/fields/field-renderer.js +3 -7
  6. package/dist/fields/file/file-field.d.ts +1 -3
  7. package/dist/fields/file/file-field.js +2 -2
  8. package/dist/fields/file/file-upload-field.js +2 -2
  9. package/dist/fields/group/group-field.d.ts +1 -7
  10. package/dist/fields/group/group-field.js +1 -2
  11. package/dist/fields/image/image-field.d.ts +1 -3
  12. package/dist/fields/image/image-field.js +2 -2
  13. package/dist/fields/image/image-upload-field.js +12 -3
  14. package/dist/forms/__probe-nested.test.node.d.ts +1 -0
  15. package/dist/forms/__probe-two.test.node.d.ts +1 -0
  16. package/dist/forms/form-context.d.ts +22 -2
  17. package/dist/forms/form-context.js +19 -4
  18. package/dist/forms/form-renderer.js +3 -1
  19. package/dist/forms/nested-path.d.ts +5 -1
  20. package/dist/forms/nested-path.js +84 -15
  21. package/dist/forms/pending-uploads.d.ts +6 -0
  22. package/dist/forms/pending-uploads.js +11 -0
  23. package/dist/forms/pending-uploads.test.node.d.ts +1 -0
  24. package/dist/forms/repeating-items.d.ts +16 -0
  25. package/dist/forms/repeating-items.js +28 -0
  26. package/dist/forms/repeating-items.test.node.d.ts +1 -0
  27. package/dist/forms/upload-executor.js +50 -29
  28. package/package.json +5 -5
  29. package/src/fields/array/array-field.tsx +28 -48
  30. package/src/fields/blocks/blocks-field.tsx +24 -27
  31. package/src/fields/code/code-field.tsx +1 -1
  32. package/src/fields/field-renderer.tsx +0 -7
  33. package/src/fields/file/file-field.tsx +4 -4
  34. package/src/fields/file/file-upload-field.tsx +9 -5
  35. package/src/fields/group/group-field.tsx +0 -8
  36. package/src/fields/image/image-field.tsx +4 -4
  37. package/src/fields/image/image-upload-field.tsx +24 -6
  38. package/src/forms/form-context.tsx +52 -4
  39. package/src/forms/form-renderer.tsx +3 -1
  40. package/src/forms/nested-path.test.node.ts +50 -1
  41. package/src/forms/nested-path.ts +106 -18
  42. package/src/forms/pending-uploads.test.node.ts +23 -0
  43. package/src/forms/pending-uploads.ts +22 -0
  44. package/src/forms/repeating-items.test.node.ts +36 -0
  45. package/src/forms/repeating-items.ts +48 -0
  46. package/src/forms/upload-executor.test.node.ts +248 -0
  47. package/src/forms/upload-executor.ts +94 -30
@@ -6,18 +6,11 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
  import type { ArrayField as ArrayFieldType, FieldAdminConfig } from '@byline/core';
9
- export declare const ArrayField: ({ field, defaultValue, path, disableSorting, collectionPath, contentLocale, fieldAdmin, }: {
9
+ export declare const ArrayField: ({ field, defaultValue, path, disableSorting, contentLocale, fieldAdmin, }: {
10
10
  field: ArrayFieldType;
11
11
  defaultValue: any;
12
12
  path: string;
13
13
  disableSorting?: boolean;
14
- /**
15
- * Collection path forwarded to upload-capable fields (`file` / `image`)
16
- * nested inside an array item, which need it to reach the `/upload`
17
- * endpoint. Without it those fields fall back to their empty placeholder
18
- * and never render an upload widget.
19
- */
20
- collectionPath?: string;
21
14
  /**
22
15
  * Active content locale, forwarded to each array item's fields so
23
16
  * localized widgets nested inside an array (e.g. a `localized` richText)
@@ -8,16 +8,18 @@ import { defaultScalarForField } from "../field-helpers.js";
8
8
  import { FieldRenderer } from "../field-renderer.js";
9
9
  import { SortableItem, StaticItem } from "../sortable-item.js";
10
10
  import { useFormContext } from "../../forms/form-context.js";
11
+ import { hasExistingIdTargets } from "../../forms/nested-path.js";
12
+ import { moveRepeatingItems, repeatingItemId, repeatingItemPath } from "../../forms/repeating-items.js";
11
13
  import array_field_module from "./array-field.module.js";
12
- const ArrayField = ({ field, defaultValue, path, disableSorting = false, collectionPath, contentLocale, fieldAdmin })=>{
13
- const { appendPatch, getFieldValue, getFieldValues, setFieldStore } = useFormContext();
14
+ const ArrayField = ({ field, defaultValue, path, disableSorting = false, contentLocale, fieldAdmin })=>{
15
+ const { appendPatch, getFieldValue, getFieldValues, removePendingUploadsUnder, setFieldStore } = useFormContext();
14
16
  const { t } = useTranslation('byline-admin');
15
17
  const [items, setItems] = useState([]);
16
18
  useEffect(()=>{
17
19
  const storeValue = getFieldValue(path);
18
20
  const source = Array.isArray(storeValue) ? storeValue : defaultValue;
19
21
  Array.isArray(source) ? setItems(source.map((item)=>({
20
- id: item && 'object' == typeof item && 'id' in item ? String(item.id) : item && 'object' == typeof item && '_id' in item ? String(item._id) : crypto.randomUUID(),
22
+ id: item && 'object' == typeof item && '_id' in item ? String(item._id) : item && 'object' == typeof item && 'id' in item ? String(item.id) : crypto.randomUUID(),
21
23
  data: item
22
24
  }))) : setItems([]);
23
25
  }, [
@@ -25,28 +27,19 @@ const ArrayField = ({ field, defaultValue, path, disableSorting = false, collect
25
27
  getFieldValue,
26
28
  path
27
29
  ]);
28
- const patchItemId = (item, index)=>{
29
- if (item && 'object' == typeof item) {
30
- if ('_id' in item) return String(item._id);
31
- if ('id' in item) return String(item.id);
32
- }
33
- return String(index);
34
- };
35
30
  const handleDragEnd = ({ moveFromIndex, moveToIndex })=>{
36
- setItems((prev)=>moveItem(prev, moveFromIndex, moveToIndex));
37
31
  const currentArray = getFieldValue(path) ?? defaultValue;
38
- if (Array.isArray(currentArray)) {
39
- const clampedFrom = Math.max(0, Math.min(moveFromIndex, currentArray.length - 1));
40
- const clampedTo = Math.max(0, Math.min(moveToIndex, currentArray.length - 1));
41
- if (clampedFrom === clampedTo) return;
42
- const item = currentArray[clampedFrom];
43
- appendPatch({
44
- kind: 'array.move',
45
- path: path,
46
- itemId: patchItemId(item, clampedFrom),
47
- toIndex: clampedTo
48
- });
49
- }
32
+ if (!Array.isArray(currentArray)) return;
33
+ const move = moveRepeatingItems(currentArray, moveFromIndex, moveToIndex);
34
+ if (null == move) return;
35
+ setItems((prev)=>moveItem(prev, move.fromIndex, move.toIndex));
36
+ setFieldStore(path, move.items);
37
+ appendPatch({
38
+ kind: 'array.move',
39
+ path,
40
+ itemId: move.itemId,
41
+ toIndex: move.toIndex
42
+ });
50
43
  };
51
44
  const handleAddItem = async (atIndex)=>{
52
45
  const childFields = field.fields ?? [];
@@ -60,6 +53,7 @@ const ArrayField = ({ field, defaultValue, path, disableSorting = false, collect
60
53
  for (const innerField of childField.fields)groupObj[innerField.name] = await defaultScalarForField(innerField, getFieldValues);
61
54
  newItem[childField.name] = groupObj;
62
55
  } else newItem[childField.name] = await defaultScalarForField(childField, getFieldValues);
56
+ if (!hasExistingIdTargets(getFieldValues(), path)) return;
63
57
  const currentArray = getFieldValue(path) ?? defaultValue;
64
58
  const insertAt = null != atIndex ? atIndex : currentArray ? currentArray.length : 0;
65
59
  const newItemWrapper = {
@@ -89,11 +83,13 @@ const ArrayField = ({ field, defaultValue, path, disableSorting = false, collect
89
83
  const currentArray = getFieldValue(path) ?? defaultValue;
90
84
  if (!Array.isArray(currentArray) || index < 0 || index >= currentArray.length) return;
91
85
  const item = currentArray[index];
86
+ const itemPath = repeatingItemPath(path, item, index);
92
87
  setItems((prev)=>prev.filter((_, i)=>i !== index));
88
+ removePendingUploadsUnder(itemPath);
93
89
  appendPatch({
94
90
  kind: 'array.remove',
95
91
  path: path,
96
- itemId: patchItemId(item, index)
92
+ itemId: repeatingItemId(item) ?? String(index)
97
93
  });
98
94
  const newArrayValue = [
99
95
  ...currentArray
@@ -106,7 +102,7 @@ const ArrayField = ({ field, defaultValue, path, disableSorting = false, collect
106
102
  };
107
103
  const renderItem = (itemWrapper, index)=>{
108
104
  const item = itemWrapper.data;
109
- const arrayElementPath = `${path}[${index}]`;
105
+ const arrayElementPath = repeatingItemPath(path, item, index);
110
106
  if (!item || 'object' != typeof item) return null;
111
107
  const childFields = field.fields ?? [];
112
108
  if (0 === childFields.length) return null;
@@ -127,7 +123,6 @@ const ArrayField = ({ field, defaultValue, path, disableSorting = false, collect
127
123
  defaultValue: groupData[innerField.name],
128
124
  basePath: `${arrayElementPath}.${childField.name}`,
129
125
  disableSorting: true,
130
- collectionPath: collectionPath,
131
126
  contentLocale: contentLocale,
132
127
  components: groupAdmin?.[innerField.name]?.components,
133
128
  editor: groupAdmin?.[innerField.name]?.editor,
@@ -141,7 +136,6 @@ const ArrayField = ({ field, defaultValue, path, disableSorting = false, collect
141
136
  defaultValue: initial,
142
137
  basePath: arrayElementPath,
143
138
  disableSorting: true,
144
- collectionPath: collectionPath,
145
139
  contentLocale: contentLocale,
146
140
  components: fieldAdmin?.[childField.name]?.components,
147
141
  editor: fieldAdmin?.[childField.name]?.editor,
@@ -8,9 +8,11 @@ import { defaultScalarForField } from "../field-helpers.js";
8
8
  import { GroupField } from "../group/group-field.js";
9
9
  import { SortableItem } from "../sortable-item.js";
10
10
  import { useFormContext } from "../../forms/form-context.js";
11
+ import { hasExistingIdTargets } from "../../forms/nested-path.js";
12
+ import { moveRepeatingItems, repeatingItemId, repeatingItemPath } from "../../forms/repeating-items.js";
11
13
  import blocks_field_module from "./blocks-field.module.js";
12
14
  const BlocksField = ({ field, defaultValue, path, contentLocale })=>{
13
- const { appendPatch, getFieldValue, getFieldValues, setFieldStore } = useFormContext();
15
+ const { appendPatch, getFieldValue, getFieldValues, removePendingUploadsUnder, setFieldStore } = useFormContext();
14
16
  const { t } = useTranslation('byline-admin');
15
17
  const [items, setItems] = useState([]);
16
18
  const [showAddBlockModal, setShowAddBlockModal] = useState(false);
@@ -43,21 +45,18 @@ const BlocksField = ({ field, defaultValue, path, contentLocale })=>{
43
45
  path
44
46
  ]);
45
47
  const handleDragEnd = ({ moveFromIndex, moveToIndex })=>{
46
- setItems((prev)=>moveItem(prev, moveFromIndex, moveToIndex));
47
48
  const currentArray = getFieldValue(path) ?? defaultValue;
48
- if (Array.isArray(currentArray)) {
49
- const clampedFrom = Math.max(0, Math.min(moveFromIndex, currentArray.length - 1));
50
- const clampedTo = Math.max(0, Math.min(moveToIndex, currentArray.length - 1));
51
- if (clampedFrom === clampedTo) return;
52
- const item = currentArray[clampedFrom];
53
- const itemId = item && 'object' == typeof item && '_id' in item ? String(item._id) : String(clampedFrom);
54
- appendPatch({
55
- kind: 'array.move',
56
- path: path,
57
- itemId,
58
- toIndex: clampedTo
59
- });
60
- }
49
+ if (!Array.isArray(currentArray)) return;
50
+ const move = moveRepeatingItems(currentArray, moveFromIndex, moveToIndex);
51
+ if (null == move) return;
52
+ setItems((prev)=>moveItem(prev, move.fromIndex, move.toIndex));
53
+ setFieldStore(path, move.items);
54
+ appendPatch({
55
+ kind: 'array.move',
56
+ path,
57
+ itemId: move.itemId,
58
+ toIndex: move.toIndex
59
+ });
61
60
  };
62
61
  const handleAddItem = async (forcedVariantName, atIndex)=>{
63
62
  setShowAddBlockModal(false);
@@ -71,6 +70,7 @@ const BlocksField = ({ field, defaultValue, path, contentLocale })=>{
71
70
  _type: variant.blockType
72
71
  };
73
72
  for (const f of compositeFields)newItem[f.name] = await defaultScalarForField(f, getFieldValues);
73
+ if (!hasExistingIdTargets(getFieldValues(), path)) return;
74
74
  const currentArray = getFieldValue(path) ?? defaultValue;
75
75
  const insertAt = null != atIndex ? atIndex : currentArray ? currentArray.length : 0;
76
76
  const newItemWrapper = {
@@ -100,8 +100,10 @@ const BlocksField = ({ field, defaultValue, path, contentLocale })=>{
100
100
  const currentArray = getFieldValue(path) ?? defaultValue;
101
101
  if (!Array.isArray(currentArray) || index < 0 || index >= currentArray.length) return;
102
102
  const item = currentArray[index];
103
- const itemId = item && 'object' == typeof item && '_id' in item ? String(item._id) : String(index);
103
+ const itemPath = repeatingItemPath(path, item, index);
104
+ const itemId = repeatingItemId(item) ?? String(index);
104
105
  setItems((prev)=>prev.filter((_, i)=>i !== index));
106
+ removePendingUploadsUnder(itemPath);
105
107
  appendPatch({
106
108
  kind: 'array.remove',
107
109
  path: path,
@@ -121,7 +123,7 @@ const BlocksField = ({ field, defaultValue, path, contentLocale })=>{
121
123
  };
122
124
  const renderItem = (itemWrapper, index)=>{
123
125
  const item = itemWrapper.data;
124
- const arrayElementPath = `${path}[${index}]`;
126
+ const arrayElementPath = repeatingItemPath(path, item, index);
125
127
  if (!item || 'object' != typeof item || 'string' != typeof item._type) return null;
126
128
  const subField = field.blocks?.find((b)=>b.blockType === item._type);
127
129
  if (null == subField) return null;
@@ -12,8 +12,6 @@ interface FieldRendererProps {
12
12
  basePath?: string;
13
13
  disableSorting?: boolean;
14
14
  hideLabel?: boolean;
15
- /** Collection path (e.g. `'media'`) forwarded to upload-capable fields. */
16
- collectionPath?: string;
17
15
  /**
18
16
  * The active content locale (e.g. `'en'`, `'fr'`). When provided and
19
17
  * `field.localized === true`, a small locale badge is shown so the editor
@@ -42,5 +40,5 @@ interface FieldRendererProps {
42
40
  */
43
41
  fieldAdmin?: Record<string, FieldAdminConfig>;
44
42
  }
45
- export declare const FieldRenderer: ({ field, defaultValue: initialDefault, basePath, disableSorting, hideLabel, collectionPath, contentLocale, components, editor, fieldAdmin, }: FieldRendererProps) => import("react").JSX.Element | null;
43
+ export declare const FieldRenderer: ({ field, defaultValue: initialDefault, basePath, disableSorting, hideLabel, contentLocale, components, editor, fieldAdmin, }: FieldRendererProps) => import("react").JSX.Element | null;
46
44
  export {};
@@ -20,7 +20,7 @@ import { TextField } from "./text/text-field.js";
20
20
  import { TextAreaField } from "./text-area/text-area-field.js";
21
21
  import { useFieldChangeHandler } from "./use-field-change-handler.js";
22
22
  import { useFieldCondition } from "./use-field-condition.js";
23
- const FieldRenderer = ({ field, defaultValue: initialDefault, basePath, disableSorting, hideLabel, collectionPath, contentLocale, components, editor, fieldAdmin })=>{
23
+ const FieldRenderer = ({ field, defaultValue: initialDefault, basePath, disableSorting, hideLabel, contentLocale, components, editor, fieldAdmin })=>{
24
24
  const path = basePath ? `${basePath}.${field.name}` : field.name;
25
25
  const htmlId = path.replace(/[[\].]/g, '-');
26
26
  const handleChange = useFieldChangeHandler(field, path);
@@ -161,8 +161,7 @@ const FieldRenderer = ({ field, defaultValue: initialDefault, basePath, disableS
161
161
  } : field,
162
162
  defaultValue: defaultValue,
163
163
  onChange: handleChange,
164
- path: path,
165
- collectionPath: collectionPath
164
+ path: path
166
165
  });
167
166
  case 'image':
168
167
  return /*#__PURE__*/ jsx(ImageField, {
@@ -172,8 +171,7 @@ const FieldRenderer = ({ field, defaultValue: initialDefault, basePath, disableS
172
171
  } : field,
173
172
  defaultValue: defaultValue,
174
173
  onChange: handleChange,
175
- path: path,
176
- collectionPath: collectionPath
174
+ path: path
177
175
  });
178
176
  case 'relation':
179
177
  if (field.hasMany) return /*#__PURE__*/ jsx(RelationManyField, {
@@ -204,7 +202,6 @@ const FieldRenderer = ({ field, defaultValue: initialDefault, basePath, disableS
204
202
  defaultValue: defaultValue,
205
203
  path: path,
206
204
  disableSorting: disableSorting,
207
- collectionPath: collectionPath,
208
205
  contentLocale: contentLocale,
209
206
  fieldAdmin: fieldAdmin
210
207
  });
@@ -223,7 +220,6 @@ const FieldRenderer = ({ field, defaultValue: initialDefault, basePath, disableS
223
220
  defaultValue: defaultValue,
224
221
  path: path,
225
222
  disableSorting: disableSorting,
226
- collectionPath: collectionPath,
227
223
  contentLocale: contentLocale,
228
224
  fieldAdmin: fieldAdmin
229
225
  });
@@ -8,12 +8,10 @@
8
8
  import { type FileField as FieldType, type StoredFileValue } from '@byline/core';
9
9
  interface FileFieldProps {
10
10
  field: FieldType;
11
- /** Collection path required to call the /upload endpoint. */
12
- collectionPath?: string;
13
11
  value?: StoredFileValue | null;
14
12
  defaultValue?: StoredFileValue | null;
15
13
  onChange?: (value: StoredFileValue | null) => void;
16
14
  path?: string;
17
15
  }
18
- export declare const FileField: ({ field, collectionPath, value, defaultValue, onChange: _onChange, path, }: FileFieldProps) => import("react").JSX.Element;
16
+ export declare const FileField: ({ field, value, defaultValue, onChange: _onChange, path, }: FileFieldProps) => import("react").JSX.Element;
19
17
  export {};
@@ -18,14 +18,14 @@ function triggerDownload(url, filename) {
18
18
  a.click();
19
19
  document.body.removeChild(a);
20
20
  }
21
- const FileField = ({ field, collectionPath, value, defaultValue, onChange: _onChange, path })=>{
21
+ const FileField = ({ field, value, defaultValue, onChange: _onChange, path })=>{
22
22
  const fieldPath = path ?? field.name;
23
23
  const { t } = useTranslation('byline-admin');
24
24
  const fieldError = useFieldError(fieldPath);
25
25
  const isDirty = useIsDirty(fieldPath);
26
26
  const fieldValue = useFieldValue(fieldPath);
27
27
  const isUploading = useIsFieldUploading(fieldPath);
28
- const { removePendingUpload, documentId } = useFormContext();
28
+ const { removePendingUpload, documentId, collectionPath } = useFormContext();
29
29
  const handleChange = useFieldChangeHandler(field, fieldPath);
30
30
  const incomingValue = isDirty ? fieldValue ?? null : value ?? fieldValue ?? defaultValue ?? null;
31
31
  const isPending = isPendingStoredFileValue(incomingValue);
@@ -17,11 +17,11 @@ const FileUploadField = ({ field: _field, collectionPath, fieldPath, onUploaded,
17
17
  setErrorMessage(null);
18
18
  const previewUrl = URL.createObjectURL(file);
19
19
  const pendingValue = createPendingStoredFileValue(file, previewUrl);
20
- addPendingUpload(fieldPath, {
20
+ if (!addPendingUpload(fieldPath, {
21
21
  file,
22
22
  previewUrl,
23
23
  collectionPath
24
- });
24
+ })) return;
25
25
  setStatus('idle');
26
26
  onUploaded(pendingValue);
27
27
  }, [
@@ -20,12 +20,6 @@ interface GroupFieldProps {
20
20
  * listeners.
21
21
  */
22
22
  disableSorting?: boolean;
23
- /**
24
- * Collection path forwarded to upload-capable child fields (`file` / `image`),
25
- * which need it to reach the `/upload` endpoint. Without it those fields fall
26
- * back to their empty placeholder and never render an upload widget.
27
- */
28
- collectionPath?: string;
29
23
  /**
30
24
  * Active content locale, forwarded to child fields so localized widgets
31
25
  * nested inside the group (e.g. a `localized` richText) can render their
@@ -44,5 +38,5 @@ interface GroupFieldProps {
44
38
  */
45
39
  fieldAdmin?: Record<string, FieldAdminConfig>;
46
40
  }
47
- export declare const GroupField: ({ field, defaultValue, path, disableSorting, collectionPath, contentLocale, fieldAdmin, }: GroupFieldProps) => import("react").JSX.Element;
41
+ export declare const GroupField: ({ field, defaultValue, path, disableSorting, contentLocale, fieldAdmin, }: GroupFieldProps) => import("react").JSX.Element;
48
42
  export {};
@@ -7,7 +7,7 @@ import { placeholderForField } from "../field-helpers.js";
7
7
  import { FieldRenderer } from "../field-renderer.js";
8
8
  import { useFieldError } from "../../forms/form-context.js";
9
9
  import group_field_module from "./group-field.module.js";
10
- const GroupField = ({ field, defaultValue, path, disableSorting = true, collectionPath, contentLocale, fieldAdmin })=>{
10
+ const GroupField = ({ field, defaultValue, path, disableSorting = true, contentLocale, fieldAdmin })=>{
11
11
  const fieldError = useFieldError(field.name);
12
12
  const groupData = useMemo(()=>{
13
13
  if (defaultValue && 'object' == typeof defaultValue && !Array.isArray(defaultValue)) return defaultValue;
@@ -48,7 +48,6 @@ const GroupField = ({ field, defaultValue, path, disableSorting = true, collecti
48
48
  defaultValue: groupData[innerField.name],
49
49
  basePath: path,
50
50
  disableSorting: disableSorting,
51
- collectionPath: collectionPath,
52
51
  contentLocale: contentLocale,
53
52
  components: fieldAdmin?.[innerField.name]?.components,
54
53
  editor: fieldAdmin?.[innerField.name]?.editor,
@@ -8,12 +8,10 @@
8
8
  import { type ImageField as FieldType, type StoredFileValue } from '@byline/core';
9
9
  interface ImageFieldProps {
10
10
  field: FieldType;
11
- /** Collection path required to call the /upload endpoint. */
12
- collectionPath?: string;
13
11
  value?: StoredFileValue | null;
14
12
  defaultValue?: StoredFileValue | null;
15
13
  onChange?: (value: StoredFileValue | null) => void;
16
14
  path?: string;
17
15
  }
18
- export declare const ImageField: ({ field, collectionPath, value, defaultValue, onChange: _onChange, path, }: ImageFieldProps) => import("react").JSX.Element;
16
+ export declare const ImageField: ({ field, value, defaultValue, onChange: _onChange, path, }: ImageFieldProps) => import("react").JSX.Element;
19
17
  export {};
@@ -8,13 +8,13 @@ import { useFieldError, useFieldValue, useFormContext, useIsDirty, useIsFieldUpl
8
8
  import { useFieldChangeHandler } from "../use-field-change-handler.js";
9
9
  import image_field_module from "./image-field.module.js";
10
10
  import { ImageUploadField } from "./image-upload-field.js";
11
- const ImageField = ({ field, collectionPath, value, defaultValue, onChange: _onChange, path })=>{
11
+ const ImageField = ({ field, value, defaultValue, onChange: _onChange, path })=>{
12
12
  const fieldPath = path ?? field.name;
13
13
  const fieldError = useFieldError(fieldPath);
14
14
  const isDirty = useIsDirty(fieldPath);
15
15
  const fieldValue = useFieldValue(fieldPath);
16
16
  const isUploading = useIsFieldUploading(fieldPath);
17
- const { removePendingUpload, documentId } = useFormContext();
17
+ const { removePendingUpload, documentId, collectionPath } = useFormContext();
18
18
  const { t } = useTranslation('byline-admin');
19
19
  const handleChange = useFieldChangeHandler(field, fieldPath);
20
20
  const incomingValue = isDirty ? fieldValue ?? null : value ?? fieldValue ?? defaultValue ?? null;
@@ -1,5 +1,5 @@
1
1
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
2
- import { useCallback, useRef, useState } from "react";
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
3
  import { createPendingStoredFileValue } from "@byline/core";
4
4
  import { useTranslation } from "@byline/i18n/react";
5
5
  import classnames from "classnames";
@@ -7,11 +7,18 @@ import { useFormContext } from "../../forms/form-context.js";
7
7
  import image_upload_field_module from "./image-upload-field.module.js";
8
8
  const ImageUploadField = ({ field: _field, collectionPath, fieldPath, onUploaded, accept = 'image/*' })=>{
9
9
  const inputRef = useRef(null);
10
+ const mountedRef = useRef(true);
10
11
  const [status, setStatus] = useState('idle');
11
12
  const [errorMessage, setErrorMessage] = useState(null);
12
13
  const [isDragOver, setIsDragOver] = useState(false);
13
14
  const { addPendingUpload } = useFormContext();
14
15
  const { t } = useTranslation('byline-admin');
16
+ useEffect(()=>{
17
+ mountedRef.current = true;
18
+ return ()=>{
19
+ mountedRef.current = false;
20
+ };
21
+ }, []);
15
22
  const handleFileSelected = useCallback((file)=>{
16
23
  setStatus('processing');
17
24
  setErrorMessage(null);
@@ -23,6 +30,7 @@ const ImageUploadField = ({ field: _field, collectionPath, fieldPath, onUploaded
23
30
  const previewUrl = URL.createObjectURL(file);
24
31
  const img = new Image();
25
32
  img.onload = ()=>{
33
+ if (!mountedRef.current) return void URL.revokeObjectURL(previewUrl);
26
34
  const w = img.naturalWidth;
27
35
  const h = img.naturalHeight;
28
36
  const dimensions = w > 0 && h > 0 ? {
@@ -30,16 +38,17 @@ const ImageUploadField = ({ field: _field, collectionPath, fieldPath, onUploaded
30
38
  height: h
31
39
  } : void 0;
32
40
  const pendingValue = createPendingStoredFileValue(file, previewUrl, dimensions);
33
- addPendingUpload(fieldPath, {
41
+ if (!addPendingUpload(fieldPath, {
34
42
  file,
35
43
  previewUrl,
36
44
  collectionPath
37
- });
45
+ })) return;
38
46
  setStatus('idle');
39
47
  onUploaded(pendingValue);
40
48
  };
41
49
  img.onerror = ()=>{
42
50
  URL.revokeObjectURL(previewUrl);
51
+ if (!mountedRef.current) return;
43
52
  setStatus('error');
44
53
  setErrorMessage(t('fields.image.upload.errors.cannotRead'));
45
54
  };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -55,6 +55,19 @@ interface FormContextType {
55
55
  * `@byline/core`).
56
56
  */
57
57
  documentId: string | null;
58
+ /**
59
+ * Path of the collection this form edits, `null` when the form is rendered
60
+ * without one. Upload widgets need it to address the upload endpoint.
61
+ *
62
+ * It lives here rather than being passed down because it is constant for
63
+ * the whole form: threading it as a prop meant every nesting-capable
64
+ * container (`array`, `group`, `blocks`) had to remember to forward it,
65
+ * and a container that forgot silently rendered upload fields read-only —
66
+ * no error, just a missing drop zone. That happened twice, in `array` /
67
+ * `group` and then in `blocks`. A value read from context cannot be
68
+ * dropped by a container that never carries it.
69
+ */
70
+ collectionPath: string | null;
58
71
  setFieldValue: (name: string, value: any) => void;
59
72
  setFieldStore: (name: string, value: any) => void;
60
73
  getFieldValue: (name: string) => any;
@@ -81,8 +94,9 @@ interface FormContextType {
81
94
  subscribeField: (name: string, listener: FieldListener) => () => void;
82
95
  subscribeErrors: (listener: ErrorsListener) => () => void;
83
96
  subscribeMeta: (listener: MetaListener) => () => void;
84
- addPendingUpload: (fieldPath: string, upload: PendingUpload) => void;
97
+ addPendingUpload: (fieldPath: string, upload: PendingUpload) => boolean;
85
98
  removePendingUpload: (fieldPath: string) => void;
99
+ removePendingUploadsUnder: (itemPath: string) => void;
86
100
  getPendingUploads: () => Map<string, PendingUpload>;
87
101
  hasPendingUploads: () => boolean;
88
102
  clearPendingUploads: () => void;
@@ -97,7 +111,7 @@ interface FormContextType {
97
111
  subscribeSystemAvailableLocales: (listener: SystemAvailableLocalesListener) => () => void;
98
112
  }
99
113
  export declare const useFormContext: () => FormContextType;
100
- export declare const FormProvider: ({ children, initialData, documentId, }: {
114
+ export declare const FormProvider: ({ children, initialData, documentId, collectionPath, }: {
101
115
  children: React.ReactNode;
102
116
  initialData?: Record<string, any>;
103
117
  /**
@@ -105,6 +119,12 @@ export declare const FormProvider: ({ children, initialData, documentId, }: {
105
119
  * the context for upload widgets honouring `upload.requireSavedDocument`.
106
120
  */
107
121
  documentId?: string | null;
122
+ /**
123
+ * Path of the collection being edited. Exposed on the context so upload
124
+ * widgets can reach the upload endpoint from any nesting depth without
125
+ * every container forwarding it — see `FormContextType.collectionPath`.
126
+ */
127
+ collectionPath?: string | null;
108
128
  }) => React.JSX.Element;
109
129
  /**
110
130
  * Subscribe to the system `path` slot edited by the path widget.
@@ -2,7 +2,8 @@
2
2
  import { jsx } from "react/jsx-runtime";
3
3
  import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
4
4
  import { normalizeHooks } from "@byline/core";
5
- import { get, set as external_nested_path_js_set } from "./nested-path.js";
5
+ import { get, hasExistingIdTargets, setWithResult } from "./nested-path.js";
6
+ import { deletePendingUploadsUnderPath } from "./pending-uploads.js";
6
7
  import { useTrackedSlot } from "./use-tracked-slot.js";
7
8
  const sameLocaleSet = (a, b)=>{
8
9
  if (a.length !== b.length) return false;
@@ -22,7 +23,7 @@ const useFormContext = ()=>{
22
23
  if (null == context) throw new Error('useFormContext must be used within a FormProvider');
23
24
  return context;
24
25
  };
25
- const FormProvider = ({ children, initialData = {}, documentId = null })=>{
26
+ const FormProvider = ({ children, initialData = {}, documentId = null, collectionPath = null })=>{
26
27
  const fieldValues = useRef(JSON.parse(JSON.stringify(initialData?.fields ?? initialData)));
27
28
  const initialValues = useRef(initialData?.fields ?? initialData);
28
29
  const errorsRef = useRef([]);
@@ -95,11 +96,12 @@ const FormProvider = ({ children, initialData = {}, documentId = null })=>{
95
96
  const newFieldValues = {
96
97
  ...fieldValues.current
97
98
  };
98
- external_nested_path_js_set(newFieldValues, name, value);
99
+ if (!setWithResult(newFieldValues, name, value)) return false;
99
100
  fieldValues.current = newFieldValues;
100
101
  dirtyFields.current.add(name);
101
102
  notifyFieldListeners(name, value);
102
103
  notifyMetaListeners();
104
+ return true;
103
105
  }, [
104
106
  notifyFieldListeners,
105
107
  notifyMetaListeners
@@ -110,7 +112,7 @@ const FormProvider = ({ children, initialData = {}, documentId = null })=>{
110
112
  updateFieldStoreInternal
111
113
  ]);
112
114
  const setFieldValue = useCallback((name, value)=>{
113
- updateFieldStoreInternal(name, value);
115
+ if (!updateFieldStoreInternal(name, value)) return;
114
116
  const patch = {
115
117
  kind: 'field.set',
116
118
  path: name,
@@ -185,11 +187,16 @@ const FormProvider = ({ children, initialData = {}, documentId = null })=>{
185
187
  };
186
188
  }, []);
187
189
  const addPendingUpload = useCallback((fieldPath, upload)=>{
190
+ if (!hasExistingIdTargets(fieldValues.current, fieldPath)) {
191
+ URL.revokeObjectURL(upload.previewUrl);
192
+ return false;
193
+ }
188
194
  const existing = pendingUploadsRef.current.get(fieldPath);
189
195
  if (existing) URL.revokeObjectURL(existing.previewUrl);
190
196
  pendingUploadsRef.current.set(fieldPath, upload);
191
197
  dirtyFields.current.add(fieldPath);
192
198
  notifyMetaListeners();
199
+ return true;
193
200
  }, [
194
201
  notifyMetaListeners
195
202
  ]);
@@ -203,6 +210,12 @@ const FormProvider = ({ children, initialData = {}, documentId = null })=>{
203
210
  }, [
204
211
  notifyMetaListeners
205
212
  ]);
213
+ const removePendingUploadsUnder = useCallback((itemPath)=>{
214
+ const deleted = deletePendingUploadsUnderPath(pendingUploadsRef.current, itemPath, (url)=>URL.revokeObjectURL(url));
215
+ if (deleted) notifyMetaListeners();
216
+ }, [
217
+ notifyMetaListeners
218
+ ]);
206
219
  const getPendingUploads = useCallback(()=>new Map(pendingUploadsRef.current), []);
207
220
  const hasPendingUploads = useCallback(()=>pendingUploadsRef.current.size > 0, []);
208
221
  const clearPendingUploads = useCallback(()=>{
@@ -377,6 +390,7 @@ const FormProvider = ({ children, initialData = {}, documentId = null })=>{
377
390
  return /*#__PURE__*/ jsx(FormContext.Provider, {
378
391
  value: {
379
392
  documentId,
393
+ collectionPath,
380
394
  setFieldValue,
381
395
  setFieldStore,
382
396
  getFieldValue,
@@ -402,6 +416,7 @@ const FormProvider = ({ children, initialData = {}, documentId = null })=>{
402
416
  subscribeMeta,
403
417
  addPendingUpload,
404
418
  removePendingUpload,
419
+ removePendingUploadsUnder,
405
420
  getPendingUploads,
406
421
  hasPendingUploads,
407
422
  clearPendingUploads,
@@ -183,7 +183,6 @@ const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpub
183
183
  return /*#__PURE__*/ jsx(FieldRenderer, {
184
184
  field: field,
185
185
  defaultValue: initialData?.fields?.[field.name],
186
- collectionPath: collectionPath,
187
186
  contentLocale: contentLocale,
188
187
  components: adminConfig?.fields?.[field.name]?.components,
189
188
  editor: adminConfig?.fields?.[field.name]?.editor,
@@ -233,6 +232,8 @@ const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpub
233
232
  noValidate: true,
234
233
  onSubmit: handleSubmit,
235
234
  className: classnames('byline-form', form_renderer_module.form),
235
+ inert: isUploading ? true : void 0,
236
+ "aria-busy": isUploading,
236
237
  children: [
237
238
  /*#__PURE__*/ jsxs("div", {
238
239
  className: classnames('byline-form-heading-row', form_renderer_module["heading-row"]),
@@ -405,6 +406,7 @@ const FormRenderer = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpu
405
406
  return /*#__PURE__*/ jsx(FormProvider, {
406
407
  initialData: initialData,
407
408
  documentId: 'edit' === mode && 'string' == typeof initialData?.id ? initialData.id : null,
409
+ collectionPath: collectionPath ?? null,
408
410
  children: /*#__PURE__*/ jsx(FormContent, {
409
411
  mode: mode,
410
412
  fields: fields,
@@ -8,7 +8,7 @@
8
8
  * Minimal nested `get`/`set` over string field paths, replacing lodash-es
9
9
  * (which pulled a large shared chunk onto unrelated bundles). Supports the
10
10
  * dot + bracket notation produced by the form field-path builders, e.g.
11
- * `title`, `a.b.c`, `items[0].title`, `blocks[2].nested[1].field`.
11
+ * `title`, `a.b.c`, `items[0].title`, `blocks[id=abc].nested[1].field`.
12
12
  *
13
13
  * `set` mirrors lodash semantics: it creates intermediate **arrays** when the
14
14
  * next path segment is a numeric index and plain **objects** otherwise, and it
@@ -21,4 +21,8 @@
21
21
  /** Split a field path into segments: `items[0].title` -> ['items','0','title']. */
22
22
  export declare function toPath(path: string): string[];
23
23
  export declare function get<T = any>(object: unknown, path: string): T;
24
+ /** Whether every stable-id selector in a path still identifies a live item. */
25
+ export declare function hasExistingIdTargets(object: unknown, path: string): boolean;
26
+ /** Set a path and report whether all stable-id selectors resolved. */
27
+ export declare function setWithResult<T extends object>(object: T, path: string, value: unknown): boolean;
24
28
  export declare function set<T extends object>(object: T, path: string, value: unknown): T;