diff --git a/.vscode/settings.json b/.vscode/settings.json index 7f7e683..690991e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,6 +4,8 @@ "camelcase", "flexbugs", "Immer", + "languagedetector", + "luxon", "pmmmwh", "reduxjs", "SVGR", @@ -12,6 +14,16 @@ "typeahead", "uncompiled" ], + "cSpell.ignorePaths": [ + "package-lock.json", + "node_modules", + "vscode-extension", + ".git/objects", + ".vscode", + ".vscode-insiders", + "public/i18n/*", + "!public/i18n/en.json" + ], "files.exclude": { "**/.git": true, "coverage": true, diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c2d264..61c5818 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.0] - 2022-07-07 + +- 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 + ## [0.2.0] - 2022-06-27 - Added [cypress.io](https://www.cypress.io/) diff --git a/jest.config.js b/jest.config.js index 57a262c..e2d882e 100644 --- a/jest.config.js +++ b/jest.config.js @@ -5,7 +5,10 @@ const config = { collectCoverageFrom: ["src/**/*.{js,jsx,ts,tsx}", "!src/**/*.d.ts"], setupFiles: ["react-app-polyfill/jsdom"], setupFilesAfterEnv: ["/src/setupTests.ts"], - testMatch: ["/src/**/__tests__/**/*.{js,jsx,ts,tsx}", "/src/**/*.{spec,test}.{js,jsx,ts,tsx}"], + testMatch: [ + "/src/**/__tests__/**/*.{js,jsx,ts,tsx}", + "/src/**/*.{spec,test}.{js,jsx,ts,tsx}", + ], testEnvironment: "jsdom", transform: { "^.+\\.(js|jsx|mjs|cjs|ts|tsx)$": "/config/jest/babelTransform.js", @@ -21,7 +24,18 @@ const config = { "^react-native$": "react-native-web", "^.+\\.module\\.(css|sass|scss)$": "identity-obj-proxy", }, - moduleFileExtensions: ["web.js", "js", "web.ts", "ts", "web.tsx", "tsx", "json", "web.jsx", "jsx", "node"], + moduleFileExtensions: [ + "web.js", + "js", + "web.ts", + "ts", + "web.tsx", + "tsx", + "json", + "web.jsx", + "jsx", + "node", + ], watchPlugins: ["jest-watch-typeahead/filename", "jest-watch-typeahead/testname"], resetMocks: true, coveragePathIgnorePatterns: [ diff --git a/package-lock.json b/package-lock.json index 01ba6a6..663748d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,8 @@ "license": "MIT", "dependencies": { "@reduxjs/toolkit": "^1.8.2", + "i18next-http-backend": "^1.4.1", + "luxon": "^2.4.0", "react": "^18.2.0", "react-app-polyfill": "^3.0.0", "react-dev-utils": "^12.0.1", @@ -28,6 +30,7 @@ "@testing-library/react": "^13.3.0", "@testing-library/user-event": "^14.2.1", "@types/jest": "^27.5.2", + "@types/luxon": "^2.3.2", "@types/node": "^17.0.45", "@types/react": "^18.0.14", "@types/react-dom": "^18.0.5", @@ -57,6 +60,8 @@ "fs-extra": "^10.0.0", "html-webpack-plugin": "^5.5.0", "husky": "^8.0.1", + "i18next": "^21.8.13", + "i18next-browser-languagedetector": "^6.1.4", "identity-obj-proxy": "^3.0.0", "immer": "^9.0.15", "jest": "^27.4.3", @@ -72,6 +77,7 @@ "prettier": "^2.7.1", "pretty-quick": "^3.1.3", "prompts": "^2.4.2", + "react-i18next": "^11.18.0", "react-refresh": "^0.11.0", "resolve": "^1.20.0", "resolve-url-loader": "^4.0.0", @@ -4156,6 +4162,12 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/luxon": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-2.3.2.tgz", + "integrity": "sha512-WOehptuhKIXukSUUkRgGbj2c997Uv/iUgYgII8U7XLJqq9W2oF0kQ6frEznRQbdurioz+L/cdaIm4GutTQfgmA==", + "dev": true + }, "node_modules/@types/mime": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", @@ -6608,6 +6620,14 @@ "node": ">=10" } }, + "node_modules/cross-fetch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", + "integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==", + "dependencies": { + "node-fetch": "2.6.7" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -10162,6 +10182,15 @@ "node": ">=12" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "dev": true, + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/html-webpack-plugin": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.5.0.tgz", @@ -10335,6 +10364,46 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/i18next": { + "version": "21.8.13", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-21.8.13.tgz", + "integrity": "sha512-DpzwrJq7Y8tjUHxx6ByOkUIjrGYdQI5Mfv4XEI7q2RWdknQ7TaO9bKi8hS/LqYD6pBV5YGxJLReyLkOCxIpouA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "dependencies": { + "@babel/runtime": "^7.17.2" + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-6.1.4.tgz", + "integrity": "sha512-wukWnFeU7rKIWT66VU5i8I+3Zc4wReGcuDK2+kuFhtoxBRGWGdvYI9UQmqNL/yQH1KogWwh+xGEaIPH8V/i2Zg==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.14.6" + } + }, + "node_modules/i18next-http-backend": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-1.4.1.tgz", + "integrity": "sha512-s4Q9hK2jS29iyhniMP82z+yYY8riGTrWbnyvsSzi5TaF7Le4E7b5deTmtuaRuab9fdDcYXtcwdBgawZG+JCEjA==", + "dependencies": { + "cross-fetch": "3.1.5" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -14041,6 +14110,14 @@ "node": ">=10" } }, + "node_modules/luxon": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-2.4.0.tgz", + "integrity": "sha512-w+NAwWOUL5hO0SgwOHsMBAmZ15SoknmQXhSO0hIbJCAmPKSsGeK8MlmhYh2w6Iib38IxN2M+/ooXWLbeis7GuA==", + "engines": { + "node": ">=12" + } + }, "node_modules/lz-string": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz", @@ -14398,6 +14475,44 @@ "tslib": "^2.0.3" } }, + "node_modules/node-fetch": { + "version": "2.6.7", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", + "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-forge": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", @@ -16891,6 +17006,28 @@ "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz", "integrity": "sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==" }, + "node_modules/react-i18next": { + "version": "11.18.0", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-11.18.0.tgz", + "integrity": "sha512-coJujU20xJ5Wa5rHjTyB5LFKZb1yfXo2A+40RRSyAF0FlZRHyy+3C1Mr92x1JPfS7W7v2TWNn8mRhpDFGJwXVg==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.14.5", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 19.0.0", + "react": ">= 16.8.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -19158,6 +19295,15 @@ "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", "dev": true }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/w3c-hr-time": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", @@ -23105,6 +23251,12 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "@types/luxon": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-2.3.2.tgz", + "integrity": "sha512-WOehptuhKIXukSUUkRgGbj2c997Uv/iUgYgII8U7XLJqq9W2oF0kQ6frEznRQbdurioz+L/cdaIm4GutTQfgmA==", + "dev": true + }, "@types/mime": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", @@ -24966,6 +25118,14 @@ "yaml": "^1.10.0" } }, + "cross-fetch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", + "integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==", + "requires": { + "node-fetch": "2.6.7" + } + }, "cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -27605,6 +27765,15 @@ "terser": "^5.10.0" } }, + "html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "dev": true, + "requires": { + "void-elements": "3.1.0" + } + }, "html-webpack-plugin": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.5.0.tgz", @@ -27723,6 +27892,32 @@ "integrity": "sha512-xs7/chUH/CKdOCs7Zy0Aev9e/dKOMZf3K1Az1nar3tzlv0jfqnYtu235bstsWTmXOR0EfINrPa97yy4Lz6RiKw==", "dev": true }, + "i18next": { + "version": "21.8.13", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-21.8.13.tgz", + "integrity": "sha512-DpzwrJq7Y8tjUHxx6ByOkUIjrGYdQI5Mfv4XEI7q2RWdknQ7TaO9bKi8hS/LqYD6pBV5YGxJLReyLkOCxIpouA==", + "dev": true, + "requires": { + "@babel/runtime": "^7.17.2" + } + }, + "i18next-browser-languagedetector": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-6.1.4.tgz", + "integrity": "sha512-wukWnFeU7rKIWT66VU5i8I+3Zc4wReGcuDK2+kuFhtoxBRGWGdvYI9UQmqNL/yQH1KogWwh+xGEaIPH8V/i2Zg==", + "dev": true, + "requires": { + "@babel/runtime": "^7.14.6" + } + }, + "i18next-http-backend": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-1.4.1.tgz", + "integrity": "sha512-s4Q9hK2jS29iyhniMP82z+yYY8riGTrWbnyvsSzi5TaF7Le4E7b5deTmtuaRuab9fdDcYXtcwdBgawZG+JCEjA==", + "requires": { + "cross-fetch": "3.1.5" + } + }, "iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -30453,6 +30648,11 @@ "yallist": "^4.0.0" } }, + "luxon": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-2.4.0.tgz", + "integrity": "sha512-w+NAwWOUL5hO0SgwOHsMBAmZ15SoknmQXhSO0hIbJCAmPKSsGeK8MlmhYh2w6Iib38IxN2M+/ooXWLbeis7GuA==" + }, "lz-string": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz", @@ -30721,6 +30921,35 @@ "tslib": "^2.0.3" } }, + "node-fetch": { + "version": "2.6.7", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", + "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", + "requires": { + "whatwg-url": "^5.0.0" + }, + "dependencies": { + "tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, + "webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "requires": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + } + } + }, "node-forge": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", @@ -32375,6 +32604,16 @@ "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz", "integrity": "sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==" }, + "react-i18next": { + "version": "11.18.0", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-11.18.0.tgz", + "integrity": "sha512-coJujU20xJ5Wa5rHjTyB5LFKZb1yfXo2A+40RRSyAF0FlZRHyy+3C1Mr92x1JPfS7W7v2TWNn8mRhpDFGJwXVg==", + "dev": true, + "requires": { + "@babel/runtime": "^7.14.5", + "html-parse-stringify": "^3.0.1" + } + }, "react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -34076,6 +34315,12 @@ } } }, + "void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "dev": true + }, "w3c-hr-time": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", diff --git a/package.json b/package.json index 43d9e95..1ef96c0 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,8 @@ }, "dependencies": { "@reduxjs/toolkit": "^1.8.2", + "i18next-http-backend": "^1.4.1", + "luxon": "^2.4.0", "react": "^18.2.0", "react-app-polyfill": "^3.0.0", "react-dev-utils": "^12.0.1", @@ -60,6 +62,7 @@ "@testing-library/react": "^13.3.0", "@testing-library/user-event": "^14.2.1", "@types/jest": "^27.5.2", + "@types/luxon": "^2.3.2", "@types/node": "^17.0.45", "@types/react": "^18.0.14", "@types/react-dom": "^18.0.5", @@ -89,6 +92,8 @@ "fs-extra": "^10.0.0", "html-webpack-plugin": "^5.5.0", "husky": "^8.0.1", + "i18next": "^21.8.13", + "i18next-browser-languagedetector": "^6.1.4", "identity-obj-proxy": "^3.0.0", "immer": "^9.0.15", "jest": "^27.4.3", @@ -104,6 +109,7 @@ "prettier": "^2.7.1", "pretty-quick": "^3.1.3", "prompts": "^2.4.2", + "react-i18next": "^11.18.0", "react-refresh": "^0.11.0", "resolve": "^1.20.0", "resolve-url-loader": "^4.0.0", diff --git a/public/i18n/en.json b/public/i18n/en.json new file mode 100644 index 0000000..dac72e5 --- /dev/null +++ b/public/i18n/en.json @@ -0,0 +1,24 @@ +{ + "common": { + "welcome": "Welcome!" + }, + "navBar": { + "intro": "Our fancy header with navigation.", + "version": "App version:", + "currentDate": "Today's date: {{date, formattedDate}}" + }, + "nav": { + "home": "home", + "about": "about", + "counter": "counter" + }, + "about": { + "title": "About" + }, + "counter": { + "status": "Working status: {{status}}", + "add_zero": "Please enter a value", + "add_one": "Add one", + "add_other": "Add {{count}}" + } +} diff --git a/public/i18n/nl.json b/public/i18n/nl.json new file mode 100644 index 0000000..7bad074 --- /dev/null +++ b/public/i18n/nl.json @@ -0,0 +1,24 @@ +{ + "common": { + "welcome": "Welkom!" + }, + "navBar": { + "intro": "Een fancy header met navigatie", + "version": "Aplicatie versie:", + "currentDate": "De datum van vandaag: {{date, formattedDate}}" + }, + "nav": { + "home": "home", + "about": "over ons", + "counter": "teller" + }, + "about": { + "title": "Over ons" + }, + "counter": { + "status": "Staat van werking: {{status}}", + "add_zero": "Vul aub een waarde in", + "add_one": "+1", + "add_other": "Voeg {{count}} toe" + } +} diff --git a/src/app/tests/mocks/i18n/WithTestTranslations.tsx b/src/app/tests/mocks/i18n/WithTestTranslations.tsx new file mode 100644 index 0000000..2b31c9c --- /dev/null +++ b/src/app/tests/mocks/i18n/WithTestTranslations.tsx @@ -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 = ({ children, keysOnly }) => { + const [_translate, i18nSettings] = useTranslation(); + if (keysOnly) { + i18nSettings.changeLanguage("noLang"); + } + + return <>{children}; +}; + +export const WithTestTranslations: FunctionComponent = ({ children, keysOnly = false }) => { + return ( + + {children} + + ); +}; diff --git a/src/app/tests/mocks/i18n/i18n.ts b/src/app/tests/mocks/i18n/i18n.ts new file mode 100644 index 0000000..79afd73 --- /dev/null +++ b/src/app/tests/mocks/i18n/i18n.ts @@ -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; diff --git a/src/features/about/About.spec.tsx b/src/features/about/About.spec.tsx index ab60d27..c288f71 100644 --- a/src/features/about/About.spec.tsx +++ b/src/features/about/About.spec.tsx @@ -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(); + render( + + + , + ); - 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( + + + , + ); + + expect(screen.getByText(/about.title/)).toBeInTheDocument(); }); }); diff --git a/src/features/about/About.tsx b/src/features/about/About.tsx index 368379b..39b5585 100644 --- a/src/features/about/About.tsx +++ b/src/features/about/About.tsx @@ -1,7 +1,9 @@ import { FunctionComponent } from "react"; +import { useTranslation } from "react-i18next"; type Props = {}; export const AboutContainer: FunctionComponent = () => { - return

