Skip to content

Commit

Permalink
Eusm Bug fixes (#1461)
Browse files Browse the repository at this point in the history
* Refactor how value set async matches values to options

By diregarding differences in the display value

By only considering code and system

* Fix operator condition when eval-ing inventory accountability status

* Remove console.log

* Fix lint issues
  • Loading branch information
peterMuriuki authored Aug 30, 2024
1 parent 7013686 commit e5f0edf
Show file tree
Hide file tree
Showing 5 changed files with 188 additions and 55 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
SearchForm,
Column,
} from '@opensrp/react-utils';
import { Alert, Button, Col, Divider, Radio, Row, Space } from 'antd';
import { Alert, Button, Col, Divider, Radio, Row, Space, Typography } from 'antd';
import { IGroup } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/IGroup';
import { accEndDateFilterKey, listResourceType, nameFilterKey } from '../../../constants';
import { IBundle } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/IBundle';
Expand Down Expand Up @@ -176,7 +176,7 @@ function activeInventoryByAccEndDate(obj: TableData) {
}
const currentAccEndDate = Date.parse(obj.accountabilityEndDate);
if (!isNaN(currentAccEndDate)) {
return Date.now() >= currentAccEndDate;
return currentAccEndDate >= Date.now();
}
return false;
}
Expand Down Expand Up @@ -322,38 +322,41 @@ export const InventoryView = ({ fhirBaseUrl, locationId }: InventoryViewProps) =
<Row data-testid="inventory-tab" className="list-view">
<Col style={{ width: '100%' }}>
<div className="main-content__header">
<Space>
<Space size={'large'}>
<SearchForm data-testid="search-form" {...searchFormProps} />
<Radio.Group
value={filterRegistry[accEndDateFilterKey].value}
buttonStyle="solid"
onChange={(event) => {
const val = event.target.value;
switch (val) {
case activeValue:
registerFilter(
accEndDateFilterKey,
(el) => {
return activeInventoryByAccEndDate(el);
},
val
);
break;
case inactiveValue:
registerFilter(
accEndDateFilterKey,
(el) => {
return !activeInventoryByAccEndDate(el);
},
val
);
break;
}
}}
>
<Radio.Button value={activeValue}>{t('Active')}</Radio.Button>
<Radio.Button value={inactiveValue}>{t('Inactive')}</Radio.Button>
</Radio.Group>
<Space>
<Typography.Text>{t('Accountability status:')}</Typography.Text>
<Radio.Group
value={filterRegistry[accEndDateFilterKey].value}
buttonStyle="solid"
onChange={(event) => {
const val = event.target.value;
switch (val) {
case activeValue:
registerFilter(
accEndDateFilterKey,
(el) => {
return activeInventoryByAccEndDate(el);
},
val
);
break;
case inactiveValue:
registerFilter(
accEndDateFilterKey,
(el) => {
return !activeInventoryByAccEndDate(el);
},
val
);
break;
}
}}
>
<Radio.Button value={activeValue}>{t('Active')}</Radio.Button>
<Radio.Button value={inactiveValue}>{t('Inactive')}</Radio.Button>
</Radio.Group>
</Space>
</Space>
<RbacCheck permissions={['Group.create']}>
<Button type="primary" onClick={() => history.push(baseInventoryPath)}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -249,22 +249,28 @@ test('works correctly - physical location', async () => {

// check records shown in table.
let tableData = [...inventoryTab.querySelectorAll('table tbody tr')].map((tr) => tr.textContent);
expect(tableData).toEqual(['HealthEdit']);
expect(tableData).toEqual(['Bed nets2/1/20242/1/2024HealthEdit', 'HealthEdit']);

// switch to inactive tab
const inactiveRadio = screen.getByRole('radio', { name: /Inactive/i });
fireEvent.click(inactiveRadio);

// recheck data
tableData = [...inventoryTab.querySelectorAll('table tbody tr')].map((tr) => tr.textContent);
expect(tableData).toEqual(['Bed nets2/1/20242/1/2024HealthEdit']);
expect(tableData).toEqual(['No data']);
checkedRadio = document.querySelector('.ant-radio-button-wrapper-checked');
expect(checkedRadio?.textContent).toEqual('Inactive');

const link = inventoryTab.querySelectorAll('a');
expect(link[0].href).toEqual(
'http://localhost/location/inventory/d9d7aa7b-7488-48e7-bae8-d8ac5bd09334/1277894c-91b5-49f6-a0ac-cdf3f72cc3d5'
);
// switch back to active to inactive tab
const activeRadio = screen.getByRole('radio', { name: /^active/i });
fireEvent.click(activeRadio);

const links = [...inventoryTab.querySelectorAll('a')].map((link) => link.href);
expect(links).toEqual([
'http://localhost/location/inventory/d9d7aa7b-7488-48e7-bae8-d8ac5bd09334/1277894c-91b5-49f6-a0ac-cdf3f72cc3d5',
'http://localhost/location/inventory/d9d7aa7b-7488-48e7-bae8-d8ac5bd09334/e44e26d0-1f7a-41d6-aa57-99c5712ddd66',
'',
]);

// validate search works.
const childLocationSearch = inventoryTab.querySelector('[data-testid="search-form"]')!;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import React from 'react';
import { SelectProps, DefaultOptionType } from 'antd/lib/select';
import { useTranslation } from '../../../mls';
import { UseQueryOptions, useQuery } from 'react-query';
import { TFunction } from '@opensrp/i18n';

export type RawValueType = string | number | (string | number)[];

Expand All @@ -29,13 +30,9 @@ function BaseAsyncSelect<QueryResponse = unknown, QueryProcessedData = unknown>(
const { data, isLoading, error } = useQuery(useQueryParams);

const options = useMemo(() => (data ? optionsGetter(data) : undefined), [data, optionsGetter]);
const selectDropDownRender = dropDownFactory(t, data, error);
const singleSelectProps = {
dropdownRender: (menu: React.ReactNode) => (
<>
{!error && data && menu}
{error && <Alert message={t('Unable to load dropdown options.')} type="error" showIcon />}
</>
),
dropdownRender: selectDropDownRender,
options,
loading: isLoading,
disabled: isLoading,
Expand All @@ -45,4 +42,23 @@ function BaseAsyncSelect<QueryResponse = unknown, QueryProcessedData = unknown>(
return <Select {...singleSelectProps} />;
}

/**
* Factory to help generate the render for dropdown with respect to how query to fetch
* options resolved.
*
* @param t - translator function
* @param data - loaded data
* @param error - query error
*/
export function dropDownFactory(t: TFunction, data?: unknown, error?: Error | null) {
return function selectErrorDropDownRender(menu: React.ReactNode) {
return (
<>
{!error && data && menu}
{error && <Alert message={t('Unable to load dropdown options.')} type="error" showIcon />}
</>
);
};
}

export { BaseAsyncSelect };
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import React from 'react';
import { DefaultOptionType, SelectProps } from 'antd/lib/select';
import React, { useMemo } from 'react';
import Select, { DefaultOptionType, SelectProps } from 'antd/lib/select';
import { ValueSetContains } from '@smile-cdr/fhirts/dist/FHIR-R4/classes/valueSetContains';
import { IValueSet } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/IValueSet';
import { BaseAsyncSelect, RawValueType } from '../BaseAsyncSelect';
import { dropDownFactory, RawValueType } from '../BaseAsyncSelect';
import { FHIRServiceClass } from '../../../helpers/dataLoaders';
import { Coding } from '@smile-cdr/fhirts/dist/FHIR-R4/classes/coding';
import { useQuery } from 'react-query';
import { useTranslation } from '../../../mls';

export interface ValueSetAsyncSelectProps extends SelectProps<RawValueType> {
export interface ValueSetAsyncSelectProps extends SelectProps<RawValueType, DefaultOptionType> {
valueSetURL: string;
fhirBaseUrl: string;
}
Expand All @@ -20,8 +22,9 @@ export const valueSetResourceType = 'ValueSet';
* @param props - AsyncSelect component props
*/
export function ValueSetAsyncSelect(props: ValueSetAsyncSelectProps) {
const { valueSetURL, fhirBaseUrl, ...selectProps } = props;
const { valueSetURL, fhirBaseUrl, value, defaultValue, ...rawSelectProps } = props;

const { t } = useTranslation();
const queryParams = {
queryKey: [valueSetResourceType, valueSetURL],
queryFn: async () =>
Expand All @@ -31,14 +34,37 @@ export function ValueSetAsyncSelect(props: ValueSetAsyncSelectProps) {
select: (data: IValueSet) => getValueSetSelectOptions(data),
};

const asyncSelectProps = {
queryParams,
optionsGetter: (options: DefaultOptionType[]) => options,
const { data, isLoading, error } = useQuery(queryParams);

const optionsByCodeAndSystem = useMemo(() => {
return (data ?? []).reduce((acc, opt) => {
try {
const optionObj = JSON.parse((opt.value ?? '{}') as string);
const key = `${optionObj.code}-${optionObj.system}`;
acc[key] = opt;
return acc;
} catch (_) {
return acc;
}
}, {} as Record<string, DefaultOptionType>);
}, [data]);
const sanitizedValue = useSanitizedValueSelectValue(optionsByCodeAndSystem, value);
const sanitizedDefValue = useSanitizedValueSelectValue(optionsByCodeAndSystem, defaultValue);

const selectDropDownRender = dropDownFactory(t, data, error as Error);

const selectProps = {
dropdownRender: selectDropDownRender,
options: data,
loading: isLoading,
disabled: isLoading,
...rawSelectProps,
filterOption: selectFilterFunction,
...selectProps,
value: sanitizedValue,
defaultValue: sanitizedDefValue,
};

return <BaseAsyncSelect<IValueSet, DefaultOptionType> {...asyncSelectProps} />;
return <Select {...selectProps} />;
}

/**
Expand Down Expand Up @@ -92,3 +118,28 @@ export function getValueSetSelectOptions(data: IValueSet) {
}));
return options;
}

/**
* valueset options are a json stringified representation of the codeable concept,
* the option.value thus can include the `display` property which we should not use when
* testing for equality between a codeableConcept value and the options.
*
* @param optionsByCodeAndSystem - lookup of options by the important parts, code and system
* @param value - a provided or selected value.
*/
export const useSanitizedValueSelectValue = (
optionsByCodeAndSystem: Record<string, DefaultOptionType>,
value?: RawValueType | null
) => {
return useMemo(() => {
try {
if (value) {
const valueOb = JSON.parse(value as string);
const key = `${valueOb.code}-${valueOb.system}`;
return optionsByCodeAndSystem[key].value ?? value;
}
} catch (_) {
return value;
}
}, [optionsByCodeAndSystem, value]);
};
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,60 @@ test('chooses correctly dropdown', async () => {
],
]);
});

test('display is of no consequence when matching value to option', async () => {
const onChangeHandlerMock = jest.fn();
const props = {
...commonProps,
id: 'select',
onChange: onChangeHandlerMock,
showSearch: true,
};
nock(props.fhirBaseUrl)
.get(`/${valueSetResourceType}/$expand?url=${props.valueSetURL}`)
.reply(200, eusmServicePoint);

// When display is different case
let placeboValue = JSON.stringify({
system: 'http://smartregister.org/CodeSystem/eusm-donors',
code: 'NatCom Switzerland',
display: 'NATCOM Switzerland',
});
const { rerender } = render(
<AppWrapper>
<ValueSetAsyncSelect {...{ ...props, value: placeboValue }} />
</AppWrapper>
);

// select is disabled during loading
const antSelectContainer = document.querySelector('.ant-select');
expect(antSelectContainer?.classList.contains('ant-select-disabled')).toBeTruthy();
expect(antSelectContainer?.classList.contains('ant-select-loading')).toBeTruthy();

await waitFor(() => {
expect(antSelectContainer?.classList).not.toContain('ant-select-loading');
});

let activeValue = document.querySelector('.ant-select-selection-item');
expect(activeValue?.textContent).toEqual('NatCom Switzerland');

// When display is left out
placeboValue = JSON.stringify({
system: 'http://smartregister.org/CodeSystem/eusm-donors',
code: 'NatCom Switzerland',
});
rerender(
<AppWrapper>
<ValueSetAsyncSelect {...{ ...props, value: placeboValue }} />
</AppWrapper>
);

await waitFor(() => {
expect(antSelectContainer?.classList).not.toContain('ant-select-loading');
});

activeValue = document.querySelector('.ant-select-selection-item');
expect(activeValue?.textContent).toEqual('NatCom Switzerland');

expect(nock.isDone()).toBeTruthy();
});

0 comments on commit e5f0edf

Please sign in to comment.