@firecms/core 3.4.0-canary.63041d0 → 3.4.0-canary.d20cc85

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 (32) hide show
  1. package/dist/components/EntityCollectionTable/internal/popup_field/PopupFormField.d.ts +3 -1
  2. package/dist/components/EntityCollectionView/EntityCollectionBoardView.d.ts +3 -1
  3. package/dist/components/EntityCollectionView/useBoardDataController.d.ts +3 -1
  4. package/dist/components/ReferenceTable/ReferenceSelectionTable.d.ts +10 -1
  5. package/dist/form/EntityForm.d.ts +6 -1
  6. package/dist/hooks/data/useCollectionFetch.d.ts +7 -1
  7. package/dist/index.es.js +177 -112
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/index.umd.js +177 -112
  10. package/dist/index.umd.js.map +1 -1
  11. package/dist/types/datasource.d.ts +17 -4
  12. package/dist/types/entities.d.ts +21 -1
  13. package/dist/util/parent_references_from_path.d.ts +1 -0
  14. package/package.json +4 -4
  15. package/src/components/EntityCollectionTable/internal/popup_field/PopupFormField.tsx +5 -1
  16. package/src/components/EntityCollectionView/EntityCollectionBoardView.tsx +4 -0
  17. package/src/components/EntityCollectionView/EntityCollectionView.tsx +10 -2
  18. package/src/components/EntityCollectionView/useBoardDataController.tsx +11 -0
  19. package/src/components/ReferenceTable/ReferenceSelectionTable.tsx +11 -0
  20. package/src/components/common/useDataSourceTableController.tsx +8 -2
  21. package/src/core/EntityEditView.tsx +5 -2
  22. package/src/form/EntityForm.tsx +8 -2
  23. package/src/hooks/data/delete.ts +2 -0
  24. package/src/hooks/data/save.ts +1 -1
  25. package/src/hooks/data/useCollectionFetch.tsx +13 -0
  26. package/src/hooks/data/useEntityFetch.tsx +10 -1
  27. package/src/hooks/useResolvedNavigationFrom.tsx +2 -0
  28. package/src/internal/useBuildDataSource.ts +14 -4
  29. package/src/types/datasource.ts +17 -4
  30. package/src/types/entities.ts +24 -1
  31. package/src/util/entities.ts +3 -1
  32. package/src/util/parent_references_from_path.ts +10 -2
@@ -96,6 +96,11 @@ export interface SaveEntityProps<M extends Record<string, any> = any> {
96
96
  */
97
97
  export interface DeleteEntityProps<M extends Record<string, any> = any> {
98
98
  entity: Entity<M>;
99
+ /**
100
+ * `entity.path` split at its real segment boundaries, when the caller knows them.
101
+ * Never derived from `entity.path` — see `pathSegments` on FetchCollectionProps.
102
+ */
103
+ pathSegments?: string[];
99
104
  collection?: EntityCollection<M> | ResolvedEntityCollection<M>;
100
105
  }
101
106
  /**
@@ -175,11 +180,17 @@ export interface DataSource {
175
180
  * @param entityId
176
181
  * @return `true` if there are no other fields besides the given entity
177
182
  */
178
- checkUniqueField(path: string, name: string, value: any, entityId?: string, collection?: EntityCollection): Promise<boolean>;
183
+ checkUniqueField(path: string, name: string, value: any, entityId?: string, collection?: EntityCollection,
184
+ /**
185
+ * Appended last, and optional, so existing implementations and callers keep
186
+ * working unchanged — a function declaring fewer parameters stays assignable.
187
+ * See `pathSegments` on FetchCollectionProps.
188
+ */
189
+ pathSegments?: string[]): Promise<boolean>;
179
190
  /**
180
191
  * Generate an id for a new entity
181
192
  */
182
- generateEntityId(path: string, collection: EntityCollection): string;
193
+ generateEntityId(path: string, collection: EntityCollection, pathSegments?: string[]): string;
183
194
  /**
184
195
  * Count the number of entities in a collection
185
196
  */
@@ -202,6 +213,8 @@ export interface DataSource {
202
213
  }) => Promise<boolean>;
203
214
  }
