Skip to content

Commit

Permalink
Update Async Select component
Browse files Browse the repository at this point in the history
To include one with client side filters and search
  • Loading branch information
peterMuriuki committed Oct 9, 2024
1 parent 89130ed commit 8cc8589
Show file tree
Hide file tree
Showing 5 changed files with 275 additions and 21 deletions.
Original file line number Diff line number Diff line change
@@ -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<ResourceT extends IResource>
extends AbstractedSelectOptions<ResourceT> {
fhirBaseUrl: string;
resourceType: string;
extraQueryParams?: URLParams;
transformOption: TransformOptions<ResourceT>;
getFullOptionOnChange?: (obj: SelectOption<ResourceT> | SelectOption<ResourceT>[]) => void;
}

/**
* Select component that loads all options as a single resource
*
* @param props - component props
*/
export function ClientSideActionsSelect<ResourceT extends IResource>(
props: ClientSideActionsSelectProps<ResourceT>
) {
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<ResourceT>(bundle).map((resource) =>
transformOption(resource)
);
return options as SelectOption<ResourceT>[];
},
});

const changeHandler = (
value: string,
fullOption: SelectOption<ResourceT> | SelectOption<ResourceT>[]
) => {
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 ? <Spin size="small" /> : <Empty description={t('No data')} />,
options,
dropdownRender: (menu: React.ReactNode) => (
<>
{!error && options?.length && menu}
<Divider style={{ margin: '8px 0' }} />
{error && <Alert message={t('Unable to load dropdown options.')} type="error" showIcon />}
</>
),
};

return <Select {...propsToSelect}></Select>;
}
Original file line number Diff line number Diff line change
@@ -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 }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);

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(
<QueryWrapper>
<ClientSideActionsSelect<IOrganization> {...props}></ClientSideActionsSelect>
</QueryWrapper>
);

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);
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends IResource> = {
label: string;
value: string | number;
ref: T;
};

export interface TransformOptions<T extends IResource> {
(resource: T): SelectOption<T> | undefined;
}

export type AbstractedSelectOptions<ResourceT extends IResource> = Omit<
SelectProps<string, SelectOption<ResourceT>>,
'loading' | 'options' | 'searchValue'
>;
import {
loadSearchableResources,
getTotalRecordsInBundles,
getTotalRecordsOnApi,
AbstractedSelectOptions,
SelectOption,
TransformOptions,
} from '../utils';

export interface PaginatedAsyncSelectProps<ResourceT extends IResource>
extends AbstractedSelectOptions<ResourceT> {
Expand Down Expand Up @@ -88,7 +79,7 @@ export function PaginatedAsyncSelect<ResourceT extends IResource>(
} = 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 },
Expand Down
2 changes: 2 additions & 0 deletions packages/react-utils/src/components/AsyncSelect/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export * from './BaseAsyncSelect';
export * from './PaginatedAsyncSelect';
export * from './ValueSetAsyncSelect';
export * from './ClientSideActionsSelect';
export * from './utils';
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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<T extends IResource> = {
label: string;
value: string | number;
ref: T;
};

export interface TransformOptions<T extends IResource> {
(resource: T): SelectOption<T> | undefined;
}

export type AbstractedSelectOptions<ResourceT extends IResource> = Omit<
SelectProps<string, SelectOption<ResourceT>>,
'loading' | 'options' | 'searchValue'
>;

0 comments on commit 8cc8589

Please sign in to comment.