From 0db3905e850c21ade933bd7f8d3547c2ea3cf0dd Mon Sep 17 00:00:00 2001 From: LaurenD Date: Tue, 8 Jul 2025 14:39:15 -0400 Subject: [PATCH 1/4] preliminary submit data approximation and dataX measure report Update test id Update contained Fix tests --- .../PopulationCalculation.test.tsx | 61 ++++--- .../calculation/PopulationCalculation.tsx | 149 ++++++++++++++++-- util/fhir/resourceCreation.ts | 38 ++++- 3 files changed, 217 insertions(+), 31 deletions(-) diff --git a/__tests__/components/calculation/PopulationCalculation.test.tsx b/__tests__/components/calculation/PopulationCalculation.test.tsx index ba2224fb..ebeda585 100644 --- a/__tests__/components/calculation/PopulationCalculation.test.tsx +++ b/__tests__/components/calculation/PopulationCalculation.test.tsx @@ -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: '', @@ -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, @@ -93,17 +94,29 @@ describe('PopulationCalculation', () => { } }); - render( - mantineRecoilWrap( - <> - - - - - - - ) - ); + jest.spyOn(Calculator, 'calculateDataRequirements').mockResolvedValue({ + results: { + resourceType: 'Library', + status: 'draft', + type: {} + } + }); + + await act(async () => { + render( + mantineRecoilWrap( + <> + + + + + + + + + ) + ); + }); const calculateButton = screen.getByRole('button', { name: 'Calculate Population Results' }) as HTMLButtonElement; expect(calculateButton).toBeInTheDocument(); @@ -161,9 +174,11 @@ describe('PopulationCalculation', () => { - - - + + + + + ) ); @@ -234,9 +249,11 @@ describe('PopulationCalculation', () => { - - - + + + + + ) ); @@ -307,9 +324,11 @@ describe('PopulationCalculation', () => { - - - + + + + + ) ); diff --git a/components/calculation/PopulationCalculation.tsx b/components/calculation/PopulationCalculation.tsx index 17ac8386..73739053 100644 --- a/components/calculation/PopulationCalculation.tsx +++ b/components/calculation/PopulationCalculation.tsx @@ -1,4 +1,4 @@ -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'; @@ -6,13 +6,17 @@ 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(); @@ -20,10 +24,14 @@ export default function PopulationCalculation() { 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([]); - 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(null); const [clauseUncoverageHTML, setClauseUncoverageHTML] = useState(null); const trustMetaProfile = useRecoilValue(trustMetaProfileState); @@ -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) + */ + const submitDataToEvaluationService = async (): Promise<(string | undefined)[]> => { + // collect data and POST to evaluation service (TODO: should be $submit-data when available) + + const measure = measureBundle.content?.entry?.find(e => e.resource?.resourceType === 'Measure') + ?.resource as fhir4.Measure; + + const postedIds: Promise[] = 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) { + showNotification({ + icon: , + 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') { + showNotification({ + icon: , + 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: , + 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 + color: 'green' + }); + // TODO: use resolvedIds to populate evaluate modal + setEnableEvaluateButton(true); + } else { + showNotification({ + icon: , + 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: , + title: 'Data Submission Error', + message: e.message, + color: 'red' + }); + } + }); + }; + /** * Uses fqm-execution library to perform calculation on all patients and return their * detailed results. @@ -99,7 +202,7 @@ export default function PopulationCalculation() { }); }); setDetailedResults(labeledDetailedResults); - setOpened(true); + setDrawerOpened(true); setEnableTableButton(true); setEnableClauseCoverageButton(true); } @@ -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" >  Show Table @@ -175,12 +278,37 @@ export default function PopulationCalculation() {  Show Clause Coverage + + + + {detailedResults.length > 0 && ( <> setOpened(false)} + opened={drawerOpened} + onClose={() => setDrawerOpened(false)} position="bottom" padding="md" overlayProps={{ @@ -207,6 +335,9 @@ export default function PopulationCalculation() { )} + + TODO: Evaluate Modal + )} diff --git a/util/fhir/resourceCreation.ts b/util/fhir/resourceCreation.ts index 4190dfdc..a3356791 100644 --- a/util/fhir/resourceCreation.ts +++ b/util/fhir/resourceCreation.ts @@ -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'; @@ -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 From 278af6161b8c7ad1437d1bc8f6af81a628ea9df9 Mon Sep 17 00:00:00 2001 From: LaurenD Date: Mon, 14 Jul 2025 11:19:55 -0400 Subject: [PATCH 2/4] Fix comments and error messages --- .../calculation/PopulationCalculation.tsx | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/components/calculation/PopulationCalculation.tsx b/components/calculation/PopulationCalculation.tsx index 73739053..d6b97c2a 100644 --- a/components/calculation/PopulationCalculation.tsx +++ b/components/calculation/PopulationCalculation.tsx @@ -53,7 +53,7 @@ export default function PopulationCalculation() { * 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) + * @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) @@ -73,17 +73,6 @@ export default function PopulationCalculation() { body: JSON.stringify(bundle), headers: { 'Content-Type': 'application/json+fhir' } }); - if (!response.ok) { - showNotification({ - icon: , - 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') { showNotification({ @@ -113,11 +102,11 @@ export default function PopulationCalculation() { submitDataToEvaluationService() .then(postedIds => { const resolvedIds = postedIds?.filter(id => id !== undefined); - if (resolvedIds) { + if (resolvedIds.length > 0) { showNotification({ icon: , 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 + 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 From 06ac04325beea06758ab9dae71e79fd2f0fecdd5 Mon Sep 17 00:00:00 2001 From: LaurenD Date: Mon, 14 Jul 2025 11:44:36 -0400 Subject: [PATCH 3/4] More robust failure notification --- components/calculation/PopulationCalculation.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/calculation/PopulationCalculation.tsx b/components/calculation/PopulationCalculation.tsx index d6b97c2a..aefca502 100644 --- a/components/calculation/PopulationCalculation.tsx +++ b/components/calculation/PopulationCalculation.tsx @@ -80,7 +80,7 @@ export default function PopulationCalculation() { title: 'Patient data submission failed', message: `Submitting data for Patient: ${getPatientNameString( currentPatients[id].patient - )} failed with message: "${responseBody.issue[0].details?.text}"`, + )} failed with details: "${responseBody.issue[0].details?.text ?? response.status}"`, color: 'red' }); return; From e8c3bae7b0ccfc4c4cb070ed177bf1ab8416735c Mon Sep 17 00:00:00 2001 From: LaurenD Date: Mon, 14 Jul 2025 11:54:31 -0400 Subject: [PATCH 4/4] Fix test warnings --- .../PopulationCalculation.test.tsx | 72 +++++++++++-------- 1 file changed, 42 insertions(+), 30 deletions(-) diff --git a/__tests__/components/calculation/PopulationCalculation.test.tsx b/__tests__/components/calculation/PopulationCalculation.test.tsx index ebeda585..4baa8749 100644 --- a/__tests__/components/calculation/PopulationCalculation.test.tsx +++ b/__tests__/components/calculation/PopulationCalculation.test.tsx @@ -28,46 +28,58 @@ const MOCK_BUNDLE: fhir4.Bundle = { }; describe('PopulationCalculation', () => { - it('should not render Calculate Population Results button by default', () => { - render( - mantineRecoilWrap( - <> - - - - - ) - ); + it('should not render Calculate Population Results button by default', async () => { + await act(async () => { + render( + mantineRecoilWrap( + <> + + + + + + + ) + ); + }); const calculateButton = screen.queryByTestId('calculate-all-button'); expect(calculateButton).not.toBeInTheDocument(); }); - it('should not render Show Table button by default', () => { - render( - mantineRecoilWrap( - <> - - - - - ) - ); + it('should not render Show Table button by default', async () => { + await act(async () => { + render( + mantineRecoilWrap( + <> + + + + + + + ) + ); + }); const showTableButton = screen.queryByTestId('show-table-button'); expect(showTableButton).not.toBeInTheDocument(); }); - it('should not render Show Clause Coverage button by default', () => { - render( - mantineRecoilWrap( - <> - - - - - ) - ); + it('should not render Show Clause Coverage button by default', async () => { + await act(async () => { + render( + mantineRecoilWrap( + <> + + + + + + + ) + ); + }); const showClauseCoverageButton = screen.queryByTestId('show-coverage-button'); expect(showClauseCoverageButton).not.toBeInTheDocument();