@firecms/core 3.4.0-canary.0ef7442 → 3.4.0-canary.1faa2a9

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 (152) hide show
  1. package/README.md +4 -0
  2. package/dist/components/EntityCollectionView/EntityCollectionView.d.ts +3 -2
  3. package/dist/components/EntityCollectionView/EntityCollectionViewActions.d.ts +3 -1
  4. package/dist/components/EntityCollectionView/EntityCollectionViewStartActions.d.ts +3 -1
  5. package/dist/components/ErrorBoundary.d.ts +1 -3
  6. package/dist/components/LicenseBanner.d.ts +23 -0
  7. package/dist/components/common/useDataSourceTableController.d.ts +4 -2
  8. package/dist/components/common/useTableSearchHelper.d.ts +3 -1
  9. package/dist/components/index.d.ts +1 -0
  10. package/dist/contexts/LicenseStatusContext.d.ts +16 -0
  11. package/dist/core/EntityEditView.d.ts +2 -0
  12. package/dist/core/EntityEditViewFormActions.d.ts +1 -1
  13. package/dist/core/index.d.ts +1 -0
  14. package/dist/core/plugin_translations.d.ts +38 -0
  15. package/dist/core/pro_plugins.d.ts +40 -0
  16. package/dist/de-KtBROUvS.js +700 -0
  17. package/dist/de-KtBROUvS.js.map +1 -0
  18. package/dist/editor/extensions/Image.d.ts +11 -0
  19. package/dist/editor/index.d.ts +2 -1
  20. package/dist/editor/lazy_editor.d.ts +6 -0
  21. package/dist/editor-BSo_aS1I.js +4563 -0
  22. package/dist/editor-BSo_aS1I.js.map +1 -0
  23. package/dist/form/EntityForm.d.ts +2 -0
  24. package/dist/form/EntityFormActions.d.ts +2 -0
  25. package/dist/fr-BRFSBaVh.js +700 -0
  26. package/dist/fr-BRFSBaVh.js.map +1 -0
  27. package/dist/hi-DwCkga5X.js +700 -0
  28. package/dist/hi-DwCkga5X.js.map +1 -0
  29. package/dist/hooks/data/delete.d.ts +1 -1
  30. package/dist/hooks/data/useCollectionFetch.d.ts +1 -1
  31. package/dist/hooks/data/useEntityFetch.d.ts +4 -1
  32. package/dist/hooks/index.d.ts +2 -0
  33. package/dist/hooks/useLicenseStatus.d.ts +16 -0
  34. package/dist/hooks/useProjectLog.d.ts +11 -6
  35. package/dist/hooks/useTranslation.d.ts +1 -1
  36. package/dist/i18n/__tests__/FireCMSi18nProvider.test.d.ts +1 -0
  37. package/dist/i18n/__tests__/nested_provider_lifecycle.test.d.ts +1 -0
  38. package/dist/index.es.js +3503 -11244
  39. package/dist/index.es.js.map +1 -1
  40. package/dist/index.umd.js +8028 -7006
  41. package/dist/index.umd.js.map +1 -1
  42. package/dist/it-B2YGgDsx.js +700 -0
  43. package/dist/it-B2YGgDsx.js.map +1 -0
  44. package/dist/pl-BxHkywK8.js +702 -0
  45. package/dist/pl-BxHkywK8.js.map +1 -0
  46. package/dist/pt-q6UV4diT.js +702 -0
  47. package/dist/pt-q6UV4diT.js.map +1 -0
  48. package/dist/types/analytics.d.ts +1 -1
  49. package/dist/types/collections.d.ts +7 -0
  50. package/dist/types/datasource.d.ts +8 -0
  51. package/dist/types/entity_actions.d.ts +6 -0
  52. package/dist/types/entity_callbacks.d.ts +21 -0
  53. package/dist/types/firecms.d.ts +18 -1
  54. package/dist/types/firecms_context.d.ts +8 -0
  55. package/dist/types/index.d.ts +1 -0
  56. package/dist/types/license.d.ts +54 -0
  57. package/dist/types/navigation.d.ts +34 -5
  58. package/dist/types/plugins.d.ts +22 -1
  59. package/dist/types/side_entity_controller.d.ts +3 -2
  60. package/dist/types/translations.d.ts +31 -2
  61. package/dist/util/date_fns_locales.d.ts +18 -0
  62. package/dist/util/entity_cache.d.ts +11 -0
  63. package/dist/util/index.d.ts +1 -0
  64. package/dist/util/lazy_eager.d.ts +3 -3
  65. package/dist/util/navigation_utils.d.ts +84 -2
  66. package/dist/util/parent_references_from_path.d.ts +15 -0
  67. package/dist/util/paths.d.ts +28 -0
  68. package/dist/util/permissions.d.ts +10 -4
  69. package/package.json +3 -3
  70. package/src/app/Scaffold.tsx +7 -0
  71. package/src/components/DeleteEntityDialog.tsx +2 -0
  72. package/src/components/EntityCollectionTable/EntityCollectionRowActions.tsx +2 -2
  73. package/src/components/EntityCollectionView/EntityCollectionBoardView.tsx +8 -0
  74. package/src/components/EntityCollectionView/EntityCollectionView.tsx +40 -15
  75. package/src/components/EntityCollectionView/EntityCollectionViewActions.tsx +6 -2
  76. package/src/components/EntityCollectionView/EntityCollectionViewStartActions.tsx +4 -0
  77. package/src/components/EntityCollectionView/useBoardDataController.tsx +7 -2
  78. package/src/components/EntityPreview.tsx +4 -1
  79. package/src/components/ErrorBoundary.tsx +12 -18
  80. package/src/components/LicenseBanner.tsx +209 -0
  81. package/src/components/ReferenceTable/ReferenceSelectionTable.tsx +8 -2
  82. package/src/components/common/default_entity_actions.tsx +22 -2
  83. package/src/components/common/useDataSourceTableController.tsx +26 -8
  84. package/src/components/common/useTableSearchHelper.ts +5 -0
  85. package/src/components/index.tsx +1 -0
  86. package/src/contexts/LicenseStatusContext.tsx +50 -0
  87. package/src/core/DefaultAppBar.tsx +28 -16
  88. package/src/core/EntityEditView.tsx +8 -12
  89. package/src/core/EntityEditViewFormActions.tsx +3 -2
  90. package/src/core/EntitySidePanel.tsx +6 -4
  91. package/src/core/FireCMS.tsx +73 -47
  92. package/src/core/SideDialogs.tsx +44 -1
  93. package/src/core/index.tsx +1 -0
  94. package/src/core/plugin_translations.ts +87 -0
  95. package/src/core/pro_plugins.ts +79 -0
  96. package/src/editor/components/SlashCommandMenu.tsx +4 -1
  97. package/src/editor/extensions/Image.ts +17 -1
  98. package/src/editor/hooks/useProseMirror.ts +5 -0
  99. package/src/editor/index.ts +11 -1
  100. package/src/editor/lazy_editor.tsx +33 -0
  101. package/src/form/EntityForm.tsx +22 -7
  102. package/src/form/EntityFormActions.tsx +2 -0
  103. package/src/hooks/data/delete.ts +8 -2
  104. package/src/hooks/data/save.ts +4 -1
  105. package/src/hooks/data/useCollectionFetch.tsx +9 -2
  106. package/src/hooks/data/useEntityFetch.tsx +19 -6
  107. package/src/hooks/index.tsx +2 -0
  108. package/src/hooks/useBuildNavigationController.tsx +70 -15
  109. package/src/hooks/useFireCMSContext.tsx +7 -3
  110. package/src/hooks/useLicenseStatus.tsx +32 -0
  111. package/src/hooks/useProjectLog.tsx +27 -9
  112. package/src/hooks/useResolvedNavigationFrom.tsx +1 -1
  113. package/src/hooks/useTranslation.ts +3 -1
  114. package/src/i18n/FireCMSi18nProvider.tsx +199 -25
  115. package/src/i18n/__tests__/FireCMSi18nProvider.test.tsx +91 -0
  116. package/src/i18n/__tests__/nested_provider_lifecycle.test.tsx +67 -0
  117. package/src/internal/useBuildDataSource.ts +13 -12
  118. package/src/internal/useBuildSideEntityController.tsx +13 -5
  119. package/src/locales/de.ts +16 -2
  120. package/src/locales/en.ts +18 -2
  121. package/src/locales/es.ts +16 -2
  122. package/src/locales/fr.ts +16 -2
  123. package/src/locales/hi.ts +16 -2
  124. package/src/locales/it.ts +16 -2
  125. package/src/locales/pl.ts +16 -2
  126. package/src/locales/pt.ts +16 -2
  127. package/src/preview/components/DatePreview.tsx +2 -4
  128. package/src/preview/components/ReferencePreview.tsx +7 -3
  129. package/src/routes/FireCMSRoute.tsx +9 -4
  130. package/src/types/analytics.ts +3 -0
  131. package/src/types/collections.ts +8 -0
  132. package/src/types/datasource.ts +8 -0
  133. package/src/types/entity_actions.tsx +6 -0
  134. package/src/types/entity_callbacks.ts +24 -0
  135. package/src/types/firecms.tsx +19 -1
  136. package/src/types/firecms_context.tsx +9 -0
  137. package/src/types/index.ts +1 -0
  138. package/src/types/license.ts +62 -0
  139. package/src/types/navigation.ts +35 -5
  140. package/src/types/plugins.tsx +23 -1
  141. package/src/types/side_entity_controller.tsx +3 -2
  142. package/src/types/translations.ts +31 -2
  143. package/src/util/date_fns_locales.ts +168 -0
  144. package/src/util/entity_cache.ts +18 -0
  145. package/src/util/index.ts +1 -0
  146. package/src/util/lazy_eager.tsx +30 -8
  147. package/src/util/navigation_from_path.ts +6 -1
  148. package/src/util/navigation_utils.ts +204 -2
  149. package/src/util/parent_references_from_path.ts +32 -1
  150. package/src/util/paths.ts +40 -3
  151. package/src/util/permissions.ts +22 -10
  152. package/src/util/useStorageUploadController.tsx +6 -1
