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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/dist/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/EntityCollectionView.d.ts +3 -2
  4. package/dist/components/EntityCollectionView/EntityCollectionViewActions.d.ts +3 -1
  5. package/dist/components/EntityCollectionView/EntityCollectionViewStartActions.d.ts +3 -1
  6. package/dist/components/EntityCollectionView/useBoardDataController.d.ts +3 -1
  7. package/dist/components/ReferenceTable/ReferenceSelectionTable.d.ts +10 -1
  8. package/dist/components/common/useDataSourceTableController.d.ts +4 -2
  9. package/dist/components/common/useTableSearchHelper.d.ts +3 -1
  10. package/dist/core/EntityEditView.d.ts +2 -0
  11. package/dist/core/EntityEditViewFormActions.d.ts +1 -1
  12. package/dist/form/EntityForm.d.ts +8 -1
  13. package/dist/form/EntityFormActions.d.ts +2 -0
  14. package/dist/hooks/data/delete.d.ts +1 -1
  15. package/dist/hooks/data/useCollectionFetch.d.ts +7 -1
  16. package/dist/hooks/data/useEntityFetch.d.ts +4 -1
  17. package/dist/i18n/__tests__/FireCMSi18nProvider.test.d.ts +1 -0
  18. package/dist/i18n/__tests__/nested_provider_lifecycle.test.d.ts +1 -0
  19. package/dist/index.es.js +970 -652
  20. package/dist/index.es.js.map +1 -1
  21. package/dist/index.umd.js +969 -651
  22. package/dist/index.umd.js.map +1 -1
  23. package/dist/types/collections.d.ts +7 -0
  24. package/dist/types/datasource.d.ts +25 -4
  25. package/dist/types/entities.d.ts +21 -1
  26. package/dist/types/entity_actions.d.ts +6 -0
  27. package/dist/types/entity_callbacks.d.ts +21 -0
  28. package/dist/types/navigation.d.ts +34 -5
  29. package/dist/types/plugins.d.ts +13 -0
  30. package/dist/types/side_entity_controller.d.ts +3 -2
  31. package/dist/util/entity_cache.d.ts +11 -0
  32. package/dist/util/navigation_utils.d.ts +84 -2
  33. package/dist/util/parent_references_from_path.d.ts +16 -0
  34. package/dist/util/paths.d.ts +28 -0
  35. package/dist/util/permissions.d.ts +10 -4
  36. package/package.json +4 -4
  37. package/src/components/DeleteEntityDialog.tsx +2 -0
  38. package/src/components/EntityCollectionTable/EntityCollectionRowActions.tsx +2 -2
  39. package/src/components/EntityCollectionTable/internal/popup_field/PopupFormField.tsx +5 -1
  40. package/src/components/EntityCollectionView/EntityCollectionBoardView.tsx +12 -0
  41. package/src/components/EntityCollectionView/EntityCollectionView.tsx +49 -16
  42. package/src/components/EntityCollectionView/EntityCollectionViewActions.tsx +6 -2
  43. package/src/components/EntityCollectionView/EntityCollectionViewStartActions.tsx +4 -0
  44. package/src/components/EntityCollectionView/useBoardDataController.tsx +17 -1
  45. package/src/components/EntityPreview.tsx +4 -1
  46. package/src/components/ReferenceTable/ReferenceSelectionTable.tsx +19 -2
  47. package/src/components/common/default_entity_actions.tsx +22 -2
  48. package/src/components/common/useDataSourceTableController.tsx +32 -8
  49. package/src/components/common/useTableSearchHelper.ts +5 -0
  50. package/src/core/EntityEditView.tsx +11 -12
  51. package/src/core/EntityEditViewFormActions.tsx +3 -2
  52. package/src/core/EntitySidePanel.tsx +6 -4
  53. package/src/form/EntityForm.tsx +30 -9
  54. package/src/form/EntityFormActions.tsx +2 -0
  55. package/src/hooks/data/delete.ts +8 -0
  56. package/src/hooks/data/save.ts +5 -2
  57. package/src/hooks/data/useCollectionFetch.tsx +21 -1
  58. package/src/hooks/data/useEntityFetch.tsx +28 -6
  59. package/src/hooks/useBuildNavigationController.tsx +23 -8
  60. package/src/hooks/useResolvedNavigationFrom.tsx +3 -1
  61. package/src/i18n/FireCMSi18nProvider.tsx +48 -4
  62. package/src/i18n/__tests__/FireCMSi18nProvider.test.tsx +91 -0
  63. package/src/i18n/__tests__/nested_provider_lifecycle.test.tsx +67 -0
  64. package/src/internal/useBuildDataSource.ts +26 -15
  65. package/src/internal/useBuildSideEntityController.tsx +13 -5
  66. package/src/preview/components/ReferencePreview.tsx +7 -3
  67. package/src/routes/FireCMSRoute.tsx +9 -4
  68. package/src/types/collections.ts +8 -0
  69. package/src/types/datasource.ts +25 -4
  70. package/src/types/entities.ts +24 -1
  71. package/src/types/entity_actions.tsx +6 -0
  72. package/src/types/entity_callbacks.ts +24 -0
  73. package/src/types/navigation.ts +35 -5
  74. package/src/types/plugins.tsx +13 -0
  75. package/src/types/side_entity_controller.tsx +3 -2
  76. package/src/util/entities.ts +3 -1
  77. package/src/util/entity_cache.ts +18 -0
  78. package/src/util/navigation_from_path.ts +6 -1
  79. package/src/util/navigation_utils.ts +204 -2
  80. package/src/util/parent_references_from_path.ts +42 -3
  81. package/src/util/paths.ts +40 -3
  82. package/src/util/permissions.ts +22 -10
