@firecms/core 3.4.0-canary.8635253 → 3.4.0-canary.ce97da7
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/CollectionTableToolbar.d.ts +6 -1
- 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 +8 -0
- 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/SelectableTable/filters/filter_text_input.d.ts +30 -0
- package/dist/components/SelectableTable/filters/filter_text_input.test.d.ts +1 -0
- package/dist/components/common/table_url_params.d.ts +24 -0
- package/dist/components/common/useDataSourceTableController.d.ts +9 -1
- package/dist/components/common/useTableSearchHelper.d.ts +9 -1
- package/dist/components/index.d.ts +1 -0
- package/dist/contexts/LicenseStatusContext.d.ts +16 -0
- package/dist/core/EntityEditView.d.ts +9 -1
- 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/save.d.ts +1 -1
- package/dist/hooks/data/useCollectionFetch.d.ts +7 -1
- package/dist/hooks/data/useEntityFetch.d.ts +9 -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 +3921 -11331
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +8950 -7585
- 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 +14 -0
- package/dist/types/datasource.d.ts +67 -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/properties.d.ts +17 -1
- package/dist/types/side_entity_controller.d.ts +7 -0
- package/dist/types/translations.d.ts +41 -2
- package/dist/util/__tests__/collections.test.d.ts +1 -0
- package/dist/util/__tests__/urls.test.d.ts +1 -0
- package/dist/util/collections.d.ts +1 -1
- 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 +2 -0
- package/dist/util/lazy_eager.d.ts +3 -3
- package/dist/util/navigation_from_path.d.ts +7 -0
- package/dist/util/navigation_utils.d.ts +87 -3
- 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/dist/util/urls.d.ts +22 -0
- package/package.json +5 -5
- 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/EntityCollectionTable.tsx +1 -0
- package/src/components/EntityCollectionTable/internal/CollectionTableToolbar.tsx +7 -0
- 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 +66 -15
- 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/SelectableTable/filters/StringNumberFilterField.tsx +33 -7
- package/src/components/SelectableTable/filters/filter_text_input.test.ts +184 -0
- package/src/components/SelectableTable/filters/filter_text_input.ts +71 -0
- package/src/components/common/default_entity_actions.tsx +22 -2
- package/src/components/common/table_url_params.ts +172 -0
- package/src/components/common/useDataSourceTableController.tsx +86 -179
- package/src/components/common/useTableSearchHelper.ts +26 -2
- package/src/components/index.tsx +1 -0
- package/src/contexts/LicenseStatusContext.tsx +50 -0
- package/src/contexts/SnackbarProvider.tsx +14 -1
- package/src/core/DefaultAppBar.tsx +28 -16
- package/src/core/EntityEditView.tsx +20 -3
- package/src/core/EntityEditViewFormActions.tsx +3 -2
- package/src/core/EntitySidePanel.tsx +8 -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 +31 -10
- package/src/form/EntityFormActions.tsx +2 -0
- package/src/hooks/data/delete.ts +8 -0
- package/src/hooks/data/save.ts +6 -1
- package/src/hooks/data/useCollectionFetch.tsx +21 -1
- package/src/hooks/data/useEntityFetch.tsx +35 -4
- 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/hooks/useValidateAuthenticator.tsx +4 -0
- 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 +37 -15
- package/src/internal/useBuildSideEntityController.tsx +19 -5
- package/src/locales/de.ts +23 -2
- package/src/locales/en.ts +26 -3
- package/src/locales/es.ts +23 -2
- package/src/locales/fr.ts +23 -2
- package/src/locales/hi.ts +23 -2
- package/src/locales/it.ts +23 -2
- package/src/locales/pl.ts +23 -2
- package/src/locales/pt.ts +23 -2
- package/src/preview/components/DatePreview.tsx +2 -4
- package/src/preview/components/ReferencePreview.tsx +7 -3
- package/src/preview/components/StorageThumbnail.tsx +24 -2
- package/src/routes/FireCMSRoute.tsx +11 -3
- package/src/types/analytics.ts +3 -0
- package/src/types/collections.ts +16 -0
- package/src/types/datasource.ts +67 -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/properties.ts +17 -0
- package/src/types/side_entity_controller.tsx +7 -0
- package/src/types/translations.ts +41 -2
- package/src/util/__tests__/collections.test.ts +32 -0
- package/src/util/__tests__/urls.test.ts +131 -0
- package/src/util/collections.ts +1 -1
- 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 +2 -0
- package/src/util/lazy_eager.tsx +30 -8
- package/src/util/navigation_from_path.ts +25 -2
- package/src/util/navigation_utils.ts +214 -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/previews.ts +8 -1
- package/src/util/resolutions.ts +8 -0
- package/src/util/urls.ts +52 -0
- package/src/util/useStorageUploadController.tsx +6 -1
|
@@ -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
|
+
}
|
|
@@ -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}
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
TextField
|
|
12
12
|
} from "@firecms/ui";
|
|
13
13
|
import { EnumValueConfig } from "../../../types";
|
|
14
|
+
import { filterValueToText, parseFilterTextInput } from "./filter_text_input";
|
|
14
15
|
|
|
15
16
|
interface StringNumberFilterFieldProps {
|
|
16
17
|
name: string,
|
|
@@ -60,8 +61,15 @@ export function StringNumberFilterField({
|
|
|
60
61
|
const [fieldOperation, fieldValue] = value || [possibleOperations[0], undefined];
|
|
61
62
|
const [operation, setOperation] = useState<VirtualTableWhereFilterOp | "is-null">(fieldOperation === "==" && fieldValue === null ? "is-null" : fieldOperation);
|
|
62
63
|
const [internalValue, setInternalValue] = useState<string | number | string[] | number[] | null | undefined>(fieldValue);
|
|
64
|
+
// Raw text of the text input, kept apart from `internalValue` so that intermediate
|
|
65
|
+
// inputs like "-", "0." or ".0" are not destroyed while the user is typing.
|
|
66
|
+
const [inputText, setInputText] = useState<string>(filterValueToText(fieldValue));
|
|
67
|
+
// Value the current `inputText` was built from, used to tell updates coming from
|
|
68
|
+
// outside apart from the ones triggered by the user typing.
|
|
69
|
+
const syncedValueRef = React.useRef(fieldValue);
|
|
63
70
|
|
|
64
71
|
React.useEffect(() => {
|
|
72
|
+
const newValue = value ? value[1] : undefined;
|
|
65
73
|
if (value) {
|
|
66
74
|
const [op, val] = value;
|
|
67
75
|
setOperation(op === "==" && val === null ? "is-null" : op);
|
|
@@ -70,15 +78,25 @@ export function StringNumberFilterField({
|
|
|
70
78
|
setOperation(possibleOperations[0]);
|
|
71
79
|
setInternalValue(undefined);
|
|
72
80
|
}
|
|
81
|
+
// only reset the text when the value was changed somewhere else, so we don't
|
|
82
|
+
// interfere with what the user is currently typing
|
|
83
|
+
if (syncedValueRef.current !== newValue) {
|
|
84
|
+
syncedValueRef.current = newValue;
|
|
85
|
+
setInputText(filterValueToText(newValue));
|
|
86
|
+
}
|
|
73
87
|
}, [value, possibleOperations[0]]);
|
|
74
88
|
|
|
75
89
|
const isNullOperation = operation === "is-null";
|
|
76
90
|
|
|
77
|
-
|
|
91
|
+
// `keepInputText` is used when the update originates from the text input, where the
|
|
92
|
+
// text typed by the user is authoritative and must not be overwritten with `val`
|
|
93
|
+
function updateFilter(op: VirtualTableWhereFilterOp | "is-null", val: string | number | string[] | number[] | null | undefined, keepInputText = false) {
|
|
78
94
|
// Handle "is null" operation
|
|
79
95
|
if (op === "is-null") {
|
|
80
96
|
setOperation(op);
|
|
81
97
|
setInternalValue(null);
|
|
98
|
+
syncedValueRef.current = null;
|
|
99
|
+
setInputText("");
|
|
82
100
|
setValue(["==", null]);
|
|
83
101
|
return;
|
|
84
102
|
}
|
|
@@ -96,6 +114,9 @@ export function StringNumberFilterField({
|
|
|
96
114
|
|
|
97
115
|
setOperation(op);
|
|
98
116
|
setInternalValue(newValue);
|
|
117
|
+
syncedValueRef.current = newValue;
|
|
118
|
+
if (!keepInputText)
|
|
119
|
+
setInputText(filterValueToText(newValue));
|
|
99
120
|
|
|
100
121
|
const hasNewValue = newValue !== null && Array.isArray(newValue)
|
|
101
122
|
? newValue.length > 0
|
|
@@ -111,6 +132,14 @@ export function StringNumberFilterField({
|
|
|
111
132
|
}
|
|
112
133
|
}
|
|
113
134
|
|
|
135
|
+
function updateFilterFromText(text: string) {
|
|
136
|
+
setInputText(text);
|
|
137
|
+
const { updateValue, value: parsedValue } = parseFilterTextInput(text, dataType);
|
|
138
|
+
// intermediate inputs like "-" or "0." keep the filter as it is
|
|
139
|
+
if (updateValue)
|
|
140
|
+
updateFilter(operation, parsedValue, true);
|
|
141
|
+
}
|
|
142
|
+
|
|
114
143
|
const multiple = multipleSelectOperations.includes(operation);
|
|
115
144
|
|
|
116
145
|
return (
|
|
@@ -138,16 +167,13 @@ export function StringNumberFilterField({
|
|
|
138
167
|
{!enumValues && <TextField
|
|
139
168
|
size={"medium"}
|
|
140
169
|
type={dataType === "number" ? "number" : undefined}
|
|
141
|
-
value={
|
|
170
|
+
value={inputText}
|
|
142
171
|
disabled={isNullOperation}
|
|
143
172
|
placeholder={isNullOperation ? "null" : undefined}
|
|
144
173
|
onChange={(evt) => {
|
|
145
|
-
|
|
146
|
-
? parseFloat(evt.target.value)
|
|
147
|
-
: evt.target.value;
|
|
148
|
-
updateFilter(operation, val);
|
|
174
|
+
updateFilterFromText(evt.target.value);
|
|
149
175
|
}}
|
|
150
|
-
endAdornment={
|
|
176
|
+
endAdornment={inputText !== "" && <IconButton
|
|
151
177
|
onClick={(e) => updateFilter(operation, undefined)}>
|
|
152
178
|
<CloseIcon />
|
|
153
179
|
</IconButton>}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { describe, expect, it } from "@jest/globals";
|
|
2
|
+
import { filterValueToText, parseFilterTextInput } from "./filter_text_input";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Type `text` one character at a time, the way the filter field does, and keep track
|
|
6
|
+
* of the filter value left after every keystroke.
|
|
7
|
+
*/
|
|
8
|
+
function typeCharByChar(text: string, dataType: "string" | "number" = "number") {
|
|
9
|
+
let value: string | number | undefined;
|
|
10
|
+
const valueAfterEachKeystroke: (string | number | undefined)[] = [];
|
|
11
|
+
for (let i = 1; i <= text.length; i++) {
|
|
12
|
+
const result = parseFilterTextInput(text.slice(0, i), dataType);
|
|
13
|
+
if (result.updateValue)
|
|
14
|
+
value = result.value;
|
|
15
|
+
valueAfterEachKeystroke.push(value);
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
value,
|
|
19
|
+
valueAfterEachKeystroke
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("parseFilterTextInput with numbers", () => {
|
|
24
|
+
|
|
25
|
+
it("should parse complete numbers", () => {
|
|
26
|
+
expect(parseFilterTextInput("0.01", "number")).toEqual({
|
|
27
|
+
updateValue: true,
|
|
28
|
+
value: 0.01
|
|
29
|
+
});
|
|
30
|
+
expect(parseFilterTextInput("1", "number")).toEqual({
|
|
31
|
+
updateValue: true,
|
|
32
|
+
value: 1
|
|
33
|
+
});
|
|
34
|
+
expect(parseFilterTextInput("1.5", "number")).toEqual({
|
|
35
|
+
updateValue: true,
|
|
36
|
+
value: 1.5
|
|
37
|
+
});
|
|
38
|
+
expect(parseFilterTextInput("-0.5", "number")).toEqual({
|
|
39
|
+
updateValue: true,
|
|
40
|
+
value: -0.5
|
|
41
|
+
});
|
|
42
|
+
expect(parseFilterTextInput(".5", "number")).toEqual({
|
|
43
|
+
updateValue: true,
|
|
44
|
+
value: 0.5
|
|
45
|
+
});
|
|
46
|
+
expect(parseFilterTextInput("1e5", "number")).toEqual({
|
|
47
|
+
updateValue: true,
|
|
48
|
+
value: 100000
|
|
49
|
+
});
|
|
50
|
+
expect(parseFilterTextInput("0.0100", "number")).toEqual({
|
|
51
|
+
updateValue: true,
|
|
52
|
+
value: 0.01
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("should keep 0 as a valid filter value", () => {
|
|
57
|
+
expect(parseFilterTextInput("0", "number")).toEqual({
|
|
58
|
+
updateValue: true,
|
|
59
|
+
value: 0
|
|
60
|
+
});
|
|
61
|
+
expect(parseFilterTextInput("0.0", "number")).toEqual({
|
|
62
|
+
updateValue: true,
|
|
63
|
+
value: 0
|
|
64
|
+
});
|
|
65
|
+
expect(parseFilterTextInput("00", "number")).toEqual({
|
|
66
|
+
updateValue: true,
|
|
67
|
+
value: 0
|
|
68
|
+
});
|
|
69
|
+
expect(parseFilterTextInput("-0", "number").updateValue).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("should clear the filter for an empty input", () => {
|
|
73
|
+
expect(parseFilterTextInput("", "number")).toEqual({
|
|
74
|
+
updateValue: true,
|
|
75
|
+
value: undefined
|
|
76
|
+
});
|
|
77
|
+
expect(parseFilterTextInput(" ", "number")).toEqual({
|
|
78
|
+
updateValue: true,
|
|
79
|
+
value: undefined
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("should not touch the filter value for intermediate inputs", () => {
|
|
84
|
+
// these are states the user goes through while typing, the text must be kept
|
|
85
|
+
// in the input without the filter value being changed
|
|
86
|
+
expect(parseFilterTextInput("0.", "number")).toEqual({ updateValue: false });
|
|
87
|
+
expect(parseFilterTextInput(".", "number")).toEqual({ updateValue: false });
|
|
88
|
+
expect(parseFilterTextInput("1.", "number")).toEqual({ updateValue: false });
|
|
89
|
+
expect(parseFilterTextInput("-", "number")).toEqual({ updateValue: false });
|
|
90
|
+
expect(parseFilterTextInput("-.", "number")).toEqual({ updateValue: false });
|
|
91
|
+
expect(parseFilterTextInput("+", "number")).toEqual({ updateValue: false });
|
|
92
|
+
expect(parseFilterTextInput("-0.", "number")).toEqual({ updateValue: false });
|
|
93
|
+
expect(parseFilterTextInput("1e", "number")).toEqual({ updateValue: false });
|
|
94
|
+
expect(parseFilterTextInput("1e-", "number")).toEqual({ updateValue: false });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("should not touch the filter value for text that is not a number", () => {
|
|
98
|
+
expect(parseFilterTextInput("abc", "number")).toEqual({ updateValue: false });
|
|
99
|
+
expect(parseFilterTextInput("12abc", "number")).toEqual({ updateValue: false });
|
|
100
|
+
expect(parseFilterTextInput("1.2.3", "number")).toEqual({ updateValue: false });
|
|
101
|
+
expect(parseFilterTextInput("--1", "number")).toEqual({ updateValue: false });
|
|
102
|
+
expect(parseFilterTextInput("0x10", "number")).toEqual({ updateValue: false });
|
|
103
|
+
expect(parseFilterTextInput("Infinity", "number")).toEqual({ updateValue: false });
|
|
104
|
+
expect(parseFilterTextInput("1e999", "number")).toEqual({ updateValue: false });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("should allow typing 0.01 character by character", () => {
|
|
108
|
+
const { value, valueAfterEachKeystroke } = typeCharByChar("0.01");
|
|
109
|
+
expect(value).toBe(0.01);
|
|
110
|
+
// "0" -> 0, "0." -> unchanged, "0.0" -> 0, "0.01" -> 0.01
|
|
111
|
+
expect(valueAfterEachKeystroke).toEqual([0, 0, 0, 0.01]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("should allow typing other decimals character by character", () => {
|
|
115
|
+
expect(typeCharByChar("0.001").value).toBe(0.001);
|
|
116
|
+
expect(typeCharByChar("-0.5").value).toBe(-0.5);
|
|
117
|
+
expect(typeCharByChar(".5").value).toBe(0.5);
|
|
118
|
+
expect(typeCharByChar(".01").value).toBe(0.01);
|
|
119
|
+
expect(typeCharByChar("1.05").value).toBe(1.05);
|
|
120
|
+
expect(typeCharByChar("10.00001").value).toBe(10.00001);
|
|
121
|
+
expect(typeCharByChar("-.5").value).toBe(-0.5);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("should keep the previous value while typing a trailing decimal separator", () => {
|
|
125
|
+
// typing "1." leaves the value at 1, the text "1." stays in the input
|
|
126
|
+
expect(typeCharByChar("1.").value).toBe(1);
|
|
127
|
+
expect(typeCharByChar("1.").valueAfterEachKeystroke).toEqual([1, 1]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("should not commit a value until a leading sign is followed by digits", () => {
|
|
131
|
+
expect(typeCharByChar("-").value).toBeUndefined();
|
|
132
|
+
expect(typeCharByChar("-1").valueAfterEachKeystroke).toEqual([undefined, -1]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("should clear the value when the input is emptied", () => {
|
|
136
|
+
// deleting "0.01" one character at a time, from the end
|
|
137
|
+
const texts = ["0.0", "0.", "0", ""];
|
|
138
|
+
let value: string | number | undefined = 0.01;
|
|
139
|
+
for (const text of texts) {
|
|
140
|
+
const result = parseFilterTextInput(text, "number");
|
|
141
|
+
if (result.updateValue)
|
|
142
|
+
value = result.value;
|
|
143
|
+
}
|
|
144
|
+
expect(value).toBeUndefined();
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("parseFilterTextInput with strings", () => {
|
|
149
|
+
|
|
150
|
+
it("should use the text as the value", () => {
|
|
151
|
+
expect(parseFilterTextInput("abc", "string")).toEqual({
|
|
152
|
+
updateValue: true,
|
|
153
|
+
value: "abc"
|
|
154
|
+
});
|
|
155
|
+
expect(parseFilterTextInput("0.", "string")).toEqual({
|
|
156
|
+
updateValue: true,
|
|
157
|
+
value: "0."
|
|
158
|
+
});
|
|
159
|
+
expect(parseFilterTextInput("", "string")).toEqual({
|
|
160
|
+
updateValue: true,
|
|
161
|
+
value: ""
|
|
162
|
+
});
|
|
163
|
+
expect(parseFilterTextInput(" spaced ", "string")).toEqual({
|
|
164
|
+
updateValue: true,
|
|
165
|
+
value: " spaced "
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("filterValueToText", () => {
|
|
171
|
+
|
|
172
|
+
it("should render values as text", () => {
|
|
173
|
+
expect(filterValueToText(0)).toBe("0");
|
|
174
|
+
expect(filterValueToText(0.01)).toBe("0.01");
|
|
175
|
+
expect(filterValueToText(-0.5)).toBe("-0.5");
|
|
176
|
+
expect(filterValueToText("abc")).toBe("abc");
|
|
177
|
+
expect(filterValueToText("")).toBe("");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("should render empty text for no value", () => {
|
|
181
|
+
expect(filterValueToText(undefined)).toBe("");
|
|
182
|
+
expect(filterValueToText(null)).toBe("");
|
|
183
|
+
});
|
|
184
|
+
});
|