@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.
- package/README.md +4 -0
- 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/ErrorBoundary.d.ts +1 -3
- package/dist/components/LicenseBanner.d.ts +23 -0
- 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-KtBROUvS.js +700 -0
- package/dist/de-KtBROUvS.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 +2 -0
- package/dist/form/EntityFormActions.d.ts +2 -0
- package/dist/fr-BRFSBaVh.js +700 -0
- package/dist/fr-BRFSBaVh.js.map +1 -0
- package/dist/hi-DwCkga5X.js +700 -0
- package/dist/hi-DwCkga5X.js.map +1 -0
- package/dist/hooks/data/delete.d.ts +1 -1
- package/dist/hooks/data/useCollectionFetch.d.ts +1 -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 +3503 -11244
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +8028 -7006
- package/dist/index.umd.js.map +1 -1
- package/dist/it-B2YGgDsx.js +700 -0
- package/dist/it-B2YGgDsx.js.map +1 -0
- package/dist/pl-BxHkywK8.js +702 -0
- package/dist/pl-BxHkywK8.js.map +1 -0
- package/dist/pt-q6UV4diT.js +702 -0
- package/dist/pt-q6UV4diT.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 +8 -0
- 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 +31 -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 +15 -0
- package/dist/util/paths.d.ts +28 -0
- package/dist/util/permissions.d.ts +10 -4
- package/package.json +3 -3
- 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/EntityCollectionView/EntityCollectionBoardView.tsx +8 -0
- package/src/components/EntityCollectionView/EntityCollectionView.tsx +40 -15
- package/src/components/EntityCollectionView/EntityCollectionViewActions.tsx +6 -2
- package/src/components/EntityCollectionView/EntityCollectionViewStartActions.tsx +4 -0
- package/src/components/EntityCollectionView/useBoardDataController.tsx +7 -2
- 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 +8 -2
- package/src/components/common/default_entity_actions.tsx +22 -2
- package/src/components/common/useDataSourceTableController.tsx +26 -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 +8 -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 +22 -7
- package/src/form/EntityFormActions.tsx +2 -0
- package/src/hooks/data/delete.ts +8 -2
- package/src/hooks/data/save.ts +4 -1
- package/src/hooks/data/useCollectionFetch.tsx +9 -2
- package/src/hooks/data/useEntityFetch.tsx +19 -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 +1 -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 +13 -12
- package/src/internal/useBuildSideEntityController.tsx +13 -5
- package/src/locales/de.ts +16 -2
- package/src/locales/en.ts +18 -2
- package/src/locales/es.ts +16 -2
- package/src/locales/fr.ts +16 -2
- package/src/locales/hi.ts +16 -2
- package/src/locales/it.ts +16 -2
- package/src/locales/pl.ts +16 -2
- package/src/locales/pt.ts +16 -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 +8 -0
- 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 +31 -2
- package/src/util/date_fns_locales.ts +168 -0
- 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 +204 -2
- package/src/util/parent_references_from_path.ts +32 -1
- package/src/util/paths.ts +40 -3
- package/src/util/permissions.ts +22 -10
- 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
|
-
|
|
8
|
-
error?: Error
|
|
7
|
+
error: Error | null
|
|
9
8
|
}> {
|
|
10
9
|
constructor(props: any) {
|
|
11
10
|
super(props);
|
|
12
|
-
this.state = {
|
|
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 {
|
|
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.
|
|
27
|
-
|
|
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="
|
|
39
|
-
<div
|
|
40
|
-
|
|
41
|
-
<div className="
|
|
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
|
-
|
|
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
|
-
const
|
|
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
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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
|
}));
|
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
|
|