@@ -27,8 +27,10 @@ export type DataSourceTableControllerProps<M extends Record<string, any> = any>
27
27
  fullPath: string;
28
28
  /**
29
29
  * `fullPath` split at its real segment boundaries. Passed through to the datasource so
30
- * a parent entity id containing "/" stays unambiguous. Defaults to splitting
31
- * `fullPath`, which is correct for any backend whose ids cannot contain "/".
30
+ * a parent entity id containing "/" stays unambiguous.
31
+ *
32
+ * Optional, and never derived by splitting `fullPath` — a guess would be wrong in
33
+ * exactly the case the field exists for. Absent means "not known here", not "no slashes".
32
34
  */
33
35
  pathSegments?: string[];
34
36
  /**
@@ -94,9 +96,12 @@ export function useDataSourceTableController<M extends Record<string, any> = any
94
96
  const [popupCell, setPopupCell] = React.useState<SelectedCellProps<M> | undefined>(undefined);
95
97
  const navigation = useNavigationController();
96
98
  const dataSource = useDataSource(collection);
97
- const resolvedPath = useMemo(() => navigation.resolveIdsFrom(fullPath), [fullPath, navigation.resolveIdsFrom]);
98
- const resolvedPathSegments = useMemo(() => pathSegments ?? resolvedPath.split("/"),
99
- [pathSegments, resolvedPath]);
99
+ // Resolved together so the two representations always describe the same chain.
100
+ // Segments are resolved, never derived (see useEntityFetch).
101
+ const resolvedPath = useMemo(() => navigation.resolveIdsFrom(fullPath, pathSegments), [fullPath, pathSegments, navigation.resolveIdsFrom]);
102
+ const resolvedPathSegments = useMemo(() => pathSegments
103
+ ? (navigation.resolveSegmentsFrom?.(pathSegments) ?? pathSegments)
104
+ : undefined, [pathSegments, navigation.resolveSegmentsFrom]);
100
105
 
101
106
  const forceFilter = forceFilterFromProps ?? forceFilterFromCollection;
102
107
  const paginationEnabled = collection.pagination === undefined || Boolean(collection.pagination);
@@ -108,6 +113,7 @@ export function useDataSourceTableController<M extends Record<string, any> = any
108
113
  return true;
109
114
  return dataSource.isFilterCombinationValid({
110
115
  path: resolvedPath,
116
+ pathSegments: resolvedPathSegments,
111
117
  collection,
112
118
  filterValues,
113
119
  sortBy
@@ -188,11 +194,23 @@ export function useDataSourceTableController<M extends Record<string, any> = any
188
194
 
189
195
  useUpdateUrl(filterValues, sortBy, searchString, updateUrl);
190
196
 
191
- const collectionScroll = scrollRestoration?.getCollectionScroll(fullPath, filterValues);
192
- const initialItemCount = collectionScroll?.data.length ?? pageSize;
197
+ // Keyed by `resolvedPath`, not `fullPath`, so the read agrees with the two writes below
198
+ // and in `onScroll`. What the cache holds is a slice of a dataset plus the offset into
199
+ // it, and the dataset is identified by what the datasource was actually queried with:
200
+ // `resolvedPath` + filters. Reading with the unresolved `fullPath` meant that for every
201
+ // collection reached through an id alias, or a subcollection whose parent ids need
202
+ // resolving, the entry was written under one key and looked up under another, so scroll
203
+ // restoration silently did nothing there.
204
+ const collectionScroll = scrollRestoration?.getCollectionScroll(resolvedPath, filterValues);
205
+ const initialItemCount = collectionScroll?.data.length || pageSize;
193
206
 
194
207
  useEffect(() => {
195
- if (scrollRestoration) {
208
+ // Only re-seed an entry we actually have something to restore for. Writing an empty
209
+ // `data` array here poisoned the cache: this is a module level Map that outlives the
210
+ // mount, so the next mount of the same collection read `data.length === 0` back as the
211
+ // initial item count and called the datasource with `limit: 0`. Custom datasources
212
+ // treat that as falsy and drop the limit altogether, loading the whole collection.
213
+ if (scrollRestoration && rawData.length > 0) {
196
214
  scrollRestoration.updateCollectionScroll({
197
215
  fullPath: resolvedPath,
198
216
  scrollOffset: collectionScroll?.scrollOffset ?? 0,
@@ -234,6 +252,11 @@ export function useDataSourceTableController<M extends Record<string, any> = any
234
252
  setDataLoading(true);
235
253
 
236
254
  const onEntitiesUpdate = async (entities: Entity<M>[]) => {
255
+ // See useEntityFetch: entities carry the segments they were loaded with, so a
256
+ // reference or a delete derived from a table row stays resolvable.
257
+ if (resolvedPathSegments) {
258
+ entities = entities.map(e => e.pathSegments ? e : { ...e, pathSegments: resolvedPathSegments });
259
+ }
237
260
  if (collection.callbacks?.onFetch) {
238
261
  try {
239
262
  entities = await Promise.all(
@@ -241,6 +264,7 @@ export function useDataSourceTableController<M extends Record<string, any> = any
241
264
  collection.callbacks!.onFetch!({
242
265
  collection,
243
266
  path: resolvedPath,
267
+ pathSegments: resolvedPathSegments,
244
268
  entity,
245
269
  context
246
270
  })));
@@ -6,6 +6,8 @@ import { useCustomizationController, useDataSource, useFireCMSContext } from "..
6
6
  export interface UseTableSearchHelperParams<M extends Record<string, any>> {
7
7
  collection: EntityCollection<M>;
8
8
  fullPath: string;
9
+ /** `fullPath` split at its real segment boundaries, when known. Never derived from it. */
10
+ pathSegments?: string[];
9
11
  parentCollectionIds?: string[];
