@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
@@ -8,7 +8,7 @@ import { EntityEditView, OnUpdateParams } from "./EntityEditView";
8
8
  import { useSideDialogContext } from "./SideDialogs";
9
9
  import { CloseIcon, IconButton, OpenInFullIcon } from "@firecms/ui";
10
10
  import { useLocation, useNavigate } from "react-router-dom";
11
- import { saveEntityToMemoryCache } from "../util/entity_cache";
11
+ import { entityCacheKey, saveEntityToMemoryCache } from "../util/entity_cache";
12
12
 
13
13
  /**
14
14
  * This is the component in charge of rendering the side dialog used
@@ -25,6 +25,7 @@ export function EntitySidePanel(props: EntitySidePanelProps) {
25
25
  path,
26
26
  entityId,
27
27
  fullIdPath,
28
+ pathSegments,
28
29
  formProps,
29
30
  } = props;
30
31
 
@@ -59,6 +60,7 @@ export function EntitySidePanel(props: EntitySidePanelProps) {
59
60
  if (params.status !== "existing") {
60
61
  sideEntityController.replace({
61
62
  path: params.path,
63
+ pathSegments: params.pathSegments ?? pathSegments,
62
64
  entityId: params.entityId,
63
65
  fullIdPath: props.fullIdPath,
64
66
  selectedTab: params.selectedTab,
@@ -75,10 +77,10 @@ export function EntitySidePanel(props: EntitySidePanelProps) {
75
77
  }
76
78
 
77
79
  const parentCollectionIds = useMemo(() => {
78
- return navigationController.getParentCollectionIds(path);
80
+ return navigationController.getParentCollectionIds(path, pathSegments);
79
81
  }, [navigationController, path]);
80
82
 
81
- const collection = props.collection ?? navigationController.getCollection(fullIdPath ?? path);
83
+ const collection = props.collection ?? navigationController.getCollection(fullIdPath ?? path, false, pathSegments);
82
84
 
83
85
  useEffect(() => {
84
86
  function beforeunload(e: any) {
@@ -116,6 +118,7 @@ export function EntitySidePanel(props: EntitySidePanelProps) {
116
118
  <EntityEditView
117
119
  {...props}
118
120
  fullIdPath={fullIdPath}
121
+ pathSegments={pathSegments}
119
122
  layout={"side_panel"}
120
123
  collection={collection as EntityCollection}
121
124
  parentCollectionIds={parentCollectionIds}
@@ -135,7 +138,7 @@ export function EntitySidePanel(props: EntitySidePanelProps) {
135
138
  className="self-center"
136
139
  size={"smallest"}
137
140
  onClick={() => {
138
- const key = (status === "new" || status === "copy") ? path + "#new" : path + "/" + entityId;
141
+ const key = (status === "new" || status === "copy") ? path + "#new" : entityCacheKey(path, entityId);
139
142
  saveEntityToMemoryCache(key, values);
140
143
  if (entityId)
141
144
  navigate(location.pathname + location.search);
@@ -152,6 +155,7 @@ export function EntitySidePanel(props: EntitySidePanelProps) {
152
155
  }) => {
153
156
  sideEntityController.replace({
154
157
  path,
158
+ pathSegments,
155
159
  entityId,
156
160
  fullIdPath: props.fullIdPath,
157
161
  selectedTab,
@@ -1,7 +1,9 @@
1
1
  "use client";
2
2
 
3
- import React, { useEffect, useMemo } from "react";
4
- import { CenteredView, Typography } from "@firecms/ui";
3
+ import React, { useEffect, useMemo, useRef } from "react";
4
+ import { CenteredView } from "@firecms/ui";
5
+ import { AppliedBundles, applyPluginTranslations, createAppliedBundles } from "./plugin_translations";
6
+ import { filterPausedPlugins, isProPaused } from "./pro_plugins";
5
7
  import { AuthController } from "../types";
6
8
  import { CustomizationController, FireCMSContext, FireCMSPlugin, FireCMSProps, User } from "../types";
7
9
  import { AuthControllerContext, ModeControllerProvider } from "../contexts";
@@ -23,6 +25,7 @@ import { AnalyticsContext } from "../contexts/AnalyticsContext";
23
25
  import { useProjectLog } from "../hooks/useProjectLog";
24
26
  import { BreadcrumbsProvider } from "../contexts/BreacrumbsContext";
25
27
  import { InternalUserManagementContext } from "../contexts/InternalUserManagementContext";
28
+ import { LicenseStatusContext, publishLicenseStatus } from "../contexts/LicenseStatusContext";
26
29
 
27
30
  /**
28
31
  * If you are using independent components of the CMS
@@ -54,6 +57,7 @@ export function FireCMS<USER extends User>(props: FireCMSProps<USER>) {
54
57
  components,
55
58
  navigationController,
56
59
  apiKey,
60
+ telemetry,
57
61
  userManagement: _userManagement
58
62
  } = props;
59
63
 
@@ -61,11 +65,42 @@ export function FireCMS<USER extends User>(props: FireCMSProps<USER>) {
61
65
  console.warn("The `plugins` prop is deprecated in the FireCMS component. You should pass your plugins to `useBuildNavigationController` instead.");
62
66
  }
63
67
 
64
- const { t, i18n } = useTranslation();
68
+ const { i18n } = useTranslation();
65
69
 
66
70
  const modeController = useBuildModeController();
67
71
 
68
- const plugins = navigationController.plugins ?? _pluginsProp;
72
+ // Every plugin the app mounts. The license check is sent all of their keys:
73
+ // the server decides from them whether the project needs a license.
74
+ const allPlugins = navigationController.plugins ?? _pluginsProp;
75
+
76
+ const licenseStatus = useProjectLog({
77
+ apiKey,
78
+ telemetry,
79
+ authController,
80
+ dataSourceDelegate,
81
+ plugins: allPlugins
82
+ });
83
+
84
+ // The CMS always renders. While PRO is paused, PRO plugins drop out of
85
+ // everything below (providers, form, collection view and home page
86
+ // contributions); `useBuildNavigationController` drops their collection,
87
+ // view and navigation contributions from the status published below.
88
+ const plugins = useMemo(
89
+ () => filterPausedPlugins(allPlugins, licenseStatus),
90
+ [allPlugins, licenseStatus]
91
+ );
92
+
93
+ useEffect(() => {
94
+ publishLicenseStatus(licenseStatus);
95
+ if (licenseStatus?.message) {
96
+ if (isProPaused(licenseStatus)) console.warn(licenseStatus.message);
97
+ else console.debug(licenseStatus.message);
98
+ }
99
+ }, [licenseStatus]);
100
+
101
+ // The status belongs to this FireCMS; do not leave it to a later one.
102
+ useEffect(() => () => publishLicenseStatus(null), []);
103
+
69
104
  const userManagement = plugins?.find(p => p.userManagement)?.userManagement
70
105
  ?? _userManagement
71
106
  ?? {
@@ -95,13 +130,6 @@ export function FireCMS<USER extends User>(props: FireCMSProps<USER>) {
95
130
  onAnalyticsEvent
96
131
  }), []);
97
132
 
98
- const accessResponse = useProjectLog({
99
- apiKey,
100
- authController,
101
- dataSourceDelegate,
102
- plugins
103
- });
104
-
105
133
  /**
106
134
  * Controller in charge of fetching and persisting data
107
135
  */
