Skip to content

Commit

Permalink
BREAKING: O3-2620 Connect Lab Orders to Orderable ConvSet (#1676)
Browse files Browse the repository at this point in the history
* O3-2620 Connect Lab Orders to Orderable ConvSet

* Clean up

* Fixup

---------

Co-authored-by: Dennis Kigen <[email protected]>
  • Loading branch information
brandones and denniskigen authored Feb 20, 2024
1 parent eb32c3e commit 059c36f
Show file tree
Hide file tree
Showing 6 changed files with 169 additions and 19 deletions.
22 changes: 18 additions & 4 deletions packages/esm-patient-labs-app/src/config-schema.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,32 @@
import { Type } from '@openmrs/esm-framework';

export const configSchema = {
concepts: {
resultsViewerConcepts: {
_type: Type.Array,
_elements: {
conceptUuid: {
_type: Type.UUID,
_description: 'UUID of concept to load from /obstree',
_description: `UUID of a test or a concept set containing tests as members, members' members, and so on. Test results will be loaded by querying the REST /obstree endpoint with this concept.`,
},
defaultOpen: {
_type: Type.Boolean,
_description: 'Set default behavior of filter accordion',
_description:
'Each concept set displays the test results it contains in an accordion. Should the accordion be open by default?',
},
},
_default: [
{
// Hematology
conceptUuid: 'ae485e65-2e3f-4297-b35e-c818bbefe894',
defaultOpen: false,
},
{
// Bloodwork (contains Hematology, above)
conceptUuid: '8904fa2b-6a8f-437d-89ec-6fce3cd99093',
defaultOpen: false,
},
{
// HIV viral load
conceptUuid: '856AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
defaultOpen: false,
},
Expand All @@ -40,6 +44,15 @@ export const configSchema = {
_description: "UUID for the 'Lab' order type",
_default: '52a447d3-a64a-11e3-9aeb-50e549534c5e',
},
labOrderableConcepts: {
_type: Type.Array,
_description:
'UUIDs of concepts that represent orderable lab tests or lab sets. If an empty array `[]` is provided, every concept with class `Test` will be considered orderable.',
_elements: {
_type: Type.UUID,
},
_default: ['1748a953-d12e-4be1-914c-f6b096c6cdef'],
},
},
labTestsWithOrderReasons: {
_type: Type.Array,
Expand Down Expand Up @@ -77,10 +90,11 @@ export interface OrderReason {
orderReasons: Array<string>;
}
export interface ConfigObject {
concepts: Array<ObsTreeEntry>;
resultsViewerConcepts: Array<ObsTreeEntry>;
showPrintButton: boolean;
orders: {
labOrderTypeUuid: string;
labOrderableConcepts: Array<string>;
};
labTestsWithOrderReasons: Array<OrderReason>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { useEffect, useRef, useState } from 'react';
import useSWRImmutable from 'swr/immutable';
import { renderHook, waitFor } from '@testing-library/react';
import { getDefaultsFromConfigSchema, openmrsFetch, useConfig } from '@openmrs/esm-framework';
import { useTestTypes } from './useTestTypes';
import { configSchema } from '../../config-schema';

jest.mock('swr/immutable');

const mockOpenrsFetch = openmrsFetch as jest.Mock;
const mockUseConfig = useConfig as jest.Mock;
const mockUseSWRImmutable = useSWRImmutable as jest.Mock;

mockUseSWRImmutable.mockImplementation((keyFcn: () => any, fetcher: any) => {
const promise = useRef(fetcher(keyFcn()));
const [data, setData] = useState(null);

useEffect(() => {
promise.current.then((response) => {
setData(response);
});
}, []);

return { data, isLoading: !data };
});

describe('useTestTypes is configurable', () => {
beforeEach(() => {
mockUseConfig.mockReset();
mockUseConfig.mockReturnValue(getDefaultsFromConfigSchema(configSchema));
mockOpenrsFetch.mockReset();
mockOpenrsFetch.mockImplementation((url: string) => {
if (url.includes('concept?class=Test')) {
return Promise.resolve({ data: { results: [{ display: 'Test concept' }] } });
} else if (/.*concept\/[0-9a-f]+.*/.test(url)) {
return Promise.resolve({ data: { display: 'Orderable set', setMembers: [{ display: 'Configured concept' }] } });
} else {
throw Error('Unexpected URL: ' + url);
}
});
mockUseSWRImmutable.mockClear();
});

it('should return all Test concepts when no labOrderableConcepts are provided', async () => {
mockUseConfig.mockReturnValue({
...getDefaultsFromConfigSchema(configSchema),
orders: { labOrderableConcepts: [] },
});
const { result } = renderHook(() => useTestTypes());
expect(mockOpenrsFetch).toHaveBeenCalledWith('/ws/rest/v1/concept?class=Test');
await waitFor(() => expect(result.current.isLoading).toBeFalsy());
expect(result.current.error).toBeFalsy();
expect(result.current.testTypes).toEqual([expect.objectContaining({ label: 'Test concept' })]);
});

it('should return children of labOrderableConcepts when provided', async () => {
const { result } = renderHook(() => useTestTypes());
expect(mockOpenrsFetch).toHaveBeenCalledWith(expect.stringContaining('/ws/rest/v1/concept/'));
await waitFor(() => expect(result.current.isLoading).toBeFalsy());
expect(result.current.error).toBeFalsy();
expect(result.current.testTypes).toEqual([expect.objectContaining({ label: 'Configured concept' })]);
});
});

describe('useTestTypes filters by text', () => {
beforeEach(() => {
mockUseConfig.mockReset();
mockUseConfig.mockReturnValue(getDefaultsFromConfigSchema(configSchema));
mockOpenrsFetch.mockReset();
mockOpenrsFetch.mockResolvedValue({
data: {
display: 'Orderable set',
setMembers: [{ display: 'Sodium Chloride' }, { display: 'Sodium Bicarbonate' }, { display: 'Potassium' }],
},
});
});

it('should filter test types by search term', async () => {
const { result, rerender } = renderHook((search: string) => useTestTypes(search), { initialProps: '' });
await waitFor(() => expect(result.current.isLoading).toBeFalsy());
expect(result.current.error).toBeFalsy();
expect(result.current.testTypes).toEqual([
expect.objectContaining({ label: 'Sodium Chloride' }),
expect.objectContaining({ label: 'Sodium Bicarbonate' }),
expect.objectContaining({ label: 'Potassium' }),
]);
rerender('sodium');
await waitFor(() => expect(result.current.isLoading).toBeFalsy());
expect(result.current.testTypes).toEqual([
expect.objectContaining({ label: 'Sodium Chloride' }),
expect.objectContaining({ label: 'Sodium Bicarbonate' }),
]);
rerender('pt');
await waitFor(() => expect(result.current.isLoading).toBeFalsy());
expect(result.current.testTypes).toEqual([expect.objectContaining({ label: 'Potassium' })]);
});
});
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { useMemo } from 'react';
import { useEffect, useMemo } from 'react';
import useSWRImmutable from 'swr/immutable';
import fuzzy from 'fuzzy';
import { type FetchResponse, openmrsFetch } from '@openmrs/esm-framework';
import { type FetchResponse, openmrsFetch, useConfig, reportError } from '@openmrs/esm-framework';
import { type Concept } from '../../types';
import { type ConfigObject } from '../../config-schema';

type ConceptResult = FetchResponse<Concept>;
type ConceptResults = FetchResponse<{ results: Array<Concept> }>;

export interface TestType {
label: string;
Expand All @@ -15,30 +19,64 @@ export interface UseTestType {
error: Error;
}

export function useTestTypes(searchTerm: string = ''): UseTestType {
const { data, error, isLoading } = useSWRImmutable<FetchResponse<{ results: Array<Concept> }>>(
() => `/ws/rest/v1/concept?class=Test`,
openmrsFetch,
function openmrsFetchMultiple(urls: Array<string>) {
// SWR has an RFC for `useSWRList`:
// https://github.com/vercel/swr/discussions/1988
// If that ever is implemented we should switch to using that.
return Promise.all(urls.map((url) => openmrsFetch<{ results: Array<Concept> }>(url)));
}

function useTestConceptsSWR(labOrderableConcepts?: Array<string>) {
const { data, isLoading, error } = useSWRImmutable(
() =>
labOrderableConcepts
? labOrderableConcepts.map((c) => `/ws/rest/v1/concept/${c}`)
: `/ws/rest/v1/concept?class=Test`,
(labOrderableConcepts ? openmrsFetchMultiple : openmrsFetch) as any,
{
shouldRetryOnError(err) {
return err instanceof Response;
},
},
);

const testTypes = useMemo(() => {
const results = data?.data.results ?? ([] as Concept[]);
return results.map((concept) => ({
const results = useMemo(() => {
if (isLoading || error) return null;
return labOrderableConcepts
? (data as Array<ConceptResult>)?.flatMap((d) => d.data.setMembers)
: (data as ConceptResults)?.data.results ?? ([] as Concept[]);
}, [data, isLoading, error]);

return {
data: results,
isLoading,
error,
};
}

export function useTestTypes(searchTerm: string = ''): UseTestType {
const { labOrderableConcepts } = useConfig<ConfigObject>().orders;

const { data, isLoading, error } = useTestConceptsSWR(labOrderableConcepts.length ? labOrderableConcepts : null);

useEffect(() => {
if (error) {
reportError(error);
}
}, [error]);

const testConcepts = useMemo(() => {
return data?.map((concept) => ({
label: concept.display,
conceptUuid: concept.uuid,
}));
}, [data]);

const filteredTestTypes = useMemo(() => {
return searchTerm
? fuzzy.filter(searchTerm, testTypes, { extract: (testType) => testType.label }).map((result) => result.original)
: testTypes;
}, [testTypes, searchTerm]);
return searchTerm && !isLoading && !error
? fuzzy.filter(searchTerm, testConcepts, { extract: (c) => c.label }).map((result) => result.original)
: testConcepts;
}, [testConcepts, searchTerm]);

return {
testTypes: filteredTestTypes,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ const FilterSet: React.FC<FilterSetProps> = ({ hideFilterSetHeader = false }) =>
{treeDataFiltered?.length > 0 ? (
treeDataFiltered?.map((root, index) => (
<div className={styles.nestedAccordion} key={`filter-node-${index}`}>
<FilterNode root={root} level={0} open={config.concepts[index].defaultOpen} />
<FilterNode root={root} level={0} open={config.resultsViewerConcepts[index].defaultOpen} />
</div>
))
) : (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ interface ResultsViewerProps {
const RoutedResultsViewer: React.FC<ResultsViewerProps> = ({ basePath, patientUuid }) => {
const { t } = useTranslation();
const config = useConfig<ConfigObject>();
const conceptUuids = config.concepts.map((concept) => concept.conceptUuid) ?? [];
const conceptUuids = config.resultsViewerConcepts.map((concept) => concept.conceptUuid) ?? [];
const { roots, loading, error } = useGetManyObstreeData(conceptUuids);

if (error) {
Expand Down
1 change: 1 addition & 0 deletions tools/setup-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ window.URL.createObjectURL = jest.fn();
global.openmrsBase = '/openmrs';
global.spaBase = '/spa';
global.getOpenmrsSpaBase = () => '/openmrs/spa/';
global.Response = Object as any;

Object.defineProperty(window, 'matchMedia', {
writable: true,
Expand Down

0 comments on commit 059c36f

Please sign in to comment.