10
12
  /**
11
13
  * Search term that is already being applied without the user having gone through the
@@ -18,6 +20,7 @@ export interface UseTableSearchHelperParams<M extends Record<string, any>> {
18
20
  export function useTableSearchHelper<M extends Record<string, any>>({
19
21
  collection,
20
22
  fullPath,
23
+ pathSegments,
21
24
  parentCollectionIds,
22
25
  initialSearchString
23
26
  }: UseTableSearchHelperParams<M>) {
@@ -36,6 +39,7 @@ export function useTableSearchHelper<M extends Record<string, any>>({
36
39
  const props = {
37
40
  context,
38
41
  path: fullPath,
42
+ pathSegments,
39
43
  databaseId: collection.databaseId,
40
44
  collection,
41
45
  parentCollectionIds
@@ -62,6 +66,7 @@ export function useTableSearchHelper<M extends Record<string, any>>({
62
66
  promises.push(p.collectionView.onTextSearchClick({
63
67
  context,
64
68
  path: fullPath,
69
+ pathSegments,
65
70
  collection,
66
71
  parentCollectionIds
67
72
  }));
@@ -12,6 +12,7 @@ import {
12
12
 
13
13
  import { CircularProgressCenter, EntityCollectionView, EntityView, ErrorBoundary } from "../components";
14
14
  import {
15
+ buildSubcollectionPathSegments,
15
16
  canEditEntity,
16
17
  encodeEntityId,
17
18
  removeInitialAndTrailingSlashes,
@@ -28,7 +29,7 @@ import {
28
29
  useLargeLayout
29
30
  } from "../hooks";
30
31
  import { CircularProgress, cls, CodeIcon, defaultBorderMixin, Tab, Tabs, Typography, Menu, MenuItem, ExpandMoreIcon } from "@firecms/ui";
31
- import { getEntityFromMemoryCache } from "../util/entity_cache";
32
+ import { entityCacheKey, getEntityFromMemoryCache } from "../util/entity_cache";
32
33
  import { EntityForm, EntityFormProps } from "../form";
33
34
  import { EntityEditViewFormActions } from "./EntityEditViewFormActions";
34
35
  import { EntityJsonPreview } from "../components/EntityJsonPreview";
@@ -42,6 +43,8 @@ export type OnUpdateParams = {
42
43
  entity: Entity<any>,
43
44
  status: EntityStatus,
44
45
  path: string,
46
+ /** `path` split at its real segment boundaries, when known. Never derived from `path`. */
47
+ pathSegments?: string[],
45
48
  entityId?: string;
