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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/dist/components/EntityCollectionTable/internal/popup_field/PopupFormField.d.ts +3 -1
  2. package/dist/components/EntityCollectionView/EntityCollectionBoardView.d.ts +3 -1
  3. package/dist/components/EntityCollectionView/EntityCollectionView.d.ts +3 -2
  4. package/dist/components/EntityCollectionView/EntityCollectionViewActions.d.ts +3 -1
  5. package/dist/components/EntityCollectionView/EntityCollectionViewStartActions.d.ts +3 -1
  6. package/dist/components/EntityCollectionView/useBoardDataController.d.ts +3 -1
  7. package/dist/components/ReferenceTable/ReferenceSelectionTable.d.ts +10 -1
  8. package/dist/components/common/useDataSourceTableController.d.ts +4 -2
  9. package/dist/components/common/useTableSearchHelper.d.ts +3 -1
  10. package/dist/core/EntityEditView.d.ts +2 -0
  11. package/dist/core/EntityEditViewFormActions.d.ts +1 -1
  12. package/dist/form/EntityForm.d.ts +8 -1
  13. package/dist/form/EntityFormActions.d.ts +2 -0
  14. package/dist/hooks/data/delete.d.ts +1 -1
  15. package/dist/hooks/data/useCollectionFetch.d.ts +7 -1
  16. package/dist/hooks/data/useEntityFetch.d.ts +4 -1
  17. package/dist/i18n/__tests__/FireCMSi18nProvider.test.d.ts +1 -0
  18. package/dist/i18n/__tests__/nested_provider_lifecycle.test.d.ts +1 -0
  19. package/dist/index.es.js +970 -652
  20. package/dist/index.es.js.map +1 -1
  21. package/dist/index.umd.js +969 -651
  22. package/dist/index.umd.js.map +1 -1
  23. package/dist/types/collections.d.ts +7 -0
  24. package/dist/types/datasource.d.ts +25 -4
  25. package/dist/types/entities.d.ts +21 -1
  26. package/dist/types/entity_actions.d.ts +6 -0
  27. package/dist/types/entity_callbacks.d.ts +21 -0
  28. package/dist/types/navigation.d.ts +34 -5
  29. package/dist/types/plugins.d.ts +13 -0
  30. package/dist/types/side_entity_controller.d.ts +3 -2
  31. package/dist/util/entity_cache.d.ts +11 -0
  32. package/dist/util/navigation_utils.d.ts +84 -2
  33. package/dist/util/parent_references_from_path.d.ts +16 -0
  34. package/dist/util/paths.d.ts +28 -0
  35. package/dist/util/permissions.d.ts +10 -4
  36. package/package.json +4 -4
  37. package/src/components/DeleteEntityDialog.tsx +2 -0
  38. package/src/components/EntityCollectionTable/EntityCollectionRowActions.tsx +2 -2
  39. package/src/components/EntityCollectionTable/internal/popup_field/PopupFormField.tsx +5 -1
  40. package/src/components/EntityCollectionView/EntityCollectionBoardView.tsx +12 -0
  41. package/src/components/EntityCollectionView/EntityCollectionView.tsx +49 -16
  42. package/src/components/EntityCollectionView/EntityCollectionViewActions.tsx +6 -2
  43. package/src/components/EntityCollectionView/EntityCollectionViewStartActions.tsx +4 -0
  44. package/src/components/EntityCollectionView/useBoardDataController.tsx +17 -1
  45. package/src/components/EntityPreview.tsx +4 -1
  46. package/src/components/ReferenceTable/ReferenceSelectionTable.tsx +19 -2
  47. package/src/components/common/default_entity_actions.tsx +22 -2
  48. package/src/components/common/useDataSourceTableController.tsx +32 -8
  49. package/src/components/common/useTableSearchHelper.ts +5 -0
  50. package/src/core/EntityEditView.tsx +11 -12
  51. package/src/core/EntityEditViewFormActions.tsx +3 -2
  52. package/src/core/EntitySidePanel.tsx +6 -4
  53. package/src/form/EntityForm.tsx +30 -9
  54. package/src/form/EntityFormActions.tsx +2 -0
  55. package/src/hooks/data/delete.ts +8 -0
  56. package/src/hooks/data/save.ts +5 -2
  57. package/src/hooks/data/useCollectionFetch.tsx +21 -1
  58. package/src/hooks/data/useEntityFetch.tsx +28 -6
  59. package/src/hooks/useBuildNavigationController.tsx +23 -8
  60. package/src/hooks/useResolvedNavigationFrom.tsx +3 -1
  61. package/src/i18n/FireCMSi18nProvider.tsx +48 -4
  62. package/src/i18n/__tests__/FireCMSi18nProvider.test.tsx +91 -0
  63. package/src/i18n/__tests__/nested_provider_lifecycle.test.tsx +67 -0
  64. package/src/internal/useBuildDataSource.ts +26 -15
  65. package/src/internal/useBuildSideEntityController.tsx +13 -5
  66. package/src/preview/components/ReferencePreview.tsx +7 -3
  67. package/src/routes/FireCMSRoute.tsx +9 -4
  68. package/src/types/collections.ts +8 -0
  69. package/src/types/datasource.ts +25 -4
  70. package/src/types/entities.ts +24 -1
  71. package/src/types/entity_actions.tsx +6 -0
  72. package/src/types/entity_callbacks.ts +24 -0
  73. package/src/types/navigation.ts +35 -5
  74. package/src/types/plugins.tsx +13 -0
  75. package/src/types/side_entity_controller.tsx +3 -2
  76. package/src/util/entities.ts +3 -1
  77. package/src/util/entity_cache.ts +18 -0
  78. package/src/util/navigation_from_path.ts +6 -1
  79. package/src/util/navigation_utils.ts +204 -2
  80. package/src/util/parent_references_from_path.ts +42 -3
  81. package/src/util/paths.ts +40 -3
  82. package/src/util/permissions.ts +22 -10
