diff --git a/adapter/package.json b/adapter/package.json index a0f8e31a6..aba625be9 100644 --- a/adapter/package.json +++ b/adapter/package.json @@ -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", 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/localeUtils.js b/adapter/src/utils/localeUtils.js new file mode 100644 index 000000000..aa52f6a9a --- /dev/null +++ b/adapter/src/utils/localeUtils.js @@ -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) +} diff --git a/adapter/src/utils/useLocale.js b/adapter/src/utils/useLocale.js index 2c34bc94f..0187622c1 100644 --- a/adapter/src/utils/useLocale.js +++ b/adapter/src/utils/useLocale.js @@ -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 } @@ -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 } } diff --git a/adapter/src/utils/useLocale.test.js b/adapter/src/utils/useLocale.test.js new file mode 100644 index 000000000..e9bc87cdb --- /dev/null +++ b/adapter/src/utils/useLocale.test.js @@ -0,0 +1,285 @@ +import { useDataQuery } from '@dhis2/app-runtime' +import i18n from '@dhis2/d2-i18n' +import { renderHook } from '@testing-library/react-hooks' +import moment from 'moment' +import { useCurrentUserLocale } from './useLocale.js' + +// 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 + +// NB: To keep tests simpler, useDataQuery will be considered synchronous, +// and loading logic isn't tested +jest.mock('@dhis2/app-runtime', () => ({ + ...jest.requireActual('@dhis2/app-runtime'), + useDataQuery: jest.fn(), +})) + +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: jest.fn(), + defineLocale: jest.fn(), +})) + +jest.spyOn(document.documentElement, 'setAttribute') + +afterEach(() => { + jest.clearAllMocks() +}) + +test('happy path initial load with en language', async () => { + useDataQuery.mockReturnValue({ + data: { userSettings: { keyUiLocale: 'en' } }, + }) + const { result } = renderHook(() => useCurrentUserLocale()) + + expect(result.current.loading).toBe(false) + 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() + expect(document.documentElement.setAttribute).toHaveBeenCalledWith( + 'dir', + 'ltr' + ) + expect(document.documentElement.setAttribute).toHaveBeenCalledWith( + 'lang', + 'en' + ) +}) + +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 () => { + useDataQuery.mockReturnValue({ + data: { userSettings: { keyUiLocale: 'pt_BR' } }, + }) + const { result, waitFor } = renderHook(() => useCurrentUserLocale()) + + 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 () => { + useDataQuery.mockReturnValue({ + data: { userSettings: { keyUiLocale: 'ar_EG' } }, + }) + const { result, waitFor } = renderHook(() => useCurrentUserLocale()) + + 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 () => { + useDataQuery.mockReturnValue({ + data: { userSettings: { keyUiLocale: 'uz_UZ_Cyrl' } }, + }) + const { result, waitFor } = renderHook(() => useCurrentUserLocale()) + + 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 () => { + useDataQuery.mockReturnValue({ + data: { userSettings: { keyUiLocale: 'uz_UZ_Latn' } }, + }) + const { result, waitFor } = renderHook(() => useCurrentUserLocale()) + + 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') + }) + }) +}) + +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 () => { + useDataQuery.mockReturnValue({ + data: { + userSettings: { keyUiLocale: 'en', keyUiLanguageTag: 'pt-BR' }, + }, + }) + const { result, waitFor } = renderHook(() => useCurrentUserLocale()) + + 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') + }) + }) + + test('keyUiLocale is missing from user settings for some reason (should fall back to browser language)', async () => { + useDataQuery.mockReturnValue({ + data: { userSettings: {} }, + }) + const { result, waitFor } = renderHook(() => useCurrentUserLocale()) + + 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 () => { + useDataQuery.mockReturnValue({ + data: { userSettings: { keyUiLocale: 'shouldCauseError' } }, + }) + const { result, waitFor } = renderHook(() => useCurrentUserLocale()) + + 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') + }) + }) +}) + +describe('config direction is respected for the document direction', () => { + test('ltr is the default and is used even for rtl languages', async () => { + useDataQuery.mockReturnValue({ + data: { userSettings: { keyUiLocale: 'ar' } }, + }) + const { result } = renderHook(() => useCurrentUserLocale()) + + 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', () => { + useDataQuery.mockReturnValue({ + data: { userSettings: { keyUiLocale: 'en' } }, + }) + const { result } = renderHook(() => useCurrentUserLocale('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)', () => { + useDataQuery.mockReturnValue({ + data: { userSettings: { keyUiLocale: 'en' } }, + }) + const { result } = renderHook(() => useCurrentUserLocale('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)', () => { + useDataQuery.mockReturnValue({ + data: { userSettings: { keyUiLocale: 'ar' } }, + }) + const { result } = renderHook(() => useCurrentUserLocale('auto')) + + expect(result.current.direction).toBe('rtl') + expect(document.documentElement.setAttribute).toHaveBeenCalledWith( + 'dir', + 'rtl' + ) + }) + + test('nonstandard config directions fall back to ltr', () => { + useDataQuery.mockReturnValue({ + data: { userSettings: { keyUiLocale: 'ar' } }, + }) + const { result } = renderHook(() => useCurrentUserLocale('whoopslol')) + + expect(result.current.direction).toBe('rtl') + expect(document.documentElement.setAttribute).toHaveBeenCalledWith( + 'dir', + 'ltr' + ) + }) +}) + +test('document `lang` attribute is set', () => { + useDataQuery.mockReturnValue({ + data: { userSettings: { keyUiLocale: 'pt_BR' } }, + }) + renderHook(() => useCurrentUserLocale()) + + expect(document.documentElement.setAttribute).toHaveBeenCalledWith( + 'lang', + 'pt-BR' + ) +}) diff --git a/cli/src/lib/shell/index.js b/cli/src/lib/shell/index.js index e2c737427..c7edc4937 100644 --- a/cli/src/lib/shell/index.js +++ b/cli/src/lib/shell/index.js @@ -9,6 +9,10 @@ module.exports = ({ config, paths }) => { version: config.version, } + if (config.direction) { + baseEnvVars.direction = config.direction + } + return { bootstrap: async (args = {}) => { await bootstrap(paths, args) 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 = { diff --git a/yarn.lock b/yarn.lock index 6d3e34f78..e1b6287a8 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"