@firecms/core 3.3.0 → 3.4.0-canary.2575a08
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/app/useApp.d.ts +1 -0
- package/dist/components/EntityCollectionTable/column_utils.d.ts +4 -3
- 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/FiltersDialog.d.ts +2 -1
- package/dist/components/EntityCollectionView/useBoardDataController.d.ts +3 -1
- package/dist/components/EntityPreview.d.ts +3 -1
- 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/core/DefaultDrawer.d.ts +10 -3
- package/dist/core/EntityEditView.d.ts +9 -1
- package/dist/core/EntityEditViewFormActions.d.ts +1 -1
- package/dist/core/field_configs.d.ts +1 -1
- package/dist/form/EntityForm.d.ts +8 -1
- package/dist/form/EntityFormActions.d.ts +2 -0
- package/dist/form/field_bindings/GeopointFieldBinding.d.ts +5 -0
- package/dist/form/index.d.ts +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/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 +3658 -1820
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +3655 -1817
- package/dist/index.umd.js.map +1 -1
- package/dist/locales/pl.d.ts +2 -0
- package/dist/preview/index.d.ts +1 -0
- package/dist/preview/property_previews/GeopointPropertyPreview.d.ts +4 -0
- package/dist/types/collections.d.ts +31 -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/navigation.d.ts +34 -5
- package/dist/types/plugins.d.ts +13 -0
- package/dist/types/properties.d.ts +17 -1
- package/dist/types/side_entity_controller.d.ts +7 -0
- package/dist/types/translations.d.ts +6 -0
- 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/entities.d.ts +1 -0
- package/dist/util/entity_cache.d.ts +11 -0
- package/dist/util/geopoint.d.ts +22 -0
- package/dist/util/index.d.ts +2 -0
- package/dist/util/navigation_blocking.d.ts +22 -0
- package/dist/util/navigation_from_path.d.ts +17 -0
- package/dist/util/navigation_utils.d.ts +107 -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 +17 -10
- package/src/app/Scaffold.tsx +34 -44
- package/src/app/useApp.tsx +1 -0
- package/src/components/DeleteEntityDialog.tsx +2 -0
- package/src/components/EntityCollectionTable/EntityCollectionRowActions.tsx +2 -2
- package/src/components/EntityCollectionTable/EntityCollectionTable.tsx +3 -1
- package/src/components/EntityCollectionTable/column_utils.tsx +11 -19
- package/src/components/EntityCollectionTable/fields/TableReferenceField.tsx +1 -1
- 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 +8 -1
- package/src/components/EntityCollectionView/FiltersDialog.tsx +39 -28
- package/src/components/EntityCollectionView/useBoardDataController.tsx +17 -1
- package/src/components/EntityPreview.tsx +45 -41
- package/src/components/ReferenceTable/ReferenceSelectionTable.tsx +19 -2
- package/src/components/ReferenceWidget.tsx +1 -1
- package/src/components/SelectableTable/filters/ReferenceFilterField.tsx +2 -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/VirtualTable/VirtualTable.tsx +19 -8
- 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 +126 -163
- package/src/components/common/useTableSearchHelper.ts +26 -2
- package/src/contexts/SnackbarProvider.tsx +14 -1
- package/src/core/DefaultDrawer.tsx +150 -64
- package/src/core/DrawerNavigationGroup.tsx +27 -29
- package/src/core/DrawerNavigationItem.tsx +10 -12
- package/src/core/EntityEditView.tsx +77 -35
- package/src/core/EntityEditViewFormActions.tsx +3 -2
- package/src/core/EntitySidePanel.tsx +8 -4
- package/src/core/field_configs.tsx +15 -0
- package/src/form/EntityForm.tsx +31 -10
- package/src/form/EntityFormActions.tsx +2 -0
- package/src/form/field_bindings/ArrayOfReferencesFieldBinding.tsx +1 -1
- package/src/form/field_bindings/GeopointFieldBinding.tsx +139 -0
- package/src/form/index.tsx +1 -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/useBuildNavigationController.tsx +23 -8
- package/src/hooks/useResolvedNavigationFrom.tsx +3 -1
- package/src/hooks/useValidateAuthenticator.tsx +4 -0
- package/src/i18n/FireCMSi18nProvider.tsx +50 -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 +37 -15
- package/src/internal/useBuildSideEntityController.tsx +20 -5
- package/src/locales/de.ts +7 -2
- package/src/locales/en.ts +7 -2
- package/src/locales/es.ts +7 -2
- package/src/locales/fr.ts +7 -2
- package/src/locales/hi.ts +7 -2
- package/src/locales/it.ts +7 -2
- package/src/locales/pl.ts +735 -0
- package/src/locales/pt.ts +7 -2
- package/src/preview/PropertyPreview.tsx +12 -0
- package/src/preview/components/ReferencePreview.tsx +7 -3
- package/src/preview/components/StorageThumbnail.tsx +24 -2
- package/src/preview/index.ts +1 -0
- package/src/preview/property_previews/GeopointPropertyPreview.tsx +23 -0
- package/src/routes/FireCMSRoute.tsx +54 -27
- package/src/types/collections.ts +34 -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/navigation.ts +35 -5
- package/src/types/plugins.tsx +13 -0
- package/src/types/properties.ts +17 -0
- package/src/types/side_entity_controller.tsx +7 -0
- package/src/types/translations.ts +6 -0
- 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/entities.ts +16 -1
- package/src/util/entity_cache.ts +18 -0
- package/src/util/geopoint.ts +81 -0
- package/src/util/index.ts +2 -0
- package/src/util/navigation_blocking.ts +45 -0
- package/src/util/navigation_from_path.ts +47 -7
- package/src/util/navigation_utils.ts +244 -4
- package/src/util/parent_references_from_path.ts +45 -4
- 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
|
@@ -3,12 +3,21 @@ 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
|
|
9
10
|
*/
|
|
10
11
|
export interface EntityFetchProps<M extends Record<string, any>, USER extends User = User> {
|
|
11
12
|
path: string;
|
|
13
|
+
/**
|
|
14
|
+
* `path` split at its real segment boundaries. Forwarded to the datasource so a parent
|
|
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".
|
|
19
|
+
*/
|
|
20
|
+
pathSegments?: string[];
|
|
12
21
|
entityId?: string;
|
|
13
22
|
databaseId?: string;
|
|
14
23
|
collection: EntityCollection<M, USER>;
|
|
@@ -39,6 +48,7 @@ const CACHE: Record<string, Entity<any> | undefined> = {};
|
|
|
39
48
|
export function useEntityFetch<M extends Record<string, any>, USER extends User>(
|
|
40
49
|
{
|
|
41
50
|
path: inputPath,
|
|
51
|
+
pathSegments: inputPathSegments,
|
|
42
52
|
entityId,
|
|
43
53
|
collection,
|
|
44
54
|
databaseId,
|
|
@@ -48,7 +58,19 @@ export function useEntityFetch<M extends Record<string, any>, USER extends User>
|
|
|
48
58
|
const dataSource = useDataSource(collection);
|
|
49
59
|
const navigationController = useNavigationController();
|
|
50
60
|
|
|
51
|
-
|
|
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);
|
|
52
74
|
|
|
53
75
|
const context: FireCMSContext<USER> = useFireCMSContext();
|
|
54
76
|
|
|
@@ -61,11 +83,18 @@ export function useEntityFetch<M extends Record<string, any>, USER extends User>
|
|
|
61
83
|
setDataLoading(true);
|
|
62
84
|
|
|
63
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
|
+
}
|
|
64
92
|
if (collection.callbacks?.onFetch && updatedEntity) {
|
|
65
93
|
try {
|
|
66
94
|
updatedEntity = await collection.callbacks.onFetch({
|
|
67
95
|
collection,
|
|
68
96
|
path,
|
|
97
|
+
pathSegments,
|
|
69
98
|
entity: updatedEntity,
|
|
70
99
|
context
|
|
71
100
|
});
|
|
@@ -73,7 +102,7 @@ export function useEntityFetch<M extends Record<string, any>, USER extends User>
|
|
|
73
102
|
console.error(e);
|
|
74
103
|
}
|
|
75
104
|
}
|
|
76
|
-
CACHE[
|
|
105
|
+
CACHE[entityCacheKey(path, entityId)] = updatedEntity;
|
|
77
106
|
setEntity(updatedEntity);
|
|
78
107
|
setDataLoading(false);
|
|
79
108
|
setDataLoadingError(undefined);
|
|
@@ -86,8 +115,8 @@ export function useEntityFetch<M extends Record<string, any>, USER extends User>
|
|
|
86
115
|
setDataLoadingError(error);
|
|
87
116
|
};
|
|
88
117
|
|
|
89
|
-
if (entityId && useCache && CACHE[
|
|
90
|
-
setEntity(CACHE[
|
|
118
|
+
if (entityId && useCache && CACHE[entityCacheKey(path, entityId)]) {
|
|
119
|
+
setEntity(CACHE[entityCacheKey(path, entityId)]);
|
|
91
120
|
setDataLoading(false);
|
|
92
121
|
setDataLoadingError(undefined);
|
|
93
122
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
@@ -97,6 +126,7 @@ export function useEntityFetch<M extends Record<string, any>, USER extends User>
|
|
|
97
126
|
if (dataSource.listenEntity) {
|
|
98
127
|
return dataSource.listenEntity<M>({
|
|
99
128
|
path,
|
|
129
|
+
pathSegments,
|
|
100
130
|
entityId,
|
|
101
131
|
databaseId,
|
|
102
132
|
collection,
|
|
@@ -106,6 +136,7 @@ export function useEntityFetch<M extends Record<string, any>, USER extends User>
|
|
|
106
136
|
} else {
|
|
107
137
|
dataSource.fetchEntity<M>({
|
|
108
138
|
path,
|
|
139
|
+
pathSegments,
|
|
109
140
|
entityId,
|
|
110
141
|
databaseId,
|
|
111
142
|
collection
|
|
@@ -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
|
})
|
|
@@ -74,6 +74,10 @@ export function useValidateAuthenticator<USER extends User = any>
|
|
|
74
74
|
|
|
75
75
|
if (authenticator instanceof Function && delegateUser && !equal(checkedUserRef.current?.uid, delegateUser.uid)) {
|
|
76
76
|
setAuthLoading(true);
|
|
77
|
+
// Reset the error of any previous authentication attempt. Otherwise, a
|
|
78
|
+
// rejected user would keep `canAccessMainView` to false for every user
|
|
79
|
+
// logging in afterwards, locking them out of the CMS until a page reload.
|
|
80
|
+
setNotAllowedError(false);
|
|
77
81
|
try {
|
|
78
82
|
const allowed = await authenticator({
|
|
79
83
|
user: delegateUser,
|
|
@@ -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";
|
|
@@ -8,6 +8,7 @@ import { fr } from "../locales/fr";
|
|
|
8
8
|
import { it } from "../locales/it";
|
|
9
9
|
import { hi } from "../locales/hi";
|
|
10
10
|
import { pt } from "../locales/pt";
|
|
11
|
+
import { pl } from "../locales/pl";
|
|
11
12
|
import { FireCMSTranslations } from "../types/translations";
|
|
12
13
|
|
|
13
14
|
const FIRECMS_NS = "firecms_core";
|
|
@@ -49,16 +50,48 @@ export function FireCMSi18nProvider({
|
|
|
49
50
|
translations,
|
|
50
51
|
children
|
|
51
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
|
+
|
|
52
71
|
const i18nRef = useRef<i18n | null>(null);
|
|
53
72
|
const [ready, setReady] = React.useState(false);
|
|
54
73
|
|
|
55
74
|
if (!i18nRef.current) {
|
|
56
75
|
const instance = i18next.createInstance();
|
|
57
76
|
|
|
58
|
-
//
|
|
77
|
+
// English baseline + this provider's overrides, with anything the parent
|
|
78
|
+
// already resolved layered in underneath the overrides.
|
|
59
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
|
+
}
|
|
60
93
|
|
|
61
|
-
let initialLocale = locale;
|
|
94
|
+
let initialLocale = parentInstance?.language ?? locale;
|
|
62
95
|
if (typeof window !== "undefined") {
|
|
63
96
|
const stored = localStorage.getItem(FIRECMS_LOCALE_STORAGE_KEY);
|
|
64
97
|
if (stored) initialLocale = stored;
|
|
@@ -89,6 +122,18 @@ export function FireCMSi18nProvider({
|
|
|
89
122
|
i18nRef.current = instance;
|
|
90
123
|
}
|
|
91
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
|
+
|
|
92
137
|
// When `locale` prop changes, switch language on the existing instance
|
|
93
138
|
// ONLY if the user hasn't explicitly set a preference
|
|
94
139
|
useEffect(() => {
|
|
@@ -139,6 +184,7 @@ function buildResources(
|
|
|
139
184
|
it: { [FIRECMS_NS]: { ...it } },
|
|
140
185
|
hi: { [FIRECMS_NS]: { ...hi } },
|
|
141
186
|
pt: { [FIRECMS_NS]: { ...pt } },
|
|
187
|
+
pl: { [FIRECMS_NS]: { ...pl } },
|
|
142
188
|
};
|
|
143
189
|
|
|
144
190
|
if (!translations) return resources;
|
|
@@ -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
|
+
});
|
|
@@ -55,6 +55,7 @@ export function useBuildDataSource({
|
|
|
55
55
|
*/
|
|
56
56
|
fetchCollection: useCallback(<M extends Record<string, any>>({
|
|
57
57
|
path,
|
|
58
|
+
pathSegments,
|
|
58
59
|
collection,
|
|
59
60
|
filter,
|
|
60
61
|
limit,
|
|
@@ -67,6 +68,7 @@ export function useBuildDataSource({
|
|
|
67
68
|
const usedDelegate = collection?.overrides?.dataSourceDelegate ?? delegate;
|
|
68
69
|
return usedDelegate.fetchCollection<M>({
|
|
69
70
|
path,
|
|
71
|
+
pathSegments,
|
|
70
72
|
filter,
|
|
71
73
|
limit,
|
|
72
74
|
startAfter,
|
|
@@ -97,6 +99,7 @@ export function useBuildDataSource({
|
|
|
97
99
|
? useCallback(<M extends Record<string, any>>(
|
|
98
100
|
{
|
|
99
101
|
path,
|
|
102
|
+
pathSegments,
|
|
100
103
|
collection: collectionProp,
|
|
101
104
|
filter,
|
|
102
105
|
limit,
|
|
@@ -109,7 +112,7 @@ export function useBuildDataSource({
|
|
|
109
112
|
}: ListenCollectionProps<M>
|
|
110
113
|
): () => void => {
|
|
111
114
|
|
|
112
|
-
const collection = collectionProp ?? navigationController.getCollection(path);
|
|
115
|
+
const collection = collectionProp ?? navigationController.getCollection(path, false, pathSegments);
|
|
113
116
|
const usedDelegate = collection?.overrides?.dataSourceDelegate ?? delegate;
|
|
114
117
|
|
|
115
118
|
if (!usedDelegate.listenCollection)
|
|
@@ -117,6 +120,7 @@ export function useBuildDataSource({
|
|
|
117
120
|
|
|
118
121
|
return usedDelegate.listenCollection<M>({
|
|
119
122
|
path,
|
|
123
|
+
pathSegments,
|
|
120
124
|
filter,
|
|
121
125
|
limit,
|
|
122
126
|
startAfter,
|
|
@@ -139,6 +143,7 @@ export function useBuildDataSource({
|
|
|
139
143
|
*/
|
|
140
144
|
fetchEntity: useCallback(<M extends Record<string, any>>({
|
|
141
145
|
path,
|
|
146
|
+
pathSegments,
|
|
142
147
|
entityId,
|
|
143
148
|
collection
|
|
144
149
|
}: FetchEntityProps<M>
|
|
@@ -146,6 +151,7 @@ export function useBuildDataSource({
|
|
|
146
151
|
const usedDelegate = collection?.overrides?.dataSourceDelegate ?? delegate;
|
|
147
152
|
return usedDelegate.fetchEntity({
|
|
148
153
|
path,
|
|
154
|
+
pathSegments,
|
|
149
155
|
entityId,
|
|
150
156
|
collection
|
|
151
157
|
});
|
|
@@ -165,6 +171,7 @@ export function useBuildDataSource({
|
|
|
165
171
|
? useCallback(<M extends Record<string, any>>(
|
|
166
172
|
{
|
|
167
173
|
path,
|
|
174
|
+
pathSegments,
|
|
168
175
|
entityId,
|
|
169
176
|
collection,
|
|
170
177
|
onUpdate,
|
|
@@ -177,6 +184,7 @@ export function useBuildDataSource({
|
|
|
177
184
|
|
|
178
185
|
return usedDelegate.listenEntity<M>({
|
|
179
186
|
path,
|
|
187
|
+
pathSegments,
|
|
180
188
|
entityId,
|
|
181
189
|
onUpdate,
|
|
182
190
|
onError,
|
|
@@ -198,13 +206,14 @@ export function useBuildDataSource({
|
|
|
198
206
|
saveEntity: useCallback(async <M extends Record<string, any>>(
|
|
199
207
|
{
|
|
200
208
|
path,
|
|
209
|
+
pathSegments,
|
|
201
210
|
entityId,
|
|
202
211
|
values,
|
|
203
212
|
collection: collectionProp,
|
|
204
213
|
status
|
|
205
214
|
}: SaveEntityProps<M>): Promise<Entity<M>> => {
|
|
206
215
|
|
|
207
|
-
const collection = collectionProp ?? navigationController.getCollection(path);
|
|
216
|
+
const collection = collectionProp ?? navigationController.getCollection(path, false, pathSegments);
|
|
208
217
|
const usedDelegate = collection?.overrides?.dataSourceDelegate ?? delegate;
|
|
209
218
|
|
|
210
219
|
const resolvedCollection = collection
|
|
@@ -244,6 +253,7 @@ export function useBuildDataSource({
|
|
|
244
253
|
try {
|
|
245
254
|
const entities = await usedDelegate.fetchCollection({
|
|
246
255
|
path,
|
|
256
|
+
pathSegments,
|
|
247
257
|
orderBy: orderProperty,
|
|
248
258
|
order: "asc",
|
|
249
259
|
limit: 1,
|
|
@@ -270,14 +280,21 @@ export function useBuildDataSource({
|
|
|
270
280
|
|
|
271
281
|
return usedDelegate.saveEntity({
|
|
272
282
|
path,
|
|
283
|
+
pathSegments,
|
|
273
284
|
collection,
|
|
274
285
|
entityId,
|
|
275
286
|
values: finalValues,
|
|
276
287
|
status
|
|
277
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.
|
|
278
292
|
return {
|
|
279
|
-
|
|
280
|
-
|
|
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,
|
|
281
298
|
values: usedDelegate.delegateToCMSModel(finalValues)
|
|
282
299
|
} as Entity<M>;
|
|
283
300
|
});
|
|
@@ -292,12 +309,15 @@ export function useBuildDataSource({
|
|
|
292
309
|
deleteEntity: useCallback(<M extends Record<string, any>>(
|
|
293
310
|
{
|
|
294
311
|
entity,
|
|
312
|
+
pathSegments,
|
|
295
313
|
collection
|
|
296
314
|
}: DeleteEntityProps<M>
|
|
297
315
|
): Promise<void> => {
|
|
298
316
|
const usedDelegate = collection?.overrides?.dataSourceDelegate ?? delegate;
|
|
299
317
|
return usedDelegate.deleteEntity({
|
|
300
318
|
entity,
|
|
319
|
+
// Falls back to what the entity carries from the fetch that produced it.
|
|
320
|
+
pathSegments: pathSegments ?? entity.pathSegments,
|
|
301
321
|
collection
|
|
302
322
|
});
|
|
303
323
|
}, [delegate.deleteEntity]),
|
|
@@ -317,33 +337,32 @@ export function useBuildDataSource({
|
|
|
317
337
|
name: string,
|
|
318
338
|
value: any,
|
|
319
339
|
entityId?: string,
|
|
320
|
-
collection?: EntityCollection
|
|
340
|
+
collection?: EntityCollection,
|
|
341
|
+
pathSegments?: string[]
|
|
321
342
|
): Promise<boolean> => {
|
|
322
343
|
const usedDelegate = collection?.overrides?.dataSourceDelegate ?? delegate;
|
|
323
|
-
return usedDelegate.checkUniqueField(path, name, value, entityId, collection);
|
|
344
|
+
return usedDelegate.checkUniqueField(path, name, value, entityId, collection, pathSegments);
|
|
324
345
|
}, [delegate.checkUniqueField]),
|
|
325
346
|
|
|
326
|
-
generateEntityId: useCallback((path: string, collection: EntityCollection): string => {
|
|
347
|
+
generateEntityId: useCallback((path: string, collection: EntityCollection, pathSegments?: string[]): string => {
|
|
327
348
|
const usedDelegate = collection?.overrides?.dataSourceDelegate ?? delegate;
|
|
328
|
-
return usedDelegate.generateEntityId(path, collection);
|
|
349
|
+
return usedDelegate.generateEntityId(path, collection, pathSegments);
|
|
329
350
|
}, [delegate.generateEntityId]),
|
|
330
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.
|
|
331
354
|
countEntities: delegate.countEntities ? async ({
|
|
332
355
|
path,
|
|
356
|
+
pathSegments,
|
|
333
357
|
collection,
|
|
334
358
|
filter,
|
|
335
359
|
order,
|
|
336
360
|
orderBy
|
|
337
|
-
}: {
|
|
338
|
-
path: string,
|
|
339
|
-
collection: EntityCollection<any>,
|
|
340
|
-
filter?: FilterValues<Extract<keyof any, string>>,
|
|
341
|
-
orderBy?: string,
|
|
342
|
-
order?: "desc" | "asc",
|
|
343
|
-
}): Promise<number> => {
|
|
361
|
+
}: FetchCollectionProps<any>): Promise<number> => {
|
|
344
362
|
const usedDelegate = collection?.overrides?.dataSourceDelegate ?? delegate;
|
|
345
363
|
return usedDelegate.countEntities!({
|
|
346
364
|
path,
|
|
365
|
+
pathSegments,
|
|
347
366
|
filter,
|
|
348
367
|
orderBy,
|
|
349
368
|
order,
|
|
@@ -353,11 +372,13 @@ export function useBuildDataSource({
|
|
|
353
372
|
|
|
354
373
|
isFilterCombinationValid: useCallback(({
|
|
355
374
|
path,
|
|
375
|
+
pathSegments,
|
|
356
376
|
databaseId,
|
|
357
377
|
filterValues,
|
|
358
378
|
sortBy
|
|
359
379
|
}: {
|
|
360
380
|
path: string,
|
|
381
|
+
pathSegments?: string[],
|
|
361
382
|
databaseId?: string,
|
|
362
383
|
filterValues: FilterValues<any>,
|
|
363
384
|
sortBy?: [string, "asc" | "desc"]
|
|
@@ -367,6 +388,7 @@ export function useBuildDataSource({
|
|
|
367
388
|
return delegate.isFilterCombinationValid(
|
|
368
389
|
{
|
|
369
390
|
path,
|
|
391
|
+
pathSegments,
|
|
370
392
|
databaseId,
|
|
371
393
|
filterValues,
|
|
372
394
|
sortBy
|