@openmrs/esm-user-onboarding-app 4.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.
Files changed (51) hide show
  1. package/.editorconfig +12 -0
  2. package/.eslintignore +2 -0
  3. package/.eslintrc +57 -0
  4. package/.husky/pre-commit +7 -0
  5. package/.husky/pre-push +6 -0
  6. package/.prettierignore +14 -0
  7. package/.turbo.json +18 -0
  8. package/.yarn/plugins/@yarnpkg/plugin-outdated.cjs +35 -0
  9. package/LICENSE +401 -0
  10. package/README.md +34 -0
  11. package/__mocks__/react-i18next.js +50 -0
  12. package/e2e/README.md +115 -0
  13. package/e2e/core/global-setup.ts +32 -0
  14. package/e2e/core/index.ts +1 -0
  15. package/e2e/core/test.ts +20 -0
  16. package/e2e/fixtures/api.ts +26 -0
  17. package/e2e/fixtures/index.ts +1 -0
  18. package/e2e/pages/home-page.ts +9 -0
  19. package/e2e/pages/index.ts +1 -0
  20. package/e2e/specs/onboarding-test.spec.ts +93 -0
  21. package/e2e/support/github/Dockerfile +34 -0
  22. package/e2e/support/github/docker-compose.yml +24 -0
  23. package/e2e/support/github/run-e2e-docker-env.sh +44 -0
  24. package/example.env +6 -0
  25. package/i18next-parser.config.js +89 -0
  26. package/jest.config.js +33 -0
  27. package/package.json +106 -0
  28. package/playwright.config.ts +39 -0
  29. package/prettier.config.js +8 -0
  30. package/src/config-schema.ts +630 -0
  31. package/src/declarations.d.ts +4 -0
  32. package/src/index.ts +22 -0
  33. package/src/root.component.tsx +109 -0
  34. package/src/root.scss +0 -0
  35. package/src/routes.json +28 -0
  36. package/src/setup-tests.ts +1 -0
  37. package/src/tooltip/tooltip.component.tsx +73 -0
  38. package/src/tooltip/tooltip.scss +83 -0
  39. package/src/tutorial/modal.component.test.tsx +98 -0
  40. package/src/tutorial/modal.component.tsx +66 -0
  41. package/src/tutorial/styles.scss +64 -0
  42. package/src/tutorial/tutorial.tsx +22 -0
  43. package/src/types.ts +15 -0
  44. package/translations/am.json +1 -0
  45. package/translations/en.json +9 -0
  46. package/translations/es.json +1 -0
  47. package/translations/fr.json +1 -0
  48. package/translations/he.json +1 -0
  49. package/translations/km.json +1 -0
  50. package/tsconfig.json +23 -0
  51. package/webpack.config.js +1 -0