46
49
  selectedTab?: string;
47
50
  collection: EntityCollection<any>
@@ -115,7 +118,7 @@ export function EntityEditView<M extends Record<string, any>, USER extends User>
115
118
  });
116
119
 
117
120
  const initialDirtyValues = entityId
118
- ? getEntityFromMemoryCache(props.path + "/" + entityId)
121
+ ? getEntityFromMemoryCache(entityCacheKey(props.path, entityId))
119
122
  : getEntityFromMemoryCache(props.path + "#new");
120
123
 
121
124
  const authController = useAuthController();
@@ -127,7 +130,7 @@ export function EntityEditView<M extends Record<string, any>, USER extends User>
127
130
  if (status === "new" || status === "copy") {
128
131
  return true;
129
132
  } else {
130
- return entity ? canEditEntity(props.collection, authController, props.path, entity ?? null) : undefined;
133
+ return entity ? canEditEntity(props.collection, authController, props.path, entity ?? null, props.pathSegments) : undefined;
131
134
  }
132
135
  }, [authController, entity, status]);
133
136
 
@@ -202,6 +205,7 @@ export function EntityEditViewInner<M extends Record<string, any>>({
202
205
  entityId,
203
206
  parentCollectionIds,
204
207
  path,
208
+ pathSegments,
205
209
  status,
206
210
  collection,
207
211
  context,
@@ -359,15 +363,9 @@ export function EntityEditViewInner<M extends Record<string, any>>({
359
363
  const newFullPath = usedEntity ? `${path}/${usedEntity?.id}/${removeInitialAndTrailingSlashes(subcollection.path)}` : undefined;
360
364
  // `newFullIdPath` is fed to buildUrlCollectionPath downstream, so it stays escaped.
361
365
  const newFullIdPath = fullIdPath && usedEntity ? `${fullIdPath}/${encodeEntityId(usedEntity.id)}/${removeInitialAndTrailingSlashes(subcollectionId)}` : undefined;
362
- // The subcollection sits under this entity, so its segments are ours plus this
363
- // entity's id (kept whole, slashes and all) plus the subcollection's own path.
364
- const newPathSegments = usedEntity
365
- ? [
366
- ...(pathSegments ?? path.split("/")),
367
- usedEntity.id,
368
- ...removeInitialAndTrailingSlashes(subcollection.path).split("/")
369
- ]
370
- : undefined;
366
+ // Only extendable when we were given real segments — deriving the parent chain
367
+ // from the flattened `path` would drop the boundary of a slash-bearing parent id.
368
+ const newPathSegments = buildSubcollectionPathSegments(pathSegments, usedEntity?.id, subcollection.path);
371
369
 
372
370
  if (selectedTab !== subcollectionId) return null;
373
371
  return (
@@ -432,6 +430,7 @@ export function EntityEditViewInner<M extends Record<string, any>>({
432
430
  fullIdPath={fullIdPath}
433
431
  collection={collection}
434
432
  path={path}
433
+ pathSegments={pathSegments}
435
434
  entityId={entityId ?? usedEntity?.id}
436
435
  onValuesModified={onValuesModified}
437
436
  entity={entity}
@@ -39,6 +39,7 @@ import { ErrorTooltip } from "../components/ErrorTooltip";
39
39
  export function EntityEditViewFormActions({
40
40
  collection,
41
41
  path,
42
+ pathSegments,
42
43
  entity,
43
44
  layout,
44
45
  savingError,
@@ -63,8 +64,8 @@ export function EntityEditViewFormActions({
63
64
  const customEntityActions = (collection.entityActions ?? [])
64
65
  .map(action => resolveEntityAction(action, customizationController.entityActions))
65
66
  .filter(Boolean) as EntityAction[];
66
- const createEnabled = canCreateEntity(collection, authController, path, null);
67
- const deleteEnabled = entity ? canDeleteEntity(collection, authController, path, entity) : false;
67
+ const createEnabled = canCreateEntity(collection, authController, path, null, pathSegments);
68
+ const deleteEnabled = entity ? canDeleteEntity(collection, authController, path, entity, entity.pathSegments ?? pathSegments) : false;
68
69
  const actions: EntityAction[] = [];
69
70
  if (createEnabled)
70
71
  actions.push(copyEntityAction);
@@ -8,7 +8,7 @@ import { EntityEditView, OnUpdateParams } from "./EntityEditView";
8
8
  import { useSideDialogContext } from "./SideDialogs";
9
9
  import { CloseIcon, IconButton, OpenInFullIcon } from "@firecms/ui";
10
10
  import { useLocation, useNavigate } from "react-router-dom";
11
- import { saveEntityToMemoryCache } from "../util/entity_cache";
11
+ import { entityCacheKey, saveEntityToMemoryCache } from "../util/entity_cache";
12
12
 
13
13
  /**
14
14
  * This is the component in charge of rendering the side dialog used
@@ -60,6 +60,7 @@ export function EntitySidePanel(props: EntitySidePanelProps) {
60
60
  if (params.status !== "existing") {
61
61
  sideEntityController.replace({
62
62
  path: params.path,
63
+ pathSegments: params.pathSegments ?? pathSegments,
63
64
  entityId: params.entityId,
64
65
  fullIdPath: props.fullIdPath,
65
66
  selectedTab: params.selectedTab,
@@ -76,10 +77,10 @@ export function EntitySidePanel(props: EntitySidePanelProps) {
76
77
  }
77
78
 
78
79
  const parentCollectionIds = useMemo(() => {
79
- return navigationController.getParentCollectionIds(path);
80
+ return navigationController.getParentCollectionIds(path, pathSegments);
80
81
  }, [navigationController, path]);
81
82
 
82
- const collection = props.collection ?? navigationController.getCollection(fullIdPath ?? path);
83
+ const collection = props.collection ?? navigationController.getCollection(fullIdPath ?? path, false, pathSegments);
83
84
 
84
85
  useEffect(() => {
85
86
  function beforeunload(e: any) {
@@ -137,7 +138,7 @@ export function EntitySidePanel(props: EntitySidePanelProps) {
137
138
  className="self-center"
138
139
  size={"smallest"}
139
140
  onClick={() => {
140
- const key = (status === "new" || status === "copy") ? path + "#new" : path + "/" + entityId;
141
+ const key = (status === "new" || status === "copy") ? path + "#new" : entityCacheKey(path, entityId);
141
142
  saveEntityToMemoryCache(key, values);
142
143
  if (entityId)
143
144
  navigate(location.pathname + location.search);
@@ -154,6 +155,7 @@ export function EntitySidePanel(props: EntitySidePanelProps) {
154
155
  }) => {
155
156
  sideEntityController.replace({
156
157
  path,
158
+ pathSegments,
157
159
  entityId,
158
160
  fullIdPath: props.fullIdPath,
159
161
  selectedTab,
@@ -48,6 +48,7 @@ import { useAnalyticsController } from "../hooks/useAnalyticsController";
48
48
  import { FormEntry, FormLayout, LabelWithIconAndTooltip, PropertyFieldBinding } from "../form";
49
49
  import { ValidationError } from "yup";
50
50
  import {
51
+ entityCacheKey,
51
52
  flattenKeys,
52
53
  getEntityFromCache,
53
54
  removeEntityFromCache,
@@ -64,6 +65,8 @@ export type OnUpdateParams = {
64
65
  entity: Entity<any>,
65
66
  status: EntityStatus,
66
67
  path: string,
68
+ /** `path` split at its real segment boundaries, when known. Never derived from `path`. */
69
+ pathSegments?: string[],
67
70
  entityId?: string;
68
71
  selectedTab?: string;
69
72
  collection: EntityCollection<any>
@@ -71,6 +74,11 @@ export type OnUpdateParams = {
71
74
 
72
75
  export type EntityFormProps<M extends Record<string, any>> = {
73
76
  path: string;
77
+ /**
78
+ * `path` split at its real segment boundaries; forwarded to the datasource for
79
+ * generateEntityId and checkUniqueField. Never derived from `path`.
80
+ */
81
+ pathSegments?: string[];
74
82
  fullIdPath?: string;
75
83
  collection: EntityCollection<M>;
76
84
  entityId?: string;
@@ -214,6 +222,7 @@ export function getChanges<T extends object>(source: Partial<T>, comparison: Par
214
222
 
215
223
  export function EntityForm<M extends Record<string, any>>({
216
224
  path,
225
+ pathSegments,
217
226
  fullIdPath,
218
227
  entityId: entityIdProp,
219
228
  collection,
@@ -270,6 +279,7 @@ export function EntityForm<M extends Record<string, any>>({
270
279
  entityId: entityIdProp,
271
280
  collection,
272
281
  path,
282
+ pathSegments,
273
283
  values: valuesToBeSaved
274
284
  });
275
285
  }, false, 2000);
@@ -292,7 +302,7 @@ export function EntityForm<M extends Record<string, any>>({
292
302
  if (mustSetCustomId) {
293
303
  return undefined;
294
304
  } else {
295
- return dataSource.generateEntityId(path, collection);
305
+ return dataSource.generateEntityId(path, collection, pathSegments);
296
306
  }
297
307
  } else {
298
308
  return entityIdProp;
@@ -308,7 +318,7 @@ export function EntityForm<M extends Record<string, any>>({
308
318
  const baseInitialValues = useMemo(() => getInitialEntityValues(authController, collection, path, status, entity, customizationController.propertyConfigs), [authController, collection, path, status, entity, customizationController.propertyConfigs]);
309
319
 
310
320
  const localChangesDataRaw = useMemo(() => entityId
311
- ? getEntityFromCache(path + "/" + entityId)
321
+ ? getEntityFromCache(entityCacheKey(path, entityId))
312
322
  : getEntityFromCache(path + "#new"), [entityId, path]);
313
323
 
314
324
  const [localChangesCleared, setLocalChangesCleared] = useState<boolean>(false);
@@ -387,7 +397,7 @@ export function EntityForm<M extends Record<string, any>>({
387
397
  onValuesModified?.(false, initialValues as M);
388
398
  },
389
399
  onValuesChangeDeferred: (values: M, controller: FormexController<M>) => {
390
- const key = (status === "new" || status === "copy") ? path + "#new" : path + "/" + entityId;
400
+ const key = (status === "new" || status === "copy") ? path + "#new" : entityCacheKey(path, entityId);
391
401
  if (controller.dirty && localChangesBackup !== false) {
392
402
  const touchedValues = removeEmptyContainers(extractTouchedValues(values, controller.touched));
393
403
  if (touchedValues && Object.keys(touchedValues).length > 0) {
@@ -468,8 +478,8 @@ export function EntityForm<M extends Record<string, any>>({
468
478
  removeEntityFromMemoryCache(path + "#new");
469
479
  removeEntityFromCache(path + "#new");
470
480
  } else {
471
- removeEntityFromMemoryCache(path + "/" + entityId);
472
- removeEntityFromCache(path + "/" + entityId);
481
+ removeEntityFromMemoryCache(entityCacheKey(path, entityId));
482
+ removeEntityFromCache(entityCacheKey(path, entityId));
473
483
  }
474
484
  }
475
485
 
@@ -491,6 +501,7 @@ export function EntityForm<M extends Record<string, any>>({
491
501
  entity: updatedEntity,
492
502
  status,
493
503
  path,
504
+ pathSegments,
494
505
  entityId: updatedEntity.id,
495
506
  collection
496
507
  });
@@ -511,16 +522,20 @@ export function EntityForm<M extends Record<string, any>>({
511
522
  previousValues,
512
523
  entityId,
513
524
  collection,
514
- path
525
+ path,
526
+ pathSegments
515
527
  }: {
516
528
  collection: EntityCollection<M>,
517
529
  path: string,
530
+ /** `path` split at its real segment boundaries, travelling with the `path` above. */
531
+ pathSegments?: string[],
518
532
  entityId: string | undefined,
519
533
  values: M,
520
534
  previousValues?: M,
521
535
  }) => {
522
536
  return saveEntityWithCallbacks({
523
537
  path,
538
+ pathSegments,
524
539
  entityId,
525
540
  values,
526
541
  previousValues,
@@ -538,6 +553,7 @@ export function EntityForm<M extends Record<string, any>>({
538
553
  type EntityFormSaveParams<M extends Record<string, any>> = {
539
554
  collection: ResolvedEntityCollection<M>,
540
555
  path: string,
556
+ pathSegments?: string[],
541
557
  entityId: string | undefined,
542
558
  values: EntityValues<M>,
543
559
  previousValues?: EntityValues<M>,
@@ -547,6 +563,7 @@ export function EntityForm<M extends Record<string, any>>({
547
563
  const onSaveEntityRequest = async ({
548
564
  collection,
549
565
  path,
566
+ pathSegments,
550
567
  entityId,
551
568
  values,
552
569
  previousValues,
@@ -560,6 +577,7 @@ export function EntityForm<M extends Record<string, any>>({
560
577
  return saveEntity({
561
578
  collection,
562
579
  path,
580
+ pathSegments,
563
581
  entityId,
564
582
  values,
565
583
  previousValues
@@ -573,6 +591,7 @@ export function EntityForm<M extends Record<string, any>>({
573
591
  return onSaveEntityRequest({
574
592
  collection: resolvedCollection,
575
593
  path,
594
+ pathSegments,
576
595
  entityId,
577
596
  values,
578
597
  previousValues: entity?.values,
@@ -623,13 +642,14 @@ export function EntityForm<M extends Record<string, any>>({
623
642
  const plugins = customizationController.plugins;
624
643
 
625
644
  const actionsDisabled = disabled || formex.isSubmitting || (status === "existing" && !formex.dirty) || Boolean(disabledProp);
626
- const parentCollectionIds = navigationController.getParentCollectionIds(path);
645
+ const parentCollectionIds = navigationController.getParentCollectionIds(path, pathSegments);
627
646
 
628
647
  if (plugins && collection) {
629
648
  const actionProps: PluginFormActionProps = {
630
649
  entityId,
631
650
  parentCollectionIds,
632
651
  path,
652
+ pathSegments,
633
653
  status,
634
654
  collection,
635
655
  context,
@@ -692,7 +712,7 @@ export function EntityForm<M extends Record<string, any>>({
692
712
  const uniqueFieldValidator: CustomFieldValidator = useCallback(({
693
713
  name,
694
714
  value
695
- }) => dataSource.checkUniqueField(path, name, value, entityId, collection),
715
+ }) => dataSource.checkUniqueField(path, name, value, entityId, collection, pathSegments),
696
716
  [dataSource, path, entityId]);
697
717
 
698
718
  const validationSchema = useMemo(() => entityId
@@ -890,6 +910,7 @@ export function EntityForm<M extends Record<string, any>>({
890
910
  const dialogActions = <EntityFormActionsComponent
891
911
  collection={resolvedCollection}
892
912
  path={path}
913
+ pathSegments={pathSegments}
893
914
  fullPath={path}
894
915
  fullIdPath={fullIdPath}
895
916
  entity={entity}
@@ -924,7 +945,7 @@ export function EntityForm<M extends Record<string, any>>({
924
945
 
925
946
  {manualApplyLocalChanges && hasLocalChanges &&
926
947
  <LocalChangesMenu
927
- cacheKey={status === "new" || status === "copy" ? path + "#new" : path + "/" + entityId}
948
+ cacheKey={status === "new" || status === "copy" ? path + "#new" : entityCacheKey(path, entityId)}
928
949
  properties={resolvedCollection.properties}
929
950
  cachedData={localChangesDataRaw as Partial<M>}
930
951
  formex={formex}
@@ -26,6 +26,8 @@ export interface EntityFormActionsProps {
26
26
  fullIdPath?: string;
27
27
  collection: ResolvedEntityCollection;
28
28
  path: string;
29
+ /** `path` split at its real segment boundaries, when known. Never derived from `path`. */
30
+ pathSegments?: string[];
29
31
  entity?: Entity;
30
32
  layout: "bottom" | "side";
31
33
  savingError?: Error;
@@ -46,6 +46,7 @@ export type DeleteEntityWithCallbacksProps<M extends Record<string, any>, USER e
46
46
  export async function deleteEntityWithCallbacks<M extends Record<string, any>, USER extends User>({
47
47
  dataSource,
48
48
  entity,
49
+ pathSegments,
49
50
  collection,
50
51
  callbacks,
51
52
  onDeleteSuccess,
@@ -62,11 +63,17 @@ export async function deleteEntityWithCallbacks<M extends Record<string, any>, U
62
63
 
63
64
  console.debug("Deleting entity", entity.path, entity.id);
64
65
 
66
+ // An explicit `pathSegments` wins, then the ones the entity was loaded with. The prop
67
+ // used to be accepted and silently ignored, which is the same class of bug this field
68
+ // exists to prevent.
69
+ const resolvedPathSegments = pathSegments ?? entity.pathSegments;
70
+
65
71
  const entityDeleteProps: EntityOnDeleteProps<M, any> = {
66
72
  entity,
67
73
  collection,
68
74
  entityId: entity.id,
69
75
  path: entity.path,
76
+ pathSegments: resolvedPathSegments,
70
77
  context
71
78
  };
72
79
 
@@ -81,6 +88,7 @@ export async function deleteEntityWithCallbacks<M extends Record<string, any>, U
81
88
  }
82
89
  }
83
90
  return dataSource.deleteEntity({
91
+ pathSegments: resolvedPathSegments,
84
92
  entity,
85
93
  collection
86
94
  }).then(() => {
@@ -68,7 +68,7 @@ export async function saveEntityWithCallbacks<M extends Record<string, any>, USE
68
68
 
69
69
  const customizationController = context.customizationController;
70
70
 
71
- const resolvedPath = context.navigation.resolveIdsFrom(path);
71
+ const resolvedPath = context.navigation.resolveIdsFrom(path, pathSegments);
72
72
 
73
73
  const callbacks = collection.callbacks;
74
74
  if (callbacks?.onPreSave) {
@@ -84,6 +84,7 @@ export async function saveEntityWithCallbacks<M extends Record<string, any>, USE
84
84
  updatedValues = await callbacks.onPreSave({
85
85
  collection: resolvedCollection,
86
86
  path,
87
+ pathSegments,
87
88
  resolvedPath,
88
89
  entityId,
89
90
  values,
@@ -104,7 +105,7 @@ export async function saveEntityWithCallbacks<M extends Record<string, any>, USE
104
105
  return dataSource.saveEntity({
105
106
  collection,
106
107
  path: resolvedPath,
107
- pathSegments: pathSegments ?? resolvedPath.split("/"),
108
+ pathSegments,
108
109
  entityId,
109
110
  values: updatedValues,
110
111
  previousValues,
@@ -123,6 +124,7 @@ export async function saveEntityWithCallbacks<M extends Record<string, any>, USE
123
124
  callbacks.onSaveSuccess({
124
125
  collection: resolvedCollection,
125
126
  path,
127
+ pathSegments,
126
128
  resolvedPath,
127
129
  entityId: entity.id,
128
130
  values: updatedValues,
@@ -152,6 +154,7 @@ export async function saveEntityWithCallbacks<M extends Record<string, any>, USE
152
154
  callbacks.onSaveFailure({
153
155
  collection: resolvedCollection,
154
156
  path,
157
+ pathSegments,
155
158
  resolvedPath,
156
159
  entityId,
157
160
  values: updatedValues,
@@ -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: inputPathSegments,
66
73
  collection,
67
74
  filterValues,
68
75
  sortBy,
@@ -73,7 +80,13 @@ export function useCollectionFetch<M extends Record<string, any>, USER extends U
73
80
  const dataSource = useDataSource(collection);
74
81
  const navigationController = useNavigationController();
75
82
 
76
- const path = navigationController.resolveIdsFrom(inputPath);
83
+ // Resolved together so the two representations always describe the same chain.
84
+ // Segments are resolved, never derived: only collection ids are swapped for their
85
+ // real paths, and entity ids are carried through whole (see useEntityFetch).
86
+ const pathSegments = inputPathSegments
87
+ ? (navigationController.resolveSegmentsFrom?.(inputPathSegments) ?? inputPathSegments)
88
+ : undefined;
89
+ const path = navigationController.resolveIdsFrom(inputPath, inputPathSegments);
77
90
 
78
91
  const sortByProperty = sortBy ? sortBy[0] : undefined;
79
92
  const currentSort = sortBy ? sortBy[1] : undefined;
@@ -91,6 +104,10 @@ export function useCollectionFetch<M extends Record<string, any>, USER extends U
91
104
  setDataLoading(true);
92
105
 
93
106
  const onEntitiesUpdate = async (entities: Entity<M>[]) => {
107
+ // See useEntityFetch: entities carry the segments they were loaded with.
108
+ if (pathSegments) {
109
+ entities = entities.map(e => e.pathSegments ? e : { ...e, pathSegments });
110
+ }
94
111
  if (collection.callbacks?.onFetch) {
95
112
  try {
96
113
  entities = await Promise.all(
@@ -98,6 +115,7 @@ export function useCollectionFetch<M extends Record<string, any>, USER extends U
98
115
  collection.callbacks!.onFetch!({
99
116
  collection,
100
117
  path,
118
+ pathSegments,
101
119
  entity,
102
120
  context
103
121
  })));
@@ -124,6 +142,7 @@ export function useCollectionFetch<M extends Record<string, any>, USER extends U
124
142
  if (dataSource.listenCollection) {
125
143
  return dataSource.listenCollection<M>({
126
144
  path,
145
+ pathSegments,
127
146
  collection,
128
147
  onUpdate: onEntitiesUpdate,
129
148
  onError,
@@ -137,6 +156,7 @@ export function useCollectionFetch<M extends Record<string, any>, USER extends U
137
156
  } else {
138
157
  dataSource.fetchCollection<M>({
139
158
  path,
159
+ pathSegments,
140
160
  collection,
141
161
  searchString,
142
162
  filter: filterValues,