@firecms/core 3.4.0-canary.0ef7442 → 3.4.0-canary.2575a08

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 (74) hide show
  1. package/dist/components/EntityCollectionView/EntityCollectionView.d.ts +3 -2
  2. package/dist/components/EntityCollectionView/EntityCollectionViewActions.d.ts +3 -1
  3. package/dist/components/EntityCollectionView/EntityCollectionViewStartActions.d.ts +3 -1
  4. package/dist/components/common/useDataSourceTableController.d.ts +4 -2
  5. package/dist/components/common/useTableSearchHelper.d.ts +3 -1
  6. package/dist/core/EntityEditView.d.ts +2 -0
  7. package/dist/core/EntityEditViewFormActions.d.ts +1 -1
  8. package/dist/form/EntityForm.d.ts +2 -0
  9. package/dist/form/EntityFormActions.d.ts +2 -0
  10. package/dist/hooks/data/delete.d.ts +1 -1
  11. package/dist/hooks/data/useCollectionFetch.d.ts +1 -1
  12. package/dist/hooks/data/useEntityFetch.d.ts +4 -1
  13. package/dist/i18n/__tests__/FireCMSi18nProvider.test.d.ts +1 -0
  14. package/dist/i18n/__tests__/nested_provider_lifecycle.test.d.ts +1 -0
  15. package/dist/index.es.js +853 -600
  16. package/dist/index.es.js.map +1 -1
  17. package/dist/index.umd.js +852 -599
  18. package/dist/index.umd.js.map +1 -1
  19. package/dist/types/collections.d.ts +7 -0
  20. package/dist/types/datasource.d.ts +8 -0
  21. package/dist/types/entity_actions.d.ts +6 -0
  22. package/dist/types/entity_callbacks.d.ts +21 -0
  23. package/dist/types/navigation.d.ts +34 -5
  24. package/dist/types/plugins.d.ts +13 -0
  25. package/dist/types/side_entity_controller.d.ts +3 -2
  26. package/dist/util/entity_cache.d.ts +11 -0
  27. package/dist/util/navigation_utils.d.ts +84 -2
  28. package/dist/util/parent_references_from_path.d.ts +15 -0
  29. package/dist/util/paths.d.ts +28 -0
  30. package/dist/util/permissions.d.ts +10 -4
  31. package/package.json +3 -3
  32. package/src/components/DeleteEntityDialog.tsx +2 -0
  33. package/src/components/EntityCollectionTable/EntityCollectionRowActions.tsx +2 -2
  34. package/src/components/EntityCollectionView/EntityCollectionBoardView.tsx +8 -0
  35. package/src/components/EntityCollectionView/EntityCollectionView.tsx +40 -15
  36. package/src/components/EntityCollectionView/EntityCollectionViewActions.tsx +6 -2
  37. package/src/components/EntityCollectionView/EntityCollectionViewStartActions.tsx +4 -0
  38. package/src/components/EntityCollectionView/useBoardDataController.tsx +7 -2
  39. package/src/components/EntityPreview.tsx +4 -1
  40. package/src/components/ReferenceTable/ReferenceSelectionTable.tsx +8 -2
  41. package/src/components/common/default_entity_actions.tsx +22 -2
  42. package/src/components/common/useDataSourceTableController.tsx +26 -8
  43. package/src/components/common/useTableSearchHelper.ts +5 -0
  44. package/src/core/EntityEditView.tsx +8 -12
  45. package/src/core/EntityEditViewFormActions.tsx +3 -2
  46. package/src/core/EntitySidePanel.tsx +6 -4
  47. package/src/form/EntityForm.tsx +22 -7
  48. package/src/form/EntityFormActions.tsx +2 -0
  49. package/src/hooks/data/delete.ts +8 -2
  50. package/src/hooks/data/save.ts +4 -1
  51. package/src/hooks/data/useCollectionFetch.tsx +9 -2
  52. package/src/hooks/data/useEntityFetch.tsx +19 -6
  53. package/src/hooks/useBuildNavigationController.tsx +23 -8
  54. package/src/hooks/useResolvedNavigationFrom.tsx +1 -1
  55. package/src/i18n/FireCMSi18nProvider.tsx +48 -4
  56. package/src/i18n/__tests__/FireCMSi18nProvider.test.tsx +91 -0
  57. package/src/i18n/__tests__/nested_provider_lifecycle.test.tsx +67 -0
  58. package/src/internal/useBuildDataSource.ts +13 -12
  59. package/src/internal/useBuildSideEntityController.tsx +13 -5
  60. package/src/preview/components/ReferencePreview.tsx +7 -3
  61. package/src/routes/FireCMSRoute.tsx +9 -4
  62. package/src/types/collections.ts +8 -0
  63. package/src/types/datasource.ts +8 -0
  64. package/src/types/entity_actions.tsx +6 -0
  65. package/src/types/entity_callbacks.ts +24 -0
  66. package/src/types/navigation.ts +35 -5
  67. package/src/types/plugins.tsx +13 -0
  68. package/src/types/side_entity_controller.tsx +3 -2
  69. package/src/util/entity_cache.ts +18 -0
  70. package/src/util/navigation_from_path.ts +6 -1
  71. package/src/util/navigation_utils.ts +204 -2
  72. package/src/util/parent_references_from_path.ts +32 -1
  73. package/src/util/paths.ts +40 -3
  74. package/src/util/permissions.ts +22 -10
