From 885403c3d39d4c4f661c8f3a91ebd366f93edf1d Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Fri, 1 Nov 2024 17:13:29 +0100 Subject: [PATCH] fix: update queries to tracker endpoint (#3346) Fix: https://dhis2.atlassian.net/browse/DHIS2-17839 and https://dhis2.atlassian.net/browse/DHIS2-18303 * Update `TrackedEntityLayer` and `EventLayer` queries to `tracker` endpoint * Handle breaking changes to `tracker` between v40 and v41 * Align `TrackedEntityPopup` on `EventPopup` --- cypress/elements/event_layer.js | 7 + cypress/integration/layers/eventlayer.cy.js | 57 ++ cypress/integration/layers/telayer.cy.js | 53 ++ i18n/en.pot | 7 +- src/components/BaseUrlShim.js | 7 - src/components/loaders/TrackedEntityLoader.js | 6 +- src/components/map/layers/EventLayer.js | 3 +- src/components/map/layers/EventPopup.js | 69 +- .../map/layers/TrackedEntityLayer.js | 222 +++-- .../map/layers/TrackedEntityPopup.js | 171 ++++ src/loaders/trackedEntityLoader.js | 63 +- .../__tests__/teiRelationshipsParser.spec.js | 901 ++++++++++-------- src/util/api.js | 29 - src/util/geojson.js | 5 +- src/util/orgUnits.js | 10 + src/util/teiRelationshipsParser.js | 59 +- 16 files changed, 1107 insertions(+), 562 deletions(-) delete mode 100644 src/components/BaseUrlShim.js create mode 100644 src/components/map/layers/TrackedEntityPopup.js diff --git a/cypress/elements/event_layer.js b/cypress/elements/event_layer.js index 6f7db422c..f01455606 100644 --- a/cypress/elements/event_layer.js +++ b/cypress/elements/event_layer.js @@ -30,4 +30,11 @@ export class EventLayer extends Layer { return this } + + selectViewAllEvents() { + // Group events by default or View all events + cy.get('[src="images/nocluster.png"]').click() + + return this + } } diff --git a/cypress/integration/layers/eventlayer.cy.js b/cypress/integration/layers/eventlayer.cy.js index 0a3df6beb..432493e57 100644 --- a/cypress/integration/layers/eventlayer.cy.js +++ b/cypress/integration/layers/eventlayer.cy.js @@ -52,4 +52,61 @@ context('Event Layers', () => { Layer.validateCardTitle('Stage 1 - Repeatable') Layer.validateCardItems(['Yes', 'No', 'Not set']) }) + + it('opens an event popup', () => { + Layer.openDialog('Events') + .selectProgram('Inpatient morbidity and mortality') + .validateStage('Inpatient morbidity and mortality') + .selectTab('Style') + .selectViewAllEvents() + .selectTab('Org Units') + .selectOu('Sierra Leone') + + cy.getByDataTest('org-unit-tree-node') + .contains('Bo') + .parents('[data-test="org-unit-tree-node"]') + .first() + .within(() => { + cy.getByDataTest('org-unit-tree-node-toggle').click() + }) + + cy.getByDataTest('org-unit-tree-node') + .contains('Badjia') + .parents('[data-test="org-unit-tree-node"]') + .first() + .within(() => { + cy.getByDataTest('org-unit-tree-node-toggle').click() + }) + + cy.getByDataTest('org-unit-tree-node').contains('Njandama MCHP').click() + + cy.getByDataTest('layeredit-addbtn').click() + + cy.wait(5000) // eslint-disable-line cypress/no-unnecessary-waiting + cy.get('#dhis2-map-container') + .findByDataTest('dhis2-uicore-componentcover', EXTENDED_TIMEOUT) + .should('not.exist') + cy.get('.dhis2-map').click('center') + + cy.get('.maplibregl-popup') + .contains('Event location') + .should('be.visible') + cy.get('.maplibregl-popup') + .contains('0.000000 0.000000') + .should('be.visible') + cy.get('.maplibregl-popup') + .contains('Organisation unit') + .should('be.visible') + cy.get('.maplibregl-popup').contains('Event time').should('be.visible') + + cy.get('.maplibregl-popup') + .contains('Age in years') + .should('be.visible') + cy.get('.maplibregl-popup') + .contains('Mode of Discharge') + .should('be.visible') + + Layer.validateCardTitle('Inpatient morbidity and mortality') + Layer.validateCardItems(['Event']) + }) }) diff --git a/cypress/integration/layers/telayer.cy.js b/cypress/integration/layers/telayer.cy.js index 058426c09..f1a41e993 100644 --- a/cypress/integration/layers/telayer.cy.js +++ b/cypress/integration/layers/telayer.cy.js @@ -27,4 +27,57 @@ describe('Tracked Entity Layers', () => { ) Layer.validateCardItems(['Malaria Entity']) }) + + it('opens a tracked entity layer popup', () => { + Layer.openDialog('Tracked entities') + .selectTab('Data') + .selectTeType('Focus area') + .selectTeProgram('Malaria focus investigation') + .selectTab('Period') + .typeStartDate('2018-01-01') + .selectTab('Org Units') + + cy.getByDataTest('org-unit-tree-node') + .contains('Bo') + .parents('[data-test="org-unit-tree-node"]') + .first() + .within(() => { + cy.getByDataTest('org-unit-tree-node-toggle').click() + }) + + cy.getByDataTest('org-unit-tree-node') + .contains('Badjia') + .parents('[data-test="org-unit-tree-node"]') + .first() + .within(() => { + cy.getByDataTest('org-unit-tree-node-toggle').click() + }) + + cy.getByDataTest('org-unit-tree-node').contains('Njandama MCHP').click() + + cy.getByDataTest('layeredit-addbtn').click() + + Layer.validateDialogClosed(true) + + cy.wait(5000) // eslint-disable-line cypress/no-unnecessary-waiting + cy.get('#dhis2-map-container') + .findByDataTest('dhis2-uicore-componentcover', EXTENDED_TIMEOUT) + .should('not.exist') + cy.get('.dhis2-map').click('center') //Click somewhere on the map + + cy.get('.maplibregl-popup') + .contains('Organisation unit') + .should('be.visible') + cy.get('.maplibregl-popup') + .contains('Last updated') + .should('be.visible') + + cy.get('.maplibregl-popup') + .contains('System Focus ID') + .should('be.visible') + cy.get('.maplibregl-popup').contains('WQQ003161').should('be.visible') + + Layer.validateCardTitle('Malaria focus investigation') + Layer.validateCardItems(['Focus area']) + }) }) diff --git a/i18n/en.pot b/i18n/en.pot index a33568f55..072bf313f 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2024-09-24T14:47:22.063Z\n" -"PO-Revision-Date: 2024-09-24T14:47:22.066Z\n" +"POT-Creation-Date: 2024-10-23T12:50:51.401Z\n" +"PO-Revision-Date: 2024-10-23T12:50:51.401Z\n" msgid "2020" msgstr "2020" @@ -733,6 +733,9 @@ msgstr "Parent unit" msgid "No data" msgstr "No data" +msgid "Could not retrieve tracked entity data" +msgstr "Could not retrieve tracked entity data" + msgid "Last updated" msgstr "Last updated" diff --git a/src/components/BaseUrlShim.js b/src/components/BaseUrlShim.js deleted file mode 100644 index 7c1a6c20b..000000000 --- a/src/components/BaseUrlShim.js +++ /dev/null @@ -1,7 +0,0 @@ -import { useConfig } from '@dhis2/app-runtime' - -export const BaseUrlShim = ({ children }) => { - const { baseUrl, apiVersion } = useConfig() - - return children({ baseUrl: `${baseUrl}/api/${apiVersion}` }) -} diff --git a/src/components/loaders/TrackedEntityLoader.js b/src/components/loaders/TrackedEntityLoader.js index f0baece58..5e02940fb 100644 --- a/src/components/loaders/TrackedEntityLoader.js +++ b/src/components/loaders/TrackedEntityLoader.js @@ -1,3 +1,4 @@ +import { useConfig } from '@dhis2/app-runtime' import PropTypes from 'prop-types' import { useEffect } from 'react' import trackedEntityLoader from '../../loaders/trackedEntityLoader.js' @@ -5,14 +6,15 @@ import useLoaderAlerts from './useLoaderAlerts.js' const TrackedEntityLoader = ({ config, onLoad, loaderAlertAction }) => { const { showAlerts } = useLoaderAlerts(loaderAlertAction) + const { serverVersion } = useConfig() useEffect(() => { - trackedEntityLoader(config).then((result) => { + trackedEntityLoader(config, serverVersion).then((result) => { if (result.alerts?.length && loaderAlertAction) { showAlerts(result.alerts) } onLoad(result) }) - }, [config, onLoad, showAlerts, loaderAlertAction]) + }, [config, onLoad, showAlerts, loaderAlertAction, serverVersion]) return null } diff --git a/src/components/map/layers/EventLayer.js b/src/components/map/layers/EventLayer.js index 7f518fd93..0d5b784e8 100644 --- a/src/components/map/layers/EventLayer.js +++ b/src/components/map/layers/EventLayer.js @@ -141,13 +141,14 @@ class EventLayer extends Layer { } render() { - const { styleDataItem } = this.props + const { styleDataItem, nameProperty } = this.props const { popup, displayElements, eventCoordinateFieldName } = this.state return popup && displayElements ? ( value !== undefined || value !== null +const hasValue = (value) => value !== undefined && value !== null const EVENTS_QUERY = { events: { - resource: 'events', + resource: 'tracker/events', id: ({ id }) => id, }, } @@ -65,23 +66,51 @@ const EventPopup = ({ coordinates, feature, styleDataItem, + nameProperty, displayElements, eventCoordinateFieldName, onClose, }) => { - const { error, data, refetch } = useDataQuery(EVENTS_QUERY, { + const [orgUnit, setOrgUnit] = useState() + + const { refetch: refetchOrgUnit, fetching: fetchingOrgUnit } = useDataQuery( + ORG_UNIT_QUERY, + { + lazy: true, + } + ) + const { + error: errorEvent, + data: dataEvent, + refetch: refetchEvent, + fetching: fetchingEvent, + } = useDataQuery(EVENTS_QUERY, { lazy: true, }) useEffect(() => { - refetch({ - id: feature.properties.id || feature.properties[EVENT_ID_FIELD], - }) - }, [feature, refetch]) + const fetchEventandOU = async () => { + const resultEvent = await refetchEvent({ + id: feature.properties.id || feature.properties[EVENT_ID_FIELD], + }) + const idOrgUnit = resultEvent?.events?.orgUnit + + if (idOrgUnit) { + const resultOrgUnit = await refetchOrgUnit({ + id: idOrgUnit, + nameProperty, + }) + const nameOrgUnit = resultOrgUnit?.orgUnit?.name + + setOrgUnit(nameOrgUnit) + } + } + fetchEventandOU() + }, [feature, nameProperty, refetchEvent, refetchOrgUnit]) const { type, coordinates: coord } = feature.geometry const { value } = feature.properties - const { dataValues = [], eventDate, orgUnitName } = data?.events || {} + const { dataValues = [], occurredAt } = dataEvent?.events || {} return ( - {error && {i18n.t('Could not retrieve event data')}} - {!error && ( + {errorEvent && ( + + + {i18n.t('Could not retrieve event data')} + + +
+ )} + {!fetchingEvent && !fetchingOrgUnit && ( - {data?.events && + {dataEvent?.events && getDataRows({ displayElements, dataValues, @@ -111,16 +147,16 @@ const EventPopup = ({ )} - {orgUnitName && ( + {orgUnit && ( - + )} - {eventDate && ( + {occurredAt && ( - + )} @@ -134,6 +170,7 @@ EventPopup.propTypes = { coordinates: PropTypes.array.isRequired, displayElements: PropTypes.array.isRequired, feature: PropTypes.object.isRequired, + nameProperty: PropTypes.string.isRequired, onClose: PropTypes.func.isRequired, eventCoordinateFieldName: PropTypes.string, styleDataItem: PropTypes.object, diff --git a/src/components/map/layers/TrackedEntityLayer.js b/src/components/map/layers/TrackedEntityLayer.js index 29a71f666..689e56976 100644 --- a/src/components/map/layers/TrackedEntityLayer.js +++ b/src/components/map/layers/TrackedEntityLayer.js @@ -1,4 +1,3 @@ -import i18n from '@dhis2/d2-i18n' import React from 'react' import { TEI_COLOR, @@ -6,12 +5,38 @@ import { TEI_RELATIONSHIP_LINE_COLOR, TEI_RELATED_COLOR, TEI_RELATED_RADIUS, + GEOJSON_LAYER, } from '../../../constants/layers.js' -import { apiFetchWithBaseUrl } from '../../../util/api.js' -import { formatTime } from '../../../util/helpers.js' -import { BaseUrlShim } from '../../BaseUrlShim.js' -import Popup from '../Popup.js' +import { + GEO_TYPE_POINT, + GEO_TYPE_POLYGON, + GEO_TYPE_LINE, + GEO_TYPE_FEATURE, +} from '../../../util/geojson.js' +import { OPTION_SET_QUERY } from '../../../util/requests.js' import Layer from './Layer.js' +import TrackedEntityPopup from './TrackedEntityPopup.js' + +const ATTRIBUTES_QUERY = { + trackedEntityType: { + resource: 'trackedEntityTypes', + id: ({ id }) => id, + params: ({ nameProperty }) => ({ + fields: `trackedEntityTypeAttributes[displayInList,trackedEntityAttribute[id,${nameProperty}~rename(name),optionSet,valueType]]`, + paging: false, + }), + }, +} +const PROGRAM_ATTRIBUTES_QUERY = { + program: { + resource: 'programs', + id: ({ id }) => id, + params: ({ nameProperty }) => ({ + fields: `programTrackedEntityAttributes[displayInList,trackedEntityAttribute[id,${nameProperty}~rename(name),optionSet,valueType]]`, + paging: false, + }), + }, +} const getCentroid = (points) => { const totals = points.reduce( @@ -25,37 +50,29 @@ const getCentroid = (points) => { return [totals[0] / points.length, totals[1] / points.length] } -const fetchTEI = async (id, fieldsString, baseUrl) => { - const data = await apiFetchWithBaseUrl({ - url: `/trackedEntityInstances/${id}?fields=${fieldsString}`, - baseUrl, - }) - return data -} - -const geomToCentroid = (type, coords) => { - switch (type) { - case 'POINT': - return JSON.parse(coords) - case 'POLYGON': +const geomToCentroid = (geometry) => { + switch (geometry.type) { + case GEO_TYPE_POINT: + return geometry.coordinates + case GEO_TYPE_POLYGON: // TODO: Support multipolygon / use turf - return getCentroid(JSON.parse(coords)[0]) + return getCentroid(geometry.coordinates[0]) default: return null } } const makeRelationshipGeometry = ({ from, to }) => { - const fromGeom = geomToCentroid(from.featureType, from.coordinates) - const toGeom = geomToCentroid(to.featureType, to.coordinates) + const fromGeom = geomToCentroid(from.geometry) + const toGeom = geomToCentroid(to.geometry) if (!fromGeom || !toGeom) { // console.error('Invalid relationship geometries', from, to); return null } return { - type: 'Feature', + type: GEO_TYPE_FEATURE, geometry: { - type: 'LineString', + type: GEO_TYPE_LINE, coordinates: [fromGeom, toGeom], }, properties: {}, @@ -63,7 +80,7 @@ const makeRelationshipGeometry = ({ from, to }) => { } const makeRelationshipLayer = (relationships, color, weight) => { return { - type: 'geoJson', + type: GEOJSON_LAYER, data: relationships.map(makeRelationshipGeometry).filter((x) => !!x), style: { color, @@ -75,6 +92,8 @@ const makeRelationshipLayer = (relationships, color, weight) => { class TrackedEntityLayer extends Layer { state = { popup: null, + displayAttributes: null, + trackedEntityCoordinateFieldName: null, } createLayer() { @@ -84,10 +103,12 @@ class TrackedEntityLayer extends Layer { opacity, isVisible, data, + engine, relationships, secondaryData, eventPointColor, eventPointRadius, + nameProperty, areaRadius, relatedPointColor, relatedPointRadius, @@ -99,14 +120,14 @@ class TrackedEntityLayer extends Layer { const radius = eventPointRadius || TEI_RADIUS const config = { - type: 'geoJson', + type: GEOJSON_LAYER, data, style: { color, weight: 1, radius, }, - onClick: this.onEntityClick.bind(this), + onClick: this.onEventClick.bind(this), } if (areaRadius) { @@ -128,16 +149,18 @@ class TrackedEntityLayer extends Layer { isVisible, }) + this.loadDisplayAttributes(engine, nameProperty) + if (relationships) { const secondaryConfig = { - type: 'geoJson', + type: GEOJSON_LAYER, data: secondaryData, style: { color: relatedPointColor || TEI_RELATED_COLOR, weight: 1, radius: relatedPointRadius || TEI_RELATED_RADIUS, }, - onClick: this.onEntityClick.bind(this), + onClick: this.onEventClickSecondary.bind(this), } const relationshipConfig = makeRelationshipLayer( @@ -158,51 +181,118 @@ class TrackedEntityLayer extends Layer { this.fitBoundsOnce() } - getPopup() { - const { coordinates, data } = this.state.popup - const { attributes = [], lastUpdated } = data - - return ( - -
{i18n.t('Organisation unit')}{orgUnitName}{orgUnit}
{i18n.t('Event time')}{formatTime(eventDate)}{formatTime(occurredAt)}
- - {attributes.map(({ name, value }) => ( - - - - - ))} - - - - - -
{name}:{value}
{i18n.t('Last updated')}:{formatTime(lastUpdated)}
-
- ) + render() { + const { program, nameProperty } = this.props + const { popup, displayAttributes } = this.state + + return popup ? ( + + ) : null } - render() { - return this.state.popup ? this.getPopup() : null + onEventClick({ feature, coordinates }) { + this.setState({ + popup: { feature, coordinates, activeDataSource: 'primary' }, + }) + } + onEventClickSecondary({ feature, coordinates }) { + this.setState({ + popup: { feature, coordinates, activeDataSource: 'secondary' }, + }) } - async onEntityClick(evt) { - const { feature, coordinates } = evt + async loadDisplayAttributes(engine, nameProperty) { + const { trackedEntityType, program } = this.props + // Get relationshipType object from loader if we want to retrieve attributes from secondary dataset - const data = await fetchTEI( - feature.properties.id, - 'lastUpdated,attributes[displayName~rename(name),value],relationships', - this.props.baseUrl + const displayNameProp = + nameProperty === 'name' ? 'displayName' : 'displayShortName' + + const { trackedEntityType: data } = await engine.query( + ATTRIBUTES_QUERY, + { + variables: { + id: trackedEntityType.id, + nameProperty: displayNameProp, + }, + } ) + let { trackedEntityTypeAttributes: trackedEntityAttributes } = data + + if (program) { + const { program: data } = await engine.query( + PROGRAM_ATTRIBUTES_QUERY, + { + variables: { + id: program.id, + nameProperty: displayNameProp, + }, + } + ) + const { programTrackedEntityAttributes } = data + + trackedEntityAttributes = [ + ...trackedEntityAttributes, + ...programTrackedEntityAttributes.filter( + (attr1) => + !trackedEntityAttributes.some( + (attr2) => + attr1.trackedEntityAttribute.id === + attr2.trackedEntityAttribute.id + ) + ), + ] + } + + let displayAttributes = [] + // let trackedEntityCoordinateFieldName when we support associated geometry + + if (Array.isArray(trackedEntityAttributes)) { + displayAttributes = trackedEntityAttributes + .filter((a) => a.displayInList) + .map((a) => a.trackedEntityAttribute) - this.setState({ popup: { feature, coordinates, data } }) + for (const a of displayAttributes) { + await this.loadOptionSet(a, engine) + } + } + + this.setState({ displayAttributes }) } -} -const TrackedEntityLayerWithBaseUrl = (props) => ( - - {({ baseUrl }) => } - -) + // Loads an option set for an attribute to get option names + async loadOptionSet(attribute, engine) { + const { optionSet } = attribute + + if (!optionSet || !optionSet.id) { + return attribute + } + + if (optionSet && optionSet.id) { + const { optionSet: fullOptionSet } = await engine.query( + OPTION_SET_QUERY, + { + variables: { id: optionSet.id }, + } + ) + + if (fullOptionSet && fullOptionSet.options) { + attribute.options = fullOptionSet.options.reduce( + (byId, option) => { + byId[option.code] = option.name + return byId + }, + {} + ) + } + } + } +} -export default TrackedEntityLayerWithBaseUrl +export default TrackedEntityLayer diff --git a/src/components/map/layers/TrackedEntityPopup.js b/src/components/map/layers/TrackedEntityPopup.js new file mode 100644 index 000000000..f427b8948 --- /dev/null +++ b/src/components/map/layers/TrackedEntityPopup.js @@ -0,0 +1,171 @@ +import { useDataQuery } from '@dhis2/app-runtime' +import i18n from '@dhis2/d2-i18n' +import PropTypes from 'prop-types' +import React, { useEffect, useState } from 'react' +import { formatTime, formatCoordinate } from '../../../util/helpers.js' +import { ORG_UNIT_QUERY } from '../../../util/orgUnits.js' +import Popup from '../Popup.js' + +// Returns true if value is not undefined or null; +const hasValue = (value) => value !== undefined && value !== null + +const TRACKED_ENTITIES_QUERY = { + trackedEntities: { + resource: `tracker/trackedEntities`, + id: ({ id }) => id, + params: ({ program }) => ({ + fields: 'updatedAt,orgUnit,attributes[displayName~rename(name),value,attribute],relationships', + program: program?.id, + }), + }, +} + +const getDataRows = ({ displayAttributes, attributes }) => { + const dataRows = [] + + // Include rows for each displayInList attribute + displayAttributes.forEach(({ id, name, valueType, options }) => { + const { value } = attributes.find((d) => d.attribute === id) || {} + let formattedValue = value + + if (valueType === 'COORDINATE' && value) { + formattedValue = formatCoordinate(value) + } else if (options) { + formattedValue = options[value] + } else if (!hasValue(value)) { + formattedValue = i18n.t('Not set') + } + + dataRows.push( + + {name} + {formattedValue} + + ) + }) + + if (dataRows.length) { + dataRows.push() + } + + return dataRows +} + +// Will display a popup for an trackeentity feature +const TrackedEntityPopup = ({ + coordinates, + feature, + activeDataSource, + program, + nameProperty, + displayAttributes, + onClose, +}) => { + const [orgUnit, setOrgUnit] = useState() + + const { refetch: refetchOrgUnit, fetching: fetchingOrgUnit } = useDataQuery( + ORG_UNIT_QUERY, + { + lazy: true, + } + ) + const { + error: errorTrackedEntity, + data: dataTrackedEntity, + refetch: refetchTrackedEntity, + fetching: fetchingTrackedEntity, + } = useDataQuery(TRACKED_ENTITIES_QUERY, { + variables: { + id: feature.properties.id, + program, + }, + lazy: true, + }) + + useEffect(() => { + const fetchTEandOU = async () => { + const resultTrackedEntity = await refetchTrackedEntity({ + id: feature.properties.id, + }) + const idOrgUnit = resultTrackedEntity?.trackedEntities?.orgUnit + + if (idOrgUnit) { + const resultOrgUnit = await refetchOrgUnit({ + id: idOrgUnit, + nameProperty, + }) + const nameOrgUnit = resultOrgUnit?.orgUnit?.name + + setOrgUnit(nameOrgUnit) + } + } + fetchTEandOU() + }, [feature, nameProperty, refetchTrackedEntity, refetchOrgUnit]) + + const { type, coordinates: coord } = feature.geometry + const { attributes = [], updatedAt } = + dataTrackedEntity?.trackedEntities || {} + + return ( + + {errorTrackedEntity && ( + + + + {i18n.t('Could not retrieve tracked entity data')} + + + +
+ )} + {!fetchingTrackedEntity && !fetchingOrgUnit && ( + + + {dataTrackedEntity?.trackedEntities && + activeDataSource == 'primary' && + getDataRows({ + displayAttributes, + attributes, + })} + {type === 'Point' && ( + + + + + )} + {orgUnit && ( + + + + + )} + {updatedAt && ( + + + + + )} + +
{i18n.t('Tracked entity location')} + {coord[0].toFixed(6)} {coord[1].toFixed(6)} +
{i18n.t('Organisation unit')}{orgUnit}
{i18n.t('Last updated')}{formatTime(updatedAt)}
+ )} +
+ ) +} + +TrackedEntityPopup.propTypes = { + coordinates: PropTypes.array.isRequired, + displayAttributes: PropTypes.array.isRequired, + feature: PropTypes.object.isRequired, + nameProperty: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, + activeDataSource: PropTypes.string, + program: PropTypes.object, +} + +export default TrackedEntityPopup diff --git a/src/loaders/trackedEntityLoader.js b/src/loaders/trackedEntityLoader.js index b303e647b..19dd75c8b 100644 --- a/src/loaders/trackedEntityLoader.js +++ b/src/loaders/trackedEntityLoader.js @@ -10,27 +10,27 @@ import { import { getProgramStatuses } from '../constants/programStatuses.js' import { getOrgUnitsFromRows } from '../util/analytics.js' import { apiFetch } from '../util/api.js' +import { + GEO_TYPE_POINT, + GEO_TYPE_POLYGON, + GEO_TYPE_MULTIPOLYGON, + GEO_TYPE_LINE, + GEO_TYPE_FEATURE, +} from '../util/geojson.js' import { getDataWithRelationships } from '../util/teiRelationshipsParser.js' import { formatStartEndDate, getDateArray } from '../util/time.js' -const fields = [ - 'trackedEntityInstance~rename(id)', - 'featureType', - 'coordinates', -] - -// Mapping netween DHIS2 types and GeoJSON types -const geometryTypesMap = { - POINT: 'Point', - POLYGON: 'Polygon', - MULTI_POLYGON: 'MultiPolygon', -} +const fields = ['trackedEntity~rename(id)', 'geometry'] // Valid geometry types for TEIs -const geometryTypes = Object.keys(geometryTypesMap) +const teiGeometryTypes = [ + GEO_TYPE_POINT, + GEO_TYPE_POLYGON, + GEO_TYPE_MULTIPOLYGON, +] //TODO: Refactor to share code with other loaders -const trackedEntityLoader = async (config) => { +const trackedEntityLoader = async (config, serverVersion) => { if (config.config && typeof config.config === 'string') { try { const customConfig = JSON.parse(config.config) @@ -90,8 +90,7 @@ const trackedEntityLoader = async (config) => { .join(';') const fieldsWithRelationships = [...fields, 'relationships'] - // https://docs.dhis2.org/2.29/en/developer/html/webapi_tracked_entity_instance_query.html - let url = `/trackedEntityInstances?skipPaging=true&fields=${fieldsWithRelationships}&ou=${orgUnits}` + let url = `/tracker/trackedEntities?skipPaging=true&fields=${fieldsWithRelationships}&orgUnit=${orgUnits}` let alert let explanation @@ -117,17 +116,22 @@ const trackedEntityLoader = async (config) => { } if (periodType === 'program') { - url += `&programStartDate=${startDate}&programEndDate=${endDate}` + url += `&enrollmentEnrolledAfter=${startDate}&enrollmentEnrolledBefore=${endDate}` } else { - url += `&lastUpdatedStartDate=${startDate}&lastUpdatedEndDate=${endDate}` + url += `&updatedAfter=${startDate}&updatedBefore=${endDate}` } - // https://docs.dhis2.org/master/en/developer/html/webapi_tracker_api.html#webapi_tei_grid_query_request_syntax const primaryData = await apiFetch(url) - const instances = primaryData.trackedEntityInstances.filter( + // https://github.com/dhis2/dhis2-releases/tree/master/releases/2.41#deprecated-apis + const trackerRootProp = + `${serverVersion.major}.${serverVersion.minor}` == '2.40' + ? 'instances' + : 'trackedEntities' + const instances = primaryData[trackerRootProp].filter( (instance) => - geometryTypes.includes(instance.featureType) && instance.coordinates + teiGeometryTypes.includes(instance.geometry?.type) && + instance.geometry?.coordinates ) if (!instances.length) { @@ -148,11 +152,12 @@ const trackedEntityLoader = async (config) => { const relatedEntityType = await apiFetch( `/trackedEntityTypes/${relatedTypeId}?fields=displayName,featureType` ) - const isPoint = relatedEntityType.featureType === 'POINT' + const isPoint = + relatedEntityType.featureType === GEO_TYPE_POINT.toUpperCase() legend.items.push( { - type: 'LineString', + type: GEO_TYPE_LINE, name: relationshipType.displayName, color: relationshipLineColor || TEI_RELATIONSHIP_LINE_COLOR, weight: 1, @@ -168,9 +173,10 @@ const trackedEntityLoader = async (config) => { ) const dataWithRels = await getDataWithRelationships( + serverVersion, instances, - relationshipType, { + relationshipType, orgUnits, organisationUnitSelectionMode, } @@ -203,12 +209,9 @@ const trackedEntityLoader = async (config) => { } const toGeoJson = (instances) => - instances.map(({ id, featureType, coordinates }) => ({ - type: 'Feature', - geometry: { - type: geometryTypesMap[featureType], - coordinates: JSON.parse(coordinates), - }, + instances.map(({ id, geometry }) => ({ + type: GEO_TYPE_FEATURE, + geometry, properties: { id, }, diff --git a/src/util/__tests__/teiRelationshipsParser.spec.js b/src/util/__tests__/teiRelationshipsParser.spec.js index 78785926b..2f80aa199 100644 --- a/src/util/__tests__/teiRelationshipsParser.spec.js +++ b/src/util/__tests__/teiRelationshipsParser.spec.js @@ -11,23 +11,22 @@ describe('fetchData', () => { { customProps: { organisationUnitSelectionMode: 'someOUMode' }, expectedUrl: - '/trackedEntityInstances?skipPaging=true&fields=someFields&ou=ouId&ouMode=someOUMode', + '/tracker/trackedEntities?skipPaging=true&fields=someFields&orgUnit=ouId&ouMode=someOUMode', }, { customProps: { type: { id: 'someTETypeId' } }, expectedUrl: - '/trackedEntityInstances?skipPaging=true&fields=someFields&ou=ouId&trackedEntityType=someTETypeId', + '/tracker/trackedEntities?skipPaging=true&fields=someFields&orgUnit=ouId&trackedEntityType=someTETypeId', }, { customProps: { program: 'someProgram' }, expectedUrl: - '/trackedEntityInstances?skipPaging=true&fields=someFields&ou=ouId&program=someProgram', + '/tracker/trackedEntities?skipPaging=true&fields=someFields&orgUnit=ouId&program=someProgram', }, ])( 'should call apiFetch correct url in different scenarios', async ({ customProps, expectedUrl }) => { - const placeholder = { some: 'object' } - const mockData = { trackedEntityInstances: placeholder } + const mockData = { some: 'object' } const baseProps = { orgUnits: 'ouId', fields: 'someFields', @@ -38,13 +37,403 @@ describe('fetchData', () => { ...baseProps, ...customProps, }) - expect(result).toEqual(placeholder) + expect(result).toEqual(mockData) expect(apiFetch).toHaveBeenCalledWith(expectedUrl) } ) }) describe('getDataWithRelationships', () => { + let mockSourceInstances, mockTargetInstances, OUProps + beforeAll(() => { + mockSourceInstances = [ + { + // Missing geometry + id: 'teFrom1', + relationships: [], + }, + { + // Missing relationships + id: 'teFrom2', + geometry: { coordinates: 'x/y' }, + relationships: [], + }, + { + // Wrong relationship type + id: 'teFrom3', + geometry: { coordinates: 'x/y' }, + relationships: [ + { + relationship: 'relationship3', + relationshipType: 'relationshipTypeId0', + }, + ], + }, + { + // Unidirectional relationship, TE is the target of the relationship, source is in another program + id: 'teFrom4', + geometry: { coordinates: 'x/y' }, + relationships: [ + { + bidirectional: false, + relationship: 'relationship4', + relationshipType: 'relationshipTypeId1', + from: { + trackedEntity: { + trackedEntity: 'teTo4', + }, + }, + to: { + trackedEntity: { + trackedEntity: 'teFrom4', + }, + }, + }, + ], + }, + { + // Unidirectional relationship, target is in same program + id: 'teFrom5', + geometry: { coordinates: 'x/y' }, + relationships: [ + { + bidirectional: false, + relationship: 'relationship5', + relationshipType: 'relationshipTypeId1', + from: { + trackedEntity: { + trackedEntity: 'teFrom5', + }, + }, + to: { + trackedEntity: { + trackedEntity: 'teTo5', + }, + }, + }, + ], + }, + { + // Bidirectional relationship, target is in same program + id: 'teFrom6', + geometry: { coordinates: 'x/y' }, + relationships: [ + { + bidirectional: true, + relationship: 'relationship6', + relationshipType: 'relationshipTypeId1', + from: { + trackedEntity: { + trackedEntity: 'teTo6', + }, + }, + to: { + trackedEntity: { + trackedEntity: 'teFrom6', + }, + }, + }, + ], + }, + { + // Bidirectional relationship, but target is in another program + id: 'teFrom7', + geometry: { coordinates: 'x/y' }, + relationships: [ + { + bidirectional: true, + relationship: 'relationship7', + relationshipType: 'relationshipTypeId1', + from: { + trackedEntity: { + trackedEntity: 'teFrom7', + }, + }, + to: { + trackedEntity: { + trackedEntity: 'teTo7', + }, + }, + }, + ], + }, + { + // Two unidirectional relationship, targets are in another program + id: 'teFrom8', + geometry: { coordinates: 'x/y' }, + relationships: [ + { + bidirectional: false, + relationship: 'relationship8A', + relationshipType: 'relationshipTypeId1', + from: { + trackedEntity: { + trackedEntity: 'teFrom8', + }, + }, + to: { + trackedEntity: { + trackedEntity: 'teTo8A', + }, + }, + }, + { + bidirectional: false, + relationship: 'relationship8B', + relationshipType: 'relationshipTypeId1', + from: { + trackedEntity: { + trackedEntity: 'teFrom8', + }, + }, + to: { + trackedEntity: { + trackedEntity: 'teTo8B', + }, + }, + }, + ], + }, + { + // Two TE with single unidirectional relationship, + // pointing at the same target in another program + id: 'teFrom9A', + geometry: { coordinates: 'x/y' }, + relationships: [ + { + bidirectional: true, + relationship: 'relationship9A', + relationshipType: 'relationshipTypeId1', + from: { + trackedEntity: { + trackedEntity: 'teFrom9A', + }, + }, + to: { + trackedEntity: { + trackedEntity: 'teTo9', + }, + }, + }, + ], + }, + { + // Two TE with single unidirectional relationship, + // pointing at the same target in another program + id: 'teFrom9B', + geometry: { coordinates: 'x/y' }, + relationships: [ + { + bidirectional: true, + relationship: 'relationship9B', + relationshipType: 'relationshipTypeId1', + from: { + trackedEntity: { + trackedEntity: 'teFrom9B', + }, + }, + to: { + trackedEntity: { + trackedEntity: 'teTo9', + }, + }, + }, + ], + }, + { id: 'teTo1', relationships: [] }, + { + id: 'teTo2', + geometry: { coordinates: 'x/y' }, + relationships: [], + }, + { + id: 'teTo3', + geometry: { coordinates: 'x/y' }, + relationships: [], + }, + { + id: 'teTo5', + geometry: { coordinates: 'x/y' }, + relationships: [], + }, + { + id: 'teTo6', + geometry: { coordinates: 'x/y' }, + relationships: [ + { + bidirectional: true, + relationship: 'relationship6', + relationshipType: 'relationshipTypeId1', + from: { + trackedEntity: { + trackedEntity: 'teTo6', + }, + }, + to: { + trackedEntity: { + trackedEntity: 'teFrom6', + }, + }, + }, + ], + }, + ] + mockTargetInstances = [ + { id: 'teTo1' }, + { id: 'teTo2', geometry: { coordinates: 'x/y' } }, + { id: 'teTo3', geometry: { coordinates: 'x/y' } }, + { + id: 'teTo4', + geometry: { coordinates: 'x/y' }, + relationships: [ + { + bidirectional: false, + relationship: 'relationship4', + relationshipType: 'relationshipTypeId1', + from: { + trackedEntity: { + trackedEntity: 'teTo4', + }, + }, + to: { + trackedEntity: { + trackedEntity: 'teFrom4', + }, + }, + }, + ], + }, + { id: 'teTo5', geometry: { coordinates: 'x/y' } }, + { + id: 'teTo6', + geometry: { coordinates: 'x/y' }, + relationships: [ + { + bidirectional: true, + relationship: 'relationship6', + relationshipType: 'relationshipTypeId1', + from: { + trackedEntity: { + trackedEntity: 'teTo6', + }, + }, + to: { + trackedEntity: { + trackedEntity: 'teFrom6', + }, + }, + }, + ], + }, + { + id: 'teTo7', + geometry: { coordinates: 'x/y' }, + relationships: [ + { + bidirectional: true, + relationship: 'relationship7', + relationshipType: 'relationshipTypeId1', + from: { + trackedEntity: { + trackedEntity: 'teFrom7', + }, + }, + to: { + trackedEntity: { + trackedEntity: 'teTo7', + }, + }, + }, + ], + }, + { + id: 'teTo8A', + geometry: { coordinates: 'x/y' }, + relationships: [ + { + bidirectional: false, + relationship: 'relationship8A', + relationshipType: 'relationshipTypeId1', + from: { + trackedEntity: { + trackedEntity: 'teFrom8', + }, + }, + to: { + trackedEntity: { + trackedEntity: 'teTo8A', + }, + }, + }, + ], + }, + { + id: 'teTo8B', + geometry: { coordinates: 'x/y' }, + relationships: [ + { + bidirectional: false, + relationship: 'relationship8B', + relationshipType: 'relationshipTypeId1', + from: { + trackedEntity: { + trackedEntity: 'teFrom8', + }, + }, + to: { + trackedEntity: { + trackedEntity: 'teTo8B', + }, + }, + }, + ], + }, + { + id: 'teTo9', + geometry: { coordinates: 'x/y' }, + relationships: [ + { + bidirectional: false, + relationship: 'relationship9A', + relationshipType: 'relationshipTypeId1', + from: { + trackedEntity: { + trackedEntity: 'teFrom9A', + }, + }, + to: { + trackedEntity: { + trackedEntity: 'teTo9', + }, + }, + }, + { + bidirectional: false, + relationship: 'relationship9B', + relationshipType: 'relationshipTypeId1', + from: { + trackedEntity: { + trackedEntity: 'teFrom9B', + }, + }, + to: { + trackedEntity: { + trackedEntity: 'teTo9', + }, + }, + }, + ], + }, + ] + OUProps = { + orgUnits: 'someOU', + organisationUnitSelectionMode: 'someOUMode', + } + }) + beforeEach(() => { + jest.resetAllMocks() + }) + it.each([ { // To relationshipEntity not supported @@ -163,385 +552,18 @@ describe('getDataWithRelationships', () => { ])( 'should return an object with primary, relationships and secondary properties', async ({ relationshipType, expected }) => { - const mockSourceInstances = [ - { - // Missing coordinates - id: 'teFrom1', - relationships: [], - }, - { - // Missing relationships - id: 'teFrom2', - coordinates: 'x/y', - relationships: [], - }, - { - // Wrong relationship type - id: 'teFrom3', - coordinates: 'x/y', - relationships: [ - { - relationship: 'relationship3', - relationshipType: 'relationshipTypeId0', - }, - ], - }, - { - // Unidirectional relationship, TE is the target of the relationship, source is in another program - id: 'teFrom4', - coordinates: 'x/y', - relationships: [ - { - bidirectional: false, - relationship: 'relationship4', - relationshipType: 'relationshipTypeId1', - from: { - trackedEntityInstance: { - trackedEntityInstance: 'teTo4', - }, - }, - to: { - trackedEntityInstance: { - trackedEntityInstance: 'teFrom4', - }, - }, - }, - ], - }, - { - // Unidirectional relationship, target is in same program - id: 'teFrom5', - coordinates: 'x/y', - relationships: [ - { - bidirectional: false, - relationship: 'relationship5', - relationshipType: 'relationshipTypeId1', - from: { - trackedEntityInstance: { - trackedEntityInstance: 'teFrom5', - }, - }, - to: { - trackedEntityInstance: { - trackedEntityInstance: 'teTo5', - }, - }, - }, - ], - }, - { - // Bidirectional relationship, target is in same program - id: 'teFrom6', - coordinates: 'x/y', - relationships: [ - { - bidirectional: true, - relationship: 'relationship6', - relationshipType: 'relationshipTypeId1', - from: { - trackedEntityInstance: { - trackedEntityInstance: 'teTo6', - }, - }, - to: { - trackedEntityInstance: { - trackedEntityInstance: 'teFrom6', - }, - }, - }, - ], - }, - { - // Bidirectional relationship, but target is in another program - id: 'teFrom7', - coordinates: 'x/y', - relationships: [ - { - bidirectional: true, - relationship: 'relationship7', - relationshipType: 'relationshipTypeId1', - from: { - trackedEntityInstance: { - trackedEntityInstance: 'teFrom7', - }, - }, - to: { - trackedEntityInstance: { - trackedEntityInstance: 'teTo7', - }, - }, - }, - ], - }, - { - // Two unidirectional relationship, targets are in another program - id: 'teFrom8', - coordinates: 'x/y', - relationships: [ - { - bidirectional: false, - relationship: 'relationship8A', - relationshipType: 'relationshipTypeId1', - from: { - trackedEntityInstance: { - trackedEntityInstance: 'teFrom8', - }, - }, - to: { - trackedEntityInstance: { - trackedEntityInstance: 'teTo8A', - }, - }, - }, - { - bidirectional: false, - relationship: 'relationship8B', - relationshipType: 'relationshipTypeId1', - from: { - trackedEntityInstance: { - trackedEntityInstance: 'teFrom8', - }, - }, - to: { - trackedEntityInstance: { - trackedEntityInstance: 'teTo8B', - }, - }, - }, - ], - }, - { - // Two TE with single unidirectional relationship, - // pointing at the same target in another program - id: 'teFrom9A', - coordinates: 'x/y', - relationships: [ - { - bidirectional: true, - relationship: 'relationship9A', - relationshipType: 'relationshipTypeId1', - from: { - trackedEntityInstance: { - trackedEntityInstance: 'teFrom9A', - }, - }, - to: { - trackedEntityInstance: { - trackedEntityInstance: 'teTo9', - }, - }, - }, - ], - }, - { - // Two TE with single unidirectional relationship, - // pointing at the same target in another program - id: 'teFrom9B', - coordinates: 'x/y', - relationships: [ - { - bidirectional: true, - relationship: 'relationship9B', - relationshipType: 'relationshipTypeId1', - from: { - trackedEntityInstance: { - trackedEntityInstance: 'teFrom9B', - }, - }, - to: { - trackedEntityInstance: { - trackedEntityInstance: 'teTo9', - }, - }, - }, - ], - }, - { id: 'teTo1', relationships: [] }, - { id: 'teTo2', coordinates: 'x/y', relationships: [] }, - { id: 'teTo3', coordinates: 'x/y', relationships: [] }, - { id: 'teTo5', coordinates: 'x/y', relationships: [] }, - { - id: 'teTo6', - coordinates: 'x/y', - relationships: [ - { - bidirectional: true, - relationship: 'relationship6', - relationshipType: 'relationshipTypeId1', - from: { - trackedEntityInstance: { - trackedEntityInstance: 'teTo6', - }, - }, - to: { - trackedEntityInstance: { - trackedEntityInstance: 'teFrom6', - }, - }, - }, - ], - }, - ] - const mockTargetInstances = [ - { id: 'teTo1' }, - { id: 'teTo2', coordinates: 'x/y' }, - { id: 'teTo3', coordinates: 'x/y' }, - { - id: 'teTo4', - coordinates: 'x/y', - relationships: [ - { - bidirectional: false, - relationship: 'relationship4', - relationshipType: 'relationshipTypeId1', - from: { - trackedEntityInstance: { - trackedEntityInstance: 'teTo4', - }, - }, - to: { - trackedEntityInstance: { - trackedEntityInstance: 'teFrom4', - }, - }, - }, - ], - }, - { id: 'teTo5', coordinates: 'x/y' }, - { - id: 'teTo6', - coordinates: 'x/y', - relationships: [ - { - bidirectional: true, - relationship: 'relationship6', - relationshipType: 'relationshipTypeId1', - from: { - trackedEntityInstance: { - trackedEntityInstance: 'teTo6', - }, - }, - to: { - trackedEntityInstance: { - trackedEntityInstance: 'teFrom6', - }, - }, - }, - ], - }, - { - id: 'teTo7', - coordinates: 'x/y', - relationships: [ - { - bidirectional: true, - relationship: 'relationship7', - relationshipType: 'relationshipTypeId1', - from: { - trackedEntityInstance: { - trackedEntityInstance: 'teFrom7', - }, - }, - to: { - trackedEntityInstance: { - trackedEntityInstance: 'teTo7', - }, - }, - }, - ], - }, - { - id: 'teTo8A', - coordinates: 'x/y', - relationships: [ - { - bidirectional: false, - relationship: 'relationship8A', - relationshipType: 'relationshipTypeId1', - from: { - trackedEntityInstance: { - trackedEntityInstance: 'teFrom8', - }, - }, - to: { - trackedEntityInstance: { - trackedEntityInstance: 'teTo8A', - }, - }, - }, - ], - }, - { - id: 'teTo8B', - coordinates: 'x/y', - relationships: [ - { - bidirectional: false, - relationship: 'relationship8B', - relationshipType: 'relationshipTypeId1', - from: { - trackedEntityInstance: { - trackedEntityInstance: 'teFrom8', - }, - }, - to: { - trackedEntityInstance: { - trackedEntityInstance: 'teTo8B', - }, - }, - }, - ], - }, - { - id: 'teTo9', - coordinates: 'x/y', - relationships: [ - { - bidirectional: false, - relationship: 'relationship9A', - relationshipType: 'relationshipTypeId1', - from: { - trackedEntityInstance: { - trackedEntityInstance: 'teFrom9A', - }, - }, - to: { - trackedEntityInstance: { - trackedEntityInstance: 'teTo9', - }, - }, - }, - { - bidirectional: false, - relationship: 'relationship9B', - relationshipType: 'relationshipTypeId1', - from: { - trackedEntityInstance: { - trackedEntityInstance: 'teFrom9B', - }, - }, - to: { - trackedEntityInstance: { - trackedEntityInstance: 'teTo9', - }, - }, - }, - ], - }, - ] - const OUProps = { - orgUnits: 'someOU', - organisationUnitSelectionMode: 'someOUMode', + const serverVersion = { + major: 2, + minor: 41, } apiFetch.mockResolvedValueOnce({ - trackedEntityInstances: mockTargetInstances, + trackedEntities: mockTargetInstances, }) const result = await getDataWithRelationships( + serverVersion, mockSourceInstances, - relationshipType, - OUProps + { relationshipType, ...OUProps } ) if (Array.isArray(expected)) { @@ -571,4 +593,125 @@ describe('getDataWithRelationships', () => { } } ) + + it.each([ + { + serverVersion: { + major: 2, + minor: 40, + }, + trackerRootProp: 'instances', + }, + { + serverVersion: { + major: 2, + minor: 41, + }, + trackerRootProp: 'trackedEntities', + }, + { + serverVersion: { + major: 2, + minor: 42, + }, + trackerRootProp: 'trackedEntities', + }, + ])( + 'should use the tracker api root property corresponding to the server version', + async ({ serverVersion, trackerRootProp }) => { + const relationshipType = { + id: 'relationshipTypeId1', + fromConstraint: { + relationshipEntity: 'TRACKED_ENTITY_INSTANCE', + trackedEntityType: { + id: 'trackedEntityType1', + }, + program: { + id: 'program1', + }, + }, + toConstraint: { + relationshipEntity: 'TRACKED_ENTITY_INSTANCE', + trackedEntityType: { + id: 'trackedEntityType1', + }, + program: { + id: 'program2', + }, + }, + } + const expected = { + primary: [ + 'teFrom2', + 'teFrom3', + 'teFrom4', + 'teFrom5', + 'teFrom6', + 'teFrom7', + 'teFrom8', + 'teFrom9A', + 'teFrom9B', + 'teTo2', + 'teTo3', + 'teTo5', + 'teTo6', + ], + relationships: [ + 'relationship5', + 'relationship6', + 'relationship7', + 'relationship8A', + 'relationship8B', + 'relationship9A', + 'relationship9B', + ], + secondary: [ + 'teTo5', + 'teTo6', + 'teTo7', + 'teTo8A', + 'teTo8B', + 'teTo9', + ], + } + + const mockData = { + [trackerRootProp]: mockTargetInstances, + } + + jest.clearAllMocks() + apiFetch.mockResolvedValueOnce(mockData) + const result = await getDataWithRelationships( + serverVersion, + mockSourceInstances, + { relationshipType, ...OUProps } + ) + + if (Array.isArray(expected)) { + expect(result).toEqual(expected) + } else { + expect(result).toHaveProperty('primary') + //console.log('primary', result.primary) + expect(result).toHaveProperty('relationships') + //console.log('relationships', result.relationships) + expect(result).toHaveProperty('secondary') + //console.log('secondary', result.secondary) + + const resultPrimaryIds = result.primary.map((item) => item.id) + expect(resultPrimaryIds.sort()).toEqual(expected.primary.sort()) + const resultRelationshipsIds = result.relationships.map( + (item) => item.id + ) + expect(resultRelationshipsIds.sort()).toEqual( + expected.relationships.sort() + ) + const resultSecondaryIds = result.secondary.map( + (item) => item.id + ) + expect(resultSecondaryIds.sort()).toEqual( + expected.secondary.sort() + ) + } + } + ) }) diff --git a/src/util/api.js b/src/util/api.js index ff102a68a..7ba586461 100644 --- a/src/util/api.js +++ b/src/util/api.js @@ -10,35 +10,6 @@ const getJsonResponse = async (response) => { return json } -export const apiFetchWithBaseUrl = async ({ url, method, body, baseUrl }) => { - const options = { - headers: { - 'Content-Type': 'application/json', // Default API response - }, - credentials: 'include', - } - - if (method && body) { - options.method = method - - if (isString(body)) { - options.headers['Content-Type'] = 'text/html' - options.body = body - } else if (isObject(body)) { - options.body = JSON.stringify(body) - } - } - - // TODO: Better error handling - return fetch(encodeURI(baseUrl + url), options) - .then((response) => - ['POST', 'PUT', 'PATCH'].includes(method) - ? response - : getJsonResponse(response) - ) - .catch((error) => console.log('Error: ', error)) -} - export const apiFetch = async (url, method, body) => { const d2 = await getD2() const options = { diff --git a/src/util/geojson.js b/src/util/geojson.js index 48eb707c7..99578db79 100644 --- a/src/util/geojson.js +++ b/src/util/geojson.js @@ -153,8 +153,9 @@ export const getGeojsonDisplayData = (feature) => { } export const GEO_TYPE_POINT = 'Point' export const GEO_TYPE_POLYGON = 'Polygon' +export const GEO_TYPE_MULTIPOLYGON = 'MultiPolygon' export const GEO_TYPE_LINE = 'LineString' -const GEO_TYPE_FEATURE = 'Feature' +export const GEO_TYPE_FEATURE = 'Feature' const GEO_TYPE_FEATURE_COLLECTION = 'FeatureCollection' const rawGeometryTypes = [ @@ -163,7 +164,7 @@ const rawGeometryTypes = [ GEO_TYPE_POLYGON, 'MultiPoint', 'MultiLineString', - 'MultiPolygon', + GEO_TYPE_MULTIPOLYGON, ] // Ensure that we are always working with a FeatureCollection diff --git a/src/util/orgUnits.js b/src/util/orgUnits.js index 1ace80c4c..f02582946 100644 --- a/src/util/orgUnits.js +++ b/src/util/orgUnits.js @@ -52,6 +52,16 @@ export const ORG_UNITS_GROUP_SET_QUERY = { }, } +export const ORG_UNIT_QUERY = { + orgUnit: { + resource: 'organisationUnits', + id: ({ id }) => id, + params: ({ nameProperty }) => ({ + fields: [`${nameProperty}~rename(name)`], + }), + }, +} + export const getPointItems = (data) => data.filter((d) => d.ty === 1) export const getPolygonItems = (data) => data.filter((d) => d.ty === 2) diff --git a/src/util/teiRelationshipsParser.js b/src/util/teiRelationshipsParser.js index 29e3a0f84..4ff1774b4 100644 --- a/src/util/teiRelationshipsParser.js +++ b/src/util/teiRelationshipsParser.js @@ -5,11 +5,11 @@ const TRACKED_ENTITY_INSTANCE = 'TRACKED_ENTITY_INSTANCE' export const fetchTEIs = async ({ program, type, - orgUnits, fields, + orgUnits, organisationUnitSelectionMode, }) => { - let url = `/trackedEntityInstances?skipPaging=true&fields=${fields}&ou=${orgUnits}` + let url = `/tracker/trackedEntities?skipPaging=true&fields=${fields}&orgUnit=${orgUnits}` if (organisationUnitSelectionMode) { url += `&ouMode=${organisationUnitSelectionMode}` } @@ -22,12 +22,12 @@ export const fetchTEIs = async ({ const data = await apiFetch(url) - return data.trackedEntityInstances + return data } const normalizeInstances = (instances) => { return instances - .filter((instance) => !!instance.coordinates) + .filter((instance) => !!instance.geometry?.coordinates) .reduce((out, instance) => { out[instance.id] = instance return out @@ -35,7 +35,7 @@ const normalizeInstances = (instances) => { } export const parseTEInstanceId = (instance) => - instance.trackedEntityInstance.trackedEntityInstance + instance.trackedEntity.trackedEntity const isValidRel = (rel, type, id) => rel.relationshipType === type && @@ -109,16 +109,11 @@ const getInstanceRelationships = ( } /* eslint-enable max-params */ -const fields = [ - 'trackedEntityInstance~rename(id)', - 'featureType', - 'coordinates', - 'relationships', -] +const fields = ['trackedEntity~rename(id)', 'geometry', 'relationships'] export const getDataWithRelationships = async ( + serverVersion, sourceInstances, - relationshipType, - { orgUnits, organisationUnitSelectionMode } + { relationshipType, orgUnits, organisationUnitSelectionMode } ) => { const from = relationshipType.fromConstraint const to = relationshipType.toConstraint @@ -171,17 +166,25 @@ export const getDataWithRelationships = async ( const normalizedSourceInstances = normalizeInstances(sourceInstances) // Retrieve potential target instances - const potentialTargetInstances = - isRecursiveTrackedEntityType & isRecursiveProgram - ? normalizedSourceInstances - : normalizeInstances( - await fetchTEIs({ - ...recursiveProp, - fields, - orgUnits, - organisationUnitSelectionMode, - }) - ) + let normalizedPotentialTargetInstances + if (isRecursiveTrackedEntityType & isRecursiveProgram) { + normalizedPotentialTargetInstances = normalizedSourceInstances + } else { + // https://github.com/dhis2/dhis2-releases/tree/master/releases/2.41#deprecated-apis + const trackerRootProp = + `${serverVersion.major}.${serverVersion.minor}` == '2.40' + ? 'instances' + : 'trackedEntities' + const potentialTargetInstances = await fetchTEIs({ + ...recursiveProp, + fields, + orgUnits, + organisationUnitSelectionMode, + }) + normalizedPotentialTargetInstances = normalizeInstances( + potentialTargetInstances[trackerRootProp] + ) + } const targetInstanceIds = [] // Keep TEI with relationship of correct type @@ -196,15 +199,15 @@ export const getDataWithRelationships = async ( getInstanceRelationships( relationshipsById, instance, - potentialTargetInstances, + normalizedPotentialTargetInstances, relationshipType.id ) ) // Keep only instances that are the target of a relationship - const targetInstances = Object.values(potentialTargetInstances).filter( - (instance) => targetInstanceIds.includes(instance.id) - ) + const targetInstances = Object.values( + normalizedPotentialTargetInstances + ).filter((instance) => targetInstanceIds.includes(instance.id)) return { primary: Object.values(normalizedSourceInstances),