@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
@@ -1,29 +1,98 @@
1
- const isIndexKey = (key)=>/^(?:0|[1-9]\d*)$/.test(key);
1
+ import { parseInstancePath } from "@byline/core";
2
2
  function toPath(path) {
3
3
  return path.match(/[^.[\]]+/g) ?? [];
4
4
  }
5
+ function selectId(value, id) {
6
+ if (!Array.isArray(value)) return -1;
7
+ return value.findIndex((item)=>null != item && 'object' == typeof item && item._id === id);
8
+ }
9
+ function newContainer(next) {
10
+ return next?.kind === 'index' || next?.kind === 'id' ? [] : {};
11
+ }
5
12
  function get(object, path) {
6
13
  if (null == object) return;
14
+ const parsed = parseInstancePath(path);
15
+ if (!parsed.ok) return;
7
16
  let current = object;
8
- for (const key of toPath(path)){
17
+ for (const segment of parsed.segments){
9
18
  if (null == current) return;
10
- current = current[key];
19
+ if ('field' === segment.kind) current = current[segment.name];
20
+ else if ('index' === segment.kind) current = current[segment.index];
21
+ else {
22
+ if ('id' !== segment.kind) return;
23
+ const index = selectId(current, segment.id);
24
+ if (-1 === index) return;
25
+ current = current[index];
26
+ }
11
27
  }
12
28
  return current;
13
29
  }
14
- function set(object, path, value) {
15
- if (null == object) return object;
16
- const keys = toPath(path);
17
- if (0 === keys.length) return object;
30
+ function hasExistingIdTargets(object, path) {
31
+ const parsed = parseInstancePath(path);
32
+ if (!parsed.ok) return false;
33
+ let current = object;
34
+ for (const segment of parsed.segments)if ('field' === segment.kind) current = current?.[segment.name];
35
+ else if ('index' === segment.kind) current = current?.[segment.index];
36
+ else {
37
+ if ('id' !== segment.kind) return false;
38
+ const index = selectId(current, segment.id);
39
+ if (-1 === index) return false;
40
+ current = current[index];
41
+ }
42
+ return true;
43
+ }
44
+ function setWithResult(object, path, value) {
45
+ if (null == object) return false;
46
+ const parsed = parseInstancePath(path);
47
+ if (!parsed.ok || 0 === parsed.segments.length) return false;
18
48
  let current = object;
19
- for(let i = 0; i < keys.length - 1; i++){
20
- const key = keys[i];
21
- const nextKey = keys[i + 1];
22
- const existing = current[key];
23
- if (null == existing || 'object' != typeof existing) current[key] = isIndexKey(nextKey) ? [] : {};
24
- current = current[key];
49
+ for(let i = 0; i < parsed.segments.length; i++){
50
+ const segment = parsed.segments[i];
51
+ const next = parsed.segments[i + 1];
52
+ const last = i === parsed.segments.length - 1;
53
+ if ('field' === segment.kind) {
54
+ if (last) {
55
+ current[segment.name] = value;
56
+ return true;
57
+ }
58
+ const existing = current[segment.name];
59
+ if (null == existing || 'object' != typeof existing) {
60
+ if (parsed.segments.slice(i + 1).some((candidate)=>'id' === candidate.kind)) return false;
61
+ current[segment.name] = newContainer(next);
62
+ }
63
+ current = current[segment.name];
64
+ continue;
65
+ }
66
+ if ('index' === segment.kind) {
67
+ if (!Array.isArray(current)) return false;
68
+ if (last) {
69
+ current[segment.index] = value;
70
+ return true;
71
+ }
72
+ const existing = current[segment.index];
73
+ if (null == existing || 'object' != typeof existing) {
74
+ if (parsed.segments.slice(i + 1).some((candidate)=>'id' === candidate.kind)) return false;
75
+ current[segment.index] = newContainer(next);
76
+ }
77
+ current = current[segment.index];
78
+ continue;
79
+ }
80
+ if ('id' === segment.kind) {
81
+ const index = selectId(current, segment.id);
82
+ if (-1 === index) return false;
83
+ if (last) {
84
+ current[index] = value;
85
+ return true;
86
+ }
87
+ current = current[index];
88
+ continue;
89
+ }
90
+ break;
25
91
  }
26
- current[keys[keys.length - 1]] = value;
92
+ return false;
93
+ }
94
+ function set(object, path, value) {
95
+ setWithResult(object, path, value);
27
96
  return object;
28
97
  }
29
- export { get, set, toPath };
98
+ export { get, hasExistingIdTargets, set, setWithResult, toPath };
@@ -0,0 +1,6 @@
1
+ interface PendingUploadLike {
2
+ previewUrl: string;
3
+ }
4
+ /** Remove deferred uploads belonging to one repeating item and its descendants. */
5
+ export declare function deletePendingUploadsUnderPath<T extends PendingUploadLike>(uploads: Map<string, T>, itemPath: string, revokeObjectURL: (url: string) => void): boolean;
6
+ export {};
@@ -0,0 +1,11 @@
1
+ function deletePendingUploadsUnderPath(uploads, itemPath, revokeObjectURL) {
2
+ const descendantPrefix = `${itemPath}.`;
3
+ let deleted = false;
4
+ for (const [fieldPath, upload] of uploads)if (fieldPath === itemPath || fieldPath.startsWith(descendantPrefix)) {
5
+ revokeObjectURL(upload.previewUrl);
6
+ uploads.delete(fieldPath);
7
+ deleted = true;
8
+ }
9
+ return deleted;
10
+ }
11
+ export { deletePendingUploadsUnderPath };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,16 @@
1
+ /** Return a canonical identity that is safe in an instance-path selector. */
2
+ export declare function repeatingItemId(item: unknown): string | undefined;
3
+ /**
4
+ * Address an array/block item by stable identity when its id is path-safe.
5
+ * Positional fallback is retained for noncanonical create defaults and legacy
6
+ * adapter data that does not carry storage identity yet.
7
+ */
8
+ export declare function repeatingItemPath(parentPath: string, item: unknown, index: number): string;
9
+ export interface RepeatingItemMove<T> {
10
+ items: T[];
11
+ itemId: string;
12
+ fromIndex: number;
13
+ toIndex: number;
14
+ }
15
+ /** Build one synchronized form-store move and its matching patch identity. */
16
+ export declare function moveRepeatingItems<T>(items: readonly T[], moveFromIndex: number, moveToIndex: number): RepeatingItemMove<T> | null;
@@ -0,0 +1,28 @@
1
+ function repeatingItemId(item) {
2
+ if (null == item || 'object' != typeof item || !('_id' in item)) return;
3
+ const id = item._id;
4
+ return 'string' != typeof id || '' === id || /[.[\]]/.test(id) ? void 0 : id;
5
+ }
6
+ function repeatingItemPath(parentPath, item, index) {
7
+ const id = repeatingItemId(item);
8
+ return null != id ? `${parentPath}[id=${id}]` : `${parentPath}[${index}]`;
9
+ }
10
+ function moveRepeatingItems(items, moveFromIndex, moveToIndex) {
11
+ if (0 === items.length) return null;
12
+ const fromIndex = Math.max(0, Math.min(moveFromIndex, items.length - 1));
13
+ const toIndex = Math.max(0, Math.min(moveToIndex, items.length - 1));
14
+ if (fromIndex === toIndex) return null;
15
+ const source = items[fromIndex];
16
+ const moved = [
17
+ ...items
18
+ ];
19
+ const [item] = moved.splice(fromIndex, 1);
20
+ moved.splice(toIndex, 0, item);
21
+ return {
22
+ items: moved,
23
+ itemId: repeatingItemId(source) ?? String(fromIndex),
24
+ fromIndex,
25
+ toIndex
26
+ };
27
+ }
28
+ export { moveRepeatingItems, repeatingItemId, repeatingItemPath };
@@ -0,0 +1 @@
1
+ export {};
@@ -1,3 +1,4 @@
1
+ import { parseInstancePath } from "@byline/core";
1
2
  import { get } from "./nested-path.js";
2
3
  async function executeUploads(pendingUploads, uploadField, executionContext) {
3
4
  const results = [];
@@ -36,16 +37,14 @@ function buildUploadFormData(fieldPath, upload, executionContext) {
36
37
  formData.append('field', uploadFieldName(fieldPath));
37
38
  formData.append('fieldPath', fieldPath);
38
39
  if (executionContext?.documentId) formData.append('documentId', executionContext.documentId);
39
- const contextPaths = executionContext?.fields != null ? findUploadFieldByPath(executionContext.fields, fieldPath)?.context : void 0;
40
- if (contextPaths && contextPaths.length > 0 && executionContext?.getFormValues) {
41
- const formValues = executionContext.getFormValues();
42
- for (const contextPath of contextPaths){
43
- const resolvedPath = resolveContextPath(fieldPath, contextPath);
44
- if (void 0 === resolvedPath) continue;
45
- const value = '' === resolvedPath ? formValues : get(formValues, resolvedPath);
46
- const serialized = serializeContextValue(value);
47
- if (void 0 !== serialized) formData.append(leafName(contextPath), serialized);
48
- }
40
+ const formValues = executionContext?.getFormValues?.();
41
+ const contextPaths = executionContext?.fields != null ? findUploadFieldByPath(executionContext.fields, fieldPath, formValues)?.context : void 0;
42
+ if (contextPaths && contextPaths.length > 0 && formValues) for (const contextPath of contextPaths){
43
+ const resolvedPath = resolveContextPath(fieldPath, contextPath);
44
+ if (void 0 === resolvedPath) continue;
45
+ const value = '' === resolvedPath ? formValues : get(formValues, resolvedPath);
46
+ const serialized = serializeContextValue(value);
47
+ if (void 0 !== serialized) formData.append(leafName(contextPath), serialized);
49
48
  }
50
49
  return formData;
51
50
  }
@@ -89,27 +88,49 @@ function serializeContextValue(value) {
89
88
  function isRelationEnvelope(value) {
90
89
  return 'object' == typeof value && null !== value && 'string' == typeof value.targetDocumentId;
91
90
  }
92
- function findUploadFieldByPath(fields, fieldPath) {
93
- const segments = fieldPath.split('.').map((s)=>s.replace(/\[\d+\]$/, ''));
94
- let currentFields = fields;
95
- for(let i = 0; i < segments.length; i++){
96
- const name = segments[i];
97
- const isLeaf = i === segments.length - 1;
98
- const field = currentFields.find((f)=>f.name === name);
99
- if (!field) break;
100
- if (isLeaf) return 'image' === field.type || 'file' === field.type ? field.upload : void 0;
101
- if ('group' === field.type || 'array' === field.type) {
102
- currentFields = field.fields;
103
- continue;
91
+ function findUploadFieldByPath(fields, fieldPath, formValues) {
92
+ const parsed = parseInstancePath(fieldPath);
93
+ if (!parsed.ok) return;
94
+ return resolveUploadConfig(fields, parsed.segments, formValues);
95
+ }
96
+ function selectItem(value, segment) {
97
+ if (!Array.isArray(value)) return;
98
+ if ('index' === segment.kind) return value[segment.index];
99
+ if ('id' === segment.kind) return value.find((item)=>item?._id === segment.id);
100
+ }
101
+ function resolveUploadConfig(fields, segments, value) {
102
+ const head = segments[0];
103
+ if (null == head || 'field' !== head.kind) return;
104
+ const field = fields.find((candidate)=>candidate.name === head.name);
105
+ if (null == field) return;
106
+ const fieldValue = value?.[head.name];
107
+ const rest = segments.slice(1);
108
+ if (0 === rest.length) return 'image' === field.type || 'file' === field.type ? field.upload : void 0;
109
+ if ('group' === field.type) return resolveUploadConfig(field.fields, rest, fieldValue);
110
+ if ('array' === field.type) {
111
+ const selector = rest[0];
112
+ if (selector?.kind === 'index' || selector?.kind === 'id') return resolveUploadConfig(field.fields, rest.slice(1), selectItem(fieldValue, selector));
113
+ return resolveUploadConfig(field.fields, rest, void 0);
114
+ }
115
+ if ('blocks' === field.type) {
116
+ const selector = rest[0];
117
+ const remainder = selector?.kind === 'index' || selector?.kind === 'id' ? rest.slice(1) : rest;
118
+ const item = selector?.kind === 'index' || selector?.kind === 'id' ? selectItem(fieldValue, selector) : void 0;
119
+ const blockType = item?._type;
120
+ if ('string' == typeof blockType) {
121
+ const block = field.blocks.find((candidate)=>candidate.blockType === blockType);
122
+ return null == block ? void 0 : resolveUploadConfig(block.fields, remainder, item);
104
123
  }
105
- if ('blocks' === field.type) {
106
- const remaining = segments[i + 1];
107
- const block = field.blocks.find((b)=>b.fields.some((f)=>f.name === remaining));
108
- if (!block) return;
109
- currentFields = block.fields;
110
- continue;
124
+ let found;
125
+ let matches = 0;
126
+ for (const block of field.blocks){
127
+ const result = resolveUploadConfig(block.fields, remainder, void 0);
128
+ if (void 0 !== result) {
129
+ matches += 1;
130
+ found = result;
131
+ }
111
132
  }
112
- break;
133
+ return 1 === matches ? found : void 0;
113
134
  }
114
135
  }
115
136
  async function executeUploadsWithProgress(pendingUploads, uploadField, onProgress, executionContext) {
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@byline/admin",
3
3
  "private": false,
4
4
  "license": "MPL-2.0",
5
- "version": "4.3.0",
5
+ "version": "4.4.1",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -168,10 +168,10 @@
168
168
  "react-diff-viewer-continued": "^4.4.0",
169
169
  "uuid": "^14.0.1",
170
170
  "zod": "^4.4.3",
171
- "@byline/core": "4.3.0",
172
- "@byline/auth": "4.3.0",
173
- "@byline/i18n": "4.3.0",
174
- "@byline/ui": "4.3.0"
171
+ "@byline/auth": "4.4.1",
172
+ "@byline/ui": "4.4.1",
173
+ "@byline/i18n": "4.4.1",
174
+ "@byline/core": "4.4.1"
175
175
  },
176
176
  "peerDependencies": {
177
177
  "react": "^19.0.0",
@@ -18,6 +18,8 @@ import { defaultScalarForField } from '../../fields/field-helpers'
18
18
  import { FieldRenderer } from '../../fields/field-renderer'
19
19
  import { SortableItem, StaticItem } from '../../fields/sortable-item'
20
20
  import { useFormContext } from '../../forms/form-context'
21
+ import { hasExistingIdTargets } from '../../forms/nested-path'
22
+ import { moveRepeatingItems, repeatingItemId, repeatingItemPath } from '../../forms/repeating-items'
21
23
  import styles from './array-field.module.css'
22
24
 
23
25
  // ---------------------------------------------------------------------------
@@ -31,7 +33,6 @@ export const ArrayField = ({
31
33
  defaultValue,
32
34
  path,
33
35
  disableSorting = false,
34
- collectionPath,
35
36
  contentLocale,
36
37
  fieldAdmin,
37
38
  }: {
@@ -39,13 +40,6 @@ export const ArrayField = ({
39
40
  defaultValue: any
40
41
  path: string
41
42
  disableSorting?: boolean
42
- /**
43
- * Collection path forwarded to upload-capable fields (`file` / `image`)
44
- * nested inside an array item, which need it to reach the `/upload`
45
- * endpoint. Without it those fields fall back to their empty placeholder
46
- * and never render an upload widget.
47
- */
48
- collectionPath?: string
49
43
  /**
50
44
  * Active content locale, forwarded to each array item's fields so
51
45
  * localized widgets nested inside an array (e.g. a `localized` richText)
@@ -63,7 +57,8 @@ export const ArrayField = ({
63
57
  */
64
58
  fieldAdmin?: Record<string, FieldAdminConfig>
65
59
  }) => {
66
- const { appendPatch, getFieldValue, getFieldValues, setFieldStore } = useFormContext()
60
+ const { appendPatch, getFieldValue, getFieldValues, removePendingUploadsUnder, setFieldStore } =
61
+ useFormContext()
67
62
  const { t } = useTranslation('byline-admin')
68
63
  const [items, setItems] = useState<{ id: string; data: any }[]>([])
69
64
 
@@ -80,10 +75,10 @@ export const ArrayField = ({
80
75
  setItems(
81
76
  source.map((item: any) => ({
82
77
  id:
83
- item && typeof item === 'object' && 'id' in item
84
- ? String((item as { id: string }).id)
85
- : item && typeof item === 'object' && '_id' in item
86
- ? String((item as { _id: string })._id)
78
+ item && typeof item === 'object' && '_id' in item
79
+ ? String((item as { _id: string })._id)
80
+ : item && typeof item === 'object' && 'id' in item
81
+ ? String((item as { id: string }).id)
87
82
  : crypto.randomUUID(),
88
83
  data: item,
89
84
  }))
@@ -93,23 +88,6 @@ export const ArrayField = ({
93
88
  }
94
89
  }, [defaultValue, getFieldValue, path])
95
90
 
96
- /**
97
- * Stable patch identity for an array item. Persisted items carry `_id`
98
- * (the array-item identity from `store_meta`) — that is what the server's
99
- * patch engine matches on (`applyArrayPatch`: `item._id === patch.itemId`),
100
- * so it MUST be preferred here. `id` is accepted as a legacy/seed-data
101
- * alias. Items added this session have neither (the storage layer assigns
102
- * `_id` at write time), so fall back to the item's current index — the
103
- * patch engine resolves a pure-integer itemId as an index fallback.
104
- */
105
- const patchItemId = (item: unknown, index: number): string => {
106
- if (item && typeof item === 'object') {
107
- if ('_id' in item) return String((item as { _id: string })._id)
108
- if ('id' in item) return String((item as { id: string }).id)
109
- }
110
- return String(index)
111
- }
112
-
113
91
  const handleDragEnd = ({
114
92
  moveFromIndex,
115
93
  moveToIndex,
@@ -117,23 +95,20 @@ export const ArrayField = ({
117
95
  moveFromIndex: number
118
96
  moveToIndex: number
119
97
  }) => {
120
- setItems((prev) => moveItem(prev, moveFromIndex, moveToIndex))
121
98
  const currentArray = (getFieldValue(path) ?? defaultValue) as any[]
99
+ if (!Array.isArray(currentArray)) return
122
100
 
123
- if (Array.isArray(currentArray)) {
124
- const clampedFrom = Math.max(0, Math.min(moveFromIndex, currentArray.length - 1))
125
- const clampedTo = Math.max(0, Math.min(moveToIndex, currentArray.length - 1))
126
- if (clampedFrom === clampedTo) return
127
-
128
- const item = currentArray[clampedFrom]
101
+ const move = moveRepeatingItems(currentArray, moveFromIndex, moveToIndex)
102
+ if (move == null) return
129
103
 
130
- appendPatch({
131
- kind: 'array.move',
132
- path: path,
133
- itemId: patchItemId(item, clampedFrom),
134
- toIndex: clampedTo,
135
- })
136
- }
104
+ setItems((prev) => moveItem(prev, move.fromIndex, move.toIndex))
105
+ setFieldStore(path, move.items)
106
+ appendPatch({
107
+ kind: 'array.move',
108
+ path,
109
+ itemId: move.itemId,
110
+ toIndex: move.toIndex,
111
+ })
137
112
  }
138
113
 
139
114
  const handleAddItem = async (atIndex?: number) => {
@@ -161,6 +136,11 @@ export const ArrayField = ({
161
136
  }
162
137
  }
163
138
 
139
+ // Defaults may resolve asynchronously. If an enclosing stable-id item was
140
+ // removed in the meantime, do not append a structural patch that would
141
+ // recreate that missing parent on the server.
142
+ if (!hasExistingIdTargets(getFieldValues(), path)) return
143
+
164
144
  const currentArray = (getFieldValue(path) ?? defaultValue) as any[]
165
145
  const insertAt = atIndex != null ? atIndex : currentArray ? currentArray.length : 0
166
146
 
@@ -188,13 +168,15 @@ export const ArrayField = ({
188
168
  if (!Array.isArray(currentArray) || index < 0 || index >= currentArray.length) return
189
169
 
190
170
  const item = currentArray[index]
171
+ const itemPath = repeatingItemPath(path, item, index)
191
172
 
192
173
  setItems((prev) => prev.filter((_, i) => i !== index))
174
+ removePendingUploadsUnder(itemPath)
193
175
 
194
176
  appendPatch({
195
177
  kind: 'array.remove',
196
178
  path: path,
197
- itemId: patchItemId(item, index),
179
+ itemId: repeatingItemId(item) ?? String(index),
198
180
  })
199
181
 
200
182
  const newArrayValue = [...currentArray]
@@ -208,7 +190,7 @@ export const ArrayField = ({
208
190
 
209
191
  const renderItem = (itemWrapper: { id: string; data: any }, index: number) => {
210
192
  const item = itemWrapper.data
211
- const arrayElementPath = `${path}[${index}]`
193
+ const arrayElementPath = repeatingItemPath(path, item, index)
212
194
 
213
195
  if (!item || typeof item !== 'object') return null
214
196
 
@@ -242,7 +224,6 @@ export const ArrayField = ({
242
224
  defaultValue={groupData[innerField.name]}
243
225
  basePath={`${arrayElementPath}.${childField.name}`}
244
226
  disableSorting={true}
245
- collectionPath={collectionPath}
246
227
  contentLocale={contentLocale}
247
228
  components={groupAdmin?.[innerField.name]?.components}
248
229
  editor={groupAdmin?.[innerField.name]?.editor}
@@ -260,7 +241,6 @@ export const ArrayField = ({
260
241
  defaultValue={initial}
261
242
  basePath={arrayElementPath}
262
243
  disableSorting={true}
263
- collectionPath={collectionPath}
264
244
  contentLocale={contentLocale}
265
245
  components={fieldAdmin?.[childField.name]?.components}
266
246
  editor={fieldAdmin?.[childField.name]?.editor}
@@ -31,6 +31,8 @@ import { defaultScalarForField } from '../../fields/field-helpers'
31
31
  import { GroupField } from '../../fields/group/group-field'
32
32
  import { SortableItem } from '../../fields/sortable-item'
33
33
  import { useFormContext } from '../../forms/form-context'
34
+ import { hasExistingIdTargets } from '../../forms/nested-path'
35
+ import { moveRepeatingItems, repeatingItemId, repeatingItemPath } from '../../forms/repeating-items'
34
36
  import styles from './blocks-field.module.css'
35
37
 
36
38
  // ---------------------------------------------------------------------------
@@ -54,7 +56,8 @@ export const BlocksField = ({
54
56
  */
55
57
  contentLocale?: string
56
58
  }) => {
57
- const { appendPatch, getFieldValue, getFieldValues, setFieldStore } = useFormContext()
59
+ const { appendPatch, getFieldValue, getFieldValues, removePendingUploadsUnder, setFieldStore } =
60
+ useFormContext()
58
61
  const { t } = useTranslation('byline-admin')
59
62
  const [items, setItems] = useState<{ id: string; data: any }[]>([])
60
63
  const [showAddBlockModal, setShowAddBlockModal] = useState(false)
@@ -118,27 +121,20 @@ export const BlocksField = ({
118
121
  moveFromIndex: number
119
122
  moveToIndex: number
120
123
  }) => {
121
- setItems((prev) => moveItem(prev, moveFromIndex, moveToIndex))
122
124
  const currentArray = (getFieldValue(path) ?? defaultValue) as any[]
125
+ if (!Array.isArray(currentArray)) return
123
126
 
124
- if (Array.isArray(currentArray)) {
125
- const clampedFrom = Math.max(0, Math.min(moveFromIndex, currentArray.length - 1))
126
- const clampedTo = Math.max(0, Math.min(moveToIndex, currentArray.length - 1))
127
- if (clampedFrom === clampedTo) return
128
-
129
- const item = currentArray[clampedFrom]
130
- const itemId =
131
- item && typeof item === 'object' && '_id' in item
132
- ? String((item as { _id: string })._id)
133
- : String(clampedFrom)
134
-
135
- appendPatch({
136
- kind: 'array.move',
137
- path: path,
138
- itemId,
139
- toIndex: clampedTo,
140
- })
141
- }
127
+ const move = moveRepeatingItems(currentArray, moveFromIndex, moveToIndex)
128
+ if (move == null) return
129
+
130
+ setItems((prev) => moveItem(prev, move.fromIndex, move.toIndex))
131
+ setFieldStore(path, move.items)
132
+ appendPatch({
133
+ kind: 'array.move',
134
+ path,
135
+ itemId: move.itemId,
136
+ toIndex: move.toIndex,
137
+ })
142
138
  }
143
139
 
144
140
  const handleAddItem = async (forcedVariantName?: string, atIndex?: number) => {
@@ -163,6 +159,8 @@ export const BlocksField = ({
163
159
  newItem[f.name] = await defaultScalarForField(f, getFieldValues)
164
160
  }
165
161
 
162
+ if (!hasExistingIdTargets(getFieldValues(), path)) return
163
+
166
164
  const currentArray = (getFieldValue(path) ?? defaultValue) as any[]
167
165
  const insertAt = atIndex != null ? atIndex : currentArray ? currentArray.length : 0
168
166
 
@@ -190,12 +188,11 @@ export const BlocksField = ({
190
188
  if (!Array.isArray(currentArray) || index < 0 || index >= currentArray.length) return
191
189
 
192
190
  const item = currentArray[index]
193
- const itemId =
194
- item && typeof item === 'object' && '_id' in item
195
- ? String((item as { _id: string })._id)
196
- : String(index)
191
+ const itemPath = repeatingItemPath(path, item, index)
192
+ const itemId = repeatingItemId(item) ?? String(index)
197
193
 
198
194
  setItems((prev) => prev.filter((_, i) => i !== index))
195
+ removePendingUploadsUnder(itemPath)
199
196
 
200
197
  appendPatch({
201
198
  kind: 'array.remove',
@@ -219,7 +216,7 @@ export const BlocksField = ({
219
216
 
220
217
  const renderItem = (itemWrapper: { id: string; data: any }, index: number) => {
221
218
  const item = itemWrapper.data
222
- const arrayElementPath = `${path}[${index}]`
219
+ const arrayElementPath = repeatingItemPath(path, item, index)
223
220
 
224
221
  if (!item || typeof item !== 'object' || typeof item._type !== 'string') return null
225
222
 
@@ -233,9 +230,9 @@ export const BlocksField = ({
233
230
  // Render the block's children directly with arrayElementPath as the
234
231
  // path (not basePath). FieldRenderer would append the group name
235
232
  // (e.g. "richTextBlock") producing paths like
236
- // "content[0].richTextBlock.constrainedWidth", but the flat block
233
+ // "content[id=...].richTextBlock.constrainedWidth", but the flat block
237
234
  // shape stores fields directly on the item so the correct path is
238
- // "content[0].constrainedWidth".
235
+ // "content[id=...].constrainedWidth".
239
236
  const body = (
240
237
  <GroupField
241
238
  key={subField.blockType}
@@ -24,7 +24,7 @@ const CodeEditor = React.lazy(() => import('./code-editor'))
24
24
 
25
25
  /**
26
26
  * Resolve the form-store path of a sibling field (same group/block/array
27
- * item scope). `content[0].code` + `language` → `content[0].language`;
27
+ * item scope). `content[id=x].code` + `language` → `content[id=x].language`;
28
28
  * a top-level `code` + `language` → `language`.
29
29
  */
30
30
  const siblingFieldPath = (fieldPath: string, siblingName: string): string => {
@@ -49,8 +49,6 @@ interface FieldRendererProps {
49
49
  basePath?: string
50
50
  disableSorting?: boolean
51
51
  hideLabel?: boolean
52
- /** Collection path (e.g. `'media'`) forwarded to upload-capable fields. */
53
- collectionPath?: string
54
52
  /**
55
53
  * The active content locale (e.g. `'en'`, `'fr'`). When provided and
56
54
  * `field.localized === true`, a small locale badge is shown so the editor
@@ -86,7 +84,6 @@ export const FieldRenderer = ({
86
84
  basePath,
87
85
  disableSorting,
88
86
  hideLabel,
89
- collectionPath,
90
87
  contentLocale,
91
88
  components,
92
89
  editor,
@@ -256,7 +253,6 @@ export const FieldRenderer = ({
256
253
  defaultValue={defaultValue}
257
254
  onChange={handleChange}
258
255
  path={path}
259
- collectionPath={collectionPath}
260
256
  />
261
257
  )
262
258
  case 'image':
@@ -266,7 +262,6 @@ export const FieldRenderer = ({
266
262
  defaultValue={defaultValue}
267
263
  onChange={handleChange}
268
264
  path={path}
269
- collectionPath={collectionPath}
270
265
  />
271
266
  )
272
267
  case 'relation':
@@ -301,7 +296,6 @@ export const FieldRenderer = ({
301
296
  defaultValue={defaultValue}
302
297
  path={path}
303
298
  disableSorting={disableSorting}
304
- collectionPath={collectionPath}
305
299
  contentLocale={contentLocale}
306
300
  fieldAdmin={fieldAdmin}
307
301
  />
@@ -324,7 +318,6 @@ export const FieldRenderer = ({
324
318
  defaultValue={defaultValue}
325
319
  path={path}
326
320
  disableSorting={disableSorting}
327
- collectionPath={collectionPath}
328
321
  contentLocale={contentLocale}
329
322
  fieldAdmin={fieldAdmin}
330
323
  />