@digihmis/esm-patient-registration-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.
Files changed (146) hide show
  1. package/README.md +4 -0
  2. package/dist/117.js +1 -0
  3. package/dist/117.js.map +1 -0
  4. package/dist/131.js +1 -0
  5. package/dist/131.js.map +1 -0
  6. package/dist/132.js +1 -0
  7. package/dist/132.js.map +1 -0
  8. package/dist/152.js +1 -0
  9. package/dist/152.js.map +1 -0
  10. package/dist/209.js +1 -0
  11. package/dist/209.js.map +1 -0
  12. package/dist/247.js +1 -0
  13. package/dist/247.js.map +1 -0
  14. package/dist/28.js +1 -0
  15. package/dist/28.js.map +1 -0
  16. package/dist/317.js +1 -0
  17. package/dist/317.js.map +1 -0
  18. package/dist/349.js +1 -0
  19. package/dist/349.js.map +1 -0
  20. package/dist/371.js +1 -0
  21. package/dist/371.js.map +1 -0
  22. package/dist/378.js +1 -0
  23. package/dist/378.js.map +1 -0
  24. package/dist/442.js +1 -0
  25. package/dist/442.js.map +1 -0
  26. package/dist/45.js +1 -0
  27. package/dist/45.js.map +1 -0
  28. package/dist/459.js +12 -0
  29. package/dist/459.js.map +1 -0
  30. package/dist/466.js +1 -0
  31. package/dist/466.js.map +1 -0
  32. package/dist/508.js +1 -0
  33. package/dist/508.js.map +1 -0
  34. package/dist/61.js +1 -0
  35. package/dist/61.js.map +1 -0
  36. package/dist/640.js +1 -0
  37. package/dist/640.js.map +1 -0
  38. package/dist/689.js +1 -0
  39. package/dist/689.js.map +1 -0
  40. package/dist/709.js +1 -0
  41. package/dist/709.js.map +1 -0
  42. package/dist/712.js +1 -0
  43. package/dist/712.js.map +1 -0
  44. package/dist/720.js +1 -0
  45. package/dist/720.js.map +1 -0
  46. package/dist/742.js +1 -0
  47. package/dist/742.js.map +1 -0
  48. package/dist/771.js +1 -0
  49. package/dist/771.js.map +1 -0
  50. package/dist/806.js +1 -0
  51. package/dist/806.js.map +1 -0
  52. package/dist/912.js +1 -0
  53. package/dist/912.js.map +1 -0
  54. package/dist/913.js +1 -0
  55. package/dist/913.js.map +1 -0
  56. package/dist/940.js +1 -0
  57. package/dist/940.js.map +1 -0
  58. package/dist/957.js +15 -0
  59. package/dist/957.js.map +1 -0
  60. package/dist/digihmis-esm-patient-registration-app.js +6 -0
  61. package/dist/digihmis-esm-patient-registration-app.js.buildmanifest.json +827 -0
  62. package/dist/digihmis-esm-patient-registration-app.js.map +1 -0
  63. package/dist/main.js +17 -0
  64. package/dist/main.js.map +1 -0
  65. package/dist/routes.json +1 -0
  66. package/jest.config.js +26 -0
  67. package/package.json +51 -0
  68. package/rspack.config.js +1 -0
  69. package/src/active-visit-table/active-visit-table.component.tsx +240 -0
  70. package/src/active-visit-table/active-visits.resource.ts +338 -0
  71. package/src/active-visit-table/active-visits.scss +211 -0
  72. package/src/active-visit-table/empty-data-illustration.component.tsx +39 -0
  73. package/src/checkin-form/base-visit-type.component.tsx +102 -0
  74. package/src/checkin-form/base-visit-type.scss +75 -0
  75. package/src/checkin-form/checkin-form.scss +167 -0
  76. package/src/checkin-form/checkin-form.workspace.tsx +703 -0
  77. package/src/checkin-form/hooks/useDefaultFacilityLocation.ts +15 -0
  78. package/src/checkin-form/hooks/useDefaultVisitLocation.ts +24 -0
  79. package/src/checkin-form/hooks/useOfflineVisitType.ts +18 -0
  80. package/src/checkin-form/hooks/usePatientHistory.ts +40 -0
  81. package/src/checkin-form/hooks/useRecommendedVisitTypes.ts +34 -0
  82. package/src/checkin-form/hooks/useVisitAttributeType.ts +102 -0
  83. package/src/checkin-form/location-selector.component.tsx +86 -0
  84. package/src/checkin-form/recommended-visit-type.component.tsx +32 -0
  85. package/src/checkin-form/visit-attribute-type.component.tsx +256 -0
  86. package/src/checkin-form/visit-attribute-type.scss +5 -0
  87. package/src/checkin-form/visit-date-time.component.tsx +201 -0
  88. package/src/checkin-form/visit-form.resource.ts +308 -0
  89. package/src/components/custom-card/compact-patient-banner.component.tsx +70 -0
  90. package/src/components/custom-card/compact-patient-banner.scss +34 -0
  91. package/src/components/custom-card/custom-loader.component.tsx +22 -0
  92. package/src/components/custom-card/patient-search-context.ts +30 -0
  93. package/src/components/custom-input/autosuggest/autosuggest.component.tsx +213 -0
  94. package/src/components/custom-input/autosuggest/autosuggest.scss +158 -0
  95. package/src/components/custom-input/autosuggest/autosuggest.test.tsx +168 -0
  96. package/src/components/custom-patient-banner/custom-patient-banner.component.tsx +39 -0
  97. package/src/components/custom-patient-banner/custom-patient-banner.resource.ts +32 -0
  98. package/src/components/custom-patient-banner/custom-patient-banner.scss +26 -0
  99. package/src/config-schema.ts +511 -0
  100. package/src/constant.ts +6 -0
  101. package/src/constants/index.ts +1 -0
  102. package/src/dashboard.meta.ts +8 -0
  103. package/src/declarations.d.ts +6 -0
  104. package/src/empty-svg/empty-svg.component.tsx +565 -0
  105. package/src/icon-wrapper/icon-wrapper.extension.tsx +17 -0
  106. package/src/identifier-workspace/identifier-configure-form.workspace.tsx +278 -0
  107. package/src/identifier-workspace/identifier-configure.scss +106 -0
  108. package/src/identifier-workspace/identifier.resource.ts +90 -0
  109. package/src/index.ts +33 -0
  110. package/src/left-panel/left-panel.component.tsx +54 -0
  111. package/src/left-panel/left-panel.scss +13 -0
  112. package/src/page-header/page-header.component.tsx +35 -0
  113. package/src/page-header/page-header.scss +76 -0
  114. package/src/patient-tags/deceased-patient-tag.extension.tsx +41 -0
  115. package/src/patient-tags/deceased-patient-tag.scss +15 -0
  116. package/src/patient-tags/patient-tags.resource.ts +30 -0
  117. package/src/patient-tags/visit-tag.extension.tsx +43 -0
  118. package/src/patient-tags/visit-tag.scss +19 -0
  119. package/src/registration-form/form-manager.ts +449 -0
  120. package/src/registration-form/registration-form.component.tsx +1565 -0
  121. package/src/registration-form/registration-form.resource.ts +264 -0
  122. package/src/registration-form/registration-form.scss +282 -0
  123. package/src/routes.json +62 -0
  124. package/src/search-bar/search-bar.component.tsx +285 -0
  125. package/src/search-bar/search-bar.resource.ts +274 -0
  126. package/src/search-bar/search-bar.scss +427 -0
  127. package/src/search-dashboard/search-dashboard.component.tsx +17 -0
  128. package/src/search-dashboard/search-dashboard.scss +13 -0
  129. package/src/setup-tests.ts +1 -0
  130. package/src/type/index.ts +463 -0
  131. package/src/utils/fhir-mapper.ts +73 -0
  132. package/src/visit-summary/visit-summary.component.tsx +32 -0
  133. package/src/visit-summary/visit-summary.resource.ts +56 -0
  134. package/src/visit-summary/visit-summary.scss +74 -0
  135. package/translations/am.json +112 -0
  136. package/translations/ar.json +120 -0
  137. package/translations/en.json +112 -0
  138. package/translations/es.json +114 -0
  139. package/translations/fr.json +114 -0
  140. package/translations/he.json +114 -0
  141. package/translations/km.json +111 -0
  142. package/translations/pt.json +114 -0
  143. package/translations/sw.json +112 -0
  144. package/translations/vi.json +111 -0
  145. package/translations/zh.json +111 -0
  146. package/tsconfig.json +5 -0
