🌐(react) add i18n

We need to be able to use localized texts for various components, like for
accessible labels. We decided to setup a lightweight implementation of
localizable to avoid relying on an existing heavy library.
The Provider includes by default full translations for english, and it is
also made to be able to load easily any custom locale directly from the
Provider.
This commit is contained in:
Nathan Vasse
2023-02-20 16:26:30 +01:00
committed by NathanVss
parent 1df3b82571
commit 90feb4ba4a
14 changed files with 322 additions and 3 deletions

View File

@@ -101,6 +101,20 @@ jobs:
name: Run test suites over all workspaces
command: yarn test
# ---- Internationalization ----
crowdin-upload:
docker:
- image: crowdin/cli:3.10.0
auth:
username: $DOCKER_USER
password: $DOCKER_PASS
working_directory: ~/cunningham
steps:
- *checkout_cunningham
- run:
name: upload translation files to crowdin
command: crowdin upload sources -c crowdin/config.yml
# ---- Deploy ----
publish-storybook:
docker:
@@ -167,6 +181,14 @@ workflows:
filters:
tags:
only: /.*/
# ---- Internationalization ----
- crowdin-upload:
filters:
branches:
only:
- main
requires:
- build
# ---- Codebase ----
- build:
filters:

3
.gitignore vendored
View File

@@ -25,4 +25,5 @@ dist
*.sln
*.sw?
vite.config.ts.timestamp-*
vite.config.ts.timestamp-*
env.d

View File

@@ -43,6 +43,7 @@ COMPOSE_RUN = $(COMPOSE) run --rm --service-ports
# permission error.
COMPOSE_RUN_NODE = $(COMPOSE_RUN) -e HOME="/tmp" node
YARN = $(COMPOSE_RUN_NODE) yarn
CROWDIN = $(COMPOSE_RUN) crowdin crowdin
# ==============================================================================
# RULES
@@ -54,7 +55,9 @@ install: ## install all repos dependencies.
.PHONY: install
bootstrap: ## install all repos dependencies and build them too.
bootstrap: build
bootstrap: \
env.d/crowdin \
build
.PHONY: bootstrap
dev: ## watch changes in apps and packages.
@@ -82,6 +85,19 @@ deploy: install
@$(YARN) deploy
.PHONY: deploy
# -- Misc
env.d/crowdin:
cp env.d/crowdin.dist env.d/crowdin
# -- Internationalization
crowdin-upload:
@$(CROWDIN) upload sources -c crowdin/config.yml
.PHONY: crowdin-upload
crowdin-download:
@$(CROWDIN) download -c crowdin/config.yml
.PHONY: crowdin-download
clean: ## restore repository state as it was freshly cloned
git clean -idx
.PHONY: clean

23
crowdin/config.yml Normal file
View File

@@ -0,0 +1,23 @@
#
# Your crowdin's credentials
#
api_token_env: CROWDIN_API_TOKEN
project_id_env: CROWDIN_PROJECT_ID
base_path_env: CROWDIN_BASE_PATH
#
# Choose file structure in crowdin
# e.g. true or false
#
preserve_hierarchy: true
#
# Files configuration
#
files: [
{
source : "/packages/react/src/locales/en-US.json",
dest: "/packages/react/en-US.json",
translation : "/packages/react/src/locales/%locale%.json"
}
]

View File

@@ -10,4 +10,12 @@ services:
- "3201:3201"
- "6006:6006"
volumes:
- .:/app
- .:/app
crowdin:
image: crowdin/cli:3.10.0
user: "${DOCKER_USER:-1000}"
working_dir: /app
env_file: env.d/crowdin
volumes:
- ".:/app"

3
env.d/crowdin.dist Normal file
View File

@@ -0,0 +1,3 @@
CROWDIN_API_TOKEN=Your-Api-Token
CROWDIN_PROJECT_ID=Your-Project-Id
CROWDIN_BASE_PATH=/app

View File

@@ -0,0 +1,4 @@
export enum Locales {
enUS = "en-US",
frFR = "fr-FR",
}

View File

