From 8cc8589ac74e4855f6670f950668d19a0f928292 Mon Sep 17 00:00:00 2001 From: Peter Muriuki Date: Wed, 9 Oct 2024 21:04:18 +0300 Subject: [PATCH] Update Async Select component To include one with client side filters and search --- .../ClientSideActionsSelect/index.tsx | 90 +++++++++++ .../tests/index.test.tsx | 143 ++++++++++++++++++ .../PaginatedAsyncSelect/index.tsx | 27 ++-- .../src/components/AsyncSelect/index.tsx | 2 + .../{PaginatedAsyncSelect => }/utils.ts | 34 ++++- 5 files changed, 275 insertions(+), 21 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/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 a47187318..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 { @@ -88,7 +79,7 @@ export function PaginatedAsyncSelect( } = useInfiniteQuery({ queryKey: [resourceType, debouncedSearchValue, page, pageSize], queryFn: async ({ pageParam = page }) => { - const response = await loadResources( + const response = await loadSearchableResources( baseUrl, resourceType, { page: pageParam, pageSize, search: debouncedSearchValue ?? null }, 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' +>;