diff --git a/cypress/integration/layers/trackedentitylayer.cy.js b/cypress/integration/layers/trackedentitylayer.cy.js index 59c134bb67..807bcf2064 100644 --- a/cypress/integration/layers/trackedentitylayer.cy.js +++ b/cypress/integration/layers/trackedentitylayer.cy.js @@ -49,6 +49,7 @@ describe('Tracked Entity Layers', () => { Layer.selectTab('Period') .typeStartDate('2018-00-00') .selectTab('Org Units') + .unselectOu('Sierra Leone') .openOu('Bo') .openOu('Badjia') .selectOu('Njandama MCHP') diff --git a/cypress/integration/requests.cy.js b/cypress/integration/requests.cy.js index 9a4d13a947..58135cae99 100644 --- a/cypress/integration/requests.cy.js +++ b/cypress/integration/requests.cy.js @@ -200,7 +200,7 @@ describe('API requests check for all layer types', () => { // -- apiFetch - src/util/api.js // -- @dhis2/d2 - src/api/Api.js 'getTrackedEntities40_TrackedEntities1', - // -- teiRelationshipsParser - src/util/teiRelationshipsParser.js + // -- trackedEntityRelationships - src/util/trackedEntityRelationships.js // -- apiFetch - src/util/api.js // -- @dhis2/d2 - src/api/Api.js // TODO: Should this be only TEIs within the same timeframe? @@ -213,7 +213,7 @@ describe('API requests check for all layer types', () => { // -- apiFetch - src/util/api.js // -- @dhis2/d2 - src/api/Api.js 'getTrackedEntities41_TrackedEntities1', - // -- teiRelationshipsParser - src/util/teiRelationshipsParser.js + // -- trackedEntityRelationships - src/util/trackedEntityRelationships.js // -- apiFetch - src/util/api.js // -- @dhis2/d2 - src/api/Api.js // TODO: Should this be only TEIs within the same timeframe? diff --git a/i18n/en.pot b/i18n/en.pot index 72d26d6fad..12d82e100e 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: 2026-09-01T12:42:29.454Z\n" -"PO-Revision-Date: 2026-09-01T12:42:29.454Z\n" +"POT-Creation-Date: 2026-10-05T12:46:47.530Z\n" +"PO-Revision-Date: 2026-10-05T12:46:47.531Z\n" msgid "2020" msgstr "2020" @@ -1885,6 +1885,9 @@ msgstr "related" msgid "Tracked entity" msgstr "Tracked entity" +msgid "Displaying first {{pageSize}} tracked entities" +msgstr "Displaying first {{pageSize}} tracked entities" + msgid "not one of" msgstr "not one of" diff --git a/package.json b/package.json index 7f9ac8a114..e40eaa952a 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "@turf/centroid": "^7.4.0", "abortcontroller-polyfill": "^1.7.8", "array-move": "^4.0.0", + "betterknown": "^1.2.0", "classnames": "^2.5.1", "d3-array": "^3.2.4", "d3-axis": "^3.0.0", diff --git a/src/components/orgunits/OrgUnitSelectMode.jsx b/src/components/orgunits/OrgUnitSelectMode.jsx index 81f8510ab4..182e2305af 100644 --- a/src/components/orgunits/OrgUnitSelectMode.jsx +++ b/src/components/orgunits/OrgUnitSelectMode.jsx @@ -1,7 +1,14 @@ +import { useConfig } from '@dhis2/app-runtime' import i18n from '@dhis2/d2-i18n' import React, { useMemo } from 'react' import { useSelector, useDispatch } from 'react-redux' import { setOrgUnitMode } from '../../actions/layerEdit.js' +import { + ORG_UNIT_MODE_CHILDREN, + ORG_UNIT_MODE_DESCENDANTS, + ORG_UNIT_MODE_SELECTED, +} from '../../constants/orgUnits.js' +import { getTrackedEntityDefaultOrgUnitMode } from '../../util/trackedEntity.js' import { SelectField } from '../core/index.js' import styles from './styles/OrgUnitSelectMode.module.css' @@ -9,20 +16,21 @@ const OrgUnitSelectMode = () => { const organisationUnitSelectionMode = useSelector( (state) => state.layerEdit.organisationUnitSelectionMode ) + const { serverVersion } = useConfig() const dispatch = useDispatch() const items = useMemo( () => [ { - id: 'SELECTED', + id: ORG_UNIT_MODE_SELECTED, name: i18n.t('Selected only'), }, { - id: 'CHILDREN', + id: ORG_UNIT_MODE_CHILDREN, name: i18n.t('Selected and below'), }, { - id: 'DESCENDANTS', + id: ORG_UNIT_MODE_DESCENDANTS, name: i18n.t('Selected and all below'), }, ], @@ -34,7 +42,10 @@ const OrgUnitSelectMode = () => { dispatch(setOrgUnitMode(mode.id))} className={styles.selectField} /> diff --git a/src/components/plugin/LayerLoader.jsx b/src/components/plugin/LayerLoader.jsx index 908877daff..8ecbdb4f0c 100644 --- a/src/components/plugin/LayerLoader.jsx +++ b/src/components/plugin/LayerLoader.jsx @@ -29,7 +29,11 @@ const LayerLoader = ({ config, onLoad }) => { const engine = useDataEngine() const [analyticsEngine] = useState(() => Analytics.getAnalytics(engine)) const { - systemSettings: { keyAnalysisDigitGroupSeparator }, + systemSettings: { + keyAnalysisDigitGroupSeparator, + KeyTrackedEntityInstanceMaxLimit, + KeyTrackedEntityMaxLimit, + }, currentUser, } = useCachedData() const { @@ -62,9 +66,11 @@ const LayerLoader = ({ config, onLoad }) => { userId, userOrgUnitIdsByKeyword, // Event loader baseUrl, - analyticsEngine, // Thematic and Event loader + analyticsEngine, // Thematic, Event and Tracked entity loader periodTypeData, // Thematic and Event loader serverVersion, // Tracked entity loader + KeyTrackedEntityInstanceMaxLimit, // Tracked entity loader (2.40) + KeyTrackedEntityMaxLimit, // Tracked entity loader }).then((result) => { onLoad(result) }) @@ -80,6 +86,8 @@ const LayerLoader = ({ config, onLoad }) => { keyAnalysisDisplayProperty, keyAnalysisDigitGroupSeparator, serverVersion, + KeyTrackedEntityInstanceMaxLimit, + KeyTrackedEntityMaxLimit, ]) return null diff --git a/src/constants/layers.js b/src/constants/layers.js index bb31be7119..119fc65238 100644 --- a/src/constants/layers.js +++ b/src/constants/layers.js @@ -128,6 +128,7 @@ export const TEI_BUFFER = 100 export const TEI_RELATED_COLOR = '#000000' export const TEI_RELATED_RADIUS = 3 export const TEI_RELATIONSHIP_LINE_COLOR = '#0000BB' +export const TEI_CLIENT_PAGE_SIZE = 50000 /* FACILITY LAYER */ export const FACILITY_BUFFER = 5000 diff --git a/src/constants/orgUnits.js b/src/constants/orgUnits.js new file mode 100644 index 0000000000..1d48c1f064 --- /dev/null +++ b/src/constants/orgUnits.js @@ -0,0 +1,4 @@ +// Org unit selection modes (ouMode / orgUnitMode API params) +export const ORG_UNIT_MODE_SELECTED = 'SELECTED' +export const ORG_UNIT_MODE_CHILDREN = 'CHILDREN' +export const ORG_UNIT_MODE_DESCENDANTS = 'DESCENDANTS' diff --git a/src/constants/settings.js b/src/constants/settings.js index f3243b8fd5..279fc135ef 100644 --- a/src/constants/settings.js +++ b/src/constants/settings.js @@ -21,6 +21,9 @@ export const SYSTEM_SETTINGS = [ 'keyHideMonthlyPeriods', 'keyHideBiMonthlyPeriods', 'keyDefaultBaseMap', + // Tracker API limit, 2.40 and 2.41+ names: see getTrackerMaxLimit + 'KeyTrackedEntityInstanceMaxLimit', + 'KeyTrackedEntityMaxLimit', ...Object.keys(MAP_SERVICE_KEY_TESTS), ] diff --git a/src/hooks/useLayersLoader.js b/src/hooks/useLayersLoader.js index 194765c4cf..0c37f2130f 100644 --- a/src/hooks/useLayersLoader.js +++ b/src/hooks/useLayersLoader.js @@ -32,7 +32,11 @@ export const useLayersLoader = () => { const engine = useDataEngine() const [analyticsEngine] = useState(() => Analytics.getAnalytics(engine)) const { - systemSettings: { keyAnalysisDigitGroupSeparator }, + systemSettings: { + keyAnalysisDigitGroupSeparator, + KeyTrackedEntityInstanceMaxLimit, + KeyTrackedEntityMaxLimit, + }, currentUser, } = useCachedData() const { showAlerts } = useLoaderAlerts() @@ -61,9 +65,11 @@ export const useLayersLoader = () => { userId, userOrgUnitIdsByKeyword, // Event loader baseUrl, - analyticsEngine, // Thematic and Event loader + analyticsEngine, // Thematic, Event and Tracked entity loader periodTypeData, // Thematic and Event loader serverVersion, // Tracked entity loader + KeyTrackedEntityInstanceMaxLimit, // Tracked entity loader (2.40) + KeyTrackedEntityMaxLimit, // Tracked entity loader loadExtended: !!dataTable, // Event loader }) if (result.alerts) { @@ -128,5 +134,7 @@ export const useLayersLoader = () => { baseUrl, dataTable, serverVersion, + KeyTrackedEntityInstanceMaxLimit, + KeyTrackedEntityMaxLimit, ]) } diff --git a/src/loaders/__tests__/trackedEntityLoader.spec.js b/src/loaders/__tests__/trackedEntityLoader.spec.js index 634049846e..5b0050ef81 100644 --- a/src/loaders/__tests__/trackedEntityLoader.spec.js +++ b/src/loaders/__tests__/trackedEntityLoader.spec.js @@ -1,9 +1,297 @@ -import { parseJsonConfig } from '../trackedEntityLoader.js' +import { loadTrackedEntitiesFromAnalytics } from '../../util/trackedEntity.js' +import trackedEntityLoader, { parseJsonConfig } from '../trackedEntityLoader.js' jest.mock('../../components/map/MapApi.js', () => ({ loadEarthEngineWorker: jest.fn(), })) +jest.mock('../../util/trackedEntity.js', () => ({ + ...jest.requireActual('../../util/trackedEntity.js'), + loadTrackedEntitiesFromAnalytics: jest.fn(), +})) + +const point = { + type: 'Feature', + geometry: { type: 'Point', coordinates: [1, 2] }, + properties: { id: 'te1' }, +} + +const baseConfig = { + layer: 'trackedEntity', + trackedEntityType: { id: 'teTypeId', name: 'Person' }, + rows: [{ dimension: 'ou', items: [{ id: 'ou1' }] }], + startDate: '2024-01-01', + endDate: '2024-12-31', +} + +const v40 = { major: 2, minor: 40, patch: 12 } +const v41 = { major: 2, minor: 41, patch: 0 } + +const createEngine = (trackerResponse) => ({ + query: jest.fn().mockResolvedValue({ trackedEntities: trackerResponse }), +}) + +describe('trackedEntityLoader', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('loads from analytics on 2.41+', async () => { + loadTrackedEntitiesFromAnalytics.mockResolvedValue({ + data: [point], + isTruncated: false, + }) + const engine = createEngine() + + const result = await trackedEntityLoader({ + config: { ...baseConfig }, + engine, + analyticsEngine: {}, + serverVersion: v41, + }) + + expect(loadTrackedEntitiesFromAnalytics).toHaveBeenCalledTimes(1) + expect(engine.query).not.toHaveBeenCalled() + expect(result.data).toEqual([point]) + expect(result.alerts).toEqual([]) + expect(result.isLoaded).toBe(true) + }) + + it('defaults the org unit mode to descendants on 2.41+', async () => { + loadTrackedEntitiesFromAnalytics.mockResolvedValue({ data: [point] }) + + await trackedEntityLoader({ + config: { ...baseConfig }, + engine: createEngine(), + analyticsEngine: {}, + serverVersion: v41, + }) + + expect( + loadTrackedEntitiesFromAnalytics.mock.calls[0][0].config + .organisationUnitSelectionMode + ).toBe('DESCENDANTS') + }) + + it('loads from the tracker API on 2.40', async () => { + const engine = createEngine({ + instances: [ + { id: 'te1', geometry: { type: 'Point', coordinates: [1, 2] } }, + { id: 'te2' }, + ], + }) + + const result = await trackedEntityLoader({ + config: { ...baseConfig }, + engine, + analyticsEngine: {}, + serverVersion: v40, + }) + + expect(loadTrackedEntitiesFromAnalytics).not.toHaveBeenCalled() + expect(engine.query.mock.calls[0][1].variables).toMatchObject({ + orgUnit: 'ou1', + ouMode: 'SELECTED', + }) + expect(result.data).toEqual([point]) + }) + + it('loads from the tracker API on 2.41+ when follow-up is requested', async () => { + const engine = createEngine({ trackedEntities: [] }) + + await trackedEntityLoader({ + config: { + ...baseConfig, + program: { id: 'programId', name: 'Malaria' }, + followUp: true, + }, + engine, + analyticsEngine: {}, + serverVersion: v41, + }) + + expect(loadTrackedEntitiesFromAnalytics).not.toHaveBeenCalled() + expect(engine.query.mock.calls[0][1].variables.followUp).toBe('TRUE') + }) + + it('loads relationships from the tracker API and adds them to the legend', async () => { + const relationship = { + relationship: 'rel1', + relationshipType: 'relType', + from: { trackedEntity: { trackedEntity: 'te1' } }, + to: { trackedEntity: { trackedEntity: 'te2' } }, + } + const constraint = { + relationshipEntity: 'TRACKED_ENTITY_INSTANCE', + trackedEntityType: { id: 'teTypeId' }, + } + const engine = { + query: jest + .fn() + .mockResolvedValueOnce({ + trackedEntities: { + trackedEntities: [ + { + id: 'te1', + geometry: { + type: 'Point', + coordinates: [1, 2], + }, + relationships: [relationship], + }, + { + id: 'te2', + geometry: { + type: 'Point', + coordinates: [3, 4], + }, + relationships: [relationship], + }, + ], + }, + }) + .mockResolvedValueOnce({ + relationshipType: { + id: 'relType', + displayName: 'Contact', + fromConstraint: constraint, + toConstraint: constraint, + }, + }) + .mockResolvedValueOnce({ + relatedEntityType: { + displayName: 'Person', + featureType: 'POINT', + }, + }), + } + + const result = await trackedEntityLoader({ + // Saved maps store relationships in the config JSON + config: { + ...baseConfig, + config: JSON.stringify({ relationships: { type: 'relType' } }), + }, + engine, + analyticsEngine: {}, + serverVersion: v41, + }) + + expect(loadTrackedEntitiesFromAnalytics).not.toHaveBeenCalled() + expect(result.relationships.map((r) => r.id)).toEqual(['rel1']) + expect(result.secondaryData.map((f) => f.properties.id)).toEqual([ + 'te2', + ]) + expect(result.legend.items.map((item) => item.name)).toEqual([ + 'Person', + 'Contact', + 'Person (related)', + ]) + expect(result.alerts).toEqual([]) + }) + + it('warns when the result is truncated', async () => { + loadTrackedEntitiesFromAnalytics.mockResolvedValue({ + data: [point], + isTruncated: true, + limit: 50000, + }) + + const result = await trackedEntityLoader({ + config: { ...baseConfig }, + engine: createEngine(), + analyticsEngine: {}, + serverVersion: v41, + keyAnalysisDigitGroupSeparator: 'COMMA', + }) + + expect(result.alerts).toEqual([ + expect.objectContaining({ + warning: true, + code: 'CUSTOM_ALERT', + message: + 'Tracked entity: Displaying first 50,000 tracked entities', + }), + ]) + }) + + it('warns when the tracker API hits the server limit', async () => { + const engine = createEngine({ + instances: [ + { id: 'te1', geometry: { type: 'Point', coordinates: [1, 2] } }, + { id: 'te2' }, + ], + }) + + const result = await trackedEntityLoader({ + config: { ...baseConfig }, + engine, + analyticsEngine: {}, + serverVersion: v40, + KeyTrackedEntityInstanceMaxLimit: 2, + }) + + expect(result.alerts).toEqual([ + expect.objectContaining({ + warning: true, + message: 'Tracked entity: Displaying first 2 tracked entities', + }), + ]) + }) + + it('warns when there is no data', async () => { + loadTrackedEntitiesFromAnalytics.mockResolvedValue({ data: [] }) + + const result = await trackedEntityLoader({ + config: { ...baseConfig }, + engine: createEngine(), + analyticsEngine: {}, + serverVersion: v41, + }) + + expect(result.alerts).toEqual([ + { code: 'WARNING_NO_DATA', message: 'Person' }, + ]) + }) + + it('sets a load error instead of throwing', async () => { + loadTrackedEntitiesFromAnalytics.mockRejectedValue( + new Error('Please ensure analytics job was run') + ) + + const result = await trackedEntityLoader({ + config: { ...baseConfig }, + engine: createEngine(), + analyticsEngine: {}, + serverVersion: v41, + }) + + expect(result.loadError).toBe('Please ensure analytics job was run') + expect(result.alerts).toEqual([ + { + code: 'ERROR_CRITICAL', + message: 'Please ensure analytics job was run', + }, + ]) + expect(result.data).toEqual([]) + expect(result.isLoaded).toBe(true) + }) + + it('sets a load error when the tracker API fails', async () => { + const engine = { query: jest.fn().mockRejectedValue(new Error('Boom')) } + + const result = await trackedEntityLoader({ + config: { ...baseConfig }, + engine, + analyticsEngine: {}, + serverVersion: v40, + }) + + expect(result.loadError).toBe('Boom') + expect(result.isLoaded).toBe(true) + }) +}) + describe('parseJsonConfig', () => { it('extracts periodType when relationships is null', () => { const config = { diff --git a/src/loaders/trackedEntityLoader.js b/src/loaders/trackedEntityLoader.js index 90ffe7b192..dccfc0f4a1 100644 --- a/src/loaders/trackedEntityLoader.js +++ b/src/loaders/trackedEntityLoader.js @@ -1,5 +1,9 @@ import i18n from '@dhis2/d2-i18n' -import { WARNING_NO_DATA } from '../constants/alerts.js' +import { + CUSTOM_ALERT, + ERROR_CRITICAL, + WARNING_NO_DATA, +} from '../constants/alerts.js' import { TEI_COLOR, TEI_RADIUS, @@ -8,106 +12,17 @@ import { TEI_RELATIONSHIP_LINE_COLOR, } from '../constants/layers.js' import { getProgramStatuses } from '../constants/programStatuses.js' -import { getOrgUnitsFromRows } from '../util/analytics.js' +import { GEO_TYPE_POINT, GEO_TYPE_LINE } from '../util/geojson.js' +import { formatWithSeparator } from '../util/numbers.js' +import { formatStartEndDate, getDateArray } from '../util/time.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 { trimTime, formatStartEndDate, getDateArray } from '../util/time.js' - -const fields = ['trackedEntity~rename(id)', 'geometry'] - -// Valid geometry types for TEIs -const teiGeometryTypes = new Set([ - GEO_TYPE_POINT, - GEO_TYPE_POLYGON, - GEO_TYPE_MULTIPOLYGON, -]) - -const TEI_40_QUERY = { - resource: 'tracker/trackedEntities', - params: ({ - fields, - orgUnits, - orgUnitMode, - program, - programStatus, - followUp, - trackedEntityType, - enrollmentEnrolledAfter, - enrollmentEnrolledBefore, - updatedAfter, - updatedBefore, - }) => ({ - fields, - orgUnit: orgUnits, - ouMode: orgUnitMode, - program: program, - programStatus, - followUp, - trackedEntityType, - enrollmentEnrolledAfter, - enrollmentEnrolledBefore, - updatedAfter, - updatedBefore, - skipPaging: true, - }), -} - -const TEI_41_QUERY = { - resource: 'tracker/trackedEntities', - params: ({ - fields, - orgUnits, - orgUnitMode, - program, - programStatus, - trackedEntityType, - enrollmentEnrolledAfter, - enrollmentEnrolledBefore, - updatedAfter, - updatedBefore, - // TODO no followUp? - }) => ({ - fields, - orgUnits, - orgUnitMode, - program, - programStatus, - trackedEntityType, - enrollmentEnrolledAfter, - enrollmentEnrolledBefore, - updatedAfter, - updatedBefore, - paging: false, - }), -} - -const RELATIONSHIP_TYPES_QUERY = { - resource: 'relationshipTypes', - id: ({ id }) => id, -} - -const TRACKED_ENTITY_TYPES_QUERY = { - resource: 'trackedEntityTypes', - id: ({ id }) => id, - params: { - fields: 'displayName,featureType', - }, -} - -const toGeoJson = (instances) => - instances.map(({ id, geometry }) => ({ - type: GEO_TYPE_FEATURE, - geometry, - properties: { - id, - }, - })) + canLoadTrackedEntitiesFromAnalytics, + getTrackedEntityDefaultOrgUnitMode, + getTrackerMaxLimit, + loadTrackedEntitiesFromAnalytics, + loadTrackedEntitiesFromTracker, +} from '../util/trackedEntity.js' +import { loadTrackedEntityRelationships } from '../util/trackedEntityRelationships.js' export const parseJsonConfig = (config) => { if (!config.config || typeof config.config !== 'string') { @@ -134,36 +49,17 @@ export const parseJsonConfig = (config) => { delete config.config } -const fetchRelationshipData = async ({ - engine, - isVersion40, - instances, - relationshipTypeID, - orgUnits, - organisationUnitSelectionMode, +const getRelationshipLegendItems = ({ + relationshipType, + relatedEntityType, relatedPointColor, relatedPointRadius, relationshipLineColor, - legend, }) => { - const { relationshipType } = await engine.query( - { relationshipType: RELATIONSHIP_TYPES_QUERY }, - { variables: { id: relationshipTypeID } } - ) - - const { relatedEntityType } = await engine.query( - { relatedEntityType: TRACKED_ENTITY_TYPES_QUERY }, - { - variables: { - id: relationshipType.toConstraint.trackedEntityType.id, - }, - } - ) - const isPoint = relatedEntityType.featureType === GEO_TYPE_POINT.toUpperCase() - legend.items.push( + return [ { type: GEO_TYPE_LINE, name: relationshipType.displayName, @@ -177,66 +73,18 @@ const fetchRelationshipData = async ({ ? relatedPointRadius || TEI_RELATED_RADIUS : undefined, weight: isPoint ? undefined : 1, - } - ) - - const dataWithRels = await getDataWithRelationships({ - isVersion40, - instances, - queryOptions: { - relationshipType, - orgUnits, - organisationUnitSelectionMode, }, - engine, - }) - - return { - data: toGeoJson(dataWithRels.primary), - relationships: dataWithRels.relationships, - secondaryData: toGeoJson(dataWithRels.secondary), - } -} - -const buildQueryVariables = ({ - fields, - orgUnits, - orgUnitMode, - program, - programStatus, - followUp, - trackedEntityType, - periodType, - startDate, - endDate, -}) => { - const followUpBool = followUp ? 'TRUE' : 'FALSE' - const boolFollowUp = - program && followUp !== undefined ? followUpBool : undefined - - return { - fields, - orgUnits, - orgUnitMode, - program: program?.id, - programStatus, - followUp: boolFollowUp, - trackedEntityType: program ? undefined : trackedEntityType?.id, - enrollmentEnrolledAfter: - periodType === 'program' ? trimTime(startDate) : undefined, - enrollmentEnrolledBefore: - periodType === 'program' ? trimTime(endDate) : undefined, - updatedAfter: - periodType === 'program' ? undefined : trimTime(startDate), - updatedBefore: periodType === 'program' ? undefined : trimTime(endDate), - } + ] } const trackedEntityLoader = async ({ config, engine, + analyticsEngine, keyAnalysisDigitGroupSeparator, serverVersion, + KeyTrackedEntityInstanceMaxLimit, + KeyTrackedEntityMaxLimit, }) => { parseJsonConfig(config) @@ -244,21 +92,18 @@ const trackedEntityLoader = async ({ trackedEntityType, program, programStatus, - followUp, relationshipType: relationshipTypeID, - periodType, startDate, endDate, - rows, organisationUnitSelectionMode, eventPointColor, eventPointRadius, areaRadius, - relatedPointColor, - relatedPointRadius, - relationshipLineColor, } = config + // Legend skeleton + // ----- + const name = program ? program.name : i18n.t('Tracked entity') const legend = { @@ -278,78 +123,101 @@ const trackedEntityLoader = async ({ ], } - // VERSION-TOGGLE: https://github.com/dhis2/dhis2-releases/tree/master/releases/2.41#deprecated-apis - const isVersion40 = `${serverVersion.minor}` === '40' - - const orgUnits = getOrgUnitsFromRows(rows) - .map((ou) => ou.id) - .join(isVersion40 ? ';' : ',') - - const fieldsWithRelationships = [...fields, 'relationships'] - let explanation - if (program && programStatus) { - explanation = `${i18n.t('Program status')}: ${ - getProgramStatuses().find((s) => s.id === programStatus).name - }` + legend.explanation = [ + `${i18n.t('Program status')}: ${ + getProgramStatuses().find((s) => s.id === programStatus).name + }`, + ] } - const { trackedEntities } = await engine.query( - { trackedEntities: isVersion40 ? TEI_40_QUERY : TEI_41_QUERY }, - { - variables: buildQueryVariables({ - fields: fieldsWithRelationships, - orgUnits, - orgUnitMode: organisationUnitSelectionMode, - program, - programStatus, - followUp, - trackedEntityType, - periodType, - startDate, - endDate, - }), - } - ) + // Data loading + // ----- - const instances = trackedEntities[ - isVersion40 ? 'instances' : 'trackedEntities' - ].filter( - (instance) => - teiGeometryTypes.has(instance.geometry?.type) && - instance.geometry?.coordinates - ) + const alerts = [] + const loadConfig = { + ...config, + organisationUnitSelectionMode: + organisationUnitSelectionMode || + getTrackedEntityDefaultOrgUnitMode(serverVersion), + } + let data = [] + let relationships, secondaryData, loadError - let alert + try { + const result = canLoadTrackedEntitiesFromAnalytics( + loadConfig, + serverVersion + ) + ? await loadTrackedEntitiesFromAnalytics({ + config: loadConfig, + analyticsEngine, + serverVersion, + }) + : await loadTrackedEntitiesFromTracker({ + config: loadConfig, + engine, + serverVersion, + maxLimit: getTrackerMaxLimit( + { + KeyTrackedEntityInstanceMaxLimit, + KeyTrackedEntityMaxLimit, + }, + serverVersion + ), + }) + + data = result.data + + if (relationshipTypeID) { + const relationshipResult = await loadTrackedEntityRelationships({ + config: loadConfig, + engine, + serverVersion, + instances: result.instances, + orgUnits: result.orgUnits, + }) + + ;({ data, relationships, secondaryData } = relationshipResult) + legend.items.push( + ...getRelationshipLegendItems({ + ...config, + ...relationshipResult, + }) + ) + } - if (!instances.length) { - alert = { - code: WARNING_NO_DATA, - message: trackedEntityType.name, + if (result.isTruncated) { + alerts.push({ + warning: true, + code: CUSTOM_ALERT, + message: `${name}: ${i18n.t( + 'Displaying first {{pageSize}} tracked entities', + { + pageSize: formatWithSeparator( + result.limit, + keyAnalysisDigitGroupSeparator + ), + } + )}`, + }) } + } catch (error) { + loadError = error.message || i18n.t('an error occurred') + alerts.push({ + code: ERROR_CRITICAL, + message: loadError, + }) } - let data, relationships, secondaryData + // Result alert + // ----- - if (relationshipTypeID) { - ;({ data, relationships, secondaryData } = await fetchRelationshipData({ - engine, - isVersion40, - instances, - relationshipTypeID, - orgUnits, - organisationUnitSelectionMode, - relatedPointColor, - relatedPointRadius, - relationshipLineColor, - legend, - })) - } else { - data = toGeoJson(instances) - } - - if (explanation) { - legend.explanation = [explanation] + if (!loadError && !data.length) { + alerts.push({ + code: WARNING_NO_DATA, + message: trackedEntityType.name, + }) } return { @@ -360,7 +228,8 @@ const trackedEntityLoader = async ({ relationships, secondaryData, legend, - ...(alert ? { alerts: [alert] } : {}), + alerts, + loadError, isLoaded: true, isLoading: false, isExpanded: true, diff --git a/src/util/__tests__/geojson.spec.js b/src/util/__tests__/geojson.spec.js index 82247a8d98..d477cb4c2e 100644 --- a/src/util/__tests__/geojson.spec.js +++ b/src/util/__tests__/geojson.spec.js @@ -11,6 +11,7 @@ import { getGeojsonDisplayData, buildGeoJsonFeatures, isPointInsideOrgUnits, + parseWkt, } from '../geojson.js' jest.mock('../../components/map/MapApi.js', () => ({ @@ -961,3 +962,108 @@ describe('geojson utils', () => { }) }) }) + +describe('parseWkt', () => { + it('parses a point with SRID prefix', () => { + expect(parseWkt('SRID=4326;POINT(-11.8079 8.3394)')).toEqual({ + type: 'Point', + coordinates: [-11.8079, 8.3394], + }) + }) + + it('parses a point without SRID prefix', () => { + expect(parseWkt('POINT(-11.8079 8.3394)')).toEqual({ + type: 'Point', + coordinates: [-11.8079, 8.3394], + }) + }) + + it('parses a polygon', () => { + expect( + parseWkt( + 'SRID=4326;POLYGON((-11.8049 8.3374,-11.8032 8.3436,-11.8076 8.3441,-11.8049 8.3374))' + ) + ).toEqual({ + type: 'Polygon', + coordinates: [ + [ + [-11.8049, 8.3374], + [-11.8032, 8.3436], + [-11.8076, 8.3441], + [-11.8049, 8.3374], + ], + ], + }) + }) + + it('parses a polygon with a hole and whitespace', () => { + expect( + parseWkt( + 'POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0), (2 2, 4 2, 4 4, 2 2))' + ) + ).toEqual({ + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [10, 0], + [10, 10], + [0, 10], + [0, 0], + ], + [ + [2, 2], + [4, 2], + [4, 4], + [2, 2], + ], + ], + }) + }) + + it('parses a multipolygon', () => { + expect( + parseWkt( + 'MULTIPOLYGON(((0 0,1 0,1 1,0 0)),((5 5,6 5,6 6,5 5),(5.2 5.2,5.4 5.2,5.4 5.4,5.2 5.2)))' + ) + ).toEqual({ + type: 'MultiPolygon', + coordinates: [ + [ + [ + [0, 0], + [1, 0], + [1, 1], + [0, 0], + ], + ], + [ + [ + [5, 5], + [6, 5], + [6, 6], + [5, 5], + ], + [ + [5.2, 5.2], + [5.4, 5.2], + [5.4, 5.4], + [5.2, 5.2], + ], + ], + ], + }) + }) + + it.each([ + [''], + [null], + [undefined], + ['LINESTRING(0 0,1 1)'], + ['POINT EMPTY'], + ['POLYGON((0 0,1 0,1 1,0 0)'], + ['{"type":"Point","coordinates":[0,0]}'], + ])('returns null for unsupported or invalid input %p', (input) => { + expect(parseWkt(input)).toBeNull() + }) +}) diff --git a/src/util/__tests__/teiRelationshipsParser.spec.js b/src/util/__tests__/teiRelationshipsParser.spec.js deleted file mode 100644 index 825a67a283..0000000000 --- a/src/util/__tests__/teiRelationshipsParser.spec.js +++ /dev/null @@ -1,676 +0,0 @@ -import { getDataWithRelationships } from '../teiRelationshipsParser.js' - -const expectResultToMatchExpected = (result, expected) => { - expect(result).toHaveProperty('primary') - expect(result).toHaveProperty('relationships') - expect(result).toHaveProperty('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()) -} - -describe('getDataWithRelationships', () => { - const 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', - }, - }, - }, - ], - }, - ] - const 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', - }, - }, - }, - ], - }, - ] - const OUProps = { - orgUnits: 'someOU', - orgUnitsMode: 'someOUMode', - } - let mockEngine - - beforeEach(() => { - jest.resetAllMocks() - - mockEngine = { - query: jest.fn().mockResolvedValue({ - tei: { trackedEntities: mockTargetInstances }, - }), - } - }) - - test('To relationshipEntity not supported', async () => { - const relationshipType = { - fromConstraint: { - relationshipEntity: 'TRACKED_ENTITY_INSTANCE', // Selection starts from TE type, so this should not change - }, - toConstraint: { - relationshipEntity: 'PROGRAM_INSTANCE', // PROGRAM_INSTANCE & PROGRAM_STAGE_INSTANCE are not supported - }, - } - - const result = await getDataWithRelationships({ - isVersion40: false, - instances: mockSourceInstances, - queryOptions: { relationshipType, ...OUProps }, - engine: mockEngine, - }) - expect(result).toEqual([]) // This the current behavior but it should be revised - expect(mockEngine.query).not.toHaveBeenCalled() - }) - - test('Same TE type and same program', async () => { - 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: 'program1', - }, - }, - } - const expected = { - primary: [ - 'teFrom2', - 'teFrom3', - 'teFrom4', - 'teFrom5', - 'teFrom6', - 'teFrom7', - 'teFrom8', - 'teFrom9A', - 'teFrom9B', - 'teTo2', - 'teTo3', - 'teTo5', - 'teTo6', - ], - relationships: ['relationship5', 'relationship6'], - secondary: ['teFrom6', 'teTo5', 'teTo6'], - } - - const result = await getDataWithRelationships({ - isVersion40: false, - instances: mockSourceInstances, - queryOptions: { relationshipType, ...OUProps }, - engine: mockEngine, - }) - - expectResultToMatchExpected(result, expected) - - expect(mockEngine.query).not.toHaveBeenCalled() - }) - - test('Same TE type and different program', async () => { - 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 result = await getDataWithRelationships({ - isVersion40: false, - instances: mockSourceInstances, - queryOptions: { relationshipType, ...OUProps }, - engine: mockEngine, - }) - - expectResultToMatchExpected(result, expected) - - expect(mockEngine.query).toHaveBeenCalledWith( - { - tei: { - resource: 'tracker/trackedEntities', - params: expect.anything(), - }, - }, - expect.objectContaining({ - variables: { - fields: [ - 'trackedEntity~rename(id)', - 'geometry', - 'relationships', - ], - orgUnits: 'someOU', - orgUnitMode: undefined, - program: 'program2', - trackedEntityType: undefined, - }, - }) - ) - }) - - it.each([ - { - trackerRootProp: 'instances', - versionString: '2.40', - isVersion40: true, - }, - { - trackerRootProp: 'trackedEntities', - versionString: '2.41', - isVersion40: false, - }, - ])( - '$versionString should use the tracker api root property "$trackerRootProp" and resource "$resource"', - async ({ isVersion40, 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 = { - tei: { [trackerRootProp]: mockTargetInstances }, - } - - mockEngine = { - query: jest.fn().mockResolvedValue(mockData), - } - - const result = await getDataWithRelationships({ - isVersion40, - instances: mockSourceInstances, - queryOptions: { relationshipType, ...OUProps }, - engine: mockEngine, - }) - - expectResultToMatchExpected(result, expected) - expect(mockEngine.query).toHaveBeenCalledWith( - { - tei: { - resource: 'tracker/trackedEntities', - params: expect.anything(), - }, - }, - expect.objectContaining({ - variables: { - fields: [ - 'trackedEntity~rename(id)', - 'geometry', - 'relationships', - ], - orgUnits: 'someOU', - orgUnitMode: undefined, - program: 'program2', - trackedEntityType: undefined, - }, - }) - ) - } - ) -}) diff --git a/src/util/__tests__/trackedEntity.spec.js b/src/util/__tests__/trackedEntity.spec.js new file mode 100644 index 0000000000..90c7846e04 --- /dev/null +++ b/src/util/__tests__/trackedEntity.spec.js @@ -0,0 +1,536 @@ +import { Analytics } from '@dhis2/analytics' +import { + canLoadTrackedEntitiesFromAnalytics, + createTrackedEntityFeatures, + getTrackedEntityDefaultOrgUnitMode, + getTrackerMaxLimit, + loadTrackedEntitiesFromAnalytics, + loadTrackedEntitiesFromTracker, +} from '../trackedEntity.js' + +const v42 = { minor: 42 } +const mockEngine = { query: jest.fn() } +const analyticsEngine = Analytics.getAnalytics(mockEngine) + +const baseConfig = { + trackedEntityType: { id: 'teTypeId' }, + rows: [{ dimension: 'ou', items: [{ id: 'ou1' }, { id: 'ou2' }] }], +} + +const headers = [ + { name: 'trackedentity' }, + { name: 'lastupdated' }, + { name: 'geometry' }, + { name: 'longitude' }, + { name: 'latitude' }, +] + +const response = (rows, pager = { page: 1, isLastPage: true }) => ({ + headers, + rows, + metaData: { pager }, +}) + +// Returns the variables the analytics client sends to the data engine +const loadAndGetVariables = async (config, serverVersion = v42) => { + mockEngine.query.mockResolvedValueOnce({ data: response([]) }) + await loadTrackedEntitiesFromAnalytics({ + config, + analyticsEngine, + serverVersion, + }) + return mockEngine.query.mock.calls[0][1].variables +} + +describe('getTrackedEntityAnalyticsRequest', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('requests trackedEntities/query with the type in the path', async () => { + const variables = await loadAndGetVariables(baseConfig) + + expect(variables.path).toBe('trackedEntities/query') + expect(variables.trackedEntityType).toBe('teTypeId') + expect(variables.program).toBeUndefined() + expect(variables.dimensions).toEqual(['ou:ou1;ou2']) + // The loader resolves the default org unit mode + expect(variables.parameters).toEqual({ + headers: 'trackedentity,geometry', + geometryOnly: true, + pageSize: 50000, + }) + }) + + it('only asks for the id and geometry columns, named by version', async () => { + const variables = await loadAndGetVariables(baseConfig, { minor: 41 }) + + expect(variables.parameters.headers).toBe( + 'trackedentityinstanceuid,geometry' + ) + }) + + it('passes program and qualified program status as query params', async () => { + const variables = await loadAndGetVariables({ + ...baseConfig, + program: { id: 'programId' }, + programStatus: 'COMPLETED', + organisationUnitSelectionMode: 'SELECTED', + }) + + // Not in the path: trackedEntities/query/{program} is invalid + expect(variables.program).toBeUndefined() + expect(variables.parameters).toMatchObject({ + program: 'programId', + programStatus: 'programId.COMPLETED', + ouMode: 'SELECTED', + }) + }) + + it('ignores program status without a program', async () => { + const variables = await loadAndGetVariables({ + ...baseConfig, + programStatus: 'COMPLETED', + }) + + expect(variables.parameters.programStatus).toBeUndefined() + }) + + it('filters by last updated date range by default', async () => { + const variables = await loadAndGetVariables({ + ...baseConfig, + startDate: '2024-01-01T00:00:00.000', + endDate: '2024-12-31T00:00:00.000', + }) + + expect(variables.parameters.lastUpdated).toBe('2024-01-01_2024-12-31') + expect(variables.parameters.enrollmentDate).toBeUndefined() + }) + + it('filters by program-qualified enrollment date range', async () => { + const variables = await loadAndGetVariables({ + ...baseConfig, + program: { id: 'programId' }, + periodType: 'program', + startDate: '2024-01-01', + endDate: '2024-12-31', + }) + + expect(variables.parameters.enrollmentDate).toBe( + 'programId.2024-01-01_2024-12-31' + ) + expect(variables.parameters.lastUpdated).toBeUndefined() + }) + + it('skips period filter when the range is incomplete', async () => { + const variables = await loadAndGetVariables({ + ...baseConfig, + startDate: '2024-01-01', + }) + + expect(variables.parameters.lastUpdated).toBeUndefined() + expect(variables.parameters.enrollmentDate).toBeUndefined() + }) + it('skips the enrollment period without a program', async () => { + const variables = await loadAndGetVariables({ + ...baseConfig, + periodType: 'program', + startDate: '2024-01-01', + endDate: '2024-12-31', + }) + + expect(variables.parameters.enrollmentDate).toBeUndefined() + expect(variables.parameters.lastUpdated).toBeUndefined() + }) +}) + +describe('createTrackedEntityFeatures', () => { + it('creates point and polygon features from WKT', () => { + const features = createTrackedEntityFeatures( + response([ + ['te1', '', 'SRID=4326;POINT(-11.8 8.3)', '-11.8', '8.3'], + ['te2', '', 'POLYGON((0 0,1 0,1 1,0 0))', '', ''], + ]), + v42 + ) + + expect(features).toEqual([ + { + type: 'Feature', + geometry: { type: 'Point', coordinates: [-11.8, 8.3] }, + properties: { id: 'te1' }, + }, + { + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [1, 0], + [1, 1], + [0, 0], + ], + ], + }, + properties: { id: 'te2' }, + }, + ]) + }) + + it('skips rows without a valid geometry', () => { + const features = createTrackedEntityFeatures( + response([ + ['te1', '', '', '', ''], + ['te2', '', 'LINESTRING(0 0,1 1)', '', ''], + ]), + v42 + ) + + expect(features).toEqual([]) + }) + + it.each([ + [{ minor: 41 }, 'trackedentityinstanceuid'], + [{ minor: 42 }, 'trackedentity'], + ])('on %p reads the id from %p', (serverVersion, idColumn) => { + const features = createTrackedEntityFeatures( + { + headers: [ + { name: 'trackedentity' }, + { name: 'trackedentityinstanceuid' }, + { name: 'geometry' }, + ], + rows: [ + idColumn === 'trackedentity' + ? ['te1', '', 'POINT(1 2)'] + : ['', 'te1', 'POINT(1 2)'], + ], + }, + serverVersion + ) + + expect(features[0].properties.id).toBe('te1') + }) +}) + +describe('loadTrackedEntitiesFromAnalytics', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it.each([ + [{ page: 1, isLastPage: true }, false], + [{ page: 1, isLastPage: false }, true], + ])('with pager %p returns isTruncated %p', async (pager, expected) => { + mockEngine.query.mockResolvedValueOnce({ + data: response([['te1', '', 'POINT(1 2)', '1', '2']], pager), + }) + + const { data, isTruncated } = await loadTrackedEntitiesFromAnalytics({ + config: baseConfig, + analyticsEngine, + serverVersion: v42, + pageSize: 1, + }) + + expect(data).toHaveLength(1) + expect(isTruncated).toBe(expected) + expect( + mockEngine.query.mock.calls[0][1].variables.parameters.pageSize + ).toBe(1) + }) + + it('is truncated when the server returns fewer rows than requested', async () => { + // e.g. capped by the analytics max limit setting + mockEngine.query.mockResolvedValueOnce({ + data: response([['te1', '', 'POINT(1 2)', '1', '2']], { + page: 1, + isLastPage: false, + }), + }) + + const { isTruncated } = await loadTrackedEntitiesFromAnalytics({ + config: baseConfig, + analyticsEngine, + serverVersion: v42, + }) + + expect(isTruncated).toBe(true) + }) + + it('is not truncated when empty, although isLastPage is false', async () => { + mockEngine.query.mockResolvedValueOnce({ + data: response([], { page: 1, isLastPage: false }), + }) + + const { data, isTruncated } = await loadTrackedEntitiesFromAnalytics({ + config: baseConfig, + analyticsEngine, + serverVersion: v42, + }) + + expect(data).toEqual([]) + expect(isTruncated).toBe(false) + }) +}) + +describe('getTrackedEntityDefaultOrgUnitMode', () => { + it('returns SELECTED on 2.40', () => { + expect(getTrackedEntityDefaultOrgUnitMode({ minor: 40 })).toBe( + 'SELECTED' + ) + }) + + it('returns DESCENDANTS from 2.41', () => { + expect(getTrackedEntityDefaultOrgUnitMode({ minor: 41 })).toBe( + 'DESCENDANTS' + ) + }) +}) + +describe('loadTrackedEntitiesFromTracker', () => { + const polygon = { + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [1, 0], + [1, 1], + [0, 0], + ], + ], + } + const trackedEntities = [ + { id: 'te1', geometry: { type: 'Point', coordinates: [1, 2] } }, + { id: 'te2', geometry: polygon }, + { id: 'te3', geometry: { type: 'LineString', coordinates: [] } }, + { id: 'te4' }, + ] + const config = { + ...baseConfig, + program: { id: 'programId' }, + organisationUnitSelectionMode: 'SELECTED', + startDate: '2024-01-01', + endDate: '2024-12-31', + } + + it('uses the 2.40 tracker API params and response', async () => { + const engine = { + query: jest.fn().mockResolvedValue({ + trackedEntities: { instances: trackedEntities }, + }), + } + + const { data, instances, orgUnits } = + await loadTrackedEntitiesFromTracker({ + config, + engine, + serverVersion: { minor: 40 }, + }) + + const [query, { variables }] = engine.query.mock.calls[0] + expect(query.trackedEntities.params(variables)).toMatchObject({ + orgUnit: 'ou1;ou2', + ouMode: 'SELECTED', + program: 'programId', + updatedAfter: '2024-01-01', + skipPaging: true, + }) + expect(orgUnits).toBe('ou1;ou2') + expect(instances.map((i) => i.id)).toEqual(['te1', 'te2']) + expect(data.map((f) => f.properties.id)).toEqual(['te1', 'te2']) + }) + + it('uses the 2.41 tracker API params and response', async () => { + const engine = { + query: jest.fn().mockResolvedValue({ + trackedEntities: { trackedEntities }, + }), + } + + const { data, instances, orgUnits } = + await loadTrackedEntitiesFromTracker({ + config, + engine, + serverVersion: { minor: 41 }, + }) + + const [query, { variables }] = engine.query.mock.calls[0] + expect(query.trackedEntities.params(variables)).toMatchObject({ + orgUnits: 'ou1,ou2', + orgUnitMode: 'SELECTED', + program: 'programId', + paging: false, + }) + expect(orgUnits).toBe('ou1,ou2') + expect(instances.map((i) => i.id)).toEqual(['te1', 'te2']) + expect(data.map((f) => f.properties.id)).toEqual(['te1', 'te2']) + }) +}) + +describe('loadTrackedEntitiesFromTracker filters', () => { + const load = async (config, serverVersion = { minor: 40 }) => { + const responseKey = + serverVersion.minor >= 41 ? 'trackedEntities' : 'instances' + const engine = { + query: jest.fn().mockResolvedValue({ + trackedEntities: { [responseKey]: [] }, + }), + } + await loadTrackedEntitiesFromTracker({ config, engine, serverVersion }) + return engine.query.mock.calls[0][1].variables + } + + it('applies the same program-scoped rules as analytics', async () => { + const params = await load({ + ...baseConfig, + programStatus: 'COMPLETED', + followUp: true, + periodType: 'program', + startDate: '2024-01-01', + endDate: '2024-12-31', + }) + + expect(params).toMatchObject({ trackedEntityType: 'teTypeId' }) + expect(params.programStatus).toBeUndefined() + expect(params.followUp).toBeUndefined() + expect(params.enrollmentEnrolledAfter).toBeUndefined() + expect(params.updatedAfter).toBeUndefined() + }) + + it('skips the period when the range is incomplete', async () => { + const params = await load({ ...baseConfig, startDate: '2024-01-01' }) + + expect(params.updatedAfter).toBeUndefined() + expect(params.updatedBefore).toBeUndefined() + }) + + it.each([{ minor: 40 }, { minor: 41 }])( + 'sends follow-up on %p', + async (serverVersion) => { + const config = { ...baseConfig, program: { id: 'programId' } } + + expect( + (await load({ ...config, followUp: true }, serverVersion)) + .followUp + ).toBe('TRUE') + expect( + (await load({ ...config, followUp: false }, serverVersion)) + .followUp + ).toBeUndefined() + expect((await load(config, serverVersion)).followUp).toBeUndefined() + } + ) +}) + +describe('canLoadTrackedEntitiesFromAnalytics', () => { + const program = { id: 'programId' } + + it.each([ + { name: 'a plain layer on 2.41+', expected: true }, + { name: 'any layer on 2.40', minor: 40, expected: false }, + { + name: 'relationships', + config: { relationshipType: 'relType' }, + expected: false, + }, + { + name: 'follow-up', + config: { program, followUp: true }, + expected: false, + }, + { + name: 'unchecked follow-up', + config: { program, followUp: false }, + expected: true, + }, + { + name: 'follow-up without program', + config: { followUp: true }, + expected: true, + }, + ])('$name => $expected', ({ config = {}, minor = 43, expected }) => { + expect(canLoadTrackedEntitiesFromAnalytics(config, { minor })).toBe( + expected + ) + }) +}) + +describe('getTrackerMaxLimit', () => { + const systemSettings = { + KeyTrackedEntityInstanceMaxLimit: 40000, + KeyTrackedEntityMaxLimit: 50000, + } + + it('reads the setting name of the server version', () => { + expect(getTrackerMaxLimit(systemSettings, { minor: 40 })).toBe(40000) + expect(getTrackerMaxLimit(systemSettings, { minor: 41 })).toBe(50000) + }) + + it.each([0, -1, undefined, ''])('treats %p as unlimited', (value) => { + expect( + getTrackerMaxLimit( + { KeyTrackedEntityMaxLimit: value }, + { minor: 41 } + ) + ).toBeNull() + }) +}) + +describe('loadTrackedEntitiesFromTracker fields', () => { + const getFields = async (config) => { + const engine = { + query: jest.fn().mockResolvedValue({ + trackedEntities: { trackedEntities: [] }, + }), + } + await loadTrackedEntitiesFromTracker({ + config, + engine, + serverVersion: { minor: 41 }, + }) + return engine.query.mock.calls[0][1].variables.fields + } + + it('only asks for relationships when the layer shows them', async () => { + expect(await getFields(baseConfig)).toEqual([ + 'trackedEntity~rename(id)', + 'geometry', + ]) + expect( + await getFields({ ...baseConfig, relationshipType: 'relType' }) + ).toContain('relationships') + }) +}) + +describe('loadTrackedEntitiesFromTracker truncation', () => { + const trackedEntities = [ + { id: 'te1', geometry: { type: 'Point', coordinates: [1, 2] } }, + { id: 'te2' }, + ] + const load = (maxLimit) => + loadTrackedEntitiesFromTracker({ + config: baseConfig, + engine: { + query: jest.fn().mockResolvedValue({ + trackedEntities: { trackedEntities }, + }), + }, + serverVersion: { minor: 41 }, + maxLimit, + }) + + it('is truncated when the limit is reached, counting entities without geometry', async () => { + const { data, isTruncated, limit } = await load(2) + + expect(data).toHaveLength(1) + expect(isTruncated).toBe(true) + expect(limit).toBe(2) + }) + + it('is not truncated below the limit, or without one', async () => { + expect((await load(3)).isTruncated).toBe(false) + expect((await load(null)).isTruncated).toBe(false) + }) +}) diff --git a/src/util/__tests__/trackedEntityRelationships.spec.js b/src/util/__tests__/trackedEntityRelationships.spec.js new file mode 100644 index 0000000000..cb1d41757d --- /dev/null +++ b/src/util/__tests__/trackedEntityRelationships.spec.js @@ -0,0 +1,410 @@ +import { + getDataWithRelationships, + loadTrackedEntityRelationships, +} from '../trackedEntityRelationships.js' + +const GEOMETRY = { coordinates: 'x/y' } + +const rel = (relationship, from, to) => ({ + bidirectional: false, + relationship, + relationshipType: 'relationshipTypeId1', + from: { trackedEntity: { trackedEntity: from } }, + to: { trackedEntity: { trackedEntity: to } }, +}) + +const biRel = (relationship, from, to) => ({ + ...rel(relationship, from, to), + bidirectional: true, +}) + +const instance = (id, relationships) => ({ + id, + geometry: GEOMETRY, + ...(relationships && { relationships }), +}) + +const teConstraint = (program) => ({ + relationshipEntity: 'TRACKED_ENTITY_INSTANCE', + trackedEntityType: { id: 'trackedEntityType1' }, + program: { id: program }, +}) + +const teRelationshipType = (fromProgram, toProgram) => ({ + id: 'relationshipTypeId1', + fromConstraint: teConstraint(fromProgram), + toConstraint: teConstraint(toProgram), +}) + +const expectResultToMatchExpected = (result, expected) => { + expect(result).toHaveProperty('primary') + expect(result).toHaveProperty('relationships') + expect(result).toHaveProperty('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()) +} + +describe('getDataWithRelationships', () => { + const mockSourceInstances = [ + // Missing geometry + { id: 'teFrom1', relationships: [] }, + // Missing relationships + instance('teFrom2', []), + // Wrong relationship type + instance('teFrom3', [ + { + relationship: 'relationship3', + relationshipType: 'relationshipTypeId0', + }, + ]), + // Unidirectional relationship, TE is the target of the relationship, source is in another program + instance('teFrom4', [rel('relationship4', 'teTo4', 'teFrom4')]), + // Unidirectional relationship, target is in same program + instance('teFrom5', [rel('relationship5', 'teFrom5', 'teTo5')]), + // Bidirectional relationship, target is in same program + instance('teFrom6', [biRel('relationship6', 'teTo6', 'teFrom6')]), + // Bidirectional relationship, but target is in another program + instance('teFrom7', [biRel('relationship7', 'teFrom7', 'teTo7')]), + // Two unidirectional relationship, targets are in another program + instance('teFrom8', [ + rel('relationship8A', 'teFrom8', 'teTo8A'), + rel('relationship8B', 'teFrom8', 'teTo8B'), + ]), + // Two TE with single unidirectional relationship, + // pointing at the same target in another program + instance('teFrom9A', [biRel('relationship9A', 'teFrom9A', 'teTo9')]), + instance('teFrom9B', [biRel('relationship9B', 'teFrom9B', 'teTo9')]), + { id: 'teTo1', relationships: [] }, + instance('teTo2', []), + instance('teTo3', []), + instance('teTo5', []), + instance('teTo6', [biRel('relationship6', 'teTo6', 'teFrom6')]), + ] + const mockTargetInstances = [ + { id: 'teTo1' }, + instance('teTo2'), + instance('teTo3'), + instance('teTo4', [rel('relationship4', 'teTo4', 'teFrom4')]), + instance('teTo5'), + instance('teTo6', [biRel('relationship6', 'teTo6', 'teFrom6')]), + instance('teTo7', [biRel('relationship7', 'teFrom7', 'teTo7')]), + instance('teTo8A', [rel('relationship8A', 'teFrom8', 'teTo8A')]), + instance('teTo8B', [rel('relationship8B', 'teFrom8', 'teTo8B')]), + instance('teTo9', [ + rel('relationship9A', 'teFrom9A', 'teTo9'), + rel('relationship9B', 'teFrom9B', 'teTo9'), + ]), + ] + const OUProps = { + orgUnits: 'someOU', + orgUnitsMode: 'someOUMode', + } + const expectedPrimary = [ + 'teFrom2', + 'teFrom3', + 'teFrom4', + 'teFrom5', + 'teFrom6', + 'teFrom7', + 'teFrom8', + 'teFrom9A', + 'teFrom9B', + 'teTo2', + 'teTo3', + 'teTo5', + 'teTo6', + ] + // Same TE type, different program: targets come from the program2 query + const expectedOtherProgram = { + primary: expectedPrimary, + relationships: [ + 'relationship5', + 'relationship6', + 'relationship7', + 'relationship8A', + 'relationship8B', + 'relationship9A', + 'relationship9B', + ], + secondary: ['teTo5', 'teTo6', 'teTo7', 'teTo8A', 'teTo8B', 'teTo9'], + } + const expectProgram2Query = (engine) => + expect(engine.query).toHaveBeenCalledWith( + { + tei: { + resource: 'tracker/trackedEntities', + params: expect.anything(), + }, + }, + expect.objectContaining({ + variables: { + fields: [ + 'trackedEntity~rename(id)', + 'geometry', + 'relationships', + ], + orgUnits: 'someOU', + orgUnitMode: undefined, + program: 'program2', + trackedEntityType: undefined, + }, + }) + ) + let mockEngine + + beforeEach(() => { + jest.resetAllMocks() + + mockEngine = { + query: jest.fn().mockResolvedValue({ + tei: { trackedEntities: mockTargetInstances }, + }), + } + }) + + test('To relationshipEntity not supported', async () => { + const relationshipType = { + fromConstraint: { + relationshipEntity: 'TRACKED_ENTITY_INSTANCE', // Selection starts from TE type, so this should not change + }, + toConstraint: { + relationshipEntity: 'PROGRAM_INSTANCE', // PROGRAM_INSTANCE & PROGRAM_STAGE_INSTANCE are not supported + }, + } + + const result = await getDataWithRelationships({ + serverVersion: { minor: 41 }, + instances: mockSourceInstances, + queryOptions: { relationshipType, ...OUProps }, + engine: mockEngine, + }) + // Tracked entities are still shown, without relationships + expect(result.primary.length).toBeGreaterThan(0) + expect(result.relationships).toEqual([]) + expect(result.secondary).toEqual([]) + expect(mockEngine.query).not.toHaveBeenCalled() + }) + + test('Same TE type and same program', async () => { + const result = await getDataWithRelationships({ + serverVersion: { minor: 41 }, + instances: mockSourceInstances, + queryOptions: { + relationshipType: teRelationshipType('program1', 'program1'), + ...OUProps, + }, + engine: mockEngine, + }) + + expectResultToMatchExpected(result, { + primary: expectedPrimary, + relationships: ['relationship5', 'relationship6'], + secondary: ['teFrom6', 'teTo5', 'teTo6'], + }) + expect(mockEngine.query).not.toHaveBeenCalled() + }) + + test('Same TE type and different program', async () => { + const result = await getDataWithRelationships({ + serverVersion: { minor: 41 }, + instances: mockSourceInstances, + queryOptions: { + relationshipType: teRelationshipType('program1', 'program2'), + ...OUProps, + }, + engine: mockEngine, + }) + + expectResultToMatchExpected(result, expectedOtherProgram) + expectProgram2Query(mockEngine) + }) + + it.each([ + { + trackerRootProp: 'instances', + versionString: '2.40', + serverVersion: { minor: 40 }, + }, + { + trackerRootProp: 'trackedEntities', + versionString: '2.41', + serverVersion: { minor: 41 }, + }, + ])( + '$versionString should use the tracker api root property "$trackerRootProp"', + async ({ serverVersion, trackerRootProp }) => { + mockEngine = { + query: jest.fn().mockResolvedValue({ + tei: { [trackerRootProp]: mockTargetInstances }, + }), + } + + const result = await getDataWithRelationships({ + serverVersion, + instances: mockSourceInstances, + queryOptions: { + relationshipType: teRelationshipType( + 'program1', + 'program2' + ), + ...OUProps, + }, + engine: mockEngine, + }) + + expectResultToMatchExpected(result, expectedOtherProgram) + expectProgram2Query(mockEngine) + } + ) +}) + +describe('getDataWithRelationships target query', () => { + // `program` is left out, not undefined: the code checks 'program' in to + const constraint = ({ type = 'type1', program } = {}) => ({ + relationshipEntity: 'TRACKED_ENTITY_INSTANCE', + trackedEntityType: { id: type }, + ...(program && { program: { id: program } }), + }) + + it.each([ + { + name: 'same type, different programs, to program set', + from: { program: 'program1' }, + to: { program: 'program2' }, + query: { program: 'program2', trackedEntityType: undefined }, + }, + { + name: 'same type, different programs, no to program', + from: { program: 'program1' }, + to: {}, + query: { program: undefined, trackedEntityType: 'type1' }, + }, + { + name: 'different types, different programs, to program set', + from: { program: 'program1' }, + to: { type: 'type2', program: 'program2' }, + query: { program: undefined, trackedEntityType: 'type2' }, + }, + { + name: 'different types, different programs, no to program', + from: { program: 'program1' }, + to: { type: 'type2' }, + query: { program: undefined, trackedEntityType: 'type2' }, + }, + { + name: 'different types, same program', + from: { program: 'program1' }, + to: { type: 'type2', program: 'program1' }, + query: { program: undefined, trackedEntityType: undefined }, + }, + { + name: 'different types, no programs', + from: {}, + to: { type: 'type2' }, + query: { program: undefined, trackedEntityType: undefined }, + }, + { + name: 'same type, same program', + from: { program: 'program1' }, + to: { program: 'program1' }, + query: null, + }, + { + name: 'same type, no programs', + from: {}, + to: {}, + query: null, + }, + ])('$name', async ({ from, to, query }) => { + const engine = { + query: jest + .fn() + .mockResolvedValue({ tei: { trackedEntities: [] } }), + } + + await getDataWithRelationships({ + serverVersion: { minor: 41 }, + instances: [instance('te1', [rel('relationship1', 'te1', 'te2')])], + queryOptions: { + relationshipType: { + id: 'relationshipTypeId1', + fromConstraint: constraint(from), + toConstraint: constraint(to), + }, + orgUnits: 'ou1', + }, + engine, + }) + + if (query) { + expect(engine.query.mock.calls[0][1].variables).toMatchObject(query) + } else { + // The targets are among the loaded instances + expect(engine.query).not.toHaveBeenCalled() + } + }) +}) + +describe('loadTrackedEntityRelationships', () => { + const constraint = { + relationshipEntity: 'TRACKED_ENTITY_INSTANCE', + trackedEntityType: { id: 'teType' }, + } + const relationshipType = { + id: 'relType', + displayName: 'Contact', + fromConstraint: constraint, + toConstraint: constraint, + } + const relatedEntityType = { displayName: 'Person', featureType: 'POINT' } + const relationship = { + relationship: 'rel1', + relationshipType: 'relType', + from: { trackedEntity: { trackedEntity: 'te1' } }, + to: { trackedEntity: { trackedEntity: 'te2' } }, + } + const instances = [ + { + id: 'te1', + geometry: { type: 'Point', coordinates: [1, 2] }, + relationships: [relationship], + }, + { + id: 'te2', + geometry: { type: 'Point', coordinates: [3, 4] }, + relationships: [relationship], + }, + ] + + it('returns features, relationships and the types for the legend', async () => { + const engine = { + query: jest + .fn() + .mockResolvedValueOnce({ relationshipType }) + .mockResolvedValueOnce({ relatedEntityType }), + } + + const result = await loadTrackedEntityRelationships({ + config: { relationshipType: 'relType' }, + engine, + serverVersion: { minor: 41 }, + instances, + orgUnits: 'ou1', + }) + + // Same type and program on both sides: no extra instance query + expect(engine.query).toHaveBeenCalledTimes(2) + expect(result.relationshipType).toBe(relationshipType) + expect(result.relatedEntityType).toBe(relatedEntityType) + expect(result.data.map((f) => f.properties.id)).toEqual(['te1', 'te2']) + expect(result.relationships.map((r) => r.id)).toEqual(['rel1']) + expect(result.secondaryData.map((f) => f.properties.id)).toEqual([ + 'te2', + ]) + }) +}) diff --git a/src/util/__tests__/versionToggle.spec.js b/src/util/__tests__/versionToggle.spec.js new file mode 100644 index 0000000000..4d40e56f0b --- /dev/null +++ b/src/util/__tests__/versionToggle.spec.js @@ -0,0 +1,61 @@ +import { + serverSupportsTracker41Api, + serverSupportsTrackedEntityAnalytics, + serverSupportsTrackedEntityAnalyticsIdColumn, +} from '../versionToggle.js' + +describe('serverSupportsTracker41Api', () => { + it('returns false on 2.40', () => { + expect(serverSupportsTracker41Api({ minor: 40 })).toBe(false) + }) + + it('returns true at 2.41', () => { + expect(serverSupportsTracker41Api({ minor: 41 })).toBe(true) + }) + + it('returns true above 2.41', () => { + expect(serverSupportsTracker41Api({ minor: 43 })).toBe(true) + }) + + it('returns false when serverVersion is undefined', () => { + expect(serverSupportsTracker41Api(undefined)).toBe(false) + }) +}) + +describe('serverSupportsTrackedEntityAnalytics', () => { + it('returns false on 2.40', () => { + expect(serverSupportsTrackedEntityAnalytics({ minor: 40 })).toBe(false) + }) + + it('returns true at 2.41', () => { + expect(serverSupportsTrackedEntityAnalytics({ minor: 41 })).toBe(true) + }) + + it('returns true above 2.41', () => { + expect(serverSupportsTrackedEntityAnalytics({ minor: 43 })).toBe(true) + }) + + it('returns false when serverVersion is undefined', () => { + expect(serverSupportsTrackedEntityAnalytics(undefined)).toBe(false) + }) +}) + +describe('serverSupportsTrackedEntityAnalyticsIdColumn', () => { + it('returns false on 2.41', () => { + expect( + serverSupportsTrackedEntityAnalyticsIdColumn({ minor: 41 }) + ).toBe(false) + }) + + it('returns true from 2.42', () => { + expect( + serverSupportsTrackedEntityAnalyticsIdColumn({ minor: 42 }) + ).toBe(true) + }) + + it('returns false when serverVersion is undefined', () => { + expect(serverSupportsTrackedEntityAnalyticsIdColumn(undefined)).toBe( + false + ) + }) +}) diff --git a/src/util/geojson.js b/src/util/geojson.js index ce6b0d372a..e9b1dc7e03 100644 --- a/src/util/geojson.js +++ b/src/util/geojson.js @@ -1,5 +1,6 @@ import { booleanPointInPolygon } from '@turf/boolean-point-in-polygon' import turfCentroid from '@turf/centroid' +import { wktToGeoJSON } from 'betterknown' import findIndex from 'lodash/findIndex' export const EVENT_ID_FIELD = 'psi' @@ -85,6 +86,26 @@ export const buildEventGeometryGetter = (headers) => { } } +export const trackedEntityGeometryTypes = new Set([ + GEO_TYPE_POINT, + GEO_TYPE_POLYGON, + GEO_TYPE_MULTIPOLYGON, +]) + +// Tracker analytics geometries are (E)WKT, with or without an SRID prefix +export const parseWkt = (wkt) => { + if (typeof wkt !== 'string' || !wkt) { + return null + } + + try { + const geometry = wktToGeoJSON(wkt) + return trackedEntityGeometryTypes.has(geometry?.type) ? geometry : null + } catch { + return null + } +} + export const createEventFeatures = (response, config = {}) => { const names = { ...response.headers.reduce( diff --git a/src/util/trackedEntity.js b/src/util/trackedEntity.js index b487fac9a4..c3c328ee2d 100644 --- a/src/util/trackedEntity.js +++ b/src/util/trackedEntity.js @@ -1,3 +1,24 @@ +import { TEI_CLIENT_PAGE_SIZE } from '../constants/layers.js' +import { + ORG_UNIT_MODE_DESCENDANTS, + ORG_UNIT_MODE_SELECTED, +} from '../constants/orgUnits.js' +import { getOrgUnitsFromRows } from './analytics.js' +import { + GEO_TYPE_FEATURE, + parseWkt, + trackedEntityGeometryTypes, +} from './geojson.js' +import { trimTime } from './time.js' +import { + serverSupportsTracker41Api, + serverSupportsTrackedEntityAnalytics, + serverSupportsTrackedEntityAnalyticsIdColumn, +} from './versionToggle.js' + +// Attribute queries (used by the layer popup) +// ----- + export const TRACKED_ENTITY_TRACKED_ENTITY_TYPE_ATTRIBUTES_QUERY = { trackedEntityType: { resource: 'trackedEntityTypes', @@ -19,3 +40,273 @@ export const TRACKED_ENTITY_PROGRAM_TRACKED_ENTITY_ATTRIBUTES_QUERY = { }), }, } + +// Filters +// ----- + +// VERSION-TOGGLE: each API's own default - see util/versionToggle.js +export const getTrackedEntityDefaultOrgUnitMode = (serverVersion) => + serverSupportsTrackedEntityAnalytics(serverVersion) + ? ORG_UNIT_MODE_DESCENDANTS + : ORG_UNIT_MODE_SELECTED + +// Tracker analytics can't return relationships or filter by follow-up +export const canLoadTrackedEntitiesFromAnalytics = ( + { program, followUp, relationshipType }, + serverVersion +) => + // VERSION-TOGGLE: see util/versionToggle.js + serverSupportsTrackedEntityAnalytics(serverVersion) && + !relationshipType && + !(program && followUp) + +// Filters shared by the analytics and tracker requests +const getTrackedEntityFilters = ({ + trackedEntityType, + program, + programStatus, + followUp, + periodType, + startDate, + endDate, + rows, + organisationUnitSelectionMode, +}) => { + const isEnrollmentPeriod = periodType === 'program' + + return { + trackedEntityTypeId: trackedEntityType.id, + programId: program?.id, + // Program-scoped filters are ignored without a program + programStatus: program ? programStatus : undefined, + // Unchecked means no filter, not "not marked for follow-up" + followUp: Boolean(program && followUp), + orgUnitIds: getOrgUnitsFromRows(rows).map((ou) => ou.id), + orgUnitMode: organisationUnitSelectionMode, + period: + startDate && endDate && (program || !isEnrollmentPeriod) + ? { + isEnrollmentPeriod, + startDate: trimTime(startDate), + endDate: trimTime(endDate), + } + : undefined, + } +} + +// Analytics (2.41+) +// ----- + +// VERSION-TOGGLE: see util/versionToggle.js +const getAnalyticsIdColumn = (serverVersion) => + serverSupportsTrackedEntityAnalyticsIdColumn(serverVersion) + ? 'trackedentity' + : 'trackedentityinstanceuid' + +export const getTrackedEntityAnalyticsRequest = ( + config, + { analyticsEngine, serverVersion } +) => { + const { + trackedEntityTypeId, + programId, + programStatus, + orgUnitIds, + orgUnitMode, + period, + } = getTrackedEntityFilters(config) + + let request = new analyticsEngine.request() + .withTrackedEntityType(trackedEntityTypeId) + .addOrgUnitDimension(orgUnitIds) + + if (orgUnitMode) { + request = request.withOuMode(orgUnitMode) + } + + // withProgram() would put the program in the path. Date params are top-level + // and enrollment ones are qualified with the program: "." + const range = period && `${period.startDate}_${period.endDate}` + + return request.withParameters({ + // Every attribute and org unit column comes back otherwise + headers: `${getAnalyticsIdColumn(serverVersion)},geometry`, + // coordinatesOnly would exclude polygons + geometryOnly: true, + ...(programId && { program: programId }), + ...(programStatus && { + programStatus: `${programId}.${programStatus}`, + }), + ...(period && + (period.isEnrollmentPeriod + ? { enrollmentDate: `${programId}.${range}` } + : { lastUpdated: range })), + }) +} + +export const createTrackedEntityFeatures = ( + { headers, rows }, + serverVersion +) => { + const idColName = getAnalyticsIdColumn(serverVersion) + const idCol = headers.findIndex((h) => h.name === idColName) + const geomCol = headers.findIndex((h) => h.name === 'geometry') + + return rows.reduce((features, row) => { + const geometry = parseWkt(row[geomCol]) + + if (geometry) { + features.push({ + type: GEO_TYPE_FEATURE, + geometry, + properties: { id: row[idCol] }, + }) + } + + return features + }, []) +} + +export const loadTrackedEntitiesFromAnalytics = async ({ + config, + analyticsEngine, + serverVersion, + pageSize = TEI_CLIENT_PAGE_SIZE, +}) => { + const request = getTrackedEntityAnalyticsRequest(config, { + analyticsEngine, + serverVersion, + }) + const response = await analyticsEngine.trackedEntities.getQuery( + request.withPageSize(pageSize) + ) + + return { + data: createTrackedEntityFeatures(response, serverVersion), + // isLastPage is false for empty results, and the server can return fewer + // rows than pageSize (analytics max limit setting) + isTruncated: + response.metaData?.pager?.isLastPage === false && + response.rows.length > 0, + limit: pageSize, + } +} + +// Tracker API (2.40, relationships and follow-up) +// ----- + +// Row limit of the tracker API, applied even with paging off +// VERSION-TOGGLE: setting renamed in 2.41 - see util/versionToggle.js +export const getTrackerMaxLimit = (systemSettings, serverVersion) => { + const limit = Number( + serverSupportsTracker41Api(serverVersion) + ? systemSettings.KeyTrackedEntityMaxLimit + : systemSettings.KeyTrackedEntityInstanceMaxLimit + ) + return limit > 0 ? limit : null +} + +const TRACKER_FIELDS = ['trackedEntity~rename(id)', 'geometry'] +// Expensive, so only for layers showing relationships +const TRACKER_RELATIONSHIP_FIELDS = [...TRACKER_FIELDS, 'relationships'] + +const TRACKED_ENTITIES_QUERY = { + resource: 'tracker/trackedEntities', + params: (params) => params, +} + +const getTrackerParams = (filters, isTracker41Api, withRelationships) => { + const { + trackedEntityTypeId, + programId, + programStatus, + followUp, + orgUnitIds, + orgUnitMode, + period, + } = filters + + const versionParams = isTracker41Api + ? { + orgUnits: orgUnitIds.join(','), + orgUnitMode, + paging: false, + } + : { + orgUnit: orgUnitIds.join(';'), + ouMode: orgUnitMode, + skipPaging: true, + } + + let periodParams = {} + + if (period?.isEnrollmentPeriod) { + periodParams = { + enrollmentEnrolledAfter: period.startDate, + enrollmentEnrolledBefore: period.endDate, + } + } else if (period) { + periodParams = { + updatedAfter: period.startDate, + updatedBefore: period.endDate, + } + } + + return { + fields: withRelationships + ? TRACKER_RELATIONSHIP_FIELDS + : TRACKER_FIELDS, + program: programId, + programStatus, + // The tracker API doesn't accept both program and type + trackedEntityType: programId ? undefined : trackedEntityTypeId, + ...(followUp && { followUp: 'TRUE' }), + ...versionParams, + ...periodParams, + } +} + +export const createTrackedEntityInstanceFeatures = (instances) => + instances.map(({ id, geometry }) => ({ + type: GEO_TYPE_FEATURE, + geometry, + properties: { id }, + })) + +export const loadTrackedEntitiesFromTracker = async ({ + config, + engine, + serverVersion, + maxLimit, +}) => { + // VERSION-TOGGLE: see util/versionToggle.js + const isTracker41Api = serverSupportsTracker41Api(serverVersion) + const params = getTrackerParams( + getTrackedEntityFilters(config), + isTracker41Api, + Boolean(config.relationshipType) + ) + + const { trackedEntities: response } = await engine.query( + { trackedEntities: TRACKED_ENTITIES_QUERY }, + { variables: params } + ) + + const allInstances = + response[isTracker41Api ? 'trackedEntities' : 'instances'] + const instances = allInstances.filter( + (instance) => + trackedEntityGeometryTypes.has(instance.geometry?.type) && + instance.geometry?.coordinates + ) + + return { + data: createTrackedEntityInstanceFeatures(instances), + // No truncation flag from the API: count all rows, geometry or not + isTruncated: Boolean(maxLimit) && allInstances.length >= maxLimit, + limit: maxLimit, + instances, + // Formatted for the relationships request + orgUnits: isTracker41Api ? params.orgUnits : params.orgUnit, + } +} diff --git a/src/util/teiRelationshipsParser.js b/src/util/trackedEntityRelationships.js similarity index 70% rename from src/util/teiRelationshipsParser.js rename to src/util/trackedEntityRelationships.js index 291aef303c..aac4755b4b 100644 --- a/src/util/teiRelationshipsParser.js +++ b/src/util/trackedEntityRelationships.js @@ -1,5 +1,12 @@ +import { createTrackedEntityInstanceFeatures } from './trackedEntity.js' +import { serverSupportsTracker41Api } from './versionToggle.js' + const TRACKED_ENTITY_INSTANCE = 'TRACKED_ENTITY_INSTANCE' +// Related instances (tracker API) +// ----- + +// VERSION-TOGGLE: see util/versionToggle.js const TEI_40_QUERY = { resource: 'tracker/trackedEntities', params: ({ @@ -36,6 +43,9 @@ const TEI_41_QUERY = { }), } +// Relationship parsing +// ----- + const normalizeInstances = (instances) => { return instances .filter((instance) => !!instance.geometry?.coordinates) @@ -111,7 +121,7 @@ const getInstanceRelationships = ( id, from, reversedTo, - bidirectional: !!bidirectional, + bidirectional: true, } } } @@ -121,7 +131,7 @@ const getInstanceRelationships = ( const fields = ['trackedEntity~rename(id)', 'geometry', 'relationships'] export const getDataWithRelationships = async ({ - isVersion40, + serverVersion, instances: sourceInstances, queryOptions, engine, @@ -136,7 +146,12 @@ export const getDataWithRelationships = async ({ from.relationshipEntity !== TRACKED_ENTITY_INSTANCE || to.relationshipEntity !== TRACKED_ENTITY_INSTANCE ) { - return [] + // Only relationships between tracked entities can be shown + return { + primary: Object.values(normalizeInstances(sourceInstances)), + relationships: [], + secondary: [], + } } const isRecursiveTrackedEntityType = @@ -151,29 +166,11 @@ export const getDataWithRelationships = async ({ // Use target as source if from/to TE Types and Programs match, otherwise // fetch/re-fetch using program if available TE type otherwise let recursiveProp = null - if ( - isRecursiveTrackedEntityType && // Same TE Type - !isRecursiveProgram && // Different Program - isToProgramDefined // Defined 'To' Program - ) { - recursiveProp = { - program: to.program.id, - } - } else if ( - isRecursiveTrackedEntityType && // Same TE Type - !isRecursiveProgram && // Different Program - !isToProgramDefined // Not Defined 'To' Program - ) { - recursiveProp = { - type: to.trackedEntityType, - } - } else if ( - !isRecursiveTrackedEntityType && // Different TE Type - !isRecursiveProgram // Different Program - ) { - recursiveProp = { - type: to.trackedEntityType, - } + if (!isRecursiveProgram) { + recursiveProp = + isRecursiveTrackedEntityType && isToProgramDefined + ? { program: to.program.id } // Same TE type, defined 'to' program + : { type: to.trackedEntityType } // Different TE type, or no 'to' program } // Keep TEI with coords and convert array to object (id = key) @@ -181,12 +178,13 @@ export const getDataWithRelationships = async ({ // Retrieve potential target instances let normalizedPotentialTargetInstances - if (isRecursiveTrackedEntityType & isRecursiveProgram) { + if (isRecursiveTrackedEntityType && isRecursiveProgram) { normalizedPotentialTargetInstances = normalizedSourceInstances } else { - // VERSION-TOGGLE: https://github.com/dhis2/dhis2-releases/tree/master/releases/2.41#deprecated-apis + // VERSION-TOGGLE: see util/versionToggle.js + const isTracker41Api = serverSupportsTracker41Api(serverVersion) const { tei } = await engine.query( - { tei: isVersion40 ? TEI_40_QUERY : TEI_41_QUERY }, + { tei: isTracker41Api ? TEI_41_QUERY : TEI_40_QUERY }, { variables: { fields, @@ -199,7 +197,7 @@ export const getDataWithRelationships = async ({ ) normalizedPotentialTargetInstances = normalizeInstances( - tei[isVersion40 ? 'instances' : 'trackedEntities'] + tei[isTracker41Api ? 'trackedEntities' : 'instances'] ) } @@ -232,3 +230,67 @@ export const getDataWithRelationships = async ({ secondary: targetInstances, } } + +// Loading +// ----- + +const RELATIONSHIP_TYPE_QUERY = { + resource: 'relationshipTypes', + id: ({ id }) => id, +} + +const TRACKED_ENTITY_TYPE_QUERY = { + resource: 'trackedEntityTypes', + id: ({ id }) => id, + params: { + fields: 'displayName,featureType', + }, +} + +// Instances must include their relationships (loadTrackedEntitiesFromTracker) +export const loadTrackedEntityRelationships = async ({ + config, + engine, + serverVersion, + instances, + orgUnits, +}) => { + const { + relationshipType: relationshipTypeId, + organisationUnitSelectionMode, + } = config + + const { relationshipType } = await engine.query( + { relationshipType: RELATIONSHIP_TYPE_QUERY }, + { variables: { id: relationshipTypeId } } + ) + + const { relatedEntityType } = await engine.query( + { relatedEntityType: TRACKED_ENTITY_TYPE_QUERY }, + { + variables: { + id: relationshipType.toConstraint.trackedEntityType.id, + }, + } + ) + + const { primary, relationships, secondary } = + await getDataWithRelationships({ + serverVersion, + instances, + queryOptions: { + relationshipType, + orgUnits, + organisationUnitSelectionMode, + }, + engine, + }) + + return { + data: createTrackedEntityInstanceFeatures(primary), + relationships, + secondaryData: createTrackedEntityInstanceFeatures(secondary), + relationshipType, + relatedEntityType, + } +} diff --git a/src/util/versionToggle.js b/src/util/versionToggle.js new file mode 100644 index 0000000000..2925fa6111 --- /dev/null +++ b/src/util/versionToggle.js @@ -0,0 +1,12 @@ +// VERSION-TOGGLE: tracker/trackedEntities params and response renamed in 2.41 +// https://github.com/dhis2/dhis2-releases/tree/master/releases/2.41#deprecated-apis +export const serverSupportsTracker41Api = (serverVersion) => + serverVersion?.minor >= 41 + +// VERSION-TOGGLE: analytics/trackedEntities/query doesn't exist on 2.40 +export const serverSupportsTrackedEntityAnalytics = (serverVersion) => + serverVersion?.minor >= 41 + +// VERSION-TOGGLE: analytics id column "trackedentityinstanceuid" renamed in 2.42 +export const serverSupportsTrackedEntityAnalyticsIdColumn = (serverVersion) => + serverVersion?.minor >= 42 diff --git a/yarn.lock b/yarn.lock index 36520b0a04..653db2bb94 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5290,6 +5290,13 @@ belter@^1.0.41: cross-domain-utils "^2" zalgo-promise "^1" +betterknown@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/betterknown/-/betterknown-1.2.0.tgz#2b58d377c5f701d433144f174ade2c44f31a91c7" + integrity sha512-9kuLOeVkgAXnKsMn9WAHLLJ45Wn51jPuZlS1XPkia7NoBpqM9EbVvr+A2Wr0g1WAEL5rX7/oM428Akd3wCL3vA== + dependencies: + "@types/geojson" "^7946.0.16" + big.js@^5.2.2: version "5.2.2" resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328"