@firecms/core 3.4.0-canary.f6a889a → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/README.md +4 -0
  2. package/dist/components/EntityCollectionTable/internal/CollectionTableToolbar.d.ts +6 -1
  3. package/dist/components/EntityCollectionTable/internal/popup_field/PopupFormField.d.ts +3 -1
  4. package/dist/components/EntityCollectionView/EntityCollectionBoardView.d.ts +3 -1
  5. package/dist/components/EntityCollectionView/EntityCollectionView.d.ts +8 -0
  6. package/dist/components/EntityCollectionView/EntityCollectionViewActions.d.ts +3 -1
  7. package/dist/components/EntityCollectionView/EntityCollectionViewStartActions.d.ts +3 -1
  8. package/dist/components/EntityCollectionView/useBoardDataController.d.ts +3 -1
  9. package/dist/components/ErrorBoundary.d.ts +1 -3
  10. package/dist/components/LicenseBanner.d.ts +24 -0
  11. package/dist/components/ReferenceTable/ReferenceSelectionTable.d.ts +10 -1
  12. package/dist/components/SelectableTable/filters/filter_text_input.d.ts +30 -0
  13. package/dist/components/SelectableTable/filters/filter_text_input.test.d.ts +1 -0
  14. package/dist/components/common/table_url_params.d.ts +24 -0
  15. package/dist/components/common/useDataSourceTableController.d.ts +9 -1
  16. package/dist/components/common/useTableSearchHelper.d.ts +9 -1
  17. package/dist/components/index.d.ts +1 -0
  18. package/dist/contexts/LicenseStatusContext.d.ts +16 -0
  19. package/dist/core/EntityEditView.d.ts +9 -1
  20. package/dist/core/EntityEditViewFormActions.d.ts +1 -1
  21. package/dist/core/index.d.ts +1 -0
  22. package/dist/core/plugin_translations.d.ts +38 -0
  23. package/dist/core/pro_plugins.d.ts +40 -0
  24. package/dist/de-CBOjCvsy.js +707 -0
  25. package/dist/de-CBOjCvsy.js.map +1 -0
  26. package/dist/editor/extensions/Image.d.ts +11 -0
  27. package/dist/editor/index.d.ts +2 -1
  28. package/dist/editor/lazy_editor.d.ts +6 -0
  29. package/dist/editor-BSo_aS1I.js +4563 -0
  30. package/dist/editor-BSo_aS1I.js.map +1 -0
  31. package/dist/form/EntityForm.d.ts +8 -1
  32. package/dist/form/EntityFormActions.d.ts +2 -0
  33. package/dist/fr-DRfttcG0.js +707 -0
  34. package/dist/fr-DRfttcG0.js.map +1 -0
  35. package/dist/hi-DwieOZNf.js +707 -0
  36. package/dist/hi-DwieOZNf.js.map +1 -0
  37. package/dist/hooks/data/delete.d.ts +1 -1
  38. package/dist/hooks/data/save.d.ts +1 -1
  39. package/dist/hooks/data/useCollectionFetch.d.ts +7 -1
  40. package/dist/hooks/data/useEntityFetch.d.ts +9 -1
  41. package/dist/hooks/index.d.ts +2 -0
  42. package/dist/hooks/useLicenseStatus.d.ts +16 -0
  43. package/dist/hooks/useProjectLog.d.ts +11 -6
  44. package/dist/hooks/useTranslation.d.ts +1 -1
  45. package/dist/i18n/__tests__/FireCMSi18nProvider.test.d.ts +1 -0
  46. package/dist/i18n/__tests__/nested_provider_lifecycle.test.d.ts +1 -0
  47. package/dist/index.es.js +4059 -11384
  48. package/dist/index.es.js.map +1 -1
  49. package/dist/index.umd.js +9090 -7610
  50. package/dist/index.umd.js.map +1 -1
  51. package/dist/it-l2KXtx9n.js +707 -0
  52. package/dist/it-l2KXtx9n.js.map +1 -0
  53. package/dist/pl-DPZrdKhi.js +709 -0
  54. package/dist/pl-DPZrdKhi.js.map +1 -0
  55. package/dist/pt-BoHpabw1.js +709 -0
  56. package/dist/pt-BoHpabw1.js.map +1 -0
  57. package/dist/types/analytics.d.ts +1 -1
  58. package/dist/types/collections.d.ts +14 -0
  59. package/dist/types/datasource.d.ts +67 -4
  60. package/dist/types/entities.d.ts +21 -1
  61. package/dist/types/entity_actions.d.ts +6 -0
  62. package/dist/types/entity_callbacks.d.ts +21 -0
  63. package/dist/types/firecms.d.ts +18 -1
  64. package/dist/types/firecms_context.d.ts +8 -0
  65. package/dist/types/index.d.ts +1 -0
  66. package/dist/types/license.d.ts +81 -0
  67. package/dist/types/navigation.d.ts +34 -5
  68. package/dist/types/plugins.d.ts +22 -1
  69. package/dist/types/properties.d.ts +17 -1
  70. package/dist/types/side_entity_controller.d.ts +7 -0
  71. package/dist/types/translations.d.ts +51 -2
  72. package/dist/util/__tests__/collections.test.d.ts +1 -0
  73. package/dist/util/__tests__/urls.test.d.ts +1 -0
  74. package/dist/util/collections.d.ts +1 -1
  75. package/dist/util/date_fns_locales.d.ts +18 -0
  76. package/dist/util/entity_cache.d.ts +11 -0
  77. package/dist/util/index.d.ts +2 -0
  78. package/dist/util/lazy_eager.d.ts +3 -3
  79. package/dist/util/local_storage.d.ts +23 -0
  80. package/dist/util/navigation_from_path.d.ts +7 -0
  81. package/dist/util/navigation_utils.d.ts +87 -3
  82. package/dist/util/parent_references_from_path.d.ts +16 -0
  83. package/dist/util/paths.d.ts +28 -0
  84. package/dist/util/permissions.d.ts +10 -4
  85. package/dist/util/urls.d.ts +22 -0
  86. package/package.json +5 -6
  87. package/src/app/Scaffold.tsx +7 -0
  88. package/src/components/DeleteEntityDialog.tsx +2 -0
  89. package/src/components/EntityCollectionTable/EntityCollectionRowActions.tsx +2 -2
  90. package/src/components/EntityCollectionTable/EntityCollectionTable.tsx +1 -0
  91. package/src/components/EntityCollectionTable/internal/CollectionTableToolbar.tsx +7 -0
  92. package/src/components/EntityCollectionTable/internal/popup_field/PopupFormField.tsx +5 -1
  93. package/src/components/EntityCollectionView/EntityCollectionBoardView.tsx +12 -0
  94. package/src/components/EntityCollectionView/EntityCollectionView.tsx +66 -15
  95. package/src/components/EntityCollectionView/EntityCollectionViewActions.tsx +6 -2
  96. package/src/components/EntityCollectionView/EntityCollectionViewStartActions.tsx +4 -0
  97. package/src/components/EntityCollectionView/useBoardDataController.tsx +17 -1
  98. package/src/components/EntityCollectionView/utils.ts +4 -4
  99. package/src/components/EntityPreview.tsx +4 -1
  100. package/src/components/ErrorBoundary.tsx +12 -18
  101. package/src/components/LicenseBanner.tsx +287 -0
  102. package/src/components/ReferenceTable/ReferenceSelectionTable.tsx +19 -2
  103. package/src/components/SelectableTable/filters/StringNumberFilterField.tsx +33 -7
  104. package/src/components/SelectableTable/filters/filter_text_input.test.ts +184 -0
  105. package/src/components/SelectableTable/filters/filter_text_input.ts +71 -0
  106. package/src/components/common/default_entity_actions.tsx +22 -2
  107. package/src/components/common/table_url_params.ts +172 -0
  108. package/src/components/common/useDataSourceTableController.tsx +86 -179
  109. package/src/components/common/useTableSearchHelper.ts +26 -2
  110. package/src/components/index.tsx +1 -0
  111. package/src/contexts/LicenseStatusContext.tsx +50 -0
  112. package/src/contexts/SnackbarProvider.tsx +14 -1
  113. package/src/core/DefaultAppBar.tsx +28 -16
  114. package/src/core/EntityEditView.tsx +20 -3
  115. package/src/core/EntityEditViewFormActions.tsx +3 -2
  116. package/src/core/EntitySidePanel.tsx +8 -4
  117. package/src/core/FireCMS.tsx +73 -47
  118. package/src/core/SideDialogs.tsx +44 -1
  119. package/src/core/index.tsx +1 -0
  120. package/src/core/plugin_translations.ts +87 -0
  121. package/src/core/pro_plugins.ts +79 -0
  122. package/src/editor/components/SlashCommandMenu.tsx +4 -1
  123. package/src/editor/extensions/Image.ts +17 -1
  124. package/src/editor/hooks/useProseMirror.ts +5 -0
  125. package/src/editor/index.ts +11 -1
  126. package/src/editor/lazy_editor.tsx +33 -0
  127. package/src/form/EntityForm.tsx +31 -10
  128. package/src/form/EntityFormActions.tsx +2 -0
  129. package/src/hooks/data/delete.ts +8 -0
  130. package/src/hooks/data/save.ts +6 -1
  131. package/src/hooks/data/useCollectionFetch.tsx +21 -1
  132. package/src/hooks/data/useEntityFetch.tsx +35 -4
  133. package/src/hooks/index.tsx +2 -0
  134. package/src/hooks/useBuildLocalConfigurationPersistence.tsx +9 -9
  135. package/src/hooks/useBuildModeController.tsx +6 -4
  136. package/src/hooks/useBuildNavigationController.tsx +70 -15
  137. package/src/hooks/useFireCMSContext.tsx +7 -3
  138. package/src/hooks/useLicenseStatus.tsx +32 -0
  139. package/src/hooks/useProjectLog.tsx +27 -9
  140. package/src/hooks/useResolvedNavigationFrom.tsx +3 -1
  141. package/src/hooks/useTranslation.ts +3 -1
  142. package/src/hooks/useValidateAuthenticator.tsx +4 -0
  143. package/src/i18n/FireCMSi18nProvider.tsx +204 -33
  144. package/src/i18n/__tests__/FireCMSi18nProvider.test.tsx +91 -0
  145. package/src/i18n/__tests__/nested_provider_lifecycle.test.tsx +67 -0
  146. package/src/internal/useBuildDataSource.ts +37 -15
  147. package/src/internal/useBuildSideEntityController.tsx +19 -5
  148. package/src/locales/de.ts +28 -2
  149. package/src/locales/en.ts +33 -5
  150. package/src/locales/es.ts +28 -2
  151. package/src/locales/fr.ts +28 -2
  152. package/src/locales/hi.ts +28 -2
  153. package/src/locales/it.ts +28 -2
  154. package/src/locales/pl.ts +28 -2
  155. package/src/locales/pt.ts +28 -2
  156. package/src/preview/components/DatePreview.tsx +2 -4
  157. package/src/preview/components/ReferencePreview.tsx +7 -3
  158. package/src/preview/components/StorageThumbnail.tsx +24 -2
  159. package/src/routes/FireCMSRoute.tsx +11 -3
  160. package/src/types/analytics.ts +3 -0
  161. package/src/types/collections.ts +16 -0
  162. package/src/types/datasource.ts +67 -4
  163. package/src/types/entities.ts +24 -1
  164. package/src/types/entity_actions.tsx +6 -0
  165. package/src/types/entity_callbacks.ts +24 -0
  166. package/src/types/firecms.tsx +19 -1
  167. package/src/types/firecms_context.tsx +9 -0
  168. package/src/types/index.ts +1 -0
  169. package/src/types/license.ts +90 -0
  170. package/src/types/navigation.ts +35 -5
  171. package/src/types/plugins.tsx +23 -1
  172. package/src/types/properties.ts +17 -0
  173. package/src/types/side_entity_controller.tsx +7 -0
  174. package/src/types/translations.ts +51 -2
  175. package/src/util/__tests__/collections.test.ts +32 -0
  176. package/src/util/__tests__/urls.test.ts +131 -0
  177. package/src/util/collections.ts +1 -1
  178. package/src/util/date_fns_locales.ts +168 -0
  179. package/src/util/entities.ts +3 -1
  180. package/src/util/entity_cache.ts +28 -2
  181. package/src/util/index.ts +2 -0
  182. package/src/util/lazy_eager.tsx +30 -8
  183. package/src/util/local_storage.ts +52 -0
  184. package/src/util/navigation_from_path.ts +25 -2
  185. package/src/util/navigation_utils.ts +214 -3
  186. package/src/util/parent_references_from_path.ts +42 -3
  187. package/src/util/paths.ts +40 -3
  188. package/src/util/permissions.ts +22 -10
  189. package/src/util/previews.ts +8 -1
  190. package/src/util/resolutions.ts +8 -0
  191. package/src/util/urls.ts +52 -0
  192. package/src/util/useStorageUploadController.tsx +6 -1
