Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 82 additions & 51 deletions __tests__/components/calculation/PopulationCalculation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Calculator } from 'fqm-execution';
import MeasureUpload from '../../../components/measure-upload/MeasureFileUpload';
import { DetailedResult } from '../../../util/types';
import { RouterContext } from 'next/dist/shared/lib/router-context.shared-runtime';
import { Suspense } from 'react';

const MOCK_DETAILED_RESULT: DetailedResult = {
patientId: '',
Expand All @@ -27,52 +28,64 @@ const MOCK_BUNDLE: fhir4.Bundle = {
};

describe('PopulationCalculation', () => {
it('should not render Calculate Population Results button by default', () => {
render(
mantineRecoilWrap(
<>
<RouterContext.Provider value={createMockRouter({ pathname: '/' })}>
<PopulationCalculation />
</RouterContext.Provider>
</>
)
);
it('should not render Calculate Population Results button by default', async () => {
await act(async () => {
render(
mantineRecoilWrap(
<>
<Suspense>
<RouterContext.Provider value={createMockRouter({ pathname: '/' })}>
<PopulationCalculation />
</RouterContext.Provider>
</Suspense>
</>
)
);
});

const calculateButton = screen.queryByTestId('calculate-all-button');
expect(calculateButton).not.toBeInTheDocument();
});

it('should not render Show Table button by default', () => {
render(
mantineRecoilWrap(
<>
<RouterContext.Provider value={createMockRouter({ pathname: '/' })}>
<PopulationCalculation />
</RouterContext.Provider>
</>
)
);
it('should not render Show Table button by default', async () => {
await act(async () => {
render(
mantineRecoilWrap(
<>
<Suspense>
<RouterContext.Provider value={createMockRouter({ pathname: '/' })}>
<PopulationCalculation />
</RouterContext.Provider>
</Suspense>
</>
)
);
});

const showTableButton = screen.queryByTestId('show-table-button');
expect(showTableButton).not.toBeInTheDocument();
});

it('should not render Show Clause Coverage button by default', () => {
render(
mantineRecoilWrap(
<>
<RouterContext.Provider value={createMockRouter({ pathname: '/' })}>
<PopulationCalculation />
</RouterContext.Provider>
</>
)
);
it('should not render Show Clause Coverage button by default', async () => {
await act(async () => {
render(
mantineRecoilWrap(
<>
<Suspense>
<RouterContext.Provider value={createMockRouter({ pathname: '/' })}>
<PopulationCalculation />
</RouterContext.Provider>
</Suspense>
</>
)
);
});

const showClauseCoverageButton = screen.queryByTestId('show-coverage-button');
expect(showClauseCoverageButton).not.toBeInTheDocument();
});

it('should render Calculate Population Results button when measure bundle is present and at least one patient created', () => {
it('should render Calculate Population Results button when measure bundle is present and at least one patient created', async () => {
const MockMB = getMockRecoilState(measureBundleState, {
fileName: 'testName',
content: MOCK_BUNDLE,
Expand All @@ -93,17 +106,29 @@ describe('PopulationCalculation', () => {
}
});

render(
mantineRecoilWrap(
<>
<MockMB />
<MockPatients />
<RouterContext.Provider value={createMockRouter({ pathname: '/' })}>
<PopulationCalculation />
</RouterContext.Provider>
</>
)
);
jest.spyOn(Calculator, 'calculateDataRequirements').mockResolvedValue({
results: {
resourceType: 'Library',
status: 'draft',
type: {}
}
});

await act(async () => {
render(
mantineRecoilWrap(
<>
<MockMB />
<MockPatients />
<Suspense>
<RouterContext.Provider value={createMockRouter({ pathname: '/' })}>
<PopulationCalculation />
</RouterContext.Provider>
</Suspense>
</>
)
);
});

const calculateButton = screen.getByRole('button', { name: 'Calculate Population Results' }) as HTMLButtonElement;
expect(calculateButton).toBeInTheDocument();
Expand Down Expand Up @@ -161,9 +186,11 @@ describe('PopulationCalculation', () => {
<MockPatients />
<MockMB />
<MeasureUpload logError={jest.fn()} />
<RouterContext.Provider value={createMockRouter({ pathname: '/' })}>
<PopulationCalculation />
</RouterContext.Provider>
<Suspense>
<RouterContext.Provider value={createMockRouter({ pathname: '/' })}>
<PopulationCalculation />
</RouterContext.Provider>
</Suspense>
</>
)
);
Expand Down Expand Up @@ -234,9 +261,11 @@ describe('PopulationCalculation', () => {
<MockPatients />
<MockMB />
<MeasureUpload logError={jest.fn()} />
<RouterContext.Provider value={createMockRouter({ pathname: '/' })}>
<PopulationCalculation />
</RouterContext.Provider>
<Suspense>
<RouterContext.Provider value={createMockRouter({ pathname: '/' })}>
<PopulationCalculation />
</RouterContext.Provider>
</Suspense>
</>
)
);
Expand Down Expand Up @@ -307,9 +336,11 @@ describe('PopulationCalculation', () => {
<MockPatients />
<MockMB />
<MeasureUpload logError={jest.fn()} />
<RouterContext.Provider value={createMockRouter({ pathname: '/' })}>
<PopulationCalculation />
</RouterContext.Provider>
<Suspense>
<RouterContext.Provider value={createMockRouter({ pathname: '/' })}>
<PopulationCalculation />
</RouterContext.Provider>
</Suspense>
</>
)
);
Expand Down
138 changes: 129 additions & 9 deletions components/calculation/PopulationCalculation.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,37 @@
import { Button, Center, Drawer, Group, Tooltip } from '@mantine/core';
import { Button, Center, Drawer, Group, Modal, Tooltip } from '@mantine/core';
import { useRecoilValue } from 'recoil';
import { Calculator, CalculatorTypes } from 'fqm-execution';
import { patientTestCaseState } from '../../state/atoms/patientTestCase';
import { measureBundleState } from '../../state/atoms/measureBundle';
import { useState } from 'react';
import { measurementPeriodFormattedState } from '../../state/atoms/measurementPeriod';
import { showNotification } from '@mantine/notifications';
import { IconAlertCircle } from '@tabler/icons';
import { getPatientInfoString } from '../../util/fhir/patient';
import { createPatientBundle } from '../../util/fhir/resourceCreation';
import { IconAlertCircle, IconCircleCheck } from '@tabler/icons';
import { getPatientInfoString, getPatientNameString } from '../../util/fhir/patient';
import { createDataExchangeMeasureReport, createPatientBundle } from '../../util/fhir/resourceCreation';
import PopulationResultTable, { LabeledDetailedResult } from './PopulationResultsTable';
import { DetailedResult } from '../../util/types';
import { useRouter } from 'next/router';
import { trustMetaProfileState } from '../../state/atoms/trustMetaProfile';
import { useDisclosure } from '@mantine/hooks';
import { evaluationState } from '../../state/atoms/evaluation';
import { dataRequirementsLookupByType } from '../../state/selectors/dataRequirementsLookupByType';
import { minimizeTestCaseResources } from '../../util/ValueSetHelper';

export default function PopulationCalculation() {
const router = useRouter();

const currentPatients = useRecoilValue(patientTestCaseState);
const measureBundle = useRecoilValue(measureBundleState);
const measurementPeriodFormatted = useRecoilValue(measurementPeriodFormattedState);
const { evaluationServiceUrl, evaluationMeasureId } = useRecoilValue(evaluationState);
const drLookupByType = useRecoilValue(dataRequirementsLookupByType);
const [detailedResults, setDetailedResults] = useState<LabeledDetailedResult[]>([]);
const [opened, setOpened] = useState(false);
const [drawerOpened, setDrawerOpened] = useState(false);
const [opened, { open, close }] = useDisclosure(false);
const [enableTableButton, setEnableTableButton] = useState(false);
const [enableClauseCoverageButton, setEnableClauseCoverageButton] = useState(false);
const [enableEvaluateButton, setEnableEvaluateButton] = useState(false);
const [clauseCoverageHTML, setClauseCoverageHTML] = useState<string | null>(null);
const [clauseUncoverageHTML, setClauseUncoverageHTML] = useState<string | null>(null);
const trustMetaProfile = useRecoilValue(trustMetaProfileState);
Expand All @@ -40,6 +48,90 @@ export default function PopulationCalculation() {
return patientLabels;
};

/**
* POSTS patient data in conformance with https://build.fhir.org/ig/HL7/davinci-deqm/OperationDefinition-submit-data.html
* without using Measure/$deqm-submit-data endpoint
* Each transaction bundle should contain DEQM Data Exchange MeasureReports with data-of-interest
* and should be for a single subject (will do a separate POST for each patient)
* @returns { string|undefined[] } the evaluation service ids for POSTed patients (may be different than sent IDs or undefined if send was unsuccessful)
*/
const submitDataToEvaluationService = async (): Promise<(string | undefined)[]> => {
// collect data and POST to evaluation service (TODO: should be $submit-data when available)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't have to do this right now, but we should consider having some sort of option to do the "poor man's submit-data" or actual $submit-data...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll try to do this during the c-thon


const measure = measureBundle.content?.entry?.find(e => e.resource?.resourceType === 'Measure')
?.resource as fhir4.Measure;

const postedIds: Promise<string | undefined>[] = Object.keys(currentPatients).map(async id => {
const bundle = createPatientBundle(
currentPatients[id].patient,
minimizeTestCaseResources(currentPatients[id], measureBundle.content, drLookupByType),
currentPatients[id].fullUrl,
createDataExchangeMeasureReport(measure, measurementPeriodFormatted as fhir4.Period, id)
);
const response = await fetch(`${evaluationServiceUrl}/`, {
method: 'POST',
body: JSON.stringify(bundle),
headers: { 'Content-Type': 'application/json+fhir' }
});
const responseBody: fhir4.Bundle | fhir4.OperationOutcome = await response.json();
if (responseBody.resourceType === 'OperationOutcome') {
Comment thread
lmd59 marked this conversation as resolved.
showNotification({
icon: <IconAlertCircle />,
title: 'Patient data submission failed',
message: `Submitting data for Patient: ${getPatientNameString(
currentPatients[id].patient
)} failed with details: "${responseBody.issue[0].details?.text ?? response.status}"`,
color: 'red'
});
return;
}

// should return transaction response bundles from which we can pull the posted patient id
return responseBody.entry
?.find(e => e.response?.location?.includes('Patient/'))
?.response?.location?.split('Patient/')[1];
});

return Promise.all(postedIds);
};

/**
* Wrapper function that calls submitDataToEvaluationService() and resolves patient data for future evaluation
*/
const submitData = () => {
submitDataToEvaluationService()
.then(postedIds => {
const resolvedIds = postedIds?.filter(id => id !== undefined);
if (resolvedIds.length > 0) {
showNotification({
icon: <IconCircleCheck />,
title: 'Successfully sent data',
message: `Successfully sent data for ${resolvedIds.length} patients for measure ${evaluationMeasureId}`, //TODO: update to canonical, currently using evaluationMeasureId here whereas it will be used for $submit-data in the future
color: 'green'
});
// TODO: use resolvedIds to populate evaluate modal
setEnableEvaluateButton(true);
} else {
showNotification({
icon: <IconAlertCircle />,
title: 'No patient information',
message: 'No patient information was successfully POSTed to the Evaluation Service',
color: 'red'
});
}
})
.catch(e => {
if (e instanceof Error) {
showNotification({
icon: <IconAlertCircle />,
title: 'Data Submission Error',
message: e.message,
color: 'red'
});
}
});
};

/**
* Uses fqm-execution library to perform calculation on all patients and return their
* detailed results.
Expand Down Expand Up @@ -99,7 +191,7 @@ export default function PopulationCalculation() {
});
});
setDetailedResults(labeledDetailedResults);
setOpened(true);
setDrawerOpened(true);
setEnableTableButton(true);
setEnableClauseCoverageButton(true);
}
Expand Down Expand Up @@ -140,7 +232,7 @@ export default function PopulationCalculation() {
aria-label="Show Table"
styles={{ root: { marginTop: 20 } }}
disabled={!enableTableButton}
onClick={() => setOpened(true)}
onClick={() => setDrawerOpened(true)}
variant="outline"
>
&nbsp;Show Table
Expand Down Expand Up @@ -175,12 +267,37 @@ export default function PopulationCalculation() {
&nbsp;Show Clause Coverage
</Button>
</Tooltip>
<Button
data-testid="submit-data-button"
aria-label="Submit Data"
styles={{ root: { marginTop: 20 } }}
onClick={() => submitData()}
variant="outline"
>
&nbsp;Submit Data
</Button>
<Tooltip
label="Disabled until data submission has succeeeded"
openDelay={1000}
disabled={enableEvaluateButton}
>
<Button
data-testid="evaluate-button"
aria-label="Evaluate"
styles={{ root: { marginTop: 20 } }}
disabled={!enableEvaluateButton}
onClick={open}
variant="outline"
>
&nbsp;Evaluate
</Button>
</Tooltip>
</Group>
{detailedResults.length > 0 && (
<>
<Drawer
opened={opened}
onClose={() => setOpened(false)}
opened={drawerOpened}
onClose={() => setDrawerOpened(false)}
position="bottom"
padding="md"
overlayProps={{
Expand All @@ -207,6 +324,9 @@ export default function PopulationCalculation() {
</Drawer>
</>
)}
<Modal centered size="xl" withCloseButton={true} opened={opened} onClose={close} title="Evaluate">
TODO: Evaluate Modal
</Modal>
</Center>
)}
</>
Expand Down
Loading