Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/api/analytics/AnalyticsEnrollments.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/api/analytics/AnalyticsEvents.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
}

/**
Expand Down
30 changes: 27 additions & 3 deletions src/api/analytics/__tests__/AnalyticsEnrollments.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,18 +36,42 @@ 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 },
})
)
})

it('should be a function', () => {
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()', () => {
Expand Down
30 changes: 27 additions & 3 deletions src/api/analytics/__tests__/AnalyticsEvents.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,18 +36,42 @@ 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 },
})
)
})

it('should be a function', () => {
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()', () => {
Expand Down
50 changes: 34 additions & 16 deletions src/modules/pivotTable/PivotTableEngine.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (`<stageId>.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) {
Expand All @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
141 changes: 141 additions & 0 deletions src/modules/pivotTable/__tests__/pivotTableEngineHierarchy.spec.js
Original file line number Diff line number Diff line change
@@ -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])
})
})
1 change: 1 addition & 0 deletions src/modules/predefinedDimensions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
Loading