@firecms/core 3.3.0 → 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 (220) 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 +4516 -10668
  56. package/dist/index.es.js.map +1 -1
  57. package/dist/index.umd.js +9403 -6774
  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 +43 -2
  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 +41 -44
  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 +66 -15
  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/LicenseBanner.tsx +207 -0
  118. package/src/components/ReferenceTable/ReferenceSelectionTable.tsx +19 -2
  119. package/src/components/ReferenceWidget.tsx +1 -1
  120. package/src/components/SelectableTable/filters/ReferenceFilterField.tsx +2 -2
  121. package/src/components/SelectableTable/filters/StringNumberFilterField.tsx +33 -7
  122. package/src/components/SelectableTable/filters/filter_text_input.test.ts +184 -0
  123. package/src/components/SelectableTable/filters/filter_text_input.ts +71 -0
  124. package/src/components/VirtualTable/VirtualTable.tsx +19 -8
  125. package/src/components/common/default_entity_actions.tsx +22 -2
  126. package/src/components/common/table_url_params.ts +172 -0
  127. package/src/components/common/useDataSourceTableController.tsx +126 -163
  128. package/src/components/common/useTableSearchHelper.ts +26 -2
  129. package/src/components/index.tsx +1 -0
  130. package/src/contexts/LicenseStatusContext.tsx +50 -0
  131. package/src/contexts/SnackbarProvider.tsx +14 -1
  132. package/src/core/DefaultAppBar.tsx +28 -16
  133. package/src/core/DefaultDrawer.tsx +150 -64
  134. package/src/core/DrawerNavigationGroup.tsx +27 -29
  135. package/src/core/DrawerNavigationItem.tsx +10 -12
  136. package/src/core/EntityEditView.tsx +77 -35
  137. package/src/core/EntityEditViewFormActions.tsx +3 -2
  138. package/src/core/EntitySidePanel.tsx +8 -4
  139. package/src/core/FireCMS.tsx +73 -47
  140. package/src/core/SideDialogs.tsx +44 -1
  141. package/src/core/field_configs.tsx +15 -0
  142. package/src/core/index.tsx +1 -0
  143. package/src/core/plugin_translations.ts +87 -0
  144. package/src/core/pro_plugins.ts +79 -0
  145. package/src/editor/components/SlashCommandMenu.tsx +4 -1
  146. package/src/editor/extensions/Image.ts +17 -1
  147. package/src/editor/hooks/useProseMirror.ts +5 -0
  148. package/src/editor/index.ts +11 -1
  149. package/src/editor/lazy_editor.tsx +33 -0
  150. package/src/form/EntityForm.tsx +31 -10
  151. package/src/form/EntityFormActions.tsx +2 -0
  152. package/src/form/field_bindings/ArrayOfReferencesFieldBinding.tsx +1 -1
  153. package/src/form/field_bindings/GeopointFieldBinding.tsx +139 -0
  154. package/src/form/index.tsx +1 -0
  155. package/src/hooks/data/delete.ts +8 -0
  156. package/src/hooks/data/save.ts +6 -1
  157. package/src/hooks/data/useCollectionFetch.tsx +21 -1
  158. package/src/hooks/data/useEntityFetch.tsx +35 -4
  159. package/src/hooks/index.tsx +2 -0
  160. package/src/hooks/useBuildNavigationController.tsx +70 -15
  161. package/src/hooks/useFireCMSContext.tsx +7 -3
  162. package/src/hooks/useLicenseStatus.tsx +32 -0
  163. package/src/hooks/useProjectLog.tsx +27 -9
  164. package/src/hooks/useResolvedNavigationFrom.tsx +3 -1
  165. package/src/hooks/useTranslation.ts +3 -1
  166. package/src/hooks/useValidateAuthenticator.tsx +4 -0
  167. package/src/i18n/FireCMSi18nProvider.tsx +199 -23
  168. package/src/i18n/__tests__/FireCMSi18nProvider.test.tsx +91 -0
  169. package/src/i18n/__tests__/nested_provider_lifecycle.test.tsx +67 -0
  170. package/src/internal/useBuildDataSource.ts +37 -15
  171. package/src/internal/useBuildSideEntityController.tsx +20 -5
  172. package/src/locales/de.ts +26 -4
  173. package/src/locales/en.ts +30 -6
  174. package/src/locales/es.ts +26 -4
  175. package/src/locales/fr.ts +26 -4
  176. package/src/locales/hi.ts +26 -4
  177. package/src/locales/it.ts +26 -4
  178. package/src/locales/pl.ts +752 -0
  179. package/src/locales/pt.ts +26 -4
  180. package/src/preview/PropertyPreview.tsx +12 -0
  181. package/src/preview/components/DatePreview.tsx +2 -4
  182. package/src/preview/components/ReferencePreview.tsx +7 -3
  183. package/src/preview/components/StorageThumbnail.tsx +24 -2
  184. package/src/preview/index.ts +1 -0
  185. package/src/preview/property_previews/GeopointPropertyPreview.tsx +23 -0
  186. package/src/routes/FireCMSRoute.tsx +54 -27
  187. package/src/types/analytics.ts +3 -0
  188. package/src/types/collections.ts +34 -0
  189. package/src/types/datasource.ts +67 -4
  190. package/src/types/entities.ts +24 -1
  191. package/src/types/entity_actions.tsx +6 -0
  192. package/src/types/entity_callbacks.ts +24 -0
  193. package/src/types/firecms.tsx +19 -1
  194. package/src/types/firecms_context.tsx +9 -0
  195. package/src/types/index.ts +1 -0
  196. package/src/types/license.ts +63 -0
  197. package/src/types/navigation.ts +35 -5
  198. package/src/types/plugins.tsx +23 -1
  199. package/src/types/properties.ts +17 -0
  200. package/src/types/side_entity_controller.tsx +7 -0
  201. package/src/types/translations.ts +43 -2
  202. package/src/util/__tests__/collections.test.ts +32 -0
  203. package/src/util/__tests__/urls.test.ts +131 -0
  204. package/src/util/collections.ts +1 -1
  205. package/src/util/date_fns_locales.ts +168 -0
  206. package/src/util/entities.ts +16 -1
  207. package/src/util/entity_cache.ts +18 -0
  208. package/src/util/geopoint.ts +81 -0
  209. package/src/util/index.ts +3 -0
  210. package/src/util/lazy_eager.tsx +30 -8
  211. package/src/util/navigation_blocking.ts +45 -0
  212. package/src/util/navigation_from_path.ts +47 -7
  213. package/src/util/navigation_utils.ts +250 -5
  214. package/src/util/parent_references_from_path.ts +45 -4
  215. package/src/util/paths.ts +40 -3
  216. package/src/util/permissions.ts +22 -10
  217. package/src/util/previews.ts +8 -1
  218. package/src/util/resolutions.ts +8 -0
  219. package/src/util/urls.ts +52 -0
  220. package/src/util/useStorageUploadController.tsx +6 -1
