From dc4330025ccbfd88ea97ee0fc3bf0d30519bd5c6 Mon Sep 17 00:00:00 2001 From: Kai Vandivier Date: Tue, 16 Jan 2024 17:28:56 +0100 Subject: [PATCH 01/17] feat: change text direction based on locale --- adapter/src/utils/useLocale.js | 59 ++++++++++++++++++++++++++++------ 1 file changed, 49 insertions(+), 10 deletions(-) diff --git a/adapter/src/utils/useLocale.js b/adapter/src/utils/useLocale.js index 2c34bc94f..a023663e5 100644 --- a/adapter/src/utils/useLocale.js +++ b/adapter/src/utils/useLocale.js @@ -3,16 +3,45 @@ import i18n from '@dhis2/d2-i18n' import moment from 'moment' import { useState, useEffect } from 'react' -i18n.setDefaultNamespace('default') +const I18N_NAMESPACE = 'default' +i18n.setDefaultNamespace(I18N_NAMESPACE) -const simplifyLocale = (locale) => { - const idx = locale.indexOf('-') - if (idx === -1) { +const transformJavaLocale = (locale) => { + return locale.replace('_', '-') +} + +// if translation resources aren't found for the given locale, try shorter +// versions of the locale +// e.g. 'pt_BR_Cyrl_asdf' => 'pt_BR', or 'ar-NotFound' => 'ar' +const validateLocaleByBundle = (locale) => { + if (i18n.hasResourceBundle(locale, I18N_NAMESPACE)) { + return locale + } + + console.log(`Translations for locale ${locale} not found`) + + // see if we can try basic versions of the locale + // (e.g. 'ar' instead of 'ar_IQ') + const match = /[_-]/.exec(locale) + if (!match) { return locale } - return locale.substr(0, idx) + + const separator = match[0] // '-' or '_' + const splitLocale = locale.split(separator) + for (let i = splitLocale.length - 1; i > 0; i--) { + const shorterLocale = splitLocale.slice(0, i).join(separator) + if (i18n.hasResourceBundle(shorterLocale, I18N_NAMESPACE)) { + return shorterLocale + } + console.log(`Translations for locale ${shorterLocale} not found`) + } + + // if nothing else works, use the initially provided locale + return locale } +// Set locale for Moment and i18n const setGlobalLocale = (locale) => { if (locale !== 'en' && locale !== 'en-us') { import( @@ -23,12 +52,21 @@ const setGlobalLocale = (locale) => { } moment.locale(locale) - const simplifiedLocale = simplifyLocale(locale) - i18n.changeLanguage(simplifiedLocale) + // for i18n.dir, need JS-formatted locale + const jsLocale = transformJavaLocale(locale) + const direction = i18n.dir(jsLocale) + // set `dir` globally (then override in app wrapper if needed) + document.body.setAttribute('dir', direction) + + const resolvedLocale = validateLocaleByBundle(locale) + i18n.changeLanguage(resolvedLocale) + + console.log('🗺 Global d2-i18n locale initialized:', resolvedLocale) } export const useLocale = (locale) => { - const [result, setResult] = useState(undefined) + const [result, setResult] = useState({}) + useEffect(() => { if (!locale) { return @@ -36,9 +74,8 @@ export const useLocale = (locale) => { setGlobalLocale(locale) setResult(locale) - - console.log('🗺 Global d2-i18n locale initialized:', locale) }, [locale]) + return result } @@ -47,6 +84,8 @@ const settingsQuery = { resource: 'userSettings', }, } +// note: userSettings.keyUiLocale is expected to be in the Java format, +// e.g. 'ar', 'ar_IQ', 'uz_UZ_Cyrl', etc. export const useCurrentUserLocale = () => { const { loading, error, data } = useDataQuery(settingsQuery) const locale = useLocale( From b309510bfafe9633a091ff8ca155409277072f1f Mon Sep 17 00:00:00 2001 From: Kai Vandivier Date: Wed, 17 Jan 2024 18:06:08 +0100 Subject: [PATCH 02/17] feat: add direction config to d2.config --- adapter/src/components/AppWrapper.js | 16 ++++++++-- adapter/src/index.js | 3 ++ adapter/src/utils/useLocale.js | 42 ++++++++++++++++++--------- cli/config/d2.config.app.js | 6 ++++ cli/src/lib/shell/index.js | 1 + docs/config/d2-config-js-reference.md | 37 +++++++++++------------ examples/simple-app/d2.config.js | 1 + shell/src/App.js | 1 + 8 files changed, 72 insertions(+), 35 deletions(-) diff --git a/adapter/src/components/AppWrapper.js b/adapter/src/components/AppWrapper.js index b01466156..e65749c01 100644 --- a/adapter/src/components/AppWrapper.js +++ b/adapter/src/components/AppWrapper.js @@ -8,8 +8,15 @@ import { ErrorBoundary } from './ErrorBoundary.js' import { LoadingMask } from './LoadingMask.js' import { styles } from './styles/AppWrapper.style.js' -const AppWrapper = ({ children, plugin, onPluginError, clearPluginError }) => { - const { loading: localeLoading } = useCurrentUserLocale() +const AppWrapper = ({ + children, + plugin, + onPluginError, + clearPluginError, + direction: configDirection, +}) => { + const { loading: localeLoading, direction: localeDirection } = + useCurrentUserLocale(configDirection) const { loading: latestUserLoading } = useVerifyLatestUser() if (localeLoading || latestUserLoading) { @@ -40,7 +47,9 @@ const AppWrapper = ({ children, plugin, onPluginError, clearPluginError }) => { return (
- +
+ +
window.location.reload()}> {children} @@ -54,6 +63,7 @@ const AppWrapper = ({ children, plugin, onPluginError, clearPluginError }) => { AppWrapper.propTypes = { children: PropTypes.node, clearPluginError: PropTypes.func, + direction: PropTypes.oneOf(['ltr', 'rtl', 'auto']), plugin: PropTypes.bool, onPluginError: PropTypes.func, } diff --git a/adapter/src/index.js b/adapter/src/index.js index 5b7124225..d47ff3b17 100644 --- a/adapter/src/index.js +++ b/adapter/src/index.js @@ -12,6 +12,7 @@ const AppAdapter = ({ appVersion, url, apiVersion, + direction, pwaEnabled, plugin, parentAlertsAdd, @@ -41,6 +42,7 @@ const AppAdapter = ({ plugin={plugin} onPluginError={onPluginError} clearPluginError={clearPluginError} + direction={direction} > {children} @@ -56,6 +58,7 @@ AppAdapter.propTypes = { apiVersion: PropTypes.number, children: PropTypes.element, clearPluginError: PropTypes.func, + direction: PropTypes.oneOf(['ltr', 'rtl', 'auto']), parentAlertsAdd: PropTypes.func, plugin: PropTypes.bool, pwaEnabled: PropTypes.bool, diff --git a/adapter/src/utils/useLocale.js b/adapter/src/utils/useLocale.js index a023663e5..a980d93e3 100644 --- a/adapter/src/utils/useLocale.js +++ b/adapter/src/utils/useLocale.js @@ -52,19 +52,29 @@ const setGlobalLocale = (locale) => { } moment.locale(locale) - // for i18n.dir, need JS-formatted locale - const jsLocale = transformJavaLocale(locale) - const direction = i18n.dir(jsLocale) - // set `dir` globally (then override in app wrapper if needed) - document.body.setAttribute('dir', direction) - const resolvedLocale = validateLocaleByBundle(locale) i18n.changeLanguage(resolvedLocale) console.log('🗺 Global d2-i18n locale initialized:', resolvedLocale) } -export const useLocale = (locale) => { +// Sets the global direction based on the app's configured direction +// (which should be done to affect modals, alerts, and other portal elements), +// then returns the locale's direction for use on the header bar +const handleDirection = ({ locale, configDirection }) => { + // for i18n.dir, need JS-formatted locale + const jsLocale = transformJavaLocale(locale) + const localeDirection = i18n.dir(jsLocale) + + const globalDirection = + configDirection === 'auto' ? localeDirection : configDirection + // set `dir` globally (then override in app wrapper if needed) + document.documentElement.setAttribute('dir', globalDirection) + + return localeDirection +} + +export const useLocale = ({ locale, configDirection }) => { const [result, setResult] = useState({}) useEffect(() => { @@ -72,9 +82,10 @@ export const useLocale = (locale) => { return } + const direction = handleDirection({ locale, configDirection }) setGlobalLocale(locale) - setResult(locale) - }, [locale]) + setResult({ locale, direction }) + }, [locale, configDirection]) return result } @@ -86,16 +97,19 @@ const settingsQuery = { } // note: userSettings.keyUiLocale is expected to be in the Java format, // e.g. 'ar', 'ar_IQ', 'uz_UZ_Cyrl', etc. -export const useCurrentUserLocale = () => { +export const useCurrentUserLocale = (configDirection) => { const { loading, error, data } = useDataQuery(settingsQuery) - const locale = useLocale( - data && (data.userSettings.keyUiLocale || window.navigator.language) - ) + const { locale, direction } = useLocale({ + locale: + data && + (data.userSettings.keyUiLocale || window.navigator.language), + configDirection, + }) if (error) { // This shouldn't happen, trigger the fatal error boundary throw new Error('Failed to fetch user locale: ' + error) } - return { loading: loading || !locale, locale } + return { loading: loading || !locale, locale, direction } } diff --git a/cli/config/d2.config.app.js b/cli/config/d2.config.app.js index fb32d2704..1c9d77d64 100644 --- a/cli/config/d2.config.app.js +++ b/cli/config/d2.config.app.js @@ -1,6 +1,12 @@ const config = { type: 'app', + // sets the `dir` HTML attribute for the app: + // options are 'ltr', 'rtl', and 'auto'. If set to 'auto', the direction + // will be inferred by the user's UI locale. + // The header bar direction will always be set by the locale. + direction: 'ltr', + entryPoints: { app: './src/App.js', }, diff --git a/cli/src/lib/shell/index.js b/cli/src/lib/shell/index.js index e2c737427..9502ea2ac 100644 --- a/cli/src/lib/shell/index.js +++ b/cli/src/lib/shell/index.js @@ -7,6 +7,7 @@ module.exports = ({ config, paths }) => { const baseEnvVars = { name: config.title, version: config.version, + direction: config.direction, } return { diff --git a/docs/config/d2-config-js-reference.md b/docs/config/d2-config-js-reference.md index 23567b002..eb3fae8ce 100644 --- a/docs/config/d2-config-js-reference.md +++ b/docs/config/d2-config-js-reference.md @@ -11,24 +11,25 @@ All properties are technically optional, but it is recommended to set them expli The following configuration properties are supported: -| Property | Type | Default | Description | -| :--------------------: | :------------------: | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **type** | _string_ | **app** | Either **app** or **lib** | -| **name** | _string_ | `pkg.name` | A short, machine-readable unique name for this app | -| **title** | _string_ | `config.name` | The human-readable application title, which will appear in the HeaderBar | -| **id** | _string_ | | The ID of the app on the [App Hub](https://apps.dhis2.org/). Used when publishing the app to the App Hub with [d2 app scripts publish](../scripts/publish). See [this guide](https://developers.dhis2.org/docs/guides/publish-apphub/) to learn how to set up continuous delivery. | -| **description** | _string_ | `pkg.description` | A full-length description of the application | -| **author** | _string_ or _object_ | `pkg.author` | The name of the developer to include in the DHIS2 manifest, following [package.json author field syntax](https://docs.npmjs.com/cli/v8/configuring-npm/package-json#people-fields-author-contributors). | -| **entryPoints.app** | _string_ | **./src/App** | The path to the application entrypoint (not used for libraries) | -| **entryPoints.plugin** | _string_ | | The path to the application's plugin entrypoint (not used for libraries) | -| **entryPoints.lib** | _string_ or _object_ | **./src/index** | The path to the library entrypoint(s) (not used for applications). Supports [conditional exports](https://nodejs.org/dist/latest-v16.x/docs/api/packages.html#packages_conditional_exports) | -| **dataStoreNamespace** | _string_ | | The DataStore and UserDataStore namespace to reserve for this application. The reserved namespace **must** be suitably unique, as other apps will fail to install if they attempt to reserve the same namespace - see the [webapp manifest docs](https://docs.dhis2.org/en/develop/loading-apps.html) | -| **customAuthorities** | _Array(string)_ | | An array of custom authorities to create when installing the app, these do not provide security protections in the DHIS2 REST API but can be assigned to user roles and used to modify the interface displayed to a user - see the [webapp manifest docs](https://docs.dhis2.org/en/develop/loading-apps.html) | -| **minDHIS2Version** | _string_ | | The minimum DHIS2 version the App supports (eg. '2.35'). Required when uploading an app to the App Hub. The app's major version in the app's package.json needs to be increased when changing this property. | -| **maxDHIS2Version** | _string_ | | The maximum DHIS2 version the App supports. | -| **coreApp** | _boolean_ | **false** | **ADVANCED** If true, build an app artifact to be included as a root-level core application | -| **standalone** | _boolean_ | **false** | **ADVANCED** If true, do NOT include a static BaseURL in the production app artifact. This includes the `Server` field in the login dialog, which is usually hidden and pre-configured in production. | -| **pwa** | _object_ | | **ADVANCED** Opts into and configures PWA settings for this app. Read more about the options in [the PWA docs](../pwa). | +| Property | Type | Default | Description | +| :--------------------: | :---------------------------: | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **type** | _string_ | **app** | Either **app** or **lib** | +| **name** | _string_ | `pkg.name` | A short, machine-readable unique name for this app | +| **title** | _string_ | `config.name` | The human-readable application title, which will appear in the HeaderBar | +| **direction** | `'ltr'`, `'rtl'`, or `'auto'` | `'ltr'` | Sets the `dir` HTML attribute on the `document` of the app. If set to `'auto'`, the direction will be inferred from the current user's UI locale setting. The header bar will always be considered 'auto' and is unaffected by this setting. | +| **id** | _string_ | | The ID of the app on the [App Hub](https://apps.dhis2.org/). Used when publishing the app to the App Hub with [d2 app scripts publish](../scripts/publish). See [this guide](https://developers.dhis2.org/docs/guides/publish-apphub/) to learn how to set up continuous delivery. | +| **description** | _string_ | `pkg.description` | A full-length description of the application | +| **author** | _string_ or _object_ | `pkg.author` | The name of the developer to include in the DHIS2 manifest, following [package.json author field syntax](https://docs.npmjs.com/cli/v8/configuring-npm/package-json#people-fields-author-contributors). | +| **entryPoints.app** | _string_ | **./src/App** | The path to the application entrypoint (not used for libraries) | +| **entryPoints.plugin** | _string_ | | The path to the application's plugin entrypoint (not used for libraries) | +| **entryPoints.lib** | _string_ or _object_ | **./src/index** | The path to the library entrypoint(s) (not used for applications). Supports [conditional exports](https://nodejs.org/dist/latest-v16.x/docs/api/packages.html#packages_conditional_exports) | +| **dataStoreNamespace** | _string_ | | The DataStore and UserDataStore namespace to reserve for this application. The reserved namespace **must** be suitably unique, as other apps will fail to install if they attempt to reserve the same namespace - see the [webapp manifest docs](https://docs.dhis2.org/en/develop/loading-apps.html) | +| **customAuthorities** | _Array(string)_ | | An array of custom authorities to create when installing the app, these do not provide security protections in the DHIS2 REST API but can be assigned to user roles and used to modify the interface displayed to a user - see the [webapp manifest docs](https://docs.dhis2.org/en/develop/loading-apps.html) | +| **minDHIS2Version** | _string_ | | The minimum DHIS2 version the App supports (eg. '2.35'). Required when uploading an app to the App Hub. The app's major version in the app's package.json needs to be increased when changing this property. | +| **maxDHIS2Version** | _string_ | | The maximum DHIS2 version the App supports. | +| **coreApp** | _boolean_ | **false** | **ADVANCED** If true, build an app artifact to be included as a root-level core application | +| **standalone** | _boolean_ | **false** | **ADVANCED** If true, do NOT include a static BaseURL in the production app artifact. This includes the `Server` field in the login dialog, which is usually hidden and pre-configured in production. | +| **pwa** | _object_ | | **ADVANCED** Opts into and configures PWA settings for this app. Read more about the options in [the PWA docs](../pwa). | > _Note_: Dynamic defaults above may reference `pkg` (a property of the local `package.json` file) or `config` (another property within `d2.config.js`). diff --git a/examples/simple-app/d2.config.js b/examples/simple-app/d2.config.js index d51c39de3..715669fa0 100644 --- a/examples/simple-app/d2.config.js +++ b/examples/simple-app/d2.config.js @@ -3,6 +3,7 @@ const config = { name: 'simple-app', title: 'Simple Example App', description: 'This is a simple example application', + direction: 'auto', // standalone: true, // Don't bake-in a DHIS2 base URL, allow the user to choose diff --git a/shell/src/App.js b/shell/src/App.js index 0b8d0a8f5..9e8106ba4 100644 --- a/shell/src/App.js +++ b/shell/src/App.js @@ -28,6 +28,7 @@ const appConfig = { apiVersion: parseInt(process.env.REACT_APP_DHIS2_API_VERSION), pwaEnabled: process.env.REACT_APP_DHIS2_APP_PWA_ENABLED === 'true', plugin: isPlugin, + direction: process.env.REACT_APP_DHIS2_APP_DIRECTION, } const pluginConfig = { From b05fa8f8be95899e26a96a30a5f51330b44eb58b Mon Sep 17 00:00:00 2001 From: Kai Vandivier Date: Fri, 19 Jan 2024 15:13:08 +0100 Subject: [PATCH 03/17] refactor: split up handleLocale --- adapter/src/utils/useLocale.js | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/adapter/src/utils/useLocale.js b/adapter/src/utils/useLocale.js index a980d93e3..68b25ab1e 100644 --- a/adapter/src/utils/useLocale.js +++ b/adapter/src/utils/useLocale.js @@ -6,10 +6,6 @@ import { useState, useEffect } from 'react' const I18N_NAMESPACE = 'default' i18n.setDefaultNamespace(I18N_NAMESPACE) -const transformJavaLocale = (locale) => { - return locale.replace('_', '-') -} - // if translation resources aren't found for the given locale, try shorter // versions of the locale // e.g. 'pt_BR_Cyrl_asdf' => 'pt_BR', or 'ar-NotFound' => 'ar' @@ -58,20 +54,19 @@ const setGlobalLocale = (locale) => { console.log('🗺 Global d2-i18n locale initialized:', resolvedLocale) } -// Sets the global direction based on the app's configured direction -// (which should be done to affect modals, alerts, and other portal elements), -// then returns the locale's direction for use on the header bar -const handleDirection = ({ locale, configDirection }) => { +const getLocaleDirection = (locale) => { // for i18n.dir, need JS-formatted locale - const jsLocale = transformJavaLocale(locale) - const localeDirection = i18n.dir(jsLocale) + const jsLocale = locale.replace('_', '-') + return i18n.dir(jsLocale) +} +// Sets the global direction based on the app's configured direction +// (which should be done to affect modals, alerts, and other portal elements). +// Note that the header bar will use the localeDirection regardless +const setGlobalDirection = ({ localeDirection, configDirection }) => { const globalDirection = configDirection === 'auto' ? localeDirection : configDirection - // set `dir` globally (then override in app wrapper if needed) document.documentElement.setAttribute('dir', globalDirection) - - return localeDirection } export const useLocale = ({ locale, configDirection }) => { @@ -82,9 +77,12 @@ export const useLocale = ({ locale, configDirection }) => { return } - const direction = handleDirection({ locale, configDirection }) setGlobalLocale(locale) - setResult({ locale, direction }) + + const localeDirection = getLocaleDirection(locale) + setGlobalDirection({ localeDirection, configDirection }) + + setResult({ locale, direction: localeDirection }) }, [locale, configDirection]) return result From 9f2f8fa9eecd3adf93773efc3b071a22af0768b3 Mon Sep 17 00:00:00 2001 From: Kai Vandivier Date: Fri, 19 Jan 2024 17:03:39 +0100 Subject: [PATCH 04/17] refactor: rename function; add dir default to adapter --- adapter/src/utils/useLocale.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/adapter/src/utils/useLocale.js b/adapter/src/utils/useLocale.js index 68b25ab1e..6a0220246 100644 --- a/adapter/src/utils/useLocale.js +++ b/adapter/src/utils/useLocale.js @@ -9,13 +9,12 @@ i18n.setDefaultNamespace(I18N_NAMESPACE) // if translation resources aren't found for the given locale, try shorter // versions of the locale // e.g. 'pt_BR_Cyrl_asdf' => 'pt_BR', or 'ar-NotFound' => 'ar' -const validateLocaleByBundle = (locale) => { +const getResolvedLocale = (locale) => { if (i18n.hasResourceBundle(locale, I18N_NAMESPACE)) { return locale } console.log(`Translations for locale ${locale} not found`) - // see if we can try basic versions of the locale // (e.g. 'ar' instead of 'ar_IQ') const match = /[_-]/.exec(locale) @@ -48,7 +47,7 @@ const setGlobalLocale = (locale) => { } moment.locale(locale) - const resolvedLocale = validateLocaleByBundle(locale) + const resolvedLocale = getResolvedLocale(locale) i18n.changeLanguage(resolvedLocale) console.log('🗺 Global d2-i18n locale initialized:', resolvedLocale) @@ -62,10 +61,11 @@ const getLocaleDirection = (locale) => { // Sets the global direction based on the app's configured direction // (which should be done to affect modals, alerts, and other portal elements). +// Defaults to 'ltr' if not set. // Note that the header bar will use the localeDirection regardless const setGlobalDirection = ({ localeDirection, configDirection }) => { const globalDirection = - configDirection === 'auto' ? localeDirection : configDirection + configDirection === 'auto' ? localeDirection : configDirection || 'ltr' document.documentElement.setAttribute('dir', globalDirection) } From 19cb6d746297183cc6e8cb2072be984ab2568ae0 Mon Sep 17 00:00:00 2001 From: Kai Vandivier Date: Mon, 22 Jan 2024 14:15:46 +0100 Subject: [PATCH 05/17] fix: remove direction from d2.config defaults in CLI --- cli/config/d2.config.app.js | 6 ------ cli/src/lib/shell/index.js | 5 ++++- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/cli/config/d2.config.app.js b/cli/config/d2.config.app.js index 1c9d77d64..fb32d2704 100644 --- a/cli/config/d2.config.app.js +++ b/cli/config/d2.config.app.js @@ -1,12 +1,6 @@ const config = { type: 'app', - // sets the `dir` HTML attribute for the app: - // options are 'ltr', 'rtl', and 'auto'. If set to 'auto', the direction - // will be inferred by the user's UI locale. - // The header bar direction will always be set by the locale. - direction: 'ltr', - entryPoints: { app: './src/App.js', }, diff --git a/cli/src/lib/shell/index.js b/cli/src/lib/shell/index.js index 9502ea2ac..c7edc4937 100644 --- a/cli/src/lib/shell/index.js +++ b/cli/src/lib/shell/index.js @@ -7,7 +7,10 @@ module.exports = ({ config, paths }) => { const baseEnvVars = { name: config.title, version: config.version, - direction: config.direction, + } + + if (config.direction) { + baseEnvVars.direction = config.direction } return { From c5c06a32d0ef62a9ae24f04b6eea68fcc7c6b1f0 Mon Sep 17 00:00:00 2001 From: Kai Vandivier Date: Tue, 23 Jan 2024 15:46:37 +0100 Subject: [PATCH 06/17] refactor: parse locale to Intl.Locale object --- adapter/src/utils/useLocale.js | 76 ++++++++++++++++++++++++++-------- 1 file changed, 58 insertions(+), 18 deletions(-) diff --git a/adapter/src/utils/useLocale.js b/adapter/src/utils/useLocale.js index 6a0220246..efd99dc44 100644 --- a/adapter/src/utils/useLocale.js +++ b/adapter/src/utils/useLocale.js @@ -38,27 +38,25 @@ const getResolvedLocale = (locale) => { // Set locale for Moment and i18n const setGlobalLocale = (locale) => { - if (locale !== 'en' && locale !== 'en-us') { + const localeString = locale.baseName + console.log({ locale, localeString }) + + // todo: try/catch here to try different arrangements + if (locale.language !== 'en' && locale.region !== 'US') { import( - /* webpackChunkName: "moment-locales/[request]" */ `moment/locale/${locale}` + /* webpackChunkName: "moment-locales/[request]" */ `moment/locale/${localeString}` ).catch(() => { /* ignore */ }) } - moment.locale(locale) + moment.locale(localeString) - const resolvedLocale = getResolvedLocale(locale) + const resolvedLocale = getResolvedLocale(localeString) i18n.changeLanguage(resolvedLocale) console.log('🗺 Global d2-i18n locale initialized:', resolvedLocale) } -const getLocaleDirection = (locale) => { - // for i18n.dir, need JS-formatted locale - const jsLocale = locale.replace('_', '-') - return i18n.dir(jsLocale) -} - // Sets the global direction based on the app's configured direction // (which should be done to affect modals, alerts, and other portal elements). // Defaults to 'ltr' if not set. @@ -69,21 +67,65 @@ const setGlobalDirection = ({ localeDirection, configDirection }) => { document.documentElement.setAttribute('dir', globalDirection) } -export const useLocale = ({ locale, configDirection }) => { - const [result, setResult] = useState({}) +/** + * userSettings.keyUiLocale is expected to be formatted by Java's + * Locale.toString(): + * https://docs.oracle.com/javase/8/docs/api/java/util/Locale.html#toString-- + * We can assume there are no Variants or Extensions to locales used by DHIS2 + */ +const parseJavaLocale = (locale) => { + const [language, region, script] = locale.split('_') + + let languageTag = language + if (script) { + languageTag += `-${script}` + } + if (region) { + languageTag += `-${region}` + } + + console.log({ locale, language, script, region, languageTag }) + return new Intl.Locale(languageTag) +} + +/** Returns a JS Intl.Locale object */ +const parseLocale = (userSettings) => { + // new property + if (userSettings.keyUiLanguageTag) { + return new Intl.Locale(userSettings.keyUiLanguageTag) + } + // legacy property + if (userSettings.keyUiLocale) { + return parseJavaLocale(userSettings.keyUiLocale) + } + + // worst-case fallback + return new Intl.Locale(window.navigator.language) +} + +export const useLocale = ({ userSettings, configDirection }) => { + const [result, setResult] = useState({ + locale: undefined, + direction: undefined, + }) useEffect(() => { - if (!locale) { + if (!userSettings) { return } + const locale = parseLocale(userSettings) + setGlobalLocale(locale) + // setI18nLocale(locale) + // setMomentLocale(locale) - const localeDirection = getLocaleDirection(locale) + // Intl.Locale dir utils aren't supported in firefox, so use i18n + const localeDirection = i18n.dir(locale.language) setGlobalDirection({ localeDirection, configDirection }) setResult({ locale, direction: localeDirection }) - }, [locale, configDirection]) + }, [userSettings, configDirection]) return result } @@ -98,9 +140,7 @@ const settingsQuery = { export const useCurrentUserLocale = (configDirection) => { const { loading, error, data } = useDataQuery(settingsQuery) const { locale, direction } = useLocale({ - locale: - data && - (data.userSettings.keyUiLocale || window.navigator.language), + userSettings: data && data.userSettings, configDirection, }) From 7d25dd2e290f7d9407de3e409bf622627b9e8aa7 Mon Sep 17 00:00:00 2001 From: Kai Vandivier Date: Tue, 23 Jan 2024 16:02:01 +0100 Subject: [PATCH 07/17] refactor: better i18nLocale logic --- adapter/src/utils/useLocale.js | 84 ++++++++++++++++++++-------------- 1 file changed, 50 insertions(+), 34 deletions(-) diff --git a/adapter/src/utils/useLocale.js b/adapter/src/utils/useLocale.js index efd99dc44..324310d70 100644 --- a/adapter/src/utils/useLocale.js +++ b/adapter/src/utils/useLocale.js @@ -6,38 +6,61 @@ import { useState, useEffect } from 'react' const I18N_NAMESPACE = 'default' i18n.setDefaultNamespace(I18N_NAMESPACE) -// if translation resources aren't found for the given locale, try shorter -// versions of the locale -// e.g. 'pt_BR_Cyrl_asdf' => 'pt_BR', or 'ar-NotFound' => 'ar' -const getResolvedLocale = (locale) => { - if (i18n.hasResourceBundle(locale, I18N_NAMESPACE)) { - return locale +// Test locales for available translation files -- if they're not found, +// try less-specific versions. +// Both "Java Locale.toString()" and BCP 47 language tag formats are tested +const setI18nLocale = (locale) => { + const { language, script, region } = locale + + const localeStringOptions = [] + if (script && region) { + localeStringOptions.push( + `${language}_${region}_${script}`, + `${language}-${script}-${region}` // NB: different order + ) } - - console.log(`Translations for locale ${locale} not found`) - // see if we can try basic versions of the locale - // (e.g. 'ar' instead of 'ar_IQ') - const match = /[_-]/.exec(locale) - if (!match) { - return locale + if (region) { + localeStringOptions.push( + `${language}_${region}`, + `${language}-${region}` + ) } - - const separator = match[0] // '-' or '_' - const splitLocale = locale.split(separator) - for (let i = splitLocale.length - 1; i > 0; i--) { - const shorterLocale = splitLocale.slice(0, i).join(separator) - if (i18n.hasResourceBundle(shorterLocale, I18N_NAMESPACE)) { - return shorterLocale + if (script) { + localeStringOptions.push( + `${language}_${script}`, + `${language}-${script}` + ) + } + localeStringOptions.push(language) + + let localeStringWithTranslations + const unsuccessfulLocaleStrings = [] + for (const localeString of localeStringOptions) { + if (i18n.hasResourceBundle(localeString, I18N_NAMESPACE)) { + localeStringWithTranslations = localeString + break } - console.log(`Translations for locale ${shorterLocale} not found`) + unsuccessfulLocaleStrings.push(localeString) + // even though the localeString === language will be the default below, + // it still tested here to provide feedback if translation files + // are not found } - // if nothing else works, use the initially provided locale - return locale + if (unsuccessfulLocaleStrings.length > 0) { + console.log( + `Translations for locale(s) ${unsuccessfulLocaleStrings.join( + ', ' + )} not found` + ) + } + + // if no translation files are found, still try to fall back to `language` + const finalLocaleString = localeStringWithTranslations || language + i18n.changeLanguage(finalLocaleString) + console.log('🗺 Global d2-i18n locale initialized:', finalLocaleString) } -// Set locale for Moment and i18n -const setGlobalLocale = (locale) => { +const setMomentLocale = (locale) => { const localeString = locale.baseName console.log({ locale, localeString }) @@ -50,11 +73,6 @@ const setGlobalLocale = (locale) => { }) } moment.locale(localeString) - - const resolvedLocale = getResolvedLocale(localeString) - i18n.changeLanguage(resolvedLocale) - - console.log('🗺 Global d2-i18n locale initialized:', resolvedLocale) } // Sets the global direction based on the app's configured direction @@ -84,7 +102,6 @@ const parseJavaLocale = (locale) => { languageTag += `-${region}` } - console.log({ locale, language, script, region, languageTag }) return new Intl.Locale(languageTag) } @@ -116,9 +133,8 @@ export const useLocale = ({ userSettings, configDirection }) => { const locale = parseLocale(userSettings) - setGlobalLocale(locale) - // setI18nLocale(locale) - // setMomentLocale(locale) + setI18nLocale(locale) + setMomentLocale(locale) // Intl.Locale dir utils aren't supported in firefox, so use i18n const localeDirection = i18n.dir(locale.language) From cbc0d1a6c800a358771068dd74a219f3fa619993 Mon Sep 17 00:00:00 2001 From: Kai Vandivier Date: Tue, 23 Jan 2024 16:30:24 +0100 Subject: [PATCH 08/17] fix: moment locale formatting & add fallbacks --- adapter/src/utils/useLocale.js | 42 ++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/adapter/src/utils/useLocale.js b/adapter/src/utils/useLocale.js index 324310d70..6aa90cc8c 100644 --- a/adapter/src/utils/useLocale.js +++ b/adapter/src/utils/useLocale.js @@ -60,19 +60,37 @@ const setI18nLocale = (locale) => { console.log('🗺 Global d2-i18n locale initialized:', finalLocaleString) } -const setMomentLocale = (locale) => { - const localeString = locale.baseName - console.log({ locale, localeString }) - - // todo: try/catch here to try different arrangements - if (locale.language !== 'en' && locale.region !== 'US') { - import( - /* webpackChunkName: "moment-locales/[request]" */ `moment/locale/${localeString}` - ).catch(() => { - /* ignore */ - }) +// Moment locales use a hyphenated, lowercase format. +// Since not all locales are included in Moment, this +// function tries permutations of the locale to find one that's supported. +// NB: None of them use both a region AND a script. +const setMomentLocale = async (locale) => { + const { language, region, script } = locale + + if (locale.language === 'en' && locale.region === 'US') { + return // this is Moment's default locale + } + + const localeNameOptions = [] + if (script) { + localeNameOptions.push(`${language}-${script}`.toLowerCase()) + } + if (region) { + localeNameOptions.push(`${language}-${region}`.toLowerCase()) + } + localeNameOptions.push(language) + + for (const localeName of localeNameOptions) { + try { + await import( + /* webpackChunkName: "moment-locales/[request]" */ `moment/locale/${localeName}` + ) + moment.locale(localeName) + break + } catch { + continue + } } - moment.locale(localeString) } // Sets the global direction based on the app's configured direction From fa0e182bf2f8b9b672d1504dfdcdd53ef1dda180 Mon Sep 17 00:00:00 2001 From: Kai Vandivier Date: Tue, 23 Jan 2024 16:31:00 +0100 Subject: [PATCH 09/17] refactor: fn rename --- adapter/src/utils/useLocale.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/adapter/src/utils/useLocale.js b/adapter/src/utils/useLocale.js index 6aa90cc8c..478cd3bf9 100644 --- a/adapter/src/utils/useLocale.js +++ b/adapter/src/utils/useLocale.js @@ -97,7 +97,7 @@ const setMomentLocale = async (locale) => { // (which should be done to affect modals, alerts, and other portal elements). // Defaults to 'ltr' if not set. // Note that the header bar will use the localeDirection regardless -const setGlobalDirection = ({ localeDirection, configDirection }) => { +const setDocumentDirection = ({ localeDirection, configDirection }) => { const globalDirection = configDirection === 'auto' ? localeDirection : configDirection || 'ltr' document.documentElement.setAttribute('dir', globalDirection) @@ -125,7 +125,7 @@ const parseJavaLocale = (locale) => { /** Returns a JS Intl.Locale object */ const parseLocale = (userSettings) => { - // new property + // proposed property if (userSettings.keyUiLanguageTag) { return new Intl.Locale(userSettings.keyUiLanguageTag) } @@ -156,7 +156,7 @@ export const useLocale = ({ userSettings, configDirection }) => { // Intl.Locale dir utils aren't supported in firefox, so use i18n const localeDirection = i18n.dir(locale.language) - setGlobalDirection({ localeDirection, configDirection }) + setDocumentDirection({ localeDirection, configDirection }) setResult({ locale, direction: localeDirection }) }, [userSettings, configDirection]) From d2faf2a2e91a48955a822c686668c0661dba62c5 Mon Sep 17 00:00:00 2001 From: Kai Vandivier Date: Wed, 24 Jan 2024 14:03:16 +0100 Subject: [PATCH 10/17] refactor: move locale utils to a new file --- adapter/src/utils/localeUtils.js | 150 +++++++++++++++++++++++++++++++ adapter/src/utils/useLocale.js | 142 ++--------------------------- 2 files changed, 156 insertions(+), 136 deletions(-) create mode 100644 adapter/src/utils/localeUtils.js diff --git a/adapter/src/utils/localeUtils.js b/adapter/src/utils/localeUtils.js new file mode 100644 index 000000000..c3093ad55 --- /dev/null +++ b/adapter/src/utils/localeUtils.js @@ -0,0 +1,150 @@ +import i18n from '@dhis2/d2-i18n' +import moment from 'moment' + +// Init i18n namespace +const I18N_NAMESPACE = 'default' +i18n.setDefaultNamespace(I18N_NAMESPACE) + +/** + * userSettings.keyUiLocale is expected to be formatted by Java's + * Locale.toString(): + * https://docs.oracle.com/javase/8/docs/api/java/util/Locale.html#toString-- + * We can assume there are no Variants or Extensions to locales used by DHIS2 + * @param {Intl.Locale} locale + */ +const parseJavaLocale = (locale) => { + const [language, region, script] = locale.split('_') + + let languageTag = language + if (script) { + languageTag += `-${script}` + } + if (region) { + languageTag += `-${region}` + } + + return new Intl.Locale(languageTag) +} + +/** + * @param {UserSettings} userSettings + * @returns Intl.Locale + */ +export const parseLocale = (userSettings) => { + // proposed property + if (userSettings.keyUiLanguageTag) { + return new Intl.Locale(userSettings.keyUiLanguageTag) + } + // legacy property + if (userSettings.keyUiLocale) { + return parseJavaLocale(userSettings.keyUiLocale) + } + + // worst-case fallback + return new Intl.Locale(window.navigator.language) +} + +/** + * Test locales for available translation files -- if they're not found, + * try less-specific versions. + * Both "Java Locale.toString()" and BCP 47 language tag formats are tested + * @param {Intl.Locale} locale + */ +export const setI18nLocale = (locale) => { + const { language, script, region } = locale + + const localeStringOptions = [] + if (script && region) { + localeStringOptions.push( + `${language}_${region}_${script}`, + `${language}-${script}-${region}` // NB: different order + ) + } + if (region) { + localeStringOptions.push( + `${language}_${region}`, + `${language}-${region}` + ) + } + if (script) { + localeStringOptions.push( + `${language}_${script}`, + `${language}-${script}` + ) + } + localeStringOptions.push(language) + + let localeStringWithTranslations + const unsuccessfulLocaleStrings = [] + for (const localeString of localeStringOptions) { + if (i18n.hasResourceBundle(localeString, I18N_NAMESPACE)) { + localeStringWithTranslations = localeString + break + } + unsuccessfulLocaleStrings.push(localeString) + // even though the localeString === language will be the default below, + // it still tested here to provide feedback if translation files + // are not found + } + + if (unsuccessfulLocaleStrings.length > 0) { + console.log( + `Translations for locale(s) ${unsuccessfulLocaleStrings.join( + ', ' + )} not found` + ) + } + + // if no translation files are found, still try to fall back to `language` + const finalLocaleString = localeStringWithTranslations || language + i18n.changeLanguage(finalLocaleString) + console.log('🗺 Global d2-i18n locale initialized:', finalLocaleString) +} + +/** + * Moment locales use a hyphenated, lowercase format. + * Since not all locales are included in Moment, this + * function tries permutations of the locale to find one that's supported. + * NB: None of them use both a region AND a script. + * @param {Intl.Locale} locale + */ +export const setMomentLocale = async (locale) => { + const { language, region, script } = locale + + if (locale.language === 'en' && locale.region === 'US') { + return // this is Moment's default locale + } + + const localeNameOptions = [] + if (script) { + localeNameOptions.push(`${language}-${script}`.toLowerCase()) + } + if (region) { + localeNameOptions.push(`${language}-${region}`.toLowerCase()) + } + localeNameOptions.push(language) + + for (const localeName of localeNameOptions) { + try { + await import( + /* webpackChunkName: "moment-locales/[request]" */ `moment/locale/${localeName}` + ) + moment.locale(localeName) + break + } catch { + continue + } + } +} + +/** + * Sets the global direction based on the app's configured direction + * (which should be done to affect modals, alerts, and other portal elements). + * Defaults to 'ltr' if not set. + * Note that the header bar will use the localeDirection regardless + */ +export const setDocumentDirection = ({ localeDirection, configDirection }) => { + const globalDirection = + configDirection === 'auto' ? localeDirection : configDirection || 'ltr' + document.documentElement.setAttribute('dir', globalDirection) +} diff --git a/adapter/src/utils/useLocale.js b/adapter/src/utils/useLocale.js index 478cd3bf9..ce7cc09db 100644 --- a/adapter/src/utils/useLocale.js +++ b/adapter/src/utils/useLocale.js @@ -1,142 +1,12 @@ import { useDataQuery } from '@dhis2/app-runtime' import i18n from '@dhis2/d2-i18n' -import moment from 'moment' import { useState, useEffect } from 'react' - -const I18N_NAMESPACE = 'default' -i18n.setDefaultNamespace(I18N_NAMESPACE) - -// Test locales for available translation files -- if they're not found, -// try less-specific versions. -// Both "Java Locale.toString()" and BCP 47 language tag formats are tested -const setI18nLocale = (locale) => { - const { language, script, region } = locale - - const localeStringOptions = [] - if (script && region) { - localeStringOptions.push( - `${language}_${region}_${script}`, - `${language}-${script}-${region}` // NB: different order - ) - } - if (region) { - localeStringOptions.push( - `${language}_${region}`, - `${language}-${region}` - ) - } - if (script) { - localeStringOptions.push( - `${language}_${script}`, - `${language}-${script}` - ) - } - localeStringOptions.push(language) - - let localeStringWithTranslations - const unsuccessfulLocaleStrings = [] - for (const localeString of localeStringOptions) { - if (i18n.hasResourceBundle(localeString, I18N_NAMESPACE)) { - localeStringWithTranslations = localeString - break - } - unsuccessfulLocaleStrings.push(localeString) - // even though the localeString === language will be the default below, - // it still tested here to provide feedback if translation files - // are not found - } - - if (unsuccessfulLocaleStrings.length > 0) { - console.log( - `Translations for locale(s) ${unsuccessfulLocaleStrings.join( - ', ' - )} not found` - ) - } - - // if no translation files are found, still try to fall back to `language` - const finalLocaleString = localeStringWithTranslations || language - i18n.changeLanguage(finalLocaleString) - console.log('🗺 Global d2-i18n locale initialized:', finalLocaleString) -} - -// Moment locales use a hyphenated, lowercase format. -// Since not all locales are included in Moment, this -// function tries permutations of the locale to find one that's supported. -// NB: None of them use both a region AND a script. -const setMomentLocale = async (locale) => { - const { language, region, script } = locale - - if (locale.language === 'en' && locale.region === 'US') { - return // this is Moment's default locale - } - - const localeNameOptions = [] - if (script) { - localeNameOptions.push(`${language}-${script}`.toLowerCase()) - } - if (region) { - localeNameOptions.push(`${language}-${region}`.toLowerCase()) - } - localeNameOptions.push(language) - - for (const localeName of localeNameOptions) { - try { - await import( - /* webpackChunkName: "moment-locales/[request]" */ `moment/locale/${localeName}` - ) - moment.locale(localeName) - break - } catch { - continue - } - } -} - -// Sets the global direction based on the app's configured direction -// (which should be done to affect modals, alerts, and other portal elements). -// Defaults to 'ltr' if not set. -// Note that the header bar will use the localeDirection regardless -const setDocumentDirection = ({ localeDirection, configDirection }) => { - const globalDirection = - configDirection === 'auto' ? localeDirection : configDirection || 'ltr' - document.documentElement.setAttribute('dir', globalDirection) -} - -/** - * userSettings.keyUiLocale is expected to be formatted by Java's - * Locale.toString(): - * https://docs.oracle.com/javase/8/docs/api/java/util/Locale.html#toString-- - * We can assume there are no Variants or Extensions to locales used by DHIS2 - */ -const parseJavaLocale = (locale) => { - const [language, region, script] = locale.split('_') - - let languageTag = language - if (script) { - languageTag += `-${script}` - } - if (region) { - languageTag += `-${region}` - } - - return new Intl.Locale(languageTag) -} - -/** Returns a JS Intl.Locale object */ -const parseLocale = (userSettings) => { - // proposed property - if (userSettings.keyUiLanguageTag) { - return new Intl.Locale(userSettings.keyUiLanguageTag) - } - // legacy property - if (userSettings.keyUiLocale) { - return parseJavaLocale(userSettings.keyUiLocale) - } - - // worst-case fallback - return new Intl.Locale(window.navigator.language) -} +import { + setI18nLocale, + parseLocale, + setDocumentDirection, + setMomentLocale, +} from './localeUtils.js' export const useLocale = ({ userSettings, configDirection }) => { const [result, setResult] = useState({ From 805e3a6500e0b1b386711aa3092cdc865739e245 Mon Sep 17 00:00:00 2001 From: Kai Vandivier Date: Wed, 24 Jan 2024 16:05:59 +0100 Subject: [PATCH 11/17] test: set up test file for useLocale.test --- adapter/package.json | 1 + adapter/src/utils/useLocale.test.js | 68 +++++++++++++++++++++++++++++ yarn.lock | 15 +++++++ 3 files changed, 84 insertions(+) create mode 100644 adapter/src/utils/useLocale.test.js diff --git a/adapter/package.json b/adapter/package.json index 64310ac55..7835ede36 100644 --- a/adapter/package.json +++ b/adapter/package.json @@ -27,6 +27,7 @@ "devDependencies": { "@dhis2/cli-app-scripts": "10.4.0", "@testing-library/react": "^12.0.0", + "@testing-library/react-hooks": "^8.0.1", "enzyme": "^3.11.0", "enzyme-adapter-react-16": "^1.15.5", "react": "^16.8", diff --git a/adapter/src/utils/useLocale.test.js b/adapter/src/utils/useLocale.test.js new file mode 100644 index 000000000..328826e08 --- /dev/null +++ b/adapter/src/utils/useLocale.test.js @@ -0,0 +1,68 @@ +import { renderHook /*, act */ } from '@testing-library/react-hooks' +import { useLocale } from './useLocale.js' + +jest.mock('@dhis2/d2-i18n', () => ({ + setDefaultNamespace: (namespace) => console.log({ namespace }), + hasResourceBundle: (bundleName) => { + console.log({ bundleName }) + return true // todo + }, + dir: (localeString) => { + console.log({ localeString }) + // rough approximation of function + return localeString.startsWith('ar') ? 'rtl' : 'ltr' + }, + changeLanguage: (langArg) => console.log({ langArg }), +})) + +jest.mock('moment', () => ({ + locale: (momentLocale) => console.log({ momentLocale }), +})) + +const defaultUserSettings = { keyUiLocale: 'en' } + +test('it renders', async () => { + const { result } = renderHook(() => + useLocale({ + userSettings: defaultUserSettings, + configDirection: undefined, + }) + ) + + console.log('current result', result.current) + + expect(result.current.locale.baseName).toBe('en') + expect(result.current.direction).toBe('ltr') +}) + +// TODO Tests +// Test undefined userSettings - todo'd +// Test undefined userSettings.keyUiLocale - todo'd +// Test undefined locale +// Test nonsense locale -- todo'd +// Test Java locale +// Test BCP 47 locale on keyUiLanguageTag +// Test ar_EG locale (before: had no translations) +// Test pt_BR +// Make sure directions are correct +// Make sure moment locale is correct +// Make sure i18n locale either has translations or is reasonable +// ^ (should it be 'en'? wondering about maintanance_tl_keys) + +// TODO Mocks +// i18n.dir +// i18n.hasResourceBundle(name, namespace) +// moment.locale() +// import ('moment/locales/${localeString}) + +describe('basic edge case handling', () => { + test('it handles undefined userSettings', () => { + // todo + }) + + test.todo('it handles undefined userSettings.keyUiLocale') + + test.todo('it handles nonsense locales') +}) + +// describe('language test cases') diff --git a/yarn.lock b/yarn.lock index 11f84038e..88b0793cf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2764,6 +2764,14 @@ lz-string "^1.4.4" pretty-format "^27.0.2" +"@testing-library/react-hooks@^8.0.1": + version "8.0.1" + resolved "https://registry.yarnpkg.com/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz#0924bbd5b55e0c0c0502d1754657ada66947ca12" + integrity sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g== + dependencies: + "@babel/runtime" "^7.12.5" + react-error-boundary "^3.1.0" + "@testing-library/react@^12.0.0", "@testing-library/react@^12.1.2": version "12.1.5" resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-12.1.5.tgz#bb248f72f02a5ac9d949dea07279095fa577963b" @@ -11479,6 +11487,13 @@ react-dom@^16.8, react-dom@^16.8.6: prop-types "^15.6.2" scheduler "^0.19.1" +react-error-boundary@^3.1.0: + version "3.1.4" + resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.4.tgz#255db92b23197108757a888b01e5b729919abde0" + integrity sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA== + dependencies: + "@babel/runtime" "^7.12.5" + react-error-overlay@^6.0.11: version "6.0.11" resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.11.tgz#92835de5841c5cf08ba00ddd2d677b6d17ff9adb" From 3f6220a3f8e400448536384537929e81bdc6d01a Mon Sep 17 00:00:00 2001 From: Kai Vandivier Date: Wed, 24 Jan 2024 17:46:01 +0100 Subject: [PATCH 12/17] fix: skip logic for en and en-US --- adapter/src/utils/localeUtils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/adapter/src/utils/localeUtils.js b/adapter/src/utils/localeUtils.js index c3093ad55..eec206ead 100644 --- a/adapter/src/utils/localeUtils.js +++ b/adapter/src/utils/localeUtils.js @@ -111,7 +111,7 @@ export const setI18nLocale = (locale) => { export const setMomentLocale = async (locale) => { const { language, region, script } = locale - if (locale.language === 'en' && locale.region === 'US') { + if (locale.language === 'en' || locale.baseName === 'en-US') { return // this is Moment's default locale } From d4bd3e4601a707742c835335011bb514cc5a2c9e Mon Sep 17 00:00:00 2001 From: Kai Vandivier Date: Wed, 24 Jan 2024 17:57:03 +0100 Subject: [PATCH 13/17] test: add first useLocale tests --- adapter/src/utils/useLocale.test.js | 180 +++++++++++++++++++++------- 1 file changed, 134 insertions(+), 46 deletions(-) diff --git a/adapter/src/utils/useLocale.test.js b/adapter/src/utils/useLocale.test.js index 328826e08..5fd1cb5ae 100644 --- a/adapter/src/utils/useLocale.test.js +++ b/adapter/src/utils/useLocale.test.js @@ -1,68 +1,156 @@ -import { renderHook /*, act */ } from '@testing-library/react-hooks' +import i18n from '@dhis2/d2-i18n' +import { renderHook, act } from '@testing-library/react-hooks' +import moment from 'moment' import { useLocale } from './useLocale.js' -jest.mock('@dhis2/d2-i18n', () => ({ - setDefaultNamespace: (namespace) => console.log({ namespace }), - hasResourceBundle: (bundleName) => { - console.log({ bundleName }) - return true // todo - }, - dir: (localeString) => { - console.log({ localeString }) - // rough approximation of function - return localeString.startsWith('ar') ? 'rtl' : 'ltr' - }, - changeLanguage: (langArg) => console.log({ langArg }), -})) +// TODO +// Test undefined locale +// Test nonsense locale +// Test BCP47 locale on keyUiLanguageTag +// Make sure i18n locale either has translations or is reasonable +// ^ (should it be 'en'? wondering about maintanance_tl_keys) + +jest.mock('@dhis2/d2-i18n', () => { + return { + setDefaultNamespace: jest.fn(), + // These cases match translation files we have + hasResourceBundle: jest.fn((localeString) => { + switch (localeString) { + case 'uz_UZ_Cyrl': + case 'uz_UZ_Latn': + case 'pt_BR': + case 'ar': + case 'en': + return true + default: + return false + } + }), + changeLanguage: jest.fn(), + // rough approximation of behavior for locales used in this file: + dir: jest.fn((localeString) => + localeString.startsWith('ar') ? 'rtl' : 'ltr' + ), + } +}) jest.mock('moment', () => ({ - locale: (momentLocale) => console.log({ momentLocale }), + locale: jest.fn(), + defineLocale: jest.fn(), })) -const defaultUserSettings = { keyUiLocale: 'en' } +afterEach(() => { + jest.clearAllMocks() +}) -test('it renders', async () => { - const { result } = renderHook(() => - useLocale({ +test('happy path initial load with en language', () => { + const defaultUserSettings = { keyUiLocale: 'en' } + const { result, rerender } = renderHook((newProps) => useLocale(newProps), { + initialProps: defaultUserSettings, + }) + + expect(result.current.locale).toBe(undefined) + expect(result.current.direction).toBe(undefined) + + act(() => { + rerender({ userSettings: defaultUserSettings, configDirection: undefined, }) - ) - - console.log('current result', result.current) + }) expect(result.current.locale.baseName).toBe('en') expect(result.current.direction).toBe('ltr') + expect(i18n.changeLanguage).toHaveBeenCalledWith('en') + // this will only be valid on the first test: + expect(i18n.setDefaultNamespace).toHaveBeenCalledWith('default') + // moment.locale doesn't need to get called if the language is 'en'... + // but it's asynchronous anyway. See following tests + expect(moment.locale).not.toHaveBeenCalled() }) -// TODO Tests -// Test undefined userSettings - todo'd -// Test undefined userSettings.keyUiLocale - todo'd -// Test undefined locale -// Test nonsense locale -- todo'd -// Test Java locale -// Test BCP 47 locale on keyUiLanguageTag -// Test ar_EG locale (before: had no translations) -// Test pt_BR -// Make sure directions are correct -// Make sure moment locale is correct -// Make sure i18n locale either has translations or is reasonable -// ^ (should it be 'en'? wondering about maintanance_tl_keys) +// For pt_BR (Portuguese in Brazil), before fixes: +// 1. i18n.dir didn't work because it needs a BCP47-formatted string +// 2. The Moment locale didn't work, because it uses another format +test('pt_BR locale', async () => { + const userSettings = { keyUiLocale: 'pt_BR' } + const { result, waitFor } = renderHook(() => + useLocale({ + userSettings, + configDirection: undefined, + }) + ) -// TODO Mocks -// i18n.dir -// i18n.hasResourceBundle(name, namespace) -// moment.locale() -// import ('moment/locales/${localeString}) + expect(result.current.direction).toBe('ltr') + // Notice different locale formats + expect(result.current.locale.baseName).toBe('pt-BR') + expect(i18n.changeLanguage).toHaveBeenCalledWith('pt_BR') + // Dynamic imports of Moment locales is asynchronous + await waitFor(() => { + expect(moment.locale).toHaveBeenCalledWith('pt-br') + }) +}) -describe('basic edge case handling', () => { - test('it handles undefined userSettings', () => { - // todo +// For ar_EG (Arabic in Egypt), before fixes: +// 1. i18n.dir didn't work because it needs a BCP47-formatted string +// 2. Setting the i18next language didn't work because there are not translation +// files for it (as of now, Jan 2024). This behavior is mocked above with +// `i18n.hasResourceBundle()` +// [Recent fixes allow for a fallback to simpler locales, e.g. 'ar', +// for much better support] +// 3. The Moment locale didn't work, both because of formatting and failing to +// fall back to simpler locales +test('ar_EG locale', async () => { + const userSettings = { keyUiLocale: 'ar_EG' } + const { result, waitFor } = renderHook(() => + useLocale({ + userSettings, + configDirection: undefined, + }) + ) + + expect(result.current.direction).toBe('rtl') + expect(result.current.locale.baseName).toBe('ar-EG') + // Notice fallbacks + expect(i18n.changeLanguage).toHaveBeenCalledWith('ar') + await waitFor(() => { + expect(moment.locale).toHaveBeenCalledWith('ar') }) +}) - test.todo('it handles undefined userSettings.keyUiLocale') +// for uz_UZ_Cyrl before fixes: +// 1. i18n.dir didn't work because it needs a BCP47-formatted string +// 2. Moment locales didn't work due to formatting and lack of fallback +test('uz_UZ_Cyrl locale', async () => { + const userSettings = { keyUiLocale: 'uz_UZ_Cyrl' } + const { result, waitFor } = renderHook(() => + useLocale({ + userSettings, + configDirection: undefined, + }) + ) - test.todo('it handles nonsense locales') + expect(result.current.direction).toBe('ltr') + expect(result.current.locale.baseName).toBe('uz-Cyrl-UZ') + expect(i18n.changeLanguage).toHaveBeenCalledWith('uz_UZ_Cyrl') + await waitFor(() => { + expect(moment.locale).toHaveBeenCalledWith('uz') + }) }) +// Similar for UZ Latin -- notice difference in the Moment locale +test('uz_UZ_Latn locale', async () => { + const userSettings = { keyUiLocale: 'uz_UZ_Latn' } + const { result, waitFor } = renderHook(() => + useLocale({ + userSettings, + configDirection: undefined, + }) + ) -// describe('language test cases') + expect(result.current.direction).toBe('ltr') + expect(result.current.locale.baseName).toBe('uz-Latn-UZ') + expect(i18n.changeLanguage).toHaveBeenCalledWith('uz_UZ_Latn') + await waitFor(() => { + expect(moment.locale).toHaveBeenCalledWith('uz-latn') + }) +}) From f7df0f2b443d129684e7a8538609812eedef6a6e Mon Sep 17 00:00:00 2001 From: Kai Vandivier Date: Thu, 25 Jan 2024 14:40:48 +0100 Subject: [PATCH 14/17] test: userSettings cases --- adapter/src/utils/localeUtils.js | 20 ++- adapter/src/utils/useLocale.test.js | 214 +++++++++++++++++++--------- 2 files changed, 156 insertions(+), 78 deletions(-) diff --git a/adapter/src/utils/localeUtils.js b/adapter/src/utils/localeUtils.js index eec206ead..aadab3ae0 100644 --- a/adapter/src/utils/localeUtils.js +++ b/adapter/src/utils/localeUtils.js @@ -31,13 +31,19 @@ const parseJavaLocale = (locale) => { * @returns Intl.Locale */ export const parseLocale = (userSettings) => { - // proposed property - if (userSettings.keyUiLanguageTag) { - return new Intl.Locale(userSettings.keyUiLanguageTag) - } - // legacy property - if (userSettings.keyUiLocale) { - return parseJavaLocale(userSettings.keyUiLocale) + try { + // proposed property + if (userSettings.keyUiLanguageTag) { + return new Intl.Locale(userSettings.keyUiLanguageTag) + } + // legacy property + if (userSettings.keyUiLocale) { + return parseJavaLocale(userSettings.keyUiLocale) + } + } catch (err) { + console.error('Unable to parse locale from user settings:', { + userSettings, + }) } // worst-case fallback diff --git a/adapter/src/utils/useLocale.test.js b/adapter/src/utils/useLocale.test.js index 5fd1cb5ae..f8fd5b298 100644 --- a/adapter/src/utils/useLocale.test.js +++ b/adapter/src/utils/useLocale.test.js @@ -10,6 +10,11 @@ import { useLocale } from './useLocale.js' // Make sure i18n locale either has translations or is reasonable // ^ (should it be 'en'? wondering about maintanance_tl_keys) +// NOTE ABOUT MOCKS: +// Luckily, `await import(`moment/locale/${locale}`)` as used in +// `setMomentLocale` in `localeUtils.js` works the same in the Jest environment +// as in the real world, so it doesn't need mocking + jest.mock('@dhis2/d2-i18n', () => { return { setDefaultNamespace: jest.fn(), @@ -69,88 +74,155 @@ test('happy path initial load with en language', () => { expect(moment.locale).not.toHaveBeenCalled() }) -// For pt_BR (Portuguese in Brazil), before fixes: -// 1. i18n.dir didn't work because it needs a BCP47-formatted string -// 2. The Moment locale didn't work, because it uses another format -test('pt_BR locale', async () => { - const userSettings = { keyUiLocale: 'pt_BR' } - const { result, waitFor } = renderHook(() => - useLocale({ - userSettings, - configDirection: undefined, - }) - ) +describe('formerly problematic locales', () => { + // For pt_BR (Portuguese in Brazil), before fixes: + // 1. i18n.dir didn't work because it needs a BCP47-formatted string + // 2. The Moment locale didn't work, because it uses another format + test('pt_BR locale', async () => { + const userSettings = { keyUiLocale: 'pt_BR' } + const { result, waitFor } = renderHook(() => + useLocale({ + userSettings, + configDirection: undefined, + }) + ) - expect(result.current.direction).toBe('ltr') - // Notice different locale formats - expect(result.current.locale.baseName).toBe('pt-BR') - expect(i18n.changeLanguage).toHaveBeenCalledWith('pt_BR') - // Dynamic imports of Moment locales is asynchronous - await waitFor(() => { - expect(moment.locale).toHaveBeenCalledWith('pt-br') + expect(result.current.direction).toBe('ltr') + // Notice different locale formats + expect(result.current.locale.baseName).toBe('pt-BR') + expect(i18n.changeLanguage).toHaveBeenCalledWith('pt_BR') + // Dynamic imports of Moment locales is asynchronous + await waitFor(() => { + expect(moment.locale).toHaveBeenCalledWith('pt-br') + }) }) -}) -// For ar_EG (Arabic in Egypt), before fixes: -// 1. i18n.dir didn't work because it needs a BCP47-formatted string -// 2. Setting the i18next language didn't work because there are not translation -// files for it (as of now, Jan 2024). This behavior is mocked above with -// `i18n.hasResourceBundle()` -// [Recent fixes allow for a fallback to simpler locales, e.g. 'ar', -// for much better support] -// 3. The Moment locale didn't work, both because of formatting and failing to -// fall back to simpler locales -test('ar_EG locale', async () => { - const userSettings = { keyUiLocale: 'ar_EG' } - const { result, waitFor } = renderHook(() => - useLocale({ - userSettings, - configDirection: undefined, + // For ar_EG (Arabic in Egypt), before fixes: + // 1. i18n.dir didn't work because it needs a BCP47-formatted string + // 2. Setting the i18next language didn't work because there are not translation + // files for it (as of now, Jan 2024). This behavior is mocked above with + // `i18n.hasResourceBundle()` + // [Recent fixes allow for a fallback to simpler locales, e.g. 'ar', + // for much better support] + // 3. The Moment locale didn't work, both because of formatting and failing to + // fall back to simpler locales + test('ar_EG locale', async () => { + const userSettings = { keyUiLocale: 'ar_EG' } + const { result, waitFor } = renderHook(() => + useLocale({ + userSettings, + configDirection: undefined, + }) + ) + + expect(result.current.direction).toBe('rtl') + expect(result.current.locale.baseName).toBe('ar-EG') + // Notice fallbacks + expect(i18n.changeLanguage).toHaveBeenCalledWith('ar') + await waitFor(() => { + expect(moment.locale).toHaveBeenCalledWith('ar') }) - ) - - expect(result.current.direction).toBe('rtl') - expect(result.current.locale.baseName).toBe('ar-EG') - // Notice fallbacks - expect(i18n.changeLanguage).toHaveBeenCalledWith('ar') - await waitFor(() => { - expect(moment.locale).toHaveBeenCalledWith('ar') }) -}) -// for uz_UZ_Cyrl before fixes: -// 1. i18n.dir didn't work because it needs a BCP47-formatted string -// 2. Moment locales didn't work due to formatting and lack of fallback -test('uz_UZ_Cyrl locale', async () => { - const userSettings = { keyUiLocale: 'uz_UZ_Cyrl' } - const { result, waitFor } = renderHook(() => - useLocale({ - userSettings, - configDirection: undefined, + // for uz_UZ_Cyrl before fixes: + // 1. i18n.dir didn't work because it needs a BCP47-formatted string + // 2. Moment locales didn't work due to formatting and lack of fallback + test('uz_UZ_Cyrl locale', async () => { + const userSettings = { keyUiLocale: 'uz_UZ_Cyrl' } + const { result, waitFor } = renderHook(() => + useLocale({ + userSettings, + configDirection: undefined, + }) + ) + + expect(result.current.direction).toBe('ltr') + expect(result.current.locale.baseName).toBe('uz-Cyrl-UZ') + expect(i18n.changeLanguage).toHaveBeenCalledWith('uz_UZ_Cyrl') + await waitFor(() => { + expect(moment.locale).toHaveBeenCalledWith('uz') }) - ) + }) + // Similar for UZ Latin -- notice difference in the Moment locale + test('uz_UZ_Latn locale', async () => { + const userSettings = { keyUiLocale: 'uz_UZ_Latn' } + const { result, waitFor } = renderHook(() => + useLocale({ + userSettings, + configDirection: undefined, + }) + ) - expect(result.current.direction).toBe('ltr') - expect(result.current.locale.baseName).toBe('uz-Cyrl-UZ') - expect(i18n.changeLanguage).toHaveBeenCalledWith('uz_UZ_Cyrl') - await waitFor(() => { - expect(moment.locale).toHaveBeenCalledWith('uz') + expect(result.current.direction).toBe('ltr') + expect(result.current.locale.baseName).toBe('uz-Latn-UZ') + expect(i18n.changeLanguage).toHaveBeenCalledWith('uz_UZ_Latn') + await waitFor(() => { + expect(moment.locale).toHaveBeenCalledWith('uz-latn') + }) }) }) -// Similar for UZ Latin -- notice difference in the Moment locale -test('uz_UZ_Latn locale', async () => { - const userSettings = { keyUiLocale: 'uz_UZ_Latn' } - const { result, waitFor } = renderHook(() => - useLocale({ - userSettings, - configDirection: undefined, + +describe('other userSettings cases', () => { + beforeEach(() => { + // Mock browser language + jest.spyOn(window.navigator, 'language', 'get').mockImplementation( + () => 'ar-EG' + ) + }) + + test('proposed keyUiLanguageTag property is used (preferrentially)', async () => { + const userSettings = { + keyUiLocale: 'en', + keyUiLanguageTag: 'pt-BR', + } + const { result, waitFor } = renderHook(() => + useLocale({ + userSettings, + configDirection: undefined, + }) + ) + + expect(result.current.direction).toBe('ltr') + expect(result.current.locale.baseName).toBe('pt-BR') + expect(i18n.changeLanguage).toHaveBeenCalledWith('pt_BR') + await waitFor(() => { + expect(moment.locale).toHaveBeenCalledWith('pt-br') }) - ) + }) - expect(result.current.direction).toBe('ltr') - expect(result.current.locale.baseName).toBe('uz-Latn-UZ') - expect(i18n.changeLanguage).toHaveBeenCalledWith('uz_UZ_Latn') - await waitFor(() => { - expect(moment.locale).toHaveBeenCalledWith('uz-latn') + test('keyUiLocale is missing from user settings for some reason (should fall back to browser language)', async () => { + const userSettings = {} + const { result, waitFor } = renderHook(() => + useLocale({ + userSettings, + configDirection: undefined, + }) + ) + + expect(result.current.direction).toBe('rtl') + expect(result.current.locale.baseName).toBe('ar-EG') + expect(i18n.changeLanguage).toHaveBeenCalledWith('ar') + await waitFor(() => { + expect(moment.locale).toHaveBeenCalledWith('ar') + }) + }) + + test('keyUiLocale is nonsense (should fall back to browser language)', async () => { + const userSettings = { keyUiLocale: 'shouldCauseError' } + const { result, waitFor } = renderHook(() => + useLocale({ + userSettings, + configDirection: undefined, + }) + ) + + expect(result.current.direction).toBe('rtl') + expect(result.current.locale.baseName).toBe('ar-EG') + expect(i18n.changeLanguage).toHaveBeenCalledWith('ar') + await waitFor(() => { + expect(moment.locale).toHaveBeenCalledWith('ar') + }) }) }) + +test.todo('document direction is set by config direction') From 2e880e23927b9b0173d621de6da92ec138df185c Mon Sep 17 00:00:00 2001 From: Kai Vandivier Date: Thu, 25 Jan 2024 15:07:26 +0100 Subject: [PATCH 15/17] test: add document direction tests --- adapter/src/utils/useLocale.test.js | 75 ++++++++++++++++++++++++++--- 1 file changed, 67 insertions(+), 8 deletions(-) diff --git a/adapter/src/utils/useLocale.test.js b/adapter/src/utils/useLocale.test.js index f8fd5b298..1c6da355b 100644 --- a/adapter/src/utils/useLocale.test.js +++ b/adapter/src/utils/useLocale.test.js @@ -3,13 +3,6 @@ import { renderHook, act } from '@testing-library/react-hooks' import moment from 'moment' import { useLocale } from './useLocale.js' -// TODO -// Test undefined locale -// Test nonsense locale -// Test BCP47 locale on keyUiLanguageTag -// Make sure i18n locale either has translations or is reasonable -// ^ (should it be 'en'? wondering about maintanance_tl_keys) - // NOTE ABOUT MOCKS: // Luckily, `await import(`moment/locale/${locale}`)` as used in // `setMomentLocale` in `localeUtils.js` works the same in the Jest environment @@ -225,4 +218,70 @@ describe('other userSettings cases', () => { }) }) -test.todo('document direction is set by config direction') +describe('config direction is respected for the document direction', () => { + jest.spyOn(document.documentElement, 'setAttribute') + + test('ltr is the default and is used even for rtl languages', async () => { + const userSettings = { keyUiLocale: 'ar' } + const { result } = renderHook(() => + useLocale({ + userSettings, + configDirection: undefined, + }) + ) + + expect(result.current.direction).toBe('rtl') + expect(document.documentElement.setAttribute).toHaveBeenCalledWith( + 'dir', + 'ltr' + ) + }) + + test('rtl will be used for the document if configured, even for an ltr language', () => { + const userSettings = { keyUiLocale: 'en' } + const { result } = renderHook(() => + useLocale({ + userSettings, + configDirection: 'rtl', + }) + ) + + expect(result.current.direction).toBe('ltr') + expect(document.documentElement.setAttribute).toHaveBeenCalledWith( + 'dir', + 'rtl' + ) + }) + + test('if auto is used, document dir will match the language dir (ltr)', () => { + const userSettings = { keyUiLocale: 'en' } + const { result } = renderHook(() => + useLocale({ + userSettings, + configDirection: 'auto', + }) + ) + + expect(result.current.direction).toBe('ltr') + expect(document.documentElement.setAttribute).toHaveBeenCalledWith( + 'dir', + 'ltr' + ) + }) + + test('if auto is used, document dir will match the language dir (ltr)', () => { + const userSettings = { keyUiLocale: 'ar' } + const { result } = renderHook(() => + useLocale({ + userSettings, + configDirection: 'auto', + }) + ) + + expect(result.current.direction).toBe('rtl') + expect(document.documentElement.setAttribute).toHaveBeenCalledWith( + 'dir', + 'rtl' + ) + }) +}) From 1d62c34957f89c04830d6c7fc5de54b10c43aeb8 Mon Sep 17 00:00:00 2001 From: Kai Vandivier Date: Thu, 25 Jan 2024 15:39:48 +0100 Subject: [PATCH 16/17] fix: handle nonstandard configDirections --- adapter/src/utils/localeUtils.js | 8 +++++++- adapter/src/utils/useLocale.test.js | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/adapter/src/utils/localeUtils.js b/adapter/src/utils/localeUtils.js index aadab3ae0..aa52f6a9a 100644 --- a/adapter/src/utils/localeUtils.js +++ b/adapter/src/utils/localeUtils.js @@ -150,7 +150,13 @@ export const setMomentLocale = async (locale) => { * Note that the header bar will use the localeDirection regardless */ export const setDocumentDirection = ({ localeDirection, configDirection }) => { + // validate config direction (also handles `undefined`) + if (!['auto', 'ltr', 'rtl'].includes(configDirection)) { + document.documentElement.setAttribute('dir', 'ltr') + return + } + const globalDirection = - configDirection === 'auto' ? localeDirection : configDirection || 'ltr' + configDirection === 'auto' ? localeDirection : configDirection document.documentElement.setAttribute('dir', globalDirection) } diff --git a/adapter/src/utils/useLocale.test.js b/adapter/src/utils/useLocale.test.js index 1c6da355b..de387b987 100644 --- a/adapter/src/utils/useLocale.test.js +++ b/adapter/src/utils/useLocale.test.js @@ -284,4 +284,20 @@ describe('config direction is respected for the document direction', () => { 'rtl' ) }) + + test('nonstandard config directions fall back to ltr', () => { + const userSettings = { keyUiLocale: 'ar' } + const { result } = renderHook(() => + useLocale({ + userSettings, + configDirection: 'whoopslol', + }) + ) + + expect(result.current.direction).toBe('rtl') + expect(document.documentElement.setAttribute).toHaveBeenCalledWith( + 'dir', + 'ltr' + ) + }) }) From 0c48e4f1f4bd06cdfbad5c0e35194c70e178292c Mon Sep 17 00:00:00 2001 From: Kai Vandivier Date: Thu, 25 Jan 2024 17:35:35 +0100 Subject: [PATCH 17/17] feat: set document `lang` attribute --- adapter/src/utils/useLocale.js | 1 + adapter/src/utils/useLocale.test.js | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/adapter/src/utils/useLocale.js b/adapter/src/utils/useLocale.js index ce7cc09db..e0672d48e 100644 --- a/adapter/src/utils/useLocale.js +++ b/adapter/src/utils/useLocale.js @@ -27,6 +27,7 @@ export const useLocale = ({ userSettings, configDirection }) => { // Intl.Locale dir utils aren't supported in firefox, so use i18n const localeDirection = i18n.dir(locale.language) setDocumentDirection({ localeDirection, configDirection }) + document.documentElement.setAttribute('lang', locale.baseName) setResult({ locale, direction: localeDirection }) }, [userSettings, configDirection]) diff --git a/adapter/src/utils/useLocale.test.js b/adapter/src/utils/useLocale.test.js index de387b987..00be9ca01 100644 --- a/adapter/src/utils/useLocale.test.js +++ b/adapter/src/utils/useLocale.test.js @@ -301,3 +301,20 @@ describe('config direction is respected for the document direction', () => { ) }) }) + +test('document `lang` attribute is set', () => { + jest.spyOn(document.documentElement, 'setAttribute') + const userSettings = { keyUiLocale: 'pt-BR' } + + renderHook(() => + useLocale({ + userSettings, + configDirection: undefined, + }) + ) + + expect(document.documentElement.setAttribute).toHaveBeenCalledWith( + 'lang', + 'pt-BR' + ) +})