diff --git a/src/api/analytics/AnalyticsEnrollments.js b/src/api/analytics/AnalyticsEnrollments.js index 8920ad2f5..71b4fa69f 100644 --- a/src/api/analytics/AnalyticsEnrollments.js +++ b/src/api/analytics/AnalyticsEnrollments.js @@ -30,7 +30,7 @@ class AnalyticsEnrollments extends AnalyticsBase { * .then(console.log); */ getAggregate(req) { - return this.fetch(req.withPath('enrollments/aggregate')) + return this.get(req.withPath('enrollments/aggregate')) } /** diff --git a/src/api/analytics/AnalyticsEvents.js b/src/api/analytics/AnalyticsEvents.js index 3f1896098..680e702f4 100644 --- a/src/api/analytics/AnalyticsEvents.js +++ b/src/api/analytics/AnalyticsEvents.js @@ -30,7 +30,7 @@ class AnalyticsEvents extends AnalyticsBase { * .then(console.log); */ getAggregate(req) { - return this.fetch(req.withPath('events/aggregate')) + return this.get(req.withPath('events/aggregate')) } /** diff --git a/src/api/analytics/__tests__/AnalyticsEnrollments.spec.js b/src/api/analytics/__tests__/AnalyticsEnrollments.spec.js index 5483714a2..549d11822 100644 --- a/src/api/analytics/__tests__/AnalyticsEnrollments.spec.js +++ b/src/api/analytics/__tests__/AnalyticsEnrollments.spec.js @@ -36,7 +36,10 @@ describe('analytics.enrollments', () => { fixture = fixtures.get('/api/analytics/aggregate') dataEngineMock.query.mockReturnValue( - Promise.resolve({ data: fixture }) + Promise.resolve({ + data: { ...fixture, metaData: undefined }, + metaData: { metaData: fixture.metaData }, + }) ) }) @@ -44,10 +47,31 @@ describe('analytics.enrollments', () => { expect(enrollments.getAggregate).toBeInstanceOf(Function) }) - it('should resolve a promise with data', () => + it('should resolve a promise with the merged data and metaData', () => enrollments.getAggregate(request).then((data) => { - expect(data).toEqual(fixture) + expect(data.rows).toEqual(fixture.rows) + expect(data.headers).toEqual(fixture.headers) + expect(data.metaData).toEqual(fixture.metaData) })) + + it('should request data and metaData separately', async () => { + await enrollments.getAggregate(request) + + const [queries, { variables }] = dataEngineMock.query.mock.calls[0] + + expect(queries.data.id(variables)).toBe('enrollments/aggregate') + expect(queries.metaData.id(variables)).toBe('enrollments/aggregate') + + expect(queries.data.params(variables)).toMatchObject({ + skipMeta: true, + skipData: false, + }) + expect(queries.metaData.params(variables)).toMatchObject({ + skipMeta: false, + skipData: true, + includeMetadataDetails: true, + }) + }) }) describe('.getQuery()', () => { diff --git a/src/api/analytics/__tests__/AnalyticsEvents.spec.js b/src/api/analytics/__tests__/AnalyticsEvents.spec.js index e75134509..d412f7bc0 100644 --- a/src/api/analytics/__tests__/AnalyticsEvents.spec.js +++ b/src/api/analytics/__tests__/AnalyticsEvents.spec.js @@ -36,7 +36,10 @@ describe('analytics.events', () => { fixture = fixtures.get('/api/analytics/aggregate') dataEngineMock.query.mockReturnValue( - Promise.resolve({ data: fixture }) + Promise.resolve({ + data: { ...fixture, metaData: undefined }, + metaData: { metaData: fixture.metaData }, + }) ) }) @@ -44,10 +47,31 @@ describe('analytics.events', () => { expect(events.getAggregate).toBeInstanceOf(Function) }) - it('should resolve a promise with data', () => + it('should resolve a promise with the merged data and metaData', () => events.getAggregate(request).then((data) => { - expect(data).toEqual(fixture) + expect(data.rows).toEqual(fixture.rows) + expect(data.headers).toEqual(fixture.headers) + expect(data.metaData).toEqual(fixture.metaData) })) + + it('should request data and metaData separately', async () => { + await events.getAggregate(request) + + const [queries, { variables }] = dataEngineMock.query.mock.calls[0] + + expect(queries.data.id(variables)).toBe('events/aggregate') + expect(queries.metaData.id(variables)).toBe('events/aggregate') + + expect(queries.data.params(variables)).toMatchObject({ + skipMeta: true, + skipData: false, + }) + expect(queries.metaData.params(variables)).toMatchObject({ + skipMeta: false, + skipData: true, + includeMetadataDetails: true, + }) + }) }) describe('.getCount()', () => { diff --git a/src/modules/pivotTable/PivotTableEngine.js b/src/modules/pivotTable/PivotTableEngine.js index 909058cf0..cdcc8257c 100644 --- a/src/modules/pivotTable/PivotTableEngine.js +++ b/src/modules/pivotTable/PivotTableEngine.js @@ -6,7 +6,10 @@ import { DIMENSION_TYPE_ORGANISATION_UNIT, DIMENSION_TYPE_PERIOD, } from '../dataTypes.js' -import { DIMENSION_ID_ORGUNIT } from '../predefinedDimensions.js' +import { + DIMENSION_ID_ENROLLMENT_ORGUNIT, + DIMENSION_ID_ORGUNIT, +} from '../predefinedDimensions.js' import { renderValue } from '../renderValue.js' import { VALUE_TYPE_NUMBER, @@ -99,6 +102,19 @@ const listByDimension = (list) => return all }, {}) +const ORGUNIT_DIMENSION_IDS = new Set([ + DIMENSION_ID_ORGUNIT, + DIMENSION_ID_ENROLLMENT_ORGUNIT, +]) + +/* Event and enrollment analytics qualify the event org unit dimension with + * the program stage (`.ou`) and name the enrollment-scoped one + * `enrollmentou`. Neither carries `dimensionType` in `metaData.items`, so + * match on the unqualified dimension id where the type is unavailable. */ +const isOrgUnitDimension = ({ dimension, meta }) => + meta?.dimensionType === DIMENSION_TYPE_ORGANISATION_UNIT || + ORGUNIT_DIMENSION_IDS.has(dimension.split('.').pop()) + const sortByHierarchy = (items) => { items.sort((a, b) => { if (!a.hierarchy || !b.hierarchy) { @@ -108,6 +124,17 @@ const sortByHierarchy = (items) => { }) } +const applyHierarchy = (ouDimension, ouNameHierarchy) => { + ouDimension.items.forEach((ou) => { + const hierarchy = ouNameHierarchy[ou.uid] + if (hierarchy) { + ou.hierarchy = hierarchy.split('/').filter((x) => x.length) + } + }) + sortByHierarchy(ouDimension.items) + ouDimension.itemIds = ouDimension.items.map((item) => item.uid) +} + const buildDimensionLookup = (visualization, metadata, headers) => { const rows = visualization.rows.map((row) => ({ dimension: row.dimension, @@ -165,21 +192,12 @@ const buildDimensionLookup = (visualization, metadata, headers) => { return out }, {}) - const ouDimension = allByDimension[DIMENSION_ID_ORGUNIT] - - if ( - visualization.showHierarchy && - metadata.ouNameHierarchy && - ouDimension - ) { - ouDimension.items.forEach((ou) => { - const hierarchy = metadata.ouNameHierarchy[ou.uid] - if (hierarchy) { - ou.hierarchy = hierarchy.split('/').filter((x) => x.length) - } - }) - sortByHierarchy(ouDimension.items) - ouDimension.itemIds = ouDimension.items.map((item) => item.uid) + if (visualization.showHierarchy && metadata.ouNameHierarchy) { + Object.values(allByDimension) + .filter(isOrgUnitDimension) + .forEach((ouDimension) => + applyHierarchy(ouDimension, metadata.ouNameHierarchy) + ) } return { diff --git a/src/modules/pivotTable/__tests__/pivotTableEngineHierarchy.spec.js b/src/modules/pivotTable/__tests__/pivotTableEngineHierarchy.spec.js new file mode 100644 index 000000000..56b5e8b3b --- /dev/null +++ b/src/modules/pivotTable/__tests__/pivotTableEngineHierarchy.spec.js @@ -0,0 +1,141 @@ +import { PivotTableEngine } from '../PivotTableEngine.js' + +/* The engine measures every cell against a canvas 2d context, which jsdom + * does not implement. */ +jest.mock('../measureText.js', () => ({ + measureTextWithWrapping: () => ({ width: 100, height: 20 }), +})) + +const OU_A = 'ouA' +const OU_B = 'ouB' +const STAGE = 'Zj7UnCAulEk' + +/* Bo sorts after Bombali alphabetically but before it in hierarchy order, + * so the two orderings disagree and the test can tell them apart. */ +const OU_NAME_HIERARCHY = { + [OU_A]: '/Sierra Leone/Western Area/Bo', + [OU_B]: '/Sierra Leone/Northern Province/Bombali', +} + +const buildData = (ouDimensionId) => ({ + headers: [ + { name: ouDimensionId, meta: true }, + { name: 'value', meta: false }, + ], + metaData: { + items: { + [ouDimensionId]: { name: 'Organisation unit' }, + [OU_A]: { uid: OU_A, name: 'Bo' }, + [OU_B]: { uid: OU_B, name: 'Bombali' }, + }, + dimensions: { [ouDimensionId]: [OU_A, OU_B] }, + ouNameHierarchy: OU_NAME_HIERARCHY, + }, + rows: [ + [OU_A, '1'], + [OU_B, '2'], + ], + height: 2, + width: 2, +}) + +const buildVisualization = (ouDimensionId) => ({ + showHierarchy: true, + rows: [{ dimension: ouDimensionId }], + columns: [], + filters: [], +}) + +const rowHierarchies = (engine) => + [0, 1].map((row) => engine.getRowHeader(row)[0].hierarchy) + +const rowNames = (engine) => + [0, 1].map((row) => engine.getRowHeader(row)[0].name) + +const cellValues = (engine) => + [0, 1].map((row) => engine.get({ row, column: 0 })?.renderedValue) + +describe('PivotTableEngine org unit hierarchy', () => { + it('applies the hierarchy to a bare `ou` dimension', () => { + const engine = new PivotTableEngine( + buildVisualization('ou'), + buildData('ou') + ) + + expect(rowHierarchies(engine)).toEqual([ + ['Sierra Leone', 'Northern Province', 'Bombali'], + ['Sierra Leone', 'Western Area', 'Bo'], + ]) + }) + + it('applies the hierarchy to a stage-qualified `ou` dimension', () => { + const dimension = `${STAGE}.ou` + const engine = new PivotTableEngine( + buildVisualization(dimension), + buildData(dimension) + ) + + expect(rowHierarchies(engine)).toEqual([ + ['Sierra Leone', 'Northern Province', 'Bombali'], + ['Sierra Leone', 'Western Area', 'Bo'], + ]) + }) + + it('applies the hierarchy to an `enrollmentou` dimension', () => { + const engine = new PivotTableEngine( + buildVisualization('enrollmentou'), + buildData('enrollmentou') + ) + + expect(rowHierarchies(engine)).toEqual([ + ['Sierra Leone', 'Northern Province', 'Bombali'], + ['Sierra Leone', 'Western Area', 'Bo'], + ]) + }) + + it('leaves items untouched when showHierarchy is off', () => { + const engine = new PivotTableEngine( + { ...buildVisualization(`${STAGE}.ou`), showHierarchy: false }, + buildData(`${STAGE}.ou`) + ) + + expect(rowHierarchies(engine)).toEqual([undefined, undefined]) + expect(engine.getRowHeader(0)[0].uid).toBe(OU_A) + }) + + /* Sorting rewrites itemIds, which the row lookup resolves data rows + * through. If the two fall out of step every cell renders empty. */ + it('keeps values aligned with the re-sorted rows', () => { + const engine = new PivotTableEngine( + buildVisualization(`${STAGE}.ou`), + buildData(`${STAGE}.ou`) + ) + + expect(rowNames(engine)).toEqual(['Bombali', 'Bo']) + expect(cellValues(engine)).toEqual(['2', '1']) + }) + + /* A backend that returns no hierarchy for the requested dimension still + * sends the key, as an empty object, which passes the truthiness guard. */ + it('is inert when ouNameHierarchy is empty', () => { + const dimension = `${STAGE}.ou` + const data = buildData(dimension) + data.metaData.ouNameHierarchy = {} + + const engine = new PivotTableEngine(buildVisualization(dimension), data) + + expect(rowNames(engine)).toEqual(['Bo', 'Bombali']) + expect(rowHierarchies(engine)).toEqual([undefined, undefined]) + expect(cellValues(engine)).toEqual(['1', '2']) + }) + + it('leaves non-org-unit dimensions untouched', () => { + const dimension = `${STAGE}.de1` + const engine = new PivotTableEngine( + buildVisualization(dimension), + buildData(dimension) + ) + + expect(rowHierarchies(engine)).toEqual([undefined, undefined]) + }) +}) diff --git a/src/modules/predefinedDimensions.js b/src/modules/predefinedDimensions.js index 330f35ad2..f42352cd2 100644 --- a/src/modules/predefinedDimensions.js +++ b/src/modules/predefinedDimensions.js @@ -9,6 +9,7 @@ import i18n from '../locales/index.js' export const DIMENSION_ID_DATA = 'dx' export const DIMENSION_ID_PERIOD = 'pe' export const DIMENSION_ID_ORGUNIT = 'ou' +export const DIMENSION_ID_ENROLLMENT_ORGUNIT = 'enrollmentou' export const DIMENSION_ID_ASSIGNED_CATEGORIES = 'co' export const DIMENSION_PROP_NO_ITEMS = 'noItems'