@@ -0,0 +1,213 @@
1
+ import React, { type HTMLAttributes, useEffect, useRef, useState, useCallback } from 'react';
2
+ import { Layer, Search, type SearchProps } from '@carbon/react';
3
+ import classNames from 'classnames';
4
+ import styles from './autosuggest.scss';
5
+ import Loader from '../../custom-card/custom-loader.component';
6
+ import upperCase from 'lodash-es/upperCase';
7
+
8
+ type InputPropsBase = Omit<HTMLAttributes<HTMLInputElement>, 'onChange'>;
9
+
10
+ interface SearchPropsExtended extends InputPropsBase {
11
+ autoComplete?: string;
12
+ className?: string;
13
+ closeButtonLabelText?: string;
14
+ defaultValue?: string | number;
15
+ disabled?: boolean;
16
+ isExpanded?: boolean;
17
+ id?: string;
18
+ labelText: React.ReactNode;
19
+ onChange?(e: { target: HTMLInputElement; type: 'change' }): void;
20
+ onClear?(): void;
21
+ onExpand?(e: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>): void;
22
+ placeholder?: string;
23
+ renderIcon?: React.ComponentType | React.FunctionComponent;
24
+ role?: string;
25
+ size?: 'sm' | 'md' | 'lg';
26
+ type?: string;
27
+ value?: string | number;
28
+ }
29
+
30
+ interface AutosuggestProps extends SearchPropsExtended {
31
+ getDisplayValue: (item: any) => string;
32
+ getFieldValue: (item: any) => string;
33
+ getSearchResults: (query: string) => Promise<any[]>;
34
+ onSuggestionSelected: (field: string, value: string | undefined) => void;
35
+ invalid?: boolean;
36
+ invalidText?: string;
37
+ }
38
+
39
+ export const Autosuggest: React.FC<AutosuggestProps> = ({
40
+ getDisplayValue,
41
+ getFieldValue,
42
+ getSearchResults,
43
+ onSuggestionSelected,
44
+ invalid,
45
+ invalidText,
46
+ ...searchProps
47
+ }) => {
48
+ const [suggestions, setSuggestions] = useState<any[]>([]);
49
+ const [isLoading, setIsLoading] = useState(false);
50
+ const [selectedIndex, setSelectedIndex] = useState(-1);
51
+ const searchBox = useRef<HTMLInputElement>(null);
52
+ const wrapper = useRef<HTMLDivElement>(null);
53
+ const suggestionsContainer = useRef<HTMLDivElement>(null);
54
+ const { id: name, labelText } = searchProps;
55
+ const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
56
+
57
+ useEffect(() => {
58
+ document.addEventListener('mousedown', handleClickOutsideComponent);
59
+
60
+ return () => {
61
+ document.removeEventListener('mousedown', handleClickOutsideComponent);
62
+ };
63
+ }, [wrapper]);
64
+
65
+ const handleClickOutsideComponent = (e: MouseEvent) => {
66
+ if (wrapper.current && !wrapper.current.contains(e.target as Node)) {
67
+ setSuggestions([]);
68
+ setSelectedIndex(-1);
69
+ }
70
+ };
71
+
72
+ const handleChange = useCallback(
73
+ async (e: React.ChangeEvent<HTMLInputElement>) => {
74
+ const query = e.target.value;
75
+ onSuggestionSelected(name, undefined);
76
+ setSelectedIndex(-1);
77
+
78
+ if (searchTimeoutRef.current) {
79
+ clearTimeout(searchTimeoutRef.current);
80
+ }
81
+
82
+ if (query) {
83
+ setIsLoading(true);
84
+ searchTimeoutRef.current = setTimeout(async () => {
85
+ try {
86
+ const results = await getSearchResults(query);
87
+ setSuggestions(results);
88
+ } catch (error) {
89
+ console.error('Error fetching suggestions:', error);
90
+ setSuggestions([]);
91
+ } finally {
92
+ setIsLoading(false);
93
+ }
94
+ }, 300);
95
+ } else {
96
+ setSuggestions([]);
97
+ setIsLoading(false);
98
+ }
99
+ },
100
+ [name, onSuggestionSelected, getSearchResults],
101
+ );
102
+
103
+ const handleClear = () => {
104
+ onSuggestionSelected(name, undefined);
105
+ setSuggestions([]);
106
+ setSelectedIndex(-1);
107
+ };
108
+
109
+ const handleClick = useCallback(
110
+ (index: number) => {
111
+ if (suggestions[index]) {
112
+ const display = getDisplayValue(suggestions[index]);
113
+ const value = getFieldValue(suggestions[index]);
114
+ if (searchBox.current) {
115
+ // Uppercase the value when setting it in the search box
116
+ searchBox.current.value = upperCase(display);
117
+ }
118
+ onSuggestionSelected(name, value);
119
+ setSuggestions([]);
120
+ setSelectedIndex(-1);
121
+ }
122
+ },
123
+ [suggestions, name, getDisplayValue, getFieldValue, onSuggestionSelected],
124
+ );
125
+
126
+ const handleKeyDown = useCallback(
127
+ (e: React.KeyboardEvent<HTMLInputElement>) => {
128
+ if (suggestions.length === 0) {
129
+ return;
130
+ }
131
+
132
+ switch (e.key) {
133
+ case 'ArrowDown':
134
+ e.preventDefault();
135
+ setSelectedIndex((prev) => (prev < suggestions.length - 1 ? prev + 1 : prev));
136
+ break;
137
+ case 'ArrowUp':
138
+ e.preventDefault();
139
+ setSelectedIndex((prev) => (prev > 0 ? prev - 1 : -1));
140
+ break;
141
+ case 'Enter':
142
+ e.preventDefault();
143
+ if (selectedIndex >= 0) {
144
+ handleClick(selectedIndex);
145
+ }
146
+ break;
147
+ case 'Escape':
148
+ setSuggestions([]);
149
+ setSelectedIndex(-1);
150
+ break;
151
+ default:
152
+ break;
153
+ }
154
+ },
155
+ [suggestions, selectedIndex, handleClick],
156
+ );
157
+
158
+ useEffect(() => {
159
+ return () => {
160
+ if (searchTimeoutRef.current) {
161
+ clearTimeout(searchTimeoutRef.current);
162
+ }
163
+ };
164
+ }, []);
165
+
166
+ return (
167
+ <div className={styles.autocomplete} ref={wrapper}>
168
+ <label className="cds--label">{labelText}</label>
169
+ <Layer className={classNames({ [styles.invalid]: invalid })}>
170
+ <Search
171
+ id="autosuggest"
172
+ onChange={handleChange}
173
+ onClear={handleClear}
174
+ onKeyDown={handleKeyDown}
175
+ ref={searchBox}
176
+ className={styles.autocompleteSearch}
177
+ {...searchProps}
178
+ />
179
+ </Layer>
180
+
181
+ {(suggestions.length > 0 || isLoading) && (
182
+ <div className={styles.suggestionsWrapper} ref={suggestionsContainer}>
183
+ {isLoading ? (
184
+ <div className={styles.loaderContainer}>
185
+ {Array.from({ length: 1 }).map((_, i) => (
186
+ <div key={`loader-${i}`} className={styles.loaderItem}>
187
+ <Loader />
188
+ </div>
189
+ ))}
190
+ </div>
191
+ ) : (
192
+ <ul className={styles.suggestions}>
193
+ {suggestions.map((suggestion, index) => (
194
+ <li
195
+ key={getFieldValue(suggestion)}
196
+ onClick={() => handleClick(index)}
197
+ className={classNames(styles.suggestionItem, {
198
+ [styles.selected]: index === selectedIndex,
199
+ })}
200
+ role="option"
201
+ aria-selected={index === selectedIndex}>
202
+ {upperCase(getDisplayValue(suggestion))}
203
+ </li>
204
+ ))}
205
+ </ul>
206
+ )}
207
+ </div>
208
+ )}
209
+
210
+ {invalid && <label className={classNames(styles.invalidMsg)}>{invalidText}</label>}
211
+ </div>
212
+ );
213
+ };
@@ -0,0 +1,158 @@
1
+ @use '@carbon/layout';
2
+ @use '@carbon/type';
3
+ @use '@openmrs/esm-styleguide/src/vars' as *;
4
+
5
+ .label01 {
6
+ @include type.type-style('label-01');
7
+ }
8
+
9
+ .autocomplete {
10
+ position: relative;
11
+ }
12
+
13
+ .autocompleteSearch {
14
+ width: 100%;
15
+ }
16
+
17
+ .suggestionsWrapper {
18
+ position: absolute;
19
+ left: 0;
20
+ right: 0;
21
+ top: calc(100% + 2px);
22
+ background-color: $ui-background;
23
+ border: 1px solid $ui-03;
24
+ border-radius: 0 0 4px 4px;
25
+ max-height: 400px;
26
+ overflow-y: auto;
27
+ z-index: 99;
28
+ box-shadow: 0 4px 12px rgba(27, 75, 102, 0.15);
29
+ animation: slideDown 0.2s ease-out;
30
+
31
+ // Custom scrollbar styling
32
+ &::-webkit-scrollbar {
33
+ width: 8px;
34
+ }
35
+
36
+ &::-webkit-scrollbar-track {
37
+ background: $ui-01;
38
+ }
39
+
40
+ &::-webkit-scrollbar-thumb {
41
+ background: var(--brand-02, #2e6b8a);
42
+ border-radius: 4px;
43
+
44
+ &:hover {
45
+ background: var(--brand-01, #1b4b66);
46
+ }
47
+ }
48
+ }
49
+
50
+ @keyframes slideDown {
51
+ from {
52
+ opacity: 0;
53
+ transform: translateY(-8px);
54
+ }
55
+ to {
56
+ opacity: 1;
57
+ transform: translateY(0);
58
+ }
59
+ }
60
+
61
+ .loaderContainer {
62
+ padding: layout.$spacing-05;
63
+ display: flex;
64
+ flex-direction: column;
65
+ gap: layout.$spacing-03;
66
+ width: 100%;
67
+ }
68
+
69
+ .loaderItem {
70
+ width: 100%;
71
+ max-width: 1400px;
72
+ }
73
+
74
+ .suggestions {
75
+ position: relative;
76
+ list-style: none;
77
+ margin: 0;
78
+ padding: layout.$spacing-02 0;
79
+ width: 100%;
80
+ }
81
+
82
+ .suggestionItem {
83
+ padding: layout.$spacing-04 layout.$spacing-05;
84
+ border-bottom: 1px solid $ui-03;
85
+ cursor: pointer;
86
+ transition: all 0.15s cubic-bezier(0.2, 0, 0.38, 0.9);
87
+ @include type.type-style('body-compact-01');
88
+ position: relative;
89
+
90
+ &::before {
91
+ content: '';
92
+ position: absolute;
93
+ left: 0;
94
+ top: 0;
95
+ bottom: 0;
96
+ width: 3px;
97
+ background-color: var(--brand-03, #4a90b8);
98
+ transform: scaleX(0);
99
+ transform-origin: left;
100
+ transition: transform 0.2s ease;
101
+ }
102
+
103
+ &:hover {
104
+ background-color: rgba(74, 144, 184, 0.08);
105
+
106
+ &::before {
107
+ transform: scaleX(1);
108
+ }
109
+ }
110
+
111
+ &.selected {
112
+ background-color: rgba(74, 144, 184, 0.15);
113
+ color: var(--brand-01, #1b4b66);
114
+ font-weight: 600;
115
+
116
+ &::before {
117
+ transform: scaleX(1);
118
+ background-color: var(--brand-01, #1b4b66);
119
+ }
120
+ }
121
+
122
+ &:last-child {
123
+ border-bottom: none;
124
+ }
125
+
126
+ &:first-child {
127
+ border-radius: 4px 4px 0 0;
128
+ }
129
+
130
+ &:last-child {
131
+ border-radius: 0 0 4px 4px;
132
+ }
133
+ }
134
+
135
+ .suggestions a {
136
+ color: inherit;
137
+ text-decoration: none;
138
+ display: block;
139
+ }
140
+
141
+ .invalid input {
142
+ outline: 2px solid var(--cds-support-error, #da1e28) !important;
143
+ outline-offset: -2px;
144
+ }
145
+
146
+ .invalidMsg {
147
+ display: block;
148
+ margin-top: layout.$spacing-03;
149
+ color: var(--cds-text-error, #da1e28);
150
+ @include type.type-style('label-01');
151
+ font-weight: 400;
152
+ }
153
+
154
+ .emptyState {
155
+ padding: layout.$spacing-05;
156
+ text-align: center;
157
+ @include type.type-style('body-compact-01');
158
+ }
@@ -0,0 +1,168 @@
1
+ import React from 'react';
2
+ import { render, screen } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { Autosuggest } from './autosuggest.component';
5
+ import { BrowserRouter } from 'react-router-dom';
6
+
7
+ export function renderWithRouter(ui: React.ReactElement) {
8
+ return render(<BrowserRouter>{ui}</BrowserRouter>);
9
+ }
10
+
11
+ const mockPersons = [
12
+ {
13
+ uuid: 'randomuuid1',
14
+ display: 'John Doe',
15
+ },
16
+ {
17
+ uuid: 'randomuuid2',
18
+ display: 'John Smith',
19
+ },
20
+ {
21
+ uuid: 'randomuuid3',
22
+ display: 'James Smith',
23
+ },
24
+ {
25
+ uuid: 'randomuuid4',
26
+ display: 'Spider Man',
27
+ },
28
+ ];
29
+
30
+ const mockGetSearchResults = async (query: string) => {
31
+ return mockPersons.filter((person) => {
32
+ return person.display.toUpperCase().includes(query.toUpperCase());
33
+ });
34
+ };
35
+
36
+ const mockHandleSuggestionSelected = jest.fn((field, value) => [field, value]);
37
+
38
+ describe('Autosuggest', () => {
39
+ it('renders a search box', () => {
40
+ renderWithRouter(
41
+ <Autosuggest
42
+ getSearchResults={mockGetSearchResults}
43
+ getDisplayValue={(item) => item.display}
44
+ getFieldValue={(item) => item.uuid}
45
+ id="person"
46
+ labelText=""
47
+ onSuggestionSelected={mockHandleSuggestionSelected}
48
+ placeholder="Find Person"
49
+ />,
50
+ );
51
+
52
+ expect(screen.getByRole('searchbox')).toBeInTheDocument();
53
+ expect(screen.queryByRole('list')).not.toBeInTheDocument();
54
+ });
55
+
56
+ it('renders matching search results in a list when the user types a query', async () => {
57
+ const user = userEvent.setup();
58
+
59
+ renderWithRouter(
60
+ <Autosuggest
61
+ getSearchResults={mockGetSearchResults}
62
+ getDisplayValue={(item) => item.display}
63
+ getFieldValue={(item) => item.uuid}
64
+ id="person"
65
+ labelText=""
66
+ onSuggestionSelected={mockHandleSuggestionSelected}
67
+ placeholder="Find Person"
68
+ />,
69
+ );
70
+
71
+ const searchbox = screen.getByRole('searchbox');
72
+ await user.type(searchbox, 'john');
73
+
74
+ const list = screen.getByRole('list');
75
+
76
+ expect(list).toBeInTheDocument();
77
+ expect(screen.getAllByRole('listitem').length).toEqual(2);
78
+ expect(screen.getAllByRole('listitem')[0]).toHaveTextContent('John Doe');
79
+ expect(screen.getAllByRole('listitem')[1]).toHaveTextContent('John Smith');
80
+ });
81
+
82
+ it('clears the list of suggestions when a suggestion is selected', async () => {
83
+ const user = userEvent.setup();
84
+
85
+ renderWithRouter(
86
+ <Autosuggest
87
+ getSearchResults={mockGetSearchResults}
88
+ getDisplayValue={(item) => item.display}
89
+ getFieldValue={(item) => item.uuid}
90
+ id="person"
91
+ labelText=""
92
+ onSuggestionSelected={mockHandleSuggestionSelected}
93
+ placeholder="Find Person"
94
+ />,
95
+ );
96
+
97
+ let list = screen.queryByRole('list');
98
+ expect(list).not.toBeInTheDocument();
99
+
100
+ const searchbox = screen.getByRole('searchbox');
101
+ await user.type(searchbox, 'john');
102
+
103
+ list = screen.getByRole('list');
104
+ expect(list).toBeInTheDocument();
105
+
106
+ const listitems = screen.getAllByRole('listitem');
107
+ await user.click(listitems[0]);
108
+
109
+ expect(mockHandleSuggestionSelected).toHaveBeenLastCalledWith('person', 'randomuuid1');
110
+
111
+ list = screen.queryByRole('list');
112
+ expect(list).not.toBeInTheDocument();
113
+ });
114
+
115
+ it('changes suggestions when a search input is changed', async () => {
116
+ const user = userEvent.setup();
117
+
118
+ renderWithRouter(
119
+ <Autosuggest
120
+ getSearchResults={mockGetSearchResults}
121
+ getDisplayValue={(item) => item.display}
122
+ getFieldValue={(item) => item.uuid}
123
+ id="person"
124
+ labelText=""
125
+ onSuggestionSelected={mockHandleSuggestionSelected}
126
+ placeholder="Find Person"
127
+ />,
128
+ );
129
+
130
+ let list = screen.queryByRole('list');
131
+ expect(list).not.toBeInTheDocument();
132
+
133
+ const searchbox = screen.getByRole('searchbox');
134
+ await user.type(searchbox, 'john');
135
+
136
+ const suggestion = await screen.findByText('John Doe');
137
+ expect(suggestion).toBeInTheDocument();
138
+
139
+ await user.clear(searchbox);
140
+
141
+ list = screen.queryByRole('list');
142
+ expect(list).not.toBeInTheDocument();
143
+ });
144
+
145
+ it('hides the list of suggestions when the user clicks outside of the component', async () => {
146
+ const user = userEvent.setup();
147
+
148
+ renderWithRouter(
149
+ <Autosuggest
150
+ getSearchResults={mockGetSearchResults}
151
+ getDisplayValue={(item) => item.display}
152
+ getFieldValue={(item) => item.uuid}
153
+ id="person"
154
+ labelText=""
155
+ onSuggestionSelected={mockHandleSuggestionSelected}
156
+ placeholder="Find Person"
157
+ />,
158
+ );
159
+
160
+ const input = screen.getByRole('searchbox');
161
+
162
+ await user.type(input, 'john');
163
+ await screen.findByText('John Doe');
164
+ await user.click(document.body);
165
+
166
+ expect(screen.queryByText('John Doe')).not.toBeInTheDocument();
167
+ });
168
+ });
@@ -0,0 +1,39 @@
1
+ import React from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { Tag } from '@carbon/react';
4
+ import styles from './custom-patient-banner.scss';
5
+ import { usePerson } from './custom-patient-banner.resource';
6
+ import capitalize from 'lodash-es/capitalize';
7
+ import { useConfig } from '@openmrs/esm-framework';
8
+ import { ConfigObject } from '../../config-schema';
9
+
10
+ interface PatientBannerProps {
11
+ patientUuid: string;
12
+ }
13
+
14
+ const PatientBanner: React.FC<PatientBannerProps> = ({ patientUuid }) => {
15
+ const { t } = useTranslation();
16
+ const { OpenmrsIdentifierTypeUuid } = useConfig<ConfigObject>();
17
+
18
+ const { isLoading, error, person } = usePerson(patientUuid);
19
+ const openmrsId =
20
+ person?.identifiers?.find((id) => id.identifierType?.uuid === OpenmrsIdentifierTypeUuid)?.identifier || '';
21
+ const isDeceased = person?.dead;
22
+
23
+ return (
24
+ <div className={styles.patientHeader}>
25
+ <span className={styles.patientName}>{capitalize(person?.display)}</span>
26
+ <span className={styles.patientDetails}>· {openmrsId} </span>
27
+ <span className={styles.patientDetails}>
28
+ · {person?.gender} · {person?.age} {t('years', 'yrs')}
29
+ {isDeceased && (
30
+ <Tag type="red" size="md" className={styles.tag}>
31
+ . {t('deceased', 'Deceased')}
32
+ </Tag>
33
+ )}
34
+ </span>
35
+ </div>
36
+ );
37
+ };
38
+
39
+ export default PatientBanner;
@@ -0,0 +1,32 @@
1
+ import { FetchResponse, openmrsFetch, restBaseUrl } from '@openmrs/esm-framework';
2
+ import useSWR from 'swr';
3
+
4
+ export interface Identifier {
5
+ identifierType: { uuid: string; display: string };
6
+ identifier: string;
7
+ }
8
+ export interface Person {
9
+ uuid: string;
10
+ display: string;
11
+ identifiers?: Array<Identifier>;
12
+ person?: Person;
13
+ gender: string;
14
+ age: number;
15
+ dead: boolean;
16
+ birthdate: string;
17
+ birthdateEstimated: boolean;
18
+ }
19
+ export interface Patient {
20
+ uuid: string;
21
+ display: string;
22
+ identifiers: Array<Identifier>;
23
+ person: Person;
24
+ }
25
+
26
+ export const usePerson = (uuid: string) => {
27
+ const customRepresentation = `custom:(uuid,display,gender,birthdate,dead,age,deathDate,causeOfDeath:(uuid,display),identifiers:(uuid,identifier,identifierType:(uuid,display),preferred),person:(uuid`;
28
+ const url = `${restBaseUrl}/person/${uuid}?v=${customRepresentation}`;
29
+ const { isLoading, error, data } = useSWR<FetchResponse<Patient['person']>>(url, openmrsFetch);
30
+ const person = data?.data;
31
+ return { isLoading, error, person };
32
+ };
@@ -0,0 +1,26 @@
1
+ @use '@carbon/colors';
2
+ @use '@carbon/layout';
3
+ @use '@carbon/type';
4
+
5
+ .patientHeader {
6
+ padding: layout.$spacing-05;
7
+ border-bottom: 1px solid colors.$gray-20;
8
+ background-color: colors.$gray-10;
9
+
10
+ .patientName {
11
+ @include type.type-style('heading-compact-01');
12
+ font-weight: 600;
13
+ margin-right: layout.$spacing-03;
14
+ }
15
+
16
+ .patientId {
17
+ @include type.type-style('body-compact-01');
18
+ font-weight: 600;
19
+ margin-right: layout.$spacing-02;
20
+ }
21
+
22
+ .patientDetails {
23
+ @include type.type-style('body-compact-01');
24
+ color: colors.$gray-70;
25
+ }
26
+ }