@@ -45,6 +45,8 @@ export interface BoardDataController<M extends Record<string, any> = any, COLUMN
45
45
  export interface UseBoardDataControllerProps<M extends Record<string, any> = any> {
46
46
  /** Full path to the collection */
47
47
  fullPath: string;
48
+ /** `fullPath` split at its real segment boundaries; forwarded to the datasource. */
49
+ pathSegments?: string[];
48
50
  /** The entity collection configuration */
49
51
  collection: EntityCollection<M>;
50
52
  /** Property key used for column assignment */
@@ -67,6 +69,7 @@ export interface UseBoardDataControllerProps<M extends Record<string, any> = any
67
69
  */
68
70
  export function useBoardDataController<M extends Record<string, any> = any, COLUMN extends string = string>({
69
71
  fullPath,
72
+ pathSegments,
70
73
  collection,
71
74
  columnProperty,
72
75
  columns,
@@ -79,7 +82,12 @@ export function useBoardDataController<M extends Record<string, any> = any, COLU
79
82
  const context = useFireCMSContext();
80
83
  const dataSource = useDataSource(collection);
81
84
  const navigation = useNavigationController();
82
- const resolvedPath = useMemo(() => navigation.resolveIdsFrom(fullPath), [fullPath, navigation.resolveIdsFrom]);
85
+ // Resolved together so the two representations always describe the same chain.
86
+ // Segments are resolved, never derived (see useEntityFetch).
87
+ const resolvedPath = useMemo(() => navigation.resolveIdsFrom(fullPath, pathSegments), [fullPath, pathSegments, navigation.resolveIdsFrom]);
88
+ const resolvedPathSegments = useMemo(() => pathSegments
89
+ ? (navigation.resolveSegmentsFrom?.(pathSegments) ?? pathSegments)
90
+ : undefined, [pathSegments, navigation.resolveSegmentsFrom]);
83
91
 
84
92
  // Stable refs for objects that shouldn't trigger re-subscriptions
85
93
  const dataSourceRef = useRef(dataSource);
@@ -95,11 +103,13 @@ export function useBoardDataController<M extends Record<string, any> = any, COLU
95
103
  const orderPropertyRef = useRef(orderProperty);
96
104
  const searchStringRef = useRef(searchString);
97
105
  const resolvedPathRef = useRef(resolvedPath);
106
+ const resolvedPathSegmentsRef = useRef(resolvedPathSegments);
98
107
  filterValuesRef.current = filterValues;
99
108
  columnPropertyRef.current = columnProperty;
100
109
  orderPropertyRef.current = orderProperty;
101
110
  searchStringRef.current = searchString;
102
111
  resolvedPathRef.current = resolvedPath;
112
+ resolvedPathSegmentsRef.current = resolvedPathSegments;
103
113
 
104
114
  // Track item count per column for pagination
105
115
  const [columnItemCounts, setColumnItemCounts] = useState<Record<string, number>>(() => {
@@ -168,6 +178,7 @@ export function useBoardDataController<M extends Record<string, any> = any, COLU
168
178
  const currentOrderProperty = orderPropertyRef.current;
169
179
  const currentSearchString = searchStringRef.current;
170
180
  const currentResolvedPath = resolvedPathRef.current;
181
+ const currentPathSegments = resolvedPathSegmentsRef.current;
171
182
 
172
183
  // Build filter for this column
173
184
  const columnFilter: FilterValues<string> = {
@@ -205,6 +216,7 @@ export function useBoardDataController<M extends Record<string, any> = any, COLU
205
216
  currentCollection.callbacks!.onFetch!({
206
217
  collection: currentCollection,
207
218
  path: currentResolvedPath,
219
+ pathSegments: currentPathSegments,
208
220
  entity,
209
221
  context: currentContext
210
222
  })
@@ -248,6 +260,7 @@ export function useBoardDataController<M extends Record<string, any> = any, COLU
248
260
  if (currentDataSource.listenCollection) {
249
261
  const unsubscribe = currentDataSource.listenCollection<M>({
250
262
  path: currentResolvedPath,
263
+ pathSegments: currentPathSegments,
251
264
  collection: currentCollection,
252
265
  onUpdate,
253
266
  onError,
@@ -262,6 +275,7 @@ export function useBoardDataController<M extends Record<string, any> = any, COLU
262
275
  } else {
263
276
  currentDataSource.fetchCollection<M>({
264
277
  path: currentResolvedPath,
278
+ pathSegments: currentPathSegments,
265
279
  collection: currentCollection,
266
280
  searchString: currentSearchString,
267
281
  filter: columnFilter,
@@ -297,6 +311,7 @@ export function useBoardDataController<M extends Record<string, any> = any, COLU
297
311
  const currentColumnProperty = columnPropertyRef.current;
298
312
  const currentSearchString = searchStringRef.current;
299
313
  const currentResolvedPath = resolvedPathRef.current;
314
+ const currentPathSegments = resolvedPathSegmentsRef.current;
300
315
  const currentColumns = columns;
301
316
  const currentColumnItemCounts = columnItemCounts;
302
317
 
@@ -316,6 +331,7 @@ export function useBoardDataController<M extends Record<string, any> = any, COLU
316
331
  };
317
332
  currentDataSource.countEntities({
318
333
  path: currentResolvedPath,
334
+ pathSegments: currentPathSegments,
319
335
  collection: currentCollection,
320
336
  filter: columnFilter,
321
337
  searchString: currentSearchString
@@ -1,3 +1,5 @@
1
+ import { getLocalStorageJSON, setLocalStorageItem } from "../../util/local_storage";
2
+
1
3
  export function addRecentId(collectionId: string, id: string) {
2
4
  const recentIds = getRecentIds(collectionId);
3
5
  const newRecentIds = [id, ...recentIds.filter(i => i !== id)];
@@ -9,11 +11,9 @@ export function addRecentId(collectionId: string, id: string) {
9
11
  }
10
12
 
11
13
  export function saveSearchedIdsLocally(collectionId: string, ids: string[]) {
12
- localStorage.setItem("recent_id_searches::" + collectionId, JSON.stringify(ids));
14
+ setLocalStorageItem("recent_id_searches::" + collectionId, JSON.stringify(ids));
13
15
  }
14
16
 
15
17
  export function getRecentIds(collectionId: string): string[] {
16
- const stored = localStorage.getItem("recent_id_searches::" + collectionId);
17
- if (!stored) return [];
18
- return JSON.parse(stored);
18
+ return getLocalStorageJSON("recent_id_searches::" + collectionId, []);
19
19
  }
@@ -63,7 +63,7 @@ export function EntityPreview({
63
63
 
64
64
  const navigationController = useNavigationController();
65
65
 
66
- const collection = collectionProp ?? navigationController.getCollection(entity.path);
66
+ const collection = collectionProp ?? navigationController.getCollection(entity.path, false, entity.pathSegments);
67
67
 
68
68
  if (!collection) {
69
69
  throw Error(`Couldn't find the corresponding collection view for the path: ${entity.path}`);
@@ -177,6 +177,9 @@ export function EntityPreview({
177
177
  sideEntityController.open({
178
178
  entityId: entity.id,
179
179
  path: entity.path,
180
+ // Paired with `entity.path` above; undefined if the entity was
181
+ // loaded without them.
182
+ pathSegments: entity.pathSegments,
180
183
  collection,
181
184
  updateUrl: true
182
185
  });
@@ -4,17 +4,16 @@ import { useTranslation } from "../hooks/useTranslation";
4
4
  import { ErrorIcon, Typography } from "@firecms/ui";
5
5
 
6
6
  export class ErrorBoundary extends React.Component<PropsWithChildren<Record<string, unknown>>, {
7
- hasError: boolean,
8
- error?: Error
7
+ error: Error | null
9
8
  }> {
10
9
  constructor(props: any) {
11
10
  super(props);
12
- this.state = { hasError: false };
11
+ this.state = { error: null };
13
12
  }
14
13
 
15
14
  // eslint-disable-next-line n/handle-callback-err
16
15
  static getDerivedStateFromError(error: Error) {
17
- return { hasError: true, error };
16
+ return { error };
18
17
  }
19
18
 
20
19
  componentDidCatch(error: Error, errorInfo: ErrorInfo) {
@@ -23,9 +22,8 @@ export class ErrorBoundary extends React.Component<PropsWithChildren<Record<stri
23
22
  }
24
23
 
25
24
  render() {
26
- if (this.state.hasError) {
27
- // You can render any custom fallback UI
28
- return <FallbackView message={this.state.error?.message}/>;
25
+ if (this.state.error) {
26
+ return <FallbackView message={this.state.error.message}/>;
29
27
  }
30
28
 
31
29
  return this.props.children;
@@ -35,18 +33,14 @@ export class ErrorBoundary extends React.Component<PropsWithChildren<Record<stri
35
33
  function FallbackView({ message }: { message?: string }) {
36
34
  const { t } = useTranslation();
37
35
  return (
38
- <div className="h-full w-full bg-slate-100 dark:bg-surface-900 flex items-center justify-center p-4">
39
- <div
40
- className="flex flex-col items-center justify-center m-4 bg-white dark:bg-surface-800 p-8 rounded-lg shadow-sm border border-gray-200 dark:border-surface-700">
41
- <div className="flex items-center mb-4 text-red-500 dark:text-red-400">
42
- <ErrorIcon/>
43
- <div className="ml-4">{t("error")}</div>
44
- </div>
45
- <div className="flex justify-center text-gray-500 dark:text-gray-400">
46
- {/* Error message is purposely removed since it's hard to access state here, but typical ErrorBoundary fallback doesn't always show the raw message */}
47
- {t("see_console_details")}
48
- </div>
36
+ <div className="flex flex-col m-2">
37
+ <div className="flex items-center m-2">
38
+ <ErrorIcon color={"error"} size={"small"}/>
39
+ <div className="ml-4">{t("error")}</div>
49
40
  </div>
41
+ <Typography variant={"caption"}>
42
+ {message ?? t("see_console_details")}
43
+ </Typography>
50
44
  </div>
51
45
  );
52
46
  }
@@ -0,0 +1,287 @@
1
+ import React, { useContext, useState } from "react";
2
+ import { Alert, Button, CloseIcon, IconButton } from "@firecms/ui";
3
+ import { AccessResponse, ProSuggestion } from "../types";
4
+ import { getLicenseSubscribeUrl, PRO_PLUGIN_KEYS } from "../core/pro_plugins";
5
+ import { CustomizationControllerContext } from "../contexts/CustomizationControllerContext";
6
+ import { useLicenseStatus } from "../hooks/useLicenseStatus";
7
+ import { useTranslation } from "../hooks/useTranslation";
8
+
9
+ const DISMISSED_STORAGE_KEY = "firecms_license_banner_dismissed";
10
+ const DISMISSED_SUGGESTIONS_STORAGE_KEY = "firecms_pro_suggestions_dismissed";
11
+ const DAY_MS = 24 * 60 * 60 * 1000;
12
+
13
+ /**
14
+ * @group Components
15
+ */
16
+ export interface LicenseBannerProps {
17
+ /**
18
+ * Applied to the banner's outer element when there is something to show.
19
+ */
20
+ className?: string;
21
+ style?: React.CSSProperties;
22
+ }
23
+
24
+ type BannerContent = {
25
+ state: string;
26
+ color: "info" | "warning";
27
+ message: string;
28
+ linkLabel: string;
29
+ href: string;
30
+ /**
31
+ * `session` hides it until the browser session ends, `forever` in this
32
+ * browser for good.
33
+ */
34
+ dismissal: "none" | "session" | "forever";
35
+ };
36
+
37
+ /**
38
+ * Where each PRO suggestion links: the plugin's docs, which say how to add it.
39
+ * The UTM parameters let the website count the clicks.
40
+ */
41
+ const SUGGESTION_DOCS_URLS: Record<ProSuggestion["plugin"], string> = {
42
+ user_management: "https://firecms.co/docs/pro/user_management/?utm_source=firecms&utm_medium=cms_suggestion&utm_campaign=user_management",
43
+ entity_history: "https://firecms.co/docs/pro/entity_history/?utm_source=firecms&utm_medium=cms_suggestion&utm_campaign=entity_history"
44
+ };
45
+
46
+ /**
47
+ * Tells the user where their FireCMS PRO license stands: days left in the
48
+ * trial, or why PRO is paused (the trial ended, the key is not linked to this
49
+ * project, or the license pays for other projects). Renders nothing when
50
+ * licensed and while the status is unknown. When no license is needed, it
51
+ * shows only a PRO plugin the license check suggests for this project, if any.
52
+ *
53
+ * The default `Scaffold` already renders it above the main content. Place it
54
+ * yourself only in a custom layout.
55
+ *
56
+ * @group Components
57
+ */
58
+ export function LicenseBanner({
59
+ className,
60
+ style
61
+ }: LicenseBannerProps) {
62
+
63
+ const licenseStatus = useLicenseStatus();
64
+ const { t, i18n } = useTranslation();
65
+
66
+ // The server answers `not_required` to every localhost and FireCMS Cloud
67
+ // request, whatever the app mounts, so a suggestion is only shown to an
68
+ // app that really runs no PRO plugin.
69
+ const plugins = useContext(CustomizationControllerContext)?.plugins;
70
+ const runsProPlugin = plugins?.some(plugin => PRO_PLUGIN_KEYS.includes(plugin.key)) ?? false;
71
+
72
+ // States dismissed in this browser session; a different state shows again.
73
+ const [dismissed, setDismissed] = useState<string[]>(() => readDismissed(sessionDismissals));
74
+ // Suggestions dismissed in this browser, for good.
75
+ const [dismissedForGood, setDismissedForGood] = useState<string[]>(() => readDismissed(browserDismissals));
76
+
77
+ // `language`, not `resolvedLanguage`: locale bundles are added after init,
78
+ // and `resolvedLanguage` stays on the English fallback when they are.
79
+ const content = licenseStatus
80
+ ? buildBannerContent(licenseStatus, t, i18n?.language, runsProPlugin)
81
+ : null;
82
+
83
+ if (!content
84
+ || (content.dismissal === "session" && dismissed.includes(content.state))
85
+ || (content.dismissal === "forever" && dismissedForGood.includes(content.state)))
86
+ return null;
87
+
88
+ const dismiss = () => {
89
+ if (content.dismissal === "forever") {
90
+ const next = [...dismissedForGood, content.state];
91
+ writeDismissed(browserDismissals, next);
92
+ setDismissedForGood(next);
93
+ } else {
94
+ const next = [...dismissed, content.state];
95
+ writeDismissed(sessionDismissals, next);
96
+ setDismissed(next);
97
+ }
98
+ };
99
+
100
+ return (
101
+ <div className={className} style={style}>
102
+ <Alert
103
+ color={content.color}
104
+ size={"small"}
105
+ className={"text-sm"}
106
+ action={<div className={"flex flex-row items-center gap-1 flex-shrink-0"}>
107
+ <Button
108
+ variant={"text"}
109
+ size={"small"}
110
+ component={"a"}
111
+ href={content.href}
112
+ target={"_blank"}
113
+ rel={"noopener noreferrer"}>
114
+ {content.linkLabel}
115
+ </Button>
116
+ {content.dismissal !== "none" && <IconButton
117
+ size={"small"}
118
+ aria-label={t("close")}
119
+ onClick={dismiss}>
120
+ <CloseIcon size={"small"}/>
121
+ </IconButton>}
122
+ </div>}>
123
+ {content.message}
124
+ </Alert>
125
+ </div>
126
+ );
127
+ }
128
+
129
+ type Translate = ReturnType<typeof useTranslation>["t"];
130
+
131
+ function buildBannerContent(status: AccessResponse,
132
+ t: Translate,
133
+ language: string | undefined,
134
+ runsProPlugin: boolean): BannerContent | null {
135
+ const href = getLicenseSubscribeUrl(status);
136
+ switch (status.licenseState) {
137
+ case "trial": {
138
+ const days = trialDaysLeft(status);
139
+ if (days === null) return null;
140
+ return {
141
+ state: "trial",
142
+ color: "info",
143
+ message: t("license_trial_banner", {
144
+ count: days,
145
+ days: String(days)
146
+ }),
147
+ linkLabel: t("license_get_license"),
148
+ href,
149
+ dismissal: "session"
150
+ };
151
+ }
152
+ case "expired": {
153
+ const date = formatDate(status.trialEndsAt, language);
154
+ return {
155
+ state: "expired",
156
+ color: "warning",
157
+ message: date
158
+ ? t("license_expired_banner", { date })
159
+ : t("license_expired_banner_undated"),
160
+ linkLabel: t("license_get_license"),
161
+ href,
162
+ dismissal: "none"
163
+ };
164
+ }
165
+ case "invalid_project":
166
+ return {
167
+ state: "invalid_project",
168
+ color: "warning",
169
+ message: status.projectId
170
+ ? t("license_invalid_project_banner", { projectId: status.projectId })
171
+ : t("license_invalid_project_banner_unnamed"),
172
+ linkLabel: t("license_add_project_to_license"),
173
+ href,
174
+ dismissal: "none"
175
+ };
176
+ case "over_quota": {
177
+ const licensed = status.licensedProjects;
178
+ if (!isCount(licensed)) return null;
179
+ return {
180
+ state: "over_quota",
181
+ color: "warning",
182
+ message: t("license_over_quota_banner", {
183
+ count: licensed,
184
+ licensed: String(licensed)
185
+ }),
186
+ linkLabel: t("license_update_license"),
187
+ href,
188
+ dismissal: "none"
189
+ };
190
+ }
191
+ case "not_required":
192
+ return runsProPlugin ? null : buildSuggestionContent(status.suggestion, t);
193
+ default:
194
+ // licensed, whitelisted, and servers too old to say
195
+ return null;
196
+ }
197
+ }
198
+
199
+ function buildSuggestionContent(suggestion: ProSuggestion | undefined,
200
+ t: Translate): BannerContent | null {
201
+ if (!suggestion || typeof suggestion !== "object") return null;
202
+ let message: string;
203
+ switch (suggestion.plugin) {
204
+ case "user_management": {
205
+ const users = suggestion.users;
206
+ // The copy is plural only: one person is not a reason to suggest roles.
207
+ message = isCount(users) && users >= 2
208
+ ? t("pro_suggestion_user_management", { users: String(Math.floor(users)) })
209
+ : t("pro_suggestion_user_management_uncounted");
210
+ break;
211
+ }
212
+ case "entity_history":
213
+ message = t("pro_suggestion_entity_history");
214
+ break;
215
+ default:
216
+ // A plugin this version has no copy for.
217
+ return null;
218
+ }
219
+ return {
220
+ state: `suggestion_${suggestion.plugin}`,
221
+ color: "info",
222
+ message,
223
+ linkLabel: t("pro_suggestion_learn_more"),
224
+ href: SUGGESTION_DOCS_URLS[suggestion.plugin],
225
+ dismissal: "forever"
226
+ };
227
+ }
228
+
229
+ function isCount(value: unknown): value is number {
230
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
231
+ }
232
+
233
+ function trialDaysLeft(status: AccessResponse): number | null {
234
+ if (isCount(status.daysLeft)) return Math.ceil(status.daysLeft);
235
+ const end = status.trialEndsAt ? new Date(status.trialEndsAt).getTime() : NaN;
236
+ if (Number.isNaN(end)) return null;
237
+ return Math.max(0, Math.ceil((end - Date.now()) / DAY_MS));
238
+ }
239
+
240
+ function formatDate(iso: string | undefined, language: string | undefined): string | null {
241
+ if (!iso) return null;
242
+ const date = new Date(iso);
243
+ if (Number.isNaN(date.getTime())) return null;
244
+ const options: Intl.DateTimeFormatOptions = {
245
+ year: "numeric",
246
+ month: "long",
247
+ day: "numeric"
248
+ };
249
+ try {
250
+ return new Intl.DateTimeFormat(language, options).format(date);
251
+ } catch {
252
+ return date.toLocaleDateString(undefined, options);
253
+ }
254
+ }
255
+
256
+ type DismissedStore = {
257
+ storage: () => Storage;
258
+ key: string;
259
+ };
260
+
261
+ const sessionDismissals: DismissedStore = {
262
+ storage: () => sessionStorage,
263
+ key: DISMISSED_STORAGE_KEY
264
+ };
265
+
266
+ const browserDismissals: DismissedStore = {
267
+ storage: () => localStorage,
268
+ key: DISMISSED_SUGGESTIONS_STORAGE_KEY
269
+ };
270
+
271
+ function readDismissed(store: DismissedStore): string[] {
272
+ try {
273
+ const value = store.storage().getItem(store.key);
274
+ const parsed = value ? JSON.parse(value) : [];
275
+ return Array.isArray(parsed) ? parsed.filter((s): s is string => typeof s === "string") : [];
276
+ } catch {
277
+ return [];
278
+ }
279
+ }
280
+
281
+ function writeDismissed(store: DismissedStore, states: string[]) {
282
+ try {
283
+ store.storage().setItem(store.key, JSON.stringify(states));
284
+ } catch {
285
+ // Private mode or storage disabled: dismissed for this page view only.
286
+ }
287
+ }
@@ -44,6 +44,15 @@ export interface ReferenceSelectionInnerProps<M extends Record<string, any>> {
44
44
  * dynamic. If not set, the dialog won't open.
45
45
  */
46
46
  path: string;
47
+ /**
48
+ * `path` split at its real segment boundaries, when the caller knows them.
49
+ *
50
+ * Left undefined by default and deliberately NOT derived from `path`: a reference
51
+ * path comes from a property's configuration, so there is no entity chain to recover
52
+ * the boundaries from. Splitting it would silently produce the wrong segments for a
53
+ * subcollection under a slash-bearing parent id, which is worse than not answering.
54
+ */
55
+ pathSegments?: string[];
47
56
 
48
57
  /**
49
58
  * If you are opening the dialog for the first time, you can select some
@@ -96,6 +105,7 @@ export function ReferenceSelectionTable<M extends Record<string, any>>(
96
105
  multiselect,
97
106
  collection,
98
107
  path: pathInput,
108
+ pathSegments,
99
109
  selectedEntityIds: selectedEntityIdsProp,
100
110
  description,
101
111
  forceFilter,
@@ -110,7 +120,7 @@ export function ReferenceSelectionTable<M extends Record<string, any>>(
110
120
  const analyticsController = useAnalyticsController();
111
121
  const customizationController = useCustomizationController();
112
122
 
113
- const fullPath = navigation.resolveIdsFrom(pathInput);
123
+ const fullPath = navigation.resolveIdsFrom(pathInput, pathSegments);
114
124
 
115
125
  const dataSource = useDataSource(collection);
116
126
 
@@ -153,6 +163,7 @@ export function ReferenceSelectionTable<M extends Record<string, any>>(
153
163
  selectedEntityIds.map((entityId) =>
154
164
  dataSource.fetchEntity({
155
165
  path: fullPath,
166
+ pathSegments,
156
167
  entityId,
157
168
  collection
158
169
  })))
@@ -204,6 +215,7 @@ export function ReferenceSelectionTable<M extends Record<string, any>>(
204
215
  });
205
216
  sideEntityController.open({
206
217
  path: fullPath,
218
+ pathSegments,
207
219
  collection,
208
220
  updateUrl: true,
209
221
  onUpdate: ({ entity }) => {
@@ -277,6 +289,7 @@ export function ReferenceSelectionTable<M extends Record<string, any>>(
277
289
  textSearchEnabled
278
290
  } =
279
291
  useTableSearchHelper({
292
+ pathSegments,
280
293
  collection,
281
294
  fullPath,
282
295
  });
@@ -315,6 +328,7 @@ export function ReferenceSelectionTable<M extends Record<string, any>>(
315
328
  actions={<ReferenceDialogActions
316
329
  collection={collection}
317
330
  path={fullPath}
331
+ pathSegments={pathSegments}
318
332
  onNewClick={onNewClick}
319
333
  onClear={onClear}/>
320
334
  }
@@ -341,11 +355,14 @@ export function ReferenceSelectionTable<M extends Record<string, any>>(
341
355
  function ReferenceDialogActions({
342
356
  collection,
343
357
  path,
358
+ pathSegments,
344
359
  onClear,
345
360
  onNewClick
346
361
  }: {
347
362
  collection: EntityCollection<any>,
348
363
  path: string,
364
+ /** `path` split at its real segment boundaries, when known. Never derived from `path`. */
365
+ pathSegments?: string[],
349
366
  onClear: () => void,
350
367
  onNewClick: () => void
351
368
  }) {
@@ -360,7 +377,7 @@ function ReferenceDialogActions({
360
377
  onNewClick();
361
378
  }
362
379
  : undefined;
363
- const addButton = canCreateEntity(collection, authController, path, null) &&
380
+ const addButton = canCreateEntity(collection, authController, path, null, pathSegments) &&
364
381
  onClick && (largeLayout
365
382
  ? <Button
366
383
  onClick={onClick}
@@ -11,6 +11,7 @@ import {
11
11
  TextField
12
12
  } from "@firecms/ui";
13
13
  import { EnumValueConfig } from "../../../types";
14
+ import { filterValueToText, parseFilterTextInput } from "./filter_text_input";
14
15
 
15
16
  interface StringNumberFilterFieldProps {
16
17
  name: string,
@@ -60,8 +61,15 @@ export function StringNumberFilterField({
60
61
  const [fieldOperation, fieldValue] = value || [possibleOperations[0], undefined];
61
62
  const [operation, setOperation] = useState<VirtualTableWhereFilterOp | "is-null">(fieldOperation === "==" && fieldValue === null ? "is-null" : fieldOperation);
62
63
  const [internalValue, setInternalValue] = useState<string | number | string[] | number[] | null | undefined>(fieldValue);
64
+ // Raw text of the text input, kept apart from `internalValue` so that intermediate
65
+ // inputs like "-", "0." or ".0" are not destroyed while the user is typing.
66
+ const [inputText, setInputText] = useState<string>(filterValueToText(fieldValue));
67
+ // Value the current `inputText` was built from, used to tell updates coming from
68
+ // outside apart from the ones triggered by the user typing.
69
+ const syncedValueRef = React.useRef(fieldValue);
63
70
 
64
71
  React.useEffect(() => {
72
+ const newValue = value ? value[1] : undefined;
65
73
  if (value) {
66
74
  const [op, val] = value;
67
75
  setOperation(op === "==" && val === null ? "is-null" : op);
@@ -70,15 +78,25 @@ export function StringNumberFilterField({
70
78
  setOperation(possibleOperations[0]);
71
79
  setInternalValue(undefined);
72
80
  }
81
+ // only reset the text when the value was changed somewhere else, so we don't
82
+ // interfere with what the user is currently typing
83
+ if (syncedValueRef.current !== newValue) {
84
+ syncedValueRef.current = newValue;
85
+ setInputText(filterValueToText(newValue));
86
+ }
73
87
  }, [value, possibleOperations[0]]);
74
88
 
75
89
  const isNullOperation = operation === "is-null";
76
90
 
77
- function updateFilter(op: VirtualTableWhereFilterOp | "is-null", val: string | number | string[] | number[] | null | undefined) {
91
+ // `keepInputText` is used when the update originates from the text input, where the
92
+ // text typed by the user is authoritative and must not be overwritten with `val`
93
+ function updateFilter(op: VirtualTableWhereFilterOp | "is-null", val: string | number | string[] | number[] | null | undefined, keepInputText = false) {
78
94
  // Handle "is null" operation
79
95
  if (op === "is-null") {
80
96
  setOperation(op);
81
97
  setInternalValue(null);
98
+ syncedValueRef.current = null;
99
+ setInputText("");
82
100
  setValue(["==", null]);
83
101
  return;
84
102
  }
@@ -96,6 +114,9 @@ export function StringNumberFilterField({
96
114
 
97
115
  setOperation(op);
98
116
  setInternalValue(newValue);
117
+ syncedValueRef.current = newValue;
118
+ if (!keepInputText)
119
+ setInputText(filterValueToText(newValue));
99
120
 
100
121
  const hasNewValue = newValue !== null && Array.isArray(newValue)
101
122
  ? newValue.length > 0
@@ -111,6 +132,14 @@ export function StringNumberFilterField({
111
132
  }
112
133
  }
113
134
 
135
+ function updateFilterFromText(text: string) {
136
+ setInputText(text);
137
+ const { updateValue, value: parsedValue } = parseFilterTextInput(text, dataType);
138
+ // intermediate inputs like "-" or "0." keep the filter as it is
139
+ if (updateValue)
140
+ updateFilter(operation, parsedValue, true);
141
+ }
142
+
114
143
  const multiple = multipleSelectOperations.includes(operation);
115
144
 
116
145
  return (
@@ -138,16 +167,13 @@ export function StringNumberFilterField({
138
167
  {!enumValues && <TextField
139
168
  size={"medium"}
140
169
  type={dataType === "number" ? "number" : undefined}
141
- value={internalValue !== undefined && internalValue != null ? String(internalValue) : ""}
170
+ value={inputText}
142
171
  disabled={isNullOperation}
143
172
  placeholder={isNullOperation ? "null" : undefined}
144
173
  onChange={(evt) => {
145
- const val = dataType === "number"
146
- ? parseFloat(evt.target.value)
147
- : evt.target.value;
148
- updateFilter(operation, val);
174
+ updateFilterFromText(evt.target.value);
149
175
  }}
150
- endAdornment={internalValue !== undefined && internalValue != null && <IconButton
176
+ endAdornment={inputText !== "" && <IconButton
151
177
  onClick={(e) => updateFilter(operation, undefined)}>
152
178
  <CloseIcon />
153
179
  </IconButton>}