@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
@@ -63,7 +63,7 @@ export function EntityPreview({
63
63
 
64
64
  const navigationController = useNavigationController();
65
65
 
66
- const collection = collectionProp ?? navigationController.getCollection(entity.path);
66
+ const collection = collectionProp ?? navigationController.getCollection(entity.path, false, entity.pathSegments);
67
67
 
68
68
  if (!collection) {
69
69
  throw Error(`Couldn't find the corresponding collection view for the path: ${entity.path}`);
@@ -177,6 +177,9 @@ export function EntityPreview({
177
177
  sideEntityController.open({
178
178
  entityId: entity.id,
179
179
  path: entity.path,
180
+ // Paired with `entity.path` above; undefined if the entity was
181
+ // loaded without them.
182
+ pathSegments: entity.pathSegments,
180
183
  collection,
181
184
  updateUrl: true
182
185
  });
@@ -4,17 +4,16 @@ import { useTranslation } from "../hooks/useTranslation";
4
4
  import { ErrorIcon, Typography } from "@firecms/ui";
5
5
 
6
6
  export class ErrorBoundary extends React.Component<PropsWithChildren<Record<string, unknown>>, {
7
- hasError: boolean,
8
- error?: Error
7
+ error: Error | null
9
8
  }> {
10
9
  constructor(props: any) {
11
10
  super(props);
12
- this.state = { hasError: false };
11
+ this.state = { error: null };
13
12
  }
14
13
 
15
14
  // eslint-disable-next-line n/handle-callback-err
16
15
  static getDerivedStateFromError(error: Error) {
17
- return { hasError: true, error };
16
+ return { error };
18
17
  }
19
18
 
20
19
  componentDidCatch(error: Error, errorInfo: ErrorInfo) {
@@ -23,9 +22,8 @@ export class ErrorBoundary extends React.Component<PropsWithChildren<Record<stri
23
22
  }
24
23
 
25
24
  render() {
26
- if (this.state.hasError) {
27
- // You can render any custom fallback UI
28
- return <FallbackView message={this.state.error?.message}/>;
25
+ if (this.state.error) {
26
+ return <FallbackView message={this.state.error.message}/>;
29
27
  }
30
28
 
31
29
  return this.props.children;
@@ -35,18 +33,14 @@ export class ErrorBoundary extends React.Component<PropsWithChildren<Record<stri
35
33
  function FallbackView({ message }: { message?: string }) {
36
34
  const { t } = useTranslation();
37
35
  return (
38
- <div className="h-full w-full bg-slate-100 dark:bg-surface-900 flex items-center justify-center p-4">
39
- <div
40
- className="flex flex-col items-center justify-center m-4 bg-white dark:bg-surface-800 p-8 rounded-lg shadow-sm border border-gray-200 dark:border-surface-700">
41
- <div className="flex items-center mb-4 text-red-500 dark:text-red-400">
42
- <ErrorIcon/>
43
- <div className="ml-4">{t("error")}</div>
44
- </div>
45
- <div className="flex justify-center text-gray-500 dark:text-gray-400">
46
- {/* Error message is purposely removed since it's hard to access state here, but typical ErrorBoundary fallback doesn't always show the raw message */}
47
- {t("see_console_details")}
48
- </div>
36
+ <div className="flex flex-col m-2">
37
+ <div className="flex items-center m-2">
38
+ <ErrorIcon color={"error"} size={"small"}/>
39
+ <div className="ml-4">{t("error")}</div>
49
40
  </div>
41
+ <Typography variant={"caption"}>
42
+ {message ?? t("see_console_details")}
43
+ </Typography>
50
44
  </div>
51
45
  );
52
46
  }
@@ -0,0 +1,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
+ }
@@ -120,7 +120,7 @@ export function ReferenceSelectionTable<M extends Record<string, any>>(
120
120
  const analyticsController = useAnalyticsController();
121
121
  const customizationController = useCustomizationController();
122
122
 
123
- const fullPath = navigation.resolveIdsFrom(pathInput);
123
+ const fullPath = navigation.resolveIdsFrom(pathInput, pathSegments);
124
124
 
125
125
  const dataSource = useDataSource(collection);
126
126
 
@@ -215,6 +215,7 @@ export function ReferenceSelectionTable<M extends Record<string, any>>(
215
215
  });
216
216
  sideEntityController.open({
217
217
  path: fullPath,
218
+ pathSegments,
218
219
  collection,
219
220
  updateUrl: true,
220
221
  onUpdate: ({ entity }) => {
@@ -288,6 +289,7 @@ export function ReferenceSelectionTable<M extends Record<string, any>>(
288
289
  textSearchEnabled
289
290
  } =
290
291
  useTableSearchHelper({
292
+ pathSegments,
291
293
  collection,
292
294
  fullPath,
293
295
  });
@@ -326,6 +328,7 @@ export function ReferenceSelectionTable<M extends Record<string, any>>(
326
328
  actions={<ReferenceDialogActions
327
329
  collection={collection}
328
330
  path={fullPath}
331
+ pathSegments={pathSegments}
329
332
  onNewClick={onNewClick}
330
333
  onClear={onClear}/>
331
334
  }
@@ -352,11 +355,14 @@ export function ReferenceSelectionTable<M extends Record<string, any>>(
352
355
  function ReferenceDialogActions({
353
356
  collection,
354
357
  path,
358
+ pathSegments,
355
359
  onClear,
356
360
  onNewClick
357
361
  }: {
358
362
  collection: EntityCollection<any>,
359
363
  path: string,
364
+ /** `path` split at its real segment boundaries, when known. Never derived from `path`. */
365
+ pathSegments?: string[],
360
366
  onClear: () => void,
361
367
  onNewClick: () => void
362
368
  }) {
@@ -371,7 +377,7 @@ function ReferenceDialogActions({
371
377
  onNewClick();
372
378
  }
373
379
  : undefined;
374
- const addButton = canCreateEntity(collection, authController, path, null) &&
380
+ const addButton = canCreateEntity(collection, authController, path, null, pathSegments) &&
375
381
  onClick && (largeLayout
376
382
  ? <Button
377
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
- // Passed through as-is; never derived from `resolvedPath` (see useEntityFetch).
99
- const resolvedPathSegments = pathSegments;
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);
@@ -189,11 +194,23 @@ export function useDataSourceTableController<M extends Record<string, any> = any
189
194
 
190
195
  useUpdateUrl(filterValues, sortBy, searchString, updateUrl);
191
196
 
192
- const collectionScroll = scrollRestoration?.getCollectionScroll(fullPath, filterValues);
193
- 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;
194
206
 
195
207
  useEffect(() => {
196
- 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) {
197
214
  scrollRestoration.updateCollectionScroll({
198
215
  fullPath: resolvedPath,
199
216
  scrollOffset: collectionScroll?.scrollOffset ?? 0,
@@ -247,6 +264,7 @@ export function useDataSourceTableController<M extends Record<string, any> = any
247
264
  collection.callbacks!.onFetch!({
248
265
  collection,
249
266
  path: resolvedPath,
267
+ pathSegments: resolvedPathSegments,
250
268
  entity,
251
269
  context
252
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