From 291fe8b56a619e4e41cfa15206eeb6385c18434a Mon Sep 17 00:00:00 2001 From: Samuel Male Date: Thu, 3 Oct 2024 22:34:04 +0300 Subject: [PATCH] (fix) Revive search functionality in "ui-select-ext" components (#403) * Fix search functionality in ui-select-ext component * Cleanup * Remove duplicate import statement * Fixup * Git rename file --- .../forms/rfe-forms/sample_ui-select-ext.json | 48 +++ .../inputs/select/dropdown.test.tsx | 5 +- .../ui-select-extended.component.tsx | 107 ++--- .../ui-select-extended.scss | 4 + .../ui-select-extended.test.tsx | 408 ++++++++++-------- .../inputs/unspecified/unspecified.test.tsx | 23 +- src/datasources/concept-data-source.ts | 20 +- src/datasources/data-source.ts | 11 + src/form-context.tsx | 42 -- ...alue.ts => useDataSourceDependentValue.ts} | 0 src/hooks/useFormFieldsMeta.ts | 2 +- src/index.ts | 1 - .../default-schema-transformer.ts | 5 + src/types/index.ts | 9 +- src/types/schema.ts | 2 + src/utils/form-helper.test.ts | 1 - 16 files changed, 388 insertions(+), 300 deletions(-) create mode 100644 __mocks__/forms/rfe-forms/sample_ui-select-ext.json delete mode 100644 src/form-context.tsx rename src/hooks/{useDatasourceDependentValue.ts => useDataSourceDependentValue.ts} (100%) diff --git a/__mocks__/forms/rfe-forms/sample_ui-select-ext.json b/__mocks__/forms/rfe-forms/sample_ui-select-ext.json new file mode 100644 index 000000000..d3292f5b4 --- /dev/null +++ b/__mocks__/forms/rfe-forms/sample_ui-select-ext.json @@ -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" +} diff --git a/src/components/inputs/select/dropdown.test.tsx b/src/components/inputs/select/dropdown.test.tsx index b9f04d99a..87debd54e 100644 --- a/src/components/inputs/select/dropdown.test.tsx +++ b/src/components/inputs/select/dropdown.test.tsx @@ -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'; @@ -29,7 +28,7 @@ const question: FormField = { id: 'patient-past-program', }; -const encounterContext: EncounterContext = { +const encounterContext = { patient: { id: '833db896-c1f0-11eb-8529-0242ac130003', }, @@ -154,4 +153,4 @@ describe.skip('dropdown input field', () => { }); }); }); -}); \ No newline at end of file +}); diff --git a/src/components/inputs/ui-select-extended/ui-select-extended.component.tsx b/src/components/inputs/ui-select-extended/ui-select-extended.component.tsx index 36a48c2a2..fa80fb053 100644 --- a/src/components/inputs/ui-select-extended/ui-select-extended.component.tsx +++ b/src/components/inputs/ui-select-extended/ui-select-extended.component.tsx @@ -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 = ({ 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) => { + 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 @@ -103,29 +105,32 @@ const UiSelectExtended: React.FC = ({ 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 ; } - return sessionMode == 'view' || sessionMode == 'embedded-view' || isTrue(field.readonly) ? ( + return isViewMode(sessionMode) || isTrue(field.readonly) ? ( item.uuid == value)?.display : value} @@ -142,6 +147,7 @@ const UiSelectExtended: React.FC = ({ 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 @@ -165,7 +171,7 @@ const UiSelectExtended: React.FC = ({ field, errors, warnin isProcessingSelection.current = false; return; } - if (field.questionOptions['isSearchable']) { + if (field.questionOptions.isSearchable) { setSearchTerm(value); } }} @@ -178,6 +184,7 @@ const UiSelectExtended: React.FC = ({ field, errors, warnin } }} /> + {isSearching && } ) diff --git a/src/components/inputs/ui-select-extended/ui-select-extended.scss b/src/components/inputs/ui-select-extended/ui-select-extended.scss index 363343f6a..4265a6cd7 100644 --- a/src/components/inputs/ui-select-extended/ui-select-extended.scss +++ b/src/components/inputs/ui-select-extended/ui-select-extended.scss @@ -13,3 +13,7 @@ color: colors.$red-60; font-weight: 600; } + +.loader { + padding: 0.25rem 0.5rem; +} diff --git a/src/components/inputs/ui-select-extended/ui-select-extended.test.tsx b/src/components/inputs/ui-select-extended/ui-select-extended.test.tsx index 9b8c8ecd6..00f6b16eb 100644 --- a/src/components/inputs/ui-select-extended/ui-select-extended.test.tsx +++ b/src/components/inputs/ui-select-extended/ui-select-extended.test.tsx @@ -1,211 +1,281 @@ import React from 'react'; -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; -import UiSelectExtended from './ui-select-extended.component'; -import { type EncounterContext, FormContext } from '../../../form-context'; -import { type FormField } from '../../../types'; - -const questions: FormField[] = [ - { - 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: 'Select criteria for new WHO stage:', - type: 'obs', - questionOptions: { - concept: '250e87b6-beb7-44a1-93a1-d3dd74d7e372', - rendering: 'select-concept-answers', - datasource: { - name: 'select_concept_answers_datasource', - config: { - concept: '250e87b6-beb7-44a1-93a1-d3dd74d7e372', - }, - }, - }, - validators: [], - id: '__sq5ELJr7p', - }, -]; - -const encounterContext: EncounterContext = { - patient: { - id: '833db896-c1f0-11eb-8529-0242ac130003', - }, - location: { - uuid: '41e6e516-c1f0-11eb-8529-0242ac130003', - }, - encounter: { - uuid: '873455da-3ec4-453c-b565-7c1fe35426be', - obs: [], - }, - sessionMode: 'enter', - encounterDate: new Date(2023, 8, 29), - setEncounterDate: (value) => {}, - encounterProvider: '2c95f6f5-788e-4e73-9079-5626911231fa', - setEncounterProvider: jest.fn, - setEncounterLocation: jest.fn, - encounterRole: '8cb3a399-d18b-4b62-aefb-5a0f948a3809', - setEncounterRole: jest.fn, -}; +import { act, render, screen } from '@testing-library/react'; +import { type FormSchema, type SessionMode, type OpenmrsEncounter } from '../../../types'; +import { usePatient, useSession } from '@openmrs/esm-framework'; +import { mockPatient } from '../../../../__mocks__/patient.mock'; +import { mockSessionDataResponse } from '../../../../__mocks__/session.mock'; +import { FormEngine } from '../../..'; +import uiSelectExtForm from '../../../../__mocks__/forms/rfe-forms/sample_ui-select-ext.json'; +import { assertFormHasAllFields, findSelectInput } from '../../../utils/test-utils'; +import userEvent from '@testing-library/user-event'; +import * as api from '../../../api'; -const renderForm = (initialValues) => { - render(<>); -}; +const mockUsePatient = jest.mocked(usePatient); +const mockUseSession = jest.mocked(useSession); +global.ResizeObserver = require('resize-observer-polyfill'); + +jest.mock('../../../hooks/useRestMaxResultsCount', () => jest.fn().mockReturnValue({ systemSetting: { value: '50' } })); +jest.mock('lodash-es/debounce', () => jest.fn((fn) => fn)); + +jest.mock('../../../api', () => { + const originalModule = jest.requireActual('../../../api'); + return { + ...originalModule, + getPreviousEncounter: jest.fn().mockImplementation(() => Promise.resolve(null)), + getConcept: jest.fn().mockImplementation(() => Promise.resolve(null)), + saveEncounter: jest.fn(), + }; +}); + +jest.mock('../../../hooks/useEncounterRole', () => ({ + useEncounterRole: jest.fn().mockReturnValue({ + isLoading: false, + encounterRole: { name: 'Clinician', uuid: 'clinician-uuid' }, + error: undefined, + }), +})); + +jest.mock('../../../hooks/useEncounter', () => ({ + useEncounter: jest.fn().mockImplementation((formJson: FormSchema) => { + return { + encounter: formJson.encounter ? (encounter as OpenmrsEncounter) : null, + isLoading: false, + error: undefined, + }; + }), +})); -// Mock the data source fetch behavior -jest.mock('../../../registry/registry', () => ({ - getRegisteredDataSource: jest.fn().mockResolvedValue({ - fetchData: jest.fn().mockImplementation((...args) => { - if (args[1].concept) { +jest.mock('../../../hooks/useConcepts', () => ({ + useConcepts: jest.fn().mockImplementation((references: Set) => { + return { + isLoading: false, + concepts: [], + error: undefined, + }; + }), +})); + +jest.mock('../../../registry/registry', () => { + const originalModule = jest.requireActual('../../../registry/registry'); + return { + ...originalModule, + getRegisteredDataSource: jest.fn().mockResolvedValue({ + fetchData: jest.fn().mockImplementation((...args) => { + if (args[1].class?.length) { + // concept DS + return Promise.resolve([ + { + uuid: 'stage-1-uuid', + display: 'stage 1', + }, + { + uuid: 'stage-2-uuid', + display: 'stage 2', + }, + ]); + } + + // location DS return Promise.resolve([ { - uuid: 'stage-1-uuid', - display: 'stage 1', + uuid: 'aaa-1', + display: 'Kololo', + }, + { + uuid: 'aaa-2', + display: 'Naguru', }, { - uuid: 'stage-2-uuid', - display: 'stage 2', + uuid: 'aaa-3', + display: 'Muyenga', }, ]); - } - - return Promise.resolve([ - { - uuid: 'aaa-1', - display: 'Kololo', - }, - { - uuid: 'aaa-2', - display: 'Naguru', - }, - { - uuid: 'aaa-3', - display: 'Muyenga', - }, - ]); + }), + fetchSingleItem: jest.fn().mockImplementation((uuid: string) => { + return Promise.resolve({ + uuid, + display: 'stage 1', + }); + }), + toUuidAndDisplay: (data) => data, }), - toUuidAndDisplay: (data) => data, - }), -})); + }; +}); -describe.skip('UiSelectExtended Component', () => { - it('renders with items from the datasource', async () => { - await act(async () => { - await renderForm({}); - }); +const encounter = { + uuid: 'encounter-uuid', + obs: [ + { + concept: { + uuid: '160540AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }, + value: 'aaa-2', + formFieldNamespace: 'rfe-forms', + formFieldPath: 'rfe-forms-patient_transfer_location', + uuid: 'obs-uuid-1', + }, + { + concept: { + uuid: '4b59ac07-cf72-4f46-b8c0-4f62b1779f7e', + }, + value: 'stage-1-uuid', + formFieldNamespace: 'rfe-forms', + formFieldPath: 'rfe-forms-problem', + uuid: 'obs-uuid-2', + }, + ], +}; - // setup - const uiSelectExtendedWidget = screen.getByLabelText('Transfer Location'); +const renderForm = (mode: SessionMode = 'enter') => { + render( + , + ); +}; + +describe('UiSelectExtended', () => { + const user = userEvent.setup(); - // assert initial values - expect(questions[0].meta.submission).toBe(undefined); + beforeEach(() => { + Object.defineProperty(window, 'i18next', { + writable: true, + configurable: true, + value: { + language: 'en', + t: jest.fn(), + }, + }); - //Click on the UiSelectExtendedWidget to open the dropdown - fireEvent.click(uiSelectExtendedWidget); + mockUsePatient.mockImplementation(() => ({ + patient: mockPatient, + isLoading: false, + error: undefined, + patientUuid: mockPatient.id, + })); - // Assert that all three items are displayed - expect(screen.getByText('Kololo')).toBeInTheDocument(); - expect(screen.getByText('Naguru')).toBeInTheDocument(); - expect(screen.getByText('Muyenga')).toBeInTheDocument(); + mockUseSession.mockImplementation(() => mockSessionDataResponse.data); }); - it('renders with items from the datasource of select-concept-answers rendering', async () => { - await act(async () => { - await renderForm({}); - }); + describe('Enter/New mode', () => { + it('should render comboboxes correctly for both "non-searchable" and "searchable" instances', async () => { + await act(async () => { + renderForm(); + }); - const uiSelectExtendedWidget = screen.getByLabelText(/Select criteria for new WHO stage:/i); - fireEvent.click(uiSelectExtendedWidget); + await assertFormHasAllFields(screen, [ + { fieldName: 'Transfer Location', fieldType: 'select' }, + { fieldName: 'Problem', fieldType: 'select' }, + ]); - // Assert that all items are displayed - expect(screen.getByText('stage 1')).toBeInTheDocument(); - expect(screen.getByText('stage 2')).toBeInTheDocument(); - }); + // Test for "non-searchable" instance + const transferLocationSelect = await findSelectInput(screen, 'Transfer Location'); + await user.click(transferLocationSelect); + expect(screen.getByText('Kololo')).toBeInTheDocument(); + expect(screen.getByText('Naguru')).toBeInTheDocument(); + expect(screen.getByText('Muyenga')).toBeInTheDocument(); - it('Selects a value from the list', async () => { - await act(async () => { - await renderForm({}); + // Test for "searchable" instance + const problemSelect = await findSelectInput(screen, 'Problem'); + expect(problemSelect).toHaveAttribute('placeholder', 'Search...'); }); - // setup - const uiSelectExtendedWidget = screen.getByLabelText('Transfer Location'); + it('should be possible to select an item from the combobox and submit the form', async () => { + const mockSaveEncounter = jest.spyOn(api, 'saveEncounter'); - //Click on the UiSelectExtendedWidget to open the dropdown - fireEvent.click(uiSelectExtendedWidget); + await act(async () => { + renderForm(); + }); - // Find the list item for 'Naguru' and click it to select - const naguruOption = screen.getByText('Naguru'); - fireEvent.click(naguruOption); + const transferLocationSelect = await findSelectInput(screen, 'Transfer Location'); + await user.click(transferLocationSelect); + const naguruOption = screen.getByText('Naguru'); + await user.click(naguruOption); - // verify - await act(async () => { - expect(questions[0].meta.submission.newValue).toEqual({ - concept: '160540AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', - formFieldNamespace: 'rfe-forms', - formFieldPath: 'rfe-forms-patient_transfer_location', - value: 'aaa-2', - }); - }); - }); + // submit the form + await user.click(screen.getByRole('button', { name: /save/i })); - it('Filters items based on user input', async () => { - await act(async () => { - await renderForm({}); + expect(mockSaveEncounter).toHaveBeenCalledWith( + expect.any(AbortController), + expect.objectContaining({ + obs: [ + { + concept: '160540AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + formFieldNamespace: 'rfe-forms', + formFieldPath: 'rfe-forms-patient_transfer_location', + value: 'aaa-2', + }, + ], + }), + undefined, + ); }); - // setup - const uiSelectExtendedWidget = screen.getByLabelText('Transfer Location'); + it('should be possible to search and select an item from the search-box and submit the form', async () => { + const mockSaveEncounter = jest.spyOn(api, 'saveEncounter'); - //Click on the UiSelectExtendedWidget to open the dropdown - fireEvent.click(uiSelectExtendedWidget); + await act(async () => { + renderForm(); + }); - // Type 'Nag' in the input field to filter items - fireEvent.change(uiSelectExtendedWidget, { target: { value: 'Nag' } }); + const problemSelect = await findSelectInput(screen, 'Problem'); + await user.click(problemSelect); + await user.type(problemSelect, 'stage'); + expect(screen.getByText('stage 1')).toBeInTheDocument(); + expect(screen.getByText('stage 2')).toBeInTheDocument(); + // select the first option + await user.click(screen.getByText('stage 1')); - // Wait for the filtered items to appear in the dropdown - await waitFor(() => { - // Verify that 'Naguru' is in the filtered items - expect(screen.getByText('Naguru')).toBeInTheDocument(); + // submit the form + await user.click(screen.getByRole('button', { name: /save/i })); + + expect(mockSaveEncounter).toHaveBeenCalledWith( + expect.any(AbortController), + expect.objectContaining({ + obs: [ + { + concept: '4b59ac07-cf72-4f46-b8c0-4f62b1779f7e', + formFieldNamespace: 'rfe-forms', + formFieldPath: 'rfe-forms-problem', + value: 'stage-1-uuid', + }, + ], + }), + undefined, + ); + }); + + it('should filter items based on user input', async () => { + await act(async () => { + renderForm(); + }); - // Verify that 'Kololo' and 'Muyenga' are not in the filtered items + const transferLocationSelect = await findSelectInput(screen, 'Transfer Location'); + await user.click(transferLocationSelect); + await user.type(transferLocationSelect, 'Nag'); + + expect(screen.getByText('Naguru')).toBeInTheDocument(); expect(screen.queryByText('Kololo')).not.toBeInTheDocument(); expect(screen.queryByText('Muyenga')).not.toBeInTheDocument(); }); }); - it('Should set the correct value for the config parameter', async () => { - // Mock the data source fetch behavior - const expectedConfigValue = { - tag: 'test-tag', - }; + describe('Edit mode', () => { + it('should initialize with the current value for both "non-searchable" and "searchable" instances', async () => { + await act(async () => { + renderForm('edit'); + }); - // Mock the getRegisteredDataSource function - jest.mock('../../../registry/registry', () => ({ - getRegisteredDataSource: jest.fn().mockResolvedValue({ - fetchData: jest.fn().mockResolvedValue([]), - toUuidAndDisplay: (data) => data, - config: expectedConfigValue, - }), - })); + // Non-searchable instance + const nonSearchableInstance = await findSelectInput(screen, 'Transfer Location'); + expect(nonSearchableInstance).toHaveValue('Naguru'); - await act(async () => { - await renderForm({}); + // Searchable instance + const searchableInstance = await findSelectInput(screen, 'Problem'); + expect(searchableInstance).toHaveValue('stage 1'); }); - const config = questions[0].questionOptions.datasource.config; - - // Assert that the config is set with the expected configuration value - expect(config).toEqual(expectedConfigValue); }); }); diff --git a/src/components/inputs/unspecified/unspecified.test.tsx b/src/components/inputs/unspecified/unspecified.test.tsx index f716321ae..3fe607dac 100644 --- a/src/components/inputs/unspecified/unspecified.test.tsx +++ b/src/components/inputs/unspecified/unspecified.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import dayjs from 'dayjs'; import { fireEvent, render, screen } from '@testing-library/react'; import { OpenmrsDatePicker } from '@openmrs/esm-framework'; -import { type FormField, type EncounterContext } from '../../..'; +import { type FormField } from '../../../types'; import { findTextOrDateInput } from '../../../utils/test-utils'; const mockOpenmrsDatePicker = jest.mocked(OpenmrsDatePicker); @@ -31,27 +31,6 @@ const question: FormField = { id: 'visit-date', }; -const encounterContext: EncounterContext = { - patient: { - id: '833db896-c1f0-11eb-8529-0242ac130003', - }, - location: { - uuid: '41e6e516-c1f0-11eb-8529-0242ac130003', - }, - encounter: { - uuid: '873455da-3ec4-453c-b565-7c1fe35426be', - obs: [], - }, - sessionMode: 'enter', - encounterDate: new Date(2020, 11, 29), - setEncounterDate: (value) => {}, - encounterProvider: '2c95f6f5-788e-4e73-9079-5626911231fa', - setEncounterProvider: jest.fn, - setEncounterLocation: jest.fn, - encounterRole: '8cb3a399-d18b-4b62-aefb-5a0f948a3809', - setEncounterRole: jest.fn, -}; - const renderForm = (initialValues) => { render(<>); }; diff --git a/src/datasources/concept-data-source.ts b/src/datasources/concept-data-source.ts index 6efd954bb..a70a2fda2 100644 --- a/src/datasources/concept-data-source.ts +++ b/src/datasources/concept-data-source.ts @@ -4,21 +4,21 @@ import { isEmpty } from '../validators/form-validator'; export class ConceptDataSource extends BaseOpenMRSDataSource { constructor() { - super(`${restBaseUrl}/concept?name=&searchType=fuzzy&v=custom:(uuid,display,conceptClass:(uuid,display))`); + super(`${restBaseUrl}/concept?v=custom:(uuid,display,conceptClass:(uuid,display))`); } - fetchData(searchTerm: string, config?: Record, uuid?: string): Promise { + fetchData(searchTerm: string, config?: Record): Promise { if (isEmpty(config?.class) && isEmpty(config?.concept) && !config?.useSetMembersByConcept && isEmpty(searchTerm)) { return Promise.resolve([]); } - let apiUrl = this.url; + let searchUrl = `${restBaseUrl}/concept?name=&searchType=fuzzy&v=custom:(uuid,display,conceptClass:(uuid,display))`; if (config?.class) { if (typeof config.class == 'string') { - const urlParts = apiUrl.split('searchType=fuzzy'); - apiUrl = `${urlParts[0]}searchType=fuzzy&class=${config.class}&${urlParts[1]}`; + const urlParts = searchUrl.split('searchType=fuzzy'); + searchUrl = `${urlParts[0]}searchType=fuzzy&class=${config.class}&${urlParts[1]}`; } else { - return openmrsFetch(searchTerm ? `${apiUrl}&q=${searchTerm}` : apiUrl).then(({ data }) => { + return openmrsFetch(searchTerm ? `${searchUrl}&q=${searchTerm}` : searchUrl).then(({ data }) => { return data.results.filter( (concept) => concept.conceptClass && config.class.includes(concept.conceptClass.uuid), ); @@ -27,15 +27,15 @@ export class ConceptDataSource extends BaseOpenMRSDataSource { } if (config?.concept && config?.useSetMembersByConcept) { - let urlParts = apiUrl.split('?name=&searchType=fuzzy&v='); - apiUrl = `${urlParts[0]}/${config.concept}?v=custom:(uuid,setMembers:(uuid,display))`; - return openmrsFetch(searchTerm ? `${apiUrl}&q=${searchTerm}` : apiUrl).then(({ data }) => { + let urlParts = searchUrl.split('?name=&searchType=fuzzy&v='); + searchUrl = `${urlParts[0]}/${config.concept}?v=custom:(uuid,setMembers:(uuid,display))`; + return openmrsFetch(searchTerm ? `${searchUrl}&q=${searchTerm}` : searchUrl).then(({ data }) => { // return the setMembers from the retrieved concept object return data['setMembers']; }); } - return openmrsFetch(searchTerm ? `${apiUrl}&q=${searchTerm}` : apiUrl).then(({ data }) => { + return openmrsFetch(searchTerm ? `${searchUrl}&q=${searchTerm}` : searchUrl).then(({ data }) => { return data.results; }); } diff --git a/src/datasources/data-source.ts b/src/datasources/data-source.ts index 9ae36e8d2..4d8ea1a2f 100644 --- a/src/datasources/data-source.ts +++ b/src/datasources/data-source.ts @@ -14,6 +14,17 @@ export class BaseOpenMRSDataSource implements DataSource { }); } + fetchSingleItem(uuid: string): Promise { + let apiUrl = this.url; + if (apiUrl.includes('?')) { + const urlParts = apiUrl.split('?'); + apiUrl = `${urlParts[0]}/${uuid}?${urlParts[1]}`; + } else { + apiUrl = `${apiUrl}/${uuid}`; + } + return openmrsFetch(apiUrl).then(({ data }) => data); + } + toUuidAndDisplay(data: OpenmrsResource): OpenmrsResource { if (typeof data.uuid === 'undefined' || typeof data.display === 'undefined') { throw new Error("'uuid' or 'display' not found in the OpenMRS object."); diff --git a/src/form-context.tsx b/src/form-context.tsx deleted file mode 100644 index 8ca5a13b6..000000000 --- a/src/form-context.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import React from 'react'; -import { type LayoutType } from '@openmrs/esm-framework'; -import { - type PatientProgram, - type FormField, - type OpenmrsEncounter, - type PatientIdentifier, - type SessionMode, -} from './types'; - -type FormContextProps = { - values: Record; - setFieldValue: (field: string, value: any, shouldValidate?: boolean) => void; - setEncounterLocation: (value: any) => void; - encounterContext: EncounterContext; - fields: FormField[]; - isFieldInitializationComplete: boolean; - isSubmitting: boolean; - layoutType?: LayoutType; - workspaceLayout?: 'minimized' | 'maximized'; -}; - -export interface EncounterContext { - patient: fhir.Patient; - encounter: OpenmrsEncounter; - previousEncounter?: OpenmrsEncounter; - location: any; - sessionMode: SessionMode; - encounterDate: Date; - setEncounterDate(value: Date): void; - encounterProvider: string; - setEncounterProvider(value: string): void; - setEncounterLocation(value: any): void; - encounterRole: string; - setEncounterRole(value: string): void; - initValues?: Record; - patientIdentifier?: PatientIdentifier; - patientPrograms?: Array; - getFormField?: (id: string) => FormField; -} - -export const FormContext = React.createContext(undefined); diff --git a/src/hooks/useDatasourceDependentValue.ts b/src/hooks/useDataSourceDependentValue.ts similarity index 100% rename from src/hooks/useDatasourceDependentValue.ts rename to src/hooks/useDataSourceDependentValue.ts diff --git a/src/hooks/useFormFieldsMeta.ts b/src/hooks/useFormFieldsMeta.ts index 89b0328f1..973b7ae00 100644 --- a/src/hooks/useFormFieldsMeta.ts +++ b/src/hooks/useFormFieldsMeta.ts @@ -41,7 +41,7 @@ export function useFormFieldsMeta(rawFormFields: FormField[], concepts: OpenmrsR return field; }); } - return []; + return rawFormFields ?? []; }, [concepts, rawFormFields]); return formFields; diff --git a/src/index.ts b/src/index.ts index 8abc13836..b070d72c5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,6 @@ export * from './constants'; export * from './utils/boolean-utils'; export * from './validators/form-validator'; export * from './utils/form-helper'; -export * from './form-context'; export * from './components/value/view/field-value-view.component'; export * from './components/previous-value-review/previous-value-review.component'; export * from './hooks/useFormJson'; diff --git a/src/transformers/default-schema-transformer.ts b/src/transformers/default-schema-transformer.ts index d3b16ac0a..0433ae339 100644 --- a/src/transformers/default-schema-transformer.ts +++ b/src/transformers/default-schema-transformer.ts @@ -164,6 +164,10 @@ function transformByRendering(question: FormField) { case 'markdown': question.type = 'control'; break; + case 'drug': + case 'problem': + question.questionOptions.isSearchable = true; + break; } return question; } @@ -193,6 +197,7 @@ function handleLabOrders(question: FormField) { } function handleSelectConceptAnswers(question: FormField) { + question.questionOptions.isSearchable = true; if (!question.questionOptions.datasource?.config) { question.questionOptions.datasource = { name: 'select_concept_answers_datasource', diff --git a/src/types/index.ts b/src/types/index.ts index d8c3ce83e..39434ae29 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -69,7 +69,14 @@ export interface DataSource { /** * Fetches arbitrary data from a data source */ - fetchData(searchTerm?: string, config?: Record, uuid?: string): Promise>; + fetchData(searchTerm?: string, config?: Record): Promise>; + + /** + * Fetches a single item from the data source based on its UUID. + * This is used for value binding with previously selected values. + */ + fetchSingleItem(uuid: string): Promise; + /** * Maps a data source item to an object with a uuid and display property */ diff --git a/src/types/schema.ts b/src/types/schema.ts index 485f4c4f6..d4ab2a875 100644 --- a/src/types/schema.ts +++ b/src/types/schema.ts @@ -198,6 +198,7 @@ export type RenderType = | 'content-switcher' | 'date' | 'datetime' + | 'drug' | 'encounter-location' | 'encounter-provider' | 'encounter-role' @@ -205,6 +206,7 @@ export type RenderType = | 'file' | 'group' | 'number' + | 'problem' | 'radio' | 'repeating' | 'select' diff --git a/src/utils/form-helper.test.ts b/src/utils/form-helper.test.ts index 05ec54e11..09d87943c 100644 --- a/src/utils/form-helper.test.ts +++ b/src/utils/form-helper.test.ts @@ -8,7 +8,6 @@ import { DefaultValueValidator } from '../validators/default-value-validator'; import { type LayoutType } from '@openmrs/esm-framework'; import { ConceptTrue } from '../constants'; import { type FormField, type OpenmrsEncounter, type SessionMode } from '../types'; -import { type EncounterContext } from '../form-context'; jest.mock('../validators/default-value-validator');