From 7f0acb350f69f919f435f7228d3d88d7f5cc7c8d Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 19 Oct 2023 17:37:29 +0200 Subject: [PATCH] frontend: integrate storybook with i18next & cleanup (#1970) --- .github/workflows/translations-download.yaml | 3 +- frontend/.storybook/main.ts | 3 + frontend/.storybook/preview.tsx | 19 +++++- frontend/locales/en.json | 5 +- frontend/package-lock.json | 52 +++++++++++++++ frontend/package.json | 1 + .../components/VerifyEmail/VerifyEmail.tsx | 9 ++- frontend/src/gql/fragment-masking.ts | 63 ++++++++++++------- frontend/src/gql/index.ts | 2 +- frontend/src/i18n.ts | 12 ++-- frontend/src/main.tsx | 15 +++-- frontend/src/routing/Link.tsx | 5 +- templates/app.html | 2 +- 13 files changed, 150 insertions(+), 41 deletions(-) diff --git a/.github/workflows/translations-download.yaml b/.github/workflows/translations-download.yaml index ee1dd8c7c..d87642e1e 100644 --- a/.github/workflows/translations-download.yaml +++ b/.github/workflows/translations-download.yaml @@ -19,7 +19,7 @@ jobs: uses: localazy/download@v1.1.0 - name: "Fix the owner of the downloaded files" - run: "sudo chown runner:docker translations/*.json" + run: "sudo chown runner:docker translations/*.json frontend/locales/*.json" - name: Create Pull Request id: cpr @@ -29,6 +29,7 @@ jobs: branch: actions/localazy-download delete-branch: true title: Localazy Download + commit-message: Translations updates - name: Enable automerge run: gh pr merge --merge --auto "$PR_NUMBER" diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts index 7ea4e461f..9665251ce 100644 --- a/frontend/.storybook/main.ts +++ b/frontend/.storybook/main.ts @@ -38,6 +38,9 @@ const config: StorybookConfig = { // Theme switch toolbar "@storybook/addon-toolbars", + + // i18next integration + "storybook-react-i18next", ], framework: "@storybook/react-vite", diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx index 9ae2ab324..028d64f16 100644 --- a/frontend/.storybook/preview.tsx +++ b/frontend/.storybook/preview.tsx @@ -12,9 +12,11 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { ArgTypes, Decorator, Parameters } from "@storybook/react"; +import { ArgTypes, Decorator, Parameters, Preview } from "@storybook/react"; import { useLayoutEffect } from "react"; + import "../src/main.css"; +import i18n from "../src/i18n"; export const parameters: Parameters = { actions: { argTypesRegex: "^on[A-Z].*" }, @@ -74,3 +76,18 @@ const withThemeProvider: Decorator = (Story, context) => { }; export const decorators: Decorator[] = [withThemeProvider]; + +const preview: Preview = { + globals: { + locale: "en", + locales: { + en: "English", + fr: "Français", + }, + }, + parameters: { + i18n, + }, +}; + +export default preview; diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 80c1e0d1b..812933832 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -1,5 +1,6 @@ { "action": { + "back": "Back", "cancel": "Cancel", "continue": "Continue", "save": "Save" @@ -131,9 +132,11 @@ }, "verify_email": { "code_field_label": "6-digit code", - "enter_code_prompt": "Enter the 6-digit code sent to <1>{{email}", + "enter_code_prompt": "Enter the 6-digit code sent to <2>{{email}}", "heading": "Verify your email", "invalid_code_alert": "Invalid code", + "resend_email": "Resend email", + "sent": "Sent!", "unknown_email": "Unknown email" } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5aa9c29c3..f83cff747 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -64,6 +64,7 @@ "react-test-renderer": "^18.2.0", "rimraf": "^5.0.5", "storybook": "^7.5.0", + "storybook-react-i18next": "^2.0.9", "tailwindcss": "^3.3.3", "typescript": "5.2.2", "vite": "^4.4.11", @@ -21674,6 +21675,57 @@ "url": "https://opencollective.com/storybook" } }, + "node_modules/storybook-i18n": { + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/storybook-i18n/-/storybook-i18n-2.0.13.tgz", + "integrity": "sha512-p0VPL5QiHdeS3W9BvV7UnuTKw7Mj1HWLW67LK0EOoh5fpSQIchu7byfrUUe1RbCF1gT0gOOhdNuTSXMoVVoTDw==", + "dev": true, + "peerDependencies": { + "@storybook/components": "^7.0.0", + "@storybook/manager-api": "^7.0.0", + "@storybook/preview-api": "^7.0.0", + "@storybook/types": "^7.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/storybook-react-i18next": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/storybook-react-i18next/-/storybook-react-i18next-2.0.9.tgz", + "integrity": "sha512-GFTOrYwOWShLqWNuTesPNhC79P3OHw1jkZ4gU3R50yTD2MUclF5DHLnuKeVfKZ323iV+I9fxLxuLIVHWVDJgXA==", + "dev": true, + "dependencies": { + "storybook-i18n": "2.0.13" + }, + "peerDependencies": { + "@storybook/components": "^7.0.0", + "@storybook/manager-api": "^7.0.0", + "@storybook/preview-api": "^7.0.0", + "@storybook/types": "^7.0.0", + "i18next": "^22.0.0 || ^23.0.0", + "i18next-browser-languagedetector": "^7.0.0", + "i18next-http-backend": "^2.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-i18next": "^12.0.0 || ^13.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/stream-composer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/stream-composer/-/stream-composer-1.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 441aebad3..889d64c1d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -72,6 +72,7 @@ "react-test-renderer": "^18.2.0", "rimraf": "^5.0.5", "storybook": "^7.5.0", + "storybook-react-i18next": "^2.0.9", "tailwindcss": "^3.3.3", "typescript": "5.2.2", "vite": "^4.4.11", diff --git a/frontend/src/components/VerifyEmail/VerifyEmail.tsx b/frontend/src/components/VerifyEmail/VerifyEmail.tsx index 7f9285847..a0314450f 100644 --- a/frontend/src/components/VerifyEmail/VerifyEmail.tsx +++ b/frontend/src/components/VerifyEmail/VerifyEmail.tsx @@ -113,6 +113,7 @@ const BackButton: React.FC = () => { const { onClick, href, pending } = useNavigationLink({ type: "profile", }); + const { t } = useTranslation(); return ( ); }; @@ -187,7 +188,7 @@ const VerifyEmail: React.FC<{ Enter the 6-digit code sent to{" "} - {{ codeEmail }} + {{ email: codeEmail }} @@ -222,7 +223,9 @@ const VerifyEmail: React.FC<{ disabled={pending || emailSent} onClick={onResendClick} > - {emailSent ? "Sent!" : "Resend email"} + {emailSent + ? t("frontend.verify_email.sent") + : t("frontend.verify_email.resend_email")} diff --git a/frontend/src/gql/fragment-masking.ts b/frontend/src/gql/fragment-masking.ts index 2ba06f10b..373a5ce41 100644 --- a/frontend/src/gql/fragment-masking.ts +++ b/frontend/src/gql/fragment-masking.ts @@ -1,15 +1,17 @@ -import { ResultOf, DocumentTypeDecoration, TypedDocumentNode } from '@graphql-typed-document-node/core'; -import { FragmentDefinitionNode } from 'graphql'; -import { Incremental } from './graphql'; +import { + ResultOf, + DocumentTypeDecoration, + TypedDocumentNode, +} from "@graphql-typed-document-node/core"; +import { FragmentDefinitionNode } from "graphql"; +import { Incremental } from "./graphql"; - -export type FragmentType> = TDocumentType extends DocumentTypeDecoration< - infer TType, - any -> - ? [TType] extends [{ ' $fragmentName'?: infer TKey }] +export type FragmentType< + TDocumentType extends DocumentTypeDecoration, +> = TDocumentType extends DocumentTypeDecoration + ? [TType] extends [{ " $fragmentName"?: infer TKey }] ? TKey extends string - ? { ' $fragmentRefs'?: { [key in TKey]: TType } } + ? { " $fragmentRefs"?: { [key in TKey]: TType } } : never : never : never; @@ -17,50 +19,67 @@ export type FragmentType> // return non-nullable if `fragmentType` is non-nullable export function useFragment( _documentNode: DocumentTypeDecoration, - fragmentType: FragmentType> + fragmentType: FragmentType>, ): TType; // return nullable if `fragmentType` is nullable export function useFragment( _documentNode: DocumentTypeDecoration, - fragmentType: FragmentType> | null | undefined + fragmentType: + | FragmentType> + | null + | undefined, ): TType | null | undefined; // return array of non-nullable if `fragmentType` is array of non-nullable export function useFragment( _documentNode: DocumentTypeDecoration, - fragmentType: ReadonlyArray>> + fragmentType: ReadonlyArray>>, ): ReadonlyArray; // return array of nullable if `fragmentType` is array of nullable export function useFragment( _documentNode: DocumentTypeDecoration, - fragmentType: ReadonlyArray>> | null | undefined + fragmentType: + | ReadonlyArray>> + | null + | undefined, ): ReadonlyArray | null | undefined; export function useFragment( _documentNode: DocumentTypeDecoration, - fragmentType: FragmentType> | ReadonlyArray>> | null | undefined + fragmentType: + | FragmentType> + | ReadonlyArray>> + | null + | undefined, ): TType | ReadonlyArray | null | undefined { return fragmentType as any; } - export function makeFragmentData< F extends DocumentTypeDecoration, - FT extends ResultOf + FT extends ResultOf, >(data: FT, _fragment: F): FragmentType { return data as FragmentType; } export function isFragmentReady( queryNode: DocumentTypeDecoration, fragmentNode: TypedDocumentNode, - data: FragmentType, any>> | null | undefined + data: + | FragmentType, any>> + | null + | undefined, ): data is FragmentType { - const deferredFields = (queryNode as { __meta__?: { deferredFields: Record } }).__meta__ - ?.deferredFields; + const deferredFields = ( + queryNode as { + __meta__?: { deferredFields: Record }; + } + ).__meta__?.deferredFields; if (!deferredFields) return true; - const fragDef = fragmentNode.definitions[0] as FragmentDefinitionNode | undefined; + const fragDef = fragmentNode.definitions[0] as + | FragmentDefinitionNode + | undefined; const fragName = fragDef?.name?.value; const fields = (fragName && deferredFields[fragName]) || []; - return fields.length > 0 && fields.every(field => data && field in data); + return fields.length > 0 && fields.every((field) => data && field in data); } diff --git a/frontend/src/gql/index.ts b/frontend/src/gql/index.ts index f51599168..0ea4a91cf 100644 --- a/frontend/src/gql/index.ts +++ b/frontend/src/gql/index.ts @@ -1,2 +1,2 @@ export * from "./fragment-masking"; -export * from "./gql"; \ No newline at end of file +export * from "./gql"; diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 99efb00d3..d5cbfccd5 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -12,9 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -import * as i18n from "i18next"; -import { InitOptions } from "i18next"; -import LanguageDetector from "i18next-browser-languagedetector"; +import { default as i18n, InitOptions } from "i18next"; +import LanguageDetector, { + DetectorOptions, +} from "i18next-browser-languagedetector"; import I18NextHttpBackend, { HttpBackendOptions } from "i18next-http-backend"; import { initReactI18next } from "react-i18next"; @@ -44,6 +45,9 @@ i18n keySeparator: ".", pluralSeparator: ":", supportedLngs, + detection: { + order: ["navigator", "htmlTag"], + } satisfies DetectorOptions, interpolation: { escapeValue: false, // React has built-in XSS protections }, @@ -60,7 +64,7 @@ i18n import.meta.hot?.on("locales-update", () => { i18n.reloadResources().then(() => { - i18n.changeLanguage(i18n.default.language); + i18n.changeLanguage(i18n.language); }); }); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 786901425..57959552e 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -16,13 +16,14 @@ import { Provider } from "jotai"; import { DevTools } from "jotai-devtools"; import { Suspense, StrictMode } from "react"; import { createRoot } from "react-dom/client"; +import { I18nextProvider } from "react-i18next"; import { HydrateAtoms } from "./atoms"; import Layout from "./components/Layout"; import LoadingScreen from "./components/LoadingScreen"; import LoadingSpinner from "./components/LoadingSpinner"; +import i18n from "./i18n"; import { Router } from "./routing"; -import "./i18n"; import "./main.css"; createRoot(document.getElementById("root") as HTMLElement).render( @@ -31,11 +32,13 @@ createRoot(document.getElementById("root") as HTMLElement).render( {import.meta.env.DEV && } }> - - }> - - - + + + }> + + + + diff --git a/frontend/src/routing/Link.tsx b/frontend/src/routing/Link.tsx index 206cc60f5..3cd4dbc52 100644 --- a/frontend/src/routing/Link.tsx +++ b/frontend/src/routing/Link.tsx @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { useTranslation } from "react-i18next"; + import styles from "./Link.module.css"; import { Route } from "./routes"; import { useNavigationLink } from "./useNavigationLink"; @@ -24,6 +26,7 @@ const Link: React.FC< } & React.HTMLProps > = ({ route, children, kind, className, ...props }) => { const { onClick, href, pending } = useNavigationLink(route); + const { t } = useTranslation(); const classNames = [ kind === "button" ? styles.linkButton : "", @@ -32,7 +35,7 @@ const Link: React.FC< return ( - {pending ? "Loading..." : children} + {pending ? t("common.loading") : children} ); }; diff --git a/templates/app.html b/templates/app.html index 2d8a54ba4..694e9a865 100644 --- a/templates/app.html +++ b/templates/app.html @@ -18,7 +18,7 @@ limitations under the License. {% set _ = translator(lang) %} - +