@@ -1,14 +1,10 @@
1
1
  import { useEffect, useRef, useState } from "react";
2
- import { AuthController, DataSourceDelegate, FireCMSPlugin } from "../types";
2
+ import { AccessResponse, AuthController, DataSourceDelegate, FireCMSPlugin } from "../types";
3
+ import { PRO_PLUGIN_KEYS } from "../core/pro_plugins";
3
4
 
4
5
  export const DEFAULT_SERVER_DEV = "https://api-kdoe6pj3qq-ey.a.run.app";
5
6
  export const DEFAULT_SERVER = "https://api.firecms.co";
6
7
 
7
- export type AccessResponse = {
8
- blocked?: boolean;
9
- message?: string;
10
- }
11
-
12
8
  async function makeRequest(authController: AuthController, dataSourceKey: string, pluginKeys: string[] | undefined, apiKey?: string): Promise<AccessResponse> {
13
9
  let idToken: string | null;
14
10
  try {
@@ -40,24 +36,46 @@ export interface UseProjectLogParams {
40
36
  apiKey?: string;
41
37
  authController: AuthController;
42
38
  dataSourceDelegate: DataSourceDelegate;
39
+ /**
40
+ * Every plugin the app mounts, before any are paused: the server decides
41
+ * from these keys whether the project needs a license.
42
+ */
43
43
  plugins?: FireCMSPlugin<any, any, any>[];
44
+ /**
45
+ * When false, no request is sent, unless the request is a license check:
46
+ * with an `apiKey`, or with a PRO plugin mounted, it is always sent.
47
+ */
48
+ telemetry?: boolean;
44
49
  }
45
50
 
46
51
  export function useProjectLog({
47
52
  authController,
48
53
  dataSourceDelegate,
49
54
  plugins,
50
- apiKey
55
+ apiKey,
56
+ telemetry = true
51
57
  }: UseProjectLogParams): AccessResponse | null {
52
58
  const [accessResponse, setAccessResponse] = useState<AccessResponse | null>(null);
53
59
  const accessedUserRef = useRef<string | null>(null);
54
60
  const dataSourceKey = dataSourceDelegate.key;
55
61
  const pluginKeys = plugins?.map(plugin => plugin.key);
62
+ // Opting out is for apps without PRO plugins. With one mounted, the
63
+ // request is what starts the trial and pauses PRO when it ends, so
64
+ // skipping it would unlock PRO for good.
65
+ const runsProPlugin = (pluginKeys ?? []).some(key => PRO_PLUGIN_KEYS.includes(key));
66
+ const enabled = Boolean(apiKey) || runsProPlugin || telemetry !== false;
56
67
  useEffect(() => {
68
+ if (!enabled) return;
57
69
  if (authController.user && authController.user.uid !== accessedUserRef.current && !authController.initialLoading) {
58
- makeRequest(authController, dataSourceKey, pluginKeys, apiKey).then(setAccessResponse);
70
+ makeRequest(authController, dataSourceKey, pluginKeys, apiKey)
71
+ .then((response) => setAccessResponse(response && typeof response === "object" ? response : null))
72
+ .catch((error) => {
73
+ // The check never gates the CMS, so a failed one only
74
+ // leaves the license status unknown.
75
+ console.debug("FireCMS license check failed", error);
76
+ });
59
77
  accessedUserRef.current = authController.user.uid;
60
78
  }
61
- }, [authController, dataSourceKey, pluginKeys]);
79
+ }, [authController, dataSourceKey, pluginKeys, enabled]);
62
80
  return accessResponse;
63
81
  }