@@ -0,0 +1,109 @@
1
+ import React from 'react';
2
+ import ReactJoyride, { ACTIONS, type CallBackProps, EVENTS, type Step } from 'react-joyride';
3
+ import { useDefineAppContext } from '@openmrs/esm-framework';
4
+ import { type TutorialContext } from './types';
5
+ import CustomTooltip from './tooltip/tooltip.component';
6
+
7
+ const RootComponent: React.FC = () => {
8
+
9
+ const [showTutorial, setShowTutorial] = React.useState(false);
10
+ const [steps, setSteps] = React.useState<Step[]>([]);
11
+ const [stepIndex, setStepIndex] = React.useState(0);
12
+
13
+ // Set steps with default step options
14
+ const updateSteps = (newSteps: Step[]) => {
15
+ setSteps(
16
+ newSteps.map((step) => ({
17
+ ...step,
18
+ disableBeacon: true,
19
+ })),
20
+ );
21
+ };
22
+
23
+ useDefineAppContext<TutorialContext>('tutorial-context', {
24
+ showTutorial,
25
+ steps,
26
+ setShowTutorial: (showTutorial: boolean) => setShowTutorial(showTutorial),
27
+ setSteps: updateSteps,
28
+ });
29
+
30
+ const onStepChange = (index: number) => {
31
+ const step = steps[index];
32
+ if (step.data && step.data.autoNextOn) {
33
+ handleAutoNext(step.data.autoNextOn, index);
34
+ }
35
+ }
36
+
37
+ const waitForTarget = (index: number) => {
38
+ setShowTutorial(false);
39
+ const interval = setInterval(() => {
40
+ const targetElement = document.querySelector(steps[index].target as string);
41
+ if (targetElement) {
42
+ setShowTutorial(true);
43
+ clearTimeout(interval);
44
+ }
45
+ }, 1000);
46
+
47
+ };
48
+
49
+ const handleAutoNext = (query: string, index: number) => {
50
+ const interval = setInterval(() => {
51
+ const targetElement = document.querySelector(query);
52
+ if (targetElement) {
53
+ setStepIndex(index + 1);
54
+ clearTimeout(interval);
55
+ }
56
+ }, 1000);
57
+ }
58
+
59
+ const currentStep = steps[stepIndex];
60
+ const overlayStyles = currentStep?.disableOverlay
61
+ ? { backgroundColor: 'transparent' }
62
+ : { height: document.body.scrollHeight };
63
+
64
+ const handleJoyrideCallback = (data: CallBackProps) => {
65
+ const {action, index, origin, status, type} = data;
66
+ switch (type) {
67
+ case EVENTS.TOUR_START:
68
+ // The target not found event is not triggered when the tour starts
69
+ waitForTarget(0);
70
+ break;
71
+ case EVENTS.STEP_BEFORE:
72
+ onStepChange(index);
73
+ break;
74
+ case EVENTS.STEP_AFTER:
75
+ setStepIndex(index + (action === ACTIONS.PREV ? -1 : 1));
76
+ break;
77
+ case EVENTS.TARGET_NOT_FOUND:
78
+ waitForTarget(index);
79
+ break;
80
+ case EVENTS.TOUR_END:
81
+ setStepIndex(0)
82
+ setShowTutorial(false);
83
+ break;
84
+ }
85
+ }
86
+
87
+ return (
88
+ <ReactJoyride
89
+ continuous
90
+ debug
91
+ disableScrolling
92
+ showProgress
93
+ showSkipButton
94
+ steps={steps}
95
+ stepIndex={stepIndex}
96
+ run={showTutorial}
97
+ callback={handleJoyrideCallback}
98
+ disableOverlayClose={true}
99
+ tooltipComponent={(props) => <CustomTooltip {...props} step={steps[props.index]} totalSteps={steps.length} />}
100
+ styles={{
101
+ options: {
102
+ zIndex: 10000,
103
+ },
104
+ overlay: overlayStyles,
105
+ }}
106
+ />
107
+ );
108
+ };
109
+ export default RootComponent;
package/src/root.scss ADDED
File without changes
@@ -0,0 +1,28 @@
1
+ {
2
+ "$schema": "https://json.openmrs.org/routes.schema.json",
3
+ "backendDependencies": {
4
+ "fhir2": ">=1.2",
5
+ "webservices.rest": "^2.24.0"
6
+ },
7
+ "extensions": [
8
+ {
9
+ "name": "tutorials",
10
+ "slot": "help-menu-slot",
11
+ "component": "tutorial",
12
+ "online": true,
13
+ "offline": true
14
+ }
15
+ ],
16
+ "modals": [
17
+ {
18
+ "name": "tutorial-modal",
19
+ "component": "tutorialModal"
20
+ }
21
+ ],
22
+ "pages": [
23
+ {
24
+ "component": "root",
25
+ "route": true
26
+ }
27
+ ]
28
+ }
@@ -0,0 +1 @@
1
+ import '@testing-library/jest-dom';
@@ -0,0 +1,73 @@
1
+ import React from 'react';
2
+ import { Button } from '@carbon/react';
3
+ import { ArrowLeft, ArrowRight, Close } from '@carbon/react/icons';
4
+ import { useTranslation } from 'react-i18next';
5
+ import styles from './tooltip.scss';
6
+
7
+ interface CustomTooltipProps {
8
+ continuous: boolean;
9
+ index: number;
10
+ step: any;
11
+ backProps: any;
12
+ skipProps: any;
13
+ primaryProps: any;
14
+ tooltipProps: any;
15
+ totalSteps: number;
16
+ }
17
+
18
+ const CustomTooltip: React.FC<CustomTooltipProps> = ({
19
+ continuous,
20
+ index,
21
+ step,
22
+ backProps,
23
+ primaryProps,
24
+ skipProps,
25
+ tooltipProps,
26
+ totalSteps,
27
+ }) => {
28
+ const { t } = useTranslation();
29
+ const isLastStep = index === totalSteps - 1;
30
+
31
+ return (
32
+ <div {...tooltipProps} className={styles.tooltipcontainer}>
33
+ <div className={styles.tooltipheader}>
34
+ <div className={styles.container}>
35
+ <h4 className={styles.tooltiptitle}>{step.title}</h4>
36
+ <div className={styles.tooltipcontent}>{step.content}</div>
37
+ </div>
38
+ <Button {...skipProps} size="sm" kind="ghost" className={styles.closeButton}>
39
+ <Close />
40
+ </Button>
41
+ </div>
42
+ <div className={styles.tooltipfooter}>
43
+ <span className={styles.tooltipstep}>{`${index + 1} of ${totalSteps}`}</span>
44
+ <div className={styles.buttonContainer}>
45
+ {!step.hideBackButton && index > 0 && (
46
+ <Button {...backProps} size="sm" kind="ghost" className={styles.buttonback}>
47
+ <div className={styles.arrowLeft}>
48
+ <ArrowLeft />
49
+ </div>
50
+ {t('back', 'Back')}
51
+ </Button>
52
+ )}
53
+ {continuous && !step.hideNextButton && (
54
+ <Button {...primaryProps} size="sm" className={styles.buttonnext}>
55
+ {isLastStep ? (
56
+ <>{t('finish', 'Finish')}</>
57
+ ) : (
58
+ <>
59
+ {t('next', 'Next')}
60
+ <div className={styles.arrowContainer}>
61
+ <ArrowRight />
62
+ </div>
63
+ </>
64
+ )}
65
+ </Button>
66
+ )}
67
+ </div>
68
+ </div>
69
+ </div>
70
+ );
71
+ };
72
+
73
+ export default CustomTooltip;
@@ -0,0 +1,83 @@
1
+ @use '@carbon/styles/scss/spacing';
2
+ @use '@carbon/styles/scss/type';
3
+ @import '~@openmrs/esm-styleguide/src/vars';
4
+
5
+ .tooltipcontainer {
6
+ background-color: $ui-02;
7
+ padding: spacing.$spacing-05;
8
+ box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
9
+ max-width: 360px;
10
+ border-radius: spacing.$spacing-02;
11
+ gap: spacing.$spacing-04;
12
+
13
+ .tooltipheader {
14
+ display: flex;
15
+ justify-content: space-between;
16
+ align-items: center;
17
+ margin-bottom: spacing.$spacing-01;
18
+
19
+ .container {
20
+ display: flex;
21
+ flex-direction: column;
22
+ flex-grow: 1;
23
+ }
24
+
25
+ .tooltiptitle {
26
+ @include type.type-style('heading-02');
27
+ margin-bottom: spacing.$spacing-02;
28
+ }
29
+
30
+ .closeButton {
31
+ min-height: 16px;
32
+ padding: 0;
33
+ align-self: flex-start;
34
+ }
35
+ }
36
+
37
+ .tooltipcontent {
38
+ color: $color-gray-70;
39
+ @include type.type-style('body-compact-01');
40
+ width: 100%;
41
+ margin-top: spacing.$spacing-01;
42
+ }
43
+
44
+
45
+ .tooltipfooter {
46
+ margin-top: spacing.$spacing-03;
47
+ display: flex;
48
+ justify-content: space-between;
49
+ align-items: center;
50
+
51
+ .tooltipstep {
52
+ color: $color-gray-70;
53
+ @include type.type-style('body-compact-01');
54
+ }
55
+
56
+ .buttonContainer {
57
+ display: flex;
58
+ gap: spacing.$spacing-03;
59
+
60
+ .buttonback,
61
+ .buttonnext {
62
+ display: flex;
63
+ align-items: center;
64
+ justify-content: center;
65
+ padding: spacing.$spacing-02;
66
+ width: spacing.$spacing-12;
67
+
68
+ .arrowLeft,
69
+ .arrowContainer {
70
+ display: flex;
71
+ align-items: center;
72
+ justify-content: center;
73
+ margin-left: spacing.$spacing-02;
74
+ }
75
+
76
+ .arrowLeft svg {
77
+ fill: $color-blue-60-2 !important;
78
+ margin-right: spacing.$spacing-02;
79
+ }
80
+ }
81
+ }
82
+ }
83
+ }
@@ -0,0 +1,98 @@
1
+ import React from 'react';
2
+ import {render, screen, waitFor} from '@testing-library/react';
3
+ import TutorialModal from './modal.component';
4
+ import {useAppContext, useConfig, navigate} from "@openmrs/esm-framework";
5
+ import userEvent from '@testing-library/user-event'
6
+
7
+ jest.mock('@openmrs/esm-framework', () => ({
8
+ useConfig: jest.fn(),
9
+ useAppContext: jest.fn(),
10
+ navigate: jest.fn(),
11
+ }));
12
+
13
+ const setShowTutorial = jest.fn();
14
+ const setSteps = jest.fn();
15
+
16
+ const mockUseAppContext = jest.mocked(useAppContext);
17
+ const mockUseConfig = jest.mocked(useConfig);
18
+ const mockNavigate = jest.mocked(navigate);
19
+
20
+ const mockTutorialData = [
21
+ {
22
+ title: 'Basic Tutorial',
23
+ description: 'This Shows Basic Tutorials',
24
+ steps: [
25
+ { target: '[aria-label="OpenMRS"]', content: 'Welcome to OpenMRS' },
26
+ { target: '[aria-label="NavBar"]', content: 'This is the Navbar' },
27
+ ],
28
+ },
29
+ {
30
+ title: 'Patient Registration Tutorial',
31
+ description: 'This Shows how to register a patient in OpenMRS',
32
+ steps: [
33
+ {
34
+ target: '[aria-label="add-btn"]',
35
+ content: 'This is the Add Patient button',
36
+ },
37
+ {
38
+ target: '[aria-label="register-form"]',
39
+ content: 'This is the Registration form',
40
+ },
41
+ {
42
+ target: '[aria-label="register-btn"]',
43
+ content: 'This is the Register button',
44
+ },
45
+ ],
46
+ },
47
+ ];
48
+
49
+ mockUseConfig.mockReturnValue({
50
+ tutorialData: mockTutorialData,
51
+ });
52
+
53
+ mockUseAppContext.mockReturnValue({
54
+ setShowTutorial,
55
+ setSteps,
56
+ });
57
+
58
+ describe('TutorialModal', () => {
59
+ afterEach(() => {
60
+ jest.clearAllMocks();
61
+ delete window.location;
62
+ window.location = { pathname: '/patient-registration' } as any;
63
+ });
64
+
65
+ test('sends correct data to the root component when walkthrough button is clicked', async () => {
66
+ const user = userEvent.setup();
67
+
68
+ (window as any).getOpenmrsSpaBase = jest.fn(() => '/spa-base/');
69
+ Object.defineProperty(window, 'location', {
70
+ value: {
71
+ pathname: '/patient-registration',
72
+ },
73
+ });
74
+
75
+ render(<TutorialModal open={true} onClose={jest.fn()}/>);
76
+
77
+ const walkthroughButton = screen.getAllByText('Walkthrough');
78
+ await user.click(walkthroughButton[0]);
79
+
80
+ expect(navigate).toHaveBeenCalledWith({ to: '/spa-base/home' });
81
+ Object.defineProperty(window.location, 'pathname', {
82
+ value: '/spa-base/home',
83
+ });
84
+
85
+ await waitFor(() => expect(setSteps).toHaveBeenCalledWith(mockTutorialData[0].steps));
86
+ await waitFor(() => expect(setShowTutorial).toHaveBeenCalledWith(true));
87
+ });
88
+
89
+ test('renders tutorials properly', async () => {
90
+ render(<TutorialModal open={true} onClose={jest.fn()} />);
91
+
92
+ mockTutorialData.forEach((tutorial) => {
93
+ expect(screen.getByText(tutorial.title)).toBeInTheDocument();
94
+ expect(screen.getByText(tutorial.description)).toBeInTheDocument();
95
+ });
96
+ });
97
+ });
98
+
@@ -0,0 +1,66 @@
1
+ import React from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { useConfig, useAppContext, navigate } from '@openmrs/esm-framework';
4
+ import styles from './styles.scss';
5
+ import { type TutorialContext } from '../types';
6
+ import { ModalHeader, ModalBody, Link } from '@carbon/react';
7
+ import { ArrowRight } from '@carbon/react/icons';
8
+
9
+ const TutorialModal = ({ open, onClose }) => {
10
+ const { t } = useTranslation();
11
+ const config = useConfig();
12
+ const tutorials = config.tutorialData;
13
+ const tutorialContext = useAppContext<TutorialContext>('tutorial-context');
14
+
15
+ const handleWalkthroughClick = (index: number) => {
16
+ const basePath = window.getOpenmrsSpaBase();
17
+ const homePath = `${basePath}home`;
18
+ const currentPath = window.location.pathname;
19
+ const tutorial = tutorials[index];
20
+
21
+ const setTutorialSteps = () => {
22
+ tutorialContext.setSteps(tutorial.steps);
23
+ tutorialContext.setShowTutorial(true);
24
+ };
25
+
26
+ if (currentPath === homePath) {
27
+ setTutorialSteps();
28
+ } else {
29
+ navigate({ to: homePath });
30
+
31
+ const intervalId = setInterval(() => {
32
+ if (window.location.pathname === homePath) {
33
+ setTutorialSteps();
34
+ clearInterval(intervalId);
35
+ }
36
+ }, 100);
37
+ }
38
+ onClose();
39
+ };
40
+
41
+ return (
42
+ <React.Fragment>
43
+ <ModalHeader closeModal={onClose} title={t('tutorial', 'Tutorial')} className={styles.modalHeader}>
44
+ <p className={styles.description}>
45
+ {t('modalDescription', 'Find walkthroughs and video tutorials on some of the core features of OpenMRS.')}
46
+ </p>
47
+ </ModalHeader>
48
+ <ModalBody className={styles.tutorialModal}>
49
+ <ul>
50
+ {tutorials.map((tutorial, index) => (
51
+ <li className={styles.tutorialItem} key={index}>
52
+ <h3 className={styles.tutorialTitle}>{tutorial.title}</h3>
53
+ <p className={styles.tutorialDescription}>{tutorial.description}</p>
54
+ <Link onClick={() => handleWalkthroughClick(index)} className={styles.tutorialLink} renderIcon={() =>
55
+ <ArrowRight aria-label="Arrow Right" />}>
56
+ {t('walkthrough', 'Walkthrough')}
57
+ </Link>
58
+ </li>
59
+ ))}
60
+ </ul>
61
+ </ModalBody>
62
+ </React.Fragment>
63
+ );
64
+ };
65
+
66
+ export default TutorialModal;
@@ -0,0 +1,64 @@
1
+ @use '@carbon/styles/scss/spacing';
2
+ @use '@carbon/styles/scss/type';
3
+ @use '@carbon/layout';
4
+ @import '~@openmrs/esm-styleguide/src/vars';
5
+
6
+ .description {
7
+ margin-bottom: spacing.$spacing-04;
8
+ @include type.type-style('body-compact-01');
9
+ }
10
+
11
+ .tutorialModal {
12
+ display: flex;
13
+ flex-direction: column;
14
+ gap: spacing.$spacing-04;
15
+
16
+ .tutorialItem {
17
+ border-bottom: 1px solid $ui-03;
18
+ padding-bottom: spacing.$spacing-02;
19
+
20
+ .tutorialTitle {
21
+ margin-bottom: spacing.$spacing-04;
22
+ @include type.type-style('heading-02');
23
+ }
24
+
25
+ .tutorialDescription {
26
+ color: $color-gray-70;
27
+ @include type.type-style('body-compact-01');
28
+ }
29
+
30
+ .tutorialLink {
31
+ margin-bottom: spacing.$spacing-04;
32
+ margin-top: spacing.$spacing-04;
33
+ cursor: pointer;
34
+ }
35
+ }
36
+ }
37
+
38
+ .modalHeader {
39
+ :global {
40
+ .cds--modal-close-button {
41
+ position: absolute;
42
+ inset-block-start: 0;
43
+ inset-inline-end: 0;
44
+ margin: 0;
45
+ margin-top: calc(-1 * #{layout.$spacing-05});
46
+ }
47
+
48
+ .cds--modal-close {
49
+ background-color: rgba(0, 0, 0, 0);
50
+
51
+ &:hover {
52
+ background-color: var(--cds-layer-hover);
53
+ }
54
+ }
55
+
56
+ .cds--popover--left > .cds--popover > .cds--popover-content {
57
+ transform: translate(-4rem, 0.85rem);
58
+ }
59
+
60
+ .cds--popover--left > .cds--popover > .cds--popover-caret {
61
+ transform: translate(-3.75rem, 1.25rem);
62
+ }
63
+ }
64
+ }
@@ -0,0 +1,22 @@
1
+ import React from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { showModal } from '@openmrs/esm-framework';
4
+
5
+ const Tutorial = () => {
6
+ const { t } = useTranslation();
7
+
8
+ const handleOpenModal = () => {
9
+ const dispose = showModal('tutorial-modal', {
10
+ onClose: () => dispose(),
11
+ size: 'sm',
12
+ });
13
+ };
14
+
15
+ return (
16
+ <>
17
+ <div onClick={handleOpenModal}>{t('tutorials', 'Tutorials')}</div>
18
+ </>
19
+ );
20
+ };
21
+
22
+ export default Tutorial;
package/src/types.ts ADDED
@@ -0,0 +1,15 @@
1
+ import {type Step} from "react-joyride";
2
+
3
+ export interface Tutorial {
4
+ title: string;
5
+ description: string;
6
+ steps: Step[];
7
+ }
8
+
9
+ export interface TutorialContext {
10
+ showTutorial: boolean;
11
+ steps: Step[];
12
+ setShowTutorial: (showTutorial: boolean) => void;
13
+ setSteps: (steps: Step[]) => void;
14
+ }
15
+
@@ -0,0 +1 @@
1
+ {}
@@ -0,0 +1,9 @@
1
+ {
2
+ "back": "Back",
3
+ "finish": "Finish",
4
+ "modalDescription": "Find walkthroughs and video tutorials on some of the core features of OpenMRS.",
5
+ "next": "Next",
6
+ "tutorial": "Tutorials",
7
+ "walkthrough": "Walkthrough",
8
+ "welcome": "Welcome to OpenMRS!"
9
+ }
@@ -0,0 +1 @@
1
+ {}
@@ -0,0 +1 @@
1
+ {}
@@ -0,0 +1 @@
1
+ {}
@@ -0,0 +1 @@
1
+ {}
package/tsconfig.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "compilerOptions": {
3
+ "esModuleInterop": true,
4
+ "module": "esnext",
5
+ "allowSyntheticDefaultImports": true,
6
+ "jsx": "react",
7
+ "skipLibCheck": true,
8
+ "moduleResolution": "node",
9
+ "lib": [
10
+ "dom",
11
+ "es5",
12
+ "scripthost",
13
+ "es2015",
14
+ "es2015.promise",
15
+ "es2016.array.include",
16
+ "es2018",
17
+ "es2020"
18
+ ],
19
+ "resolveJsonModule": true,
20
+ "noEmit": true,
21
+ "target": "esnext"
22
+ }
23
+ }
@@ -0,0 +1 @@
1
+ module.exports = require('openmrs/default-webpack-config');