@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.
- package/README.md +4 -0
- package/dist/117.js +1 -0
- package/dist/117.js.map +1 -0
- package/dist/131.js +1 -0
- package/dist/131.js.map +1 -0
- package/dist/132.js +1 -0
- package/dist/132.js.map +1 -0
- package/dist/152.js +1 -0
- package/dist/152.js.map +1 -0
- package/dist/209.js +1 -0
- package/dist/209.js.map +1 -0
- package/dist/247.js +1 -0
- package/dist/247.js.map +1 -0
- package/dist/28.js +1 -0
- package/dist/28.js.map +1 -0
- package/dist/317.js +1 -0
- package/dist/317.js.map +1 -0
- package/dist/349.js +1 -0
- package/dist/349.js.map +1 -0
- package/dist/371.js +1 -0
- package/dist/371.js.map +1 -0
- package/dist/378.js +1 -0
- package/dist/378.js.map +1 -0
- package/dist/442.js +1 -0
- package/dist/442.js.map +1 -0
- package/dist/45.js +1 -0
- package/dist/45.js.map +1 -0
- package/dist/459.js +12 -0
- package/dist/459.js.map +1 -0
- package/dist/466.js +1 -0
- package/dist/466.js.map +1 -0
- package/dist/508.js +1 -0
- package/dist/508.js.map +1 -0
- package/dist/61.js +1 -0
- package/dist/61.js.map +1 -0
- package/dist/640.js +1 -0
- package/dist/640.js.map +1 -0
- package/dist/689.js +1 -0
- package/dist/689.js.map +1 -0
- package/dist/709.js +1 -0
- package/dist/709.js.map +1 -0
- package/dist/712.js +1 -0
- package/dist/712.js.map +1 -0
- package/dist/720.js +1 -0
- package/dist/720.js.map +1 -0
- package/dist/742.js +1 -0
- package/dist/742.js.map +1 -0
- package/dist/771.js +1 -0
- package/dist/771.js.map +1 -0
- package/dist/806.js +1 -0
- package/dist/806.js.map +1 -0
- package/dist/912.js +1 -0
- package/dist/912.js.map +1 -0
- package/dist/913.js +1 -0
- package/dist/913.js.map +1 -0
- package/dist/940.js +1 -0
- package/dist/940.js.map +1 -0
- package/dist/957.js +15 -0
- package/dist/957.js.map +1 -0
- package/dist/digihmis-esm-patient-registration-app.js +6 -0
- package/dist/digihmis-esm-patient-registration-app.js.buildmanifest.json +827 -0
- package/dist/digihmis-esm-patient-registration-app.js.map +1 -0
- package/dist/main.js +17 -0
- package/dist/main.js.map +1 -0
- package/dist/routes.json +1 -0
- package/jest.config.js +26 -0
- package/package.json +51 -0
- package/rspack.config.js +1 -0
- package/src/active-visit-table/active-visit-table.component.tsx +240 -0
- package/src/active-visit-table/active-visits.resource.ts +338 -0
- package/src/active-visit-table/active-visits.scss +211 -0
- package/src/active-visit-table/empty-data-illustration.component.tsx +39 -0
- package/src/checkin-form/base-visit-type.component.tsx +102 -0
- package/src/checkin-form/base-visit-type.scss +75 -0
- package/src/checkin-form/checkin-form.scss +167 -0
- package/src/checkin-form/checkin-form.workspace.tsx +703 -0
- package/src/checkin-form/hooks/useDefaultFacilityLocation.ts +15 -0
- package/src/checkin-form/hooks/useDefaultVisitLocation.ts +24 -0
- package/src/checkin-form/hooks/useOfflineVisitType.ts +18 -0
- package/src/checkin-form/hooks/usePatientHistory.ts +40 -0
- package/src/checkin-form/hooks/useRecommendedVisitTypes.ts +34 -0
- package/src/checkin-form/hooks/useVisitAttributeType.ts +102 -0
- package/src/checkin-form/location-selector.component.tsx +86 -0
- package/src/checkin-form/recommended-visit-type.component.tsx +32 -0
- package/src/checkin-form/visit-attribute-type.component.tsx +256 -0
- package/src/checkin-form/visit-attribute-type.scss +5 -0
- package/src/checkin-form/visit-date-time.component.tsx +201 -0
- package/src/checkin-form/visit-form.resource.ts +308 -0
- package/src/components/custom-card/compact-patient-banner.component.tsx +70 -0
- package/src/components/custom-card/compact-patient-banner.scss +34 -0
- package/src/components/custom-card/custom-loader.component.tsx +22 -0
- package/src/components/custom-card/patient-search-context.ts +30 -0
- package/src/components/custom-input/autosuggest/autosuggest.component.tsx +213 -0
- package/src/components/custom-input/autosuggest/autosuggest.scss +158 -0
- package/src/components/custom-input/autosuggest/autosuggest.test.tsx +168 -0
- package/src/components/custom-patient-banner/custom-patient-banner.component.tsx +39 -0
- package/src/components/custom-patient-banner/custom-patient-banner.resource.ts +32 -0
- package/src/components/custom-patient-banner/custom-patient-banner.scss +26 -0
- package/src/config-schema.ts +511 -0
- package/src/constant.ts +6 -0
- package/src/constants/index.ts +1 -0
- package/src/dashboard.meta.ts +8 -0
- package/src/declarations.d.ts +6 -0
- package/src/empty-svg/empty-svg.component.tsx +565 -0
- package/src/icon-wrapper/icon-wrapper.extension.tsx +17 -0
- package/src/identifier-workspace/identifier-configure-form.workspace.tsx +278 -0
- package/src/identifier-workspace/identifier-configure.scss +106 -0
- package/src/identifier-workspace/identifier.resource.ts +90 -0
- package/src/index.ts +33 -0
- package/src/left-panel/left-panel.component.tsx +54 -0
- package/src/left-panel/left-panel.scss +13 -0
- package/src/page-header/page-header.component.tsx +35 -0
- package/src/page-header/page-header.scss +76 -0
- package/src/patient-tags/deceased-patient-tag.extension.tsx +41 -0
- package/src/patient-tags/deceased-patient-tag.scss +15 -0
- package/src/patient-tags/patient-tags.resource.ts +30 -0
- package/src/patient-tags/visit-tag.extension.tsx +43 -0
- package/src/patient-tags/visit-tag.scss +19 -0
- package/src/registration-form/form-manager.ts +449 -0
- package/src/registration-form/registration-form.component.tsx +1565 -0
- package/src/registration-form/registration-form.resource.ts +264 -0
- package/src/registration-form/registration-form.scss +282 -0
- package/src/routes.json +62 -0
- package/src/search-bar/search-bar.component.tsx +285 -0
- package/src/search-bar/search-bar.resource.ts +274 -0
- package/src/search-bar/search-bar.scss +427 -0
- package/src/search-dashboard/search-dashboard.component.tsx +17 -0
- package/src/search-dashboard/search-dashboard.scss +13 -0
- package/src/setup-tests.ts +1 -0
- package/src/type/index.ts +463 -0
- package/src/utils/fhir-mapper.ts +73 -0
- package/src/visit-summary/visit-summary.component.tsx +32 -0
- package/src/visit-summary/visit-summary.resource.ts +56 -0
- package/src/visit-summary/visit-summary.scss +74 -0
- package/translations/am.json +112 -0
- package/translations/ar.json +120 -0
- package/translations/en.json +112 -0
- package/translations/es.json +114 -0
- package/translations/fr.json +114 -0
- package/translations/he.json +114 -0
- package/translations/km.json +111 -0
- package/translations/pt.json +114 -0
- package/translations/sw.json +112 -0
- package/translations/vi.json +111 -0
- package/translations/zh.json +111 -0
- 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
|
+
}
|