@@ -1,6 +1,8 @@
1
- import { useCallback, useEffect, useRef, useState } from "react";
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import equal from "react-fast-compare"
3
3
  import { useBlocker, useNavigate } from "react-router-dom";
4
+ import { filterPausedPlugins, isProPaused } from "../core/pro_plugins";
5
+ import { useLicenseStatus } from "./useLicenseStatus";
4
6
 
5
7
  import {
6
8
  AuthController,
@@ -22,11 +24,14 @@ import {
22
24
  } from "../types";
23
25
  import {
24
26
  applyPermissionsFunctionIfEmpty,
27
+ collectionSegmentsFrom,
28
+ fullPathToCollectionSegments,
25
29
  getCollectionByPathOrId,
26
30
  mergeDeep,
27
31
  removeFunctions,
28
32
  removeInitialAndTrailingSlashes,
29
33
  resolveCollectionPathIds,
34
+ resolveCollectionPathSegments,
30
35
  resolvePermissions
31
36
  } from "../util";
32
37
  import { getParentReferencesFromPath } from "../util/parent_references_from_path";
@@ -112,7 +117,7 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
112
117
  views: viewsProp,
113
118
  adminViews: adminViewsProp,
114
119
  viewsOrder,
115
- plugins,
120
+ plugins: allPlugins,
116
121
  userConfigPersistence,
117
122
  dataSourceDelegate,
118
123
  disabled,
@@ -121,6 +126,27 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
121
126
 
122
127
  const navigate = useNavigate();
123
128
 
129
+ // While PRO is paused, PRO plugins contribute no collections, collection
130
+ // modifications, views or navigation entries. This hook runs above
131
+ // `FireCMS`, so the status arrives through the store `FireCMS` publishes to,
132
+ // and its arrival re-renders this hook, which resolves navigation again.
133
+ const licenseStatus = useLicenseStatus();
134
+ const proPaused = isProPaused(licenseStatus);
135
+ const plugins = useMemo(
136
+ () => filterPausedPlugins(allPlugins, licenseStatus),
137
+ [allPlugins, licenseStatus]
138
+ );
139
+
140
+ // Navigation resolves asynchronously, so a resolution started before the
141
+ // pause changed can finish after one started since. This ref holds the
142
+ // current pause so the older one is dropped instead of restoring what the
143
+ // paused plugins contributed. Declared before the effect that refreshes.
144
+ const proPausedRef = useRef(proPaused);
145
+ useEffect(() => {
146
+ proPausedRef.current = proPaused;
147
+ }, [proPaused]);
148
+ const resolvedProPausedRef = useRef<boolean | undefined>(undefined);
149
+
124
150
  const collectionsRef = useRef<EntityCollection[] | undefined>(undefined);
125
151
  const viewsRef = useRef<CMSView[] | undefined>(undefined);
126
152
  const adminViewsRef = useRef<CMSView[] | undefined>(undefined);
@@ -299,7 +325,7 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
299
325
  groups: uniqueGroups,
300
326
  onNavigationEntriesUpdate: onNavigationEntriesUpdateCallback!,
301
327
  };
