@firecms/core 3.4.0-canary.e2466ba → 3.4.0

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.
@@ -1,3 +1,14 @@
1
+ /**
2
+ * Key identifying one entity in a cache.
3
+ *
4
+ * The id is escaped so that a `(path, entityId)` pair maps to exactly one key. Without it
5
+ * `("a", "b/c")` and `("a/b", "c")` both flatten to `"a/b/c"`, and one entity's cached
6
+ * values are served for the other.
7
+ *
8
+ * `encodeEntityId` is the identity for any id without "/", "?", "#" or "%", so this changes
9
+ * no key in an app whose ids cannot contain them.
10
+ */
11
+ export declare function entityCacheKey(path: string | undefined, entityId: string | undefined): string;
1
12
  /**
2
13
  * Saves data to the in-memory cache and persists it individually in `localStorage`.
3
14
  * @param path - The unique path/key for the data.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@firecms/core",
3
3
  "type": "module",
4
- "version": "3.4.0-canary.e2466ba",
4
+ "version": "3.4.0",
5
5
  "description": "Awesome Firebase/Firestore-based headless open-source CMS",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/firecmsco"
@@ -53,8 +53,8 @@
53
53
  "@dnd-kit/core": "^6.3.1",
54
54
  "@dnd-kit/modifiers": "^9.0.0",
55
55
  "@dnd-kit/sortable": "^10.0.0",
56
- "@firecms/formex": "3.4.0-canary.e2466ba",
57
- "@firecms/ui": "3.4.0-canary.e2466ba",
56
+ "@firecms/formex": "3.4.0",
57
+ "@firecms/ui": "3.4.0",
58
58
  "@floating-ui/dom": "^1.7.4",
59
59
  "@radix-ui/react-portal": "^1.1.10",
60
60
  "@radix-ui/react-slot": "^1.2.4",
@@ -3,7 +3,7 @@ import React, { MouseEvent, useCallback } from "react";
3
3
  import { CollectionSize, Entity, EntityAction, EntityCollection, SelectionController } from "../../types";
4
4
  import { Badge, Checkbox, cls, IconButton, Menu, MenuItem, MoreVertIcon, Skeleton, Tooltip } from "@firecms/ui";
5
5
  import { useFireCMSContext, useLargeLayout } from "../../hooks";
6
- import { getEntityFromCache } from "../../util/entity_cache";
6
+ import { entityCacheKey, getEntityFromCache } from "../../util/entity_cache";
7
7
  import { getLocalChangesBackup } from "../../util";
8
8
  import { getChanges } from "../../form/EntityForm";
9
9
 
@@ -82,7 +82,7 @@ export const EntityCollectionRowActions = function EntityCollectionRowActions({
82
82
  const collapsedActions = actions.filter(a => a.collapsed || a.collapsed === undefined);
83
83
  const uncollapsedActions = actions.filter(a => a.collapsed === false);
84
84
  const enableLocalChangesBackup = collection ? getLocalChangesBackup(collection) : false;
85
- const cachedData = enableLocalChangesBackup ? getEntityFromCache(fullPath + "/" + entity.id) : undefined;
85
+ const cachedData = enableLocalChangesBackup ? getEntityFromCache(entityCacheKey(fullPath, entity.id)) : undefined;
86
86
  const hasDraft = (() => {
87
87
  if (!cachedData || typeof cachedData !== "object" || Object.keys(cachedData).length === 0) return false;
88
88
  const realChanges = getChanges(cachedData as any, (entity?.values ?? {}) as any);
@@ -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
 
@@ -693,7 +694,7 @@ export const EntityCollectionView = React.memo(
693
694
  {collectionsWithPath.map((reference) => {
694
695
  return (
695
696
  <ReferencePreview
696
- key={reference.path + "/" + reference.id}
697
+ key={entityCacheKey(reference.path, reference.id)}
697
698
  reference={reference}
698
699
  size={"small"} />
699
700
  );
@@ -4,17 +4,16 @@ import { useTranslation } from "../hooks/useTranslation";
4
4
  import { ErrorIcon, Typography } from "@firecms/ui";
5
5
 
6
6
  export class ErrorBoundary extends React.Component<PropsWithChildren<Record<string, unknown>>, {
7
- hasError: boolean,
8
- error?: Error
7
+ error: Error | null
9
8
  }> {
10
9
  constructor(props: any) {
11
10
  super(props);
12
- this.state = { hasError: false };
11
+ this.state = { error: null };
13
12
  }
14
13
 
15
14
  // eslint-disable-next-line n/handle-callback-err
16
15
  static getDerivedStateFromError(error: Error) {
17
- return { hasError: true, error };
16
+ return { error };
18
17
  }
19
18
 
20
19
  componentDidCatch(error: Error, errorInfo: ErrorInfo) {
@@ -23,9 +22,8 @@ export class ErrorBoundary extends React.Component<PropsWithChildren<Record<stri
23
22
  }
24
23
 
25
24
  render() {
26
- if (this.state.hasError) {
27
- // You can render any custom fallback UI
28
- return <FallbackView message={this.state.error?.message}/>;
25
+ if (this.state.error) {
26
+ return <FallbackView message={this.state.error.message}/>;
29
27
  }
30
28
 
31
29
  return this.props.children;
@@ -35,18 +33,14 @@ export class ErrorBoundary extends React.Component<PropsWithChildren<Record<stri
35
33
  function FallbackView({ message }: { message?: string }) {
36
34
  const { t } = useTranslation();
37
35
  return (
38
- <div className="h-full w-full bg-slate-100 dark:bg-surface-900 flex items-center justify-center p-4">
39
- <div
40
- className="flex flex-col items-center justify-center m-4 bg-white dark:bg-surface-800 p-8 rounded-lg shadow-sm border border-gray-200 dark:border-surface-700">
41
- <div className="flex items-center mb-4 text-red-500 dark:text-red-400">
42
- <ErrorIcon/>
43
- <div className="ml-4">{t("error")}</div>
44
- </div>
45
- <div className="flex justify-center text-gray-500 dark:text-gray-400">
46
- {/* Error message is purposely removed since it's hard to access state here, but typical ErrorBoundary fallback doesn't always show the raw message */}
47
- {t("see_console_details")}
48
- </div>
36
+ <div className="flex flex-col m-2">
37
+ <div className="flex items-center m-2">
38
+ <ErrorIcon color={"error"} size={"small"}/>
39
+ <div className="ml-4">{t("error")}</div>
49
40
  </div>