Welcome to the about page :)

; + const [translate] = useTranslation(); + return

{translate("about.title")}

; }; diff --git a/src/features/counter/Counter.tsx b/src/features/counter/Counter.tsx index b8abf65..c2642d5 100644 --- a/src/features/counter/Counter.tsx +++ b/src/features/counter/Counter.tsx @@ -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 (
- Status: {status} + {translate("counter.status", { status })}
-
diff --git a/src/index.tsx b/src/index.tsx index c9e123b..75f176e 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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( - + + + , ); diff --git a/src/infrastructure/i18n/formatters/formattedDate.ts b/src/infrastructure/i18n/formatters/formattedDate.ts new file mode 100644 index 0000000..a011471 --- /dev/null +++ b/src/infrastructure/i18n/formatters/formattedDate.ts @@ -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); +}; diff --git a/src/infrastructure/i18n/init.ts b/src/infrastructure/i18n/init.ts new file mode 100644 index 0000000..2802897 --- /dev/null +++ b/src/infrastructure/i18n/init.ts @@ -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; diff --git a/src/infrastructure/loader/appLoader.tsx b/src/infrastructure/loader/appLoader.tsx new file mode 100644 index 0000000..5f95643 --- /dev/null +++ b/src/infrastructure/loader/appLoader.tsx @@ -0,0 +1,7 @@ +import { FunctionComponent } from "react"; + +type Props = {}; + +export const AppLoader: FunctionComponent = () => { + return

Loading app...

; +}; diff --git a/src/infrastructure/navbar/Navbar.spec.tsx b/src/infrastructure/navbar/Navbar.spec.tsx index 7bb6ae2..3685923 100644 --- a/src/infrastructure/navbar/Navbar.spec.tsx +++ b/src/infrastructure/navbar/Navbar.spec.tsx @@ -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( - - - , + + + + + , ); expect(screen.getAllByTestId("nav")?.length).toBeGreaterThan(0); diff --git a/src/infrastructure/navbar/Navbar.tsx b/src/infrastructure/navbar/Navbar.tsx index fbdf3de..81779e3 100644 --- a/src/infrastructure/navbar/Navbar.tsx +++ b/src/infrastructure/navbar/Navbar.tsx @@ -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 = () => { + const [translate, i18n] = useTranslation(); return ( <> -

Our fancy header with navigation.

-

App version: {JSON.stringify(Config.version)}

+

{translate("navBar.intro")}

+

+ {/* trans can also be used to translate */} + App version: + {JSON.stringify(Config.version)} +

+ + {/* This translation uses a formatter in the translation files */} +

{translate("navBar.currentDate", { date: DateTime.now().toJSDate() })}

diff --git a/src/infrastructure/wrappers/WithPageSuspense.tsx b/src/infrastructure/wrappers/WithPageSuspense.tsx new file mode 100644 index 0000000..63cad31 --- /dev/null +++ b/src/infrastructure/wrappers/WithPageSuspense.tsx @@ -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 = ({ children }) => { + return }>{children}; +};