From bf6dad9908472f3bec01dd63c77d988641accc76 Mon Sep 17 00:00:00 2001 From: Peter Muriuki Date: Fri, 11 Oct 2024 15:43:01 +0300 Subject: [PATCH] Inventory product select update (#1484) * Fix pagination bug in PaginatedAsyncSelect component * Update Async Select component To include one with client side filters and search * Update select component used on the location inventory view --- .../src/components/LocationInventory/form.tsx | 8 +- .../LocationInventory/tests/form.test.tsx | 97 ++++++---- .../ClientSideActionsSelect/index.tsx | 90 +++++++++ .../tests/index.test.tsx | 143 ++++++++++++++ .../PaginatedAsyncSelect/index.tsx | 106 ++++++----- .../PaginatedAsyncSelect/tests/fixtures.ts | 180 ++++++++++++++++++ .../PaginatedAsyncSelect/tests/index.test.tsx | 98 +++++++++- .../src/components/AsyncSelect/index.tsx | 2 + .../{PaginatedAsyncSelect => }/utils.ts | 34 +++- 9 files changed, 659 insertions(+), 99 deletions(-) create mode 100644 packages/react-utils/src/components/AsyncSelect/ClientSideActionsSelect/index.tsx create mode 100644 packages/react-utils/src/components/AsyncSelect/ClientSideActionsSelect/tests/index.test.tsx rename packages/react-utils/src/components/AsyncSelect/{PaginatedAsyncSelect => }/utils.ts (65%) diff --git a/packages/fhir-group-management/src/components/LocationInventory/form.tsx b/packages/fhir-group-management/src/components/LocationInventory/form.tsx index 42429566c..0268e7000 100644 --- a/packages/fhir-group-management/src/components/LocationInventory/form.tsx +++ b/packages/fhir-group-management/src/components/LocationInventory/form.tsx @@ -1,11 +1,11 @@ import React, { useState } from 'react'; import { Form, Button, Input, DatePicker, Space, Switch } from 'antd'; import { - PaginatedAsyncSelect, formItemLayout, tailLayout, SelectOption as ProductSelectOption, ValueSetAsyncSelect, + ClientSideActionsSelect, } from '@opensrp/react-utils'; import { useTranslation } from '../../mls'; import { useQueryClient, useMutation } from 'react-query'; @@ -162,12 +162,12 @@ const AddLocationInventoryForm = (props: LocationInventoryFormProps) => { initialValues={initialValues} > - - baseUrl={fhirBaseURL} + + fhirBaseUrl={fhirBaseURL} resourceType={groupResourceType} transformOption={processProductOptions} extraQueryParams={productQueryFilters} - showSearch={false} + showSearch={true} placeholder={t('Select product')} getFullOptionOnChange={productChangeHandler} disabled={editMode} diff --git a/packages/fhir-group-management/src/components/LocationInventory/tests/form.test.tsx b/packages/fhir-group-management/src/components/LocationInventory/tests/form.test.tsx index 9ae6f8b83..b6fed5610 100644 --- a/packages/fhir-group-management/src/components/LocationInventory/tests/form.test.tsx +++ b/packages/fhir-group-management/src/components/LocationInventory/tests/form.test.tsx @@ -152,7 +152,13 @@ test('creates new inventory as expected', async () => { const preFetchScope = nock(props.fhirBaseURL) .get(`/${groupResourceType}/_search`) .query({ - _getpagesoffset: 0, + _summary: 'count', + code: 'http://snomed.info/sct|386452003', + '_has:List:item:_id': props.commodityListId, + }) + .reply(200, { total: 20 }) + .get(`/${groupResourceType}/_search`) + .query({ _count: 20, code: 'http://snomed.info/sct|386452003', '_has:List:item:_id': props.commodityListId, @@ -194,29 +200,32 @@ test('creates new inventory as expected', async () => { render(); await waitFor(() => { - expect(preFetchScope.isDone()).toBeTruthy(); + expect(preFetchScope.pendingMocks()).toEqual([]); }); - // simulate value selection for product - const productSelectComponent = document.querySelector(`input#${product}`)!; - fireEvent.mouseDown(productSelectComponent); - - const optionTexts = [ - ...document.querySelectorAll( - `#${product}_list+div.rc-virtual-list .ant-select-item-option-content` - ), - ].map((option) => { - return option.textContent; + await waitFor(() => { + // simulate value selection for product + const productSelectComponent = document.querySelector(`input#${product}`)!; + fireEvent.mouseDown(productSelectComponent); + + const optionTexts = [ + ...document.querySelectorAll( + `#${product}_list+div.rc-virtual-list .ant-select-item-option-content` + ), + ].map((option) => { + return option.textContent; + }); + + expect(optionTexts).toEqual([ + 'Yellow sunshine', + 'Fig tree', + 'Lumpy nuts', + 'Happy Feet', + 'Lilly Flowers', + 'Smartphone TEST', + ]); }); - expect(optionTexts).toEqual([ - 'Yellow sunshine', - 'Fig tree', - 'Lumpy nuts', - 'Happy Feet', - 'Lilly Flowers', - 'Smartphone TEST', - ]); fireEvent.click(document.querySelector(`[title="${'Lumpy nuts'}"]`)!); const quantity = screen.getByLabelText('Quantity'); @@ -269,7 +278,13 @@ test('#1384 - correctly updates location inventory', async () => { const preFetchScope = nock(props.fhirBaseURL) .get(`/${groupResourceType}/_search`) .query({ - _getpagesoffset: 0, + _summary: 'count', + code: 'http://snomed.info/sct|386452003', + '_has:List:item:_id': props.commodityListId, + }) + .reply(200, { total: 20 }) + .get(`/${groupResourceType}/_search`) + .query({ _count: 20, code: 'http://snomed.info/sct|386452003', '_has:List:item:_id': props.commodityListId, @@ -322,26 +337,28 @@ test('#1384 - correctly updates location inventory', async () => { // serial number is initially not shown on the form expect(screen.queryByText('Serial number')).not.toBeInTheDocument(); - // simulate value selection for product - const productSelectComponent = document.querySelector(`input#${product}`)!; - fireEvent.mouseDown(productSelectComponent); - - const optionTexts = [ - ...document.querySelectorAll( - `#${product}_list+div.rc-virtual-list .ant-select-item-option-content` - ), - ].map((option) => { - return option.textContent; + await waitFor(() => { + // simulate value selection for product + const productSelectComponent = document.querySelector(`input#${product}`)!; + fireEvent.mouseDown(productSelectComponent); + + const optionTexts = [ + ...document.querySelectorAll( + `#${product}_list+div.rc-virtual-list .ant-select-item-option-content` + ), + ].map((option) => { + return option.textContent; + }); + + expect(optionTexts).toEqual([ + 'Yellow sunshine', + 'Fig tree', + 'Lumpy nuts', + 'Happy Feet', + 'Lilly Flowers', + 'Smartphone TEST', + ]); }); - - expect(optionTexts).toEqual([ - 'Yellow sunshine', - 'Fig tree', - 'Lumpy nuts', - 'Happy Feet', - 'Lilly Flowers', - 'Smartphone TEST', - ]); fireEvent.click(document.querySelector(`[title="${'Lumpy nuts'}"]`)!); const quantity = screen.getByLabelText('Quantity'); diff --git a/packages/react-utils/src/components/AsyncSelect/ClientSideActionsSelect/index.tsx b/packages/react-utils/src/components/AsyncSelect/ClientSideActionsSelect/index.tsx new file mode 100644 index 000000000..3c576fdaa --- /dev/null +++ b/packages/react-utils/src/components/AsyncSelect/ClientSideActionsSelect/index.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { URLParams } from '@opensrp/server-service'; +import { useQuery } from 'react-query'; +import { Divider, Select, Empty, Spin, Alert } from 'antd'; +import { IResource } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/IResource'; +import { getResourcesFromBundle } from '../../../helpers/utils'; +import { useTranslation } from '../../../mls'; +import { loadAllResources } from '../../../helpers/fhir-utils'; +import { + AbstractedSelectOptions, + defaultSelectFilterFunction, + SelectOption, + TransformOptions, +} from '../utils'; + +export interface ClientSideActionsSelectProps + extends AbstractedSelectOptions { + fhirBaseUrl: string; + resourceType: string; + extraQueryParams?: URLParams; + transformOption: TransformOptions; + getFullOptionOnChange?: (obj: SelectOption | SelectOption[]) => void; +} + +/** + * Select component that loads all options as a single resource + * + * @param props - component props + */ +export function ClientSideActionsSelect( + props: ClientSideActionsSelectProps +) { + const { + fhirBaseUrl, + resourceType, + extraQueryParams = {}, + transformOption, + onChange, + getFullOptionOnChange, + ...restProps + } = props; + + const { t } = useTranslation(); + + const { + data: options, + isLoading, + error, + } = useQuery({ + queryKey: [ClientSideActionsSelect.name, resourceType], + queryFn: async () => { + return await loadAllResources(fhirBaseUrl, resourceType, extraQueryParams); + }, + refetchOnWindowFocus: false, + select: (bundle) => { + const options = getResourcesFromBundle(bundle).map((resource) => + transformOption(resource) + ); + return options as SelectOption[]; + }, + }); + + const changeHandler = ( + value: string, + fullOption: SelectOption | SelectOption[] + ) => { + const saneFullOption = Array.isArray(fullOption) ? fullOption.slice() : fullOption; + props.onChange?.(value, saneFullOption); + getFullOptionOnChange?.(saneFullOption); + }; + + const propsToSelect = { + className: 'asyncSelect', + filterOption: defaultSelectFilterFunction, + ...restProps, + onChange: changeHandler, + loading: isLoading, + notFoundContent: isLoading ? : , + options, + dropdownRender: (menu: React.ReactNode) => ( + <> + {!error && options?.length && menu} + + {error && } + + ), + }; + + return ; +} diff --git a/packages/react-utils/src/components/AsyncSelect/ClientSideActionsSelect/tests/index.test.tsx b/packages/react-utils/src/components/AsyncSelect/ClientSideActionsSelect/tests/index.test.tsx new file mode 100644 index 000000000..11488bd00 --- /dev/null +++ b/packages/react-utils/src/components/AsyncSelect/ClientSideActionsSelect/tests/index.test.tsx @@ -0,0 +1,143 @@ +import { + cleanup, + fireEvent, + render, + screen, + waitForElementToBeRemoved, +} from '@testing-library/react'; +import React from 'react'; +import * as reactQuery from 'react-query'; +import { ClientSideActionsSelect } from '../index'; +import { IOrganization } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/IOrganization'; +import nock from 'nock'; +import { store } from '@opensrp/store'; +import { authenticateUser } from '@onaio/session-reducer'; +import flushPromises from 'flush-promises'; +import { + organizationsPage1, + organizationsPage1Summary, +} from '../../PaginatedAsyncSelect/tests/fixtures'; +import userEvent from '@testing-library/user-event'; + +const organizationResourceType = 'Organization'; + +jest.mock('fhirclient', () => { + return jest.requireActual('fhirclient/lib/entry/browser'); +}); + +const { QueryClient, QueryClientProvider } = reactQuery; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + cacheTime: 0, + }, + }, +}); + +export const QueryWrapper = ({ children }: { children: JSX.Element }) => ( + {children} +); + +beforeAll(() => { + nock.disableNetConnect(); + store.dispatch( + authenticateUser( + true, + { + email: 'bob@example.com', + name: 'Bobbie', + username: 'RobertBaratheon', + }, + { api_token: 'hunter2', oAuth2Data: { access_token: 'sometoken', state: 'abcde' } } + ) + ); +}); + +afterAll(() => { + nock.enableNetConnect(); +}); + +afterEach(() => { + nock.cleanAll(); + cleanup(); + jest.resetAllMocks(); +}); + +const commonProps = { + fhirBaseUrl: 'https://sample.com', + resourceType: organizationResourceType, + transformOption: (resource: IOrganization) => { + const { name } = resource; + const id = resource.id as string; + return { + label: name ?? id, + value: id, + ref: resource, + }; + }, +}; + +test('works correctly nominal case', async () => { + nock(commonProps.fhirBaseUrl) + .get(`/${organizationResourceType}/_search`) + .query({ _count: '10' }) + .reply(200, organizationsPage1); + + nock(commonProps.fhirBaseUrl) + .get(`/${organizationResourceType}/_search`) + .query({ _summary: 'count' }) + .reply(200, organizationsPage1Summary); + + const changeMock = jest.fn(); + const fullOptionHandlerMock = jest.fn(); + + const props = { + ...commonProps, + onChange: changeMock, + getFullOptionOnChange: fullOptionHandlerMock, + }; + + render( + + {...props}> + + ); + + await waitForElementToBeRemoved(document.querySelector('.anticon-spin')); + + // click on input. - should see the first 5 records by default + const input = document.querySelector('.ant-select-selector') as Element; + + // simulate click on select - to show dropdown items + fireEvent.mouseDown(input); + + // find antd select options + const selectOptions = document.querySelectorAll('.ant-select-item-option-content'); + + await flushPromises(); + // expect all practitioners (except inactive ones) + expect([...selectOptions].map((opt) => opt.textContent)).toStrictEqual([ + '高雄榮民總醫院', + 'Blok Operacyjny Chirurgii Naczyń', + 'Volunteer virtual hospital 志工虛擬醫院', + 'Volunteer virtual hospital 志工虛擬醫院', + 'Volunteer virtual hospital 志工虛擬醫院', + ]); + + // search and then select. + userEvent.type(input.querySelector('input') as Element, 'Blok'); + + fireEvent.click(screen.getByTitle('Blok Operacyjny Chirurgii Naczyń') as Element); + + const blokOrgId = '22332'; + const blokOrganizationFullOption = { + value: '22332', + ref: organizationsPage1.entry[1].resource, + label: 'Blok Operacyjny Chirurgii Naczyń', + }; + + expect(changeMock).toHaveBeenCalledWith(blokOrgId, blokOrganizationFullOption); + expect(fullOptionHandlerMock).toHaveBeenCalledWith(blokOrganizationFullOption); +}); diff --git a/packages/react-utils/src/components/AsyncSelect/PaginatedAsyncSelect/index.tsx b/packages/react-utils/src/components/AsyncSelect/PaginatedAsyncSelect/index.tsx index 25ad2497e..9597c5d6b 100644 --- a/packages/react-utils/src/components/AsyncSelect/PaginatedAsyncSelect/index.tsx +++ b/packages/react-utils/src/components/AsyncSelect/PaginatedAsyncSelect/index.tsx @@ -4,27 +4,18 @@ import { IBundle } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/IBundle'; import { useInfiniteQuery, useQuery } from 'react-query'; import { VerticalAlignBottomOutlined } from '@ant-design/icons'; import { Button, Divider, Select, Empty, Space, Spin, Alert } from 'antd'; -import type { SelectProps } from 'antd'; import { IResource } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/IResource'; import { debounce } from 'lodash'; import { getResourcesFromBundle } from '../../../helpers/utils'; import { useTranslation } from '../../../mls'; -import { loadResources, getTotalRecordsInBundles, getTotalRecordsOnApi } from './utils'; - -export type SelectOption = { - label: string; - value: string | number; - ref: T; -}; - -export interface TransformOptions { - (resource: T): SelectOption | undefined; -} - -export type AbstractedSelectOptions = Omit< - SelectProps>, - 'loading' | 'options' | 'searchValue' ->; +import { + loadSearchableResources, + getTotalRecordsInBundles, + getTotalRecordsOnApi, + AbstractedSelectOptions, + SelectOption, + TransformOptions, +} from '../utils'; export interface PaginatedAsyncSelectProps extends AbstractedSelectOptions { @@ -76,38 +67,51 @@ export function PaginatedAsyncSelect( }); }, [searchValue]); - const { data, fetchNextPage, hasNextPage, isLoading, isFetchingNextPage, isFetching, error } = - useInfiniteQuery({ - queryKey: [resourceType, debouncedSearchValue, page, pageSize], - queryFn: async ({ pageParam = page }) => { - const response = await loadResources( - baseUrl, - resourceType, - { page: pageParam, pageSize, search: debouncedSearchValue ?? null }, - extraQueryParams - ); - return response; - }, - getNextPageParam: (lastGroup: IBundle, allGroups: IBundle[]) => { - const totalFetched = getTotalRecordsInBundles(allGroups); - const total = lastGroup.total as number; - if (totalFetched < total) { - return page + 1; - } else { - return false; - } - }, - getPreviousPageParam: () => { - if (page === 1) { - return undefined; - } else { - return page - 1; - } - }, - refetchOnWindowFocus: false, - }); + type PageResponse = { res: IBundle; page: number; pageSize: number }; + const { + data: rawData, + fetchNextPage, + hasNextPage, + isLoading, + isFetchingNextPage, + isFetching, + error, + } = useInfiniteQuery({ + queryKey: [resourceType, debouncedSearchValue, page, pageSize], + queryFn: async ({ pageParam = page }) => { + const response = await loadSearchableResources( + baseUrl, + resourceType, + { page: pageParam, pageSize, search: debouncedSearchValue ?? null }, + extraQueryParams + ).then((res) => ({ res, page: pageParam, pageSize })); + return response; + }, + getNextPageParam: (lastGroup: PageResponse, allGroups: PageResponse[]) => { + const allBundles = allGroups.map((group) => group.res); + const totalFetched = getTotalRecordsInBundles(allBundles); + const total = lastGroup.res.total as number; + const nextPage = lastGroup.page + 1; + if (totalFetched < total) { + return nextPage; + } else { + return false; + } + }, + getPreviousPageParam: (lastGroup: PageResponse) => { + const nextPage = lastGroup.page - 1; + if (nextPage === 1) { + return undefined; + } else { + return nextPage; + } + }, + refetchOnWindowFocus: false, + }); + + const data = rawData?.pages.map((page) => page.res) ?? []; - const options = ((data?.pages ?? []) as IBundle[]).flatMap((resourceBundle: IBundle) => { + const options = data.flatMap((resourceBundle: IBundle) => { const resources = getResourcesFromBundle(resourceBundle); const allOptions = resources.map(transformOption); const saneOptions = allOptions.filter((option) => option !== undefined); @@ -167,7 +171,7 @@ export function PaginatedAsyncSelect( setSearchValue(value); }; - const pages = (data?.pages ?? []) as IBundle[]; + const pages = data; const recordsFetchedNum = getTotalRecordsInBundles(pages); const totalPossibleRecords = getTotalRecordsOnApi(pages); const remainingRecords = totalPossibleRecords - recordsFetchedNum; @@ -184,13 +188,13 @@ export function PaginatedAsyncSelect( searchValue, dropdownRender: (menu: React.ReactNode) => ( <> - {!error && data && menu} + {!error && data.length && menu} {error ? ( ) : ( - {data && ( + {data.length && ( {t('Showing {{recordsFetchedNum}}; {{remainingRecords}} more records.', { recordsFetchedNum, diff --git a/packages/react-utils/src/components/AsyncSelect/PaginatedAsyncSelect/tests/fixtures.ts b/packages/react-utils/src/components/AsyncSelect/PaginatedAsyncSelect/tests/fixtures.ts index 897af2d52..d64fc4626 100644 --- a/packages/react-utils/src/components/AsyncSelect/PaginatedAsyncSelect/tests/fixtures.ts +++ b/packages/react-utils/src/components/AsyncSelect/PaginatedAsyncSelect/tests/fixtures.ts @@ -376,3 +376,183 @@ export const organizationsPage1Summary = { type: 'searchset', total: 10, }; + +export const pageSummary = { + resourceType: 'Bundle', + id: '73d0c5dd-8446-453f-a7be-badb4bac22c8', + meta: { + lastUpdated: '2023-01-31T09:06:30.352+00:00', + tag: [ + { + system: 'http://terminology.hl7.org/CodeSystem/v3-ObservationValue', + code: 'SUBSETTED', + display: 'Resource encoded in summary mode', + }, + ], + }, + type: 'searchset', + total: 18, +}; + +export const firstDefaultPage = { + resourceType: 'Bundle', + id: 'b3fcfa95-cc95-4a44-8bf5-abd68292945e', + meta: { + lastUpdated: '2023-01-31T08:38:11.517+00:00', + }, + type: 'searchset', + total: 18, + entry: [ + { + fullUrl: 'https://hapi.fhir.org/baseR4/Organization/1839', + resource: { + resourceType: 'Organization', + id: '1839', + meta: { + versionId: '1', + lastUpdated: '2019-09-21T01:13:54.367+00:00', + source: '#899bf40a941da002', + }, + type: [ + { + coding: [ + { + system: 'http://hl7.org/fhir/organization-type', + code: 'prov', + display: 'Healthcare Provider', + }, + ], + text: 'Healthcare Provider', + }, + ], + name: '高雄榮民總醫院', + }, + search: { + mode: 'match', + }, + }, + ], +}; + +export const secondPage = { + resourceType: 'Bundle', + id: 'b3fcfa95-cc95-4a44-8bf5-abd68292945e', + meta: { + lastUpdated: '2023-01-31T08:38:11.517+00:00', + }, + type: 'searchset', + total: 18, + entry: [ + { + fullUrl: 'https://hapi.fhir.org/baseR4/Organization/30099', + resource: { + resourceType: 'Organization', + id: '30099', + meta: { + versionId: '1', + lastUpdated: '2019-09-26T13:14:11.303+00:00', + source: '#20dc8ea0e407f070', + }, + active: true, + name: 'Hospital Krel Tarron', + }, + search: { + mode: 'match', + }, + }, + ], +}; + +export const thirdPage = { + resourceType: 'Bundle', + id: 'b3fcfa95-cc95-4a44-8bf5-abd68292945e', + meta: { + lastUpdated: '2023-01-31T08:38:11.517+00:00', + }, + type: 'searchset', + total: 18, + entry: [ + { + fullUrl: 'https://hapi.fhir.org/baseR4/Organization/31863', + resource: { + resourceType: 'Organization', + id: '31863', + meta: { + versionId: '1', + lastUpdated: '2019-09-27T12:41:52.007+00:00', + source: '#6b29cdf4ae6b69bd', + }, + text: { + status: 'generated', + div: '
\n Health Level Seven International\n
\n\t\t\t\t3300 Washtenaw Avenue, Suite 227\n
\n\t\t\t\tAnn Arbor, MI 48104\n
\n\t\t\t\tUSA\n
\n\t\t\t\t(+1) 734-677-7777 (phone)\n
\n\t\t\t\t(+1) 734-677-6622 (fax)\n
\n\t\t\t\tE-mail: \n hq@HL7.org\n \n
', + }, + name: 'Health Level Seven International', + alias: ['HL7 International'], + telecom: [ + { + system: 'phone', + value: '(+1) 734-677-7777', + }, + { + system: 'fax', + value: '(+1) 734-677-6622', + }, + { + system: 'email', + value: 'hq@HL7.org', + }, + ], + address: [ + { + line: ['3300 Washtenaw Avenue, Suite 227'], + city: 'Ann Arbor', + state: 'MI', + postalCode: '48104', + country: 'USA', + }, + ], + }, + search: { + mode: 'match', + }, + }, + ], +}; + +export const fourthPage = { + resourceType: 'Bundle', + id: 'b3fcfa95-cc95-4a44-8bf5-abd68292945e', + meta: { + lastUpdated: '2023-01-31T08:38:11.517+00:00', + }, + type: 'searchset', + total: 18, + entry: [ + { + fullUrl: 'https://hapi.fhir.org/baseR4/Organization/30165', + resource: { + resourceType: 'Organization', + id: '30165', + meta: { + versionId: '1', + lastUpdated: '2019-09-26T14:34:41.185+00:00', + source: '#8c99c9b0e07e31fd', + }, + text: { + status: 'generated', + div: '
clinFhir
', + }, + identifier: [ + { + system: 'http://fhir.hl7.org.nz/identifier', + value: 'cf', + }, + ], + name: 'clinFHIR Sample creator', + }, + search: { + mode: 'match', + }, + }, + ], +}; diff --git a/packages/react-utils/src/components/AsyncSelect/PaginatedAsyncSelect/tests/index.test.tsx b/packages/react-utils/src/components/AsyncSelect/PaginatedAsyncSelect/tests/index.test.tsx index c9877d6a8..8fd2761fc 100644 --- a/packages/react-utils/src/components/AsyncSelect/PaginatedAsyncSelect/tests/index.test.tsx +++ b/packages/react-utils/src/components/AsyncSelect/PaginatedAsyncSelect/tests/index.test.tsx @@ -2,6 +2,7 @@ import { cleanup, fireEvent, render, + screen, waitFor, waitForElementToBeRemoved, } from '@testing-library/react'; @@ -12,7 +13,15 @@ import { IOrganization } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/IOrgani import nock from 'nock'; import { store } from '@opensrp/store'; import { authenticateUser } from '@onaio/session-reducer'; -import { organizationsPage1, organizationsPage1Summary, organizationsPage2 } from './fixtures'; +import { + firstDefaultPage, + fourthPage, + organizationsPage1, + organizationsPage1Summary, + organizationsPage2, + secondPage, + thirdPage, +} from './fixtures'; import flushPromises from 'flush-promises'; const organizationResourceType = 'Organization'; @@ -205,6 +214,93 @@ test('works correctly nominal case', async () => { expect(fullOptionHandlerMock).toHaveBeenCalledWith(tarronHospital); }); +test('paginating the infinity query works ok.', async () => { + nock(commonProps.baseUrl) + .get(`/${organizationResourceType}/_search`) + .query({ _getpagesoffset: '0', _count: '5' }) + .reply(200, firstDefaultPage); + + const props = { + ...commonProps, + }; + + render( + + {...props}> + + ); + + await waitForElementToBeRemoved(document.querySelector('.anticon-spin')); + + // // click on input. - should see the first 5 records by default + const input = document.querySelector('.ant-select-selector') as Element; + // simulate click on select - to show dropdown items + fireEvent.mouseDown(input); + + // load more button + const loadMoreButton = screen.getByRole('button', { name: /Load more options/ }); + + // load second page of data. + nock(commonProps.baseUrl) + .get(`/${organizationResourceType}/_search`) + .query({ _getpagesoffset: '5', _count: '5' }) + .reply(200, secondPage); + + fireEvent.click(loadMoreButton); + + await waitFor(() => { + expect(screen.queryByText(/Fetching next page/)).toBeInTheDocument(); + }); + + await waitForElementToBeRemoved(document.querySelector('.anticon-spin')); + + // load third page of data. + nock(commonProps.baseUrl) + .get(`/${organizationResourceType}/_search`) + .query({ _getpagesoffset: '10', _count: '5' }) + .reply(200, thirdPage); + + fireEvent.click(loadMoreButton); + + await waitFor(() => { + expect(screen.queryByText(/Fetching next page/)).toBeInTheDocument(); + }); + + await waitForElementToBeRemoved(document.querySelector('.anticon-spin')); + + // load fourth and last page of data. + nock(commonProps.baseUrl) + .get(`/${organizationResourceType}/_search`) + .query({ _getpagesoffset: '15', _count: '5' }) + .reply(200, fourthPage); + + fireEvent.click(loadMoreButton); + + await waitFor(() => { + expect(screen.queryByText(/Fetching next page/)).toBeInTheDocument(); + }); + + await waitForElementToBeRemoved(document.querySelector('.anticon-spin')); + + // find antd select options + const selectOptions = document.querySelectorAll('.ant-select-item-option-content'); + + await flushPromises(); + // expect all practitioners (except inactive ones) + expect([...selectOptions].map((opt) => opt.textContent)).toStrictEqual([ + '高雄榮民總醫院', + 'Hospital Krel Tarron', + 'clinFHIR Sample creator', + 'Health Level Seven International', + ]); + + // how many records + const recordsText = screen.queryByText(/Showing\s*4\s*\s*;\s*14\s*more records/); + expect(recordsText).toBeInTheDocument(); + + expect(nock.pendingMocks()).toEqual([]); +}); + test('handles error in request', async () => { nock(commonProps.baseUrl) .get(`/${organizationResourceType}/_search`) diff --git a/packages/react-utils/src/components/AsyncSelect/index.tsx b/packages/react-utils/src/components/AsyncSelect/index.tsx index fde192088..26eed9f86 100644 --- a/packages/react-utils/src/components/AsyncSelect/index.tsx +++ b/packages/react-utils/src/components/AsyncSelect/index.tsx @@ -1,3 +1,5 @@ export * from './BaseAsyncSelect'; export * from './PaginatedAsyncSelect'; export * from './ValueSetAsyncSelect'; +export * from './ClientSideActionsSelect'; +export * from './utils'; diff --git a/packages/react-utils/src/components/AsyncSelect/PaginatedAsyncSelect/utils.ts b/packages/react-utils/src/components/AsyncSelect/utils.ts similarity index 65% rename from packages/react-utils/src/components/AsyncSelect/PaginatedAsyncSelect/utils.ts rename to packages/react-utils/src/components/AsyncSelect/utils.ts index 19326ff6d..bd350e528 100644 --- a/packages/react-utils/src/components/AsyncSelect/PaginatedAsyncSelect/utils.ts +++ b/packages/react-utils/src/components/AsyncSelect/utils.ts @@ -1,7 +1,10 @@ import { URLParams } from '@opensrp/server-service'; import { IBundle } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/IBundle'; -import { FHIRServiceClass } from '../../../helpers/dataLoaders'; -import { FhirApiFilter } from '../../../helpers/utils'; +import { FHIRServiceClass } from '../../helpers/dataLoaders'; +import { FhirApiFilter } from '../../helpers/utils'; +import { DefaultOptionType } from 'antd/lib/select'; +import type { SelectProps } from 'antd'; +import { IResource } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/IResource'; /** * Unified function that gets a list of FHIR resources from a FHIR hapi server @@ -11,7 +14,7 @@ import { FhirApiFilter } from '../../../helpers/utils'; * @param params - our params * @param extraParams - any extra user-defined params */ -export const loadResources = async ( +export const loadSearchableResources = async ( baseUrl: string, resourceType: string, params: FhirApiFilter, @@ -64,3 +67,28 @@ export const getTotalRecordsInBundles = (bundles: IBundle[]) => { .reduce((a, v) => a + v, 0) ); }; + +/** + * filter select on search + * + * @param inputValue search term + * @param option select option to filter against + */ +export const defaultSelectFilterFunction = (inputValue: string, option?: DefaultOptionType) => { + return !!option?.label?.toString()?.toLowerCase().includes(inputValue.toLowerCase()); +}; + +export type SelectOption = { + label: string; + value: string | number; + ref: T; +}; + +export interface TransformOptions { + (resource: T): SelectOption | undefined; +} + +export type AbstractedSelectOptions = Omit< + SelectProps>, + 'loading' | 'options' | 'searchValue' +>;