@firecms/core 3.4.0-canary.0ef7442 → 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/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/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 +2 -0
- package/dist/form/EntityFormActions.d.ts +2 -0
- package/dist/hooks/data/delete.d.ts +1 -1
- package/dist/hooks/data/useCollectionFetch.d.ts +1 -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 +853 -600
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +852 -599
- package/dist/index.umd.js.map +1 -1
- package/dist/types/collections.d.ts +7 -0
- package/dist/types/datasource.d.ts +8 -0
- 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 +15 -0
- package/dist/util/paths.d.ts +28 -0
- package/dist/util/permissions.d.ts +10 -4
- package/package.json +3 -3
- package/src/components/DeleteEntityDialog.tsx +2 -0
- package/src/components/EntityCollectionTable/EntityCollectionRowActions.tsx +2 -2
- package/src/components/EntityCollectionView/EntityCollectionBoardView.tsx +8 -0
- package/src/components/EntityCollectionView/EntityCollectionView.tsx +40 -15
- package/src/components/EntityCollectionView/EntityCollectionViewActions.tsx +6 -2
- package/src/components/EntityCollectionView/EntityCollectionViewStartActions.tsx +4 -0
- package/src/components/EntityCollectionView/useBoardDataController.tsx +7 -2
- package/src/components/EntityPreview.tsx +4 -1
- package/src/components/ReferenceTable/ReferenceSelectionTable.tsx +8 -2
- package/src/components/common/default_entity_actions.tsx +22 -2
- package/src/components/common/useDataSourceTableController.tsx +26 -8
- package/src/components/common/useTableSearchHelper.ts +5 -0
- package/src/core/EntityEditView.tsx +8 -12
- package/src/core/EntityEditViewFormActions.tsx +3 -2
- package/src/core/EntitySidePanel.tsx +6 -4
- package/src/form/EntityForm.tsx +22 -7
- package/src/form/EntityFormActions.tsx +2 -0
- package/src/hooks/data/delete.ts +8 -2
- package/src/hooks/data/save.ts +4 -1
- package/src/hooks/data/useCollectionFetch.tsx +9 -2
- package/src/hooks/data/useEntityFetch.tsx +19 -6
- package/src/hooks/useBuildNavigationController.tsx +23 -8
- package/src/hooks/useResolvedNavigationFrom.tsx +1 -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 +13 -12
- 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 +8 -0
- 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/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 +32 -1
- package/src/util/paths.ts +40 -3
- package/src/util/permissions.ts +22 -10
|
@@ -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
|
|
@@ -60,6 +60,7 @@ export function EntitySidePanel(props: EntitySidePanelProps) {
|
|
|
60
60
|
if (params.status !== "existing") {
|
|
61
61
|
sideEntityController.replace({
|
|
62
62
|
path: params.path,
|
|
63
|
+
pathSegments: params.pathSegments ?? pathSegments,
|
|
63
64
|
entityId: params.entityId,
|
|
64
65
|
fullIdPath: props.fullIdPath,
|
|
65
66
|
selectedTab: params.selectedTab,
|
|
@@ -76,10 +77,10 @@ export function EntitySidePanel(props: EntitySidePanelProps) {
|
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
const parentCollectionIds = useMemo(() => {
|
|
79
|
-
return navigationController.getParentCollectionIds(path);
|
|
80
|
+
return navigationController.getParentCollectionIds(path, pathSegments);
|
|
80
81
|
}, [navigationController, path]);
|
|
81
82
|
|
|
82
|
-
const collection = props.collection ?? navigationController.getCollection(fullIdPath ?? path);
|
|
83
|
+
const collection = props.collection ?? navigationController.getCollection(fullIdPath ?? path, false, pathSegments);
|
|
83
84
|
|
|
84
85
|
useEffect(() => {
|
|
85
86
|
function beforeunload(e: any) {
|
|
@@ -137,7 +138,7 @@ export function EntitySidePanel(props: EntitySidePanelProps) {
|
|
|
137
138
|
className="self-center"
|
|
138
139
|
size={"smallest"}
|
|
139
140
|
onClick={() => {
|
|
140
|
-
const key = (status === "new" || status === "copy") ? path + "#new" : path
|
|
141
|
+
const key = (status === "new" || status === "copy") ? path + "#new" : entityCacheKey(path, entityId);
|
|
141
142
|
saveEntityToMemoryCache(key, values);
|
|
142
143
|
if (entityId)
|
|
143
144
|
navigate(location.pathname + location.search);
|
|
@@ -154,6 +155,7 @@ export function EntitySidePanel(props: EntitySidePanelProps) {
|
|
|
154
155
|
}) => {
|
|
155
156
|
sideEntityController.replace({
|
|
156
157
|
path,
|
|
158
|
+
pathSegments,
|
|
157
159
|
entityId,
|
|
158
160
|
fullIdPath: props.fullIdPath,
|
|
159
161
|
selectedTab,
|
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,
|
|
@@ -64,6 +65,8 @@ export type OnUpdateParams = {
|
|
|
64
65
|
entity: Entity<any>,
|
|
65
66
|
status: EntityStatus,
|
|
66
67
|
path: string,
|
|
68
|
+
/** `path` split at its real segment boundaries, when known. Never derived from `path`. */
|
|
69
|
+
pathSegments?: string[],
|
|
67
70
|
entityId?: string;
|
|
68
71
|
selectedTab?: string;
|
|
69
72
|
collection: EntityCollection<any>
|
|
@@ -276,6 +279,7 @@ export function EntityForm<M extends Record<string, any>>({
|
|
|
276
279
|
entityId: entityIdProp,
|
|
277
280
|
collection,
|
|
278
281
|
path,
|
|
282
|
+
pathSegments,
|
|
279
283
|
values: valuesToBeSaved
|
|
280
284
|
});
|
|
281
285
|
}, false, 2000);
|
|
@@ -314,7 +318,7 @@ export function EntityForm<M extends Record<string, any>>({
|
|
|
314
318
|
const baseInitialValues = useMemo(() => getInitialEntityValues(authController, collection, path, status, entity, customizationController.propertyConfigs), [authController, collection, path, status, entity, customizationController.propertyConfigs]);
|
|
315
319
|
|
|
316
320
|
const localChangesDataRaw = useMemo(() => entityId
|
|
317
|
-
? getEntityFromCache(path
|
|
321
|
+
? getEntityFromCache(entityCacheKey(path, entityId))
|
|
318
322
|
: getEntityFromCache(path + "#new"), [entityId, path]);
|
|
319
323
|
|
|
320
324
|
const [localChangesCleared, setLocalChangesCleared] = useState<boolean>(false);
|
|
@@ -393,7 +397,7 @@ export function EntityForm<M extends Record<string, any>>({
|
|
|
393
397
|
onValuesModified?.(false, initialValues as M);
|
|
394
398
|
},
|
|
395
399
|
onValuesChangeDeferred: (values: M, controller: FormexController<M>) => {
|
|
396
|
-
const key = (status === "new" || status === "copy") ? path + "#new" : path
|
|
400
|
+
const key = (status === "new" || status === "copy") ? path + "#new" : entityCacheKey(path, entityId);
|
|
397
401
|
if (controller.dirty && localChangesBackup !== false) {
|
|
398
402
|
const touchedValues = removeEmptyContainers(extractTouchedValues(values, controller.touched));
|
|
399
403
|
if (touchedValues && Object.keys(touchedValues).length > 0) {
|
|
@@ -474,8 +478,8 @@ export function EntityForm<M extends Record<string, any>>({
|
|
|
474
478
|
removeEntityFromMemoryCache(path + "#new");
|
|
475
479
|
removeEntityFromCache(path + "#new");
|
|
476
480
|
} else {
|
|
477
|
-
removeEntityFromMemoryCache(path
|
|
478
|
-
removeEntityFromCache(path
|
|
481
|
+
removeEntityFromMemoryCache(entityCacheKey(path, entityId));
|
|
482
|
+
removeEntityFromCache(entityCacheKey(path, entityId));
|
|
479
483
|
}
|
|
480
484
|
}
|
|
481
485
|
|
|
@@ -497,6 +501,7 @@ export function EntityForm<M extends Record<string, any>>({
|
|
|
497
501
|
entity: updatedEntity,
|
|
498
502
|
status,
|
|
499
503
|
path,
|
|
504
|
+
pathSegments,
|
|
500
505
|
entityId: updatedEntity.id,
|
|
501
506
|
collection
|
|
502
507
|
});
|
|
@@ -517,16 +522,20 @@ export function EntityForm<M extends Record<string, any>>({
|
|
|
517
522
|
previousValues,
|
|
518
523
|
entityId,
|
|
519
524
|
collection,
|
|
520
|
-
path
|
|
525
|
+
path,
|
|
526
|
+
pathSegments
|
|
521
527
|
}: {
|
|
522
528
|
collection: EntityCollection<M>,
|
|
523
529
|
path: string,
|
|
530
|
+
/** `path` split at its real segment boundaries, travelling with the `path` above. */
|
|
531
|
+
pathSegments?: string[],
|
|
524
532
|
entityId: string | undefined,
|
|
525
533
|
values: M,
|
|
526
534
|
previousValues?: M,
|
|
527
535
|
}) => {
|
|
528
536
|
return saveEntityWithCallbacks({
|
|
529
537
|
path,
|
|
538
|
+
pathSegments,
|
|
530
539
|
entityId,
|
|
531
540
|
values,
|
|
532
541
|
previousValues,
|
|
@@ -544,6 +553,7 @@ export function EntityForm<M extends Record<string, any>>({
|
|
|
544
553
|
type EntityFormSaveParams<M extends Record<string, any>> = {
|
|
545
554
|
collection: ResolvedEntityCollection<M>,
|
|
546
555
|
path: string,
|
|
556
|
+
pathSegments?: string[],
|
|
547
557
|
entityId: string | undefined,
|
|
548
558
|
values: EntityValues<M>,
|
|
549
559
|
previousValues?: EntityValues<M>,
|
|
@@ -553,6 +563,7 @@ export function EntityForm<M extends Record<string, any>>({
|
|
|
553
563
|
const onSaveEntityRequest = async ({
|
|
554
564
|
collection,
|
|
555
565
|
path,
|
|
566
|
+
pathSegments,
|
|
556
567
|
entityId,
|
|
557
568
|
values,
|
|
558
569
|
previousValues,
|
|
@@ -566,6 +577,7 @@ export function EntityForm<M extends Record<string, any>>({
|
|
|
566
577
|
return saveEntity({
|
|
567
578
|
collection,
|
|
568
579
|
path,
|
|
580
|
+
pathSegments,
|
|
569
581
|
entityId,
|
|
570
582
|
values,
|
|
571
583
|
previousValues
|
|
@@ -579,6 +591,7 @@ export function EntityForm<M extends Record<string, any>>({
|
|
|
579
591
|
return onSaveEntityRequest({
|
|
580
592
|
collection: resolvedCollection,
|
|
581
593
|
path,
|
|
594
|
+
pathSegments,
|
|
582
595
|
entityId,
|
|
583
596
|
values,
|
|
584
597
|
previousValues: entity?.values,
|
|
@@ -629,13 +642,14 @@ export function EntityForm<M extends Record<string, any>>({
|
|
|
629
642
|
const plugins = customizationController.plugins;
|
|
630
643
|
|
|
631
644
|
const actionsDisabled = disabled || formex.isSubmitting || (status === "existing" && !formex.dirty) || Boolean(disabledProp);
|
|
632
|
-
const parentCollectionIds = navigationController.getParentCollectionIds(path);
|
|
645
|
+
const parentCollectionIds = navigationController.getParentCollectionIds(path, pathSegments);
|
|
633
646
|
|
|
634
647
|
if (plugins && collection) {
|
|
635
648
|
const actionProps: PluginFormActionProps = {
|
|
636
649
|
entityId,
|
|
637
650
|
parentCollectionIds,
|
|
638
651
|
path,
|
|
652
|
+
pathSegments,
|
|
639
653
|
status,
|
|
640
654
|
collection,
|
|
641
655
|
context,
|
|
@@ -896,6 +910,7 @@ export function EntityForm<M extends Record<string, any>>({
|
|
|
896
910
|
const dialogActions = <EntityFormActionsComponent
|
|
897
911
|
collection={resolvedCollection}
|
|
898
912
|
path={path}
|
|
913
|
+
pathSegments={pathSegments}
|
|
899
914
|
fullPath={path}
|
|
900
915
|
fullIdPath={fullIdPath}
|
|
901
916
|
entity={entity}
|
|
@@ -930,7 +945,7 @@ export function EntityForm<M extends Record<string, any>>({
|
|
|
930
945
|
|
|
931
946
|
{manualApplyLocalChanges && hasLocalChanges &&
|
|
932
947
|
<LocalChangesMenu
|
|
933
|
-
cacheKey={status === "new" || status === "copy" ? path + "#new" : path
|
|
948
|
+
cacheKey={status === "new" || status === "copy" ? path + "#new" : entityCacheKey(path, entityId)}
|
|
934
949
|
properties={resolvedCollection.properties}
|
|
935
950
|
cachedData={localChangesDataRaw as Partial<M>}
|
|
936
951
|
formex={formex}
|
|
@@ -26,6 +26,8 @@ export interface EntityFormActionsProps {
|
|
|
26
26
|
fullIdPath?: string;
|
|
27
27
|
collection: ResolvedEntityCollection;
|
|
28
28
|
path: string;
|
|
29
|
+
/** `path` split at its real segment boundaries, when known. Never derived from `path`. */
|
|
30
|
+
pathSegments?: string[];
|
|
29
31
|
entity?: Entity;
|
|
30
32
|
layout: "bottom" | "side";
|
|
31
33
|
savingError?: Error;
|
package/src/hooks/data/delete.ts
CHANGED
|
@@ -46,6 +46,7 @@ export type DeleteEntityWithCallbacksProps<M extends Record<string, any>, USER e
|
|
|
46
46
|
export async function deleteEntityWithCallbacks<M extends Record<string, any>, USER extends User>({
|
|
47
47
|
dataSource,
|
|
48
48
|
entity,
|
|
49
|
+
pathSegments,
|
|
49
50
|
collection,
|
|
50
51
|
callbacks,
|
|
51
52
|
onDeleteSuccess,
|
|
@@ -62,11 +63,17 @@ export async function deleteEntityWithCallbacks<M extends Record<string, any>, U
|
|
|
62
63
|
|
|
63
64
|
console.debug("Deleting entity", entity.path, entity.id);
|
|
64
65
|
|
|
66
|
+
// An explicit `pathSegments` wins, then the ones the entity was loaded with. The prop
|
|
67
|
+
// used to be accepted and silently ignored, which is the same class of bug this field
|
|
68
|
+
// exists to prevent.
|
|
69
|
+
const resolvedPathSegments = pathSegments ?? entity.pathSegments;
|
|
70
|
+
|
|
65
71
|
const entityDeleteProps: EntityOnDeleteProps<M, any> = {
|
|
66
72
|
entity,
|
|
67
73
|
collection,
|
|
68
74
|
entityId: entity.id,
|
|
69
75
|
path: entity.path,
|
|
76
|
+
pathSegments: resolvedPathSegments,
|
|
70
77
|
context
|
|
71
78
|
};
|
|
72
79
|
|
|
@@ -81,8 +88,7 @@ export async function deleteEntityWithCallbacks<M extends Record<string, any>, U
|
|
|
81
88
|
}
|
|
82
89
|
}
|
|
83
90
|
return dataSource.deleteEntity({
|
|
84
|
-
|
|
85
|
-
pathSegments: entity.pathSegments,
|
|
91
|
+
pathSegments: resolvedPathSegments,
|
|
86
92
|
entity,
|
|
87
93
|
collection
|
|
88
94
|
}).then(() => {
|
package/src/hooks/data/save.ts
CHANGED
|
@@ -68,7 +68,7 @@ export async function saveEntityWithCallbacks<M extends Record<string, any>, USE
|
|
|
68
68
|
|
|
69
69
|
const customizationController = context.customizationController;
|
|
70
70
|
|
|
71
|
-
const resolvedPath = context.navigation.resolveIdsFrom(path);
|
|
71
|
+
const resolvedPath = context.navigation.resolveIdsFrom(path, pathSegments);
|
|
72
72
|
|
|
73
73
|
const callbacks = collection.callbacks;
|
|
74
74
|
if (callbacks?.onPreSave) {
|
|
@@ -84,6 +84,7 @@ export async function saveEntityWithCallbacks<M extends Record<string, any>, USE
|
|
|
84
84
|
updatedValues = await callbacks.onPreSave({
|
|
85
85
|
collection: resolvedCollection,
|
|
86
86
|
path,
|
|
87
|
+
pathSegments,
|
|
87
88
|
resolvedPath,
|
|
88
89
|
entityId,
|
|
89
90
|
values,
|
|
@@ -123,6 +124,7 @@ export async function saveEntityWithCallbacks<M extends Record<string, any>, USE
|
|
|
123
124
|
callbacks.onSaveSuccess({
|
|
124
125
|
collection: resolvedCollection,
|
|
125
126
|
path,
|
|
127
|
+
pathSegments,
|
|
126
128
|
resolvedPath,
|
|
127
129
|
entityId: entity.id,
|
|
128
130
|
values: updatedValues,
|
|
@@ -152,6 +154,7 @@ export async function saveEntityWithCallbacks<M extends Record<string, any>, USE
|
|
|
152
154
|
callbacks.onSaveFailure({
|
|
153
155
|
collection: resolvedCollection,
|
|
154
156
|
path,
|
|
157
|
+
pathSegments,
|
|
155
158
|
resolvedPath,
|
|
156
159
|
entityId,
|
|
157
160
|
values: updatedValues,
|
|
@@ -69,7 +69,7 @@ export interface CollectionFetchResult<M extends Record<string, any>> {
|
|
|
69
69
|
export function useCollectionFetch<M extends Record<string, any>, USER extends User>(
|
|
70
70
|
{
|
|
71
71
|
path: inputPath,
|
|
72
|
-
pathSegments,
|
|
72
|
+
pathSegments: inputPathSegments,
|
|
73
73
|
collection,
|
|
74
74
|
filterValues,
|
|
75
75
|
sortBy,
|
|
@@ -80,7 +80,13 @@ export function useCollectionFetch<M extends Record<string, any>, USER extends U
|
|
|
80
80
|
const dataSource = useDataSource(collection);
|
|
81
81
|
const navigationController = useNavigationController();
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
// Resolved together so the two representations always describe the same chain.
|
|
84
|
+
// Segments are resolved, never derived: only collection ids are swapped for their
|
|
85
|
+
// real paths, and entity ids are carried through whole (see useEntityFetch).
|
|
86
|
+
const pathSegments = inputPathSegments
|
|
87
|
+
? (navigationController.resolveSegmentsFrom?.(inputPathSegments) ?? inputPathSegments)
|
|
88
|
+
: undefined;
|
|
89
|
+
const path = navigationController.resolveIdsFrom(inputPath, inputPathSegments);
|
|
84
90
|
|
|
85
91
|
const sortByProperty = sortBy ? sortBy[0] : undefined;
|
|
86
92
|
const currentSort = sortBy ? sortBy[1] : undefined;
|
|
@@ -109,6 +115,7 @@ export function useCollectionFetch<M extends Record<string, any>, USER extends U
|
|
|
109
115
|
collection.callbacks!.onFetch!({
|
|
110
116
|
collection,
|
|
111
117
|
path,
|
|
118
|
+
pathSegments,
|
|
112
119
|
entity,
|
|
113
120
|
context
|
|
114
121
|
})));
|
|
@@ -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,11 +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
|
-
const path = navigationController.resolveIdsFrom(inputPath);
|
|
58
61
|
// Never fabricate segments. If the caller did not thread them, pass undefined so a
|
|
59
62
|
// delegate can tell "not provided" from a real answer — splitting `path` here would
|
|
60
63
|
// produce a confidently wrong array for any id containing "/".
|
|
61
|
-
|
|
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);
|
|
62
74
|
|
|
63
75
|
const context: FireCMSContext<USER> = useFireCMSContext();
|
|
64
76
|
|
|
@@ -82,6 +94,7 @@ export function useEntityFetch<M extends Record<string, any>, USER extends User>
|
|
|
82
94
|
updatedEntity = await collection.callbacks.onFetch({
|
|
83
95
|
collection,
|
|
84
96
|
path,
|
|
97
|
+
pathSegments,
|
|
85
98
|
entity: updatedEntity,
|
|
86
99
|
context
|
|
87
100
|
});
|
|
@@ -89,7 +102,7 @@ export function useEntityFetch<M extends Record<string, any>, USER extends User>
|
|
|
89
102
|
console.error(e);
|
|
90
103
|
}
|
|
91
104
|
}
|
|
92
|
-
CACHE[
|
|
105
|
+
CACHE[entityCacheKey(path, entityId)] = updatedEntity;
|
|
93
106
|
setEntity(updatedEntity);
|
|
94
107
|
setDataLoading(false);
|
|
95
108
|
setDataLoadingError(undefined);
|
|
@@ -102,8 +115,8 @@ export function useEntityFetch<M extends Record<string, any>, USER extends User>
|
|
|
102
115
|
setDataLoadingError(error);
|
|
103
116
|
};
|
|
104
117
|
|
|
105
|
-
if (entityId && useCache && CACHE[
|
|
106
|
-
setEntity(CACHE[
|
|
118
|
+
if (entityId && useCache && CACHE[entityCacheKey(path, entityId)]) {
|
|
119
|
+
setEntity(CACHE[entityCacheKey(path, entityId)]);
|
|
107
120
|
setDataLoading(false);
|
|
108
121
|
setDataLoadingError(undefined);
|
|
109
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,7 +80,7 @@ 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
|
}
|
|
@@ -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
|
+
});
|