@firecms/core 3.4.0-canary.63041d0 → 3.4.0-canary.9408c41

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 (160) hide show
  1. package/README.md +4 -0
  2. package/dist/components/EntityCollectionTable/internal/popup_field/PopupFormField.d.ts +3 -1
  3. package/dist/components/EntityCollectionView/EntityCollectionBoardView.d.ts +3 -1
  4. package/dist/components/EntityCollectionView/EntityCollectionView.d.ts +3 -2
  5. package/dist/components/EntityCollectionView/EntityCollectionViewActions.d.ts +3 -1
  6. package/dist/components/EntityCollectionView/EntityCollectionViewStartActions.d.ts +3 -1
  7. package/dist/components/EntityCollectionView/useBoardDataController.d.ts +3 -1
  8. package/dist/components/ErrorBoundary.d.ts +1 -3
  9. package/dist/components/LicenseBanner.d.ts +23 -0
  10. package/dist/components/ReferenceTable/ReferenceSelectionTable.d.ts +10 -1
  11. package/dist/components/common/useDataSourceTableController.d.ts +4 -2
  12. package/dist/components/common/useTableSearchHelper.d.ts +3 -1
  13. package/dist/components/index.d.ts +1 -0
  14. package/dist/contexts/LicenseStatusContext.d.ts +16 -0
  15. package/dist/core/EntityEditView.d.ts +2 -0
  16. package/dist/core/EntityEditViewFormActions.d.ts +1 -1
  17. package/dist/core/index.d.ts +1 -0
  18. package/dist/core/plugin_translations.d.ts +38 -0
  19. package/dist/core/pro_plugins.d.ts +40 -0
  20. package/dist/de-DMp6cIY1.js +702 -0
  21. package/dist/de-DMp6cIY1.js.map +1 -0
  22. package/dist/editor/extensions/Image.d.ts +11 -0
  23. package/dist/editor/index.d.ts +2 -1
  24. package/dist/editor/lazy_editor.d.ts +6 -0
  25. package/dist/editor-BSo_aS1I.js +4563 -0
  26. package/dist/editor-BSo_aS1I.js.map +1 -0
  27. package/dist/form/EntityForm.d.ts +8 -1
  28. package/dist/form/EntityFormActions.d.ts +2 -0
  29. package/dist/fr-Cckz4IkR.js +702 -0
  30. package/dist/fr-Cckz4IkR.js.map +1 -0
  31. package/dist/hi-CDCoIqbu.js +702 -0
  32. package/dist/hi-CDCoIqbu.js.map +1 -0
  33. package/dist/hooks/data/delete.d.ts +1 -1
  34. package/dist/hooks/data/useCollectionFetch.d.ts +7 -1
  35. package/dist/hooks/data/useEntityFetch.d.ts +4 -1
  36. package/dist/hooks/index.d.ts +2 -0
  37. package/dist/hooks/useLicenseStatus.d.ts +16 -0
  38. package/dist/hooks/useProjectLog.d.ts +11 -6
  39. package/dist/hooks/useTranslation.d.ts +1 -1
  40. package/dist/i18n/__tests__/FireCMSi18nProvider.test.d.ts +1 -0
  41. package/dist/i18n/__tests__/nested_provider_lifecycle.test.d.ts +1 -0
  42. package/dist/index.es.js +3113 -10785
  43. package/dist/index.es.js.map +1 -1
  44. package/dist/index.umd.js +7939 -6836
  45. package/dist/index.umd.js.map +1 -1
  46. package/dist/it-BJCUqixj.js +702 -0
  47. package/dist/it-BJCUqixj.js.map +1 -0
  48. package/dist/pl-DgT_joFf.js +704 -0
  49. package/dist/pl-DgT_joFf.js.map +1 -0
  50. package/dist/pt-B3dN3x0X.js +704 -0
  51. package/dist/pt-B3dN3x0X.js.map +1 -0
  52. package/dist/types/analytics.d.ts +1 -1
  53. package/dist/types/collections.d.ts +7 -0
  54. package/dist/types/datasource.d.ts +25 -4
  55. package/dist/types/entities.d.ts +21 -1
  56. package/dist/types/entity_actions.d.ts +6 -0
  57. package/dist/types/entity_callbacks.d.ts +21 -0
  58. package/dist/types/firecms.d.ts +18 -1
  59. package/dist/types/firecms_context.d.ts +8 -0
  60. package/dist/types/index.d.ts +1 -0
  61. package/dist/types/license.d.ts +54 -0
  62. package/dist/types/navigation.d.ts +34 -5
  63. package/dist/types/plugins.d.ts +22 -1
  64. package/dist/types/side_entity_controller.d.ts +3 -2
  65. package/dist/types/translations.d.ts +35 -2
  66. package/dist/util/date_fns_locales.d.ts +18 -0
  67. package/dist/util/entity_cache.d.ts +11 -0
  68. package/dist/util/index.d.ts +1 -0
  69. package/dist/util/lazy_eager.d.ts +3 -3
  70. package/dist/util/navigation_utils.d.ts +84 -2
  71. package/dist/util/parent_references_from_path.d.ts +16 -0
  72. package/dist/util/paths.d.ts +28 -0
  73. package/dist/util/permissions.d.ts +10 -4
  74. package/package.json +5 -6
  75. package/src/app/Scaffold.tsx +7 -0
  76. package/src/components/DeleteEntityDialog.tsx +2 -0
  77. package/src/components/EntityCollectionTable/EntityCollectionRowActions.tsx +2 -2
  78. package/src/components/EntityCollectionTable/internal/popup_field/PopupFormField.tsx +5 -1
  79. package/src/components/EntityCollectionView/EntityCollectionBoardView.tsx +12 -0
  80. package/src/components/EntityCollectionView/EntityCollectionView.tsx +49 -16
  81. package/src/components/EntityCollectionView/EntityCollectionViewActions.tsx +6 -2
  82. package/src/components/EntityCollectionView/EntityCollectionViewStartActions.tsx +4 -0
  83. package/src/components/EntityCollectionView/useBoardDataController.tsx +17 -1
  84. package/src/components/EntityPreview.tsx +4 -1
  85. package/src/components/ErrorBoundary.tsx +12 -18
  86. package/src/components/LicenseBanner.tsx +209 -0
  87. package/src/components/ReferenceTable/ReferenceSelectionTable.tsx +19 -2
  88. package/src/components/common/default_entity_actions.tsx +22 -2
  89. package/src/components/common/useDataSourceTableController.tsx +32 -8
  90. package/src/components/common/useTableSearchHelper.ts +5 -0
  91. package/src/components/index.tsx +1 -0
  92. package/src/contexts/LicenseStatusContext.tsx +50 -0
  93. package/src/core/DefaultAppBar.tsx +28 -16
  94. package/src/core/EntityEditView.tsx +11 -12
  95. package/src/core/EntityEditViewFormActions.tsx +3 -2
  96. package/src/core/EntitySidePanel.tsx +6 -4
  97. package/src/core/FireCMS.tsx +73 -47
  98. package/src/core/SideDialogs.tsx +44 -1
  99. package/src/core/index.tsx +1 -0
  100. package/src/core/plugin_translations.ts +87 -0
  101. package/src/core/pro_plugins.ts +79 -0
  102. package/src/editor/components/SlashCommandMenu.tsx +4 -1
  103. package/src/editor/extensions/Image.ts +17 -1
  104. package/src/editor/hooks/useProseMirror.ts +5 -0
  105. package/src/editor/index.ts +11 -1
  106. package/src/editor/lazy_editor.tsx +33 -0
  107. package/src/form/EntityForm.tsx +30 -9
  108. package/src/form/EntityFormActions.tsx +2 -0
  109. package/src/hooks/data/delete.ts +8 -0
  110. package/src/hooks/data/save.ts +5 -2
  111. package/src/hooks/data/useCollectionFetch.tsx +21 -1
  112. package/src/hooks/data/useEntityFetch.tsx +28 -6
  113. package/src/hooks/index.tsx +2 -0
  114. package/src/hooks/useBuildNavigationController.tsx +70 -15
  115. package/src/hooks/useFireCMSContext.tsx +7 -3
  116. package/src/hooks/useLicenseStatus.tsx +32 -0
  117. package/src/hooks/useProjectLog.tsx +27 -9
  118. package/src/hooks/useResolvedNavigationFrom.tsx +3 -1
  119. package/src/hooks/useTranslation.ts +3 -1
  120. package/src/i18n/FireCMSi18nProvider.tsx +199 -25
  121. package/src/i18n/__tests__/FireCMSi18nProvider.test.tsx +91 -0
  122. package/src/i18n/__tests__/nested_provider_lifecycle.test.tsx +67 -0
  123. package/src/internal/useBuildDataSource.ts +26 -15
  124. package/src/internal/useBuildSideEntityController.tsx +13 -5
  125. package/src/locales/de.ts +18 -2
  126. package/src/locales/en.ts +21 -3
  127. package/src/locales/es.ts +18 -2
  128. package/src/locales/fr.ts +18 -2
  129. package/src/locales/hi.ts +18 -2
  130. package/src/locales/it.ts +18 -2
  131. package/src/locales/pl.ts +18 -2
  132. package/src/locales/pt.ts +18 -2
  133. package/src/preview/components/DatePreview.tsx +2 -4
  134. package/src/preview/components/ReferencePreview.tsx +7 -3
  135. package/src/routes/FireCMSRoute.tsx +9 -4
  136. package/src/types/analytics.ts +3 -0
  137. package/src/types/collections.ts +8 -0
  138. package/src/types/datasource.ts +25 -4
  139. package/src/types/entities.ts +24 -1
  140. package/src/types/entity_actions.tsx +6 -0
  141. package/src/types/entity_callbacks.ts +24 -0
  142. package/src/types/firecms.tsx +19 -1
  143. package/src/types/firecms_context.tsx +9 -0
  144. package/src/types/index.ts +1 -0
  145. package/src/types/license.ts +62 -0
  146. package/src/types/navigation.ts +35 -5
  147. package/src/types/plugins.tsx +23 -1
  148. package/src/types/side_entity_controller.tsx +3 -2
  149. package/src/types/translations.ts +35 -2
  150. package/src/util/date_fns_locales.ts +168 -0
  151. package/src/util/entities.ts +3 -1
  152. package/src/util/entity_cache.ts +18 -0
  153. package/src/util/index.ts +1 -0
  154. package/src/util/lazy_eager.tsx +30 -8
  155. package/src/util/navigation_from_path.ts +6 -1
  156. package/src/util/navigation_utils.ts +210 -3
  157. package/src/util/parent_references_from_path.ts +42 -3
  158. package/src/util/paths.ts +40 -3
  159. package/src/util/permissions.ts +22 -10
  160. package/src/util/useStorageUploadController.tsx +6 -1
