Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions adapter/src/components/ServerVersionProvider.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { useOfflineInterface } from './OfflineInterfaceContext.js'

export const ServerVersionProvider = ({
appName,
appUrlSlug,
appVersion,
url, // url from env vars
apiVersion,
Expand Down Expand Up @@ -210,6 +211,7 @@ export const ServerVersionProvider = ({
<Provider
config={{
appName,
appUrlSlug,
appVersion: parseVersion(appVersion),
baseUrl,
apiVersion: apiVersion || realApiVersion,
Expand All @@ -230,6 +232,7 @@ export const ServerVersionProvider = ({

ServerVersionProvider.propTypes = {
appName: PropTypes.string.isRequired,
appUrlSlug: PropTypes.string.isRequired,
appVersion: PropTypes.string.isRequired,
apiVersion: PropTypes.number,
children: PropTypes.element,
Expand Down
4 changes: 4 additions & 0 deletions adapter/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { ServerVersionProvider } from './components/ServerVersionProvider.js'

const AppAdapter = ({
appName,
appUrlSlug,
appVersion,
url,
apiVersion,
Expand Down Expand Up @@ -38,6 +39,7 @@ const AppAdapter = ({
>
<ServerVersionProvider
appName={appName}
appUrlSlug={appUrlSlug}
appVersion={appVersion}
url={url}
apiVersion={apiVersion}
Expand All @@ -62,6 +64,7 @@ const AppAdapter = ({
<PWALoadingBoundary>
<ServerVersionProvider
appName={appName}
appUrlSlug={appUrlSlug}
appVersion={appVersion}
url={url}
apiVersion={apiVersion}
Expand All @@ -87,6 +90,7 @@ const AppAdapter = ({

AppAdapter.propTypes = {
appName: PropTypes.string.isRequired,
appUrlSlug: PropTypes.string.isRequired,
appVersion: PropTypes.string.isRequired,
apiVersion: PropTypes.number,
children: PropTypes.element,
Expand Down
58 changes: 58 additions & 0 deletions adapter/src/utils/customTranslations.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { useConfig, useDataEngine } from '@dhis2/app-runtime'
import i18n from '@dhis2/d2-i18n'
import { useCallback } from 'react'
import { I18N_NAMESPACE } from './localeUtils'

const customTranslationsQuery = {
customTranslations: {
resource: 'dataStore/custom-translations',
id: ({ appUrlSlug, dhis2Locale }) => `${appUrlSlug}__${dhis2Locale}`,
},
}
/**
* Returns a function to look for custom translations for this app and locale
* in the datastore, using a key convention with the app name and user locale
* in the 'custom-translations' namespace.
* If the translations exist, they will be added to the translation bundle for
* the user's locale. This search will run asynchronously and is not awaited,
* but it will usually resolve before the app's main translation bundles are
* added, so steps are taken to make sure the custom translations take priority
* over (and don't get overwritten by) the main app translations
*/
export const useCustomTranslations = () => {
const { appUrlSlug } = useConfig()
const engine = useDataEngine()

const getCustomTranslations = useCallback(
/**
* Checks the datastore for custom translations and loads them if found
* @param {Object} params
* @param {Intl.Locale} params.locale - The parsed locale in BCP47 format
* @param {string} params.dhis2Locale - The locale in DHIS2 format
*/
async ({ locale, dhis2Locale }) => {
if (!dhis2Locale) {
return
}
try {
const data = await engine.query(customTranslationsQuery, {
variables: { appUrlSlug, dhis2Locale },
})
i18n.addResourceBundle(
locale?.baseName ?? 'en',
I18N_NAMESPACE,
data.customTranslations,
true, // 'deep' -- add keys in this bundle to existing translations
true // 'overwrite' -- overwrite already existing keys
)
} catch {
console.warning(
`No custom translations found in the datastore for this app and locale (looked for the key ${appUrlSlug}__${dhis2Locale} in the custom-translations namespace)`
)
}
},
[engine, appUrlSlug]
)

return getCustomTranslations
}
16 changes: 11 additions & 5 deletions adapter/src/utils/localeUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,23 @@ import i18n from '@dhis2/d2-i18n'
import moment from 'moment'

// Init i18n namespace
const I18N_NAMESPACE = 'default'
export const I18N_NAMESPACE = 'default'
i18n.setDefaultNamespace(I18N_NAMESPACE)

/**
* userSettings.keyUiLocale is expected to be formatted by Java's
* Locale.toString():
* Locale.toString()... kind of: <language>[_<REGION>[_<Script>]]
* https://github.com/dhis2/dhis2-core/pull/22819
* 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
*
* Note: if a BCP 47 language tag-formatted locale is provided for the `locale`
* argument, this function happens to work as well
*
* @param {string} locale
* @returns Intl.Locale
*/
const parseJavaLocale = (locale) => {
const parseDhis2Locale = (locale) => {
const [language, region, script] = locale.split('_')

let languageTag = language
Expand All @@ -38,7 +44,7 @@ export const parseLocale = (userSettings) => {
}
// legacy property
if (userSettings.keyUiLocale) {
return parseJavaLocale(userSettings.keyUiLocale)
return parseDhis2Locale(userSettings.keyUiLocale)
}
} catch (err) {
console.error('Unable to parse locale from user settings:', {
Expand Down
91 changes: 82 additions & 9 deletions adapter/src/utils/useLocale.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import { useDataQuery } from '@dhis2/app-runtime'
import { useConfig, useDataQuery } from '@dhis2/app-runtime'
import i18n from '@dhis2/d2-i18n'
import { useState, useEffect, useMemo } from 'react'
import { useCustomTranslations } from './customTranslations.js'
import {
setI18nLocale,
parseLocale,
setDocumentDirection,
setMomentLocale,
} from './localeUtils.js'

const useLocale = ({ userSettings, configDirection }) => {
const useLocale = ({
userSettings,
configDirection,
customTranslationsInfo,
}) => {
const { appUrlSlug } = useConfig()
const getCustomTranslations = useCustomTranslations()
const [result, setResult] = useState({
locale: undefined,
direction: undefined,
Expand All @@ -20,7 +27,33 @@ const useLocale = ({ userSettings, configDirection }) => {
}

const locale = parseLocale(userSettings)
const dhis2Locale = userSettings.keyUiLocale

let shouldFetchCustomTranslations = false
if (customTranslationsInfo) {
try {
// In the `custom-translations/controller` data store key,
// is there configured an object { [appUrlSlug]: dhis2Locale[] }
// that includes the current dhis2Locale in the array?
shouldFetchCustomTranslations =
customTranslationsInfo[appUrlSlug]?.includes(dhis2Locale)
if (!shouldFetchCustomTranslations) {
console.debug(
'Custom translations not found in controller for this app and locale.'
)
}
} catch (err) {
console.error('Error parsing custom translation controller')
console.error(err)
}
}

// Asynchronous - check datastore for custom translations if enabled
const customTranslationsPromise = shouldFetchCustomTranslations
? getCustomTranslations({ locale, dhis2Locale })
: Promise.resolve()

// Synchronous -- will resolve before state is set and the child app is rendered
setI18nLocale(locale)
setMomentLocale(locale)

Expand All @@ -29,23 +62,59 @@ const useLocale = ({ userSettings, configDirection }) => {
setDocumentDirection({ localeDirection, configDirection })
document.documentElement.setAttribute('lang', locale.baseName)

setResult({ locale, direction: localeDirection })
}, [userSettings, configDirection])
customTranslationsPromise.then(() => {
setResult({ locale, direction: localeDirection })
})
}, [
userSettings,
configDirection,
getCustomTranslations,
customTranslationsInfo,
appUrlSlug,
])

return result
}

const settingsQuery = {
// NOTE: This info would be nice to get from the Global Shell
const USER_SETTINGS_QUERY = {
userSettings: {
resource: 'userSettings',
},
}
// note: userSettings.keyUiLocale is expected to be in the Java format,
// For use in v43+
const CUSTOM_TRANSLATIONS_QUERY = {
setting: {
resource: 'systemSettings/keyCustomTranslationsEnabled',
},
// The values in this key are expected to be in the format
// { [appUrlSlug]: dhis2LocaleCode[] }
info: {
resource: 'dataStore/custom-translations/controller',
},
}
// note: userSettings.keyUiLocale is expected to be in the DHIS2 locale format,
// e.g. 'ar', 'ar_IQ', 'uz_UZ_Cyrl', etc.
export const useCurrentUserLocale = (configDirection) => {
const { loading, error, data } = useDataQuery(settingsQuery)
const { serverVersion } = useConfig()
const customTranslationsAvailable = serverVersion?.minor >= 43

const { loading, error, data } = useDataQuery(USER_SETTINGS_QUERY)
const customTranslationsQuery = useDataQuery(CUSTOM_TRANSLATIONS_QUERY, {
// Below v43, don't run this query
lazy: !customTranslationsAvailable,
// If there's an error, it could be because the datastore isn't set up
onError: () => console.debug('Custom translations not available.'),
})

const customTranslationsInfo =
customTranslationsAvailable &&
customTranslationsQuery.data?.setting.keyCustomTranslationsEnabled &&
customTranslationsQuery.data?.info

const { locale, direction } = useLocale({
userSettings: data && data.userSettings,
userSettings: data?.userSettings,
customTranslationsInfo,
configDirection,
})

Expand All @@ -54,7 +123,11 @@ export const useCurrentUserLocale = (configDirection) => {
throw new Error('Failed to fetch user locale: ' + error)
}

return { loading: loading || !locale, locale, direction }
return {
loading: loading || !locale,
locale,
direction,
}
}

const loginConfigQuery = {
Expand Down
Loading
Loading