@firecms/core 3.3.0-canary.d3242eb → 3.4.0-canary.00f9be5

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 (221) hide show
  1. package/README.md +4 -0
  2. package/dist/app/useApp.d.ts +1 -0
  3. package/dist/components/EntityCollectionTable/column_utils.d.ts +4 -3
  4. package/dist/components/EntityCollectionTable/internal/CollectionTableToolbar.d.ts +6 -1
  5. package/dist/components/EntityCollectionTable/internal/popup_field/PopupFormField.d.ts +3 -1
  6. package/dist/components/EntityCollectionView/EntityCollectionBoardView.d.ts +3 -1
  7. package/dist/components/EntityCollectionView/EntityCollectionView.d.ts +8 -0
  8. package/dist/components/EntityCollectionView/EntityCollectionViewActions.d.ts +3 -1
  9. package/dist/components/EntityCollectionView/EntityCollectionViewStartActions.d.ts +3 -1
  10. package/dist/components/EntityCollectionView/FiltersDialog.d.ts +2 -1
  11. package/dist/components/EntityCollectionView/useBoardDataController.d.ts +3 -1
  12. package/dist/components/EntityPreview.d.ts +3 -1
  13. package/dist/components/ErrorBoundary.d.ts +1 -3
  14. package/dist/components/LicenseBanner.d.ts +23 -0
  15. package/dist/components/ReferenceTable/ReferenceSelectionTable.d.ts +10 -1
  16. package/dist/components/SelectableTable/filters/filter_text_input.d.ts +30 -0
  17. package/dist/components/SelectableTable/filters/filter_text_input.test.d.ts +1 -0
  18. package/dist/components/common/table_url_params.d.ts +24 -0
  19. package/dist/components/common/useDataSourceTableController.d.ts +9 -1
  20. package/dist/components/common/useTableSearchHelper.d.ts +9 -1
  21. package/dist/components/index.d.ts +1 -0
  22. package/dist/contexts/LicenseStatusContext.d.ts +16 -0
  23. package/dist/core/DefaultDrawer.d.ts +10 -3
  24. package/dist/core/EntityEditView.d.ts +9 -1
  25. package/dist/core/EntityEditViewFormActions.d.ts +1 -1
  26. package/dist/core/field_configs.d.ts +1 -1
  27. package/dist/core/index.d.ts +1 -0
  28. package/dist/core/plugin_translations.d.ts +38 -0
  29. package/dist/core/pro_plugins.d.ts +40 -0
  30. package/dist/de-BF5vcQPY.js +703 -0
  31. package/dist/de-BF5vcQPY.js.map +1 -0
  32. package/dist/editor/extensions/Image.d.ts +11 -0
  33. package/dist/editor/index.d.ts +2 -1
  34. package/dist/editor/lazy_editor.d.ts +6 -0
  35. package/dist/editor-BSo_aS1I.js +4563 -0
  36. package/dist/editor-BSo_aS1I.js.map +1 -0
  37. package/dist/form/EntityForm.d.ts +8 -1
  38. package/dist/form/EntityFormActions.d.ts +2 -0
  39. package/dist/form/field_bindings/GeopointFieldBinding.d.ts +5 -0
  40. package/dist/form/index.d.ts +1 -0
  41. package/dist/fr-DH9_wyFI.js +703 -0
  42. package/dist/fr-DH9_wyFI.js.map +1 -0
  43. package/dist/hi-BM8dm1Lu.js +703 -0
  44. package/dist/hi-BM8dm1Lu.js.map +1 -0
  45. package/dist/hooks/data/delete.d.ts +1 -1
  46. package/dist/hooks/data/save.d.ts +1 -1
  47. package/dist/hooks/data/useCollectionFetch.d.ts +7 -1
  48. package/dist/hooks/data/useEntityFetch.d.ts +9 -1
  49. package/dist/hooks/index.d.ts +2 -0
  50. package/dist/hooks/useLicenseStatus.d.ts +16 -0
  51. package/dist/hooks/useProjectLog.d.ts +11 -6
  52. package/dist/hooks/useTranslation.d.ts +1 -1
  53. package/dist/i18n/__tests__/FireCMSi18nProvider.test.d.ts +1 -0
  54. package/dist/i18n/__tests__/nested_provider_lifecycle.test.d.ts +1 -0
  55. package/dist/index.es.js +4429 -10479
  56. package/dist/index.es.js.map +1 -1
  57. package/dist/index.umd.js +9313 -6582
  58. package/dist/index.umd.js.map +1 -1
  59. package/dist/it-D3K1gfe4.js +703 -0
  60. package/dist/it-D3K1gfe4.js.map +1 -0
  61. package/dist/locales/pl.d.ts +2 -0
  62. package/dist/pl-CP1zeLzn.js +705 -0
  63. package/dist/pl-CP1zeLzn.js.map +1 -0
  64. package/dist/preview/index.d.ts +1 -0
  65. package/dist/preview/property_previews/GeopointPropertyPreview.d.ts +4 -0
  66. package/dist/pt-Cus7eKQR.js +705 -0
  67. package/dist/pt-Cus7eKQR.js.map +1 -0
  68. package/dist/types/analytics.d.ts +1 -1
  69. package/dist/types/collections.d.ts +31 -0
  70. package/dist/types/datasource.d.ts +67 -4
  71. package/dist/types/entities.d.ts +21 -1
  72. package/dist/types/entity_actions.d.ts +6 -0
  73. package/dist/types/entity_callbacks.d.ts +21 -0
  74. package/dist/types/firecms.d.ts +18 -1
  75. package/dist/types/firecms_context.d.ts +8 -0
  76. package/dist/types/index.d.ts +1 -0
  77. package/dist/types/license.d.ts +55 -0
  78. package/dist/types/navigation.d.ts +34 -5
  79. package/dist/types/plugins.d.ts +22 -1
  80. package/dist/types/properties.d.ts +17 -1
  81. package/dist/types/side_entity_controller.d.ts +7 -0
  82. package/dist/types/translations.d.ts +54 -5
  83. package/dist/util/__tests__/collections.test.d.ts +1 -0
  84. package/dist/util/__tests__/urls.test.d.ts +1 -0
  85. package/dist/util/collections.d.ts +1 -1
  86. package/dist/util/date_fns_locales.d.ts +18 -0
  87. package/dist/util/entities.d.ts +1 -0
  88. package/dist/util/entity_cache.d.ts +11 -0
  89. package/dist/util/geopoint.d.ts +22 -0
  90. package/dist/util/index.d.ts +3 -0
  91. package/dist/util/lazy_eager.d.ts +3 -3
  92. package/dist/util/navigation_blocking.d.ts +22 -0
  93. package/dist/util/navigation_from_path.d.ts +17 -0
  94. package/dist/util/navigation_utils.d.ts +107 -3
  95. package/dist/util/parent_references_from_path.d.ts +16 -0
  96. package/dist/util/paths.d.ts +28 -0
  97. package/dist/util/permissions.d.ts +10 -4
  98. package/dist/util/urls.d.ts +22 -0
  99. package/package.json +17 -11
  100. package/src/app/Scaffold.tsx +54 -45
  101. package/src/app/useApp.tsx +1 -0
  102. package/src/components/DeleteEntityDialog.tsx +2 -0
  103. package/src/components/EntityCollectionTable/EntityCollectionRowActions.tsx +2 -2
  104. package/src/components/EntityCollectionTable/EntityCollectionTable.tsx +3 -1
  105. package/src/components/EntityCollectionTable/column_utils.tsx +11 -19
  106. package/src/components/EntityCollectionTable/fields/TableReferenceField.tsx +1 -1
  107. package/src/components/EntityCollectionTable/internal/CollectionTableToolbar.tsx +7 -0
  108. package/src/components/EntityCollectionTable/internal/popup_field/PopupFormField.tsx +5 -1
  109. package/src/components/EntityCollectionView/EntityCollectionBoardView.tsx +12 -0
  110. package/src/components/EntityCollectionView/EntityCollectionView.tsx +71 -17
  111. package/src/components/EntityCollectionView/EntityCollectionViewActions.tsx +6 -2
  112. package/src/components/EntityCollectionView/EntityCollectionViewStartActions.tsx +8 -1
  113. package/src/components/EntityCollectionView/FiltersDialog.tsx +39 -28
  114. package/src/components/EntityCollectionView/useBoardDataController.tsx +17 -1
  115. package/src/components/EntityPreview.tsx +45 -41
  116. package/src/components/ErrorBoundary.tsx +12 -18
  117. package/src/components/HomePage/NavigationCardBinding.tsx +6 -3
  118. package/src/components/LicenseBanner.tsx +207 -0
  119. package/src/components/ReferenceTable/ReferenceSelectionTable.tsx +19 -2
  120. package/src/components/ReferenceWidget.tsx +1 -1
  121. package/src/components/SelectableTable/filters/ReferenceFilterField.tsx +2 -2
  122. package/src/components/SelectableTable/filters/StringNumberFilterField.tsx +33 -7
  123. package/src/components/SelectableTable/filters/filter_text_input.test.ts +184 -0
  124. package/src/components/SelectableTable/filters/filter_text_input.ts +71 -0
  125. package/src/components/VirtualTable/VirtualTable.tsx +19 -8
  126. package/src/components/common/default_entity_actions.tsx +22 -2
  127. package/src/components/common/table_url_params.ts +172 -0
  128. package/src/components/common/useDataSourceTableController.tsx +126 -163
  129. package/src/components/common/useTableSearchHelper.ts +26 -2
  130. package/src/components/index.tsx +1 -0
  131. package/src/contexts/LicenseStatusContext.tsx +50 -0
  132. package/src/contexts/SnackbarProvider.tsx +14 -1
  133. package/src/core/DefaultAppBar.tsx +28 -16
  134. package/src/core/DefaultDrawer.tsx +151 -65
  135. package/src/core/DrawerNavigationGroup.tsx +27 -29
  136. package/src/core/DrawerNavigationItem.tsx +10 -12
  137. package/src/core/EntityEditView.tsx +77 -35
  138. package/src/core/EntityEditViewFormActions.tsx +3 -2
  139. package/src/core/EntitySidePanel.tsx +8 -4
  140. package/src/core/FireCMS.tsx +73 -47
  141. package/src/core/SideDialogs.tsx +44 -1
  142. package/src/core/field_configs.tsx +15 -0
  143. package/src/core/index.tsx +1 -0
  144. package/src/core/plugin_translations.ts +87 -0
  145. package/src/core/pro_plugins.ts +79 -0
  146. package/src/editor/components/SlashCommandMenu.tsx +4 -1
  147. package/src/editor/extensions/Image.ts +17 -1
  148. package/src/editor/hooks/useProseMirror.ts +5 -0
  149. package/src/editor/index.ts +11 -1
  150. package/src/editor/lazy_editor.tsx +33 -0
  151. package/src/form/EntityForm.tsx +31 -10
  152. package/src/form/EntityFormActions.tsx +2 -0
  153. package/src/form/field_bindings/ArrayOfReferencesFieldBinding.tsx +1 -1
  154. package/src/form/field_bindings/GeopointFieldBinding.tsx +139 -0
  155. package/src/form/index.tsx +1 -0
  156. package/src/hooks/data/delete.ts +8 -0
  157. package/src/hooks/data/save.ts +6 -1
  158. package/src/hooks/data/useCollectionFetch.tsx +21 -1
  159. package/src/hooks/data/useEntityFetch.tsx +35 -4
  160. package/src/hooks/index.tsx +2 -0
  161. package/src/hooks/useBuildNavigationController.tsx +75 -16
  162. package/src/hooks/useFireCMSContext.tsx +7 -3
  163. package/src/hooks/useLicenseStatus.tsx +32 -0
  164. package/src/hooks/useProjectLog.tsx +27 -9
  165. package/src/hooks/useResolvedNavigationFrom.tsx +3 -1
  166. package/src/hooks/useTranslation.ts +3 -1
  167. package/src/hooks/useValidateAuthenticator.tsx +4 -0
  168. package/src/i18n/FireCMSi18nProvider.tsx +199 -23
  169. package/src/i18n/__tests__/FireCMSi18nProvider.test.tsx +91 -0
  170. package/src/i18n/__tests__/nested_provider_lifecycle.test.tsx +67 -0
  171. package/src/internal/useBuildDataSource.ts +37 -15
  172. package/src/internal/useBuildSideEntityController.tsx +20 -5
  173. package/src/locales/de.ts +37 -7
  174. package/src/locales/en.ts +41 -9
  175. package/src/locales/es.ts +37 -7
  176. package/src/locales/fr.ts +37 -7
  177. package/src/locales/hi.ts +37 -7
  178. package/src/locales/it.ts +37 -7
  179. package/src/locales/pl.ts +752 -0
  180. package/src/locales/pt.ts +37 -7
  181. package/src/preview/PropertyPreview.tsx +12 -0
  182. package/src/preview/components/DatePreview.tsx +2 -4
  183. package/src/preview/components/ReferencePreview.tsx +7 -3
  184. package/src/preview/components/StorageThumbnail.tsx +24 -2
  185. package/src/preview/index.ts +1 -0
  186. package/src/preview/property_previews/GeopointPropertyPreview.tsx +23 -0
  187. package/src/routes/FireCMSRoute.tsx +54 -27
  188. package/src/types/analytics.ts +3 -0
  189. package/src/types/collections.ts +34 -0
  190. package/src/types/datasource.ts +67 -4
  191. package/src/types/entities.ts +24 -1
  192. package/src/types/entity_actions.tsx +6 -0
  193. package/src/types/entity_callbacks.ts +24 -0
  194. package/src/types/firecms.tsx +19 -1
  195. package/src/types/firecms_context.tsx +9 -0
  196. package/src/types/index.ts +1 -0
  197. package/src/types/license.ts +63 -0
  198. package/src/types/navigation.ts +35 -5
  199. package/src/types/plugins.tsx +23 -1
  200. package/src/types/properties.ts +17 -0
  201. package/src/types/side_entity_controller.tsx +7 -0
  202. package/src/types/translations.ts +54 -5
  203. package/src/util/__tests__/collections.test.ts +32 -0
  204. package/src/util/__tests__/urls.test.ts +131 -0
  205. package/src/util/collections.ts +1 -1
  206. package/src/util/date_fns_locales.ts +168 -0
  207. package/src/util/entities.ts +16 -1
  208. package/src/util/entity_cache.ts +18 -0
  209. package/src/util/geopoint.ts +81 -0
  210. package/src/util/index.ts +3 -0
  211. package/src/util/lazy_eager.tsx +30 -8
  212. package/src/util/navigation_blocking.ts +45 -0
  213. package/src/util/navigation_from_path.ts +47 -7
  214. package/src/util/navigation_utils.ts +250 -5
  215. package/src/util/parent_references_from_path.ts +45 -4
  216. package/src/util/paths.ts +40 -3
  217. package/src/util/permissions.ts +22 -10
  218. package/src/util/previews.ts +8 -1
  219. package/src/util/resolutions.ts +11 -0
  220. package/src/util/urls.ts +52 -0
  221. package/src/util/useStorageUploadController.tsx +6 -1
