diff --git a/__mocks__/forms/ohri-forms/external_data_source_form.json b/__mocks__/forms/ohri-forms/external_data_source_form.json index 6abb73621..9c2d76d8d 100644 --- a/__mocks__/forms/ohri-forms/external_data_source_form.json +++ b/__mocks__/forms/ohri-forms/external_data_source_form.json @@ -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": [], diff --git a/src/api/types.ts b/src/api/types.ts index 0046b5b8d..7df8e2916 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -191,6 +191,7 @@ export interface PostSubmissionAction { sessionMode: SessionMode; }): void; } + // OpenMRS Type Definitions export interface OpenmrsEncounter { uuid?: string; @@ -241,3 +242,14 @@ export interface OpenmrsFormResource extends OpenmrsResource { dataType: string; valueReference: string; } + +export interface DataSource { + /** + * Fetches arbitrary data from a data source + */ + fetchData(searchTerm?: string): Promise>; + /** + * Maps a data source item to an object with a uuid and display property + */ + toUuidAndDisplay(item: T): OpenmrsResource; +} diff --git a/src/components/encounter/ohri-encounter-form.component.tsx b/src/components/encounter/ohri-encounter-form.component.tsx index de3bc81b5..5feb48a1e 100644 --- a/src/components/encounter/ohri-encounter-form.component.tsx +++ b/src/components/encounter/ohri-encounter-form.component.tsx @@ -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; diff --git a/src/components/inputs/ui-select-extended/ui-select-extended.scss b/src/components/inputs/ui-select-extended/ui-select-extended.scss new file mode 100644 index 000000000..0e868a1f0 --- /dev/null +++ b/src/components/inputs/ui-select-extended/ui-select-extended.scss @@ -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; + } + } diff --git a/src/components/inputs/ui-select-extended/ui-select-extended.tsx b/src/components/inputs/ui-select-extended/ui-select-extended.tsx new file mode 100644 index 000000000..b6e67f2e3 --- /dev/null +++ b/src/components/inputs/ui-select-extended/ui-select-extended.tsx @@ -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 = ({ 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) ? ( +
+ +
+ ) : ( + !question.isHidden && ( +
+
+ 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); + } + }} + /> +
+ {isLoading ? ( +
+ +
+ ) : ( + previousValueForReview && ( +
+ +
+ ) + )} +
+ ) + ); +}; diff --git a/src/components/loaders/inline-loader.component.tsx b/src/components/loaders/inline-loader.component.tsx new file mode 100644 index 000000000..6953bae2e --- /dev/null +++ b/src/components/loaders/inline-loader.component.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import styles from './inline-loader.scss'; +import { InlineLoading } from '@carbon/react'; + +const InlineLoader: React.FC = () => ( +
+ +
+ +
+
+); + +export default InlineLoader; diff --git a/src/components/loaders/inline-loader.scss b/src/components/loaders/inline-loader.scss new file mode 100644 index 000000000..cbac6e400 --- /dev/null +++ b/src/components/loaders/inline-loader.scss @@ -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; + } \ No newline at end of file diff --git a/src/ohri-form-context.tsx b/src/ohri-form-context.tsx index fe6e99ea9..8dd222116 100644 --- a/src/ohri-form-context.tsx +++ b/src/ohri-form-context.tsx @@ -21,6 +21,7 @@ export interface EncounterContext { encounter: OpenmrsEncounter; previousEncounter?: OpenmrsEncounter; location: any; + provider?: any; sessionMode: SessionMode; encounterDate: Date; setEncounterDate(value: Date): void; diff --git a/src/ohri-form.component.tsx b/src/ohri-form.component.tsx index 9353b27e7..73033ca0e 100644 --- a/src/ohri-form.component.tsx +++ b/src/ohri-form.component.tsx @@ -10,6 +10,7 @@ import { getAsyncLifecycle, registerExtension, showToast, + usePatient, useSession, Visit, } from '@openmrs/esm-framework'; @@ -91,7 +92,7 @@ const OHRIForm: React.FC = ({ 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, diff --git a/src/registry/registry.ts b/src/registry/registry.ts index 76f5fd4f2..ba8fbf060 100644 --- a/src/registry/registry.ts +++ b/src/registry/registry.ts @@ -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'; @@ -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; @@ -46,6 +47,10 @@ interface ValidatorRegistryItem extends RegistryItem { component: FieldValidator; } +interface DataSourceRegistryItem extends Omit { + component: DataSource; +} + export interface FormsRegistryStoreState { customControls: Array; postSubmissionActions: Array; @@ -94,6 +99,11 @@ export const baseFieldComponents: Array = [ type: 'encounter-location', alias: '', }, + { + id: 'UISelectExtended', + loadControl: () => Promise.resolve({ default: UISelectExtended }), + type: 'ui-select-extended', + }, { id: 'OHRIDropdown', loadControl: () => Promise.resolve({ default: OHRIDropdown }), @@ -188,6 +198,8 @@ const fieldValidators: Array = [ }, ]; +const dataSources: Array = []; + export const getFieldComponent = renderType => { let lazy = baseFieldComponents.find(item => item.type == renderType || item?.alias == renderType)?.loadControl; if (!lazy) { @@ -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 { + return dataSources.find(dataSource => dataSource.id == id)?.component; +} + export function registerControl(registration: CustomControlRegistration) { getOHRIFormsStore().customControls.push(registration); }