@@ -90,6 +90,7 @@ import { useSelectionController } from "./useSelectionController";
90
90
  import { EntityCollectionViewStartActions } from "./EntityCollectionViewStartActions";
91
91
  import { addRecentId, getRecentIds } from "./utils";
92
92
  import { useScrollRestoration } from "../common/useScrollRestoration";
93
+ import { entityCacheKey } from "../../util/entity_cache";
93
94
 
94
95
  const DEFAULT_ENTITY_OPEN_MODE: "side_panel" | "full_screen" = "side_panel";
95
96
 
@@ -109,8 +110,9 @@ export type EntityCollectionViewProps<M extends Record<string, any>> = {
109
110
  /**
110
111
  * `fullPath` split at its real segment boundaries, e.g. `["nodes", "node/42", "edges"]`.
111
112
  * Forwarded to the datasource so a parent entity id containing "/" stays unambiguous.
112
- * Defaults to splitting `fullPath`, which is correct for any backend whose ids cannot
113
- * contain "/".
113
+ *
114
+ * Optional, and never derived by splitting `fullPath` — a guess would be wrong in
115
+ * exactly the case the field exists for. Absent means "not known here", not "no slashes".
114
116
  */
115
117
  pathSegments?: string[];
116
118
  /**
@@ -198,7 +200,7 @@ export const EntityCollectionView = React.memo(
198
200
  collectionRef.current = collection;
199
201
  }, [collection]);
200
202
 
201
- const canCreateEntities = canCreateEntity(collection, authController, fullPath, null);
203
+ const canCreateEntities = canCreateEntity(collection, authController, fullPath, null, pathSegments);
202
204
  const [highlightedEntity, setHighlightedEntity] = useState<Entity<M> | undefined>(undefined);
203
205
  const [deleteEntityClicked, setDeleteEntityClicked] = React.useState<Entity<M> | Entity<M>[] | undefined>(undefined);
204
206
 
@@ -228,7 +230,7 @@ export const EntityCollectionView = React.memo(
228
230
 
229
231
  const checkInlineEditing = useCallback((entity?: Entity<any>): boolean => {
230
232
  const collection = collectionRef.current;
231
- if (!canEditEntity(collection, authController, fullPath, entity ?? null)) {
233
+ if (!canEditEntity(collection, authController, fullPath, entity ?? null, pathSegments)) {
232
234
  return false;
233
235
  }
234
236
  return collection.inlineEditing === undefined || collection.inlineEditing;
@@ -351,10 +353,16 @@ export const EntityCollectionView = React.memo(
351
353
  addRecentId(collection.id, clickedEntity.id);
352
354
  }
353
355
 
354
- const path = collection?.collectionGroup ? clickedEntity.path : (fullPath ?? clickedEntity.path);
356
+ // In a collection group the row's own path is the real one, so the row's segments
357
+ // are the matching ones; otherwise both come from this view. `path` and
358
+ // `pathSegments` are picked from the same source so they cannot describe
359
+ // different chains.
360
+ const isCollectionGroup = Boolean(collection?.collectionGroup);
361
+ const path = isCollectionGroup ? clickedEntity.path : (fullPath ?? clickedEntity.path);
355
362
  navigateToEntity({
356
363
  navigation,
357
364
  path,
365
+ pathSegments: isCollectionGroup ? clickedEntity.pathSegments : (pathSegments ?? clickedEntity.pathSegments),
358
366
  fullIdPath,
359
367
  sideEntityController,
360
368
  openEntityMode,
@@ -461,7 +469,7 @@ export const EntityCollectionView = React.memo(
461
469
  }
462
470
  }, [setViewMode, userConfigPersistence, onCollectionModifiedForUser, fullPath, analyticsController, viewMode]);
463
471
 
464
- const createEnabled = canCreateEntity(collection, authController, fullPath, null);
472
+ const createEnabled = canCreateEntity(collection, authController, fullPath, null, pathSegments);
465
473
 
466
474
  const uniqueFieldValidator: UniqueFieldValidator = useCallback(
467
475
  ({
@@ -469,8 +477,8 @@ export const EntityCollectionView = React.memo(
469
477
  value,
470
478
  property,
471
479
  entityId
472
- }) => dataSource.checkUniqueField(fullPath, name, value, entityId, collection),
473
- [fullPath]);
480
+ }) => dataSource.checkUniqueField(fullPath, name, value, entityId, collection, pathSegments),
481
+ [fullPath, pathSegments]);
474
482
 
475
483
  const onValueChange: OnCellValueChange<any, any> = ({
476
484
  value,
@@ -484,6 +492,8 @@ export const EntityCollectionView = React.memo(
484
492
 
485
493
  const saveProps: SaveEntityProps = {
486
494
  path: entity.path ?? fullPath,
495
+ // Paired with the `path` above: the row's segments describe the row's path.
496
+ pathSegments: entity.path ? entity.pathSegments : pathSegments,
487
497
  entityId: entity.id,
488
498
  values: updatedValues,
489
499
  previousValues: entity.values,
@@ -514,7 +524,7 @@ export const EntityCollectionView = React.memo(
514
524
 
515
525
  };
516
526
 
517
- const resolvedFullPath = navigation.resolveIdsFrom(fullPath);
527
+ const resolvedFullPath = navigation.resolveIdsFrom(fullPath, pathSegments);
518
528
  const resolvedCollection = useMemo(() => resolveCollection<M>({
519
529
  collection,
520
530
  path: fullPath,
@@ -676,13 +686,15 @@ export const EntityCollectionView = React.memo(
676
686
  width: 260,
677
687
  dependencies: [],
678
688
  Builder: ({ entity }) => {
679
- const collectionsWithPath = navigation.getParentReferencesFromPath(entity.path);
689
+ // `entity.path` is a RAW datasource path, so its segments are passed:
690
+ // splitting it would mis-read any parent id containing "/".
691
+ const collectionsWithPath = navigation.getParentReferencesFromPath(entity.path, entity.pathSegments);
680
692
  return (
681
693
  <div className={"flex flex-col gap-2 w-full"}>
682
694
  {collectionsWithPath.map((reference) => {
683
695
  return (
684
696
  <ReferencePreview
685
- key={reference.path + "/" + reference.id}
697
+ key={entityCacheKey(reference.path, reference.id)}
686
698
  reference={reference}
687
699
  size={"small"} />
688
700
  );
@@ -713,7 +725,7 @@ export const EntityCollectionView = React.memo(
713
725
  entity?: Entity<M>,
714
726
  customEntityActions?: EntityAction[]
715
727
  }): EntityAction[] => {
716
- const deleteEnabled = entity ? canDeleteEntity(collection, authController, fullPath, entity) : true;
728
+ const deleteEnabled = entity ? canDeleteEntity(collection, authController, fullPath, entity, entity.pathSegments ?? pathSegments) : true;
717
729
  const actions: EntityAction[] = [
718
730
  { ...editEntityAction, name: t("edit") }
719
731
  ];
@@ -843,6 +855,7 @@ export const EntityCollectionView = React.memo(
843
855
  onTextSearchClick,
844
856
  textSearchEnabled
845
857
  } = useTableSearchHelper({
858
+ pathSegments,
846
859
  collection,
847
860
  fullPath: resolvedFullPath,
848
861
  parentCollectionIds,
@@ -907,6 +920,7 @@ export const EntityCollectionView = React.memo(
907
920
  collection={collection}
908
921
  tableController={tableController}
909
922
  path={fullPath}
923
+ pathSegments={pathSegments}
910
924
  relativePath={collection.path}
911
925
  selectionController={usedSelectionController}
912
926
  collectionEntitiesCount={docsCount}
@@ -918,6 +932,7 @@ export const EntityCollectionView = React.memo(
918
932
  onMultipleDeleteClick={onMultipleDeleteClick}
919
933
  onNewClick={onNewClick}
920
934
  path={fullPath}
935
+ pathSegments={pathSegments}
921
936
  relativePath={collection.path}
922
937
  selectionController={usedSelectionController}
923
938
  selectionEnabled={selectionEnabled}
@@ -1026,6 +1041,7 @@ export const EntityCollectionView = React.memo(
1026
1041
  getIdColumnWidth={getIdColumnWidth}
1027
1042
  additionalIDHeaderWidget={<EntityIdHeaderWidget
1028
1043
  path={fullPath}
1044
+ pathSegments={pathSegments}
1029
1045
  fullIdPath={fullIdPath ?? fullPath}
1030
1046
  collection={collection} />}
1031
1047
  openEntityMode={openEntityMode}
@@ -1140,8 +1156,12 @@ function EntitiesCount({
1140
1156
 
1141
1157
  const sortByProperty = sortBy ? sortBy[0] : undefined;
1142
1158
  const currentSort = sortBy ? sortBy[1] : undefined;
1143
- const resolvedPath = useMemo(() => navigation.resolveIdsFrom(fullPath), [fullPath, navigation.resolveIdsFrom]);
1144
- const resolvedPathSegments = pathSegments;
1159
+ // Resolved together so the two representations always describe the same chain.
1160
+ // Segments are resolved, never derived (see useEntityFetch).
1161
+ const resolvedPath = useMemo(() => navigation.resolveIdsFrom(fullPath, pathSegments), [fullPath, pathSegments, navigation.resolveIdsFrom]);
1162
+ const resolvedPathSegments = useMemo(() => pathSegments
1163
+ ? (navigation.resolveSegmentsFrom?.(pathSegments) ?? pathSegments)
1164
+ : undefined, [pathSegments, navigation.resolveSegmentsFrom]);
1145
1165
 
1146
1166
  useEffect(() => {
1147
1167
  if (dataSource.countEntities)
@@ -1182,10 +1202,13 @@ function buildPropertyWidthOverwrite(key: string, width: number): PartialEntityC
1182
1202
  function EntityIdHeaderWidget({
1183
1203
  collection,
1184
1204
  path,
1205
+ pathSegments,
1185
1206
  fullIdPath
1186
1207
  }: {
1187
1208
  collection: EntityCollection,
1188
1209
  path: string,
1210
+ /** `path` split at its real segment boundaries, when the caller knows them. */
1211
+ pathSegments?: string[],
1189
1212
  fullIdPath: string
1190
1213
  }) {
1191
1214
 
@@ -1225,6 +1248,7 @@ function EntityIdHeaderWidget({
1225
1248
  collection,
1226
1249
  entityId,
1227
1250
  path,
1251
+ pathSegments,
1228
1252
  fullIdPath,
1229
1253
  sideEntityController,
1230
1254
  navigation
@@ -1250,7 +1274,7 @@ function EntityIdHeaderWidget({
1250
1274
  </form>
1251
1275
  {recentIds && recentIds.length > 0 && <div className="flex flex-col gap-2 p-2">
1252
1276
  {recentIds.map(id => (
1253
- <ReferencePreview reference={new EntityReference(id, path)}
1277
+ <ReferencePreview reference={new EntityReference(id, path, undefined, pathSegments)}
1254
1278
  key={id}
1255
1279
  hover={true}
1256
1280
  onClick={() => {
@@ -1260,6 +1284,7 @@ function EntityIdHeaderWidget({
1260
1284
  collection,
1261
1285
  entityId: id,
1262
1286
  path,
1287
+ pathSegments,
1263
1288
  fullIdPath,
1264
1289
  sideEntityController,
1265
1290
  navigation
@@ -22,6 +22,8 @@ import { useTranslation } from "../../hooks/useTranslation";
22
22
  export type EntityCollectionViewActionsProps<M extends Record<string, any>> = {
23
23
  collection: EntityCollection<M>;
24
24
  path: string;
25
+ /** `path` split at its real segment boundaries, when known. Never derived from `path`. */
26
+ pathSegments?: string[];
25
27
  relativePath: string;
26
28
  parentCollectionIds: string[];
27
29
  selectionEnabled: boolean;
@@ -40,6 +42,7 @@ export function EntityCollectionViewActions<M extends Record<string, any>>({
40
42
  onMultipleDeleteClick,
41
43
  selectionEnabled,
42
44
  path,
45
+ pathSegments,
43
46
  selectionController,
44
47
  tableController,
45
48
  collectionEntitiesCount,
@@ -56,7 +59,7 @@ export function EntityCollectionViewActions<M extends Record<string, any>>({
56
59
 
57
60
  const selectedEntities = selectionController.selectedEntities;
58
61
 
59
- const addButton = canCreateEntity(collection, authController, path, null) &&
62
+ const addButton = canCreateEntity(collection, authController, path, null, pathSegments) &&
60
63
  onNewClick && (largeLayout
61
64
  ? <Button
62
65
  id={`add_entity_${path}`}
@@ -75,7 +78,7 @@ export function EntityCollectionViewActions<M extends Record<string, any>>({
75
78
  <AddIcon size={"small"} />
76
79
  </Button>);
77
80
 
78
- const multipleDeleteEnabled = canDeleteEntity(collection, authController, path, null);
81
+ const multipleDeleteEnabled = canDeleteEntity(collection, authController, path, null, pathSegments);
79
82
 
80
83
  let multipleDeleteButton: React.ReactNode | undefined;
81
84
  if (selectionEnabled) {
@@ -107,6 +110,7 @@ export function EntityCollectionViewActions<M extends Record<string, any>>({
107
110
 
108
111
  const actionProps: CollectionActionsProps = {
109
112
  path,
113
+ pathSegments,
110
114
  relativePath,
111
115
  parentCollectionIds,
112
116
  collection,
@@ -17,6 +17,8 @@ import { useTranslation } from "../../hooks/useTranslation";
17
17
  export type EntityCollectionViewStartActionsProps<M extends Record<string, any>> = {
18
18
  collection: EntityCollection<M>;
19
19
  path: string;
20
+ /** `path` split at its real segment boundaries, when known. Never derived from `path`. */
21
+ pathSegments?: string[];
20
22
  relativePath: string;
21
23
  parentCollectionIds: string[];
22
24
  selectionController: SelectionController<M>;
@@ -33,6 +35,7 @@ export function EntityCollectionViewStartActions<M extends Record<string, any>>(
33
35
  relativePath,
34
36
  parentCollectionIds,
35
37
  path,
38
+ pathSegments,
36
39
  selectionController,
37
40
  tableController,
38
41
  collectionEntitiesCount,
@@ -57,6 +60,7 @@ export function EntityCollectionViewStartActions<M extends Record<string, any>>(
57
60
 
58
61
  const actionProps: CollectionActionsProps = {
59
62
  path,
63
+ pathSegments,
60
64
  relativePath,
61
65
  parentCollectionIds,
62
66
  collection,
@@ -82,8 +82,12 @@ export function useBoardDataController<M extends Record<string, any> = any, COLU
82
82
  const context = useFireCMSContext();
83
83
  const dataSource = useDataSource(collection);
84
84
  const navigation = useNavigationController();
85
- const resolvedPath = useMemo(() => navigation.resolveIdsFrom(fullPath), [fullPath, navigation.resolveIdsFrom]);
86
- const resolvedPathSegments = pathSegments;
85
+ // Resolved together so the two representations always describe the same chain.
86
+ // Segments are resolved, never derived (see useEntityFetch).
87
+ const resolvedPath = useMemo(() => navigation.resolveIdsFrom(fullPath, pathSegments), [fullPath, pathSegments, navigation.resolveIdsFrom]);
88
+ const resolvedPathSegments = useMemo(() => pathSegments
89
+ ? (navigation.resolveSegmentsFrom?.(pathSegments) ?? pathSegments)
90
+ : undefined, [pathSegments, navigation.resolveSegmentsFrom]);
87
91
 
88
92
  // Stable refs for objects that shouldn't trigger re-subscriptions
89
93
  const dataSourceRef = useRef(dataSource);
@@ -212,6 +216,7 @@ export function useBoardDataController<M extends Record<string, any> = any, COLU
212
216
  currentCollection.callbacks!.onFetch!({
213
217
  collection: currentCollection,
214
218
  path: currentResolvedPath,
219
+ pathSegments: currentPathSegments,
215
220
  entity,
216
221
  context: currentContext
217
222
  })
@@ -63,7 +63,7 @@ export function EntityPreview({
63
63
 
64
64
  const navigationController = useNavigationController();
65
65
 
66
- const collection = collectionProp ?? navigationController.getCollection(entity.path);
66
+ const collection = collectionProp ?? navigationController.getCollection(entity.path, false, entity.pathSegments);
67
67
 
68
68
  if (!collection) {
69
69
  throw Error(`Couldn't find the corresponding collection view for the path: ${entity.path}`);
@@ -177,6 +177,9 @@ export function EntityPreview({
177
177
  sideEntityController.open({
178
178
  entityId: entity.id,
179
179
  path: entity.path,
180
+ // Paired with `entity.path` above; undefined if the entity was
181
+ // loaded without them.
182
+ pathSegments: entity.pathSegments,
180
183
  collection,
181
184
  updateUrl: true
182
185
  });
@@ -120,7 +120,7 @@ export function ReferenceSelectionTable<M extends Record<string, any>>(
120
120
  const analyticsController = useAnalyticsController();
121
121
  const customizationController = useCustomizationController();
122
122
 
123
- const fullPath = navigation.resolveIdsFrom(pathInput);
123
+ const fullPath = navigation.resolveIdsFrom(pathInput, pathSegments);
124
124
 
125
125
  const dataSource = useDataSource(collection);
126
126
 
@@ -215,6 +215,7 @@ export function ReferenceSelectionTable<M extends Record<string, any>>(
215
215
  });
216
216
  sideEntityController.open({
217
217
  path: fullPath,
218
+ pathSegments,
218
219
  collection,
219
220
  updateUrl: true,
220
221
  onUpdate: ({ entity }) => {
@@ -288,6 +289,7 @@ export function ReferenceSelectionTable<M extends Record<string, any>>(
288
289
  textSearchEnabled
289
290
  } =
290
291
  useTableSearchHelper({
292
+ pathSegments,
291
293
  collection,
292
294
  fullPath,
293
295
  });
@@ -326,6 +328,7 @@ export function ReferenceSelectionTable<M extends Record<string, any>>(
326
328
  actions={<ReferenceDialogActions
327
329
  collection={collection}
328
330
  path={fullPath}
331
+ pathSegments={pathSegments}
329
332
  onNewClick={onNewClick}
330
333
  onClear={onClear}/>
331
334
  }
@@ -352,11 +355,14 @@ export function ReferenceSelectionTable<M extends Record<string, any>>(
352
355
  function ReferenceDialogActions({
353
356
  collection,
354
357
  path,
358
+ pathSegments,
355
359
  onClear,
356
360
  onNewClick
357
361
  }: {
358
362
  collection: EntityCollection<any>,
359
363
  path: string,
364
+ /** `path` split at its real segment boundaries, when known. Never derived from `path`. */
365
+ pathSegments?: string[],
360
366
  onClear: () => void,
361
367
  onNewClick: () => void
362
368
  }) {
@@ -371,7 +377,7 @@ function ReferenceDialogActions({
371
377
  onNewClick();
372
378
  }
373
379
  : undefined;
374
- const addButton = canCreateEntity(collection, authController, path, null) &&
380
+ const addButton = canCreateEntity(collection, authController, path, null, pathSegments) &&
375
381
  onClick && (largeLayout
376
382
  ? <Button
377
383
  onClick={onClick}
@@ -18,6 +18,7 @@ export const editEntityAction: EntityAction = {
18
18
  entity,
19
19
  collection,
20
20
  fullPath,
21
+ pathSegments,
21
22
  fullIdPath,
22
23
  context,
23
24
  highlightEntity,
@@ -41,6 +42,12 @@ export const editEntityAction: EntityAction = {
41
42
  }
42
43
 
43
44
  const path = collection?.collectionGroup ? entity.path : (fullPath ?? collection?.path ?? entity.path);
45
+ // In a collection group `path` is the entity's own, so the entity's segments describe
46
+ // it. Otherwise the caller's segments win, falling back to the ones the entity was
47
+ // loaded with — both describe the same collection. Never derived from `path`.
48
+ const resolvedPathSegments = collection?.collectionGroup
49
+ ? entity.pathSegments
50
+ : (pathSegments ?? entity.pathSegments);
44
51
  const newFullIdPath = collection?.collectionGroup ? collection.id : (fullIdPath ?? collection?.id ?? entity.path);
45
52
  const defaultSelectedView = resolveDefaultSelectedView(
46
53
  collection ? collection.defaultSelectedView : undefined,
@@ -54,6 +61,7 @@ export const editEntityAction: EntityAction = {
54
61
  collection,
55
62
  entityId: entity.id,
56
63
  path,
64
+ pathSegments: resolvedPathSegments,
57
65
  fullIdPath: newFullIdPath,
58
66
  sideEntityController: context.sideEntityController,
59
67
  onClose: () => unhighlightEntity?.(entity),
@@ -75,6 +83,8 @@ export const copyEntityAction: EntityAction = {
75
83
  collection,
76
84
  context,
77
85
  fullPath,
86
+ pathSegments,
87
+ fullIdPath,
78
88
  highlightEntity,
79
89
  unhighlightEntity,
80
90
  openEntityMode
@@ -89,13 +99,23 @@ export const copyEntityAction: EntityAction = {
89
99
  });
90
100
 
91
101
  const path = collection?.collectionGroup ? collection.path : (fullPath ?? collection?.path ?? entity.path);
92
- const fullIdPath = collection?.collectionGroup ? collection.id : (fullPath ?? collection?.id ?? entity.path);
102
+ // In a collection group `path` is the group's own root, which the entity's segments
103
+ // (its real nested location) do not describe — so they are deliberately not reused,
104
+ // and no substitute is invented.
105
+ const resolvedPathSegments = collection?.collectionGroup
106
+ ? undefined
107
+ : (pathSegments ?? entity.pathSegments);
108
+ // `fullIdPath` is the ESCAPED chain and is what becomes the URL; `fullPath` is the raw
109
+ // datasource path, whose parent ids may contain "/". Using the latter here wrote a URL
110
+ // that read back as a different entity. Mirrors editEntityAction.
111
+ const newFullIdPath = collection?.collectionGroup ? collection.id : (fullIdPath ?? collection?.id);
93
112
  navigateToEntity({
94
113
  openEntityMode,
95
114
  collection,
96
115
  entityId: entity.id,
97
116
  path,
98
- fullIdPath,
117
+ pathSegments: resolvedPathSegments,
118
+ fullIdPath: newFullIdPath,
99
119
  copy: true,
100
120
  sideEntityController: context.sideEntityController,
101
121
  onClose: () => unhighlightEntity?.(entity),
@@ -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
- // Passed through as-is; never derived from `resolvedPath` (see useEntityFetch).
99
- const resolvedPathSegments = pathSegments;
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);
@@ -189,11 +194,23 @@ export function useDataSourceTableController<M extends Record<string, any> = any
189
194
 
190
195
  useUpdateUrl(filterValues, sortBy, searchString, updateUrl);
191
196
 
192
- const collectionScroll = scrollRestoration?.getCollectionScroll(fullPath, filterValues);
193
- 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;
194
206
 
195
207
  useEffect(() => {
196
- 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) {
197
214
  scrollRestoration.updateCollectionScroll({
198
215
  fullPath: resolvedPath,
199
216
  scrollOffset: collectionScroll?.scrollOffset ?? 0,
@@ -247,6 +264,7 @@ export function useDataSourceTableController<M extends Record<string, any> = any
247
264
  collection.callbacks!.onFetch!({
248
265
  collection,
249
266
  path: resolvedPath,
267
+ pathSegments: resolvedPathSegments,
250
268
  entity,
251
269
  context
252
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,17 +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
366
  // Only extendable when we were given real segments — deriving the parent chain
365
367
  // from the flattened `path` would drop the boundary of a slash-bearing parent id.
366
- const newPathSegments = usedEntity && pathSegments
367
- ? [
368
- ...pathSegments,
369
- usedEntity.id,
370
- ...removeInitialAndTrailingSlashes(subcollection.path).split("/")
371
- ]
372
- : undefined;
368
+ const newPathSegments = buildSubcollectionPathSegments(pathSegments, usedEntity?.id, subcollection.path);
373
369
 
374
370
  if (selectedTab !== subcollectionId) return null;
375
371
  return (
@@ -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);