@firecms/core 3.4.0-canary.8635253 → 3.4.0-canary.ce97da7

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 (187) 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 +23 -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-DMp6cIY1.js +702 -0
  25. package/dist/de-DMp6cIY1.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-Cckz4IkR.js +702 -0
  34. package/dist/fr-Cckz4IkR.js.map +1 -0
  35. package/dist/hi-CDCoIqbu.js +702 -0
  36. package/dist/hi-CDCoIqbu.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 +3921 -11331
  48. package/dist/index.es.js.map +1 -1
  49. package/dist/index.umd.js +8950 -7585
  50. package/dist/index.umd.js.map +1 -1
  51. package/dist/it-BJCUqixj.js +702 -0
  52. package/dist/it-BJCUqixj.js.map +1 -0
  53. package/dist/pl-DgT_joFf.js +704 -0
  54. package/dist/pl-DgT_joFf.js.map +1 -0
  55. package/dist/pt-B3dN3x0X.js +704 -0
  56. package/dist/pt-B3dN3x0X.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 +54 -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 +41 -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/navigation_from_path.d.ts +7 -0
  80. package/dist/util/navigation_utils.d.ts +87 -3
  81. package/dist/util/parent_references_from_path.d.ts +16 -0
  82. package/dist/util/paths.d.ts +28 -0
  83. package/dist/util/permissions.d.ts +10 -4
  84. package/dist/util/urls.d.ts +22 -0
  85. package/package.json +5 -5
  86. package/src/app/Scaffold.tsx +7 -0
  87. package/src/components/DeleteEntityDialog.tsx +2 -0
  88. package/src/components/EntityCollectionTable/EntityCollectionRowActions.tsx +2 -2
  89. package/src/components/EntityCollectionTable/EntityCollectionTable.tsx +1 -0
  90. package/src/components/EntityCollectionTable/internal/CollectionTableToolbar.tsx +7 -0
  91. package/src/components/EntityCollectionTable/internal/popup_field/PopupFormField.tsx +5 -1
  92. package/src/components/EntityCollectionView/EntityCollectionBoardView.tsx +12 -0
  93. package/src/components/EntityCollectionView/EntityCollectionView.tsx +66 -15
  94. package/src/components/EntityCollectionView/EntityCollectionViewActions.tsx +6 -2
  95. package/src/components/EntityCollectionView/EntityCollectionViewStartActions.tsx +4 -0
  96. package/src/components/EntityCollectionView/useBoardDataController.tsx +17 -1
  97. package/src/components/EntityPreview.tsx +4 -1
  98. package/src/components/ErrorBoundary.tsx +12 -18
  99. package/src/components/LicenseBanner.tsx +209 -0
  100. package/src/components/ReferenceTable/ReferenceSelectionTable.tsx +19 -2
  101. package/src/components/SelectableTable/filters/StringNumberFilterField.tsx +33 -7
  102. package/src/components/SelectableTable/filters/filter_text_input.test.ts +184 -0
  103. package/src/components/SelectableTable/filters/filter_text_input.ts +71 -0
  104. package/src/components/common/default_entity_actions.tsx +22 -2
  105. package/src/components/common/table_url_params.ts +172 -0
  106. package/src/components/common/useDataSourceTableController.tsx +86 -179
  107. package/src/components/common/useTableSearchHelper.ts +26 -2
  108. package/src/components/index.tsx +1 -0
  109. package/src/contexts/LicenseStatusContext.tsx +50 -0
  110. package/src/contexts/SnackbarProvider.tsx +14 -1
  111. package/src/core/DefaultAppBar.tsx +28 -16
  112. package/src/core/EntityEditView.tsx +20 -3
  113. package/src/core/EntityEditViewFormActions.tsx +3 -2
  114. package/src/core/EntitySidePanel.tsx +8 -4
  115. package/src/core/FireCMS.tsx +73 -47
  116. package/src/core/SideDialogs.tsx +44 -1
  117. package/src/core/index.tsx +1 -0
  118. package/src/core/plugin_translations.ts +87 -0
  119. package/src/core/pro_plugins.ts +79 -0
  120. package/src/editor/components/SlashCommandMenu.tsx +4 -1
  121. package/src/editor/extensions/Image.ts +17 -1
  122. package/src/editor/hooks/useProseMirror.ts +5 -0
  123. package/src/editor/index.ts +11 -1
  124. package/src/editor/lazy_editor.tsx +33 -0
  125. package/src/form/EntityForm.tsx +31 -10
  126. package/src/form/EntityFormActions.tsx +2 -0
  127. package/src/hooks/data/delete.ts +8 -0
  128. package/src/hooks/data/save.ts +6 -1
  129. package/src/hooks/data/useCollectionFetch.tsx +21 -1
  130. package/src/hooks/data/useEntityFetch.tsx +35 -4
  131. package/src/hooks/index.tsx +2 -0
  132. package/src/hooks/useBuildNavigationController.tsx +70 -15
  133. package/src/hooks/useFireCMSContext.tsx +7 -3
  134. package/src/hooks/useLicenseStatus.tsx +32 -0
  135. package/src/hooks/useProjectLog.tsx +27 -9
  136. package/src/hooks/useResolvedNavigationFrom.tsx +3 -1
  137. package/src/hooks/useTranslation.ts +3 -1
  138. package/src/hooks/useValidateAuthenticator.tsx +4 -0
  139. package/src/i18n/FireCMSi18nProvider.tsx +199 -25
  140. package/src/i18n/__tests__/FireCMSi18nProvider.test.tsx +91 -0
  141. package/src/i18n/__tests__/nested_provider_lifecycle.test.tsx +67 -0
  142. package/src/internal/useBuildDataSource.ts +37 -15
  143. package/src/internal/useBuildSideEntityController.tsx +19 -5
  144. package/src/locales/de.ts +23 -2
  145. package/src/locales/en.ts +26 -3
  146. package/src/locales/es.ts +23 -2
  147. package/src/locales/fr.ts +23 -2
  148. package/src/locales/hi.ts +23 -2
  149. package/src/locales/it.ts +23 -2
  150. package/src/locales/pl.ts +23 -2
  151. package/src/locales/pt.ts +23 -2
  152. package/src/preview/components/DatePreview.tsx +2 -4
  153. package/src/preview/components/ReferencePreview.tsx +7 -3
  154. package/src/preview/components/StorageThumbnail.tsx +24 -2
  155. package/src/routes/FireCMSRoute.tsx +11 -3
  156. package/src/types/analytics.ts +3 -0
  157. package/src/types/collections.ts +16 -0
  158. package/src/types/datasource.ts +67 -4
  159. package/src/types/entities.ts +24 -1
  160. package/src/types/entity_actions.tsx +6 -0
  161. package/src/types/entity_callbacks.ts +24 -0
  162. package/src/types/firecms.tsx +19 -1
  163. package/src/types/firecms_context.tsx +9 -0
  164. package/src/types/index.ts +1 -0
  165. package/src/types/license.ts +62 -0
  166. package/src/types/navigation.ts +35 -5
  167. package/src/types/plugins.tsx +23 -1
  168. package/src/types/properties.ts +17 -0
  169. package/src/types/side_entity_controller.tsx +7 -0
  170. package/src/types/translations.ts +41 -2
  171. package/src/util/__tests__/collections.test.ts +32 -0
  172. package/src/util/__tests__/urls.test.ts +131 -0
  173. package/src/util/collections.ts +1 -1
  174. package/src/util/date_fns_locales.ts +168 -0
  175. package/src/util/entities.ts +3 -1
  176. package/src/util/entity_cache.ts +18 -0
  177. package/src/util/index.ts +2 -0
  178. package/src/util/lazy_eager.tsx +30 -8
  179. package/src/util/navigation_from_path.ts +25 -2
  180. package/src/util/navigation_utils.ts +214 -3
  181. package/src/util/parent_references_from_path.ts +42 -3
  182. package/src/util/paths.ts +40 -3
  183. package/src/util/permissions.ts +22 -10
  184. package/src/util/previews.ts +8 -1
  185. package/src/util/resolutions.ts +8 -0
  186. package/src/util/urls.ts +52 -0
  187. package/src/util/useStorageUploadController.tsx +6 -1