204
215
  export type FilterCombinationValidProps = {
216
+ /** `path` split at its real segment boundaries — see `pathSegments` on FetchCollectionProps. */
217
+ pathSegments?: string[];
205
218
  path: string;
206
219
  collection: EntityCollection<any>;
207
220
  filterValues: FilterValues<any>;
@@ -287,11 +300,11 @@ export interface DataSourceDelegate {
287
300
  * @param collection
288
301
  * @return `true` if there are no other fields besides the given entity
289
302
  */
290
- checkUniqueField(path: string, name: string, value: any, entityId?: string, collection?: EntityCollection): Promise<boolean>;
303
+ checkUniqueField(path: string, name: string, value: any, entityId?: string, collection?: EntityCollection, pathSegments?: string[]): Promise<boolean>;
291
304
  /**
292
305
  * Generate an id for a new entity
293
306
  */
294
- generateEntityId(path: string, collection?: EntityCollection): string;
307
+ generateEntityId(path: string, collection?: EntityCollection, pathSegments?: string[]): string;
295
308
  /**
296
309
  * Count the number of entities in a collection
297
310
  */
@@ -17,6 +17,16 @@ export interface Entity<M extends object = any> {
17
17
  * to the root of the database).
18
18
  */
19
19
  path: string;
20
+ /**
21
+ * `path` split at its real segment boundaries, e.g. `["nodes", "node/42", "edges"]`.
22
+ *
23
+ * `path` is a single flattened string, so a parent entity id containing "/" cannot be
24
+ * recovered from it. This keeps each segment whole.
25
+ *
26
+ * Optional, and never derived by splitting `path` — a guess would be wrong in exactly
27
+ * the case the field exists for. Absent means "not known here", not "no slashes".
28
+ */
29
+ pathSegments?: string[];
20
30
  /**
21
31
  * Current values
22
32
  */
@@ -46,7 +56,17 @@ export declare class EntityReference {
46
56
  * Optional database ID where the entity is stored (if multiple databases are used)
47
57
  */
48
58
  readonly databaseId?: string;
49
- constructor(id: string, path: string, databaseId?: string);
59
+ /**
60
+ * `path` split at its real segment boundaries, e.g. `["nodes", "node/42", "edges"]`.
61
+ *
62
+ * `path` is a single flattened string, so a parent entity id containing "/" cannot be
63
+ * recovered from it. This keeps each segment whole.
64
+ *
65
+ * Optional, and never derived by splitting `path` — a guess would be wrong in exactly
66
+ * the case the field exists for. Absent means "not known here", not "no slashes".
67
+ */
68
+ readonly pathSegments?: string[];
69
+ constructor(id: string, path: string, databaseId?: string, pathSegments?: string[]);
50
70
  get pathWithId(): string;
51
71
  get pathWithIdAndDatabase(): string;
52
72
  isEntityReference(): boolean;
@@ -3,4 +3,5 @@ export declare function getParentReferencesFromPath(props: {
3
3
  path: string;
4
4
  collections: EntityCollection[] | undefined;
5
5
  currentFullPath?: string;
6
+ currentPathSegments?: string[];
6
7
  }): EntityReference[];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@firecms/core",
3
3
  "type": "module",
4
- "version": "3.4.0-canary.63041d0",
4
+ "version": "3.4.0-canary.d20cc85",
5
5
  "description": "Awesome Firebase/Firestore-based headless open-source CMS",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/firecmsco"
@@ -53,8 +53,8 @@
53
53
  "@dnd-kit/core": "^6.3.1",
54
54
  "@dnd-kit/modifiers": "^9.0.0",
55
55
  "@dnd-kit/sortable": "^10.0.0",
56
- "@firecms/formex": "3.4.0-canary.63041d0",
57
- "@firecms/ui": "3.4.0-canary.63041d0",
56
+ "@firecms/formex": "3.4.0-canary.d20cc85",
57
+ "@firecms/ui": "3.4.0-canary.d20cc85",
58
58
  "@floating-ui/dom": "^1.7.4",
59
59
  "@radix-ui/react-portal": "^1.1.10",
60
60
  "@radix-ui/react-slot": "^1.2.4",
@@ -89,7 +89,7 @@
89
89
  "prosemirror-view": "^1.33.8",
90
90
  "react-dropzone": "^14.3.8",
91
91
  "react-fast-compare": "^3.2.2",
92
- "react-i18next": "^14.1.3",
92
+ "react-i18next": "^17.0.11",
93
93
  "react-image-crop": "^11.0.10",
94
94
  "react-markdown": "^9.1.0",
95
95
  "react-moveable": "^0.56.0",
@@ -26,6 +26,8 @@ import { OnCellValueChangeParams } from "../../../common";
26
26
  interface PopupFormFieldProps<M extends Record<string, any>> {
27
27
  customFieldValidator?: CustomFieldValidator;
28
28
  path: string;
29
+ /** `path` split at its real segment boundaries; forwarded to the datasource. */
30
+ pathSegments?: string[];
29
31
  entityId: string;
30
32
  tableKey: string;
31
33
  propertyKey?: Extract<keyof M, string>;
@@ -54,6 +56,7 @@ export function PopupFormFieldLoading<M extends Record<string, any>>({
54
56
  propertyKey,
55
57
  collection: inputCollection,
56
58
  path,
59
+ pathSegments,
57
60
  cellRect,
58
61
  open,
59
62
  onClose,
@@ -66,11 +69,12 @@ export function PopupFormFieldLoading<M extends Record<string, any>>({
66
69
  if (entityId && inputCollection) {
67
70
  dataSource.fetchEntity({
68
71
  path,
72
+ pathSegments,
69
73
  entityId,
70
74
  collection: inputCollection
71
75
  }).then(setEntity);
72
76
  }
73
- }, [entityId, inputCollection, dataSource, path]);
77
+ }, [entityId, inputCollection, dataSource, path, pathSegments]);
74
78
 
75
79
  if (!entity) return null;
76
80
  return <PopupFormFieldInternal {...{
@@ -42,6 +42,8 @@ export type EntityCollectionBoardViewProps<M extends Record<string, any> = any>
42
42
  collection: EntityCollection<M>;
43
43
  tableController: EntityTableController<M>;
44
44
  fullPath: string;
45
+ /** `fullPath` split at its real segment boundaries; forwarded to the datasource. */
46
+ pathSegments?: string[];
45
47
  parentCollectionIds?: string[];
46
48
  columnProperty: string;
47
49
  onEntityClick?: (entity: Entity<M>) => void;
@@ -60,6 +62,7 @@ export function EntityCollectionBoardView<M extends Record<string, any> = any>({
60
62
  collection,
61
63
  tableController,
62
64
  fullPath,
65
+ pathSegments,
63
66
  parentCollectionIds = [],
64
67
  columnProperty,
65
68
  onEntityClick,
@@ -169,6 +172,7 @@ export function EntityCollectionBoardView<M extends Record<string, any> = any>({
169
172
  // Use the new per-column data controller
170
173
  const boardDataController = useBoardDataController<M>({
171
174
  fullPath,
175
+ pathSegments,
172
176
  collection,
173
177
  columnProperty,
174
178
  columns,
@@ -789,6 +789,7 @@ export const EntityCollectionView = React.memo(
789
789
  // EntitiesCount fetches count and updates breadcrumb - no visual rendering needed here
790
790
  const countFetcher = <EntitiesCount
791
791
  fullPath={fullPath}
792
+ pathSegments={pathSegments}
792
793
  collection={collection}
793
794
  filter={tableController.filterValues}
794
795
  sortBy={tableController.sortBy}
@@ -933,6 +934,7 @@ export const EntityCollectionView = React.memo(
933
934
  collection={collection}
934
935
  tableController={tableController}
935
936
  fullPath={fullPath}
937
+ pathSegments={pathSegments}
936
938
  parentCollectionIds={parentCollectionIds}
937
939
  columnProperty={selectedKanbanProperty}
938
940
  onEntityClick={onEntityClick}
@@ -1063,6 +1065,7 @@ export const EntityCollectionView = React.memo(
1063
1065
 
1064
1066
  {popupCell && <PopupFormField
1065
1067
  key={`popup_form_${popupCell?.propertyKey}_${popupCell?.entityId}`}
1068
+ pathSegments={pathSegments}
1066
1069
  open={Boolean(popupCell)}
1067
1070
  onClose={onPopupClose}
1068
1071
  cellRect={popupCell?.cellRect}
@@ -1118,13 +1121,16 @@ function EntitiesCount({
1118
1121
  collection,
1119
1122
  filter,
1120
1123
  sortBy,
1121
- onCountChange
1124
+ onCountChange,
1125
+ pathSegments
1122
1126
  }: {
1123
1127
  fullPath: string,
1124
1128
  collection: EntityCollection,
1125
1129
  filter?: FilterValues<any>,
1126
1130
  sortBy?: [string, "asc" | "desc"],
1127
1131
  onCountChange?: (count: number) => void,
1132
+ /** `fullPath` split at its real segment boundaries; forwarded to countEntities. */
1133
+ pathSegments?: string[],
1128
1134
  }) {
1129
1135
 
1130
1136
  const dataSource = useDataSource(collection);
@@ -1135,17 +1141,19 @@ function EntitiesCount({
1135
1141
  const sortByProperty = sortBy ? sortBy[0] : undefined;
1136
1142
  const currentSort = sortBy ? sortBy[1] : undefined;
1137
1143
  const resolvedPath = useMemo(() => navigation.resolveIdsFrom(fullPath), [fullPath, navigation.resolveIdsFrom]);
1144
+ const resolvedPathSegments = pathSegments;
1138
1145
 
1139
1146
  useEffect(() => {
1140
1147
  if (dataSource.countEntities)
1141
1148
  dataSource.countEntities({
1142
1149
  path: resolvedPath,
1150
+ pathSegments: resolvedPathSegments,
1143
1151
  collection,
1144
1152
  filter,
1145
1153
  orderBy: sortByProperty,
1146
1154
  order: currentSort
1147
1155
  }).then(setCount).catch(setError);
1148
- }, [fullPath, dataSource.countEntities, resolvedPath, collection, filter, sortByProperty, currentSort]);
1156
+ }, [fullPath, dataSource.countEntities, resolvedPath, resolvedPathSegments, collection, filter, sortByProperty, currentSort]);
1149
1157
 
1150
1158
  useEffect(() => {
1151
1159
  if (onCountChange && count !== undefined) {
@@ -45,6 +45,8 @@ export interface BoardDataController<M extends Record<string, any> = any, COLUMN
45
45
  export interface UseBoardDataControllerProps<M extends Record<string, any> = any> {
46
46
  /** Full path to the collection */
47
47
  fullPath: string;
48
+ /** `fullPath` split at its real segment boundaries; forwarded to the datasource. */
49
+ pathSegments?: string[];
48
50
  /** The entity collection configuration */
49
51
  collection: EntityCollection<M>;
50
52
  /** Property key used for column assignment */
@@ -67,6 +69,7 @@ export interface UseBoardDataControllerProps<M extends Record<string, any> = any
67
69
  */
68
70
  export function useBoardDataController<M extends Record<string, any> = any, COLUMN extends string = string>({
69
71
  fullPath,
72
+ pathSegments,
70
73
  collection,
71
74
  columnProperty,
72
75
  columns,
@@ -80,6 +83,7 @@ export function useBoardDataController<M extends Record<string, any> = any, COLU
80
83
  const dataSource = useDataSource(collection);
81
84
  const navigation = useNavigationController();
82
85
  const resolvedPath = useMemo(() => navigation.resolveIdsFrom(fullPath), [fullPath, navigation.resolveIdsFrom]);
86
+ const resolvedPathSegments = pathSegments;
83
87
 
84
88
  // Stable refs for objects that shouldn't trigger re-subscriptions
85
89
  const dataSourceRef = useRef(dataSource);
@@ -95,11 +99,13 @@ export function useBoardDataController<M extends Record<string, any> = any, COLU
95
99
  const orderPropertyRef = useRef(orderProperty);
96
100
  const searchStringRef = useRef(searchString);
97
101
  const resolvedPathRef = useRef(resolvedPath);
102
+ const resolvedPathSegmentsRef = useRef(resolvedPathSegments);
98
103
  filterValuesRef.current = filterValues;
99
104
  columnPropertyRef.current = columnProperty;
100
105
  orderPropertyRef.current = orderProperty;
101
106
  searchStringRef.current = searchString;
102
107
  resolvedPathRef.current = resolvedPath;
108
+ resolvedPathSegmentsRef.current = resolvedPathSegments;
103
109
 
104
110
  // Track item count per column for pagination
105
111
  const [columnItemCounts, setColumnItemCounts] = useState<Record<string, number>>(() => {
@@ -168,6 +174,7 @@ export function useBoardDataController<M extends Record<string, any> = any, COLU
168
174
  const currentOrderProperty = orderPropertyRef.current;
169
175
  const currentSearchString = searchStringRef.current;
170
176
  const currentResolvedPath = resolvedPathRef.current;
177
+ const currentPathSegments = resolvedPathSegmentsRef.current;
171
178
 
172
179
  // Build filter for this column
173
180
  const columnFilter: FilterValues<string> = {
@@ -248,6 +255,7 @@ export function useBoardDataController<M extends Record<string, any> = any, COLU
248
255
  if (currentDataSource.listenCollection) {
249
256
  const unsubscribe = currentDataSource.listenCollection<M>({
250
257
  path: currentResolvedPath,
258
+ pathSegments: currentPathSegments,
251
259
  collection: currentCollection,
252
260
  onUpdate,
253
261
  onError,
@@ -262,6 +270,7 @@ export function useBoardDataController<M extends Record<string, any> = any, COLU
262
270
  } else {
263
271
  currentDataSource.fetchCollection<M>({
264
272
  path: currentResolvedPath,
273
+ pathSegments: currentPathSegments,
265
274
  collection: currentCollection,
266
275
  searchString: currentSearchString,
267
276
  filter: columnFilter,
@@ -297,6 +306,7 @@ export function useBoardDataController<M extends Record<string, any> = any, COLU
297
306
  const currentColumnProperty = columnPropertyRef.current;
298
307
  const currentSearchString = searchStringRef.current;
299
308
  const currentResolvedPath = resolvedPathRef.current;
309
+ const currentPathSegments = resolvedPathSegmentsRef.current;
300
310
  const currentColumns = columns;
301
311
  const currentColumnItemCounts = columnItemCounts;
302
312
 
@@ -316,6 +326,7 @@ export function useBoardDataController<M extends Record<string, any> = any, COLU
316
326
  };
317
327
  currentDataSource.countEntities({
318
328
  path: currentResolvedPath,
329
+ pathSegments: currentPathSegments,
319
330
  collection: currentCollection,
320
331
  filter: columnFilter,
321
332
  searchString: currentSearchString
@@ -44,6 +44,15 @@ export interface ReferenceSelectionInnerProps<M extends Record<string, any>> {
44
44
  * dynamic. If not set, the dialog won't open.
45
45
  */
46
46
  path: string;
47
+ /**
48
+ * `path` split at its real segment boundaries, when the caller knows them.
49
+ *
50
+ * Left undefined by default and deliberately NOT derived from `path`: a reference
51
+ * path comes from a property's configuration, so there is no entity chain to recover
52
+ * the boundaries from. Splitting it would silently produce the wrong segments for a
53
+ * subcollection under a slash-bearing parent id, which is worse than not answering.
54
+ */
55
+ pathSegments?: string[];
47
56
 
48
57
  /**
49
58
  * If you are opening the dialog for the first time, you can select some
@@ -96,6 +105,7 @@ export function ReferenceSelectionTable<M extends Record<string, any>>(
96
105
  multiselect,
97
106
  collection,
98
107
  path: pathInput,
108
+ pathSegments,
99
109
  selectedEntityIds: selectedEntityIdsProp,
100
110
  description,
101
111
  forceFilter,
@@ -153,6 +163,7 @@ export function ReferenceSelectionTable<M extends Record<string, any>>(
153
163
  selectedEntityIds.map((entityId) =>
154
164
  dataSource.fetchEntity({
155
165
  path: fullPath,
166
+ pathSegments,
156
167
  entityId,
157
168
  collection
158
169
  })))
@@ -95,8 +95,8 @@ export function useDataSourceTableController<M extends Record<string, any> = any
95
95
  const navigation = useNavigationController();
96
96
  const dataSource = useDataSource(collection);
97
97
  const resolvedPath = useMemo(() => navigation.resolveIdsFrom(fullPath), [fullPath, navigation.resolveIdsFrom]);
98
- const resolvedPathSegments = useMemo(() => pathSegments ?? resolvedPath.split("/"),
99
- [pathSegments, resolvedPath]);
98
+ // Passed through as-is; never derived from `resolvedPath` (see useEntityFetch).
99
+ const resolvedPathSegments = pathSegments;
100
100
 
101
101
  const forceFilter = forceFilterFromProps ?? forceFilterFromCollection;
102
102
  const paginationEnabled = collection.pagination === undefined || Boolean(collection.pagination);
@@ -108,6 +108,7 @@ export function useDataSourceTableController<M extends Record<string, any> = any
108
108
  return true;
109
109
  return dataSource.isFilterCombinationValid({
110
110
  path: resolvedPath,
111
+ pathSegments: resolvedPathSegments,
111
112
  collection,
112
113
  filterValues,
113
114
  sortBy
@@ -234,6 +235,11 @@ export function useDataSourceTableController<M extends Record<string, any> = any
234
235
  setDataLoading(true);
235
236
 
236
237
  const onEntitiesUpdate = async (entities: Entity<M>[]) => {
238
+ // See useEntityFetch: entities carry the segments they were loaded with, so a
239
+ // reference or a delete derived from a table row stays resolvable.
240
+ if (resolvedPathSegments) {
241
+ entities = entities.map(e => e.pathSegments ? e : { ...e, pathSegments: resolvedPathSegments });
242
+ }
237
243
  if (collection.callbacks?.onFetch) {
238
244
  try {
239
245
  entities = await Promise.all(
@@ -361,9 +361,11 @@ export function EntityEditViewInner<M extends Record<string, any>>({
361
361
  const newFullIdPath = fullIdPath && usedEntity ? `${fullIdPath}/${encodeEntityId(usedEntity.id)}/${removeInitialAndTrailingSlashes(subcollectionId)}` : undefined;
362
362
  // The subcollection sits under this entity, so its segments are ours plus this
363
363
  // entity's id (kept whole, slashes and all) plus the subcollection's own path.
364
- const newPathSegments = usedEntity
364
+ // Only extendable when we were given real segments — deriving the parent chain
365
+ // from the flattened `path` would drop the boundary of a slash-bearing parent id.
366
+ const newPathSegments = usedEntity && pathSegments
365
367
  ? [
366
- ...(pathSegments ?? path.split("/")),
368
+ ...pathSegments,
367
369
  usedEntity.id,
368
370
  ...removeInitialAndTrailingSlashes(subcollection.path).split("/")
369
371
  ]
@@ -432,6 +434,7 @@ export function EntityEditViewInner<M extends Record<string, any>>({
432
434
  fullIdPath={fullIdPath}
433
435
  collection={collection}
434
436
  path={path}
437
+ pathSegments={pathSegments}
435
438
  entityId={entityId ?? usedEntity?.id}
436
439
  onValuesModified={onValuesModified}
437
440
  entity={entity}
@@ -71,6 +71,11 @@ export type OnUpdateParams = {
71
71
 
72
72
  export type EntityFormProps<M extends Record<string, any>> = {
73
73
  path: string;
74
+ /**
75
+ * `path` split at its real segment boundaries; forwarded to the datasource for
76
+ * generateEntityId and checkUniqueField. Never derived from `path`.
77
+ */
78
+ pathSegments?: string[];
74
79
  fullIdPath?: string;
75
80
  collection: EntityCollection<M>;
76
81
  entityId?: string;
@@ -214,6 +219,7 @@ export function getChanges<T extends object>(source: Partial<T>, comparison: Par
214
219
 
215
220
  export function EntityForm<M extends Record<string, any>>({
216
221
  path,
222
+ pathSegments,
217
223
  fullIdPath,
218
224
  entityId: entityIdProp,
219
225
  collection,
@@ -292,7 +298,7 @@ export function EntityForm<M extends Record<string, any>>({
292
298
  if (mustSetCustomId) {
293
299
  return undefined;
294
300
  } else {
295
- return dataSource.generateEntityId(path, collection);
301
+ return dataSource.generateEntityId(path, collection, pathSegments);
296
302
  }
297
303
  } else {
298
304
  return entityIdProp;
@@ -692,7 +698,7 @@ export function EntityForm<M extends Record<string, any>>({
692
698
  const uniqueFieldValidator: CustomFieldValidator = useCallback(({
693
699
  name,
694
700
  value
695
- }) => dataSource.checkUniqueField(path, name, value, entityId, collection),
701
+ }) => dataSource.checkUniqueField(path, name, value, entityId, collection, pathSegments),
696
702
  [dataSource, path, entityId]);
697
703
 
698
704
  const validationSchema = useMemo(() => entityId
@@ -81,6 +81,8 @@ export async function deleteEntityWithCallbacks<M extends Record<string, any>, U
81
81
  }
82
82
  }
83
83
  return dataSource.deleteEntity({
84
+ // Carried by the entity from the fetch that produced it; undefined if unknown.
85
+ pathSegments: entity.pathSegments,
84
86
  entity,
85
87
  collection
86
88
  }).then(() => {
@@ -104,7 +104,7 @@ export async function saveEntityWithCallbacks<M extends Record<string, any>, USE
104
104
  return dataSource.saveEntity({
105
105
  collection,
106
106
  path: resolvedPath,
107
- pathSegments: pathSegments ?? resolvedPath.split("/"),
107
+ pathSegments,
108
108
  entityId,
109
109
  values: updatedValues,
110
110
  previousValues,
@@ -13,6 +13,12 @@ export interface CollectionFetchProps<M extends Record<string, any>> {
13
13
  * Absolute collection path
14
14
  */
15
15
  path: string;
16
+ /**
17
+ * `path` split at its real segment boundaries. Forwarded to the datasource so a parent
18
+ * entity id containing "/" stays unambiguous. Never derived from `path` — see
19
+ * `pathSegments` on the datasource props.
20
+ */
21
+ pathSegments?: string[];
16
22
 
17
23
  /**
18
24
  * collection of the entity displayed by this collection
@@ -63,6 +69,7 @@ export interface CollectionFetchResult<M extends Record<string, any>> {
63
69
  export function useCollectionFetch<M extends Record<string, any>, USER extends User>(
64
70
  {
65
71
  path: inputPath,
72
+ pathSegments,
66
73
  collection,
67
74
  filterValues,
68
75
  sortBy,
@@ -91,6 +98,10 @@ export function useCollectionFetch<M extends Record<string, any>, USER extends U
91
98
  setDataLoading(true);
92
99
 
93
100
  const onEntitiesUpdate = async (entities: Entity<M>[]) => {
101
+ // See useEntityFetch: entities carry the segments they were loaded with.
102
+ if (pathSegments) {
103
+ entities = entities.map(e => e.pathSegments ? e : { ...e, pathSegments });
104
+ }
94
105
  if (collection.callbacks?.onFetch) {
95
106
  try {
96
107
  entities = await Promise.all(
@@ -124,6 +135,7 @@ export function useCollectionFetch<M extends Record<string, any>, USER extends U
124
135
  if (dataSource.listenCollection) {
125
136
  return dataSource.listenCollection<M>({
126
137
  path,
138
+ pathSegments,
127
139
  collection,
128
140
  onUpdate: onEntitiesUpdate,
129
141
  onError,
@@ -137,6 +149,7 @@ export function useCollectionFetch<M extends Record<string, any>, USER extends U
137
149
  } else {
138
150
  dataSource.fetchCollection<M>({
139
151
  path,
152
+ pathSegments,
140
153
  collection,
141
154
  searchString,
142
155
  filter: filterValues,
@@ -55,7 +55,10 @@ export function useEntityFetch<M extends Record<string, any>, USER extends User>
55
55
  const navigationController = useNavigationController();
56
56
 
57
57
  const path = navigationController.resolveIdsFrom(inputPath);
58
- const pathSegments = inputPathSegments ?? path.split("/");
58
+ // Never fabricate segments. If the caller did not thread them, pass undefined so a
59
+ // delegate can tell "not provided" from a real answer — splitting `path` here would
60
+ // produce a confidently wrong array for any id containing "/".
61
+ const pathSegments = inputPathSegments;
59
62
 
60
63
  const context: FireCMSContext<USER> = useFireCMSContext();
61
64
 
@@ -68,6 +71,12 @@ export function useEntityFetch<M extends Record<string, any>, USER extends User>
68
71
  setDataLoading(true);
69
72
 
70
73
  const onEntityUpdate = async (updatedEntity: Entity<M> | undefined) => {
74
+ // Attach the segments this entity was loaded with, so anything derived from it
75
+ // later — a reference, a delete — stays resolvable without re-deriving them
76
+ // from the flattened path. Delegates are not required to set this themselves.
77
+ if (updatedEntity && pathSegments && !updatedEntity.pathSegments) {
78
+ updatedEntity = { ...updatedEntity, pathSegments };
79
+ }
71
80
  if (collection.callbacks?.onFetch && updatedEntity) {
72
81
  try {
73
82
  updatedEntity = await collection.callbacks.onFetch({
@@ -86,6 +86,8 @@ export function resolveNavigationFrom<M extends Record<string, any>, USER extend
86
86
  }
87
87
  return dataSource.fetchEntity({
88
88
  path: entry.path,
89
+ // The navigation entry already knows the real segment boundaries.
90
+ pathSegments: entry.pathSegments,
89
91
  entityId: entry.entityId,
90
92
  collection
91
93
  })
@@ -303,12 +303,15 @@ export function useBuildDataSource({
303
303
  deleteEntity: useCallback(<M extends Record<string, any>>(
304
304
  {
305
305
  entity,
306
+ pathSegments,
306
307
  collection
307
308
  }: DeleteEntityProps<M>
308
309
  ): Promise<void> => {
309
310
  const usedDelegate = collection?.overrides?.dataSourceDelegate ?? delegate;
310
311
  return usedDelegate.deleteEntity({
311
312
  entity,
313
+ // Falls back to what the entity carries from the fetch that produced it.
314
+ pathSegments: pathSegments ?? entity.pathSegments,
312
315
  collection
313
316
  });
314
317
  }, [delegate.deleteEntity]),
@@ -328,25 +331,28 @@ export function useBuildDataSource({
328
331
  name: string,
329
332
  value: any,
330
333
  entityId?: string,
331
- collection?: EntityCollection
334
+ collection?: EntityCollection,
335
+ pathSegments?: string[]
332
336
  ): Promise<boolean> => {
333
337
  const usedDelegate = collection?.overrides?.dataSourceDelegate ?? delegate;
334
- return usedDelegate.checkUniqueField(path, name, value, entityId, collection);
338
+ return usedDelegate.checkUniqueField(path, name, value, entityId, collection, pathSegments);
335
339
  }, [delegate.checkUniqueField]),
336
340
 
337
- generateEntityId: useCallback((path: string, collection: EntityCollection): string => {
341
+ generateEntityId: useCallback((path: string, collection: EntityCollection, pathSegments?: string[]): string => {
338
342
  const usedDelegate = collection?.overrides?.dataSourceDelegate ?? delegate;
339
- return usedDelegate.generateEntityId(path, collection);
343
+ return usedDelegate.generateEntityId(path, collection, pathSegments);
340
344
  }, [delegate.generateEntityId]),
341
345
 
342
346
  countEntities: delegate.countEntities ? async ({
343
347
  path,
348
+ pathSegments,
344
349
  collection,
345
350
  filter,
346
351
  order,
347
352
  orderBy
348
353
  }: {
349
354
  path: string,
355
+ pathSegments?: string[],
350
356
  collection: EntityCollection<any>,
351
357
  filter?: FilterValues<Extract<keyof any, string>>,
352
358
  orderBy?: string,
@@ -355,6 +361,7 @@ export function useBuildDataSource({
355
361
  const usedDelegate = collection?.overrides?.dataSourceDelegate ?? delegate;
356
362
  return usedDelegate.countEntities!({
357
363
  path,
364
+ pathSegments,
358
365
  filter,
359
366
  orderBy,
360
367
  order,
@@ -364,11 +371,13 @@ export function useBuildDataSource({
364
371
 
365
372
  isFilterCombinationValid: useCallback(({
366
373
  path,
374
+ pathSegments,
367
375
  databaseId,
368
376
  filterValues,
369
377
  sortBy
370
378
  }: {
371
379
  path: string,
380
+ pathSegments?: string[],
372
381
  databaseId?: string,
373
382
  filterValues: FilterValues<any>,
374
383
  sortBy?: [string, "asc" | "desc"]
@@ -378,6 +387,7 @@ export function useBuildDataSource({
378
387
  return delegate.isFilterCombinationValid(
379
388
  {
380
389
  path,
390
+ pathSegments,
381
391
  databaseId,
382
392
  filterValues,
383
393
  sortBy