41
+ <Typography variant={"caption"}>
42
+ {message ?? t("see_console_details")}
43
+ </Typography>
50
44
  </div>
51
45
  );
52
46
  }
@@ -84,6 +84,7 @@ export const copyEntityAction: EntityAction = {
84
84
  context,
85
85
  fullPath,
86
86
  pathSegments,
87
+ fullIdPath,
87
88
  highlightEntity,
88
89
  unhighlightEntity,
89
90
  openEntityMode
@@ -104,14 +105,17 @@ export const copyEntityAction: EntityAction = {
104
105
  const resolvedPathSegments = collection?.collectionGroup
105
106
  ? undefined
106
107
  : (pathSegments ?? entity.pathSegments);
107
- const fullIdPath = collection?.collectionGroup ? collection.id : (fullPath ?? collection?.id ?? entity.path);
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);
108
112
  navigateToEntity({
109
113
  openEntityMode,
110
114
  collection,
111
115
  entityId: entity.id,
112
116
  path,
113
117
  pathSegments: resolvedPathSegments,
114
- fullIdPath,
118
+ fullIdPath: newFullIdPath,
115
119
  copy: true,
116
120
  sideEntityController: context.sideEntityController,
117
121
  onClose: () => unhighlightEntity?.(entity),
@@ -194,11 +194,23 @@ export function useDataSourceTableController<M extends Record<string, any> = any
194
194
 
195
195
  useUpdateUrl(filterValues, sortBy, searchString, updateUrl);
196
196
 
197
- const collectionScroll = scrollRestoration?.getCollectionScroll(fullPath, filterValues);
198
- 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;
199
206
 
200
207
  useEffect(() => {
201
- 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) {
202
214
  scrollRestoration.updateCollectionScroll({
203
215
  fullPath: resolvedPath,
204
216
  scrollOffset: collectionScroll?.scrollOffset ?? 0,
@@ -29,7 +29,7 @@ import {
29
29
  useLargeLayout
30
30
  } from "../hooks";
31
31
  import { CircularProgress, cls, CodeIcon, defaultBorderMixin, Tab, Tabs, Typography, Menu, MenuItem, ExpandMoreIcon } from "@firecms/ui";
32
- import { getEntityFromMemoryCache } from "../util/entity_cache";
32
+ import { entityCacheKey, getEntityFromMemoryCache } from "../util/entity_cache";
33
33
  import { EntityForm, EntityFormProps } from "../form";
34
34
  import { EntityEditViewFormActions } from "./EntityEditViewFormActions";
35
35
  import { EntityJsonPreview } from "../components/EntityJsonPreview";
@@ -118,7 +118,7 @@ export function EntityEditView<M extends Record<string, any>, USER extends User>
118
118
  });
119
119
 
120
120
  const initialDirtyValues = entityId
121
- ? getEntityFromMemoryCache(props.path + "/" + entityId)
121
+ ? getEntityFromMemoryCache(entityCacheKey(props.path, entityId))
122
122
  : getEntityFromMemoryCache(props.path + "#new");
123
123
 
124
124
  const authController = useAuthController();
@@ -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
@@ -138,7 +138,7 @@ export function EntitySidePanel(props: EntitySidePanelProps) {
138
138
  className="self-center"
139
139
  size={"smallest"}
140
140
  onClick={() => {
141
- const key = (status === "new" || status === "copy") ? path + "#new" : path + "/" + entityId;
141
+ const key = (status === "new" || status === "copy") ? path + "#new" : entityCacheKey(path, entityId);
142
142
  saveEntityToMemoryCache(key, values);
143
143
  if (entityId)
144
144
  navigate(location.pathname + location.search);
@@ -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,
@@ -317,7 +318,7 @@ export function EntityForm<M extends Record<string, any>>({
317
318
  const baseInitialValues = useMemo(() => getInitialEntityValues(authController, collection, path, status, entity, customizationController.propertyConfigs), [authController, collection, path, status, entity, customizationController.propertyConfigs]);
318
319
 
319
320
  const localChangesDataRaw = useMemo(() => entityId
320
- ? getEntityFromCache(path + "/" + entityId)
321
+ ? getEntityFromCache(entityCacheKey(path, entityId))
321
322
  : getEntityFromCache(path + "#new"), [entityId, path]);
322
323
 
323
324
  const [localChangesCleared, setLocalChangesCleared] = useState<boolean>(false);
@@ -396,7 +397,7 @@ export function EntityForm<M extends Record<string, any>>({
396
397
  onValuesModified?.(false, initialValues as M);
397
398
  },
398
399
  onValuesChangeDeferred: (values: M, controller: FormexController<M>) => {
399
- const key = (status === "new" || status === "copy") ? path + "#new" : path + "/" + entityId;
400
+ const key = (status === "new" || status === "copy") ? path + "#new" : entityCacheKey(path, entityId);
400
401
  if (controller.dirty && localChangesBackup !== false) {
401
402
  const touchedValues = removeEmptyContainers(extractTouchedValues(values, controller.touched));
402
403
  if (touchedValues && Object.keys(touchedValues).length > 0) {
@@ -477,8 +478,8 @@ export function EntityForm<M extends Record<string, any>>({
477
478
  removeEntityFromMemoryCache(path + "#new");
478
479
  removeEntityFromCache(path + "#new");
479
480
  } else {
480
- removeEntityFromMemoryCache(path + "/" + entityId);
481
- removeEntityFromCache(path + "/" + entityId);
481
+ removeEntityFromMemoryCache(entityCacheKey(path, entityId));
482
+ removeEntityFromCache(entityCacheKey(path, entityId));
482
483
  }
483
484
  }
484
485
 
@@ -944,7 +945,7 @@ export function EntityForm<M extends Record<string, any>>({
944
945
 
945
946
  {manualApplyLocalChanges && hasLocalChanges &&
946
947
  <LocalChangesMenu
947
- cacheKey={status === "new" || status === "copy" ? path + "#new" : path + "/" + entityId}
948
+ cacheKey={status === "new" || status === "copy" ? path + "#new" : entityCacheKey(path, entityId)}
948
949
  properties={resolvedCollection.properties}
949
950
  cachedData={localChangesDataRaw as Partial<M>}
950
951
  formex={formex}
@@ -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
@@ -101,7 +102,7 @@ export function useEntityFetch<M extends Record<string, any>, USER extends User>
101
102
  console.error(e);
102
103
  }
103
104
  }
104
- CACHE[`${path}/${entityId}`] = updatedEntity;
105
+ CACHE[entityCacheKey(path, entityId)] = updatedEntity;
105
106
  setEntity(updatedEntity);
106
107
  setDataLoading(false);
107
108
  setDataLoadingError(undefined);
@@ -114,8 +115,8 @@ export function useEntityFetch<M extends Record<string, any>, USER extends User>
114
115
  setDataLoadingError(error);
115
116
  };
116
117
 
117
- if (entityId && useCache && CACHE[`${path}/${entityId}`]) {
118
- setEntity(CACHE[`${path}/${entityId}`]);
118
+ if (entityId && useCache && CACHE[entityCacheKey(path, entityId)]) {
119
+ setEntity(CACHE[entityCacheKey(path, entityId)]);
119
120
  setDataLoading(false);
120
121
  setDataLoadingError(undefined);
121
122
  // eslint-disable-next-line @typescript-eslint/no-empty-function
@@ -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
+ });
@@ -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;
@@ -108,10 +109,10 @@ function ReferencePreviewExisting<M extends Record<string, any> = any>({
108
109
  });
109
110
 
110
111
  if (entity) {
111
- referencesCache.set(reference.pathWithId, entity);
112
+ referencesCache.set(entityCacheKey(reference.path, reference.id), entity);
112
113
  }
113
114
 
114
- const usedEntity = entity ?? referencesCache.get(reference.pathWithId);
115
+ const usedEntity = entity ?? referencesCache.get(entityCacheKey(reference.path, reference.id));
115
116
 
116
117
  let body: React.ReactNode;
117
118
 
@@ -1,5 +1,23 @@
1
1
  import { EntityReference, GeoPoint, Vector } from "../types";
2
2
  import { isObject, isPlainObject } from "./objects";
3
+ import { encodeEntityId } from "./navigation_utils";
4
+
5
+ /**
6
+ * Key identifying one entity in a cache.
7
+ *
8
+ * The id is escaped so that a `(path, entityId)` pair maps to exactly one key. Without it
9
+ * `("a", "b/c")` and `("a/b", "c")` both flatten to `"a/b/c"`, and one entity's cached
10
+ * values are served for the other.
11
+ *
12
+ * `encodeEntityId` is the identity for any id without "/", "?", "#" or "%", so this changes
13
+ * no key in an app whose ids cannot contain them.
14
+ */
15
+ export function entityCacheKey(path: string | undefined, entityId: string | undefined): string {
16
+ // Either part may be undefined at a call site that used to build the key by string
17
+ // concatenation; both are stringified exactly as `path + "/" + entityId` did, so such a
18
+ // caller keeps the key it had.
19
+ return `${path}/${entityId === undefined ? entityId : encodeEntityId(entityId)}`;
20
+ }
3
21
 
4
22
  // Define a unique prefix for entity keys in localStorage to avoid key collisions
5
23
  const LOCAL_STORAGE_PREFIX = "entity_cache::";
@@ -147,7 +147,12 @@ export function getNavigationEntriesFromPath(props: {
147
147
  path: newPath,
148
148
  collections: collection.subcollections,
149
149
  currentFullPath: fullPath,
150
- currentFullIdPath: fullIdPath,
150
+ // The entity id is a hop in the id chain exactly as it is in the
151
+ // other two. Without it a nested `fullIdPath` was
152
+ // "products/locales" rather than "products/pid/locales", so any
153
+ // URL built from it pointed at a collection that does not exist.
154
+ // Escaped, because `fullIdPath` is URL-facing.
155
+ currentFullIdPath: fullIdPath + "/" + encodedEntityId,
151
156
  currentFullUrlPath: fullUrlPath,
152
157
  currentPathSegments: entitySegments,
153
158
  contextEntityViews: props.contextEntityViews