@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,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";
@@ -48,6 +48,7 @@ import { useAnalyticsController } from "../hooks/useAnalyticsController";
48
48
  import { FormEntry, FormLayout, LabelWithIconAndTooltip, PropertyFieldBinding } from "../form";
49
49
  import { ValidationError } from "yup";
50
50
  import {
51
+ entityCacheKey,
51
52
  flattenKeys,
52
53
  getEntityFromCache,
53
54
  removeEntityFromCache,
@@ -64,6 +65,8 @@ export type OnUpdateParams = {
64
65
  entity: Entity<any>,
65
66
  status: EntityStatus,
66
67
  path: string,
68
+ /** `path` split at its real segment boundaries, when known. Never derived from `path`. */
69
+ pathSegments?: string[],
67
70
  entityId?: string;
68
71
  selectedTab?: string;
69
72
  collection: EntityCollection<any>
@@ -71,6 +74,11 @@ export type OnUpdateParams = {
71
74
 
72
75
  export type EntityFormProps<M extends Record<string, any>> = {
73
76
  path: string;
77
+ /**
78
+ * `path` split at its real segment boundaries; forwarded to the datasource for
79
+ * generateEntityId and checkUniqueField. Never derived from `path`.
80
+ */
81
+ pathSegments?: string[];
74
82
  fullIdPath?: string;
75
83
  collection: EntityCollection<M>;
76
84
  entityId?: string;
@@ -214,6 +222,7 @@ export function getChanges<T extends object>(source: Partial<T>, comparison: Par
214
222
 
215
223
  export function EntityForm<M extends Record<string, any>>({
216
224
  path,
225
+ pathSegments,
217
226
  fullIdPath,
218
227
  entityId: entityIdProp,
219
228
  collection,
@@ -270,6 +279,7 @@ export function EntityForm<M extends Record<string, any>>({
270
279
  entityId: entityIdProp,
271
280
  collection,
272
281
  path,
282
+ pathSegments,
273
283
  values: valuesToBeSaved
274
284
  });
275
285
  }, false, 2000);
@@ -292,7 +302,7 @@ export function EntityForm<M extends Record<string, any>>({
292
302
  if (mustSetCustomId) {
293
303
  return undefined;
294
304
  } else {
295
- return dataSource.generateEntityId(path, collection);
305
+ return dataSource.generateEntityId(path, collection, pathSegments);
296
306
  }
297
307
  } else {
298
308
  return entityIdProp;
@@ -308,7 +318,7 @@ export function EntityForm<M extends Record<string, any>>({
308
318
  const baseInitialValues = useMemo(() => getInitialEntityValues(authController, collection, path, status, entity, customizationController.propertyConfigs), [authController, collection, path, status, entity, customizationController.propertyConfigs]);
309
319
 
310
320
  const localChangesDataRaw = useMemo(() => entityId
311
- ? getEntityFromCache(path + "/" + entityId)
321
+ ? getEntityFromCache(entityCacheKey(path, entityId))
312
322
  : getEntityFromCache(path + "#new"), [entityId, path]);
313
323
 
314
324
  const [localChangesCleared, setLocalChangesCleared] = useState<boolean>(false);
@@ -387,8 +397,8 @@ export function EntityForm<M extends Record<string, any>>({
387
397
  onValuesModified?.(false, initialValues as M);
388
398
  },
389
399
  onValuesChangeDeferred: (values: M, controller: FormexController<M>) => {
390
- const key = (status === "new" || status === "copy") ? path + "#new" : path + "/" + entityId;
391
- if (controller.dirty) {
400
+ const key = (status === "new" || status === "copy") ? path + "#new" : entityCacheKey(path, entityId);
401
+ if (controller.dirty && localChangesBackup !== false) {
392
402
  const touchedValues = removeEmptyContainers(extractTouchedValues(values, controller.touched));
393
403
  if (touchedValues && Object.keys(touchedValues).length > 0) {
394
404
  saveEntityToCache(key, touchedValues);
@@ -468,8 +478,8 @@ export function EntityForm<M extends Record<string, any>>({
468
478
  removeEntityFromMemoryCache(path + "#new");
469
479
  removeEntityFromCache(path + "#new");
470
480
  } else {
471
- removeEntityFromMemoryCache(path + "/" + entityId);
472
- removeEntityFromCache(path + "/" + entityId);
481
+ removeEntityFromMemoryCache(entityCacheKey(path, entityId));
482
+ removeEntityFromCache(entityCacheKey(path, entityId));
473
483
  }
474
484
  }
475
485
 
@@ -491,6 +501,7 @@ export function EntityForm<M extends Record<string, any>>({
491
501
  entity: updatedEntity,
492
502
  status,
493
503
  path,
504
+ pathSegments,
494
505
  entityId: updatedEntity.id,
495
506
  collection
496
507
  });
@@ -511,16 +522,20 @@ export function EntityForm<M extends Record<string, any>>({
511
522
  previousValues,
512
523
  entityId,
513
524
  collection,
514
- path
525
+ path,
526
+ pathSegments
515
527
  }: {
516
528
  collection: EntityCollection<M>,
517
529
  path: string,
530
+ /** `path` split at its real segment boundaries, travelling with the `path` above. */
531
+ pathSegments?: string[],
518
532
  entityId: string | undefined,
519
533
  values: M,
520
534
  previousValues?: M,
521
535
  }) => {
522
536
  return saveEntityWithCallbacks({
523
537
  path,
538
+ pathSegments,
524
539
  entityId,
525
540
  values,
526
541
  previousValues,
@@ -538,6 +553,7 @@ export function EntityForm<M extends Record<string, any>>({
538
553
  type EntityFormSaveParams<M extends Record<string, any>> = {
539
554
  collection: ResolvedEntityCollection<M>,
540
555
  path: string,
556
+ pathSegments?: string[],
541
557
  entityId: string | undefined,
542
558
  values: EntityValues<M>,
543
559
  previousValues?: EntityValues<M>,
@@ -547,6 +563,7 @@ export function EntityForm<M extends Record<string, any>>({
547
563
  const onSaveEntityRequest = async ({
548
564
  collection,
549
565
  path,
566
+ pathSegments,
550
567
  entityId,
551
568
  values,
552
569
  previousValues,
@@ -560,6 +577,7 @@ export function EntityForm<M extends Record<string, any>>({
560
577
  return saveEntity({
561
578
  collection,
562
579
  path,
580
+ pathSegments,
563
581
  entityId,
564
582
  values,
565
583
  previousValues
@@ -573,6 +591,7 @@ export function EntityForm<M extends Record<string, any>>({
573
591
  return onSaveEntityRequest({
574
592
  collection: resolvedCollection,
575
593
  path,
594
+ pathSegments,
576
595
  entityId,
577
596
  values,
578
597
  previousValues: entity?.values,
@@ -623,13 +642,14 @@ export function EntityForm<M extends Record<string, any>>({
623
642
  const plugins = customizationController.plugins;
624
643
 
625
644
  const actionsDisabled = disabled || formex.isSubmitting || (status === "existing" && !formex.dirty) || Boolean(disabledProp);
626
- const parentCollectionIds = navigationController.getParentCollectionIds(path);
645
+ const parentCollectionIds = navigationController.getParentCollectionIds(path, pathSegments);
627
646
 
628
647
  if (plugins && collection) {
629
648
  const actionProps: PluginFormActionProps = {
630
649
  entityId,
631
650
  parentCollectionIds,
632
651
  path,
652
+ pathSegments,
633
653
  status,
634
654
  collection,
635
655
  context,
@@ -692,7 +712,7 @@ export function EntityForm<M extends Record<string, any>>({
692
712
  const uniqueFieldValidator: CustomFieldValidator = useCallback(({
693
713
  name,
694
714
  value
695
- }) => dataSource.checkUniqueField(path, name, value, entityId, collection),
715
+ }) => dataSource.checkUniqueField(path, name, value, entityId, collection, pathSegments),
696
716
  [dataSource, path, entityId]);
697
717
 
698
718
  const validationSchema = useMemo(() => entityId
@@ -890,6 +910,7 @@ export function EntityForm<M extends Record<string, any>>({
890
910
  const dialogActions = <EntityFormActionsComponent
891
911
  collection={resolvedCollection}
892
912
  path={path}
913
+ pathSegments={pathSegments}
893
914
  fullPath={path}
894
915
  fullIdPath={fullIdPath}
895
916
  entity={entity}
@@ -924,7 +945,7 @@ export function EntityForm<M extends Record<string, any>>({
924
945
 
925
946
  {manualApplyLocalChanges && hasLocalChanges &&
926
947
  <LocalChangesMenu
927
- cacheKey={status === "new" || status === "copy" ? path + "#new" : path + "/" + entityId}
948
+ cacheKey={status === "new" || status === "copy" ? path + "#new" : entityCacheKey(path, entityId)}
928
949
  properties={resolvedCollection.properties}
929
950
  cachedData={localChangesDataRaw as Partial<M>}
930
951
  formex={formex}
@@ -26,6 +26,8 @@ export interface EntityFormActionsProps {
26
26
  fullIdPath?: string;
27
27
  collection: ResolvedEntityCollection;
28
28
  path: string;
29
+ /** `path` split at its real segment boundaries, when known. Never derived from `path`. */
30
+ pathSegments?: string[];
29
31
  entity?: Entity;
30
32
  layout: "bottom" | "side";
31
33
  savingError?: Error;
@@ -46,6 +46,7 @@ export type DeleteEntityWithCallbacksProps<M extends Record<string, any>, USER e
46
46
  export async function deleteEntityWithCallbacks<M extends Record<string, any>, USER extends User>({
47
47
  dataSource,
48
48
  entity,
49
+ pathSegments,
49
50
  collection,
50
51
  callbacks,
51
52
  onDeleteSuccess,
@@ -62,11 +63,17 @@ export async function deleteEntityWithCallbacks<M extends Record<string, any>, U
62
63
 
63
64
  console.debug("Deleting entity", entity.path, entity.id);
64
65
 
66
+ // An explicit `pathSegments` wins, then the ones the entity was loaded with. The prop
67
+ // used to be accepted and silently ignored, which is the same class of bug this field
68
+ // exists to prevent.
69
+ const resolvedPathSegments = pathSegments ?? entity.pathSegments;
70
+
65
71
  const entityDeleteProps: EntityOnDeleteProps<M, any> = {
66
72
  entity,
67
73
  collection,
68
74
  entityId: entity.id,
69
75
  path: entity.path,
76
+ pathSegments: resolvedPathSegments,
70
77
  context
71
78
  };
72
79
 
@@ -81,6 +88,7 @@ export async function deleteEntityWithCallbacks<M extends Record<string, any>, U
81
88
  }
82
89
  }
83
90
  return dataSource.deleteEntity({
91
+ pathSegments: resolvedPathSegments,
84
92
  entity,
85
93
  collection
86
94
  }).then(() => {
@@ -43,6 +43,7 @@ export type SaveEntityWithCallbacksProps<M extends Record<string, any>> =
43
43
  export async function saveEntityWithCallbacks<M extends Record<string, any>, USER extends User>({
44
44
  collection,
45
45
  path,
46
+ pathSegments,
46
47
  entityId,
47
48
  values,
48
49
  previousValues,
@@ -67,7 +68,7 @@ export async function saveEntityWithCallbacks<M extends Record<string, any>, USE
67
68
 
68
69
  const customizationController = context.customizationController;
69
70
 
70
- const resolvedPath = context.navigation.resolveIdsFrom(path);
71
+ const resolvedPath = context.navigation.resolveIdsFrom(path, pathSegments);
71
72
 
72
73
  const callbacks = collection.callbacks;
73
74
  if (callbacks?.onPreSave) {
@@ -83,6 +84,7 @@ export async function saveEntityWithCallbacks<M extends Record<string, any>, USE
83
84
  updatedValues = await callbacks.onPreSave({
84
85
  collection: resolvedCollection,
85
86
  path,
87
+ pathSegments,
86
88
  resolvedPath,
87
89
  entityId,
88
90
  values,
@@ -103,6 +105,7 @@ export async function saveEntityWithCallbacks<M extends Record<string, any>, USE
103
105
  return dataSource.saveEntity({
104
106
  collection,
105
107
  path: resolvedPath,
108
+ pathSegments,
106
109
  entityId,
107
110
  values: updatedValues,
108
111
  previousValues,
@@ -121,6 +124,7 @@ export async function saveEntityWithCallbacks<M extends Record<string, any>, USE
121
124
  callbacks.onSaveSuccess({
122
125
  collection: resolvedCollection,
123
126
  path,
127
+ pathSegments,
124
128
  resolvedPath,
125
129
  entityId: entity.id,
126
130
  values: updatedValues,
@@ -150,6 +154,7 @@ export async function saveEntityWithCallbacks<M extends Record<string, any>, USE
150
154
  callbacks.onSaveFailure({
151
155
  collection: resolvedCollection,
152
156
  path,
157
+ pathSegments,
153
158
  resolvedPath,
154
159
  entityId,
155
160
  values: updatedValues,
@@ -13,6 +13,12 @@ export interface CollectionFetchProps<M extends Record<string, any>> {
13
13
  * Absolute collection path
14
14
  */
15
15
  path: string;
16
+ /**
17
+ * `path` split at its real segment boundaries. Forwarded to the datasource so a parent
18
+ * entity id containing "/" stays unambiguous. Never derived from `path` — see
19
+ * `pathSegments` on the datasource props.
20
+ */
21
+ pathSegments?: string[];
16
22
 
17
23
  /**
18
24
  * collection of the entity displayed by this collection
@@ -63,6 +69,7 @@ export interface CollectionFetchResult<M extends Record<string, any>> {
63
69
  export function useCollectionFetch<M extends Record<string, any>, USER extends User>(
64
70
  {
65
71
  path: inputPath,
72
+ pathSegments: inputPathSegments,
66
73
  collection,
67
74
  filterValues,
68
75
  sortBy,
@@ -73,7 +80,13 @@ export function useCollectionFetch<M extends Record<string, any>, USER extends U
73
80
  const dataSource = useDataSource(collection);
74
81
  const navigationController = useNavigationController();
75
82
 
76
- const path = navigationController.resolveIdsFrom(inputPath);
83
+ // Resolved together so the two representations always describe the same chain.
84
+ // Segments are resolved, never derived: only collection ids are swapped for their
85
+ // real paths, and entity ids are carried through whole (see useEntityFetch).
86
+ const pathSegments = inputPathSegments
87
+ ? (navigationController.resolveSegmentsFrom?.(inputPathSegments) ?? inputPathSegments)
88
+ : undefined;
89
+ const path = navigationController.resolveIdsFrom(inputPath, inputPathSegments);
77
90
 
78
91
  const sortByProperty = sortBy ? sortBy[0] : undefined;
79
92
  const currentSort = sortBy ? sortBy[1] : undefined;
@@ -91,6 +104,10 @@ export function useCollectionFetch<M extends Record<string, any>, USER extends U
91
104
  setDataLoading(true);
92
105
 
93
106
  const onEntitiesUpdate = async (entities: Entity<M>[]) => {
107
+ // See useEntityFetch: entities carry the segments they were loaded with.
108
+ if (pathSegments) {
109
+ entities = entities.map(e => e.pathSegments ? e : { ...e, pathSegments });
110
+ }
94
111
  if (collection.callbacks?.onFetch) {
95
112
  try {
96
113
  entities = await Promise.all(
@@ -98,6 +115,7 @@ export function useCollectionFetch<M extends Record<string, any>, USER extends U
98
115
  collection.callbacks!.onFetch!({
99
116
  collection,
100
117
  path,
118
+ pathSegments,
101
119
  entity,
102
120
  context
103
121
  })));
@@ -124,6 +142,7 @@ export function useCollectionFetch<M extends Record<string, any>, USER extends U
124
142
  if (dataSource.listenCollection) {
125
143
  return dataSource.listenCollection<M>({
126
144
  path,
145
+ pathSegments,
127
146
  collection,
128
147
  onUpdate: onEntitiesUpdate,
129
148
  onError,
@@ -137,6 +156,7 @@ export function useCollectionFetch<M extends Record<string, any>, USER extends U
137
156
  } else {
138
157
  dataSource.fetchCollection<M>({
139
158
  path,
159
+ pathSegments,
140
160
  collection,
141
161
  searchString,
142
162
  filter: filterValues,