diff --git a/src/util/__tests__/teiRelationshipsParser.spec.js b/src/util/__tests__/teiRelationshipsParser.spec.js new file mode 100644 index 000000000..78785926b --- /dev/null +++ b/src/util/__tests__/teiRelationshipsParser.spec.js @@ -0,0 +1,574 @@ +import { apiFetch } from '../api.js' +import { + fetchTEIs, + getDataWithRelationships, +} from '../teiRelationshipsParser.js' + +jest.mock('../api.js') + +describe('fetchData', () => { + it.each([ + { + customProps: { organisationUnitSelectionMode: 'someOUMode' }, + expectedUrl: + '/trackedEntityInstances?skipPaging=true&fields=someFields&ou=ouId&ouMode=someOUMode', + }, + { + customProps: { type: { id: 'someTETypeId' } }, + expectedUrl: + '/trackedEntityInstances?skipPaging=true&fields=someFields&ou=ouId&trackedEntityType=someTETypeId', + }, + { + customProps: { program: 'someProgram' }, + expectedUrl: + '/trackedEntityInstances?skipPaging=true&fields=someFields&ou=ouId&program=someProgram', + }, + ])( + 'should call apiFetch correct url in different scenarios', + async ({ customProps, expectedUrl }) => { + const placeholder = { some: 'object' } + const mockData = { trackedEntityInstances: placeholder } + const baseProps = { + orgUnits: 'ouId', + fields: 'someFields', + } + + apiFetch.mockResolvedValueOnce(mockData) + const result = await fetchTEIs({ + ...baseProps, + ...customProps, + }) + expect(result).toEqual(placeholder) + expect(apiFetch).toHaveBeenCalledWith(expectedUrl) + } + ) +}) + +describe('getDataWithRelationships', () => { + it.each([ + { + // To relationshipEntity not supported + 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 + }, + }, + expected: [], // This the current behavior but it should be revised + }, + { + // Same TE type and same program + 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', + }, + }, + }, + expected: { + primary: [ + 'teFrom2', + 'teFrom3', + 'teFrom4', + 'teFrom5', + 'teFrom6', + 'teFrom7', + 'teFrom8', + 'teFrom9A', + 'teFrom9B', + 'teTo2', + 'teTo3', + 'teTo5', + 'teTo6', + ], + relationships: ['relationship5', 'relationship6'], + secondary: ['teFrom6', 'teTo5', 'teTo6'], + }, + }, + { + // Same TE type but different program + // Different TE type and different program - would be the same given we mock the API call + 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', + }, + }, + }, + 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', + ], + }, + }, + ])( + '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', + } + + apiFetch.mockResolvedValueOnce({ + trackedEntityInstances: mockTargetInstances, + }) + const result = await getDataWithRelationships( + 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/teiRelationshipsParser.js b/src/util/teiRelationshipsParser.js index 1abb8114d..29e3a0f84 100644 --- a/src/util/teiRelationshipsParser.js +++ b/src/util/teiRelationshipsParser.js @@ -3,6 +3,7 @@ import { apiFetch } from './api.js' const TRACKED_ENTITY_INSTANCE = 'TRACKED_ENTITY_INSTANCE' export const fetchTEIs = async ({ + program, type, orgUnits, fields, @@ -12,8 +13,12 @@ export const fetchTEIs = async ({ if (organisationUnitSelectionMode) { url += `&ouMode=${organisationUnitSelectionMode}` } - - url += `&trackedEntityType=${type.id}` + if (program) { + url += `&program=${program}` + } + if (type) { + url += `&trackedEntityType=${type.id}` + } const data = await apiFetch(url) @@ -33,20 +38,31 @@ export const parseTEInstanceId = (instance) => instance.trackedEntityInstance.trackedEntityInstance const isValidRel = (rel, type, id) => - rel.relationshipType === type && parseTEInstanceId(rel.from) === id + rel.relationshipType === type && + (parseTEInstanceId(rel.from) === id || parseTEInstanceId(rel.to) === id) -const isIndexInstance = (instance, type) => { +const isIndexInstance = (instance, type, targetInstanceIds) => { + const alwaysBidirectional = false // We might want to have a setting to be able to see relationship no mater if primary is source or target let hasChildren = false for (let i = 0; i < instance.relationships.length; ++i) { const rel = instance.relationships[i] if (rel.relationshipType !== type) { continue } - if (parseTEInstanceId(rel.to) === instance.id) { - return false - } - if (parseTEInstanceId(rel.from) === instance.id) { + + const toIdMatches = parseTEInstanceId(rel.to) === instance.id + const fromIdMatches = parseTEInstanceId(rel.from) === instance.id + if ( + alwaysBidirectional || + (!rel.bidirectional && fromIdMatches && !toIdMatches) || // When not bidirectional we want the from id to match + (rel.bidirectional && (fromIdMatches || toIdMatches)) // When bidirectional it can be either from or to id that matches + ) { hasChildren = true + if (fromIdMatches) { + targetInstanceIds.push(parseTEInstanceId(rel.to)) + } else { + targetInstanceIds.push(parseTEInstanceId(rel.from)) + } } } return hasChildren @@ -57,36 +73,37 @@ const getInstanceRelationships = ( relationshipsById, from, targetInstances, - type, - isRecursive + type ) => { + const alwaysBidirectional = false // We might want to have a setting to be able to see relationship no mater if primary is source or target const localRels = from.relationships.filter((rel) => isValidRel(rel, type, from.id) ) localRels.forEach((rel) => { const id = rel.relationship + const bidirectional = rel.bidirectional || alwaysBidirectional if (relationshipsById[id]) { return } const to = targetInstances[parseTEInstanceId(rel.to)] - if (!to) { - // console.error('NOT FOUND', rel.to); - return - } - relationshipsById[id] = { - id, - from, - to, - } - if (isRecursive) { - getInstanceRelationships( - relationshipsById, + if (to && from.id !== to.id) { + relationshipsById[id] = { + id, + from, to, - targetInstances, - type, - true - ) + bidirectional: !!bidirectional, + } + } else { + const reversedTo = targetInstances[parseTEInstanceId(rel.from)] + if (reversedTo && from.id !== reversedTo.id && bidirectional) { + relationshipsById[id] = { + id, + from, + reversedTo, + bidirectional: !!bidirectional, + } + } } }) } @@ -113,42 +130,85 @@ export const getDataWithRelationships = async ( return [] } - const isRecursive = from.trackedEntityType.id === to.trackedEntityType.id - - const targetInstances = normalizeInstances( - isRecursive - ? sourceInstances - : await fetchTEIs({ - type: to.trackedEntityType, - fields, - orgUnits, - organisationUnitSelectionMode, - }) + const isRecursiveTrackedEntityType = + from.trackedEntityType.id === to.trackedEntityType.id + const isRecursiveProgram = // program specified and same in both or not specified in both + ('program' in from && + 'program' in to && + from?.program?.id === to?.program?.id) || + !('program' in from || 'program' in to) + const isToProgramDefined = 'program' in to + + // 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, + } + } + + // Keep TEI with coords and convert array to object (id = key) + const normalizedSourceInstances = normalizeInstances(sourceInstances) + + // Retrieve potential target instances + const potentialTargetInstances = + isRecursiveTrackedEntityType & isRecursiveProgram + ? normalizedSourceInstances + : normalizeInstances( + await fetchTEIs({ + ...recursiveProp, + fields, + orgUnits, + organisationUnitSelectionMode, + }) + ) + + const targetInstanceIds = [] + // Keep TEI with relationship of correct type + // Store Ids of target relationships + const filteredSourceInstances = sourceInstances.filter((instance) => + isIndexInstance(instance, relationshipType.id, targetInstanceIds) ) - const filteredSourceInstances = isRecursive - ? sourceInstances.filter((instance) => - isIndexInstance(instance, relationshipType.id) - ) - : sourceInstances const relationshipsById = {} - + // Create relationship objects filteredSourceInstances.forEach((instance) => getInstanceRelationships( relationshipsById, instance, - targetInstances, - relationshipType.id, - isRecursive + potentialTargetInstances, + relationshipType.id ) ) - filteredSourceInstances.forEach((instance) => { - delete targetInstances[instance.id] - }) + // Keep only instances that are the target of a relationship + const targetInstances = Object.values(potentialTargetInstances).filter( + (instance) => targetInstanceIds.includes(instance.id) + ) + return { - primary: filteredSourceInstances, + primary: Object.values(normalizedSourceInstances), relationships: Object.values(relationshipsById), - secondary: Object.values(targetInstances), + secondary: targetInstances, } }