@@ -1,4 +1,4 @@
1
- import { useState } from "react";
1
+ import { useEffect, useRef, useState } from "react";
2
2
 
3
3
  import { EntityCollection } from "../../types";
4
4
  import { useCustomizationController, useDataSource, useFireCMSContext } from "../../hooks";
@@ -6,13 +6,23 @@ import { useCustomizationController, useDataSource, useFireCMSContext } from "..
6
6
  export interface UseTableSearchHelperParams<M extends Record<string, any>> {
7
7
  collection: EntityCollection<M>;
8
8
  fullPath: string;
9
+ /** `fullPath` split at its real segment boundaries, when known. Never derived from it. */
10
+ pathSegments?: string[];
9
11
  parentCollectionIds?: string[];
12
+ /**
13
+ * Search term that is already being applied without the user having gone through the
14
+ * search bar, e.g. one restored from the URL query params. When set, text search is
15
+ * initialised automatically instead of waiting for a click on the search bar.
16
+ */
17
+ initialSearchString?: string;
10
18
  }
11
19
 
12
20
  export function useTableSearchHelper<M extends Record<string, any>>({
13
21
  collection,
14
22
  fullPath,
15
- parentCollectionIds
23
+ pathSegments,
24
+ parentCollectionIds,
25
+ initialSearchString
16
26
  }: UseTableSearchHelperParams<M>) {
17
27
 
18
28
  const context = useFireCMSContext();
@@ -21,6 +31,7 @@ export function useTableSearchHelper<M extends Record<string, any>>({
21
31
 
22
32
  const [textSearchLoading, setTextSearchLoading] = useState<boolean>(false);
23
33
  const [textSearchInitialised, setTextSearchInitialised] = useState<boolean>(false);
34
+ const autoInitialisedRef = useRef<boolean>(false);
24
35
 
25
36
  let onTextSearchClick: (() => void) | undefined;
26
37
  let textSearchEnabled = Boolean(collection.textSearchEnabled);
@@ -28,6 +39,7 @@ export function useTableSearchHelper<M extends Record<string, any>>({
28
39
  const props = {
29
40
  context,
30
41
  path: fullPath,
42
+ pathSegments,
31
43
  databaseId: collection.databaseId,
32
44
  collection,
33
45
  parentCollectionIds
@@ -54,6 +66,7 @@ export function useTableSearchHelper<M extends Record<string, any>>({
54
66
  promises.push(p.collectionView.onTextSearchClick({
55
67
  context,
56
68
  path: fullPath,
69
+ pathSegments,
57
70
  collection,
58
71
  parentCollectionIds
59
72
  }));
@@ -79,6 +92,17 @@ export function useTableSearchHelper<M extends Record<string, any>>({
79
92
  }
80
93
  })
81
94
  }
95
+
96
+ // Only ever runs once, and only when a search term was restored from outside the search
97
+ // bar: otherwise the search bar would show a term the user can neither edit nor clear,
98
+ // because it stays read only until text search is initialised.
99
+ const shouldAutoInitialise = Boolean(initialSearchString) && textSearchEnabled && !textSearchInitialised && Boolean(onTextSearchClick);
100
+ useEffect(() => {
101
+ if (autoInitialisedRef.current || !shouldAutoInitialise) return;
102
+ autoInitialisedRef.current = true;
103
+ onTextSearchClick?.();
104
+ }, [shouldAutoInitialise]);
105
+
82
106
  return {
83
107
  textSearchLoading,
84
108
  textSearchInitialised,
@@ -43,3 +43,4 @@ export * from "./EntityPreview";
43
43
 
44
44
  export * from "./AIIcon";
45
45
  export * from "./LanguageToggle";
46
+ export * from "./LicenseBanner";
@@ -0,0 +1,50 @@
1
+ import React from "react";
2
+ import { AccessResponse } from "../types";
3
+
4
+ /**
5
+ * License status of the enclosing `FireCMS`. `undefined` means there is no
6
+ * `FireCMS` above; `null` means the check has not answered (or was skipped).
7
+ * @internal
8
+ */
9
+ export const LicenseStatusContext = React.createContext<AccessResponse | null | undefined>(undefined);
10
+
11
+ /*
12
+ * The same status, published outside React's tree.
13
+ *
14
+ * `useBuildNavigationController` runs in the app component that renders
15
+ * `<FireCMS>`, above it, so it cannot read the context. It still has to know
16
+ * when PRO is paused: it applies the plugins' collection, view and home page
17
+ * navigation contributions itself. `FireCMS` publishes here, and the navigation
18
+ * controller subscribes with `useSyncExternalStore`.
19
+ *
20
+ * One value per page: two `FireCMS` instances on one page would share it, which
21
+ * is harmless because both check the same project.
22
+ */
23
+
24
+ let currentStatus: AccessResponse | null = null;
25
+ const listeners = new Set<() => void>();
26
+
27
+ /** @internal */
28
+ export function getLicenseStatusSnapshot(): AccessResponse | null {
29
+ return currentStatus;
30
+ }
31
+
32
+ /** @internal */
33
+ export function getServerLicenseStatusSnapshot(): AccessResponse | null {
34
+ return null;
35
+ }
36
+
37
+ /** @internal */
38
+ export function subscribeLicenseStatus(listener: () => void): () => void {
39
+ listeners.add(listener);
40
+ return () => {
41
+ listeners.delete(listener);
42
+ };
43
+ }
44
+
45
+ /** @internal */
46
+ export function publishLicenseStatus(status: AccessResponse | null): void {
47
+ if (status === currentStatus) return;
48
+ currentStatus = status;
49
+ listeners.forEach(listener => listener());
50
+ }
@@ -1,12 +1,25 @@
1
1
  "use client";
2
2
 
3
- import React, { PropsWithChildren } from "react";
3
+ import React, { PropsWithChildren, useEffect, useState } from "react";
4
4
  import { SnackbarProvider as NotistackSnackbarProvider } from "notistack";
5
5
 
6
6
  export const SnackbarProvider: React.FC<PropsWithChildren<{}>> = ({ children }) => {
7
7
 
8
+ // Notistack renders the snackbars inline, right where this provider sits in the
9
+ // tree, unless it is given a `domRoot`. Dialogs and side dialogs, on the other
10
+ // hand, are portalled to the body, so any ancestor of this provider that creates
11
+ // a stacking context (a transform, a filter, an `isolate`, a positioned element
12
+ // with a z-index...) traps the snackbars underneath them. Portalling the
13
+ // snackbars to the body as well puts them in the same stacking context as the
14
+ // dialogs, where their z-index actually wins.
15
+ const [domRoot, setDomRoot] = useState<HTMLElement | undefined>(undefined);
16
+ useEffect(() => {
17
+ setDomRoot(document.body);
18
+ }, []);
19
+
8
20
  return (
9
21
  <NotistackSnackbarProvider maxSnack={3}
22
+ domRoot={domRoot}
10
23
  autoHideDuration={3500}>
11
24
  {children}
12
25
  </NotistackSnackbarProvider>
@@ -1,7 +1,7 @@
1
1
  import React from "react";
2
2
 
3
3
  import { Link, useNavigate } from "react-router-dom";
4
- import { ErrorBoundary, FireCMSLogo, LanguageToggle } from "../components";
4
+ import { ErrorBoundary, LanguageToggle } from "../components";
5
5
  import {
6
6
  Avatar,
7
7
  BrightnessMediumIcon,
@@ -20,6 +20,7 @@ import { useAuthController, useLargeLayout, useModeController, useNavigationCont
20
20
  import { User } from "../types";
21
21
  import { useApp } from "../app/useApp";
22
22
  import { useBreadcrumbsController } from "../hooks/useBreadcrumbsController";
23
+ import { DrawerLogo } from "./DefaultDrawer";
23
24
 
24
25
  export type DefaultAppBarProps<ADDITIONAL_PROPS = object> = {
25
26
 
@@ -119,32 +120,43 @@ export const DefaultAppBar = function DefaultAppBar({
119
120
  "flex flex-row gap-2 px-4 items-center",
120
121
  {
121
122
  "pl-[19rem]": drawerOpen && largeLayout,
122
- "pl-24": hasDrawer && !(drawerOpen && largeLayout),
123
+ // The title has to sit at the same x whether or not a Drawer
124
+ // is mounted, so the bar reserves the collapsed rail's width
125
+ // in both cases. Only a pinned-open drawer moves it, in step
126
+ // with the drawer itself.
127
+ "pl-24": !(drawerOpen && largeLayout) && (hasDrawer || largeLayout),
123
128
  "z-10": largeLayout,
124
129
  "duration-100": drawerOpen && largeLayout,
125
130
  },
126
131
  className)}>
127
132
 
128
133
 
134
+ {/* Rendering DrawerLogo itself, rather than rebuilding its box here,
135
+ is what keeps the mark from moving when a screen with a drawer
136
+ follows one without: matching it by hand still left it 4px high,
137
+ because DrawerLogo centres a 40px box under `pt-4` instead of
138
+ centring in the 64px bar.
139
+
140
+ Gated on `largeLayout`, not on `lg:`: useLargeLayout resolves at
141
+ min-width 1025px while Tailwind's `lg:` starts at 1024px, and at
142
+ exactly 1024px the logo would render into a bar that had not
143
+ reserved room for it, landing on top of the title. */}
144
+ {navigation && !hasDrawer && largeLayout &&
145
+ <div className={"absolute left-0 top-0"}>
146
+ <DrawerLogo logo={logo} />
147
+ </div>}
148
+
129
149
  {navigation && <div className="mr-2 hidden lg:block">
130
150
  <Link
131
151
  className="visited:text-inherit visited:dark:text-inherit block"
132
152
  to={navigation?.basePath ?? "/"}
133
153
  >
134
- <div className={"flex flex-row gap-4"}>
135
- {!hasDrawer && (logo
136
- ? <img src={logo}
137
- alt="Logo"
138
- className={cls("w-[32px] h-[32px] object-contain")} />
139
- : <FireCMSLogo width={"32px"} height={"32px"} />)}
140
-
141
- {typeof title === "string"
142
- ? <Typography variant="subtitle1"
143
- noWrap>
144
- {title}
145
- </Typography>
146
- : title}
147
- </div>
154
+ {typeof title === "string"
155
+ ? <Typography variant="subtitle1"
156
+ noWrap>
157
+ {title}
158
+ </Typography>
159
+ : title}
148
160
  </Link>
149
161
  </div>}
150
162
 
@@ -12,6 +12,7 @@ import {
12
12
 
13
13
  import { CircularProgressCenter, EntityCollectionView, EntityView, ErrorBoundary } from "../components";
14
14
  import {
15
+ buildSubcollectionPathSegments,
15
16
  canEditEntity,
16
17
  encodeEntityId,
17
18
  removeInitialAndTrailingSlashes,
@@ -28,7 +29,7 @@ import {
28
29
  useLargeLayout
29
30
  } from "../hooks";
30
31
  import { CircularProgress, cls, CodeIcon, defaultBorderMixin, Tab, Tabs, Typography, Menu, MenuItem, ExpandMoreIcon } from "@firecms/ui";
31
- import { getEntityFromMemoryCache } from "../util/entity_cache";
32
+ import { entityCacheKey, getEntityFromMemoryCache } from "../util/entity_cache";
32
33
  import { EntityForm, EntityFormProps } from "../form";
33
34
  import { EntityEditViewFormActions } from "./EntityEditViewFormActions";
34
35
  import { EntityJsonPreview } from "../components/EntityJsonPreview";
@@ -42,6 +43,8 @@ export type OnUpdateParams = {
42
43
  entity: Entity<any>,
43
44
  status: EntityStatus,
44
45
  path: string,
46
+ /** `path` split at its real segment boundaries, when known. Never derived from `path`. */
47
+ pathSegments?: string[],
45
48
  entityId?: string;
46
49
  selectedTab?: string;
47
50
  collection: EntityCollection<any>
@@ -71,6 +74,12 @@ export interface EntityEditViewProps<M extends Record<string, any>> {
71
74
  * The navigation path to the entity.
72
75
  */
73
76
  fullIdPath?: string;
77
+ /**
78
+ * `path` split at its real segment boundaries, e.g. `["nodes", "node/42", "edges"]`.
79
+ * Forwarded to the datasource, and extended for any subcollection rendered inside this
80
+ * view, so a parent entity id containing "/" stays unambiguous.
81
+ */
82
+ pathSegments?: string[];
74
83
  collection: EntityCollection<M>;
75
84
  entityId?: string;
76
85
  databaseId?: string;
@@ -101,6 +110,7 @@ export function EntityEditView<M extends Record<string, any>, USER extends User>
101
110
  dataLoadingError
102
111
  } = useEntityFetch<M, USER>({
103
112
  path: props.path,
113
+ pathSegments: props.pathSegments,
104
114
  entityId: entityId,
105
115
  collection: props.collection,
106
116
  databaseId: props.databaseId,
@@ -108,7 +118,7 @@ export function EntityEditView<M extends Record<string, any>, USER extends User>
108
118
  });
109
119
 
110
120
  const initialDirtyValues = entityId
111
- ? getEntityFromMemoryCache(props.path + "/" + entityId)
121
+ ? getEntityFromMemoryCache(entityCacheKey(props.path, entityId))
112
122
  : getEntityFromMemoryCache(props.path + "#new");
113
123
 
114
124
  const authController = useAuthController();
@@ -120,7 +130,7 @@ export function EntityEditView<M extends Record<string, any>, USER extends User>
120
130
  if (status === "new" || status === "copy") {
121
131
  return true;
122
132
  } else {
123
- return entity ? canEditEntity(props.collection, authController, props.path, entity ?? null) : undefined;
133
+ return entity ? canEditEntity(props.collection, authController, props.path, entity ?? null, props.pathSegments) : undefined;
124
134
  }
125
135
  }, [authController, entity, status]);
126
136
 
@@ -146,6 +156,7 @@ export function EntityEditView<M extends Record<string, any>, USER extends User>
146
156
  export function EntityEditViewInner<M extends Record<string, any>>({
147
157
  path,
148
158
  fullIdPath,
159
+ pathSegments,
149
160
  entityId,
150
161
  selectedTab: selectedTabProp,
151
162
  collection,
@@ -194,6 +205,7 @@ export function EntityEditViewInner<M extends Record<string, any>>({
194
205
  entityId,
195
206
  parentCollectionIds,
196
207
  path,
208
+ pathSegments,
197
209
  status,
198
210
  collection,
199
211
  context,
@@ -351,6 +363,9 @@ export function EntityEditViewInner<M extends Record<string, any>>({
351
363
  const newFullPath = usedEntity ? `${path}/${usedEntity?.id}/${removeInitialAndTrailingSlashes(subcollection.path)}` : undefined;
352
364
  // `newFullIdPath` is fed to buildUrlCollectionPath downstream, so it stays escaped.
353
365
  const newFullIdPath = fullIdPath && usedEntity ? `${fullIdPath}/${encodeEntityId(usedEntity.id)}/${removeInitialAndTrailingSlashes(subcollectionId)}` : undefined;
366
+ // Only extendable when we were given real segments — deriving the parent chain
367
+ // from the flattened `path` would drop the boundary of a slash-bearing parent id.
368
+ const newPathSegments = buildSubcollectionPathSegments(pathSegments, usedEntity?.id, subcollection.path);
354
369
 
355
370
  if (selectedTab !== subcollectionId) return null;
356
371
  return (
@@ -365,6 +380,7 @@ export function EntityEditViewInner<M extends Record<string, any>>({
365
380
  (usedEntity && newFullPath
366
381
  ? <EntityCollectionView
367
382
  fullPath={newFullPath}
383
+ pathSegments={newPathSegments}
368
384
  fullIdPath={newFullIdPath}
369
385
  parentCollectionIds={[...parentCollectionIds, collection.id]}
370
386
  isSubCollection={true}
@@ -414,6 +430,7 @@ export function EntityEditViewInner<M extends Record<string, any>>({
414
430
  fullIdPath={fullIdPath}
415
431
  collection={collection}
416
432
  path={path}
433
+ pathSegments={pathSegments}
417
434
  entityId={entityId ?? usedEntity?.id}
418
435
  onValuesModified={onValuesModified}
419
436
  entity={entity}
@@ -39,6 +39,7 @@ import { ErrorTooltip } from "../components/ErrorTooltip";
39
39
  export function EntityEditViewFormActions({
40
40
  collection,
41
41
  path,
42
+ pathSegments,
42
43
  entity,
43
44
  layout,
44
45
  savingError,
@@ -63,8 +64,8 @@ export function EntityEditViewFormActions({
63
64
  const customEntityActions = (collection.entityActions ?? [])
64
65
  .map(action => resolveEntityAction(action, customizationController.entityActions))
65
66
  .filter(Boolean) as EntityAction[];
66
- const createEnabled = canCreateEntity(collection, authController, path, null);
67
- const deleteEnabled = entity ? canDeleteEntity(collection, authController, path, entity) : false;
67
+ const createEnabled = canCreateEntity(collection, authController, path, null, pathSegments);
68
+ const deleteEnabled = entity ? canDeleteEntity(collection, authController, path, entity, entity.pathSegments ?? pathSegments) : false;
68
69
  const actions: EntityAction[] = [];
69
70
  if (createEnabled)
70
71
  actions.push(copyEntityAction);
@@ -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
  }}