@@ -0,0 +1,209 @@
1
+ import React, { useState } from "react";
2
+ import { Alert, Button, CloseIcon, IconButton } from "@firecms/ui";
3
+ import { AccessResponse } from "../types";
4
+ import { getLicenseSubscribeUrl } from "../core/pro_plugins";
5
+ import { useLicenseStatus } from "../hooks/useLicenseStatus";
6
+ import { useTranslation } from "../hooks/useTranslation";
7
+
8
+ const DISMISSED_STORAGE_KEY = "firecms_license_banner_dismissed";
9
+ const DAY_MS = 24 * 60 * 60 * 1000;
10
+
11
+ /**
12
+ * @group Components
13
+ */
14
+ export interface LicenseBannerProps {
15
+ /**
16
+ * Applied to the banner's outer element when there is something to show.
17
+ */
18
+ className?: string;
19
+ style?: React.CSSProperties;
20
+ }
21
+
22
+ type BannerContent = {
23
+ state: string;
24
+ color: "info" | "warning";
25
+ message: string;
26
+ linkLabel: string;
27
+ href: string;
28
+ dismissible: boolean;
29
+ };
30
+
31
+ /**
32
+ * Tells the user where their FireCMS PRO license stands: days left in the
33
+ * trial, a paused state, or a license that covers fewer projects than are
34
+ * linked to it. Renders nothing when licensed, when no license is needed, and
35
+ * while the status is unknown.
36
+ *
37
+ * The default `Scaffold` already renders it above the main content. Place it
38
+ * yourself only in a custom layout.
39
+ *
40
+ * @group Components
41
+ */
42
+ export function LicenseBanner({
43
+ className,
44
+ style
45
+ }: LicenseBannerProps) {
46
+
47
+ const licenseStatus = useLicenseStatus();
48
+ const { t, i18n } = useTranslation();
49
+
50
+ // States dismissed in this browser session; a different state shows again.
51
+ const [dismissed, setDismissed] = useState<string[]>(readDismissed);
52
+
53
+ // `language`, not `resolvedLanguage`: locale bundles are added after init,
54
+ // and `resolvedLanguage` stays on the English fallback when they are.
55
+ const content = licenseStatus
56
+ ? buildBannerContent(licenseStatus, t, i18n?.language)
57
+ : null;
58
+
59
+ if (!content || (content.dismissible && dismissed.includes(content.state)))
60
+ return null;
61
+
62
+ const dismiss = () => {
63
+ const next = [...dismissed, content.state];
64
+ writeDismissed(next);
65
+ setDismissed(next);
66
+ };
67
+
68
+ return (
69
+ <div className={className} style={style}>
70
+ <Alert
71
+ color={content.color}
72
+ size={"small"}
73
+ className={"text-sm"}
74
+ action={<div className={"flex flex-row items-center gap-1 flex-shrink-0"}>
75
+ <Button
76
+ variant={"text"}
77
+ size={"small"}
78
+ component={"a"}
79
+ href={content.href}
80
+ target={"_blank"}
81
+ rel={"noopener noreferrer"}>
82
+ {content.linkLabel}
83
+ </Button>
84
+ {content.dismissible && <IconButton
85
+ size={"small"}
86
+ aria-label={t("close")}
87
+ onClick={dismiss}>
88
+ <CloseIcon size={"small"}/>
89
+ </IconButton>}
90
+ </div>}>
91
+ {content.message}
92
+ </Alert>
93
+ </div>
94
+ );
95
+ }
96
+
97
+ type Translate = ReturnType<typeof useTranslation>["t"];
98
+
99
+ function buildBannerContent(status: AccessResponse,
100
+ t: Translate,
101
+ language: string | undefined): BannerContent | null {
102
+ const href = getLicenseSubscribeUrl(status);
103
+ switch (status.licenseState) {
104
+ case "trial": {
105
+ const days = trialDaysLeft(status);
106
+ if (days === null) return null;
107
+ return {
108
+ state: "trial",
109
+ color: "info",
110
+ message: t("license_trial_banner", {
111
+ count: days,
112
+ days: String(days)
113
+ }),
114
+ linkLabel: t("license_get_license"),
115
+ href,
116
+ dismissible: true
117
+ };
118
+ }
119
+ case "expired": {
120
+ const date = formatDate(status.trialEndsAt, language);
121
+ return {
122
+ state: "expired",
123
+ color: "warning",
124
+ message: date
125
+ ? t("license_expired_banner", { date })
126
+ : t("license_expired_banner_undated"),
127
+ linkLabel: t("license_get_license"),
128
+ href,
129
+ dismissible: false
130
+ };
131
+ }
132
+ case "invalid_project":
133
+ return {
134
+ state: "invalid_project",
135
+ color: "warning",
136
+ message: status.projectId
137
+ ? t("license_invalid_project_banner", { projectId: status.projectId })
138
+ : t("license_invalid_project_banner_unnamed"),
139
+ linkLabel: t("license_add_project_to_license"),
140
+ href,
141
+ dismissible: false
142
+ };
143
+ case "over_quota": {
144
+ const licensed = status.licensedProjects;
145
+ const actual = status.linkedProjects;
146
+ if (!isCount(licensed) || !isCount(actual)) return null;
147
+ return {
148
+ state: "over_quota",
149
+ color: "info",
150
+ message: t("license_over_quota_banner", {
151
+ count: licensed,
152
+ licensed: String(licensed),
153
+ actual: String(actual)
154
+ }),
155
+ linkLabel: t("license_update_license"),
156
+ href,
157
+ dismissible: true
158
+ };
159
+ }
160
+ default:
161
+ // licensed, not_required, whitelisted, and servers too old to say
162
+ return null;
163
+ }
164
+ }
165
+
166
+ function isCount(value: unknown): value is number {
167
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
168
+ }
169
+
170
+ function trialDaysLeft(status: AccessResponse): number | null {
171
+ if (isCount(status.daysLeft)) return Math.ceil(status.daysLeft);
172
+ const end = status.trialEndsAt ? new Date(status.trialEndsAt).getTime() : NaN;
173
+ if (Number.isNaN(end)) return null;
174
+ return Math.max(0, Math.ceil((end - Date.now()) / DAY_MS));
175
+ }
176
+
177
+ function formatDate(iso: string | undefined, language: string | undefined): string | null {
178
+ if (!iso) return null;
179
+ const date = new Date(iso);
180
+ if (Number.isNaN(date.getTime())) return null;
181
+ const options: Intl.DateTimeFormatOptions = {
182
+ year: "numeric",
183
+ month: "long",
184
+ day: "numeric"
185
+ };
186
+ try {
187
+ return new Intl.DateTimeFormat(language, options).format(date);
188
+ } catch {
189
+ return date.toLocaleDateString(undefined, options);
190
+ }
191
+ }
192
+
193
+ function readDismissed(): string[] {
194
+ try {
195
+ const value = sessionStorage.getItem(DISMISSED_STORAGE_KEY);
196
+ const parsed = value ? JSON.parse(value) : [];
197
+ return Array.isArray(parsed) ? parsed.filter((s): s is string => typeof s === "string") : [];
198
+ } catch {
199
+ return [];
200
+ }
201
+ }
202
+
203
+ function writeDismissed(states: string[]) {
204
+ try {
205
+ sessionStorage.setItem(DISMISSED_STORAGE_KEY, JSON.stringify(states));
206
+ } catch {
207
+ // Private mode or storage disabled: dismissed for this page view only.
208
+ }
209
+ }
@@ -44,6 +44,15 @@ export interface ReferenceSelectionInnerProps<M extends Record<string, any>> {
44
44
  * dynamic. If not set, the dialog won't open.
45
45
  */
46
46
  path: string;
47
+ /**
48
+ * `path` split at its real segment boundaries, when the caller knows them.
49
+ *
50
+ * Left undefined by default and deliberately NOT derived from `path`: a reference
51
+ * path comes from a property's configuration, so there is no entity chain to recover
52
+ * the boundaries from. Splitting it would silently produce the wrong segments for a
53
+ * subcollection under a slash-bearing parent id, which is worse than not answering.
54
+ */
55
+ pathSegments?: string[];
47
56
 
48
57
  /**
49
58
  * If you are opening the dialog for the first time, you can select some
@@ -96,6 +105,7 @@ export function ReferenceSelectionTable<M extends Record<string, any>>(
96
105
  multiselect,
97
106
  collection,
98
107
  path: pathInput,
108
+ pathSegments,
99
109
  selectedEntityIds: selectedEntityIdsProp,
100
110
  description,
101
111
  forceFilter,
@@ -110,7 +120,7 @@ export function ReferenceSelectionTable<M extends Record<string, any>>(
110
120
  const analyticsController = useAnalyticsController();
111
121
  const customizationController = useCustomizationController();
112
122
 
113
- const fullPath = navigation.resolveIdsFrom(pathInput);
123
+ const fullPath = navigation.resolveIdsFrom(pathInput, pathSegments);
114
124
 
115
125
  const dataSource = useDataSource(collection);
116
126
 
@@ -153,6 +163,7 @@ export function ReferenceSelectionTable<M extends Record<string, any>>(
153
163
  selectedEntityIds.map((entityId) =>
154
164
  dataSource.fetchEntity({
155
165
  path: fullPath,
166
+ pathSegments,
156
167
  entityId,
157
168
  collection
158
169
  })))
@@ -204,6 +215,7 @@ export function ReferenceSelectionTable<M extends Record<string, any>>(
204
215
  });
205
216
  sideEntityController.open({
206
217
  path: fullPath,
218
+ pathSegments,
207
219
  collection,
208
220
  updateUrl: true,
209
221
  onUpdate: ({ entity }) => {
@@ -277,6 +289,7 @@ export function ReferenceSelectionTable<M extends Record<string, any>>(
277
289
  textSearchEnabled
278
290
  } =
279
291
  useTableSearchHelper({
292
+ pathSegments,
280
293
  collection,
281
294
  fullPath,
282
295
  });
@@ -315,6 +328,7 @@ export function ReferenceSelectionTable<M extends Record<string, any>>(
315
328
  actions={<ReferenceDialogActions
316
329
  collection={collection}
317
330
  path={fullPath}
331
+ pathSegments={pathSegments}
318
332
  onNewClick={onNewClick}
319
333
  onClear={onClear}/>
320
334
  }
@@ -341,11 +355,14 @@ export function ReferenceSelectionTable<M extends Record<string, any>>(
341
355
  function ReferenceDialogActions({
342
356
  collection,
343
357
  path,
358
+ pathSegments,
344
359
  onClear,
345
360
  onNewClick
346
361
  }: {
347
362
  collection: EntityCollection<any>,
348
363
  path: string,
364
+ /** `path` split at its real segment boundaries, when known. Never derived from `path`. */
365
+ pathSegments?: string[],
349
366
  onClear: () => void,
350
367
  onNewClick: () => void
351
368
  }) {
@@ -360,7 +377,7 @@ function ReferenceDialogActions({
360
377
  onNewClick();
361
378
  }
362
379
  : undefined;
363
- const addButton = canCreateEntity(collection, authController, path, null) &&
380
+ const addButton = canCreateEntity(collection, authController, path, null, pathSegments) &&
364
381
  onClick && (largeLayout
365
382
  ? <Button
366
383
  onClick={onClick}
@@ -18,6 +18,7 @@ export const editEntityAction: EntityAction = {
18
18
  entity,
19
19
  collection,
20
20
  fullPath,
21
+ pathSegments,
21
22
  fullIdPath,
22
23
  context,
23
24
  highlightEntity,
@@ -41,6 +42,12 @@ export const editEntityAction: EntityAction = {
41
42
  }
42
43
 
43
44
  const path = collection?.collectionGroup ? entity.path : (fullPath ?? collection?.path ?? entity.path);
45
+ // In a collection group `path` is the entity's own, so the entity's segments describe
46
+ // it. Otherwise the caller's segments win, falling back to the ones the entity was
47
+ // loaded with — both describe the same collection. Never derived from `path`.
48
+ const resolvedPathSegments = collection?.collectionGroup
49
+ ? entity.pathSegments
50
+ : (pathSegments ?? entity.pathSegments);
44
51
  const newFullIdPath = collection?.collectionGroup ? collection.id : (fullIdPath ?? collection?.id ?? entity.path);
45
52
  const defaultSelectedView = resolveDefaultSelectedView(
46
53
  collection ? collection.defaultSelectedView : undefined,
@@ -54,6 +61,7 @@ export const editEntityAction: EntityAction = {
54
61
  collection,
55
62
  entityId: entity.id,
56
63
  path,
64
+ pathSegments: resolvedPathSegments,
57
65
  fullIdPath: newFullIdPath,
58
66
  sideEntityController: context.sideEntityController,
59
67
  onClose: () => unhighlightEntity?.(entity),
@@ -75,6 +83,8 @@ export const copyEntityAction: EntityAction = {
75
83
  collection,
76
84
  context,
77
85
  fullPath,
86
+ pathSegments,
87
+ fullIdPath,
78
88
  highlightEntity,
79
89
  unhighlightEntity,
80
90
  openEntityMode
@@ -89,13 +99,23 @@ export const copyEntityAction: EntityAction = {
89
99
  });
90
100
 
91
101
  const path = collection?.collectionGroup ? collection.path : (fullPath ?? collection?.path ?? entity.path);
92
- const fullIdPath = collection?.collectionGroup ? collection.id : (fullPath ?? collection?.id ?? entity.path);
102
+ // In a collection group `path` is the group's own root, which the entity's segments
103
+ // (its real nested location) do not describe — so they are deliberately not reused,
104
+ // and no substitute is invented.
105
+ const resolvedPathSegments = collection?.collectionGroup
106
+ ? undefined
107
+ : (pathSegments ?? entity.pathSegments);
108
+ // `fullIdPath` is the ESCAPED chain and is what becomes the URL; `fullPath` is the raw
109
+ // datasource path, whose parent ids may contain "/". Using the latter here wrote a URL
110
+ // that read back as a different entity. Mirrors editEntityAction.
111
+ const newFullIdPath = collection?.collectionGroup ? collection.id : (fullIdPath ?? collection?.id);
93
112
  navigateToEntity({
94
113
  openEntityMode,
95
114
  collection,
96
115
  entityId: entity.id,
97
116
  path,
98
- fullIdPath,
117
+ pathSegments: resolvedPathSegments,
118
+ fullIdPath: newFullIdPath,
99
119
  copy: true,
100
120
  sideEntityController: context.sideEntityController,
101
121
  onClose: () => unhighlightEntity?.(entity),
@@ -27,8 +27,10 @@ export type DataSourceTableControllerProps<M extends Record<string, any> = any>
27
27
  fullPath: string;
28
28
  /**
29
29
  * `fullPath` split at its real segment boundaries. Passed through to the datasource so
30
- * a parent entity id containing "/" stays unambiguous. Defaults to splitting
31
- * `fullPath`, which is correct for any backend whose ids cannot contain "/".
30
+ * a parent entity id containing "/" stays unambiguous.
31
+ *
32
+ * Optional, and never derived by splitting `fullPath` — a guess would be wrong in
33
+ * exactly the case the field exists for. Absent means "not known here", not "no slashes".
32
34
  */
33
35
  pathSegments?: string[];
34
36
  /**
@@ -94,9 +96,12 @@ export function useDataSourceTableController<M extends Record<string, any> = any
94
96
  const [popupCell, setPopupCell] = React.useState<SelectedCellProps<M> | undefined>(undefined);
95
97
  const navigation = useNavigationController();
96
98
  const dataSource = useDataSource(collection);
97
- const resolvedPath = useMemo(() => navigation.resolveIdsFrom(fullPath), [fullPath, navigation.resolveIdsFrom]);
98
- const resolvedPathSegments = useMemo(() => pathSegments ?? resolvedPath.split("/"),
99
- [pathSegments, resolvedPath]);
99
+ // Resolved together so the two representations always describe the same chain.
100
+ // Segments are resolved, never derived (see useEntityFetch).
101
+ const resolvedPath = useMemo(() => navigation.resolveIdsFrom(fullPath, pathSegments), [fullPath, pathSegments, navigation.resolveIdsFrom]);
102
+ const resolvedPathSegments = useMemo(() => pathSegments
103
+ ? (navigation.resolveSegmentsFrom?.(pathSegments) ?? pathSegments)
104
+ : undefined, [pathSegments, navigation.resolveSegmentsFrom]);
100
105
 
101
106
  const forceFilter = forceFilterFromProps ?? forceFilterFromCollection;
102
107
  const paginationEnabled = collection.pagination === undefined || Boolean(collection.pagination);
@@ -108,6 +113,7 @@ export function useDataSourceTableController<M extends Record<string, any> = any
108
113
  return true;
109
114
  return dataSource.isFilterCombinationValid({
110
115
  path: resolvedPath,
116
+ pathSegments: resolvedPathSegments,
111
117
  collection,
112
118
  filterValues,
113
119
  sortBy
@@ -188,11 +194,23 @@ export function useDataSourceTableController<M extends Record<string, any> = any
188
194
 
189
195
  useUpdateUrl(filterValues, sortBy, searchString, updateUrl);
190
196
 
191
- const collectionScroll = scrollRestoration?.getCollectionScroll(fullPath, filterValues);
192
- const initialItemCount = collectionScroll?.data.length ?? pageSize;
197
+ // Keyed by `resolvedPath`, not `fullPath`, so the read agrees with the two writes below
198
+ // and in `onScroll`. What the cache holds is a slice of a dataset plus the offset into
199
+ // it, and the dataset is identified by what the datasource was actually queried with:
200
+ // `resolvedPath` + filters. Reading with the unresolved `fullPath` meant that for every
201
+ // collection reached through an id alias, or a subcollection whose parent ids need
202
+ // resolving, the entry was written under one key and looked up under another, so scroll
203
+ // restoration silently did nothing there.
204
+ const collectionScroll = scrollRestoration?.getCollectionScroll(resolvedPath, filterValues);
205
+ const initialItemCount = collectionScroll?.data.length || pageSize;
193
206
 
194
207
  useEffect(() => {
195
- if (scrollRestoration) {
208
+ // Only re-seed an entry we actually have something to restore for. Writing an empty
209
+ // `data` array here poisoned the cache: this is a module level Map that outlives the
210
+ // mount, so the next mount of the same collection read `data.length === 0` back as the
211
+ // initial item count and called the datasource with `limit: 0`. Custom datasources
212
+ // treat that as falsy and drop the limit altogether, loading the whole collection.
213
+ if (scrollRestoration && rawData.length > 0) {
196
214
  scrollRestoration.updateCollectionScroll({
197
215
  fullPath: resolvedPath,
198
216
  scrollOffset: collectionScroll?.scrollOffset ?? 0,
@@ -234,6 +252,11 @@ export function useDataSourceTableController<M extends Record<string, any> = any
234
252
  setDataLoading(true);
235
253
 
236
254
  const onEntitiesUpdate = async (entities: Entity<M>[]) => {
255
+ // See useEntityFetch: entities carry the segments they were loaded with, so a
256
+ // reference or a delete derived from a table row stays resolvable.
257
+ if (resolvedPathSegments) {
258
+ entities = entities.map(e => e.pathSegments ? e : { ...e, pathSegments: resolvedPathSegments });
259
+ }
237
260
  if (collection.callbacks?.onFetch) {
238
261
  try {
239
262
  entities = await Promise.all(
@@ -241,6 +264,7 @@ export function useDataSourceTableController<M extends Record<string, any> = any
241
264
  collection.callbacks!.onFetch!({
242
265
  collection,
243
266
  path: resolvedPath,
267
+ pathSegments: resolvedPathSegments,
244
268
  entity,
245
269
  context
246
270
  })));
@@ -6,6 +6,8 @@ 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[];
10
12
  /**
11
13
  * Search term that is already being applied without the user having gone through the
@@ -18,6 +20,7 @@ export interface UseTableSearchHelperParams<M extends Record<string, any>> {
18
20
  export function useTableSearchHelper<M extends Record<string, any>>({
19
21
  collection,
20
22
  fullPath,
23
+ pathSegments,
21
24
  parentCollectionIds,
22
25
  initialSearchString
23
26
  }: UseTableSearchHelperParams<M>) {
@@ -36,6 +39,7 @@ export function useTableSearchHelper<M extends Record<string, any>>({
36
39
  const props = {
37
40
  context,
38
41
  path: fullPath,
42
+ pathSegments,
39
43
  databaseId: collection.databaseId,
40
44
  collection,
41
45
  parentCollectionIds
@@ -62,6 +66,7 @@ export function useTableSearchHelper<M extends Record<string, any>>({
62
66
  promises.push(p.collectionView.onTextSearchClick({
63
67
  context,
64
68
  path: fullPath,
69
+ pathSegments,
65
70
  collection,
66
71
  parentCollectionIds
67
72
  }));
@@ -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,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