302
- }, [navigationGroupMappings, buildCMSUrlPath, buildUrlCollectionPath, pluginGroups]);
328
+ }, [navigationGroupMappings, buildCMSUrlPath, buildUrlCollectionPath, pluginGroups, plugins]);
303
329
 
304
330
  const onNavigationEntriesOrderUpdate = useCallback((entries: NavigationGroupMapping[]) => {
305
331
  if (!plugins) {
@@ -339,6 +365,8 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
339
365
 
340
366
  console.debug("Refreshing navigation");
341
367
 
368
+ const resolvingWhilePaused = proPaused;
369
+
342
370
  try {
343
371
 
344
372
  const [resolvedCollections = [], resolvedViews, resolvedAdminViews = []] = await Promise.all([
@@ -348,10 +376,21 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
348
376
  ]
349
377
  );
350
378
 
379
+ // Superseded: a newer resolution runs with the current pause.
380
+ if (resolvingWhilePaused !== proPausedRef.current)
381
+ return;
382
+
351
383
  const computedTopLevelNav = computeTopNavigation(resolvedCollections, resolvedViews, resolvedAdminViews, viewsOrder, undefined, onNavigationEntriesOrderUpdate);
352
384
 
353
- let shouldUpdateTopLevelNav = false;
354
- if (!areCollectionListsEqual(collectionsRef.current ?? [], resolvedCollections)) {
385
+ // When the pause changes, everything below is replaced, including
386
+ // what the comparisons ignore: they skip functions, and a plugin may
387
+ // have added only collection callbacks, or only regrouped entries.
388
+ const pauseChanged = resolvedProPausedRef.current !== undefined
389
+ && resolvedProPausedRef.current !== resolvingWhilePaused;
390
+ resolvedProPausedRef.current = resolvingWhilePaused;
391
+
392
+ let shouldUpdateTopLevelNav = pauseChanged;
393
+ if (pauseChanged || !areCollectionListsEqual(collectionsRef.current ?? [], resolvedCollections)) {
355
394
  collectionsRef.current = resolvedCollections;
356
395
  console.debug("Collections have changed", resolvedCollections);
357
396
  shouldUpdateTopLevelNav = true;
@@ -360,7 +399,7 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
360
399
  collectionsRef.current = resolvedCollections;
361
400
  shouldUpdateTopLevelNav = true;
362
401
  }
363
- if (!equal(viewsRef.current, resolvedViews)) {
402
+ if (pauseChanged || !equal(viewsRef.current, resolvedViews)) {
364
403
  viewsRef.current = resolvedViews;
365
404
  shouldUpdateTopLevelNav = true;
366
405
  }
@@ -397,6 +436,8 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
397
436
  viewsProp,
398
437
  adminViewsProp,
399
438
  computeTopNavigation,
439
+ plugins,
440
+ proPaused
400
441
  ]);
401
442
 
402
443
  useEffect(() => {
@@ -405,13 +446,14 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
405
446
 
406
447
  const getCollection = useCallback((
407
448
  idOrPath: string,
408
- includeUserOverride = false
449
+ includeUserOverride = false,
450
+ pathSegments?: string[]
409
451
  ): EC | undefined => {
410
452
  const collections = collectionsRef.current;
411
453
  if (!collections)
412
454
  return undefined;
413
455
 
414
- const baseCollection = getCollectionByPathOrId(removeInitialAndTrailingSlashes(idOrPath), collections);
456
+ const baseCollection = getCollectionByPathOrId(removeInitialAndTrailingSlashes(idOrPath), collections, pathSegments);
415
457
 
416
458
  const userOverride = includeUserOverride ? userConfigPersistence?.getCollectionConfig(idOrPath) : undefined;
417
459
  let overriddenCollection = baseCollection;
@@ -505,23 +547,33 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
505
547
  throw Error("Expected path starting with " + fullCollectionPath);
506
548
  }, [fullCollectionPath]);
507
549
 
508
- const resolveIdsFrom = useCallback((path: string): string => {
550
+ const resolveIdsFrom = useCallback((path: string, pathSegments?: string[]): string => {
551
+ const collections = collectionsRef.current ?? [];
552
+ return resolveCollectionPathIds(path, collections, pathSegments);
553
+ }, []);
554
+
555
+ const resolveSegmentsFrom = useCallback((pathSegments: string[]): string[] => {
509
556
  const collections = collectionsRef.current ?? [];
510
- return resolveCollectionPathIds(path, collections);
557
+ return resolveCollectionPathSegments(pathSegments, collections);
511
558
  }, []);
512
559
 
513
- const getAllParentReferencesForPath = useCallback((path: string): EntityReference[] => {
560
+ const getAllParentReferencesForPath = useCallback((path: string, pathSegments?: string[]): EntityReference[] => {
514
561
  const collections = collectionsRef.current ?? [];
515
562
  return getParentReferencesFromPath({
516
563
  path,
564
+ pathSegments,
517
565
  collections
518
566
  });
519
567
  }, []);
520
568
 
521
- const getParentCollectionIds = useCallback((path: string): string[] => {
569
+ const getParentCollectionIds = useCallback((path: string, pathSegments?: string[]): string[] => {
522
570
 
523
- const strings = path.split("/");
524
- const oddPathSegments = strings.filter((_, i) => i % 2 === 0);
571
+ // Positional filtering only picks out the collection segments while no entity id
572
+ // contains "/"; with one, every following segment shifts. Given the real boundaries
573
+ // the same rule applies correctly; without them this is the historical reading.
574
+ const oddPathSegments = pathSegments
575
+ ? collectionSegmentsFrom(pathSegments)
576
+ : fullPathToCollectionSegments(path);
525
577
  oddPathSegments.pop();
526
578
 
527
579
  const result: string[][] = [];
@@ -567,13 +619,16 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
567
619
  urlPathToDataPath,
568
620
  buildUrlCollectionPath,
569
621
  resolveIdsFrom,
622
+ resolveSegmentsFrom,
570
623
  topLevelNavigation,
571
624
  refreshNavigation,
572
625
  getParentReferencesFromPath: getAllParentReferencesForPath,
573
626
  getParentCollectionIds,
574
627
  convertIdsToPaths,
575
628
  navigate,
576
- plugins
629
+ // All of them, paused or not: `FireCMS` sends every key to the license
630
+ // check and applies the same pause to what it renders.
631
+ plugins: allPlugins
577
632
  };
578
633
  }
579
634
 
@@ -12,6 +12,7 @@ import { useCustomizationController } from "./useCustomizationController";
12
12
  import { useAnalyticsController } from "./useAnalyticsController";
13
13
  import React, { useEffect } from "react";
14
14
  import { useInternalUserManagementController } from "./useInternalUserManagementController";
15
+ import { useLicenseStatus } from "./useLicenseStatus";
15
16
 
16
17
  /**
17
18
  * Hook to retrieve the {@link FireCMSContext}.
@@ -36,6 +37,7 @@ export const useFireCMSContext = <USER extends User = User, AuthControllerType e
36
37
  const customizationController = useCustomizationController();
37
38
  const analyticsController = useAnalyticsController();
38
39
  const userManagement = useInternalUserManagementController<USER>();
40
+ const licenseStatus = useLicenseStatus();
39
41
 
40
42
  const fireCMSContextRef = React.useRef<FireCMSContext<USER, AuthControllerType>>({
41
43
  authController,
@@ -49,7 +51,8 @@ export const useFireCMSContext = <USER extends User = User, AuthControllerType e
49
51
  dialogsController,
50
52
  customizationController,
51
53
  analyticsController,
52
- userManagement
54
+ userManagement,
55
+ licenseStatus
53
56
  });
54
57
 
55
58
  useEffect(() => {
@@ -65,9 +68,10 @@ export const useFireCMSContext = <USER extends User = User, AuthControllerType e
65
68
  dialogsController,
66
69
  customizationController,
67
70
  analyticsController,
68
- userManagement
71
+ userManagement,
72
+ licenseStatus
69
73
  };
70
- }, [authController, dialogsController, navigation, sideDialogsController]);
74
+ }, [authController, dialogsController, navigation, sideDialogsController, licenseStatus]);
71
75
 
72
76
  return fireCMSContextRef.current;
73
77
  }
@@ -0,0 +1,32 @@
1
+ import { useContext, useSyncExternalStore } from "react";
2
+ import { AccessResponse } from "../types";
3
+ import {
4
+ getLicenseStatusSnapshot,
5
+ getServerLicenseStatusSnapshot,
6
+ LicenseStatusContext,
7
+ subscribeLicenseStatus
8
+ } from "../contexts/LicenseStatusContext";
9
+
10
+ /**
11
+ * The FireCMS license status of this project: whether PRO runs on a trial, is
12
+ * licensed, or is paused, plus the link to get a license.
13
+ *
14
+ * `null` until the license check answers, and when it is not sent at all
15
+ * (`telemetry={false}` without an `apiKey`). A response from an older server
16
+ * may lack `licenseState`; treat that as unknown. PRO features are paused when
17
+ * `blocked` is true; use `isProPaused(status)` for that check.
18
+ *
19
+ * Works inside `FireCMS` and also above it, e.g. in the component that calls
20
+ * `useBuildNavigationController`.
21
+ *
22
+ * @group Hooks and utilities
23
+ */
24
+ export function useLicenseStatus(): AccessResponse | null {
25
+ const fromContext = useContext(LicenseStatusContext);
26
+ const published = useSyncExternalStore(
27
+ subscribeLicenseStatus,
28
+ getLicenseStatusSnapshot,
29
+ getServerLicenseStatusSnapshot
30
+ );
31
+ return fromContext !== undefined ? fromContext : published;
32
+ }
@@ -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,12 +80,14 @@ export function resolveNavigationFrom<M extends Record<string, any>, USER extend
80
80
  if (entry.type === "collection") {
81
81
  return Promise.resolve(entry);
82
82
  } else if (entry.type === "entity") {
83
- const collection = navigation.getCollection(entry.path);
83
+ const collection = navigation.getCollection(entry.path, false, entry.pathSegments);
84
84
  if (!collection) {
85
85
  throw Error(`No collection defined in the navigation for the entity with path ${entry.path}`);
86
86
  }
87
87
  return dataSource.fetchEntity({
88
88
  path: entry.path,
89
+ // The navigation entry already knows the real segment boundaries.
90
+ pathSegments: entry.pathSegments,
89
91
  entityId: entry.entityId,
90
92
  collection
91
93
  })
@@ -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 };
@@ -74,6 +74,10 @@ export function useValidateAuthenticator<USER extends User = any>
74
74
 
75
75
  if (authenticator instanceof Function && delegateUser && !equal(checkedUserRef.current?.uid, delegateUser.uid)) {
76
76
  setAuthLoading(true);
77
+ // Reset the error of any previous authentication attempt. Otherwise, a
78
+ // rejected user would keep `canAccessMainView` to false for every user
79
+ // logging in afterwards, locking them out of the CMS until a page reload.
80
+ setNotAllowedError(false);
77
81
  try {
78
82
  const allowed = await authenticator({
79
83
  user: delegateUser,
@@ -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