@@ -112,27 +140,42 @@ export function FireCMS<USER extends User>(props: FireCMSProps<USER>) {
112
140
  authController
113
141
  });
114
142
 
115
- // Inject plugin translations into the existing i18next instance
143
+ // Inject plugin translations into the live i18next instance.
144
+ //
145
+ // The work, and the reason repeat writes have to be suppressed, is in
146
+ // `applyPluginTranslations`. Paused plugins keep their strings: app code
147
+ // may render their components directly, and strings unlock nothing.
148
+ const appliedBundles = useRef<AppliedBundles>(createAppliedBundles());
149
+ const appliedTo = useRef<unknown>(null);
150
+
116
151
  useEffect(() => {
117
152
  if (!i18n) return;
118
- plugins?.forEach(plugin => {
119
- if (plugin.i18n) {
120
- Object.keys(plugin.i18n).forEach(locale => {
121
- i18n.addResourceBundle(
122
- locale,
123
- "firecms_core",
124
- plugin.i18n![locale],
125
- true, // deep merge
126
- true // overwrite
127
- );
128
- });
129
- }
153
+
154
+ // A different instance has its own store and has been written nothing.
155
+ // Reset here rather than in an effect of its own, which would run after
156
+ // this one and wipe the record of what this pass just wrote.
157
+ if (appliedTo.current !== i18n) {
158
+ appliedTo.current = i18n;
159
+ appliedBundles.current = createAppliedBundles();
160
+ }
161
+
162
+ let active = true;
163
+ const isActive = () => active;
164
+ const applyFor = (language: string) => applyPluginTranslations({
165
+ i18n,
166
+ plugins: allPlugins,
167
+ language,
168
+ applied: appliedBundles.current,
169
+ isActive
130
170
  });
131
- }, [i18n, plugins]);
132
171
 
133
- if (accessResponse?.message) {
134
- console.warn(accessResponse.message);
135
- }
172
+ applyFor(i18n.language ?? "en");
173
+ i18n.on("languageChanged", applyFor);
174
+ return () => {
175
+ active = false;
176
+ i18n.off("languageChanged", applyFor);
177
+ };
178
+ }, [i18n, allPlugins]);
136
179
 
