@firecms/core 3.4.0-canary.63041d0 → 3.4.0-canary.cb27f16
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/EntityCollectionTable/internal/popup_field/PopupFormField.d.ts +3 -1
- package/dist/components/EntityCollectionView/EntityCollectionBoardView.d.ts +3 -1
- package/dist/components/EntityCollectionView/EntityCollectionView.d.ts +3 -2
- package/dist/components/EntityCollectionView/EntityCollectionViewActions.d.ts +3 -1
- package/dist/components/EntityCollectionView/EntityCollectionViewStartActions.d.ts +3 -1
- package/dist/components/EntityCollectionView/useBoardDataController.d.ts +3 -1
- package/dist/components/ReferenceTable/ReferenceSelectionTable.d.ts +10 -1
- package/dist/components/common/useDataSourceTableController.d.ts +4 -2
- package/dist/components/common/useTableSearchHelper.d.ts +3 -1
- package/dist/core/EntityEditView.d.ts +2 -0
- package/dist/core/EntityEditViewFormActions.d.ts +1 -1
- package/dist/form/EntityForm.d.ts +8 -1
- package/dist/form/EntityFormActions.d.ts +2 -0
- package/dist/hooks/data/delete.d.ts +1 -1
- package/dist/hooks/data/useCollectionFetch.d.ts +7 -1
- package/dist/hooks/data/useEntityFetch.d.ts +4 -1
- package/dist/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 +970 -652
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +969 -651
- package/dist/index.umd.js.map +1 -1
- package/dist/types/collections.d.ts +7 -0
- package/dist/types/datasource.d.ts +25 -4
- package/dist/types/entities.d.ts +21 -1
- package/dist/types/entity_actions.d.ts +6 -0
- package/dist/types/entity_callbacks.d.ts +21 -0
- package/dist/types/navigation.d.ts +34 -5
- package/dist/types/plugins.d.ts +13 -0
- package/dist/types/side_entity_controller.d.ts +3 -2
- package/dist/util/entity_cache.d.ts +11 -0
- package/dist/util/navigation_utils.d.ts +84 -2
- package/dist/util/parent_references_from_path.d.ts +16 -0
- package/dist/util/paths.d.ts +28 -0
- package/dist/util/permissions.d.ts +10 -4
- package/package.json +4 -4
- package/src/components/DeleteEntityDialog.tsx +2 -0
- package/src/components/EntityCollectionTable/EntityCollectionRowActions.tsx +2 -2
- package/src/components/EntityCollectionTable/internal/popup_field/PopupFormField.tsx +5 -1
- package/src/components/EntityCollectionView/EntityCollectionBoardView.tsx +12 -0
- package/src/components/EntityCollectionView/EntityCollectionView.tsx +49 -16
- package/src/components/EntityCollectionView/EntityCollectionViewActions.tsx +6 -2
- package/src/components/EntityCollectionView/EntityCollectionViewStartActions.tsx +4 -0
- package/src/components/EntityCollectionView/useBoardDataController.tsx +17 -1
- package/src/components/EntityPreview.tsx +4 -1
- package/src/components/ReferenceTable/ReferenceSelectionTable.tsx +19 -2
- package/src/components/common/default_entity_actions.tsx +22 -2
- package/src/components/common/useDataSourceTableController.tsx +32 -8
- package/src/components/common/useTableSearchHelper.ts +5 -0
- package/src/core/EntityEditView.tsx +11 -12
- package/src/core/EntityEditViewFormActions.tsx +3 -2
- package/src/core/EntitySidePanel.tsx +6 -4
- package/src/form/EntityForm.tsx +30 -9
- package/src/form/EntityFormActions.tsx +2 -0
- package/src/hooks/data/delete.ts +8 -0
- package/src/hooks/data/save.ts +5 -2
- package/src/hooks/data/useCollectionFetch.tsx +21 -1
- package/src/hooks/data/useEntityFetch.tsx +28 -6
- package/src/hooks/useBuildNavigationController.tsx +23 -8
- package/src/hooks/useResolvedNavigationFrom.tsx +3 -1
- 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/useBuildDataSource.ts +26 -15
- package/src/internal/useBuildSideEntityController.tsx +13 -5
- package/src/preview/components/ReferencePreview.tsx +7 -3
- package/src/routes/FireCMSRoute.tsx +9 -4
- package/src/types/collections.ts +8 -0
- package/src/types/datasource.ts +25 -4
- package/src/types/entities.ts +24 -1
- package/src/types/entity_actions.tsx +6 -0
- package/src/types/entity_callbacks.ts +24 -0
- package/src/types/navigation.ts +35 -5
- package/src/types/plugins.tsx +13 -0
- package/src/types/side_entity_controller.tsx +3 -2
- package/src/util/entities.ts +3 -1
- package/src/util/entity_cache.ts +18 -0
- package/src/util/navigation_from_path.ts +6 -1
- package/src/util/navigation_utils.ts +204 -2
- package/src/util/parent_references_from_path.ts +42 -3
- package/src/util/paths.ts +40 -3
- package/src/util/permissions.ts +22 -10
|
@@ -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
|
|
@@ -11,7 +12,10 @@ export interface EntityFetchProps<M extends Record<string, any>, USER extends Us
|
|
|
11
12
|
path: string;
|
|
12
13
|
/**
|
|
13
14
|
* `path` split at its real segment boundaries. Forwarded to the datasource so a parent
|
|
14
|
-
* entity id containing "/" stays unambiguous.
|
|
15
|
+
* entity id containing "/" stays unambiguous.
|
|
16
|
+
*
|
|
17
|
+
* Optional, and never derived by splitting `path` — a guess would be wrong in exactly
|
|
18
|
+
* the case the field exists for. Absent means "not known here", not "no slashes".
|
|
15
19
|
*/
|
|
16
20
|
pathSegments?: string[];
|
|
17
21
|
entityId?: string;
|
|
@@ -54,8 +58,19 @@ export function useEntityFetch<M extends Record<string, any>, USER extends User>
|
|
|
54
58
|
const dataSource = useDataSource(collection);
|
|
55
59
|
const navigationController = useNavigationController();
|
|
56
60
|
|
|
57
|
-
|
|
58
|
-
|
|
61
|
+
// Never fabricate segments. If the caller did not thread them, pass undefined so a
|
|
62
|
+
// delegate can tell "not provided" from a real answer — splitting `path` here would
|
|
63
|
+
// produce a confidently wrong array for any id containing "/".
|
|
64
|
+
//
|
|
65
|
+
// When they were threaded, both representations are resolved from them together, so
|
|
66
|
+
// `path` cannot end up describing a different chain than `pathSegments`.
|
|
67
|
+
// `resolveSegmentsFrom` is optional on the controller: a host app may be supplying one
|
|
68
|
+
// built against an earlier version. Falling back to the segments as given is what
|
|
69
|
+
// happened before it existed — they are used unresolved, never invented.
|
|
70
|
+
const pathSegments = inputPathSegments
|
|
71
|
+
? (navigationController.resolveSegmentsFrom?.(inputPathSegments) ?? inputPathSegments)
|
|
72
|
+
: undefined;
|
|
73
|
+
const path = navigationController.resolveIdsFrom(inputPath, inputPathSegments);
|
|
59
74
|
|
|
60
75
|
const context: FireCMSContext<USER> = useFireCMSContext();
|
|
61
76
|
|
|
@@ -68,11 +83,18 @@ export function useEntityFetch<M extends Record<string, any>, USER extends User>
|
|
|
68
83
|
setDataLoading(true);
|
|
69
84
|
|
|
70
85
|
const onEntityUpdate = async (updatedEntity: Entity<M> | undefined) => {
|
|
86
|
+
// Attach the segments this entity was loaded with, so anything derived from it
|
|
87
|
+
// later — a reference, a delete — stays resolvable without re-deriving them
|
|
88
|
+
// from the flattened path. Delegates are not required to set this themselves.
|
|
89
|
+
if (updatedEntity && pathSegments && !updatedEntity.pathSegments) {
|
|
90
|
+
updatedEntity = { ...updatedEntity, pathSegments };
|
|
91
|
+
}
|
|
71
92
|
if (collection.callbacks?.onFetch && updatedEntity) {
|
|
72
93
|
try {
|
|
73
94
|
updatedEntity = await collection.callbacks.onFetch({
|
|
74
95
|
collection,
|
|
75
96
|
path,
|
|
97
|
+
pathSegments,
|
|
76
98
|
entity: updatedEntity,
|
|
77
99
|
context
|
|
78
100
|
});
|
|
@@ -80,7 +102,7 @@ export function useEntityFetch<M extends Record<string, any>, USER extends User>
|
|
|
80
102
|
console.error(e);
|
|
81
103
|
}
|
|
82
104
|
}
|
|
83
|
-
CACHE[
|
|
105
|
+
CACHE[entityCacheKey(path, entityId)] = updatedEntity;
|
|
84
106
|
setEntity(updatedEntity);
|
|
85
107
|
setDataLoading(false);
|
|
86
108
|
setDataLoadingError(undefined);
|
|
@@ -93,8 +115,8 @@ export function useEntityFetch<M extends Record<string, any>, USER extends User>
|
|
|
93
115
|
setDataLoadingError(error);
|
|
94
116
|
};
|
|
95
117
|
|
|
96
|
-
if (entityId && useCache && CACHE[
|
|
97
|
-
setEntity(CACHE[
|
|
118
|
+
if (entityId && useCache && CACHE[entityCacheKey(path, entityId)]) {
|
|
119
|
+
setEntity(CACHE[entityCacheKey(path, entityId)]);
|
|
98
120
|
setDataLoading(false);
|
|
99
121
|
setDataLoadingError(undefined);
|
|
100
122
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
@@ -22,11 +22,14 @@ import {
|
|
|
22
22
|
} from "../types";
|
|
23
23
|
import {
|
|
24
24
|
applyPermissionsFunctionIfEmpty,
|
|
25
|
+
collectionSegmentsFrom,
|
|
26
|
+
fullPathToCollectionSegments,
|
|
25
27
|
getCollectionByPathOrId,
|
|
26
28
|
mergeDeep,
|
|
27
29
|
removeFunctions,
|
|
28
30
|
removeInitialAndTrailingSlashes,
|
|
29
31
|
resolveCollectionPathIds,
|
|
32
|
+
resolveCollectionPathSegments,
|
|
30
33
|
resolvePermissions
|
|
31
34
|
} from "../util";
|
|
32
35
|
import { getParentReferencesFromPath } from "../util/parent_references_from_path";
|
|
@@ -405,13 +408,14 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
|
|
|
405
408
|
|
|
406
409
|
const getCollection = useCallback((
|
|
407
410
|
idOrPath: string,
|
|
408
|
-
includeUserOverride = false
|
|
411
|
+
includeUserOverride = false,
|
|
412
|
+
pathSegments?: string[]
|
|
409
413
|
): EC | undefined => {
|
|
410
414
|
const collections = collectionsRef.current;
|
|
411
415
|
if (!collections)
|
|
412
416
|
return undefined;
|
|
413
417
|
|
|
414
|
-
const baseCollection = getCollectionByPathOrId(removeInitialAndTrailingSlashes(idOrPath), collections);
|
|
418
|
+
const baseCollection = getCollectionByPathOrId(removeInitialAndTrailingSlashes(idOrPath), collections, pathSegments);
|
|
415
419
|
|
|
416
420
|
const userOverride = includeUserOverride ? userConfigPersistence?.getCollectionConfig(idOrPath) : undefined;
|
|
417
421
|
let overriddenCollection = baseCollection;
|
|
@@ -505,23 +509,33 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
|
|
|
505
509
|
throw Error("Expected path starting with " + fullCollectionPath);
|
|
506
510
|
}, [fullCollectionPath]);
|
|
507
511
|
|
|
508
|
-
const resolveIdsFrom = useCallback((path: string): string => {
|
|
512
|
+
const resolveIdsFrom = useCallback((path: string, pathSegments?: string[]): string => {
|
|
509
513
|
const collections = collectionsRef.current ?? [];
|
|
510
|
-
return resolveCollectionPathIds(path, collections);
|
|
514
|
+
return resolveCollectionPathIds(path, collections, pathSegments);
|
|
511
515
|
}, []);
|
|
512
516
|
|
|
513
|
-
const
|
|
517
|
+
const resolveSegmentsFrom = useCallback((pathSegments: string[]): string[] => {
|
|
518
|
+
const collections = collectionsRef.current ?? [];
|
|
519
|
+
return resolveCollectionPathSegments(pathSegments, collections);
|
|
520
|
+
}, []);
|
|
521
|
+
|
|
522
|
+
const getAllParentReferencesForPath = useCallback((path: string, pathSegments?: string[]): EntityReference[] => {
|
|
514
523
|
const collections = collectionsRef.current ?? [];
|
|
515
524
|
return getParentReferencesFromPath({
|
|
516
525
|
path,
|
|
526
|
+
pathSegments,
|
|
517
527
|
collections
|
|
518
528
|
});
|
|
519
529
|
}, []);
|
|
520
530
|
|
|
521
|
-
const getParentCollectionIds = useCallback((path: string): string[] => {
|
|
531
|
+
const getParentCollectionIds = useCallback((path: string, pathSegments?: string[]): string[] => {
|
|
522
532
|
|
|
523
|
-
|
|
524
|
-
|
|
533
|
+
// Positional filtering only picks out the collection segments while no entity id
|
|
534
|
+
// contains "/"; with one, every following segment shifts. Given the real boundaries
|
|
535
|
+
// the same rule applies correctly; without them this is the historical reading.
|
|
536
|
+
const oddPathSegments = pathSegments
|
|
537
|
+
? collectionSegmentsFrom(pathSegments)
|
|
538
|
+
: fullPathToCollectionSegments(path);
|
|
525
539
|
oddPathSegments.pop();
|
|
526
540
|
|
|
527
541
|
const result: string[][] = [];
|
|
@@ -567,6 +581,7 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
|
|
|
567
581
|
urlPathToDataPath,
|
|
568
582
|
buildUrlCollectionPath,
|
|
569
583
|
resolveIdsFrom,
|
|
584
|
+
resolveSegmentsFrom,
|
|
570
585
|
topLevelNavigation,
|
|
571
586
|
refreshNavigation,
|
|
572
587
|
getParentReferencesFromPath: getAllParentReferencesForPath,
|
|
@@ -80,12 +80,14 @@ export function resolveNavigationFrom<M extends Record<string, any>, USER extend
|
|
|
80
80
|
if (entry.type === "collection") {
|
|
81
81
|
return Promise.resolve(entry);
|
|
82
82
|
} else if (entry.type === "entity") {
|
|
83
|
-
const collection = navigation.getCollection(entry.path);
|
|
83
|
+
const collection = navigation.getCollection(entry.path, false, entry.pathSegments);
|
|
84
84
|
if (!collection) {
|
|
85
85
|
throw Error(`No collection defined in the navigation for the entity with path ${entry.path}`);
|
|
86
86
|
}
|
|
87
87
|
return dataSource.fetchEntity({
|
|
88
88
|
path: entry.path,
|
|
89
|
+
// The navigation entry already knows the real segment boundaries.
|
|
90
|
+
pathSegments: entry.pathSegments,
|
|
89
91
|
entityId: entry.entityId,
|
|
90
92
|
collection
|
|
91
93
|
})
|
|
@@ -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
|
+
});
|
|
@@ -112,7 +112,7 @@ export function useBuildDataSource({
|
|
|
112
112
|
}: ListenCollectionProps<M>
|
|
113
113
|
): () => void => {
|
|
114
114
|
|
|
115
|
-
const collection = collectionProp ?? navigationController.getCollection(path);
|
|
115
|
+
const collection = collectionProp ?? navigationController.getCollection(path, false, pathSegments);
|
|
116
116
|
const usedDelegate = collection?.overrides?.dataSourceDelegate ?? delegate;
|
|
117
117
|
|
|
118
118
|
if (!usedDelegate.listenCollection)
|
|
@@ -213,7 +213,7 @@ export function useBuildDataSource({
|
|
|
213
213
|
status
|
|
214
214
|
}: SaveEntityProps<M>): Promise<Entity<M>> => {
|
|
215
215
|
|
|
216
|
-
const collection = collectionProp ?? navigationController.getCollection(path);
|
|
216
|
+
const collection = collectionProp ?? navigationController.getCollection(path, false, pathSegments);
|
|
217
217
|
const usedDelegate = collection?.overrides?.dataSourceDelegate ?? delegate;
|
|
218
218
|
|
|
219
219
|
const resolvedCollection = collection
|
|
@@ -286,9 +286,15 @@ export function useBuildDataSource({
|
|
|
286
286
|
values: finalValues,
|
|
287
287
|
status
|
|
288
288
|
}).then((res) => {
|
|
289
|
+
// Keep everything the delegate resolved — `databaseId`, and `pathSegments` in
|
|
290
|
+
// particular — so a just-saved entity is as resolvable as a fetched one.
|
|
291
|
+
// Only the values are re-mapped back to the CMS model.
|
|
289
292
|
return {
|
|
290
|
-
|
|
291
|
-
|
|
293
|
+
...res,
|
|
294
|
+
// A delegate that predates `pathSegments` returns none; the segments we
|
|
295
|
+
// just saved with describe exactly this entity's collection, so they are
|
|
296
|
+
// the authoritative answer rather than a guess.
|
|
297
|
+
pathSegments: res.pathSegments ?? pathSegments,
|
|
292
298
|
values: usedDelegate.delegateToCMSModel(finalValues)
|
|
293
299
|
} as Entity<M>;
|
|
294
300
|
});
|
|
@@ -303,12 +309,15 @@ export function useBuildDataSource({
|
|
|
303
309
|
deleteEntity: useCallback(<M extends Record<string, any>>(
|
|
304
310
|
{
|
|
305
311
|
entity,
|
|
312
|
+
pathSegments,
|
|
306
313
|
collection
|
|
307
314
|
}: DeleteEntityProps<M>
|
|
308
315
|
): Promise<void> => {
|
|
309
316
|
const usedDelegate = collection?.overrides?.dataSourceDelegate ?? delegate;
|
|
310
317
|
return usedDelegate.deleteEntity({
|
|
311
318
|
entity,
|
|
319
|
+
// Falls back to what the entity carries from the fetch that produced it.
|
|
320
|
+
pathSegments: pathSegments ?? entity.pathSegments,
|
|
312
321
|
collection
|
|
313
322
|
});
|
|
314
323
|
}, [delegate.deleteEntity]),
|
|
@@ -328,33 +337,32 @@ export function useBuildDataSource({
|
|
|
328
337
|
name: string,
|
|
329
338
|
value: any,
|
|
330
339
|
entityId?: string,
|
|
331
|
-
collection?: EntityCollection
|
|
340
|
+
collection?: EntityCollection,
|
|
341
|
+
pathSegments?: string[]
|
|
332
342
|
): Promise<boolean> => {
|
|
333
343
|
const usedDelegate = collection?.overrides?.dataSourceDelegate ?? delegate;
|
|
334
|
-
return usedDelegate.checkUniqueField(path, name, value, entityId, collection);
|
|
344
|
+
return usedDelegate.checkUniqueField(path, name, value, entityId, collection, pathSegments);
|
|
335
345
|
}, [delegate.checkUniqueField]),
|
|
336
346
|
|
|
337
|
-
generateEntityId: useCallback((path: string, collection: EntityCollection): string => {
|
|
347
|
+
generateEntityId: useCallback((path: string, collection: EntityCollection, pathSegments?: string[]): string => {
|
|
338
348
|
const usedDelegate = collection?.overrides?.dataSourceDelegate ?? delegate;
|
|
339
|
-
return usedDelegate.generateEntityId(path, collection);
|
|
349
|
+
return usedDelegate.generateEntityId(path, collection, pathSegments);
|
|
340
350
|
}, [delegate.generateEntityId]),
|
|
341
351
|
|
|
352
|
+
// Typed with the declared props rather than an inline duplicate: the duplicate is
|
|
353
|
+
// free to drift from the interface, and a field it forgets is dropped in silence.
|
|
342
354
|
countEntities: delegate.countEntities ? async ({
|
|
343
355
|
path,
|
|
356
|
+
pathSegments,
|
|
344
357
|
collection,
|
|
345
358
|
filter,
|
|
346
359
|
order,
|
|
347
360
|
orderBy
|
|
348
|
-
}: {
|
|
349
|
-
path: string,
|
|
350
|
-
collection: EntityCollection<any>,
|
|
351
|
-
filter?: FilterValues<Extract<keyof any, string>>,
|
|
352
|
-
orderBy?: string,
|
|
353
|
-
order?: "desc" | "asc",
|
|
354
|
-
}): Promise<number> => {
|
|
361
|
+
}: FetchCollectionProps<any>): Promise<number> => {
|
|
355
362
|
const usedDelegate = collection?.overrides?.dataSourceDelegate ?? delegate;
|
|
356
363
|
return usedDelegate.countEntities!({
|
|
357
364
|
path,
|
|
365
|
+
pathSegments,
|
|
358
366
|
filter,
|
|
359
367
|
orderBy,
|
|
360
368
|
order,
|
|
@@ -364,11 +372,13 @@ export function useBuildDataSource({
|
|
|
364
372
|
|
|
365
373
|
isFilterCombinationValid: useCallback(({
|
|
366
374
|
path,
|
|
375
|
+
pathSegments,
|
|
367
376
|
databaseId,
|
|
368
377
|
filterValues,
|
|
369
378
|
sortBy
|
|
370
379
|
}: {
|
|
371
380
|
path: string,
|
|
381
|
+
pathSegments?: string[],
|
|
372
382
|
databaseId?: string,
|
|
373
383
|
filterValues: FilterValues<any>,
|
|
374
384
|
sortBy?: [string, "asc" | "desc"]
|
|
@@ -378,6 +388,7 @@ export function useBuildDataSource({
|
|
|
378
388
|
return delegate.isFilterCombinationValid(
|
|
379
389
|
{
|
|
380
390
|
path,
|
|
391
|
+
pathSegments,
|
|
381
392
|
databaseId,
|
|
382
393
|
filterValues,
|
|
383
394
|
sortBy
|
|
@@ -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;
|
|
@@ -53,7 +54,7 @@ function ReferencePreviewInternal({
|
|
|
53
54
|
|
|
54
55
|
const navigationController = useNavigationController();
|
|
55
56
|
|
|
56
|
-
const collection = navigationController.getCollection(reference.path);
|
|
57
|
+
const collection = navigationController.getCollection(reference.path, false, reference.pathSegments);
|
|
57
58
|
if (!collection) {
|
|
58
59
|
if (customizationController.components?.missingReference) {
|
|
59
60
|
return <customizationController.components.missingReference path={reference.path}/>;
|
|
@@ -99,16 +100,19 @@ function ReferencePreviewExisting<M extends Record<string, any> = any>({
|
|
|
99
100
|
dataLoadingError
|
|
100
101
|
} = useEntityFetch({
|
|
101
102
|
path: reference.path,
|
|
103
|
+
// References carry the segments of the collection they point at, when the code that
|
|
104
|
+
// built them knew them (see getReferenceFrom).
|
|
105
|
+
pathSegments: reference.pathSegments,
|
|
102
106
|
entityId: reference.id,
|
|
103
107
|
collection,
|
|
104
108
|
useCache: true
|
|
105
109
|
});
|
|
106
110
|
|
|
107
111
|
if (entity) {
|
|
108
|
-
referencesCache.set(reference.
|
|
112
|
+
referencesCache.set(entityCacheKey(reference.path, reference.id), entity);
|
|
109
113
|
}
|
|
110
114
|
|
|
111
|
-
const usedEntity = entity ?? referencesCache.get(reference.
|
|
115
|
+
const usedEntity = entity ?? referencesCache.get(entityCacheKey(reference.path, reference.id));
|
|
112
116
|
|
|
113
117
|
let body: React.ReactNode;
|
|
114
118
|
|
|
@@ -90,7 +90,7 @@ export function FireCMSRoute() {
|
|
|
90
90
|
let collection: EntityCollection<any> | undefined;
|
|
91
91
|
collection = navigation.getCollectionById(navigationEntries[0].id);
|
|
92
92
|
if (!collection)
|
|
93
|
-
collection = navigation.getCollection(navigationEntries[0].path);
|
|
93
|
+
collection = navigation.getCollection(navigationEntries[0].path, false, navigationEntries[0].pathSegments);
|
|
94
94
|
if (!collection)
|
|
95
95
|
return null;
|
|
96
96
|
return <React.Suspense fallback={null}>
|
|
@@ -114,7 +114,7 @@ export function FireCMSRoute() {
|
|
|
114
114
|
const firstEntry = navigationEntries[0] as NavigationViewCollectionInternal<any>;
|
|
115
115
|
collection = navigation.getCollectionById(firstEntry.id);
|
|
116
116
|
if (!collection)
|
|
117
|
-
collection = navigation.getCollection(firstEntry.path);
|
|
117
|
+
collection = navigation.getCollection(firstEntry.path, false, firstEntry.pathSegments);
|
|
118
118
|
if (!collection)
|
|
119
119
|
return null;
|
|
120
120
|
return <React.Suspense fallback={null}>
|
|
@@ -186,6 +186,8 @@ function EntityFullScreenRoute({
|
|
|
186
186
|
const urlTab = getSelectedTabFromUrl(isNew, lastCustomView);
|
|
187
187
|
const [selectedTab, setSelectedTab] = useState<string | undefined>(urlTab);
|
|
188
188
|
|
|
189
|
+
// `navigationPath` comes from the URL, where entity ids are still escaped (see
|
|
190
|
+
// encodeEntityId), so splitting it on "/" inside is correct and no segments are needed.
|
|
189
191
|
const parentCollectionIds = navigation.getParentCollectionIds(navigationPath);
|
|
190
192
|
useEffect(() => {
|
|
191
193
|
if (urlTab !== selectedTab) {
|
|
@@ -250,14 +252,17 @@ function EntityFullScreenRoute({
|
|
|
250
252
|
// `fullIdPath` is used downstream to build URLs, so it carries the escaped chain;
|
|
251
253
|
// `collectionPath` addresses the datasource, so it is resolved from the raw one.
|
|
252
254
|
const fullIdPath = isNew ? lastCollectionEntry!.fullPath : (parentCollectionEntry?.fullPath ?? lastEntityEntry!.fullPath);
|
|
253
|
-
|
|
255
|
+
// The navigation entries know the real segment boundaries, so the path is resolved
|
|
256
|
+
// from them rather than re-parsed out of the flattened string.
|
|
257
|
+
const pathSegments = isNew ? lastCollectionEntry!.pathSegments : lastEntityEntry!.pathSegments;
|
|
258
|
+
const collectionPath = navigation.resolveIdsFrom(isNew ? lastCollectionEntry!.path : lastEntityEntry!.path, pathSegments);
|
|
254
259
|
return <>
|
|
255
260
|
<React.Suspense fallback={null}>
|
|
256
261
|
<EntityEditView
|
|
257
262
|
key={collection.id + "_" + (isNew ? "new" : (isCopy ? entityId + "_copy" : entityId))}
|
|
258
263
|
entityId={isNew ? undefined : entityId}
|
|
259
264
|
fullIdPath={fullIdPath}
|
|
260
|
-
pathSegments={
|
|
265
|
+
pathSegments={pathSegments}
|
|
261
266
|
collection={collection}
|
|
262
267
|
layout={"full_screen"}
|
|
263
268
|
path={collectionPath}
|