Skip to content

Commit

Permalink
Inventory product select update (#1484)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
peterMuriuki authored Oct 11, 2024
1 parent c781da0 commit bf6dad9
Show file tree
Hide file tree
Showing 9 changed files with 659 additions and 99 deletions.
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -162,12 +162,12 @@ const AddLocationInventoryForm = (props: LocationInventoryFormProps) => {
initialValues={initialValues}
>
<FormItem id={product} name={product} label={t('Product name')}>
<PaginatedAsyncSelect<IGroup>
baseUrl={fhirBaseURL}
<ClientSideActionsSelect<IGroup>
fhirBaseUrl={fhirBaseURL}
resourceType={groupResourceType}
transformOption={processProductOptions}
extraQueryParams={productQueryFilters}
showSearch={false}
showSearch={true}
placeholder={t('Select product')}
getFullOptionOnChange={productChangeHandler}
disabled={editMode}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -194,29 +200,32 @@ test('creates new inventory as expected', async () => {
render(<AppWrapper {...thisProps}></AppWrapper>);

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');
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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');
Expand Down
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);
});
Loading

0 comments on commit bf6dad9

Please sign in to comment.