137
180
  if (navigationController.navigationLoadingError) {
138
181
  return (
@@ -154,26 +197,8 @@ export function FireCMS<USER extends User>(props: FireCMSProps<USER>) {
154
197
  );
155
198
  }
156
199
 
157
- if (accessResponse?.blocked) {
158
- return (
159
- <CenteredView maxWidth={"md"} fullScreen={true} className={"flex flex-col gap-2"}>
160
- {/* eslint-disable-next-line i18next/no-literal-string */}
161
- <Typography variant={"h4"} gutterBottom>
162
- {t("license_needed")}
163
- </Typography>
164
- <Typography>
165
- {(() => {
166
- const parts = t("license_description", { email: "%%EMAIL%%" }).split("%%EMAIL%%");
167
- return <>{parts[0]}<a href={"mailto:hello@firecms.co"}>hello@firecms.co</a>{parts[1]}</>;
168
- })()}
169
- </Typography>
170
- {accessResponse?.message &&
171
- <Typography>{accessResponse?.message}</Typography>}
172
- </CenteredView>
173
- );
174
- }
175
-
176
200
  return (
201
+ <LicenseStatusContext.Provider value={licenseStatus}>
177
202
  <AnalyticsContext.Provider value={analyticsController}>
178
203
  <CustomizationControllerContext.Provider value={customizationController}>
179
204
  <UserConfigurationPersistenceContext.Provider
@@ -209,6 +234,7 @@ export function FireCMS<USER extends User>(props: FireCMSProps<USER>) {
209
234
  </UserConfigurationPersistenceContext.Provider>
210
235
  </CustomizationControllerContext.Provider>
211
236
  </AnalyticsContext.Provider>
237
+ </LicenseStatusContext.Provider>
212
238
  );
213
239
 
214
240
  }
@@ -1,4 +1,4 @@
1
- import React, { useContext, useEffect, useState } from "react";
1
+ import React, { useContext, useEffect, useRef, useState } from "react";
2
2
  import { useSideDialogsController } from "../hooks";
3
3
  import { EntitySidePanelProps, SideDialogPanelProps } from "../types";
4
4
  import { Sheet } from "@firecms/ui";
@@ -67,6 +67,40 @@ export function SideDialogs() {
67
67
  </>;
68
68
  }
69
69
 
70
+ // Menus, selects and popovers opened inside a side dialog are dismissed by Radix as
71
+ // soon as the pointer goes down, but the dialog underneath only makes up its mind on
72
+ // the click that follows, when that layer is already gone and the dialog looks like
73
+ // the topmost one. Without this, the click that dismisses an open menu also asks the
74
+ // panel to close, and an edited entity greets the user with the unsaved changes dialog.
75
+ const NESTED_LAYER_SELECTOR = [
76
+ "[data-radix-menu-content]",
77
+ "[data-radix-select-viewport]",
78
+ "[data-radix-popper-content-wrapper] [role=\"dialog\"]",
79
+ "[data-suggestion-menu=\"true\"]"
80
+ ].join(",");
81
+
82
+ function useNestedLayerOpenOnPointerDown() {
83
+ const ref = useRef(false);
84
+ useEffect(() => {
85
+ const onPointerDown = () => {
86
+ ref.current = Array.from(document.querySelectorAll(NESTED_LAYER_SELECTOR))
87
+ .some(element => window.getComputedStyle(element).visibility !== "hidden");
88
+ };
89
+ // the keyboard dismisses one layer at a time on its own, so a stale pointer
90
+ // interaction must not swallow a close requested with Escape
91
+ const onKeyDown = () => {
92
+ ref.current = false;
93
+ };
94
+ document.addEventListener("pointerdown", onPointerDown, true);
95
+ document.addEventListener("keydown", onKeyDown, true);
96
+ return () => {
97
+ document.removeEventListener("pointerdown", onPointerDown, true);
98
+ document.removeEventListener("keydown", onKeyDown, true);
99
+ };
100
+ }, []);
101
+ return ref;
102
+ }
103
+
70
104
  function SideDialogView({
71
105
  offsetPosition,
72
106
  panel
@@ -82,6 +116,8 @@ function SideDialogView({
82
116
 
83
117
  const [pendingClose, setPendingClose] = useState(false);
84
118
 
119
+ const nestedLayerOpenOnPointerDown = useNestedLayerOpenOnPointerDown();
120
+
85
121
  const widthRef = React.useRef<string | undefined>(panel?.width);
86
122
  const width = widthRef.current;
87
123
 
@@ -144,6 +180,13 @@ function SideDialogView({
144
180
  // Let Tiptap handle closing the menu first
145
181
  return;
146
182
  }
183
+ // this interaction started on top of a menu or popover: it dismissed
184
+ // that layer, it was not a request to close the panel. The flag is
185
+ // left alone because a single click asks to close more than once;
186
+ // the next pointer or key interaction recomputes it.
187
+ if (nestedLayerOpenOnPointerDown.current) {
188
+ return;
189
+ }
147
190
  onCloseRequest();
148
191
  }
149
192
  }}
@@ -6,6 +6,7 @@ import {
6
6
  ArrayOfReferencesFieldBinding,
7
7
  BlockFieldBinding,
8
8
  DateTimeFieldBinding,
9
+ GeopointFieldBinding,
9
10
  KeyValueFieldBinding,
10
11
  MapFieldBinding,
11
12
  MultiSelectFieldBinding,
@@ -34,6 +35,7 @@ import {
34
35
  ListAltIcon,
35
36
  ListIcon,
36
37
  MailIcon,
38
+ LocationOnIcon,
37
39
  NumbersIcon, PersonIcon,
38
40
  RepeatIcon,
39
41
  ScheduleIcon,
@@ -273,6 +275,17 @@ export const DEFAULT_FIELD_CONFIGS: Record<string, PropertyConfig<any>> = {
273
275
  Field: DateTimeFieldBinding
274
276
  }
275
277
  },
278
+ geopoint: {
279
+ key: "geopoint",
280
+ name: "Geopoint",
281
+ description: "Latitude and longitude pair",
282
+ Icon: LocationOnIcon,
283
+ color: "#0ea5e9",
284
+ property: {
285
+ dataType: "geopoint",
286
+ Field: GeopointFieldBinding
287
+ }
288
+ },
276
289
  group: {
277
290
  key: "group",
278
291
  name: "Group",
@@ -419,6 +432,8 @@ export function getDefaultFieldId(property: Property | ResolvedProperty) {
419
432
  return "switch";
420
433
  } else if (property.dataType === "date") {
421
434
  return "date_time";
435
+ } else if (property.dataType === "geopoint") {
436
+ return "geopoint";
422
437
  } else if (property.dataType === "reference") {
423
438
  return "reference";
424
439
  }
@@ -1,4 +1,5 @@
1
1
  export * from "./FireCMS";
2
+ export * from "./pro_plugins";
2
3
 
3
4
  export * from "./DefaultDrawer";
4
5
  export * from "./DrawerNavigationItem";
@@ -0,0 +1,87 @@
1
+ import type { i18n } from "i18next";
2
+ import { FireCMSPlugin, PluginTranslations } from "../types";
3
+
4
+ const FIRECMS_NS = "firecms_core";
5
+
6
+ /**
7
+ * Record of which bundles have already been written to which locale, so the
8
+ * same one is never written twice.
9
+ *
10
+ * Plugin translation objects are module-level constants and a lazily loaded one
11
+ * resolves to the same module object every time, so identity is a sound test.
12
+ */
13
+ export type AppliedBundles = Map<string, Set<object>>;
14
+
15
+ export function createAppliedBundles(): AppliedBundles {
16
+ return new Map();
17
+ }
18
+
19
+ /**
20
+ * Write the plugins' strings for one language into a live i18next instance.
21
+ *
22
+ * Only the language in use is resolved, plus English, which every other language
23
+ * falls back to — a locale given as a function is a bundle that has not been
24
+ * downloaded yet, and downloading all of them would undo the point of deferring
25
+ * them. Plain records are applied for every locale, as before: they are already
26
+ * in the bundle, so there is nothing to save by holding them back.
27
+ *
28
+ * `applied` is what stops the injection feeding itself. A write raises a store
29
+ * event; the event repaints everything subscribed to this instance, including
30
+ * the component that owns the `plugins` array — the collection editor and SaaS
31
+ * plugin hooks both call `useTranslation` — and that array is an inline literal,
32
+ * so its fresh identity re-runs the effect that called this. react-i18next
33
+ * happens to stop re-rendering after one pass, but that is a bail-out rather
34
+ * than a guarantee, and the redundant writes are worth avoiding regardless.
35
+ *
36
+ * Returns a function that reports whether the caller is still mounted, so a
37
+ * bundle that arrives after unmount is dropped rather than written.
38
+ */
39
+ export function applyPluginTranslations({
40
+ i18n,
41
+ plugins,
42
+ language,
43
+ applied,
44
+ isActive = () => true
45
+ }: {
46
+ i18n: i18n;
47
+ plugins?: FireCMSPlugin<any, any, any>[];
48
+ language: string;
49
+ applied: AppliedBundles;
50
+ isActive?: () => boolean;
51
+ }) {
52
+ const wanted = new Set([language.split("-")[0], "en"]);
53
+
54
+ const add = (locale: string, bundle: PluginTranslations) => {
55
+ if (!isActive()) return;
56
+ let written = applied.get(locale);
57
+ if (!written) {
58
+ written = new Set();
59
+ applied.set(locale, written);
60
+ }
61
+ if (written.has(bundle)) return;
62
+ written.add(bundle);
63
+ i18n.addResourceBundle(
64
+ locale,
65
+ FIRECMS_NS,
66
+ bundle,
67
+ true, // deep merge
68
+ true // overwrite
69
+ );
70
+ };
71
+
72
+ plugins?.forEach((plugin) => {
73
+ if (!plugin.i18n) return;
74
+ Object.entries(plugin.i18n).forEach(([locale, translations]) => {
75
+ if (typeof translations === "function") {
76
+ if (!wanted.has(locale.split("-")[0])) return;
77
+ translations()
78
+ .then((bundle) => add(locale, bundle))
79
+ .catch(() => {
80
+ // A locale that fails to load falls back to English.
81
+ });
82
+ } else {
83
+ add(locale, translations);
84
+ }
85
+ });
86
+ });
87
+ }
@@ -0,0 +1,79 @@
1
+ import { AccessResponse, FireCMSPlugin } from "../types";
2
+
3
+ /**
4
+ * Keys of the plugins that need a FireCMS PRO license.
5
+ *
6
+ * Kept in sync by hand with `PRO_PLUGIN_KEYS` in
7
+ * `saas_backend/functions/src/business_rules/licenses.ts`, which decides from
8
+ * the same list whether a project needs a license. Change both together.
9
+ *
10
+ * @group Core
11
+ */
12
+ export const PRO_PLUGIN_KEYS: readonly string[] = [
13
+ "collection_editor",
14
+ "user_management",
15
+ "import_export",
16
+ "import",
17
+ "export",
18
+ "entity_history",
19
+ "data_enhancement",
20
+ "datatalk"
21
+ ];
22
+
23
+ /**
24
+ * PRO plugins that stay mounted while PRO is paused. `user_management` supplies
25
+ * sign-in checks and roles, so removing it would lock users out of the CMS
26
+ * instead of pausing a feature; its own Users and Roles views show a notice.
27
+ */
28
+ const KEPT_WHILE_PAUSED: readonly string[] = ["user_management"];
29
+
30
+ /**
31
+ * Whether PRO features are paused for this license status. Any `blocked`
32
+ * response pauses them, whatever its `licenseState` (`expired` and
33
+ * `invalid_project` both come with it). An unknown status pauses nothing.
34
+ *
35
+ * @group Core
36
+ */
37
+ export function isProPaused(licenseStatus?: AccessResponse | null): boolean {
38
+ return Boolean(licenseStatus?.blocked);
39
+ }
40
+
41
+ /**
42
+ * Whether this plugin is dropped while PRO is paused.
43
+ *
44
+ * @group Core
45
+ */
46
+ export function isPausedPlugin(plugin: Pick<FireCMSPlugin, "key">): boolean {
47
+ return PRO_PLUGIN_KEYS.includes(plugin.key) && !KEPT_WHILE_PAUSED.includes(plugin.key);
48
+ }
49
+
50
+ /**
51
+ * Where to get or fix the license for this status: the server's
52
+ * `subscribeUrl` when it is an https URL, otherwise the license page on
53
+ * app.firecms.co with the project, when known.
54
+ *
55
+ * @group Core
56
+ */
57
+ export function getLicenseSubscribeUrl(licenseStatus?: AccessResponse | null): string {
58
+ const subscribeUrl = licenseStatus?.subscribeUrl;
59
+ if (subscribeUrl && /^https:\/\//i.test(subscribeUrl))
60
+ return subscribeUrl;
61
+ const params = new URLSearchParams({ intent: "pro" });
62
+ if (licenseStatus?.projectId) params.set("projectId", licenseStatus.projectId);
63
+ return `https://app.firecms.co/subscriptions?${params.toString()}`;
64
+ }
65
+
66
+ /**
67
+ * The plugins that stay active under this license status. Returns the same
68
+ * array when nothing is dropped, so it can feed memo and effect dependencies.
69
+ *
70
+ * @group Core
71
+ */
72
+ export function filterPausedPlugins<P extends Pick<FireCMSPlugin, "key">>(
73
+ plugins: P[] | undefined,
74
+ licenseStatus?: AccessResponse | null
75
+ ): P[] | undefined {
76
+ if (!plugins || !isProPaused(licenseStatus)) return plugins;
77
+ const active = plugins.filter(plugin => !isPausedPlugin(plugin));
78
+ return active.length === plugins.length ? plugins : active;
79
+ }
@@ -198,11 +198,14 @@ const autocompleteSuggestionItem: SuggestionItem = {
198
198
  let buffer = "";
199
199
  const result = await aiController.autocomplete(textBeforeCursor, textAfterCursor, (delta) => {
200
200
  buffer += delta;
201
- if (delta.length !== 0) {
201
+ if (delta.length !== 0 && !view.isDestroyed) {
202
202
  textLoadingCommands.toggleLoadingDecoration(view.state, view.dispatch, buffer);
203
203
  }
204
204
  });
205
205
 
206
+ // The editor may have been unmounted while the completion streamed in.
207
+ if (view.isDestroyed) return;
208
+
206
209
  // Insert parsed text result at cursor natively
207
210
  try {
208
211
  // The AI controller might stream literal "\n" characters depending on its JSON decoding layer.
@@ -4,9 +4,20 @@ import { Plugin, PluginKey } from "prosemirror-state";
4
4
 
5
5
  export type UploadFn = (image: File) => Promise<string>;
6
6
 
7
+ /**
8
+ * Shows the image as a placeholder at `pos`, uploads it, and swaps the
9
+ * placeholder for the uploaded image once it has loaded.
10
+ *
11
+ * Every step after the first runs asynchronously — the FileReader callback that
12
+ * calls this, the upload, and the preload of the uploaded image — and the editor
13
+ * can be unmounted during any of them (the user closes the entity or navigates
14
+ * away while an image is still uploading). Dispatching on a destroyed
15
+ * `EditorView` throws inside ProseMirror (`docView` is null), so each step
16
+ * checks the view is still alive before touching it.
17
+ */
7
18
  export async function onFileRead(view: EditorView, readerEvent: ProgressEvent<FileReader>, pos: number, upload: UploadFn, image: File) {
8
19
 
9
- const { schema } = view.state;
20
+ if (view.isDestroyed) return;
10
21
 
11
22
  // @ts-ignore
12
23
  const plugin = view.state.plugins.find((p: Plugin) => p.key === ImagePluginKey.key);
@@ -31,7 +42,12 @@ export async function onFileRead(view: EditorView, readerEvent: ProgressEvent<Fi
31
42
  const src = await upload(image);
32
43
  console.debug("Uploaded image", src);
33
44
 
45
+ // The upload is kept (the caller stored the file), but there is no longer
46
+ // an editor to put it in.
47
+ if (view.isDestroyed) return;
48
+
34
49
  const replacePlaceholder = () => {
50
+ if (view.isDestroyed) return;
35
51
  // Retrieve the LATEST state after the async upload
36
52
  let currentDecos = plugin.getState(view.state) as DecorationSet;
37
53
  const foundDecos = currentDecos.find(undefined, undefined, spec => spec.id === decoId);
@@ -58,6 +58,11 @@ export function useProseMirror({ initialContent, editable = true, handleImageUpl
58
58
  state: defaultState,
59
59
  editable: () => editable,
60
60
  dispatchTransaction: (tr: Transaction) => {
61
+ // Async work (image uploads and their preload, AI autocomplete
62
+ // streams) can finish after the editor unmounted. Applying a
63
+ // transaction to a destroyed view throws inside ProseMirror
64
+ // (`docView` is null), so drop it: there is nothing to update.
65
+ if (editorView.isDestroyed) return;
61
66
  const newState = editorView.state.apply(tr);
62
67
  editorView.updateState(newState);
63
68
  setState(newState);
@@ -1,2 +1,12 @@
1
- export * from "./editor";
1
+ // The editor's runtime entry point is the lazy wrapper, not the implementation:
2
+ // a static re-export here would pull ProseMirror into every consumer of
3
+ // `@firecms/core`. The types come straight from the implementation and are
4
+ // erased at build time, so they cost nothing.
5
+ export { FireCMSEditor } from "./lazy_editor";
6
+ export type {
7
+ CustomEditorComponent,
8
+ MarkdownEditorConfig,
9
+ FireCMSEditorTextSize,
10
+ FireCMSEditorProps
11
+ } from "./editor";
2
12
  export * from "./types";
@@ -0,0 +1,33 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+
5
+ import { CircularProgressCenter } from "../components/CircularProgressCenter";
6
+ import type { FireCMSEditorProps } from "./editor";
7
+
8
+ /**
9
+ * `FireCMSEditor`, loaded on demand.
10
+ *
11
+ * The editor is built on ProseMirror — roughly 250KB across a dozen packages —
12
+ * and is only reachable from a markdown field, which most views never render.
13
+ * `MarkdownEditorFieldBinding` already loaded it lazily, but that split was
14
+ * defeated by `src/index.ts` re-exporting the editor from the package barrel:
15
+ * a static re-export puts the module back in the entry graph regardless of who
16
+ * imports it dynamically. So ProseMirror sat in `@firecms/core` for everyone.
17
+ *
18
+ * The name and props are unchanged, and this component carries its own Suspense
19
+ * boundary, so it stays a drop-in for the synchronous component it replaces —
20
+ * a consumer rendering it without a boundary of their own still works.
21
+ */
22
+ const LazyFireCMSEditor = React.lazy(() =>
23
+ import("./editor").then((module) => ({ default: module.FireCMSEditor })));
24
+
25
+ export function FireCMSEditor(props: FireCMSEditorProps) {
26
+ return (
27
+ <React.Suspense fallback={<CircularProgressCenter/>}>
28
+ <LazyFireCMSEditor {...props}/>
29
+ </React.Suspense>
30
+ );
31
+ }
32
+
33
+ FireCMSEditor.displayName = "FireCMSEditor";