Skip to content

Commit 7b33619

Browse files
author
Tim Roes
authored
Allow overwriting i18n messages via LaunchDarkly (#13347)
* Allow overwriting i18n messages via LaunchDarkly * add string type check * Add unit tests * Change to have locale part of user attributes * Add test for strong tags
1 parent 3125d92 commit 7b33619

File tree

6 files changed

+173
-36
lines changed

6 files changed

+173
-36
lines changed

airbyte-webapp/src/App.tsx

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import React, { Suspense } from "react";
2-
import { IntlProvider } from "react-intl";
32
import { BrowserRouter as Router } from "react-router-dom";
43
import { ThemeProvider } from "styled-components";
54

65
import { ApiServices } from "core/ApiServices";
6+
import { I18nProvider } from "core/i18n";
77
import { ServicesProvider } from "core/servicesProvider";
88
import { ConfirmationModalService } from "hooks/services/ConfirmationModal";
99
import { FeatureService } from "hooks/services/Feature";
@@ -35,18 +35,6 @@ const StyleProvider: React.FC = ({ children }) => (
3535
</ThemeProvider>
3636
);
3737

38-
const I18NProvider: React.FC = ({ children }) => (
39-
<IntlProvider
40-
locale="en"
41-
messages={en}
42-
defaultRichTextElements={{
43-
b: (chunk) => <strong>{chunk}</strong>,
44-
}}
45-
>
46-
{children}
47-
</IntlProvider>
48-
);
49-
5038
const configProviders: ValueProvider<Config> = [envConfigProvider, windowConfigProvider];
5139

5240
const Services: React.FC = ({ children }) => (
@@ -71,7 +59,7 @@ const App: React.FC = () => {
7159
return (
7260
<React.StrictMode>
7361
<StyleProvider>
74-
<I18NProvider>
62+
<I18nProvider locale="en" messages={en}>
7563
<StoreProvider>
7664
<ServicesProvider>
7765
<Suspense fallback={<LoadingPage />}>
@@ -85,7 +73,7 @@ const App: React.FC = () => {
8573
</Suspense>
8674
</ServicesProvider>
8775
</StoreProvider>
88-
</I18NProvider>
76+
</I18nProvider>
8977
</StyleProvider>
9078
</React.StrictMode>
9179
);
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { render } from "@testing-library/react";
2+
import { act, renderHook } from "@testing-library/react-hooks";
3+
import { FormattedMessage, IntlConfig, useIntl } from "react-intl";
4+
5+
import { I18nProvider, useI18nContext } from "./I18nProvider";
6+
7+
const provider = (messages: IntlConfig["messages"], locale = "en"): React.FC => {
8+
return ({ children }) => (
9+
<I18nProvider locale={locale} messages={messages}>
10+
{children}
11+
</I18nProvider>
12+
);
13+
};
14+
15+
const useMessages = () => {
16+
const { setMessageOverwrite } = useI18nContext();
17+
const { messages } = useIntl();
18+
return { setMessageOverwrite, messages };
19+
};
20+
21+
describe("I18nProvider", () => {
22+
it("should set the react-intl locale correctly", () => {
23+
const { result } = renderHook(() => useIntl(), {
24+
wrapper: provider({}, "fr"),
25+
});
26+
expect(result.current.locale).toBe("fr");
27+
});
28+
29+
it("should set messages for consumption via react-intl", () => {
30+
const wrapper = render(
31+
<span data-testid="msg">
32+
<FormattedMessage id="test.id" />
33+
</span>,
34+
{ wrapper: provider({ "test.id": "Hello world!" }) }
35+
);
36+
expect(wrapper.getByTestId("msg").textContent).toBe("Hello world!");
37+
});
38+
39+
it("should allow render <b></b> tags for every message", () => {
40+
const wrapper = render(
41+
<span data-testid="msg">
42+
<FormattedMessage id="test.id" />
43+
</span>,
44+
{ wrapper: provider({ "test.id": "Hello <b>world</b>!" }) }
45+
);
46+
expect(wrapper.getByTestId("msg").innerHTML).toBe("Hello <strong>world</strong>!");
47+
});
48+
49+
describe("useI18nContext", () => {
50+
it("should allow overwriting default and setting additional messages", () => {
51+
const { result } = renderHook(() => useMessages(), {
52+
wrapper: provider({ test: "default message" }),
53+
});
54+
expect(result.current.messages).toHaveProperty("test", "default message");
55+
act(() => result.current.setMessageOverwrite({ test: "overwritten message", other: "new message" }));
56+
expect(result.current.messages).toHaveProperty("test", "overwritten message");
57+
expect(result.current.messages).toHaveProperty("other", "new message");
58+
});
59+
60+
it("should allow resetting overwrites with an empty object", () => {
61+
const { result } = renderHook(() => useMessages(), {
62+
wrapper: provider({ test: "default message" }),
63+
});
64+
act(() => result.current.setMessageOverwrite({ test: "overwritten message" }));
65+
expect(result.current.messages).toHaveProperty("test", "overwritten message");
66+
act(() => result.current.setMessageOverwrite({}));
67+
expect(result.current.messages).toHaveProperty("test", "default message");
68+
});
69+
70+
it("should allow resetting overwrites with undefined", () => {
71+
const { result } = renderHook(() => useMessages(), {
72+
wrapper: provider({ test: "default message" }),
73+
});
74+
act(() => result.current.setMessageOverwrite({ test: "overwritten message" }));
75+
expect(result.current.messages).toHaveProperty("test", "overwritten message");
76+
act(() => result.current.setMessageOverwrite(undefined));
77+
expect(result.current.messages).toHaveProperty("test", "default message");
78+
});
79+
});
80+
});
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import type { IntlConfig } from "react-intl";
2+
3+
import React, { useContext, useMemo, useState } from "react";
4+
import { IntlProvider } from "react-intl";
5+
6+
type Messages = IntlConfig["messages"];
7+
8+
interface I18nContext {
9+
setMessageOverwrite: (messages: Messages) => void;
10+
}
11+
12+
const i18nContext = React.createContext<I18nContext>({ setMessageOverwrite: () => null });
13+
14+
export const useI18nContext = () => {
15+
return useContext(i18nContext);
16+
};
17+
18+
interface I18nProviderProps {
19+
messages: Messages;
20+
locale: string;
21+
}
22+
23+
export const I18nProvider: React.FC<I18nProviderProps> = ({ children, messages, locale }) => {
24+
const [overwrittenMessages, setOvewrittenMessages] = useState<Messages>();
25+
26+
const i18nOverwriteContext = useMemo<I18nContext>(
27+
() => ({
28+
setMessageOverwrite: (messages) => {
29+
setOvewrittenMessages(messages);
30+
},
31+
}),
32+
[]
33+
);
34+
35+
const mergedMessages = useMemo(
36+
() => ({
37+
...messages,
38+
...(overwrittenMessages ?? {}),
39+
}),
40+
[messages, overwrittenMessages]
41+
);
42+
43+
return (
44+
<i18nContext.Provider value={i18nOverwriteContext}>
45+
<IntlProvider
46+
locale={locale}
47+
messages={mergedMessages}
48+
defaultRichTextElements={{
49+
b: (chunk) => <strong>{chunk}</strong>,
50+
}}
51+
>
52+
{children}
53+
</IntlProvider>
54+
</i18nContext.Provider>
55+
);
56+
};

airbyte-webapp/src/core/i18n/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { I18nProvider, useI18nContext } from "./I18nProvider";

airbyte-webapp/src/packages/cloud/App.tsx

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import GlobalStyle from "global-styles";
22
import React, { Suspense } from "react";
3-
import { IntlProvider } from "react-intl";
43
import { BrowserRouter as Router } from "react-router-dom";
54
import { ThemeProvider } from "styled-components";
65

76
import ApiErrorBoundary from "components/ApiErrorBoundary";
87
import LoadingPage from "components/LoadingPage";
98

9+
import { I18nProvider } from "core/i18n";
1010
import { ConfirmationModalService } from "hooks/services/ConfirmationModal";
1111
import { FeatureService } from "hooks/services/Feature";
1212
import { FormChangeTrackerService } from "hooks/services/FormChangeTracker";
@@ -23,19 +23,7 @@ import { AppServicesProvider } from "./services/AppServicesProvider";
2323
import { ConfigProvider } from "./services/ConfigProvider";
2424
import { IntercomProvider } from "./services/thirdParty/intercom/IntercomProvider";
2525

26-
const messages = Object.assign({}, en, cloudLocales);
27-
28-
const I18NProvider: React.FC = ({ children }) => (
29-
<IntlProvider
30-
locale="en"
31-
messages={messages}
32-
defaultRichTextElements={{
33-
b: (chunk) => <strong>{chunk}</strong>,
34-
}}
35-
>
36-
{children}
37-
</IntlProvider>
38-
);
26+
const messages = { ...en, ...cloudLocales };
3927

4028
const StyleProvider: React.FC = ({ children }) => (
4129
<ThemeProvider theme={theme}>
@@ -68,7 +56,7 @@ const App: React.FC = () => {
6856
return (
6957
<React.StrictMode>
7058
<StyleProvider>
71-
<I18NProvider>
59+
<I18nProvider locale="en" messages={messages}>
7260
<StoreProvider>
7361
<Suspense fallback={<LoadingPage />}>
7462
<ConfigProvider>
@@ -80,7 +68,7 @@ const App: React.FC = () => {
8068
</ConfigProvider>
8169
</Suspense>
8270
</StoreProvider>
83-
</I18NProvider>
71+
</I18nProvider>
8472
</StyleProvider>
8573
</React.StrictMode>
8674
);

airbyte-webapp/src/packages/cloud/services/thirdParty/launchdarkly/LDExperimentService.tsx

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import * as LDClient from "launchdarkly-js-client-sdk";
22
import { useEffect, useRef, useState } from "react";
3+
import { useIntl } from "react-intl";
34
import { useEffectOnce } from "react-use";
45
import { finalize, Subject } from "rxjs";
56

67
import { LoadingPage } from "components";
78

89
import { useConfig } from "config";
10+
import { useI18nContext } from "core/i18n";
911
import { useAnalytics } from "hooks/services/Analytics";
1012
import { ExperimentProvider, ExperimentService } from "hooks/services/Experiment";
1113
import type { Experiments } from "hooks/services/Experiment/experiments";
@@ -21,17 +23,18 @@ const INITIALIZATION_TIMEOUT = 1500;
2123

2224
type LDInitState = "initializing" | "failed" | "initialized";
2325

24-
function mapUserToLDUser(user: User | null): LDClient.LDUser {
26+
function mapUserToLDUser(user: User | null, locale: string): LDClient.LDUser {
2527
return user
2628
? {
2729
key: user.userId,
2830
email: user.email,
2931
name: user.name,
30-
custom: { intercomHash: user.intercomHash },
32+
custom: { intercomHash: user.intercomHash, locale },
3133
anonymous: false,
3234
}
3335
: {
3436
anonymous: true,
37+
custom: { locale },
3538
};
3639
}
3740

@@ -40,9 +43,26 @@ const LDInitializationWrapper: React.FC<{ apiKey: string }> = ({ children, apiKe
4043
const [state, setState] = useState<LDInitState>("initializing");
4144
const { user } = useAuthService();
4245
const { addContextProps: addAnalyticsContext } = useAnalytics();
46+
const { locale } = useIntl();
47+
const { setMessageOverwrite } = useI18nContext();
48+
49+
/**
50+
* This function checks for all experiments to find the ones beginning with "i18n_{locale}_"
51+
* and treats them as message overwrites for our bundled messages. Empty messages will be treated as not overwritten.
52+
*/
53+
const updateI18nMessages = () => {
54+
const prefix = `i18n_`;
55+
const messageOverwrites = Object.entries(ldClient.current?.allFlags() ?? {})
56+
// Only filter experiments beginning with the prefix and having an actual non-empty string value set
57+
.filter(([id, value]) => id.startsWith(prefix) && !!value && typeof value === "string")
58+
// Slice away the prefix of the key, to only keep the actual i18n id as a key
59+
.map(([id, msg]) => [id.slice(prefix.length), msg]);
60+
// Use those messages as overwrites in the i18nContext
61+
setMessageOverwrite(Object.fromEntries(messageOverwrites));
62+
};
4363

4464
if (!ldClient.current) {
45-
ldClient.current = LDClient.initialize(apiKey, mapUserToLDUser(user));
65+
ldClient.current = LDClient.initialize(apiKey, mapUserToLDUser(user, locale));
4666
// Wait for either LaunchDarkly to initialize or a specific timeout to pass first
4767
Promise.race([
4868
ldClient.current.waitForInitialization(),
@@ -53,6 +73,8 @@ const LDInitializationWrapper: React.FC<{ apiKey: string }> = ({ children, apiKe
5373
setState("initialized");
5474
// Make sure enabled experiments are added to each analytics event
5575
addAnalyticsContext({ experiments: ldClient.current?.allFlags() });
76+
// Check for overwritten i18n messages
77+
updateI18nMessages();
5678
})
5779
.catch((reason) => {
5880
// If the promise fails, either because LaunchDarkly service fails to initialize, or
@@ -67,15 +89,17 @@ const LDInitializationWrapper: React.FC<{ apiKey: string }> = ({ children, apiKe
6789
const onFeatureFlagsChanged = () => {
6890
// Update analytics context whenever a flag changes
6991
addAnalyticsContext({ experiments: ldClient.current?.allFlags() });
92+
// Check for overwritten i18n messages
93+
updateI18nMessages();
7094
};
7195
ldClient.current?.on("change", onFeatureFlagsChanged);
7296
return () => ldClient.current?.off("change", onFeatureFlagsChanged);
7397
});
7498

7599
// Whenever the user should change (e.g. login/logout) we need to reidentify the changes with the LD client
76100
useEffect(() => {
77-
ldClient.current?.identify(mapUserToLDUser(user));
78-
}, [user]);
101+
ldClient.current?.identify(mapUserToLDUser(user, locale));
102+
}, [locale, user]);
79103

80104
// Show the loading page while we're still waiting for the initial set of feature flags (or them to time out)
81105
if (state === "initializing") {

0 commit comments

Comments
 (0)