Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
61 changes: 40 additions & 21 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 Down Expand Up @@ -72,7 +73,7 @@ describe('PopulationCalculation', () => {
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 +94,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 +174,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 +249,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 +324,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
149 changes: 140 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,101 @@ 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[] } the evaluation service ids for POSTed patients (may be different than sent IDs or undefined if send was unsuccessful)
Comment thread
lmd59 marked this conversation as resolved.
Outdated
*/
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' }
});
if (!response.ok) {
Comment thread
lmd59 marked this conversation as resolved.
Outdated
showNotification({
icon: <IconAlertCircle />,
title: 'Evaluation service failure',
message: `Submitting data for Patient: ${getPatientNameString(
currentPatients[id].patient
)} failed with code ${response.status}`,
color: 'red'
});
return;
}
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 message: "${responseBody.issue[0].details?.text}"`,
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) {
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 $submitdata in the future
Comment thread
lmd59 marked this conversation as resolved.
Outdated
Comment thread
lmd59 marked this conversation as resolved.
Outdated
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 +202,7 @@ export default function PopulationCalculation() {
});
});
setDetailedResults(labeledDetailedResults);
setOpened(true);
setDrawerOpened(true);
setEnableTableButton(true);
setEnableClauseCoverageButton(true);
}
Expand Down Expand Up @@ -140,7 +243,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 +278,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 +335,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
38 changes: 37 additions & 1 deletion util/fhir/resourceCreation.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { v4 as uuidv4 } from 'uuid';
import { getRandomFirstName, getRandomLastName } from '../randomizer';
import _ from 'lodash';
import { getResourcePrimaryDates } from './dates';
import { getResourcePrimaryDates, jsDateToFHIRDate } from './dates';
import { getResourcePatientReference } from './patient';
import { getResourceCode } from './codes';
import { Enums } from 'fqm-execution';
Expand Down Expand Up @@ -181,6 +181,42 @@ export function createFHIRResourceString(
return JSON.stringify(resource, null, 2);
}

/**
* Creates a FHIR data exchange MeasureReport from measure and subject data to be submitted with associated patient
* https://build.fhir.org/ig/HL7/davinci-deqm/StructureDefinition-datax-measurereport-deqm.html
* @param measure FHIR Measure
* @param measurementPeriod FHIR Period representing the measurement period
* @param subjectId the patient id the MeasureReport is associated with
* @returns { fhir4.MeasureReport } a data exchange measure report used to send Measure-relevant data to a server
*/
export function createDataExchangeMeasureReport(
measure: fhir4.Measure,
measurementPeriod: fhir4.Period,
subjectId: string
): fhir4.MeasureReport {
return {
resourceType: 'MeasureReport',
id: uuidv4(),
measure: measure.url?.includes('|') ? measure.url : `${measure.url}|${measure.version}`, //canonical measure/version
period: measurementPeriod,
status: 'complete',
type: 'data-collection',
subject: { reference: `Patient/${subjectId}` },
date: jsDateToFHIRDate(new Date()),
reporter: { reference: 'Organization/fqm-testify' }, //TODO: do we need to send an organization resource?
meta: {
profile: ['http://hl7.org/fhir/us/davinci-deqm/StructureDefinition/datax-measurereport-deqm']
},
extension: [
{
url: 'http://hl7.org/fhir/us/davinci-deqm/StructureDefinition/extension-submitDataUpdateType',
valueCode: 'snapshot'
}
],
contained: [{ resourceType: 'Organization', id: 'fqm-testify' }]
};
}

/**
* Creates a FHIR cqfm test case MeasureReport from measure and subject data to be exported with associated patient
* @param mb FHIR MeasureBundle
Expand Down