@firecms/core 3.4.0-canary.f6a889a → 3.5.0
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 +24 -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-CBOjCvsy.js +707 -0
- package/dist/de-CBOjCvsy.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-DRfttcG0.js +707 -0
- package/dist/fr-DRfttcG0.js.map +1 -0
- package/dist/hi-DwieOZNf.js +707 -0
- package/dist/hi-DwieOZNf.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 +4059 -11384
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +9090 -7610
- package/dist/index.umd.js.map +1 -1
- package/dist/it-l2KXtx9n.js +707 -0
- package/dist/it-l2KXtx9n.js.map +1 -0
- package/dist/pl-DPZrdKhi.js +709 -0
- package/dist/pl-DPZrdKhi.js.map +1 -0
- package/dist/pt-BoHpabw1.js +709 -0
- package/dist/pt-BoHpabw1.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 +81 -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 +51 -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/local_storage.d.ts +23 -0
- 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 -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/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/EntityCollectionView/utils.ts +4 -4
- package/src/components/EntityPreview.tsx +4 -1
- package/src/components/ErrorBoundary.tsx +12 -18
- package/src/components/LicenseBanner.tsx +287 -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/useBuildLocalConfigurationPersistence.tsx +9 -9
- package/src/hooks/useBuildModeController.tsx +6 -4
- 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 +204 -33
- 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 +28 -2
- package/src/locales/en.ts +33 -5
- package/src/locales/es.ts +28 -2
- package/src/locales/fr.ts +28 -2
- package/src/locales/hi.ts +28 -2
- package/src/locales/it.ts +28 -2
- package/src/locales/pl.ts +28 -2
- package/src/locales/pt.ts +28 -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 +90 -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 +51 -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 +28 -2
- package/src/util/index.ts +2 -0
- package/src/util/lazy_eager.tsx +30 -8
- package/src/util/local_storage.ts +52 -0
- 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
|
@@ -45,6 +45,8 @@ export interface BoardDataController<M extends Record<string, any> = any, COLUMN
|
|
|
45
45
|
export interface UseBoardDataControllerProps<M extends Record<string, any> = any> {
|
|
46
46
|
/** Full path to the collection */
|
|
47
47
|
fullPath: string;
|
|
48
|
+
/** `fullPath` split at its real segment boundaries; forwarded to the datasource. */
|
|
49
|
+
pathSegments?: string[];
|
|
48
50
|
/** The entity collection configuration */
|
|
49
51
|
collection: EntityCollection<M>;
|
|
50
52
|
/** Property key used for column assignment */
|
|
@@ -67,6 +69,7 @@ export interface UseBoardDataControllerProps<M extends Record<string, any> = any
|
|
|
67
69
|
*/
|
|
68
70
|
export function useBoardDataController<M extends Record<string, any> = any, COLUMN extends string = string>({
|
|
69
71
|
fullPath,
|
|
72
|
+
pathSegments,
|
|
70
73
|
collection,
|
|
71
74
|
columnProperty,
|
|
72
75
|
columns,
|
|
@@ -79,7 +82,12 @@ export function useBoardDataController<M extends Record<string, any> = any, COLU
|
|
|
79
82
|
const context = useFireCMSContext();
|
|
80
83
|
const dataSource = useDataSource(collection);
|
|
81
84
|
const navigation = useNavigationController();
|
|
82
|
-
|
|
85
|
+
// Resolved together so the two representations always describe the same chain.
|
|
86
|
+
// Segments are resolved, never derived (see useEntityFetch).
|
|
87
|
+
const resolvedPath = useMemo(() => navigation.resolveIdsFrom(fullPath, pathSegments), [fullPath, pathSegments, navigation.resolveIdsFrom]);
|
|
88
|
+
const resolvedPathSegments = useMemo(() => pathSegments
|
|
89
|
+
? (navigation.resolveSegmentsFrom?.(pathSegments) ?? pathSegments)
|
|
90
|
+
: undefined, [pathSegments, navigation.resolveSegmentsFrom]);
|
|
83
91
|
|
|
84
92
|
// Stable refs for objects that shouldn't trigger re-subscriptions
|
|
85
93
|
const dataSourceRef = useRef(dataSource);
|
|
@@ -95,11 +103,13 @@ export function useBoardDataController<M extends Record<string, any> = any, COLU
|
|
|
95
103
|
const orderPropertyRef = useRef(orderProperty);
|
|
96
104
|
const searchStringRef = useRef(searchString);
|
|
97
105
|
const resolvedPathRef = useRef(resolvedPath);
|
|
106
|
+
const resolvedPathSegmentsRef = useRef(resolvedPathSegments);
|
|
98
107
|
filterValuesRef.current = filterValues;
|
|
99
108
|
columnPropertyRef.current = columnProperty;
|
|
100
109
|
orderPropertyRef.current = orderProperty;
|
|
101
110
|
searchStringRef.current = searchString;
|
|
102
111
|
resolvedPathRef.current = resolvedPath;
|
|
112
|
+
resolvedPathSegmentsRef.current = resolvedPathSegments;
|
|
103
113
|
|
|
104
114
|
// Track item count per column for pagination
|
|
105
115
|
const [columnItemCounts, setColumnItemCounts] = useState<Record<string, number>>(() => {
|
|
@@ -168,6 +178,7 @@ export function useBoardDataController<M extends Record<string, any> = any, COLU
|
|
|
168
178
|
const currentOrderProperty = orderPropertyRef.current;
|
|
169
179
|
const currentSearchString = searchStringRef.current;
|
|
170
180
|
const currentResolvedPath = resolvedPathRef.current;
|
|
181
|
+
const currentPathSegments = resolvedPathSegmentsRef.current;
|
|
171
182
|
|
|
172
183
|
// Build filter for this column
|
|
173
184
|
const columnFilter: FilterValues<string> = {
|
|
@@ -205,6 +216,7 @@ export function useBoardDataController<M extends Record<string, any> = any, COLU
|
|
|
205
216
|
currentCollection.callbacks!.onFetch!({
|
|
206
217
|
collection: currentCollection,
|
|
207
218
|
path: currentResolvedPath,
|
|
219
|
+
pathSegments: currentPathSegments,
|
|
208
220
|
entity,
|
|
209
221
|
context: currentContext
|
|
210
222
|
})
|
|
@@ -248,6 +260,7 @@ export function useBoardDataController<M extends Record<string, any> = any, COLU
|
|
|
248
260
|
if (currentDataSource.listenCollection) {
|
|
249
261
|
const unsubscribe = currentDataSource.listenCollection<M>({
|
|
250
262
|
path: currentResolvedPath,
|
|
263
|
+
pathSegments: currentPathSegments,
|
|
251
264
|
collection: currentCollection,
|
|
252
265
|
onUpdate,
|
|
253
266
|
onError,
|
|
@@ -262,6 +275,7 @@ export function useBoardDataController<M extends Record<string, any> = any, COLU
|
|
|
262
275
|
} else {
|
|
263
276
|
currentDataSource.fetchCollection<M>({
|
|
264
277
|
path: currentResolvedPath,
|
|
278
|
+
pathSegments: currentPathSegments,
|
|
265
279
|
collection: currentCollection,
|
|
266
280
|
searchString: currentSearchString,
|
|
267
281
|
filter: columnFilter,
|
|
@@ -297,6 +311,7 @@ export function useBoardDataController<M extends Record<string, any> = any, COLU
|
|
|
297
311
|
const currentColumnProperty = columnPropertyRef.current;
|
|
298
312
|
const currentSearchString = searchStringRef.current;
|
|
299
313
|
const currentResolvedPath = resolvedPathRef.current;
|
|
314
|
+
const currentPathSegments = resolvedPathSegmentsRef.current;
|
|
300
315
|
const currentColumns = columns;
|
|
301
316
|
const currentColumnItemCounts = columnItemCounts;
|
|
302
317
|
|
|
@@ -316,6 +331,7 @@ export function useBoardDataController<M extends Record<string, any> = any, COLU
|
|
|
316
331
|
};
|
|
317
332
|
currentDataSource.countEntities({
|
|
318
333
|
path: currentResolvedPath,
|
|
334
|
+
pathSegments: currentPathSegments,
|
|
319
335
|
collection: currentCollection,
|
|
320
336
|
filter: columnFilter,
|
|
321
337
|
searchString: currentSearchString
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { getLocalStorageJSON, setLocalStorageItem } from "../../util/local_storage";
|
|
2
|
+
|
|
1
3
|
export function addRecentId(collectionId: string, id: string) {
|
|
2
4
|
const recentIds = getRecentIds(collectionId);
|
|
3
5
|
const newRecentIds = [id, ...recentIds.filter(i => i !== id)];
|
|
@@ -9,11 +11,9 @@ export function addRecentId(collectionId: string, id: string) {
|
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
export function saveSearchedIdsLocally(collectionId: string, ids: string[]) {
|
|
12
|
-
|
|
14
|
+
setLocalStorageItem("recent_id_searches::" + collectionId, JSON.stringify(ids));
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
export function getRecentIds(collectionId: string): string[] {
|
|
16
|
-
|
|
17
|
-
if (!stored) return [];
|
|
18
|
-
return JSON.parse(stored);
|
|
18
|
+
return getLocalStorageJSON("recent_id_searches::" + collectionId, []);
|
|
19
19
|
}
|
|
@@ -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,287 @@
|
|
|
1
|
+
import React, { useContext, useState } from "react";
|
|
2
|
+
import { Alert, Button, CloseIcon, IconButton } from "@firecms/ui";
|
|
3
|
+
import { AccessResponse, ProSuggestion } from "../types";
|
|
4
|
+
import { getLicenseSubscribeUrl, PRO_PLUGIN_KEYS } from "../core/pro_plugins";
|
|
5
|
+
import { CustomizationControllerContext } from "../contexts/CustomizationControllerContext";
|
|
6
|
+
import { useLicenseStatus } from "../hooks/useLicenseStatus";
|
|
7
|
+
import { useTranslation } from "../hooks/useTranslation";
|
|
8
|
+
|
|
9
|
+
const DISMISSED_STORAGE_KEY = "firecms_license_banner_dismissed";
|
|
10
|
+
const DISMISSED_SUGGESTIONS_STORAGE_KEY = "firecms_pro_suggestions_dismissed";
|
|
11
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @group Components
|
|
15
|
+
*/
|
|
16
|
+
export interface LicenseBannerProps {
|
|
17
|
+
/**
|
|
18
|
+
* Applied to the banner's outer element when there is something to show.
|
|
19
|
+
*/
|
|
20
|
+
className?: string;
|
|
21
|
+
style?: React.CSSProperties;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type BannerContent = {
|
|
25
|
+
state: string;
|
|
26
|
+
color: "info" | "warning";
|
|
27
|
+
message: string;
|
|
28
|
+
linkLabel: string;
|
|
29
|
+
href: string;
|
|
30
|
+
/**
|
|
31
|
+
* `session` hides it until the browser session ends, `forever` in this
|
|
32
|
+
* browser for good.
|
|
33
|
+
*/
|
|
34
|
+
dismissal: "none" | "session" | "forever";
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Where each PRO suggestion links: the plugin's docs, which say how to add it.
|
|
39
|
+
* The UTM parameters let the website count the clicks.
|
|
40
|
+
*/
|
|
41
|
+
const SUGGESTION_DOCS_URLS: Record<ProSuggestion["plugin"], string> = {
|
|
42
|
+
user_management: "https://firecms.co/docs/pro/user_management/?utm_source=firecms&utm_medium=cms_suggestion&utm_campaign=user_management",
|
|
43
|
+
entity_history: "https://firecms.co/docs/pro/entity_history/?utm_source=firecms&utm_medium=cms_suggestion&utm_campaign=entity_history"
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Tells the user where their FireCMS PRO license stands: days left in the
|
|
48
|
+
* trial, or why PRO is paused (the trial ended, the key is not linked to this
|
|
49
|
+
* project, or the license pays for other projects). Renders nothing when
|
|
50
|
+
* licensed and while the status is unknown. When no license is needed, it
|
|
51
|
+
* shows only a PRO plugin the license check suggests for this project, if any.
|
|
52
|
+
*
|
|
53
|
+
* The default `Scaffold` already renders it above the main content. Place it
|
|
54
|
+
* yourself only in a custom layout.
|
|
55
|
+
*
|
|
56
|
+
* @group Components
|
|
57
|
+
*/
|
|
58
|
+
export function LicenseBanner({
|
|
59
|
+
className,
|
|
60
|
+
style
|
|
61
|
+
}: LicenseBannerProps) {
|
|
62
|
+
|
|
63
|
+
const licenseStatus = useLicenseStatus();
|
|
64
|
+
const { t, i18n } = useTranslation();
|
|
65
|
+
|
|
66
|
+
// The server answers `not_required` to every localhost and FireCMS Cloud
|
|
67
|
+
// request, whatever the app mounts, so a suggestion is only shown to an
|
|
68
|
+
// app that really runs no PRO plugin.
|
|
69
|
+
const plugins = useContext(CustomizationControllerContext)?.plugins;
|
|
70
|
+
const runsProPlugin = plugins?.some(plugin => PRO_PLUGIN_KEYS.includes(plugin.key)) ?? false;
|
|
71
|
+
|
|
72
|
+
// States dismissed in this browser session; a different state shows again.
|
|
73
|
+
const [dismissed, setDismissed] = useState<string[]>(() => readDismissed(sessionDismissals));
|
|
74
|
+
// Suggestions dismissed in this browser, for good.
|
|
75
|
+
const [dismissedForGood, setDismissedForGood] = useState<string[]>(() => readDismissed(browserDismissals));
|
|
76
|
+
|
|
77
|
+
// `language`, not `resolvedLanguage`: locale bundles are added after init,
|
|
78
|
+
// and `resolvedLanguage` stays on the English fallback when they are.
|
|
79
|
+
const content = licenseStatus
|
|
80
|
+
? buildBannerContent(licenseStatus, t, i18n?.language, runsProPlugin)
|
|
81
|
+
: null;
|
|
82
|
+
|
|
83
|
+
if (!content
|
|
84
|
+
|| (content.dismissal === "session" && dismissed.includes(content.state))
|
|
85
|
+
|| (content.dismissal === "forever" && dismissedForGood.includes(content.state)))
|
|
86
|
+
return null;
|
|
87
|
+
|
|
88
|
+
const dismiss = () => {
|
|
89
|
+
if (content.dismissal === "forever") {
|
|
90
|
+
const next = [...dismissedForGood, content.state];
|
|
91
|
+
writeDismissed(browserDismissals, next);
|
|
92
|
+
setDismissedForGood(next);
|
|
93
|
+
} else {
|
|
94
|
+
const next = [...dismissed, content.state];
|
|
95
|
+
writeDismissed(sessionDismissals, next);
|
|
96
|
+
setDismissed(next);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<div className={className} style={style}>
|
|
102
|
+
<Alert
|
|
103
|
+
color={content.color}
|
|
104
|
+
size={"small"}
|
|
105
|
+
className={"text-sm"}
|
|
106
|
+
action={<div className={"flex flex-row items-center gap-1 flex-shrink-0"}>
|
|
107
|
+
<Button
|
|
108
|
+
variant={"text"}
|
|
109
|
+
size={"small"}
|
|
110
|
+
component={"a"}
|
|
111
|
+
href={content.href}
|
|
112
|
+
target={"_blank"}
|
|
113
|
+
rel={"noopener noreferrer"}>
|
|
114
|
+
{content.linkLabel}
|
|
115
|
+
</Button>
|
|
116
|
+
{content.dismissal !== "none" && <IconButton
|
|
117
|
+
size={"small"}
|
|
118
|
+
aria-label={t("close")}
|
|
119
|
+
onClick={dismiss}>
|
|
120
|
+
<CloseIcon size={"small"}/>
|
|
121
|
+
</IconButton>}
|
|
122
|
+
</div>}>
|
|
123
|
+
{content.message}
|
|
124
|
+
</Alert>
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
type Translate = ReturnType<typeof useTranslation>["t"];
|
|
130
|
+
|
|
131
|
+
function buildBannerContent(status: AccessResponse,
|
|
132
|
+
t: Translate,
|
|
133
|
+
language: string | undefined,
|
|
134
|
+
runsProPlugin: boolean): BannerContent | null {
|
|
135
|
+
const href = getLicenseSubscribeUrl(status);
|
|
136
|
+
switch (status.licenseState) {
|
|
137
|
+
case "trial": {
|
|
138
|
+
const days = trialDaysLeft(status);
|
|
139
|
+
if (days === null) return null;
|
|
140
|
+
return {
|
|
141
|
+
state: "trial",
|
|
142
|
+
color: "info",
|
|
143
|
+
message: t("license_trial_banner", {
|
|
144
|
+
count: days,
|
|
145
|
+
days: String(days)
|
|
146
|
+
}),
|
|
147
|
+
linkLabel: t("license_get_license"),
|
|
148
|
+
href,
|
|
149
|
+
dismissal: "session"
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
case "expired": {
|
|
153
|
+
const date = formatDate(status.trialEndsAt, language);
|
|
154
|
+
return {
|
|
155
|
+
state: "expired",
|
|
156
|
+
color: "warning",
|
|
157
|
+
message: date
|
|
158
|
+
? t("license_expired_banner", { date })
|
|
159
|
+
: t("license_expired_banner_undated"),
|
|
160
|
+
linkLabel: t("license_get_license"),
|
|
161
|
+
href,
|
|
162
|
+
dismissal: "none"
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
case "invalid_project":
|
|
166
|
+
return {
|
|
167
|
+
state: "invalid_project",
|
|
168
|
+
color: "warning",
|
|
169
|
+
message: status.projectId
|
|
170
|
+
? t("license_invalid_project_banner", { projectId: status.projectId })
|
|
171
|
+
: t("license_invalid_project_banner_unnamed"),
|
|
172
|
+
linkLabel: t("license_add_project_to_license"),
|
|
173
|
+
href,
|
|
174
|
+
dismissal: "none"
|
|
175
|
+
};
|
|
176
|
+
case "over_quota": {
|
|
177
|
+
const licensed = status.licensedProjects;
|
|
178
|
+
if (!isCount(licensed)) return null;
|
|
179
|
+
return {
|
|
180
|
+
state: "over_quota",
|
|
181
|
+
color: "warning",
|
|
182
|
+
message: t("license_over_quota_banner", {
|
|
183
|
+
count: licensed,
|
|
184
|
+
licensed: String(licensed)
|
|
185
|
+
}),
|
|
186
|
+
linkLabel: t("license_update_license"),
|
|
187
|
+
href,
|
|
188
|
+
dismissal: "none"
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
case "not_required":
|
|
192
|
+
return runsProPlugin ? null : buildSuggestionContent(status.suggestion, t);
|
|
193
|
+
default:
|
|
194
|
+
// licensed, whitelisted, and servers too old to say
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function buildSuggestionContent(suggestion: ProSuggestion | undefined,
|
|
200
|
+
t: Translate): BannerContent | null {
|
|
201
|
+
if (!suggestion || typeof suggestion !== "object") return null;
|
|
202
|
+
let message: string;
|
|
203
|
+
switch (suggestion.plugin) {
|
|
204
|
+
case "user_management": {
|
|
205
|
+
const users = suggestion.users;
|
|
206
|
+
// The copy is plural only: one person is not a reason to suggest roles.
|
|
207
|
+
message = isCount(users) && users >= 2
|
|
208
|
+
? t("pro_suggestion_user_management", { users: String(Math.floor(users)) })
|
|
209
|
+
: t("pro_suggestion_user_management_uncounted");
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
case "entity_history":
|
|
213
|
+
message = t("pro_suggestion_entity_history");
|
|
214
|
+
break;
|
|
215
|
+
default:
|
|
216
|
+
// A plugin this version has no copy for.
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
state: `suggestion_${suggestion.plugin}`,
|
|
221
|
+
color: "info",
|
|
222
|
+
message,
|
|
223
|
+
linkLabel: t("pro_suggestion_learn_more"),
|
|
224
|
+
href: SUGGESTION_DOCS_URLS[suggestion.plugin],
|
|
225
|
+
dismissal: "forever"
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function isCount(value: unknown): value is number {
|
|
230
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function trialDaysLeft(status: AccessResponse): number | null {
|
|
234
|
+
if (isCount(status.daysLeft)) return Math.ceil(status.daysLeft);
|
|
235
|
+
const end = status.trialEndsAt ? new Date(status.trialEndsAt).getTime() : NaN;
|
|
236
|
+
if (Number.isNaN(end)) return null;
|
|
237
|
+
return Math.max(0, Math.ceil((end - Date.now()) / DAY_MS));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function formatDate(iso: string | undefined, language: string | undefined): string | null {
|
|
241
|
+
if (!iso) return null;
|
|
242
|
+
const date = new Date(iso);
|
|
243
|
+
if (Number.isNaN(date.getTime())) return null;
|
|
244
|
+
const options: Intl.DateTimeFormatOptions = {
|
|
245
|
+
year: "numeric",
|
|
246
|
+
month: "long",
|
|
247
|
+
day: "numeric"
|
|
248
|
+
};
|
|
249
|
+
try {
|
|
250
|
+
return new Intl.DateTimeFormat(language, options).format(date);
|
|
251
|
+
} catch {
|
|
252
|
+
return date.toLocaleDateString(undefined, options);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
type DismissedStore = {
|
|
257
|
+
storage: () => Storage;
|
|
258
|
+
key: string;
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const sessionDismissals: DismissedStore = {
|
|
262
|
+
storage: () => sessionStorage,
|
|
263
|
+
key: DISMISSED_STORAGE_KEY
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const browserDismissals: DismissedStore = {
|
|
267
|
+
storage: () => localStorage,
|
|
268
|
+
key: DISMISSED_SUGGESTIONS_STORAGE_KEY
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
function readDismissed(store: DismissedStore): string[] {
|
|
272
|
+
try {
|
|
273
|
+
const value = store.storage().getItem(store.key);
|
|
274
|
+
const parsed = value ? JSON.parse(value) : [];
|
|
275
|
+
return Array.isArray(parsed) ? parsed.filter((s): s is string => typeof s === "string") : [];
|
|
276
|
+
} catch {
|
|
277
|
+
return [];
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function writeDismissed(store: DismissedStore, states: string[]) {
|
|
282
|
+
try {
|
|
283
|
+
store.storage().setItem(store.key, JSON.stringify(states));
|
|
284
|
+
} catch {
|
|
285
|
+
// Private mode or storage disabled: dismissed for this page view only.
|
|
286
|
+
}
|
|
287
|
+
}
|
|
@@ -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>}
|