diff --git a/src/main/java/tech/jhipster/lite/generator/client/react/i18n/application/ReactI18nApplicationService.java b/src/main/java/tech/jhipster/lite/generator/client/react/i18n/application/ReactI18nApplicationService.java new file mode 100644 index 00000000000..588a845c9a7 --- /dev/null +++ b/src/main/java/tech/jhipster/lite/generator/client/react/i18n/application/ReactI18nApplicationService.java @@ -0,0 +1,20 @@ +package tech.jhipster.lite.generator.client.react.i18n.application; + +import org.springframework.stereotype.Service; +import tech.jhipster.lite.generator.client.react.i18n.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/react/i18n/domain/ReactI18nModuleFactory.java b/src/main/java/tech/jhipster/lite/generator/client/react/i18n/domain/ReactI18nModuleFactory.java new file mode 100644 index 00000000000..c7043fcb28f --- /dev/null +++ b/src/main/java/tech/jhipster/lite/generator/client/react/i18n/domain/ReactI18nModuleFactory.java @@ -0,0 +1,77 @@ +package tech.jhipster.lite.generator.client.react.i18n.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/react/i18n/src/main/webapp/app"); + private static final JHipsterSource ASSETS_FR_SOURCE = from("client/react/i18n/src/main/webapp/assets/locales/fr"); + private static final JHipsterSource ASSETS_EN_SOURCE = from("client/react/i18n/src/main/webapp/assets/locales/en"); + + 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.COMMON) + .addDependency(packageName("i18next-browser-languagedetector"), VersionSource.COMMON) + .addDependency(packageName("i18next-http-backend"), VersionSource.COMMON) + .addDependency(packageName("react-i18next"), VersionSource.REACT) + .and() + .files() + .batch(APP_SOURCE, to(INDEX + "/app")) + .addFile("i18n.ts") + .and() + .batch(ASSETS_EN_SOURCE, to(INDEX + "assets/locales/en/")) + .addFile("translation.json") + .and() + .batch(ASSETS_FR_SOURCE, to(INDEX + "assets/locales/fr/")) + .addFile("translation.json") + .and() + .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/react/i18n/infrastructure/primary/ReactI18nModuleConfiguration.java b/src/main/java/tech/jhipster/lite/generator/client/react/i18n/infrastructure/primary/ReactI18nModuleConfiguration.java new file mode 100644 index 00000000000..4b245b4f8f5 --- /dev/null +++ b/src/main/java/tech/jhipster/lite/generator/client/react/i18n/infrastructure/primary/ReactI18nModuleConfiguration.java @@ -0,0 +1,27 @@ +package tech.jhipster.lite.generator.client.react.i18n.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.react.i18n.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 +class ReactI18nModuleConfiguration { + + @Bean + JHipsterModuleResource i18nModule(ReactI18nApplicationService i18n) { + return JHipsterModuleResource.builder() + .slug(REACT_I18N) + .propertiesDefinition(JHipsterModulePropertiesDefinition.builder().build()) + .apiDoc("Frontend - React", "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/react/i18n/package-info.java b/src/main/java/tech/jhipster/lite/generator/client/react/i18n/package-info.java new file mode 100644 index 00000000000..b0d7de1f18a --- /dev/null +++ b/src/main/java/tech/jhipster/lite/generator/client/react/i18n/package-info.java @@ -0,0 +1,2 @@ +@tech.jhipster.lite.BusinessContext +package tech.jhipster.lite.generator.client.react.i18n; 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..70958adfb86 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 @@ -74,7 +74,6 @@ public enum JHLiteModuleSlug implements JHipsterModuleSlugFactory { MAVEN_WRAPPER("maven-wrapper"), MONGOCK("mongock"), MONGODB("mongodb"), - REDIS("redis"), MSSQL("mssql"), MYSQL("mysql"), NEO4J("neo4j"), @@ -87,7 +86,9 @@ public enum JHLiteModuleSlug implements JHipsterModuleSlugFactory { PROTOBUF("protobuf"), PROTOBUF_BACKWARDS_COMPATIBILITY_CHECK("protobuf-backwards-compatibility-check"), REACT_CORE("react-core"), + REACT_I18N("react-i18next"), REACT_JWT("react-jwt"), + REDIS("redis"), REST_PAGINATION("rest-pagination"), SAMPLE_CASSANDRA_PERSISTENCE("sample-cassandra-persistence"), SAMPLE_FEATURE("sample-feature"), 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/client/react/i18n/src/main/webapp/app/i18n.ts b/src/main/resources/generator/client/react/i18n/src/main/webapp/app/i18n.ts new file mode 100644 index 00000000000..738c25d7376 --- /dev/null +++ b/src/main/resources/generator/client/react/i18n/src/main/webapp/app/i18n.ts @@ -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/react/i18n/src/main/webapp/assets/locales/en/translation.json b/src/main/resources/generator/client/react/i18n/src/main/webapp/assets/locales/en/translation.json new file mode 100644 index 00000000000..1d29147104b --- /dev/null +++ b/src/main/resources/generator/client/react/i18n/src/main/webapp/assets/locales/en/translation.json @@ -0,0 +1,3 @@ +{ + "translationEnabled": "Internationalization enabled" +} diff --git a/src/main/resources/generator/client/react/i18n/src/main/webapp/assets/locales/fr/translation.json b/src/main/resources/generator/client/react/i18n/src/main/webapp/assets/locales/fr/translation.json new file mode 100644 index 00000000000..f4c3ffcea83 --- /dev/null +++ b/src/main/resources/generator/client/react/i18n/src/main/webapp/assets/locales/fr/translation.json @@ -0,0 +1,3 @@ +{ + "translationEnabled": "Internationalisation activée" +} diff --git a/src/main/resources/generator/dependencies/common/package.json b/src/main/resources/generator/dependencies/common/package.json index 8b3b8a33f94..0fef04e77d4 100644 --- a/src/main/resources/generator/dependencies/common/package.json +++ b/src/main/resources/generator/dependencies/common/package.json @@ -3,6 +3,11 @@ "version": "0.0.0", "description": "JHipster Lite : used for Dependencies", "license": "Apache-2.0", + "dependencies": { + "i18next": "23.14.0", + "i18next-browser-languagedetector": "8.0.0", + "i18next-http-backend": "2.6.1" + }, "devDependencies": { "@babel/cli": "7.24.8", "@playwright/test": "1.46.1", diff --git a/src/main/resources/generator/dependencies/react/package.json b/src/main/resources/generator/dependencies/react/package.json index cc7a65d63cc..0ee7c85f9fe 100644 --- a/src/main/resources/generator/dependencies/react/package.json +++ b/src/main/resources/generator/dependencies/react/package.json @@ -9,7 +9,8 @@ "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", + "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..38eaf1d4b93 --- /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/react/i18n/domain/ReactI18nModuleFactoryTest.java b/src/test/java/tech/jhipster/lite/generator/client/react/i18n/domain/ReactI18nModuleFactoryTest.java new file mode 100644 index 00000000000..6ef3d826463 --- /dev/null +++ b/src/test/java/tech/jhipster/lite/generator/client/react/i18n/domain/ReactI18nModuleFactoryTest.java @@ -0,0 +1,104 @@ +package tech.jhipster.lite.generator.client.react.i18n.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/java/tech/jhipster/lite/module/infrastructure/secondary/npm/FileSystemNpmVersionReaderTest.java b/src/test/java/tech/jhipster/lite/module/infrastructure/secondary/npm/FileSystemNpmVersionReaderTest.java index 3060383d862..5d28245b12c 100644 --- a/src/test/java/tech/jhipster/lite/module/infrastructure/secondary/npm/FileSystemNpmVersionReaderTest.java +++ b/src/test/java/tech/jhipster/lite/module/infrastructure/secondary/npm/FileSystemNpmVersionReaderTest.java @@ -42,6 +42,15 @@ void shouldGetVersionFromSource() { assertThat(version).isEqualTo(new NpmPackageVersion("1.2.3")); } + @Test + void shouldGetVersionFromEmptySourceWithEmptyDevSource() { + emptyProjectFiles(); + + NpmPackageVersion version = reader.get().get(new NpmPackageName("vue"), NpmVersionSource.COMMON); + + assertThat(version).isEqualTo(new NpmPackageVersion("1.2.3")); + } + private void mockProjectFiles() { when(projectFiles.readString(anyString())).thenReturn( """ @@ -80,4 +89,20 @@ private void mockProjectFiles() { """ ); } + + private void emptyProjectFiles() { + when(projectFiles.readString(anyString())).thenReturn( + """ + { + "name": "jhlite-dependencies", + "version": "0.0.0", + "description": "JHipster Lite : used for Dependencies", + "license": "Apache-2.0", + "dependencies": { + "vue": "1.2.3" + }, + } + """ + ); + } } 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(); + }); +});