@@ -1,17 +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
6
  import { FireCMSTranslations } from "../types/translations";
12
7
 
13
8
  const FIRECMS_NS = "firecms_core";
14
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
+
15
74
  export const FIRECMS_LOCALE_STORAGE_KEY = "firecms_locale";
16
75
 
17
76
  /** DeepPartial helper — allows partial overrides at any nesting level */
@@ -49,21 +108,44 @@ export function FireCMSi18nProvider({
49
108
  translations,
50
109
  children
51
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
+
52
129
  const i18nRef = useRef<i18n | null>(null);
53
130
  const [ready, setReady] = React.useState(false);
54
131
 
55
132
  if (!i18nRef.current) {
56
133
  const instance = i18next.createInstance();
57
134
 
58
- // Build the initial resources: English baseline + any consumer overrides
59
- 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);
60
138
 
61
- let initialLocale = locale;
139
+ let initialLocale = parentInstance?.language ?? locale;
62
140
  if (typeof window !== "undefined") {
63
141
  const stored = localStorage.getItem(FIRECMS_LOCALE_STORAGE_KEY);
64
142
  if (stored) initialLocale = stored;
65
143
  }
66
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
+
67
149
  instance
68
150
  .use(initReactI18next)
69
151
  .init({
@@ -76,19 +158,54 @@ export function FireCMSi18nProvider({
76
158
  // React already escapes — don't double-escape
77
159
  escapeValue: false,
78
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
+ },
79
170
  }, () => {
80
- 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
+ });
81
183
  });
82
184
 
83
185
  instance.on("languageChanged", (lng) => {
84
186
  if (typeof window !== "undefined") {
85
187
  localStorage.setItem(FIRECMS_LOCALE_STORAGE_KEY, lng);
86
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));
87
192
  });
