mirror of
https://github.com/Mastermindzh/react-starter-kit.git
synced 2025-08-02 15:45:12 +02:00
- Added translations
- Added pluralization example - Added formatter example (with Luxon) - Used HTTP loader - Added a suspense fallback page for app loading - Added cypress eslint rules
This commit is contained in:
22
src/app/tests/mocks/i18n/WithTestTranslations.tsx
Normal file
22
src/app/tests/mocks/i18n/WithTestTranslations.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { FunctionComponent, ReactNode } from "react";
|
||||
import { I18nextProvider, useTranslation } from "react-i18next";
|
||||
import i18n from "./i18n";
|
||||
|
||||
type Props = { children?: ReactNode; keysOnly?: boolean };
|
||||
|
||||
const ProvidedComponent: FunctionComponent<Props> = ({ children, keysOnly }) => {
|
||||
const [_translate, i18nSettings] = useTranslation();
|
||||
if (keysOnly) {
|
||||
i18nSettings.changeLanguage("noLang");
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export const WithTestTranslations: FunctionComponent<Props> = ({ children, keysOnly = false }) => {
|
||||
return (
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<ProvidedComponent keysOnly={keysOnly}>{children}</ProvidedComponent>
|
||||
</I18nextProvider>
|
||||
);
|
||||
};
|
33
src/app/tests/mocks/i18n/i18n.ts
Normal file
33
src/app/tests/mocks/i18n/i18n.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import i18n from "i18next";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import { i18nSettings } from "../../../../infrastructure/i18n/init";
|
||||
|
||||
import en from "app/../../public/i18n/en.json";
|
||||
import nl from "app/../../public/i18n/nl.json";
|
||||
|
||||
i18n
|
||||
// detect user language
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
...i18nSettings,
|
||||
...{
|
||||
backend: undefined,
|
||||
fallbackLng: "noLang",
|
||||
debug: false,
|
||||
resources: {
|
||||
en: {
|
||||
translation: en,
|
||||
},
|
||||
nl: {
|
||||
translation: nl,
|
||||
},
|
||||
noLang: {
|
||||
translation: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
@@ -1,10 +1,27 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { WithTestTranslations } from "../../app/tests/mocks/i18n/WithTestTranslations";
|
||||
|
||||
import { AboutContainer } from "./About";
|
||||
|
||||
describe("About container", () => {
|
||||
it("renders welcome to the about page", () => {
|
||||
render(<AboutContainer />);
|
||||
render(
|
||||
<WithTestTranslations>
|
||||
<AboutContainer />
|
||||
</WithTestTranslations>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Welcome to the about page/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/About/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses the about.title key for translation rendering", () => {
|
||||
// we can specify that we only want translations keys to be rendered so we can check for translation keys instead
|
||||
render(
|
||||
<WithTestTranslations keysOnly={true}>
|
||||
<AboutContainer />
|
||||
</WithTestTranslations>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/about.title/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
@@ -1,7 +1,9 @@
|
||||
import { FunctionComponent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type Props = {};
|
||||
|
||||
export const AboutContainer: FunctionComponent<Props> = () => {
|
||||
return <h1>Welcome to the about page :)</h1>;
|
||||
const [translate] = useTranslation();
|
||||
return <h1>{translate("about.title")}</h1>;
|
||||
};
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppDispatch, useAppSelector } from "../../app/hooks";
|
||||
import styles from "./Counter.module.css";
|
||||
import { incrementAsync } from "./state/actions/incrementAsync";
|
||||
@@ -15,12 +16,13 @@ export function CounterContainer() {
|
||||
const { value, status } = useAppSelector(selectCountAndStatus);
|
||||
const dispatch = useAppDispatch();
|
||||
const [incrementAmount, setIncrementAmount] = useState("2");
|
||||
const [translate] = useTranslation();
|
||||
|
||||
const incrementValue = Number(incrementAmount) || 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
Status: {status}
|
||||
{translate("counter.status", { status })}
|
||||
<div className={styles.row}>
|
||||
<button
|
||||
className={styles.button}
|
||||
@@ -49,7 +51,10 @@ export function CounterContainer() {
|
||||
className={styles.button}
|
||||
onClick={() => dispatch(incrementByAmount(incrementValue))}
|
||||
>
|
||||
Add Amount
|
||||
{/* Setting count allows you to pluralize / display different text based on the count
|
||||
See: https://www.i18next.com/translation-function/plurals
|
||||
*/}
|
||||
{translate("counter.add", { count: incrementValue })}
|
||||
</button>
|
||||
<button
|
||||
className={styles.asyncButton}
|
||||
@@ -57,10 +62,7 @@ export function CounterContainer() {
|
||||
>
|
||||
Add Async
|
||||
</button>
|
||||
<button
|
||||
className={styles.button}
|
||||
onClick={() => dispatch(incrementIfOdd(incrementValue))}
|
||||
>
|
||||
<button className={styles.button} onClick={() => dispatch(incrementIfOdd(incrementValue))}>
|
||||
Add If Odd
|
||||
</button>
|
||||
</div>
|
||||
|
@@ -4,6 +4,8 @@ import { Provider } from "react-redux";
|
||||
import App from "./App";
|
||||
import { store } from "./app/store";
|
||||
import "./index.css";
|
||||
import "./infrastructure/i18n/init";
|
||||
import { Loader } from "./infrastructure/wrappers/WithPageSuspense";
|
||||
import reportWebVitals from "./reportWebVitals";
|
||||
|
||||
const container = document.getElementById("root")!;
|
||||
@@ -12,7 +14,9 @@ const root = createRoot(container);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<Provider store={store}>
|
||||
<App />
|
||||
<Loader>
|
||||
<App />
|
||||
</Loader>
|
||||
</Provider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
27
src/infrastructure/i18n/formatters/formattedDate.ts
Normal file
27
src/infrastructure/i18n/formatters/formattedDate.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
/**
|
||||
* Luxon based date formatter for a specific language
|
||||
* @param value date to format
|
||||
* @param language language of the current user
|
||||
* @returns formatted date
|
||||
*/
|
||||
export const FormattedDate = (value: Date, language: string | undefined) => {
|
||||
if (!language) {
|
||||
language = "en";
|
||||
}
|
||||
|
||||
let format;
|
||||
|
||||
switch (language) {
|
||||
case "nl":
|
||||
format = "dd/MM/yyyy";
|
||||
break;
|
||||
case "en":
|
||||
format = "yyyy-MM-dd";
|
||||
break;
|
||||
default:
|
||||
format = "yyyy-MM-dd";
|
||||
}
|
||||
return DateTime.fromJSDate(value).setLocale(language).toFormat(format);
|
||||
};
|
32
src/infrastructure/i18n/init.ts
Normal file
32
src/infrastructure/i18n/init.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import i18n, { InitOptions } from "i18next";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import Backend from "i18next-http-backend";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import { FormattedDate } from "./formatters/formattedDate";
|
||||
|
||||
/**
|
||||
* Initial i18n settings
|
||||
* https://www.i18next.com/overview/configuration-options
|
||||
*/
|
||||
export const i18nSettings: InitOptions = {
|
||||
debug: false,
|
||||
fallbackLng: "en",
|
||||
lng: "en",
|
||||
interpolation: {
|
||||
escapeValue: false, // not needed for react as it escapes by default
|
||||
},
|
||||
// i18next-http-back-end options, for all options read: https://github.com/i18next/i18next-http-backend
|
||||
backend: { loadPath: "/i18n/{{lng}}.json" },
|
||||
};
|
||||
|
||||
i18n
|
||||
// detect user language
|
||||
.use(Backend)
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init(i18nSettings);
|
||||
|
||||
// format date based on language
|
||||
i18n.services.formatter?.add("formattedDate", FormattedDate);
|
||||
|
||||
export default i18n;
|
7
src/infrastructure/loader/appLoader.tsx
Normal file
7
src/infrastructure/loader/appLoader.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { FunctionComponent } from "react";
|
||||
|
||||
type Props = {};
|
||||
|
||||
export const AppLoader: FunctionComponent<Props> = () => {
|
||||
return <h1>Loading app...</h1>;
|
||||
};
|
@@ -1,13 +1,16 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { WithTestTranslations } from "../../app/tests/mocks/i18n/WithTestTranslations";
|
||||
import { WithRouter } from "../wrappers/WithRouter";
|
||||
import { Navbar } from "./Navbar";
|
||||
|
||||
describe("Navbar container", () => {
|
||||
it("renders a navigation section identified by the nav test-id", () => {
|
||||
it.only("renders a navigation section identified by the nav test-id", () => {
|
||||
render(
|
||||
<WithRouter>
|
||||
<Navbar />
|
||||
</WithRouter>,
|
||||
<WithTestTranslations>
|
||||
<WithRouter>
|
||||
<Navbar />
|
||||
</WithRouter>
|
||||
</WithTestTranslations>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByTestId("nav")?.length).toBeGreaterThan(0);
|
||||
|
@@ -1,24 +1,36 @@
|
||||
import { DateTime } from "luxon";
|
||||
import { FunctionComponent } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Config } from "../config";
|
||||
import "./Navbar.css";
|
||||
type Props = {};
|
||||
|
||||
export const Navbar: FunctionComponent<Props> = () => {
|
||||
const [translate, i18n] = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<h1>Our fancy header with navigation.</h1>
|
||||
<p>App version: {JSON.stringify(Config.version)}</p>
|
||||
<h1>{translate("navBar.intro")}</h1>
|
||||
<p>
|
||||
{/* trans can also be used to translate */}
|
||||
<Trans i18nKey="navBar.version">App version:</Trans>
|
||||
{JSON.stringify(Config.version)}
|
||||
</p>
|
||||
|
||||
{/* This translation uses a formatter in the translation files */}
|
||||
<p>{translate("navBar.currentDate", { date: DateTime.now().toJSDate() })}</p>
|
||||
<nav data-testid="nav">
|
||||
<Link to="/" data-testid="nav.home">
|
||||
Home
|
||||
{translate("nav.home")}
|
||||
</Link>
|
||||
<Link to="/about" data-testid="nav.about">
|
||||
About
|
||||
{translate("nav.about")}
|
||||
</Link>
|
||||
<Link to="/counter" data-testid="nav.counter">
|
||||
Counter
|
||||
{translate("nav.counter")}
|
||||
</Link>
|
||||
<button onClick={() => i18n.changeLanguage("en")}>en</button>
|
||||
<button onClick={() => i18n.changeLanguage("nl")}>nl</button>
|
||||
<hr />
|
||||
</nav>
|
||||
</>
|
||||
|
11
src/infrastructure/wrappers/WithPageSuspense.tsx
Normal file
11
src/infrastructure/wrappers/WithPageSuspense.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { FunctionComponent, ReactNode, Suspense } from "react";
|
||||
import { AppLoader } from "../loader/appLoader";
|
||||
|
||||
type Props = { children?: ReactNode };
|
||||
|
||||
/**
|
||||
* Component which wraps children in a fallback loader
|
||||
*/
|
||||
export const Loader: FunctionComponent<Props> = ({ children }) => {
|
||||
return <Suspense fallback={<AppLoader />}>{children}</Suspense>;
|
||||
};
|
Reference in New Issue
Block a user