@@ -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
  }
@@ -23,8 +23,10 @@ export function useTranslation() {
23
23
  * Also supports i18next interpolation variables, e.g.
24
24
  * t("add_to_field", { fieldName: "Tags" })
25
25
  * t("error_deleting", { message: err.message })
26
+ * Pass `count` as a number to pick a plural form (`key_one`, falling back
27
+ * to `key`); i18next ignores a string `count`.
26
28
  */
27
- const typedT = (key: string, vars?: Record<string, string>): string =>
29
+ const typedT = (key: string, vars?: Record<string, string | number>): string =>
28
30
  t(key, vars) as string;
29
31
 
30
32
  return { t: typedT, i18n };
@@ -1,18 +1,76 @@
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
- import { de } from "../locales/de";
7
- import { fr } from "../locales/fr";
8
- import { it } from "../locales/it";
9
- import { hi } from "../locales/hi";
10
- import { pt } from "../locales/pt";
11
- import { pl } from "../locales/pl";
12
6
  import { FireCMSTranslations } from "../types/translations";
13
7
 
14
8
  const FIRECMS_NS = "firecms_core";
15
9
 
10
+ /**
11
+ * Translations that are not part of the bundle every app downloads.
12
+ *
13
+ * All eight locales used to be imported statically, which put ~305KB of strings
14
+ * — every language, for every user — into `@firecms/core` and therefore onto the
15
+ * startup path of every FireCMS app. Only the active language is ever read.
16
+ *
17
+ * `en` and `es` stay static: `en` is the fallback language and the base every
18
+ * unknown locale is seeded from, and both are re-exported from the package root,
19
+ * so a consumer may be importing them by name.
20
+ *
21
+ * The specifiers are written out rather than built from a template so bundlers
22
+ * can see them and emit one chunk per locale.
23
+ */
24
+ const localeLoaders: Record<string, () => Promise<FireCMSTranslations>> = {
25
+ de: () => import("../locales/de").then((m) => m.de),
26
+ fr: () => import("../locales/fr").then((m) => m.fr),
27
+ it: () => import("../locales/it").then((m) => m.it),
28
+ hi: () => import("../locales/hi").then((m) => m.hi),
29
+ pt: () => import("../locales/pt").then((m) => m.pt),
30
+ pl: () => import("../locales/pl").then((m) => m.pl)
31
+ };
32
+
33
+ /**
34
+ * Locale bundles available synchronously: the two static ones plus anything a
35
+ * previous provider already fetched. `buildResources` reads this, so a rebuild
36
+ * triggered by a changing `translations` prop cannot overwrite a lazily loaded
37
+ * language with the English base it would otherwise fall back to.
38
+ */
39
+ const loadedLocales: Record<string, FireCMSTranslations> = {
40
+ en,
41
+ es
42
+ };
43
+
44
+ const inFlightLocales: Record<string, Promise<void>> = {};
45
+
46
+ /**
47
+ * Fetch a locale bundle into `loadedLocales`.
48
+ *
49
+ * Returns undefined when the bundle is already in memory or there is nothing to
50
+ * fetch, so callers can stay synchronous in the common case.
51
+ */
52
+ function loadLocale(locale?: string): Promise<void> | undefined {
53
+ if (!locale) return undefined;
54
+ const language = locale.split("-")[0];
55
+ if (loadedLocales[language]) return undefined;
56
+ const loader = localeLoaders[language];
57
+ if (!loader) return undefined;
58
+ if (!inFlightLocales[language]) {
59
+ inFlightLocales[language] = loader()
60
+ .then((bundle) => {
61
+ loadedLocales[language] = bundle;
62
+ })
63
+ .catch(() => {
64
+ // A locale that fails to load falls back to English, which is
65
+ // what an unknown locale has always done.
66
+ })
67
+ .finally(() => {
68
+ delete inFlightLocales[language];
69
+ });
70
+ }
71
+ return inFlightLocales[language];
72
+ }
73
+
16
74
  export const FIRECMS_LOCALE_STORAGE_KEY = "firecms_locale";
17
75
 
18
76
  /** DeepPartial helper — allows partial overrides at any nesting level */
@@ -50,21 +108,44 @@ export function FireCMSi18nProvider({
50
108
  translations,
51
109
  children
52
110
  }: PropsWithChildren<FireCMSi18nProviderProps>) {
111
+
112
+ // A FireCMS i18next instance already in context means this provider is nested —
113
+ // e.g. FireCMSCloudApp mounts one inside the one the host app already mounted.
114
+ // A second instance built only from the defaults would shadow the parent's and
115
+ // silently drop every translation the host registered, leaving the host's own
116
+ // strings to render as raw keys.
117
+ //
118
+ // So the nested instance is *seeded* from the parent's resources rather than
119
+ // sharing them. Sharing one mutable instance was the obvious alternative and is
120
+ // wrong: bundles added by one provider outlive it, so opening one project's app
121
+ // and then switching to another left the first project's translations answering
122
+ // for keys the second never defined. Copying keeps each provider's strings to
123
+ // itself, which is also how this behaved before nesting was handled at all.
124
+ const parentI18n = useContext(I18nContext)?.i18n;
125
+ const parentInstance = parentI18n?.hasResourceBundle?.("en", FIRECMS_NS)
126
+ ? parentI18n
127
+ : undefined;
128
+
53
129
  const i18nRef = useRef<i18n | null>(null);
54
130
  const [ready, setReady] = React.useState(false);
55
131
 
56
132
  if (!i18nRef.current) {
57
133
  const instance = i18next.createInstance();
58
134
 
59
- // Build the initial resources: English baseline + any consumer overrides
60
- const resources = buildResources(translations);
135
+ // English baseline + this provider's overrides, with anything the parent
136
+ // already resolved layered in underneath the overrides.
137
+ const resources = buildResourcesWithParent(translations, parentInstance);
61
138
 
62
- let initialLocale = locale;
139
+ let initialLocale = parentInstance?.language ?? locale;
63
140
  if (typeof window !== "undefined") {
64
141
  const stored = localStorage.getItem(FIRECMS_LOCALE_STORAGE_KEY);
65
142
  if (stored) initialLocale = stored;
66
143
  }
67
144
 
145
+ // Kicked off before init so the request is in flight while i18next sets
146
+ // itself up, rather than after it.
147
+ const pendingInitialLocale = loadLocale(initialLocale);
148
+
68
149
  instance
69
150
  .use(initReactI18next)
70
151
  .init({
@@ -77,19 +158,54 @@ export function FireCMSi18nProvider({
77
158
  // React already escapes — don't double-escape
78
159
  escapeValue: false,
79
160
  },
161
+ react: {
162
+ // Locale bundles now arrive after init, and a bundle landing
163
+ // in the store raises an `added` event rather than
164
+ // `languageChanged`. react-i18next binds only the latter by
165
+ // default, so without this a language switched from the
166
+ // toggle would fetch its strings, put them in the store, and
167
+ // leave the UI sitting on the English fallback.
168
+ bindI18nStore: "added"
169
+ },
80
170
  }, () => {
81
- setReady(true);
171
+ if (!pendingInitialLocale) {
172
+ setReady(true);
173
+ return;
174
+ }
175
+ // Holding the first render until the active language has arrived
176
+ // keeps a non-English user from seeing English strings repaint.
177
+ // Only locales outside the static pair wait, and only on the
178
+ // first mount.
179
+ pendingInitialLocale.then(() => {
180
+ applyLoadedLocale(instance, initialLocale, translations, parentInstance);
181
+ setReady(true);
182
+ });
82
183
  });
83
184
 
84
185
  instance.on("languageChanged", (lng) => {
85
186
  if (typeof window !== "undefined") {
86
187
  localStorage.setItem(FIRECMS_LOCALE_STORAGE_KEY, lng);
87
188
  }
189
+ // Switching to a language whose bundle has not been fetched yet
190
+ // renders the English fallback until it lands.
191
+ loadLocale(lng)?.then(() => applyLoadedLocale(instance, lng, translations, parentInstance));
88
192
  });
89
193
 
90
194
  i18nRef.current = instance;
91
195
  }
92
196
 
197
+ // Follow the parent's language, so a switch made outside this provider is not
198
+ // stranded on the other side of the boundary.
199
+ useEffect(() => {
200
+ if (!parentInstance || !i18nRef.current) return;
201
+ const instance = i18nRef.current;
202
+ const follow = (lng: string) => {
203
+ if (instance.language !== lng) instance.changeLanguage(lng);
204
+ };
205
+ parentInstance.on("languageChanged", follow);
206
+ return () => { parentInstance.off("languageChanged", follow); };
207
+ }, [parentInstance]);
208
+
93
209
  // When `locale` prop changes, switch language on the existing instance
94
210
  // ONLY if the user hasn't explicitly set a preference
95
211
  useEffect(() => {
@@ -101,10 +217,16 @@ export function FireCMSi18nProvider({
101
217
  }
102
218
  }, [locale]);
103
219
 
104
- // When consumer translations prop changes, update the resource bundles
220
+ // When consumer translations prop changes, update the resource bundles.
221
+ //
222
+ // Built through the same layering as the initial instance, parent included.
223
+ // Rebuilding from the bare English baseline here would overwrite whatever the
224
+ // host registered — a nested provider without translations of its own would
225
+ // reset the host's overrides to the built-in strings. That was already true;
226
+ // it was simply invisible until `bindI18nStore` made a store write repaint.
105
227
  useEffect(() => {
106
228
  if (!i18nRef.current) return;
107
- const resources = buildResources(translations);
229
+ const resources = buildResourcesWithParent(translations, parentInstance);
108
230
  for (const [lang, bundle] of Object.entries(resources)) {
109
231
  i18nRef.current.addResourceBundle(
110
232
  lang,
@@ -114,7 +236,7 @@ export function FireCMSi18nProvider({
114
236
  true // overwrite existing keys
115
237
  );
116
238
  }
117
- }, [translations]);
239
+ }, [translations, parentInstance]);
118
240
 
119
241
  if (!ready || !i18nRef.current) return null;
120
242
 
@@ -125,6 +247,64 @@ export function FireCMSi18nProvider({
125
247
  );
126
248
  }
127
249
 
250
+ /**
251
+ * Push a lazily loaded locale bundle into a live i18next instance.
252
+ *
253
+ * Consumer overrides are re-applied on top, because the instance may already be
254
+ * holding a bundle that `buildResources` seeded from English for this language,
255
+ * and the real translations have to win over that seed while the overrides win
256
+ * over both.
257
+ */
258
+ function applyLoadedLocale(
259
+ instance: i18n,
260
+ locale: string,
261
+ translations?: { [locale: string]: DeepPartial<FireCMSTranslations> },
262
+ parentInstance?: i18n
263
+ ) {
264
+ const language = locale.split("-")[0];
265
+ if (!loadedLocales[language]) return;
266
+ const bundle = buildResourcesWithParent(translations, parentInstance)[language]?.[FIRECMS_NS];
267
+ if (!bundle) return;
268
+ instance.addResourceBundle(
269
+ language,
270
+ FIRECMS_NS,
271
+ bundle,
272
+ true, // deep merge
273
+ true // overwrite the English seed
274
+ );
275
+ }
276
+
277
+ /**
278
+ * The full resource map for this provider: the locale bundles currently in
279
+ * memory, then anything the parent provider already resolved, then this
280
+ * provider's own overrides on top.
281
+ *
282
+ * Shared by every path that writes into the instance — initial construction, a
283
+ * changed `translations` prop, and a locale arriving late — so all three layer
284
+ * in the same order and none of them can undo another.
285
+ */
286
+ function buildResourcesWithParent(
287
+ translations?: { [locale: string]: DeepPartial<FireCMSTranslations> },
288
+ parentInstance?: i18n
289
+ ): Record<string, Record<string, object>> {
290
+ const resources = buildResources(translations);
291
+ if (!parentInstance) return resources;
292
+
293
+ const inherited: Record<string, any> = parentInstance.services?.resourceStore?.data ?? {};
294
+ for (const [lang, namespaces] of Object.entries(inherited)) {
295
+ const bundle = (namespaces as any)?.[FIRECMS_NS];
296
+ if (!bundle) continue;
297
+ resources[lang] = {
298
+ [FIRECMS_NS]: {
299
+ ...(resources[lang]?.[FIRECMS_NS] ?? {}),
300
+ ...bundle,
301
+ ...(translations?.[lang] ?? {})
302
+ }
303
+ };
304
+ }
305
+ return resources;
306
+ }
307
+
128
308
  /**
129
309
  * Build an i18next resources object from the English baseline plus any
130
310
  * consumer-provided overrides.
@@ -132,16 +312,10 @@ export function FireCMSi18nProvider({
132
312
  function buildResources(
133
313
  translations?: { [locale: string]: DeepPartial<FireCMSTranslations> }
134
314
  ): Record<string, Record<string, object>> {
135
- const resources: Record<string, Record<string, object>> = {
136
- en: { [FIRECMS_NS]: { ...en } },
137
- es: { [FIRECMS_NS]: { ...es } },
138
- de: { [FIRECMS_NS]: { ...de } },
139
- fr: { [FIRECMS_NS]: { ...fr } },
140
- it: { [FIRECMS_NS]: { ...it } },
141
- hi: { [FIRECMS_NS]: { ...hi } },
142
- pt: { [FIRECMS_NS]: { ...pt } },
143
- pl: { [FIRECMS_NS]: { ...pl } },
144
- };
315
+ const resources: Record<string, Record<string, object>> = {};
316
+ for (const [lang, bundle] of Object.entries(loadedLocales)) {
317
+ resources[lang] = { [FIRECMS_NS]: { ...bundle } };
318
+ }
145
319
 
146
320
  if (!translations) return resources;
147
321
 
@@ -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
  });
@@ -343,6 +349,8 @@ export function useBuildDataSource({
343
349
  return usedDelegate.generateEntityId(path, collection, pathSegments);
344
350
  }, [delegate.generateEntityId]),
345
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.
346
354
  countEntities: delegate.countEntities ? async ({
347
355
  path,
348
356
  pathSegments,
@@ -350,14 +358,7 @@ export function useBuildDataSource({
350
358
  filter,
351
359
  order,
352
360
  orderBy
353
- }: {
354
- path: string,
355
- pathSegments?: string[],
356
- collection: EntityCollection<any>,
357
- filter?: FilterValues<Extract<keyof any, string>>,
358
- orderBy?: string,
359
- order?: "desc" | "asc",
360
- }): Promise<number> => {
361
+ }: FetchCollectionProps<any>): Promise<number> => {
361
362
  const usedDelegate = collection?.overrides?.dataSourceDelegate ?? delegate;
362
363
  return usedDelegate.countEntities!({
363
364
  path,
@@ -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