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

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 +967 -649
  20. package/dist/index.es.js.map +1 -1
  21. package/dist/index.umd.js +966 -648
  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 +17 -5
  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
@@ -234,6 +240,11 @@ export function useDataSourceTableController<M extends Record<string, any> = any
234
240
  setDataLoading(true);
235
241
 
236
242
  const onEntitiesUpdate = async (entities: Entity<M>[]) => {
243
+ // See useEntityFetch: entities carry the segments they were loaded with, so a
244
+ // reference or a delete derived from a table row stays resolvable.
245
+ if (resolvedPathSegments) {
246
+ entities = entities.map(e => e.pathSegments ? e : { ...e, pathSegments: resolvedPathSegments });
247
+ }
237
248
  if (collection.callbacks?.onFetch) {
238
249
  try {
239
250
  entities = await Promise.all(
@@ -241,6 +252,7 @@ export function useDataSourceTableController<M extends Record<string, any> = any
241
252
  collection.callbacks!.onFetch!({
242
253
  collection,
243
254
  path: resolvedPath,
255
+ pathSegments: resolvedPathSegments,
244
256
  entity,
245
257
  context
246
258
  })));
@@ -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,
@@ -3,6 +3,7 @@ import { Entity, EntityCollection, FireCMSContext, User } from "../../types";
3
3
  import { useDataSource } from "./useDataSource";
4
4
  import { useNavigationController } from "../useNavigationController";
5
5
  import { useFireCMSContext } from "../useFireCMSContext";
6
+ import { entityCacheKey } from "../../util/entity_cache";
6
7
 
7
8
  /**
8
9
  * @group Hooks and utilities
@@ -11,7 +12,10 @@ export interface EntityFetchProps<M extends Record<string, any>, USER extends Us
11
12
  path: string;
12
13
  /**
13
14
  * `path` split at its real segment boundaries. Forwarded to the datasource so a parent
14
- * entity id containing "/" stays unambiguous. Defaults to splitting the resolved path.
15
+ * entity id containing "/" stays unambiguous.
16
+ *
17
+ * Optional, and never derived by splitting `path` — a guess would be wrong in exactly
18
+ * the case the field exists for. Absent means "not known here", not "no slashes".
15
19
  */
16
20
  pathSegments?: string[];
17
21
  entityId?: string;
@@ -54,8 +58,19 @@ export function useEntityFetch<M extends Record<string, any>, USER extends User>
54
58
  const dataSource = useDataSource(collection);
55
59
  const navigationController = useNavigationController();
56
60
 
57
- const path = navigationController.resolveIdsFrom(inputPath);
58
- const pathSegments = inputPathSegments ?? path.split("/");
61
+ // Never fabricate segments. If the caller did not thread them, pass undefined so a
62
+ // delegate can tell "not provided" from a real answer — splitting `path` here would
63
+ // produce a confidently wrong array for any id containing "/".
64
+ //
65
+ // When they were threaded, both representations are resolved from them together, so
66
+ // `path` cannot end up describing a different chain than `pathSegments`.
67
+ // `resolveSegmentsFrom` is optional on the controller: a host app may be supplying one
68
+ // built against an earlier version. Falling back to the segments as given is what
69
+ // happened before it existed — they are used unresolved, never invented.
70
+ const pathSegments = inputPathSegments
71
+ ? (navigationController.resolveSegmentsFrom?.(inputPathSegments) ?? inputPathSegments)
72
+ : undefined;
73
+ const path = navigationController.resolveIdsFrom(inputPath, inputPathSegments);
59
74
 
60
75
  const context: FireCMSContext<USER> = useFireCMSContext();
61
76
 
@@ -68,11 +83,18 @@ export function useEntityFetch<M extends Record<string, any>, USER extends User>
68
83
  setDataLoading(true);
69
84
 
70
85
  const onEntityUpdate = async (updatedEntity: Entity<M> | undefined) => {
86
+ // Attach the segments this entity was loaded with, so anything derived from it
87
+ // later — a reference, a delete — stays resolvable without re-deriving them
88
+ // from the flattened path. Delegates are not required to set this themselves.
89
+ if (updatedEntity && pathSegments && !updatedEntity.pathSegments) {
90
+ updatedEntity = { ...updatedEntity, pathSegments };
91
+ }
71
92
  if (collection.callbacks?.onFetch && updatedEntity) {
72
93
  try {
73
94
  updatedEntity = await collection.callbacks.onFetch({
74
95
  collection,
75
96
  path,
97
+ pathSegments,
76
98
  entity: updatedEntity,
77
99
  context
78
100
  });
@@ -80,7 +102,7 @@ export function useEntityFetch<M extends Record<string, any>, USER extends User>
80
102
  console.error(e);
81
103
  }
82
104
  }
83
- CACHE[`${path}/${entityId}`] = updatedEntity;
105
+ CACHE[entityCacheKey(path, entityId)] = updatedEntity;
84
106
  setEntity(updatedEntity);
85
107
  setDataLoading(false);
86
108
  setDataLoadingError(undefined);
@@ -93,8 +115,8 @@ export function useEntityFetch<M extends Record<string, any>, USER extends User>
93
115
  setDataLoadingError(error);
94
116
  };
95
117
 
96
- if (entityId && useCache && CACHE[`${path}/${entityId}`]) {
97
- setEntity(CACHE[`${path}/${entityId}`]);
118
+ if (entityId && useCache && CACHE[entityCacheKey(path, entityId)]) {
119
+ setEntity(CACHE[entityCacheKey(path, entityId)]);
98
120
  setDataLoading(false);
99
121
  setDataLoadingError(undefined);
100
122
  // eslint-disable-next-line @typescript-eslint/no-empty-function