Skip to content

Commit e24b7db

Browse files
VojtechVidraflows-bot[bot]OPesicka
authored
feat(react,js): localization support (#385)
* feat(react,js): add locale option * test: locale option * @flows/react@1.9.1-canary.0 * test: improve automatic locale tests * feat: change locale to language * @flows/react@1.9.1-canary.1 * feat: export types * @flows/react@1.9.1-canary.2 * docs: update prop description * docs: tsdoc for Locale type --------- Co-authored-by: flows-bot[bot] <170794745+flows-bot[bot]@users.noreply.github.com> Co-authored-by: Ondřej Pešička <77627332+OPesicka@users.noreply.github.com>
1 parent caf5f02 commit e24b7db

File tree

16 files changed

+856
-13
lines changed

16 files changed

+856
-13
lines changed

workspaces/e2e/pages/js.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@ import * as _components from "@flows/js-components/components";
99
import * as _tourComponents from "@flows/js-components/tour-components";
1010
import "@flows/js-components/index.css";
1111
import { startWorkflow, StateMemory as IStateMemory } from "@flows/js";
12+
import { LanguageOption } from "@flows/shared";
1213

1314
const apiUrl = new URLSearchParams(window.location.search).get("apiUrl") ?? undefined;
1415
const noCurrentBlocks =
1516
new URLSearchParams(window.location.search).get("noCurrentBlocks") === "true";
17+
const language = new URLSearchParams(window.location.search).get("language") as LanguageOption;
1618

1719
const Card: Component<{ text: string }> = (props) => {
1820
const card = document.createElement("div");
@@ -116,6 +118,7 @@ init({
116118
environment: "prod",
117119
organizationId: "orgId",
118120
userId: "testUserId",
121+
language,
119122
apiUrl,
120123
userProperties: {
121124
email: "test@flows.sh",

workspaces/e2e/pages/react.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@ import { createRoot } from "react-dom/client";
1414
import * as components from "@flows/react-components";
1515
import * as tourComponents from "@flows/react-components/tour";
1616
import "@flows/react-components/index.css";
17+
import { LanguageOption } from "@flows/shared";
1718

1819
const apiUrl = new URLSearchParams(window.location.search).get("apiUrl") ?? undefined;
1920
const noUserId = new URLSearchParams(window.location.search).get("noUserId") === "true";
2021
const noCurrentBlocks =
2122
new URLSearchParams(window.location.search).get("noCurrentBlocks") === "true";
23+
const language = new URLSearchParams(window.location.search).get("language") as LanguageOption;
2224

2325
const Card: FC<ComponentProps<{ text: string }>> = (props) => (
2426
<div
@@ -93,6 +95,7 @@ createRoot(document.getElementById("root")!).render(
9395
organizationId="orgId"
9496
environment="prod"
9597
userId={noUserId ? null : "testUserId"}
98+
language={language}
9699
userProperties={{
97100
email: "test@flows.sh",
98101
age: 10,

workspaces/e2e/tests/init.spec.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ const run = (packageName: string) => {
3838
body.userId === "testUserId" &&
3939
body.environment === "prod" &&
4040
body.userProperties.email === "test@flows.sh" &&
41-
body.userProperties.age === 10
41+
body.userProperties.age === 10 &&
42+
body.language === undefined
4243
);
4344
});
4445
await page.goto(`/${packageName}.html`);
@@ -58,12 +59,13 @@ const run = (packageName: string) => {
5859
body.userId === "testUserId" &&
5960
body.environment === "prod" &&
6061
body.userProperties.email === "test@flows.sh" &&
61-
body.userProperties.age === 10
62+
body.userProperties.age === 10 &&
63+
body.language === undefined
6264
);
6365
});
64-
await page.goto(
65-
`/${packageName}.html?apiUrl=${encodeURIComponent("https://custom.api.flows.com")}`,
66-
);
66+
const urlParams = new URLSearchParams();
67+
urlParams.set("apiUrl", "https://custom.api.flows.com");
68+
await page.goto(`/${packageName}.html?${urlParams.toString()}`);
6769
await blocksReq;
6870
});
6971
test(`${packageName} - shouldn't overwrite blocks state by /blocks result`, async ({ page }) => {
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { test } from "@playwright/test";
2+
3+
test.beforeEach(async ({ page }) => {
4+
await page.routeWebSocket(
5+
(url) => url.pathname === "/ws/sdk/block-updates",
6+
() => {},
7+
);
8+
});
9+
10+
const run = (packageName: string, language: string) => {
11+
test(`${packageName} (${language}) - should call custom apiUrl`, async ({ page }) => {
12+
await page.route("**/v2/sdk/blocks", (route) => {
13+
route.fulfill({ json: { blocks: [] } });
14+
});
15+
const blocksReq = page.waitForRequest((req) => {
16+
const body = req.postDataJSON();
17+
return req.url() === "https://api.flows-cloud.com/v2/sdk/blocks" && body.language === "en-GB";
18+
});
19+
const urlParams = new URLSearchParams();
20+
urlParams.set("language", "en-GB");
21+
await page.goto(`/${packageName}.html?${urlParams.toString()}`);
22+
await blocksReq;
23+
});
24+
test(`${packageName} (${language}) - should call with detected language`, async ({ page }) => {
25+
await page.route("**/v2/sdk/blocks", (route) => {
26+
route.fulfill({ json: { blocks: [] } });
27+
});
28+
const blocksReq = page.waitForRequest((req) => {
29+
const body = req.postDataJSON();
30+
return (
31+
req.url() === "https://api.flows-cloud.com/v2/sdk/blocks" && body.language === language
32+
);
33+
});
34+
const urlParams = new URLSearchParams();
35+
urlParams.set("language", "automatic");
36+
await page.goto(`/${packageName}.html?${urlParams.toString()}`);
37+
await blocksReq;
38+
});
39+
};
40+
41+
test.describe("en-US", () => {
42+
test.use({ locale: "en-US" });
43+
run("js", "en-US");
44+
run("react", "en-US");
45+
});
46+
47+
test.describe("fr-FR", () => {
48+
test.use({ locale: "fr-FR" });
49+
run("js", "fr-FR");
50+
run("react", "fr-FR");
51+
});

workspaces/js/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,7 @@ export type {
66
TourComponentProps,
77
ComponentProps,
88
StateMemory,
9+
LanguageOption,
10+
Locale,
911
} from "@flows/shared";
1012
export type { FlowsOptions } from "./types/configuration";

workspaces/js/src/init.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,16 @@ let locationChangeInterval: number | null = null;
1414
export const init = (options: FlowsOptions): void => {
1515
const apiUrl = options.apiUrl ?? "https://api.flows-cloud.com";
1616
config.value = { ...options, apiUrl };
17-
const { environment, organizationId, userId, userProperties } = options;
17+
const { environment, organizationId, userId, userProperties, language } = options;
1818

19-
connectToWebsocketAndFetchBlocks({ apiUrl, environment, organizationId, userId, userProperties });
19+
connectToWebsocketAndFetchBlocks({
20+
apiUrl,
21+
environment,
22+
organizationId,
23+
userId,
24+
userProperties,
25+
language,
26+
});
2027

2128
if (locationChangeInterval !== null) clearInterval(locationChangeInterval);
2229
locationChangeInterval = window.setInterval(() => {

workspaces/js/src/lib/blocks.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import {
22
type BlockUpdatesPayload,
33
getApi,
4+
getUserLanguage,
5+
type LanguageOption,
46
log,
57
deduplicateBlocks,
68
type UserProperties,
@@ -15,6 +17,7 @@ interface Props {
1517
organizationId: string;
1618
userId: string;
1719
userProperties?: UserProperties;
20+
language?: LanguageOption;
1821
}
1922

2023
let disconnect: Disconnect | null = null;
@@ -29,7 +32,11 @@ export const connectToWebsocketAndFetchBlocks = (props: Props): void => {
2932

3033
const fetchBlocks = (): void => {
3134
void getApi(apiUrl, packageAndVersion)
32-
.getBlocks({ ...params, userProperties: props.userProperties })
35+
.getBlocks({
36+
...params,
37+
language: getUserLanguage(props.language),
38+
userProperties: props.userProperties,
39+
})
3340
.then((res) => {
3441
blocks.value = deduplicateBlocks(blocks.value, res.blocks);
3542
// Disconnect if the user is usage limited

workspaces/js/src/types/configuration.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { type UserProperties } from "@flows/shared";
1+
import { type LanguageOption, type UserProperties } from "@flows/shared";
22

33
export interface FlowsOptions {
44
/**
@@ -21,4 +21,12 @@ export interface FlowsOptions {
2121
* Custom API URL useful when using proxy to send Flows requests through your own domain.
2222
*/
2323
apiUrl?: string;
24+
/**
25+
* Language used to enable [localization](https://flows.sh/docs/localization). Based on the set language, the correct translation for the block data will be selected.
26+
* - `disabled` (default) - The user will be served content in the default language group of your organization.
27+
* - `automatic` - Automatically detect the user's language and use the matching language group. The language is determined by the `Navigator.language` property in the browser.
28+
* - Manual - A specific language string, e.g. `en-US`, `fr-FR`, etc. This will use the matching language group for the specified language. See [https://www.localeplanet.com/icu/](https://www.localeplanet.com/icu/) for a full list of supported languages.
29+
* @defaultValue `disabled`
30+
*/
31+
language?: LanguageOption;
2432
}

workspaces/react/src/flows-provider.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { type FC, type ReactNode } from "react";
2-
import { type UserProperties } from "@flows/shared";
2+
import { type LanguageOption, type UserProperties } from "@flows/shared";
33
import { type TourComponents, type Components } from "./types";
44
import { FlowsContext } from "./flows-context";
55
import { useRunningTours } from "./hooks/use-running-tours";
@@ -40,6 +40,14 @@ export interface FlowsProviderProps {
4040
* Components used for tour blocks.
4141
*/
4242
tourComponents: TourComponents;
43+
/**
44+
* Language used to enable [localization](https://flows.sh/docs/localization). Based on the set language, the correct translation for the block data will be selected.
45+
* - `disabled` (default) - The user will be served content in the default language group of your organization.
46+
* - `automatic` - Automatically detect the user's language and use the matching language group. The language is determined by the `Navigator.language` property in the browser.
47+
* - Manual - A specific language string, e.g. `en-US`, `fr-FR`, etc. This will use the matching language group for the specified language. See [https://www.localeplanet.com/icu/](https://www.localeplanet.com/icu/) for a full list of supported languages.
48+
* @defaultValue `disabled`
49+
*/
50+
language?: LanguageOption;
4351

4452
children: ReactNode;
4553
}
@@ -64,6 +72,7 @@ const FlowsProviderInner: FC<Props> = ({
6472
components,
6573
tourComponents,
6674
userProperties,
75+
language,
6776
}) => {
6877
globalConfig.apiUrl = apiUrl;
6978
globalConfig.environment = environment;
@@ -76,6 +85,7 @@ const FlowsProviderInner: FC<Props> = ({
7685
organizationId,
7786
userId,
7887
userProperties,
88+
language,
7989
});
8090

8191
const runningTours = useRunningTours({ blocks, removeBlock });

workspaces/react/src/hooks/use-blocks.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import {
66
type Block,
77
type TourStep,
88
type BlockUpdatesPayload,
9+
type LanguageOption,
10+
getUserLanguage,
911
deduplicateBlocks,
1012
} from "@flows/shared";
1113
import { packageAndVersion } from "../lib/constants";
@@ -18,6 +20,7 @@ interface Props {
1820
organizationId: string;
1921
userId: string;
2022
userProperties?: UserProperties;
23+
language?: LanguageOption;
2124
}
2225

2326
interface Return {
@@ -32,6 +35,7 @@ export const useBlocks = ({
3235
organizationId,
3336
userId,
3437
userProperties,
38+
language,
3539
}: Props): Return => {
3640
const [blocks, setBlocks] = useState<Block[]>([]);
3741
const [usageLimited, setUsageLimited] = useState(false);
@@ -45,18 +49,21 @@ export const useBlocks = ({
4549
userPropertiesRef.current = userProperties;
4650
}, [userProperties]);
4751

48-
// TODO: call fetchBlocks on reconnect
4952
const fetchBlocks = useCallback(() => {
5053
void getApi(apiUrl, packageAndVersion)
51-
.getBlocks({ ...params, userProperties: userPropertiesRef.current })
54+
.getBlocks({
55+
...params,
56+
language: getUserLanguage(language),
57+
userProperties: userPropertiesRef.current,
58+
})
5259
.then((res) => {
5360
setBlocks((prevBlocks) => deduplicateBlocks(prevBlocks, res.blocks));
5461
if (res.meta?.usage_limited) setUsageLimited(true);
5562
})
5663
.catch((err: unknown) => {
5764
log.error("Failed to load blocks", err);
5865
});
59-
}, [apiUrl, params]);
66+
}, [apiUrl, language, params]);
6067

6168
const websocketUrl = useMemo(() => {
6269
if (usageLimited) return;

workspaces/react/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,7 @@ export type {
66
FlowsProperties,
77
ActiveBlock,
88
StateMemory,
9+
LanguageOption,
10+
Locale,
911
} from "@flows/shared";
1012
export * from "./methods";

workspaces/shared/src/api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ interface GetBlocksRequest {
2626
environment: string;
2727
organizationId: string;
2828
userProperties?: Record<string, unknown>;
29+
language?: string;
2930
}
3031

3132
interface BlockResponseMeta {

workspaces/shared/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ export * from "./component-props";
44
export * from "./log";
55
export * from "./matchers";
66
export * from "./pathname";
7+
export * from "./language";
78
export * from "./types";

workspaces/shared/src/language.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { type LanguageOption } from "./types";
2+
3+
export const getUserLanguage = (language?: LanguageOption): string | undefined => {
4+
if (!language || language === "disabled") return undefined;
5+
if (language === "automatic") {
6+
const browserLanguage = navigator.languages.at(0) ?? navigator.language;
7+
return browserLanguage;
8+
}
9+
return language;
10+
};

workspaces/shared/src/types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * from "./active-block";
22
export * from "./block";
33
export * from "./components";
4+
export * from "./language";
45
export * from "./tooltip";
56
export * from "./user-properties";

0 commit comments

Comments
 (0)