@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.
- package/README.md +4 -0
- package/dist/components/EntityCollectionTable/internal/popup_field/PopupFormField.d.ts +3 -1
- package/dist/components/EntityCollectionView/EntityCollectionBoardView.d.ts +3 -1
- package/dist/components/EntityCollectionView/EntityCollectionView.d.ts +3 -2
- package/dist/components/EntityCollectionView/EntityCollectionViewActions.d.ts +3 -1
- package/dist/components/EntityCollectionView/EntityCollectionViewStartActions.d.ts +3 -1
- package/dist/components/EntityCollectionView/useBoardDataController.d.ts +3 -1
- package/dist/components/ErrorBoundary.d.ts +1 -3
- package/dist/components/LicenseBanner.d.ts +23 -0
- package/dist/components/ReferenceTable/ReferenceSelectionTable.d.ts +10 -1
- package/dist/components/common/useDataSourceTableController.d.ts +4 -2
- package/dist/components/common/useTableSearchHelper.d.ts +3 -1
- package/dist/components/index.d.ts +1 -0
- package/dist/contexts/LicenseStatusContext.d.ts +16 -0
- package/dist/core/EntityEditView.d.ts +2 -0
- package/dist/core/EntityEditViewFormActions.d.ts +1 -1
- package/dist/core/index.d.ts +1 -0
- package/dist/core/plugin_translations.d.ts +38 -0
- package/dist/core/pro_plugins.d.ts +40 -0
- package/dist/de-DMp6cIY1.js +702 -0
- package/dist/de-DMp6cIY1.js.map +1 -0
- package/dist/editor/extensions/Image.d.ts +11 -0
- package/dist/editor/index.d.ts +2 -1
- package/dist/editor/lazy_editor.d.ts +6 -0
- package/dist/editor-BSo_aS1I.js +4563 -0
- package/dist/editor-BSo_aS1I.js.map +1 -0
- package/dist/form/EntityForm.d.ts +8 -1
- package/dist/form/EntityFormActions.d.ts +2 -0
- package/dist/fr-Cckz4IkR.js +702 -0
- package/dist/fr-Cckz4IkR.js.map +1 -0
- package/dist/hi-CDCoIqbu.js +702 -0
- package/dist/hi-CDCoIqbu.js.map +1 -0
- package/dist/hooks/data/delete.d.ts +1 -1
- package/dist/hooks/data/useCollectionFetch.d.ts +7 -1
- package/dist/hooks/data/useEntityFetch.d.ts +4 -1
- package/dist/hooks/index.d.ts +2 -0
- package/dist/hooks/useLicenseStatus.d.ts +16 -0
- package/dist/hooks/useProjectLog.d.ts +11 -6
- package/dist/hooks/useTranslation.d.ts +1 -1
- package/dist/i18n/__tests__/FireCMSi18nProvider.test.d.ts +1 -0
- package/dist/i18n/__tests__/nested_provider_lifecycle.test.d.ts +1 -0
- package/dist/index.es.js +3113 -10785
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +7939 -6836
- package/dist/index.umd.js.map +1 -1
- package/dist/it-BJCUqixj.js +702 -0
- package/dist/it-BJCUqixj.js.map +1 -0
- package/dist/pl-DgT_joFf.js +704 -0
- package/dist/pl-DgT_joFf.js.map +1 -0
- package/dist/pt-B3dN3x0X.js +704 -0
- package/dist/pt-B3dN3x0X.js.map +1 -0
- package/dist/types/analytics.d.ts +1 -1
- package/dist/types/collections.d.ts +7 -0
- package/dist/types/datasource.d.ts +25 -4
- package/dist/types/entities.d.ts +21 -1
- package/dist/types/entity_actions.d.ts +6 -0
- package/dist/types/entity_callbacks.d.ts +21 -0
- package/dist/types/firecms.d.ts +18 -1
- package/dist/types/firecms_context.d.ts +8 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/license.d.ts +54 -0
- package/dist/types/navigation.d.ts +34 -5
- package/dist/types/plugins.d.ts +22 -1
- package/dist/types/side_entity_controller.d.ts +3 -2
- package/dist/types/translations.d.ts +35 -2
- package/dist/util/date_fns_locales.d.ts +18 -0
- package/dist/util/entity_cache.d.ts +11 -0
- package/dist/util/index.d.ts +1 -0
- package/dist/util/lazy_eager.d.ts +3 -3
- package/dist/util/navigation_utils.d.ts +84 -2
- package/dist/util/parent_references_from_path.d.ts +16 -0
- package/dist/util/paths.d.ts +28 -0
- package/dist/util/permissions.d.ts +10 -4
- package/package.json +5 -6
- package/src/app/Scaffold.tsx +7 -0
- package/src/components/DeleteEntityDialog.tsx +2 -0
- package/src/components/EntityCollectionTable/EntityCollectionRowActions.tsx +2 -2
- package/src/components/EntityCollectionTable/internal/popup_field/PopupFormField.tsx +5 -1
- package/src/components/EntityCollectionView/EntityCollectionBoardView.tsx +12 -0
- package/src/components/EntityCollectionView/EntityCollectionView.tsx +49 -16
- package/src/components/EntityCollectionView/EntityCollectionViewActions.tsx +6 -2
- package/src/components/EntityCollectionView/EntityCollectionViewStartActions.tsx +4 -0
- package/src/components/EntityCollectionView/useBoardDataController.tsx +17 -1
- package/src/components/EntityPreview.tsx +4 -1
- package/src/components/ErrorBoundary.tsx +12 -18
- package/src/components/LicenseBanner.tsx +209 -0
- package/src/components/ReferenceTable/ReferenceSelectionTable.tsx +19 -2
- package/src/components/common/default_entity_actions.tsx +22 -2
- package/src/components/common/useDataSourceTableController.tsx +32 -8
- package/src/components/common/useTableSearchHelper.ts +5 -0
- package/src/components/index.tsx +1 -0
- package/src/contexts/LicenseStatusContext.tsx +50 -0
- package/src/core/DefaultAppBar.tsx +28 -16
- package/src/core/EntityEditView.tsx +11 -12
- package/src/core/EntityEditViewFormActions.tsx +3 -2
- package/src/core/EntitySidePanel.tsx +6 -4
- package/src/core/FireCMS.tsx +73 -47
- package/src/core/SideDialogs.tsx +44 -1
- package/src/core/index.tsx +1 -0
- package/src/core/plugin_translations.ts +87 -0
- package/src/core/pro_plugins.ts +79 -0
- package/src/editor/components/SlashCommandMenu.tsx +4 -1
- package/src/editor/extensions/Image.ts +17 -1
- package/src/editor/hooks/useProseMirror.ts +5 -0
- package/src/editor/index.ts +11 -1
- package/src/editor/lazy_editor.tsx +33 -0
- package/src/form/EntityForm.tsx +30 -9
- package/src/form/EntityFormActions.tsx +2 -0
- package/src/hooks/data/delete.ts +8 -0
- package/src/hooks/data/save.ts +5 -2
- package/src/hooks/data/useCollectionFetch.tsx +21 -1
- package/src/hooks/data/useEntityFetch.tsx +28 -6
- package/src/hooks/index.tsx +2 -0
- package/src/hooks/useBuildNavigationController.tsx +70 -15
- package/src/hooks/useFireCMSContext.tsx +7 -3
- package/src/hooks/useLicenseStatus.tsx +32 -0
- package/src/hooks/useProjectLog.tsx +27 -9
- package/src/hooks/useResolvedNavigationFrom.tsx +3 -1
- package/src/hooks/useTranslation.ts +3 -1
- package/src/i18n/FireCMSi18nProvider.tsx +199 -25
- package/src/i18n/__tests__/FireCMSi18nProvider.test.tsx +91 -0
- package/src/i18n/__tests__/nested_provider_lifecycle.test.tsx +67 -0
- package/src/internal/useBuildDataSource.ts +26 -15
- package/src/internal/useBuildSideEntityController.tsx +13 -5
- package/src/locales/de.ts +18 -2
- package/src/locales/en.ts +21 -3
- package/src/locales/es.ts +18 -2
- package/src/locales/fr.ts +18 -2
- package/src/locales/hi.ts +18 -2
- package/src/locales/it.ts +18 -2
- package/src/locales/pl.ts +18 -2
- package/src/locales/pt.ts +18 -2
- package/src/preview/components/DatePreview.tsx +2 -4
- package/src/preview/components/ReferencePreview.tsx +7 -3
- package/src/routes/FireCMSRoute.tsx +9 -4
- package/src/types/analytics.ts +3 -0
- package/src/types/collections.ts +8 -0
- package/src/types/datasource.ts +25 -4
- package/src/types/entities.ts +24 -1
- package/src/types/entity_actions.tsx +6 -0
- package/src/types/entity_callbacks.ts +24 -0
- package/src/types/firecms.tsx +19 -1
- package/src/types/firecms_context.tsx +9 -0
- package/src/types/index.ts +1 -0
- package/src/types/license.ts +62 -0
- package/src/types/navigation.ts +35 -5
- package/src/types/plugins.tsx +23 -1
- package/src/types/side_entity_controller.tsx +3 -2
- package/src/types/translations.ts +35 -2
- package/src/util/date_fns_locales.ts +168 -0
- package/src/util/entities.ts +3 -1
- package/src/util/entity_cache.ts +18 -0
- package/src/util/index.ts +1 -0
- package/src/util/lazy_eager.tsx +30 -8
- package/src/util/navigation_from_path.ts +6 -1
- package/src/util/navigation_utils.ts +210 -3
- package/src/util/parent_references_from_path.ts +42 -3
- package/src/util/paths.ts +40 -3
- package/src/util/permissions.ts +22 -10
- 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
|
-
|
|
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
|
-
|
|
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.
|
|
31
|
-
*
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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
|
}));
|
package/src/components/index.tsx
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|