@firecms/core 3.4.0-canary.0ef7442 → 3.4.0-canary.1faa2a9

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 (152) hide show
  1. package/README.md +4 -0
  2. package/dist/components/EntityCollectionView/EntityCollectionView.d.ts +3 -2
  3. package/dist/components/EntityCollectionView/EntityCollectionViewActions.d.ts +3 -1
  4. package/dist/components/EntityCollectionView/EntityCollectionViewStartActions.d.ts +3 -1
  5. package/dist/components/ErrorBoundary.d.ts +1 -3
  6. package/dist/components/LicenseBanner.d.ts +23 -0
  7. package/dist/components/common/useDataSourceTableController.d.ts +4 -2
  8. package/dist/components/common/useTableSearchHelper.d.ts +3 -1
  9. package/dist/components/index.d.ts +1 -0
  10. package/dist/contexts/LicenseStatusContext.d.ts +16 -0
  11. package/dist/core/EntityEditView.d.ts +2 -0
  12. package/dist/core/EntityEditViewFormActions.d.ts +1 -1
  13. package/dist/core/index.d.ts +1 -0
  14. package/dist/core/plugin_translations.d.ts +38 -0
  15. package/dist/core/pro_plugins.d.ts +40 -0
  16. package/dist/de-KtBROUvS.js +700 -0
  17. package/dist/de-KtBROUvS.js.map +1 -0
  18. package/dist/editor/extensions/Image.d.ts +11 -0
  19. package/dist/editor/index.d.ts +2 -1
  20. package/dist/editor/lazy_editor.d.ts +6 -0
  21. package/dist/editor-BSo_aS1I.js +4563 -0
  22. package/dist/editor-BSo_aS1I.js.map +1 -0
  23. package/dist/form/EntityForm.d.ts +2 -0
  24. package/dist/form/EntityFormActions.d.ts +2 -0
  25. package/dist/fr-BRFSBaVh.js +700 -0
  26. package/dist/fr-BRFSBaVh.js.map +1 -0
  27. package/dist/hi-DwCkga5X.js +700 -0
  28. package/dist/hi-DwCkga5X.js.map +1 -0
  29. package/dist/hooks/data/delete.d.ts +1 -1
  30. package/dist/hooks/data/useCollectionFetch.d.ts +1 -1
  31. package/dist/hooks/data/useEntityFetch.d.ts +4 -1
  32. package/dist/hooks/index.d.ts +2 -0
  33. package/dist/hooks/useLicenseStatus.d.ts +16 -0
  34. package/dist/hooks/useProjectLog.d.ts +11 -6
  35. package/dist/hooks/useTranslation.d.ts +1 -1
  36. package/dist/i18n/__tests__/FireCMSi18nProvider.test.d.ts +1 -0
  37. package/dist/i18n/__tests__/nested_provider_lifecycle.test.d.ts +1 -0
  38. package/dist/index.es.js +3503 -11244
  39. package/dist/index.es.js.map +1 -1
  40. package/dist/index.umd.js +8028 -7006
  41. package/dist/index.umd.js.map +1 -1
  42. package/dist/it-B2YGgDsx.js +700 -0
  43. package/dist/it-B2YGgDsx.js.map +1 -0
  44. package/dist/pl-BxHkywK8.js +702 -0
  45. package/dist/pl-BxHkywK8.js.map +1 -0
  46. package/dist/pt-q6UV4diT.js +702 -0
  47. package/dist/pt-q6UV4diT.js.map +1 -0
  48. package/dist/types/analytics.d.ts +1 -1
  49. package/dist/types/collections.d.ts +7 -0
  50. package/dist/types/datasource.d.ts +8 -0
  51. package/dist/types/entity_actions.d.ts +6 -0
  52. package/dist/types/entity_callbacks.d.ts +21 -0
  53. package/dist/types/firecms.d.ts +18 -1
  54. package/dist/types/firecms_context.d.ts +8 -0
  55. package/dist/types/index.d.ts +1 -0
  56. package/dist/types/license.d.ts +54 -0
  57. package/dist/types/navigation.d.ts +34 -5
  58. package/dist/types/plugins.d.ts +22 -1
  59. package/dist/types/side_entity_controller.d.ts +3 -2
  60. package/dist/types/translations.d.ts +31 -2
  61. package/dist/util/date_fns_locales.d.ts +18 -0
  62. package/dist/util/entity_cache.d.ts +11 -0
  63. package/dist/util/index.d.ts +1 -0
  64. package/dist/util/lazy_eager.d.ts +3 -3
  65. package/dist/util/navigation_utils.d.ts +84 -2
  66. package/dist/util/parent_references_from_path.d.ts +15 -0
  67. package/dist/util/paths.d.ts +28 -0
  68. package/dist/util/permissions.d.ts +10 -4
  69. package/package.json +3 -3
  70. package/src/app/Scaffold.tsx +7 -0
  71. package/src/components/DeleteEntityDialog.tsx +2 -0
  72. package/src/components/EntityCollectionTable/EntityCollectionRowActions.tsx +2 -2
  73. package/src/components/EntityCollectionView/EntityCollectionBoardView.tsx +8 -0
  74. package/src/components/EntityCollectionView/EntityCollectionView.tsx +40 -15
  75. package/src/components/EntityCollectionView/EntityCollectionViewActions.tsx +6 -2
  76. package/src/components/EntityCollectionView/EntityCollectionViewStartActions.tsx +4 -0
  77. package/src/components/EntityCollectionView/useBoardDataController.tsx +7 -2
  78. package/src/components/EntityPreview.tsx +4 -1
  79. package/src/components/ErrorBoundary.tsx +12 -18
  80. package/src/components/LicenseBanner.tsx +209 -0
  81. package/src/components/ReferenceTable/ReferenceSelectionTable.tsx +8 -2
  82. package/src/components/common/default_entity_actions.tsx +22 -2
  83. package/src/components/common/useDataSourceTableController.tsx +26 -8
  84. package/src/components/common/useTableSearchHelper.ts +5 -0
  85. package/src/components/index.tsx +1 -0
  86. package/src/contexts/LicenseStatusContext.tsx +50 -0
  87. package/src/core/DefaultAppBar.tsx +28 -16
  88. package/src/core/EntityEditView.tsx +8 -12
  89. package/src/core/EntityEditViewFormActions.tsx +3 -2
  90. package/src/core/EntitySidePanel.tsx +6 -4
  91. package/src/core/FireCMS.tsx +73 -47
  92. package/src/core/SideDialogs.tsx +44 -1
  93. package/src/core/index.tsx +1 -0
  94. package/src/core/plugin_translations.ts +87 -0
  95. package/src/core/pro_plugins.ts +79 -0
  96. package/src/editor/components/SlashCommandMenu.tsx +4 -1
  97. package/src/editor/extensions/Image.ts +17 -1
  98. package/src/editor/hooks/useProseMirror.ts +5 -0
  99. package/src/editor/index.ts +11 -1
  100. package/src/editor/lazy_editor.tsx +33 -0
  101. package/src/form/EntityForm.tsx +22 -7
  102. package/src/form/EntityFormActions.tsx +2 -0
  103. package/src/hooks/data/delete.ts +8 -2
  104. package/src/hooks/data/save.ts +4 -1
  105. package/src/hooks/data/useCollectionFetch.tsx +9 -2
  106. package/src/hooks/data/useEntityFetch.tsx +19 -6
  107. package/src/hooks/index.tsx +2 -0
  108. package/src/hooks/useBuildNavigationController.tsx +70 -15
  109. package/src/hooks/useFireCMSContext.tsx +7 -3
  110. package/src/hooks/useLicenseStatus.tsx +32 -0
  111. package/src/hooks/useProjectLog.tsx +27 -9
  112. package/src/hooks/useResolvedNavigationFrom.tsx +1 -1
  113. package/src/hooks/useTranslation.ts +3 -1
  114. package/src/i18n/FireCMSi18nProvider.tsx +199 -25
  115. package/src/i18n/__tests__/FireCMSi18nProvider.test.tsx +91 -0
  116. package/src/i18n/__tests__/nested_provider_lifecycle.test.tsx +67 -0
  117. package/src/internal/useBuildDataSource.ts +13 -12
  118. package/src/internal/useBuildSideEntityController.tsx +13 -5
  119. package/src/locales/de.ts +16 -2
  120. package/src/locales/en.ts +18 -2
  121. package/src/locales/es.ts +16 -2
  122. package/src/locales/fr.ts +16 -2
  123. package/src/locales/hi.ts +16 -2
  124. package/src/locales/it.ts +16 -2
  125. package/src/locales/pl.ts +16 -2
  126. package/src/locales/pt.ts +16 -2
  127. package/src/preview/components/DatePreview.tsx +2 -4
  128. package/src/preview/components/ReferencePreview.tsx +7 -3
  129. package/src/routes/FireCMSRoute.tsx +9 -4
  130. package/src/types/analytics.ts +3 -0
  131. package/src/types/collections.ts +8 -0
  132. package/src/types/datasource.ts +8 -0
  133. package/src/types/entity_actions.tsx +6 -0
  134. package/src/types/entity_callbacks.ts +24 -0
  135. package/src/types/firecms.tsx +19 -1
  136. package/src/types/firecms_context.tsx +9 -0
  137. package/src/types/index.ts +1 -0
  138. package/src/types/license.ts +62 -0
  139. package/src/types/navigation.ts +35 -5
  140. package/src/types/plugins.tsx +23 -1
  141. package/src/types/side_entity_controller.tsx +3 -2
  142. package/src/types/translations.ts +31 -2
  143. package/src/util/date_fns_locales.ts +168 -0
  144. package/src/util/entity_cache.ts +18 -0
  145. package/src/util/index.ts +1 -0
  146. package/src/util/lazy_eager.tsx +30 -8
  147. package/src/util/navigation_from_path.ts +6 -1
  148. package/src/util/navigation_utils.ts +204 -2
  149. package/src/util/parent_references_from_path.ts +32 -1
  150. package/src/util/paths.ts +40 -3
  151. package/src/util/permissions.ts +22 -10
  152. package/src/util/useStorageUploadController.tsx +6 -1
