Skip to content

Commit

Permalink
implement generic search combo box
Browse files Browse the repository at this point in the history
  • Loading branch information
kajambiya committed Jun 2, 2023
1 parent fd804a6 commit 8606070
Show file tree
Hide file tree
Showing 10 changed files with 263 additions and 7 deletions.
8 changes: 4 additions & 4 deletions __mocks__/forms/ohri-forms/external_data_source_form.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,16 @@
"questionOptions": {
"rendering": "number",
"concept": "560555AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"calculate": {
"calculateExpression": "resolve(api.getLatestObs('50512033-e047-4855-b2d3-1a6d9e889ff4', '560555AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')).valueNumeric"
},
"calculate": {
"calculateExpression": "resolve(api.getLatestObs('50512033-e047-4855-b2d3-1a6d9e889ff4', '560555AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')).valueNumeric"
},
"conceptMappings": [
{
"type": "CIEL",
"value": "160555"
}
]

},
"id": "bodyWeight",
"validators": [],
Expand Down
12 changes: 12 additions & 0 deletions src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ export interface PostSubmissionAction {
sessionMode: SessionMode;
}): void;
}

// OpenMRS Type Definitions
export interface OpenmrsEncounter {
uuid?: string;
Expand Down Expand Up @@ -241,3 +242,14 @@ export interface OpenmrsFormResource extends OpenmrsResource {
dataType: string;
valueReference: string;
}

export interface DataSource<T> {
/**
* Fetches arbitrary data from a data source
*/
fetchData(searchTerm?: string): Promise<Array<T>>;
/**
* Maps a data source item to an object with a uuid and display property
*/
toUuidAndDisplay(item: T): OpenmrsResource;
}
1 change: 0 additions & 1 deletion src/components/encounter/ohri-encounter-form.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ import { scrollIntoView } from '../../utils/ohri-sidebar';
import { useEncounter } from '../../hooks/useEncounter';
import { useInitialValues } from '../../hooks/useInitialValues';
import { useEncounterRole } from '../../hooks/useEncounterRole';

interface OHRIEncounterFormProps {
formJson: OHRIFormSchema;
patient: any;
Expand Down
45 changes: 45 additions & 0 deletions src/components/inputs/ui-select-extended/ui-select-extended.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
@use '@carbon/colors';

.formField {
margin-top: 10px;
}

.formField > div > div > label {
color: colors.$gray-70;
}

.multiselectOverride {
width: auto;
padding-bottom: 15px;
}

.multiselectOverride > div > div {
width: 22.313rem !important;
}

.multiselectOverride > div div div input {
border: 0 !important;
border-bottom: 1px solid #8d8d8d !important;
}
.row {
display: flex;
flex-direction: row;
align-items: baseline;
}

.errorLabel label {
color: colors.$red-60 !important;
}

.formInputField {
@extend .formField;
width: auto;

> div > div > label {
max-width: auto;
}

> div > div > div {
width: 22.313rem !important;
}
}
154 changes: 154 additions & 0 deletions src/components/inputs/ui-select-extended/ui-select-extended.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { ComboBox } from '@carbon/react';
import { OHRIFormFieldProps } from '../../../api/types';
import { useField } from 'formik';
import styles from './ui-select-extended.scss';
import { OHRIFormContext } from '../../../ohri-form-context';
import { getConceptNameAndUUID } from '../../../utils/ohri-form-helper';
import { OHRIFieldValueView } from '../../value/view/ohri-field-value-view.component';
import { isTrue } from '../../../utils/boolean-utils';
import { getDataSource } from '../../../registry/registry';
import { fieldRequiredErrCode, isEmpty } from '../../../validators/ohri-form-validator';
import { PreviousValueReview } from '../../previous-value-review/previous-value-review.component';
import debounce from 'lodash-es/debounce';
import InlineLoader from '../../loaders/inline-loader.component';

export const UISelectExtended: React.FC<OHRIFormFieldProps> = ({ question, handler, onChange }) => {
const [field, meta] = useField(question.id);
const { setFieldValue, encounterContext, fields } = React.useContext(OHRIFormContext);
const [conceptName, setConceptName] = useState('Loading...');
const [items, setItems] = useState([]);
const [warnings, setWarnings] = useState([]);
const [errors, setErrors] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const isFieldRequiredError = useMemo(() => errors[0]?.errCode == fieldRequiredErrCode, [errors]);
const [previousValueForReview, setPreviousValueForReview] = useState(null);
const inputValue = useRef('');
const dataSource = useMemo(() => getDataSource(question.questionOptions['datasource']), []);
useEffect(() => {
if (question['submission']) {
question['submission'].errors && setErrors(question['submission'].errors);
question['submission'].warnings && setWarnings(question['submission'].warnings);
}
}, [question['submission']]);

const handleChange = value => {
setFieldValue(question.id, value);
onChange(question.id, value, setErrors, setWarnings);
question.value = handler?.handleFieldSubmission(question, value, encounterContext);
};

const debouncedSearch = debounce((searchterm, dataSource) => {
setIsLoading(true);
dataSource.fetchData(searchterm).then(dataItems => {
setItems(dataItems.map(dataSource.toUuidAndDisplay));
setIsLoading(false);
});
}, 300);

useEffect(() => {
// If not searchable, preload the items
if (dataSource && !isTrue(question.questionOptions['isSearchable'])) {
setIsLoading(true);
dataSource.fetchData().then(dataItems => {
setItems(dataItems.map(dataSource.toUuidAndDisplay));
setIsLoading(false);
});
}
}, [dataSource]);

useEffect(() => {
// get the data source
if (dataSource && isTrue(question.questionOptions['isSearchable']) && !isEmpty(searchTerm)) {
debouncedSearch(searchTerm, dataSource);
} else {
}
}, [dataSource, searchTerm]);

useEffect(() => {
getConceptNameAndUUID(question.questionOptions.concept).then(conceptTooltip => {
setConceptName(conceptTooltip);
});
}, [conceptName]);

useEffect(() => {
if (encounterContext?.previousEncounter && !question.questionOptions.usePreviousValueDisabled) {
const prevValue = handler?.getPreviousValue(question, encounterContext?.previousEncounter, fields);
if (!isEmpty(prevValue?.value)) {
setPreviousValueForReview(prevValue);
}
}
}, [encounterContext?.previousEncounter]);

const handleStateChange = (changes, stateAndHelpers) => {
// Intercept the state change for onBlur event
if (changes?.type === stateAndHelpers?.changeTypes?.blur && !changes?.selectedItem) {
// Return modified state to persist the inputValue
return { ...changes, value: inputValue.current };
}
return changes;
};

return encounterContext.sessionMode == 'view' || isTrue(question.readonly) ? (
<div className={styles.formField}>
<OHRIFieldValueView
label={question.label}
value={field.value ? field.value.display : field.value}
conceptName={conceptName}
isInline
/>
</div>
) : (
!question.isHidden && (
<div className={`${styles.formInputField} ${styles.row}`}>
<div
className={
isFieldRequiredError
? `${styles.errorLabel} ${styles.multiselectOverride}`
: `${styles.multiselectOverride}`
}>
<ComboBox
id={question.id}
titleText={question.label}
items={items}
isLoading={isLoading}
loadingMessage="loading..."
itemToString={item => item?.display}
selectedItem={field.value}
shouldFilterItem={({ item, inputValue }) => {
if (!inputValue) {
// Carbon's initial call at component mount
return true;
}
return item.display.toLowerCase().includes(inputValue.toLowerCase());
}}
onChange={({ selectedItem }) => handleChange(selectedItem)}
disabled={question.disabled}
onInputChange={value => {
inputValue.current = value;
if (question.questionOptions['isSearchable']) {
setSearchTerm(value);
}
}}
/>
</div>
{isLoading ? (
<div>
<InlineLoader />
</div>
) : (
previousValueForReview && (
<div>
<PreviousValueReview
value={previousValueForReview.value}
displayText={previousValueForReview.display}
setValue={handleChange}
/>
</div>
)
)}
</div>
)
);
};
14 changes: 14 additions & 0 deletions src/components/loaders/inline-loader.component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import React from 'react';
import styles from './inline-loader.scss';
import { InlineLoading } from '@carbon/react';

const InlineLoader: React.FC = () => (
<div className={styles.formField}>
<span className="cds--label"></span>
<div className={styles.row}>
<InlineLoading status="active" />
</div>
</div>
);

export default InlineLoader;
14 changes: 14 additions & 0 deletions src/components/loaders/inline-loader.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
@use '@carbon/colors';
.formField {
margin-left: 2rem;
max-width: 19rem;

> div > div > label {
color: colors.$gray-70;
}
}
.row {
display: flex;
flex-direction: row;
align-items: baseline;
}
1 change: 1 addition & 0 deletions src/ohri-form-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface EncounterContext {
encounter: OpenmrsEncounter;
previousEncounter?: OpenmrsEncounter;
location: any;
provider?: any;
sessionMode: SessionMode;
encounterDate: Date;
setEncounterDate(value: Date): void;
Expand Down
3 changes: 2 additions & 1 deletion src/ohri-form.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
getAsyncLifecycle,
registerExtension,
showToast,
usePatient,
useSession,
Visit,
} from '@openmrs/esm-framework';
Expand Down Expand Up @@ -91,7 +92,7 @@ const OHRIForm: React.FC<OHRIFormProps> = ({
const session = useSession();
const currentProvider = session?.currentProvider?.uuid ? session.currentProvider.uuid : null;
const location = session && !(encounterUUID || encounterUuid) ? session?.sessionLocation : null;
const { patient, isLoadingPatient: isLoadingPatient, patientError: patientError } = usePatientData(patientUUID);
const { patient, isLoading: isLoadingPatient, error: patientError } = usePatient(patientUUID);
const { formJson: refinedFormJson, isLoading: isLoadingFormJson, formError } = useFormJson(
formUUID,
formJson,
Expand Down
18 changes: 17 additions & 1 deletion src/registry/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import OHRIToggle from '../components/inputs/toggle/ohri-toggle.component';
import { OHRIRepeat } from '../components/repeat/ohri-repeat.component';
import { OHRIFieldValidator } from '../validators/ohri-form-validator';
import { EncounterLocationSubmissionHandler, ObsSubmissionHandler } from '../submission-handlers/base-handlers';
import { FieldValidator, PostSubmissionAction, SubmissionHandler } from '../api/types';
import { DataSource, FieldValidator, PostSubmissionAction, SubmissionHandler } from '../api/types';
import OHRIFixedValue from '../components/inputs/fixed-value/ohri-fixed-value.component';
import OHRIMarkdown from '../components/inputs/markdown/ohri-markdown.component';
import { OHRIDateValidator } from '../validators/ohri-date-validator';
Expand All @@ -21,6 +21,7 @@ import { getGlobalStore } from '@openmrs/esm-framework';
import { OHRIFormsStore } from '../constants';
import OHRIExtensionParcel from '../components/extension/ohri-extension-parcel.component';
import { EncounterDatetimeHandler } from '../submission-handlers/encounterDatetimeHandler';
import { UISelectExtended } from '../components/inputs/ui-select-extended/ui-select-extended';

export interface RegistryItem {
id: string;
Expand All @@ -46,6 +47,10 @@ interface ValidatorRegistryItem extends RegistryItem {
component: FieldValidator;
}

interface DataSourceRegistryItem extends Omit<RegistryItem, 'type'> {
component: DataSource<any>;
}

export interface FormsRegistryStoreState {
customControls: Array<CustomControlRegistration>;
postSubmissionActions: Array<PostSubmissionActionRegistration>;
Expand Down Expand Up @@ -94,6 +99,11 @@ export const baseFieldComponents: Array<CustomControlRegistration> = [
type: 'encounter-location',
alias: '',
},
{
id: 'UISelectExtended',
loadControl: () => Promise.resolve({ default: UISelectExtended }),
type: 'ui-select-extended',
},
{
id: 'OHRIDropdown',
loadControl: () => Promise.resolve({ default: OHRIDropdown }),
Expand Down Expand Up @@ -188,6 +198,8 @@ const fieldValidators: Array<ValidatorRegistryItem> = [
},
];

const dataSources: Array<DataSourceRegistryItem> = [];

export const getFieldComponent = renderType => {
let lazy = baseFieldComponents.find(item => item.type == renderType || item?.alias == renderType)?.loadControl;
if (!lazy) {
Expand Down Expand Up @@ -222,6 +234,10 @@ export function getValidator(id: string): FieldValidator {
return fieldValidators.find(validator => validator.id == id)?.component || fieldValidators[0].component;
}

export function getDataSource(id: string): DataSource<any> {
return dataSources.find(dataSource => dataSource.id == id)?.component;
}

export function registerControl(registration: CustomControlRegistration) {
getOHRIFormsStore().customControls.push(registration);
}
Expand Down

0 comments on commit 8606070

Please sign in to comment.