Skip to content

Commit

Permalink
feat: changes to support right-to-left (#830)
Browse files Browse the repository at this point in the history
* feat: `dir` config for language directions and other i18n fixes [DHIS2-16480] (#825)

* feat: change text direction based on locale

* feat: add direction config to d2.config

* refactor: split up handleLocale

* refactor: rename function; add dir default to adapter

* fix: remove direction from d2.config defaults in CLI

* refactor: parse locale to Intl.Locale object

* refactor: better i18nLocale logic

* fix: moment locale formatting & add fallbacks

* refactor: fn rename

* refactor: move locale utils to a new file

* test: set up test file for useLocale.test

* fix: skip logic for en and en-US

* test: add first useLocale tests

* test: userSettings cases

* test: add document direction tests

* fix: handle nonstandard configDirections

* feat: set document `lang` attribute

* fix: remove unused export for useLocale (and update tests) (#826)

* test: update tests to target useCurrentUserLocale

* fix: remove unused export on useLocale

---------

Co-authored-by: Kai Vandivier <[email protected]>
  • Loading branch information
kabaros and KaiVandivier committed Feb 19, 2024
1 parent 700628e commit 6d5433c
Show file tree
Hide file tree
Showing 11 changed files with 537 additions and 58 deletions.
1 change: 1 addition & 0 deletions adapter/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"devDependencies": {
"@dhis2/cli-app-scripts": "10.4.1",
"@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",
Expand Down
16 changes: 13 additions & 3 deletions adapter/src/components/AppWrapper.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -40,7 +47,9 @@ const AppWrapper = ({ children, plugin, onPluginError, clearPluginError }) => {
return (
<div className="app-shell-adapter">
<style jsx>{styles}</style>
<ConnectedHeaderBar />
<div dir={localeDirection}>
<ConnectedHeaderBar />
</div>
<div className="app-shell-app">
<ErrorBoundary onRetry={() => window.location.reload()}>
{children}
Expand All @@ -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,
}
Expand Down
3 changes: 3 additions & 0 deletions adapter/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const AppAdapter = ({
appVersion,
url,
apiVersion,
direction,
pwaEnabled,
plugin,
parentAlertsAdd,
Expand Down Expand Up @@ -41,6 +42,7 @@ const AppAdapter = ({
plugin={plugin}
onPluginError={onPluginError}
clearPluginError={clearPluginError}
direction={direction}
>
{children}
</AppWrapper>
Expand All @@ -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,
Expand Down
162 changes: 162 additions & 0 deletions adapter/src/utils/localeUtils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
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) => {
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
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.baseName === 'en-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 }) => {
// validate config direction (also handles `undefined`)
if (!['auto', 'ltr', 'rtl'].includes(configDirection)) {
document.documentElement.setAttribute('dir', 'ltr')
return
}

const globalDirection =
configDirection === 'auto' ? localeDirection : configDirection
document.documentElement.setAttribute('dir', globalDirection)
}
70 changes: 33 additions & 37 deletions adapter/src/utils/useLocale.js
Original file line number Diff line number Diff line change
@@ -1,44 +1,37 @@
import { useDataQuery } from '@dhis2/app-runtime'
import i18n from '@dhis2/d2-i18n'
import moment from 'moment'
import { useState, useEffect } from 'react'
import {
setI18nLocale,
parseLocale,
setDocumentDirection,
setMomentLocale,
} from './localeUtils.js'

const useLocale = ({ userSettings, configDirection }) => {
const [result, setResult] = useState({
locale: undefined,
direction: undefined,
})

i18n.setDefaultNamespace('default')

const simplifyLocale = (locale) => {
const idx = locale.indexOf('-')
if (idx === -1) {
return locale
}
return locale.substr(0, idx)
}

const setGlobalLocale = (locale) => {
if (locale !== 'en' && locale !== 'en-us') {
import(
/* webpackChunkName: "moment-locales/[request]" */ `moment/locale/${locale}`
).catch(() => {
/* ignore */
})
}
moment.locale(locale)

const simplifiedLocale = simplifyLocale(locale)
i18n.changeLanguage(simplifiedLocale)
}

export const useLocale = (locale) => {
const [result, setResult] = useState(undefined)
useEffect(() => {
if (!locale) {
if (!userSettings) {
return
}

setGlobalLocale(locale)
setResult(locale)
const locale = parseLocale(userSettings)

setI18nLocale(locale)
setMomentLocale(locale)

// 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])

console.log('🗺 Global d2-i18n locale initialized:', locale)
}, [locale])
return result
}

Expand All @@ -47,16 +40,19 @@ const settingsQuery = {
resource: 'userSettings',
},
}
export const useCurrentUserLocale = () => {
// note: userSettings.keyUiLocale is expected to be in the Java format,
// e.g. 'ar', 'ar_IQ', 'uz_UZ_Cyrl', etc.
export const useCurrentUserLocale = (configDirection) => {
const { loading, error, data } = useDataQuery(settingsQuery)
const locale = useLocale(
data && (data.userSettings.keyUiLocale || window.navigator.language)
)
const { locale, direction } = useLocale({
userSettings: data && data.userSettings,
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 }
}
Loading

0 comments on commit 6d5433c

Please sign in to comment.