@@ -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>
@@ -276,6 +279,7 @@ export function EntityForm<M extends Record<string, any>>({
276
279
  entityId: entityIdProp,
277
280
  collection,
278
281
  path,
282
+ pathSegments,
279
283
  values: valuesToBeSaved
280
284
  });
281
285
  }, false, 2000);
@@ -314,7 +318,7 @@ export function EntityForm<M extends Record<string, any>>({
314
318
  const baseInitialValues = useMemo(() => getInitialEntityValues(authController, collection, path, status, entity, customizationController.propertyConfigs), [authController, collection, path, status, entity, customizationController.propertyConfigs]);
315
319
 
316
320
  const localChangesDataRaw = useMemo(() => entityId
317
- ? getEntityFromCache(path + "/" + entityId)
321
+ ? getEntityFromCache(entityCacheKey(path, entityId))
318
322
  : getEntityFromCache(path + "#new"), [entityId, path]);
319
323
 
320
324
  const [localChangesCleared, setLocalChangesCleared] = useState<boolean>(false);
@@ -393,7 +397,7 @@ export function EntityForm<M extends Record<string, any>>({
393
397
  onValuesModified?.(false, initialValues as M);
394
398
  },
395
399
  onValuesChangeDeferred: (values: M, controller: FormexController<M>) => {
396
- const key = (status === "new" || status === "copy") ? path + "#new" : path + "/" + entityId;
400
+ const key = (status === "new" || status === "copy") ? path + "#new" : entityCacheKey(path, entityId);
397
401
  if (controller.dirty && localChangesBackup !== false) {
398
402
  const touchedValues = removeEmptyContainers(extractTouchedValues(values, controller.touched));
399
403
  if (touchedValues && Object.keys(touchedValues).length > 0) {
@@ -474,8 +478,8 @@ export function EntityForm<M extends Record<string, any>>({
474
478
  removeEntityFromMemoryCache(path + "#new");
475
479
  removeEntityFromCache(path + "#new");
476
480
  } else {
477
- removeEntityFromMemoryCache(path + "/" + entityId);
478
- removeEntityFromCache(path + "/" + entityId);
481
+ removeEntityFromMemoryCache(entityCacheKey(path, entityId));
482
+ removeEntityFromCache(entityCacheKey(path, entityId));
479
483
  }
480
484
  }
481
485
 
@@ -497,6 +501,7 @@ export function EntityForm<M extends Record<string, any>>({
497
501
  entity: updatedEntity,
498
502
  status,
499
503
  path,
504
+ pathSegments,
500
505
  entityId: updatedEntity.id,
501
506
  collection
502
507
  });
@@ -517,16 +522,20 @@ export function EntityForm<M extends Record<string, any>>({
517
522
  previousValues,
518
523
  entityId,
519
524
  collection,
520
- path
525
+ path,
526
+ pathSegments
521
527
  }: {
522
528
  collection: EntityCollection<M>,
523
529
  path: string,
530
+ /** `path` split at its real segment boundaries, travelling with the `path` above. */
531
+ pathSegments?: string[],
524
532
  entityId: string | undefined,
525
533
  values: M,
526
534
  previousValues?: M,
527
535
  }) => {
528
536
  return saveEntityWithCallbacks({
529
537
  path,
538
+ pathSegments,
530
539
  entityId,
531
540
  values,
532
541
  previousValues,
@@ -544,6 +553,7 @@ export function EntityForm<M extends Record<string, any>>({
544
553
  type EntityFormSaveParams<M extends Record<string, any>> = {
545
554
  collection: ResolvedEntityCollection<M>,
546
555
  path: string,
556
+ pathSegments?: string[],
547
557
  entityId: string | undefined,
548
558
  values: EntityValues<M>,
549
559
  previousValues?: EntityValues<M>,
@@ -553,6 +563,7 @@ export function EntityForm<M extends Record<string, any>>({
553
563
  const onSaveEntityRequest = async ({
554
564
  collection,
555
565
  path,
566
+ pathSegments,
556
567
  entityId,
557
568
  values,
558
569
  previousValues,
@@ -566,6 +577,7 @@ export function EntityForm<M extends Record<string, any>>({
566
577
  return saveEntity({
567
578
  collection,
568
579
  path,
580
+ pathSegments,
569
581
  entityId,
570
582
  values,
571
583
  previousValues
@@ -579,6 +591,7 @@ export function EntityForm<M extends Record<string, any>>({
579
591
  return onSaveEntityRequest({
580
592
  collection: resolvedCollection,
581
593
  path,
594
+ pathSegments,
582
595
  entityId,
583
596
  values,
584
597
  previousValues: entity?.values,
@@ -629,13 +642,14 @@ export function EntityForm<M extends Record<string, any>>({
629
642
  const plugins = customizationController.plugins;
630
643
 
631
644
  const actionsDisabled = disabled || formex.isSubmitting || (status === "existing" && !formex.dirty) || Boolean(disabledProp);
632
- const parentCollectionIds = navigationController.getParentCollectionIds(path);
645
+ const parentCollectionIds = navigationController.getParentCollectionIds(path, pathSegments);
633
646
 
634
647
  if (plugins && collection) {
635
648
  const actionProps: PluginFormActionProps = {
636
649
  entityId,
637
650
  parentCollectionIds,
638
651
  path,
652
+ pathSegments,
639
653
  status,
640
654
  collection,
641
655
  context,
@@ -896,6 +910,7 @@ export function EntityForm<M extends Record<string, any>>({
896
910
  const dialogActions = <EntityFormActionsComponent
897
911
  collection={resolvedCollection}
898
912
  path={path}
913
+ pathSegments={pathSegments}
899
914
  fullPath={path}
900
915
  fullIdPath={fullIdPath}
901
916
  entity={entity}
@@ -930,7 +945,7 @@ export function EntityForm<M extends Record<string, any>>({
930
945
 
931
946
  {manualApplyLocalChanges && hasLocalChanges &&
932
947
  <LocalChangesMenu
933
- cacheKey={status === "new" || status === "copy" ? path + "#new" : path + "/" + entityId}
948
+ cacheKey={status === "new" || status === "copy" ? path + "#new" : entityCacheKey(path, entityId)}
934
949
  properties={resolvedCollection.properties}
935
950
  cachedData={localChangesDataRaw as Partial<M>}
936
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,8 +88,7 @@ export async function deleteEntityWithCallbacks<M extends Record<string, any>, U
81
88
  }
82
89
  }
83
90
  return dataSource.deleteEntity({
84
- // Carried by the entity from the fetch that produced it; undefined if unknown.
85
- pathSegments: entity.pathSegments,
91
+ pathSegments: resolvedPathSegments,
86
92
  entity,
87
93
  collection
88
94
  }).then(() => {
@@ -68,7 +68,7 @@ export async function saveEntityWithCallbacks<M extends Record<string, any>, USE
68
68
 
69
69
  const customizationController = context.customizationController;
70
70
 
71
- const resolvedPath = context.navigation.resolveIdsFrom(path);
71
+ const resolvedPath = context.navigation.resolveIdsFrom(path, pathSegments);
72
72
 
73
73
  const callbacks = collection.callbacks;
74
74
  if (callbacks?.onPreSave) {
@@ -84,6 +84,7 @@ export async function saveEntityWithCallbacks<M extends Record<string, any>, USE
84
84
  updatedValues = await callbacks.onPreSave({
85
85
  collection: resolvedCollection,
86
86
  path,
87
+ pathSegments,
87
88
  resolvedPath,
88
89
  entityId,
89
90
  values,
@@ -123,6 +124,7 @@ export async function saveEntityWithCallbacks<M extends Record<string, any>, USE
123
124
  callbacks.onSaveSuccess({
124
125
  collection: resolvedCollection,
125
126
  path,
127
+ pathSegments,
126
128
  resolvedPath,
127
129
  entityId: entity.id,
128
130
  values: updatedValues,
@@ -152,6 +154,7 @@ export async function saveEntityWithCallbacks<M extends Record<string, any>, USE
152
154
  callbacks.onSaveFailure({
153
155
  collection: resolvedCollection,
154
156
  path,
157
+ pathSegments,
155
158
  resolvedPath,
156
159
  entityId,
157
160
  values: updatedValues,
@@ -69,7 +69,7 @@ export interface CollectionFetchResult<M extends Record<string, any>> {
69
69
  export function useCollectionFetch<M extends Record<string, any>, USER extends User>(
70
70
  {
71
71
  path: inputPath,
72
- pathSegments,
72
+ pathSegments: inputPathSegments,
73
73
  collection,
74
74
  filterValues,
75
75
  sortBy,
@@ -80,7 +80,13 @@ export function useCollectionFetch<M extends Record<string, any>, USER extends U
80
80
  const dataSource = useDataSource(collection);
81
81
  const navigationController = useNavigationController();
82
82
 
83
- 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);
84
90
 
85
91
  const sortByProperty = sortBy ? sortBy[0] : undefined;
86
92
  const currentSort = sortBy ? sortBy[1] : undefined;
@@ -109,6 +115,7 @@ export function useCollectionFetch<M extends Record<string, any>, USER extends U
109
115
  collection.callbacks!.onFetch!({
110
116
  collection,
111
117
  path,
118
+ pathSegments,
112
119
  entity,
113
120
  context
114
121
  })));
@@ -3,6 +3,7 @@ import { Entity, EntityCollection, FireCMSContext, User } from "../../types";
3
3
  import { useDataSource } from "./useDataSource";
4
4
  import { useNavigationController } from "../useNavigationController";
5
5
  import { useFireCMSContext } from "../useFireCMSContext";
6
+ import { entityCacheKey } from "../../util/entity_cache";
6
7
 
7
8
  /**
8
9
  * @group Hooks and utilities
@@ -11,7 +12,10 @@ export interface EntityFetchProps<M extends Record<string, any>, USER extends Us
11
12
  path: string;
12
13
  /**
13
14
  * `path` split at its real segment boundaries. Forwarded to the datasource so a parent
14
- * entity id containing "/" stays unambiguous. Defaults to splitting the resolved path.
15
+ * entity id containing "/" stays unambiguous.
16
+ *
17
+ * Optional, and never derived by splitting `path` — a guess would be wrong in exactly
18
+ * the case the field exists for. Absent means "not known here", not "no slashes".
15
19
  */
16
20
  pathSegments?: string[];
17
21
  entityId?: string;
@@ -54,11 +58,19 @@ export function useEntityFetch<M extends Record<string, any>, USER extends User>
54
58
  const dataSource = useDataSource(collection);
55
59
  const navigationController = useNavigationController();
56
60
 
57
- const path = navigationController.resolveIdsFrom(inputPath);
58
61
  // Never fabricate segments. If the caller did not thread them, pass undefined so a
59
62
  // delegate can tell "not provided" from a real answer — splitting `path` here would
60
63
  // produce a confidently wrong array for any id containing "/".
61
- const pathSegments = inputPathSegments;
64
+ //
65
+ // When they were threaded, both representations are resolved from them together, so
66
+ // `path` cannot end up describing a different chain than `pathSegments`.
67
+ // `resolveSegmentsFrom` is optional on the controller: a host app may be supplying one
68
+ // built against an earlier version. Falling back to the segments as given is what
69
+ // happened before it existed — they are used unresolved, never invented.
70
+ const pathSegments = inputPathSegments
71
+ ? (navigationController.resolveSegmentsFrom?.(inputPathSegments) ?? inputPathSegments)
72
+ : undefined;
73
+ const path = navigationController.resolveIdsFrom(inputPath, inputPathSegments);
62
74
 
63
75
  const context: FireCMSContext<USER> = useFireCMSContext();
64
76
 
@@ -82,6 +94,7 @@ export function useEntityFetch<M extends Record<string, any>, USER extends User>
82
94
  updatedEntity = await collection.callbacks.onFetch({
83
95
  collection,
84
96
  path,
97
+ pathSegments,
85
98
  entity: updatedEntity,
86
99
  context
87
100
  });
@@ -89,7 +102,7 @@ export function useEntityFetch<M extends Record<string, any>, USER extends User>
89
102
  console.error(e);
90
103
  }
91
104
  }
92
- CACHE[`${path}/${entityId}`] = updatedEntity;
105
+ CACHE[entityCacheKey(path, entityId)] = updatedEntity;
93
106
  setEntity(updatedEntity);
94
107
  setDataLoading(false);
95
108
  setDataLoadingError(undefined);
@@ -102,8 +115,8 @@ export function useEntityFetch<M extends Record<string, any>, USER extends User>
102
115
  setDataLoadingError(error);
103
116
  };
104
117
 
105
- if (entityId && useCache && CACHE[`${path}/${entityId}`]) {
106
- setEntity(CACHE[`${path}/${entityId}`]);
118
+ if (entityId && useCache && CACHE[entityCacheKey(path, entityId)]) {
119
+ setEntity(CACHE[entityCacheKey(path, entityId)]);
107
120
  setDataLoading(false);
108
121
  setDataLoadingError(undefined);
109
122
  // eslint-disable-next-line @typescript-eslint/no-empty-function
@@ -14,6 +14,7 @@ export * from "./useDialogsController";
14
14
  export * from "./useSideDialogsController";
15
15
  export * from "./useSideEntityController";
16
16
  export * from "./useFireCMSContext";
17
+ export * from "./useAnalyticsController";
17
18
  export * from "./useSnackbarController";
18
19
  export * from "./useModeController";
19
20
  export * from "./useClipboard";
@@ -32,3 +33,4 @@ export * from "./useBuildModeController";
32
33
 
33
34
  export * from "./useValidateAuthenticator";
34
35
  export * from "./useTranslation";
36
+ export * from "./useLicenseStatus";
@@ -1,6 +1,8 @@
1
- import { useCallback, useEffect, useRef, useState } from "react";
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import equal from "react-fast-compare"
3
3
  import { useBlocker, useNavigate } from "react-router-dom";
4
+ import { filterPausedPlugins, isProPaused } from "../core/pro_plugins";
5
+ import { useLicenseStatus } from "./useLicenseStatus";
4
6
 
5
7
  import {
6
8
  AuthController,
@@ -22,11 +24,14 @@ import {
22
24
  } from "../types";
23
25
  import {
24
26
  applyPermissionsFunctionIfEmpty,
27
+ collectionSegmentsFrom,
28
+ fullPathToCollectionSegments,
25
29
  getCollectionByPathOrId,
26
30
  mergeDeep,
27
31
  removeFunctions,
28
32
  removeInitialAndTrailingSlashes,
29
33
  resolveCollectionPathIds,
34
+ resolveCollectionPathSegments,
30
35
  resolvePermissions
31
36
  } from "../util";
32
37
  import { getParentReferencesFromPath } from "../util/parent_references_from_path";
@@ -112,7 +117,7 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
112
117
  views: viewsProp,
113
118
  adminViews: adminViewsProp,
114
119
  viewsOrder,
115
- plugins,
120
+ plugins: allPlugins,
116
121
  userConfigPersistence,
117
122
  dataSourceDelegate,
118
123
  disabled,
@@ -121,6 +126,27 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
121
126
 
122
127
  const navigate = useNavigate();
123
128
 
129
+ // While PRO is paused, PRO plugins contribute no collections, collection
130
+ // modifications, views or navigation entries. This hook runs above
131
+ // `FireCMS`, so the status arrives through the store `FireCMS` publishes to,
132
+ // and its arrival re-renders this hook, which resolves navigation again.
133
+ const licenseStatus = useLicenseStatus();
134
+ const proPaused = isProPaused(licenseStatus);
135
+ const plugins = useMemo(
136
+ () => filterPausedPlugins(allPlugins, licenseStatus),
137
+ [allPlugins, licenseStatus]
138
+ );
139
+
140
+ // Navigation resolves asynchronously, so a resolution started before the
141
+ // pause changed can finish after one started since. This ref holds the
142
+ // current pause so the older one is dropped instead of restoring what the
143
+ // paused plugins contributed. Declared before the effect that refreshes.
144
+ const proPausedRef = useRef(proPaused);
145
+ useEffect(() => {
146
+ proPausedRef.current = proPaused;
147
+ }, [proPaused]);
148
+ const resolvedProPausedRef = useRef<boolean | undefined>(undefined);
149
+
124
150
  const collectionsRef = useRef<EntityCollection[] | undefined>(undefined);
125
151
  const viewsRef = useRef<CMSView[] | undefined>(undefined);
126
152
  const adminViewsRef = useRef<CMSView[] | undefined>(undefined);
@@ -299,7 +325,7 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
299
325
  groups: uniqueGroups,
300
326
  onNavigationEntriesUpdate: onNavigationEntriesUpdateCallback!,
301
327
  };
302
- }, [navigationGroupMappings, buildCMSUrlPath, buildUrlCollectionPath, pluginGroups]);
328
+ }, [navigationGroupMappings, buildCMSUrlPath, buildUrlCollectionPath, pluginGroups, plugins]);
303
329
 
304
330
  const onNavigationEntriesOrderUpdate = useCallback((entries: NavigationGroupMapping[]) => {
305
331
  if (!plugins) {
@@ -339,6 +365,8 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
339
365
 
340
366
  console.debug("Refreshing navigation");
341
367
 
368
+ const resolvingWhilePaused = proPaused;
369
+
342
370
  try {
343
371
 
344
372
  const [resolvedCollections = [], resolvedViews, resolvedAdminViews = []] = await Promise.all([
@@ -348,10 +376,21 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
348
376
  ]
349
377
  );
350
378
 
379
+ // Superseded: a newer resolution runs with the current pause.
380
+ if (resolvingWhilePaused !== proPausedRef.current)
381
+ return;
382
+
351
383
  const computedTopLevelNav = computeTopNavigation(resolvedCollections, resolvedViews, resolvedAdminViews, viewsOrder, undefined, onNavigationEntriesOrderUpdate);
352
384
 
353
- let shouldUpdateTopLevelNav = false;
354
- if (!areCollectionListsEqual(collectionsRef.current ?? [], resolvedCollections)) {
385
+ // When the pause changes, everything below is replaced, including
386
+ // what the comparisons ignore: they skip functions, and a plugin may
387
+ // have added only collection callbacks, or only regrouped entries.
388
+ const pauseChanged = resolvedProPausedRef.current !== undefined
389
+ && resolvedProPausedRef.current !== resolvingWhilePaused;
390
+ resolvedProPausedRef.current = resolvingWhilePaused;
391
+
392
+ let shouldUpdateTopLevelNav = pauseChanged;
393
+ if (pauseChanged || !areCollectionListsEqual(collectionsRef.current ?? [], resolvedCollections)) {
355
394
  collectionsRef.current = resolvedCollections;
356
395
  console.debug("Collections have changed", resolvedCollections);
357
396
  shouldUpdateTopLevelNav = true;
@@ -360,7 +399,7 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
360
399
  collectionsRef.current = resolvedCollections;
361
400
  shouldUpdateTopLevelNav = true;
362
401
  }
363
- if (!equal(viewsRef.current, resolvedViews)) {
402
+ if (pauseChanged || !equal(viewsRef.current, resolvedViews)) {
364
403
  viewsRef.current = resolvedViews;
365
404
  shouldUpdateTopLevelNav = true;
366
405
  }
@@ -397,6 +436,8 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
397
436
  viewsProp,
398
437
  adminViewsProp,
399
438
  computeTopNavigation,
439
+ plugins,
440
+ proPaused
400
441
  ]);
401
442
 
402
443
  useEffect(() => {
@@ -405,13 +446,14 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
405
446
 
406
447
  const getCollection = useCallback((
407
448
  idOrPath: string,
408
- includeUserOverride = false
449
+ includeUserOverride = false,
450
+ pathSegments?: string[]
409
451
  ): EC | undefined => {
410
452
  const collections = collectionsRef.current;
411
453
  if (!collections)
412
454
  return undefined;
413
455
 
414
- const baseCollection = getCollectionByPathOrId(removeInitialAndTrailingSlashes(idOrPath), collections);
456
+ const baseCollection = getCollectionByPathOrId(removeInitialAndTrailingSlashes(idOrPath), collections, pathSegments);
415
457
 
416
458
  const userOverride = includeUserOverride ? userConfigPersistence?.getCollectionConfig(idOrPath) : undefined;
417
459
  let overriddenCollection = baseCollection;
@@ -505,23 +547,33 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
505
547
  throw Error("Expected path starting with " + fullCollectionPath);
506
548
  }, [fullCollectionPath]);
507
549
 
508
- const resolveIdsFrom = useCallback((path: string): string => {
550
+ const resolveIdsFrom = useCallback((path: string, pathSegments?: string[]): string => {
551
+ const collections = collectionsRef.current ?? [];
552
+ return resolveCollectionPathIds(path, collections, pathSegments);
553
+ }, []);
554
+
555
+ const resolveSegmentsFrom = useCallback((pathSegments: string[]): string[] => {
509
556
  const collections = collectionsRef.current ?? [];
510
- return resolveCollectionPathIds(path, collections);
557
+ return resolveCollectionPathSegments(pathSegments, collections);
511
558
  }, []);
512
559
 
513
- const getAllParentReferencesForPath = useCallback((path: string): EntityReference[] => {
560
+ const getAllParentReferencesForPath = useCallback((path: string, pathSegments?: string[]): EntityReference[] => {
514
561
  const collections = collectionsRef.current ?? [];
515
562
  return getParentReferencesFromPath({
516
563
  path,
564
+ pathSegments,
517
565
  collections
518
566
  });
519
567
  }, []);
520
568
 
521
- const getParentCollectionIds = useCallback((path: string): string[] => {
569
+ const getParentCollectionIds = useCallback((path: string, pathSegments?: string[]): string[] => {
522
570
 
523
- const strings = path.split("/");
524
- const oddPathSegments = strings.filter((_, i) => i % 2 === 0);
571
+ // Positional filtering only picks out the collection segments while no entity id
572
+ // contains "/"; with one, every following segment shifts. Given the real boundaries
573
+ // the same rule applies correctly; without them this is the historical reading.
574
+ const oddPathSegments = pathSegments
575
+ ? collectionSegmentsFrom(pathSegments)
576
+ : fullPathToCollectionSegments(path);
525
577
  oddPathSegments.pop();
526
578
 
527
579
  const result: string[][] = [];
@@ -567,13 +619,16 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
567
619
  urlPathToDataPath,
568
620
  buildUrlCollectionPath,
569
621
  resolveIdsFrom,
622
+ resolveSegmentsFrom,
570
623
  topLevelNavigation,
571
624
  refreshNavigation,
572
625
  getParentReferencesFromPath: getAllParentReferencesForPath,
573
626
  getParentCollectionIds,
574
627
  convertIdsToPaths,
575
628
  navigate,
576
- plugins
629
+ // All of them, paused or not: `FireCMS` sends every key to the license
630
+ // check and applies the same pause to what it renders.
631
+ plugins: allPlugins
577
632
  };
578
633
  }
579
634
 
@@ -12,6 +12,7 @@ import { useCustomizationController } from "./useCustomizationController";
12
12
  import { useAnalyticsController } from "./useAnalyticsController";
13
13
  import React, { useEffect } from "react";
14
14
  import { useInternalUserManagementController } from "./useInternalUserManagementController";
15
+ import { useLicenseStatus } from "./useLicenseStatus";
15
16
 
16
17
  /**
17
18
  * Hook to retrieve the {@link FireCMSContext}.
@@ -36,6 +37,7 @@ export const useFireCMSContext = <USER extends User = User, AuthControllerType e
36
37
  const customizationController = useCustomizationController();
37
38
  const analyticsController = useAnalyticsController();
38
39
  const userManagement = useInternalUserManagementController<USER>();
40
+ const licenseStatus = useLicenseStatus();
39
41
 
40
42
  const fireCMSContextRef = React.useRef<FireCMSContext<USER, AuthControllerType>>({
41
43
  authController,
@@ -49,7 +51,8 @@ export const useFireCMSContext = <USER extends User = User, AuthControllerType e
49
51
  dialogsController,
50
52
  customizationController,
51
53
  analyticsController,
52
- userManagement
54
+ userManagement,
55
+ licenseStatus
53
56
  });
54
57
 
55
58
  useEffect(() => {
@@ -65,9 +68,10 @@ export const useFireCMSContext = <USER extends User = User, AuthControllerType e
65
68
  dialogsController,
66
69
  customizationController,
67
70
  analyticsController,
68
- userManagement
71
+ userManagement,
72
+ licenseStatus
69
73
  };
70
- }, [authController, dialogsController, navigation, sideDialogsController]);
74
+ }, [authController, dialogsController, navigation, sideDialogsController, licenseStatus]);
71
75
 
72
76
  return fireCMSContextRef.current;
73
77
  }
@@ -0,0 +1,32 @@
1
+ import { useContext, useSyncExternalStore } from "react";
2
+ import { AccessResponse } from "../types";
3
+ import {
4
+ getLicenseStatusSnapshot,
5
+ getServerLicenseStatusSnapshot,
6
+ LicenseStatusContext,
7
+ subscribeLicenseStatus
8
+ } from "../contexts/LicenseStatusContext";
9
+
10
+ /**
11
+ * The FireCMS license status of this project: whether PRO runs on a trial, is
12
+ * licensed, or is paused, plus the link to get a license.
13
+ *
14
+ * `null` until the license check answers, and when it is not sent at all
15
+ * (`telemetry={false}` without an `apiKey`). A response from an older server
16
+ * may lack `licenseState`; treat that as unknown. PRO features are paused when
17
+ * `blocked` is true; use `isProPaused(status)` for that check.
18
+ *
19
+ * Works inside `FireCMS` and also above it, e.g. in the component that calls
20
+ * `useBuildNavigationController`.
21
+ *
22
+ * @group Hooks and utilities
23
+ */
24
+ export function useLicenseStatus(): AccessResponse | null {
25
+ const fromContext = useContext(LicenseStatusContext);
26
+ const published = useSyncExternalStore(
27
+ subscribeLicenseStatus,
28
+ getLicenseStatusSnapshot,
29
+ getServerLicenseStatusSnapshot
30
+ );
31
+ return fromContext !== undefined ? fromContext : published;
32
+ }