@@ -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
@@ -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,12 +80,14 @@ 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
  }
87
87
  return dataSource.fetchEntity({
88
88
  path: entry.path,
89
+ // The navigation entry already knows the real segment boundaries.
90
+ pathSegments: entry.pathSegments,
89
91
  entityId: entry.entityId,
90
92
  collection
91
93
  })
@@ -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
+ });
@@ -0,0 +1,67 @@
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
+ function Str({ k }: { k: string }) {
10
+ const { t } = useTranslation("firecms_core");
11
+ return <span data-testid={k}>{t(k)}</span>;
12
+ }
13
+
14
+ const host = { en: { host_key: "Host" } } as any;
15
+ const projectA = { en: { only_in_a: "From project A" } } as any;
16
+ const projectB = { en: { only_in_b: "From project B" } } as any;
17
+
18
+ describe("nested provider lifecycle", () => {
19
+ beforeEach(() => window.localStorage.clear());
20
+
21
+ it("does not carry one project's translations into the next", () => {
22
+ // The real shape of a project switch: App.tsx's provider stays mounted while
23
+ // FireCMSCloudApp's inner one is torn down and rebuilt with another project's
24
+ // translations. Both share the outer i18next instance now, so anything the
25
+ // first project added could outlive it.
26
+ const { rerender } = render(
27
+ <FireCMSi18nProvider translations={host}>
28
+ <FireCMSi18nProvider key={"a"} translations={projectA}>
29
+ <Str k={"only_in_a"}/>
30
+ </FireCMSi18nProvider>
31
+ </FireCMSi18nProvider>
32
+ );
33
+ expect(screen.getByTestId("only_in_a").textContent).toBe("From project A");
34
+
35
+ rerender(
36
+ <FireCMSi18nProvider translations={host}>
37
+ <FireCMSi18nProvider key={"b"} translations={projectB}>
38
+ <>
39
+ <Str k={"only_in_b"}/>
40
+ <Str k={"only_in_a"}/>
41
+ </>
42
+ </FireCMSi18nProvider>
43
+ </FireCMSi18nProvider>
44
+ );
45
+
46
+ expect(screen.getByTestId("only_in_b").textContent).toBe("From project B");
47
+ expect(screen.getByTestId("only_in_a").textContent).toBe("only_in_a");
48
+ });
49
+
50
+ it("keeps the host's own translations across the switch", () => {
51
+ const { rerender } = render(
52
+ <FireCMSi18nProvider translations={host}>
53
+ <FireCMSi18nProvider key={"a"} translations={projectA}>
54
+ <Str k={"host_key"}/>
55
+ </FireCMSi18nProvider>
56
+ </FireCMSi18nProvider>
57
+ );
58
+ rerender(
59
+ <FireCMSi18nProvider translations={host}>
60
+ <FireCMSi18nProvider key={"b"} translations={projectB}>
61
+ <Str k={"host_key"}/>
62
+ </FireCMSi18nProvider>
63
+ </FireCMSi18nProvider>
64
+ );
65
+ expect(screen.getByTestId("host_key").textContent).toBe("Host");
66
+ });
67
+ });
@@ -112,7 +112,7 @@ export function useBuildDataSource({
112
112
  }: ListenCollectionProps<M>
113
113
  ): () => void => {
114
114
 
115
- const collection = collectionProp ?? navigationController.getCollection(path);
115
+ const collection = collectionProp ?? navigationController.getCollection(path, false, pathSegments);
116
116
  const usedDelegate = collection?.overrides?.dataSourceDelegate ?? delegate;
117
117
 
118
118
  if (!usedDelegate.listenCollection)
@@ -213,7 +213,7 @@ export function useBuildDataSource({
213
213
  status
214
214
  }: SaveEntityProps<M>): Promise<Entity<M>> => {
215
215
 
216
- const collection = collectionProp ?? navigationController.getCollection(path);
216
+ const collection = collectionProp ?? navigationController.getCollection(path, false, pathSegments);
217
217
  const usedDelegate = collection?.overrides?.dataSourceDelegate ?? delegate;
218
218
 
219
219
  const resolvedCollection = collection
@@ -286,9 +286,15 @@ export function useBuildDataSource({
286
286
  values: finalValues,
287
287
  status
288
288
  }).then((res) => {
289
+ // Keep everything the delegate resolved — `databaseId`, and `pathSegments` in
290
+ // particular — so a just-saved entity is as resolvable as a fetched one.
291
+ // Only the values are re-mapped back to the CMS model.
289
292
  return {
290
- id: res.id,
291
- path: res.path,
293
+ ...res,
294
+ // A delegate that predates `pathSegments` returns none; the segments we
295
+ // just saved with describe exactly this entity's collection, so they are
296
+ // the authoritative answer rather than a guess.
297
+ pathSegments: res.pathSegments ?? pathSegments,
292
298
  values: usedDelegate.delegateToCMSModel(finalValues)
293
299
  } as Entity<M>;
294
300
  });
@@ -303,12 +309,15 @@ export function useBuildDataSource({
303
309
  deleteEntity: useCallback(<M extends Record<string, any>>(
304
310
  {
305
311
  entity,
312
+ pathSegments,
306
313
  collection
307
314
  }: DeleteEntityProps<M>
308
315
  ): Promise<void> => {
309
316
  const usedDelegate = collection?.overrides?.dataSourceDelegate ?? delegate;
310
317
  return usedDelegate.deleteEntity({
311
318
  entity,
319
+ // Falls back to what the entity carries from the fetch that produced it.
320
+ pathSegments: pathSegments ?? entity.pathSegments,
312
321
  collection
313
322
  });
314
323
  }, [delegate.deleteEntity]),
@@ -328,33 +337,32 @@ export function useBuildDataSource({
328
337
  name: string,
329
338
  value: any,
330
339
  entityId?: string,
331
- collection?: EntityCollection
340
+ collection?: EntityCollection,
341
+ pathSegments?: string[]
332
342
  ): Promise<boolean> => {
333
343
  const usedDelegate = collection?.overrides?.dataSourceDelegate ?? delegate;
334
- return usedDelegate.checkUniqueField(path, name, value, entityId, collection);
344
+ return usedDelegate.checkUniqueField(path, name, value, entityId, collection, pathSegments);
335
345
  }, [delegate.checkUniqueField]),
336
346
 
337
- generateEntityId: useCallback((path: string, collection: EntityCollection): string => {
347
+ generateEntityId: useCallback((path: string, collection: EntityCollection, pathSegments?: string[]): string => {
338
348
  const usedDelegate = collection?.overrides?.dataSourceDelegate ?? delegate;
339
- return usedDelegate.generateEntityId(path, collection);
349
+ return usedDelegate.generateEntityId(path, collection, pathSegments);
340
350
  }, [delegate.generateEntityId]),
341
351
 
352
+ // Typed with the declared props rather than an inline duplicate: the duplicate is
353
+ // free to drift from the interface, and a field it forgets is dropped in silence.
342
354
  countEntities: delegate.countEntities ? async ({
343
355
  path,
356
+ pathSegments,
344
357
  collection,
345
358
  filter,
346
359
  order,
347
360
  orderBy
348
- }: {
349
- path: string,
350
- collection: EntityCollection<any>,
351
- filter?: FilterValues<Extract<keyof any, string>>,
352
- orderBy?: string,
353
- order?: "desc" | "asc",
354
- }): Promise<number> => {
361
+ }: FetchCollectionProps<any>): Promise<number> => {
355
362
  const usedDelegate = collection?.overrides?.dataSourceDelegate ?? delegate;
356
363
  return usedDelegate.countEntities!({
357
364
  path,
365
+ pathSegments,
358
366
  filter,
359
367
  orderBy,
360
368
  order,
@@ -364,11 +372,13 @@ export function useBuildDataSource({
364
372
 
365
373
  isFilterCombinationValid: useCallback(({
366
374
  path,
375
+ pathSegments,
367
376
  databaseId,
368
377
  filterValues,
369
378
  sortBy
370
379
  }: {
371
380
  path: string,
381
+ pathSegments?: string[],
372
382
  databaseId?: string,
373
383
  filterValues: FilterValues<any>,
374
384
  sortBy?: [string, "asc" | "desc"]
@@ -378,6 +388,7 @@ export function useBuildDataSource({
378
388
  return delegate.isFilterCombinationValid(
379
389
  {
380
390
  path,
391
+ pathSegments,
381
392
  databaseId,
382
393
  filterValues,
383
394
  sortBy
@@ -281,11 +281,17 @@ const propsToSidePanel = (props: EntitySidePanelProps,
281
281
  locationSearch: string
282
282
  ): SideDialogPanelProps => {
283
283
 
284
- const collectionPath = removeInitialAndTrailingSlashes(props.path);
284
+ // The URL is the ESCAPED representation of the chain — it is what `buildSidePanelsFromUrl`
285
+ // and `FireCMSRoute` parse back on reload, on a deep link, and on every pathname change.
286
+ // `props.path` is the RAW datasource path, so a parent entity id containing "/" reads back
287
+ // there as extra collection/entity hops and resolves to a different entity entirely.
288
+ // `fullIdPath` carries the same chain with its ids already escaped, which is what it is
289
+ // threaded alongside `path` for.
290
+ const urlChain = removeInitialAndTrailingSlashes(props.fullIdPath ?? props.path);
285
291
 
286
292
  const urlPath = props.entityId
287
- ? buildUrlCollectionPath(`${collectionPath}/${encodeEntityId(props.entityId)}${props.selectedTab ? "/" + props.selectedTab : ""}${locationSearch}#${SIDE_URL_HASH}`)
288
- : buildUrlCollectionPath(`${collectionPath}${locationSearch}#${NEW_URL_HASH}`);
293
+ ? buildUrlCollectionPath(`${urlChain}/${encodeEntityId(props.entityId)}${props.selectedTab ? "/" + props.selectedTab : ""}${locationSearch}#${SIDE_URL_HASH}`)
294
+ : buildUrlCollectionPath(`${urlChain}${locationSearch}#${NEW_URL_HASH}`);
289
295
 
290
296
  const resolvedPanelProps: EntitySidePanelProps<any> = {
291
297
  ...props,
@@ -294,10 +300,12 @@ const propsToSidePanel = (props: EntitySidePanelProps,
294
300
 
295
301
  const entityViewWidth = getEntityViewWidth(props, smallLayout, customizationController, authController);
296
302
  return {
297
- key: `${props.path}/${props.entityId}`,
303
+ // Built from the escaped chain so that two different chains which happen to flatten
304
+ // to the same raw string are still two different panels.
305
+ key: `${urlChain}/${props.entityId ? encodeEntityId(props.entityId) : ""}`,
298
306
  component: undefined, // Lazy render in SideDialogs for better performance
299
307
  urlPath: urlPath,
300
- parentUrlPath: buildUrlCollectionPath(collectionPath),
308
+ parentUrlPath: buildUrlCollectionPath(urlChain),
301
309
  width: entityViewWidth,
302
310
  onClose: props.onClose,
303
311
  additional: resolvedPanelProps
@@ -7,6 +7,7 @@ import { Skeleton } from "@firecms/ui";
7
7
  import { ErrorBoundary, ErrorView } from "../../components";
8
8
  import { EntityPreview, EntityPreviewContainer } from "../../components/EntityPreview";
9
9
  import { jsonStringifyReplacer } from "../../util/objects";
10
+ import { entityCacheKey } from "../../util/entity_cache";
10
11
 
11
12
  export type ReferencePreviewProps = {
12
13
  disabled?: boolean;
@@ -53,7 +54,7 @@ function ReferencePreviewInternal({
53
54
 
54
55
  const navigationController = useNavigationController();
55
56
 
56
- const collection = navigationController.getCollection(reference.path);
57
+ const collection = navigationController.getCollection(reference.path, false, reference.pathSegments);
57
58
  if (!collection) {
58
59
  if (customizationController.components?.missingReference) {
59
60
  return <customizationController.components.missingReference path={reference.path}/>;
@@ -99,16 +100,19 @@ function ReferencePreviewExisting<M extends Record<string, any> = any>({
99
100
  dataLoadingError
100
101
  } = useEntityFetch({
101
102
  path: reference.path,
103
+ // References carry the segments of the collection they point at, when the code that
104
+ // built them knew them (see getReferenceFrom).
105
+ pathSegments: reference.pathSegments,
102
106
  entityId: reference.id,
103
107
  collection,
104
108
  useCache: true
105
109
  });
106
110
 
107
111
  if (entity) {
108
- referencesCache.set(reference.pathWithId, entity);
112
+ referencesCache.set(entityCacheKey(reference.path, reference.id), entity);
109
113
  }
110
114
 
111
- const usedEntity = entity ?? referencesCache.get(reference.pathWithId);
115
+ const usedEntity = entity ?? referencesCache.get(entityCacheKey(reference.path, reference.id));
112
116
 
113
117
  let body: React.ReactNode;
114
118
 
@@ -90,7 +90,7 @@ export function FireCMSRoute() {
90
90
  let collection: EntityCollection<any> | undefined;
91
91
  collection = navigation.getCollectionById(navigationEntries[0].id);
92
92
  if (!collection)
93
- collection = navigation.getCollection(navigationEntries[0].path);
93
+ collection = navigation.getCollection(navigationEntries[0].path, false, navigationEntries[0].pathSegments);
94
94
  if (!collection)
95
95
  return null;
96
96
  return <React.Suspense fallback={null}>
@@ -114,7 +114,7 @@ export function FireCMSRoute() {
114
114
  const firstEntry = navigationEntries[0] as NavigationViewCollectionInternal<any>;
115
115
  collection = navigation.getCollectionById(firstEntry.id);
116
116
  if (!collection)
117
- collection = navigation.getCollection(firstEntry.path);
117
+ collection = navigation.getCollection(firstEntry.path, false, firstEntry.pathSegments);
118
118
  if (!collection)
119
119
  return null;
120
120
  return <React.Suspense fallback={null}>
@@ -186,6 +186,8 @@ function EntityFullScreenRoute({
186
186
  const urlTab = getSelectedTabFromUrl(isNew, lastCustomView);
187
187
  const [selectedTab, setSelectedTab] = useState<string | undefined>(urlTab);
188
188
 
189
+ // `navigationPath` comes from the URL, where entity ids are still escaped (see
190
+ // encodeEntityId), so splitting it on "/" inside is correct and no segments are needed.
189
191
  const parentCollectionIds = navigation.getParentCollectionIds(navigationPath);
190
192
  useEffect(() => {
191
193
  if (urlTab !== selectedTab) {
@@ -250,14 +252,17 @@ function EntityFullScreenRoute({
250
252
  // `fullIdPath` is used downstream to build URLs, so it carries the escaped chain;
251
253
  // `collectionPath` addresses the datasource, so it is resolved from the raw one.
252
254
  const fullIdPath = isNew ? lastCollectionEntry!.fullPath : (parentCollectionEntry?.fullPath ?? lastEntityEntry!.fullPath);
253
- const collectionPath = navigation.resolveIdsFrom(isNew ? lastCollectionEntry!.path : lastEntityEntry!.path);
255
+ // The navigation entries know the real segment boundaries, so the path is resolved
256
+ // from them rather than re-parsed out of the flattened string.
257
+ const pathSegments = isNew ? lastCollectionEntry!.pathSegments : lastEntityEntry!.pathSegments;
258
+ const collectionPath = navigation.resolveIdsFrom(isNew ? lastCollectionEntry!.path : lastEntityEntry!.path, pathSegments);
254
259
  return <>
255
260
  <React.Suspense fallback={null}>
256
261
  <EntityEditView
257
262
  key={collection.id + "_" + (isNew ? "new" : (isCopy ? entityId + "_copy" : entityId))}
258
263
  entityId={isNew ? undefined : entityId}
259
264
  fullIdPath={fullIdPath}
260
- pathSegments={isNew ? lastCollectionEntry!.pathSegments : lastEntityEntry!.pathSegments}
265
+ pathSegments={pathSegments}
261
266
  collection={collection}
262
267
  layout={"full_screen"}
263
268
  path={collectionPath}