@@ -0,0 +1,105 @@
import { render, screen } from "@testing-library/react";
import React, { PropsWithChildren, useMemo, useState } from "react";
import userEvent from "@testing-library/user-event";
import { CunninghamProvider, useCunningham } from "components/Provider/index";
import * as enUS from "locales/en-US.json";
import { Button } from "components/Button";
describe("<CunninghamProvider />", () => {
it("should render", () => {
render(
<CunninghamProvider>
<h1>Hi</h1>
</CunninghamProvider>
);
screen.getByRole("heading", { level: 1, name: "Hi" });
});
it("should render accurate translation", () => {
const Wrapper = (props: PropsWithChildren) => {
return <CunninghamProvider>{props.children}</CunninghamProvider>;
};
const Wrapped = () => {
const { t } = useCunningham();
return <h1>{t("components.provider.test", { name: "Bob" })}</h1>;
};
render(<Wrapped />, { wrapper: Wrapper });
screen.getByRole("heading", { level: 1, name: "This is a test: Bob" });
});
it("should render custom translations and switching", async () => {
const Wrapper = (props: PropsWithChildren) => {
// Create a new locale with a custom translation.
const zuBU = useMemo(() => {
const base = JSON.parse(JSON.stringify(enUS));
base.components.provider.test = "Zubu Zubu";
return base;
}, []);
const [currentLocale, setCurrentLocale] = useState("en-US");
const onSwitch = () => {
setCurrentLocale(currentLocale === "en-US" ? "zu-BU" : "en-US");
};
return (
<CunninghamProvider
customLocales={{
"zu-BU": zuBU,
}}
currentLocale={currentLocale}
>
{props.children}
<Button onClick={onSwitch}>Switch</Button>
</CunninghamProvider>
);
};
const Wrapped = () => {
const { t } = useCunningham();
return <h1>{t("components.provider.test", { name: "Bob" })}</h1>;
};
render(<Wrapped />, { wrapper: Wrapper });
screen.getByRole("heading", { level: 1, name: "This is a test: Bob" });
const switchButton = screen.getByRole("button", { name: "Switch" });
const user = userEvent.setup();
user.click(switchButton);
await screen.findByRole("heading", { level: 1, name: "Zubu Zubu" });
});
it("should use default locale for undefined translations in current locale", () => {
const Wrapper = (props: PropsWithChildren) => {
return (
<CunninghamProvider currentLocale="tu-TU">
{props.children}
</CunninghamProvider>
);
};
const Wrapped = () => {
const { t } = useCunningham();
return <h1>{t("components.provider.test", { name: "Bob" })}</h1>;
};
render(<Wrapped />, { wrapper: Wrapper });
screen.getByRole("heading", { level: 1, name: "This is a test: Bob" });
});
it("should use key as translation when the key is not defined across current locale and default locale", () => {
const Wrapper = (props: PropsWithChildren) => {
return <CunninghamProvider>{props.children}</CunninghamProvider>;
};
const Wrapped = () => {
const { t } = useCunningham();
return <h1>{t("components.will_never_exist")}</h1>;
};
render(<Wrapped />, { wrapper: Wrapper });
screen.getByRole("heading", {
level: 1,
name: "components.will_never_exist",
});
});
});

View File

@@ -0,0 +1,92 @@
import React, {
createContext,
PropsWithChildren,
useContext,
useMemo,
} from "react";
import * as enUS from "locales/en-US.json";
import * as frFR from "locales/fr-FR.json";
import { PartialNested } from "types";
import { Locales } from "components/Provider/Locales";
type TranslationSet = PartialNested<typeof enUS>;
const CunninghamContext = createContext<
| undefined
| {
t: (key: string, vars?: Record<string, string | number>) => string;
}
>(undefined);
export const useCunningham = () => {
const context = useContext(CunninghamContext);
if (context === undefined) {
throw new Error("useCunningham must be used within a CunninghamProvider.");
}
return context;
};
interface Props extends PropsWithChildren {
customLocales?: Record<string, TranslationSet>;
currentLocale?: string;
}
export const DEFAULT_LOCALE = Locales.enUS;
export const SUPPORTED_LOCALES = Object.values(Locales);
const findTranslation = (
key: string,
locale: TranslationSet
): string | undefined => {
const [namespace, ...keys] = key.split(".");
return keys.reduce((acc, subKey) => acc[subKey], (locale as any)[namespace]);
};
export const CunninghamProvider = ({
currentLocale = DEFAULT_LOCALE,
customLocales,
children,
}: Props) => {
const locales: Record<string, TranslationSet> = useMemo(
() => ({
[DEFAULT_LOCALE]: enUS,
"fr-FR": frFR,
...customLocales,
}),
[customLocales]
);
const locale = useMemo(() => {
if (!locales[currentLocale]) {
return locales[DEFAULT_LOCALE];
}
return locales[currentLocale];
}, [currentLocale, locales]);
const context = useMemo(
() => ({
t: (key: string, vars?: Record<string, string | number>) => {
let message: string =
findTranslation(key, locale) ??
findTranslation(key, locales[DEFAULT_LOCALE]) ??
key;
// Replace vars in message from vars in form of {varName}.
if (vars) {
Object.keys(vars).forEach((varName) => {
message = message?.replace(`{${varName}}`, "" + vars[varName]);
});
}
return message;
},
}),
[currentLocale, locales]
);
return (
<CunninghamContext.Provider value={context}>
{children}
</CunninghamContext.Provider>
);
};

View File

@@ -1,3 +1,4 @@
import "./index.scss";
export * from "./components/Button";
export * from "./components/Provider";

View File

@@ -0,0 +1,20 @@
{
"components": {
"pagination": {
"goto_label": "Go to",
"goto_label_aria": "Go to any page",
"next_aria": "Go to next page",
"previous_aria": "Go to previous page",
"goto_page_aria": "Go to page {page}",
"current_page_aria": "You are currently on page {page}"
},
"datagrid": {
"empty": "This table is empty",
"empty_alt": "Illustration of an empty table",
"loader_aria": "Loading data"
},
"provider": {
"test": "This is a test: {name}"
}
}
}

View File

@@ -0,0 +1,20 @@
{
"components": {
"pagination": {
"goto_label": "Aller à",
"goto_label_aria": "Aller à la page",
"next_aria": "Aller à la page suivante",
"previous_aria": "Aller à la page précédente",
"goto_page_aria": "Aller à la page {page}",
"current_page_aria": "Vous êtes actuellement sur la page {page}"
},
"datagrid": {
"empty": "Ce tableau est vide",
"empty_alt": "Illustration d'un tableau vide",
"loader_aria": "Loading data"
},
"provider": {
"test": "Ceci est un test : {name}"
}
}
}

View File

@@ -0,0 +1,3 @@
export type PartialNested<T> = {
[K in keyof T]?: T extends object ? PartialNested<T[K]> : T[K];
};

View File

@@ -0,0 +1 @@
export const noop = () => undefined;