@firecms/core 3.4.0-canary.e2466ba → 3.4.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/dist/components/ErrorBoundary.d.ts +1 -3
- 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 +266 -222
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +265 -221
- package/dist/index.umd.js.map +1 -1
- package/dist/util/entity_cache.d.ts +11 -0
- package/package.json +3 -3
- package/src/components/EntityCollectionTable/EntityCollectionRowActions.tsx +2 -2
- package/src/components/EntityCollectionView/EntityCollectionView.tsx +2 -1
- package/src/components/ErrorBoundary.tsx +12 -18
- package/src/components/common/default_entity_actions.tsx +6 -2
- package/src/components/common/useDataSourceTableController.tsx +15 -3
- package/src/core/EntityEditView.tsx +2 -2
- package/src/core/EntitySidePanel.tsx +2 -2
- package/src/form/EntityForm.tsx +6 -5
- package/src/hooks/data/useEntityFetch.tsx +4 -3
- package/src/i18n/FireCMSi18nProvider.tsx +48 -4
- package/src/i18n/__tests__/FireCMSi18nProvider.test.tsx +91 -0
- package/src/i18n/__tests__/nested_provider_lifecycle.test.tsx +67 -0
- package/src/internal/useBuildSideEntityController.tsx +13 -5
- package/src/preview/components/ReferencePreview.tsx +3 -2
- package/src/util/entity_cache.ts +18 -0
- package/src/util/navigation_from_path.ts +6 -1
- package/src/util/navigation_utils.ts +7 -0
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Key identifying one entity in a cache.
|
|
3
|
+
*
|
|
4
|
+
* The id is escaped so that a `(path, entityId)` pair maps to exactly one key. Without it
|
|
5
|
+
* `("a", "b/c")` and `("a/b", "c")` both flatten to `"a/b/c"`, and one entity's cached
|
|
6
|
+
* values are served for the other.
|
|
7
|
+
*
|
|
8
|
+
* `encodeEntityId` is the identity for any id without "/", "?", "#" or "%", so this changes
|
|
9
|
+
* no key in an app whose ids cannot contain them.
|
|
10
|
+
*/
|
|
11
|
+
export declare function entityCacheKey(path: string | undefined, entityId: string | undefined): string;
|
|
1
12
|
/**
|
|
2
13
|
* Saves data to the in-memory cache and persists it individually in `localStorage`.
|
|
3
14
|
* @param path - The unique path/key for the data.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@firecms/core",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "3.4.0
|
|
4
|
+
"version": "3.4.0",
|
|
5
5
|
"description": "Awesome Firebase/Firestore-based headless open-source CMS",
|
|
6
6
|
"funding": {
|
|
7
7
|
"url": "https://github.com/sponsors/firecmsco"
|
|
@@ -53,8 +53,8 @@
|
|
|
53
53
|
"@dnd-kit/core": "^6.3.1",
|
|
54
54
|
"@dnd-kit/modifiers": "^9.0.0",
|
|
55
55
|
"@dnd-kit/sortable": "^10.0.0",
|
|
56
|
-
"@firecms/formex": "3.4.0
|
|
57
|
-
"@firecms/ui": "3.4.0
|
|
56
|
+
"@firecms/formex": "3.4.0",
|
|
57
|
+
"@firecms/ui": "3.4.0",
|
|
58
58
|
"@floating-ui/dom": "^1.7.4",
|
|
59
59
|
"@radix-ui/react-portal": "^1.1.10",
|
|
60
60
|
"@radix-ui/react-slot": "^1.2.4",
|
|
@@ -3,7 +3,7 @@ import React, { MouseEvent, useCallback } from "react";
|
|
|
3
3
|
import { CollectionSize, Entity, EntityAction, EntityCollection, SelectionController } from "../../types";
|
|
4
4
|
import { Badge, Checkbox, cls, IconButton, Menu, MenuItem, MoreVertIcon, Skeleton, Tooltip } from "@firecms/ui";
|
|
5
5
|
import { useFireCMSContext, useLargeLayout } from "../../hooks";
|
|
6
|
-
import { getEntityFromCache } from "../../util/entity_cache";
|
|
6
|
+
import { entityCacheKey, getEntityFromCache } from "../../util/entity_cache";
|
|
7
7
|
import { getLocalChangesBackup } from "../../util";
|
|
8
8
|
import { getChanges } from "../../form/EntityForm";
|
|
9
9
|
|
|
@@ -82,7 +82,7 @@ export const EntityCollectionRowActions = function EntityCollectionRowActions({
|
|
|
82
82
|
const collapsedActions = actions.filter(a => a.collapsed || a.collapsed === undefined);
|
|
83
83
|
const uncollapsedActions = actions.filter(a => a.collapsed === false);
|
|
84
84
|
const enableLocalChangesBackup = collection ? getLocalChangesBackup(collection) : false;
|
|
85
|
-
const cachedData = enableLocalChangesBackup ? getEntityFromCache(fullPath
|
|
85
|
+
const cachedData = enableLocalChangesBackup ? getEntityFromCache(entityCacheKey(fullPath, entity.id)) : undefined;
|
|
86
86
|
const hasDraft = (() => {
|
|
87
87
|
if (!cachedData || typeof cachedData !== "object" || Object.keys(cachedData).length === 0) return false;
|
|
88
88
|
const realChanges = getChanges(cachedData as any, (entity?.values ?? {}) as any);
|
|
@@ -90,6 +90,7 @@ import { useSelectionController } from "./useSelectionController";
|
|
|
90
90
|
import { EntityCollectionViewStartActions } from "./EntityCollectionViewStartActions";
|
|
91
91
|
import { addRecentId, getRecentIds } from "./utils";
|
|
92
92
|
import { useScrollRestoration } from "../common/useScrollRestoration";
|
|
93
|
+
import { entityCacheKey } from "../../util/entity_cache";
|
|
93
94
|
|
|
94
95
|
const DEFAULT_ENTITY_OPEN_MODE: "side_panel" | "full_screen" = "side_panel";
|
|
95
96
|
|
|
@@ -693,7 +694,7 @@ export const EntityCollectionView = React.memo(
|
|
|
693
694
|
{collectionsWithPath.map((reference) => {
|
|
694
695
|
return (
|
|
695
696
|
<ReferencePreview
|
|
696
|
-
key={reference.path
|
|
697
|
+
key={entityCacheKey(reference.path, reference.id)}
|
|
697
698
|
reference={reference}
|
|
698
699
|
size={"small"} />
|
|
699
700
|
);
|
|
@@ -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
|
}
|
|
@@ -84,6 +84,7 @@ export const copyEntityAction: EntityAction = {
|
|
|
84
84
|
context,
|
|
85
85
|
fullPath,
|
|
86
86
|
pathSegments,
|
|
87
|
+
fullIdPath,
|
|
87
88
|
highlightEntity,
|
|
88
89
|
unhighlightEntity,
|
|
89
90
|
openEntityMode
|
|
@@ -104,14 +105,17 @@ export const copyEntityAction: EntityAction = {
|
|
|
104
105
|
const resolvedPathSegments = collection?.collectionGroup
|
|
105
106
|
? undefined
|
|
106
107
|
: (pathSegments ?? entity.pathSegments);
|
|
107
|
-
|
|
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);
|
|
108
112
|
navigateToEntity({
|
|
109
113
|
openEntityMode,
|
|
110
114
|
collection,
|
|
111
115
|
entityId: entity.id,
|
|
112
116
|
path,
|
|
113
117
|
pathSegments: resolvedPathSegments,
|
|
114
|
-
fullIdPath,
|
|
118
|
+
fullIdPath: newFullIdPath,
|
|
115
119
|
copy: true,
|
|
116
120
|
sideEntityController: context.sideEntityController,
|
|
117
121
|
onClose: () => unhighlightEntity?.(entity),
|
|
@@ -194,11 +194,23 @@ export function useDataSourceTableController<M extends Record<string, any> = any
|
|
|
194
194
|
|
|
195
195
|
useUpdateUrl(filterValues, sortBy, searchString, updateUrl);
|
|
196
196
|
|
|
197
|
-
|
|
198
|
-
|
|
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;
|
|
199
206
|
|
|
200
207
|
useEffect(() => {
|
|
201
|
-
|
|
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) {
|
|
202
214
|
scrollRestoration.updateCollectionScroll({
|
|
203
215
|
fullPath: resolvedPath,
|
|
204
216
|
scrollOffset: collectionScroll?.scrollOffset ?? 0,
|
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
useLargeLayout
|
|
30
30
|
} from "../hooks";
|
|
31
31
|
import { CircularProgress, cls, CodeIcon, defaultBorderMixin, Tab, Tabs, Typography, Menu, MenuItem, ExpandMoreIcon } from "@firecms/ui";
|
|
32
|
-
import { getEntityFromMemoryCache } from "../util/entity_cache";
|
|
32
|
+
import { entityCacheKey, getEntityFromMemoryCache } from "../util/entity_cache";
|
|
33
33
|
import { EntityForm, EntityFormProps } from "../form";
|
|
34
34
|
import { EntityEditViewFormActions } from "./EntityEditViewFormActions";
|
|
35
35
|
import { EntityJsonPreview } from "../components/EntityJsonPreview";
|
|
@@ -118,7 +118,7 @@ export function EntityEditView<M extends Record<string, any>, USER extends User>
|
|
|
118
118
|
});
|
|
119
119
|
|
|
120
120
|
const initialDirtyValues = entityId
|
|
121
|
-
? getEntityFromMemoryCache(props.path
|
|
121
|
+
? getEntityFromMemoryCache(entityCacheKey(props.path, entityId))
|
|
122
122
|
: getEntityFromMemoryCache(props.path + "#new");
|
|
123
123
|
|
|
124
124
|
const authController = useAuthController();
|
|
@@ -8,7 +8,7 @@ import { EntityEditView, OnUpdateParams } from "./EntityEditView";
|
|
|
8
8
|
import { useSideDialogContext } from "./SideDialogs";
|
|
9
9
|
import { CloseIcon, IconButton, OpenInFullIcon } from "@firecms/ui";
|
|
10
10
|
import { useLocation, useNavigate } from "react-router-dom";
|
|
11
|
-
import { saveEntityToMemoryCache } from "../util/entity_cache";
|
|
11
|
+
import { entityCacheKey, saveEntityToMemoryCache } from "../util/entity_cache";
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* This is the component in charge of rendering the side dialog used
|
|
@@ -138,7 +138,7 @@ export function EntitySidePanel(props: EntitySidePanelProps) {
|
|
|
138
138
|
className="self-center"
|
|
139
139
|
size={"smallest"}
|
|
140
140
|
onClick={() => {
|
|
141
|
-
const key = (status === "new" || status === "copy") ? path + "#new" : path
|
|
141
|
+
const key = (status === "new" || status === "copy") ? path + "#new" : entityCacheKey(path, entityId);
|
|
142
142
|
saveEntityToMemoryCache(key, values);
|
|
143
143
|
if (entityId)
|
|
144
144
|
navigate(location.pathname + location.search);
|
package/src/form/EntityForm.tsx
CHANGED
|
@@ -48,6 +48,7 @@ import { useAnalyticsController } from "../hooks/useAnalyticsController";
|
|
|
48
48
|
import { FormEntry, FormLayout, LabelWithIconAndTooltip, PropertyFieldBinding } from "../form";
|
|
49
49
|
import { ValidationError } from "yup";
|
|
50
50
|
import {
|
|
51
|
+
entityCacheKey,
|
|
51
52
|
flattenKeys,
|
|
52
53
|
getEntityFromCache,
|
|
53
54
|
removeEntityFromCache,
|
|
@@ -317,7 +318,7 @@ export function EntityForm<M extends Record<string, any>>({
|
|
|
317
318
|
const baseInitialValues = useMemo(() => getInitialEntityValues(authController, collection, path, status, entity, customizationController.propertyConfigs), [authController, collection, path, status, entity, customizationController.propertyConfigs]);
|
|
318
319
|
|
|
319
320
|
const localChangesDataRaw = useMemo(() => entityId
|
|
320
|
-
? getEntityFromCache(path
|
|
321
|
+
? getEntityFromCache(entityCacheKey(path, entityId))
|
|
321
322
|
: getEntityFromCache(path + "#new"), [entityId, path]);
|
|
322
323
|
|
|
323
324
|
const [localChangesCleared, setLocalChangesCleared] = useState<boolean>(false);
|
|
@@ -396,7 +397,7 @@ export function EntityForm<M extends Record<string, any>>({
|
|
|
396
397
|
onValuesModified?.(false, initialValues as M);
|
|
397
398
|
},
|
|
398
399
|
onValuesChangeDeferred: (values: M, controller: FormexController<M>) => {
|
|
399
|
-
const key = (status === "new" || status === "copy") ? path + "#new" : path
|
|
400
|
+
const key = (status === "new" || status === "copy") ? path + "#new" : entityCacheKey(path, entityId);
|
|
400
401
|
if (controller.dirty && localChangesBackup !== false) {
|
|
401
402
|
const touchedValues = removeEmptyContainers(extractTouchedValues(values, controller.touched));
|
|
402
403
|
if (touchedValues && Object.keys(touchedValues).length > 0) {
|
|
@@ -477,8 +478,8 @@ export function EntityForm<M extends Record<string, any>>({
|
|
|
477
478
|
removeEntityFromMemoryCache(path + "#new");
|
|
478
479
|
removeEntityFromCache(path + "#new");
|
|
479
480
|
} else {
|
|
480
|
-
removeEntityFromMemoryCache(path
|
|
481
|
-
removeEntityFromCache(path
|
|
481
|
+
removeEntityFromMemoryCache(entityCacheKey(path, entityId));
|
|
482
|
+
removeEntityFromCache(entityCacheKey(path, entityId));
|
|
482
483
|
}
|
|
483
484
|
}
|
|
484
485
|
|
|
@@ -944,7 +945,7 @@ export function EntityForm<M extends Record<string, any>>({
|
|
|
944
945
|
|
|
945
946
|
{manualApplyLocalChanges && hasLocalChanges &&
|
|
946
947
|
<LocalChangesMenu
|
|
947
|
-
cacheKey={status === "new" || status === "copy" ? path + "#new" : path
|
|
948
|
+
cacheKey={status === "new" || status === "copy" ? path + "#new" : entityCacheKey(path, entityId)}
|
|
948
949
|
properties={resolvedCollection.properties}
|
|
949
950
|
cachedData={localChangesDataRaw as Partial<M>}
|
|
950
951
|
formex={formex}
|
|
@@ -3,6 +3,7 @@ import { Entity, EntityCollection, FireCMSContext, User } from "../../types";
|
|
|
3
3
|
import { useDataSource } from "./useDataSource";
|
|
4
4
|
import { useNavigationController } from "../useNavigationController";
|
|
5
5
|
import { useFireCMSContext } from "../useFireCMSContext";
|
|
6
|
+
import { entityCacheKey } from "../../util/entity_cache";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* @group Hooks and utilities
|
|
@@ -101,7 +102,7 @@ export function useEntityFetch<M extends Record<string, any>, USER extends User>
|
|
|
101
102
|
console.error(e);
|
|
102
103
|
}
|
|
103
104
|
}
|
|
104
|
-
CACHE[
|
|
105
|
+
CACHE[entityCacheKey(path, entityId)] = updatedEntity;
|
|
105
106
|
setEntity(updatedEntity);
|
|
106
107
|
setDataLoading(false);
|
|
107
108
|
setDataLoadingError(undefined);
|
|
@@ -114,8 +115,8 @@ export function useEntityFetch<M extends Record<string, any>, USER extends User>
|
|
|
114
115
|
setDataLoadingError(error);
|
|
115
116
|
};
|
|
116
117
|
|
|
117
|
-
if (entityId && useCache && CACHE[
|
|
118
|
-
setEntity(CACHE[
|
|
118
|
+
if (entityId && useCache && CACHE[entityCacheKey(path, entityId)]) {
|
|
119
|
+
setEntity(CACHE[entityCacheKey(path, entityId)]);
|
|
119
120
|
setDataLoading(false);
|
|
120
121
|
setDataLoadingError(undefined);
|
|
121
122
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import React, { PropsWithChildren, useEffect, useRef } from "react";
|
|
1
|
+
import React, { PropsWithChildren, useContext, useEffect, useMemo, useRef } from "react";
|
|
2
2
|
import i18next, { i18n } from "i18next";
|
|
3
|
-
import { I18nextProvider, initReactI18next } from "react-i18next";
|
|
3
|
+
import { I18nContext, I18nextProvider, initReactI18next } from "react-i18next";
|
|
4
4
|
import { en } from "../locales/en";
|
|
5
5
|
import { es } from "../locales/es";
|
|
6
6
|
import { de } from "../locales/de";
|
|
@@ -50,16 +50,48 @@ export function FireCMSi18nProvider({
|
|
|
50
50
|
translations,
|
|
51
51
|
children
|
|
52
52
|
}: PropsWithChildren<FireCMSi18nProviderProps>) {
|
|
53
|
+
|
|
54
|
+
// A FireCMS i18next instance already in context means this provider is nested —
|
|
55
|
+
// e.g. FireCMSCloudApp mounts one inside the one the host app already mounted.
|
|
56
|
+
// A second instance built only from the defaults would shadow the parent's and
|
|
57
|
+
// silently drop every translation the host registered, leaving the host's own
|
|
58
|
+
// strings to render as raw keys.
|
|
59
|
+
//
|
|
60
|
+
// So the nested instance is *seeded* from the parent's resources rather than
|
|
61
|
+
// sharing them. Sharing one mutable instance was the obvious alternative and is
|
|
62
|
+
// wrong: bundles added by one provider outlive it, so opening one project's app
|
|
63
|
+
// and then switching to another left the first project's translations answering
|
|
64
|
+
// for keys the second never defined. Copying keeps each provider's strings to
|
|
65
|
+
// itself, which is also how this behaved before nesting was handled at all.
|
|
66
|
+
const parentI18n = useContext(I18nContext)?.i18n;
|
|
67
|
+
const parentInstance = parentI18n?.hasResourceBundle?.("en", FIRECMS_NS)
|
|
68
|
+
? parentI18n
|
|
69
|
+
: undefined;
|
|
70
|
+
|
|
53
71
|
const i18nRef = useRef<i18n | null>(null);
|
|
54
72
|
const [ready, setReady] = React.useState(false);
|
|
55
73
|
|
|
56
74
|
if (!i18nRef.current) {
|
|
57
75
|
const instance = i18next.createInstance();
|
|
58
76
|
|
|
59
|
-
//
|
|
77
|
+
// English baseline + this provider's overrides, with anything the parent
|
|
78
|
+
// already resolved layered in underneath the overrides.
|
|
60
79
|
const resources = buildResources(translations);
|
|
80
|
+
if (parentInstance) {
|
|
81
|
+
const inherited: Record<string, any> = parentInstance.services?.resourceStore?.data ?? {};
|
|
82
|
+
for (const [lang, namespaces] of Object.entries(inherited)) {
|
|
83
|
+
const bundle = (namespaces as any)?.[FIRECMS_NS];
|
|
84
|
+
if (!bundle) continue;
|
|
85
|
+
resources[lang] = {
|
|
86
|
+
[FIRECMS_NS]: {
|
|
87
|
+
...bundle,
|
|
88
|
+
...(translations?.[lang] ?? {})
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
}
|
|
61
93
|
|
|
62
|
-
let initialLocale = locale;
|
|
94
|
+
let initialLocale = parentInstance?.language ?? locale;
|
|
63
95
|
if (typeof window !== "undefined") {
|
|
64
96
|
const stored = localStorage.getItem(FIRECMS_LOCALE_STORAGE_KEY);
|
|
65
97
|
if (stored) initialLocale = stored;
|
|
@@ -90,6 +122,18 @@ export function FireCMSi18nProvider({
|
|
|
90
122
|
i18nRef.current = instance;
|
|
91
123
|
}
|
|
92
124
|
|
|
125
|
+
// Follow the parent's language, so a switch made outside this provider is not
|
|
126
|
+
// stranded on the other side of the boundary.
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
if (!parentInstance || !i18nRef.current) return;
|
|
129
|
+
const instance = i18nRef.current;
|
|
130
|
+
const follow = (lng: string) => {
|
|
131
|
+
if (instance.language !== lng) instance.changeLanguage(lng);
|
|
132
|
+
};
|
|
133
|
+
parentInstance.on("languageChanged", follow);
|
|
134
|
+
return () => { parentInstance.off("languageChanged", follow); };
|
|
135
|
+
}, [parentInstance]);
|
|
136
|
+
|
|
93
137
|
// When `locale` prop changes, switch language on the existing instance
|
|
94
138
|
// ONLY if the user hasn't explicitly set a preference
|
|
95
139
|
useEffect(() => {
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import React from "react";
|
|
5
|
+
import { render, screen } from "@testing-library/react";
|
|
6
|
+
import { useTranslation } from "react-i18next";
|
|
7
|
+
import { FireCMSi18nProvider } from "../FireCMSi18nProvider";
|
|
8
|
+
|
|
9
|
+
/** Reads a key through the same channel application code uses. */
|
|
10
|
+
function Str({ k }: { k: string }) {
|
|
11
|
+
const { t } = useTranslation("firecms_core");
|
|
12
|
+
return <span data-testid={k}>{t(k)}</span>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const HOST_TRANSLATIONS = {
|
|
16
|
+
en: {
|
|
17
|
+
host_only_key: "Explore your Firestore data",
|
|
18
|
+
save: "Publish" // an override of a real core key
|
|
19
|
+
}
|
|
20
|
+
} as any;
|
|
21
|
+
|
|
22
|
+
describe("FireCMSi18nProvider", () => {
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
window.localStorage.clear();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("resolves its own translations", () => {
|
|
29
|
+
render(
|
|
30
|
+
<FireCMSi18nProvider translations={HOST_TRANSLATIONS}>
|
|
31
|
+
<Str k={"host_only_key"}/>
|
|
32
|
+
</FireCMSi18nProvider>
|
|
33
|
+
);
|
|
34
|
+
expect(screen.getByTestId("host_only_key").textContent).toBe("Explore your Firestore data");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("keeps the host's translations when a nested provider is mounted without any", () => {
|
|
38
|
+
// This is the FireCMSCloudApp case: it mounts its own provider with
|
|
39
|
+
// `translations={appConfig?.translations}`, which is undefined for every
|
|
40
|
+
// project that has not deployed custom code. A nested provider that built its
|
|
41
|
+
// own isolated instance would shadow the host's and render the raw key.
|
|
42
|
+
render(
|
|
43
|
+
<FireCMSi18nProvider translations={HOST_TRANSLATIONS}>
|
|
44
|
+
<FireCMSi18nProvider translations={undefined}>
|
|
45
|
+
<Str k={"host_only_key"}/>
|
|
46
|
+
</FireCMSi18nProvider>
|
|
47
|
+
</FireCMSi18nProvider>
|
|
48
|
+
);
|
|
49
|
+
expect(screen.getByTestId("host_only_key").textContent).toBe("Explore your Firestore data");
|
|
50
|
+
expect(screen.getByTestId("host_only_key").textContent).not.toBe("host_only_key");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("does not let a nested provider clobber a host override", () => {
|
|
54
|
+
render(
|
|
55
|
+
<FireCMSi18nProvider translations={HOST_TRANSLATIONS}>
|
|
56
|
+
<FireCMSi18nProvider translations={undefined}>
|
|
57
|
+
<Str k={"save"}/>
|
|
58
|
+
</FireCMSi18nProvider>
|
|
59
|
+
</FireCMSi18nProvider>
|
|
60
|
+
);
|
|
61
|
+
expect(screen.getByTestId("save").textContent).toBe("Publish");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("lets a nested provider add its own translations on top of the host's", () => {
|
|
65
|
+
render(
|
|
66
|
+
<FireCMSi18nProvider translations={HOST_TRANSLATIONS}>
|
|
67
|
+
<FireCMSi18nProvider translations={{ en: { nested_key: "From the nested provider" } } as any}>
|
|
68
|
+
<>
|
|
69
|
+
<Str k={"host_only_key"}/>
|
|
70
|
+
<Str k={"nested_key"}/>
|
|
71
|
+
</>
|
|
72
|
+
</FireCMSi18nProvider>
|
|
73
|
+
</FireCMSi18nProvider>
|
|
74
|
+
);
|
|
75
|
+
expect(screen.getByTestId("host_only_key").textContent).toBe("Explore your Firestore data");
|
|
76
|
+
expect(screen.getByTestId("nested_key").textContent).toBe("From the nested provider");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("still serves the built-in core strings through a nested provider", () => {
|
|
80
|
+
render(
|
|
81
|
+
<FireCMSi18nProvider>
|
|
82
|
+
<FireCMSi18nProvider translations={undefined}>
|
|
83
|
+
<Str k={"discard"}/>
|
|
84
|
+
</FireCMSi18nProvider>
|
|
85
|
+
</FireCMSi18nProvider>
|
|
86
|
+
);
|
|
87
|
+
const el = screen.getByTestId("discard");
|
|
88
|
+
expect(el.textContent).not.toBe("discard");
|
|
89
|
+
expect(el.textContent?.length).toBeGreaterThan(0);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import React from "react";
|
|
5
|
+
import { render, screen } from "@testing-library/react";
|
|
6
|
+
import { useTranslation } from "react-i18next";
|
|
7
|
+
import { FireCMSi18nProvider } from "../FireCMSi18nProvider";
|
|
8
|
+
|
|
9
|
+
function Str({ k }: { k: string }) {
|
|
10
|
+
const { t } = useTranslation("firecms_core");
|
|
11
|
+
return <span data-testid={k}>{t(k)}</span>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const host = { en: { host_key: "Host" } } as any;
|
|
15
|
+
const projectA = { en: { only_in_a: "From project A" } } as any;
|
|
16
|
+
const projectB = { en: { only_in_b: "From project B" } } as any;
|
|
17
|
+
|
|
18
|
+
describe("nested provider lifecycle", () => {
|
|
19
|
+
beforeEach(() => window.localStorage.clear());
|
|
20
|
+
|
|
21
|
+
it("does not carry one project's translations into the next", () => {
|
|
22
|
+
// The real shape of a project switch: App.tsx's provider stays mounted while
|
|
23
|
+
// FireCMSCloudApp's inner one is torn down and rebuilt with another project's
|
|
24
|
+
// translations. Both share the outer i18next instance now, so anything the
|
|
25
|
+
// first project added could outlive it.
|
|
26
|
+
const { rerender } = render(
|
|
27
|
+
<FireCMSi18nProvider translations={host}>
|
|
28
|
+
<FireCMSi18nProvider key={"a"} translations={projectA}>
|
|
29
|
+
<Str k={"only_in_a"}/>
|
|
30
|
+
</FireCMSi18nProvider>
|
|
31
|
+
</FireCMSi18nProvider>
|
|
32
|
+
);
|
|
33
|
+
expect(screen.getByTestId("only_in_a").textContent).toBe("From project A");
|
|
34
|
+
|
|
35
|
+
rerender(
|
|
36
|
+
<FireCMSi18nProvider translations={host}>
|
|
37
|
+
<FireCMSi18nProvider key={"b"} translations={projectB}>
|
|
38
|
+
<>
|
|
39
|
+
<Str k={"only_in_b"}/>
|
|
40
|
+
<Str k={"only_in_a"}/>
|
|
41
|
+
</>
|
|
42
|
+
</FireCMSi18nProvider>
|
|
43
|
+
</FireCMSi18nProvider>
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
expect(screen.getByTestId("only_in_b").textContent).toBe("From project B");
|
|
47
|
+
expect(screen.getByTestId("only_in_a").textContent).toBe("only_in_a");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("keeps the host's own translations across the switch", () => {
|
|
51
|
+
const { rerender } = render(
|
|
52
|
+
<FireCMSi18nProvider translations={host}>
|
|
53
|
+
<FireCMSi18nProvider key={"a"} translations={projectA}>
|
|
54
|
+
<Str k={"host_key"}/>
|
|
55
|
+
</FireCMSi18nProvider>
|
|
56
|
+
</FireCMSi18nProvider>
|
|
57
|
+
);
|
|
58
|
+
rerender(
|
|
59
|
+
<FireCMSi18nProvider translations={host}>
|
|
60
|
+
<FireCMSi18nProvider key={"b"} translations={projectB}>
|
|
61
|
+
<Str k={"host_key"}/>
|
|
62
|
+
</FireCMSi18nProvider>
|
|
63
|
+
</FireCMSi18nProvider>
|
|
64
|
+
);
|
|
65
|
+
expect(screen.getByTestId("host_key").textContent).toBe("Host");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -281,11 +281,17 @@ const propsToSidePanel = (props: EntitySidePanelProps,
|
|
|
281
281
|
locationSearch: string
|
|
282
282
|
): SideDialogPanelProps => {
|
|
283
283
|
|
|
284
|
-
|
|
284
|
+
// The URL is the ESCAPED representation of the chain — it is what `buildSidePanelsFromUrl`
|
|
285
|
+
// and `FireCMSRoute` parse back on reload, on a deep link, and on every pathname change.
|
|
286
|
+
// `props.path` is the RAW datasource path, so a parent entity id containing "/" reads back
|
|
287
|
+
// there as extra collection/entity hops and resolves to a different entity entirely.
|
|
288
|
+
// `fullIdPath` carries the same chain with its ids already escaped, which is what it is
|
|
289
|
+
// threaded alongside `path` for.
|
|
290
|
+
const urlChain = removeInitialAndTrailingSlashes(props.fullIdPath ?? props.path);
|
|
285
291
|
|
|
286
292
|
const urlPath = props.entityId
|
|
287
|
-
? buildUrlCollectionPath(`${
|
|
288
|
-
: buildUrlCollectionPath(`${
|
|
293
|
+
? buildUrlCollectionPath(`${urlChain}/${encodeEntityId(props.entityId)}${props.selectedTab ? "/" + props.selectedTab : ""}${locationSearch}#${SIDE_URL_HASH}`)
|
|
294
|
+
: buildUrlCollectionPath(`${urlChain}${locationSearch}#${NEW_URL_HASH}`);
|
|
289
295
|
|
|
290
296
|
const resolvedPanelProps: EntitySidePanelProps<any> = {
|
|
291
297
|
...props,
|
|
@@ -294,10 +300,12 @@ const propsToSidePanel = (props: EntitySidePanelProps,
|
|
|
294
300
|
|
|
295
301
|
const entityViewWidth = getEntityViewWidth(props, smallLayout, customizationController, authController);
|
|
296
302
|
return {
|
|
297
|
-
|
|
303
|
+
// Built from the escaped chain so that two different chains which happen to flatten
|
|
304
|
+
// to the same raw string are still two different panels.
|
|
305
|
+
key: `${urlChain}/${props.entityId ? encodeEntityId(props.entityId) : ""}`,
|
|
298
306
|
component: undefined, // Lazy render in SideDialogs for better performance
|
|
299
307
|
urlPath: urlPath,
|
|
300
|
-
parentUrlPath: buildUrlCollectionPath(
|
|
308
|
+
parentUrlPath: buildUrlCollectionPath(urlChain),
|
|
301
309
|
width: entityViewWidth,
|
|
302
310
|
onClose: props.onClose,
|
|
303
311
|
additional: resolvedPanelProps
|
|
@@ -7,6 +7,7 @@ import { Skeleton } from "@firecms/ui";
|
|
|
7
7
|
import { ErrorBoundary, ErrorView } from "../../components";
|
|
8
8
|
import { EntityPreview, EntityPreviewContainer } from "../../components/EntityPreview";
|
|
9
9
|
import { jsonStringifyReplacer } from "../../util/objects";
|
|
10
|
+
import { entityCacheKey } from "../../util/entity_cache";
|
|
10
11
|
|
|
11
12
|
export type ReferencePreviewProps = {
|
|
12
13
|
disabled?: boolean;
|
|
@@ -108,10 +109,10 @@ function ReferencePreviewExisting<M extends Record<string, any> = any>({
|
|
|
108
109
|
});
|
|
109
110
|
|
|
110
111
|
if (entity) {
|
|
111
|
-
referencesCache.set(reference.
|
|
112
|
+
referencesCache.set(entityCacheKey(reference.path, reference.id), entity);
|
|
112
113
|
}
|
|
113
114
|
|
|
114
|
-
const usedEntity = entity ?? referencesCache.get(reference.
|
|
115
|
+
const usedEntity = entity ?? referencesCache.get(entityCacheKey(reference.path, reference.id));
|
|
115
116
|
|
|
116
117
|
let body: React.ReactNode;
|
|
117
118
|
|
package/src/util/entity_cache.ts
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
import { EntityReference, GeoPoint, Vector } from "../types";
|
|
2
2
|
import { isObject, isPlainObject } from "./objects";
|
|
3
|
+
import { encodeEntityId } from "./navigation_utils";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Key identifying one entity in a cache.
|
|
7
|
+
*
|
|
8
|
+
* The id is escaped so that a `(path, entityId)` pair maps to exactly one key. Without it
|
|
9
|
+
* `("a", "b/c")` and `("a/b", "c")` both flatten to `"a/b/c"`, and one entity's cached
|
|
10
|
+
* values are served for the other.
|
|
11
|
+
*
|
|
12
|
+
* `encodeEntityId` is the identity for any id without "/", "?", "#" or "%", so this changes
|
|
13
|
+
* no key in an app whose ids cannot contain them.
|
|
14
|
+
*/
|
|
15
|
+
export function entityCacheKey(path: string | undefined, entityId: string | undefined): string {
|
|
16
|
+
// Either part may be undefined at a call site that used to build the key by string
|
|
17
|
+
// concatenation; both are stringified exactly as `path + "/" + entityId` did, so such a
|
|
18
|
+
// caller keeps the key it had.
|
|
19
|
+
return `${path}/${entityId === undefined ? entityId : encodeEntityId(entityId)}`;
|
|
20
|
+
}
|
|
3
21
|
|
|
4
22
|
// Define a unique prefix for entity keys in localStorage to avoid key collisions
|
|
5
23
|
const LOCAL_STORAGE_PREFIX = "entity_cache::";
|
|
@@ -147,7 +147,12 @@ export function getNavigationEntriesFromPath(props: {
|
|
|
147
147
|
path: newPath,
|
|
148
148
|
collections: collection.subcollections,
|
|
149
149
|
currentFullPath: fullPath,
|
|
150
|
-
|
|
150
|
+
// The entity id is a hop in the id chain exactly as it is in the
|
|
151
|
+
// other two. Without it a nested `fullIdPath` was
|
|
152
|
+
// "products/locales" rather than "products/pid/locales", so any
|
|
153
|
+
// URL built from it pointed at a collection that does not exist.
|
|
154
|
+
// Escaped, because `fullIdPath` is URL-facing.
|
|
155
|
+
currentFullIdPath: fullIdPath + "/" + encodedEntityId,
|
|
151
156
|
currentFullUrlPath: fullUrlPath,
|
|
152
157
|
currentPathSegments: entitySegments,
|
|
153
158
|
contextEntityViews: props.contextEntityViews
|