diff --git a/frontend/src/app/models/response-wrapper.ts b/frontend/src/app/models/response-wrapper.ts index 9b528500..19331a5c 100644 --- a/frontend/src/app/models/response-wrapper.ts +++ b/frontend/src/app/models/response-wrapper.ts @@ -1,5 +1,5 @@ export class ResponseWrapper { data: any success: boolean - error: string + error?: string } diff --git a/frontend/src/app/services/fasten-api.service.spec.ts b/frontend/src/app/services/fasten-api.service.spec.ts index 7a50103d..7b80014d 100644 --- a/frontend/src/app/services/fasten-api.service.spec.ts +++ b/frontend/src/app/services/fasten-api.service.spec.ts @@ -26,78 +26,4 @@ describe('FastenApiService', () => { expect(service).toBeTruthy(); }); - it('fhirPathMapQueryFn should generate a valid select map with aliases', () => { - //setup - let patient = { - "resourceType": "Patient", - "id": "example", - "address": [ - { - "use": "home", - "city": "PleasantVille", - "type": "both", - "state": "Vic", - "line": [ - "534 Erewhon St" - ], - "postalCode": "3999", - "period": { - "start": "1974-12-25" - }, - "district": "Rainbow", - "text": "534 Erewhon St PeasantVille, Rainbow, Vic 3999" - } - ] - } - - let query = { - // use: string - select: ['address.use', "address.where(type='both').state as state"], - from: 'Patient', - where: {'id':'example'} - - } as DashboardWidgetQuery - - - let query2 = { - // use: string - select: ['*', "address.where(type='both').use | address.city as joined"], - from: 'Patient', - where: {'id':'example'} - - } as DashboardWidgetQuery - - - //test - let fn = service.fhirPathMapQueryFn(query) - expect(fn(patient)).toEqual({ "address.use": [ 'home' ], "state": [ 'Vic' ], 'id': 'example', 'resourceType': 'Patient' }) - - // let fn2 = service.fhirPathMapQueryFn(query2) - let fn2 = service.fhirPathMapQueryFn(query2) - expect(fn2(patient)).toEqual({ - "joined": [ 'home', 'PleasantVille' ], - "*": { - "resourceType": "Patient", - "id": "example", - "address": [ - { - "use": "home", - "city": "PleasantVille", - "type": "both", - "state": "Vic", - "line": [ - "534 Erewhon St" - ], - "postalCode": "3999", - "period": { - "start": "1974-12-25" - }, - "district": "Rainbow", - "text": "534 Erewhon St PeasantVille, Rainbow, Vic 3999" - } - ] - }, 'id': 'example', 'resourceType': 'Patient' }) - - - }); }); diff --git a/frontend/src/app/services/fasten-api.service.ts b/frontend/src/app/services/fasten-api.service.ts index 52c67530..59764f1a 100644 --- a/frontend/src/app/services/fasten-api.service.ts +++ b/frontend/src/app/services/fasten-api.service.ts @@ -162,46 +162,11 @@ export class FastenApiService { //TODO: add caching here, we dont want the same query to be run multiple times whne loading the dashboard. // we should also add a way to invalidate the cache when a source is synced - queryResources(query?: DashboardWidgetQuery): Observable { + //this function is special, as it returns the raw response, for processing in the DashboardWidgetComponent + queryResources(query?: DashboardWidgetQuery): Observable { return this._httpClient.post(`${GetEndpointAbsolutePath(globalThis.location, environment.fasten_api_endpoint_base)}/secure/query`, query) - .pipe( - map((response: ResponseWrapper) => { - console.log("RESPONSE", response) - - //TODO: eventually do filtering in backend, however until sql-on-fhir project is completed, we'll be doing it here - //it's less preformant, but it's a temporary solution - if(!response.data || !response.data.length){ - console.log("NO QUERY DATA FOUND") - return [] - } - let results = response.data - .map((resource: ResourceFhir) => { - if (!resource.resource_raw) { - return null - } - return this.fhirPathMapQueryFn(query)(resource.resource_raw) - }) - - if(query.aggregation_type){ - switch (query.aggregation_type) { - case "countBy": - - return Object.entries(_[query.aggregation_type](results, ...(query.aggregation_params || []))).map(pair => { - return {key: pair[0], value: pair[1]} - }) - - break; - default: - throw new Error("unsupported aggregation type") - } - } - else { - return results - } - }) - ); } getResourceGraph(graphType?: string): Observable<{[resourceType: string]: ResourceFhir[]}> { @@ -272,56 +237,4 @@ export class FastenApiService { return of(new BinaryModel(attachmentModel)); } } - - //private methods - - // This function will convert DashboardWidgetQuery.select filters into a FHIRPath query strings and return the results - // as a map (keyed by the select alias) - // ie. `name.where(given='Jim')` will be converted to `Patient.name.where(given='Jim')` - // ie. `name.where(given='Jim') as GivenName` will be converted to `Patient.name.where(given='Jim')` and be stored in the returned map as GivenName` - // the returned map will always contain a `id` key, which will be the resource id and a `resourceType` key, which will be the resource type - - fhirPathMapQueryFn(query: DashboardWidgetQuery): (rawResource: any) => { [name:string]: string | string[] | any } { - let selectPathFilters: { [name:string]: string } = query.select.reduce((selectAliasMap, selectPathFilter): { [name:string]: string } => { - let alias = selectPathFilter - let selectPath = selectPathFilter - if(selectPathFilter.indexOf(" as ") > -1){ - let selectPathFilterParts = selectPathFilter.split(" as ") - selectPath = selectPathFilterParts[0] as string - alias = selectPathFilterParts[1] as string - } else if(selectPathFilter.indexOf(" AS ") > -1){ - let selectPathFilterParts = selectPathFilter.split(" AS ") - selectPath = selectPathFilterParts[0] as string - alias = selectPathFilterParts[1] as string - } - - selectAliasMap[alias] = selectPath - // if(selectPath == '*'){ - // selectAliasMap[alias] = selectPath - // } else { - // selectAliasMap[alias] = `${query.from}.${selectPath}` - // } - - return selectAliasMap - }, {}) - - // console.log(selectPathFilters) - return function(rawResource: any):{ [name:string]: string | string[] | any } { - let results = {} - for(let alias in selectPathFilters){ - let selectPathFilter = selectPathFilters[alias] - if(selectPathFilter == '*'){ - results[alias] = rawResource - } else { - results[alias] = fhirpath.evaluate(rawResource, selectPathFilter) - } - } - - results["id"] = rawResource.id - results["resourceType"] = rawResource.resourceType - return results - } - } - - } diff --git a/frontend/src/app/widgets/dashboard-widget/dashboard-widget.component.spec.ts b/frontend/src/app/widgets/dashboard-widget/dashboard-widget.component.spec.ts index 6f22f272..8ed86fc0 100644 --- a/frontend/src/app/widgets/dashboard-widget/dashboard-widget.component.spec.ts +++ b/frontend/src/app/widgets/dashboard-widget/dashboard-widget.component.spec.ts @@ -9,6 +9,8 @@ import {HttpClient} from '@angular/common/http'; import weightFixture from "../fixtures/weight.json" import claimsFixture from "../fixtures/claims.json" import immunizationsFixture from "../fixtures/immunizations.json" +import encountersFixture from "../fixtures/encounters.json" +import {DashboardWidgetQuery} from '../../models/widget/dashboard-widget-query'; describe('DashboardWidgetComponent', () => { let component: DashboardWidgetComponent; @@ -41,7 +43,7 @@ describe('DashboardWidgetComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); - describe('processQueryResults', () => { + describe('chartProcessQueryResults', () => { describe('Weight - SimpleLineChartWidget', () => { it('should parse data', () => { expect(component).toBeTruthy(); @@ -73,22 +75,23 @@ describe('DashboardWidgetComponent', () => { } } //test - component.processQueryResults([weightFixture['data']]) + let processedQueryResponse = component.processQueryResourcesSelectClause(component.widgetConfig.queries[0].q, weightFixture) + component.chartProcessQueryResults([processedQueryResponse]) //assert expect(component.isEmpty).toBeFalse() expect(component.loading).toBeFalse() expect(component.chartLabels).toEqual([ - "4c0ab718-2236-4933-bb31-2ff3a62f3c97", - "6fa1b324-1951-4704-9257-41a8959c87d4", - "75b5050f-a52c-4838-a860-fdf643e99bb4", - "7bfe2e7e-0484-4fc7-90f5-35c2bace9742", - "84c89bf1-aead-481f-8cec-b1ef9683eddd", - "9a11be87-b435-4e39-b060-76b9d892f5d6", - "a425342e-cb4b-4391-b320-634fcb91ec79", - "a737caca-f54e-4896-b846-f4d1fa9b518c", - "b771e5b8-6c57-4b45-beb7-88f5a3676991", - "fc8b95c3-8068-4704-ac54-d3248ecdc64b" + ['2012-04-23T12:22:44-04:00'], + ['2016-05-16T12:22:44-04:00'], + ['2010-04-12T12:22:44-04:00'], + ['2014-05-05T12:22:44-04:00'], + ['2011-04-18T12:22:44-04:00'], + ['2019-06-03T12:22:44-04:00'], + ['2015-05-11T12:22:44-04:00'], + ['2013-04-29T12:22:44-04:00'], + ['2017-05-22T12:22:44-04:00'], + ['2018-05-28T12:22:44-04:00'], ]) expect(component.chartDatasets.length).toBe(1) // @ts-ignore @@ -143,38 +146,169 @@ describe('DashboardWidgetComponent', () => { } } //test - component.processQueryResults([claimsFixture['data'], immunizationsFixture['data']]) + let processedClaimsQueryResponse = component.processQueryResourcesSelectClause(component.widgetConfig.queries[0].q, claimsFixture) + let processedImmunizationsQueryResponse = component.processQueryResourcesSelectClause(component.widgetConfig.queries[1].q, immunizationsFixture) + component.chartProcessQueryResults([processedClaimsQueryResponse, processedImmunizationsQueryResponse]) //assert expect(component.isEmpty).toBeFalse() expect(component.loading).toBeFalse() - expect(component.chartLabels).toEqual([ - '2a332c10-0a12-4d96-a819-0b2a6bfae84a', - '31830307-f2ea-4aee-ab3a-d9623b5cfd2c', - '4738ca48-c949-4d1e-be77-71b7b81a1aa2', - '51ace308-124f-45fa-8d2a-d8e0d84716f7', - '684178bf-2231-4641-a581-9ecde3b3e60c', - '70435780-0fcf-4d08-af8d-a90cac6806d9', - '70db153f-b145-44a0-b8aa-aac646d01c24', - '720374cc-f64a-402e-9f07-940fc22ceafe', - '82c6b29a-453c-4bc7-b3e8-94bf997622d4', - '82f6e9a4-46ba-4d78-a0cb-d37609662918', - '85205d78-9fe4-48d6-979d-5697fa42aebc', - 'a29483c2-7bdc-428d-aa5a-1777fe18b81a', - 'c8ea36b5-1c0b-4488-9df4-6b101048eec5', - 'd4ddd4b5-f57b-4304-a12b-b74914e79d88', - 'd591e8f1-744b-464a-99ab-9131b970863c', - 'd660e444-49a6-4633-a761-e95b12a5a8eb' + expect(component.chartLabels).toEqual([ //TODO: should this be 'Immunization' and 'Claim'? + 'Immunization' ]) expect(component.chartDatasets.length).toBe(2) // // @ts-ignore - expect(component.chartDatasets[0].data.length).toBe(22) - expect(component.chartDatasets[1].data.length).toBe(16) + expect(component.chartDatasets[0].data.length).toBe(1) + expect(component.chartDatasets[1].data.length).toBe(1) // expect(component.chartDatasets.length).toBe(component.chartLabels.length) }); }) + describe('Recent Encounters - TableWidget', () => { + it('should parse data', () => { + expect(component).toBeTruthy(); + + //setup + component.widgetConfig = { + "title_text": "Recent Encounters", + "description_text": "Recent interactions with healthcare providers", + "x": 4, + "y": 10, + "width": 8, + "height": 4, + "item_type": "table-widget", + "queries": [{ + "q": { + "select": [ + "serviceProvider.display as institution", + "period.start as date", + "reasonCode.coding.display.first() as reason", + "participant.individual.display as provider" + ], + "from": "Encounter", + "where": {} + } + }], + "parsing": { + "Id": "id", + "Institution": "institution", + "Reason": "reason", + "Provider": "provider" + } + } + //test + let processedEncountersQueryResponse = component.processQueryResourcesSelectClause(component.widgetConfig.queries[0].q, encountersFixture) + component.chartProcessQueryResults([processedEncountersQueryResponse]) + + //assert + expect(component.isEmpty).toBeFalse() + expect(component.loading).toBeFalse() + expect(component.chartLabels).toEqual([ + '9ea75521-441c-41e4-96df-237219e5ca63', + 'cb3ae560-0a65-41f8-9712-aa3c5766ffe5', + '47f5936b-ef68-4404-9183-8971e75e5a1d', + 'd051d64b-5b2f-4465-92c3-4e693d54f653', + 'cde6c902-957a-48c1-883f-38c808188fe3', + '9f4e40e3-6f6b-4959-b407-3926e9ed049c', + 'cd4bfc24-8fff-4eb0-bd93-3c7054551514', + '9adf6236-72dc-4abf-ab7e-df370b02701c', + '9a6f9230-3549-43e1-83c8-f0fd740a24f3', + '919f002e-ff43-4ea6-8acb-be3f1139c59d', + '7a38b6bf-1787-4227-af08-ae10c29632e4', + 'e44ec534-b752-4647-acd3-3794bc51db6d', + '51dec3d2-a098-4671-99bc-d7cf68561903', + '1a3bbe38-b7c3-43fc-b175-740adfcaa83a', + '3fba349b-e165-45f7-9cf2-66c391f293b6', + '24f73ec3-9b7a-42a9-be03-53bffd9b304c', + '92b439da-6fdd-48e5-aa8d-b0543f840a1b', + 'd9a7c76f-dfe4-41c6-9924-82a405613f44', + ]) + expect(component.chartDatasets.length).toBe(1) + // // @ts-ignore + expect(component.chartDatasets[0].data.length).toBe(18) + // expect(component.chartDatasets.length).toBe(component.chartLabels.length) + }); + + }) + describe('Vitals - ListWidget', () => {}) + describe('Resource Aggregation - DonutWidget', () => {}) + }) + it('fhirPathMapQueryFn should generate a valid select map with aliases', () => { + //setup + let patient = { + "resourceType": "Patient", + "id": "example", + "address": [ + { + "use": "home", + "city": "PleasantVille", + "type": "both", + "state": "Vic", + "line": [ + "534 Erewhon St" + ], + "postalCode": "3999", + "period": { + "start": "1974-12-25" + }, + "district": "Rainbow", + "text": "534 Erewhon St PeasantVille, Rainbow, Vic 3999" + } + ] + } + + let query = { + // use: string + select: ['address.use', "address.where(type='both').state as state"], + from: 'Patient', + where: {'id':'example'} + + } as DashboardWidgetQuery + + + let query2 = { + // use: string + select: ['*', "address.where(type='both').use | address.city as joined"], + from: 'Patient', + where: {'id':'example'} + + } as DashboardWidgetQuery + + + //test + let fn = component.fhirPathMapQueryFn(query) + expect(fn(patient)).toEqual({ "address.use": [ 'home' ], "state": [ 'Vic' ], 'id': 'example', 'resourceType': 'Patient' }) + + // let fn2 = service.fhirPathMapQueryFn(query2) + let fn2 = component.fhirPathMapQueryFn(query2) + expect(fn2(patient)).toEqual({ + "joined": [ 'home', 'PleasantVille' ], + "*": { + "resourceType": "Patient", + "id": "example", + "address": [ + { + "use": "home", + "city": "PleasantVille", + "type": "both", + "state": "Vic", + "line": [ + "534 Erewhon St" + ], + "postalCode": "3999", + "period": { + "start": "1974-12-25" + }, + "district": "Rainbow", + "text": "534 Erewhon St PeasantVille, Rainbow, Vic 3999" + } + ] + }, 'id': 'example', 'resourceType': 'Patient' }) + + + }); + }); diff --git a/frontend/src/app/widgets/dashboard-widget/dashboard-widget.component.ts b/frontend/src/app/widgets/dashboard-widget/dashboard-widget.component.ts index 6c34a8d2..f87cf79c 100644 --- a/frontend/src/app/widgets/dashboard-widget/dashboard-widget.component.ts +++ b/frontend/src/app/widgets/dashboard-widget/dashboard-widget.component.ts @@ -10,6 +10,11 @@ import {forkJoin} from 'rxjs'; import { BehaviorSubject } from 'rxjs'; import * as _ from 'lodash'; import {LoadingWidgetComponent} from '../loading-widget/loading-widget.component'; +import {ResponseWrapper} from '../../models/response-wrapper'; +import {ResourceFhir} from '../../models/fasten/resource_fhir'; +import {DashboardWidgetQuery} from '../../models/widget/dashboard-widget-query'; +import fhirpath from 'fhirpath'; +import {map} from 'rxjs/operators'; @Component({ standalone: true, @@ -41,7 +46,7 @@ export class DashboardWidgetComponent implements OnInit, DashboardWidgetComponen // }[] = []; chartDatasetsDefaults = []; chartDatasets: ChartConfiguration<'line'>['data']['datasets'] = []; - chartLabels: string[] = []; + chartLabels: any[] | string[] = []; chartOptions: ChartOptions = {} // chartColors: any; @@ -55,8 +60,14 @@ export class DashboardWidgetComponent implements OnInit, DashboardWidgetComponen } this.loading = true var currentThis = this - forkJoin(this.widgetConfig.queries.map(query => { return this.fastenApi.queryResources(query.q)})).subscribe( - this.processQueryResults.bind(currentThis), + forkJoin(this.widgetConfig.queries.map(query => { + return this.fastenApi.queryResources(query.q).pipe( + map((responseWrapper: ResponseWrapper) => { + return this.processQueryResourcesSelectClause(query.q, responseWrapper) + }) + ) + })).subscribe( + this.chartProcessQueryResults.bind(currentThis), (error) => { this.loading = false }, @@ -65,8 +76,8 @@ export class DashboardWidgetComponent implements OnInit, DashboardWidgetComponen }) } - //requires widgetConfig to be specified - processQueryResults(queryResults: any[]) { + //process query requests for chartJS library. This is the default implementation, but can be overridden by child classes + chartProcessQueryResults(queryResults: any[]) { try { this.chartDatasets = [] @@ -129,4 +140,92 @@ export class DashboardWidgetComponent implements OnInit, DashboardWidgetComponen } } + + + // This function will process the raw response from the Dashboard Query API call, which requires frontend processing of the select clause. + // it will call the fhirPathMapQueryFn which will extract FHIRPath values from the resource_raw field of the ResourceFhir object + // fhirPathMapQueryFn will also assign aliases where appropriate. + // `where` clause filtering is processed in the backend. + processQueryResourcesSelectClause(query: DashboardWidgetQuery, response: ResponseWrapper): any[]{ + console.log("RESPONSE", response) + if(!response.data || !response.data.length){ + console.log("NO QUERY DATA FOUND") + return [] + } + let results = response.data + .map((resource: ResourceFhir) => { + if (!resource.resource_raw) { + return null + } + return this.fhirPathMapQueryFn(query)(resource.resource_raw) + }) + + if(query.aggregation_type){ + switch (query.aggregation_type) { + case "countBy": + + return Object.entries(_[query.aggregation_type](results, ...(query.aggregation_params || []))).map(pair => { + return {key: pair[0], value: pair[1]} + }) + + break; + default: + throw new Error("unsupported aggregation type") + } + } + else { + return results + } + } + + + // This function will convert DashboardWidgetQuery.select filters into a FHIRPath query strings and return the results + // as a map (keyed by the select alias) + // ie. `name.where(given='Jim')` will be converted to `Patient.name.where(given='Jim')` + // ie. `name.where(given='Jim') as GivenName` will be converted to `Patient.name.where(given='Jim')` and be stored in the returned map as GivenName` + // the returned map will always contain a `id` key, which will be the resource id and a `resourceType` key, which will be the resource type + fhirPathMapQueryFn(query: DashboardWidgetQuery): (rawResource: any) => { [name:string]: string | string[] | any } { + let selectPathFilters: { [name:string]: string } = query.select.reduce((selectAliasMap, selectPathFilter): { [name:string]: string } => { + let alias = selectPathFilter + let selectPath = selectPathFilter + if(selectPathFilter.indexOf(" as ") > -1){ + let selectPathFilterParts = selectPathFilter.split(" as ") + selectPath = selectPathFilterParts[0] as string + alias = selectPathFilterParts[1] as string + } else if(selectPathFilter.indexOf(" AS ") > -1){ + let selectPathFilterParts = selectPathFilter.split(" AS ") + selectPath = selectPathFilterParts[0] as string + alias = selectPathFilterParts[1] as string + } + + selectAliasMap[alias] = selectPath + // if(selectPath == '*'){ + // selectAliasMap[alias] = selectPath + // } else { + // selectAliasMap[alias] = `${query.from}.${selectPath}` + // } + + return selectAliasMap + }, {}) + + // console.log(selectPathFilters) + return function(rawResource: any):{ [name:string]: string | string[] | any } { + let results = {} + for(let alias in selectPathFilters){ + let selectPathFilter = selectPathFilters[alias] + if(selectPathFilter == '*'){ + results[alias] = rawResource + } else { + results[alias] = fhirpath.evaluate(rawResource, selectPathFilter) + } + } + + results["id"] = rawResource.id + results["resourceType"] = rawResource.resourceType + return results + } + } + + + } diff --git a/frontend/src/app/widgets/fixtures/patient_vitals.json b/frontend/src/app/widgets/fixtures/patient_vitals_observation.json similarity index 100% rename from frontend/src/app/widgets/fixtures/patient_vitals.json rename to frontend/src/app/widgets/fixtures/patient_vitals_observation.json diff --git a/frontend/src/app/widgets/fixtures/patient_vitals_patient.json b/frontend/src/app/widgets/fixtures/patient_vitals_patient.json new file mode 100644 index 00000000..619ce3c2 --- /dev/null +++ b/frontend/src/app/widgets/fixtures/patient_vitals_patient.json @@ -0,0 +1,200 @@ +{ + "data": [{ + "id": "11116c9e-ff18-4180-872f-a4aab7c537c7", + "created_at": "2023-06-21T10:30:33.641052-07:00", + "updated_at": "2023-06-21T10:30:33.641052-07:00", + "user_id": "8b6a16f4-4a5f-4b01-bf4c-93c73c9ff0e8", + "source_id": "9d6c6593-4349-4a35-830a-b6eef25b80cc", + "source_resource_type": "Patient", + "source_resource_id": "b426b062-8273-4b93-a907-de3176c0567d", + "sort_date": null, + "sort_title": null, + "resource_raw": { + "resourceType": "Patient", + "id": "b426b062-8273-4b93-a907-de3176c0567d", + "text": { + "status": "generated", + "div": "
Generated by Synthea.Version identifier: v2.4.0-404-ge7ce2295\n . Person seed: 4209933468692678950 Population seed: 0
" + }, + "extension": [ + { + "url": "http://hl7.org/fhir/us/core/StructureDefinition/us-core-race", + "extension": [ + { + "url": "ombCategory", + "valueCoding": { + "system": "urn:oid:2.16.840.1.113883.6.238", + "code": "2106-3", + "display": "White" + } + }, + { + "url": "text", + "valueString": "White" + } + ] + }, + { + "url": "http://hl7.org/fhir/us/core/StructureDefinition/us-core-ethnicity", + "extension": [ + { + "url": "ombCategory", + "valueCoding": { + "system": "urn:oid:2.16.840.1.113883.6.238", + "code": "2186-5", + "display": "Not Hispanic or Latino" + } + }, + { + "url": "text", + "valueString": "Not Hispanic or Latino" + } + ] + }, + { + "url": "http://hl7.org/fhir/StructureDefinition/patient-mothersMaidenName", + "valueString": "Sanda877 Kshlerin58" + }, + { + "url": "http://hl7.org/fhir/us/core/StructureDefinition/us-core-birthsex", + "valueCode": "M" + }, + { + "url": "http://hl7.org/fhir/StructureDefinition/patient-birthPlace", + "valueAddress": { + "city": "Peabody", + "state": "Massachusetts", + "country": "US" + } + }, + { + "url": "http://synthetichealth.github.io/synthea/disability-adjusted-life-years", + "valueDecimal": 0.0 + }, + { + "url": "http://synthetichealth.github.io/synthea/quality-adjusted-life-years", + "valueDecimal": 16.0 + } + ], + "identifier": [ + { + "system": "https://github.com/synthetichealth/synthea", + "value": "262b819a-5193-404a-9787-b7f599358035" + }, + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0203", + "code": "MR", + "display": "Medical Record Number" + } + ], + "text": "Medical Record Number" + }, + "system": "http://hospital.smarthealthit.org", + "value": "262b819a-5193-404a-9787-b7f599358035" + }, + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0203", + "code": "SS", + "display": "Social Security Number" + } + ], + "text": "Social Security Number" + }, + "system": "http://hl7.org/fhir/sid/us-ssn", + "value": "999-19-7941" + }, + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0203", + "code": "DL", + "display": "Driver's License" + } + ], + "text": "Driver's License" + }, + "system": "urn:oid:2.16.840.1.113883.4.3.25", + "value": "S99986287" + } + ], + "name": [ + { + "use": "official", + "family": "Heller342", + "given": [ + "Abraham100" + ] + } + ], + "telecom": [ + { + "system": "phone", + "value": "555-803-7962", + "use": "home" + } + ], + "gender": "male", + "birthDate": "2002-04-15", + "address": [ + { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/geolocation", + "extension": [ + { + "url": "latitude", + "valueDecimal": 42.410027249980345 + }, + { + "url": "longitude", + "valueDecimal": -71.07762136029856 + } + ] + } + ], + "line": [ + "834 Hansen Run" + ], + "city": "Somerville", + "state": "Massachusetts", + "postalCode": "02138", + "country": "US" + } + ], + "maritalStatus": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v3-MaritalStatus", + "code": "S", + "display": "Never Married" + } + ], + "text": "Never Married" + }, + "multipleBirthBoolean": false, + "communication": [ + { + "language": { + "coding": [ + { + "system": "urn:ietf:bcp:47", + "code": "en-US", + "display": "English" + } + ], + "text": "English" + } + } + ] + }, + "related_resources": null + }], + "success": true +} diff --git a/frontend/src/app/widgets/patient-vitals-widget/patient-vitals-widget.component.spec.ts b/frontend/src/app/widgets/patient-vitals-widget/patient-vitals-widget.component.spec.ts index f0ce172a..55086855 100644 --- a/frontend/src/app/widgets/patient-vitals-widget/patient-vitals-widget.component.spec.ts +++ b/frontend/src/app/widgets/patient-vitals-widget/patient-vitals-widget.component.spec.ts @@ -5,6 +5,9 @@ import {FastenApiService} from '../../services/fasten-api.service'; import {HTTP_CLIENT_TOKEN} from '../../dependency-injection'; import {HttpClient} from '@angular/common/http'; import {RouterTestingModule} from '@angular/router/testing'; +import {of} from 'rxjs'; +import patientVitalsObservationFixture from "../fixtures/patient_vitals_observation.json" +import patientVitalsPatientFixture from "../fixtures/patient_vitals_patient.json" describe('PatientVitalsWidgetComponent', () => { let component: PatientVitalsWidgetComponent; @@ -28,6 +31,8 @@ describe('PatientVitalsWidgetComponent', () => { ] }) .compileComponents(); + mockedFastenApiService.queryResources.and.returnValue(of(patientVitalsObservationFixture)); + fixture = TestBed.createComponent(PatientVitalsWidgetComponent); component = fixture.componentInstance; @@ -37,4 +42,37 @@ describe('PatientVitalsWidgetComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + + describe('vitalsProcessQueryResults', () => { + describe('PatientVitals - PatientVitalsWidget', () => { + it('should parse data', () => { + expect(component).toBeTruthy(); + + + //test + let processedVitalsQueryResponse = component.processQueryResourcesSelectClause(component.widgetConfig.queries[0].q, patientVitalsObservationFixture) + let processedPatientQueryResponse = component.processQueryResourcesSelectClause(component.widgetConfig.queries[1].q, patientVitalsPatientFixture) + component.chartProcessQueryResults([processedVitalsQueryResponse, processedPatientQueryResponse]) + + //assert + // name: string = '' + // age: string = '' + // gender: string = '' + // vitalSigns: { + // display: string, + // code: string, + // date: string, + // value: string, + // unit: string + // }[] = [] + + + expect(component.name).toEqual('Abraham100 Heller342') + expect(component.age).toEqual('21 years') + expect(component.gender).toEqual('male') + expect(component.vitalSigns.length).toEqual(16) + }); + }) + }) }); diff --git a/frontend/src/app/widgets/patient-vitals-widget/patient-vitals-widget.component.ts b/frontend/src/app/widgets/patient-vitals-widget/patient-vitals-widget.component.ts index e9e51c42..4aa0080d 100644 --- a/frontend/src/app/widgets/patient-vitals-widget/patient-vitals-widget.component.ts +++ b/frontend/src/app/widgets/patient-vitals-widget/patient-vitals-widget.component.ts @@ -72,12 +72,12 @@ export class PatientVitalsWidgetComponent extends DashboardWidgetComponent imple } as DashboardWidgetConfig super.ngOnInit(); - this.chartDatasetsSubject.subscribe(this.processQueryResults.bind(this)) + this.chartDatasetsSubject.subscribe(this.vitalsProcessQueryResults.bind(this)) } //process query results - processQueryResults(queryResults: any[]): void { + vitalsProcessQueryResults(queryResults: any[]): void { if(!queryResults || queryResults.length < 2){ return } diff --git a/frontend/src/app/widgets/table-widget/table-widget.component.spec.ts b/frontend/src/app/widgets/table-widget/table-widget.component.spec.ts index fefffe04..541ae837 100644 --- a/frontend/src/app/widgets/table-widget/table-widget.component.spec.ts +++ b/frontend/src/app/widgets/table-widget/table-widget.component.spec.ts @@ -4,6 +4,7 @@ import { TableWidgetComponent } from './table-widget.component'; import {FastenApiService} from '../../services/fasten-api.service'; import {HTTP_CLIENT_TOKEN} from '../../dependency-injection'; import {HttpClient} from '@angular/common/http'; +import encountersFixture from "../fixtures/encounters.json" describe('TableWidgetComponent', () => { let component: TableWidgetComponent; @@ -36,4 +37,81 @@ describe('TableWidgetComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + describe('tableProcessQueryResults', () => { + describe('Recent Encounters - TableWidget', () => { + it('should parse data', () => { + expect(component).toBeTruthy(); + + //setup + component.widgetConfig = { + "title_text": "Recent Encounters", + "description_text": "Recent interactions with healthcare providers", + "x": 4, + "y": 10, + "width": 8, + "height": 4, + "item_type": "table-widget", + "queries": [{ + "q": { + "select": [ + "serviceProvider.display as institution", + "period.start as date", + "reasonCode.coding.display.first() as reason", + "participant.individual.display as provider" + ], + "from": "Encounter", + "where": {} + } + }], + "parsing": { + "Id": "id", + "Institution": "institution", + "Reason": "reason", + "Provider": "provider" + } + } + //test + let processedQueryResponse = component.processQueryResourcesSelectClause(component.widgetConfig.queries[0].q, encountersFixture) + component.chartProcessQueryResults([processedQueryResponse]) + + //assert + expect(component.keys).toEqual([ + 'id', + 'institution', + 'reason', + 'provider', + ]) + expect(component.headers).toEqual([ + 'Id', + 'Institution', + 'Reason', + 'Provider', + ]) + expect(component.rows).toEqual([ + [ '9ea75521-441c-41e4-96df-237219e5ca63', 'PCP1336', '', 'Dr. Mariela993 Schamberger479'], + [ 'cb3ae560-0a65-41f8-9712-aa3c5766ffe5', 'PCP1336', '', 'Dr. Mariela993 Schamberger479'], + [ '47f5936b-ef68-4404-9183-8971e75e5a1d', 'HALLMARK HEALTH SYSTEM', 'Acute bronchitis (disorder)', 'Dr. Renato359 Jenkins714' ], + [ 'd051d64b-5b2f-4465-92c3-4e693d54f653', 'PCP1336', '', 'Dr. Mariela993 Schamberger479'], + [ 'cde6c902-957a-48c1-883f-38c808188fe3', 'PCP1336', '', 'Dr. Mariela993 Schamberger479' ], + [ '9f4e40e3-6f6b-4959-b407-3926e9ed049c', 'PCP1336', '', 'Dr. Mariela993 Schamberger479' ], + [ 'cd4bfc24-8fff-4eb0-bd93-3c7054551514', 'PROMPT CARE WALK-IN CLINIC', '', 'Dr. Candyce305 Prohaska837' ], + [ '9adf6236-72dc-4abf-ab7e-df370b02701c', 'HALLMARK HEALTH SYSTEM', 'Otitis media', 'Dr. Renato359 Jenkins714' ], + [ '9a6f9230-3549-43e1-83c8-f0fd740a24f3', 'HALLMARK HEALTH SYSTEM', 'Viral sinusitis (disorder)', 'Dr. Renato359 Jenkins714' ], + [ '919f002e-ff43-4ea6-8acb-be3f1139c59d', 'HALLMARK HEALTH SYSTEM', 'Viral sinusitis (disorder)', 'Dr. Renato359 Jenkins714' ], + [ '7a38b6bf-1787-4227-af08-ae10c29632e4', 'HALLMARK HEALTH SYSTEM', 'Viral sinusitis (disorder)', 'Dr. Renato359 Jenkins714' ], + [ 'e44ec534-b752-4647-acd3-3794bc51db6d', 'PCP1336', '', 'Dr. Mariela993 Schamberger479' ], + [ '51dec3d2-a098-4671-99bc-d7cf68561903', 'PCP1336', '', 'Dr. Mariela993 Schamberger479' ], + [ '1a3bbe38-b7c3-43fc-b175-740adfcaa83a', 'PCP1336', '', 'Dr. Mariela993 Schamberger479' ], + [ '3fba349b-e165-45f7-9cf2-66c391f293b6', 'HALLMARK HEALTH SYSTEM', 'Viral sinusitis (disorder)', 'Dr. Renato359 Jenkins714' ], + [ '24f73ec3-9b7a-42a9-be03-53bffd9b304c', 'HALLMARK HEALTH SYSTEM' , 'Acute viral pharyngitis (disorder)', 'Dr. Renato359 Jenkins714' ], + [ '92b439da-6fdd-48e5-aa8d-b0543f840a1b', 'PCP1336', '', 'Dr. Mariela993 Schamberger479' ], + [ 'd9a7c76f-dfe4-41c6-9924-82a405613f44', 'PCP1336', '', 'Dr. Mariela993 Schamberger479' ], + ]) + }); + + }) + + }) + }); diff --git a/frontend/src/app/widgets/table-widget/table-widget.component.ts b/frontend/src/app/widgets/table-widget/table-widget.component.ts index dc9fa6fc..84c4a649 100644 --- a/frontend/src/app/widgets/table-widget/table-widget.component.ts +++ b/frontend/src/app/widgets/table-widget/table-widget.component.ts @@ -18,10 +18,10 @@ export class TableWidgetComponent extends DashboardWidgetComponent implements O rows: any[] = [] ngOnInit(): void { super.ngOnInit() - this.chartDatasetsSubject.subscribe(this.processQueryResults.bind(this)) + this.chartDatasetsSubject.subscribe(this.tableProcessQueryResults.bind(this)) } - processQueryResults(queryResults: any[]) { + tableProcessQueryResults(queryResults: any[]) { if(!queryResults || queryResults.length < 1){ return }