@digihmis/esm-primary-navigation-app 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -0
- package/__mocks__/mock-session.ts +23 -0
- package/__mocks__/mock-user.ts +23 -0
- package/__mocks__/openmrs-esm-framework.mock.tsx +30 -0
- package/__mocks__/react-i18next.mock.js +49 -0
- package/dist/1480.js +1 -0
- package/dist/1480.js.map +1 -0
- package/dist/1646.js +1 -0
- package/dist/1646.js.map +1 -0
- package/dist/1716.js +1 -0
- package/dist/1716.js.map +1 -0
- package/dist/1797.js +1 -0
- package/dist/1797.js.map +1 -0
- package/dist/1869.js +1 -0
- package/dist/1869.js.map +1 -0
- package/dist/1877.js +1 -0
- package/dist/1877.js.map +1 -0
- package/dist/2317.js +1 -0
- package/dist/2317.js.map +1 -0
- package/dist/2416.js +1 -0
- package/dist/2416.js.map +1 -0
- package/dist/2711.js +1 -0
- package/dist/2711.js.map +1 -0
- package/dist/282.js +1 -0
- package/dist/282.js.map +1 -0
- package/dist/2881.js +1 -0
- package/dist/2881.js.map +1 -0
- package/dist/2939.js +15 -0
- package/dist/2939.js.map +1 -0
- package/dist/3378.js +1 -0
- package/dist/3378.js.map +1 -0
- package/dist/3963.js +1 -0
- package/dist/3963.js.map +1 -0
- package/dist/4106.js +1 -0
- package/dist/4106.js.map +1 -0
- package/dist/4111.js +1 -0
- package/dist/4111.js.map +1 -0
- package/dist/434.js +1 -0
- package/dist/434.js.map +1 -0
- package/dist/4348.js +1 -0
- package/dist/4348.js.map +1 -0
- package/dist/4383.js +1 -0
- package/dist/4383.js.map +1 -0
- package/dist/4658.js +1 -0
- package/dist/4658.js.map +1 -0
- package/dist/466.js +1 -0
- package/dist/466.js.map +1 -0
- package/dist/4928.js +1 -0
- package/dist/4928.js.map +1 -0
- package/dist/5117.js +1 -0
- package/dist/5117.js.map +1 -0
- package/dist/5132.js +1 -0
- package/dist/5132.js.map +1 -0
- package/dist/5145.js +1 -0
- package/dist/5145.js.map +1 -0
- package/dist/5503.js +1 -0
- package/dist/5503.js.map +1 -0
- package/dist/556.js +1 -0
- package/dist/556.js.map +1 -0
- package/dist/5644.js +1 -0
- package/dist/5644.js.map +1 -0
- package/dist/5867.js +12 -0
- package/dist/5867.js.map +1 -0
- package/dist/5940.js +1 -0
- package/dist/5940.js.map +1 -0
- package/dist/6047.js +1 -0
- package/dist/6047.js.map +1 -0
- package/dist/6371.js +1 -0
- package/dist/6371.js.map +1 -0
- package/dist/6377.js +1 -0
- package/dist/6377.js.map +1 -0
- package/dist/6444.js +1 -0
- package/dist/6444.js.map +1 -0
- package/dist/6508.js +1 -0
- package/dist/6508.js.map +1 -0
- package/dist/6724.js +1 -0
- package/dist/6724.js.map +1 -0
- package/dist/689.js +1 -0
- package/dist/689.js.map +1 -0
- package/dist/6904.js +1 -0
- package/dist/6904.js.map +1 -0
- package/dist/7044.js +1 -0
- package/dist/7044.js.map +1 -0
- package/dist/7045.js +1 -0
- package/dist/7045.js.map +1 -0
- package/dist/7175.js +1 -0
- package/dist/7175.js.map +1 -0
- package/dist/7182.js +1 -0
- package/dist/7182.js.map +1 -0
- package/dist/7302.js +1 -0
- package/dist/7302.js.map +1 -0
- package/dist/7587.js +1 -0
- package/dist/7587.js.map +1 -0
- package/dist/7742.js +1 -0
- package/dist/7742.js.map +1 -0
- package/dist/7786.js +1 -0
- package/dist/7786.js.map +1 -0
- package/dist/7912.js +1 -0
- package/dist/7912.js.map +1 -0
- package/dist/8349.js +1 -0
- package/dist/8349.js.map +1 -0
- package/dist/8358.js +1 -0
- package/dist/8358.js.map +1 -0
- package/dist/8359.js +1 -0
- package/dist/8359.js.map +1 -0
- package/dist/8695.js +1 -0
- package/dist/8695.js.map +1 -0
- package/dist/8924.js +1 -0
- package/dist/8924.js.map +1 -0
- package/dist/903.js +1 -0
- package/dist/903.js.map +1 -0
- package/dist/9072.js +1 -0
- package/dist/9072.js.map +1 -0
- package/dist/9105.js +1 -0
- package/dist/9105.js.map +1 -0
- package/dist/9712.js +1 -0
- package/dist/9712.js.map +1 -0
- package/dist/9806.js +1 -0
- package/dist/9806.js.map +1 -0
- package/dist/digihmis-esm-primary-navigation-app.js +6 -0
- package/dist/digihmis-esm-primary-navigation-app.js.buildmanifest.json +1542 -0
- package/dist/digihmis-esm-primary-navigation-app.js.map +1 -0
- package/dist/main.js +6 -0
- package/dist/main.js.map +1 -0
- package/dist/routes.json +1 -0
- package/jest.config.js +18 -0
- package/package.json +68 -0
- package/rspack.config.js +1 -0
- package/src/components/change-language/change-language-link.extension.tsx +37 -0
- package/src/components/change-language/change-language-link.scss +14 -0
- package/src/components/change-language/change-language.modal.tsx +115 -0
- package/src/components/change-language/change-language.resource.ts +35 -0
- package/src/components/change-language/change-language.scss +59 -0
- package/src/components/change-language/change-language.test.tsx +107 -0
- package/src/components/dashboard/dashboard.component.tsx +104 -0
- package/src/components/generic-link/generic-link.component.tsx +25 -0
- package/src/components/logo/logo.component.tsx +28 -0
- package/src/components/logo/logo.scss +11 -0
- package/src/components/logo/logo.test.tsx +79 -0
- package/src/components/nav-group/nav-group.component.tsx +52 -0
- package/src/components/navbar/app-menu-button.component.tsx +43 -0
- package/src/components/navbar/navbar.component.tsx +123 -0
- package/src/components/navbar/navbar.scss +51 -0
- package/src/components/navbar/types.ts +5 -0
- package/src/components/navbar/user-menu-button.component.tsx +44 -0
- package/src/components/navbar-header-panels/app-menu-panel.component.tsx +48 -0
- package/src/components/navbar-header-panels/app-menu-panel.scss +43 -0
- package/src/components/navbar-header-panels/notifications-menu-panel.component.tsx +23 -0
- package/src/components/navbar-header-panels/notifications-menu-panel.test.tsx +9 -0
- package/src/components/navbar-header-panels/notifications-menu.panel.scss +9 -0
- package/src/components/navbar-header-panels/side-menu-panel.component.tsx +37 -0
- package/src/components/navbar-header-panels/user-menu-panel.component.tsx +32 -0
- package/src/components/navbar-header-panels/user-menu-panel.scss +65 -0
- package/src/components/user-panel-switcher-item/user-panel-switcher.component.tsx +15 -0
- package/src/components/user-panel-switcher-item/user-panel-switcher.test.tsx +22 -0
- package/src/config-schema.ts +56 -0
- package/src/constants.ts +2 -0
- package/src/declarations.d.ts +1 -0
- package/src/index.ts +68 -0
- package/src/root.component.test.tsx +57 -0
- package/src/root.component.tsx +25 -0
- package/src/root.resource.tsx +13 -0
- package/src/root.scss +5 -0
- package/src/routes.json +78 -0
- package/src/setupTests.ts +7 -0
- package/src/utils.ts +3 -0
- package/translations/am.json +14 -0
- package/translations/ar.json +14 -0
- package/translations/ar_SY.json +14 -0
- package/translations/bn.json +14 -0
- package/translations/cs.json +14 -0
- package/translations/de.json +14 -0
- package/translations/en.json +14 -0
- package/translations/en_US.json +14 -0
- package/translations/es.json +14 -0
- package/translations/es_MX.json +14 -0
- package/translations/fr.json +14 -0
- package/translations/he.json +14 -0
- package/translations/hi.json +14 -0
- package/translations/hi_IN.json +14 -0
- package/translations/id.json +14 -0
- package/translations/it.json +14 -0
- package/translations/ka.json +14 -0
- package/translations/km.json +14 -0
- package/translations/ku.json +14 -0
- package/translations/ky.json +14 -0
- package/translations/lg.json +14 -0
- package/translations/ne.json +14 -0
- package/translations/pl.json +14 -0
- package/translations/pt.json +14 -0
- package/translations/pt_BR.json +14 -0
- package/translations/qu.json +14 -0
- package/translations/ro_RO.json +14 -0
- package/translations/ru_RU.json +14 -0
- package/translations/si.json +14 -0
- package/translations/sq.json +14 -0
- package/translations/sw.json +12 -0
- package/translations/sw_KE.json +14 -0
- package/translations/tr.json +14 -0
- package/translations/tr_TR.json +14 -0
- package/translations/uk.json +14 -0
- package/translations/uz.json +14 -0
- package/translations/uz@Latn.json +14 -0
- package/translations/uz_UZ.json +14 -0
- package/translations/vi.json +14 -0
- package/translations/zh.json +14 -0
- package/translations/zh_CN.json +14 -0
- package/translations/zh_TW.json +14 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import classNames from 'classnames';
|
|
2
|
+
import React, { useCallback, useMemo, useState } from 'react';
|
|
3
|
+
import { useTranslation } from 'react-i18next';
|
|
4
|
+
import { capitalize } from 'lodash-es';
|
|
5
|
+
import {
|
|
6
|
+
Button,
|
|
7
|
+
Checkbox,
|
|
8
|
+
InlineLoading,
|
|
9
|
+
ModalBody,
|
|
10
|
+
ModalFooter,
|
|
11
|
+
ModalHeader,
|
|
12
|
+
RadioButton,
|
|
13
|
+
RadioButtonGroup,
|
|
14
|
+
} from '@carbon/react';
|
|
15
|
+
import { useAbortController, useSession } from '@openmrs/esm-framework';
|
|
16
|
+
import { updateSessionLocale, updateUserProperties } from './change-language.resource';
|
|
17
|
+
import styles from './change-language.scss';
|
|
18
|
+
|
|
19
|
+
interface ChangeLanguageModalProps {
|
|
20
|
+
close(): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default function ChangeLanguageModal({ close }: ChangeLanguageModalProps) {
|
|
24
|
+
const { t } = useTranslation();
|
|
25
|
+
const session = useSession();
|
|
26
|
+
const user = session?.user;
|
|
27
|
+
const allowedLocales = session?.allowedLocales ?? [];
|
|
28
|
+
const [selectedLocale, setSelectedLocale] = useState(session?.locale);
|
|
29
|
+
const [shouldChangeDefaultLocale, setShouldChangeDefaultLocale] = useState(true);
|
|
30
|
+
const [isChangingLanguage, setIsChangingLanguage] = useState(false);
|
|
31
|
+
const ac = useAbortController();
|
|
32
|
+
|
|
33
|
+
const handleSubmit = useCallback(() => {
|
|
34
|
+
setIsChangingLanguage(true);
|
|
35
|
+
|
|
36
|
+
if (selectedLocale && selectedLocale !== session?.locale) {
|
|
37
|
+
const formattedLocale = selectedLocale.replace(/-/gi, '_');
|
|
38
|
+
if (shouldChangeDefaultLocale) {
|
|
39
|
+
updateUserProperties(
|
|
40
|
+
user.uuid,
|
|
41
|
+
{
|
|
42
|
+
...(user.userProperties ?? {}),
|
|
43
|
+
defaultLocale: formattedLocale,
|
|
44
|
+
},
|
|
45
|
+
ac,
|
|
46
|
+
);
|
|
47
|
+
} else {
|
|
48
|
+
updateSessionLocale(formattedLocale, ac);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}, [user.userProperties, user.uuid, selectedLocale, shouldChangeDefaultLocale]);
|
|
52
|
+
|
|
53
|
+
const languageNames = useMemo(
|
|
54
|
+
() =>
|
|
55
|
+
Object.fromEntries(
|
|
56
|
+
allowedLocales.map((locale) => [locale, new Intl.DisplayNames([locale], { type: 'language' }).of(locale)]),
|
|
57
|
+
),
|
|
58
|
+
[allowedLocales],
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<>
|
|
63
|
+
<ModalHeader closeModal={close} title={t('changeLanguage', 'Change language')} />
|
|
64
|
+
<ModalBody>
|
|
65
|
+
<div className={styles.languageOptionsContainer}>
|
|
66
|
+
<RadioButtonGroup
|
|
67
|
+
valueSelected={selectedLocale}
|
|
68
|
+
orientation="vertical"
|
|
69
|
+
name="Language options"
|
|
70
|
+
onChange={(locale) => setSelectedLocale(locale.toString())}
|
|
71
|
+
>
|
|
72
|
+
{allowedLocales.map((locale, i) => (
|
|
73
|
+
<RadioButton
|
|
74
|
+
className={styles.languageRadioButton}
|
|
75
|
+
key={`locale-option-${locale}-${i}`}
|
|
76
|
+
id={`locale-option-${locale}-${i}`}
|
|
77
|
+
name={locale}
|
|
78
|
+
labelText={capitalize(languageNames[locale])}
|
|
79
|
+
value={locale}
|
|
80
|
+
/>
|
|
81
|
+
))}
|
|
82
|
+
</RadioButtonGroup>
|
|
83
|
+
</div>
|
|
84
|
+
</ModalBody>
|
|
85
|
+
<div className={classNames('cds--layer-two', styles.updateDefaultLocaleContainer)} role="region">
|
|
86
|
+
<Checkbox
|
|
87
|
+
id={`change-default-locale`}
|
|
88
|
+
labelText={t('changeDefaultLocale', 'Save as my default language')}
|
|
89
|
+
checked={shouldChangeDefaultLocale}
|
|
90
|
+
onChange={(_, { checked }) => setShouldChangeDefaultLocale(checked)}
|
|
91
|
+
/>
|
|
92
|
+
<p className={classNames(styles.updateDefaultLocaleExplainer)}>
|
|
93
|
+
{t('changeDefaultLocaleExplanation', 'Leave this unchecked to change language for this session only')}
|
|
94
|
+
</p>
|
|
95
|
+
</div>
|
|
96
|
+
<ModalFooter>
|
|
97
|
+
<Button kind="secondary" onClick={close}>
|
|
98
|
+
{t('cancel', 'Cancel')}
|
|
99
|
+
</Button>
|
|
100
|
+
<Button
|
|
101
|
+
className={styles.submitButton}
|
|
102
|
+
disabled={isChangingLanguage || selectedLocale === session?.locale}
|
|
103
|
+
type="submit"
|
|
104
|
+
onClick={handleSubmit}
|
|
105
|
+
>
|
|
106
|
+
{isChangingLanguage ? (
|
|
107
|
+
<InlineLoading description={t('changingLanguage', 'Changing language') + '...'} />
|
|
108
|
+
) : (
|
|
109
|
+
<span>{t('change', 'Change')}</span>
|
|
110
|
+
)}
|
|
111
|
+
</Button>
|
|
112
|
+
</ModalFooter>
|
|
113
|
+
</>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { openmrsFetch, restBaseUrl } from '@openmrs/esm-framework/src/internal';
|
|
2
|
+
|
|
3
|
+
export type PostUserProperties = (
|
|
4
|
+
userUuid: string,
|
|
5
|
+
userProperties: Record<string, string>,
|
|
6
|
+
abortController?: AbortController,
|
|
7
|
+
) => Promise<void>;
|
|
8
|
+
|
|
9
|
+
export async function updateUserProperties(
|
|
10
|
+
userUuid: string,
|
|
11
|
+
userProperties: Record<string, string>,
|
|
12
|
+
abortController?: AbortController,
|
|
13
|
+
): Promise<void> {
|
|
14
|
+
await openmrsFetch(`${restBaseUrl}/user/${userUuid}`, {
|
|
15
|
+
method: 'POST',
|
|
16
|
+
body: { userProperties },
|
|
17
|
+
headers: { 'Content-Type': 'application/json' },
|
|
18
|
+
signal: abortController?.signal,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// Force the reload of the page to ensure all data coming from the backend is fetched in the newly set locale.
|
|
22
|
+
window.location.reload();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function updateSessionLocale(locale: string, abortController?: AbortController): Promise<void> {
|
|
26
|
+
await openmrsFetch(`${restBaseUrl}/session`, {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
body: { locale },
|
|
29
|
+
headers: { 'Content-Type': 'application/json' },
|
|
30
|
+
signal: abortController?.signal,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Force the reload of the page to ensure all data coming from the backend is fetched in the newly set locale.
|
|
34
|
+
window.location.reload();
|
|
35
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
@use '@carbon/layout';
|
|
2
|
+
@use '@carbon/type';
|
|
3
|
+
@use '@openmrs/esm-styleguide/src/vars' as *;
|
|
4
|
+
|
|
5
|
+
.primaryNavContainer {
|
|
6
|
+
height: var(--omrs-topnav-height);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.languageOptionsContainer {
|
|
10
|
+
display: flex;
|
|
11
|
+
flex-direction: column;
|
|
12
|
+
padding-bottom: layout.$spacing-05;
|
|
13
|
+
max-height: 28rem;
|
|
14
|
+
overflow-y: auto;
|
|
15
|
+
overflow-x: hidden;
|
|
16
|
+
padding-right: layout.$spacing-03; /* Prevent scrollbar overlap */
|
|
17
|
+
|
|
18
|
+
&::-webkit-scrollbar {
|
|
19
|
+
width: 8px;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
&::-webkit-scrollbar-track {
|
|
23
|
+
background: transparent;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
&::-webkit-scrollbar-thumb {
|
|
27
|
+
background: #888;
|
|
28
|
+
border-radius: 4px;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.languageRadioButton {
|
|
33
|
+
display: flex;
|
|
34
|
+
height: layout.$spacing-09;
|
|
35
|
+
align-items: center;
|
|
36
|
+
@include type.type-style('body-compact-01');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.updateDefaultLocaleContainer {
|
|
40
|
+
padding-top: layout.$spacing-03;
|
|
41
|
+
padding-left: layout.$spacing-05;
|
|
42
|
+
padding-right: layout.$spacing-05;
|
|
43
|
+
padding-bottom: layout.$spacing-06;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.updateDefaultLocaleExplainer {
|
|
47
|
+
@include type.type-style('body-compact-01');
|
|
48
|
+
color: $text-02;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.submitButton {
|
|
52
|
+
:global(.cds--inline-loading) {
|
|
53
|
+
min-height: layout.$spacing-05 !important;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
:global(.cds--inline-loading__text) {
|
|
57
|
+
font-size: unset !important;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { render, screen } from '@testing-library/react';
|
|
4
|
+
import { type LoggedInUser, type Session, useSession } from '@openmrs/esm-framework';
|
|
5
|
+
import ChangeLanguageModal from './change-language.modal';
|
|
6
|
+
|
|
7
|
+
const mockUser = {
|
|
8
|
+
uuid: 'uuid',
|
|
9
|
+
userProperties: {
|
|
10
|
+
defaultLocale: 'fr',
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const mockUpdateUserProperties = jest.fn((...args) => Promise.resolve());
|
|
15
|
+
const mockUpdateSessionLocale = jest.fn((...args) => Promise.resolve());
|
|
16
|
+
const mockUseSession = jest.mocked(useSession);
|
|
17
|
+
|
|
18
|
+
jest.mock('./change-language.resource', () => ({
|
|
19
|
+
updateUserProperties: (...args) => mockUpdateUserProperties(...args),
|
|
20
|
+
updateSessionLocale: (...args) => mockUpdateSessionLocale(...args),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
describe(`Change Language Modal`, () => {
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
mockUseSession.mockReturnValue({
|
|
26
|
+
authenticated: true,
|
|
27
|
+
user: mockUser as unknown as LoggedInUser,
|
|
28
|
+
allowedLocales: ['en', 'fr', 'it', 'pt'],
|
|
29
|
+
locale: 'fr',
|
|
30
|
+
} as Session);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should correctly displays all allowed locales', () => {
|
|
34
|
+
render(<ChangeLanguageModal close={jest.fn()} />);
|
|
35
|
+
|
|
36
|
+
expect(screen.getByRole('radio', { name: /english/i })).toBeInTheDocument();
|
|
37
|
+
expect(screen.getByRole('radio', { name: /français/i })).toBeInTheDocument();
|
|
38
|
+
expect(screen.getByRole('radio', { name: /italiano/i })).toBeInTheDocument();
|
|
39
|
+
expect(screen.getByRole('radio', { name: /português/i })).toBeInTheDocument();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should close the modal when the cancel button is clicked', async () => {
|
|
43
|
+
const user = userEvent.setup();
|
|
44
|
+
const mockClose = jest.fn();
|
|
45
|
+
|
|
46
|
+
render(<ChangeLanguageModal close={mockClose} />);
|
|
47
|
+
|
|
48
|
+
await user.click(screen.getByRole('button', { name: /cancel/i }));
|
|
49
|
+
expect(mockClose).toHaveBeenCalled();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should change user locale when the submit button is clicked', async () => {
|
|
53
|
+
const user = userEvent.setup();
|
|
54
|
+
|
|
55
|
+
render(<ChangeLanguageModal close={jest.fn()} />);
|
|
56
|
+
|
|
57
|
+
expect(screen.getByRole('radio', { name: /français/i })).toBeChecked();
|
|
58
|
+
|
|
59
|
+
await user.click(screen.getByRole('radio', { name: /english/i }));
|
|
60
|
+
await user.click(screen.getByRole('button', { name: /change/i }));
|
|
61
|
+
|
|
62
|
+
expect(mockUpdateUserProperties).toHaveBeenCalledWith(mockUser.uuid, { defaultLocale: 'en' }, expect.anything());
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should show a loading indicator in the submit button while language change is in progress', async () => {
|
|
66
|
+
const user = userEvent.setup();
|
|
67
|
+
mockUpdateUserProperties.mockImplementation(() => new Promise(() => {}));
|
|
68
|
+
|
|
69
|
+
render(<ChangeLanguageModal close={jest.fn()} />);
|
|
70
|
+
|
|
71
|
+
await user.click(screen.getByRole('radio', { name: /english/i }));
|
|
72
|
+
await user.click(screen.getByRole('button', { name: /change/i }));
|
|
73
|
+
|
|
74
|
+
expect(screen.getByText(/changing language.../i)).toBeInTheDocument();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should display the "Save as my default language" checkbox checked by default', () => {
|
|
78
|
+
render(<ChangeLanguageModal close={jest.fn()} />);
|
|
79
|
+
|
|
80
|
+
const checkbox = screen.getByRole('checkbox', { name: /Save as my default language/i });
|
|
81
|
+
expect(checkbox).toBeChecked();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should call updateSessionLocale when checkbox is unchecked and user changes locale', async () => {
|
|
85
|
+
const user = userEvent.setup();
|
|
86
|
+
|
|
87
|
+
render(<ChangeLanguageModal close={jest.fn()} />);
|
|
88
|
+
|
|
89
|
+
// Uncheck the checkbox to only update session locale
|
|
90
|
+
const checkbox = screen.getByRole('checkbox', { name: /Save as my default language/i });
|
|
91
|
+
await user.click(checkbox);
|
|
92
|
+
|
|
93
|
+
// Change locale
|
|
94
|
+
await user.click(screen.getByRole('radio', { name: /english/i }));
|
|
95
|
+
await user.click(screen.getByRole('button', { name: /change/i }));
|
|
96
|
+
|
|
97
|
+
expect(mockUpdateSessionLocale).toHaveBeenCalledWith('en', expect.anything());
|
|
98
|
+
expect(mockUpdateUserProperties).not.toHaveBeenCalled();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should disable submit button when selected locale is same as current locale', () => {
|
|
102
|
+
render(<ChangeLanguageModal close={jest.fn()} />);
|
|
103
|
+
|
|
104
|
+
const submitButton = screen.getByRole('button', { name: /change/i });
|
|
105
|
+
expect(submitButton).toBeDisabled();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import React, { useContext, useMemo } from 'react';
|
|
2
|
+
import { BrowserRouter } from 'react-router-dom';
|
|
3
|
+
import { useTranslation } from 'react-i18next';
|
|
4
|
+
import { InlineNotification } from '@carbon/react';
|
|
5
|
+
import {
|
|
6
|
+
ComponentContext,
|
|
7
|
+
DashboardExtension,
|
|
8
|
+
getCoreTranslation,
|
|
9
|
+
useConfig,
|
|
10
|
+
openmrsComponentDecorator,
|
|
11
|
+
type OpenmrsReactComponentProps,
|
|
12
|
+
Type,
|
|
13
|
+
} from '@openmrs/esm-framework/src/internal';
|
|
14
|
+
|
|
15
|
+
export const dashboardConfigSchema = {
|
|
16
|
+
title: {
|
|
17
|
+
_description: 'The display string for this dashboard',
|
|
18
|
+
_default: 'My Dashboard',
|
|
19
|
+
_type: Type.String,
|
|
20
|
+
},
|
|
21
|
+
path: {
|
|
22
|
+
_description: 'The URL fragment this link points to',
|
|
23
|
+
_default: '',
|
|
24
|
+
_type: Type.String,
|
|
25
|
+
},
|
|
26
|
+
icon: {
|
|
27
|
+
_description: 'The icons for the navigation menu',
|
|
28
|
+
_default: '',
|
|
29
|
+
_type: Type.String,
|
|
30
|
+
},
|
|
31
|
+
moduleName: {
|
|
32
|
+
_description: 'The module which the extension should be loaded into',
|
|
33
|
+
_default: '',
|
|
34
|
+
_type: Type.String,
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export interface DashboardConfig {
|
|
39
|
+
path: string;
|
|
40
|
+
title: string;
|
|
41
|
+
icon: string;
|
|
42
|
+
moduleName: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface DashboardProps {
|
|
46
|
+
basePath: string;
|
|
47
|
+
moduleName?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export default function Dashboard({ basePath, moduleName }: DashboardProps) {
|
|
51
|
+
const componentContext = useContext(ComponentContext);
|
|
52
|
+
const module = moduleName ?? componentContext.extension?.extensionSlotModuleName ?? componentContext.moduleName;
|
|
53
|
+
|
|
54
|
+
const Component = useMemo(
|
|
55
|
+
() =>
|
|
56
|
+
openmrsComponentDecorator<OpenmrsReactComponentProps>({
|
|
57
|
+
moduleName: module,
|
|
58
|
+
featureName: 'dashboard',
|
|
59
|
+
})(() => <DashboardInternal basePath={basePath} />),
|
|
60
|
+
[basePath, module],
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<BrowserRouter>
|
|
65
|
+
<Component
|
|
66
|
+
_extensionContext={{
|
|
67
|
+
extensionId: componentContext.extension?.extensionId,
|
|
68
|
+
extensionSlotName: componentContext.extension?.extensionSlotName,
|
|
69
|
+
extensionSlotModuleName: module,
|
|
70
|
+
}}
|
|
71
|
+
/>
|
|
72
|
+
</BrowserRouter>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// t('noPathInDashboardExtension', 'Cannot render the dashboard extension without the property "path" being set in the configuration schema')
|
|
77
|
+
function DashboardInternal({ basePath }: { basePath: string }) {
|
|
78
|
+
const { t } = useTranslation('@openmrs/esm-primary-navigation-app');
|
|
79
|
+
const config = useConfig<DashboardConfig>();
|
|
80
|
+
|
|
81
|
+
if (!config.path) {
|
|
82
|
+
return (
|
|
83
|
+
<InlineNotification
|
|
84
|
+
kind="error"
|
|
85
|
+
subtitle={t(
|
|
86
|
+
'noPathInDashboardExtension',
|
|
87
|
+
`Cannot render the dashboard extension without the property "path" being set in the configuration schema`,
|
|
88
|
+
)}
|
|
89
|
+
title={getCoreTranslation('error')}
|
|
90
|
+
/>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// config.icon is a free-form string from the config schema; DashboardExtension types
|
|
95
|
+
// it as a fixed icon-id union, so narrow it to the prop's expected type.
|
|
96
|
+
return (
|
|
97
|
+
<DashboardExtension
|
|
98
|
+
path={config.path}
|
|
99
|
+
title={config.title}
|
|
100
|
+
basePath={basePath}
|
|
101
|
+
icon={config.icon as React.ComponentProps<typeof DashboardExtension>['icon']}
|
|
102
|
+
/>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { ConfigurableLink, Type, useConfig, validators } from '@openmrs/esm-framework';
|
|
3
|
+
|
|
4
|
+
export const genericLinkConfigSchema = {
|
|
5
|
+
title: {
|
|
6
|
+
_default: 'New Link',
|
|
7
|
+
_type: Type.String,
|
|
8
|
+
},
|
|
9
|
+
target: {
|
|
10
|
+
_default: '#',
|
|
11
|
+
_type: Type.String,
|
|
12
|
+
_description: 'The URL to link to.',
|
|
13
|
+
_validators: [validators.isUrl],
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export interface GenericLinkConfig {
|
|
18
|
+
title: string;
|
|
19
|
+
target: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default function GenericLink() {
|
|
23
|
+
const config = useConfig<GenericLinkConfig>();
|
|
24
|
+
return <ConfigurableLink to={config.target}>{config.title}</ConfigurableLink>;
|
|
25
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { interpolateUrl, useConfig } from '@openmrs/esm-framework';
|
|
3
|
+
import { type ConfigSchema } from '../../config-schema';
|
|
4
|
+
import styles from './logo.scss';
|
|
5
|
+
|
|
6
|
+
const Logo: React.FC = () => {
|
|
7
|
+
const { logo } = useConfig<ConfigSchema>();
|
|
8
|
+
|
|
9
|
+
const handleImageError = (e: React.SyntheticEvent<HTMLImageElement>) => {
|
|
10
|
+
console.error('Failed to load logo image:', e);
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<>
|
|
15
|
+
{logo?.src ? (
|
|
16
|
+
<img alt={logo.alt} className={styles.logo} onError={handleImageError} src={interpolateUrl(logo.src)} />
|
|
17
|
+
) : logo?.name ? (
|
|
18
|
+
logo.name
|
|
19
|
+
) : (
|
|
20
|
+
<svg aria-label="OpenMRS Logo" role="img" width={110} height={40}>
|
|
21
|
+
<use href="#omrs-logo-white" />
|
|
22
|
+
</svg>
|
|
23
|
+
)}
|
|
24
|
+
</>
|
|
25
|
+
);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export default Logo;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { render, screen } from '@testing-library/react';
|
|
4
|
+
import { useConfig } from '@openmrs/esm-framework';
|
|
5
|
+
import { type ConfigSchema } from '../../config-schema';
|
|
6
|
+
import Logo from './logo.component';
|
|
7
|
+
|
|
8
|
+
// FIXME: Figure out why I can't annotate this mock like so: jest.mocked(useConfig<ConfigSchema>);
|
|
9
|
+
const mockUseConfig = jest.mocked(useConfig);
|
|
10
|
+
|
|
11
|
+
describe('Logo', () => {
|
|
12
|
+
it('should display the OpenMRS logo by default', () => {
|
|
13
|
+
const mockConfig = { logo: { src: null, alt: null, name: null } };
|
|
14
|
+
mockUseConfig.mockReturnValue(mockConfig as ConfigSchema);
|
|
15
|
+
|
|
16
|
+
render(<Logo />);
|
|
17
|
+
|
|
18
|
+
const logo = screen.getByRole('img');
|
|
19
|
+
|
|
20
|
+
expect(logo).toBeInTheDocument();
|
|
21
|
+
expect(logo).toContainHTML('svg');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should display name', () => {
|
|
25
|
+
const mockConfig = {
|
|
26
|
+
logo: { src: null, alt: null, name: 'Some weird EMR', link: null },
|
|
27
|
+
externalRefLinks: null,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
mockUseConfig.mockReturnValue(mockConfig as ConfigSchema);
|
|
31
|
+
|
|
32
|
+
render(<Logo />);
|
|
33
|
+
|
|
34
|
+
expect(screen.getByText(/Some weird EMR/i)).toBeInTheDocument();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should display image logo', () => {
|
|
38
|
+
const mockConfig = {
|
|
39
|
+
logo: {
|
|
40
|
+
src: 'https://someimage.png',
|
|
41
|
+
alt: 'alternative text',
|
|
42
|
+
name: null,
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
mockUseConfig.mockReturnValue(mockConfig);
|
|
47
|
+
|
|
48
|
+
render(<Logo />);
|
|
49
|
+
|
|
50
|
+
const logo = screen.getByRole('img');
|
|
51
|
+
|
|
52
|
+
expect(logo).toBeInTheDocument();
|
|
53
|
+
expect(logo).toHaveAttribute('alt');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should handle image load errors', () => {
|
|
57
|
+
const user = userEvent.setup();
|
|
58
|
+
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
|
|
59
|
+
const mockConfig = {
|
|
60
|
+
logo: {
|
|
61
|
+
src: 'invalid-image.png',
|
|
62
|
+
alt: 'alt text',
|
|
63
|
+
name: null,
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
mockUseConfig.mockReturnValue(mockConfig);
|
|
68
|
+
|
|
69
|
+
render(<Logo />);
|
|
70
|
+
|
|
71
|
+
const img = screen.getByRole('img');
|
|
72
|
+
|
|
73
|
+
const errorEvent = new Event('error');
|
|
74
|
+
img.dispatchEvent(errorEvent);
|
|
75
|
+
|
|
76
|
+
expect(consoleSpy).toHaveBeenCalledWith('Failed to load logo image:', expect.any(Object));
|
|
77
|
+
consoleSpy.mockRestore();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import React, { useContext } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import { Accordion, AccordionItem } from '@carbon/react';
|
|
4
|
+
import { ComponentContext, ExtensionSlot, useConfig, Type } from '@openmrs/esm-framework/src/internal';
|
|
5
|
+
|
|
6
|
+
export const navGroupFeatureName = 'Nav Group';
|
|
7
|
+
|
|
8
|
+
export const navGroupConfigSchema = {
|
|
9
|
+
title: {
|
|
10
|
+
_type: Type.String,
|
|
11
|
+
_description: 'The title of the nav group.',
|
|
12
|
+
_default: 'My Group',
|
|
13
|
+
},
|
|
14
|
+
slotName: {
|
|
15
|
+
_type: Type.String,
|
|
16
|
+
_description: 'The name of the slot to create, which links can be added to.',
|
|
17
|
+
_default: '',
|
|
18
|
+
},
|
|
19
|
+
isExpanded: {
|
|
20
|
+
_type: Type.Boolean,
|
|
21
|
+
_description: 'The boolean to determine whether the nav group is expanded or not.',
|
|
22
|
+
_default: false,
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export interface NavGroupConfig {
|
|
27
|
+
title: string;
|
|
28
|
+
slotName: string;
|
|
29
|
+
isExpanded?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface NavGroupProps {
|
|
33
|
+
basePath?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function NavGroup({ basePath }: NavGroupProps) {
|
|
37
|
+
const componentContext = useContext(ComponentContext);
|
|
38
|
+
|
|
39
|
+
const { t } = useTranslation();
|
|
40
|
+
const { title, isExpanded, slotName } = useConfig<NavGroupConfig>();
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<Accordion>
|
|
44
|
+
<AccordionItem open={isExpanded} title={t(title)}>
|
|
45
|
+
<ExtensionSlot
|
|
46
|
+
name={slotName ?? `nav-group-${title}`}
|
|
47
|
+
state={{ basePath, moduleName: componentContext.extension?.extensionSlotModuleName }}
|
|
48
|
+
/>
|
|
49
|
+
</AccordionItem>
|
|
50
|
+
</Accordion>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { HeaderGlobalAction } from '@carbon/react';
|
|
2
|
+
import { CloseIcon, SwitcherIcon, useAssignedExtensions, useOnClickOutside } from '@openmrs/esm-framework';
|
|
3
|
+
import classNames from 'classnames';
|
|
4
|
+
import React, { useMemo } from 'react';
|
|
5
|
+
import { useTranslation } from 'react-i18next';
|
|
6
|
+
import AppMenuPanel from '../navbar-header-panels/app-menu-panel.component';
|
|
7
|
+
import styles from './navbar.scss';
|
|
8
|
+
import { type MenuButtonProps } from './types';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* This component displays the app menu button and the menu itself (when toggled on)
|
|
12
|
+
*/
|
|
13
|
+
const AppMenuButton: React.FC<MenuButtonProps> = ({ isActivePanel, togglePanel, hidePanel }) => {
|
|
14
|
+
const appMenuItems = useAssignedExtensions('app-menu-slot');
|
|
15
|
+
const showAppMenu = useMemo(() => appMenuItems.length > 0, [appMenuItems.length]);
|
|
16
|
+
const { t } = useTranslation();
|
|
17
|
+
const appMenuRef = useOnClickOutside<HTMLDivElement>(hidePanel('appMenu'), isActivePanel('appMenu'));
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
showAppMenu && (
|
|
21
|
+
<div ref={appMenuRef} className={styles.panelWrapper}>
|
|
22
|
+
<HeaderGlobalAction
|
|
23
|
+
aria-label={t('AppMenuTooltip', 'App Menu')}
|
|
24
|
+
aria-labelledby="App Menu"
|
|
25
|
+
className={classNames({
|
|
26
|
+
[styles.headerGlobalBarButton]: isActivePanel('appMenu'),
|
|
27
|
+
[styles.activePanel]: !isActivePanel('appMenu'),
|
|
28
|
+
})}
|
|
29
|
+
isActive={isActivePanel('appMenu')}
|
|
30
|
+
onClick={() => {
|
|
31
|
+
togglePanel('appMenu');
|
|
32
|
+
}}
|
|
33
|
+
tooltipAlignment="end"
|
|
34
|
+
>
|
|
35
|
+
{isActivePanel('appMenu') ? <CloseIcon size={20} /> : <SwitcherIcon size={20} />}
|
|
36
|
+
</HeaderGlobalAction>
|
|
37
|
+
<AppMenuPanel expanded={isActivePanel('appMenu')} hidePanel={hidePanel('appMenu')} />
|
|
38
|
+
</div>
|
|
39
|
+
)
|
|
40
|
+
);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export default AppMenuButton;
|