Skip to content

Commit

Permalink
(fix) Revive search functionality in "ui-select-ext" components (#403)
Browse files Browse the repository at this point in the history
* Fix search functionality in ui-select-ext component

* Cleanup

* Remove duplicate import statement

* Fixup

* Git rename file
  • Loading branch information
samuelmale authored Oct 3, 2024
1 parent 5316533 commit 291fe8b
Show file tree
Hide file tree
Showing 16 changed files with 388 additions and 300 deletions.
48 changes: 48 additions & 0 deletions __mocks__/forms/rfe-forms/sample_ui-select-ext.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
{
"encounterType": "e22e39fd-7db2-45e7-80f1-60fa0d5a4378",
"name": "Sample UI Select",
"processor": "EncounterFormProcessor",
"referencedForms": [],
"uuid": "f7768d34-8e41-4f6b-a276-12c12e023165",
"version": "1.0",
"pages": [
{
"label": "First Page",
"sections": [
{
"label": "A Section",
"isExpanded": "true",
"questions": [
{
"label": "Transfer Location",
"type": "obs",
"questionOptions": {
"rendering": "ui-select-extended",
"concept": "160540AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"datasource": {
"name": "location_datasource",
"config": {
"tag": "test-tag"
}
}
},
"meta": {},
"id": "patient_transfer_location"
},
{
"label": "Problem",
"type": "obs",
"questionOptions": {
"isSearchable": true,
"rendering": "problem",
"concept": "4b59ac07-cf72-4f46-b8c0-4f62b1779f7e"
},
"id": "problem"
}
]
}
]
}
],
"description": "Sample UI Select"
}
5 changes: 2 additions & 3 deletions src/components/inputs/select/dropdown.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import React from 'react';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { type EncounterContext, FormContext } from '../../../form-context';
import Dropdown from './dropdown.component';
import { type FormField } from '../../../types';

Expand Down Expand Up @@ -29,7 +28,7 @@ const question: FormField = {
id: 'patient-past-program',
};

const encounterContext: EncounterContext = {
const encounterContext = {
patient: {
id: '833db896-c1f0-11eb-8529-0242ac130003',
},
Expand Down Expand Up @@ -154,4 +153,4 @@ describe.skip('dropdown input field', () => {
});
});
});
});
});
Original file line number Diff line number Diff line change
@@ -1,87 +1,89 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import debounce from 'lodash-es/debounce';
import { ComboBox, DropdownSkeleton, Layer } from '@carbon/react';
import { ComboBox, DropdownSkeleton, Layer, InlineLoading } from '@carbon/react';
import { isTrue } from '../../../utils/boolean-utils';
import { useTranslation } from 'react-i18next';
import { getRegisteredDataSource } from '../../../registry/registry';
import { getControlTemplate } from '../../../registry/inbuilt-components/control-templates';
import { type FormFieldInputProps } from '../../../types';
import { type DataSource, type FormFieldInputProps } from '../../../types';
import { isEmpty } from '../../../validators/form-validator';
import { shouldUseInlineLayout } from '../../../utils/form-helper';
import FieldValueView from '../../value/view/field-value-view.component';
import styles from './ui-select-extended.scss';
import { useFormProviderContext } from '../../../provider/form-provider';
import FieldLabel from '../../field-label/field-label.component';
import useDataSourceDependentValue from '../../../hooks/useDatasourceDependentValue';
import { useWatch } from 'react-hook-form';
import useDataSourceDependentValue from '../../../hooks/useDataSourceDependentValue';
import { isViewMode } from '../../../utils/common-utils';
import { type OpenmrsResource } from '@openmrs/esm-framework';

