From 015f131b843fdb0e8c2fb27ddaf571d2dcd2d587 Mon Sep 17 00:00:00 2001 From: fabienpuissant Date: Thu, 22 Aug 2024 23:09:38 +0200 Subject: [PATCH] add react internationalization create react internalization module using i18next 23.14.0 with language detector see https://react.i18next.com/latest/using-with-hooks Fix #10413 --- .../ReactI18nApplicationService.java | 20 ++++ .../domain/ReactI18nModuleFactory.java | 70 ++++++++++++ .../primary/ReactI18nModuleConfiguration.java | 27 +++++ .../client/tools/reacti18n/package-info.java | 2 + .../slug/domain/JHLiteFeatureSlug.java | 1 + .../slug/domain/JHLiteModuleSlug.java | 1 + .../lite/module/domain/JHipsterModule.java | 4 + .../src/main/webapp/app/i18n.ts.mustache | 24 ++++ .../main/webapp/assets/english.json.mustache | 3 + .../main/webapp/assets/french.json.mustache | 3 + .../generator/dependencies/react/package.json | 6 +- src/test/features/client/reacti18n.feature | 13 +++ .../domain/ReactI18nModuleFactoryTest.java | 104 ++++++++++++++++++ .../resources/projects/react-app/App.spec.tsx | 12 ++ 14 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 src/main/java/tech/jhipster/lite/generator/client/tools/reacti18n/application/ReactI18nApplicationService.java create mode 100644 src/main/java/tech/jhipster/lite/generator/client/tools/reacti18n/domain/ReactI18nModuleFactory.java create mode 100644 src/main/java/tech/jhipster/lite/generator/client/tools/reacti18n/infrastructure/primary/ReactI18nModuleConfiguration.java create mode 100644 src/main/java/tech/jhipster/lite/generator/client/tools/reacti18n/package-info.java create mode 100644 src/main/resources/generator/client/common/reacti18n/src/main/webapp/app/i18n.ts.mustache create mode 100644 src/main/resources/generator/client/common/reacti18n/src/main/webapp/assets/english.json.mustache create mode 100644 src/main/resources/generator/client/common/reacti18n/src/main/webapp/assets/french.json.mustache create mode 100644 src/test/features/client/reacti18n.feature create mode 100644 src/test/java/tech/jhipster/lite/generator/client/tools/reacti18n/domain/ReactI18nModuleFactoryTest.java create mode 100644 src/test/resources/projects/react-app/App.spec.tsx diff --git a/src/main/java/tech/jhipster/lite/generator/client/tools/reacti18n/application/ReactI18nApplicationService.java b/src/main/java/tech/jhipster/lite/generator/client/tools/reacti18n/application/ReactI18nApplicationService.java new file mode 100644 index 00000000000..e9417c5e1d1 --- /dev/null +++ b/src/main/java/tech/jhipster/lite/generator/client/tools/reacti18n/application/ReactI18nApplicationService.java @@ -0,0 +1,20 @@ +package tech.jhipster.lite.generator.client.tools.reacti18n.application; + +import org.springframework.stereotype.Service; +import tech.jhipster.lite.generator.client.tools.reacti18n.domain.ReactI18nModuleFactory; +import tech.jhipster.lite.module.domain.JHipsterModule; +import tech.jhipster.lite.module.domain.properties.JHipsterModuleProperties; + +@Service +public class ReactI18nApplicationService { + + private final ReactI18nModuleFactory factory; + + public ReactI18nApplicationService() { + factory = new ReactI18nModuleFactory(); + } + + public JHipsterModule buildModule(JHipsterModuleProperties properties) { + return factory.buildModule(properties); + } +} diff --git a/src/main/java/tech/jhipster/lite/generator/client/tools/reacti18n/domain/ReactI18nModuleFactory.java b/src/main/java/tech/jhipster/lite/generator/client/tools/reacti18n/domain/ReactI18nModuleFactory.java new file mode 100644 index 00000000000..a1e6ed835b2 --- /dev/null +++ b/src/main/java/tech/jhipster/lite/generator/client/tools/reacti18n/domain/ReactI18nModuleFactory.java @@ -0,0 +1,70 @@ +package tech.jhipster.lite.generator.client.tools.reacti18n.domain; + +import static tech.jhipster.lite.module.domain.JHipsterModule.*; + +import tech.jhipster.lite.module.domain.JHipsterModule; +import tech.jhipster.lite.module.domain.file.JHipsterSource; +import tech.jhipster.lite.module.domain.packagejson.VersionSource; +import tech.jhipster.lite.module.domain.properties.JHipsterModuleProperties; +import tech.jhipster.lite.shared.error.domain.Assert; + +public class ReactI18nModuleFactory { + + private static final JHipsterSource APP_SOURCE = from("client/common/reacti18n/src/main/webapp/app"); + private static final JHipsterSource ASSETS_SOURCE = from("client/common/reacti18n/src/main/webapp/assets"); + + private static final String INDEX = "src/main/webapp/"; + private static final String INDEX_TEST = "src/test/webapp/unit/common/primary/app/"; + + public JHipsterModule buildModule(JHipsterModuleProperties properties) { + Assert.notNull("properties", properties); + + //@formatter:off + return moduleBuilder(properties) + .packageJson() + .addDependency(packageName("i18next"), VersionSource.REACT) + .addDependency(packageName("i18next-browser-languagedetector"), VersionSource.REACT) + .addDependency(packageName("i18next-http-backend"), VersionSource.REACT) + .addDependency(packageName("react-i18next"), VersionSource.REACT) + .and() + .files() + .add(APP_SOURCE.template("i18n.ts"), to(INDEX + "app/i18n.ts")) + .add(ASSETS_SOURCE.template("english.json"), to(INDEX + "assets/locales/en/translation.json")) + .add(ASSETS_SOURCE.template("french.json"), to(INDEX + "assets/locales/fr/translation.json")) + .and() + .mandatoryReplacements() + .in(path(INDEX + "app/common/primary/app/App.tsx")) + .add(lineAfterText("import ReactLogo from '@assets/ReactLogo.png';"), "import { useTranslation } from 'react-i18next';") + .add(lineBeforeText("return ("), properties.indentation().times(1) + "const { t } = useTranslation();" + LINE_BREAK) + .add(lineAfterText(""), LINE_BREAK + + properties.indentation().times(4) + "

