@firecms/core 3.4.0-canary.0ef7442 → 3.4.0-canary.1faa2a9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/dist/components/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/ErrorBoundary.d.ts +1 -3
- package/dist/components/LicenseBanner.d.ts +23 -0
- package/dist/components/common/useDataSourceTableController.d.ts +4 -2
- package/dist/components/common/useTableSearchHelper.d.ts +3 -1
- package/dist/components/index.d.ts +1 -0
- package/dist/contexts/LicenseStatusContext.d.ts +16 -0
- package/dist/core/EntityEditView.d.ts +2 -0
- package/dist/core/EntityEditViewFormActions.d.ts +1 -1
- package/dist/core/index.d.ts +1 -0
- package/dist/core/plugin_translations.d.ts +38 -0
- package/dist/core/pro_plugins.d.ts +40 -0
- package/dist/de-KtBROUvS.js +700 -0
- package/dist/de-KtBROUvS.js.map +1 -0
- package/dist/editor/extensions/Image.d.ts +11 -0
- package/dist/editor/index.d.ts +2 -1
- package/dist/editor/lazy_editor.d.ts +6 -0
- package/dist/editor-BSo_aS1I.js +4563 -0
- package/dist/editor-BSo_aS1I.js.map +1 -0
- package/dist/form/EntityForm.d.ts +2 -0
- package/dist/form/EntityFormActions.d.ts +2 -0
- package/dist/fr-BRFSBaVh.js +700 -0
- package/dist/fr-BRFSBaVh.js.map +1 -0
- package/dist/hi-DwCkga5X.js +700 -0
- package/dist/hi-DwCkga5X.js.map +1 -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/hooks/index.d.ts +2 -0
- package/dist/hooks/useLicenseStatus.d.ts +16 -0
- package/dist/hooks/useProjectLog.d.ts +11 -6
- package/dist/hooks/useTranslation.d.ts +1 -1
- package/dist/i18n/__tests__/FireCMSi18nProvider.test.d.ts +1 -0
- package/dist/i18n/__tests__/nested_provider_lifecycle.test.d.ts +1 -0
- package/dist/index.es.js +3503 -11244
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +8028 -7006
- package/dist/index.umd.js.map +1 -1
- package/dist/it-B2YGgDsx.js +700 -0
- package/dist/it-B2YGgDsx.js.map +1 -0
- package/dist/pl-BxHkywK8.js +702 -0
- package/dist/pl-BxHkywK8.js.map +1 -0
- package/dist/pt-q6UV4diT.js +702 -0
- package/dist/pt-q6UV4diT.js.map +1 -0
- package/dist/types/analytics.d.ts +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/firecms.d.ts +18 -1
- package/dist/types/firecms_context.d.ts +8 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/license.d.ts +54 -0
- package/dist/types/navigation.d.ts +34 -5
- package/dist/types/plugins.d.ts +22 -1
- package/dist/types/side_entity_controller.d.ts +3 -2
- package/dist/types/translations.d.ts +31 -2
- package/dist/util/date_fns_locales.d.ts +18 -0
- package/dist/util/entity_cache.d.ts +11 -0
- package/dist/util/index.d.ts +1 -0
- package/dist/util/lazy_eager.d.ts +3 -3
- 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/app/Scaffold.tsx +7 -0
- 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/ErrorBoundary.tsx +12 -18
- package/src/components/LicenseBanner.tsx +209 -0
- 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/components/index.tsx +1 -0
- package/src/contexts/LicenseStatusContext.tsx +50 -0
- package/src/core/DefaultAppBar.tsx +28 -16
- package/src/core/EntityEditView.tsx +8 -12
- package/src/core/EntityEditViewFormActions.tsx +3 -2
- package/src/core/EntitySidePanel.tsx +6 -4
- package/src/core/FireCMS.tsx +73 -47
- package/src/core/SideDialogs.tsx +44 -1
- package/src/core/index.tsx +1 -0
- package/src/core/plugin_translations.ts +87 -0
- package/src/core/pro_plugins.ts +79 -0
- package/src/editor/components/SlashCommandMenu.tsx +4 -1
- package/src/editor/extensions/Image.ts +17 -1
- package/src/editor/hooks/useProseMirror.ts +5 -0
- package/src/editor/index.ts +11 -1
- package/src/editor/lazy_editor.tsx +33 -0
- package/src/form/EntityForm.tsx +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/index.tsx +2 -0
- package/src/hooks/useBuildNavigationController.tsx +70 -15
- package/src/hooks/useFireCMSContext.tsx +7 -3
- package/src/hooks/useLicenseStatus.tsx +32 -0
- package/src/hooks/useProjectLog.tsx +27 -9
- package/src/hooks/useResolvedNavigationFrom.tsx +1 -1
- package/src/hooks/useTranslation.ts +3 -1
- package/src/i18n/FireCMSi18nProvider.tsx +199 -25
- 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/locales/de.ts +16 -2
- package/src/locales/en.ts +18 -2
- package/src/locales/es.ts +16 -2
- package/src/locales/fr.ts +16 -2
- package/src/locales/hi.ts +16 -2
- package/src/locales/it.ts +16 -2
- package/src/locales/pl.ts +16 -2
- package/src/locales/pt.ts +16 -2
- package/src/preview/components/DatePreview.tsx +2 -4
- package/src/preview/components/ReferencePreview.tsx +7 -3
- package/src/routes/FireCMSRoute.tsx +9 -4
- package/src/types/analytics.ts +3 -0
- 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/firecms.tsx +19 -1
- package/src/types/firecms_context.tsx +9 -0
- package/src/types/index.ts +1 -0
- package/src/types/license.ts +62 -0
- package/src/types/navigation.ts +35 -5
- package/src/types/plugins.tsx +23 -1
- package/src/types/side_entity_controller.tsx +3 -2
- package/src/types/translations.ts +31 -2
- package/src/util/date_fns_locales.ts +168 -0
- package/src/util/entity_cache.ts +18 -0
- package/src/util/index.ts +1 -0
- package/src/util/lazy_eager.tsx +30 -8
- 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
- package/src/util/useStorageUploadController.tsx +6 -1
|
@@ -1,14 +1,10 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from "react";
|
|
2
|
-
import { AuthController, DataSourceDelegate, FireCMSPlugin } from "../types";
|
|
2
|
+
import { AccessResponse, AuthController, DataSourceDelegate, FireCMSPlugin } from "../types";
|
|
3
|
+
import { PRO_PLUGIN_KEYS } from "../core/pro_plugins";
|
|
3
4
|
|
|
4
5
|
export const DEFAULT_SERVER_DEV = "https://api-kdoe6pj3qq-ey.a.run.app";
|
|
5
6
|
export const DEFAULT_SERVER = "https://api.firecms.co";
|
|
6
7
|
|
|
7
|
-
export type AccessResponse = {
|
|
8
|
-
blocked?: boolean;
|
|
9
|
-
message?: string;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
8
|
async function makeRequest(authController: AuthController, dataSourceKey: string, pluginKeys: string[] | undefined, apiKey?: string): Promise<AccessResponse> {
|
|
13
9
|
let idToken: string | null;
|
|
14
10
|
try {
|
|
@@ -40,24 +36,46 @@ export interface UseProjectLogParams {
|
|
|
40
36
|
apiKey?: string;
|
|
41
37
|
authController: AuthController;
|
|
42
38
|
dataSourceDelegate: DataSourceDelegate;
|
|
39
|
+
/**
|
|
40
|
+
* Every plugin the app mounts, before any are paused: the server decides
|
|
41
|
+
* from these keys whether the project needs a license.
|
|
42
|
+
*/
|
|
43
43
|
plugins?: FireCMSPlugin<any, any, any>[];
|
|
44
|
+
/**
|
|
45
|
+
* When false, no request is sent, unless the request is a license check:
|
|
46
|
+
* with an `apiKey`, or with a PRO plugin mounted, it is always sent.
|
|
47
|
+
*/
|
|
48
|
+
telemetry?: boolean;
|
|
44
49
|
}
|
|
45
50
|
|
|
46
51
|
export function useProjectLog({
|
|
47
52
|
authController,
|
|
48
53
|
dataSourceDelegate,
|
|
49
54
|
plugins,
|
|
50
|
-
apiKey
|
|
55
|
+
apiKey,
|
|
56
|
+
telemetry = true
|
|
51
57
|
}: UseProjectLogParams): AccessResponse | null {
|
|
52
58
|
const [accessResponse, setAccessResponse] = useState<AccessResponse | null>(null);
|
|
53
59
|
const accessedUserRef = useRef<string | null>(null);
|
|
54
60
|
const dataSourceKey = dataSourceDelegate.key;
|
|
55
61
|
const pluginKeys = plugins?.map(plugin => plugin.key);
|
|
62
|
+
// Opting out is for apps without PRO plugins. With one mounted, the
|
|
63
|
+
// request is what starts the trial and pauses PRO when it ends, so
|
|
64
|
+
// skipping it would unlock PRO for good.
|
|
65
|
+
const runsProPlugin = (pluginKeys ?? []).some(key => PRO_PLUGIN_KEYS.includes(key));
|
|
66
|
+
const enabled = Boolean(apiKey) || runsProPlugin || telemetry !== false;
|
|
56
67
|
useEffect(() => {
|
|
68
|
+
if (!enabled) return;
|
|
57
69
|
if (authController.user && authController.user.uid !== accessedUserRef.current && !authController.initialLoading) {
|
|
58
|
-
makeRequest(authController, dataSourceKey, pluginKeys, apiKey)
|
|
70
|
+
makeRequest(authController, dataSourceKey, pluginKeys, apiKey)
|
|
71
|
+
.then((response) => setAccessResponse(response && typeof response === "object" ? response : null))
|
|
72
|
+
.catch((error) => {
|
|
73
|
+
// The check never gates the CMS, so a failed one only
|
|
74
|
+
// leaves the license status unknown.
|
|
75
|
+
console.debug("FireCMS license check failed", error);
|
|
76
|
+
});
|
|
59
77
|
accessedUserRef.current = authController.user.uid;
|
|
60
78
|
}
|
|
61
|
-
}, [authController, dataSourceKey, pluginKeys]);
|
|
79
|
+
}, [authController, dataSourceKey, pluginKeys, enabled]);
|
|
62
80
|
return accessResponse;
|
|
63
81
|
}
|
|
@@ -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
|
}
|
|
@@ -23,8 +23,10 @@ export function useTranslation() {
|
|
|
23
23
|
* Also supports i18next interpolation variables, e.g.
|
|
24
24
|
* t("add_to_field", { fieldName: "Tags" })
|
|
25
25
|
* t("error_deleting", { message: err.message })
|
|
26
|
+
* Pass `count` as a number to pick a plural form (`key_one`, falling back
|
|
27
|
+
* to `key`); i18next ignores a string `count`.
|
|
26
28
|
*/
|
|
27
|
-
const typedT = (key: string, vars?: Record<string, string>): string =>
|
|
29
|
+
const typedT = (key: string, vars?: Record<string, string | number>): string =>
|
|
28
30
|
t(key, vars) as string;
|
|
29
31
|
|
|
30
32
|
return { t: typedT, i18n };
|
|
@@ -1,18 +1,76 @@
|
|
|
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
|
-
import { de } from "../locales/de";
|
|
7
|
-
import { fr } from "../locales/fr";
|
|
8
|
-
import { it } from "../locales/it";
|
|
9
|
-
import { hi } from "../locales/hi";
|
|
10
|
-
import { pt } from "../locales/pt";
|
|
11
|
-
import { pl } from "../locales/pl";
|
|
12
6
|
import { FireCMSTranslations } from "../types/translations";
|
|
13
7
|
|
|
14
8
|
const FIRECMS_NS = "firecms_core";
|
|
15
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Translations that are not part of the bundle every app downloads.
|
|
12
|
+
*
|
|
13
|
+
* All eight locales used to be imported statically, which put ~305KB of strings
|
|
14
|
+
* — every language, for every user — into `@firecms/core` and therefore onto the
|
|
15
|
+
* startup path of every FireCMS app. Only the active language is ever read.
|
|
16
|
+
*
|
|
17
|
+
* `en` and `es` stay static: `en` is the fallback language and the base every
|
|
18
|
+
* unknown locale is seeded from, and both are re-exported from the package root,
|
|
19
|
+
* so a consumer may be importing them by name.
|
|
20
|
+
*
|
|
21
|
+
* The specifiers are written out rather than built from a template so bundlers
|
|
22
|
+
* can see them and emit one chunk per locale.
|
|
23
|
+
*/
|
|
24
|
+
const localeLoaders: Record<string, () => Promise<FireCMSTranslations>> = {
|
|
25
|
+
de: () => import("../locales/de").then((m) => m.de),
|
|
26
|
+
fr: () => import("../locales/fr").then((m) => m.fr),
|
|
27
|
+
it: () => import("../locales/it").then((m) => m.it),
|
|
28
|
+
hi: () => import("../locales/hi").then((m) => m.hi),
|
|
29
|
+
pt: () => import("../locales/pt").then((m) => m.pt),
|
|
30
|
+
pl: () => import("../locales/pl").then((m) => m.pl)
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Locale bundles available synchronously: the two static ones plus anything a
|
|
35
|
+
* previous provider already fetched. `buildResources` reads this, so a rebuild
|
|
36
|
+
* triggered by a changing `translations` prop cannot overwrite a lazily loaded
|
|
37
|
+
* language with the English base it would otherwise fall back to.
|
|
38
|
+
*/
|
|
39
|
+
const loadedLocales: Record<string, FireCMSTranslations> = {
|
|
40
|
+
en,
|
|
41
|
+
es
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const inFlightLocales: Record<string, Promise<void>> = {};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Fetch a locale bundle into `loadedLocales`.
|
|
48
|
+
*
|
|
49
|
+
* Returns undefined when the bundle is already in memory or there is nothing to
|
|
50
|
+
* fetch, so callers can stay synchronous in the common case.
|
|
51
|
+
*/
|
|
52
|
+
function loadLocale(locale?: string): Promise<void> | undefined {
|
|
53
|
+
if (!locale) return undefined;
|
|
54
|
+
const language = locale.split("-")[0];
|
|
55
|
+
if (loadedLocales[language]) return undefined;
|
|
56
|
+
const loader = localeLoaders[language];
|
|
57
|
+
if (!loader) return undefined;
|
|
58
|
+
if (!inFlightLocales[language]) {
|
|
59
|
+
inFlightLocales[language] = loader()
|
|
60
|
+
.then((bundle) => {
|
|
61
|
+
loadedLocales[language] = bundle;
|
|
62
|
+
})
|
|
63
|
+
.catch(() => {
|
|
64
|
+
// A locale that fails to load falls back to English, which is
|
|
65
|
+
// what an unknown locale has always done.
|
|
66
|
+
})
|
|
67
|
+
.finally(() => {
|
|
68
|
+
delete inFlightLocales[language];
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return inFlightLocales[language];
|
|
72
|
+
}
|
|
73
|
+
|
|
16
74
|
export const FIRECMS_LOCALE_STORAGE_KEY = "firecms_locale";
|
|
17
75
|
|
|
18
76
|
/** DeepPartial helper — allows partial overrides at any nesting level */
|
|
@@ -50,21 +108,44 @@ export function FireCMSi18nProvider({
|
|
|
50
108
|
translations,
|
|
51
109
|
children
|
|
52
110
|
}: PropsWithChildren<FireCMSi18nProviderProps>) {
|
|
111
|
+
|
|
112
|
+
// A FireCMS i18next instance already in context means this provider is nested —
|
|
113
|
+
// e.g. FireCMSCloudApp mounts one inside the one the host app already mounted.
|
|
114
|
+
// A second instance built only from the defaults would shadow the parent's and
|
|
115
|
+
// silently drop every translation the host registered, leaving the host's own
|
|
116
|
+
// strings to render as raw keys.
|
|
117
|
+
//
|
|
118
|
+
// So the nested instance is *seeded* from the parent's resources rather than
|
|
119
|
+
// sharing them. Sharing one mutable instance was the obvious alternative and is
|
|
120
|
+
// wrong: bundles added by one provider outlive it, so opening one project's app
|
|
121
|
+
// and then switching to another left the first project's translations answering
|
|
122
|
+
// for keys the second never defined. Copying keeps each provider's strings to
|
|
123
|
+
// itself, which is also how this behaved before nesting was handled at all.
|
|
124
|
+
const parentI18n = useContext(I18nContext)?.i18n;
|
|
125
|
+
const parentInstance = parentI18n?.hasResourceBundle?.("en", FIRECMS_NS)
|
|
126
|
+
? parentI18n
|
|
127
|
+
: undefined;
|
|
128
|
+
|
|
53
129
|
const i18nRef = useRef<i18n | null>(null);
|
|
54
130
|
const [ready, setReady] = React.useState(false);
|
|
55
131
|
|
|
56
132
|
if (!i18nRef.current) {
|
|
57
133
|
const instance = i18next.createInstance();
|
|
58
134
|
|
|
59
|
-
//
|
|
60
|
-
|
|
135
|
+
// English baseline + this provider's overrides, with anything the parent
|
|
136
|
+
// already resolved layered in underneath the overrides.
|
|
137
|
+
const resources = buildResourcesWithParent(translations, parentInstance);
|
|
61
138
|
|
|
62
|
-
let initialLocale = locale;
|
|
139
|
+
let initialLocale = parentInstance?.language ?? locale;
|
|
63
140
|
if (typeof window !== "undefined") {
|
|
64
141
|
const stored = localStorage.getItem(FIRECMS_LOCALE_STORAGE_KEY);
|
|
65
142
|
if (stored) initialLocale = stored;
|
|
66
143
|
}
|
|
67
144
|
|
|
145
|
+
// Kicked off before init so the request is in flight while i18next sets
|
|
146
|
+
// itself up, rather than after it.
|
|
147
|
+
const pendingInitialLocale = loadLocale(initialLocale);
|
|
148
|
+
|
|
68
149
|
instance
|
|
69
150
|
.use(initReactI18next)
|
|
70
151
|
.init({
|
|
@@ -77,19 +158,54 @@ export function FireCMSi18nProvider({
|
|
|
77
158
|
// React already escapes — don't double-escape
|
|
78
159
|
escapeValue: false,
|
|
79
160
|
},
|
|
161
|
+
react: {
|
|
162
|
+
// Locale bundles now arrive after init, and a bundle landing
|
|
163
|
+
// in the store raises an `added` event rather than
|
|
164
|
+
// `languageChanged`. react-i18next binds only the latter by
|
|
165
|
+
// default, so without this a language switched from the
|
|
166
|
+
// toggle would fetch its strings, put them in the store, and
|
|
167
|
+
// leave the UI sitting on the English fallback.
|
|
168
|
+
bindI18nStore: "added"
|
|
169
|
+
},
|
|
80
170
|
}, () => {
|
|
81
|
-
|
|
171
|
+
if (!pendingInitialLocale) {
|
|
172
|
+
setReady(true);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
// Holding the first render until the active language has arrived
|
|
176
|
+
// keeps a non-English user from seeing English strings repaint.
|
|
177
|
+
// Only locales outside the static pair wait, and only on the
|
|
178
|
+
// first mount.
|
|
179
|
+
pendingInitialLocale.then(() => {
|
|
180
|
+
applyLoadedLocale(instance, initialLocale, translations, parentInstance);
|
|
181
|
+
setReady(true);
|
|
182
|
+
});
|
|
82
183
|
});
|
|
83
184
|
|
|
84
185
|
instance.on("languageChanged", (lng) => {
|
|
85
186
|
if (typeof window !== "undefined") {
|
|
86
187
|
localStorage.setItem(FIRECMS_LOCALE_STORAGE_KEY, lng);
|
|
87
188
|
}
|
|
189
|
+
// Switching to a language whose bundle has not been fetched yet
|
|
190
|
+
// renders the English fallback until it lands.
|
|
191
|
+
loadLocale(lng)?.then(() => applyLoadedLocale(instance, lng, translations, parentInstance));
|
|
88
192
|
});
|
|
89
193
|
|
|
90
194
|
i18nRef.current = instance;
|
|
91
195
|
}
|
|
92
196
|
|
|
197
|
+
// Follow the parent's language, so a switch made outside this provider is not
|
|
198
|
+
// stranded on the other side of the boundary.
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
if (!parentInstance || !i18nRef.current) return;
|
|
201
|
+
const instance = i18nRef.current;
|
|
202
|
+
const follow = (lng: string) => {
|
|
203
|
+
if (instance.language !== lng) instance.changeLanguage(lng);
|
|
204
|
+
};
|
|
205
|
+
parentInstance.on("languageChanged", follow);
|
|
206
|
+
return () => { parentInstance.off("languageChanged", follow); };
|
|
207
|
+
}, [parentInstance]);
|
|
208
|
+
|
|
93
209
|
// When `locale` prop changes, switch language on the existing instance
|
|
94
210
|
// ONLY if the user hasn't explicitly set a preference
|
|
95
211
|
useEffect(() => {
|
|
@@ -101,10 +217,16 @@ export function FireCMSi18nProvider({
|
|
|
101
217
|
}
|
|
102
218
|
}, [locale]);
|
|
103
219
|
|
|
104
|
-
// When consumer translations prop changes, update the resource bundles
|
|
220
|
+
// When consumer translations prop changes, update the resource bundles.
|
|
221
|
+
//
|
|
222
|
+
// Built through the same layering as the initial instance, parent included.
|
|
223
|
+
// Rebuilding from the bare English baseline here would overwrite whatever the
|
|
224
|
+
// host registered — a nested provider without translations of its own would
|
|
225
|
+
// reset the host's overrides to the built-in strings. That was already true;
|
|
226
|
+
// it was simply invisible until `bindI18nStore` made a store write repaint.
|
|
105
227
|
useEffect(() => {
|
|
106
228
|
if (!i18nRef.current) return;
|
|
107
|
-
const resources =
|
|
229
|
+
const resources = buildResourcesWithParent(translations, parentInstance);
|
|
108
230
|
for (const [lang, bundle] of Object.entries(resources)) {
|
|
109
231
|
i18nRef.current.addResourceBundle(
|
|
110
232
|
lang,
|
|
@@ -114,7 +236,7 @@ export function FireCMSi18nProvider({
|
|
|
114
236
|
true // overwrite existing keys
|
|
115
237
|
);
|
|
116
238
|
}
|
|
117
|
-
}, [translations]);
|
|
239
|
+
}, [translations, parentInstance]);
|
|
118
240
|
|
|
119
241
|
if (!ready || !i18nRef.current) return null;
|
|
120
242
|
|
|
@@ -125,6 +247,64 @@ export function FireCMSi18nProvider({
|
|
|
125
247
|
);
|
|
126
248
|
}
|
|
127
249
|
|
|
250
|
+
/**
|
|
251
|
+
* Push a lazily loaded locale bundle into a live i18next instance.
|
|
252
|
+
*
|
|
253
|
+
* Consumer overrides are re-applied on top, because the instance may already be
|
|
254
|
+
* holding a bundle that `buildResources` seeded from English for this language,
|
|
255
|
+
* and the real translations have to win over that seed while the overrides win
|
|
256
|
+
* over both.
|
|
257
|
+
*/
|
|
258
|
+
function applyLoadedLocale(
|
|
259
|
+
instance: i18n,
|
|
260
|
+
locale: string,
|
|
261
|
+
translations?: { [locale: string]: DeepPartial<FireCMSTranslations> },
|
|
262
|
+
parentInstance?: i18n
|
|
263
|
+
) {
|
|
264
|
+
const language = locale.split("-")[0];
|
|
265
|
+
if (!loadedLocales[language]) return;
|
|
266
|
+
const bundle = buildResourcesWithParent(translations, parentInstance)[language]?.[FIRECMS_NS];
|
|
267
|
+
if (!bundle) return;
|
|
268
|
+
instance.addResourceBundle(
|
|
269
|
+
language,
|
|
270
|
+
FIRECMS_NS,
|
|
271
|
+
bundle,
|
|
272
|
+
true, // deep merge
|
|
273
|
+
true // overwrite the English seed
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The full resource map for this provider: the locale bundles currently in
|
|
279
|
+
* memory, then anything the parent provider already resolved, then this
|
|
280
|
+
* provider's own overrides on top.
|
|
281
|
+
*
|
|
282
|
+
* Shared by every path that writes into the instance — initial construction, a
|
|
283
|
+
* changed `translations` prop, and a locale arriving late — so all three layer
|
|
284
|
+
* in the same order and none of them can undo another.
|
|
285
|
+
*/
|
|
286
|
+
function buildResourcesWithParent(
|
|
287
|
+
translations?: { [locale: string]: DeepPartial<FireCMSTranslations> },
|
|
288
|
+
parentInstance?: i18n
|
|
289
|
+
): Record<string, Record<string, object>> {
|
|
290
|
+
const resources = buildResources(translations);
|
|
291
|
+
if (!parentInstance) return resources;
|
|
292
|
+
|
|
293
|
+
const inherited: Record<string, any> = parentInstance.services?.resourceStore?.data ?? {};
|
|
294
|
+
for (const [lang, namespaces] of Object.entries(inherited)) {
|
|
295
|
+
const bundle = (namespaces as any)?.[FIRECMS_NS];
|
|
296
|
+
if (!bundle) continue;
|
|
297
|
+
resources[lang] = {
|
|
298
|
+
[FIRECMS_NS]: {
|
|
299
|
+
...(resources[lang]?.[FIRECMS_NS] ?? {}),
|
|
300
|
+
...bundle,
|
|
301
|
+
...(translations?.[lang] ?? {})
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
return resources;
|
|
306
|
+
}
|
|
307
|
+
|
|
128
308
|
/**
|
|
129
309
|
* Build an i18next resources object from the English baseline plus any
|
|
130
310
|
* consumer-provided overrides.
|
|
@@ -132,16 +312,10 @@ export function FireCMSi18nProvider({
|
|
|
132
312
|
function buildResources(
|
|
133
313
|
translations?: { [locale: string]: DeepPartial<FireCMSTranslations> }
|
|
134
314
|
): Record<string, Record<string, object>> {
|
|
135
|
-
const resources: Record<string, Record<string, object>> = {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
fr: { [FIRECMS_NS]: { ...fr } },
|
|
140
|
-
it: { [FIRECMS_NS]: { ...it } },
|
|
141
|
-
hi: { [FIRECMS_NS]: { ...hi } },
|
|
142
|
-
pt: { [FIRECMS_NS]: { ...pt } },
|
|
143
|
-
pl: { [FIRECMS_NS]: { ...pl } },
|
|
144
|
-
};
|
|
315
|
+
const resources: Record<string, Record<string, object>> = {};
|
|
316
|
+
for (const [lang, bundle] of Object.entries(loadedLocales)) {
|
|
317
|
+
resources[lang] = { [FIRECMS_NS]: { ...bundle } };
|
|
318
|
+
}
|
|
145
319
|
|
|
146
320
|
if (!translations) return resources;
|
|
147
321
|
|
|
@@ -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
|
});
|
|
@@ -343,6 +349,8 @@ export function useBuildDataSource({
|
|
|
343
349
|
return usedDelegate.generateEntityId(path, collection, pathSegments);
|
|
344
350
|
}, [delegate.generateEntityId]),
|
|
345
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.
|
|
346
354
|
countEntities: delegate.countEntities ? async ({
|
|
347
355
|
path,
|
|
348
356
|
pathSegments,
|
|
@@ -350,14 +358,7 @@ export function useBuildDataSource({
|
|
|
350
358
|
filter,
|
|
351
359
|
order,
|
|
352
360
|
orderBy
|
|
353
|
-
}: {
|
|
354
|
-
path: string,
|
|
355
|
-
pathSegments?: string[],
|
|
356
|
-
collection: EntityCollection<any>,
|
|
357
|
-
filter?: FilterValues<Extract<keyof any, string>>,
|
|
358
|
-
orderBy?: string,
|
|
359
|
-
order?: "desc" | "asc",
|
|
360
|
-
}): Promise<number> => {
|
|
361
|
+
}: FetchCollectionProps<any>): Promise<number> => {
|
|
361
362
|
const usedDelegate = collection?.overrides?.dataSourceDelegate ?? delegate;
|
|
362
363
|
return usedDelegate.countEntities!({
|
|
363
364
|
path,
|
|
@@ -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
|