@ampath/esm-patient-registration-app 6.0.1-pre.6
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/.turbo/turbo-build.log +41 -0
- package/README.md +7 -0
- package/dist/130.js +2 -0
- package/dist/130.js.LICENSE.txt +3 -0
- package/dist/130.js.map +1 -0
- package/dist/152.js +1 -0
- package/dist/152.js.map +1 -0
- package/dist/249.js +2 -0
- package/dist/249.js.LICENSE.txt +46 -0
- package/dist/249.js.map +1 -0
- package/dist/255.js +2 -0
- package/dist/255.js.LICENSE.txt +9 -0
- package/dist/255.js.map +1 -0
- package/dist/271.js +1 -0
- package/dist/303.js +1 -0
- package/dist/303.js.map +1 -0
- package/dist/319.js +1 -0
- package/dist/365.js +1 -0
- package/dist/365.js.map +1 -0
- package/dist/460.js +1 -0
- package/dist/525.js +1 -0
- package/dist/525.js.map +1 -0
- package/dist/537.js +1 -0
- package/dist/537.js.map +1 -0
- package/dist/574.js +1 -0
- package/dist/591.js +2 -0
- package/dist/591.js.LICENSE.txt +32 -0
- package/dist/591.js.map +1 -0
- package/dist/621.js +1 -0
- package/dist/621.js.map +1 -0
- package/dist/644.js +1 -0
- package/dist/729.js +1 -0
- package/dist/729.js.map +1 -0
- package/dist/735.js +1 -0
- package/dist/735.js.map +1 -0
- package/dist/757.js +1 -0
- package/dist/784.js +2 -0
- package/dist/784.js.LICENSE.txt +9 -0
- package/dist/784.js.map +1 -0
- package/dist/788.js +1 -0
- package/dist/807.js +1 -0
- package/dist/833.js +1 -0
- package/dist/879.js +1 -0
- package/dist/879.js.map +1 -0
- package/dist/ampath-esm-patient-registration-app.js +1 -0
- package/dist/ampath-esm-patient-registration-app.js.buildmanifest.json +649 -0
- package/dist/ampath-esm-patient-registration-app.js.map +1 -0
- package/dist/main.js +2 -0
- package/dist/main.js.LICENSE.txt +56 -0
- package/dist/main.js.map +1 -0
- package/dist/routes.json +1 -0
- package/docs/images/patient-registration-hierarchy.png +0 -0
- package/jest.config.js +3 -0
- package/package.json +61 -0
- package/src/add-patient-link.scss +3 -0
- package/src/add-patient-link.test.tsx +20 -0
- package/src/add-patient-link.tsx +21 -0
- package/src/config-schema.ts +410 -0
- package/src/constants.ts +14 -0
- package/src/declarations.d.ts +6 -0
- package/src/index.ts +71 -0
- package/src/nav-link.test.tsx +13 -0
- package/src/nav-link.tsx +10 -0
- package/src/offline.resources.ts +155 -0
- package/src/offline.ts +91 -0
- package/src/patient-registration/before-save-prompt.tsx +73 -0
- package/src/patient-registration/date-util.ts +52 -0
- package/src/patient-registration/field/__mocks__/field.resource.ts +60 -0
- package/src/patient-registration/field/address/address-field.component.tsx +153 -0
- package/src/patient-registration/field/address/address-hierarchy-levels.component.tsx +73 -0
- package/src/patient-registration/field/address/address-hierarchy.resource.tsx +157 -0
- package/src/patient-registration/field/address/address-search.component.tsx +85 -0
- package/src/patient-registration/field/address/address-search.scss +53 -0
- package/src/patient-registration/field/address/custom-address-field.component.tsx +31 -0
- package/src/patient-registration/field/address/tests/address-hierarchy.test.tsx +214 -0
- package/src/patient-registration/field/address/tests/address-search-component.test.tsx +135 -0
- package/src/patient-registration/field/custom-field.component.tsx +25 -0
- package/src/patient-registration/field/dob/dob.component.tsx +159 -0
- package/src/patient-registration/field/dob/dob.test.tsx +75 -0
- package/src/patient-registration/field/field.component.tsx +47 -0
- package/src/patient-registration/field/field.resource.ts +35 -0
- package/src/patient-registration/field/field.scss +127 -0
- package/src/patient-registration/field/field.test.tsx +294 -0
- package/src/patient-registration/field/gender/gender-field.component.tsx +49 -0
- package/src/patient-registration/field/gender/gender-field.test.tsx +59 -0
- package/src/patient-registration/field/id/id-field.component.tsx +144 -0
- package/src/patient-registration/field/id/id-field.test.tsx +107 -0
- package/src/patient-registration/field/id/identifier-selection-overlay.component.tsx +198 -0
- package/src/patient-registration/field/id/identifier-selection.scss +37 -0
- package/src/patient-registration/field/name/name-field.component.tsx +142 -0
- package/src/patient-registration/field/obs/obs-field.component.tsx +204 -0
- package/src/patient-registration/field/obs/obs-field.test.tsx +205 -0
- package/src/patient-registration/field/person-attributes/coded-attributes.component.tsx +60 -0
- package/src/patient-registration/field/person-attributes/coded-person-attribute-field.component.tsx +116 -0
- package/src/patient-registration/field/person-attributes/coded-person-attribute-field.test.tsx +127 -0
- package/src/patient-registration/field/person-attributes/person-attribute-field.component.tsx +88 -0
- package/src/patient-registration/field/person-attributes/person-attribute-field.test.tsx +187 -0
- package/src/patient-registration/field/person-attributes/person-attributes.resource.ts +20 -0
- package/src/patient-registration/field/person-attributes/text-person-attribute-field.component.tsx +58 -0
- package/src/patient-registration/field/person-attributes/text-person-attribute-field.test.tsx +88 -0
- package/src/patient-registration/field/phone/phone-field.component.tsx +16 -0
- package/src/patient-registration/form-manager.test.ts +67 -0
- package/src/patient-registration/form-manager.ts +414 -0
- package/src/patient-registration/input/basic-input/input/input.component.tsx +179 -0
- package/src/patient-registration/input/basic-input/input/input.test.tsx +72 -0
- package/src/patient-registration/input/basic-input/select/select-input.component.tsx +32 -0
- package/src/patient-registration/input/basic-input/select/select-input.test.tsx +49 -0
- package/src/patient-registration/input/combo-input/combo-input.component.tsx +128 -0
- package/src/patient-registration/input/combo-input/selection-tick.component.tsx +20 -0
- package/src/patient-registration/input/custom-input/autosuggest/autosuggest.component.tsx +187 -0
- package/src/patient-registration/input/custom-input/autosuggest/autosuggest.scss +62 -0
- package/src/patient-registration/input/custom-input/autosuggest/autosuggest.test.tsx +132 -0
- package/src/patient-registration/input/custom-input/identifier/identifier-input.component.tsx +156 -0
- package/src/patient-registration/input/custom-input/identifier/identifier-input.test.tsx +107 -0
- package/src/patient-registration/input/custom-input/identifier/utils.test.ts +81 -0
- package/src/patient-registration/input/custom-input/identifier/utils.ts +19 -0
- package/src/patient-registration/input/dummy-data/dummy-data-input.component.tsx +53 -0
- package/src/patient-registration/input/dummy-data/dummy-data-input.test.tsx +43 -0
- package/src/patient-registration/input/input.scss +118 -0
- package/src/patient-registration/patient-registration-context.ts +24 -0
- package/src/patient-registration/patient-registration-hooks.ts +287 -0
- package/src/patient-registration/patient-registration-utils.ts +216 -0
- package/src/patient-registration/patient-registration.component.tsx +240 -0
- package/src/patient-registration/patient-registration.resource.test.tsx +26 -0
- package/src/patient-registration/patient-registration.resource.ts +250 -0
- package/src/patient-registration/patient-registration.scss +122 -0
- package/src/patient-registration/patient-registration.test.tsx +471 -0
- package/src/patient-registration/patient-registration.types.ts +318 -0
- package/src/patient-registration/section/death-info/death-info-section.component.tsx +31 -0
- package/src/patient-registration/section/death-info/death-info-section.test.tsx +64 -0
- package/src/patient-registration/section/demographics/demographics-section.component.tsx +30 -0
- package/src/patient-registration/section/demographics/demographics-section.test.tsx +83 -0
- package/src/patient-registration/section/generic-section.component.tsx +17 -0
- package/src/patient-registration/section/patient-relationships/relationships-section.component.tsx +235 -0
- package/src/patient-registration/section/patient-relationships/relationships-section.test.tsx +100 -0
- package/src/patient-registration/section/patient-relationships/relationships.resource.tsx +78 -0
- package/src/patient-registration/section/patient-relationships/relationships.scss +35 -0
- package/src/patient-registration/section/section-wrapper.component.tsx +40 -0
- package/src/patient-registration/section/section.component.tsx +23 -0
- package/src/patient-registration/section/section.scss +1 -0
- package/src/patient-registration/ui-components/overlay/overlay.component.tsx +51 -0
- package/src/patient-registration/ui-components/overlay/overlay.scss +63 -0
- package/src/patient-registration/validation/patient-registration-validation.test.tsx +157 -0
- package/src/patient-registration/validation/patient-registration-validation.tsx +60 -0
- package/src/patient-verification/client-registry-constants.ts +13 -0
- package/src/patient-verification/client-registry.component.tsx +66 -0
- package/src/patient-verification/client-registry.scss +1 -0
- package/src/patient-verification/utils.tsx +56 -0
- package/src/patient-verification/verification-modal.scss +20 -0
- package/src/patient-verification/verification.component.tsx +48 -0
- package/src/resource.ts +12 -0
- package/src/root.component.tsx +63 -0
- package/src/root.scss +7 -0
- package/src/root.test.tsx +32 -0
- package/src/routes.json +66 -0
- package/src/widgets/cancel-patient-edit.component.tsx +37 -0
- package/src/widgets/cancel-patient-edit.test.tsx +27 -0
- package/src/widgets/delete-identifier-confirmation-modal.test.tsx +34 -0
- package/src/widgets/delete-identifier-confirmation-modal.tsx +41 -0
- package/src/widgets/delete-identifier-modal.scss +34 -0
- package/src/widgets/display-photo.component.tsx +30 -0
- package/src/widgets/display-photo.test.tsx +37 -0
- package/src/widgets/edit-patient-details-button.component.tsx +34 -0
- package/src/widgets/edit-patient-details-button.scss +3 -0
- package/src/widgets/edit-patient-details-button.test.tsx +41 -0
- package/translations/am.json +97 -0
- package/translations/ar.json +97 -0
- package/translations/en.json +103 -0
- package/translations/es.json +97 -0
- package/translations/fr.json +97 -0
- package/translations/he.json +97 -0
- package/translations/km.json +97 -0
- package/translations/zh.json +89 -0
- package/translations/zh_CN.json +89 -0
- package/tsconfig.json +5 -0
- package/webpack.config.js +1 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import { Formik, Form } from 'formik';
|
|
5
|
+
import { SelectInput } from './select-input.component';
|
|
6
|
+
|
|
7
|
+
describe('the select input', () => {
|
|
8
|
+
const setupSelect = async () => {
|
|
9
|
+
render(
|
|
10
|
+
<Formik initialValues={{ select: '' }} onSubmit={null}>
|
|
11
|
+
<Form>
|
|
12
|
+
<SelectInput label="Select" name="select" options={['A Option', 'B Option']} required />
|
|
13
|
+
</Form>
|
|
14
|
+
</Formik>,
|
|
15
|
+
);
|
|
16
|
+
return screen.getByLabelText('Select') as HTMLInputElement;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
it('exists', async () => {
|
|
20
|
+
const input = await setupSelect();
|
|
21
|
+
expect(input.type).toEqual('select-one');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('can input data', async () => {
|
|
25
|
+
const user = userEvent.setup();
|
|
26
|
+
const input = await setupSelect();
|
|
27
|
+
const expected = 'A Option';
|
|
28
|
+
|
|
29
|
+
await user.selectOptions(input, expected);
|
|
30
|
+
|
|
31
|
+
await expect(input.value).toEqual(expected);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should show optional label if the input is not required', async () => {
|
|
35
|
+
render(
|
|
36
|
+
<Formik initialValues={{ select: '' }} onSubmit={null}>
|
|
37
|
+
<Form>
|
|
38
|
+
<SelectInput label="Select" name="select" options={['A Option', 'B Option']} />
|
|
39
|
+
</Form>
|
|
40
|
+
</Formik>,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
await expect(screen.findByRole('combobox'));
|
|
44
|
+
|
|
45
|
+
const selectInput = screen.getByRole('combobox', { name: 'Select (optional)' }) as HTMLSelectElement;
|
|
46
|
+
expect(selectInput.labels).toHaveLength(1);
|
|
47
|
+
expect(selectInput.labels[0]).toHaveTextContent('Select (optional)');
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import classNames from 'classnames';
|
|
3
|
+
import { TextInput, Layer } from '@carbon/react';
|
|
4
|
+
import SelectionTick from './selection-tick.component';
|
|
5
|
+
import styles from '../input.scss';
|
|
6
|
+
|
|
7
|
+
interface ComboInputProps {
|
|
8
|
+
entries: Array<string>;
|
|
9
|
+
name: string;
|
|
10
|
+
fieldProps: {
|
|
11
|
+
value: string;
|
|
12
|
+
labelText: string;
|
|
13
|
+
[x: string]: any;
|
|
14
|
+
};
|
|
15
|
+
handleInputChange: (newValue: string) => void;
|
|
16
|
+
handleSelection: (newSelection) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const ComboInput: React.FC<ComboInputProps> = ({ entries, fieldProps, handleInputChange, handleSelection }) => {
|
|
20
|
+
const [highlightedEntry, setHighlightedEntry] = useState(-1);
|
|
21
|
+
const { value = '' } = fieldProps;
|
|
22
|
+
const [showEntries, setShowEntries] = useState(false);
|
|
23
|
+
const comboInputRef = useRef(null);
|
|
24
|
+
|
|
25
|
+
const handleFocus = useCallback(() => {
|
|
26
|
+
setShowEntries(true);
|
|
27
|
+
setHighlightedEntry(-1);
|
|
28
|
+
}, [setShowEntries, setHighlightedEntry]);
|
|
29
|
+
|
|
30
|
+
const filteredEntries = useMemo(() => {
|
|
31
|
+
if (!entries) {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
if (!value) {
|
|
35
|
+
return entries;
|
|
36
|
+
}
|
|
37
|
+
return entries.filter((entry) => entry.toLowerCase().includes(value.toLowerCase()));
|
|
38
|
+
}, [entries, value]);
|
|
39
|
+
|
|
40
|
+
const handleOptionClick = useCallback(
|
|
41
|
+
(newSelection: string, e: KeyboardEvent = null) => {
|
|
42
|
+
e?.preventDefault();
|
|
43
|
+
handleSelection(newSelection);
|
|
44
|
+
setShowEntries(false);
|
|
45
|
+
},
|
|
46
|
+
[handleSelection, setShowEntries],
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const handleKeyPress = useCallback(
|
|
50
|
+
(e: KeyboardEvent) => {
|
|
51
|
+
const totalResults = filteredEntries.length ?? 0;
|
|
52
|
+
|
|
53
|
+
if (e.key === 'Tab') {
|
|
54
|
+
setShowEntries(false);
|
|
55
|
+
setHighlightedEntry(-1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (e.key === 'ArrowUp') {
|
|
59
|
+
setHighlightedEntry((prev) => Math.max(-1, prev - 1));
|
|
60
|
+
} else if (e.key === 'ArrowDown') {
|
|
61
|
+
setHighlightedEntry((prev) => Math.min(totalResults - 1, prev + 1));
|
|
62
|
+
} else if (e.key === 'Enter' && highlightedEntry > -1) {
|
|
63
|
+
handleOptionClick(filteredEntries[highlightedEntry], e);
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
[highlightedEntry, handleOptionClick, filteredEntries, setHighlightedEntry, setShowEntries],
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
const listener = (e) => {
|
|
71
|
+
if (!comboInputRef.current.contains(e.target as Node)) {
|
|
72
|
+
setShowEntries(false);
|
|
73
|
+
setHighlightedEntry(-1);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
window.addEventListener('click', listener);
|
|
77
|
+
return () => {
|
|
78
|
+
window.removeEventListener('click', listener);
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div className={styles.comboInput} ref={comboInputRef}>
|
|
84
|
+
<Layer>
|
|
85
|
+
<TextInput
|
|
86
|
+
{...fieldProps}
|
|
87
|
+
onChange={(e) => {
|
|
88
|
+
setHighlightedEntry(-1);
|
|
89
|
+
handleInputChange(e.target.value);
|
|
90
|
+
}}
|
|
91
|
+
onFocus={handleFocus}
|
|
92
|
+
autoComplete={'off'}
|
|
93
|
+
onKeyDown={handleKeyPress}
|
|
94
|
+
/>
|
|
95
|
+
</Layer>
|
|
96
|
+
<div className={styles.comboInputEntries}>
|
|
97
|
+
{showEntries && (
|
|
98
|
+
<div className="cds--combo-box cds--list-box cds--list-box--expanded">
|
|
99
|
+
<div id="downshift-1-menu" className="cds--list-box__menu" role="listbox">
|
|
100
|
+
{filteredEntries.map((entry, indx) => (
|
|
101
|
+
<div
|
|
102
|
+
className={classNames('cds--list-box__menu-item', {
|
|
103
|
+
'cds--list-box__menu-item--highlighted': indx === highlightedEntry,
|
|
104
|
+
})}
|
|
105
|
+
key={indx}
|
|
106
|
+
id="downshift-1-item-0"
|
|
107
|
+
role="option"
|
|
108
|
+
tabIndex={-1}
|
|
109
|
+
aria-selected="true"
|
|
110
|
+
onClick={() => handleOptionClick(entry)}>
|
|
111
|
+
<div
|
|
112
|
+
className={classNames('cds--list-box__menu-item__option', styles.comboInputItemOption, {
|
|
113
|
+
'cds--list-box__menu-item--active': entry === value,
|
|
114
|
+
})}>
|
|
115
|
+
{entry}
|
|
116
|
+
{entry === value && <SelectionTick />}
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
))}
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
)}
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export default ComboInput;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
function SelectionTick() {
|
|
4
|
+
return (
|
|
5
|
+
<svg
|
|
6
|
+
focusable="false"
|
|
7
|
+
preserveAspectRatio="xMidYMid meet"
|
|
8
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
9
|
+
fill="currentColor"
|
|
10
|
+
width="16"
|
|
11
|
+
height="16"
|
|
12
|
+
viewBox="0 0 32 32"
|
|
13
|
+
aria-hidden="true"
|
|
14
|
+
className="cds--list-box__menu-item__selected-icon">
|
|
15
|
+
<path d="M13 24L4 15 5.414 13.586 13 21.171 26.586 7.586 28 9 13 24z"></path>
|
|
16
|
+
</svg>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default SelectionTick;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import React, { type HTMLAttributes, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { Layer, Search, type SearchProps } from '@carbon/react';
|
|
3
|
+
import classNames from 'classnames';
|
|
4
|
+
import styles from './autosuggest.scss';
|
|
5
|
+
|
|
6
|
+
// FIXME Temporarily included types from Carbon
|
|
7
|
+
type InputPropsBase = Omit<HTMLAttributes<HTMLInputElement>, 'onChange'>;
|
|
8
|
+
|
|
9
|
+
interface SearchProps extends InputPropsBase {
|
|
10
|
+
/**
|
|
11
|
+
* Specify an optional value for the `autocomplete` property on the underlying
|
|
12
|
+
* `<input>`, defaults to "off"
|
|
13
|
+
*/
|
|
14
|
+
autoComplete?: string;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Specify an optional className to be applied to the container node
|
|
18
|
+
*/
|
|
19
|
+
className?: string;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Specify a label to be read by screen readers on the "close" button
|
|
23
|
+
*/
|
|
24
|
+
closeButtonLabelText?: string;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Optionally provide the default value of the `<input>`
|
|
28
|
+
*/
|
|
29
|
+
defaultValue?: string | number;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Specify whether the `<input>` should be disabled
|
|
33
|
+
*/
|
|
34
|
+
disabled?: boolean;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Specify whether or not ExpandableSearch should render expanded or not
|
|
38
|
+
*/
|
|
39
|
+
isExpanded?: boolean;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Specify a custom `id` for the input
|
|
43
|
+
*/
|
|
44
|
+
id?: string;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Provide the label text for the Search icon
|
|
48
|
+
*/
|
|
49
|
+
labelText: React.ReactNode;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Optional callback called when the search value changes.
|
|
53
|
+
*/
|
|
54
|
+
onChange?(e: { target: HTMLInputElement; type: 'change' }): void;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Optional callback called when the search value is cleared.
|
|
58
|
+
*/
|
|
59
|
+
onClear?(): void;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Optional callback called when the magnifier icon is clicked in ExpandableSearch.
|
|
63
|
+
*/
|
|
64
|
+
onExpand?(e: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>): void;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Provide an optional placeholder text for the Search.
|
|
68
|
+
* Note: if the label and placeholder differ,
|
|
69
|
+
* VoiceOver on Mac will read both
|
|
70
|
+
*/
|
|
71
|
+
placeholder?: string;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Rendered icon for the Search.
|
|
75
|
+
* Can be a React component class
|
|
76
|
+
*/
|
|
77
|
+
renderIcon?: React.ComponentType | React.FunctionComponent;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Specify the role for the underlying `<input>`, defaults to `searchbox`
|
|
81
|
+
*/
|
|
82
|
+
role?: string;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Specify the size of the Search
|
|
86
|
+
*/
|
|
87
|
+
size?: 'sm' | 'md' | 'lg';
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Optional prop to specify the type of the `<input>`
|
|
91
|
+
*/
|
|
92
|
+
type?: string;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Specify the value of the `<input>`
|
|
96
|
+
*/
|
|
97
|
+
value?: string | number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface AutosuggestProps extends SearchProps {
|
|
101
|
+
getDisplayValue: Function;
|
|
102
|
+
getFieldValue: Function;
|
|
103
|
+
getSearchResults: (query: string) => Promise<any>;
|
|
104
|
+
onSuggestionSelected: (field: string, value: string) => void;
|
|
105
|
+
invalid?: boolean | undefined;
|
|
106
|
+
invalidText?: string | undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const Autosuggest: React.FC<AutosuggestProps> = ({
|
|
110
|
+
getDisplayValue,
|
|
111
|
+
getFieldValue,
|
|
112
|
+
getSearchResults,
|
|
113
|
+
onSuggestionSelected,
|
|
114
|
+
invalid,
|
|
115
|
+
invalidText,
|
|
116
|
+
...searchProps
|
|
117
|
+
}) => {
|
|
118
|
+
const [suggestions, setSuggestions] = useState([]);
|
|
119
|
+
const searchBox = useRef(null);
|
|
120
|
+
const wrapper = useRef(null);
|
|
121
|
+
const { id: name, labelText } = searchProps;
|
|
122
|
+
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
document.addEventListener('mousedown', handleClickOutsideComponent);
|
|
125
|
+
|
|
126
|
+
return () => {
|
|
127
|
+
document.removeEventListener('mousedown', handleClickOutsideComponent);
|
|
128
|
+
};
|
|
129
|
+
}, [wrapper]);
|
|
130
|
+
|
|
131
|
+
const handleClickOutsideComponent = (e) => {
|
|
132
|
+
if (wrapper.current && !wrapper.current.contains(e.target)) {
|
|
133
|
+
setSuggestions([]);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
138
|
+
const query = e.target.value;
|
|
139
|
+
onSuggestionSelected(name, undefined);
|
|
140
|
+
|
|
141
|
+
if (query) {
|
|
142
|
+
getSearchResults(query).then((suggestions) => {
|
|
143
|
+
setSuggestions(suggestions);
|
|
144
|
+
});
|
|
145
|
+
} else {
|
|
146
|
+
setSuggestions([]);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const handleClear = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
151
|
+
onSuggestionSelected(name, undefined);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const handleClick = (index: number) => {
|
|
155
|
+
const display = getDisplayValue(suggestions[index]);
|
|
156
|
+
const value = getFieldValue(suggestions[index]);
|
|
157
|
+
searchBox.current.value = display;
|
|
158
|
+
onSuggestionSelected(name, value);
|
|
159
|
+
setSuggestions([]);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
<div className={styles.autocomplete} ref={wrapper}>
|
|
164
|
+
<label className="cds--label">{labelText}</label>
|
|
165
|
+
<Layer className={classNames({ [styles.invalid]: invalid })}>
|
|
166
|
+
<Search
|
|
167
|
+
id="autosuggest"
|
|
168
|
+
onChange={handleChange}
|
|
169
|
+
onClear={handleClear}
|
|
170
|
+
ref={searchBox}
|
|
171
|
+
className={styles.autocompleteSearch}
|
|
172
|
+
{...searchProps}
|
|
173
|
+
/>
|
|
174
|
+
</Layer>
|
|
175
|
+
{suggestions.length > 0 && (
|
|
176
|
+
<ul className={styles.suggestions}>
|
|
177
|
+
{suggestions.map((suggestion, index) => (
|
|
178
|
+
<li key={index} onClick={(e) => handleClick(index)}>
|
|
179
|
+
{getDisplayValue(suggestion)}
|
|
180
|
+
</li>
|
|
181
|
+
))}
|
|
182
|
+
</ul>
|
|
183
|
+
)}
|
|
184
|
+
{invalid ? <label className={classNames(styles.invalidMsg)}>{invalidText}</label> : <></>}
|
|
185
|
+
</div>
|
|
186
|
+
);
|
|
187
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
@use '@carbon/styles/scss/spacing';
|
|
2
|
+
@use '@carbon/styles/scss/type';
|
|
3
|
+
@import '~@openmrs/esm-styleguide/src/vars';
|
|
4
|
+
|
|
5
|
+
.label01 {
|
|
6
|
+
@include type.type-style('label-01');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.suggestions {
|
|
10
|
+
position: relative;
|
|
11
|
+
border-top-width: 0;
|
|
12
|
+
list-style: none;
|
|
13
|
+
margin-top: 0;
|
|
14
|
+
max-height: 143px;
|
|
15
|
+
overflow-y: auto;
|
|
16
|
+
padding-left: 0;
|
|
17
|
+
width: 100%;
|
|
18
|
+
position: absolute;
|
|
19
|
+
left: 0;
|
|
20
|
+
background-color: #fff;
|
|
21
|
+
margin-bottom: 20px;
|
|
22
|
+
z-index: 99;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.suggestions li {
|
|
26
|
+
padding: spacing.$spacing-05;
|
|
27
|
+
line-height: 1.29;
|
|
28
|
+
color: #525252;
|
|
29
|
+
border-bottom: 1px solid #8d8d8d;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.suggestions li:hover {
|
|
33
|
+
background-color: #e5e5e5;
|
|
34
|
+
color: #161616;
|
|
35
|
+
cursor: pointer;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.suggestions li:not(:last-of-type) {
|
|
39
|
+
border-bottom: 1px solid #999;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.autocomplete {
|
|
43
|
+
position: relative;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.autocompleteSearch {
|
|
47
|
+
width: 100%;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.suggestions a {
|
|
51
|
+
color: inherit;
|
|
52
|
+
text-decoration: none;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.invalid input {
|
|
56
|
+
outline: 2px solid var(--cds-support-error, #da1e28);
|
|
57
|
+
outline-offset: -2px;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.invalidMsg {
|
|
61
|
+
color: var(--cds-text-error, #da1e28);
|
|
62
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Autosuggest } from './autosuggest.component';
|
|
3
|
+
import { BrowserRouter } from 'react-router-dom';
|
|
4
|
+
import { render, screen } from '@testing-library/react';
|
|
5
|
+
import userEvent from '@testing-library/user-event';
|
|
6
|
+
|
|
7
|
+
const mockPersons = [
|
|
8
|
+
{
|
|
9
|
+
uuid: 'randomuuid1',
|
|
10
|
+
display: 'John Doe',
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
uuid: 'randomuuid2',
|
|
14
|
+
display: 'John Smith',
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
uuid: 'randomuuid3',
|
|
18
|
+
display: 'James Smith',
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
uuid: 'randomuuid4',
|
|
22
|
+
display: 'Spider Man',
|
|
23
|
+
},
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const mockedGetSearchResults = async (query: string) => {
|
|
27
|
+
return mockPersons.filter((person) => {
|
|
28
|
+
return person.display.toUpperCase().includes(query.toUpperCase());
|
|
29
|
+
});
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const mockedHandleSuggestionSelected = jest.fn((field, value) => [field, value]);
|
|
33
|
+
|
|
34
|
+
describe('Autosuggest', () => {
|
|
35
|
+
afterEach(() => mockedHandleSuggestionSelected.mockClear());
|
|
36
|
+
|
|
37
|
+
it('renders a search box', () => {
|
|
38
|
+
renderAutosuggest();
|
|
39
|
+
|
|
40
|
+
expect(screen.getByRole('searchbox')).toBeInTheDocument();
|
|
41
|
+
expect(screen.queryByRole('list')).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('renders matching search results in a list when the user types a query', async () => {
|
|
45
|
+
const user = userEvent.setup();
|
|
46
|
+
|
|
47
|
+
renderAutosuggest();
|
|
48
|
+
|
|
49
|
+
const searchbox = screen.getByRole('searchbox');
|
|
50
|
+
await user.type(searchbox, 'john');
|
|
51
|
+
|
|
52
|
+
const list = screen.getByRole('list');
|
|
53
|
+
|
|
54
|
+
expect(list).toBeInTheDocument();
|
|
55
|
+
expect(list.children).toHaveLength(2);
|
|
56
|
+
expect(screen.getAllByRole('listitem')[0]).toHaveTextContent('John Doe');
|
|
57
|
+
expect(screen.getAllByRole('listitem')[1]).toHaveTextContent('John Smith');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('clears the list of suggestions when a suggestion is selected', async () => {
|
|
61
|
+
const user = userEvent.setup();
|
|
62
|
+
|
|
63
|
+
renderAutosuggest();
|
|
64
|
+
|
|
65
|
+
let list = screen.queryByRole('list');
|
|
66
|
+
expect(list).toBeNull();
|
|
67
|
+
|
|
68
|
+
const searchbox = screen.getByRole('searchbox');
|
|
69
|
+
await user.type(searchbox, 'john');
|
|
70
|
+
|
|
71
|
+
list = screen.getByRole('list');
|
|
72
|
+
expect(list).toBeInTheDocument();
|
|
73
|
+
|
|
74
|
+
const listitems = screen.getAllByRole('listitem');
|
|
75
|
+
await user.click(listitems[0]);
|
|
76
|
+
|
|
77
|
+
expect(mockedHandleSuggestionSelected).toHaveBeenLastCalledWith('person', 'randomuuid1');
|
|
78
|
+
|
|
79
|
+
list = screen.queryByRole('list');
|
|
80
|
+
expect(list).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('changes suggestions when a search input is changed', async () => {
|
|
84
|
+
const user = userEvent.setup();
|
|
85
|
+
|
|
86
|
+
renderAutosuggest();
|
|
87
|
+
|
|
88
|
+
let list = screen.queryByRole('list');
|
|
89
|
+
expect(list).toBeNull();
|
|
90
|
+
|
|
91
|
+
const searchbox = screen.getByRole('searchbox');
|
|
92
|
+
await user.type(searchbox, 'john');
|
|
93
|
+
|
|
94
|
+
const suggestion = await screen.findByText('John Doe');
|
|
95
|
+
expect(suggestion).toBeInTheDocument();
|
|
96
|
+
|
|
97
|
+
await user.clear(searchbox);
|
|
98
|
+
|
|
99
|
+
list = screen.queryByRole('list');
|
|
100
|
+
expect(list).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('hides the list of suggestions when the user clicks outside of the component', async () => {
|
|
104
|
+
const user = userEvent.setup();
|
|
105
|
+
|
|
106
|
+
renderAutosuggest();
|
|
107
|
+
|
|
108
|
+
const input = screen.getByRole('searchbox');
|
|
109
|
+
|
|
110
|
+
await user.type(input, 'john');
|
|
111
|
+
await screen.findByText('John Doe');
|
|
112
|
+
await user.click(document.body);
|
|
113
|
+
|
|
114
|
+
expect(screen.queryByText('John Doe')).not.toBeInTheDocument();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
function renderAutosuggest() {
|
|
119
|
+
render(
|
|
120
|
+
<BrowserRouter>
|
|
121
|
+
<Autosuggest
|
|
122
|
+
getSearchResults={mockedGetSearchResults}
|
|
123
|
+
getDisplayValue={(item) => item.display}
|
|
124
|
+
getFieldValue={(item) => item.uuid}
|
|
125
|
+
id="person"
|
|
126
|
+
labelText=""
|
|
127
|
+
onSuggestionSelected={mockedHandleSuggestionSelected}
|
|
128
|
+
placeholder="Find Person"
|
|
129
|
+
/>
|
|
130
|
+
</BrowserRouter>,
|
|
131
|
+
);
|
|
132
|
+
}
|