@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
@@ -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>
@@ -276,6 +279,7 @@ export function EntityForm<M extends Record<string, any>>({
276
279
  entityId: entityIdProp,
277
280
  collection,
278
281
  path,
282
+ pathSegments,
279
283
  values: valuesToBeSaved
280
284
  });
281
285
  }, false, 2000);
@@ -314,7 +318,7 @@ export function EntityForm<M extends Record<string, any>>({
314
318
  const baseInitialValues = useMemo(() => getInitialEntityValues(authController, collection, path, status, entity, customizationController.propertyConfigs), [authController, collection, path, status, entity, customizationController.propertyConfigs]);
315
319
 
316
320
  const localChangesDataRaw = useMemo(() => entityId
317
- ? getEntityFromCache(path + "/" + entityId)
321
+ ? getEntityFromCache(entityCacheKey(path, entityId))
318
322
  : getEntityFromCache(path + "#new"), [entityId, path]);
319
323
 
320
324
  const [localChangesCleared, setLocalChangesCleared] = useState<boolean>(false);
@@ -393,7 +397,7 @@ export function EntityForm<M extends Record<string, any>>({
393
397
  onValuesModified?.(false, initialValues as M);
394
398
  },
395
399
  onValuesChangeDeferred: (values: M, controller: FormexController<M>) => {
396
- const key = (status === "new" || status === "copy") ? path + "#new" : path + "/" + entityId;
400
+ const key = (status === "new" || status === "copy") ? path + "#new" : entityCacheKey(path, entityId);
397
401
  if (controller.dirty && localChangesBackup !== false) {
398
402
  const touchedValues = removeEmptyContainers(extractTouchedValues(values, controller.touched));
399
403
  if (touchedValues && Object.keys(touchedValues).length > 0) {
@@ -474,8 +478,8 @@ export function EntityForm<M extends Record<string, any>>({
474
478
  removeEntityFromMemoryCache(path + "#new");
475
479
  removeEntityFromCache(path + "#new");
476
480
  } else {
477
- removeEntityFromMemoryCache(path + "/" + entityId);
478
- removeEntityFromCache(path + "/" + entityId);
481
+ removeEntityFromMemoryCache(entityCacheKey(path, entityId));
482
+ removeEntityFromCache(entityCacheKey(path, entityId));
479
483
  }
480
484
  }
481
485
 
@@ -497,6 +501,7 @@ export function EntityForm<M extends Record<string, any>>({
497
501
  entity: updatedEntity,
498
502
  status,
499
503
  path,
504
+ pathSegments,
500
505
  entityId: updatedEntity.id,
501
506
  collection
502
507
  });
@@ -517,16 +522,20 @@ export function EntityForm<M extends Record<string, any>>({
517
522
  previousValues,
518
523
  entityId,
519
524
  collection,
520
- path
525
+ path,
526
+ pathSegments
521
527
  }: {
522
528
  collection: EntityCollection<M>,
523
529
  path: string,
530
+ /** `path` split at its real segment boundaries, travelling with the `path` above. */
531
+ pathSegments?: string[],
524
532
  entityId: string | undefined,
525
533
  values: M,
526
534
  previousValues?: M,
527
535
  }) => {
528
536
  return saveEntityWithCallbacks({
529
537
  path,
538
+ pathSegments,
530
539
  entityId,
531
540
  values,
532
541
  previousValues,
@@ -544,6 +553,7 @@ export function EntityForm<M extends Record<string, any>>({
544
553
  type EntityFormSaveParams<M extends Record<string, any>> = {
545
554
  collection: ResolvedEntityCollection<M>,
546
555
  path: string,
556
+ pathSegments?: string[],
547
557
  entityId: string | undefined,
548
558
  values: EntityValues<M>,
549
559
  previousValues?: EntityValues<M>,
@@ -553,6 +563,7 @@ export function EntityForm<M extends Record<string, any>>({
553
563
  const onSaveEntityRequest = async ({
554
564
  collection,
555
565
  path,
566
+ pathSegments,
556
567
  entityId,
557
568
  values,
558
569
  previousValues,
@@ -566,6 +577,7 @@ export function EntityForm<M extends Record<string, any>>({
566
577
  return saveEntity({
567
578
  collection,
568
579
  path,
580
+ pathSegments,
569
581
  entityId,
570
582
  values,
571
583
  previousValues
@@ -579,6 +591,7 @@ export function EntityForm<M extends Record<string, any>>({
579
591
  return onSaveEntityRequest({
580
592
  collection: resolvedCollection,
581
593
  path,
594
+ pathSegments,
582
595
  entityId,
583
596
  values,
584
597
  previousValues: entity?.values,
@@ -629,13 +642,14 @@ export function EntityForm<M extends Record<string, any>>({
629
642
  const plugins = customizationController.plugins;
630
643
 
631
644
  const actionsDisabled = disabled || formex.isSubmitting || (status === "existing" && !formex.dirty) || Boolean(disabledProp);
632
- const parentCollectionIds = navigationController.getParentCollectionIds(path);
645
+ const parentCollectionIds = navigationController.getParentCollectionIds(path, pathSegments);
633
646
 
634
647
  if (plugins && collection) {
635
648
  const actionProps: PluginFormActionProps = {
636
649
  entityId,
637
650
  parentCollectionIds,
638
651
  path,
652
+ pathSegments,
639
653
  status,
640
654
  collection,
641
655
  context,
@@ -896,6 +910,7 @@ export function EntityForm<M extends Record<string, any>>({
896
910
  const dialogActions = <EntityFormActionsComponent
897
911
  collection={resolvedCollection}
898
912
  path={path}
913
+ pathSegments={pathSegments}
899
914
  fullPath={path}
900
915
  fullIdPath={fullIdPath}
901
916
  entity={entity}
@@ -930,7 +945,7 @@ export function EntityForm<M extends Record<string, any>>({
930
945
 
931
946
  {manualApplyLocalChanges && hasLocalChanges &&
932
947
  <LocalChangesMenu
933
- cacheKey={status === "new" || status === "copy" ? path + "#new" : path + "/" + entityId}
948
+ cacheKey={status === "new" || status === "copy" ? path + "#new" : entityCacheKey(path, entityId)}
934
949
  properties={resolvedCollection.properties}
935
950
  cachedData={localChangesDataRaw as Partial<M>}
936
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,8 +88,7 @@ export async function deleteEntityWithCallbacks<M extends Record<string, any>, U
81
88
  }
82
89
  }
83
90
  return dataSource.deleteEntity({
84
- // Carried by the entity from the fetch that produced it; undefined if unknown.
85
- pathSegments: entity.pathSegments,
91
+ pathSegments: resolvedPathSegments,
86
92
  entity,
87
93
  collection
88
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,
@@ -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,
@@ -69,7 +69,7 @@ export interface CollectionFetchResult<M extends Record<string, any>> {
69
69
  export function useCollectionFetch<M extends Record<string, any>, USER extends User>(
70
70
  {
71
71
  path: inputPath,
72
- pathSegments,
72
+ pathSegments: inputPathSegments,
73
73
  collection,
74
74
  filterValues,
75
75
  sortBy,
@@ -80,7 +80,13 @@ export function useCollectionFetch<M extends Record<string, any>, USER extends U
80
80
  const dataSource = useDataSource(collection);
81
81
  const navigationController = useNavigationController();
82
82
 
83
- 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);
84
90
 
85
91
  const sortByProperty = sortBy ? sortBy[0] : undefined;
86
92
  const currentSort = sortBy ? sortBy[1] : undefined;
@@ -109,6 +115,7 @@ export function useCollectionFetch<M extends Record<string, any>, USER extends U
109
115
  collection.callbacks!.onFetch!({
110
116
  collection,
111
117
  path,
118
+ pathSegments,
112
119
  entity,
113
120
  context
114
121
  })));
@@ -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,11 +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
61
  // Never fabricate segments. If the caller did not thread them, pass undefined so a
59
62
  // delegate can tell "not provided" from a real answer — splitting `path` here would
60
63
  // produce a confidently wrong array for any id containing "/".
61
- const pathSegments = inputPathSegments;
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);
62
74
 
63
75
  const context: FireCMSContext<USER> = useFireCMSContext();
64
76
 
@@ -82,6 +94,7 @@ export function useEntityFetch<M extends Record<string, any>, USER extends User>
82
94
  updatedEntity = await collection.callbacks.onFetch({
83
95
  collection,
84
96
  path,
97
+ pathSegments,
85
98
  entity: updatedEntity,
86
99
  context
87
100
  });
@@ -89,7 +102,7 @@ export function useEntityFetch<M extends Record<string, any>, USER extends User>
89
102
  console.error(e);
90
103
  }
91
104
  }
92
- CACHE[`${path}/${entityId}`] = updatedEntity;
105
+ CACHE[entityCacheKey(path, entityId)] = updatedEntity;
93
106
  setEntity(updatedEntity);
94
107
  setDataLoading(false);
95
108
  setDataLoadingError(undefined);
@@ -102,8 +115,8 @@ export function useEntityFetch<M extends Record<string, any>, USER extends User>
102
115
  setDataLoadingError(error);
103
116
  };
104
117
 
105
- if (entityId && useCache && CACHE[`${path}/${entityId}`]) {
106
- setEntity(CACHE[`${path}/${entityId}`]);
118
+ if (entityId && useCache && CACHE[entityCacheKey(path, entityId)]) {
119
+ setEntity(CACHE[entityCacheKey(path, entityId)]);
107
120
  setDataLoading(false);
108
121
  setDataLoadingError(undefined);
109
122
  // eslint-disable-next-line @typescript-eslint/no-empty-function
@@ -22,11 +22,14 @@ import {
22
22
  } from "../types";
23
23
  import {
24
24
  applyPermissionsFunctionIfEmpty,
25
+ collectionSegmentsFrom,
26
+ fullPathToCollectionSegments,
25
27
  getCollectionByPathOrId,
26
28
  mergeDeep,
27
29
  removeFunctions,
28
30
  removeInitialAndTrailingSlashes,
29
31
  resolveCollectionPathIds,
32
+ resolveCollectionPathSegments,
30
33
  resolvePermissions
31
34
  } from "../util";
32
35
  import { getParentReferencesFromPath } from "../util/parent_references_from_path";
@@ -405,13 +408,14 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
405
408
 
406
409
  const getCollection = useCallback((
407
410
  idOrPath: string,
408
- includeUserOverride = false
411
+ includeUserOverride = false,
412
+ pathSegments?: string[]
409
413
  ): EC | undefined => {
410
414
  const collections = collectionsRef.current;
411
415
  if (!collections)
412
416
  return undefined;
413
417
 
414
- const baseCollection = getCollectionByPathOrId(removeInitialAndTrailingSlashes(idOrPath), collections);
418
+ const baseCollection = getCollectionByPathOrId(removeInitialAndTrailingSlashes(idOrPath), collections, pathSegments);
415
419
 
416
420
  const userOverride = includeUserOverride ? userConfigPersistence?.getCollectionConfig(idOrPath) : undefined;
417
421
  let overriddenCollection = baseCollection;
@@ -505,23 +509,33 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
505
509
  throw Error("Expected path starting with " + fullCollectionPath);
506
510
  }, [fullCollectionPath]);
507
511
 
508
- const resolveIdsFrom = useCallback((path: string): string => {
512
+ const resolveIdsFrom = useCallback((path: string, pathSegments?: string[]): string => {
509
513
  const collections = collectionsRef.current ?? [];
510
- return resolveCollectionPathIds(path, collections);
514
+ return resolveCollectionPathIds(path, collections, pathSegments);
511
515
  }, []);
512
516
 
513
- const getAllParentReferencesForPath = useCallback((path: string): EntityReference[] => {
517
+ const resolveSegmentsFrom = useCallback((pathSegments: string[]): string[] => {
518
+ const collections = collectionsRef.current ?? [];
519
+ return resolveCollectionPathSegments(pathSegments, collections);
520
+ }, []);
521
+
522
+ const getAllParentReferencesForPath = useCallback((path: string, pathSegments?: string[]): EntityReference[] => {
514
523
  const collections = collectionsRef.current ?? [];
515
524
  return getParentReferencesFromPath({
516
525
  path,
526
+ pathSegments,
517
527
  collections
518
528
  });
519
529
  }, []);
520
530
 
521
- const getParentCollectionIds = useCallback((path: string): string[] => {
531
+ const getParentCollectionIds = useCallback((path: string, pathSegments?: string[]): string[] => {
522
532
 
523
- const strings = path.split("/");
524
- const oddPathSegments = strings.filter((_, i) => i % 2 === 0);
533
+ // Positional filtering only picks out the collection segments while no entity id
534
+ // contains "/"; with one, every following segment shifts. Given the real boundaries
535
+ // the same rule applies correctly; without them this is the historical reading.
536
+ const oddPathSegments = pathSegments
537
+ ? collectionSegmentsFrom(pathSegments)
538
+ : fullPathToCollectionSegments(path);
525
539
  oddPathSegments.pop();
526
540
 
527
541
  const result: string[][] = [];
@@ -567,6 +581,7 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
567
581
  urlPathToDataPath,
568
582
  buildUrlCollectionPath,
569
583
  resolveIdsFrom,
584
+ resolveSegmentsFrom,
570
585
  topLevelNavigation,
571
586
  refreshNavigation,
572
587
  getParentReferencesFromPath: getAllParentReferencesForPath,
@@ -80,7 +80,7 @@ export function resolveNavigationFrom<M extends Record<string, any>, USER extend
80
80
  if (entry.type === "collection") {
81
81
  return Promise.resolve(entry);
82
82
  } else if (entry.type === "entity") {
83
- const collection = navigation.getCollection(entry.path);
83
+ const collection = navigation.getCollection(entry.path, false, entry.pathSegments);
84
84
  if (!collection) {
85
85
  throw Error(`No collection defined in the navigation for the entity with path ${entry.path}`);
86
86
  }
@@ -1,6 +1,6 @@
1
- import React, { PropsWithChildren, useEffect, useRef } from "react";
1
+ import React, { PropsWithChildren, useContext, useEffect, useMemo, useRef } from "react";
2
2
  import i18next, { i18n } from "i18next";
3
- import { I18nextProvider, initReactI18next } from "react-i18next";
3
+ import { I18nContext, I18nextProvider, initReactI18next } from "react-i18next";
4
4
  import { en } from "../locales/en";
5
5
  import { es } from "../locales/es";
6
6
  import { de } from "../locales/de";
@@ -50,16 +50,48 @@ export function FireCMSi18nProvider({
50
50
  translations,
51
51
  children
52
52
  }: PropsWithChildren<FireCMSi18nProviderProps>) {
53
+
54
+ // A FireCMS i18next instance already in context means this provider is nested —
55
+ // e.g. FireCMSCloudApp mounts one inside the one the host app already mounted.
56
+ // A second instance built only from the defaults would shadow the parent's and
57
+ // silently drop every translation the host registered, leaving the host's own
58
+ // strings to render as raw keys.
59
+ //
60
+ // So the nested instance is *seeded* from the parent's resources rather than
61
+ // sharing them. Sharing one mutable instance was the obvious alternative and is
62
+ // wrong: bundles added by one provider outlive it, so opening one project's app
63
+ // and then switching to another left the first project's translations answering
64
+ // for keys the second never defined. Copying keeps each provider's strings to
65
+ // itself, which is also how this behaved before nesting was handled at all.
66
+ const parentI18n = useContext(I18nContext)?.i18n;
67
+ const parentInstance = parentI18n?.hasResourceBundle?.("en", FIRECMS_NS)
68
+ ? parentI18n
69
+ : undefined;
70
+
53
71
  const i18nRef = useRef<i18n | null>(null);
54
72
  const [ready, setReady] = React.useState(false);
55
73
 
56
74
  if (!i18nRef.current) {
57
75
  const instance = i18next.createInstance();
58
76
 
59
- // Build the initial resources: English baseline + any consumer overrides
77
+ // English baseline + this provider's overrides, with anything the parent
78
+ // already resolved layered in underneath the overrides.
60
79
  const resources = buildResources(translations);
80
+ if (parentInstance) {
81
+ const inherited: Record<string, any> = parentInstance.services?.resourceStore?.data ?? {};
82
+ for (const [lang, namespaces] of Object.entries(inherited)) {
83
+ const bundle = (namespaces as any)?.[FIRECMS_NS];
84
+ if (!bundle) continue;
85
+ resources[lang] = {
86
+ [FIRECMS_NS]: {
87
+ ...bundle,
88
+ ...(translations?.[lang] ?? {})
89
+ }
90
+ };
91
+ }
92
+ }
61
93
 
62
- let initialLocale = locale;
94
+ let initialLocale = parentInstance?.language ?? locale;
63
95
  if (typeof window !== "undefined") {
64
96
  const stored = localStorage.getItem(FIRECMS_LOCALE_STORAGE_KEY);
65
97
  if (stored) initialLocale = stored;
@@ -90,6 +122,18 @@ export function FireCMSi18nProvider({
90
122
  i18nRef.current = instance;
91
123
  }
92
124
 
125
+ // Follow the parent's language, so a switch made outside this provider is not
126
+ // stranded on the other side of the boundary.
127
+ useEffect(() => {
128
+ if (!parentInstance || !i18nRef.current) return;
129
+ const instance = i18nRef.current;
130
+ const follow = (lng: string) => {
131
+ if (instance.language !== lng) instance.changeLanguage(lng);
132
+ };
133
+ parentInstance.on("languageChanged", follow);
134
+ return () => { parentInstance.off("languageChanged", follow); };
135
+ }, [parentInstance]);
136
+
93
137
  // When `locale` prop changes, switch language on the existing instance
94
138
  // ONLY if the user hasn't explicitly set a preference
95
139
  useEffect(() => {
@@ -0,0 +1,91 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import React from "react";
5
+ import { render, screen } from "@testing-library/react";
6
+ import { useTranslation } from "react-i18next";
7
+ import { FireCMSi18nProvider } from "../FireCMSi18nProvider";
8
+
9
+ /** Reads a key through the same channel application code uses. */
10
+ function Str({ k }: { k: string }) {
11
+ const { t } = useTranslation("firecms_core");
12
+ return <span data-testid={k}>{t(k)}</span>;
13
+ }
14
+
15
+ const HOST_TRANSLATIONS = {
16
+ en: {
17
+ host_only_key: "Explore your Firestore data",
18
+ save: "Publish" // an override of a real core key
19
+ }
20
+ } as any;
21
+
22
+ describe("FireCMSi18nProvider", () => {
23
+
24
+ beforeEach(() => {
25
+ window.localStorage.clear();
26
+ });
27
+
28
+ it("resolves its own translations", () => {
29
+ render(
30
+ <FireCMSi18nProvider translations={HOST_TRANSLATIONS}>
31
+ <Str k={"host_only_key"}/>
32
+ </FireCMSi18nProvider>
33
+ );
34
+ expect(screen.getByTestId("host_only_key").textContent).toBe("Explore your Firestore data");
35
+ });
36
+
37
+ it("keeps the host's translations when a nested provider is mounted without any", () => {
38
+ // This is the FireCMSCloudApp case: it mounts its own provider with
39
+ // `translations={appConfig?.translations}`, which is undefined for every
40
+ // project that has not deployed custom code. A nested provider that built its
41
+ // own isolated instance would shadow the host's and render the raw key.
42
+ render(
43
+ <FireCMSi18nProvider translations={HOST_TRANSLATIONS}>
44
+ <FireCMSi18nProvider translations={undefined}>
45
+ <Str k={"host_only_key"}/>
46
+ </FireCMSi18nProvider>
47
+ </FireCMSi18nProvider>
48
+ );
49
+ expect(screen.getByTestId("host_only_key").textContent).toBe("Explore your Firestore data");
50
+ expect(screen.getByTestId("host_only_key").textContent).not.toBe("host_only_key");
51
+ });
52
+
53
+ it("does not let a nested provider clobber a host override", () => {
54
+ render(
55
+ <FireCMSi18nProvider translations={HOST_TRANSLATIONS}>
56
+ <FireCMSi18nProvider translations={undefined}>
57
+ <Str k={"save"}/>
58
+ </FireCMSi18nProvider>
59
+ </FireCMSi18nProvider>
60
+ );
61
+ expect(screen.getByTestId("save").textContent).toBe("Publish");
62
+ });
63
+
64
+ it("lets a nested provider add its own translations on top of the host's", () => {
65
+ render(
66
+ <FireCMSi18nProvider translations={HOST_TRANSLATIONS}>
67
+ <FireCMSi18nProvider translations={{ en: { nested_key: "From the nested provider" } } as any}>
68
+ <>
69
+ <Str k={"host_only_key"}/>
70
+ <Str k={"nested_key"}/>
71
+ </>
72
+ </FireCMSi18nProvider>
73
+ </FireCMSi18nProvider>
74
+ );
75
+ expect(screen.getByTestId("host_only_key").textContent).toBe("Explore your Firestore data");
76
+ expect(screen.getByTestId("nested_key").textContent).toBe("From the nested provider");
77
+ });
78
+
79
+ it("still serves the built-in core strings through a nested provider", () => {
80
+ render(
81
+ <FireCMSi18nProvider>
82
+ <FireCMSi18nProvider translations={undefined}>
83
+ <Str k={"discard"}/>
84
+ </FireCMSi18nProvider>
85
+ </FireCMSi18nProvider>
86
+ );
87
+ const el = screen.getByTestId("discard");
88
+ expect(el.textContent).not.toBe("discard");
89
+ expect(el.textContent?.length).toBeGreaterThan(0);
90
+ });
91
+ });