{t('translationEnabled')}

") + .and() + .in(path(INDEX + "app/index.tsx")) + .add(lineAfterText("import './index.css';"), "import './i18n';" + LINE_BREAK) + .and() + .in(path(INDEX_TEST + "App.spec.tsx")) + .add(append(), LINE_BREAK + """ + describe('App I18next', () => { + it('renders with translation', () => { + vi.mock('react-i18next', () => ({ + useTranslation: () => { + return { + t: vi.fn().mockImplementation((_str: string) => 'Internationalization enabled'), + i18n: { + changeLanguage: () => new Promise(() => {}), + }, + }; + }, + })); + render(); + const { getAllByText } = render(); + const title = getAllByText('Internationalization enabled'); + expect(title).toBeTruthy(); + }); + });""" ) + .and() + .and() + .build(); + //@formatter:off + } +} diff --git a/src/main/java/tech/jhipster/lite/generator/client/tools/reacti18n/infrastructure/primary/ReactI18nModuleConfiguration.java b/src/main/java/tech/jhipster/lite/generator/client/tools/reacti18n/infrastructure/primary/ReactI18nModuleConfiguration.java new file mode 100644 index 00000000000..d8fd58c4d30 --- /dev/null +++ b/src/main/java/tech/jhipster/lite/generator/client/tools/reacti18n/infrastructure/primary/ReactI18nModuleConfiguration.java @@ -0,0 +1,27 @@ +package tech.jhipster.lite.generator.client.tools.reacti18n.infrastructure.primary; + +import static tech.jhipster.lite.generator.slug.domain.JHLiteFeatureSlug.CLIENT_INTERNATIONALIZATION; +import static tech.jhipster.lite.generator.slug.domain.JHLiteModuleSlug.REACT_CORE; +import static tech.jhipster.lite.generator.slug.domain.JHLiteModuleSlug.REACT_I18N; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import tech.jhipster.lite.generator.client.tools.reacti18n.application.ReactI18nApplicationService; +import tech.jhipster.lite.module.domain.resource.JHipsterModuleOrganization; +import tech.jhipster.lite.module.domain.resource.JHipsterModulePropertiesDefinition; +import tech.jhipster.lite.module.domain.resource.JHipsterModuleResource; + +@Configuration +public class ReactI18nModuleConfiguration { + + @Bean + JHipsterModuleResource i18nModule(ReactI18nApplicationService i18n) { + return JHipsterModuleResource.builder() + .slug(REACT_I18N) + .propertiesDefinition(JHipsterModulePropertiesDefinition.builder().build()) + .apiDoc("react i18n", "Add react internationalization") + .organization(JHipsterModuleOrganization.builder().feature(CLIENT_INTERNATIONALIZATION).addDependency(REACT_CORE).build()) + .tags("client", "react", "i18n") + .factory(i18n::buildModule); + } +} diff --git a/src/main/java/tech/jhipster/lite/generator/client/tools/reacti18n/package-info.java b/src/main/java/tech/jhipster/lite/generator/client/tools/reacti18n/package-info.java new file mode 100644 index 00000000000..06b5c1d8c84 --- /dev/null +++ b/src/main/java/tech/jhipster/lite/generator/client/tools/reacti18n/package-info.java @@ -0,0 +1,2 @@ +@tech.jhipster.lite.BusinessContext +package tech.jhipster.lite.generator.client.tools.reacti18n; diff --git a/src/main/java/tech/jhipster/lite/generator/slug/domain/JHLiteFeatureSlug.java b/src/main/java/tech/jhipster/lite/generator/slug/domain/JHLiteFeatureSlug.java index 7432d9bc5f9..c358958b55a 100644 --- a/src/main/java/tech/jhipster/lite/generator/slug/domain/JHLiteFeatureSlug.java +++ b/src/main/java/tech/jhipster/lite/generator/slug/domain/JHLiteFeatureSlug.java @@ -8,6 +8,7 @@ public enum JHLiteFeatureSlug implements JHipsterFeatureSlugFactory { AUTHENTICATION_SPRINGDOC("authentication-springdoc"), JCACHE("jcache"), CLIENT_CORE("client-core"), + CLIENT_INTERNATIONALIZATION("client-internationalization"), CUCUMBER_AUTHENTICATION("cucumber-authentication"), DATABASE_MIGRATION("database-migration"), DOCKERFILE("dockerfile"), diff --git a/src/main/java/tech/jhipster/lite/generator/slug/domain/JHLiteModuleSlug.java b/src/main/java/tech/jhipster/lite/generator/slug/domain/JHLiteModuleSlug.java index a7326069bf2..ff9f20647ac 100644 --- a/src/main/java/tech/jhipster/lite/generator/slug/domain/JHLiteModuleSlug.java +++ b/src/main/java/tech/jhipster/lite/generator/slug/domain/JHLiteModuleSlug.java @@ -46,6 +46,7 @@ public enum JHLiteModuleSlug implements JHipsterModuleSlugFactory { GRADLE_WRAPPER("gradle-wrapper"), HIBERNATE_2ND_LEVEL_CACHE("hibernate-2nd-level-cache"), INFINITEST_FILTERS("infinitest-filters"), + REACT_I18N("react-i18next"), INIT("init"), INTERNATIONALIZED_ERRORS("internationalized-errors"), JACOCO("jacoco"), diff --git a/src/main/java/tech/jhipster/lite/module/domain/JHipsterModule.java b/src/main/java/tech/jhipster/lite/module/domain/JHipsterModule.java index b46ccf365e4..768f5f7ae02 100644 --- a/src/main/java/tech/jhipster/lite/module/domain/JHipsterModule.java +++ b/src/main/java/tech/jhipster/lite/module/domain/JHipsterModule.java @@ -290,6 +290,10 @@ public static RegexNeedleAfterReplacer lineAfterRegex(String regex) { return new RegexNeedleAfterReplacer(notContainingReplacement(), Pattern.compile(regex, Pattern.MULTILINE)); } + public static EndOfFileReplacer append() { + return new EndOfFileReplacer(ReplacementCondition.always()); + } + public static BuildProfileId buildProfileId(String id) { return new BuildProfileId(id); } diff --git a/src/main/resources/generator/client/common/reacti18n/src/main/webapp/app/i18n.ts.mustache b/src/main/resources/generator/client/common/reacti18n/src/main/webapp/app/i18n.ts.mustache new file mode 100644 index 00000000000..738c25d7376 --- /dev/null +++ b/src/main/resources/generator/client/common/reacti18n/src/main/webapp/app/i18n.ts.mustache @@ -0,0 +1,24 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; + +import Backend from 'i18next-http-backend'; +import LanguageDetector from 'i18next-browser-languagedetector'; + +i18n + .use(Backend) + .use(LanguageDetector) + .use(initReactI18next) + + .init({ + fallbackLng: 'en', + debug: false, + + interpolation: { + escapeValue: false, + }, + backend: { + loadPath: '../assets/locales/{{ lng }}/{{ ns }}.json', + }, + }); + +export default i18n; diff --git a/src/main/resources/generator/client/common/reacti18n/src/main/webapp/assets/english.json.mustache b/src/main/resources/generator/client/common/reacti18n/src/main/webapp/assets/english.json.mustache new file mode 100644 index 00000000000..1d29147104b --- /dev/null +++ b/src/main/resources/generator/client/common/reacti18n/src/main/webapp/assets/english.json.mustache @@ -0,0 +1,3 @@ +{ + "translationEnabled": "Internationalization enabled" +} diff --git a/src/main/resources/generator/client/common/reacti18n/src/main/webapp/assets/french.json.mustache b/src/main/resources/generator/client/common/reacti18n/src/main/webapp/assets/french.json.mustache new file mode 100644 index 00000000000..f4c3ffcea83 --- /dev/null +++ b/src/main/resources/generator/client/common/reacti18n/src/main/webapp/assets/french.json.mustache @@ -0,0 +1,3 @@ +{ + "translationEnabled": "Internationalisation activée" +} diff --git a/src/main/resources/generator/dependencies/react/package.json b/src/main/resources/generator/dependencies/react/package.json index cc7a65d63cc..95b1818ed9f 100644 --- a/src/main/resources/generator/dependencies/react/package.json +++ b/src/main/resources/generator/dependencies/react/package.json @@ -9,7 +9,11 @@ "axios": "1.7.4", "react": "18.3.1", "react-dom": "18.3.1", - "react-hook-form": "7.52.2" + "react-hook-form": "7.52.2", + "i18next": "23.14.0", + "i18next-browser-languagedetector": "8.0.0", + "i18next-http-backend": "2.6.1", + "react-i18next": "15.0.1" }, "devDependencies": { "@testing-library/dom": "10.4.0", diff --git a/src/test/features/client/reacti18n.feature b/src/test/features/client/reacti18n.feature new file mode 100644 index 00000000000..010ec5c352f --- /dev/null +++ b/src/test/features/client/reacti18n.feature @@ -0,0 +1,13 @@ +Feature: React i18n + + Scenario: Should apply react i18n module to react + When I apply modules to default project + | init | + | react-core | + | react-i18next | + Then I should have files in "src/main/webapp/app" + | i18n.ts | + And I should have files in "src/main/webapp/assets/locales/en" + | translation.json | + And I should have files in "src/main/webapp/assets/locales/fr" + | translation.json | diff --git a/src/test/java/tech/jhipster/lite/generator/client/tools/reacti18n/domain/ReactI18nModuleFactoryTest.java b/src/test/java/tech/jhipster/lite/generator/client/tools/reacti18n/domain/ReactI18nModuleFactoryTest.java new file mode 100644 index 00000000000..30bf5246b7a --- /dev/null +++ b/src/test/java/tech/jhipster/lite/generator/client/tools/reacti18n/domain/ReactI18nModuleFactoryTest.java @@ -0,0 +1,104 @@ +package tech.jhipster.lite.generator.client.tools.reacti18n.domain; + +import static tech.jhipster.lite.module.infrastructure.secondary.JHipsterModulesAssertions.*; + +import org.junit.jupiter.api.Test; +import tech.jhipster.lite.TestFileUtils; +import tech.jhipster.lite.UnitTest; +import tech.jhipster.lite.module.domain.JHipsterModule; +import tech.jhipster.lite.module.domain.JHipsterModulesFixture; + +@UnitTest +public class ReactI18nModuleFactoryTest { + + public static final ReactI18nModuleFactory factory = new ReactI18nModuleFactory(); + + private static final String APP_TSX = "src/main/webapp/app/common/primary/app/App.tsx"; + + @Test + void shouldBuildI18nModule() { + JHipsterModule module = factory.buildModule( + JHipsterModulesFixture.propertiesBuilder(TestFileUtils.tmpDirForTest()).projectBaseName("jhipster").build() + ); + + JHipsterModuleAsserter asserter = assertThatModuleWithFiles(module, packageJsonFile(), app(), appTest(), index()); + + asserter + .hasFile("package.json") + .containing(nodeDependency("i18next")) + .containing(nodeDependency("i18next-browser-languagedetector")) + .containing(nodeDependency("i18next-http-backend")) + .containing(nodeDependency("react-i18next")) + .and() + .hasFile("src/main/webapp/app/i18n.ts") + .containing( + """ + import i18n from 'i18next'; + import { initReactI18next } from 'react-i18next'; + + import Backend from 'i18next-http-backend'; + import LanguageDetector from 'i18next-browser-languagedetector'; + + i18n + .use(Backend) + .use(LanguageDetector) + .use(initReactI18next) + + .init({ + fallbackLng: 'en', + debug: false, + + interpolation: { + escapeValue: false, + }, + backend: { + loadPath: '../assets/locales/{{ lng }}/{{ ns }}.json', + }, + }); + + export default i18n; + """ + ) + .and() + .hasFile("src/main/webapp/app/index.tsx") + .containing("import './i18n'") + .and() + .hasFile("src/main/webapp/app/common/primary/app/App.tsx") + .containing("import { useTranslation } from 'react-i18next") + .containing("const { t } = useTranslation();") + .containing("{t('translationEnabled')}") + .and() + .hasFile("src/main/webapp/assets/locales/en/translation.json") + .containing( + """ + { + "translationEnabled": "Internationalization enabled" + } + """ + ) + .and() + .hasFile("src/main/webapp/assets/locales/fr/translation.json") + .containing( + """ + { + "translationEnabled": "Internationalisation activée" + } + """ + ) + .and() + .hasFile("src/test/webapp/unit/common/primary/app/App.spec.tsx") + .containing("describe('App I18next', () => {"); + } + + private ModuleFile app() { + return file("src/test/resources/projects/react-app/App.tsx", APP_TSX); + } + + private ModuleFile appTest() { + return file("src/test/resources/projects/react-app/App.spec.tsx", "src/test/webapp/unit/common/primary/app/App.spec.tsx"); + } + + private ModuleFile index() { + return file("src/test/resources/projects/react-app/index.tsx", "src/main/webapp/app/index.tsx"); + } +} diff --git a/src/test/resources/projects/react-app/App.spec.tsx b/src/test/resources/projects/react-app/App.spec.tsx new file mode 100644 index 00000000000..f5e87d503ed --- /dev/null +++ b/src/test/resources/projects/react-app/App.spec.tsx @@ -0,0 +1,12 @@ +import { render } from '@testing-library/react'; +import { describe, it } from 'vitest'; + +import App from '@/common/primary/app/App'; + +describe('App tests', () => { + it('renders without crashing', () => { + const { getByText } = render(); + const title = getByText('React + TypeScript + Vite'); + expect(title).toBeTruthy(); + }); +});