const UiSelectExtended: React.FC<FormFieldInputProps> = ({ field, errors, warnings, setFieldValue }) => {
const { t } = useTranslation();
const [items, setItems] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [isSearching, setIsSearching] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const isProcessingSelection = useRef(false);
const [dataSource, setDataSource] = useState(null);
const [config, setConfig] = useState({});
const [savedSearchableItem, setSavedSearchableItem] = useState({});
const dataSourceDependentValue = useDataSourceDependentValue(field);
const isSearchable = isTrue(field.questionOptions.isSearchable);
const {
layoutType,
sessionMode,
workspaceLayout,
methods: { control },
methods: { control, getFieldState },
} = useFormProviderContext();

const value = useWatch({ control, name: field.id, exact: true });
const { isDirty } = getFieldState(field.id);

const isInline = useMemo(() => {
if (['view', 'embedded-view'].includes(sessionMode) || isTrue(field.readonly)) {
if (isViewMode(sessionMode) || isTrue(field.readonly)) {
return shouldUseInlineLayout(field.inlineRendering, layoutType, workspaceLayout, sessionMode);
}
return false;
}, [sessionMode, field.readonly, field.inlineRendering, layoutType, workspaceLayout]);

useEffect(() => {
const dataSource = field.questionOptions?.datasource?.name;
setConfig(
dataSource
? field.questionOptions.datasource?.config
: getControlTemplate(field.questionOptions.rendering)?.datasource?.config,
);
getRegisteredDataSource(dataSource ? dataSource : field.questionOptions.rendering).then((ds) => setDataSource(ds));
}, [field.questionOptions?.datasource]);
const selectedItem = useMemo(() => items.find((item) => item.uuid == value) || null, [items, value]);

const selectedItem = useMemo(() => items.find((item) => item.uuid == value), [items, value]);

const debouncedSearch = debounce((searchTerm, dataSource) => {
setItems([]);
setIsLoading(true);
const debouncedSearch = debounce((searchTerm: string, dataSource: DataSource<OpenmrsResource>) => {
setIsSearching(true);
dataSource
.fetchData(searchTerm, config)
.then((dataItems) => {
setItems(dataItems.map(dataSource.toUuidAndDisplay));
setIsLoading(false);
if (dataItems.length) {
const currentSelectedItem = items.find((item) => item.uuid == value);
const newItems = dataItems.map(dataSource.toUuidAndDisplay);
if (currentSelectedItem && !newItems.some((item) => item.uuid == currentSelectedItem.uuid)) {
newItems.unshift(currentSelectedItem);
}
setItems(newItems);
}
setIsSearching(false);
})
.catch((err) => {
console.error(err);
setIsLoading(false);
setItems([]);
setIsSearching(false);
});
}, 300);

const processSearchableValues = (value) => {
dataSource
.fetchData(null, config, value)
.then((dataItem) => {
setSavedSearchableItem(dataItem);
setIsLoading(false);
})
.catch((err) => {
console.error(err);
setIsLoading(false);
setItems([]);
});
};
const searchTermHasMatchingItem = useCallback(
(searchTerm: string) => {
return items.some((item) => item.display?.toLowerCase().includes(searchTerm.toLowerCase()));
},
[items],
);

useEffect(() => {
const dataSource = field.questionOptions?.datasource?.name;
setConfig(
dataSource
? field.questionOptions.datasource?.config
: getControlTemplate(field.questionOptions.rendering)?.datasource?.config,
);
getRegisteredDataSource(dataSource ? dataSource : field.questionOptions.rendering).then((ds) => setDataSource(ds));
}, [field.questionOptions?.datasource]);

useEffect(() => {
// If not searchable, preload the items
Expand All @@ -103,29 +105,32 @@ const UiSelectExtended: React.FC<FormFieldInputProps> = ({ field, errors, warnin
}, [dataSource, config, dataSourceDependentValue]);

useEffect(() => {
if (dataSource && isTrue(field.questionOptions.isSearchable) && !isEmpty(searchTerm)) {
if (dataSource && isSearchable && !isEmpty(searchTerm) && !searchTermHasMatchingItem(searchTerm)) {
debouncedSearch(searchTerm, dataSource);
}
}, [dataSource, searchTerm, config]);

useEffect(() => {
if (
dataSource &&
isTrue(field.questionOptions.isSearchable) &&
isEmpty(searchTerm) &&
value &&
!Object.keys(savedSearchableItem).length
) {
if (value && !isDirty && dataSource && isSearchable && sessionMode !== 'enter' && !items.length) {
// While in edit mode, search-based instances should fetch the initial item (previously selected value) to resolve its display property
setIsLoading(true);
processSearchableValues(value);
try {
dataSource.fetchSingleItem(value).then((item) => {
setItems([dataSource.toUuidAndDisplay(item)]);
setIsLoading(false);
});
} catch (error) {
console.error(error);
setIsLoading(false);
}
}
}, [value]);
}, [value, isDirty, sessionMode, dataSource, isSearchable, items]);

if (isLoading) {
return <DropdownSkeleton />;
}

return sessionMode == 'view' || sessionMode == 'embedded-view' || isTrue(field.readonly) ? (
return isViewMode(sessionMode) || isTrue(field.readonly) ? (
<FieldValueView
label={t(field.label)}
value={value ? items.find((item) => item.uuid == value)?.display : value}
Expand All @@ -142,6 +147,7 @@ const UiSelectExtended: React.FC<FormFieldInputProps> = ({ field, errors, warnin
items={items}
itemToString={(item) => item?.display}
selectedItem={selectedItem}
placeholder={isSearchable ? t('search', 'Search') + '...' : null}
shouldFilterItem={({ item, inputValue }) => {
if (!inputValue) {
// Carbon's initial call at component mount
Expand All @@ -165,7 +171,7 @@ const UiSelectExtended: React.FC<FormFieldInputProps> = ({ field, errors, warnin
isProcessingSelection.current = false;
return;
}
if (field.questionOptions['isSearchable']) {
if (field.questionOptions.isSearchable) {
setSearchTerm(value);
}
}}
Expand All @@ -178,6 +184,7 @@ const UiSelectExtended: React.FC<FormFieldInputProps> = ({ field, errors, warnin
}
}}
/>
{isSearching && <InlineLoading className={styles.loader} description={t('searching', 'Searching') + '...'} />}
</Layer>
</div>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,7 @@
color: colors.$red-60;
font-weight: 600;
}

.loader {
padding: 0.25rem 0.5rem;
}
Loading

0 comments on commit 291fe8b

Please sign in to comment.