88
193
 
89
194
  i18nRef.current = instance;
90
195
  }
91
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
+
92
209
  // When `locale` prop changes, switch language on the existing instance
93
210
  // ONLY if the user hasn't explicitly set a preference
94
211
  useEffect(() => {
@@ -100,10 +217,16 @@ export function FireCMSi18nProvider({
100
217
  }
101
218
  }, [locale]);
102
219
 
103
- // 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.
104
227
  useEffect(() => {
105
228
  if (!i18nRef.current) return;
106
- const resources = buildResources(translations);
229
+ const resources = buildResourcesWithParent(translations, parentInstance);
107
230
  for (const [lang, bundle] of Object.entries(resources)) {
108
231
  i18nRef.current.addResourceBundle(
109
232
  lang,
@@ -113,7 +236,7 @@ export function FireCMSi18nProvider({
113
236
  true // overwrite existing keys
114
237
  );
115
238
  }
116
- }, [translations]);
239
+ }, [translations, parentInstance]);
117
240
 
118
241
  if (!ready || !i18nRef.current) return null;
119
242
 
@@ -124,6 +247,64 @@ export function FireCMSi18nProvider({
124
247
  );
125
248
  }
126
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
+
127
308
  /**
128
309
  * Build an i18next resources object from the English baseline plus any
129
310
  * consumer-provided overrides.
@@ -131,15 +312,10 @@ export function FireCMSi18nProvider({
131
312
  function buildResources(
132
313
  translations?: { [locale: string]: DeepPartial<FireCMSTranslations> }
133
314
  ): Record<string, Record<string, object>> {
134
- const resources: Record<string, Record<string, object>> = {
135
- en: { [FIRECMS_NS]: { ...en } },
136
- es: { [FIRECMS_NS]: { ...es } },
137
- de: { [FIRECMS_NS]: { ...de } },
138
- fr: { [FIRECMS_NS]: { ...fr } },
139
- it: { [FIRECMS_NS]: { ...it } },
140
- hi: { [FIRECMS_NS]: { ...hi } },
141
- pt: { [FIRECMS_NS]: { ...pt } },
142
- };
315
+ const resources: Record<string, Record<string, object>> = {};
316
+ for (const [lang, bundle] of Object.entries(loadedLocales)) {
317
+ resources[lang] = { [FIRECMS_NS]: { ...bundle } };
318
+ }
143
319
 
144
320
  if (!translations) return resources;
145
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
+ });
@@ -55,6 +55,7 @@ export function useBuildDataSource({
55
55
  */
56
56
  fetchCollection: useCallback(<M extends Record<string, any>>({
57
57
  path,
58
+ pathSegments,
58
59
  collection,
59
60
  filter,
60
61
  limit,
@@ -67,6 +68,7 @@ export function useBuildDataSource({
67
68
  const usedDelegate = collection?.overrides?.dataSourceDelegate ?? delegate;
68
69
  return usedDelegate.fetchCollection<M>({
69
70
  path,
71
+ pathSegments,
70
72
  filter,
71
73
  limit,
72
74
  startAfter,
@@ -97,6 +99,7 @@ export function useBuildDataSource({
97
99
  ? useCallback(<M extends Record<string, any>>(
98
100
  {
99
101
  path,
102
+ pathSegments,
100
103
  collection: collectionProp,
101
104
  filter,
102
105
  limit,
@@ -109,7 +112,7 @@ export function useBuildDataSource({
109
112
  }: ListenCollectionProps<M>
110
113
  ): () => void => {
111
114
 
112
- const collection = collectionProp ?? navigationController.getCollection(path);
115
+ const collection = collectionProp ?? navigationController.getCollection(path, false, pathSegments);
113
116
  const usedDelegate = collection?.overrides?.dataSourceDelegate ?? delegate;
114
117
 
115
118
  if (!usedDelegate.listenCollection)
@@ -117,6 +120,7 @@ export function useBuildDataSource({
117
120
 
118
121
  return usedDelegate.listenCollection<M>({
119
122
  path,
123
+ pathSegments,
120
124
  filter,
121
125
  limit,
122
126
  startAfter,
@@ -139,6 +143,7 @@ export function useBuildDataSource({
139
143
  */
140
144
  fetchEntity: useCallback(<M extends Record<string, any>>({
141
145
  path,
146
+ pathSegments,
142
147
  entityId,
143
148
  collection
144
149
  }: FetchEntityProps<M>
@@ -146,6 +151,7 @@ export function useBuildDataSource({
146
151
  const usedDelegate = collection?.overrides?.dataSourceDelegate ?? delegate;
147
152
  return usedDelegate.fetchEntity({
148
153
  path,
154
+ pathSegments,
149
155
  entityId,
150
156
  collection
151
157
  });
@@ -165,6 +171,7 @@ export function useBuildDataSource({
165
171
  ? useCallback(<M extends Record<string, any>>(
166
172
  {
167
173
  path,
174
+ pathSegments,
168
175
  entityId,
169
176
  collection,
170
177
  onUpdate,
@@ -177,6 +184,7 @@ export function useBuildDataSource({
177
184
 
178
185
  return usedDelegate.listenEntity<M>({
179
186
  path,
187
+ pathSegments,
180
188
  entityId,
181
189
  onUpdate,
182
190
  onError,
@@ -198,13 +206,14 @@ export function useBuildDataSource({
198
206
  saveEntity: useCallback(async <M extends Record<string, any>>(
199
207
  {
200
208
  path,
209
+ pathSegments,
201
210
  entityId,
202
211
  values,
203
212
  collection: collectionProp,
204
213
  status
205
214
  }: SaveEntityProps<M>): Promise<Entity<M>> => {
206
215
 
207
- const collection = collectionProp ?? navigationController.getCollection(path);
216
+ const collection = collectionProp ?? navigationController.getCollection(path, false, pathSegments);
208
217
  const usedDelegate = collection?.overrides?.dataSourceDelegate ?? delegate;
209
218
 
210
219
  const resolvedCollection = collection
@@ -244,6 +253,7 @@ export function useBuildDataSource({
244
253
  try {
245
254
  const entities = await usedDelegate.fetchCollection({
246
255
  path,
256
+ pathSegments,
247
257
  orderBy: orderProperty,
248
258
  order: "asc",
249
259
  limit: 1,
@@ -270,14 +280,21 @@ export function useBuildDataSource({
270
280
 
271
281
  return usedDelegate.saveEntity({
272
282
  path,
283
+ pathSegments,
273
284
  collection,
274
285
  entityId,
275
286
  values: finalValues,
276
287
  status
277
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.
278
292
  return {
279
- id: res.id,
280
- 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,
281
298
  values: usedDelegate.delegateToCMSModel(finalValues)
282
299
  } as Entity<M>;
283
300
  });
@@ -292,12 +309,15 @@ export function useBuildDataSource({
292
309
  deleteEntity: useCallback(<M extends Record<string, any>>(
293
310
  {
294
311
  entity,
312
+ pathSegments,
295
313
  collection
296
314
  }: DeleteEntityProps<M>
297
315
  ): Promise<void> => {
298
316
  const usedDelegate = collection?.overrides?.dataSourceDelegate ?? delegate;
299
317
  return usedDelegate.deleteEntity({
300
318
  entity,
319
+ // Falls back to what the entity carries from the fetch that produced it.
320
+ pathSegments: pathSegments ?? entity.pathSegments,
301
321
  collection
302
322
  });
303
323
  }, [delegate.deleteEntity]),
@@ -317,33 +337,32 @@ export function useBuildDataSource({
317
337
  name: string,
318
338
  value: any,
319
339
  entityId?: string,
320
- collection?: EntityCollection
340
+ collection?: EntityCollection,
341
+ pathSegments?: string[]
321
342
  ): Promise<boolean> => {
322
343
  const usedDelegate = collection?.overrides?.dataSourceDelegate ?? delegate;
323
- return usedDelegate.checkUniqueField(path, name, value, entityId, collection);
344
+ return usedDelegate.checkUniqueField(path, name, value, entityId, collection, pathSegments);
324
345
  }, [delegate.checkUniqueField]),
325
346
 
326
- generateEntityId: useCallback((path: string, collection: EntityCollection): string => {
347
+ generateEntityId: useCallback((path: string, collection: EntityCollection, pathSegments?: string[]): string => {
327
348
  const usedDelegate = collection?.overrides?.dataSourceDelegate ?? delegate;
328
- return usedDelegate.generateEntityId(path, collection);
349
+ return usedDelegate.generateEntityId(path, collection, pathSegments);
329
350
  }, [delegate.generateEntityId]),
330
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.
331
354
  countEntities: delegate.countEntities ? async ({
332
355
  path,
356
+ pathSegments,
333
357
  collection,
334
358
  filter,
335
359
  order,
336
360
  orderBy
337
- }: {
338
- path: string,
339
- collection: EntityCollection<any>,
340
- filter?: FilterValues<Extract<keyof any, string>>,
341
- orderBy?: string,
342
- order?: "desc" | "asc",
343
- }): Promise<number> => {
361
+ }: FetchCollectionProps<any>): Promise<number> => {
344
362
  const usedDelegate = collection?.overrides?.dataSourceDelegate ?? delegate;
345
363
  return usedDelegate.countEntities!({
346
364
  path,
365
+ pathSegments,
347
366
  filter,
348
367
  orderBy,
349
368
  order,
@@ -353,11 +372,13 @@ export function useBuildDataSource({
353
372
 
354
373
  isFilterCombinationValid: useCallback(({
355
374
  path,
375
+ pathSegments,
356
376
  databaseId,
357
377
  filterValues,
358
378
  sortBy
359
379
  }: {
360
380
  path: string,
381
+ pathSegments?: string[],
361
382
  databaseId?: string,
362
383
  filterValues: FilterValues<any>,
363
384
  sortBy?: [string, "asc" | "desc"]
@@ -367,6 +388,7 @@ export function useBuildDataSource({
367
388
  return delegate.isFilterCombinationValid(
368
389
  {
369
390
  path,
391
+ pathSegments,
370
392
  databaseId,
371
393
  filterValues,
372
394
  sortBy