From a34a3b7951e8718816ca255063676636bafba008 Mon Sep 17 00:00:00 2001 From: Kostas Karvounis Date: Tue, 4 Oct 2022 10:52:14 +1100 Subject: [PATCH 01/12] [no-issue]: Convert web-config-server tests to jest (#4194) --- jest.config-js.json | 2 +- packages/central-server/package.json | 1 + packages/database/.babelrc.js | 10 +- packages/database/jest.config.js | 3 + packages/database/package.json | 2 - packages/lesmis-server/.gitignore | 8 - packages/psss-server/.gitignore | 8 - packages/report-server/.gitignore | 8 - packages/web-config-server/.babelrc | 7 +- .../web-config-server/.vscode/launch.json | 2 +- packages/web-config-server/jest.config.js | 20 ++ packages/web-config-server/jest.setup.js | 12 + packages/web-config-server/package.json | 12 +- .../buildAggregationOptions.test.js | 102 +++--- .../generic/analytics/analytics.test.js | 43 ++- .../analytics/analyticsPerPeriod.test.js | 59 ++-- .../actualMonthlyValuesVsIdeal.test.js | 8 +- .../generic/compose/composeData.test.js | 51 ++- .../compose/composeDataPerPeriod.test.js | 57 ++- .../composePercentagesPerPeriod.test.js | 108 +++--- .../generic/count/countEvents.test.js | 51 ++- .../countEventsPerPeriodByDataValue.test.js | 46 +-- .../multiDataValuesLatestSurvey.test.js | 12 +- .../generic/orgUnit/basicDataVillage.test.js | 23 +- .../percentagesByNominatedPairs.test.js | 20 +- .../reportServerDataBuilder.test.js | 129 +++++++ .../dataBuilders/generic/sum/sum.test.js | 84 +++++ .../generic/table/simpleTableOfEvents.test.js | 82 ++--- .../table/tableOfDataValues/helpers.js | 54 +-- .../helpers/buildCategoryData.test.js | 4 +- .../tableOfDataValues.fixtures.js | 0 .../tableOfDataValues.test.js | 0 ...ableOfPercentagesOfValueCounts.fixtures.js | 0 .../tableOfPercentagesOfValueCounts.test.js | 1 - .../tableOfValuesForOrgUnits.test.js | 0 .../table/tableOfDataValues/testCategories.js | 32 +- .../tableOfDataValues/testNoCategories.js | 18 +- .../table/tableOfDataValues/testOptions.js | 10 +- .../testOrgUnitCategories.js | 8 +- .../table/tableOfDataValues/testTotals.js | 72 ++-- .../selectUniqueValueFromEvents.test.js | 72 ++++ .../calculateOperationForAnalytics.test.js | 324 +++++++++++++++++ .../helpers/checkAgainstConditions.test.js | 6 +- .../helpers/composeDataByDataClass.test.js | 5 +- .../dataBuilders/helpers/divideValues.test.js | 31 ++ .../eventMetadata/eventMetadata.fixtures.js | 0 .../eventMetadata/eventMetadata.test.js | 16 +- .../eventMetadata/testAddMetadataToEvents.js | 54 +-- .../helpers/fetchComposedData.test.js | 52 ++- .../getCategoryPresentationOption.test.js | 49 +++ .../dataBuilders/helpers/groupEvents.test.js | 186 +++++----- .../helpers/mapMeasureDataToCountries.test.js | 9 +- .../modules/covid-samoa/flight/Flight.test.js | 21 +- .../flight/flightAgeRanges.test.js | 25 +- .../apiV1/dataBuilders/transform.test.js | 48 +-- .../composePercentagePerOrgUnit.test.js | 40 +-- .../groupEventsPerOrgUnit.test.js | 32 +- .../apiV1/utils/getAggregatePeriod.test.js | 19 +- .../apiV1/utils/layerYearOnYear.test.js | 7 +- .../apiV1/utils/mapOrgUnitCodeToGroup.test.js | 4 +- .../utils/mapOrgUnitIdsToGroupIds.test.js | 4 +- .../getDhisApiInstance.test.js | 4 +- .../src/tests/TestableApp.js | 48 --- .../reportServerDataBuilder.test.js | 139 -------- .../dataBuilders/generic/sum/sum.test.js | 87 ----- .../selectUniqueValueFromEvents.test.js | 72 ---- .../calculateOperationForAnalytics.test.js | 326 ------------------ .../dataBuilders/helpers/divideValues.test.js | 33 -- .../getCategoryPresentationOption.test.js | 82 ----- .../src/tests/getTestModels.js | 16 - .../src/tests/permissions.test.js | 149 -------- .../src/tests/scaffolding.test.js | 39 --- .../src/utils/getIsProductionEnvironment.js | 7 - packages/web-config-server/src/utils/index.js | 1 - yarn.lock | 26 +- 75 files changed, 1387 insertions(+), 1815 deletions(-) create mode 100644 packages/web-config-server/jest.config.js create mode 100644 packages/web-config-server/jest.setup.js rename packages/web-config-server/src/{tests => __tests__}/aggregator/buildAggregationOptions.test.js (55%) rename packages/web-config-server/src/{tests => __tests__}/apiV1/dataBuilders/generic/analytics/analytics.test.js (51%) rename packages/web-config-server/src/{tests => __tests__}/apiV1/dataBuilders/generic/analytics/analyticsPerPeriod.test.js (67%) rename packages/web-config-server/src/{tests => __tests__}/apiV1/dataBuilders/generic/comparison/actualMonthlyValuesVsIdeal.test.js (96%) rename packages/web-config-server/src/{tests => __tests__}/apiV1/dataBuilders/generic/compose/composeData.test.js (79%) rename packages/web-config-server/src/{tests => __tests__}/apiV1/dataBuilders/generic/compose/composeDataPerPeriod.test.js (59%) rename packages/web-config-server/src/{tests => __tests__}/apiV1/dataBuilders/generic/compose/composePercentagesPerPeriod.test.js (56%) rename packages/web-config-server/src/{tests => __tests__}/apiV1/dataBuilders/generic/count/countEvents.test.js (50%) rename packages/web-config-server/src/{tests => __tests__}/apiV1/dataBuilders/generic/count/countEventsPerPeriodByDataValue.test.js (57%) rename packages/web-config-server/src/{tests => __tests__}/apiV1/dataBuilders/generic/latestData/multiDataValuesLatestSurvey.test.js (96%) rename packages/web-config-server/src/{tests => __tests__}/apiV1/dataBuilders/generic/orgUnit/basicDataVillage.test.js (77%) rename packages/web-config-server/src/{tests => __tests__}/apiV1/dataBuilders/generic/percentage/percentagesByNominatedPairs.test.js (93%) create mode 100644 packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/reportServer/reportServerDataBuilder.test.js create mode 100644 packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/sum/sum.test.js rename packages/web-config-server/src/{tests => __tests__}/apiV1/dataBuilders/generic/table/simpleTableOfEvents.test.js (51%) rename packages/web-config-server/src/{tests => __tests__}/apiV1/dataBuilders/generic/table/tableOfDataValues/helpers.js (55%) rename packages/web-config-server/src/{tests => __tests__}/apiV1/dataBuilders/generic/table/tableOfDataValues/helpers/buildCategoryData.test.js (94%) rename packages/web-config-server/src/{tests => __tests__}/apiV1/dataBuilders/generic/table/tableOfDataValues/tableOfDataValues.fixtures.js (100%) rename packages/web-config-server/src/{tests => __tests__}/apiV1/dataBuilders/generic/table/tableOfDataValues/tableOfDataValues.test.js (100%) rename packages/web-config-server/src/{tests => __tests__}/apiV1/dataBuilders/generic/table/tableOfDataValues/tableOfPercentagesOfValueCounts.fixtures.js (100%) rename packages/web-config-server/src/{tests => __tests__}/apiV1/dataBuilders/generic/table/tableOfDataValues/tableOfPercentagesOfValueCounts.test.js (99%) rename packages/web-config-server/src/{tests => __tests__}/apiV1/dataBuilders/generic/table/tableOfDataValues/tableOfValuesForOrgUnits.test.js (100%) rename packages/web-config-server/src/{tests => __tests__}/apiV1/dataBuilders/generic/table/tableOfDataValues/testCategories.js (92%) rename packages/web-config-server/src/{tests => __tests__}/apiV1/dataBuilders/generic/table/tableOfDataValues/testNoCategories.js (92%) rename packages/web-config-server/src/{tests => __tests__}/apiV1/dataBuilders/generic/table/tableOfDataValues/testOptions.js (95%) rename packages/web-config-server/src/{tests => __tests__}/apiV1/dataBuilders/generic/table/tableOfDataValues/testOrgUnitCategories.js (96%) rename packages/web-config-server/src/{tests => __tests__}/apiV1/dataBuilders/generic/table/tableOfDataValues/testTotals.js (93%) create mode 100644 packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/unique/selectUniqueValueFromEvents.test.js create mode 100644 packages/web-config-server/src/__tests__/apiV1/dataBuilders/helpers/calculateOperationForAnalytics.test.js rename packages/web-config-server/src/{tests => __tests__}/apiV1/dataBuilders/helpers/checkAgainstConditions.test.js (98%) rename packages/web-config-server/src/{tests => __tests__}/apiV1/dataBuilders/helpers/composeDataByDataClass.test.js (91%) create mode 100644 packages/web-config-server/src/__tests__/apiV1/dataBuilders/helpers/divideValues.test.js rename packages/web-config-server/src/{tests => __tests__}/apiV1/dataBuilders/helpers/eventMetadata/eventMetadata.fixtures.js (100%) rename packages/web-config-server/src/{tests => __tests__}/apiV1/dataBuilders/helpers/eventMetadata/eventMetadata.test.js (76%) rename packages/web-config-server/src/{tests => __tests__}/apiV1/dataBuilders/helpers/eventMetadata/testAddMetadataToEvents.js (61%) rename packages/web-config-server/src/{tests => __tests__}/apiV1/dataBuilders/helpers/fetchComposedData.test.js (58%) create mode 100644 packages/web-config-server/src/__tests__/apiV1/dataBuilders/helpers/getCategoryPresentationOption.test.js rename packages/web-config-server/src/{tests => __tests__}/apiV1/dataBuilders/helpers/groupEvents.test.js (72%) rename packages/web-config-server/src/{tests => __tests__}/apiV1/dataBuilders/helpers/mapMeasureDataToCountries.test.js (80%) rename packages/web-config-server/src/{tests => __tests__}/apiV1/dataBuilders/modules/covid-samoa/flight/Flight.test.js (60%) rename packages/web-config-server/src/{tests => __tests__}/apiV1/dataBuilders/modules/covid-samoa/flight/flightAgeRanges.test.js (72%) rename packages/web-config-server/src/{tests => __tests__}/apiV1/dataBuilders/transform.test.js (59%) rename packages/web-config-server/src/{tests => __tests__}/apiV1/measureBuilders/composePercentagePerOrgUnit.test.js (77%) rename packages/web-config-server/src/{tests => __tests__}/apiV1/measureBuilders/groupEventsPerOrgUnit.test.js (84%) rename packages/web-config-server/src/{tests => __tests__}/apiV1/utils/getAggregatePeriod.test.js (73%) rename packages/web-config-server/src/{tests => __tests__}/apiV1/utils/layerYearOnYear.test.js (88%) rename packages/web-config-server/src/{tests => __tests__}/apiV1/utils/mapOrgUnitCodeToGroup.test.js (94%) rename packages/web-config-server/src/{tests => __tests__}/apiV1/utils/mapOrgUnitIdsToGroupIds.test.js (93%) rename packages/web-config-server/src/{tests => __tests__}/getDhisApiInstance.test.js (78%) delete mode 100644 packages/web-config-server/src/tests/TestableApp.js delete mode 100644 packages/web-config-server/src/tests/apiV1/dataBuilders/generic/reportServer/reportServerDataBuilder.test.js delete mode 100644 packages/web-config-server/src/tests/apiV1/dataBuilders/generic/sum/sum.test.js delete mode 100644 packages/web-config-server/src/tests/apiV1/dataBuilders/generic/unique/selectUniqueValueFromEvents.test.js delete mode 100644 packages/web-config-server/src/tests/apiV1/dataBuilders/helpers/calculateOperationForAnalytics.test.js delete mode 100644 packages/web-config-server/src/tests/apiV1/dataBuilders/helpers/divideValues.test.js delete mode 100644 packages/web-config-server/src/tests/apiV1/dataBuilders/helpers/getCategoryPresentationOption.test.js delete mode 100644 packages/web-config-server/src/tests/getTestModels.js delete mode 100644 packages/web-config-server/src/tests/permissions.test.js delete mode 100644 packages/web-config-server/src/tests/scaffolding.test.js delete mode 100644 packages/web-config-server/src/utils/getIsProductionEnvironment.js diff --git a/jest.config-js.json b/jest.config-js.json index 7c5bdd42d7..5b51fb21a4 100644 --- a/jest.config-js.json +++ b/jest.config-js.json @@ -1,6 +1,6 @@ { "clearMocks": true, - "collectCoverageFrom": ["/src/**/*.js)"], + "collectCoverageFrom": ["/src/**/*.js"], "coveragePathIgnorePatterns": ["__tests__"], "testMatch": ["/src/__tests__/**/**.test.js"], "setupFilesAfterEnv": ["../../jest.setup.js"], diff --git a/packages/central-server/package.json b/packages/central-server/package.json index 99f9fb06d7..ecfea32edb 100644 --- a/packages/central-server/package.json +++ b/packages/central-server/package.json @@ -77,6 +77,7 @@ "mocha": "^8.1.3", "npm-run-all": "^4.1.5", "nyc": "^15.1.0", + "sinon-chai": "^3.3.0", "sinon-test": "^3.0.0", "supertest": "^3.1.0" } diff --git a/packages/database/.babelrc.js b/packages/database/.babelrc.js index 762105f1e1..65cfd4a47d 100644 --- a/packages/database/.babelrc.js +++ b/packages/database/.babelrc.js @@ -1,10 +1,3 @@ -const getPlugins = api => { - if (api.env(['development', 'test'])) { - return ['istanbul']; - } - return []; -}; - const includedDateOffset = 90 * 24 * 60 * 60 * 1000; // include migrations up to 90 days old const includedMigrationsDate = new Date().setTime(Date.now() - includedDateOffset); const checkMigrationOutdated = function (migrationName) { @@ -46,7 +39,6 @@ const getIgnore = api => { }; module.exports = function (api) { - const plugins = getPlugins(api); const ignore = getIgnore(api); - return { plugins, ignore }; + return { ignore }; }; diff --git a/packages/database/jest.config.js b/packages/database/jest.config.js index 2573bf8129..3b58bbcdec 100644 --- a/packages/database/jest.config.js +++ b/packages/database/jest.config.js @@ -1,7 +1,10 @@ const baseConfig = require('../../jest.config-js.json'); +const { coveragePathIgnorePatterns = [] } = baseConfig; + module.exports = async () => ({ ...baseConfig, rootDir: '.', + coveragePathIgnorePatterns: [...coveragePathIgnorePatterns, '/src/migrations'], setupFilesAfterEnv: ['../../jest.setup.js', './jest.setup.js'], }); diff --git a/packages/database/package.json b/packages/database/package.json index 5ce0280884..76f6d1071e 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -51,9 +51,7 @@ }, "devDependencies": { "@babel/node": "^7.10.5", - "cross-env": "^7.0.2", "npm-run-all": "^4.1.5", - "nyc": "^15.1.0", "pluralize": "^8.0.0" } } diff --git a/packages/lesmis-server/.gitignore b/packages/lesmis-server/.gitignore index e306a0fa4b..7c1ec402e1 100644 --- a/packages/lesmis-server/.gitignore +++ b/packages/lesmis-server/.gitignore @@ -13,7 +13,6 @@ aws-access-keys.js # Icon must end with two \r Icon - # Thumbnails ._* @@ -33,7 +32,6 @@ Network Trash Folder Temporary Items .apdisk - ### https://raw.github.com/github/gitignore/49d13cdba39774f7fa224ef13f4a1153200e2710/Node.gitignore # Logs @@ -53,9 +51,6 @@ lib-cov # Coverage directory used by tools like istanbul coverage -# nyc test coverage -.nyc_output - # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) .grunt @@ -83,6 +78,3 @@ jspm_packages # Yarn Integrity file .yarn-integrity - - - diff --git a/packages/psss-server/.gitignore b/packages/psss-server/.gitignore index e306a0fa4b..7c1ec402e1 100644 --- a/packages/psss-server/.gitignore +++ b/packages/psss-server/.gitignore @@ -13,7 +13,6 @@ aws-access-keys.js # Icon must end with two \r Icon - # Thumbnails ._* @@ -33,7 +32,6 @@ Network Trash Folder Temporary Items .apdisk - ### https://raw.github.com/github/gitignore/49d13cdba39774f7fa224ef13f4a1153200e2710/Node.gitignore # Logs @@ -53,9 +51,6 @@ lib-cov # Coverage directory used by tools like istanbul coverage -# nyc test coverage -.nyc_output - # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) .grunt @@ -83,6 +78,3 @@ jspm_packages # Yarn Integrity file .yarn-integrity - - - diff --git a/packages/report-server/.gitignore b/packages/report-server/.gitignore index e306a0fa4b..7c1ec402e1 100644 --- a/packages/report-server/.gitignore +++ b/packages/report-server/.gitignore @@ -13,7 +13,6 @@ aws-access-keys.js # Icon must end with two \r Icon - # Thumbnails ._* @@ -33,7 +32,6 @@ Network Trash Folder Temporary Items .apdisk - ### https://raw.github.com/github/gitignore/49d13cdba39774f7fa224ef13f4a1153200e2710/Node.gitignore # Logs @@ -53,9 +51,6 @@ lib-cov # Coverage directory used by tools like istanbul coverage -# nyc test coverage -.nyc_output - # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) .grunt @@ -83,6 +78,3 @@ jspm_packages # Yarn Integrity file .yarn-integrity - - - diff --git a/packages/web-config-server/.babelrc b/packages/web-config-server/.babelrc index c7422e6e54..a8ae01972d 100644 --- a/packages/web-config-server/.babelrc +++ b/packages/web-config-server/.babelrc @@ -9,10 +9,5 @@ } } ] - ], - "env": { - "test": { - "plugins": ["istanbul"] - } - } + ] } diff --git a/packages/web-config-server/.vscode/launch.json b/packages/web-config-server/.vscode/launch.json index c161e82da7..53b2c00eaf 100644 --- a/packages/web-config-server/.vscode/launch.json +++ b/packages/web-config-server/.vscode/launch.json @@ -19,7 +19,7 @@ "program": "${workspaceRoot}/node_modules/mocha/bin/_mocha", "stopOnEntry": false, "args": [ - "./src/tests/dataBuilderPercentagesByNominatedPair.test.js", + "./src/__tests__/dataBuilderPercentagesByNominatedPair.test.js", "--require babel-register", "--require @babel/polyfill", "--timeout 100000", diff --git a/packages/web-config-server/jest.config.js b/packages/web-config-server/jest.config.js new file mode 100644 index 0000000000..1e8cc9e190 --- /dev/null +++ b/packages/web-config-server/jest.config.js @@ -0,0 +1,20 @@ +const baseConfig = require('../../jest.config-js.json'); + +module.exports = async () => ({ + ...baseConfig, + rootDir: '.', + moduleNameMapper: { + '^/aggregator(.*)$': '/src/aggregator$1', + '^/*apiV1(.*)$': '/src/apiV1$1', + '^/app(.*)$': '/src/app$1', + '^/appServer(.*)$': '/src/appServer$1', + '^/authSession(.*)$': '/src/authSession$1', + '^/connections(.*)$': '/src/connections$1', + '^/dhis(.*)$': '/src/dhis$1', + '^/export(.*)$': '/src/export$1', + '^/models(.*)$': '/src/models$1', + '^/preaggregation(.*)$': '/src/preaggregation$1', + '^/utils(.*)$': '/src/utils$1', + }, + setupFilesAfterEnv: ['../../jest.setup.js', './jest.setup.js'], +}); diff --git a/packages/web-config-server/jest.setup.js b/packages/web-config-server/jest.setup.js new file mode 100644 index 0000000000..e80049e612 --- /dev/null +++ b/packages/web-config-server/jest.setup.js @@ -0,0 +1,12 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2022 Beyond Essential Systems Pty Ltd + */ + +import { clearTestData, getTestDatabase } from '@tupaia/database'; + +afterAll(async () => { + const database = getTestDatabase(); + await clearTestData(database); + await database.closeConnections(); +}); diff --git a/packages/web-config-server/package.json b/packages/web-config-server/package.json index 2d1a4c4f95..1d432349ce 100755 --- a/packages/web-config-server/package.json +++ b/packages/web-config-server/package.json @@ -17,8 +17,8 @@ "start": "node dist", "start-dev": "yarn package:start:backend-start-dev 9997", "start-verbose": "LOG_LEVEL=debug yarn start-dev", - "test": "yarn workspace @tupaia/database check-test-database-exists && DB_NAME=tupaia_test mocha", - "test:coverage": "cross-env NODE_ENV=test nyc mocha" + "test": "yarn package:test:withdb --runInBand", + "test:coverage": "yarn test --coverage" }, "dependencies": { "@tupaia/access-policy": "3.0.0", @@ -69,12 +69,6 @@ }, "devDependencies": { "babel-plugin-module-resolver": "^4.0.0", - "chai": "^4.1.2", - "chai-like": "^1.1.1", - "cross-env": "^7.0.2", - "csvtojson": "^1.1.5", - "mocha": "^8.1.3", - "nyc": "^15.1.0", - "sinon-chai": "^3.3.0" + "csvtojson": "^1.1.5" } } diff --git a/packages/web-config-server/src/tests/aggregator/buildAggregationOptions.test.js b/packages/web-config-server/src/__tests__/aggregator/buildAggregationOptions.test.js similarity index 55% rename from packages/web-config-server/src/tests/aggregator/buildAggregationOptions.test.js rename to packages/web-config-server/src/__tests__/aggregator/buildAggregationOptions.test.js index b86d5e864f..e1e18cb145 100644 --- a/packages/web-config-server/src/tests/aggregator/buildAggregationOptions.test.js +++ b/packages/web-config-server/src/__tests__/aggregator/buildAggregationOptions.test.js @@ -3,8 +3,8 @@ * Copyright (c) 2019 Beyond Essential Systems Pty Ltd */ -import { expect } from 'chai'; -import sinon from 'sinon'; +import { when } from 'jest-when'; + import { Aggregator } from '@tupaia/aggregator'; import { buildAggregationOptions } from '/aggregator/buildAggregationOptions'; @@ -16,46 +16,46 @@ const BASIC_ENTITY_AGGREGATION_OPTIONS = { }; const BASIC_DATA_SOURCE_ENTITIES = [{ code: 'f1', name: 'Facility 1', type: 'facility' }]; +const mockFetchAncestorDetailsByDescendantCode = jest.fn(); +when(mockFetchAncestorDetailsByDescendantCode) + .calledWith(['f1'], HIERARCHY_ID, 'district') + .mockResolvedValue({ + f1: { + code: 'd9', + name: 'District 9', + }, + }); + const models = { entity: { - fetchAncestorDetailsByDescendantCode: sinon - .stub() - .withArgs(['f1'], HIERARCHY_ID, 'district') - .resolves({ - f1: { - code: 'd9', - name: 'District 9', - }, - }), + fetchAncestorDetailsByDescendantCode: mockFetchAncestorDetailsByDescendantCode, }, }; describe('buildAggregationOptions', () => { it('should build basic aggregation options', async () => { - return expect( - buildAggregationOptions( - models, - BASIC_AGGREGATION_OPTIONS, - BASIC_DATA_SOURCE_ENTITIES, - BASIC_ENTITY_AGGREGATION_OPTIONS, - HIERARCHY_ID, - ), - ).to.eventually.deep.equal({ + const results = await buildAggregationOptions( + models, + BASIC_AGGREGATION_OPTIONS, + BASIC_DATA_SOURCE_ENTITIES, + BASIC_ENTITY_AGGREGATION_OPTIONS, + HIERARCHY_ID, + ); + expect(results).toStrictEqual({ aggregations: [{ type: 'SUM_MOST_RECENT_PER_FACILITY', config: undefined }], filter: {}, }); }); it('should build basic aggregation options and entity aggregation options', async () => { - return expect( - buildAggregationOptions( - models, - BASIC_AGGREGATION_OPTIONS, - BASIC_DATA_SOURCE_ENTITIES, - { ...BASIC_ENTITY_AGGREGATION_OPTIONS, aggregationEntityType: 'district' }, - HIERARCHY_ID, - ), - ).to.eventually.deep.equal({ + const results = await buildAggregationOptions( + models, + BASIC_AGGREGATION_OPTIONS, + BASIC_DATA_SOURCE_ENTITIES, + { ...BASIC_ENTITY_AGGREGATION_OPTIONS, aggregationEntityType: 'district' }, + HIERARCHY_ID, + ); + expect(results).toStrictEqual({ aggregations: [ { type: 'SUM_MOST_RECENT_PER_FACILITY', config: undefined }, { @@ -70,35 +70,33 @@ describe('buildAggregationOptions', () => { }); it('should build basic aggregation options without redundant entity aggregation options', async () => { - return expect( - buildAggregationOptions( - models, - BASIC_AGGREGATION_OPTIONS, - BASIC_DATA_SOURCE_ENTITIES, - { ...BASIC_ENTITY_AGGREGATION_OPTIONS, aggregationEntityType: 'facility' }, - HIERARCHY_ID, - ), - ).to.eventually.deep.equal({ + const results = await buildAggregationOptions( + models, + BASIC_AGGREGATION_OPTIONS, + BASIC_DATA_SOURCE_ENTITIES, + { ...BASIC_ENTITY_AGGREGATION_OPTIONS, aggregationEntityType: 'facility' }, + HIERARCHY_ID, + ); + expect(results).toStrictEqual({ aggregations: [{ type: 'SUM_MOST_RECENT_PER_FACILITY', config: undefined }], filter: {}, }); }); it('should build entity aggregation options before basic aggregation options if configured', async () => { - return expect( - buildAggregationOptions( - models, - BASIC_AGGREGATION_OPTIONS, - BASIC_DATA_SOURCE_ENTITIES, - { - ...BASIC_ENTITY_AGGREGATION_OPTIONS, - dataSourceEntityType: 'facility', - aggregationEntityType: 'district', - aggregationOrder: 'BEFORE', - }, - HIERARCHY_ID, - ), - ).to.eventually.deep.equal({ + const results = await buildAggregationOptions( + models, + BASIC_AGGREGATION_OPTIONS, + BASIC_DATA_SOURCE_ENTITIES, + { + ...BASIC_ENTITY_AGGREGATION_OPTIONS, + dataSourceEntityType: 'facility', + aggregationEntityType: 'district', + aggregationOrder: 'BEFORE', + }, + HIERARCHY_ID, + ); + expect(results).toStrictEqual({ aggregations: [ { type: Aggregator.aggregationTypes.REPLACE_ORG_UNIT_WITH_ORG_GROUP, diff --git a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/analytics/analytics.test.js b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/analytics/analytics.test.js similarity index 51% rename from packages/web-config-server/src/tests/apiV1/dataBuilders/generic/analytics/analytics.test.js rename to packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/analytics/analytics.test.js index 10617c06bd..ba7b728a69 100644 --- a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/analytics/analytics.test.js +++ b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/analytics/analytics.test.js @@ -3,10 +3,7 @@ * Copyright (c) 2017 - 2020 Beyond Essential Systems Pty Ltd */ -import { expect } from 'chai'; -import sinon from 'sinon'; - -import { Aggregator } from '@tupaia/aggregator'; +import { createJestMockInstance } from '@tupaia/utils'; import { analytics } from '/apiV1/dataBuilders/generic/analytics/analytics'; describe('analytics', () => { @@ -15,35 +12,37 @@ describe('analytics', () => { { dataElement: 'TEST02', orgUnit: 'PG', period: '20200101', value: 2 }, ]; - const aggregator = sinon.createStubInstance(Aggregator, { - fetchAnalytics: sinon.stub().resolves({ results: ANALYTICS }), - }); - - beforeEach(() => { - aggregator.fetchAnalytics.resetHistory(); + const aggregator = createJestMockInstance('@tupaia/aggregator', 'Aggregator', { + fetchAnalytics: jest.fn().mockResolvedValue({ results: ANALYTICS }), }); it('fetches analytics using the `dataElementCodes` specified in the config', async () => { const dataElementCodes = ['TEST01', 'TEST02']; await analytics({ dataBuilderConfig: { dataElementCodes } }, aggregator); - expect(aggregator.fetchAnalytics).to.have.been.calledOnceWith(dataElementCodes); + expect(aggregator.fetchAnalytics).toHaveBeenCalledOnceWith( + dataElementCodes, + expect.anything(), + undefined, + expect.anything(), + ); }); it('uses an `aggregationType` if specified in the config', async () => { const aggregationType = 'SUM'; await analytics({ dataBuilderConfig: { dataElementCodes: [], aggregationType } }, aggregator); - expect(aggregator.fetchAnalytics).to.have.been.calledOnceWith( - sinon.match.any, - sinon.match.any, - sinon.match.any, - sinon.match({ aggregationType }), + expect(aggregator.fetchAnalytics).toHaveBeenCalledOnceWith( + expect.anything(), + expect.anything(), + undefined, + expect.objectContaining({ aggregationType }), ); }); - it('returns the fetched analytics', async () => - expect( - analytics({ dataBuilderConfig: { dataElementCodes: ['TEST01', 'TEST02'] } }, aggregator), - ).to.eventually.deep.equal({ - data: ANALYTICS, - })); + it('returns the fetched analytics', async () => { + const response = await analytics( + { dataBuilderConfig: { dataElementCodes: ['TEST01', 'TEST02'] } }, + aggregator, + ); + expect(response).toStrictEqual({ data: ANALYTICS }); + }); }); diff --git a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/analytics/analyticsPerPeriod.test.js b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/analytics/analyticsPerPeriod.test.js similarity index 67% rename from packages/web-config-server/src/tests/apiV1/dataBuilders/generic/analytics/analyticsPerPeriod.test.js rename to packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/analytics/analyticsPerPeriod.test.js index aeb4ecf7d8..7bd9b95544 100644 --- a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/analytics/analyticsPerPeriod.test.js +++ b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/analytics/analyticsPerPeriod.test.js @@ -3,10 +3,7 @@ * Copyright (c) 2017 - 2020 Beyond Essential Systems Pty Ltd */ -import { expect } from 'chai'; -import sinon from 'sinon'; - -import { Aggregator } from '@tupaia/aggregator'; +import { createJestMockInstance } from '@tupaia/utils'; import { analyticsPerPeriod } from '/apiV1/dataBuilders/generic/analytics/analyticsPerPeriod'; describe('analyticsPerPeriod', () => { @@ -25,27 +22,23 @@ describe('analyticsPerPeriod', () => { '2020-01-01T00:00:00Z': 1577836800000, }; - const aggregator = sinon.createStubInstance(Aggregator, { - fetchAnalytics: sinon.stub().callsFake(async dataElementCodes => ({ + const aggregator = createJestMockInstance('@tupaia/aggregator', 'Aggregator', { + fetchAnalytics: jest.fn(async dataElementCodes => ({ results: ANALYTICS.filter(({ dataElement }) => dataElementCodes.includes(dataElement)), })), }); - beforeEach(() => { - aggregator.fetchAnalytics.resetHistory(); - }); - it('uses an `aggregationType` if specified in the config', async () => { const aggregationType = 'SUM'; await analyticsPerPeriod( { dataBuilderConfig: { dataElementCode: 'CASES', aggregationType } }, aggregator, ); - expect(aggregator.fetchAnalytics).to.have.been.calledOnceWith( - sinon.match.any, - sinon.match.any, - sinon.match.any, - sinon.match({ aggregationType }), + expect(aggregator.fetchAnalytics).toHaveBeenCalledOnceWith( + expect.anything(), + expect.anything(), + undefined, + expect.objectContaining({ aggregationType }), ); }); @@ -56,37 +49,51 @@ describe('analyticsPerPeriod', () => { { key: 'population', dataElementCode: 'POP' }, ], }; + it('fetches analytics using data element codes specified in config `series`', async () => { await analyticsPerPeriod({ dataBuilderConfig }, aggregator); - expect(aggregator.fetchAnalytics).to.have.been.calledOnceWith( - sinon.match.array.contains(['CASES', 'POP']).and(sinon.match.has('length', 2)), + expect(aggregator.fetchAnalytics).toHaveBeenCalledOnceWith( + ['CASES', 'POP'], + expect.anything(), + undefined, + expect.anything(), ); }); - it('returns timestamped results using the specified series, sorted by timestamp', async () => - expect(analyticsPerPeriod({ dataBuilderConfig }, aggregator)).to.eventually.deep.equal({ + it('returns timestamped results using the specified series, sorted by timestamp', async () => { + const response = await analyticsPerPeriod({ dataBuilderConfig }, aggregator); + expect(response).toStrictEqual({ data: [ { timestamp: TIMESTAMPS['2019-01-01T00:00:00Z'], cases: 10, population: 100 }, { timestamp: TIMESTAMPS['2020-01-01T00:00:00Z'], cases: 11, population: 110 }, ], - })); + }); + }); }); describe('non-series config', () => { it('fetches analytics using the `dataElementCode` specified in the config', async () => { const dataElementCode = 'CASES'; await analyticsPerPeriod({ dataBuilderConfig: { dataElementCode } }, aggregator); - expect(aggregator.fetchAnalytics).to.have.been.calledOnceWith([dataElementCode]); + expect(aggregator.fetchAnalytics).toHaveBeenCalledOnceWith( + [dataElementCode], + expect.anything(), + undefined, + expect.anything(), + ); }); - it('returns timestamped results using the default series key, sorted by timestamp', async () => - expect( - analyticsPerPeriod({ dataBuilderConfig: { dataElementCode: 'CASES' } }, aggregator), - ).to.eventually.deep.equal({ + it('returns timestamped results using the default series key, sorted by timestamp', async () => { + const response = await analyticsPerPeriod( + { dataBuilderConfig: { dataElementCode: 'CASES' } }, + aggregator, + ); + expect(response).toStrictEqual({ data: [ { timestamp: TIMESTAMPS['2019-01-01T00:00:00Z'], value: 10 }, { timestamp: TIMESTAMPS['2020-01-01T00:00:00Z'], value: 11 }, ], - })); + }); + }); }); }); diff --git a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/comparison/actualMonthlyValuesVsIdeal.test.js b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/comparison/actualMonthlyValuesVsIdeal.test.js similarity index 96% rename from packages/web-config-server/src/tests/apiV1/dataBuilders/generic/comparison/actualMonthlyValuesVsIdeal.test.js rename to packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/comparison/actualMonthlyValuesVsIdeal.test.js index 6922a9c335..e8832cfdc6 100644 --- a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/comparison/actualMonthlyValuesVsIdeal.test.js +++ b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/comparison/actualMonthlyValuesVsIdeal.test.js @@ -3,8 +3,6 @@ * Copyright (c) 2018 Beyond Essential Systems Pty Ltd */ -import { expect } from 'chai'; - import { actualMonthlyValuesVsIdeal } from '/apiV1/dataBuilders/generic/comparison/actualMonthlyValuesVsIdeal'; import { NO_DATA_AVAILABLE } from '/apiV1/dataBuilders/constants'; @@ -165,7 +163,7 @@ describe('actualMonthlyValuesVsIdeal', () => { dhisApiMockup, ); const keys = result.columns.map(({ key }) => key); - expect(keys).to.deep.equal(['ideal', 20181113, 20171113, 20131113]); + expect(keys).toStrictEqual(['ideal', 20181113, 20171113, 20131113]); }); it('should have Sclerotherapy needle be 80 for 20131113', async () => { @@ -177,7 +175,7 @@ describe('actualMonthlyValuesVsIdeal', () => { aggregatorMockup, dhisApiMockup, ); - expect(result.rows[0]['20131113']).to.equal(80); + expect(result.rows[0]['20131113']).toBe(80); }); it("should have balloon dilators be 'No data' for 20131113", async () => { @@ -189,7 +187,7 @@ describe('actualMonthlyValuesVsIdeal', () => { aggregatorMockup, dhisApiMockup, ); - expect(result.rows.find(row => row.dataElement === 'Balloon dilators')['20131113']).to.equal( + expect(result.rows.find(row => row.dataElement === 'Balloon dilators')['20131113']).toBe( NO_DATA_AVAILABLE, ); }); diff --git a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/compose/composeData.test.js b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/compose/composeData.test.js similarity index 79% rename from packages/web-config-server/src/tests/apiV1/dataBuilders/generic/compose/composeData.test.js rename to packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/compose/composeData.test.js index d82573750d..1233435833 100644 --- a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/compose/composeData.test.js +++ b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/compose/composeData.test.js @@ -1,27 +1,20 @@ /** - * Tupaia Config Server - * Copyright (c) 2019 Beyond Essential Systems Pty Ltd + * Tupaia + * Copyright (c) 2017 - 2022 Beyond Essential Systems Pty Ltd */ -import { expect } from 'chai'; -import sinon from 'sinon'; - +import { createJestMockInstance } from '@tupaia/utils'; import * as FetchComposedData from '/apiV1/dataBuilders/helpers/fetchComposedData'; import { composeData } from '/apiV1/dataBuilders/generic/compose'; -const aggregator = {}; -const dhisApi = {}; +const aggregator = createJestMockInstance('@tupaia/aggregator', 'Aggregator'); +const dhisApi = createJestMockInstance('@tupaia/dhis-api', 'DhisApi'); const stubFetchComposedData = expectedResults => { - const fetchComposedDataStub = sinon.stub(FetchComposedData, 'fetchComposedData'); - fetchComposedDataStub.returns(expectedResults); + jest.spyOn(FetchComposedData, 'fetchComposedData').mockResolvedValue(expectedResults); }; describe('composeData', () => { - afterEach(() => { - FetchComposedData.fetchComposedData.restore(); - }); - it('should compose data', async () => { const testConfig = { dataBuilderConfig: { @@ -44,13 +37,13 @@ describe('composeData', () => { series2: { data: data2 }, }); - return expect(composeData(testConfig, aggregator, dhisApi)).to.eventually.have.deep.property( - 'data', - [ + const response = await composeData(testConfig, aggregator, dhisApi); + expect(response).toStrictEqual({ + data: [ { name: 'series1', 'Name 1': 1, 'Name 2': 0.4 }, { name: 'series2', 'Name 1': 'No Data', 'Name 2': 4 }, ], - ); + }); }); it('should compose data from one datapoint', async () => { @@ -69,13 +62,13 @@ describe('composeData', () => { series2: { data: data2 }, }); - return expect(composeData(testConfig, aggregator, dhisApi)).to.eventually.have.deep.property( - 'data', - [ + const response = await composeData(testConfig, aggregator, dhisApi); + expect(response).toStrictEqual({ + data: [ { name: 'series1', 'Name 1': 1 }, { name: 'series2', 'Name 1': 4 }, ], - ); + }); }); it('should compose data from more than 2 sources', async () => { @@ -109,14 +102,14 @@ describe('composeData', () => { series3: { data: data3 }, }); - return expect(composeData(testConfig, aggregator, dhisApi)).to.eventually.have.deep.property( - 'data', - [ + const response = await composeData(testConfig, aggregator, dhisApi); + expect(response).toStrictEqual({ + data: [ { name: 'series1', 'Name 1': 1, 'Name 2': 2, 'Name 3': 3 }, { name: 'series2', 'Name 1': 4, 'Name 2': 5, 'Name 3': 6 }, { name: 'series3', 'Name 1': 7, 'Name 2': 8, 'Name 3': 9 }, ], - ); + }); }); it('should return according to sortOrder', async () => { @@ -150,13 +143,13 @@ describe('composeData', () => { series3: { data: data3 }, }); - return expect(composeData(testConfig, aggregator, dhisApi)).to.eventually.have.deep.property( - 'data', - [ + const response = await composeData(testConfig, aggregator, dhisApi); + expect(response).toStrictEqual({ + data: [ { name: 'series3', 'Name 1': 7, 'Name 2': 8, 'Name 3': 9 }, { name: 'series1', 'Name 1': 1, 'Name 2': 2, 'Name 3': 3 }, { name: 'series2', 'Name 1': 4, 'Name 2': 5, 'Name 3': 6 }, ], - ); + }); }); }); diff --git a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/compose/composeDataPerPeriod.test.js b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/compose/composeDataPerPeriod.test.js similarity index 59% rename from packages/web-config-server/src/tests/apiV1/dataBuilders/generic/compose/composeDataPerPeriod.test.js rename to packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/compose/composeDataPerPeriod.test.js index 56a8c233e7..9acfb51705 100644 --- a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/compose/composeDataPerPeriod.test.js +++ b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/compose/composeDataPerPeriod.test.js @@ -3,26 +3,24 @@ * Copyright (c) 2019 Beyond Essential Systems Pty Ltd */ -import { expect } from 'chai'; -import sinon from 'sinon'; +import { when } from 'jest-when'; +import { createJestMockInstance } from '@tupaia/utils'; import { composeDataPerPeriod } from '/apiV1/dataBuilders/generic/compose/composeDataPerPeriod'; import * as FetchComposedData from '/apiV1/dataBuilders/helpers/fetchComposedData'; -const aggregator = {}; -const dhisApi = {}; -const config = {}; +const aggregator = createJestMockInstance('@tupaia/aggregator', 'Aggregator'); +const dhisApi = createJestMockInstance('@tupaia/dhis-api', 'DhisApi'); +const config = { dataBuilderConfig: {} }; const stubFetchComposedData = expectedResults => { - const fetchComposedDataStub = sinon.stub(FetchComposedData, 'fetchComposedData'); - fetchComposedDataStub.returns({}).withArgs(config, aggregator, dhisApi).returns(expectedResults); + const fetchComposedData = jest.spyOn(FetchComposedData, 'fetchComposedData'); + when(fetchComposedData) + .calledWith(config, aggregator, dhisApi) + .mockResolvedValue(expectedResults); }; describe('composeDataPerPeriod', () => { - afterEach(() => { - FetchComposedData.fetchComposedData.restore(); - }); - it('should throw an error if non period data are fetched', async () => { const data = [ { timestamp: 1569888000000, name: 'Oct 2019', count: 1 }, @@ -32,7 +30,7 @@ describe('composeDataPerPeriod', () => { results: { data }, }); - return expect(composeDataPerPeriod(config, aggregator, dhisApi)).to.be.rejectedWith( + await expect(composeDataPerPeriod(config, aggregator, dhisApi)).toBeRejectedWith( 'composed of period data builders', ); }); @@ -45,9 +43,8 @@ describe('composeDataPerPeriod', () => { ]; stubFetchComposedData({ results: { data } }); - return expect( - composeDataPerPeriod(config, aggregator, dhisApi), - ).to.eventually.have.deep.property('data', data); + const response = await composeDataPerPeriod(config, aggregator, dhisApi); + expect(response).toStrictEqual({ data }); }); it('should compose period data from multiple data builders', async () => { @@ -66,13 +63,14 @@ describe('composeDataPerPeriod', () => { percentage: { data: percentageData }, }); - return expect( - composeDataPerPeriod(config, aggregator, dhisApi), - ).to.eventually.have.deep.property('data', [ - { timestamp: 1567296000000, name: 'Sep 2019', count: 0, percentage: 0 }, - { timestamp: 1569888000000, name: 'Oct 2019', count: 1, percentage: 0.1 }, - { timestamp: 1572566400000, name: 'Nov 2019', count: 2, percentage: 0.2 }, - ]); + const response = await composeDataPerPeriod(config, aggregator, dhisApi); + expect(response).toStrictEqual({ + data: [ + { timestamp: 1567296000000, name: 'Sep 2019', count: 0, percentage: 0 }, + { timestamp: 1569888000000, name: 'Oct 2019', count: 1, percentage: 0.1 }, + { timestamp: 1572566400000, name: 'Nov 2019', count: 2, percentage: 0.2 }, + ], + }); }); it('should replace "value" keys in the response items with a corresponding key from the builder config', async () => { @@ -83,12 +81,13 @@ describe('composeDataPerPeriod', () => { ]; stubFetchComposedData({ results: { data } }); - return expect( - composeDataPerPeriod(config, aggregator, dhisApi), - ).to.eventually.have.deep.property('data', [ - { timestamp: 1567296000000, name: 'Sep 2019', results: 0 }, - { timestamp: 1569888000000, name: 'Oct 2019', results: 10 }, - { timestamp: 1572566400000, name: 'Nov 2019', results: 20 }, - ]); + const response = await composeDataPerPeriod(config, aggregator, dhisApi); + expect(response).toStrictEqual({ + data: [ + { timestamp: 1567296000000, name: 'Sep 2019', results: 0 }, + { timestamp: 1569888000000, name: 'Oct 2019', results: 10 }, + { timestamp: 1572566400000, name: 'Nov 2019', results: 20 }, + ], + }); }); }); diff --git a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/compose/composePercentagesPerPeriod.test.js b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/compose/composePercentagesPerPeriod.test.js similarity index 56% rename from packages/web-config-server/src/tests/apiV1/dataBuilders/generic/compose/composePercentagesPerPeriod.test.js rename to packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/compose/composePercentagesPerPeriod.test.js index c08d1494ec..d5e6035f33 100644 --- a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/compose/composePercentagesPerPeriod.test.js +++ b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/compose/composePercentagesPerPeriod.test.js @@ -1,21 +1,10 @@ -import { expect } from 'chai'; -import sinon from 'sinon'; - import { composePercentagesPerPeriod } from '/apiV1/dataBuilders'; import * as ComposeDataPerPeriod from '/apiV1/dataBuilders/generic/compose/composeDataPerPeriod'; const stubComposeDataPerPeriod = expectedData => - sinon.stub(ComposeDataPerPeriod, 'composeDataPerPeriod').returns(expectedData); - -const restoreComposeDataPerPeriod = () => { - ComposeDataPerPeriod.composeDataPerPeriod.restore(); -}; + jest.spyOn(ComposeDataPerPeriod, 'composeDataPerPeriod').mockResolvedValue(expectedData); describe('composePercentagesPerPeriod', () => { - afterEach(() => { - restoreComposeDataPerPeriod(); - }); - it('should call composeDataPerPeriod() with the correct arguments', async () => { const composeDataPerPeriodStub = stubComposeDataPerPeriod({ data: [] }); const config = { dataBuilderConfig: { percentages: {} } }; @@ -23,11 +12,7 @@ describe('composePercentagesPerPeriod', () => { const dhisApiStub = {}; await composePercentagesPerPeriod(config, aggregatorStub, dhisApiStub); - expect(composeDataPerPeriodStub).to.have.been.calledOnceWith( - config, - aggregatorStub, - dhisApiStub, - ); + expect(composeDataPerPeriodStub).toHaveBeenCalledOnceWith(config, aggregatorStub, dhisApiStub); }); it('should compose period data for a single percentage definition', async () => { @@ -44,20 +29,23 @@ describe('composePercentagesPerPeriod', () => { ]; stubComposeDataPerPeriod({ data }); - return expect(composePercentagesPerPeriod(config)).to.eventually.have.deep.property('data', [ - { - timestamp: 1569888000000, - name: 'Oct 2019', - result: 0.5, - result_metadata: { numerator: 1, denominator: 2 }, - }, - { - timestamp: 1572566400000, - name: 'Nov 2019', - result: 0.75, - result_metadata: { numerator: 3, denominator: 4 }, - }, - ]); + const response = await composePercentagesPerPeriod(config); + expect(response).toStrictEqual({ + data: [ + { + timestamp: 1569888000000, + name: 'Oct 2019', + result: 0.5, + result_metadata: { numerator: 1, denominator: 2 }, + }, + { + timestamp: 1572566400000, + name: 'Nov 2019', + result: 0.75, + result_metadata: { numerator: 3, denominator: 4 }, + }, + ], + }); }); it('should compose period data for multiple percentage definitions', async () => { @@ -89,24 +77,27 @@ describe('composePercentagesPerPeriod', () => { ]; stubComposeDataPerPeriod({ data }); - return expect(composePercentagesPerPeriod(config)).to.eventually.have.deep.property('data', [ - { - timestamp: 1569888000000, - name: 'Oct 2019', - positivePercentage: 0.5, - positivePercentage_metadata: { numerator: 1, denominator: 2 }, - femalePercentage: 0.25, - femalePercentage_metadata: { numerator: 150, denominator: 600 }, - }, - { - timestamp: 1572566400000, - name: 'Nov 2019', - positivePercentage: 0.75, - positivePercentage_metadata: { numerator: 3, denominator: 4 }, - femalePercentage: 0.5, - femalePercentage_metadata: { numerator: 300, denominator: 600 }, - }, - ]); + const response = await composePercentagesPerPeriod(config); + expect(response).toStrictEqual({ + data: [ + { + timestamp: 1569888000000, + name: 'Oct 2019', + positivePercentage: 0.5, + positivePercentage_metadata: { numerator: 1, denominator: 2 }, + femalePercentage: 0.25, + femalePercentage_metadata: { numerator: 150, denominator: 600 }, + }, + { + timestamp: 1572566400000, + name: 'Nov 2019', + positivePercentage: 0.75, + positivePercentage_metadata: { numerator: 3, denominator: 4 }, + femalePercentage: 0.5, + femalePercentage_metadata: { numerator: 300, denominator: 600 }, + }, + ], + }); }); it('should exclude non numeric percentages from the results', async () => { @@ -123,13 +114,16 @@ describe('composePercentagesPerPeriod', () => { ]; stubComposeDataPerPeriod({ data }); - return expect(composePercentagesPerPeriod(config)).to.eventually.have.deep.property('data', [ - { - timestamp: 1572566400000, - name: 'Nov 2019', - result: 0.75, - result_metadata: { numerator: 3, denominator: 4 }, - }, - ]); + const response = await composePercentagesPerPeriod(config); + expect(response).toStrictEqual({ + data: [ + { + timestamp: 1572566400000, + name: 'Nov 2019', + result: 0.75, + result_metadata: { numerator: 3, denominator: 4 }, + }, + ], + }); }); }); diff --git a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/count/countEvents.test.js b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/count/countEvents.test.js similarity index 50% rename from packages/web-config-server/src/tests/apiV1/dataBuilders/generic/count/countEvents.test.js rename to packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/count/countEvents.test.js index 21ad5f7176..061dc6d106 100644 --- a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/count/countEvents.test.js +++ b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/count/countEvents.test.js @@ -3,10 +3,7 @@ * Copyright (c) 2019 Beyond Essential Systems Pty Ltd */ -import { expect } from 'chai'; -import sinon from 'sinon'; - -import { Aggregator } from '@tupaia/aggregator'; +import { createJestMockInstance } from '@tupaia/utils'; import { CountEventsBuilder } from '/apiV1/dataBuilders/generic/count/countEvents'; const MOCK_EVENTS = [ @@ -28,39 +25,33 @@ const dataServices = [{ isDataRegional: true }]; const entity = {}; const query = { organisationUnitCode: 'PG' }; const models = { - project: { findOne: sinon.stub().resolves({ entity_hierarchy_id: 'xxx' }) }, + project: { findOne: async () => ({ entity_hierarchy_id: 'xxx' }) }, }; -const fetchEvents = sinon.stub().returns(MOCK_EVENTS); -const aggregator = sinon.createStubInstance(Aggregator, { fetchEvents }); -const dhisApi = {}; +const fetchEvents = async () => MOCK_EVENTS; +const aggregator = createJestMockInstance('@tupaia/aggregator', 'Aggregator', { fetchEvents }); +const dhisApi = createJestMockInstance('@tupaia/dhis-api', 'DhisApi'); describe('CountEventsBuilder', () => { - const assertBuilderResponseIsCorrect = async (builderConfig, expectedResponse) => { - const config = { ...builderConfig, dataServices }; + it('counts events', async () => { + const config = { dataServices }; const builder = new CountEventsBuilder(models, aggregator, dhisApi, config, query, entity); - return expect(builder.build()).to.eventually.deep.equal(expectedResponse); - }; - it('counts events', () => - assertBuilderResponseIsCorrect( - {}, - { - data: [{ name: 'value', value: 4 }], - }, - )); + const response = await builder.build(); + expect(response).toStrictEqual({ + data: [{ name: 'value', value: 4 }], + }); + }); - it('counts events when grouped', () => + it('counts events when grouped', async () => { // Note: this is testing groupBy as well, which is outside the responsibility of this unit test, // but it's difficult to mock an imported function - assertBuilderResponseIsCorrect( - { - groupBy: { - type: 'nothing', - }, - }, - { - data: [{ name: 'all', value: 4 }], - }, - )); + const config = { dataServices, groupBy: { type: 'nothing' } }; + const builder = new CountEventsBuilder(models, aggregator, dhisApi, config, query, entity); + + const response = await builder.build(); + expect(response).toStrictEqual({ + data: [{ name: 'all', value: 4 }], + }); + }); }); diff --git a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/count/countEventsPerPeriodByDataValue.test.js b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/count/countEventsPerPeriodByDataValue.test.js similarity index 57% rename from packages/web-config-server/src/tests/apiV1/dataBuilders/generic/count/countEventsPerPeriodByDataValue.test.js rename to packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/count/countEventsPerPeriodByDataValue.test.js index c10c5d6c71..17ffd8f530 100644 --- a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/count/countEventsPerPeriodByDataValue.test.js +++ b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/count/countEventsPerPeriodByDataValue.test.js @@ -3,19 +3,9 @@ * Copyright (c) 2019 Beyond Essential Systems Pty Ltd */ -import { expect } from 'chai'; -import sinon from 'sinon'; - -import { Aggregator } from '@tupaia/aggregator'; +import { createJestMockInstance } from '@tupaia/utils'; import { CountEventsPerPeriodByDataValueBuilder } from '/apiV1/dataBuilders/generic/count/countEventsPerPeriodByDataValue'; -const RETURN_OBJ = { - data: [ - { value1: 0.5, value2: 0.5, timestamp: 1580688000000, name: '3rd Feb 2020' }, - { value1: 1, timestamp: 1581292800000, name: '10th Feb 2020' }, - ], -}; - const EVENTS = [ { organisationUnitCode: 'ORG1', @@ -44,13 +34,18 @@ const models = {}; const entity = {}; const query = { organisationUnitCode: 'PG' }; -const fetchEvents = sinon.stub().returns(EVENTS); -const aggregator = sinon.createStubInstance(Aggregator, { fetchEvents }); -const dhisApi = {}; +const fetchEvents = jest.fn().mockResolvedValue(EVENTS); +const aggregator = createJestMockInstance('@tupaia/aggregator', 'Aggregator', { fetchEvents }); +const dhisApi = createJestMockInstance('@tupaia/dhis-api', 'DhisApi'); describe('CountEventsPerPeriodByDataValueBuilder', () => { - const assertBuilderResponseIsCorrect = async (sumConfig, expectedResponse) => { - const config = { ...sumConfig, dataServices }; + it('should return number of events by type for an existing dataElement', async () => { + const config = { + dataElement: 'element1', + isPercentage: true, + periodType: 'week', + dataServices, + }; const builder = new CountEventsPerPeriodByDataValueBuilder( models, aggregator, @@ -59,16 +54,13 @@ describe('CountEventsPerPeriodByDataValueBuilder', () => { query, entity, ); - return expect(builder.build()).to.eventually.deep.equal(expectedResponse); - }; - it('should return number of events by type for an existing dataElement', () => - assertBuilderResponseIsCorrect( - { - dataElement: 'element1', - isPercentage: true, - periodType: 'week', - }, - RETURN_OBJ, - )); + const response = await builder.build(); + expect(response).toStrictEqual({ + data: [ + { value1: 0.5, value2: 0.5, timestamp: 1580688000000, name: '3rd Feb 2020' }, + { value1: 1, timestamp: 1581292800000, name: '10th Feb 2020' }, + ], + }); + }); }); diff --git a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/latestData/multiDataValuesLatestSurvey.test.js b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/latestData/multiDataValuesLatestSurvey.test.js similarity index 96% rename from packages/web-config-server/src/tests/apiV1/dataBuilders/generic/latestData/multiDataValuesLatestSurvey.test.js rename to packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/latestData/multiDataValuesLatestSurvey.test.js index 8766c47815..30c403710e 100644 --- a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/latestData/multiDataValuesLatestSurvey.test.js +++ b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/latestData/multiDataValuesLatestSurvey.test.js @@ -3,8 +3,6 @@ * Copyright (c) 2018 Beyond Essential Systems Pty Ltd */ -import { expect } from 'chai'; - import { multiDataValuesLatestSurvey } from '/apiV1/dataBuilders/generic/latestData/multiDataValuesLatestSurvey'; const query = {}; @@ -162,7 +160,7 @@ describe('multiDataValuesLatestSurvey', () => { aggregatorMockup, dhisApiMockup, ); - expect(result.data.find(({ dataElement }) => dataElement === 'gH8Ka9ty1f2').name).to.equal( + expect(result.data.find(({ dataElement }) => dataElement === 'gH8Ka9ty1f2').name).toBe( 'Photos of damage 6', ); }); @@ -176,7 +174,7 @@ describe('multiDataValuesLatestSurvey', () => { aggregatorMockup, dhisApiMockup, ); - expect(result.data.find(({ dataElement }) => dataElement === 'gH8Ka9ty1f2').value).to.equal( + expect(result.data.find(({ dataElement }) => dataElement === 'gH8Ka9ty1f2').value).toBe( 'https://tupaia.s3.amazonaws.com/dev_uploads/images/1544743303944_108594.png', ); }); @@ -190,7 +188,7 @@ describe('multiDataValuesLatestSurvey', () => { aggregatorMockup, dhisApiMockup, ); - expect(result.data.length).to.equal(5); + expect(result.data.length).toBe(5); }); it('should handle missing image', async () => { @@ -202,7 +200,7 @@ describe('multiDataValuesLatestSurvey', () => { aggregatorMockup, dhisApiMockup, ); - expect(result.data.find(({ dataElement }) => dataElement === 'YGxBoAYFWwt')).to.be.undefined; + expect(result.data.find(({ dataElement }) => dataElement === 'YGxBoAYFWwt')).toBe(undefined); }); it('should handle missing survey item', async () => { @@ -214,6 +212,6 @@ describe('multiDataValuesLatestSurvey', () => { aggregatorMockup, dhisApiMockup, ); - expect(result.data.find(({ dataElement }) => dataElement === 'iv1g1ZlX4oj')).to.be.undefined; + expect(result.data.find(({ dataElement }) => dataElement === 'iv1g1ZlX4oj')).toBe(undefined); }); }); diff --git a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/orgUnit/basicDataVillage.test.js b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/orgUnit/basicDataVillage.test.js similarity index 77% rename from packages/web-config-server/src/tests/apiV1/dataBuilders/generic/orgUnit/basicDataVillage.test.js rename to packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/orgUnit/basicDataVillage.test.js index 5ad0011955..0eed5435e0 100644 --- a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/orgUnit/basicDataVillage.test.js +++ b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/orgUnit/basicDataVillage.test.js @@ -1,21 +1,17 @@ -import { expect } from 'chai'; -import { EntityType, DatabaseModel } from '@tupaia/database'; +import { createJestMockInstance } from '@tupaia/utils'; import { basicDataVillage } from '/apiV1/dataBuilders/generic/orgUnit/basicDataVillage'; -import sinon from 'sinon'; const createEntity = parents => - sinon.createStubInstance(EntityType, { - getParent: sinon - .stub() - .callsFake(hierarchyId => parents.find(parent => parent && parent.hierarchy === hierarchyId)), + createJestMockInstance('@tupaia/database', 'EntityType', { + getParent: async hierarchyId => parents.find(parent => parent?.hierarchy === hierarchyId), }); const projects = { explore: { entity_hierarchy_id: 'explore_hierarchy' }, lily: { entity_hierarchy_id: 'lily_hierarchy' }, }; -const projectModel = sinon.createStubInstance(DatabaseModel, { - findOne: sinon.stub().callsFake(({ code: projectCode }) => projects[projectCode]), +const projectModel = createJestMockInstance('@tupaia/database', 'DatabaseModel', { + findOne: ({ code: projectCode }) => projects[projectCode], }); const models = { project: projectModel }; @@ -27,9 +23,8 @@ const assertResultDataIncludeMembers = async ({ }) => { const entity = createEntity(parentFacilities); const results = await basicDataVillage({ entity, models, query: { projectCode } }); - - expect(results).to.have.property('data'); - expect(results.data).to.deep.include.members(members); + expect(results).toHaveProperty('data'); + expect(results.data).toIncludeAllMembers(members); }; describe('basicDataVillage', () => { @@ -37,8 +32,8 @@ describe('basicDataVillage', () => { const entity = createEntity([null]); const results = await basicDataVillage({ entity, models, query: { projectCode: 'explore' } }); - expect(results).to.have.property('data'); - expect(results.data).to.have.property('length', 2); + expect(results).toHaveProperty('data'); + expect(results.data).toHaveProperty('length', 2); }); it('should specify the village type', async () => { diff --git a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/percentage/percentagesByNominatedPairs.test.js b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/percentage/percentagesByNominatedPairs.test.js similarity index 93% rename from packages/web-config-server/src/tests/apiV1/dataBuilders/generic/percentage/percentagesByNominatedPairs.test.js rename to packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/percentage/percentagesByNominatedPairs.test.js index 2b137f959d..809ae0e6f4 100644 --- a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/percentage/percentagesByNominatedPairs.test.js +++ b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/percentage/percentagesByNominatedPairs.test.js @@ -3,8 +3,6 @@ * Copyright (c) 2018 Beyond Essential Systems Pty Ltd */ -import { expect } from 'chai'; - import { percentagesByNominatedPairs } from '/apiV1/dataBuilders/generic/percentage/percentagesByNominatedPairs'; const dataBuilderConfig = { @@ -121,11 +119,11 @@ const aggregatorMockup = { }; const dhisApiMockup = {}; -describe('percentagesByNominatedPairs', async () => { +describe('percentagesByNominatedPairs', () => { let data; let aggregate; - before(async () => { + beforeAll(async () => { const response = await percentagesByNominatedPairs( { dataBuilderConfig, @@ -139,27 +137,27 @@ describe('percentagesByNominatedPairs', async () => { }); it('should have PEG tubes 10 times the ideal level', () => { - expect(data.find(({ name }) => name === 'PEG tubes').value).to.equal(10); + expect(data.find(({ name }) => name === 'PEG tubes').value).toBe(10); }); it('should have Sclerotherapy needle 10% of the ideal level', () => { - expect(data.find(({ name }) => name === 'Sclerotherapy needle').value).to.equal(0.1); + expect(data.find(({ name }) => name === 'Sclerotherapy needle').value).toBe(0.1); }); it('should aggregate multiple entries for Oesophageal stent', () => { - expect(data.find(({ name }) => name === 'Oesophageal stent').value).to.equal(1); + expect(data.find(({ name }) => name === 'Oesophageal stent').value).toBe(1); }); it('should cope with numerators and no denominators', () => { - expect(data.find(({ name }) => name === 'Haemoclips').value).to.equal('No data'); + expect(data.find(({ name }) => name === 'Haemoclips').value).toBe('No data'); }); it('should cope with denominators and no numerators', () => { - expect(data.find(({ name }) => name === 'Haemoclips').value).to.equal('No data'); + expect(data.find(({ name }) => name === 'Haemoclips').value).toBe('No data'); }); it('should return the correct average', () => { - expect(aggregate.count).to.equal(3); - expect(aggregate.sum).to.equal(11.1); + expect(aggregate.count).toBe(3); + expect(aggregate.sum).toBe(11.1); }); }); diff --git a/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/reportServer/reportServerDataBuilder.test.js b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/reportServer/reportServerDataBuilder.test.js new file mode 100644 index 0000000000..923d996a11 --- /dev/null +++ b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/reportServer/reportServerDataBuilder.test.js @@ -0,0 +1,129 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2021 Beyond Essential Systems Pty Ltd + */ + +import { ReportServerBuilder } from '/apiV1/dataBuilders/generic/reportServer/reportServerDataBuilder'; + +const reportRequestKey = (reportCode, query = {}, body = {}) => + `reportCode:${reportCode},${Object.entries(query) + .map(entry => entry.join(':')) + .join(',')},${Object.entries(body) + .map(entry => entry.join(':')) + .join(',')}`; + +const REPORT_SERVER_RESPONSES = { + [reportRequestKey('1', { organisationUnitCodes: 'TO', hierarchy: 'psss' })]: { + results: [ + { period: '2018', organisationUnit: 'TO', dataElement: 'AGGR01', value: 1 }, + { period: '2019', organisationUnit: 'TO', dataElement: 'AGGR02', value: 3 }, + { period: '2020', organisationUnit: 'TO', dataElement: 'AGGR01', value: 4 }, + { period: '2021', organisationUnit: 'TO', dataElement: 'AGGR02', value: 5 }, + ], + }, + [reportRequestKey('1', { + organisationUnitCodes: 'TO', + hierarchy: 'psss', + startDate: '2020-01-01', + endDate: '2021-01-01', + })]: { + results: [ + { period: '2020', organisationUnit: 'TO', dataElement: 'AGGR01', value: 4 }, + { period: '2021', organisationUnit: 'TO', dataElement: 'AGGR02', value: 5 }, + ], + }, + [reportRequestKey('2', { + organisationUnitCodes: 'PG', + hierarchy: 'strive', + startDate: '2020-01-01', + })]: { + results: [ + { period: '2020', organisationUnit: 'PG', dataElement: 'PSSS01', value: 4 }, + { period: '2021', organisationUnit: 'PG', dataElement: 'PSSS02', value: 5 }, + ], + }, +}; + +const PROJECTS = [ + { code: 'ps', entity_hierarchy_id: '1' }, + { code: 'str', entity_hierarchy_id: '2' }, +]; + +const ENTITY_HIERARCHIES = [ + { id: '1', name: 'psss' }, + { id: '2', name: 'strive' }, +]; + +const req = { session: { userJson: { userName: 'test' } } }; + +const fetchReport = (reportCode, query, body) => + REPORT_SERVER_RESPONSES[reportRequestKey(reportCode, query, body)]; + +const findProject = ({ code }) => PROJECTS.find(project => project.code === code); + +const findEntityHierarchyById = id => ENTITY_HIERARCHIES.find(hierarchy => hierarchy.id === id); + +const reportConnection = { fetchReport }; +const models = { + project: { findOne: findProject }, + entityHierarchy: { findById: findEntityHierarchyById }, +}; + +describe('ReportServerDataBuilder', () => { + it('should request correct report', async () => { + const config = { reportCode: '1' }; + const query = { projectCode: 'ps' }; + const entity = { code: 'TO' }; + const dataBuilder = new ReportServerBuilder(req, models, config, query, entity); + dataBuilder.reportConnection = reportConnection; + + const response = await dataBuilder.build(); + expect(response).toStrictEqual({ + data: [ + { period: '2018', organisationUnit: 'TO', dataElement: 'AGGR01', value: 1 }, + { period: '2019', organisationUnit: 'TO', dataElement: 'AGGR02', value: 3 }, + { period: '2020', organisationUnit: 'TO', dataElement: 'AGGR01', value: 4 }, + { period: '2021', organisationUnit: 'TO', dataElement: 'AGGR02', value: 5 }, + ], + }); + }); + + it('should request for correct start/end date', async () => { + const config = { reportCode: '1' }; + const query = { + projectCode: 'ps', + startDate: '2020-01-01', + endDate: '2021-01-01', + }; + const entity = { code: 'TO' }; + const dataBuilder = new ReportServerBuilder(req, models, config, query, entity); + dataBuilder.reportConnection = reportConnection; + + const response = await dataBuilder.build(); + expect(response).toStrictEqual({ + data: [ + { period: '2020', organisationUnit: 'TO', dataElement: 'AGGR01', value: 4 }, + { period: '2021', organisationUnit: 'TO', dataElement: 'AGGR02', value: 5 }, + ], + }); + }); + + it('should request for correct entity hierarchy', async () => { + const config = { reportCode: '2' }; + const query = { + projectCode: 'str', + startDate: '2020-01-01', + }; + const entity = { code: 'PG' }; + const dataBuilder = new ReportServerBuilder(req, models, config, query, entity); + dataBuilder.reportConnection = reportConnection; + + const response = await dataBuilder.build(); + expect(response).toStrictEqual({ + data: [ + { period: '2020', organisationUnit: 'PG', dataElement: 'PSSS01', value: 4 }, + { period: '2021', organisationUnit: 'PG', dataElement: 'PSSS02', value: 5 }, + ], + }); + }); +}); diff --git a/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/sum/sum.test.js b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/sum/sum.test.js new file mode 100644 index 0000000000..5319b2e09f --- /dev/null +++ b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/sum/sum.test.js @@ -0,0 +1,84 @@ +/** + * Tupaia Config Server + * Copyright (c) 2019 Beyond Essential Systems Pty Ltd + */ + +import { when } from 'jest-when'; + +import { createJestMockInstance } from '@tupaia/utils'; +import { SumBuilder } from '/apiV1/dataBuilders/generic/sum/sum'; + +const AGGREGATE_ANALYTICS = [ + { dataElement: 'AGGR01', value: 1 }, + { dataElement: 'AGGR02', value: 3 }, +]; +const EVENT_ANALYTICS = [ + { dataElement: 'EVENT01', value: 5 }, + { dataElement: 'EVENT02', value: 8 }, +]; +const PROGRAM_CODE = 'CD8'; + +const dataServices = [{ isDataRegional: false }]; +const models = {}; +const entity = {}; +const query = { organisationUnitCode: 'TO' }; +const aggregationType = 'FINAL_EACH_MONTH'; +const aggregationConfig = {}; +const filter = {}; + +const fetchAnalytics = jest.fn(); +when(fetchAnalytics) + .calledWith(expect.anything(), expect.objectContaining({ dataServices }), query, { + aggregations: undefined, + aggregationConfig, + aggregationType, + filter, + }) + .mockImplementation((dataElementCodes, { programCodes }) => { + const getAnalyticsToUse = () => { + if (programCodes) { + return programCodes.includes(PROGRAM_CODE) ? EVENT_ANALYTICS : []; + } + return AGGREGATE_ANALYTICS; + }; + + const analytics = getAnalyticsToUse(); + const results = analytics.filter(({ dataElement }) => dataElementCodes.includes(dataElement)); + return { results }; + }); + +const aggregator = createJestMockInstance('@tupaia/aggregator', 'Aggregator', { fetchAnalytics }); +const dhisApi = createJestMockInstance('@tupaia/dhis-api', 'DhisApi'); + +describe('SumBuilder', () => { + const getBuilder = ({ config }) => + new SumBuilder(models, aggregator, dhisApi, config, query, entity, aggregationType); + + it('should return zero sum for empty results', async () => { + const config = { dataElementCodes: ['NON_EXISTING_CODE'], dataServices }; + const builder = getBuilder({ config }); + + const response = await builder.build(); + expect(response).toStrictEqual({ data: [{ name: 'sum', value: 0 }] }); + }); + + it('should sum all the values for aggregate data elements', async () => { + const config = { dataElementCodes: ['AGGR01', 'AGGR02'], dataServices }; + const builder = getBuilder({ config }); + + const response = await builder.build(); + expect(response).toStrictEqual({ data: [{ name: 'sum', value: 4 }] }); + }); + + it('should sum all the values for event data elements', async () => { + const config = { + dataElementCodes: ['EVENT01', 'EVENT02'], + programCode: PROGRAM_CODE, + dataServices, + }; + const builder = getBuilder({ config }); + + const response = await builder.build(); + expect(response).toStrictEqual({ data: [{ name: 'sum', value: 13 }] }); + }); +}); diff --git a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/table/simpleTableOfEvents.test.js b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/table/simpleTableOfEvents.test.js similarity index 51% rename from packages/web-config-server/src/tests/apiV1/dataBuilders/generic/table/simpleTableOfEvents.test.js rename to packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/table/simpleTableOfEvents.test.js index 214b016b54..9bd3420eed 100644 --- a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/table/simpleTableOfEvents.test.js +++ b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/table/simpleTableOfEvents.test.js @@ -3,9 +3,9 @@ * Copyright (c) 2020 Beyond Essential Systems Pty Ltd */ -import { expect } from 'chai'; -import sinon from 'sinon'; +import { when } from 'jest-when'; +import { createJestMockInstance } from '@tupaia/utils'; import { simpleTableOfEvents } from '/apiV1/dataBuilders/generic/table/simpleTableOfEvents'; const dataServices = [{ isDataRegional: true }]; @@ -17,7 +17,7 @@ const dataBuilderConfig = { const query = { organisationUnitCode: 'World' }; const entity = {}; -const analytics = { +const ANALYTICS = { results: [ { dataElement: 'WHOSPAR', @@ -43,33 +43,9 @@ const analytics = { }, }; -const responseData = [ - { - dataElement: 'WHOSPAR', - organisationUnit: 'World', - period: '20100208', - value: 8, - name: '2010', - }, - { - dataElement: 'WHOSPAR', - organisationUnit: 'World', - period: '20110208', - value: 7, - name: '2011', - }, - { - dataElement: 'WHOSPAR', - organisationUnit: 'World', - period: '20120208', - value: 13, - name: '2012', - }, -]; - -const fetchAnalytics = sinon.stub(); -fetchAnalytics - .withArgs( +const fetchAnalytics = jest.fn(); +when(fetchAnalytics) + .calledWith( ['WHOSPAR'], { dataServices, @@ -85,17 +61,45 @@ fetchAnalytics filter: {}, }, ) - .returns(analytics); + .mockResolvedValue(ANALYTICS); -const aggregator = { +const aggregator = createJestMockInstance('@tupaia/aggregator', 'Aggregator', { fetchAnalytics, aggregationTypes: { FINAL_EACH_YEAR: 'FINAL_EACH_YEAR' }, -}; -const dhisApi = {}; +}); +const dhisApi = createJestMockInstance('@tupaia/dhis-api', 'DhisApi'); -describe('simpleTableOfEvents', async () => { - it('should return expected data', () => - expect( - simpleTableOfEvents({ dataBuilderConfig, query, entity }, aggregator, dhisApi), - ).to.eventually.deep.equal({ data: responseData })); +describe('simpleTableOfEvents', () => { + it('should return expected data', async () => { + const response = await simpleTableOfEvents( + { dataBuilderConfig, query, entity }, + aggregator, + dhisApi, + ); + expect(response).toStrictEqual({ + data: [ + { + dataElement: 'WHOSPAR', + organisationUnit: 'World', + period: '20100208', + value: 8, + name: '2010', + }, + { + dataElement: 'WHOSPAR', + organisationUnit: 'World', + period: '20110208', + value: 7, + name: '2011', + }, + { + dataElement: 'WHOSPAR', + organisationUnit: 'World', + period: '20120208', + value: 13, + name: '2012', + }, + ], + }); + }); }); diff --git a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/table/tableOfDataValues/helpers.js b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/table/tableOfDataValues/helpers.js similarity index 55% rename from packages/web-config-server/src/tests/apiV1/dataBuilders/generic/table/tableOfDataValues/helpers.js rename to packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/table/tableOfDataValues/helpers.js index b65593a304..6e8decdd85 100644 --- a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/table/tableOfDataValues/helpers.js +++ b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/table/tableOfDataValues/helpers.js @@ -3,11 +3,10 @@ * Copyright (c) 2019 Beyond Essential Systems Pty Ltd */ -import { expect } from 'chai'; +import { when } from 'jest-when'; import pickBy from 'lodash.pickby'; -import sinon from 'sinon'; -import { Aggregator } from '/aggregator'; +import { createJestMockInstance } from '@tupaia/utils'; import { DATA_ELEMENTS, ORG_UNITS } from './tableOfDataValues.fixtures'; const query = { organisationUnitCode: 'TO' }; @@ -19,61 +18,66 @@ const period = { }; const createAggregatorStub = dataValues => { - const fetchAnalytics = sinon.stub(); - fetchAnalytics - .returns({ results: [] }) - .withArgs(sinon.match.any, sinon.match({ dataServices }), sinon.match(query)) - .callsFake(dataElementCodes => ({ + const fetchAnalytics = jest.fn(); + when(fetchAnalytics) + .calledWith( + expect.anything(), + expect.objectContaining({ dataServices }), + expect.objectContaining(query), + ) + .mockImplementation(dataElementCodes => ({ results: Object.values(dataValues).filter(({ dataElement }) => dataElementCodes.includes(dataElement), ), period, })); - const fetchDataElements = sinon.stub(); - fetchDataElements - .returns({ dataElements: [] }) - .withArgs( - sinon.match.any, - sinon.match({ + const fetchDataElements = jest.fn(); + when(fetchDataElements) + .calledWith( + expect.anything(), + expect.objectContaining({ organisationUnitCode: query.organisationUnitCode, dataServices, includeOptions: true, }), ) - .callsFake(codes => pickBy(DATA_ELEMENTS, ({ code }) => codes.includes(code))); + .mockImplementation(codes => pickBy(DATA_ELEMENTS, ({ code }) => codes.includes(code))); - return sinon.createStubInstance(Aggregator, { fetchAnalytics, fetchDataElements }); + return createJestMockInstance('@tupaia/aggregator', 'Aggregator', { + aggregationTypes: { + SUM_MOST_RECENT_PER_FACILITY: 'SUM_MOST_RECENT_PER_FACILITY', + }, + fetchAnalytics, + fetchDataElements, + }); }; +const dhisApi = createJestMockInstance('@tupaia/dhis-api', 'DhisApi'); + const models = { entity: { - find: sinon - .stub() - .callsFake(({ code: codes }) => ORG_UNITS.filter(({ code }) => codes.includes(code))), + find: ({ code: codes }) => ORG_UNITS.filter(({ code }) => codes.includes(code)), }, }; export const createAssertTableResults = (table, availableDataValues) => { const aggregator = createAggregatorStub(availableDataValues); - const dhisApi = {}; return async (tableConfig, expectedResults) => { const dataBuilderConfig = { ...tableConfig, dataServices }; - return expect( - table({ models, dataBuilderConfig, query }, aggregator, dhisApi), - ).to.eventually.deep.equal({ period, ...expectedResults }); + const results = await table({ models, dataBuilderConfig, query }, aggregator, dhisApi); + return expect(results).toStrictEqual({ period, ...expectedResults }); }; }; export const createAssertErrorIsThrown = (table, availableDataValues) => { const aggregator = createAggregatorStub(availableDataValues); - const dhisApi = {}; return async (tableConfig, expectedError) => { const dataBuilderConfig = { ...tableConfig, dataServices }; return expect( table({ models, dataBuilderConfig, query }, aggregator, dhisApi), - ).to.be.rejectedWith(expectedError); + ).toBeRejectedWith(expectedError); }; }; diff --git a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/table/tableOfDataValues/helpers/buildCategoryData.test.js b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/table/tableOfDataValues/helpers/buildCategoryData.test.js similarity index 94% rename from packages/web-config-server/src/tests/apiV1/dataBuilders/generic/table/tableOfDataValues/helpers/buildCategoryData.test.js rename to packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/table/tableOfDataValues/helpers/buildCategoryData.test.js index a53d456469..3a47d06ee5 100644 --- a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/table/tableOfDataValues/helpers/buildCategoryData.test.js +++ b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/table/tableOfDataValues/helpers/buildCategoryData.test.js @@ -1,5 +1,3 @@ -import { expect } from 'chai'; - import { buildCategoryData } from '/apiV1/dataBuilders/generic/table/tableOfDataValues/helpers'; const rows = [ @@ -38,7 +36,7 @@ const expectedValue = { describe('buildCategoryData()', () => { describe('type: $condition', () => { it('condition: someNotAll', () => { - expect(buildCategoryData({ rows, categoryAggregatorConfig, columns })).to.deep.equal( + expect(buildCategoryData({ rows, categoryAggregatorConfig, columns })).toStrictEqual( expectedValue, ); }); diff --git a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/table/tableOfDataValues/tableOfDataValues.fixtures.js b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/table/tableOfDataValues/tableOfDataValues.fixtures.js similarity index 100% rename from packages/web-config-server/src/tests/apiV1/dataBuilders/generic/table/tableOfDataValues/tableOfDataValues.fixtures.js rename to packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/table/tableOfDataValues/tableOfDataValues.fixtures.js diff --git a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/table/tableOfDataValues/tableOfDataValues.test.js b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/table/tableOfDataValues/tableOfDataValues.test.js similarity index 100% rename from packages/web-config-server/src/tests/apiV1/dataBuilders/generic/table/tableOfDataValues/tableOfDataValues.test.js rename to packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/table/tableOfDataValues/tableOfDataValues.test.js diff --git a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/table/tableOfDataValues/tableOfPercentagesOfValueCounts.fixtures.js b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/table/tableOfDataValues/tableOfPercentagesOfValueCounts.fixtures.js similarity index 100% rename from packages/web-config-server/src/tests/apiV1/dataBuilders/generic/table/tableOfDataValues/tableOfPercentagesOfValueCounts.fixtures.js rename to packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/table/tableOfDataValues/tableOfPercentagesOfValueCounts.fixtures.js diff --git a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/table/tableOfDataValues/tableOfPercentagesOfValueCounts.test.js b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/table/tableOfDataValues/tableOfPercentagesOfValueCounts.test.js similarity index 99% rename from packages/web-config-server/src/tests/apiV1/dataBuilders/generic/table/tableOfDataValues/tableOfPercentagesOfValueCounts.test.js rename to packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/table/tableOfDataValues/tableOfPercentagesOfValueCounts.test.js index 6ee7aab166..3570e570ea 100644 --- a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/table/tableOfDataValues/tableOfPercentagesOfValueCounts.test.js +++ b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/table/tableOfDataValues/tableOfPercentagesOfValueCounts.test.js @@ -7,7 +7,6 @@ import { createAssertTableResults } from './helpers'; import { DATA_VALUES } from './tableOfPercentagesOfValueCounts.fixtures'; import { tableOfPercentagesOfValueCounts } from '/apiV1/dataBuilders'; -const models = {}; const assertTableResults = createAssertTableResults( tableOfPercentagesOfValueCounts, DATA_VALUES.filter(({ organisationUnit }) => organisationUnit === 'TO_Nukuhc'), diff --git a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/table/tableOfDataValues/tableOfValuesForOrgUnits.test.js b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/table/tableOfDataValues/tableOfValuesForOrgUnits.test.js similarity index 100% rename from packages/web-config-server/src/tests/apiV1/dataBuilders/generic/table/tableOfDataValues/tableOfValuesForOrgUnits.test.js rename to packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/table/tableOfDataValues/tableOfValuesForOrgUnits.test.js diff --git a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/table/tableOfDataValues/testCategories.js b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/table/tableOfDataValues/testCategories.js similarity index 92% rename from packages/web-config-server/src/tests/apiV1/dataBuilders/generic/table/tableOfDataValues/testCategories.js rename to packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/table/tableOfDataValues/testCategories.js index ffbc1523c7..d1133601b6 100644 --- a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/table/tableOfDataValues/testCategories.js +++ b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/table/tableOfDataValues/testCategories.js @@ -14,7 +14,7 @@ const assertTableResults = createAssertTableResults( export const testCategories = () => { describe('row categories', () => { - it('1 category x 1 row', () => + it('1 category x 1 row', async () => assertTableResults( { rows: [{ category: 'Risk Factors', rows: ['Smokers'] }], @@ -30,7 +30,7 @@ export const testCategories = () => { }, )); - it('1 category x 2 rows', () => + it('1 category x 2 rows', async () => assertTableResults( { rows: [{ category: 'Risk Factors', rows: ['Smokers', 'Overweight'] }], @@ -47,7 +47,7 @@ export const testCategories = () => { }, )); - it('2 categories x 1 row', () => + it('2 categories x 1 row', async () => assertTableResults( { rows: [ @@ -68,7 +68,7 @@ export const testCategories = () => { }, )); - it('2 categories x 2 rows', () => + it('2 categories x 2 rows', async () => assertTableResults( { rows: [ @@ -91,7 +91,7 @@ export const testCategories = () => { }, )); - it('rows with same title in the same categories', () => + it('rows with same title in the same categories', async () => assertTableResults( { rows: [{ category: 'Risk Factors', rows: ['Female', 'Female'] }], @@ -108,7 +108,7 @@ export const testCategories = () => { }, )); - it('rows with same title in different categories', () => + it('rows with same title in different categories', async () => assertTableResults( { rows: [ @@ -129,7 +129,7 @@ export const testCategories = () => { }, )); - it('should fetch rows from data', () => + it('should fetch rows from data', async () => assertTableResults( { rows: [ @@ -159,7 +159,7 @@ export const testCategories = () => { }, )); - it('should fetch rowInfo from data', () => + it('should fetch rowInfo from data', async () => assertTableResults( { rows: [ @@ -193,7 +193,7 @@ export const testCategories = () => { }); describe('column categories', () => { - it('1 category x 1 column', () => + it('1 category x 1 column', async () => assertTableResults( { rows: ['Female'], @@ -211,7 +211,7 @@ export const testCategories = () => { }, )); - it('1 category x 2 columns', () => + it('1 category x 2 columns', async () => assertTableResults( { rows: ['Female'], @@ -232,7 +232,7 @@ export const testCategories = () => { }, )); - it('2 categories x 1 column', () => + it('2 categories x 1 column', async () => assertTableResults( { rows: ['Female'], @@ -257,7 +257,7 @@ export const testCategories = () => { }, )); - it('2 categories x 2 columns', () => + it('2 categories x 2 columns', async () => assertTableResults( { rows: ['Female'], @@ -288,7 +288,7 @@ export const testCategories = () => { }, )); - it('columns with same title in the same categories', () => + it('columns with same title in the same categories', async () => assertTableResults( { rows: ['Count'], @@ -309,7 +309,7 @@ export const testCategories = () => { }, )); - it('columns with same title in different categories', () => + it('columns with same title in different categories', async () => assertTableResults( { rows: ['Count'], @@ -336,7 +336,7 @@ export const testCategories = () => { }); describe('row and column categories', () => { - it('1 category x 1 row, 1 category x 1 column', () => + it('1 category x 1 row, 1 category x 1 column', async () => assertTableResults( { rows: [{ category: 'Risk Factors', rows: ['Smokers'] }], @@ -357,7 +357,7 @@ export const testCategories = () => { }, )); - it('2 categories x 2 rows, 1 category x 2 columns, ', () => + it('2 categories x 2 rows, 1 category x 2 columns, ', async () => assertTableResults( { rows: [ diff --git a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/table/tableOfDataValues/testNoCategories.js b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/table/tableOfDataValues/testNoCategories.js similarity index 92% rename from packages/web-config-server/src/tests/apiV1/dataBuilders/generic/table/tableOfDataValues/testNoCategories.js rename to packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/table/tableOfDataValues/testNoCategories.js index 3b34b376db..f391517acf 100644 --- a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/table/tableOfDataValues/testNoCategories.js +++ b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/table/tableOfDataValues/testNoCategories.js @@ -13,7 +13,7 @@ const assertTableResults = createAssertTableResults( ); export const testNoCategories = () => { - it('1x1', () => + it('1x1', async () => assertTableResults( { rows: ['Smokers'], @@ -26,7 +26,7 @@ export const testNoCategories = () => { }, )); - it('1x2', () => + it('1x2', async () => assertTableResults( { rows: ['Smokers'], @@ -42,7 +42,7 @@ export const testNoCategories = () => { }, )); - it('2x1', () => + it('2x1', async () => assertTableResults( { rows: ['Smokers', 'Overweight'], @@ -58,7 +58,7 @@ export const testNoCategories = () => { }, )); - it('2x2', () => + it('2x2', async () => assertTableResults( { rows: ['Smokers', 'Overweight'], @@ -80,7 +80,7 @@ export const testNoCategories = () => { }, )); - it('rows with same title', () => + it('rows with same title', async () => assertTableResults( { rows: ['Female', 'Female'], @@ -96,7 +96,7 @@ export const testNoCategories = () => { }, )); - it('columns with same title', () => + it('columns with same title', async () => assertTableResults( { rows: ['Female'], @@ -112,7 +112,7 @@ export const testNoCategories = () => { }, )); - it('should ignore empty cells', () => + it('should ignore empty cells', async () => assertTableResults( { rows: ['Smokers', 'Overweight'], @@ -134,7 +134,7 @@ export const testNoCategories = () => { }, )); - it('should build rows from data values', () => + it('should build rows from data values', async () => assertTableResults( { rows: [ @@ -156,7 +156,7 @@ export const testNoCategories = () => { }, )); - it('should fetch rowInfo from data', () => + it('should fetch rowInfo from data', async () => assertTableResults( { rows: [ diff --git a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/table/tableOfDataValues/testOptions.js b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/table/tableOfDataValues/testOptions.js similarity index 95% rename from packages/web-config-server/src/tests/apiV1/dataBuilders/generic/table/tableOfDataValues/testOptions.js rename to packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/table/tableOfDataValues/testOptions.js index 850847e4f3..b9fba65f34 100644 --- a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/table/tableOfDataValues/testOptions.js +++ b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/table/tableOfDataValues/testOptions.js @@ -13,7 +13,7 @@ const assertTableResults = createAssertTableResults( ); export const testOptions = () => { - it('1x1', () => + it('1x1', async () => assertTableResults( { rows: ['Smokers'], @@ -26,7 +26,7 @@ export const testOptions = () => { }, )); - it('1x2', () => + it('1x2', async () => assertTableResults( { rows: ['Smokers'], @@ -42,7 +42,7 @@ export const testOptions = () => { }, )); - it('2x1', () => + it('2x1', async () => assertTableResults( { rows: ['Smokers', 'Overweight'], @@ -58,7 +58,7 @@ export const testOptions = () => { }, )); - it('2x2', () => + it('2x2', async () => assertTableResults( { rows: ['Smokers', 'Overweight'], @@ -80,7 +80,7 @@ export const testOptions = () => { }, )); - it('table total', () => + it('table total', async () => assertTableResults( { rows: ['Smokers', 'Overweight', 'Total'], diff --git a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/table/tableOfDataValues/testOrgUnitCategories.js b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/table/tableOfDataValues/testOrgUnitCategories.js similarity index 96% rename from packages/web-config-server/src/tests/apiV1/dataBuilders/generic/table/tableOfDataValues/testOrgUnitCategories.js rename to packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/table/tableOfDataValues/testOrgUnitCategories.js index 3260d0f1ce..137c8dc0fa 100644 --- a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/table/tableOfDataValues/testOrgUnitCategories.js +++ b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/table/tableOfDataValues/testOrgUnitCategories.js @@ -18,7 +18,7 @@ const assertTableResults = createAssertTableResults( export const testOrgUnitCategories = () => { describe('row org unit categories', () => { - it('no column categories', () => + it('no column categories', async () => assertTableResults( { rows: [{ category: '$orgUnit', rows: ['Smokers'] }], @@ -39,7 +39,7 @@ export const testOrgUnitCategories = () => { }, )); - it('specified column categories', () => + it('specified column categories', async () => assertTableResults( { rows: [{ category: '$orgUnit', rows: ['Female', 'Male'] }], @@ -96,7 +96,7 @@ export const testOrgUnitCategories = () => { }); describe('column org unit categories', () => { - it('no row categories', () => + it('no row categories', async () => assertTableResults( { rows: ['Female', 'Male'], @@ -121,7 +121,7 @@ export const testOrgUnitCategories = () => { }, )); - it('specified row categories', () => + it('specified row categories', async () => assertTableResults( { rows: [ diff --git a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/table/tableOfDataValues/testTotals.js b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/table/tableOfDataValues/testTotals.js similarity index 93% rename from packages/web-config-server/src/tests/apiV1/dataBuilders/generic/table/tableOfDataValues/testTotals.js rename to packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/table/tableOfDataValues/testTotals.js index 83be791246..995821cb70 100644 --- a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/table/tableOfDataValues/testTotals.js +++ b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/table/tableOfDataValues/testTotals.js @@ -14,7 +14,7 @@ const assertTableResults = createAssertTableResults(tableOfDataValues, dataValue const assertErrorIsThrown = createAssertErrorIsThrown(tableOfDataValues, dataValues); export const testTotals = () => { - it('table total', () => + it('table total', async () => assertTableResults( { rows: ['Smokers', 'Overweight', 'Total'], @@ -40,7 +40,7 @@ export const testTotals = () => { )); describe('row totals', () => { - it('1 row', () => + it('1 row', async () => assertTableResults( { rows: ['Smokers'], @@ -57,7 +57,7 @@ export const testTotals = () => { }, )); - it('2 rows', () => + it('2 rows', async () => assertTableResults( { rows: ['Smokers', 'Overweight'], @@ -82,7 +82,7 @@ export const testTotals = () => { }); describe('column totals', () => { - it('1 column', () => + it('1 column', async () => assertTableResults( { rows: ['Smokers', 'Overweight', 'Total'], @@ -99,7 +99,7 @@ export const testTotals = () => { }, )); - it('2 columns', () => + it('2 columns', async () => assertTableResults( { rows: ['Smokers', 'Overweight', 'Totals'], @@ -125,7 +125,7 @@ export const testTotals = () => { }); describe('row and column totals', () => { - it('1x1 non-total cells', () => + it('1x1 non-total cells', async () => assertTableResults( { rows: ['Smokers', 'Total'], @@ -144,7 +144,7 @@ export const testTotals = () => { }, )); - it('2x2 non-total cells', () => + it('2x2 non-total cells', async () => assertTableResults( { rows: ['Smokers', 'Overweight', 'Totals'], @@ -171,7 +171,7 @@ export const testTotals = () => { }); describe('row category column totals', () => { - it('throws error if row categories are not defined', () => + it('throws error if row categories are not defined', async () => assertErrorIsThrown( { rows: ['Smokers', 'Overweight', 'Totals'], @@ -185,7 +185,7 @@ export const testTotals = () => { 'row categories are not defined', )); - it('1 category', () => + it('1 category', async () => assertTableResults( { rows: [{ category: 'Risk Factors', rows: ['Smokers', 'Overweight', 'Totals'] }], @@ -210,7 +210,7 @@ export const testTotals = () => { }, )); - it('2 categories', () => + it('2 categories', async () => assertTableResults( { rows: [ @@ -247,7 +247,7 @@ export const testTotals = () => { }); describe('column category row totals', () => { - it('throws error if column categories are not defined', () => + it('throws error if column categories are not defined', async () => assertErrorIsThrown( { rows: ['Female', 'Male'], @@ -260,7 +260,7 @@ export const testTotals = () => { 'column categories are not defined', )); - it('1 category', () => + it('1 category', async () => assertTableResults( { rows: ['Female', 'Male'], @@ -288,7 +288,7 @@ export const testTotals = () => { }, )); - it('2 categories', () => + it('2 categories', async () => assertTableResults( { rows: ['Female', 'Male'], @@ -329,7 +329,7 @@ export const testTotals = () => { }); describe('row and column category totals', () => { - it('2 row categories x 1 column category', () => + it('2 row categories x 1 column category', async () => assertTableResults( { rows: [ @@ -372,7 +372,7 @@ export const testTotals = () => { }); describe('total type combinations', () => { - it('row, column and overall totals', () => + it('row, column and overall totals', async () => assertTableResults( { rows: ['Smokers', 'Overweight', 'Totals'], @@ -397,7 +397,7 @@ export const testTotals = () => { }, )); - it('row category and whole column totals ', () => + it('row category and whole column totals ', async () => assertTableResults( { rows: [ @@ -434,7 +434,7 @@ export const testTotals = () => { }, )); - it('column category and whole row totals ', () => + it('column category and whole row totals ', async () => assertTableResults( { rows: ['Female', 'Male'], @@ -511,7 +511,7 @@ export const testTotals = () => { }, )); - it('whole row category total, and row totals', () => + it('whole row category total, and row totals', async () => assertTableResults( { rows: [ @@ -602,7 +602,7 @@ export const testTotals = () => { }, )); - it('whole column category total, and column totals', () => + it('whole column category total, and column totals', async () => assertTableResults( { rows: ['Antenatal', 'Postnatal', 'Totals'], @@ -700,7 +700,7 @@ export const testTotals = () => { }); describe('total of missing cells', () => { - it('row, column and overall totals, all missing', () => + it('row, column and overall totals, all missing', async () => assertTableResults( { rows: ['Smokers', 'Overweight', 'Totals'], @@ -725,7 +725,7 @@ export const testTotals = () => { }, )); - it('row, column and overall totals, some missing', () => + it('row, column and overall totals, some missing', async () => assertTableResults( { rows: ['Smokers', 'Overweight', 'Totals'], @@ -750,7 +750,7 @@ export const testTotals = () => { }, )); - it('row totals, all missing', () => + it('row totals, all missing', async () => assertTableResults( { rows: ['Smokers', 'Overweight'], @@ -773,7 +773,7 @@ export const testTotals = () => { }, )); - it('row totals, some missing', () => + it('row totals, some missing', async () => assertTableResults( { rows: ['Smokers', 'Overweight'], @@ -796,7 +796,7 @@ export const testTotals = () => { }, )); - it('column totals, all missing', () => + it('column totals, all missing', async () => assertTableResults( { rows: ['Smokers', 'Overweight', 'Totals'], @@ -819,29 +819,5 @@ export const testTotals = () => { ], }, )); - - it('row, column and overall totals, some missing', () => - assertTableResults( - { - rows: ['Smokers', 'Overweight', 'Totals'], - columns: ['Female', 'Male'], - cells: [ - ['CD1', undefined], - [undefined, 'CD4'], - ['$columnTotal', '$columnTotal'], - ], - }, - { - rows: [ - { dataElement: 'Smokers', Col1: 1, Col2: '' }, - { dataElement: 'Overweight', Col1: '', Col2: 4 }, - { dataElement: 'Totals', Col1: 1, Col2: 4 }, - ], - columns: [ - { key: 'Col1', title: 'Female' }, - { key: 'Col2', title: 'Male' }, - ], - }, - )); }); }; diff --git a/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/unique/selectUniqueValueFromEvents.test.js b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/unique/selectUniqueValueFromEvents.test.js new file mode 100644 index 0000000000..6591c990e3 --- /dev/null +++ b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/generic/unique/selectUniqueValueFromEvents.test.js @@ -0,0 +1,72 @@ +/** + * Tupaia Config Server + * Copyright (c) 2019 Beyond Essential Systems Pty Ltd + */ + +import { createJestMockInstance } from '@tupaia/utils'; +import { selectUniqueValueFromEvents } from '/apiV1/dataBuilders/generic/unique'; +import { NO_UNIQUE_VALUE } from '/apiV1/dataBuilders/helpers/uniqueValues'; + +const EVENTS = [ + { + organisationUnitCode: 'ORG1', + eventDate: '2020-02-03T11:14:00.000', + dataValues: { element1: 'value1', element2: 'value2' }, + }, + { + organisationUnitCode: 'ORG1', + eventDate: '2020-02-03T11:14:00.000', + dataValues: { element1: 'value1', element2: 'value2' }, + }, + { + organisationUnitCode: 'ORG1', + eventDate: '2020-02-10T11:14:00.000', + dataValues: { element1: 'value1', element2: 'value2' }, + }, + { + organisationUnitCode: 'ORG1', + eventDate: '2020-02-10T11:15:00.000', + dataValues: { element1: 'value1', element2: 'value3' }, + }, +]; + +const dataServices = [{ isDataRegional: false }]; +const entity = {}; +const query = { organisationUnitCode: 'TO' }; + +const aggregator = createJestMockInstance('@tupaia/aggregator', 'Aggregator', { + fetchEvents: async () => EVENTS, +}); +const dhisApi = createJestMockInstance('@tupaia/dhis-api', 'DhisApi'); + +describe('selectUniqueValueFromEvents', () => { + it('returns the unique value if it exists', async () => { + const dataBuilderConfig = { valueToSelect: 'element1', dataServices }; + const response = await selectUniqueValueFromEvents( + { dataBuilderConfig, query, entity }, + aggregator, + dhisApi, + ); + return expect(response).toStrictEqual({ data: [{ name: 'element1', value: 'value1' }] }); + }); + + it('returns no unique value if there is no unique value', async () => { + const dataBuilderConfig = { valueToSelect: 'element2', dataServices }; + const response = await selectUniqueValueFromEvents( + { dataBuilderConfig, query, entity }, + aggregator, + dhisApi, + ); + return expect(response).toStrictEqual({ data: [{ name: 'element2', value: NO_UNIQUE_VALUE }] }); + }); + + it('returns undefined if no events exist containing the valueToSelect', async () => { + const dataBuilderConfig = { valueToSelect: 'element3', dataServices }; + const response = await selectUniqueValueFromEvents( + { dataBuilderConfig, query, entity }, + aggregator, + dhisApi, + ); + return expect(response).toStrictEqual({ data: [{ name: 'element3', value: undefined }] }); + }); +}); diff --git a/packages/web-config-server/src/__tests__/apiV1/dataBuilders/helpers/calculateOperationForAnalytics.test.js b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/helpers/calculateOperationForAnalytics.test.js new file mode 100644 index 0000000000..99d5d11568 --- /dev/null +++ b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/helpers/calculateOperationForAnalytics.test.js @@ -0,0 +1,324 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2022 Beyond Essential Systems Pty Ltd + */ + +import { arrayToAnalytics } from '@tupaia/data-broker'; +import { NO_DATA_AVAILABLE } from '/apiV1/dataBuilders/constants'; + +import { calculateOperationForAnalytics } from '/apiV1/dataBuilders/helpers'; + +const models = {}; + +const analytics = arrayToAnalytics([ + ['temperature', 'TO', '20220101', 2], + ['temperature', 'TO', '20220101', 5], + ['temperature', 'TO', '20220101', 7], + ['temperature', 'TO', '20220101', 2], + + ['result', 'TO', '20220101', 'Positive'], + ['result', 'TO', '20220101', 'Positive'], + ['result', 'TO', '20220101', 'Positive Mixed'], + ['result', 'TO', '20220101', 'Negative'], + + ['uniqueCode', 'TO', '20220101', 'Yes, more than 100'], + ['uniqueCodeForName', 'TO', '20220101', 'Octavia'], + + ['height', 'TO', '20220101', 15], + ['width', 'TO', '20220101', 2], + + ['Flower_found_Daisy', 'TO', '20220101', 1], + ['Flower_found_Tulip', 'TO', '20220101', 'No'], + ['Flower_found_Orchid', 'TO', '20220101', 1], + ['Flower_found_Orchid', 'TO', '20220101', 1], + + ['Best_Superhero1', 'TO', '20220101', 'SuperGirl'], + ['Best_Superhero2', 'TO', '20220101', 'Black Widow'], + ['Best_Superhero3', 'TO', '20220101', 'My Sister'], + + ['population', 'Melbourne', '19990101', 10], + ['population', 'Melbourne', '20050101', 100], + ['population', 'Sydney', '20100101', 20], + ['population', 'Sydney', '20050101', 200], +]); + +describe('calculateOperationForAnalytics', () => { + it('throws if the operation is not defined', async () => { + await expect( + calculateOperationForAnalytics(models, analytics, { operator: 'NOT_AN_OPERATOR' }), + ).toBeRejectedWith('Cannot find operator: NOT_AN_OPERATOR'); + await expect(calculateOperationForAnalytics(models, analytics, {})).toBeRejectedWith( + 'Cannot find operator: undefined', + ); + }); + + // TODO RN-676: skipping test cases until the tested functionality is fixed + describe.skip('CHECK_CONDITION', () => { + it('throws an error when passed too many analytics', async () => { + await expect( + calculateOperationForAnalytics(models, analytics, { + operator: 'CHECK_CONDITION', + dataElement: 'result', + condition: { value: 'Positive', operator: 'regex' }, + }), + ).toBeRejectedWith( + 'Too many results passed to checkConditions (calculateOperationForAnalytics)', + ); + }); + + it('returns no data if no analytics match the dataElement', async () => { + const result = await calculateOperationForAnalytics(models, analytics, { + operator: 'CHECK_CONDITION', + dataElement: 'NON_EXISTENT', + condition: { value: 'Positive', operator: 'regex' }, + }); + expect(result).toBe(NO_DATA_AVAILABLE); + }); + + it('returns correctly for valid cases', async () => { + const result1 = await calculateOperationForAnalytics(models, analytics, { + operator: 'CHECK_CONDITION', + dataElement: 'uniqueCode', + condition: { value: 'Yes', operator: 'regex' }, + }); + expect(result1).toBe('Yes'); + const result2 = await calculateOperationForAnalytics(models, analytics, { + operator: 'CHECK_CONDITION', + dataElement: 'uniqueCode', + condition: { value: 'No', operator: 'regex' }, + }); + expect(result2).toBe('No'); + }); + }); + + describe('SUBTRACT', () => { + const createSubtractionConfig = (codes1, codes2) => ({ + operator: 'SUBTRACT', + operands: [ + { + dataValues: codes1, + }, + { + dataValues: codes2, + }, + ], + }); + + it('returns no data if appropriate', async () => { + const result1 = await calculateOperationForAnalytics( + models, + analytics, + createSubtractionConfig(['uniqueCode'], ['NON_EXISTENT']), + ); + expect(result1).toBe(NO_DATA_AVAILABLE); + const result2 = await calculateOperationForAnalytics( + models, + analytics, + createSubtractionConfig(['NON_EXISTENT'], ['NON_EXISTENT_EITHER']), + ); + expect(result2).toBe(NO_DATA_AVAILABLE); + }); + + it('throws if there are not 2 or more operands in the config', async () => { + await expect( + calculateOperationForAnalytics(models, analytics, { + operator: 'SUBTRACT', + operands: [{ dataValues: 'hi' }], + }), + ).toBeRejectedWith('Must have 2 or more operands'); + }); + + it('subtracts correctly in valid cases (and sum multiple analytics with the same code)', async () => { + const result = await calculateOperationForAnalytics( + models, + analytics, + createSubtractionConfig(['height', 'width'], ['temperature']), + ); + expect(result).toBe(1); + }); + + it('handles SUM_LATEST_PER_ORG_UNIT aggregation', async () => { + const result = await calculateOperationForAnalytics(models, analytics, { + operator: 'SUBTRACT', + operands: [ + { + dataValues: ['temperature'], + }, + { + dataValues: ['population'], + aggregationType: 'SUM_LATEST_PER_ORG_UNIT', + }, + ], + }); + expect(result).toBe(-104); // (2 + 5 + 7 + 2) - (100 + 20) + }); + }); + + // TODO RN-676: skipping test cases until the tested functionality is fixed + describe.skip('FORMAT', () => { + it('throws an error when passed too many analytics', async () => { + await expect( + calculateOperationForAnalytics(models, analytics, { + operator: 'FORMAT', + dataElement: 'result', + format: 'Hello: {value}', + }), + ).toBeRejectedWith( + 'Too many results passed to checkConditions (calculateOperationForAnalytics)', + ); + }); + + it('returns no data if no analytics match the dataElement', async () => { + const result = await calculateOperationForAnalytics(models, analytics, { + operator: 'FORMAT', + dataElement: 'NON_EXISTENT', + format: 'Hello: {value}', + }); + expect(result).toBe(NO_DATA_AVAILABLE); + }); + + it('returns correctly for valid case', async () => { + const result = await calculateOperationForAnalytics(models, analytics, { + operator: 'FORMAT', + dataElement: 'uniqueCodeForName', + format: 'Hello: {value}', + }); + expect(result).toBe('Hello: Octavia'); + }); + + it('returns correctly for multiple replacements', async () => { + const result = await calculateOperationForAnalytics(models, analytics, { + operator: 'FORMAT', + dataElement: 'uniqueCodeForName', + format: 'Hello: {value} and also {value}', + }); + expect(result).toBe('Hello: Octavia and also Octavia'); + }); + + it('returns correctly for no replacements', async () => { + const result = await calculateOperationForAnalytics(models, analytics, { + operator: 'FORMAT', + dataElement: 'uniqueCodeForName', + format: 'Hello: My friend', + }); + expect(result).toBe('Hello: My friend'); + }); + }); + + describe('COMBINE_BINARY_AS_STRING', () => { + it('returns the string "None" if no analytics match the dataElement', async () => { + const result = await calculateOperationForAnalytics(models, analytics, { + operator: 'COMBINE_BINARY_AS_STRING', + dataElementToString: { + NON_EXISTENT: 'Should not matter', + }, + }); + expect(result).toBe('None'); + }); + + it('returns the string "None" if there are no data values equal to "Yes"', async () => { + const result = await calculateOperationForAnalytics(models, analytics, { + operator: 'COMBINE_BINARY_AS_STRING', + dataElementToString: { + temperature: 'Explicitly not "Yes"', + }, + }); + expect(result).toBe('None'); + }); + + it('returns correctly for valid case', async () => { + const result = await calculateOperationForAnalytics(models, analytics, { + operator: 'COMBINE_BINARY_AS_STRING', + dataElementToString: { + Flower_found_Daisy: 'Daisy', + Flower_found_Tulip: 'Tulip', + }, + }); + expect(result).toBe('Daisy'); + }); + + it('returns correctly for multiple "Yes" values - order not guaranteed', async () => { + const flowerList = await calculateOperationForAnalytics(models, analytics, { + operator: 'COMBINE_BINARY_AS_STRING', + dataElementToString: { + Flower_found_Daisy: 'Daisy', + Flower_found_Tulip: 'Tulip', + Flower_found_Orchid: 'Orchid', + }, + }); + // This does assert that there is a duplicate 'Orchid' entry + expect(flowerList.split(', ')).toStrictEqual( + expect.arrayContaining(['Orchid', 'Orchid', 'Daisy']), + ); + }); + }); + + // TODO RN-676: skipping test cases until the tested functionality is fixed + describe.skip('GROUP', () => { + it('throws an error when passed too many analytics', async () => { + await expect( + calculateOperationForAnalytics(models, analytics, { + operator: 'GROUP', + dataElement: 'result', + groups: { + Marvel: { value: '(Black Widow)|(Iron Man)', operator: 'regex' }, + DC: { value: '(SuperGirl)|(Batman)', operator: 'regex' }, + }, + defaultValue: 'Not a superhero', + }), + ).toBeRejectedWith( + 'Too many results passed to checkConditions (calculateOperationForAnalytics)', + ); + }); + + it('returns no data if no analytics match the dataElement', async () => { + const result = await calculateOperationForAnalytics(models, analytics, { + operator: 'GROUP', + dataElement: 'NON_EXISTENT', + groups: { + Marvel: { value: '(Black Widow)|(Iron Man)', operator: 'regex' }, + DC: { value: '(SuperGirl)|(Batman)', operator: 'regex' }, + }, + defaultValue: 'Not a superhero', + }); + expect(result).toBe(NO_DATA_AVAILABLE); + }); + + it('returns correctly for valid cases', async () => { + const result1 = await calculateOperationForAnalytics(models, analytics, { + operator: 'GROUP', + dataElement: 'Best_Superhero1', + groups: { + Marvel: { value: '(Black Widow)|(Iron Man)', operator: 'regex' }, + DC: { value: '(SuperGirl)|(Batman)', operator: 'regex' }, + }, + defaultValue: 'Not a superhero', + }); + expect(result1).toBe('DC'); + + const result2 = await calculateOperationForAnalytics(models, analytics, { + operator: 'GROUP', + dataElement: 'Best_Superhero2', + groups: { + Marvel: { value: '(Black Widow)|(Iron Man)', operator: 'regex' }, + DC: { value: '(SuperGirl)|(Batman)', operator: 'regex' }, + }, + defaultValue: 'Not a superhero', + }); + expect(result2).toBe('Marvel'); + }); + + it('returns the default value correctly', async () => { + const result = await calculateOperationForAnalytics(models, analytics, { + operator: 'GROUP', + dataElement: 'Best_Superhero3', + groups: { + Marvel: { value: '(Black Widow)|(Iron Man)', operator: 'regex' }, + DC: { value: '(SuperGirl)|(Batman)', operator: 'regex' }, + }, + defaultValue: 'Not a superhero', + }); + expect(result).toBe('Not a superhero'); + }); + }); +}); diff --git a/packages/web-config-server/src/tests/apiV1/dataBuilders/helpers/checkAgainstConditions.test.js b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/helpers/checkAgainstConditions.test.js similarity index 98% rename from packages/web-config-server/src/tests/apiV1/dataBuilders/helpers/checkAgainstConditions.test.js rename to packages/web-config-server/src/__tests__/apiV1/dataBuilders/helpers/checkAgainstConditions.test.js index e958398dc8..abe38a17ab 100644 --- a/packages/web-config-server/src/tests/apiV1/dataBuilders/helpers/checkAgainstConditions.test.js +++ b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/helpers/checkAgainstConditions.test.js @@ -1,5 +1,3 @@ -import { expect } from 'chai'; - import { countEventsThatSatisfyConditions, countAnalyticsThatSatisfyConditions, @@ -15,7 +13,7 @@ describe('checkAgainstConditions', () => { { dataValues: { temperature: '', result: '' } }, ]; const assertCountOfEventsForConditions = (conditions, expectedResult) => - expect(countEventsThatSatisfyConditions(events, conditions)).to.equal(expectedResult); + expect(countEventsThatSatisfyConditions(events, conditions)).toBe(expectedResult); it('should return the event count when no conditions are specified', () => { assertCountOfEventsForConditions({}, events.length); @@ -99,7 +97,7 @@ describe('checkAgainstConditions', () => { { dataElement: 'result', value: 'Negative' }, ]; const assertCountOfAnalyticsForConditions = (conditions, expectedResult) => - expect(countAnalyticsThatSatisfyConditions(analytics, conditions)).to.equal(expectedResult); + expect(countAnalyticsThatSatisfyConditions(analytics, conditions)).toBe(expectedResult); it('should return 0 when no conditions are specified', () => { assertCountOfAnalyticsForConditions({}, 0); diff --git a/packages/web-config-server/src/tests/apiV1/dataBuilders/helpers/composeDataByDataClass.test.js b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/helpers/composeDataByDataClass.test.js similarity index 91% rename from packages/web-config-server/src/tests/apiV1/dataBuilders/helpers/composeDataByDataClass.test.js rename to packages/web-config-server/src/__tests__/apiV1/dataBuilders/helpers/composeDataByDataClass.test.js index 6cf826ad5a..181170d710 100644 --- a/packages/web-config-server/src/tests/apiV1/dataBuilders/helpers/composeDataByDataClass.test.js +++ b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/helpers/composeDataByDataClass.test.js @@ -1,8 +1,7 @@ -import { expect } from 'chai'; import { composeDataByDataClass } from '../../../../apiV1/dataBuilders/helpers/composeDataByDataClass'; describe('composeDataByDataClass', () => { - it('composeDataByDataClass', () => + it('composeDataByDataClass', async () => expect( composeDataByDataClass( [ @@ -34,7 +33,7 @@ describe('composeDataByDataClass', () => { }, }, ), - ).to.deep.equal([ + ).toStrictEqual([ { name: '10-19 years', Males: 80, Females: 75 }, { name: '20-29 years', Males: 508, Females: 497 }, ])); diff --git a/packages/web-config-server/src/__tests__/apiV1/dataBuilders/helpers/divideValues.test.js b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/helpers/divideValues.test.js new file mode 100644 index 0000000000..9f0928b691 --- /dev/null +++ b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/helpers/divideValues.test.js @@ -0,0 +1,31 @@ +import { divideValues } from '/apiV1/dataBuilders/helpers/divideValues'; +import { NO_DATA_AVAILABLE } from '/apiV1/dataBuilders/constants'; + +describe('divideValues()', () => { + it('numerator: defined, denominator: defined', () => { + expect(divideValues(1, 2)).toBe(0.5); + expect(divideValues(2, 2)).toBe(1); + }); + + it('numerator: not defined, denominator: any', () => { + expect(divideValues()).toBe(NO_DATA_AVAILABLE); + expect(divideValues(undefined)).toBe(NO_DATA_AVAILABLE); + expect(divideValues(undefined, 2)).toBe(NO_DATA_AVAILABLE); + expect(divideValues(null)).toBe(NO_DATA_AVAILABLE); + expect(divideValues(null, 2)).toBe(NO_DATA_AVAILABLE); + }); + + it('numerator: 0, denominator: defined not 0', () => { + expect(divideValues(0, 1)).toBe(0); + }); + + it('numerator: defined, denominator: not defined', () => { + expect(divideValues(1)).toBe(NO_DATA_AVAILABLE); + expect(divideValues(1, undefined)).toBe(NO_DATA_AVAILABLE); + expect(divideValues(1, null)).toBe(NO_DATA_AVAILABLE); + }); + + it('numerator: defined, denominator: 0', () => { + expect(divideValues(1, 0)).toBe(NO_DATA_AVAILABLE); + }); +}); diff --git a/packages/web-config-server/src/tests/apiV1/dataBuilders/helpers/eventMetadata/eventMetadata.fixtures.js b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/helpers/eventMetadata/eventMetadata.fixtures.js similarity index 100% rename from packages/web-config-server/src/tests/apiV1/dataBuilders/helpers/eventMetadata/eventMetadata.fixtures.js rename to packages/web-config-server/src/__tests__/apiV1/dataBuilders/helpers/eventMetadata/eventMetadata.fixtures.js diff --git a/packages/web-config-server/src/tests/apiV1/dataBuilders/helpers/eventMetadata/eventMetadata.test.js b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/helpers/eventMetadata/eventMetadata.test.js similarity index 76% rename from packages/web-config-server/src/tests/apiV1/dataBuilders/helpers/eventMetadata/eventMetadata.test.js rename to packages/web-config-server/src/__tests__/apiV1/dataBuilders/helpers/eventMetadata/eventMetadata.test.js index 39a73a277a..1447454ec7 100644 --- a/packages/web-config-server/src/tests/apiV1/dataBuilders/helpers/eventMetadata/eventMetadata.test.js +++ b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/helpers/eventMetadata/eventMetadata.test.js @@ -3,8 +3,6 @@ * Copyright (c) 2019 Beyond Essential Systems Pty Ltd */ -import { expect } from 'chai'; - import { isMetadataKey, metadataKeysToDataElementMap, @@ -15,32 +13,32 @@ import { testAddMetadataToEvents } from './testAddMetadataToEvents'; describe('eventMetadata', () => { describe('isMetadataKey()', () => { it('should identify a metadata key', () => { - Object.keys(METADATA_KEYS).forEach(key => expect(isMetadataKey(key)).to.be.true); + Object.keys(METADATA_KEYS).forEach(key => expect(isMetadataKey(key)).toBe(true)); }); it('should identify a non metadata key', () => { - ['wrongKey', 'CD1', 'eventOrgUnitName'].forEach( - key => expect(isMetadataKey(key)).to.be.false, + ['wrongKey', 'CD1', 'eventOrgUnitName'].forEach(key => + expect(isMetadataKey(key)).toBe(false), ); }); }); describe('metadataKeysToDataElementMap()', () => { it('should return an empty object for empty input', () => { - expect(metadataKeysToDataElementMap()).to.deep.equal({}); - expect(metadataKeysToDataElementMap([])).to.deep.equal({}); + expect(metadataKeysToDataElementMap()).toStrictEqual({}); + expect(metadataKeysToDataElementMap([])).toStrictEqual({}); }); it('should throw an error if an invalid key has been provided', () => { const assertErrorIsThrownForKeys = keys => - expect(() => metadataKeysToDataElementMap(keys)).to.throw('Invalid metadata key'); + expect(() => metadataKeysToDataElementMap(keys)).toThrow('Invalid metadata key'); assertErrorIsThrownForKeys(['invalidKey']); assertErrorIsThrownForKeys(['$eventOrgUnitName', 'invalidKey']); }); it('should create a map out of valid metadata keys', () => { - expect(metadataKeysToDataElementMap(['$eventOrgUnitName'])).to.deep.equal({ + expect(metadataKeysToDataElementMap(['$eventOrgUnitName'])).toStrictEqual({ $eventOrgUnitName: { name: 'Location', id: '$eventOrgUnitName', diff --git a/packages/web-config-server/src/tests/apiV1/dataBuilders/helpers/eventMetadata/testAddMetadataToEvents.js b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/helpers/eventMetadata/testAddMetadataToEvents.js similarity index 61% rename from packages/web-config-server/src/tests/apiV1/dataBuilders/helpers/eventMetadata/testAddMetadataToEvents.js rename to packages/web-config-server/src/__tests__/apiV1/dataBuilders/helpers/eventMetadata/testAddMetadataToEvents.js index e163d6dc1f..b2ceecb60e 100644 --- a/packages/web-config-server/src/tests/apiV1/dataBuilders/helpers/eventMetadata/testAddMetadataToEvents.js +++ b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/helpers/eventMetadata/testAddMetadataToEvents.js @@ -3,44 +3,44 @@ * Copyright (c) 2019 Beyond Essential Systems Pty Ltd */ -import { expect } from 'chai'; -import sinon from 'sinon'; - import { addMetadataToEvents } from '/apiV1/dataBuilders/helpers/eventMetadata'; import { EVENTS, ORG_UNITS } from './eventMetadata.fixtures'; export const testAddMetadataToEvents = () => { const models = { entity: { - find: sinon - .stub() - .callsFake(({ code }) => - ORG_UNITS.filter(({ code: currentCode }) => code.includes(currentCode)), - ), + find: ({ code }) => ORG_UNITS.filter(({ code: currentCode }) => code.includes(currentCode)), }, }; it('should throw an error if an invalid key has been provided', async () => { - const assertErrorIsThrownForKeys = async keys => - expect(addMetadataToEvents(models, [EVENTS.objectDataValue], keys)).to.be.rejectedWith( - 'Invalid metadata key', - ); + const events = [EVENTS.objectDataValues]; - await assertErrorIsThrownForKeys(['invalidKey']); - return assertErrorIsThrownForKeys(['$eventOrgUnitName', 'invalidKey']); + await expect(addMetadataToEvents(models, events, ['invalidKey'])).toBeRejectedWith( + 'Invalid metadata key', + ); + await expect( + addMetadataToEvents(models, events, ['$eventOrgUnitName', 'invalidKey']), + ).toBeRejectedWith('Invalid metadata key'); }); it('should return the events input if no metadata keys are provided', async () => { - const events = [EVENTS.objectDataValue1]; - await expect(addMetadataToEvents(models, events)).to.eventually.deep.equal(events); - return expect(addMetadataToEvents(models, events, [])).to.eventually.deep.equal(events); + const events = [EVENTS.objectDataValues]; + + await Promise.all( + [undefined, []].map(async metadataKeys => { + const results = await addMetadataToEvents(models, events, metadataKeys); + expect(results).toStrictEqual(events); + }), + ); }); it('should return an empty array if no events are provided', async () => { - await expect(addMetadataToEvents(models, [])).to.eventually.deep.equal([]); - await expect(addMetadataToEvents(models, [], [])).to.eventually.deep.equal([]); - return expect(addMetadataToEvents(models, [], ['$eventOrgUnitName'])).to.eventually.deep.equal( - [], + await Promise.all( + [undefined, [], ['$eventOrgUnitName']].map(async metadataKeys => { + const results = await addMetadataToEvents(models, [], metadataKeys); + expect(results).toStrictEqual([]); + }), ); }); @@ -54,16 +54,16 @@ export const testAddMetadataToEvents = () => { const events = [EVENTS.objectDataValues]; const eventsWithMetadata = await addMetadataToEvents(models, events, ['$eventOrgUnitName']); - expect(eventsWithMetadata).to.be.like(events); - expect(eventsWithMetadata[0].dataValues).to.have.property('$eventOrgUnitName'); + expect(eventsWithMetadata).toMatchObject(events); + expect(eventsWithMetadata[0].dataValues).toHaveProperty('$eventOrgUnitName'); }); it('should add metadata to events with no data values', async () => { const events = [EVENTS.objectNoDataValues]; const eventsWithMetadata = await addMetadataToEvents(models, events, ['$eventOrgUnitName']); - expect(eventsWithMetadata).to.be.like(events); - expect(eventsWithMetadata[0].dataValues).to.have.property('$eventOrgUnitName'); + expect(eventsWithMetadata).toMatchObject(events); + expect(eventsWithMetadata[0].dataValues).toHaveProperty('$eventOrgUnitName'); }); }); @@ -73,7 +73,7 @@ export const testAddMetadataToEvents = () => { const events = [EVENTS.objectDataValues]; const eventsWithMetadata = await addMetadataToEvents(models, events, ['$eventOrgUnitName']); - expect(eventsWithMetadata).to.deep.equal([ + expect(eventsWithMetadata).toStrictEqual([ { orgUnit: 'TO_Nukunuku', dataValues: { @@ -89,7 +89,7 @@ export const testAddMetadataToEvents = () => { const events = [EVENTS.unknownOrgUnit]; const eventsWithMetadata = await addMetadataToEvents(models, events, ['$eventOrgUnitName']); - expect(eventsWithMetadata).to.deep.equal([ + expect(eventsWithMetadata).toStrictEqual([ { orgUnit: 'Unknown_Org_Unit', dataValues: { diff --git a/packages/web-config-server/src/tests/apiV1/dataBuilders/helpers/fetchComposedData.test.js b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/helpers/fetchComposedData.test.js similarity index 58% rename from packages/web-config-server/src/tests/apiV1/dataBuilders/helpers/fetchComposedData.test.js rename to packages/web-config-server/src/__tests__/apiV1/dataBuilders/helpers/fetchComposedData.test.js index 2e9e0433e0..d4978b2800 100644 --- a/packages/web-config-server/src/tests/apiV1/dataBuilders/helpers/fetchComposedData.test.js +++ b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/helpers/fetchComposedData.test.js @@ -1,8 +1,9 @@ -import { expect } from 'chai'; -import sinon from 'sinon'; +/** + * Tupaia + * Copyright (c) 2017 - 2022 Beyond Essential Systems Pty Ltd + */ -import { Aggregator } from '@tupaia/aggregator'; -import { DhisApi } from '/dhis/DhisApi'; +import { createJestMockInstance } from '@tupaia/utils'; import { fetchComposedData } from '/apiV1/dataBuilders/helpers/fetchComposedData'; import * as GetDataBuilder from '/apiV1/dataBuilders/getDataBuilder'; @@ -12,11 +13,11 @@ const DATA_RESPONSES = { }; const DATA_BUILDERS = { countBuilder: { - stub: sinon.stub().returns(DATA_RESPONSES.countBuilder), + stub: jest.fn().mockResolvedValue(DATA_RESPONSES.countBuilder), config: { dataElementCode: 'STR_169' }, }, percentageBuilder: { - stub: sinon.stub().returns(DATA_RESPONSES.percentageBuilder), + stub: jest.fn().mockResolvedValue(DATA_RESPONSES.percentageBuilder), config: { limitRange: [0, 1] }, }, }; @@ -25,10 +26,10 @@ const query = { endPeriod: '201911', }; const dataServices = [{ isDataRegional: true }]; -const aggregator = sinon.createStubInstance(Aggregator); -const dhisApi = sinon.createStubInstance(DhisApi); +const aggregator = createJestMockInstance('@tupaia/aggregator', 'Aggregator'); +const dhisApi = createJestMockInstance('@tupaia/dhis-api', 'DhisApi'); -const callFetchComposedData = async () => { +describe('fetchComposedData()', () => { const dataBuilderConfig = { dataBuilders: { count: { @@ -43,41 +44,36 @@ const callFetchComposedData = async () => { dataServices, }; - return fetchComposedData({ dataBuilderConfig, query }, aggregator, dhisApi); -}; - -describe('fetchComposedData()', () => { - before(() => { - sinon - .stub(GetDataBuilder, 'getDataBuilder') - .callsFake(builderName => DATA_BUILDERS[builderName].stub); - }); - - after(() => { - GetDataBuilder.getDataBuilder.restore(); + beforeAll(() => { + jest + .spyOn(GetDataBuilder, 'getDataBuilder') + .mockImplementation(builderName => DATA_BUILDERS[builderName].stub); }); it('should throw an error if no data builders are provided', () => - expect(fetchComposedData({ dataBuilderConfig: {} })).to.be.rejectedWith('Data builders')); + expect(fetchComposedData({ dataBuilderConfig: {} })).toBeRejectedWith('Data builders')); it('should invoke the specified data builders with the expected arguments', async () => { - await callFetchComposedData(); + await fetchComposedData({ dataBuilderConfig, query }, aggregator, dhisApi); - expect(DATA_BUILDERS.countBuilder.stub).to.have.been.calledOnceWith( + expect(DATA_BUILDERS.countBuilder.stub).toHaveBeenCalledOnceWith( { dataBuilderConfig: { ...DATA_BUILDERS.countBuilder.config, dataServices }, query }, aggregator, dhisApi, ); - expect(DATA_BUILDERS.percentageBuilder.stub).to.have.been.calledOnceWith( + expect(DATA_BUILDERS.percentageBuilder.stub).toHaveBeenCalledOnceWith( { dataBuilderConfig: { ...DATA_BUILDERS.percentageBuilder.config, dataServices }, query }, aggregator, dhisApi, ); }); - it('should return a map of builder keys to data responses per builder ', async () => - expect(callFetchComposedData()).to.eventually.deep.equal({ + it('should return a map of builder keys to data responses per builder ', async () => { + const response = await fetchComposedData({ dataBuilderConfig, query }, aggregator, dhisApi); + + expect(response).toStrictEqual({ count: DATA_RESPONSES.countBuilder, percentage: DATA_RESPONSES.percentageBuilder, - })); + }); + }); }); diff --git a/packages/web-config-server/src/__tests__/apiV1/dataBuilders/helpers/getCategoryPresentationOption.test.js b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/helpers/getCategoryPresentationOption.test.js new file mode 100644 index 0000000000..c2885e18d9 --- /dev/null +++ b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/helpers/getCategoryPresentationOption.test.js @@ -0,0 +1,49 @@ +import { getCategoryPresentationOption } from '/apiV1/dataBuilders/helpers/getCategoryPresentationOption'; + +describe('getCategoryPresentationOption()', () => { + const config = { + type: '$condition', + conditions: [ + { + key: 'red', + condition: { + in: [null, 0], + }, + }, + { + key: 'green', + condition: { + '>': 0, + }, + }, + { + key: 'orange', + condition: { + someNotAll: { '>': 0 }, + }, + }, + ], + }; + + describe('type: $condition', () => { + it('condition: someNotAll', () => { + expect(getCategoryPresentationOption(config, [null, null, 2, null])).toBe('orange'); + expect(getCategoryPresentationOption(config, [1, 1, 3, 0])).toBe('orange'); + }); + + it('condition: >', () => { + expect(getCategoryPresentationOption(config, [1, 1, 2, 2])).toBe('green'); + expect(getCategoryPresentationOption(config, [1, 1, 3, 4])).toBe('green'); + }); + + it('condition: in', () => { + expect(getCategoryPresentationOption(config, [0, 0, 0, 0])).toBe('red'); + expect(getCategoryPresentationOption(config, [0, null, 0, null])).toBe('red'); + }); + + it('no condition meet', () => { + expect(getCategoryPresentationOption(config, [null, null, -2, null])).toBe(undefined); + expect(getCategoryPresentationOption(config, [-1, -1, -3, 0])).toBe(undefined); + }); + }); +}); diff --git a/packages/web-config-server/src/tests/apiV1/dataBuilders/helpers/groupEvents.test.js b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/helpers/groupEvents.test.js similarity index 72% rename from packages/web-config-server/src/tests/apiV1/dataBuilders/helpers/groupEvents.test.js rename to packages/web-config-server/src/__tests__/apiV1/dataBuilders/helpers/groupEvents.test.js index c66fad5efb..3b8272a4df 100644 --- a/packages/web-config-server/src/tests/apiV1/dataBuilders/helpers/groupEvents.test.js +++ b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/helpers/groupEvents.test.js @@ -2,9 +2,6 @@ * Tupaia * Copyright (c) 2017 - 2020 Beyond Essential Systems Pty Ltd */ -import { expect } from 'chai'; -import sinon from 'sinon'; - import { groupEvents, getAllDataElementCodes } from '/apiV1/dataBuilders/helpers/groupEvents'; const EVENTS = [ @@ -111,7 +108,7 @@ const PARENT_ORG_UNIT_WITH_CHILDREN_WITH_NON_UNIQUE_NAMES = { const models = { entity: { - findOne: sinon.stub().callsFake(({ code }) => { + findOne: ({ code }) => { switch (code) { case PARENT_ORG_UNIT.code: return PARENT_ORG_UNIT; @@ -126,58 +123,60 @@ const models = { default: return null; } - }), + }, }, }; describe('groupEvents()', () => { - it('groups by nothing', () => - expect(groupEvents(models, EVENTS, { type: 'nothing' })).to.eventually.deep.equal({ + it('groups by nothing', async () => { + const response = await groupEvents(models, EVENTS, { type: 'nothing' }); + expect(response).toStrictEqual({ all: [...EVENTS], - })); + }); + }); it('rejects unknown groupBy type', async () => { - const assertCorrectErrorIsThrown = groupBySpecs => - expect(groupEvents(models, EVENTS, groupBySpecs)).to.be.rejectedWith( - 'not a supported groupBy type', - ); - - return Promise.all([{ type: 'unknownType' }, { type: '' }, {}].map(assertCorrectErrorIsThrown)); + await Promise.all( + [{ type: 'unknownType' }, { type: '' }, {}].map(groupBySpecs => + expect(groupEvents(models, EVENTS, groupBySpecs)).toBeRejectedWith( + 'not a supported groupBy type', + ), + ), + ); }); describe('getAllDataElementCodes', () => { - it('returns empty by default', () => - expect( - getAllDataElementCodes({ - type: 'allOrgUnitNames', - options: { parentCode: 'TO', type: 'district' }, - }), - ).to.deep.equal([])); + it('returns empty by default', async () => { + const results = getAllDataElementCodes({ + type: 'allOrgUnitNames', + options: { parentCode: 'TO', type: 'district' }, + }); + expect(results).toStrictEqual([]); + }); - it('finds all data element codes used in conditionals', () => - expect( - getAllDataElementCodes({ - type: 'dataValues', - options: { - GROUPING_1: { - dataValues: { A: { operator: '=', value: '10' } }, - }, - GROUPING_2: { - dataValues: { B: { operator: '=', value: '21' } }, - }, + it('finds all data element codes used in conditionals', async () => { + const results = getAllDataElementCodes({ + type: 'dataValues', + options: { + GROUPING_1: { + dataValues: { A: { operator: '=', value: '10' } }, + }, + GROUPING_2: { + dataValues: { B: { operator: '=', value: '21' } }, }, - }), - ).to.deep.equal(['A', 'B'])); + }, + }); + expect(results).toStrictEqual(['A', 'B']); + }); }); describe('type: allOrgUnitNames', () => { - it('groups', () => - expect( - groupEvents(models, EVENTS, { - type: 'allOrgUnitNames', - options: { parentCode: 'TO', type: 'district' }, - }), - ).to.eventually.deep.equal({ + it('groups', async () => { + const response = await groupEvents(models, EVENTS, { + type: 'allOrgUnitNames', + options: { parentCode: 'TO', type: 'district' }, + }); + expect(response).toStrictEqual({ Tongatapu: [ { orgUnit: 'TO_Tongatapu', @@ -198,9 +197,10 @@ describe('groupEvents()', () => { }, ], Haapai: [], - })); + }); + }); - it('can handle non-unique org unit names', () => { + it('can handle non-unique org unit names', async () => { const events = [ { orgUnit: 'NZ_WELLINGTON_CITY', @@ -219,12 +219,11 @@ describe('groupEvents()', () => { }, // same org unit name ]; - return expect( - groupEvents(models, events, { - type: 'allOrgUnitNames', - options: { parentCode: 'NZ', type: 'district' }, - }), - ).to.eventually.deep.equal({ + const response = await groupEvents(models, events, { + type: 'allOrgUnitNames', + options: { parentCode: 'NZ', type: 'district' }, + }); + return expect(response).toStrictEqual({ Wellington: [ { orgUnit: 'NZ_WELLINGTON_CITY', @@ -251,7 +250,7 @@ describe('groupEvents()', () => { }); describe('type: allOrgUnitParentNames', () => { - it('groups', () => { + it('groups', async () => { const events = [ { orgUnit: 'MLB_C', @@ -262,12 +261,11 @@ describe('groupEvents()', () => { { orgUnit: 'SYD_U', orgUnitName: 'Ultimo', dataValues: { A: '3' } }, ]; - expect( - groupEvents(models, events, { - type: 'allOrgUnitParentNames', - options: { parentCode: 'AU1', type: 'district' }, - }), - ).to.eventually.deep.equal({ + const response = await groupEvents(models, events, { + type: 'allOrgUnitParentNames', + options: { parentCode: 'AU1', type: 'district' }, + }); + expect(response).toStrictEqual({ Melbourne: [ { orgUnit: 'MLB_C', @@ -290,7 +288,7 @@ describe('groupEvents()', () => { }); }); - it('can handle non-unique org unit names', () => { + it('can handle non-unique org unit names', async () => { const events = [ { orgUnit: 'MLB_C', @@ -300,12 +298,11 @@ describe('groupEvents()', () => { { orgUnit: 'SYD_U', orgUnitName: 'Ultimo', dataValues: { A: '2' } }, ]; - expect( - groupEvents(models, events, { - type: 'allOrgUnitParentNames', - options: { parentCode: 'AU2', type: 'district' }, - }), - ).to.eventually.deep.equal({ + const response = await groupEvents(models, events, { + type: 'allOrgUnitParentNames', + options: { parentCode: 'AU2', type: 'district' }, + }); + expect(response).toStrictEqual({ 'Other (MLB)': [ { orgUnit: 'MLB_C', @@ -323,7 +320,7 @@ describe('groupEvents()', () => { }); }); - it('can handle non-unique child org unit names', () => { + it('can handle non-unique child org unit names', async () => { const events = [ { orgUnit: 'MLB_C', @@ -335,12 +332,11 @@ describe('groupEvents()', () => { { orgUnit: 'SYD_O', orgUnitName: 'Other', dataValues: { A: '4' } }, // same child org unit name ]; - expect( - groupEvents(models, events, { - type: 'allOrgUnitParentNames', - options: { parentCode: 'AU3', type: 'district' }, - }), - ).to.eventually.deep.equal({ + const response = await groupEvents(models, events, { + type: 'allOrgUnitParentNames', + options: { parentCode: 'AU3', type: 'district' }, + }); + expect(response).toStrictEqual({ Melbourne: [ { orgUnit: 'MLB_C', @@ -376,20 +372,19 @@ describe('groupEvents()', () => { { dataValues: { C: '30' } }, ]; - it('groups', () => - expect( - groupEvents(models, events, { - type: 'dataValues', - options: { - GROUPING_1: { - dataValues: { A: { operator: '=', value: '10' } }, - }, - GROUPING_2: { - dataValues: { B: { operator: '=', value: '21' } }, - }, + it('groups', async () => { + const response = await groupEvents(models, events, { + type: 'dataValues', + options: { + GROUPING_1: { + dataValues: { A: { operator: '=', value: '10' } }, }, - }), - ).to.eventually.deep.equal({ + GROUPING_2: { + dataValues: { B: { operator: '=', value: '21' } }, + }, + }, + }); + expect(response).toStrictEqual({ GROUPING_1: [ { dataValues: { A: '10', B: '20' }, @@ -401,24 +396,25 @@ describe('groupEvents()', () => { dataValues: { A: '10', B: '21' }, }, ], - })); + }); + }); - it('uses a union type condition check', () => - expect( - groupEvents(models, events, { - type: 'dataValues', - options: { - GROUPING_1: { - dataValues: { A: { operator: '=', value: '10' }, B: { operator: '=', value: '21' } }, - }, + it('uses a union type condition check', async () => { + const response = await groupEvents(models, events, { + type: 'dataValues', + options: { + GROUPING_1: { + dataValues: { A: { operator: '=', value: '10' }, B: { operator: '=', value: '21' } }, }, - }), - ).to.eventually.deep.equal({ + }, + }); + expect(response).toStrictEqual({ GROUPING_1: [ { dataValues: { A: '10', B: '21' }, }, ], - })); + }); + }); }); }); diff --git a/packages/web-config-server/src/tests/apiV1/dataBuilders/helpers/mapMeasureDataToCountries.test.js b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/helpers/mapMeasureDataToCountries.test.js similarity index 80% rename from packages/web-config-server/src/tests/apiV1/dataBuilders/helpers/mapMeasureDataToCountries.test.js rename to packages/web-config-server/src/__tests__/apiV1/dataBuilders/helpers/mapMeasureDataToCountries.test.js index f6786a5750..3da2726f85 100644 --- a/packages/web-config-server/src/tests/apiV1/dataBuilders/helpers/mapMeasureDataToCountries.test.js +++ b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/helpers/mapMeasureDataToCountries.test.js @@ -2,10 +2,7 @@ * Tupaia * Copyright (c) 2017 - 2020 Beyond Essential Systems Pty Ltd */ -import { expect } from 'chai'; -import { getTestModels } from '../../../getTestModels'; -import { upsertDummyRecord } from '@tupaia/database'; - +import { getTestModels, upsertDummyRecord } from '@tupaia/database'; import { mapMeasureDataToCountries } from '/apiV1/measureBuilders/helpers'; const ANALYTICS = [ @@ -26,7 +23,7 @@ const ENTITY_COUNTRY_CODE = 'DL'; let models; describe('mapMeasureDataToCountries()', () => { - before(async () => { + beforeAll(async () => { models = getTestModels(); await upsertDummyRecord(models.entity, { code: 'TEST_FACILITY', @@ -37,7 +34,7 @@ describe('mapMeasureDataToCountries()', () => { it('replace facility orgUnit codes with their corresponding country codes', async () => { const countryAnalytics = await mapMeasureDataToCountries(models, ANALYTICS); countryAnalytics.forEach(analytic => { - expect(analytic.organisationUnitCode).to.equal(ENTITY_COUNTRY_CODE); + expect(analytic.organisationUnitCode).toBe(ENTITY_COUNTRY_CODE); }); }); }); diff --git a/packages/web-config-server/src/tests/apiV1/dataBuilders/modules/covid-samoa/flight/Flight.test.js b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/modules/covid-samoa/flight/Flight.test.js similarity index 60% rename from packages/web-config-server/src/tests/apiV1/dataBuilders/modules/covid-samoa/flight/Flight.test.js rename to packages/web-config-server/src/__tests__/apiV1/dataBuilders/modules/covid-samoa/flight/Flight.test.js index c975bde901..64ac03e79e 100644 --- a/packages/web-config-server/src/tests/apiV1/dataBuilders/modules/covid-samoa/flight/Flight.test.js +++ b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/modules/covid-samoa/flight/Flight.test.js @@ -3,7 +3,6 @@ * Copyright (c) 2019 Beyond Essential Systems Pty Ltd */ -import { expect } from 'chai'; import { Flight } from '../../../../../../apiV1/dataBuilders/modules/covid-samoa/flight'; describe('Flight', () => { @@ -21,15 +20,15 @@ describe('Flight', () => { }, ]); - expect(flights.length).to.equal(2); + expect(flights.length).toBe(2); - expect(flights[0].key).to.equal('2020-10-30'); - expect(flights[0].date).to.equal('2020-10-30'); - expect(flights[0].events.length).to.equal(2); + expect(flights[0].key).toBe('2020-10-30'); + expect(flights[0].date).toBe('2020-10-30'); + expect(flights[0].events.length).toBe(2); - expect(flights[1].key).to.equal('2020-11-05'); - expect(flights[1].date).to.equal('2020-11-05'); - expect(flights[1].events.length).to.equal(1); + expect(flights[1].key).toBe('2020-11-05'); + expect(flights[1].date).toBe('2020-11-05'); + expect(flights[1].events.length).toBe(1); }); it('skips events that do not have a flight date', () => { @@ -42,10 +41,10 @@ describe('Flight', () => { }, ]); - expect(flights.length).to.equal(1); + expect(flights.length).toBe(1); - expect(flights[0].events.length).to.equal(1); - expect(flights[0].events[0].dataValues.QMIA028).to.equal('2020-10-30T22:46:00+14:00'); + expect(flights[0].events.length).toBe(1); + expect(flights[0].events[0].dataValues.QMIA028).toBe('2020-10-30T22:46:00+14:00'); }); }); }); diff --git a/packages/web-config-server/src/tests/apiV1/dataBuilders/modules/covid-samoa/flight/flightAgeRanges.test.js b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/modules/covid-samoa/flight/flightAgeRanges.test.js similarity index 72% rename from packages/web-config-server/src/tests/apiV1/dataBuilders/modules/covid-samoa/flight/flightAgeRanges.test.js rename to packages/web-config-server/src/__tests__/apiV1/dataBuilders/modules/covid-samoa/flight/flightAgeRanges.test.js index 6e925198f7..921aa52ad1 100644 --- a/packages/web-config-server/src/tests/apiV1/dataBuilders/modules/covid-samoa/flight/flightAgeRanges.test.js +++ b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/modules/covid-samoa/flight/flightAgeRanges.test.js @@ -3,7 +3,6 @@ * Copyright (c) 2019 Beyond Essential Systems Pty Ltd */ -import { expect } from 'chai'; import { Flight, getPassengersPerDataValue, @@ -27,12 +26,12 @@ describe('flightAgeRanges', () => { }, ]; - expect(getTotalNumPassengers(flight)).to.equal(3); + expect(getTotalNumPassengers(flight)).toBe(3); const passengersPerAgeRange = getPassengersPerAgeRange(flight); - expect(passengersPerAgeRange['0_4'].numPassengers).to.equal(2); - expect(passengersPerAgeRange['40_44'].numPassengers).to.equal(1); + expect(passengersPerAgeRange['0_4'].numPassengers).toBe(2); + expect(passengersPerAgeRange['40_44'].numPassengers).toBe(1); }); it('skips events that do not have an age, or are outside of the age brackets', () => { @@ -50,11 +49,11 @@ describe('flightAgeRanges', () => { }, ]; - expect(getTotalNumPassengers(flight)).to.equal(3); + expect(getTotalNumPassengers(flight)).toBe(3); const passengersPerAgeRange = getPassengersPerAgeRange(flight); - expect(passengersPerAgeRange['0_4'].numPassengers).to.equal(1); + expect(passengersPerAgeRange['0_4'].numPassengers).toBe(1); }); it('can count passengers per data_value', () => { @@ -77,13 +76,13 @@ describe('flightAgeRanges', () => { const dataValues = [['QMIA031'], ['QMIA032'], ['QMIA033']]; - expect(getTotalNumPassengers(flight)).to.equal(4); + expect(getTotalNumPassengers(flight)).toBe(4); const passengersPerDataValue = getPassengersPerDataValue(flight, dataValues, false); - expect(passengersPerDataValue.QMIA031.numPassengers).to.equal(2); - expect(passengersPerDataValue.QMIA032.numPassengers).to.equal(1); - expect(passengersPerDataValue.QMIA033.numPassengers).to.equal(0); + expect(passengersPerDataValue.QMIA031.numPassengers).toBe(2); + expect(passengersPerDataValue.QMIA032.numPassengers).toBe(1); + expect(passengersPerDataValue.QMIA033.numPassengers).toBe(0); }); it('can count passengers per data_value with specified value', () => { @@ -109,11 +108,11 @@ describe('flightAgeRanges', () => { ['QMIA009', 'M'], ]; - expect(getTotalNumPassengers(flight)).to.equal(4); + expect(getTotalNumPassengers(flight)).toBe(4); const passengersPerDataValue = getPassengersPerDataValue(flight, dataValues, true); - expect(passengersPerDataValue.QMIA009_M.numPassengers).to.equal(1); - expect(passengersPerDataValue.QMIA009_F.numPassengers).to.equal(3); + expect(passengersPerDataValue.QMIA009_M.numPassengers).toBe(1); + expect(passengersPerDataValue.QMIA009_F.numPassengers).toBe(3); }); }); diff --git a/packages/web-config-server/src/tests/apiV1/dataBuilders/transform.test.js b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/transform.test.js similarity index 59% rename from packages/web-config-server/src/tests/apiV1/dataBuilders/transform.test.js rename to packages/web-config-server/src/__tests__/apiV1/dataBuilders/transform.test.js index 7bccac1526..6b1444741d 100644 --- a/packages/web-config-server/src/tests/apiV1/dataBuilders/transform.test.js +++ b/packages/web-config-server/src/__tests__/apiV1/dataBuilders/transform.test.js @@ -3,9 +3,6 @@ * Copyright (c) 2019 Beyond Essential Systems Pty Ltd */ -import { expect } from 'chai'; -import sinon from 'sinon'; - import { transformValue, transformObject } from 'apiV1/dataBuilders/transform'; const ORG_UNITS = { @@ -21,25 +18,28 @@ const ORG_UNITS = { const models = { entity: { - findOne: sinon.stub().callsFake(({ code }) => ORG_UNITS[code] || null), + findOne: ({ code }) => ORG_UNITS[code] || null, }, }; describe('transform', () => { describe('transformValue()', () => { - it('should throw an error for an invalid transformation type', async () => - expect(transformValue(models, 'invalidType')).to.be.rejectedWith('Invalid transformation')); + it('should throw an error for an invalid transformation type', async () => { + await expect(transformValue(models, 'invalidType')).toBeRejectedWith( + 'Invalid transformation', + ); + }); describe('transformation: orgUnitCodeToName', () => { - it('should return the name of an org unit given its code', async () => - expect(transformValue(models, 'orgUnitCodeToName', ORG_UNITS.FJ.code)).to.eventually.equal( - ORG_UNITS.FJ.name, - )); + it('should return the name of an org unit given its code', async () => { + const result = await transformValue(models, 'orgUnitCodeToName', ORG_UNITS.FJ.code); + expect(result).toBe(ORG_UNITS.FJ.name); + }); - it('should use the input if the org unit is not found', async () => - expect(transformValue(models, 'orgUnitCodeToName', 'wrongCode')).to.eventually.equal( - 'wrongCode', - )); + it('should use the input if the org unit is not found', async () => { + const result = await transformValue(models, 'orgUnitCodeToName', 'wrongCode'); + expect(result).toBe('wrongCode'); + }); }); }); @@ -48,24 +48,24 @@ describe('transform', () => { await Promise.all( [undefined, null, {}].map(async object => { const result = await transformObject(models, 'orgUnitCodeToName', object); - expect(result).to.deep.equal({}); + expect(result).toStrictEqual({}); }), ); }); describe('transformation: orgUnitCodeToName', () => { - it('should transform org unit codes to names', async () => - expect( - transformObject(models, 'orgUnitCodeToName', { - site1: ORG_UNITS.FJ.code, - site2: ORG_UNITS.PG.code, - site3: 'noMatchingOrgUnit', - }), - ).to.eventually.deep.equal({ + it('should transform org unit codes to names', async () => { + const results = await transformObject(models, 'orgUnitCodeToName', { + site1: ORG_UNITS.FJ.code, + site2: ORG_UNITS.PG.code, + site3: 'noMatchingOrgUnit', + }); + expect(results).toStrictEqual({ site1: ORG_UNITS.FJ.name, site2: ORG_UNITS.PG.name, site3: 'noMatchingOrgUnit', - })); + }); + }); }); }); }); diff --git a/packages/web-config-server/src/tests/apiV1/measureBuilders/composePercentagePerOrgUnit.test.js b/packages/web-config-server/src/__tests__/apiV1/measureBuilders/composePercentagePerOrgUnit.test.js similarity index 77% rename from packages/web-config-server/src/tests/apiV1/measureBuilders/composePercentagePerOrgUnit.test.js rename to packages/web-config-server/src/__tests__/apiV1/measureBuilders/composePercentagePerOrgUnit.test.js index 545114e725..af04d4fe8b 100644 --- a/packages/web-config-server/src/tests/apiV1/measureBuilders/composePercentagePerOrgUnit.test.js +++ b/packages/web-config-server/src/__tests__/apiV1/measureBuilders/composePercentagePerOrgUnit.test.js @@ -3,31 +3,26 @@ * Copyright (c) 2019 Beyond Essential Systems Pty Ltd */ -import { expect } from 'chai'; -import sinon from 'sinon'; +import { when } from 'jest-when'; +import { createJestMockInstance } from '@tupaia/utils'; import { composePercentagePerOrgUnit } from '/apiV1/measureBuilders/composePercentagePerOrgUnit'; import * as FetchComposedData from '/apiV1/measureBuilders/helpers'; -const models = {}; -const aggregator = {}; -const dhisApi = {}; -const config = {}; +const models = createJestMockInstance('@tupaia/database', 'ModelRegistry'); +const aggregator = createJestMockInstance('@tupaia/aggregator', 'Aggregator'); +const dhisApi = createJestMockInstance('@tupaia/dhis-api', 'DhisApi'); +const config = { dataBuilderConfig: {} }; const query = { dataElementCode: 'value' }; const stubFetchComposedData = expectedResults => { - const fetchComposedDataStub = sinon.stub(FetchComposedData, 'fetchComposedData'); - fetchComposedDataStub - .returns({}) - .withArgs(models, aggregator, dhisApi, query, config) - .returns(expectedResults); + const fetchComposedData = jest.spyOn(FetchComposedData, 'fetchComposedData'); + when(fetchComposedData) + .calledWith(models, aggregator, dhisApi, query, config) + .mockResolvedValue(expectedResults); }; describe('composePercentagePerOrgUnit', () => { - afterEach(() => { - FetchComposedData.fetchComposedData.restore(); - }); - it('should compose data per org unit into percentages', async () => { stubFetchComposedData({ numerator: { @@ -44,9 +39,8 @@ describe('composePercentagePerOrgUnit', () => { }, }); - return expect( - composePercentagePerOrgUnit(models, aggregator, dhisApi, query, config), - ).to.eventually.deep.equal({ + const response = await composePercentagePerOrgUnit(models, aggregator, dhisApi, query, config); + expect(response).toStrictEqual({ data: [ { name: 'Kolonga', @@ -81,9 +75,8 @@ describe('composePercentagePerOrgUnit', () => { }, }); - return expect( - composePercentagePerOrgUnit(models, aggregator, dhisApi, query, config), - ).to.eventually.deep.equal({ + const response = await composePercentagePerOrgUnit(models, aggregator, dhisApi, query, config); + expect(response).toStrictEqual({ data: [ { name: 'Kolonga', @@ -122,9 +115,8 @@ describe('composePercentagePerOrgUnit', () => { }, }); - return expect( - composePercentagePerOrgUnit(models, aggregator, dhisApi, query, config), - ).to.eventually.deep.equal({ + const response = await composePercentagePerOrgUnit(models, aggregator, dhisApi, query, config); + expect(response).toStrictEqual({ data: [ { name: 'Kolonga', diff --git a/packages/web-config-server/src/tests/apiV1/measureBuilders/groupEventsPerOrgUnit.test.js b/packages/web-config-server/src/__tests__/apiV1/measureBuilders/groupEventsPerOrgUnit.test.js similarity index 84% rename from packages/web-config-server/src/tests/apiV1/measureBuilders/groupEventsPerOrgUnit.test.js rename to packages/web-config-server/src/__tests__/apiV1/measureBuilders/groupEventsPerOrgUnit.test.js index 01ed69e1f3..6f02f23d2d 100644 --- a/packages/web-config-server/src/tests/apiV1/measureBuilders/groupEventsPerOrgUnit.test.js +++ b/packages/web-config-server/src/__tests__/apiV1/measureBuilders/groupEventsPerOrgUnit.test.js @@ -3,8 +3,7 @@ * Copyright (c) 2020 Beyond Essential Systems Pty Ltd */ -import { expect } from 'chai'; -import sinon from 'sinon'; +import { when } from 'jest-when'; import { groupEventsPerOrgUnit } from '/apiV1/measureBuilders/groupEventsPerOrgUnit'; @@ -90,10 +89,9 @@ const events = [ ]; const createAggregator = () => { - const fetchEvents = sinon.stub(); - fetchEvents - .resolves([]) - .withArgs(programCode, { + const fetchEvents = jest.fn(); + when(fetchEvents) + .calledWith(programCode, { dataServices, entityAggregation: config.entityAggregation, dataSourceEntityFilter, @@ -103,7 +101,7 @@ const createAggregator = () => { trackedEntityInstance: undefined, eventId: undefined, }) - .resolves(events); + .mockResolvedValue(events); return { fetchEvents, @@ -111,10 +109,11 @@ const createAggregator = () => { }; describe('groupEventsPerOrgUnit', () => { + const aggregator = createAggregator(); + it('should group counts of events into buckets', async () => { - return expect( - groupEventsPerOrgUnit(models, createAggregator(), {}, query, config, entity), - ).to.eventually.deep.equal({ + const response = await groupEventsPerOrgUnit(models, aggregator, {}, query, config, entity); + expect(response).toStrictEqual({ data: [ { organisationUnitCode: 'oneEventLand', value: 'lessThanTwo', originalValue: 1 }, { organisationUnitCode: 'twoEventLand', value: 'twoToThree', originalValue: 2 }, @@ -135,9 +134,9 @@ describe('groupEventsPerOrgUnit', () => { }, }, }; - return expect( - groupEventsPerOrgUnit(models, createAggregator(), {}, query, newConfig, entity), - ).to.eventually.deep.equal({ + + const response = await groupEventsPerOrgUnit(models, aggregator, {}, query, newConfig, entity); + expect(response).toStrictEqual({ data: [ { organisationUnitCode: 'oneEventLand', value: 1, originalValue: 1 }, { organisationUnitCode: 'twoEventLand', value: 2, originalValue: 2 }, @@ -158,8 +157,9 @@ describe('groupEventsPerOrgUnit', () => { }, }, }; - return expect( - groupEventsPerOrgUnit(models, createAggregator(), {}, query, newConfig, entity), - ).to.be.rejectedWith("Unknown operator: 'no-op'"); + + expect( + groupEventsPerOrgUnit(models, aggregator, {}, query, newConfig, entity), + ).toBeRejectedWith("Unknown operator: 'no-op'"); }); }); diff --git a/packages/web-config-server/src/tests/apiV1/utils/getAggregatePeriod.test.js b/packages/web-config-server/src/__tests__/apiV1/utils/getAggregatePeriod.test.js similarity index 73% rename from packages/web-config-server/src/tests/apiV1/utils/getAggregatePeriod.test.js rename to packages/web-config-server/src/__tests__/apiV1/utils/getAggregatePeriod.test.js index e58e5d7771..8741ae1de3 100644 --- a/packages/web-config-server/src/tests/apiV1/utils/getAggregatePeriod.test.js +++ b/packages/web-config-server/src/__tests__/apiV1/utils/getAggregatePeriod.test.js @@ -2,9 +2,6 @@ * Tupaia MediTrak * Copyright (c) 2020 Beyond Essential Systems Pty Ltd */ -import { expect } from 'chai'; -import { it, describe } from 'mocha'; - import { getAggregatePeriod } from '/apiV1/utils/getAggregatePeriod'; const periods = [ @@ -27,18 +24,18 @@ const periods = [ describe('getAggregatePeriod()', () => { it('should return null if periods is empty or falsey, or only contains falsey values', () => { - expect(getAggregatePeriod()).to.equal(null); - expect(getAggregatePeriod(null)).to.equal(null); - expect(getAggregatePeriod([])).to.equal(null); - expect(getAggregatePeriod([undefined, null])).to.equal(null); + expect(getAggregatePeriod()).toBe(null); + expect(getAggregatePeriod(null)).toBe(null); + expect(getAggregatePeriod([])).toBe(null); + expect(getAggregatePeriod([undefined, null])).toBe(null); }); it('should return an unadulterated period if there is only 1 period passed in', () => { - expect(getAggregatePeriod([periods[0]])).to.deep.equal(periods[0]); + expect(getAggregatePeriod([periods[0]])).toStrictEqual(periods[0]); }); it('should return an unadulterated period if there is only 1 truthy period passed in', () => { - expect(getAggregatePeriod([undefined, periods[0], null, false])).to.deep.equal(periods[0]); + expect(getAggregatePeriod([undefined, periods[0], null, false])).toStrictEqual(periods[0]); }); it('should return an aggregate period for 2 inputs', () => { @@ -48,7 +45,7 @@ describe('getAggregatePeriod()', () => { requested: 'WILL_ALWAYS_BE_RETURNED_AS_IT_IS_AT_INDEX_0', }; - expect(getAggregatePeriod([periods[0], periods[1]])).to.deep.equal(expectedResponse); + expect(getAggregatePeriod([periods[0], periods[1]])).toStrictEqual(expectedResponse); }); it('should return an aggregate period for more than 2 inputs', () => { @@ -58,6 +55,6 @@ describe('getAggregatePeriod()', () => { requested: 'WILL_ALWAYS_BE_RETURNED_AS_IT_IS_AT_INDEX_0', }; - expect(getAggregatePeriod(periods)).to.deep.equal(expectedResponse); + expect(getAggregatePeriod(periods)).toStrictEqual(expectedResponse); }); }); diff --git a/packages/web-config-server/src/tests/apiV1/utils/layerYearOnYear.test.js b/packages/web-config-server/src/__tests__/apiV1/utils/layerYearOnYear.test.js similarity index 88% rename from packages/web-config-server/src/tests/apiV1/utils/layerYearOnYear.test.js rename to packages/web-config-server/src/__tests__/apiV1/utils/layerYearOnYear.test.js index aedf670864..be4d3cafb8 100644 --- a/packages/web-config-server/src/tests/apiV1/utils/layerYearOnYear.test.js +++ b/packages/web-config-server/src/__tests__/apiV1/utils/layerYearOnYear.test.js @@ -5,7 +5,6 @@ import { arrayToAnalytics } from '@tupaia/data-broker'; import { layerYearOnYear } from '../../../apiV1/utils/layerYearOnYear'; -import { expect } from 'chai'; describe('layerYearOnYear()', () => { it('layers the years on previous years', () => { @@ -22,7 +21,7 @@ describe('layerYearOnYear()', () => { ['BCD1_3_yr_ago', 'TO', '20200101', 3], ['BCD1_3_yr_ago', 'TO', '20200102', 4], // this is folded into latest year's data ]); - expect(layerYearOnYear(analytics)).to.deep.equal(expected); + expect(layerYearOnYear(analytics)).toStrictEqual(expected); }); it('it works with different period types', () => { @@ -40,13 +39,13 @@ describe('layerYearOnYear()', () => { ['BCD1_2_yr_ago', 'TO', '2021W09', 3], ['BCD1_2_yr_ago', 'TO', '2021W10', 4], // this is folded into latest year's data ]); - expect(layerYearOnYear(analytics)).to.deep.equal(expected); + expect(layerYearOnYear(analytics)).toStrictEqual(expected); }); it('it can handle empty input', () => { // 'YYYYW01' eg const analytics = arrayToAnalytics([]); const expected = arrayToAnalytics([]); - expect(layerYearOnYear(analytics)).to.deep.equal(expected); + expect(layerYearOnYear(analytics)).toStrictEqual(expected); }); }); diff --git a/packages/web-config-server/src/tests/apiV1/utils/mapOrgUnitCodeToGroup.test.js b/packages/web-config-server/src/__tests__/apiV1/utils/mapOrgUnitCodeToGroup.test.js similarity index 94% rename from packages/web-config-server/src/tests/apiV1/utils/mapOrgUnitCodeToGroup.test.js rename to packages/web-config-server/src/__tests__/apiV1/utils/mapOrgUnitCodeToGroup.test.js index 29f807e37d..62bd4b1682 100644 --- a/packages/web-config-server/src/tests/apiV1/utils/mapOrgUnitCodeToGroup.test.js +++ b/packages/web-config-server/src/__tests__/apiV1/utils/mapOrgUnitCodeToGroup.test.js @@ -3,8 +3,6 @@ * Copyright (c) 2020 Beyond Essential Systems Pty Ltd */ -import { expect } from 'chai'; - import { mapOrgUnitCodeToGroup } from '/apiV1/utils/mapOrgUnitCodeToGroup'; const organisationUnits = [ @@ -36,7 +34,7 @@ const organisationUnits = [ describe('mapOrgUnitCodeToGroup', () => { it('should map org unit codes to group info', () => { - expect(mapOrgUnitCodeToGroup(organisationUnits)).to.deep.equal({ + expect(mapOrgUnitCodeToGroup(organisationUnits)).toStrictEqual({ 'SB_Guadalcanal Province': { code: 'SB_Guadalcanal Province', name: 'Guadalcanal Province' }, SB_10503: { code: 'SB_Guadalcanal Province', name: 'Guadalcanal Province' }, SB_10203: { code: 'SB_Guadalcanal Province', name: 'Guadalcanal Province' }, diff --git a/packages/web-config-server/src/tests/apiV1/utils/mapOrgUnitIdsToGroupIds.test.js b/packages/web-config-server/src/__tests__/apiV1/utils/mapOrgUnitIdsToGroupIds.test.js similarity index 93% rename from packages/web-config-server/src/tests/apiV1/utils/mapOrgUnitIdsToGroupIds.test.js rename to packages/web-config-server/src/__tests__/apiV1/utils/mapOrgUnitIdsToGroupIds.test.js index fdb815a24b..f150051bd5 100644 --- a/packages/web-config-server/src/tests/apiV1/utils/mapOrgUnitIdsToGroupIds.test.js +++ b/packages/web-config-server/src/__tests__/apiV1/utils/mapOrgUnitIdsToGroupIds.test.js @@ -2,8 +2,6 @@ * Tupaia MediTrak * Copyright (c) 2020 Beyond Essential Systems Pty Ltd */ -import { expect } from 'chai'; - import { mapOrgUnitIdsToGroupIds } from '/apiV1/utils/mapOrgUnitIdsToGroupIds'; const organisationUnits = [ @@ -35,7 +33,7 @@ const organisationUnits = [ describe('mapOrgUnitIdsToGroupIds', () => { it('should map facility ids to group ids', () => { - expect(mapOrgUnitIdsToGroupIds(organisationUnits)).to.deep.equal({ + expect(mapOrgUnitIdsToGroupIds(organisationUnits)).toStrictEqual({ As8RCJJNVGC: 'As8RCJJNVGC', IYRU3RH79ti: 'As8RCJJNVGC', GqumUN45VC8: 'As8RCJJNVGC', diff --git a/packages/web-config-server/src/tests/getDhisApiInstance.test.js b/packages/web-config-server/src/__tests__/getDhisApiInstance.test.js similarity index 78% rename from packages/web-config-server/src/tests/getDhisApiInstance.test.js rename to packages/web-config-server/src/__tests__/getDhisApiInstance.test.js index 2f9d00535f..3d6b511975 100644 --- a/packages/web-config-server/src/tests/getDhisApiInstance.test.js +++ b/packages/web-config-server/src/__tests__/getDhisApiInstance.test.js @@ -2,8 +2,6 @@ * Tupaia MediTrak * Copyright (c) 2020 Beyond Essential Systems Pty Ltd */ -import { expect } from 'chai'; -import { it, describe } from 'mocha'; import { getDhisApiInstance } from '../dhis'; describe('getDhisApiInstance()', () => { @@ -15,6 +13,6 @@ describe('getDhisApiInstance()', () => { const result1 = await getDhisApiInstance(options); const result2 = await getDhisApiInstance(options); - expect(result1 === result2).to.equal(true); + expect(result1 === result2).toBe(true); }); }); diff --git a/packages/web-config-server/src/tests/TestableApp.js b/packages/web-config-server/src/tests/TestableApp.js deleted file mode 100644 index 01552876e7..0000000000 --- a/packages/web-config-server/src/tests/TestableApp.js +++ /dev/null @@ -1,48 +0,0 @@ -import supertest from 'supertest'; -import { util } from 'client-sessions'; -import { createApp } from '/app'; -import { USER_SESSION_CONFIG } from '/authSession'; - -export const DEFAULT_API_VERSION = 1; -const getVersionedEndpoint = (endpoint, apiVersion = DEFAULT_API_VERSION) => - `/api/v${apiVersion}/${endpoint}`; - -const app = createApp(); - -export class TestableApp { - constructor() { - this.app = app; - this.currentCookies = null; - } - - async mockSessionUserJson(userName, email, accessPolicy) { - this.session = util.encode(USER_SESSION_CONFIG, { - userJson: { - userName, - email, - accessPolicy, - }, - }); - } - - get(endpoint, options, apiVersion = DEFAULT_API_VERSION) { - const versionedEndpoint = getVersionedEndpoint(endpoint, apiVersion); - return this.addOptionsToRequest(supertest(this.app).get(versionedEndpoint), options); - } - - post(endpoint, options, apiVersion = DEFAULT_API_VERSION) { - const versionedEndpoint = getVersionedEndpoint(endpoint, apiVersion); - return this.addOptionsToRequest(supertest(this.app).post(versionedEndpoint), options); - } - - addOptionsToRequest(request, { headers, body } = {}) { - if (headers) { - Object.entries(headers).forEach(([key, value]) => request.set(key, value)); - } - request.set('Cookie', `${USER_SESSION_CONFIG.cookieName}=${this.session}`); - if (body) { - request.send(body); - } - return request; - } -} diff --git a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/reportServer/reportServerDataBuilder.test.js b/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/reportServer/reportServerDataBuilder.test.js deleted file mode 100644 index 15385c620f..0000000000 --- a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/reportServer/reportServerDataBuilder.test.js +++ /dev/null @@ -1,139 +0,0 @@ -/** - * Tupaia - * Copyright (c) 2017 - 2021 Beyond Essential Systems Pty Ltd - */ - -import { expect } from 'chai'; -import sinon from 'sinon'; - -import { ReportServerBuilder } from '/apiV1/dataBuilders/generic/reportServer/reportServerDataBuilder'; - -const reportRequestKey = (reportCode, query = {}, body = {}) => - `reportCode:${reportCode},${Object.entries(query) - .map(entry => entry.join(':')) - .join(',')},${Object.entries(body) - .map(entry => entry.join(':')) - .join(',')}`; - -const REPORT_SERVER_RESPONSES = { - [reportRequestKey('1', { organisationUnitCodes: 'TO', hierarchy: 'psss' })]: { - results: [ - { period: '2018', organisationUnit: 'TO', dataElement: 'AGGR01', value: 1 }, - { period: '2019', organisationUnit: 'TO', dataElement: 'AGGR02', value: 3 }, - { period: '2020', organisationUnit: 'TO', dataElement: 'AGGR01', value: 4 }, - { period: '2021', organisationUnit: 'TO', dataElement: 'AGGR02', value: 5 }, - ], - }, - [reportRequestKey('1', { - organisationUnitCodes: 'TO', - hierarchy: 'psss', - startDate: '2020-01-01', - endDate: '2021-01-01', - })]: { - results: [ - { period: '2020', organisationUnit: 'TO', dataElement: 'AGGR01', value: 4 }, - { period: '2021', organisationUnit: 'TO', dataElement: 'AGGR02', value: 5 }, - ], - }, - [reportRequestKey('2', { - organisationUnitCodes: 'PG', - hierarchy: 'strive', - startDate: '2020-01-01', - })]: { - results: [ - { period: '2020', organisationUnit: 'PG', dataElement: 'PSSS01', value: 4 }, - { period: '2021', organisationUnit: 'PG', dataElement: 'PSSS02', value: 5 }, - ], - }, -}; - -const PROJECTS = [ - { code: 'ps', entity_hierarchy_id: '1' }, - { code: 'str', entity_hierarchy_id: '2' }, -]; - -const ENTITY_HIERARCHIES = [ - { id: '1', name: 'psss' }, - { id: '2', name: 'strive' }, -]; - -const req = { session: { userJson: { userName: 'test' } } }; - -const fetchReport = sinon.stub(); -fetchReport.callsFake( - (reportCode, query, body) => REPORT_SERVER_RESPONSES[reportRequestKey(reportCode, query, body)], -); - -const findProject = sinon.stub(); -findProject.callsFake(({ code }) => PROJECTS.find(project => project.code === code)); - -const findEntityHierarchyById = sinon.stub(); -findEntityHierarchyById.callsFake(id => ENTITY_HIERARCHIES.find(hierarchy => hierarchy.id === id)); - -const reportConnection = { fetchReport }; -const models = { - project: { findOne: findProject }, - entityHierarchy: { findById: findEntityHierarchyById }, -}; - -describe('ReportServerDataBuilder', () => { - const assertBuilderResponseIsCorrect = async ({ config, query, entity }, expectedResponse) => { - const dataBuilder = new ReportServerBuilder(req, models, config, query, entity); - dataBuilder.reportConnection = reportConnection; - return expect(dataBuilder.build()).to.eventually.deep.equal(expectedResponse); - }; - - it('should request correct report', () => - assertBuilderResponseIsCorrect( - { - config: { reportCode: '1' }, - query: { projectCode: 'ps' }, - entity: { code: 'TO' }, - }, - { - data: [ - { period: '2018', organisationUnit: 'TO', dataElement: 'AGGR01', value: 1 }, - { period: '2019', organisationUnit: 'TO', dataElement: 'AGGR02', value: 3 }, - { period: '2020', organisationUnit: 'TO', dataElement: 'AGGR01', value: 4 }, - { period: '2021', organisationUnit: 'TO', dataElement: 'AGGR02', value: 5 }, - ], - }, - )); - - it('should request for correct start/end date', () => - assertBuilderResponseIsCorrect( - { - config: { reportCode: '1' }, - query: { - projectCode: 'ps', - startDate: '2020-01-01', - endDate: '2021-01-01', - }, - entity: { code: 'TO' }, - }, - { - data: [ - { period: '2020', organisationUnit: 'TO', dataElement: 'AGGR01', value: 4 }, - { period: '2021', organisationUnit: 'TO', dataElement: 'AGGR02', value: 5 }, - ], - }, - )); - - it('should request for correct entity hierarchy', () => - assertBuilderResponseIsCorrect( - { - config: { reportCode: '2' }, - query: { - projectCode: 'str', - startDate: '2020-01-01', - }, - entity: { code: 'PG' }, - }, - { - data: [ - { period: '2020', organisationUnit: 'PG', dataElement: 'PSSS01', value: 4 }, - { period: '2021', organisationUnit: 'PG', dataElement: 'PSSS02', value: 5 }, - ], - }, - )); -}); diff --git a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/sum/sum.test.js b/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/sum/sum.test.js deleted file mode 100644 index 9879f43e93..0000000000 --- a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/sum/sum.test.js +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Tupaia Config Server - * Copyright (c) 2019 Beyond Essential Systems Pty Ltd - */ - -import { expect } from 'chai'; -import sinon from 'sinon'; - -import { Aggregator } from '@tupaia/aggregator'; -import { SumBuilder } from '/apiV1/dataBuilders/generic/sum/sum'; - -const AGGREGATE_ANALYTICS = [ - { dataElement: 'AGGR01', value: 1 }, - { dataElement: 'AGGR02', value: 3 }, -]; -const EVENT_ANALYTICS = [ - { dataElement: 'EVENT01', value: 5 }, - { dataElement: 'EVENT02', value: 8 }, -]; -const PROGRAM_CODE = 'CD8'; - -const dataServices = [{ isDataRegional: false }]; -const models = {}; -const entity = {}; -const query = { organisationUnitCode: 'TO' }; -const aggregationType = 'FINAL_EACH_MONTH'; -const aggregationConfig = {}; -const filter = {}; - -const fetchAnalytics = sinon.stub(); -fetchAnalytics - .returns({ results: [] }) - .withArgs(sinon.match.any, sinon.match({ dataServices }), query, { - aggregations: undefined, - aggregationConfig, - aggregationType, - filter, - }) - .callsFake((dataElementCodes, { programCodes }) => { - const getAnalyticsToUse = () => { - if (programCodes) { - return programCodes.includes(PROGRAM_CODE) ? EVENT_ANALYTICS : []; - } - return AGGREGATE_ANALYTICS; - }; - - const analytics = getAnalyticsToUse(); - const results = analytics.filter(({ dataElement }) => dataElementCodes.includes(dataElement)); - return { results }; - }); - -const aggregator = sinon.createStubInstance(Aggregator, { fetchAnalytics }); -const dhisApi = {}; - -describe('SumBuilder', () => { - const assertBuilderResponseIsCorrect = async (sumConfig, expectedResponse) => { - const config = { ...sumConfig, dataServices }; - const builder = new SumBuilder( - models, - aggregator, - dhisApi, - config, - query, - entity, - aggregationType, - ); - return expect(builder.build()).to.eventually.deep.equal(expectedResponse); - }; - - it('should return zero sum for empty results', () => - assertBuilderResponseIsCorrect( - { dataElementCodes: ['NON_EXISTING_CODE'] }, - { data: [{ name: 'sum', value: 0 }] }, - )); - - it('should sum all the values for aggregate data elements', () => - assertBuilderResponseIsCorrect( - { dataElementCodes: ['AGGR01', 'AGGR02'] }, - { data: [{ name: 'sum', value: 4 }] }, - )); - - it('should sum all the values for event data elements', () => - assertBuilderResponseIsCorrect( - { dataElementCodes: ['EVENT01', 'EVENT02'], programCode: PROGRAM_CODE }, - { data: [{ name: 'sum', value: 13 }] }, - )); -}); diff --git a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/unique/selectUniqueValueFromEvents.test.js b/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/unique/selectUniqueValueFromEvents.test.js deleted file mode 100644 index 022e10889c..0000000000 --- a/packages/web-config-server/src/tests/apiV1/dataBuilders/generic/unique/selectUniqueValueFromEvents.test.js +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Tupaia Config Server - * Copyright (c) 2019 Beyond Essential Systems Pty Ltd - */ - -import { expect } from 'chai'; -import sinon from 'sinon'; - -import { Aggregator } from '@tupaia/aggregator'; -import { selectUniqueValueFromEvents } from '/apiV1/dataBuilders/generic/unique'; -import { NO_UNIQUE_VALUE } from '/apiV1/dataBuilders/helpers/uniqueValues'; - -const EVENTS = [ - { - organisationUnitCode: 'ORG1', - eventDate: '2020-02-03T11:14:00.000', - dataValues: { element1: 'value1', element2: 'value2' }, - }, - { - organisationUnitCode: 'ORG1', - eventDate: '2020-02-03T11:14:00.000', - dataValues: { element1: 'value1', element2: 'value2' }, - }, - { - organisationUnitCode: 'ORG1', - eventDate: '2020-02-10T11:14:00.000', - dataValues: { element1: 'value1', element2: 'value2' }, - }, - { - organisationUnitCode: 'ORG1', - eventDate: '2020-02-10T11:15:00.000', - dataValues: { element1: 'value1', element2: 'value3' }, - }, -]; - -const dataServices = [{ isDataRegional: false }]; -const entity = {}; -const query = { organisationUnitCode: 'TO' }; - -const fetchEvents = sinon.stub().returns(EVENTS); -const aggregator = sinon.createStubInstance(Aggregator, { fetchEvents }); -const dhisApi = {}; - -describe('selectUniqueValueFromEvents', () => { - const assertBuilderResponseIsCorrect = async (config, expectedResponse) => { - const dataBuilderConfig = { ...config, dataServices }; - const response = selectUniqueValueFromEvents( - { dataBuilderConfig, query, entity }, - aggregator, - dhisApi, - ); - return expect(response).to.eventually.deep.equal(expectedResponse); - }; - - it('should return the unique value if it exists', () => - assertBuilderResponseIsCorrect( - { valueToSelect: 'element1' }, - { data: [{ name: 'element1', value: 'value1' }] }, - )); - - it('should return no unique value if there is no unique value', () => - assertBuilderResponseIsCorrect( - { valueToSelect: 'element2' }, - { data: [{ name: 'element2', value: NO_UNIQUE_VALUE }] }, - )); - - it('should return undefined if no events exist containing the valueToSelect', () => - assertBuilderResponseIsCorrect( - { valueToSelect: 'element3' }, - { data: [{ name: 'element3', value: undefined }] }, - )); -}); diff --git a/packages/web-config-server/src/tests/apiV1/dataBuilders/helpers/calculateOperationForAnalytics.test.js b/packages/web-config-server/src/tests/apiV1/dataBuilders/helpers/calculateOperationForAnalytics.test.js deleted file mode 100644 index cc82fd03c7..0000000000 --- a/packages/web-config-server/src/tests/apiV1/dataBuilders/helpers/calculateOperationForAnalytics.test.js +++ /dev/null @@ -1,326 +0,0 @@ -import { expect } from 'chai'; -import { NO_DATA_AVAILABLE } from '/apiV1/dataBuilders/constants'; - -import { calculateOperationForAnalytics } from '/apiV1/dataBuilders/helpers'; - -const models = {}; - -const analytics = [ - { dataElement: 'temperature', value: 2 }, - { dataElement: 'result', value: 'Positive' }, - { dataElement: 'temperature', value: 5 }, - { dataElement: 'result', value: 'Positive' }, - { dataElement: 'temperature', value: 7 }, - { dataElement: 'result', value: 'Positive Mixed' }, - { dataElement: 'temperature', value: 2 }, - { dataElement: 'result', value: 'Negative' }, - { dataElement: 'uniqueCode', value: 'Yes, more than 100' }, - { dataElement: 'uniqueCodeForName', value: 'Octavia' }, - { dataElement: 'height', value: 15 }, - { dataElement: 'width', value: 2 }, - { dataElement: 'Flower_found_Daisy', value: 1 }, - { dataElement: 'Flower_found_Tulip', value: 'No' }, - { dataElement: 'Flower_found_Orchid', value: 1 }, - { dataElement: 'Flower_found_Orchid', value: 1 }, - { dataElement: 'Best_Superhero1', value: 'SuperGirl' }, - { dataElement: 'Best_Superhero2', value: 'Black Widow' }, - { dataElement: 'Best_Superhero3', value: 'My Sister' }, - { dataElement: 'population', value: 10, period: '19990101', organisationUnit: 'Melbourne' }, - { dataElement: 'population', value: 100, period: '20050101', organisationUnit: 'Melbourne' }, - { dataElement: 'population', value: 20, period: '20100101', organisationUnit: 'Sydney' }, - { dataElement: 'population', value: 200, period: '20050101', organisationUnit: 'Sydney' }, -]; - -describe('calculateOperationForAnalytics', () => { - it('should throw if the operation is not defined', () => { - expect( - calculateOperationForAnalytics(models, analytics, { operator: 'NOT_AN_OPERATOR' }), - ).to.eventually.throw('Cannot find operator: NOT_AN_OPERATOR'); - expect(calculateOperationForAnalytics(models, analytics, {})).to.eventually.throw( - 'Cannot find operator: undefined', - ); - }); - - describe('CHECK_CONDITION', () => { - it('should throw an error when passed too many analytics', () => { - expect( - calculateOperationForAnalytics(models, analytics, { - operator: 'CHECK_CONDITION', - dataElement: 'result', - condition: { value: 'Positive', operator: 'regex' }, - }), - ).to.eventually.throw( - 'Too many results passed to checkConditions (calculateOperationForAnalytics)', - ); - }); - - it('should return no data if no analytics match the dataElement', () => { - expect( - calculateOperationForAnalytics(models, analytics, { - operator: 'CHECK_CONDITION', - dataElement: 'NON_EXISTENT', - condition: { value: 'Positive', operator: 'regex' }, - }), - ).to.eventually.equal(NO_DATA_AVAILABLE); - }); - - it('should return correctly for valid cases', () => { - expect( - calculateOperationForAnalytics(models, analytics, { - operator: 'CHECK_CONDITION', - dataElement: 'uniqueCode', - condition: { value: 'Yes', operator: 'regex' }, - }), - ).to.eventually.equal('Yes'); - expect( - calculateOperationForAnalytics(models, analytics, { - operator: 'CHECK_CONDITION', - dataElement: 'uniqueCode', - condition: { value: 'No', operator: 'regex' }, - }), - ).to.eventually.equal('No'); - }); - }); - - describe('SUBTRACT', () => { - const createSubtractionConfig = (codes1, codes2) => ({ - operator: 'SUBTRACT', - operands: [ - { - dataValues: codes1, - }, - { - dataValues: codes2, - }, - ], - }); - - it('should return no data if appropriate', () => { - expect( - calculateOperationForAnalytics( - models, - analytics, - createSubtractionConfig(['uniqueCode'], ['NON_EXISTENT']), - ), - ).to.eventually.equal(NO_DATA_AVAILABLE); - expect( - calculateOperationForAnalytics( - models, - analytics, - createSubtractionConfig(['NON_EXISTENT'], ['NON_EXISTENT_EITHER']), - ), - ).to.eventually.equal(NO_DATA_AVAILABLE); - }); - - it('should throw if there are not 2 or more operands in the config', () => { - expect( - calculateOperationForAnalytics(models, analytics, { - operator: 'SUBTRACT', - operands: [{ dataValues: 'hi' }], - }), - ).to.eventually.throw('Must have 2 or more operands'); - }); - - it('should subtract correctly in valid cases (and sum multiple analytics with the same code)', () => { - expect( - calculateOperationForAnalytics( - models, - analytics, - createSubtractionConfig(['height', 'width'], ['temperature']), - ), - ).to.eventually.equal(1); - }); - - it('should handle SUM_LATEST_PER_ORG_UNIT aggregation', () => { - expect( - calculateOperationForAnalytics(models, analytics, { - operator: 'SUBTRACT', - operands: [ - { - dataValues: ['temperature'], - }, - { - dataValues: ['population'], - aggregationType: 'SUM_LATEST_PER_ORG_UNIT', - }, - ], - }), - ).to.eventually.equal(-104); // (2 + 5 + 7 + 2) - (100 + 20) - }); - }); - - describe('FORMAT', () => { - it('should throw an error when passed too many analytics', () => { - expect( - calculateOperationForAnalytics(models, analytics, { - operator: 'FORMAT', - dataElement: 'result', - format: 'Hello: {value}', - }), - ).to.eventually.throw( - 'Too many results passed to checkConditions (calculateOperationForAnalytics)', - ); - }); - - it('should return no data if no analytics match the dataElement', () => { - expect( - calculateOperationForAnalytics(models, analytics, { - operator: 'FORMAT', - dataElement: 'NON_EXISTENT', - format: 'Hello: {value}', - }), - ).to.eventually.equal(NO_DATA_AVAILABLE); - }); - - it('should return correctly for valid case', () => { - expect( - calculateOperationForAnalytics(models, analytics, { - operator: 'FORMAT', - dataElement: 'uniqueCodeForName', - format: 'Hello: {value}', - }), - ).to.eventually.equal('Hello: Octavia'); - }); - - it('should return correctly for multiple replacements', () => { - expect( - calculateOperationForAnalytics(models, analytics, { - operator: 'FORMAT', - dataElement: 'uniqueCodeForName', - format: 'Hello: {value} and also {value}', - }), - ).to.eventually.equal('Hello: Octavia and also Octavia'); - }); - - it('should return correctly for no replacements', () => { - expect( - calculateOperationForAnalytics(models, analytics, { - operator: 'FORMAT', - dataElement: 'uniqueCodeForName', - format: 'Hello: My friend', - }), - ).to.eventually.equal('Hello: My friend'); - }); - }); - - describe('COMBINE_BINARY_AS_STRING', () => { - it('should return the string "None" if no analytics match the dataElement', () => { - expect( - calculateOperationForAnalytics(models, analytics, { - operator: 'COMBINE_BINARY_AS_STRING', - dataElementToString: { - NON_EXISTENT: 'Should not matter', - }, - }), - ).to.eventually.equal('None'); - }); - - it('should return the string "None" if there are no data values equal to "Yes"', () => { - expect( - calculateOperationForAnalytics(models, analytics, { - operator: 'COMBINE_BINARY_AS_STRING', - dataElementToString: { - temperature: 'Explicitly not "Yes"', - }, - }), - ).to.eventually.equal('None'); - }); - - it('should return correctly for valid case', () => { - expect( - calculateOperationForAnalytics(models, analytics, { - operator: 'COMBINE_BINARY_AS_STRING', - dataElementToString: { - Flower_found_Daisy: 'Daisy', - Flower_found_Tulip: 'Tulip', - }, - }), - ).to.eventually.equal('Daisy'); - }); - - it('should return correctly for multiple "Yes" values - order not guaranteed', async () => { - const flowerList = await calculateOperationForAnalytics(models, analytics, { - operator: 'COMBINE_BINARY_AS_STRING', - dataElementToString: { - Flower_found_Daisy: 'Daisy', - Flower_found_Tulip: 'Tulip', - Flower_found_Orchid: 'Orchid', - }, - }); - // This does assert that there is a duplicate 'Orchid' entry - expect(flowerList.split(', ')).to.have.members(['Orchid', 'Orchid', 'Daisy']); - }); - }); - - describe('GROUP', () => { - it('should throw an error when passed too many analytics', () => { - expect( - calculateOperationForAnalytics(models, analytics, { - operator: 'GROUP', - dataElement: 'result', - groups: { - Marvel: { value: '(Black Widow)|(Iron Man)', operator: 'regex' }, - DC: { value: '(SuperGirl)|(Batman)', operator: 'regex' }, - }, - defaultValue: 'Not a superhero', - }), - ).to.eventually.throw( - 'Too many results passed to checkConditions (calculateOperationForAnalytics)', - ); - }); - - it('should return no data if no analytics match the dataElement', () => { - expect( - calculateOperationForAnalytics(models, analytics, { - operator: 'GROUP', - dataElement: 'NON_EXISTENT', - groups: { - Marvel: { value: '(Black Widow)|(Iron Man)', operator: 'regex' }, - DC: { value: '(SuperGirl)|(Batman)', operator: 'regex' }, - }, - defaultValue: 'Not a superhero', - }), - ).to.eventually.equal(NO_DATA_AVAILABLE); - }); - - it('should return correctly for valid cases', () => { - expect( - calculateOperationForAnalytics(models, analytics, { - operator: 'GROUP', - dataElement: 'Best_Superhero1', - groups: { - Marvel: { value: '(Black Widow)|(Iron Man)', operator: 'regex' }, - DC: { value: '(SuperGirl)|(Batman)', operator: 'regex' }, - }, - defaultValue: 'Not a superhero', - }), - ).to.eventually.equal('DC'); - - expect( - calculateOperationForAnalytics(models, analytics, { - operator: 'GROUP', - dataElement: 'Best_Superhero2', - groups: { - Marvel: { value: '(Black Widow)|(Iron Man)', operator: 'regex' }, - DC: { value: '(SuperGirl)|(Batman)', operator: 'regex' }, - }, - defaultValue: 'Not a superhero', - }), - ).to.eventually.equal('Marvel'); - }); - - it('should return the default value correctly', () => { - expect( - calculateOperationForAnalytics(models, analytics, { - operator: 'GROUP', - dataElement: 'Best_Superhero3', - groups: { - Marvel: { value: '(Black Widow)|(Iron Man)', operator: 'regex' }, - DC: { value: '(SuperGirl)|(Batman)', operator: 'regex' }, - }, - defaultValue: 'Not a superhero', - }), - ).to.eventually.equal('Not a superhero'); - }); - }); -}); diff --git a/packages/web-config-server/src/tests/apiV1/dataBuilders/helpers/divideValues.test.js b/packages/web-config-server/src/tests/apiV1/dataBuilders/helpers/divideValues.test.js deleted file mode 100644 index e866b4d5de..0000000000 --- a/packages/web-config-server/src/tests/apiV1/dataBuilders/helpers/divideValues.test.js +++ /dev/null @@ -1,33 +0,0 @@ -import { expect } from 'chai'; - -import { divideValues } from '/apiV1/dataBuilders/helpers/divideValues'; -import { NO_DATA_AVAILABLE } from '/apiV1/dataBuilders/constants'; - -describe('divideValues()', () => { - it('numerator: defined, denominator: defined', () => { - expect(divideValues(1, 2)).to.equal(0.5); - expect(divideValues(2, 2)).to.equal(1); - }); - - it('numerator: not defined, denominator: any', () => { - expect(divideValues()).to.equal(NO_DATA_AVAILABLE); - expect(divideValues(undefined)).to.equal(NO_DATA_AVAILABLE); - expect(divideValues(undefined, 2)).to.equal(NO_DATA_AVAILABLE); - expect(divideValues(null)).to.equal(NO_DATA_AVAILABLE); - expect(divideValues(null, 2)).to.equal(NO_DATA_AVAILABLE); - }); - - it('numerator: 0, denominator: defined not 0', () => { - expect(divideValues(0, 1)).to.equal(0); - }); - - it('numerator: defined, denominator: not defined', () => { - expect(divideValues(1)).to.equal(NO_DATA_AVAILABLE); - expect(divideValues(1, undefined)).to.equal(NO_DATA_AVAILABLE); - expect(divideValues(1, null)).to.equal(NO_DATA_AVAILABLE); - }); - - it('numerator: defined, denominator: 0', () => { - expect(divideValues(1, 0)).to.equal(NO_DATA_AVAILABLE); - }); -}); diff --git a/packages/web-config-server/src/tests/apiV1/dataBuilders/helpers/getCategoryPresentationOption.test.js b/packages/web-config-server/src/tests/apiV1/dataBuilders/helpers/getCategoryPresentationOption.test.js deleted file mode 100644 index c7ed04bda6..0000000000 --- a/packages/web-config-server/src/tests/apiV1/dataBuilders/helpers/getCategoryPresentationOption.test.js +++ /dev/null @@ -1,82 +0,0 @@ -import { expect } from 'chai'; - -import { getCategoryPresentationOption } from '/apiV1/dataBuilders/helpers/getCategoryPresentationOption'; - -describe('getCategoryPresentationOption()', () => { - const testConfig = { - type: '$condition', - conditions: [ - { - key: 'red', - condition: { - in: [null, 0], - }, - }, - { - key: 'green', - condition: { - '>': 0, - }, - }, - { - key: 'orange', - condition: { - someNotAll: { '>': 0 }, - }, - }, - ], - }; - - const testFunction = (testData, config) => { - for (const [values, expectedValue] of testData) { - expect(getCategoryPresentationOption(config, values)).to.equal(expectedValue); - } - }; - describe('type: $condition', () => { - it('condition: someNotAll', () => { - const testData = [ - [[null, null, 2, null], 'orange'], - [[1, 1, 3, 0], 'orange'], - ]; - testFunction(testData, testConfig); - }); - it('condition: >', () => { - const testData = [ - [[1, 1, 2, 2], 'green'], - [[1, 1, 3, 4], 'green'], - ]; - testFunction(testData, testConfig); - }); - it('condition: in', () => { - const testData = [ - [[0, 0, 0, 0], 'red'], - [[0, null, 0, null], 'red'], - ]; - testFunction(testData, testConfig); - }); - it('no condition meet', () => { - const newTestConfig = { - type: '$condition', - conditions: [ - { - key: 'red', - condition: { - in: [null, 0], - }, - }, - { - key: 'green', - condition: { - '>': 0, - }, - }, - ], - }; - const testData = [ - [[null, null, 2, null], undefined], - [[1, 1, 3, 0], undefined], - ]; - testFunction(testData, newTestConfig); - }); - }); -}); diff --git a/packages/web-config-server/src/tests/getTestModels.js b/packages/web-config-server/src/tests/getTestModels.js deleted file mode 100644 index ede6b2932a..0000000000 --- a/packages/web-config-server/src/tests/getTestModels.js +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Tupaia - * Copyright (c) 2017 - 2020 Beyond Essential Systems Pty Ltd - */ -import { TupaiaDatabase, ModelRegistry } from '@tupaia/database'; -import { modelClasses } from '/models'; - -let modelsSingleton = null; -export function getTestModels() { - if (!modelsSingleton) { - const database = new TupaiaDatabase(); - modelsSingleton = new ModelRegistry(database, modelClasses); - } - - return modelsSingleton; -} diff --git a/packages/web-config-server/src/tests/permissions.test.js b/packages/web-config-server/src/tests/permissions.test.js deleted file mode 100644 index b2875dc0d6..0000000000 --- a/packages/web-config-server/src/tests/permissions.test.js +++ /dev/null @@ -1,149 +0,0 @@ -/** - * Tupaia MediTrak - * Copyright (c) 2017 Beyond Essential Systems Pty Ltd - */ - -import { expect } from 'chai'; -import { TestableApp } from './TestableApp'; - -const accessPolicy = { - permissions: { - reports: { - _items: { - DL: { - _access: { - Public: true, - }, - _items: { - DL_North: { - _access: { - Admin: true, - Donor: true, - 'Royal Australasian College of Surgeons': true, - }, - _items: { - DL_North_Slytherin: { - _access: { - Public: false, - }, - }, - }, - }, - 'DL_South West': { - _access: { - Public: false, - }, - }, - 'DL_South East': { - _items: { - 'DL_South East_Gryffindor': { - _items: { - DL_3: { - _access: { - Admin: true, - Donor: true, - 'Royal Australasian College of Surgeons': true, - }, - }, - }, - }, - }, - }, - }, - }, - TO: { - _access: { - Public: true, - }, - _items: { - TO_Tongatapu: { - _access: { - Admin: true, - Donor: true, - 'Royal Australasian College of Surgeons': true, - }, - }, - }, - }, - }, - }, - }, -}; - -describe('UserHasAccess', function () { - const app = new TestableApp(); - app.mockSessionUserJson('Test user', 'testuser@test.com', accessPolicy); - - xit('should have access to some root level Demo Land organisation units', async () => { - const userResponse = await app.get('getUser'); - expect(userResponse.body.email).to.equal('testuser@test.com'); - - const demolandResponse = await app.get('organisationUnit?organisationUnitCode=DL'); - const { organisationUnitChildren } = demolandResponse.body; - const childCodes = organisationUnitChildren.map(child => child.organisationUnitCode); - expect(childCodes).to.include('DL_North'); - expect(childCodes).to.include('DL_South East'); - expect(childCodes).to.not.include('DL_South West'); - }); - xit('should not have access to Demo Land organisation units specified as public access false', async () => { - await app.get('organisationUnit?organisationUnitCode=DL_South%20West').expect(401); - await app.get('organisationUnit?organisationUnitCode=DL_North_Slytherin').expect(401); - await app.get('organisationUnit?organisationUnitCode=DL_7').expect(401); - }); - - xit('should only retrieve public level dashboards for Tonga org units unless Access Policy otherwise specifies', async () => { - const tongaDashboardResponse = await app - .get('dashboard?organisationUnitCode=TO&projectCode=explore') - .expect(200); - expect(tongaDashboardResponse.body).to.have.all.keys('General'); - expect(tongaDashboardResponse.body).to.not.have.any.keys('PEHS'); - - const niuasDashboardResponse = await app - .get('dashboard?organisationUnitCode=TO_Niuas&projectCode=explore') - .expect(200); - expect(niuasDashboardResponse.body).to.have.all.keys('General'); - expect(niuasDashboardResponse.body).to.not.have.any.keys('PEHS'); - - const tongatapuDashboardResponse = await app - .get('dashboard?organisationUnitCode=TO_Tongatapu&projectCode=explore') - .expect(200); - expect(tongatapuDashboardResponse.body).to.have.all.keys('General', 'PEHS'); - }); - xit('should not have access to donor level measure group for top-level Tonga organisation unit', async () => { - const tongaDashboardResponse = await app.get('measures?organisationUnitCode=TO').expect(200); - - expect(tongaDashboardResponse.body.measures).to.not.have.property('Facility equipment'); - }); - xit('should not have access to donor level measure group for organisation unit that does not have any donor level permissions', async () => { - const niuasDashboardResponse = await app - .get('measures?organisationUnitCode=TO_Niuas&projectCode=explore') - .expect(200); - expect(niuasDashboardResponse.body.measures).to.not.have.property('Facility equipment'); - }); - xit('should have access to donor level measure group for nested organisation unit with donor level permissions', async () => { - const tongaDashboardResponse = await app - .get('measures?organisationUnitCode=TO_Tongatapu&projectCode=explore') - .expect(200); - - expect(tongaDashboardResponse.body.measures).to.have.property('Facility equipment'); - expect(tongaDashboardResponse.body.measures['Facility equipment']).to.deep.include({ - measureId: 1, - name: 'Adult weighing scale', - }); - }); - xit('should reveal public level measure data', async () => { - const measureDataResponse = await app - .get('measureData?organisationUnitCode=TO&measureId=126') - .expect(200); - - expect(measureDataResponse.body.displayType).to.equal('dot'); - expect(measureDataResponse.body.measureId).to.equal(126); - expect(measureDataResponse.body.measureOptions).to.deep.include({ name: 'open', value: '0' }); - }); - xit('should not reveal donor level measure data for organisation units that the user does not have access to', async () => { - await app.get('measureData?organisationUnitCode=TO&measureId=7').expect(401); - }); - xit('should reveal donor level measure data for organisation units that the user has access to', async () => { - await app.get('measureData?organisationUnitCode=TO_Tongatapu&measureId=7').expect(200); - }); -}); diff --git a/packages/web-config-server/src/tests/scaffolding.test.js b/packages/web-config-server/src/tests/scaffolding.test.js deleted file mode 100644 index c062a9aab4..0000000000 --- a/packages/web-config-server/src/tests/scaffolding.test.js +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Tupaia Config Server - * Copyright (c) 2019 Beyond Essential Systems Pty Ltd - */ - -import chai from 'chai'; -import chaiAsPromised from 'chai-as-promised'; -import chaiLike from 'chai-like'; -import sinonChai from 'sinon-chai'; -import moment from 'moment'; -import { clearTestData } from '@tupaia/database'; -import { getTestModels } from './getTestModels'; -import { getIsProductionEnvironment } from '../utils'; - -const testStartTime = moment().format('YYYY-MM-DD HH:mm:ss'); - -// These setup tasks need to be performed before any test, so we -// do them in this file outside of any describe block. - -before(() => { - const isProductionEnvironment = getIsProductionEnvironment(); - if (isProductionEnvironment) { - // Don't test on production - throw new Error('Never run the test suite on the production server, it messes with data!'); - } - - chai.use(chaiLike); - chai.use(sinonChai); - - // `chaiAsPromised` must be used after other plugins to promisify them - chai.use(chaiAsPromised); -}); - -after(async () => { - const models = getTestModels(); - - await clearTestData(models.database, testStartTime); - await models.database.closeConnections(); -}); diff --git a/packages/web-config-server/src/utils/getIsProductionEnvironment.js b/packages/web-config-server/src/utils/getIsProductionEnvironment.js deleted file mode 100644 index 1d85d5b97f..0000000000 --- a/packages/web-config-server/src/utils/getIsProductionEnvironment.js +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Tupaia MediTrak - * Copyright (c) 2017-2020 Beyond Essential Systems Pty Ltd - */ - -export const getIsProductionEnvironment = () => - process.env.IS_PRODUCTION_ENVIRONMENT === 'true' && !process.env.CI_BUILD_ID; diff --git a/packages/web-config-server/src/utils/index.js b/packages/web-config-server/src/utils/index.js index 1e1087627a..eba8c067be 100644 --- a/packages/web-config-server/src/utils/index.js +++ b/packages/web-config-server/src/utils/index.js @@ -5,5 +5,4 @@ export { getDefaultPeriod, EARLIEST_DATA_DATE } from './getDefaultPeriod'; export { handleError } from './handleError'; -export { getIsProductionEnvironment } from './getIsProductionEnvironment'; export { logApiRequest } from './logApiRequest'; diff --git a/yarn.lock b/yarn.lock index 33a18d6461..3e8d82f4ef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5148,6 +5148,7 @@ __metadata: react-native-uuid: ^1.4.9 s3urls: ^1.5.2 semver-compare: ^1.0.0 + sinon-chai: ^3.3.0 sinon-test: ^3.0.0 string-similarity: ^1.2.2 supertest: ^3.1.0 @@ -5242,7 +5243,6 @@ __metadata: "@tupaia/auth": 1.0.0 "@tupaia/tsutils": 1.0.0 "@tupaia/utils": 1.0.0 - cross-env: ^7.0.2 db-migrate: ^0.11.5 db-migrate-pg: ^1.2.2 dotenv: ^8.2.0 @@ -5253,7 +5253,6 @@ __metadata: lodash.orderby: ^4.6.0 moment: ^2.24.0 npm-run-all: ^4.1.5 - nyc: ^15.1.0 os: 0.1.1 pg: 8.5.1 pg-pubsub: 0.6.1 @@ -5721,13 +5720,10 @@ __metadata: babel-plugin-module-resolver: ^4.0.0 body-parser: ^1.18.2 case: ^1.5.5 - chai: ^4.1.2 - chai-like: ^1.1.1 client-sessions: ^0.8.0 compression: ^1.7.2 cookie-parser: ^1.4.3 cors: ^2.8.4 - cross-env: ^7.0.2 csvtojson: ^1.1.5 dotenv: ^8.2.0 express: ^4.16.2 @@ -5749,15 +5745,12 @@ __metadata: lodash.sumby: ^4.6.0 lodash.tail: ^4.1.1 lodash.zipobject: ^4.1.3 - mocha: ^8.1.3 moment: ^2.22.2 morgan: ^1.9.0 nodemailer: ^4.6.7 - nyc: ^15.1.0 promise: ^7.3.1 react-autobind: ^1.0.6 request: ^2.81.0 - sinon-chai: ^3.3.0 urlencode: ^1.1.0 winston: ^3.3.3 xlsx: ^0.17.0 @@ -10446,15 +10439,6 @@ __metadata: languageName: node linkType: hard -"chai-like@npm:^1.1.1": - version: 1.1.1 - resolution: "chai-like@npm:1.1.1" - peerDependencies: - chai: 2 - 4 - checksum: c0b1162568b7a0188a099309a501c37b883ca29ea85a44ec01a1f5225665d811e15ef986f6641b001356aa30d8d051604a483a2fc1a17c4f9cc9a55d5b01e1c9 - languageName: node - linkType: hard - "chai-subset@npm:^1.6.0": version: 1.6.0 resolution: "chai-subset@npm:1.6.0" @@ -31480,12 +31464,12 @@ __metadata: linkType: hard "sinon-chai@npm:^3.3.0": - version: 3.4.0 - resolution: "sinon-chai@npm:3.4.0" + version: 3.7.0 + resolution: "sinon-chai@npm:3.7.0" peerDependencies: chai: ^4.0.0 - sinon: ">=4.0.0 <9.0.0" - checksum: cf27947adb16c1b98ba1f4ba3e271358dd2286a3535b4cdbc0e7e6a454cb9e41a0f88385d976281708d4c8e8f1ed26b27bcb624573548f4cb3c9e2fefa393ed0 + sinon: ">=4.0.0" + checksum: 49a353d8eb66cc6db35ac452f6965c72778aa090d1f036dd1e54ba88594b1c3f314b1a403eaff22a4e314f94dc92d9c7d03cbb88c21d89e814293bf5b299964d languageName: node linkType: hard From fa509c76a285a5d87445ecdb0ba4e8e7ba5460fe Mon Sep 17 00:00:00 2001 From: Rohan Port <59544282+rohan-bes@users.noreply.github.com> Date: Mon, 10 Oct 2022 14:12:52 +1100 Subject: [PATCH 02/12] RN-674: Migrate admin-panel-server over to @tupaia/api-client (#4197) --- packages/admin-panel-server/package.json | 1 + .../src/@types/express/index.d.ts | 9 +- .../admin-panel-server/src/app/createApp.ts | 2 + .../src/auth/authHandlerProvider.ts | 20 +++++ packages/admin-panel-server/src/auth/index.ts | 6 ++ .../src/connections/CentralConnection.ts | 82 ------------------- .../src/connections/EntityConnection.ts | 31 ------- .../src/connections/ReportConnection.ts | 27 ------ .../src/connections/index.ts | 8 -- .../middleware/attachAuthorizationHeader.ts | 24 ------ .../src/middleware/index.ts | 1 - .../routes/FetchAggregationOptionsRoute.ts | 14 +--- .../src/routes/FetchHierarchyEntitiesRoute.ts | 37 +++++---- .../src/routes/FetchReportPreviewDataRoute.ts | 13 +-- .../src/routes/FetchReportSchemasRoute.ts | 14 +--- .../src/routes/UserRoute.ts | 19 ++--- .../ExportDashboardVisualisationRoute.ts | 32 +++----- .../FetchDashboardVisualisationRoute.ts | 13 +-- .../ImportDashboardVisualisationRoute.ts | 26 ++---- .../SaveDashboardVisualisationRoute.ts | 16 +--- .../ExportMapOverlayVisualisationRoute.ts | 23 ++---- .../FetchMapOverlayVisualisationRoute.ts | 13 +-- .../ImportMapOverlayVisualisationRoute.ts | 27 +++--- .../SaveMapOverlayVisualisationRoute.ts | 16 +--- packages/api-client/src/TupaiaApiClient.ts | 4 +- .../src/connections/ApiConnection.ts | 2 +- .../api-client/src/connections/CentralApi.ts | 60 ++++++++++++++ .../api-client/src/connections/ReportApi.ts | 22 +++++ packages/api-client/src/connections/index.ts | 1 + .../src/orchestrator/api/ApiBuilder.ts | 4 +- yarn.lock | 1 + 31 files changed, 210 insertions(+), 358 deletions(-) create mode 100644 packages/admin-panel-server/src/auth/authHandlerProvider.ts create mode 100644 packages/admin-panel-server/src/auth/index.ts delete mode 100644 packages/admin-panel-server/src/connections/CentralConnection.ts delete mode 100644 packages/admin-panel-server/src/connections/EntityConnection.ts delete mode 100644 packages/admin-panel-server/src/connections/ReportConnection.ts delete mode 100644 packages/admin-panel-server/src/connections/index.ts delete mode 100644 packages/admin-panel-server/src/middleware/attachAuthorizationHeader.ts create mode 100644 packages/api-client/src/connections/ReportApi.ts diff --git a/packages/admin-panel-server/package.json b/packages/admin-panel-server/package.json index b58eb52991..c4c11dfb24 100644 --- a/packages/admin-panel-server/package.json +++ b/packages/admin-panel-server/package.json @@ -27,6 +27,7 @@ }, "dependencies": { "@tupaia/access-policy": "3.0.0", + "@tupaia/api-client": "3.1.0", "@tupaia/database": "1.0.0", "@tupaia/report-server": "0.0.0", "@tupaia/server-boilerplate": "1.0.0", diff --git a/packages/admin-panel-server/src/@types/express/index.d.ts b/packages/admin-panel-server/src/@types/express/index.d.ts index 843bcc4df1..413fe5b60c 100644 --- a/packages/admin-panel-server/src/@types/express/index.d.ts +++ b/packages/admin-panel-server/src/@types/express/index.d.ts @@ -3,6 +3,7 @@ * Copyright (c) 2017 - 2021 Beyond Essential Systems Pty Ltd */ import { AccessPolicy } from '@tupaia/access-policy'; +import { TupaiaApiClient } from '@tupaia/api-client'; import { AdminPanelSessionType } from '../../models'; @@ -11,11 +12,9 @@ declare global { export interface Request { accessPolicy: AccessPolicy; session: AdminPanelSessionType; - } - - export interface Response { - accessPolicy: AccessPolicy; - session: AdminPanelSessionType; + ctx: { + services: TupaiaApiClient; + }; } } } diff --git a/packages/admin-panel-server/src/app/createApp.ts b/packages/admin-panel-server/src/app/createApp.ts index f75bd6ae1a..6eff8c9c5b 100644 --- a/packages/admin-panel-server/src/app/createApp.ts +++ b/packages/admin-panel-server/src/app/createApp.ts @@ -41,6 +41,7 @@ import { FetchTransformSchemasRequest, FetchTransformSchemasRoute, } from '../routes'; +import { authHandlerProvider } from '../auth'; const { CENTRAL_API_URL = 'http://localhost:8090/v2' } = process.env; @@ -49,6 +50,7 @@ const { CENTRAL_API_URL = 'http://localhost:8090/v2' } = process.env; */ export function createApp() { const app = new OrchestratorApiBuilder(new TupaiaDatabase(), 'admin-panel') + .attachApiClientToContext(authHandlerProvider) .useSessionModel(AdminPanelSessionModel) .verifyLogin(hasTupaiaAdminPanelAccess) .get('user', handleWith(UserRoute)) diff --git a/packages/admin-panel-server/src/auth/authHandlerProvider.ts b/packages/admin-panel-server/src/auth/authHandlerProvider.ts new file mode 100644 index 0000000000..d4f0b514a9 --- /dev/null +++ b/packages/admin-panel-server/src/auth/authHandlerProvider.ts @@ -0,0 +1,20 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2022 Beyond Essential Systems Pty Ltd + */ + +import { UnauthenticatedError } from '@tupaia/utils'; +import { Request } from 'express'; + +export const authHandlerProvider = (req: Request) => { + return { + getAuthHeader: () => { + const { session } = req; + + if (!session) { + throw new UnauthenticatedError('Session is not attached'); + } + return session.getAuthHeader(); + }, + }; +}; diff --git a/packages/admin-panel-server/src/auth/index.ts b/packages/admin-panel-server/src/auth/index.ts new file mode 100644 index 0000000000..5b798dbde9 --- /dev/null +++ b/packages/admin-panel-server/src/auth/index.ts @@ -0,0 +1,6 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2022 Beyond Essential Systems Pty Ltd + */ + +export { authHandlerProvider } from './authHandlerProvider'; diff --git a/packages/admin-panel-server/src/connections/CentralConnection.ts b/packages/admin-panel-server/src/connections/CentralConnection.ts deleted file mode 100644 index 24e4cc1b42..0000000000 --- a/packages/admin-panel-server/src/connections/CentralConnection.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Tupaia - * Copyright (c) 2017 - 2021 Beyond Essential Systems Pty Ltd - */ -import camelcaseKeys from 'camelcase-keys'; - -import { AccessPolicy } from '@tupaia/access-policy'; -import { QueryParameters, RequestBody, ApiConnection } from '@tupaia/server-boilerplate'; - -import { BES_ADMIN_PERMISSION_GROUP } from '../constants'; - -const { CENTRAL_API_URL = 'http://localhost:8090/v2' } = process.env; - -const isBESAdmin = (policy: Record) => { - return new AccessPolicy(policy).allowsSome(undefined, BES_ADMIN_PERMISSION_GROUP); -}; - -const translateParams = (queryParameters?: Record) => { - const translatedParams = queryParameters?.filter - ? { ...queryParameters, filter: JSON.stringify(queryParameters?.filter) } - : queryParameters; - return translatedParams as QueryParameters; -}; - -/** - * @deprecated use @tupaia/api-client - */ -export class CentralConnection extends ApiConnection { - public baseUrl = CENTRAL_API_URL; - - public async getUser() { - const user = await this.get('me'); - return { ...camelcaseKeys(user), isBESAdmin: isBESAdmin(user.accessPolicy) }; - } - - public async fetchResources(endpoint: string, params?: Record) { - return this.get(endpoint, translateParams(params)); - } - - public async createResource( - endpoint: string, - params: Record, - body: RequestBody, - ) { - return this.post(endpoint, translateParams(params), body); - } - - public async updateResource( - endpoint: string, - params: Record, - body: RequestBody, - ) { - return this.put(endpoint, translateParams(params), body); - } - - public async deleteResource(endpoint: string) { - return this.delete(endpoint); - } - - public async upsertResource( - endpoint: string, - params: Record, - body: RequestBody, - ) { - const results = await this.fetchResources(endpoint, params); - - if (results.length > 1) { - throw new Error( - `Cannot upsert ${endpoint} since multiple resources were found: please use unique fields in you query`, - ); - } - - if (results.length === 1) { - await this.updateResource(`${endpoint}/${results[0].id}`, params, body); - } else { - await this.createResource(endpoint, params, body); - } - - const [resource] = await this.fetchResources(endpoint, params); - return resource; - } -} diff --git a/packages/admin-panel-server/src/connections/EntityConnection.ts b/packages/admin-panel-server/src/connections/EntityConnection.ts deleted file mode 100644 index 0814003258..0000000000 --- a/packages/admin-panel-server/src/connections/EntityConnection.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Tupaia - * Copyright (c) 2017 - 2021 Beyond Essential Systems Pty Ltd - * - */ - -import { ApiConnection, QueryParameters } from '@tupaia/server-boilerplate'; - -const { ENTITY_API_URL = 'http://localhost:8050/v1' } = process.env; - -/** - * @deprecated use @tupaia/api-client - */ -export class EntityConnection extends ApiConnection { - public baseUrl = ENTITY_API_URL; - - public async getEntities( - hierarchyName: string, - entityCode: string, - queryParameters: QueryParameters = {}, - ) { - const projectEntity = await this.post(`hierarchy/${hierarchyName}`, queryParameters, { - entities: [entityCode], - }); - const descendants = await this.get( - `hierarchy/${hierarchyName}/${entityCode}/descendants`, - queryParameters, - ); - return projectEntity.concat(descendants); - } -} diff --git a/packages/admin-panel-server/src/connections/ReportConnection.ts b/packages/admin-panel-server/src/connections/ReportConnection.ts deleted file mode 100644 index 2f9a21a9bc..0000000000 --- a/packages/admin-panel-server/src/connections/ReportConnection.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Tupaia - * Copyright (c) 2017 - 2021 Beyond Essential Systems Pty Ltd - */ - -import { QueryParameters, ApiConnection, RequestBody } from '@tupaia/server-boilerplate'; - -const { REPORT_API_URL = 'http://localhost:8030/v1' } = process.env; - -/** - * @deprecated use @tupaia/api-client - */ -export class ReportConnection extends ApiConnection { - public baseUrl = REPORT_API_URL; - - public async testReport(query: QueryParameters, body: RequestBody) { - return this.post('testReport', query, body); - } - - public async fetchAggregationOptions() { - return this.get('fetchAggregationOptions'); - } - - public async fetchTransformSchemas() { - return this.get('fetchTransformSchemas'); - } -} diff --git a/packages/admin-panel-server/src/connections/index.ts b/packages/admin-panel-server/src/connections/index.ts deleted file mode 100644 index d25949d0b7..0000000000 --- a/packages/admin-panel-server/src/connections/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Tupaia - * Copyright (c) 2017 - 2021 Beyond Essential Systems Pty Ltd - */ - -export { ReportConnection } from './ReportConnection'; -export { EntityConnection } from './EntityConnection'; -export { CentralConnection } from './CentralConnection'; diff --git a/packages/admin-panel-server/src/middleware/attachAuthorizationHeader.ts b/packages/admin-panel-server/src/middleware/attachAuthorizationHeader.ts deleted file mode 100644 index 6f4358a700..0000000000 --- a/packages/admin-panel-server/src/middleware/attachAuthorizationHeader.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Tupaia - * Copyright (c) 2017 - 2021 Beyond Essential Systems Pty Ltd - */ - -import { Request, Response, NextFunction } from 'express'; - -import { UnauthenticatedError } from '@tupaia/utils'; - -export const attachAuthorizationHeader = async ( - req: Request, - res: Response, - next: NextFunction, -) => { - const { session } = req; - - if (!session) { - throw new UnauthenticatedError('Session is not attached'); - } - - req.headers.authorization = await session.getAuthHeader(); - - next(); -} diff --git a/packages/admin-panel-server/src/middleware/index.ts b/packages/admin-panel-server/src/middleware/index.ts index 0fd36e8f22..58f897275e 100644 --- a/packages/admin-panel-server/src/middleware/index.ts +++ b/packages/admin-panel-server/src/middleware/index.ts @@ -3,6 +3,5 @@ * Copyright (c) 2017 - 2021 Beyond Essential Systems Pty Ltd */ -export { attachAuthorizationHeader } from './attachAuthorizationHeader'; export { upload } from './upload'; export { verifyBESAdminAccess } from './verifyBESAdminAccess'; diff --git a/packages/admin-panel-server/src/routes/FetchAggregationOptionsRoute.ts b/packages/admin-panel-server/src/routes/FetchAggregationOptionsRoute.ts index 487a738d3e..9f8822643f 100644 --- a/packages/admin-panel-server/src/routes/FetchAggregationOptionsRoute.ts +++ b/packages/admin-panel-server/src/routes/FetchAggregationOptionsRoute.ts @@ -4,12 +4,10 @@ * */ -import { Request, Response, NextFunction } from 'express'; +import { Request } from 'express'; import { Route } from '@tupaia/server-boilerplate'; -import { ReportConnection } from '../connections'; - export type FetchAggregationOptionsRequest = Request< Record, Record[], @@ -17,15 +15,7 @@ export type FetchAggregationOptionsRequest = Request< >; export class FetchAggregationOptionsRoute extends Route { - private readonly reportConnection: ReportConnection; - - public constructor(req: FetchAggregationOptionsRequest, res: Response, next: NextFunction) { - super(req, res, next); - - this.reportConnection = new ReportConnection(req.session); - } - public async buildResponse() { - return this.reportConnection.fetchAggregationOptions(); + return this.req.ctx.services.report.fetchAggregationOptions(); } } diff --git a/packages/admin-panel-server/src/routes/FetchHierarchyEntitiesRoute.ts b/packages/admin-panel-server/src/routes/FetchHierarchyEntitiesRoute.ts index 860c9078fb..9730a9d4f6 100644 --- a/packages/admin-panel-server/src/routes/FetchHierarchyEntitiesRoute.ts +++ b/packages/admin-panel-server/src/routes/FetchHierarchyEntitiesRoute.ts @@ -4,11 +4,9 @@ * */ -import { Request, Response, NextFunction } from 'express'; +import { Request } from 'express'; -import { QueryParameters, Route } from '@tupaia/server-boilerplate'; - -import { EntityConnection } from '../connections'; +import { Route } from '@tupaia/server-boilerplate'; export type FetchHierarchyEntitiesRequest = Request< { hierarchyName: string; entityCode: string }, @@ -18,24 +16,31 @@ export type FetchHierarchyEntitiesRequest = Request< >; export class FetchHierarchyEntitiesRoute extends Route { - private readonly entityConnection: EntityConnection; - - public constructor(req: FetchHierarchyEntitiesRequest, res: Response, next: NextFunction) { - super(req, res, next); - - this.entityConnection = new EntityConnection(req.session); - } - public async buildResponse() { + const { entity: entityApi } = this.req.ctx.services; const { hierarchyName, entityCode } = this.req.params; const { fields, search } = this.req.query; - const queryParams: QueryParameters = {}; + const queryParams: { + fields?: string[]; + filter?: Record; + } = {}; if (fields) { - queryParams.fields = fields; + queryParams.fields = fields.split(','); } if (search) { - queryParams.filter = `name=@${search}`; + queryParams.filter = { + name: { + comparator: 'ilike', + comparisonValue: search, + }, + }; } - return this.entityConnection.getEntities(hierarchyName, entityCode, queryParams); + const projectEntity = await entityApi.getEntities(hierarchyName, [entityCode], queryParams); + const descendants = await entityApi.getDescendantsOfEntity( + hierarchyName, + entityCode, + queryParams, + ); + return projectEntity.concat(descendants); } } diff --git a/packages/admin-panel-server/src/routes/FetchReportPreviewDataRoute.ts b/packages/admin-panel-server/src/routes/FetchReportPreviewDataRoute.ts index 77862f1248..bd021d145d 100644 --- a/packages/admin-panel-server/src/routes/FetchReportPreviewDataRoute.ts +++ b/packages/admin-panel-server/src/routes/FetchReportPreviewDataRoute.ts @@ -4,11 +4,10 @@ * */ -import { Request, Response, NextFunction } from 'express'; +import { Request } from 'express'; import { Route } from '@tupaia/server-boilerplate'; -import { ReportConnection } from '../connections'; import { DashboardVisualisationExtractor, draftDashboardItemValidator, @@ -31,14 +30,6 @@ export type FetchReportPreviewDataRequest = Request< >; export class FetchReportPreviewDataRoute extends Route { - private readonly reportConnection: ReportConnection; - - public constructor(req: FetchReportPreviewDataRequest, res: Response, next: NextFunction) { - super(req, res, next); - - this.reportConnection = new ReportConnection(req.session); - } - public async buildResponse() { this.validate(); @@ -47,7 +38,7 @@ export class FetchReportPreviewDataRoute extends Route, Record[], @@ -17,15 +15,7 @@ export type FetchTransformSchemasRequest = Request< >; export class FetchTransformSchemasRoute extends Route { - private readonly reportConnection: ReportConnection; - - public constructor(req: FetchTransformSchemasRequest, res: Response, next: NextFunction) { - super(req, res, next); - - this.reportConnection = new ReportConnection(req.session); - } - public async buildResponse() { - return this.reportConnection.fetchTransformSchemas(); + return this.req.ctx.services.report.fetchTransformSchemas(); } } diff --git a/packages/admin-panel-server/src/routes/UserRoute.ts b/packages/admin-panel-server/src/routes/UserRoute.ts index a2257a46d5..c8b520d494 100644 --- a/packages/admin-panel-server/src/routes/UserRoute.ts +++ b/packages/admin-panel-server/src/routes/UserRoute.ts @@ -3,19 +3,18 @@ * Copyright (c) 2017 - 2021 Beyond Essential Systems Pty Ltd */ -import { Request, Response, NextFunction } from 'express'; +import camelcaseKeys from 'camelcase-keys'; import { Route } from '@tupaia/server-boilerplate'; -import { CentralConnection } from '../connections'; +import { AccessPolicy } from '@tupaia/access-policy'; +import { BES_ADMIN_PERMISSION_GROUP } from '../constants'; -export class UserRoute extends Route { - private readonly centralConnection: CentralConnection; - - public constructor(req: Request, res: Response, next: NextFunction) { - super(req, res, next); - this.centralConnection = new CentralConnection(req.session); - } +const isBESAdmin = (policy: Record) => { + return new AccessPolicy(policy).allowsSome(undefined, BES_ADMIN_PERMISSION_GROUP); +}; +export class UserRoute extends Route { public async buildResponse() { - return this.centralConnection.getUser(); + const user = await this.req.ctx.services.central.getUser(); + return { ...camelcaseKeys(user), isBESAdmin: isBESAdmin(user.accessPolicy) }; } } diff --git a/packages/admin-panel-server/src/routes/dashboardVisualisations/ExportDashboardVisualisationRoute.ts b/packages/admin-panel-server/src/routes/dashboardVisualisations/ExportDashboardVisualisationRoute.ts index 6ed8c6e00b..63f6efee07 100644 --- a/packages/admin-panel-server/src/routes/dashboardVisualisations/ExportDashboardVisualisationRoute.ts +++ b/packages/admin-panel-server/src/routes/dashboardVisualisations/ExportDashboardVisualisationRoute.ts @@ -4,12 +4,11 @@ * */ -import { Request, Response, NextFunction } from 'express'; +import { Request } from 'express'; import { keyBy } from 'lodash'; import { camelKeys } from '@tupaia/utils'; import { Route } from '@tupaia/server-boilerplate'; -import { CentralConnection } from '../../connections'; import { combineDashboardVisualisation } from '../../viz-builder'; import type { Dashboard, @@ -31,14 +30,6 @@ export type ExportDashboardVisualisationRequest = Request< export class ExportDashboardVisualisationRoute extends Route { protected readonly type = 'download'; - private readonly centralConnection: CentralConnection; - - public constructor(req: ExportDashboardVisualisationRequest, res: Response, next: NextFunction) { - super(req, res, next); - - this.centralConnection = new CentralConnection(req.session); - } - public async buildResponse() { this.validate(); @@ -84,9 +75,10 @@ export class ExportDashboardVisualisationRoute extends Route => { const { dashboardVisualisationId } = this.req.params; + const { central: centralApi } = this.req.ctx.services; if (dashboardVisualisationId) { - const dashboardItem = await this.centralConnection.fetchResources( + const dashboardItem = await centralApi.fetchResources( `dashboardItems/${dashboardVisualisationId}`, ); if (!dashboardItem) { @@ -97,7 +89,7 @@ export class ExportDashboardVisualisationRoute extends Route { - const vizResource: DashboardVizResource = await this.centralConnection.fetchResources( + const vizResource: DashboardVizResource = await this.req.ctx.services.central.fetchResources( `dashboardVisualisations/${dashboardItem.id}`, ); return combineDashboardVisualisation(vizResource); @@ -116,19 +108,17 @@ export class ExportDashboardVisualisationRoute extends Route dr.dashboard_id); - const dashboardRecords: DashboardRecord[] = await this.centralConnection.fetchResources( - 'dashboards', - { - filter: { - id: dashboardIds, - }, + const dashboardRecords: DashboardRecord[] = await centralApi.fetchResources('dashboards', { + filter: { + id: dashboardIds, }, - ); + }); const dashboardsById = keyBy(dashboardRecords, 'id'); const dashboards = dashboardRecords.map(({ id, ...dashboard }) => camelKeys(dashboard)); diff --git a/packages/admin-panel-server/src/routes/dashboardVisualisations/FetchDashboardVisualisationRoute.ts b/packages/admin-panel-server/src/routes/dashboardVisualisations/FetchDashboardVisualisationRoute.ts index 418c5fd8d7..9a248aa0f8 100644 --- a/packages/admin-panel-server/src/routes/dashboardVisualisations/FetchDashboardVisualisationRoute.ts +++ b/packages/admin-panel-server/src/routes/dashboardVisualisations/FetchDashboardVisualisationRoute.ts @@ -4,11 +4,10 @@ * */ -import { Request, Response, NextFunction } from 'express'; +import { Request } from 'express'; import { Route } from '@tupaia/server-boilerplate'; -import { CentralConnection } from '../../connections'; import { combineDashboardVisualisation, DashboardViz } from '../../viz-builder'; export type FetchDashboardVisualisationRequest = Request< @@ -19,17 +18,9 @@ export type FetchDashboardVisualisationRequest = Request< >; export class FetchDashboardVisualisationRoute extends Route { - private readonly centralConnection: CentralConnection; - - public constructor(req: FetchDashboardVisualisationRequest, res: Response, next: NextFunction) { - super(req, res, next); - - this.centralConnection = new CentralConnection(req.session); - } - public async buildResponse() { const { dashboardVisualisationId } = this.req.params; - const visualisationResource = await this.centralConnection.fetchResources( + const visualisationResource = await this.req.ctx.services.central.fetchResources( `dashboardVisualisations/${dashboardVisualisationId}`, ); const visualisation = combineDashboardVisualisation(visualisationResource); diff --git a/packages/admin-panel-server/src/routes/dashboardVisualisations/ImportDashboardVisualisationRoute.ts b/packages/admin-panel-server/src/routes/dashboardVisualisations/ImportDashboardVisualisationRoute.ts index 0ba5be38cc..93c46e875d 100644 --- a/packages/admin-panel-server/src/routes/dashboardVisualisations/ImportDashboardVisualisationRoute.ts +++ b/packages/admin-panel-server/src/routes/dashboardVisualisations/ImportDashboardVisualisationRoute.ts @@ -5,12 +5,11 @@ */ import assert from 'assert'; -import { Request, Response, NextFunction } from 'express'; +import { Request } from 'express'; import { Route } from '@tupaia/server-boilerplate'; import { reduceToDictionary, snakeKeys, UploadError, yup } from '@tupaia/utils'; -import { CentralConnection } from '../../connections'; import { dashboardValidator, dashboardRelationObjectValidator, @@ -51,14 +50,6 @@ type ImportFileContent = { } & Record; export class ImportDashboardVisualisationRoute extends Route { - private readonly centralConnection: CentralConnection; - - public constructor(req: ImportDashboardVisualisationRequest, res: Response, next: NextFunction) { - super(req, res, next); - - this.centralConnection = new CentralConnection(req.session); - } - public async buildResponse() { const { files } = this.req; if (!files || !Array.isArray(files)) { @@ -118,7 +109,7 @@ export class ImportDashboardVisualisationRoute extends Route) => { const { id, code } = visualisation; - const [viz] = await this.centralConnection.fetchResources('dashboardVisualisations', { + const [viz] = await this.req.ctx.services.central.fetchResources('dashboardVisualisations', { filter: { id: id ?? undefined, code, @@ -128,8 +119,9 @@ export class ImportDashboardVisualisationRoute extends Route { - await this.centralConnection.createResource('dashboardVisualisations', {}, visualisation); - const [viz]: DashboardVizResource[] = await this.centralConnection.fetchResources( + const { central: centralApi } = this.req.ctx.services; + await centralApi.createResource('dashboardVisualisations', {}, visualisation); + const [viz]: DashboardVizResource[] = await centralApi.fetchResources( 'dashboardVisualisations', { filter: { @@ -145,7 +137,7 @@ export class ImportDashboardVisualisationRoute extends Route) => { - await this.centralConnection.updateResource( + await this.req.ctx.services.central.updateResource( `dashboardVisualisations/${vizId}`, {}, visualisation, @@ -159,7 +151,7 @@ export class ImportDashboardVisualisationRoute extends Route { await this.upsertDashboards(dashboards.map(d => snakeKeys(d))); - const dashboardRecords = await this.centralConnection.fetchResources('dashboards', { + const dashboardRecords = await this.req.ctx.services.central.fetchResources('dashboards', { filter: { code: dashboardRelations.map(dr => dr.dashboardCode), }, @@ -181,7 +173,7 @@ export class ImportDashboardVisualisationRoute extends Route Promise.all( dashboards.map(dashboard => - this.centralConnection.upsertResource( + this.req.ctx.services.central.upsertResource( 'dashboards', { filter: { @@ -196,7 +188,7 @@ export class ImportDashboardVisualisationRoute extends Route Promise.all( dashboardRelations.map(relation => - this.centralConnection.upsertResource( + this.req.ctx.services.central.upsertResource( 'dashboardRelations', { filter: { diff --git a/packages/admin-panel-server/src/routes/dashboardVisualisations/SaveDashboardVisualisationRoute.ts b/packages/admin-panel-server/src/routes/dashboardVisualisations/SaveDashboardVisualisationRoute.ts index 6a55b4f1af..129e0fe612 100644 --- a/packages/admin-panel-server/src/routes/dashboardVisualisations/SaveDashboardVisualisationRoute.ts +++ b/packages/admin-panel-server/src/routes/dashboardVisualisations/SaveDashboardVisualisationRoute.ts @@ -4,11 +4,10 @@ * */ -import { Request, Response, NextFunction } from 'express'; +import { Request } from 'express'; import { Route } from '@tupaia/server-boilerplate'; -import { CentralConnection } from '../../connections'; import { DashboardVisualisationExtractor, draftDashboardItemValidator, @@ -23,17 +22,10 @@ export type SaveDashboardVisualisationRequest = Request< >; export class SaveDashboardVisualisationRoute extends Route { - private readonly centralConnection: CentralConnection; - - public constructor(req: SaveDashboardVisualisationRequest, res: Response, next: NextFunction) { - super(req, res, next); - - this.centralConnection = new CentralConnection(req.session); - } - public async buildResponse() { const { visualisation } = this.req.body; const { dashboardVisualisationId } = this.req.params; + const { central: centralApi } = this.req.ctx.services; if (!visualisation) { throw new Error('Visualisation cannot be empty.'); @@ -50,13 +42,13 @@ export class SaveDashboardVisualisationRoute extends Route { protected readonly type = 'download'; - private readonly centralConnection: CentralConnection; - - public constructor(req: ExportMapOverlayVisualisationRequest, res: Response, next: NextFunction) { - super(req, res, next); - - this.centralConnection = new CentralConnection(req.session); - } - public async buildResponse() { this.validate(); @@ -84,9 +75,10 @@ export class ExportMapOverlayVisualisationRoute extends Route => { const { mapOverlayVisualisationId } = this.req.params; + const { central: centralApi } = this.req.ctx.services; if (mapOverlayVisualisationId) { - const mapOverlay = await this.centralConnection.fetchResources( + const mapOverlay = await centralApi.fetchResources( `mapOverlays/${mapOverlayVisualisationId}`, ); if (!mapOverlay) { @@ -97,7 +89,7 @@ export class ExportMapOverlayVisualisationRoute extends Route { - const vizResource: MapOverlayVizResource = await this.centralConnection.fetchResources( + const vizResource: MapOverlayVizResource = await this.req.ctx.services.central.fetchResources( `mapOverlayVisualisations/${mapOverlay.id}`, ); return combineMapOverlayVisualisation(vizResource); @@ -116,12 +108,13 @@ export class ExportMapOverlayVisualisationRoute extends Route mogr.map_overlay_group_id); - const mapOverlayGroupRecords: MapOverlayGroupRecord[] = await this.centralConnection.fetchResources( + const mapOverlayGroupRecords: MapOverlayGroupRecord[] = await centralApi.fetchResources( 'mapOverlayGroups', { filter: { diff --git a/packages/admin-panel-server/src/routes/mapOverlayVisualisations/FetchMapOverlayVisualisationRoute.ts b/packages/admin-panel-server/src/routes/mapOverlayVisualisations/FetchMapOverlayVisualisationRoute.ts index 7d73b0053b..b637551009 100644 --- a/packages/admin-panel-server/src/routes/mapOverlayVisualisations/FetchMapOverlayVisualisationRoute.ts +++ b/packages/admin-panel-server/src/routes/mapOverlayVisualisations/FetchMapOverlayVisualisationRoute.ts @@ -4,11 +4,10 @@ * */ -import { Request, Response, NextFunction } from 'express'; +import { Request } from 'express'; import { Route } from '@tupaia/server-boilerplate'; -import { CentralConnection } from '../../connections'; import { combineMapOverlayVisualisation, MapOverlayViz } from '../../viz-builder'; export type FetchMapOverlayVisualisationRequest = Request< @@ -19,17 +18,9 @@ export type FetchMapOverlayVisualisationRequest = Request< >; export class FetchMapOverlayVisualisationRoute extends Route { - private readonly centralConnection: CentralConnection; - - public constructor(req: FetchMapOverlayVisualisationRequest, res: Response, next: NextFunction) { - super(req, res, next); - - this.centralConnection = new CentralConnection(req.session); - } - public async buildResponse() { const { mapOverlayVisualisationId } = this.req.params; - const visualisationResource = await this.centralConnection.fetchResources( + const visualisationResource = await this.req.ctx.services.central.fetchResources( `mapOverlayVisualisations/${mapOverlayVisualisationId}`, ); const visualisation = combineMapOverlayVisualisation(visualisationResource); diff --git a/packages/admin-panel-server/src/routes/mapOverlayVisualisations/ImportMapOverlayVisualisationRoute.ts b/packages/admin-panel-server/src/routes/mapOverlayVisualisations/ImportMapOverlayVisualisationRoute.ts index 905a479a35..373264900d 100644 --- a/packages/admin-panel-server/src/routes/mapOverlayVisualisations/ImportMapOverlayVisualisationRoute.ts +++ b/packages/admin-panel-server/src/routes/mapOverlayVisualisations/ImportMapOverlayVisualisationRoute.ts @@ -5,12 +5,11 @@ */ import assert from 'assert'; -import { Request, Response, NextFunction } from 'express'; +import { Request } from 'express'; import { Route } from '@tupaia/server-boilerplate'; import { ValidationError, reduceToDictionary, snakeKeys, UploadError, yup } from '@tupaia/utils'; -import { CentralConnection } from '../../connections'; import { MapOverlayVisualisationExtractor, draftReportValidator, @@ -51,14 +50,6 @@ type ImportFileContent = { } & Record; export class ImportMapOverlayVisualisationRoute extends Route { - private readonly centralConnection: CentralConnection; - - public constructor(req: ImportMapOverlayVisualisationRequest, res: Response, next: NextFunction) { - super(req, res, next); - - this.centralConnection = new CentralConnection(req.session); - } - public async buildResponse() { const { files } = this.req; if (!files || !Array.isArray(files)) { @@ -119,7 +110,7 @@ export class ImportMapOverlayVisualisationRoute extends Route) => { const { id, code } = visualisation; - const [viz] = await this.centralConnection.fetchResources('mapOverlayVisualisations', { + const [viz] = await this.req.ctx.services.central.fetchResources('mapOverlayVisualisations', { filter: { id: id ?? undefined, code, @@ -129,8 +120,9 @@ export class ImportMapOverlayVisualisationRoute extends Route { - await this.centralConnection.createResource('mapOverlayVisualisations', {}, visualisation); - const [viz]: MapOverlayVizResource[] = await this.centralConnection.fetchResources( + const { central: centralApi } = this.req.ctx.services; + await centralApi.createResource('mapOverlayVisualisations', {}, visualisation); + const [viz]: MapOverlayVizResource[] = await centralApi.fetchResources( 'mapOverlayVisualisations', { filter: { @@ -146,7 +138,7 @@ export class ImportMapOverlayVisualisationRoute extends Route) => { - await this.centralConnection.updateResource( + await this.req.ctx.services.central.updateResource( `mapOverlayVisualisations/${vizId}`, {}, visualisation, @@ -159,8 +151,9 @@ export class ImportMapOverlayVisualisationRoute extends Route { + const { central: centralApi } = this.req.ctx.services; await this.upsertMapOverlayGroups(mapOverlayGroups.map(mog => snakeKeys(mog))); - const mapOverlayGroupRecords = await this.centralConnection.fetchResources('mapOverlayGroups', { + const mapOverlayGroupRecords = await centralApi.fetchResources('mapOverlayGroups', { filter: { code: mapOverlayGroupRelations.map(mogr => mogr.mapOverlayGroupCode), }, @@ -187,7 +180,7 @@ export class ImportMapOverlayVisualisationRoute extends Route Promise.all( mapOverlayGroups.map(mapOverlayGroup => - this.centralConnection.upsertResource( + this.req.ctx.services.central.upsertResource( 'mapOverlayGroups', { filter: { @@ -204,7 +197,7 @@ export class ImportMapOverlayVisualisationRoute extends Route Promise.all( mapOverlayGroupRelations.map(relation => - this.centralConnection.upsertResource( + this.req.ctx.services.central.upsertResource( 'mapOverlayGroupRelations', { filter: { diff --git a/packages/admin-panel-server/src/routes/mapOverlayVisualisations/SaveMapOverlayVisualisationRoute.ts b/packages/admin-panel-server/src/routes/mapOverlayVisualisations/SaveMapOverlayVisualisationRoute.ts index 40bc2183cd..face30189a 100644 --- a/packages/admin-panel-server/src/routes/mapOverlayVisualisations/SaveMapOverlayVisualisationRoute.ts +++ b/packages/admin-panel-server/src/routes/mapOverlayVisualisations/SaveMapOverlayVisualisationRoute.ts @@ -4,11 +4,10 @@ * */ -import { Request, Response, NextFunction } from 'express'; +import { Request } from 'express'; import { Route } from '@tupaia/server-boilerplate'; -import { CentralConnection } from '../../connections'; import { MapOverlayVisualisationExtractor, draftMapOverlayValidator, @@ -23,17 +22,10 @@ export type SaveMapOverlayVisualisationRequest = Request< >; export class SaveMapOverlayVisualisationRoute extends Route { - private readonly centralConnection: CentralConnection; - - public constructor(req: SaveMapOverlayVisualisationRequest, res: Response, next: NextFunction) { - super(req, res, next); - - this.centralConnection = new CentralConnection(req.session); - } - public async buildResponse() { const { visualisation } = this.req.body; const { mapOverlayVisualisationId } = this.req.params; + const { central: centralApi } = this.req.ctx.services; if (!visualisation) { throw new Error('Visualisation cannot be empty.'); @@ -50,13 +42,13 @@ export class SaveMapOverlayVisualisationRoute extends Route | Record[]; +export type RequestBody = Record | Record[]; type FetchHeaders = HeadersInit & { Authorization: string; diff --git a/packages/api-client/src/connections/CentralApi.ts b/packages/api-client/src/connections/CentralApi.ts index e952b28be1..763ed1b709 100644 --- a/packages/api-client/src/connections/CentralApi.ts +++ b/packages/api-client/src/connections/CentralApi.ts @@ -3,6 +3,8 @@ * Copyright (c) 2017 - 2020 Beyond Essential Systems Pty Ltd */ +import { QueryParameters } from '../types'; +import { RequestBody } from './ApiConnection'; import { BaseApi } from './BaseApi'; export type SurveyResponse = { @@ -16,7 +18,18 @@ export type Answers = { [key: string]: string; // question_code -> value }; +const stringifyParams = (queryParameters?: Record) => { + const translatedParams = queryParameters?.filter + ? { ...queryParameters, filter: JSON.stringify(queryParameters?.filter) } + : queryParameters; + return translatedParams as QueryParameters; +}; + export class CentralApi extends BaseApi { + public async getUser() { + return this.connection.get('me'); + } + public async registerUserAccount( userFields: Record, ): Promise<{ userId: string; message: string }> { @@ -36,4 +49,51 @@ export class CentralApi extends BaseApi { await this.connection.post('surveyResponse', null, chunk); } } + + public async fetchResources(endpoint: string, params?: Record) { + return this.connection.get(endpoint, stringifyParams(params)); + } + + public async createResource( + endpoint: string, + params: Record, + body: RequestBody, + ) { + return this.connection.post(endpoint, stringifyParams(params), body); + } + + public async updateResource( + endpoint: string, + params: Record, + body: RequestBody, + ) { + return this.connection.put(endpoint, stringifyParams(params), body); + } + + public async deleteResource(endpoint: string) { + return this.connection.delete(endpoint); + } + + public async upsertResource( + endpoint: string, + params: Record, + body: RequestBody, + ) { + const results = await this.fetchResources(endpoint, params); + + if (results.length > 1) { + throw new Error( + `Cannot upsert ${endpoint} since multiple resources were found: please use unique fields in you query`, + ); + } + + if (results.length === 1) { + await this.updateResource(`${endpoint}/${results[0].id}`, params, body); + } else { + await this.createResource(endpoint, params, body); + } + + const [resource] = await this.fetchResources(endpoint, params); + return resource; + } } diff --git a/packages/api-client/src/connections/ReportApi.ts b/packages/api-client/src/connections/ReportApi.ts new file mode 100644 index 0000000000..565538b01e --- /dev/null +++ b/packages/api-client/src/connections/ReportApi.ts @@ -0,0 +1,22 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2022 Beyond Essential Systems Pty Ltd + */ + +import { QueryParameters } from '../types'; +import { RequestBody } from './ApiConnection'; +import { BaseApi } from './BaseApi'; + +export class ReportApi extends BaseApi { + public async testReport(query: QueryParameters, body: RequestBody) { + return this.connection.post('testReport', query, body); + } + + public async fetchAggregationOptions() { + return this.connection.get('fetchAggregationOptions'); + } + + public async fetchTransformSchemas() { + return this.connection.get('fetchTransformSchemas'); + } +} diff --git a/packages/api-client/src/connections/index.ts b/packages/api-client/src/connections/index.ts index a6a4c98d9e..872b8e6889 100644 --- a/packages/api-client/src/connections/index.ts +++ b/packages/api-client/src/connections/index.ts @@ -8,3 +8,4 @@ export { ApiConnection } from './ApiConnection'; export { AuthApi } from './AuthApi'; export { EntityApi } from './EntityApi'; export { CentralApi } from './CentralApi'; +export { ReportApi } from './ReportApi'; diff --git a/packages/server-boilerplate/src/orchestrator/api/ApiBuilder.ts b/packages/server-boilerplate/src/orchestrator/api/ApiBuilder.ts index 64d79a1308..36e6183e42 100644 --- a/packages/server-boilerplate/src/orchestrator/api/ApiBuilder.ts +++ b/packages/server-boilerplate/src/orchestrator/api/ApiBuilder.ts @@ -161,7 +161,7 @@ export class ApiBuilder { } public attachApiClientToContext(authHandlerProvider: (req: Request) => AuthHandler) { - return this.use('*', (req, res, next) => { + this.app.use((req, res, next) => { try { const baseUrls = process.env.NODE_ENV === 'test' ? LOCALHOST_BASE_URLS : getBaseUrlsForHost(req.hostname); @@ -173,6 +173,8 @@ export class ApiBuilder { next(err); } }); + + return this; } public use = Request>( diff --git a/yarn.lock b/yarn.lock index 3e8d82f4ef..8e18be2733 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4991,6 +4991,7 @@ __metadata: resolution: "@tupaia/admin-panel-server@workspace:packages/admin-panel-server" dependencies: "@tupaia/access-policy": 3.0.0 + "@tupaia/api-client": 3.1.0 "@tupaia/database": 1.0.0 "@tupaia/report-server": 0.0.0 "@tupaia/server-boilerplate": 1.0.0 From 6ee711bfd1314b820a282b8eebf91cf9af87f750 Mon Sep 17 00:00:00 2001 From: Rohan Port <59544282+rohan-bes@users.noreply.github.com> Date: Mon, 10 Oct 2022 15:00:47 +1100 Subject: [PATCH 03/12] Removed waitForAllChangeHandlers() from loops (#4209) --- .../sync/PullChangesRoute.test.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/meditrak-app-server/src/__tests__/__integration__/sync/PullChangesRoute.test.ts b/packages/meditrak-app-server/src/__tests__/__integration__/sync/PullChangesRoute.test.ts index 5250dbfe7a..ec8d00b7c2 100644 --- a/packages/meditrak-app-server/src/__tests__/__integration__/sync/PullChangesRoute.test.ts +++ b/packages/meditrak-app-server/src/__tests__/__integration__/sync/PullChangesRoute.test.ts @@ -140,9 +140,10 @@ describe('changes (GET)', () => { await oneSecondSleep(); for (let i = 0; i < numberOfQuestionsToAdd; i++) { newQuestions.push(await upsertDummyQuestion(models)); - await models.database.waitForAllChangeHandlers(); } + await models.database.waitForAllChangeHandlers(); + const response = await app.get('changes', { headers: { Authorization: authHeader, @@ -172,7 +173,6 @@ describe('changes (GET)', () => { const questionsInFirstUpdate = []; for (let i = 0; i < numberOfQuestionsToAddInFirstUpdate; i++) { questionsInFirstUpdate.push(await upsertDummyQuestion(models)); - await models.database.waitForAllChangeHandlers(); } // Add some more questions @@ -187,7 +187,6 @@ describe('changes (GET)', () => { const questionsInSecondUpdate = []; for (let i = 0; i < numberOfQuestionsToAddInSecondUpdate; i++) { questionsInSecondUpdate.push(await upsertDummyQuestion(models)); - await models.database.waitForAllChangeHandlers(); } // Delete some of the questions added in the first update @@ -211,7 +210,6 @@ describe('changes (GET)', () => { ); for (let i = 0; i < numberOfQuestionsToDeleteFromFirstUpdate; i++) { await models.question.deleteById(questionsInFirstUpdate[i].id); - await models.database.waitForAllChangeHandlers(); } // Delete some of the questions added in the second update @@ -235,9 +233,10 @@ describe('changes (GET)', () => { ); for (let i = 0; i < numberOfQuestionsToDeleteFromSecondUpdate; i++) { await models.question.deleteById(questionsInSecondUpdate[i].id); - await models.database.waitForAllChangeHandlers(); } + await models.database.waitForAllChangeHandlers(); + // If syncing from before the first update, should only need to sync the number of records that // actually need to be added. No need to know about deletes of records we never integrated const keptQuestions = [...questionKeptFromFirstUpdate, ...questionKeptFromSecondUpdate]; @@ -326,9 +325,10 @@ describe('changes (GET)', () => { const newEntities = []; for (let i = 0; i < numberOfEntitiesToAdd; i++) { newEntities.push(await upsertDummyRecord(models.entity, {})); - await models.database.waitForAllChangeHandlers(); } + await models.database.waitForAllChangeHandlers(); + const entitySupportedResponse = await app.get('changes', { headers: { Authorization: authHeader, @@ -364,15 +364,15 @@ describe('changes (GET)', () => { const newEntities = []; for (let i = 0; i < numberOfEntitiesToAdd; i++) { newEntities.push(await upsertDummyRecord(models.entity, {})); - await models.database.waitForAllChangeHandlers(); } const newQuestions = []; for (let i = 0; i < numberOfQuestionsToAdd; i++) { newQuestions.push(await upsertDummyQuestion(models)); - await models.database.waitForAllChangeHandlers(); } + await models.database.waitForAllChangeHandlers(); + const entityChangesResponse = await app.get('changes', { headers: { Authorization: authHeader, @@ -426,9 +426,10 @@ describe('changes (GET)', () => { await oneSecondSleep(); for (let i = 0; i < numberOfQuestionsToAdd; i++) { newQuestions.push(await upsertDummyQuestion(models)); - await models.database.waitForAllChangeHandlers(); } + await models.database.waitForAllChangeHandlers(); + const expectedResults = await Promise.all( newQuestions.map(questionCreated => recordToChange('question', questionCreated, 'update')), ); From e76d2da6098ed536cfd0604c6b78faab464d117f Mon Sep 17 00:00:00 2001 From: Chris Pollard Date: Mon, 10 Oct 2022 15:08:11 +1100 Subject: [PATCH 04/12] RN-592: Add Multiline To FreeText Question Field (#4207) --- .../assessment/specificQuestions/FreeTextQuestion.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/meditrak-app/app/assessment/specificQuestions/FreeTextQuestion.js b/packages/meditrak-app/app/assessment/specificQuestions/FreeTextQuestion.js index 02681b1019..2c810a852f 100644 --- a/packages/meditrak-app/app/assessment/specificQuestions/FreeTextQuestion.js +++ b/packages/meditrak-app/app/assessment/specificQuestions/FreeTextQuestion.js @@ -50,7 +50,7 @@ export class FreeTextQuestion extends Component { this.onFocus()} @@ -58,6 +58,7 @@ export class FreeTextQuestion extends Component { onSubmitEditing={onSubmitEditing} onChangeText={onChangeAnswer} placeholderTextColor={getThemeColorOneFaded(0.7)} + multiline={true} {...restOfTextInputProps} /> @@ -82,7 +83,7 @@ FreeTextQuestion.defaultProps = { const localStyles = StyleSheet.create({ wrapper: { position: 'relative', - marginVertical: 10, + marginVertical: 20, borderBottomWidth: 1, borderBottomColor: getThemeColorOneFaded(0.2), }, @@ -93,10 +94,9 @@ const localStyles = StyleSheet.create({ color: THEME_TEXT_COLOR_ONE, fontFamily: THEME_FONT_FAMILY, fontSize: THEME_FONT_SIZE_ONE, + lineHeight: 25, paddingVertical: 10, - }, - textInputFixedHeight: { - height: 40, + paddingEnd: 28, }, icon: { position: 'absolute', From 0e3690e476746133b923a6d2beef43179f4e2d65 Mon Sep 17 00:00:00 2001 From: Rohan Port <59544282+rohan-bes@users.noreply.github.com> Date: Mon, 10 Oct 2022 15:50:06 +1100 Subject: [PATCH 05/12] RN-665: Fix the login button in the project page project cards (#4190) --- .../src/containers/OverlayDiv/components/LandingPage/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/web-frontend/src/containers/OverlayDiv/components/LandingPage/index.js b/packages/web-frontend/src/containers/OverlayDiv/components/LandingPage/index.js index e68d3f835c..24100a7e91 100644 --- a/packages/web-frontend/src/containers/OverlayDiv/components/LandingPage/index.js +++ b/packages/web-frontend/src/containers/OverlayDiv/components/LandingPage/index.js @@ -45,11 +45,11 @@ const ViewProjectsButton = styled(Button)` `; export const LandingPage = ({ isUserLoggedIn, isViewingProjects }) => { - const [isProjectsPageVisible, setIsProjectsPageVisible] = React.useState(false); + const [isProjectsPageVisible, setIsProjectsPageVisible] = React.useState(isViewingProjects); const showProjects = React.useCallback(() => setIsProjectsPageVisible(true)); const hideProjects = React.useCallback(() => setIsProjectsPageVisible(false)); - const isLoginPageVisible = !isViewingProjects && !isUserLoggedIn && !isProjectsPageVisible; + const isLoginPageVisible = !isUserLoggedIn && !isProjectsPageVisible; return ( From 3c376b014bc4eabfa2c588d5d11290df9441807c Mon Sep 17 00:00:00 2001 From: Rohan Port <59544282+rohan-bes@users.noreply.github.com> Date: Tue, 11 Oct 2022 12:51:35 +1100 Subject: [PATCH 06/12] RN-175: Add order columns transform (#4191) --- .../DashboardVisualisationExtractor.test.ts | 48 ++++ .../DashboardVisualisationExtractor.ts | 10 +- .../MapOverlayVisualisationExtractor.ts | 10 +- .../extractDataFromReport.ts} | 2 +- .../viz-builder/utils/getVizOutputConfig.ts | 25 ++ .../src/viz-builder/utils/index.ts | 7 + .../api/queries/useReportPreview.js | 2 + .../components/PreviewSection.js | 46 +++- packages/data-api/package.json | 1 + packages/data-api/src/TupaiaDataApi.ts | 5 +- packages/data-api/src/utils.ts | 4 - packages/report-server/package.json | 1 + .../reportBuilder/output/default.test.ts | 26 ++ .../reportBuilder/output/matrix.test.ts | 39 ++- .../output/rawDataExport.test.ts | 5 +- .../reportBuilder/output/rows.test.ts | 29 +++ .../output/rowsAndColumns.test.ts | 32 +++ .../reportBuilder/testUtils/entityApiMock.ts | 2 +- .../reportBuilder/transform/aliases.test.ts | 170 ++++++++----- .../transform/excludeColumns.test.ts | 18 +- .../transform/excludeRows.test.ts | 12 +- .../transform/gatherColumns.test.ts | 62 ++--- .../transform/insertColumns.test.ts | 65 +++-- .../transform/insertRows.test.ts | 182 ++++++++------ .../reportBuilder/transform/mergeRows.test.ts | 223 ++++++++++------- .../transform/orderColumns.test.ts | 141 +++++++++++ .../transform/parser/functions.test.ts | 2 +- .../transform/parser/parser.test.ts | 229 ++++++++++-------- .../reportBuilder/transform/sortRows.test.ts | 152 ++++++------ .../transform/table/TransformTable.test.ts | 229 ++++++++++++++++++ .../transform/transform.fixtures.ts | 20 ++ .../reportBuilder/transform/transform.test.ts | 10 +- .../transform/updateColumns.test.ts | 88 ++++--- .../reportBuilder/output/functions/default.ts | 10 - .../output/functions/matrix/matrix.ts | 8 +- .../output/functions/matrix/matrixBuilder.ts | 23 +- .../output/functions/outputBuilders.ts | 7 +- .../functions/rawDataExport/rawDataExport.ts | 10 +- .../rawDataExport/rawDataExportBuilder.ts | 20 +- .../reportBuilder/output/functions/rows.ts | 10 + .../output/functions/rowsAndColumns.ts | 13 + .../src/reportBuilder/output/output.ts | 8 +- .../src/reportBuilder/reportBuilder.ts | 4 +- .../aliases/entityMetadataAliases.ts | 22 +- .../aliases/keyValueByFieldAliases.ts | 43 ++-- .../transform/aliases/summaryAliases.ts | 86 ++++--- .../transform/functions/excludeColumns.ts | 37 +-- .../transform/functions/excludeRows.ts | 19 +- .../transform/functions/gatherColumns.ts | 38 +-- .../transform/functions/index.ts | 10 +- .../transform/functions/insertColumns.ts | 37 ++- .../transform/functions/insertRows.ts | 53 ++-- .../functions/mergeRows/createGroupKey.ts | 2 +- .../functions/mergeRows/mergeRows.ts | 38 +-- .../functions/mergeRows/mergeStrategies.ts | 25 +- .../transform/functions/orderColumns/index.ts | 6 + .../functions/orderColumns/orderColumns.ts | 105 ++++++++ .../functions/orderColumns/sortByFunctions.ts | 36 +++ .../transform/functions/sortRows.ts | 15 +- .../transform/functions/updateColumns.ts | 64 +++-- .../src/reportBuilder/transform/index.ts | 1 + .../transform/parser/TransformParser.ts | 7 +- .../transform/parser/functions/utils.ts | 2 +- .../transform/table/TransformTable.ts | 213 ++++++++++++++++ .../reportBuilder/transform/table/index.ts | 6 + .../src/reportBuilder/transform/transform.ts | 14 +- packages/tsutils/src/index.ts | 5 +- packages/tsutils/src/typeGuards.ts | 9 + packages/utils/src/period/period.js | 40 +++ tupaia-packages.code-workspace | 1 + yarn.lock | 2 + 71 files changed, 2124 insertions(+), 822 deletions(-) rename packages/admin-panel-server/src/viz-builder/{utils.ts => utils/extractDataFromReport.ts} (93%) create mode 100644 packages/admin-panel-server/src/viz-builder/utils/getVizOutputConfig.ts create mode 100644 packages/admin-panel-server/src/viz-builder/utils/index.ts create mode 100644 packages/report-server/src/__tests__/reportBuilder/output/default.test.ts create mode 100644 packages/report-server/src/__tests__/reportBuilder/output/rows.test.ts create mode 100644 packages/report-server/src/__tests__/reportBuilder/output/rowsAndColumns.test.ts create mode 100644 packages/report-server/src/__tests__/reportBuilder/transform/orderColumns.test.ts create mode 100644 packages/report-server/src/__tests__/reportBuilder/transform/table/TransformTable.test.ts delete mode 100644 packages/report-server/src/reportBuilder/output/functions/default.ts create mode 100644 packages/report-server/src/reportBuilder/output/functions/rows.ts create mode 100644 packages/report-server/src/reportBuilder/output/functions/rowsAndColumns.ts create mode 100644 packages/report-server/src/reportBuilder/transform/functions/orderColumns/index.ts create mode 100644 packages/report-server/src/reportBuilder/transform/functions/orderColumns/orderColumns.ts create mode 100644 packages/report-server/src/reportBuilder/transform/functions/orderColumns/sortByFunctions.ts create mode 100644 packages/report-server/src/reportBuilder/transform/table/TransformTable.ts create mode 100644 packages/report-server/src/reportBuilder/transform/table/index.ts create mode 100644 packages/tsutils/src/typeGuards.ts diff --git a/packages/admin-panel-server/src/__tests__/viz-builder/dashboardVisualisation/DashboardVisualisationExtractor.test.ts b/packages/admin-panel-server/src/__tests__/viz-builder/dashboardVisualisation/DashboardVisualisationExtractor.test.ts index ce3ac125d9..1c163f69cf 100644 --- a/packages/admin-panel-server/src/__tests__/viz-builder/dashboardVisualisation/DashboardVisualisationExtractor.test.ts +++ b/packages/admin-panel-server/src/__tests__/viz-builder/dashboardVisualisation/DashboardVisualisationExtractor.test.ts @@ -331,6 +331,11 @@ describe('DashboardVisualisationExtractor', () => { aggregations: ['SUM_EACH_WEEK'], }, transform: ['keyValueByDataElementName'], + output: { + type: 'bar', + x: 'period', + y: 'BCD1', + }, }, permissionGroup: 'Admin', }); @@ -380,6 +385,49 @@ describe('DashboardVisualisationExtractor', () => { permissionGroup: 'Admin', }); }); + + it('includes rowsAndColumns output if previewMode is data', () => { + const extractor = new DashboardVisualisationExtractor( + { + code: 'viz', + name: 'My Viz', + data: { + fetch: { + dataElements: ['BCD1', 'BCD2'], + }, + transform: ['keyValueByDataElementName'], + }, + presentation: { + type: 'chart', + chartType: 'bar', + output: { + type: 'bar', + x: 'period', + y: 'BCD1', + }, + }, + permissionGroup: 'Admin', + }, + draftDashboardItemValidator, + draftReportValidator, + ); + + const report = extractor.getReport(PreviewMode.DATA); + + expect(report).toEqual({ + code: 'viz', + config: { + fetch: { + dataElements: ['BCD1', 'BCD2'], + }, + transform: ['keyValueByDataElementName'], + output: { + type: 'rowsAndColumns', + }, + }, + permissionGroup: 'Admin', + }); + }); }); describe('getDashboardItem()', () => { diff --git a/packages/admin-panel-server/src/viz-builder/dashboardVisualisation/DashboardVisualisationExtractor.ts b/packages/admin-panel-server/src/viz-builder/dashboardVisualisation/DashboardVisualisationExtractor.ts index 188f3e8100..f24cb8398c 100644 --- a/packages/admin-panel-server/src/viz-builder/dashboardVisualisation/DashboardVisualisationExtractor.ts +++ b/packages/admin-panel-server/src/viz-builder/dashboardVisualisation/DashboardVisualisationExtractor.ts @@ -11,6 +11,7 @@ import type { DashboardVisualisationResource } from './types'; import type { LegacyReport, Report, ExpandType } from '../types'; import { PreviewMode } from '../types'; import { baseVisualisationValidator, baseVisualisationDataValidator } from '../validators'; +import { getVizOutputConfig } from '../utils'; export class DashboardVisualisationExtractor< DashboardItemValidator extends yup.AnyObjectSchema, @@ -72,7 +73,9 @@ export class DashboardVisualisationExtractor< return this.dashboardItemValidator.validateSync(this.vizToDashboardItem()); } - private vizToReport(previewMode?: PreviewMode): Record { + private vizToReport( + previewMode: PreviewMode = PreviewMode.PRESENTATION, + ): Record { const { code, permissionGroup, data, presentation } = this.visualisation; const validatedData = baseVisualisationDataValidator.validateSync(data); @@ -95,11 +98,14 @@ export class DashboardVisualisationExtractor< }, isNil, ); + + const output = getVizOutputConfig(previewMode, presentation); + const config = omitBy( { fetch, transform, - output: previewMode === PreviewMode.PRESENTATION ? presentation?.output : null, + output, }, isNil, ); diff --git a/packages/admin-panel-server/src/viz-builder/mapOverlayVisualisation/MapOverlayVisualisationExtractor.ts b/packages/admin-panel-server/src/viz-builder/mapOverlayVisualisation/MapOverlayVisualisationExtractor.ts index edf82bf59b..a04229dd60 100644 --- a/packages/admin-panel-server/src/viz-builder/mapOverlayVisualisation/MapOverlayVisualisationExtractor.ts +++ b/packages/admin-panel-server/src/viz-builder/mapOverlayVisualisation/MapOverlayVisualisationExtractor.ts @@ -11,6 +11,7 @@ import type { MapOverlayVisualisationResource } from './types'; import type { LegacyReport, Report, ExpandType } from '../types'; import { PreviewMode } from '../types'; import { baseVisualisationValidator, baseVisualisationDataValidator } from '../validators'; +import { getVizOutputConfig } from '../utils'; export class MapOverlayVisualisationExtractor< MapOverlayValidator extends yup.AnyObjectSchema, @@ -77,7 +78,9 @@ export class MapOverlayVisualisationExtractor< return this.mapOverlayValidator.validateSync(this.vizToMapOverlay()); } - private vizToReport(previewMode?: PreviewMode): Record { + private vizToReport( + previewMode: PreviewMode = PreviewMode.PRESENTATION, + ): Record { const { code, reportPermissionGroup: permissionGroup, data, presentation } = this.visualisation; const validatedData = baseVisualisationDataValidator.validateSync(data); @@ -90,11 +93,14 @@ export class MapOverlayVisualisationExtractor< }, isNil, ); + + const output = getVizOutputConfig(previewMode, presentation); + const config = omitBy( { fetch, transform, - output: previewMode === PreviewMode.PRESENTATION ? presentation?.output : null, + output, }, isNil, ); diff --git a/packages/admin-panel-server/src/viz-builder/utils.ts b/packages/admin-panel-server/src/viz-builder/utils/extractDataFromReport.ts similarity index 93% rename from packages/admin-panel-server/src/viz-builder/utils.ts rename to packages/admin-panel-server/src/viz-builder/utils/extractDataFromReport.ts index 6bbe42675f..075c87e2b9 100644 --- a/packages/admin-panel-server/src/viz-builder/utils.ts +++ b/packages/admin-panel-server/src/viz-builder/utils/extractDataFromReport.ts @@ -3,7 +3,7 @@ * Copyright (c) 2017 - 2022 Beyond Essential Systems Pty Ltd */ -import { Report } from './types'; +import { Report } from '../types'; // Used when combining the report and dashboardItem/mapOverlay export const extractDataFromReport = (report: Report) => { diff --git a/packages/admin-panel-server/src/viz-builder/utils/getVizOutputConfig.ts b/packages/admin-panel-server/src/viz-builder/utils/getVizOutputConfig.ts new file mode 100644 index 0000000000..bbafbd9a03 --- /dev/null +++ b/packages/admin-panel-server/src/viz-builder/utils/getVizOutputConfig.ts @@ -0,0 +1,25 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2022 Beyond Essential Systems Pty Ltd + */ + +import { PreviewMode } from '../types'; + +export const getVizOutputConfig = ( + previewMode: PreviewMode, + presentation: Record, +) => { + switch (previewMode) { + case PreviewMode.PRESENTATION: { + // Use whatever is configured in the viz's output config in the presentation options + return presentation?.output; + } + case PreviewMode.DATA: { + // Use rowsAndColumns output to render the viz-builder data-table with correct columns order + return { type: 'rowsAndColumns' }; + } + default: { + throw new Error(`Unknown preview mode ${previewMode}`); + } + } +}; diff --git a/packages/admin-panel-server/src/viz-builder/utils/index.ts b/packages/admin-panel-server/src/viz-builder/utils/index.ts new file mode 100644 index 0000000000..129e4ff2e1 --- /dev/null +++ b/packages/admin-panel-server/src/viz-builder/utils/index.ts @@ -0,0 +1,7 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2022 Beyond Essential Systems Pty Ltd + */ + +export { extractDataFromReport } from './extractDataFromReport'; +export { getVizOutputConfig } from './getVizOutputConfig'; diff --git a/packages/admin-panel/src/VizBuilderApp/api/queries/useReportPreview.js b/packages/admin-panel/src/VizBuilderApp/api/queries/useReportPreview.js index f457b3da7c..f7171eb137 100644 --- a/packages/admin-panel/src/VizBuilderApp/api/queries/useReportPreview.js +++ b/packages/admin-panel/src/VizBuilderApp/api/queries/useReportPreview.js @@ -14,6 +14,7 @@ export const useReportPreview = ({ enabled, onSettled, vizType, + previewMode, }) => useQuery( ['fetchReportPreviewData', visualisation], @@ -23,6 +24,7 @@ export const useReportPreview = ({ entityCode: location, hierarchy: project, vizType, + previewMode, }, data: { testData, diff --git a/packages/admin-panel/src/VizBuilderApp/components/PreviewSection.js b/packages/admin-panel/src/VizBuilderApp/components/PreviewSection.js index 2d65bb2b7a..ac1447964c 100644 --- a/packages/admin-panel/src/VizBuilderApp/components/PreviewSection.js +++ b/packages/admin-panel/src/VizBuilderApp/components/PreviewSection.js @@ -91,6 +91,21 @@ const EditorContainer = styled.div` } `; +const TABS = { + DATA: { + index: 0, + label: 'Data Preview', + previewMode: 'data', + }, + CHART: { + index: 1, + label: 'Chart Preview', + previewMode: 'presentation', + }, +}; + +const getTab = index => Object.values(TABS).find(tab => tab.index === index); + const convertValueToPrimitive = val => { if (val === null) return val; switch (typeof val) { @@ -103,8 +118,7 @@ const convertValueToPrimitive = val => { } }; -const getColumns = data => { - const columnKeys = [...new Set(data.map(d => Object.keys(d)).flat())]; +const getColumns = ({ columns: columnKeys = [] }) => { const indexColumn = { Header: '#', id: 'index', @@ -121,6 +135,8 @@ const getColumns = data => { }; export const PreviewSection = () => { + const [tab, setTab] = useState(0); + const { fetchEnabled, setFetchEnabled, showData } = usePreviewData(); const { hasPresentationError, setPresentationError } = useVizConfigError(); @@ -131,7 +147,13 @@ export const PreviewSection = () => { const { vizType } = useParams(); - const { data: reportData = [], isLoading, isFetching, isError, error } = useReportPreview({ + const { + data: reportData = { columns: [], rows: [] }, + isLoading, + isFetching, + isError, + error, + } = useReportPreview({ visualisation, project, location, @@ -141,11 +163,12 @@ export const PreviewSection = () => { setFetchEnabled(false); }, vizType, + previewMode: getTab(tab).previewMode, }); - const [tab, setTab] = useState(0); const handleChange = (event, newValue) => { setTab(newValue); + setFetchEnabled(true); }; const handleInvalidPresentationChange = errMsg => { @@ -157,7 +180,8 @@ export const PreviewSection = () => { setPresentationError(null); }; - const columns = useMemo(() => getColumns(reportData), [reportData]); + const columns = useMemo(() => (tab === 0 ? getColumns(reportData) : []), [reportData]); + const rows = useMemo(() => (tab === 0 ? reportData.rows || [] : []), [reportData]); const data = useMemo(() => reportData, [reportData]); // only update Chart Preview when play button is clicked @@ -175,27 +199,27 @@ export const PreviewSection = () => { textColor="primary" onChange={handleChange} > - - + + - + {showData ? ( - + ) : ( )} - + {showData ? ( diff --git a/packages/data-api/package.json b/packages/data-api/package.json index 1b4f84c72d..fc56389eec 100644 --- a/packages/data-api/package.json +++ b/packages/data-api/package.json @@ -27,6 +27,7 @@ }, "dependencies": { "@tupaia/database": "1.0.0", + "@tupaia/tsutils": "1.0.0", "@tupaia/utils": "1.0.0", "@types/lodash.groupby": "^4.6.0", "db-migrate": "^0.11.5", diff --git a/packages/data-api/src/TupaiaDataApi.ts b/packages/data-api/src/TupaiaDataApi.ts index 0dd3245a14..d328216334 100644 --- a/packages/data-api/src/TupaiaDataApi.ts +++ b/packages/data-api/src/TupaiaDataApi.ts @@ -8,9 +8,10 @@ import groupBy from 'lodash.groupby'; import moment from 'moment'; import { TupaiaDatabase, SqlQuery } from '@tupaia/database'; import { getSortByKey, DEFAULT_BINARY_OPTIONS, yup } from '@tupaia/utils'; +import { isNotNullish } from '@tupaia/tsutils'; import { AnalyticsFetchQuery } from './AnalyticsFetchQuery'; import { EventsFetchQuery, EventAnswer } from './EventsFetchQuery'; -import { sanitizeMetadataValue, sanitizeAnalyticsTableValue, isDefined } from './utils'; +import { sanitizeMetadataValue, sanitizeAnalyticsTableValue } from './utils'; import { eventOptionsValidator, analyticsOptionsValidator } from './validators'; import { sanitiseFetchDataOptions } from './sanitiseFetchDataOptions'; @@ -175,7 +176,7 @@ export class TupaiaDataApi { if (includeOptions) { // Get all possible option_set_ids from questions const optionSetIds = [ - ...new Set(dataElementsMetadata.map(d => d.option_set_id).filter(isDefined)), + ...new Set(dataElementsMetadata.map(d => d.option_set_id).filter(isNotNullish)), ]; // Get all the options from the option sets and grouped by set ids. const optionsGroupedBySetId = await this.getOptionsGroupedBySetId(optionSetIds); diff --git a/packages/data-api/src/utils.ts b/packages/data-api/src/utils.ts index 3fdb499e19..06b42c251b 100644 --- a/packages/data-api/src/utils.ts +++ b/packages/data-api/src/utils.ts @@ -33,7 +33,3 @@ export const sanitizeAnalyticsTableValue = (value: string, type: string) => { return value; } }; - -// TODO: Move to ts-utils package -export const isDefined = (val: T): val is Exclude => - val !== undefined && val !== null; diff --git a/packages/report-server/package.json b/packages/report-server/package.json index 5ef33ea455..38ad7d11d5 100644 --- a/packages/report-server/package.json +++ b/packages/report-server/package.json @@ -45,6 +45,7 @@ "lodash.keyby": "^4.6.0", "lodash.pick": "^4.4.0", "mathjs": "^9.4.0", + "moment": "^2.24.0", "winston": "^3.2.1" }, "devDependencies": { diff --git a/packages/report-server/src/__tests__/reportBuilder/output/default.test.ts b/packages/report-server/src/__tests__/reportBuilder/output/default.test.ts new file mode 100644 index 0000000000..98acd519c3 --- /dev/null +++ b/packages/report-server/src/__tests__/reportBuilder/output/default.test.ts @@ -0,0 +1,26 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2022 Beyond Essential Systems Pty Ltd + */ + +import { Aggregator } from '@tupaia/aggregator'; +import { ReportServerAggregator } from '../../../aggregator'; +import { buildOutput } from '../../../reportBuilder/output'; +import { TransformTable } from '../../../reportBuilder/transform'; +import { MULTIPLE_TRANSFORMED_DATA } from './output.fixtures'; + +describe('default', () => { + const dataBroker = { context: {} }; + const aggregator = new Aggregator(dataBroker); + + it('defaults to rows', async () => { + const table = TransformTable.fromRows(MULTIPLE_TRANSFORMED_DATA); + const expectedData = table.getRows(); + const context = {}; + const reportServerAggregator = new ReportServerAggregator(aggregator); + const output = buildOutput(undefined, context, reportServerAggregator); + + const results = await output(table); + expect(results).toEqual(expectedData); + }); +}); diff --git a/packages/report-server/src/__tests__/reportBuilder/output/matrix.test.ts b/packages/report-server/src/__tests__/reportBuilder/output/matrix.test.ts index b1862ea25f..a2786c2d48 100644 --- a/packages/report-server/src/__tests__/reportBuilder/output/matrix.test.ts +++ b/packages/report-server/src/__tests__/reportBuilder/output/matrix.test.ts @@ -5,6 +5,7 @@ import { ReportServerAggregator } from '../../../aggregator'; import { buildOutput } from '../../../reportBuilder/output'; +import { TransformTable } from '../../../reportBuilder/transform'; import { MULTIPLE_TRANSFORMED_DATA, MULTIPLE_TRANSFORMED_DATA_WITH_CATEGORIES, @@ -28,7 +29,7 @@ describe('matrix', () => { ); const outputFn = async () => { - await output([]); + await output(new TransformTable()); }; await expect(outputFn()).rejects.toThrow("columns must be either '*' or an array"); @@ -88,7 +89,9 @@ describe('matrix', () => { {}, reportServerAggregator, ); - const result = await output(MULTIPLE_TRANSFORMED_DATA_WITH_CATEGORIES); + const result = await output( + TransformTable.fromRows(MULTIPLE_TRANSFORMED_DATA_WITH_CATEGORIES), + ); expect(result).toEqual(expectedData); }); @@ -147,7 +150,9 @@ describe('matrix', () => { {}, reportServerAggregator, ); - const result = await output(MULTIPLE_TRANSFORMED_DATA_WITH_CATEGORIES); + const result = await output( + TransformTable.fromRows(MULTIPLE_TRANSFORMED_DATA_WITH_CATEGORIES), + ); expect(result).toEqual(expectedData); }); @@ -185,7 +190,9 @@ describe('matrix', () => { {}, reportServerAggregator, ); - const result = await output(MULTIPLE_TRANSFORMED_DATA_FOR_SPECIFIED_COLUMNS); + const result = await output( + TransformTable.fromRows(MULTIPLE_TRANSFORMED_DATA_FOR_SPECIFIED_COLUMNS), + ); expect(result).toEqual(expectedData); }); @@ -239,7 +246,9 @@ describe('matrix', () => { {}, reportServerAggregator, ); - const result = await output(MULTIPLE_TRANSFORMED_DATA_FOR_SPECIFIED_COLUMNS); + const result = await output( + TransformTable.fromRows(MULTIPLE_TRANSFORMED_DATA_FOR_SPECIFIED_COLUMNS), + ); expect(result).toEqual(expectedData); }); }); @@ -257,7 +266,7 @@ describe('matrix', () => { reportServerAggregator, ); await expect(async () => { - await output([]); + await output(new TransformTable()); }).rejects.toThrow( 'categoryField cannot be one of: [InfrastructureType,Laos,Tonga] they are already specified as columns', ); @@ -274,7 +283,7 @@ describe('matrix', () => { reportServerAggregator, ); await expect(async () => { - await output([]); + await output(new TransformTable()); }).rejects.toThrow( 'rowField cannot be: FacilityType, it is already specified as categoryField', ); @@ -323,7 +332,7 @@ describe('matrix', () => { {}, reportServerAggregator, ); - const result = await output(MULTIPLE_TRANSFORMED_DATA); + const result = await output(TransformTable.fromRows(MULTIPLE_TRANSFORMED_DATA)); expect(result).toEqual(expectedData); }); @@ -382,7 +391,9 @@ describe('matrix', () => { {}, reportServerAggregator, ); - const result = await output(MULTIPLE_TRANSFORMED_DATA_WITH_CATEGORIES); + const result = await output( + TransformTable.fromRows(MULTIPLE_TRANSFORMED_DATA_WITH_CATEGORIES), + ); expect(result).toEqual(expectedData); }); }); @@ -400,7 +411,7 @@ describe('matrix', () => { reportServerAggregator, ); await expect(async () => { - await output([]); + await output(new TransformTable()); }).rejects.toThrow( 'rowField cannot be one of: [FacilityType,Laos,Tonga] they are already specified as columns', ); @@ -417,7 +428,7 @@ describe('matrix', () => { reportServerAggregator, ); await expect(async () => { - await output([]); + await output(new TransformTable()); }).rejects.toThrow( 'rowField cannot be: FacilityType, it is already specified as categoryField', ); @@ -433,7 +444,7 @@ describe('matrix', () => { reportServerAggregator, ); await expect(async () => { - await output([]); + await output(new TransformTable()); }).rejects.toThrow('rowField is a required field'); }); @@ -488,7 +499,9 @@ describe('matrix', () => { {}, reportServerAggregator, ); - const result = await output(MULTIPLE_TRANSFORMED_DATA_WITH_CATEGORIES); + const result = await output( + TransformTable.fromRows(MULTIPLE_TRANSFORMED_DATA_WITH_CATEGORIES), + ); expect(result).toEqual(expectedData); }); }); diff --git a/packages/report-server/src/__tests__/reportBuilder/output/rawDataExport.test.ts b/packages/report-server/src/__tests__/reportBuilder/output/rawDataExport.test.ts index f5ee02f10d..ee93dd24a9 100644 --- a/packages/report-server/src/__tests__/reportBuilder/output/rawDataExport.test.ts +++ b/packages/report-server/src/__tests__/reportBuilder/output/rawDataExport.test.ts @@ -6,6 +6,7 @@ import { Aggregator } from '@tupaia/aggregator'; import { ReportServerAggregator } from '../../../aggregator'; import { buildOutput } from '../../../reportBuilder/output'; +import { TransformTable } from '../../../reportBuilder/transform'; import { MULTIPLE_TRANSFORMED_DATA_FOR_RAW_DATA_EXPORT } from './output.fixtures'; describe('rawDataExport', () => { @@ -80,7 +81,9 @@ describe('rawDataExport', () => { const reportServerAggregator = new ReportServerAggregator(aggregator); const output = buildOutput(config, context, reportServerAggregator); - const results = await output(MULTIPLE_TRANSFORMED_DATA_FOR_RAW_DATA_EXPORT); + const results = await output( + TransformTable.fromRows(MULTIPLE_TRANSFORMED_DATA_FOR_RAW_DATA_EXPORT), + ); expect(results).toEqual(expectedData); }); }); diff --git a/packages/report-server/src/__tests__/reportBuilder/output/rows.test.ts b/packages/report-server/src/__tests__/reportBuilder/output/rows.test.ts new file mode 100644 index 0000000000..d885a1f2e8 --- /dev/null +++ b/packages/report-server/src/__tests__/reportBuilder/output/rows.test.ts @@ -0,0 +1,29 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2022 Beyond Essential Systems Pty Ltd + */ + +import { Aggregator } from '@tupaia/aggregator'; +import { ReportServerAggregator } from '../../../aggregator'; +import { buildOutput } from '../../../reportBuilder/output'; +import { TransformTable } from '../../../reportBuilder/transform'; +import { MULTIPLE_TRANSFORMED_DATA } from './output.fixtures'; + +describe('rows', () => { + const dataBroker = { context: {} }; + const aggregator = new Aggregator(dataBroker); + + it('returns the rows of the table', async () => { + const table = TransformTable.fromRows(MULTIPLE_TRANSFORMED_DATA); + const expectedData = table.getRows(); + const config = { + type: 'rows', + }; + const context = {}; + const reportServerAggregator = new ReportServerAggregator(aggregator); + const output = buildOutput(config, context, reportServerAggregator); + + const results = await output(table); + expect(results).toEqual(expectedData); + }); +}); diff --git a/packages/report-server/src/__tests__/reportBuilder/output/rowsAndColumns.test.ts b/packages/report-server/src/__tests__/reportBuilder/output/rowsAndColumns.test.ts new file mode 100644 index 0000000000..35e4d7cfd6 --- /dev/null +++ b/packages/report-server/src/__tests__/reportBuilder/output/rowsAndColumns.test.ts @@ -0,0 +1,32 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2022 Beyond Essential Systems Pty Ltd + */ + +import { Aggregator } from '@tupaia/aggregator'; +import { ReportServerAggregator } from '../../../aggregator'; +import { buildOutput } from '../../../reportBuilder/output'; +import { TransformTable } from '../../../reportBuilder/transform'; +import { MULTIPLE_TRANSFORMED_DATA } from './output.fixtures'; + +describe('rowsAndColumns', () => { + const dataBroker = { context: {} }; + const aggregator = new Aggregator(dataBroker); + + it('returns the rows and columns of the data', async () => { + const table = TransformTable.fromRows(MULTIPLE_TRANSFORMED_DATA); + const expectedData = { + columns: table.getColumns(), + rows: table.getRows(), + }; + const config = { + type: 'rowsAndColumns', + }; + const context = {}; + const reportServerAggregator = new ReportServerAggregator(aggregator); + const output = buildOutput(config, context, reportServerAggregator); + + const results = await output(table); + expect(results).toEqual(expectedData); + }); +}); diff --git a/packages/report-server/src/__tests__/reportBuilder/testUtils/entityApiMock.ts b/packages/report-server/src/__tests__/reportBuilder/testUtils/entityApiMock.ts index ad7a7e5b6e..ccb6ceb6f2 100644 --- a/packages/report-server/src/__tests__/reportBuilder/testUtils/entityApiMock.ts +++ b/packages/report-server/src/__tests__/reportBuilder/testUtils/entityApiMock.ts @@ -3,6 +3,7 @@ * Copyright (c) 2017 - 2021 Beyond Essential Systems Pty Ltd */ +import { isDefined } from '@tupaia/tsutils'; import pick from 'lodash.pick'; export const entityApiMock = ( @@ -25,7 +26,6 @@ export const entityApiMock = ( continue; } - const isDefined = (val: T): val is Exclude => val !== undefined; const children = relationsInHierarchy .filter(({ parent: parentCode }) => parent.code === parentCode) .map(({ child }) => entitiesInHierarchy.find(entity => entity.code === child)) diff --git a/packages/report-server/src/__tests__/reportBuilder/transform/aliases.test.ts b/packages/report-server/src/__tests__/reportBuilder/transform/aliases.test.ts index 0180a6a924..3050cee4c9 100644 --- a/packages/report-server/src/__tests__/reportBuilder/transform/aliases.test.ts +++ b/packages/report-server/src/__tests__/reportBuilder/transform/aliases.test.ts @@ -4,122 +4,168 @@ */ import { - MULTIPLE_ANALYTICS, MERGEABLE_ANALYTICS, + MULTIPLE_ANALYTICS, MULTIPLE_MERGEABLE_ANALYTICS, SINGLE_ANALYTIC, SINGLE_EVENT, TRANSFORMED_SUMMARY_BINARY, TRANSFORMED_SUMMARY_VARIOUS, } from './transform.fixtures'; -import { buildTransform } from '../../../reportBuilder/transform'; +import { buildTransform, TransformTable } from '../../../reportBuilder/transform'; describe('aliases', () => { it('keyValueByDataElementName', () => { const transform = buildTransform(['keyValueByDataElementName']); - expect(transform(MULTIPLE_ANALYTICS)).toEqual([ - { period: '20200101', organisationUnit: 'TO', BCD1: 4 }, - { period: '20200102', organisationUnit: 'TO', BCD1: 2 }, - { period: '20200103', organisationUnit: 'TO', BCD1: 5 }, - ]); + expect(transform(TransformTable.fromRows(MULTIPLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows([ + { period: '20200101', organisationUnit: 'TO', BCD1: 4 }, + { period: '20200102', organisationUnit: 'TO', BCD1: 2 }, + { period: '20200103', organisationUnit: 'TO', BCD1: 5 }, + ]), + ); }); it('keyValueByOrgUnit', () => { const transform = buildTransform(['keyValueByOrgUnit']); - expect(transform(MULTIPLE_ANALYTICS)).toEqual([ - { period: '20200101', TO: 4, dataElement: 'BCD1' }, - { period: '20200102', TO: 2, dataElement: 'BCD1' }, - { period: '20200103', TO: 5, dataElement: 'BCD1' }, - ]); + expect(transform(TransformTable.fromRows(MULTIPLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows([ + { period: '20200101', dataElement: 'BCD1', TO: 4 }, + { period: '20200102', dataElement: 'BCD1', TO: 2 }, + { period: '20200103', dataElement: 'BCD1', TO: 5 }, + ]), + ); }); it('keyValueByPeriod', () => { const transform = buildTransform(['keyValueByPeriod']); - expect(transform(MULTIPLE_ANALYTICS)).toEqual([ - { '20200101': 4, organisationUnit: 'TO', dataElement: 'BCD1' }, - { '20200102': 2, organisationUnit: 'TO', dataElement: 'BCD1' }, - { '20200103': 5, organisationUnit: 'TO', dataElement: 'BCD1' }, - ]); + expect(transform(TransformTable.fromRows(MULTIPLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows( + [ + { organisationUnit: 'TO', dataElement: 'BCD1', '20200101': 4 }, + { organisationUnit: 'TO', dataElement: 'BCD1', '20200102': 2 }, + { organisationUnit: 'TO', dataElement: 'BCD1', '20200103': 5 }, + ], + ['organisationUnit', 'dataElement', '20200101', '20200102', '20200103'], + ), + ); }); it('mostRecentValuePerOrgUnit', () => { const transform = buildTransform(['mostRecentValuePerOrgUnit']); - expect(transform(MERGEABLE_ANALYTICS)).toEqual([ - { period: '20200103', organisationUnit: 'TO', BCD1: 5, BCD2: 0 }, - { period: '20200103', organisationUnit: 'PG', BCD1: 2, BCD2: -1 }, - ]); + expect(transform(TransformTable.fromRows(MERGEABLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows([ + { period: '20200103', organisationUnit: 'TO', BCD1: 5, BCD2: 0 }, + { period: '20200103', organisationUnit: 'PG', BCD1: 2, BCD2: -1 }, + ]), + ); }); it('firstValuePerPeriodPerOrgUnit', () => { const transform = buildTransform(['firstValuePerPeriodPerOrgUnit']); - expect(transform(MULTIPLE_MERGEABLE_ANALYTICS)).toEqual([ - { period: '20200101', organisationUnit: 'TO', BCD1: 4, BCD2: 11 }, - { period: '20200102', organisationUnit: 'TO', BCD1: 2, BCD2: 1 }, - { period: '20200103', organisationUnit: 'TO', BCD1: 5, BCD2: 0 }, - { period: '20200101', organisationUnit: 'PG', BCD1: 7, BCD2: 13 }, - { period: '20200102', organisationUnit: 'PG', BCD1: 8, BCD2: 99 }, - { period: '20200103', organisationUnit: 'PG', BCD1: 2, BCD2: -1 }, - ]); + expect(transform(TransformTable.fromRows(MULTIPLE_MERGEABLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows([ + { period: '20200101', organisationUnit: 'TO', BCD1: 4, BCD2: 11 }, + { period: '20200102', organisationUnit: 'TO', BCD1: 2, BCD2: 1 }, + { period: '20200103', organisationUnit: 'TO', BCD1: 5, BCD2: 0 }, + { period: '20200101', organisationUnit: 'PG', BCD1: 7, BCD2: 13 }, + { period: '20200102', organisationUnit: 'PG', BCD1: 8, BCD2: 99 }, + { period: '20200103', organisationUnit: 'PG', BCD1: 2, BCD2: -1 }, + ]), + ); }); it('lastValuePerPeriodPerOrgUnit', () => { const transform = buildTransform(['lastValuePerPeriodPerOrgUnit']); - expect(transform(MULTIPLE_MERGEABLE_ANALYTICS)).toEqual([ - { period: '20200101', organisationUnit: 'TO', BCD1: 7, BCD2: 4 }, - { period: '20200102', organisationUnit: 'TO', BCD1: 12, BCD2: 18 }, - { period: '20200103', organisationUnit: 'TO', BCD1: 23, BCD2: 9 }, - { period: '20200101', organisationUnit: 'PG', BCD1: 17, BCD2: 23 }, - { period: '20200102', organisationUnit: 'PG', BCD1: 4, BCD2: -4 }, - { period: '20200103', organisationUnit: 'PG', BCD1: 1, BCD2: 12 }, - ]); + expect(transform(TransformTable.fromRows(MULTIPLE_MERGEABLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows([ + { period: '20200101', organisationUnit: 'TO', BCD1: 7, BCD2: 4 }, + { period: '20200102', organisationUnit: 'TO', BCD1: 12, BCD2: 18 }, + { period: '20200103', organisationUnit: 'TO', BCD1: 23, BCD2: 9 }, + { period: '20200101', organisationUnit: 'PG', BCD1: 17, BCD2: 23 }, + { period: '20200102', organisationUnit: 'PG', BCD1: 4, BCD2: -4 }, + { period: '20200103', organisationUnit: 'PG', BCD1: 1, BCD2: 12 }, + ]), + ); }); it('convertPeriodToWeek', () => { const transform = buildTransform(['convertPeriodToWeek']); - expect(transform(SINGLE_ANALYTIC)).toEqual([{ ...SINGLE_ANALYTIC[0], period: '2020W01' }]); + expect(transform(TransformTable.fromRows(SINGLE_ANALYTIC))).toStrictEqual( + TransformTable.fromRows([{ ...SINGLE_ANALYTIC[0], period: '2020W01' }]), + ); }); it('convertEventDateToWeek', () => { const transform = buildTransform(['convertEventDateToWeek']); - expect(transform(SINGLE_EVENT)).toEqual([{ ...SINGLE_EVENT[0], period: '2020W01' }]); + expect(transform(TransformTable.fromRows(SINGLE_EVENT))).toStrictEqual( + TransformTable.fromRows([{ ...SINGLE_EVENT[0], period: '2020W01' }]), + ); }); it('insertNumberOfFacilitiesColumn', () => { const transform = buildTransform(['insertNumberOfFacilitiesColumn'], { facilityCountByOrgUnit: { TO: 14 }, }); - expect(transform(SINGLE_ANALYTIC)).toEqual([{ ...SINGLE_ANALYTIC[0], numberOfFacilities: 14 }]); + expect(transform(TransformTable.fromRows(SINGLE_ANALYTIC))).toStrictEqual( + TransformTable.fromRows([{ ...SINGLE_ANALYTIC[0], numberOfFacilities: 14 }]), + ); }); }); describe('insertSummaryRowAndColumn', () => { it('inserts a summary row and summary column', () => { const transform = buildTransform(['insertSummaryRowAndColumn']); - expect(transform(TRANSFORMED_SUMMARY_BINARY)).toEqual([ - { summaryColumn: '75.0%', dataElement: 'Male condoms', TO: 'N', FJ: 'N', NR: 'Y', KI: 'N' }, - { summaryColumn: '25.0%', dataElement: 'Female condoms', TO: 'N', FJ: 'Y', NR: 'Y', KI: 'Y' }, - { - summaryColumn: '0.0%', - dataElement: 'Injectable contraceptives', - TO: 'Y', - FJ: 'Y', - }, - { TO: '66.7%', FJ: '33.3%', NR: '0.0%', KI: '50.0%' }, - ]); + expect(transform(TransformTable.fromRows(TRANSFORMED_SUMMARY_BINARY))).toStrictEqual( + TransformTable.fromRows([ + { dataElement: 'Male condoms', TO: 'N', FJ: 'N', NR: 'Y', KI: 'N', summaryColumn: '75.0%' }, + { + dataElement: 'Female condoms', + TO: 'N', + FJ: 'Y', + NR: 'Y', + KI: 'Y', + summaryColumn: '25.0%', + }, + { + dataElement: 'Injectable contraceptives', + TO: 'Y', + FJ: 'Y', + summaryColumn: '0.0%', + }, + { TO: '66.7%', FJ: '33.3%', NR: '0.0%', KI: '50.0%' }, + ]), + ); }); it('only summarises columns that have only Y | N | undefined values', () => { const transform = buildTransform(['insertSummaryRowAndColumn']); - expect(transform(TRANSFORMED_SUMMARY_VARIOUS)).toEqual([ - { summaryColumn: '66.7%', dataElement: 'Male condoms', TO: 'Yes', FJ: 'N', NR: 'Y', KI: 'N' }, - { summaryColumn: '0.0%', dataElement: 'Female condoms', TO: 'N', FJ: 'Y', NR: 'Y', KI: 'Y' }, - { - summaryColumn: '0.0%', - dataElement: 'Injectable contraceptives', - TO: 'Y', - FJ: 'Y', - }, - { FJ: '33.3%', NR: '0.0%', KI: '50.0%' }, - ]); + expect(transform(TransformTable.fromRows(TRANSFORMED_SUMMARY_VARIOUS))).toStrictEqual( + TransformTable.fromRows([ + { + dataElement: 'Male condoms', + TO: 'Yes', + FJ: 'N', + NR: 'Y', + KI: 'N', + summaryColumn: '66.7%', + }, + { + dataElement: 'Female condoms', + TO: 'N', + FJ: 'Y', + NR: 'Y', + KI: 'Y', + summaryColumn: '0.0%', + }, + { + dataElement: 'Injectable contraceptives', + TO: 'Y', + FJ: 'Y', + summaryColumn: '0.0%', + }, + { FJ: '33.3%', NR: '0.0%', KI: '50.0%' }, + ]), + ); }); }); diff --git a/packages/report-server/src/__tests__/reportBuilder/transform/excludeColumns.test.ts b/packages/report-server/src/__tests__/reportBuilder/transform/excludeColumns.test.ts index 7f111ac0dc..be1d33c0fc 100644 --- a/packages/report-server/src/__tests__/reportBuilder/transform/excludeColumns.test.ts +++ b/packages/report-server/src/__tests__/reportBuilder/transform/excludeColumns.test.ts @@ -4,7 +4,7 @@ */ import { SINGLE_ANALYTIC, MULTIPLE_ANALYTICS } from './transform.fixtures'; -import { buildTransform } from '../../../reportBuilder/transform'; +import { buildTransform, TransformTable } from '../../../reportBuilder/transform'; describe('excludeColumns', () => { it('can exclude all fields', () => { @@ -14,7 +14,9 @@ describe('excludeColumns', () => { columns: '*', }, ]); - expect(transform(SINGLE_ANALYTIC)).toEqual([{}]); + expect(transform(TransformTable.fromRows(SINGLE_ANALYTIC))).toStrictEqual( + TransformTable.fromRows([{}]), + ); }); it('can exclude selected fields', () => { @@ -24,10 +26,12 @@ describe('excludeColumns', () => { columns: ['organisationUnit', 'value'], }, ]); - expect(transform(MULTIPLE_ANALYTICS)).toEqual([ - { period: '20200101', dataElement: 'BCD1' }, - { period: '20200102', dataElement: 'BCD1' }, - { period: '20200103', dataElement: 'BCD1' }, - ]); + expect(transform(TransformTable.fromRows(MULTIPLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows([ + { period: '20200101', dataElement: 'BCD1' }, + { period: '20200102', dataElement: 'BCD1' }, + { period: '20200103', dataElement: 'BCD1' }, + ]), + ); }); }); diff --git a/packages/report-server/src/__tests__/reportBuilder/transform/excludeRows.test.ts b/packages/report-server/src/__tests__/reportBuilder/transform/excludeRows.test.ts index 7c5396e80f..3d55871373 100644 --- a/packages/report-server/src/__tests__/reportBuilder/transform/excludeRows.test.ts +++ b/packages/report-server/src/__tests__/reportBuilder/transform/excludeRows.test.ts @@ -4,7 +4,7 @@ */ import { EXCLUDEABLE_ANALYTICS } from './transform.fixtures'; -import { buildTransform } from '../../../reportBuilder/transform'; +import { buildTransform, TransformTable } from '../../../reportBuilder/transform'; describe('excludeRows', () => { it('can exclude by boolean expression on row', () => { @@ -14,9 +14,11 @@ describe('excludeRows', () => { where: '=$BCD1 < 6', }, ]); - expect(transform(EXCLUDEABLE_ANALYTICS)).toEqual([ - { period: '20200101', organisationUnit: 'PG', BCD1: 7 }, - { period: '20200102', organisationUnit: 'PG', BCD1: 8 }, - ]); + expect(transform(TransformTable.fromRows(EXCLUDEABLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows([ + { period: '20200101', organisationUnit: 'PG', BCD1: 7 }, + { period: '20200102', organisationUnit: 'PG', BCD1: 8 }, + ]), + ); }); }); diff --git a/packages/report-server/src/__tests__/reportBuilder/transform/gatherColumns.test.ts b/packages/report-server/src/__tests__/reportBuilder/transform/gatherColumns.test.ts index c0ebc4100c..c6d78995ec 100644 --- a/packages/report-server/src/__tests__/reportBuilder/transform/gatherColumns.test.ts +++ b/packages/report-server/src/__tests__/reportBuilder/transform/gatherColumns.test.ts @@ -4,7 +4,7 @@ */ import { MULTIPLE_ANALYTICS, SINGLE_ANALYTIC } from './transform.fixtures'; -import { buildTransform } from '../../../reportBuilder/transform'; +import { buildTransform, TransformTable } from '../../../reportBuilder/transform'; describe('gatherColumns', () => { it('can gather columns for single analytic', () => { @@ -13,12 +13,14 @@ describe('gatherColumns', () => { transform: 'gatherColumns', }, ]); - expect(transform(SINGLE_ANALYTIC)).toEqual([ - { value: '20200101', columnName: 'period' }, - { value: 'TO', columnName: 'organisationUnit' }, - { value: 'BCD1', columnName: 'dataElement' }, - { value: 4, columnName: 'value' }, - ]); + expect(transform(TransformTable.fromRows(SINGLE_ANALYTIC))).toStrictEqual( + TransformTable.fromRows([ + { value: '20200101', columnName: 'period' }, + { value: 'TO', columnName: 'organisationUnit' }, + { value: 'BCD1', columnName: 'dataElement' }, + { value: 4, columnName: 'value' }, + ]), + ); }); it('can gather columns with one included field as string', () => { @@ -28,11 +30,13 @@ describe('gatherColumns', () => { keep: 'organisationUnit', }, ]); - expect(transform(SINGLE_ANALYTIC)).toEqual([ - { organisationUnit: 'TO', value: '20200101', columnName: 'period' }, - { organisationUnit: 'TO', value: 'BCD1', columnName: 'dataElement' }, - { organisationUnit: 'TO', value: 4, columnName: 'value' }, - ]); + expect(transform(TransformTable.fromRows(SINGLE_ANALYTIC))).toStrictEqual( + TransformTable.fromRows([ + { organisationUnit: 'TO', value: '20200101', columnName: 'period' }, + { organisationUnit: 'TO', value: 'BCD1', columnName: 'dataElement' }, + { organisationUnit: 'TO', value: 4, columnName: 'value' }, + ]), + ); }); it('can gather columns with included fields', () => { @@ -42,10 +46,12 @@ describe('gatherColumns', () => { keep: ['organisationUnit', 'period'], }, ]); - expect(transform(SINGLE_ANALYTIC)).toEqual([ - { organisationUnit: 'TO', period: '20200101', value: 'BCD1', columnName: 'dataElement' }, - { organisationUnit: 'TO', period: '20200101', value: 4, columnName: 'value' }, - ]); + expect(transform(TransformTable.fromRows(SINGLE_ANALYTIC))).toStrictEqual( + TransformTable.fromRows([ + { period: '20200101', organisationUnit: 'TO', value: 'BCD1', columnName: 'dataElement' }, + { period: '20200101', organisationUnit: 'TO', value: 4, columnName: 'value' }, + ]), + ); }); it('can gather columns for multiple analytics', () => { @@ -55,16 +61,18 @@ describe('gatherColumns', () => { keep: ['period'], }, ]); - expect(transform(MULTIPLE_ANALYTICS)).toEqual([ - { period: '20200101', value: 'TO', columnName: 'organisationUnit' }, - { period: '20200101', value: 'BCD1', columnName: 'dataElement' }, - { period: '20200101', value: 4, columnName: 'value' }, - { period: '20200102', value: 'TO', columnName: 'organisationUnit' }, - { period: '20200102', value: 'BCD1', columnName: 'dataElement' }, - { period: '20200102', value: 2, columnName: 'value' }, - { period: '20200103', value: 'TO', columnName: 'organisationUnit' }, - { period: '20200103', value: 'BCD1', columnName: 'dataElement' }, - { period: '20200103', value: 5, columnName: 'value' }, - ]); + expect(transform(TransformTable.fromRows(MULTIPLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows([ + { period: '20200101', value: 'TO', columnName: 'organisationUnit' }, + { period: '20200101', value: 'BCD1', columnName: 'dataElement' }, + { period: '20200101', value: 4, columnName: 'value' }, + { period: '20200102', value: 'TO', columnName: 'organisationUnit' }, + { period: '20200102', value: 'BCD1', columnName: 'dataElement' }, + { period: '20200102', value: 2, columnName: 'value' }, + { period: '20200103', value: 'TO', columnName: 'organisationUnit' }, + { period: '20200103', value: 'BCD1', columnName: 'dataElement' }, + { period: '20200103', value: 5, columnName: 'value' }, + ]), + ); }); }); diff --git a/packages/report-server/src/__tests__/reportBuilder/transform/insertColumns.test.ts b/packages/report-server/src/__tests__/reportBuilder/transform/insertColumns.test.ts index 021aee8db7..c865cf38e0 100644 --- a/packages/report-server/src/__tests__/reportBuilder/transform/insertColumns.test.ts +++ b/packages/report-server/src/__tests__/reportBuilder/transform/insertColumns.test.ts @@ -4,7 +4,7 @@ */ import { SINGLE_ANALYTIC, MULTIPLE_ANALYTICS, MERGEABLE_ANALYTICS } from './transform.fixtures'; -import { buildTransform } from '../../../reportBuilder/transform'; +import { buildTransform, TransformTable } from '../../../reportBuilder/transform'; describe('insertColumns', () => { it('can insert basic values', () => { @@ -18,9 +18,9 @@ describe('insertColumns', () => { }, }, ]); - expect(transform(SINGLE_ANALYTIC)).toEqual([ - { ...SINGLE_ANALYTIC[0], number: 1, string: 'Hi', boolean: false }, - ]); + expect(transform(TransformTable.fromRows(SINGLE_ANALYTIC))).toStrictEqual( + TransformTable.fromRows([{ ...SINGLE_ANALYTIC[0], number: 1, string: 'Hi', boolean: false }]), + ); }); it('can insert using a value from the row', () => { @@ -32,7 +32,9 @@ describe('insertColumns', () => { }, }, ]); - expect(transform(SINGLE_ANALYTIC)).toEqual([{ ...SINGLE_ANALYTIC[0], dataElementValue: 4 }]); + expect(transform(TransformTable.fromRows(SINGLE_ANALYTIC))).toStrictEqual( + TransformTable.fromRows([{ ...SINGLE_ANALYTIC[0], dataElementValue: 4 }]), + ); }); it('can use a value from the row as a column name', () => { @@ -44,7 +46,9 @@ describe('insertColumns', () => { }, }, ]); - expect(transform(SINGLE_ANALYTIC)).toEqual([{ ...SINGLE_ANALYTIC[0], BCD1: 4 }]); + expect(transform(TransformTable.fromRows(SINGLE_ANALYTIC))).toStrictEqual( + TransformTable.fromRows([{ ...SINGLE_ANALYTIC[0], BCD1: 4 }]), + ); }); it('can execute functions', () => { @@ -56,7 +60,9 @@ describe('insertColumns', () => { }, }, ]); - expect(transform(SINGLE_ANALYTIC)).toEqual([{ ...SINGLE_ANALYTIC[0], period: '1st Jan 2020' }]); + expect(transform(TransformTable.fromRows(SINGLE_ANALYTIC))).toStrictEqual( + TransformTable.fromRows([{ ...SINGLE_ANALYTIC[0], period: '1st Jan 2020' }]), + ); }); it('can perform the insert on all rows', () => { @@ -69,11 +75,13 @@ describe('insertColumns', () => { }, }, ]); - expect(transform(MULTIPLE_ANALYTICS)).toEqual([ - { ...MULTIPLE_ANALYTICS[0], period: '1st Jan 2020', BCD1: 4 }, - { ...MULTIPLE_ANALYTICS[1], period: '2nd Jan 2020', BCD1: 2 }, - { ...MULTIPLE_ANALYTICS[2], period: '3rd Jan 2020', BCD1: 5 }, - ]); + expect(transform(TransformTable.fromRows(MULTIPLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows([ + { ...MULTIPLE_ANALYTICS[0], period: '1st Jan 2020', BCD1: 4 }, + { ...MULTIPLE_ANALYTICS[1], period: '2nd Jan 2020', BCD1: 2 }, + { ...MULTIPLE_ANALYTICS[2], period: '3rd Jan 2020', BCD1: 5 }, + ]), + ); }); it('where is processed before remaining fields', () => { @@ -86,19 +94,24 @@ describe('insertColumns', () => { }, }, ]); - expect(transform(MERGEABLE_ANALYTICS)).toEqual([ - { ...MERGEABLE_ANALYTICS[0], newVal: 8 }, - { ...MERGEABLE_ANALYTICS[1], newVal: 4 }, - { ...MERGEABLE_ANALYTICS[2], newVal: 10 }, - { ...MERGEABLE_ANALYTICS[3] }, - { ...MERGEABLE_ANALYTICS[4] }, - { ...MERGEABLE_ANALYTICS[5] }, - { ...MERGEABLE_ANALYTICS[6], newVal: 14 }, - { ...MERGEABLE_ANALYTICS[7], newVal: 16 }, - { ...MERGEABLE_ANALYTICS[8], newVal: 4 }, - { ...MERGEABLE_ANALYTICS[9] }, - { ...MERGEABLE_ANALYTICS[10] }, - { ...MERGEABLE_ANALYTICS[11] }, - ]); + expect(transform(TransformTable.fromRows(MERGEABLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows( + [ + { ...MERGEABLE_ANALYTICS[0], newVal: 8 }, + { ...MERGEABLE_ANALYTICS[1], newVal: 4 }, + { ...MERGEABLE_ANALYTICS[2], newVal: 10 }, + { ...MERGEABLE_ANALYTICS[3] }, + { ...MERGEABLE_ANALYTICS[4] }, + { ...MERGEABLE_ANALYTICS[5] }, + { ...MERGEABLE_ANALYTICS[6], newVal: 14 }, + { ...MERGEABLE_ANALYTICS[7], newVal: 16 }, + { ...MERGEABLE_ANALYTICS[8], newVal: 4 }, + { ...MERGEABLE_ANALYTICS[9] }, + { ...MERGEABLE_ANALYTICS[10] }, + { ...MERGEABLE_ANALYTICS[11] }, + ], + ['period', 'organisationUnit', 'BCD1', 'BCD2', 'newVal'], + ), + ); }); }); diff --git a/packages/report-server/src/__tests__/reportBuilder/transform/insertRows.test.ts b/packages/report-server/src/__tests__/reportBuilder/transform/insertRows.test.ts index 9785b47e14..12e5ad01b4 100644 --- a/packages/report-server/src/__tests__/reportBuilder/transform/insertRows.test.ts +++ b/packages/report-server/src/__tests__/reportBuilder/transform/insertRows.test.ts @@ -4,7 +4,7 @@ */ import { SINGLE_ANALYTIC, MULTIPLE_ANALYTICS, MERGEABLE_ANALYTICS } from './transform.fixtures'; -import { buildTransform } from '../../../reportBuilder/transform'; +import { buildTransform, TransformTable } from '../../../reportBuilder/transform'; describe('insertRows', () => { // SAME AS INSERT COLUMNS FUNCTIONALITY @@ -19,10 +19,9 @@ describe('insertRows', () => { }, }, ]); - expect(transform(SINGLE_ANALYTIC)).toEqual([ - ...SINGLE_ANALYTIC, - { number: 1, string: 'Hi', boolean: false }, - ]); + expect(transform(TransformTable.fromRows(SINGLE_ANALYTIC))).toStrictEqual( + TransformTable.fromRows([...SINGLE_ANALYTIC, { number: 1, string: 'Hi', boolean: false }]), + ); }); it('can insert row with values from previous row', () => { @@ -34,7 +33,9 @@ describe('insertRows', () => { }, }, ]); - expect(transform(SINGLE_ANALYTIC)).toEqual([...SINGLE_ANALYTIC, { dataElementValue: 4 }]); + expect(transform(TransformTable.fromRows(SINGLE_ANALYTIC))).toStrictEqual( + TransformTable.fromRows([...SINGLE_ANALYTIC, { dataElementValue: 4 }]), + ); }); it('can select a value from the row as a field name', () => { @@ -46,7 +47,9 @@ describe('insertRows', () => { }, }, ]); - expect(transform(SINGLE_ANALYTIC)).toEqual([...SINGLE_ANALYTIC, { BCD1: 4 }]); + expect(transform(TransformTable.fromRows(SINGLE_ANALYTIC))).toStrictEqual( + TransformTable.fromRows([...SINGLE_ANALYTIC, { BCD1: 4 }]), + ); }); it('can execute functions', () => { @@ -58,7 +61,9 @@ describe('insertRows', () => { }, }, ]); - expect(transform(SINGLE_ANALYTIC)).toEqual([...SINGLE_ANALYTIC, { period: '1st Jan 2020' }]); + expect(transform(TransformTable.fromRows(SINGLE_ANALYTIC))).toStrictEqual( + TransformTable.fromRows([...SINGLE_ANALYTIC, { period: '1st Jan 2020' }]), + ); }); // SPECIFIC TO INSERT @@ -75,12 +80,17 @@ describe('insertRows', () => { }, }, ]); - expect(transform(MULTIPLE_ANALYTICS)).toEqual([ - { number: 1, string: 'Hi', boolean: false }, - { period: '20200101', organisationUnit: 'TO', dataElement: 'BCD1', value: 4 }, - { period: '20200102', organisationUnit: 'TO', dataElement: 'BCD1', value: 2 }, - { period: '20200103', organisationUnit: 'TO', dataElement: 'BCD1', value: 5 }, - ]); + expect(transform(TransformTable.fromRows(MULTIPLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows( + [ + { number: 1, string: 'Hi', boolean: false }, + { period: '20200101', organisationUnit: 'TO', dataElement: 'BCD1', value: 4 }, + { period: '20200102', organisationUnit: 'TO', dataElement: 'BCD1', value: 2 }, + { period: '20200103', organisationUnit: 'TO', dataElement: 'BCD1', value: 5 }, + ], + ['period', 'organisationUnit', 'dataElement', 'value', 'number', 'string', 'boolean'], + ), + ); }); it('can insert a single row at end', () => { @@ -95,12 +105,14 @@ describe('insertRows', () => { }, }, ]); - expect(transform(MULTIPLE_ANALYTICS)).toEqual([ - { period: '20200101', organisationUnit: 'TO', dataElement: 'BCD1', value: 4 }, - { period: '20200102', organisationUnit: 'TO', dataElement: 'BCD1', value: 2 }, - { period: '20200103', organisationUnit: 'TO', dataElement: 'BCD1', value: 5 }, - { number: 1, string: 'Hi', boolean: false }, - ]); + expect(transform(TransformTable.fromRows(MULTIPLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows([ + { period: '20200101', organisationUnit: 'TO', dataElement: 'BCD1', value: 4 }, + { period: '20200102', organisationUnit: 'TO', dataElement: 'BCD1', value: 2 }, + { period: '20200103', organisationUnit: 'TO', dataElement: 'BCD1', value: 5 }, + { number: 1, string: 'Hi', boolean: false }, + ]), + ); }); it('can insert multiple rows', () => { @@ -112,14 +124,16 @@ describe('insertRows', () => { }, }, ]); - expect(transform(MULTIPLE_ANALYTICS)).toEqual([ - { period: '20200101', organisationUnit: 'TO', dataElement: 'BCD1', value: 4 }, - { dataElementValue: 4 }, - { period: '20200102', organisationUnit: 'TO', dataElement: 'BCD1', value: 2 }, - { dataElementValue: 2 }, - { period: '20200103', organisationUnit: 'TO', dataElement: 'BCD1', value: 5 }, - { dataElementValue: 5 }, - ]); + expect(transform(TransformTable.fromRows(MULTIPLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows([ + { period: '20200101', organisationUnit: 'TO', dataElement: 'BCD1', value: 4 }, + { dataElementValue: 4 }, + { period: '20200102', organisationUnit: 'TO', dataElement: 'BCD1', value: 2 }, + { dataElementValue: 2 }, + { period: '20200103', organisationUnit: 'TO', dataElement: 'BCD1', value: 5 }, + { dataElementValue: 5 }, + ]), + ); }); it('can insert new rows before the relative row', () => { @@ -132,14 +146,19 @@ describe('insertRows', () => { position: 'before', }, ]); - expect(transform(MULTIPLE_ANALYTICS)).toEqual([ - { dataElementValue: 4 }, - { period: '20200101', organisationUnit: 'TO', dataElement: 'BCD1', value: 4 }, - { dataElementValue: 2 }, - { period: '20200102', organisationUnit: 'TO', dataElement: 'BCD1', value: 2 }, - { dataElementValue: 5 }, - { period: '20200103', organisationUnit: 'TO', dataElement: 'BCD1', value: 5 }, - ]); + expect(transform(TransformTable.fromRows(MULTIPLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows( + [ + { dataElementValue: 4 }, + { period: '20200101', organisationUnit: 'TO', dataElement: 'BCD1', value: 4 }, + { dataElementValue: 2 }, + { period: '20200102', organisationUnit: 'TO', dataElement: 'BCD1', value: 2 }, + { dataElementValue: 5 }, + { period: '20200103', organisationUnit: 'TO', dataElement: 'BCD1', value: 5 }, + ], + ['period', 'organisationUnit', 'dataElement', 'value', 'dataElementValue'], + ), + ); }); it('can insert new rows at the beginning of the list', () => { @@ -152,14 +171,19 @@ describe('insertRows', () => { position: 'start', }, ]); - expect(transform(MULTIPLE_ANALYTICS)).toEqual([ - { dataElementValue: 4 }, - { dataElementValue: 2 }, - { dataElementValue: 5 }, - { period: '20200101', organisationUnit: 'TO', dataElement: 'BCD1', value: 4 }, - { period: '20200102', organisationUnit: 'TO', dataElement: 'BCD1', value: 2 }, - { period: '20200103', organisationUnit: 'TO', dataElement: 'BCD1', value: 5 }, - ]); + expect(transform(TransformTable.fromRows(MULTIPLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows( + [ + { dataElementValue: 4 }, + { dataElementValue: 2 }, + { dataElementValue: 5 }, + { period: '20200101', organisationUnit: 'TO', dataElement: 'BCD1', value: 4 }, + { period: '20200102', organisationUnit: 'TO', dataElement: 'BCD1', value: 2 }, + { period: '20200103', organisationUnit: 'TO', dataElement: 'BCD1', value: 5 }, + ], + ['period', 'organisationUnit', 'dataElement', 'value', 'dataElementValue'], + ), + ); }); it('can insert specific new rows using a where clause', () => { @@ -173,13 +197,15 @@ describe('insertRows', () => { where: "=not(eq($period, '20200101'))", }, ]); - expect(transform(MULTIPLE_ANALYTICS)).toEqual([ - { period: '20200101', organisationUnit: 'TO', dataElement: 'BCD1', value: 4 }, - { period: '20200102', organisationUnit: 'TO', dataElement: 'BCD1', value: 2 }, - { dataElementValue: 2 }, - { period: '20200103', organisationUnit: 'TO', dataElement: 'BCD1', value: 5 }, - { dataElementValue: 5 }, - ]); + expect(transform(TransformTable.fromRows(MULTIPLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows([ + { period: '20200101', organisationUnit: 'TO', dataElement: 'BCD1', value: 4 }, + { period: '20200102', organisationUnit: 'TO', dataElement: 'BCD1', value: 2 }, + { dataElementValue: 2 }, + { period: '20200103', organisationUnit: 'TO', dataElement: 'BCD1', value: 5 }, + { dataElementValue: 5 }, + ]), + ); }); // USE CASES @@ -193,12 +219,14 @@ describe('insertRows', () => { }, }, ]); - expect(transform(MULTIPLE_ANALYTICS)).toEqual([ - { period: '20200101', organisationUnit: 'TO', dataElement: 'BCD1', value: 4 }, - { period: '20200102', organisationUnit: 'TO', dataElement: 'BCD1', value: 2 }, - { period: '20200103', organisationUnit: 'TO', dataElement: 'BCD1', value: 5 }, - { Total: 11 }, - ]); + expect(transform(TransformTable.fromRows(MULTIPLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows([ + { period: '20200101', organisationUnit: 'TO', dataElement: 'BCD1', value: 4 }, + { period: '20200102', organisationUnit: 'TO', dataElement: 'BCD1', value: 2 }, + { period: '20200103', organisationUnit: 'TO', dataElement: 'BCD1', value: 5 }, + { Total: 11 }, + ]), + ); }); it('compare adjacent rows to insert between', () => { @@ -214,24 +242,26 @@ describe('insertRows', () => { }, }, ]); - expect(transform(MERGEABLE_ANALYTICS)).toEqual([ - { period: '20200101', organisationUnit: 'TO', BCD1: 4 }, - { period: '20200102', organisationUnit: 'TO', BCD1: 2 }, - { period: '20200103', organisationUnit: 'TO', BCD1: 5 }, - { period: '20200101', organisationUnit: 'TO', BCD2: 11 }, - { period: '20200102', organisationUnit: 'TO', BCD2: 1 }, - { period: '20200103', organisationUnit: 'TO', BCD2: 0 }, - - { Total_BCD1: 11, Total_BCD2: 12 }, - - { period: '20200101', organisationUnit: 'PG', BCD1: 7 }, - { period: '20200102', organisationUnit: 'PG', BCD1: 8 }, - { period: '20200103', organisationUnit: 'PG', BCD1: 2 }, - { period: '20200101', organisationUnit: 'PG', BCD2: 13 }, - { period: '20200102', organisationUnit: 'PG', BCD2: 99 }, - { period: '20200103', organisationUnit: 'PG', BCD2: -1 }, - - { Total_BCD1: 17, Total_BCD2: 111 }, - ]); + expect(transform(TransformTable.fromRows(MERGEABLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows([ + { period: '20200101', organisationUnit: 'TO', BCD1: 4 }, + { period: '20200102', organisationUnit: 'TO', BCD1: 2 }, + { period: '20200103', organisationUnit: 'TO', BCD1: 5 }, + { period: '20200101', organisationUnit: 'TO', BCD2: 11 }, + { period: '20200102', organisationUnit: 'TO', BCD2: 1 }, + { period: '20200103', organisationUnit: 'TO', BCD2: 0 }, + + { Total_BCD1: 11, Total_BCD2: 12 }, + + { period: '20200101', organisationUnit: 'PG', BCD1: 7 }, + { period: '20200102', organisationUnit: 'PG', BCD1: 8 }, + { period: '20200103', organisationUnit: 'PG', BCD1: 2 }, + { period: '20200101', organisationUnit: 'PG', BCD2: 13 }, + { period: '20200102', organisationUnit: 'PG', BCD2: 99 }, + { period: '20200103', organisationUnit: 'PG', BCD2: -1 }, + + { Total_BCD1: 17, Total_BCD2: 111 }, + ]), + ); }); }); diff --git a/packages/report-server/src/__tests__/reportBuilder/transform/mergeRows.test.ts b/packages/report-server/src/__tests__/reportBuilder/transform/mergeRows.test.ts index 1102da4f44..407bc95249 100644 --- a/packages/report-server/src/__tests__/reportBuilder/transform/mergeRows.test.ts +++ b/packages/report-server/src/__tests__/reportBuilder/transform/mergeRows.test.ts @@ -9,7 +9,7 @@ import { MERGEABLE_ANALYTICS_WITH_NULL_VALUES, SINGLE_MERGEABLE_ANALYTICS, } from './transform.fixtures'; -import { buildTransform } from '../../../reportBuilder/transform'; +import { buildTransform, TransformTable } from '../../../reportBuilder/transform'; describe('mergeRows', () => { it('throws error when mergeUsing contains an invalid merge strategy', () => { @@ -30,9 +30,9 @@ describe('mergeRows', () => { groupBy: 'period', }, ]); - expect(transform(SINGLE_MERGEABLE_ANALYTICS)).toEqual([ - { period: '20200101', BCD1: 4, BCD2: 4, BCD3: 4 }, - ]); + expect(transform(TransformTable.fromRows(SINGLE_MERGEABLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows([{ period: '20200101', BCD1: 4, BCD2: 4, BCD3: 4 }]), + ); }); it('when no groupBy is specified, group to a single row', () => { @@ -40,13 +40,17 @@ describe('mergeRows', () => { { transform: 'mergeRows', using: { - organisationUnit: 'exclude', - period: 'exclude', + organisationUnit: 'first', + period: 'first', '*': 'sum', }, }, ]); - expect(transform(MERGEABLE_ANALYTICS)).toEqual([{ BCD1: 28, BCD2: 123 }]); + expect(transform(TransformTable.fromRows(MERGEABLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows([ + { period: '20200101', organisationUnit: 'TO', BCD1: 28, BCD2: 123 }, + ]), + ); }); it('can group by a single field', () => { @@ -57,10 +61,12 @@ describe('mergeRows', () => { using: 'last', }, ]); - expect(transform(MERGEABLE_ANALYTICS)).toEqual([ - { organisationUnit: 'TO', period: '20200103', BCD1: 5, BCD2: 0 }, - { organisationUnit: 'PG', period: '20200103', BCD1: 2, BCD2: -1 }, - ]); + expect(transform(TransformTable.fromRows(MERGEABLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows([ + { period: '20200103', organisationUnit: 'TO', BCD1: 5, BCD2: 0 }, + { period: '20200103', organisationUnit: 'PG', BCD1: 2, BCD2: -1 }, + ]), + ); }); it('can group by a multiple fields', () => { @@ -71,14 +77,16 @@ describe('mergeRows', () => { using: 'sum', }, ]); - expect(transform(MERGEABLE_ANALYTICS)).toEqual([ - { organisationUnit: 'TO', period: '20200101', BCD1: 4, BCD2: 11 }, - { organisationUnit: 'TO', period: '20200102', BCD1: 2, BCD2: 1 }, - { organisationUnit: 'TO', period: '20200103', BCD1: 5, BCD2: 0 }, - { organisationUnit: 'PG', period: '20200101', BCD1: 7, BCD2: 13 }, - { organisationUnit: 'PG', period: '20200102', BCD1: 8, BCD2: 99 }, - { organisationUnit: 'PG', period: '20200103', BCD1: 2, BCD2: -1 }, - ]); + expect(transform(TransformTable.fromRows(MERGEABLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows([ + { period: '20200101', organisationUnit: 'TO', BCD1: 4, BCD2: 11 }, + { period: '20200102', organisationUnit: 'TO', BCD1: 2, BCD2: 1 }, + { period: '20200103', organisationUnit: 'TO', BCD1: 5, BCD2: 0 }, + { period: '20200101', organisationUnit: 'PG', BCD1: 7, BCD2: 13 }, + { period: '20200102', organisationUnit: 'PG', BCD1: 8, BCD2: 99 }, + { period: '20200103', organisationUnit: 'PG', BCD1: 2, BCD2: -1 }, + ]), + ); }); it('can perform different merge strategies on different fields', () => { @@ -93,10 +101,12 @@ describe('mergeRows', () => { }, }, ]); - expect(transform(MERGEABLE_ANALYTICS)).toEqual([ - { organisationUnit: 'TO', period: '20200103', BCD1: 11, BCD2: 0 }, - { organisationUnit: 'PG', period: '20200103', BCD1: 17, BCD2: -1 }, - ]); + expect(transform(TransformTable.fromRows(MERGEABLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows([ + { period: '20200103', organisationUnit: 'TO', BCD1: 11, BCD2: 0 }, + { period: '20200103', organisationUnit: 'PG', BCD1: 17, BCD2: -1 }, + ]), + ); }); describe('merge strategies', () => { @@ -106,15 +116,17 @@ describe('mergeRows', () => { transform: 'mergeRows', groupBy: 'organisationUnit', using: { - period: 'exclude', + period: 'first', '*': 'sum', }, }, ]); - expect(transform(MERGEABLE_ANALYTICS)).toEqual([ - { organisationUnit: 'TO', BCD1: 11, BCD2: 12 }, - { organisationUnit: 'PG', BCD1: 17, BCD2: 111 }, - ]); + expect(transform(TransformTable.fromRows(MERGEABLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows([ + { period: '20200101', organisationUnit: 'TO', BCD1: 11, BCD2: 12 }, + { period: '20200101', organisationUnit: 'PG', BCD1: 17, BCD2: 111 }, + ]), + ); }); it('sum -> exclude null values', () => { @@ -123,15 +135,24 @@ describe('mergeRows', () => { transform: 'mergeRows', groupBy: 'organisationUnit', using: { - period: 'exclude', + period: 'first', '*': 'sum', }, }, ]); - expect(transform([...MERGEABLE_ANALYTICS, ...MERGEABLE_ANALYTICS_WITH_NULL_VALUES])).toEqual([ - { organisationUnit: 'TO', BCD1: 11, BCD2: 12 }, - { organisationUnit: 'PG', BCD1: 17, BCD2: 111 }, - ]); + expect( + transform( + TransformTable.fromRows([ + ...MERGEABLE_ANALYTICS, + ...MERGEABLE_ANALYTICS_WITH_NULL_VALUES, + ]), + ), + ).toStrictEqual( + TransformTable.fromRows([ + { period: '20200101', organisationUnit: 'TO', BCD1: 11, BCD2: 12 }, + { period: '20200101', organisationUnit: 'PG', BCD1: 17, BCD2: 111 }, + ]), + ); }); it('average', () => { @@ -140,15 +161,17 @@ describe('mergeRows', () => { transform: 'mergeRows', groupBy: 'organisationUnit', using: { - period: 'exclude', + period: 'first', '*': 'average', }, }, ]); - expect(transform(MERGEABLE_ANALYTICS)).toEqual([ - { organisationUnit: 'TO', BCD1: 3.6666666666666665, BCD2: 4 }, - { organisationUnit: 'PG', BCD1: 5.666666666666667, BCD2: 37 }, - ]); + expect(transform(TransformTable.fromRows(MERGEABLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows([ + { period: '20200101', organisationUnit: 'TO', BCD1: 3.6666666666666665, BCD2: 4 }, + { period: '20200101', organisationUnit: 'PG', BCD1: 5.666666666666667, BCD2: 37 }, + ]), + ); }); it('count', () => { @@ -159,11 +182,13 @@ describe('mergeRows', () => { using: 'count', }, ]); - expect(transform(MERGEABLE_ANALYTICS)).toEqual([ - { organisationUnit: 4, period: '20200101', BCD1: 2, BCD2: 2 }, - { organisationUnit: 4, period: '20200102', BCD1: 2, BCD2: 2 }, - { organisationUnit: 4, period: '20200103', BCD1: 2, BCD2: 2 }, - ]); + expect(transform(TransformTable.fromRows(MERGEABLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows([ + { period: '20200101', organisationUnit: 4, BCD1: 2, BCD2: 2 }, + { period: '20200102', organisationUnit: 4, BCD1: 2, BCD2: 2 }, + { period: '20200103', organisationUnit: 4, BCD1: 2, BCD2: 2 }, + ]), + ); }); it('max', () => { @@ -174,10 +199,12 @@ describe('mergeRows', () => { using: 'max', }, ]); - expect(transform(MERGEABLE_ANALYTICS)).toEqual([ - { organisationUnit: 'TO', period: '20200103', BCD1: 5, BCD2: 11 }, - { organisationUnit: 'PG', period: '20200103', BCD1: 8, BCD2: 99 }, - ]); + expect(transform(TransformTable.fromRows(MERGEABLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows([ + { period: '20200103', organisationUnit: 'TO', BCD1: 5, BCD2: 11 }, + { period: '20200103', organisationUnit: 'PG', BCD1: 8, BCD2: 99 }, + ]), + ); }); it('min', () => { @@ -188,11 +215,13 @@ describe('mergeRows', () => { using: 'min', }, ]); - expect(transform(MERGEABLE_ANALYTICS)).toEqual([ - { organisationUnit: 'PG', period: '20200101', BCD1: 4, BCD2: 11 }, - { organisationUnit: 'PG', period: '20200102', BCD1: 2, BCD2: 1 }, - { organisationUnit: 'PG', period: '20200103', BCD1: 2, BCD2: -1 }, - ]); + expect(transform(TransformTable.fromRows(MERGEABLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows([ + { period: '20200101', organisationUnit: 'PG', BCD1: 4, BCD2: 11 }, + { period: '20200102', organisationUnit: 'PG', BCD1: 2, BCD2: 1 }, + { period: '20200103', organisationUnit: 'PG', BCD1: 2, BCD2: -1 }, + ]), + ); }); it('min -> consider null as minimum', () => { @@ -203,11 +232,20 @@ describe('mergeRows', () => { using: 'min', }, ]); - expect(transform([...MERGEABLE_ANALYTICS, ...MERGEABLE_ANALYTICS_WITH_NULL_VALUES])).toEqual([ - { organisationUnit: 'PG', period: '20200101', BCD1: null, BCD2: null }, - { organisationUnit: 'PG', period: '20200102', BCD1: 2, BCD2: 1 }, - { organisationUnit: 'PG', period: '20200103', BCD1: 2, BCD2: -1 }, - ]); + expect( + transform( + TransformTable.fromRows([ + ...MERGEABLE_ANALYTICS, + ...MERGEABLE_ANALYTICS_WITH_NULL_VALUES, + ]), + ), + ).toStrictEqual( + TransformTable.fromRows([ + { period: '20200101', organisationUnit: 'PG', BCD1: null, BCD2: null }, + { period: '20200102', organisationUnit: 'PG', BCD1: 2, BCD2: 1 }, + { period: '20200103', organisationUnit: 'PG', BCD1: 2, BCD2: -1 }, + ]), + ); }); it('unique', () => { @@ -218,20 +256,22 @@ describe('mergeRows', () => { using: 'unique', }, ]); - expect(transform(UNIQUE_MERGEABLE_ANALYTICS)).toEqual([ - { - organisationUnit: 'TO', - period: 'NO_UNIQUE_VALUE', - BCD1: 4, - BCD2: 'NO_UNIQUE_VALUE', - }, - { - organisationUnit: 'PG', - period: 'NO_UNIQUE_VALUE', - BCD1: 'NO_UNIQUE_VALUE', - BCD2: 99, - }, - ]); + expect(transform(TransformTable.fromRows(UNIQUE_MERGEABLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows([ + { + period: 'NO_UNIQUE_VALUE', + organisationUnit: 'TO', + BCD1: 4, + BCD2: 'NO_UNIQUE_VALUE', + }, + { + period: 'NO_UNIQUE_VALUE', + organisationUnit: 'PG', + BCD1: 'NO_UNIQUE_VALUE', + BCD2: 99, + }, + ]), + ); }); it('exclude', () => { @@ -242,11 +282,12 @@ describe('mergeRows', () => { using: 'exclude', }, ]); - expect(transform(MERGEABLE_ANALYTICS)).toEqual([ - { period: '20200101' }, - { period: '20200102' }, - { period: '20200103' }, - ]); + expect(transform(TransformTable.fromRows(MERGEABLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows( + [{ period: '20200101' }, { period: '20200102' }, { period: '20200103' }], + ['period', 'organisationUnit', 'BCD1', 'BCD2'], // excludes values, but keeps columns + ), + ); }); it('first', () => { @@ -257,10 +298,12 @@ describe('mergeRows', () => { using: 'first', }, ]); - expect(transform(MERGEABLE_ANALYTICS)).toEqual([ - { organisationUnit: 'TO', period: '20200101', BCD1: 4, BCD2: 11 }, - { organisationUnit: 'PG', period: '20200101', BCD1: 7, BCD2: 13 }, - ]); + expect(transform(TransformTable.fromRows(MERGEABLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows([ + { period: '20200101', organisationUnit: 'TO', BCD1: 4, BCD2: 11 }, + { period: '20200101', organisationUnit: 'PG', BCD1: 7, BCD2: 13 }, + ]), + ); }); it('last', () => { @@ -271,11 +314,13 @@ describe('mergeRows', () => { using: 'last', }, ]); - expect(transform(MERGEABLE_ANALYTICS)).toEqual([ - { organisationUnit: 'PG', period: '20200101', BCD1: 7, BCD2: 13 }, - { organisationUnit: 'PG', period: '20200102', BCD1: 8, BCD2: 99 }, - { organisationUnit: 'PG', period: '20200103', BCD1: 2, BCD2: -1 }, - ]); + expect(transform(TransformTable.fromRows(MERGEABLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows([ + { period: '20200101', organisationUnit: 'PG', BCD1: 7, BCD2: 13 }, + { period: '20200102', organisationUnit: 'PG', BCD1: 8, BCD2: 99 }, + { period: '20200103', organisationUnit: 'PG', BCD1: 2, BCD2: -1 }, + ]), + ); }); describe('single', () => { @@ -287,7 +332,7 @@ describe('mergeRows', () => { using: 'single', }, ]); - expect(() => transform(MERGEABLE_ANALYTICS)).toThrow(); + expect(() => transform(TransformTable.fromRows(MERGEABLE_ANALYTICS))).toThrow(); }); it('returns the value if a single value exists per group', () => { @@ -298,9 +343,9 @@ describe('mergeRows', () => { using: 'single', }, ]); - expect(transform(SINGLE_MERGEABLE_ANALYTICS)).toEqual([ - { period: '20200101', BCD1: 4, BCD2: 4, BCD3: 4 }, - ]); + expect(transform(TransformTable.fromRows(SINGLE_MERGEABLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows([{ period: '20200101', BCD1: 4, BCD2: 4, BCD3: 4 }]), + ); }); }); }); diff --git a/packages/report-server/src/__tests__/reportBuilder/transform/orderColumns.test.ts b/packages/report-server/src/__tests__/reportBuilder/transform/orderColumns.test.ts new file mode 100644 index 0000000000..5e85c2afef --- /dev/null +++ b/packages/report-server/src/__tests__/reportBuilder/transform/orderColumns.test.ts @@ -0,0 +1,141 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2020 Beyond Essential Systems Pty Ltd + */ + +import { SINGLE_ANALYTIC, DATE_COLUMNS, PERIOD_COLUMNS } from './transform.fixtures'; +import { buildTransform, TransformTable } from '../../../reportBuilder/transform'; + +describe('orderColumns', () => { + it('can re-order columns explicitly', () => { + const transform = buildTransform([ + { + transform: 'orderColumns', + order: ['dataElement', 'value', 'period', 'organisationUnit'], + }, + ]); + expect(transform(TransformTable.fromRows(SINGLE_ANALYTIC))).toEqual( + TransformTable.fromRows([ + { dataElement: 'BCD1', value: 4, period: '20200101', organisationUnit: 'TO' }, + ]), + ); + }); + + it('can re-order columns explicitly with a wildCard', () => { + const transform = buildTransform([ + { + transform: 'orderColumns', + order: ['dataElement', '*', 'organisationUnit'], + }, + ]); + expect(transform(TransformTable.fromRows(SINGLE_ANALYTIC))).toEqual( + TransformTable.fromRows([ + { dataElement: 'BCD1', period: '20200101', value: 4, organisationUnit: 'TO' }, + ]), + ); + }); + + it('ignores columns in the explicit order that do not exist in the table', () => { + const transform = buildTransform([ + { + transform: 'orderColumns', + order: ['dataElement', 'BCD1', 'period', 'value', 'organisationUnit'], + }, + ]); + expect(transform(TransformTable.fromRows(SINGLE_ANALYTIC))).toEqual( + TransformTable.fromRows([ + { dataElement: 'BCD1', period: '20200101', value: 4, organisationUnit: 'TO' }, + ]), + ); + }); + + it('defaults to appending columns to the end if they are not listed in the order', () => { + const transform = buildTransform([ + { + transform: 'orderColumns', + order: ['dataElement', 'organisationUnit'], + }, + ]); + expect(transform(TransformTable.fromRows(SINGLE_ANALYTIC))).toEqual( + TransformTable.fromRows([ + { dataElement: 'BCD1', organisationUnit: 'TO', period: '20200101', value: 4 }, + ]), + ); + }); + + describe('sortBy functions', () => { + it('throws error for unknown sortBy function', () => { + expect(() => + buildTransform([ + { + transform: 'orderColumns', + sortBy: 'not_a_real_sort_by_function', + }, + ]), + ).toThrow('sortBy must be one of the following values:'); + }); + + describe('alphabetic', () => { + it('can sort columns alphabetically', () => { + const transform = buildTransform([ + { + transform: 'orderColumns', + sortBy: 'alphabetic', + }, + ]); + expect(transform(TransformTable.fromRows(SINGLE_ANALYTIC))).toEqual( + TransformTable.fromRows([ + { dataElement: 'BCD1', organisationUnit: 'TO', period: '20200101', value: 4 }, + ]), + ); + }); + }); + + describe('date', () => { + it('can sort columns by date', () => { + const transform = buildTransform([ + { + transform: 'orderColumns', + sortBy: 'date', + }, + ]); + expect(transform(TransformTable.fromRows(DATE_COLUMNS))).toEqual( + TransformTable.fromRows([ + { + 'Q1 2021': 4, + '13th Jan 2022': 2, + '2nd Aug 2022': 1, + 'Sep 2022': 3, + organisationUnit: 'Tonga', + }, + ]), + ); + }); + }); + + describe('period', () => { + it('can sort columns by period', () => { + const transform = buildTransform([ + { + transform: 'orderColumns', + sortBy: 'period', + }, + ]); + expect(transform(TransformTable.fromRows(PERIOD_COLUMNS))).toEqual( + TransformTable.fromRows( + [ + { + '2021Q1': 4, + '2022W03': 2, + '20220802': 1, + '202209': 3, + organisationUnit: 'Tonga', + }, + ], + ['2021Q1', '2022W03', '20220802', '202209', 'organisationUnit'], + ), + ); + }); + }); + }); +}); diff --git a/packages/report-server/src/__tests__/reportBuilder/transform/parser/functions.test.ts b/packages/report-server/src/__tests__/reportBuilder/transform/parser/functions.test.ts index 22a107cbe4..8590aba77b 100644 --- a/packages/report-server/src/__tests__/reportBuilder/transform/parser/functions.test.ts +++ b/packages/report-server/src/__tests__/reportBuilder/transform/parser/functions.test.ts @@ -138,7 +138,7 @@ describe('functions', () => { describe('orgUnitAttribute()', () => { const context = { orgUnits: [ - { id: '1234', code: 'FJ', name: 'Fiji', attributes: { x: 1 }}, + { id: '1234', code: 'FJ', name: 'Fiji', attributes: { x: 1 } }, { id: '5678', code: 'TO', name: 'Tonga', attributes: { y: 2 } }, ], }; diff --git a/packages/report-server/src/__tests__/reportBuilder/transform/parser/parser.test.ts b/packages/report-server/src/__tests__/reportBuilder/transform/parser/parser.test.ts index 8c5d4e3412..3e7f9dfacc 100644 --- a/packages/report-server/src/__tests__/reportBuilder/transform/parser/parser.test.ts +++ b/packages/report-server/src/__tests__/reportBuilder/transform/parser/parser.test.ts @@ -4,7 +4,7 @@ */ import { PARSABLE_ANALYTICS } from '../transform.fixtures'; -import { buildTransform } from '../../../../reportBuilder/transform'; +import { buildTransform, TransformTable } from '../../../../reportBuilder/transform'; describe('parser', () => { it('can do lookups', () => { @@ -30,78 +30,94 @@ describe('parser', () => { ], { query: { animal: 'cat' } }, ); - expect(transform(PARSABLE_ANALYTICS)).toEqual([ - { - variable: 4, - current: 4, - index: 1, - next: 2, - lastAll: 2, - sumAllPrevious: 4, - sumWhereMatchingOrgUnit: 11, - tableLength: 6, - requestParam: 'cat', - }, - { - variable: 2, - current: 2, - index: 2, - previous: 4, - next: 5, - lastAll: 2, - sumAllPrevious: 6, - sumWhereMatchingOrgUnit: 11, - tableLength: 6, - requestParam: 'cat', - }, - { - variable: 5, - current: 5, - index: 3, - previous: 2, - next: 7, - lastAll: 2, - sumAllPrevious: 11, - sumWhereMatchingOrgUnit: 11, - tableLength: 6, - requestParam: 'cat', - }, - { - variable: 7, - current: 7, - index: 4, - previous: 5, - next: 8, - lastAll: 2, - sumAllPrevious: 18, - sumWhereMatchingOrgUnit: 17, - tableLength: 6, - requestParam: 'cat', - }, - { - variable: 8, - current: 8, - index: 5, - previous: 7, - next: 2, - lastAll: 2, - sumAllPrevious: 26, - sumWhereMatchingOrgUnit: 17, - tableLength: 6, - requestParam: 'cat', - }, - { - variable: 2, - current: 2, - index: 6, - previous: 8, - lastAll: 2, - sumAllPrevious: 28, - sumWhereMatchingOrgUnit: 17, - tableLength: 6, - requestParam: 'cat', - }, - ]); + expect(transform(TransformTable.fromRows(PARSABLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows( + [ + { + variable: 4, + current: 4, + index: 1, + next: 2, + lastAll: 2, + sumAllPrevious: 4, + sumWhereMatchingOrgUnit: 11, + tableLength: 6, + requestParam: 'cat', + }, + { + variable: 2, + current: 2, + index: 2, + previous: 4, + next: 5, + lastAll: 2, + sumAllPrevious: 6, + sumWhereMatchingOrgUnit: 11, + tableLength: 6, + requestParam: 'cat', + }, + { + variable: 5, + current: 5, + index: 3, + previous: 2, + next: 7, + lastAll: 2, + sumAllPrevious: 11, + sumWhereMatchingOrgUnit: 11, + tableLength: 6, + requestParam: 'cat', + }, + { + variable: 7, + current: 7, + index: 4, + previous: 5, + next: 8, + lastAll: 2, + sumAllPrevious: 18, + sumWhereMatchingOrgUnit: 17, + tableLength: 6, + requestParam: 'cat', + }, + { + variable: 8, + current: 8, + index: 5, + previous: 7, + next: 2, + lastAll: 2, + sumAllPrevious: 26, + sumWhereMatchingOrgUnit: 17, + tableLength: 6, + requestParam: 'cat', + }, + { + variable: 2, + current: 2, + index: 6, + previous: 8, + lastAll: 2, + sumAllPrevious: 28, + sumWhereMatchingOrgUnit: 17, + tableLength: 6, + requestParam: 'cat', + }, + ], + [ + 'variable', + 'current', + 'index', + 'previous', + 'next', + 'lastAll', + 'sumAllPrevious', + 'sumWhereMatchingOrgUnit', + 'tableLength', + 'requestParam', + ], + ), + ); }); describe('in transforms', () => { @@ -117,12 +133,17 @@ describe('parser', () => { where: "=eq($organisationUnit, 'TO')", }, ]); - expect(transform(PARSABLE_ANALYTICS)).toEqual([ - { BCD1: 11 }, - { period: '20200101', organisationUnit: 'PG', BCD1: 7 }, - { period: '20200102', organisationUnit: 'PG', BCD1: 8 }, - { period: '20200103', organisationUnit: 'PG', BCD1: 2 }, - ]); + expect(transform(TransformTable.fromRows(PARSABLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows( + [ + { BCD1: 11 }, + { period: '20200101', organisationUnit: 'PG', BCD1: 7 }, + { period: '20200102', organisationUnit: 'PG', BCD1: 8 }, + { period: '20200103', organisationUnit: 'PG', BCD1: 2 }, + ], + ['period', 'organisationUnit', 'BCD1'], + ), + ); }); it('excludeRows supports parser lookups on where', () => { @@ -133,12 +154,14 @@ describe('parser', () => { '=$BCD1 <= mean(where(f(@otherRow) = eq($organisationUnit, @otherRow.organisationUnit)).BCD1)', }, ]); - expect(transform(PARSABLE_ANALYTICS)).toEqual([ - { period: '20200101', organisationUnit: 'TO', BCD1: 4 }, - { period: '20200103', organisationUnit: 'TO', BCD1: 5 }, - { period: '20200101', organisationUnit: 'PG', BCD1: 7 }, - { period: '20200102', organisationUnit: 'PG', BCD1: 8 }, - ]); + expect(transform(TransformTable.fromRows(PARSABLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows([ + { period: '20200101', organisationUnit: 'TO', BCD1: 4 }, + { period: '20200103', organisationUnit: 'TO', BCD1: 5 }, + { period: '20200101', organisationUnit: 'PG', BCD1: 7 }, + { period: '20200102', organisationUnit: 'PG', BCD1: 8 }, + ]), + ); }); it('updateColumns supports parser lookups in column name and values', () => { @@ -151,14 +174,16 @@ describe('parser', () => { exclude: ['organisationUnit', 'BCD1'], }, ]); - expect(transform(PARSABLE_ANALYTICS)).toEqual([ - { period: '20200101', TO: 4 }, - { period: '20200102', TO: 2 }, - { period: '20200103', TO: 5 }, - { period: '20200101', PG: 7 }, - { period: '20200102', PG: 8 }, - { period: '20200103', PG: 2 }, - ]); + expect(transform(TransformTable.fromRows(PARSABLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows([ + { period: '20200101', TO: 4 }, + { period: '20200102', TO: 2 }, + { period: '20200103', TO: 5 }, + { period: '20200101', PG: 7 }, + { period: '20200102', PG: 8 }, + { period: '20200103', PG: 2 }, + ]), + ); }); it('sortRows supports row parser lookups', () => { @@ -168,14 +193,16 @@ describe('parser', () => { by: '=$BCD1', }, ]); - expect(transform(PARSABLE_ANALYTICS)).toEqual([ - { period: '20200102', organisationUnit: 'TO', BCD1: 2 }, - { period: '20200103', organisationUnit: 'PG', BCD1: 2 }, - { period: '20200101', organisationUnit: 'TO', BCD1: 4 }, - { period: '20200103', organisationUnit: 'TO', BCD1: 5 }, - { period: '20200101', organisationUnit: 'PG', BCD1: 7 }, - { period: '20200102', organisationUnit: 'PG', BCD1: 8 }, - ]); + expect(transform(TransformTable.fromRows(PARSABLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows([ + { period: '20200102', organisationUnit: 'TO', BCD1: 2 }, + { period: '20200103', organisationUnit: 'PG', BCD1: 2 }, + { period: '20200101', organisationUnit: 'TO', BCD1: 4 }, + { period: '20200103', organisationUnit: 'TO', BCD1: 5 }, + { period: '20200101', organisationUnit: 'PG', BCD1: 7 }, + { period: '20200102', organisationUnit: 'PG', BCD1: 8 }, + ]), + ); }); }); }); diff --git a/packages/report-server/src/__tests__/reportBuilder/transform/sortRows.test.ts b/packages/report-server/src/__tests__/reportBuilder/transform/sortRows.test.ts index d007e041c0..53e5b7893e 100644 --- a/packages/report-server/src/__tests__/reportBuilder/transform/sortRows.test.ts +++ b/packages/report-server/src/__tests__/reportBuilder/transform/sortRows.test.ts @@ -4,7 +4,7 @@ */ import { SORTABLE_ANALYTICS } from './transform.fixtures'; -import { buildTransform } from '../../../reportBuilder/transform'; +import { buildTransform, TransformTable } from '../../../reportBuilder/transform'; describe('sortRows', () => { it('throws an error if by is not specified', () => { @@ -25,20 +25,22 @@ describe('sortRows', () => { by: 'period', }, ]); - expect(transform(SORTABLE_ANALYTICS)).toEqual([ - { period: '20200101', organisationUnit: 'TO', BCD1: 4 }, - { period: '20200101', organisationUnit: 'TO', BCD1: 11 }, - { period: '20200101', organisationUnit: 'PG', BCD1: 7 }, - { period: '20200101', organisationUnit: 'PG', BCD1: 13 }, - { period: '20200102', organisationUnit: 'TO', BCD1: 2 }, - { period: '20200102', organisationUnit: 'TO', BCD1: 1 }, - { period: '20200102', organisationUnit: 'PG', BCD1: 8 }, - { period: '20200102', organisationUnit: 'PG', BCD1: 99 }, - { period: '20200103', organisationUnit: 'TO', BCD1: 5 }, - { period: '20200103', organisationUnit: 'TO', BCD1: 0 }, - { period: '20200103', organisationUnit: 'PG', BCD1: 2 }, - { period: '20200103', organisationUnit: 'PG', BCD1: -1 }, - ]); + expect(transform(TransformTable.fromRows(SORTABLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows([ + { period: '20200101', organisationUnit: 'TO', BCD1: 4 }, + { period: '20200101', organisationUnit: 'TO', BCD1: 11 }, + { period: '20200101', organisationUnit: 'PG', BCD1: 7 }, + { period: '20200101', organisationUnit: 'PG', BCD1: 13 }, + { period: '20200102', organisationUnit: 'TO', BCD1: 2 }, + { period: '20200102', organisationUnit: 'TO', BCD1: 1 }, + { period: '20200102', organisationUnit: 'PG', BCD1: 8 }, + { period: '20200102', organisationUnit: 'PG', BCD1: 99 }, + { period: '20200103', organisationUnit: 'TO', BCD1: 5 }, + { period: '20200103', organisationUnit: 'TO', BCD1: 0 }, + { period: '20200103', organisationUnit: 'PG', BCD1: 2 }, + { period: '20200103', organisationUnit: 'PG', BCD1: -1 }, + ]), + ); }); it('can reverse sort by a column', () => { @@ -49,20 +51,22 @@ describe('sortRows', () => { direction: 'desc', }, ]); - expect(transform(SORTABLE_ANALYTICS)).toEqual([ - { period: '20200103', organisationUnit: 'TO', BCD1: 5 }, - { period: '20200103', organisationUnit: 'TO', BCD1: 0 }, - { period: '20200103', organisationUnit: 'PG', BCD1: 2 }, - { period: '20200103', organisationUnit: 'PG', BCD1: -1 }, - { period: '20200102', organisationUnit: 'TO', BCD1: 2 }, - { period: '20200102', organisationUnit: 'TO', BCD1: 1 }, - { period: '20200102', organisationUnit: 'PG', BCD1: 8 }, - { period: '20200102', organisationUnit: 'PG', BCD1: 99 }, - { period: '20200101', organisationUnit: 'TO', BCD1: 4 }, - { period: '20200101', organisationUnit: 'TO', BCD1: 11 }, - { period: '20200101', organisationUnit: 'PG', BCD1: 7 }, - { period: '20200101', organisationUnit: 'PG', BCD1: 13 }, - ]); + expect(transform(TransformTable.fromRows(SORTABLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows([ + { period: '20200103', organisationUnit: 'TO', BCD1: 5 }, + { period: '20200103', organisationUnit: 'TO', BCD1: 0 }, + { period: '20200103', organisationUnit: 'PG', BCD1: 2 }, + { period: '20200103', organisationUnit: 'PG', BCD1: -1 }, + { period: '20200102', organisationUnit: 'TO', BCD1: 2 }, + { period: '20200102', organisationUnit: 'TO', BCD1: 1 }, + { period: '20200102', organisationUnit: 'PG', BCD1: 8 }, + { period: '20200102', organisationUnit: 'PG', BCD1: 99 }, + { period: '20200101', organisationUnit: 'TO', BCD1: 4 }, + { period: '20200101', organisationUnit: 'TO', BCD1: 11 }, + { period: '20200101', organisationUnit: 'PG', BCD1: 7 }, + { period: '20200101', organisationUnit: 'PG', BCD1: 13 }, + ]), + ); }); it('can sort by multiple columns', () => { @@ -72,20 +76,22 @@ describe('sortRows', () => { by: ['period', 'organisationUnit'], }, ]); - expect(transform(SORTABLE_ANALYTICS)).toEqual([ - { period: '20200101', organisationUnit: 'PG', BCD1: 7 }, - { period: '20200101', organisationUnit: 'PG', BCD1: 13 }, - { period: '20200101', organisationUnit: 'TO', BCD1: 4 }, - { period: '20200101', organisationUnit: 'TO', BCD1: 11 }, - { period: '20200102', organisationUnit: 'PG', BCD1: 8 }, - { period: '20200102', organisationUnit: 'PG', BCD1: 99 }, - { period: '20200102', organisationUnit: 'TO', BCD1: 2 }, - { period: '20200102', organisationUnit: 'TO', BCD1: 1 }, - { period: '20200103', organisationUnit: 'PG', BCD1: 2 }, - { period: '20200103', organisationUnit: 'PG', BCD1: -1 }, - { period: '20200103', organisationUnit: 'TO', BCD1: 5 }, - { period: '20200103', organisationUnit: 'TO', BCD1: 0 }, - ]); + expect(transform(TransformTable.fromRows(SORTABLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows([ + { period: '20200101', organisationUnit: 'PG', BCD1: 7 }, + { period: '20200101', organisationUnit: 'PG', BCD1: 13 }, + { period: '20200101', organisationUnit: 'TO', BCD1: 4 }, + { period: '20200101', organisationUnit: 'TO', BCD1: 11 }, + { period: '20200102', organisationUnit: 'PG', BCD1: 8 }, + { period: '20200102', organisationUnit: 'PG', BCD1: 99 }, + { period: '20200102', organisationUnit: 'TO', BCD1: 2 }, + { period: '20200102', organisationUnit: 'TO', BCD1: 1 }, + { period: '20200103', organisationUnit: 'PG', BCD1: 2 }, + { period: '20200103', organisationUnit: 'PG', BCD1: -1 }, + { period: '20200103', organisationUnit: 'TO', BCD1: 5 }, + { period: '20200103', organisationUnit: 'TO', BCD1: 0 }, + ]), + ); }); it('can sort by different directions per column', () => { @@ -96,20 +102,22 @@ describe('sortRows', () => { direction: ['asc', 'desc'], }, ]); - expect(transform(SORTABLE_ANALYTICS)).toEqual([ - { period: '20200101', organisationUnit: 'PG', BCD1: 13 }, - { period: '20200101', organisationUnit: 'TO', BCD1: 11 }, - { period: '20200101', organisationUnit: 'PG', BCD1: 7 }, - { period: '20200101', organisationUnit: 'TO', BCD1: 4 }, - { period: '20200102', organisationUnit: 'PG', BCD1: 99 }, - { period: '20200102', organisationUnit: 'PG', BCD1: 8 }, - { period: '20200102', organisationUnit: 'TO', BCD1: 2 }, - { period: '20200102', organisationUnit: 'TO', BCD1: 1 }, - { period: '20200103', organisationUnit: 'TO', BCD1: 5 }, - { period: '20200103', organisationUnit: 'PG', BCD1: 2 }, - { period: '20200103', organisationUnit: 'TO', BCD1: 0 }, - { period: '20200103', organisationUnit: 'PG', BCD1: -1 }, - ]); + expect(transform(TransformTable.fromRows(SORTABLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows([ + { period: '20200101', organisationUnit: 'PG', BCD1: 13 }, + { period: '20200101', organisationUnit: 'TO', BCD1: 11 }, + { period: '20200101', organisationUnit: 'PG', BCD1: 7 }, + { period: '20200101', organisationUnit: 'TO', BCD1: 4 }, + { period: '20200102', organisationUnit: 'PG', BCD1: 99 }, + { period: '20200102', organisationUnit: 'PG', BCD1: 8 }, + { period: '20200102', organisationUnit: 'TO', BCD1: 2 }, + { period: '20200102', organisationUnit: 'TO', BCD1: 1 }, + { period: '20200103', organisationUnit: 'TO', BCD1: 5 }, + { period: '20200103', organisationUnit: 'PG', BCD1: 2 }, + { period: '20200103', organisationUnit: 'TO', BCD1: 0 }, + { period: '20200103', organisationUnit: 'PG', BCD1: -1 }, + ]), + ); }); it('can sort by expressions', () => { @@ -119,19 +127,21 @@ describe('sortRows', () => { by: '=$BCD1 * $BCD1', }, ]); - expect(transform(SORTABLE_ANALYTICS)).toEqual([ - { period: '20200103', organisationUnit: 'TO', BCD1: 0 }, - { period: '20200102', organisationUnit: 'TO', BCD1: 1 }, - { period: '20200103', organisationUnit: 'PG', BCD1: -1 }, - { period: '20200102', organisationUnit: 'TO', BCD1: 2 }, - { period: '20200103', organisationUnit: 'PG', BCD1: 2 }, - { period: '20200101', organisationUnit: 'TO', BCD1: 4 }, - { period: '20200103', organisationUnit: 'TO', BCD1: 5 }, - { period: '20200101', organisationUnit: 'PG', BCD1: 7 }, - { period: '20200102', organisationUnit: 'PG', BCD1: 8 }, - { period: '20200101', organisationUnit: 'TO', BCD1: 11 }, - { period: '20200101', organisationUnit: 'PG', BCD1: 13 }, - { period: '20200102', organisationUnit: 'PG', BCD1: 99 }, - ]); + expect(transform(TransformTable.fromRows(SORTABLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows([ + { period: '20200103', organisationUnit: 'TO', BCD1: 0 }, + { period: '20200102', organisationUnit: 'TO', BCD1: 1 }, + { period: '20200103', organisationUnit: 'PG', BCD1: -1 }, + { period: '20200102', organisationUnit: 'TO', BCD1: 2 }, + { period: '20200103', organisationUnit: 'PG', BCD1: 2 }, + { period: '20200101', organisationUnit: 'TO', BCD1: 4 }, + { period: '20200103', organisationUnit: 'TO', BCD1: 5 }, + { period: '20200101', organisationUnit: 'PG', BCD1: 7 }, + { period: '20200102', organisationUnit: 'PG', BCD1: 8 }, + { period: '20200101', organisationUnit: 'TO', BCD1: 11 }, + { period: '20200101', organisationUnit: 'PG', BCD1: 13 }, + { period: '20200102', organisationUnit: 'PG', BCD1: 99 }, + ]), + ); }); }); diff --git a/packages/report-server/src/__tests__/reportBuilder/transform/table/TransformTable.test.ts b/packages/report-server/src/__tests__/reportBuilder/transform/table/TransformTable.test.ts new file mode 100644 index 0000000000..74b159b8fc --- /dev/null +++ b/packages/report-server/src/__tests__/reportBuilder/transform/table/TransformTable.test.ts @@ -0,0 +1,229 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2022 Beyond Essential Systems Pty Ltd + */ + +import { TransformTable } from '../../../../reportBuilder/transform'; + +const TEST_TABLE = new TransformTable( + ['animal', 'name', 'age'], + [ + { animal: 'cat', name: 'Mr. Meow', age: 7 }, + { animal: 'dog', name: 'Wooferton Barkly', age: 5 }, + { animal: 'fish', name: 'Bubbles', age: 50000 }, + ], +); + +describe('TransformTable', () => { + describe('insertRows', () => { + it('throws an error if index is out of bounds', () => { + const row = { animal: 'wolf', name: 'Howl', age: 9 }; + const insertRowOutOfRange = () => + TEST_TABLE.insertRows([ + { + row, + index: TEST_TABLE.length() + 1, + }, + ]); + + const insertRowAtNegativeIndex = () => TEST_TABLE.insertRows([{ row, index: -1 }]); + + expect(insertRowOutOfRange).toThrow(`Error inserting row`); + expect(insertRowAtNegativeIndex).toThrow('Error inserting row'); + }); + + it('returns a table with the new row inserted', () => { + const row = { animal: 'wolf', name: 'Howl', age: 9 }; + const index = 0; + const newTable = TEST_TABLE.insertRows([{ row, index }]); + + expect(newTable).toEqual( + new TransformTable(TEST_TABLE.getColumns(), [row, ...TEST_TABLE.getRows()]), + ); + }); + + it('inserts at the end of the table by default', () => { + const row = { animal: 'wolf', name: 'Howl', age: 9 }; + const newTable = TEST_TABLE.insertRows([{ row }]); + + expect(newTable).toEqual( + new TransformTable(TEST_TABLE.getColumns(), [...TEST_TABLE.getRows(), row]), + ); + }); + + it('can insert at any index in the table', () => { + const columnNames = TEST_TABLE.getColumns(); + const row = { animal: 'wolf', name: 'Howl', age: 9 }; + const indexToInsertAt = 2; + const newTable = TEST_TABLE.insertRows([{ row, index: indexToInsertAt }]); + + expect(newTable).toEqual( + new TransformTable(columnNames, [ + ...TEST_TABLE.getRows().filter((_, index) => index < indexToInsertAt), + row, + ...TEST_TABLE.getRows().filter((_, index) => index >= indexToInsertAt), + ]), + ); + }); + + it('can insert with a different column order, and preserve the original column order', () => { + const columnNames = TEST_TABLE.getColumns(); + const row = { age: 9, name: 'Howl', animal: 'wolf' }; + const newTable = TEST_TABLE.insertRows([ + { + row, + }, + ]); + + expect(newTable).toEqual( + new TransformTable(columnNames, [ + ...TEST_TABLE.getRows(), + { + animal: row.animal, + name: row.name, + age: row.age, + }, + ]), + ); + }); + + it('can insert a row with only partial columns', () => { + const columnNames = TEST_TABLE.getColumns(); + const row = { animal: 'wolf', age: 9 }; + const newTable = TEST_TABLE.insertRows([ + { + row, + }, + ]); + + expect(newTable).toEqual( + new TransformTable(columnNames, [ + ...TEST_TABLE.getRows(), + { animal: row.animal, name: undefined, age: row.age }, + ]), + ); + }); + + it('can insert a row with new columns in it, and those columns will be added to the table', () => { + const row = { animal: 'wolf', favourite_food: 'banana', favourite_place: 'cave' }; + const newTable = TEST_TABLE.insertRows([{ row }]); + + expect(newTable).toEqual( + new TransformTable( + [...TEST_TABLE.getColumns(), 'favourite_food', 'favourite_place'], + [...TEST_TABLE.getRows(), row], + ), + ); + }); + }); + + describe('upsertColumn', () => { + it('throws an error if incorrect column length is given', () => { + const upsertColumnTooShort = () => + TEST_TABLE.upsertColumns([ + { columnName: 'too_short', values: new Array(TEST_TABLE.length() - 1).fill(undefined) }, + ]); + + const upsertColumnTooLong = () => + TEST_TABLE.upsertColumns([ + { columnName: 'too_long', values: new Array(TEST_TABLE.length() + 1).fill(undefined) }, + ]); + + expect(upsertColumnTooShort).toThrow(`Error upserting column`); + expect(upsertColumnTooLong).toThrow('Error upserting column'); + }); + + it('can insert a new column', () => { + const columnName = 'favourite_food'; + const values = ['butter', 'bones', 'bitcoin']; + const newTable = TEST_TABLE.upsertColumns([{ columnName, values }]); + + expect(newTable).toEqual( + new TransformTable( + [...TEST_TABLE.getColumns(), columnName], + TEST_TABLE.getRows().map((row, index) => ({ ...row, favourite_food: values[index] })), + ), + ); + }); + + it('can overwrite an existing column', () => { + const columnName = 'age'; + const values = ['butter', 'bones', 'bitcoin']; + const newTable = TEST_TABLE.upsertColumns([ + { + columnName, + values, + }, + ]); + + expect(newTable).toEqual( + new TransformTable( + TEST_TABLE.getColumns(), + TEST_TABLE.getRows().map((row, index) => ({ ...row, [columnName]: values[index] })), + ), + ); + }); + }); + + describe('dropRows', () => { + it('can drop multiple rows', () => { + const rowsToDrop = [1, 3]; + const newTable = TEST_TABLE.dropRows(rowsToDrop); + + expect(newTable).toEqual( + new TransformTable( + TEST_TABLE.getColumns(), + TEST_TABLE.getRows().filter((_, index) => !rowsToDrop.includes(index)), + ), + ); + }); + + it('can handle index out of range gracefully', () => { + const rowsToDrop = [-1, 1, 5]; + const newTable = TEST_TABLE.dropRows(rowsToDrop); + + expect(newTable).toEqual( + new TransformTable( + TEST_TABLE.getColumns(), + TEST_TABLE.getRows().filter((_, index) => !rowsToDrop.includes(index)), + ), + ); + }); + + it('can drop in any order', () => { + const rowsToDrop = [2, 1]; + const newTable = TEST_TABLE.dropRows(rowsToDrop); + + expect(newTable).toEqual( + new TransformTable( + TEST_TABLE.getColumns(), + TEST_TABLE.getRows().filter((_, index) => !rowsToDrop.includes(index)), + ), + ); + }); + }); + + describe('dropColumns', () => { + it('can drop columns', () => { + const columnsToDrop = ['name', 'age']; + const newTable = TEST_TABLE.dropColumns(columnsToDrop); + + expect(newTable).toEqual( + new TransformTable( + TEST_TABLE.getColumns().filter(columnName => !columnsToDrop.includes(columnName)), + TEST_TABLE.getRows().map(row => + Object.fromEntries( + Object.entries(row).filter(([columnName]) => !columnsToDrop.includes(columnName)), + ), + ), + ), + ); + }); + + it('can handle unknown columns gracefully', () => { + const newTable = TEST_TABLE.dropColumns(['not a real column']); + + expect(newTable).toEqual(TEST_TABLE); + }); + }); +}); diff --git a/packages/report-server/src/__tests__/reportBuilder/transform/transform.fixtures.ts b/packages/report-server/src/__tests__/reportBuilder/transform/transform.fixtures.ts index feb530608f..be01f1e719 100644 --- a/packages/report-server/src/__tests__/reportBuilder/transform/transform.fixtures.ts +++ b/packages/report-server/src/__tests__/reportBuilder/transform/transform.fixtures.ts @@ -129,3 +129,23 @@ export const PARSABLE_ANALYTICS = [ { period: '20200102', organisationUnit: 'PG', BCD1: 8 }, { period: '20200103', organisationUnit: 'PG', BCD1: 2 }, ]; + +export const DATE_COLUMNS = [ + { + organisationUnit: 'Tonga', + '2nd Aug 2022': 1, + '13th Jan 2022': 2, + 'Sep 2022': 3, + 'Q1 2021': 4, + }, +]; + +export const PERIOD_COLUMNS = [ + { + organisationUnit: 'Tonga', + '20220802': 1, + '2022W03': 2, + '202209': 3, + '2021Q1': 4, + }, +]; diff --git a/packages/report-server/src/__tests__/reportBuilder/transform/transform.test.ts b/packages/report-server/src/__tests__/reportBuilder/transform/transform.test.ts index a50a667e22..3f52c3fa09 100644 --- a/packages/report-server/src/__tests__/reportBuilder/transform/transform.test.ts +++ b/packages/report-server/src/__tests__/reportBuilder/transform/transform.test.ts @@ -4,7 +4,7 @@ */ import { MULTIPLE_ANALYTICS } from './transform.fixtures'; -import { buildTransform } from '../../../reportBuilder/transform'; +import { buildTransform, TransformTable } from '../../../reportBuilder/transform'; describe('transform', () => { it('throws an error for an unknown transform', () => { @@ -42,7 +42,9 @@ describe('transform', () => { exclude: '*', }, ]); - expect(transform(MULTIPLE_ANALYTICS)).toEqual([{ Total: 11 }]); + expect(transform(TransformTable.fromRows(MULTIPLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows([{ Total: 11 }]), + ); }); it('supports title and description in transforms', () => { @@ -72,6 +74,8 @@ describe('transform', () => { exclude: '*', }, ]); - expect(transform(MULTIPLE_ANALYTICS)).toEqual([{ Total: 11 }]); + expect(transform(TransformTable.fromRows(MULTIPLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows([{ Total: 11 }]), + ); }); }); diff --git a/packages/report-server/src/__tests__/reportBuilder/transform/updateColumns.test.ts b/packages/report-server/src/__tests__/reportBuilder/transform/updateColumns.test.ts index c6f7cc54fc..39dbe6cf0e 100644 --- a/packages/report-server/src/__tests__/reportBuilder/transform/updateColumns.test.ts +++ b/packages/report-server/src/__tests__/reportBuilder/transform/updateColumns.test.ts @@ -4,7 +4,7 @@ */ import { SINGLE_ANALYTIC, MULTIPLE_ANALYTICS, MERGEABLE_ANALYTICS } from './transform.fixtures'; -import { buildTransform } from '../../../reportBuilder/transform'; +import { buildTransform, TransformTable } from '../../../reportBuilder/transform'; describe('updateColumns', () => { it('can do nothing', () => { @@ -13,7 +13,9 @@ describe('updateColumns', () => { transform: 'updateColumns', }, ]); - expect(transform(SINGLE_ANALYTIC)).toEqual([{ ...SINGLE_ANALYTIC[0] }]); + expect(transform(TransformTable.fromRows(SINGLE_ANALYTIC))).toStrictEqual( + TransformTable.fromRows([{ ...SINGLE_ANALYTIC[0] }]), + ); }); it('can insert basic values', () => { @@ -27,9 +29,9 @@ describe('updateColumns', () => { }, }, ]); - expect(transform(SINGLE_ANALYTIC)).toEqual([ - { ...SINGLE_ANALYTIC[0], number: 1, string: 'Hi', boolean: false }, - ]); + expect(transform(TransformTable.fromRows(SINGLE_ANALYTIC))).toStrictEqual( + TransformTable.fromRows([{ ...SINGLE_ANALYTIC[0], number: 1, string: 'Hi', boolean: false }]), + ); }); it('can update a value from the row', () => { @@ -41,7 +43,9 @@ describe('updateColumns', () => { }, }, ]); - expect(transform(SINGLE_ANALYTIC)).toEqual([{ ...SINGLE_ANALYTIC[0], dataElementValue: 4 }]); + expect(transform(TransformTable.fromRows(SINGLE_ANALYTIC))).toStrictEqual( + TransformTable.fromRows([{ ...SINGLE_ANALYTIC[0], dataElementValue: 4 }]), + ); }); it('can use a value from the row as a column name', () => { @@ -53,7 +57,9 @@ describe('updateColumns', () => { }, }, ]); - expect(transform(SINGLE_ANALYTIC)).toEqual([{ ...SINGLE_ANALYTIC[0], BCD1: 4 }]); + expect(transform(TransformTable.fromRows(SINGLE_ANALYTIC))).toStrictEqual( + TransformTable.fromRows([{ ...SINGLE_ANALYTIC[0], BCD1: 4 }]), + ); }); it('can execute functions', () => { @@ -65,7 +71,9 @@ describe('updateColumns', () => { }, }, ]); - expect(transform(SINGLE_ANALYTIC)).toEqual([{ ...SINGLE_ANALYTIC[0], period: '1st Jan 2020' }]); + expect(transform(TransformTable.fromRows(SINGLE_ANALYTIC))).toStrictEqual( + TransformTable.fromRows([{ ...SINGLE_ANALYTIC[0], period: '1st Jan 2020' }]), + ); }); it('can include all remaining fields', () => { @@ -78,9 +86,11 @@ describe('updateColumns', () => { include: '*', }, ]); - expect(transform(SINGLE_ANALYTIC)).toEqual([ - { period: '1st Jan 2020', organisationUnit: 'TO', dataElement: 'BCD1', value: 4 }, - ]); + expect(transform(TransformTable.fromRows(SINGLE_ANALYTIC))).toStrictEqual( + TransformTable.fromRows([ + { period: '1st Jan 2020', organisationUnit: 'TO', dataElement: 'BCD1', value: 4 }, + ]), + ); }); it('can include selected remaining fields', () => { @@ -93,9 +103,9 @@ describe('updateColumns', () => { include: ['organisationUnit', 'value'], }, ]); - expect(transform(SINGLE_ANALYTIC)).toEqual([ - { period: '1st Jan 2020', organisationUnit: 'TO', value: 4 }, - ]); + expect(transform(TransformTable.fromRows(SINGLE_ANALYTIC))).toStrictEqual( + TransformTable.fromRows([{ organisationUnit: 'TO', value: 4, period: '1st Jan 2020' }]), + ); }); it('can exclude all remaining fields', () => { @@ -108,7 +118,9 @@ describe('updateColumns', () => { exclude: '*', }, ]); - expect(transform(SINGLE_ANALYTIC)).toEqual([{ period: '1st Jan 2020' }]); + expect(transform(TransformTable.fromRows(SINGLE_ANALYTIC))).toStrictEqual( + TransformTable.fromRows([{ period: '1st Jan 2020' }]), + ); }); it('can exclude selected remaining fields', () => { @@ -121,7 +133,9 @@ describe('updateColumns', () => { exclude: ['organisationUnit', 'value'], }, ]); - expect(transform(SINGLE_ANALYTIC)).toEqual([{ period: '1st Jan 2020', dataElement: 'BCD1' }]); + expect(transform(TransformTable.fromRows(SINGLE_ANALYTIC))).toStrictEqual( + TransformTable.fromRows([{ period: '1st Jan 2020', dataElement: 'BCD1' }]), + ); }); it('can perform the update on all rows', () => { @@ -135,11 +149,13 @@ describe('updateColumns', () => { include: ['organisationUnit'], }, ]); - expect(transform(MULTIPLE_ANALYTICS)).toEqual([ - { period: '1st Jan 2020', organisationUnit: 'TO', BCD1: 4 }, - { period: '2nd Jan 2020', organisationUnit: 'TO', BCD1: 2 }, - { period: '3rd Jan 2020', organisationUnit: 'TO', BCD1: 5 }, - ]); + expect(transform(TransformTable.fromRows(MULTIPLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows([ + { organisationUnit: 'TO', period: '1st Jan 2020', BCD1: 4 }, + { organisationUnit: 'TO', period: '2nd Jan 2020', BCD1: 2 }, + { organisationUnit: 'TO', period: '3rd Jan 2020', BCD1: 5 }, + ]), + ); }); it('where is processed before remaining fields', () => { @@ -153,19 +169,21 @@ describe('updateColumns', () => { include: ['period', 'organisationUnit'], }, ]); - expect(transform(MERGEABLE_ANALYTICS)).toEqual([ - { period: '20200101', organisationUnit: 'TO', newVal: 8 }, - { period: '20200102', organisationUnit: 'TO', newVal: 4 }, - { period: '20200103', organisationUnit: 'TO', newVal: 10 }, - { period: '20200101', organisationUnit: 'TO', BCD2: 11 }, - { period: '20200102', organisationUnit: 'TO', BCD2: 1 }, - { period: '20200103', organisationUnit: 'TO', BCD2: 0 }, - { period: '20200101', organisationUnit: 'PG', newVal: 14 }, - { period: '20200102', organisationUnit: 'PG', newVal: 16 }, - { period: '20200103', organisationUnit: 'PG', newVal: 4 }, - { period: '20200101', organisationUnit: 'PG', BCD2: 13 }, - { period: '20200102', organisationUnit: 'PG', BCD2: 99 }, - { period: '20200103', organisationUnit: 'PG', BCD2: -1 }, - ]); + expect(transform(TransformTable.fromRows(MERGEABLE_ANALYTICS))).toStrictEqual( + TransformTable.fromRows([ + { period: '20200101', organisationUnit: 'TO', newVal: 8 }, + { period: '20200102', organisationUnit: 'TO', newVal: 4 }, + { period: '20200103', organisationUnit: 'TO', newVal: 10 }, + { period: '20200101', organisationUnit: 'TO', BCD2: 11 }, + { period: '20200102', organisationUnit: 'TO', BCD2: 1 }, + { period: '20200103', organisationUnit: 'TO', BCD2: 0 }, + { period: '20200101', organisationUnit: 'PG', newVal: 14 }, + { period: '20200102', organisationUnit: 'PG', newVal: 16 }, + { period: '20200103', organisationUnit: 'PG', newVal: 4 }, + { period: '20200101', organisationUnit: 'PG', BCD2: 13 }, + { period: '20200102', organisationUnit: 'PG', BCD2: 99 }, + { period: '20200103', organisationUnit: 'PG', BCD2: -1 }, + ]), + ); }); }); diff --git a/packages/report-server/src/reportBuilder/output/functions/default.ts b/packages/report-server/src/reportBuilder/output/functions/default.ts deleted file mode 100644 index b58f052dca..0000000000 --- a/packages/report-server/src/reportBuilder/output/functions/default.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Tupaia - * Copyright (c) 2017 - 2021 Beyond Essential Systems Pty Ltd - */ - -import { Row } from '../../types'; - -export const buildDefault = () => { - return (rows: Row[]) => rows; -}; diff --git a/packages/report-server/src/reportBuilder/output/functions/matrix/matrix.ts b/packages/report-server/src/reportBuilder/output/functions/matrix/matrix.ts index 379fa7f05c..0a52f7816d 100644 --- a/packages/report-server/src/reportBuilder/output/functions/matrix/matrix.ts +++ b/packages/report-server/src/reportBuilder/output/functions/matrix/matrix.ts @@ -4,8 +4,8 @@ */ import { yup } from '@tupaia/utils'; +import { TransformTable } from '../../../transform'; -import { Row } from '../../../types'; import { MatrixBuilder } from './matrixBuilder'; import { Matrix, MatrixParams } from './types'; @@ -56,8 +56,8 @@ const paramsValidator = yup.object().shape( [['rowField', 'categoryField']], ); -const matrix = (rows: Row[], params: MatrixParams): Matrix => { - return new MatrixBuilder(rows, params).build(); +const matrix = (table: TransformTable, params: MatrixParams): Matrix => { + return new MatrixBuilder(table, params).build(); }; const buildParams = (params: unknown): MatrixParams => { @@ -76,5 +76,5 @@ const buildParams = (params: unknown): MatrixParams => { export const buildMatrix = (params: unknown) => { const builtParams = buildParams(params); - return (rows: Row[]) => matrix(rows, builtParams); + return (table: TransformTable) => matrix(table, builtParams); }; diff --git a/packages/report-server/src/reportBuilder/output/functions/matrix/matrixBuilder.ts b/packages/report-server/src/reportBuilder/output/functions/matrix/matrixBuilder.ts index 85f4605ce2..abed4fff85 100644 --- a/packages/report-server/src/reportBuilder/output/functions/matrix/matrixBuilder.ts +++ b/packages/report-server/src/reportBuilder/output/functions/matrix/matrixBuilder.ts @@ -1,4 +1,5 @@ import pick from 'lodash.pick'; +import { TransformTable } from '../../../transform'; import { Row } from '../../../types'; import { MatrixParams, Matrix } from './types'; @@ -11,12 +12,12 @@ const CATEGORY_FIELD_KEY = 'categoryId'; const NON_COLUMNS_KEYS = [CATEGORY_FIELD_KEY, ROW_FIELD_KEY]; export class MatrixBuilder { - private rows: Row[]; + private table: TransformTable; private matrixData: Matrix; private params: MatrixParams; - public constructor(rows: Row[], params: MatrixParams) { - this.rows = rows; + public constructor(table: TransformTable, params: MatrixParams) { + this.table = table; this.params = params; this.matrixData = { columns: [], rows: [] }; } @@ -40,15 +41,11 @@ export class MatrixBuilder { }; const getRemainingFieldsFromRows = (includeFields: string[], excludeFields: string[]) => { - const columns = new Set(); - this.rows.forEach(row => { - Object.keys(row).forEach(key => { - if (!excludeFields.includes(key) && !includeFields.includes(key)) { - columns.add(key); - } - }); - }); - return Array.from(columns); + return this.table + .getColumns() + .filter( + columnName => !excludeFields.includes(columnName) && !includeFields.includes(columnName), + ); }; const { includeFields, excludeFields } = this.params.columns; @@ -66,7 +63,7 @@ export class MatrixBuilder { const rows: Row[] = []; const { rowField, categoryField } = this.params.rows; - this.rows.forEach(row => { + this.table.getRows().forEach(row => { let newRows: Row; if (categoryField) { const { [rowField]: rowFieldData, [categoryField]: categoryId, ...restOfRow } = row; diff --git a/packages/report-server/src/reportBuilder/output/functions/outputBuilders.ts b/packages/report-server/src/reportBuilder/output/functions/outputBuilders.ts index ee22e29e96..1334cbdc34 100644 --- a/packages/report-server/src/reportBuilder/output/functions/outputBuilders.ts +++ b/packages/report-server/src/reportBuilder/output/functions/outputBuilders.ts @@ -4,7 +4,8 @@ */ import { Resolved } from '@tupaia/tsutils'; -import { buildDefault } from './default'; +import { buildRows } from './rows'; +import { buildRowsAndColumns } from './rowsAndColumns'; import { buildMatrix } from './matrix'; import { buildRawDataExport } from './rawDataExport'; @@ -15,5 +16,7 @@ export type OutputType = Resolved< export const outputBuilders = { matrix: buildMatrix, rawDataExport: buildRawDataExport, - default: buildDefault, + rowsAndColumns: buildRowsAndColumns, + rows: buildRows, + default: buildRows, }; diff --git a/packages/report-server/src/reportBuilder/output/functions/rawDataExport/rawDataExport.ts b/packages/report-server/src/reportBuilder/output/functions/rawDataExport/rawDataExport.ts index 5b7b43b4a7..a6480fa8ed 100644 --- a/packages/report-server/src/reportBuilder/output/functions/rawDataExport/rawDataExport.ts +++ b/packages/report-server/src/reportBuilder/output/functions/rawDataExport/rawDataExport.ts @@ -6,7 +6,7 @@ import { yup } from '@tupaia/utils'; import { ReportServerAggregator } from '../../../../aggregator'; -import { Row } from '../../../types'; +import { TransformTable } from '../../../transform'; import { OutputContext } from '../../types'; import { RawDataExportBuilder } from './rawDataExportBuilder'; import { RawDataExport, RawDataExportContext } from './types'; @@ -16,12 +16,12 @@ const contextValidator = yup.object().shape({ }); const rawDataExport = async ( - rows: Row[], + table: TransformTable, params: unknown, outputContext: RawDataExportContext, aggregator: ReportServerAggregator, ): Promise => { - return new RawDataExportBuilder(rows, params, outputContext, aggregator).build(); + return new RawDataExportBuilder(table, params, outputContext, aggregator).build(); }; const buildParams = (params: unknown): unknown => { @@ -37,6 +37,6 @@ export const buildRawDataExport = (params: unknown, outputContext: OutputContext const builtParams = buildParams(params); const builtOutputContext = buildContext(outputContext); - return (rows: Row[], aggregator: ReportServerAggregator) => - rawDataExport(rows, builtParams, builtOutputContext, aggregator); + return (table: TransformTable, aggregator: ReportServerAggregator) => + rawDataExport(table, builtParams, builtOutputContext, aggregator); }; diff --git a/packages/report-server/src/reportBuilder/output/functions/rawDataExport/rawDataExportBuilder.ts b/packages/report-server/src/reportBuilder/output/functions/rawDataExport/rawDataExportBuilder.ts index 9d22a7f0d1..bf3ccad7f7 100644 --- a/packages/report-server/src/reportBuilder/output/functions/rawDataExport/rawDataExportBuilder.ts +++ b/packages/report-server/src/reportBuilder/output/functions/rawDataExport/rawDataExportBuilder.ts @@ -4,23 +4,23 @@ */ import { ReportServerAggregator } from '../../../../aggregator'; -import { Row } from '../../../types'; +import { TransformTable } from '../../../transform'; import { RawDataExportContext, RawDataExport } from './types'; export class RawDataExportBuilder { - private rows: Row[]; + private table: TransformTable; private matrixData: RawDataExport; private params: unknown; private outputContext: RawDataExportContext; private aggregator: ReportServerAggregator; public constructor( - rows: Row[], + table: TransformTable, params: unknown, outputContext: RawDataExportContext, aggregator: ReportServerAggregator, ) { - this.rows = rows; + this.table = table; this.params = params; this.outputContext = outputContext; this.aggregator = aggregator; @@ -29,21 +29,13 @@ export class RawDataExportBuilder { public async build() { this.matrixData.columns = this.buildColumns(); - this.matrixData.rows = this.rows; + this.matrixData.rows = this.table.getRows(); await this.attachAllDataElementsToColumns(); return this.matrixData; } private buildColumns() { - const columns = new Set(); - this.rows.forEach(row => { - Object.keys(row).forEach(key => { - columns.add(key); - }); - }); - const allFields = Array.from(columns); - - return allFields.map(c => ({ key: c, title: c })); + return this.table.getColumns().map(columnName => ({ key: columnName, title: columnName })); } private async attachAllDataElementsToColumns() { diff --git a/packages/report-server/src/reportBuilder/output/functions/rows.ts b/packages/report-server/src/reportBuilder/output/functions/rows.ts new file mode 100644 index 0000000000..1b1775dbf3 --- /dev/null +++ b/packages/report-server/src/reportBuilder/output/functions/rows.ts @@ -0,0 +1,10 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2021 Beyond Essential Systems Pty Ltd + */ + +import { TransformTable } from '../../transform'; + +export const buildRows = () => { + return (table: TransformTable) => table.getRows(); +}; diff --git a/packages/report-server/src/reportBuilder/output/functions/rowsAndColumns.ts b/packages/report-server/src/reportBuilder/output/functions/rowsAndColumns.ts new file mode 100644 index 0000000000..34e7b11d91 --- /dev/null +++ b/packages/report-server/src/reportBuilder/output/functions/rowsAndColumns.ts @@ -0,0 +1,13 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2021 Beyond Essential Systems Pty Ltd + */ + +import { TransformTable } from '../../transform'; + +export const buildRowsAndColumns = () => { + return (table: TransformTable) => ({ + columns: table.getColumns(), + rows: table.getRows(), + }); +}; diff --git a/packages/report-server/src/reportBuilder/output/output.ts b/packages/report-server/src/reportBuilder/output/output.ts index a2300cd7b7..9bdf785b22 100644 --- a/packages/report-server/src/reportBuilder/output/output.ts +++ b/packages/report-server/src/reportBuilder/output/output.ts @@ -6,7 +6,7 @@ import { yup } from '@tupaia/utils'; import { ReportServerAggregator } from '../../aggregator'; -import { Row } from '../types'; +import { TransformTable } from '../transform'; import { outputBuilders } from './functions/outputBuilders'; import { OutputContext } from './types'; @@ -22,7 +22,7 @@ const paramsValidator = yup.object().shape({ }); const output = ( - rows: Row[], + table: TransformTable, params: OutputParams, outputContext: OutputContext, aggregator: ReportServerAggregator, @@ -30,7 +30,7 @@ const output = ( const { type, config } = params; const outputBuilder = outputBuilders[type](config, outputContext); - return outputBuilder(rows, aggregator); + return outputBuilder(table, aggregator); }; const buildParams = (params: unknown): OutputParams => { @@ -45,5 +45,5 @@ export const buildOutput = ( aggregator: ReportServerAggregator, ) => { const builtParams = buildParams(params); - return (rows: Row[]) => output(rows, builtParams, outputContext, aggregator); + return (table: TransformTable) => output(table, builtParams, outputContext, aggregator); }; diff --git a/packages/report-server/src/reportBuilder/reportBuilder.ts b/packages/report-server/src/reportBuilder/reportBuilder.ts index efb6725a36..b294a76d17 100644 --- a/packages/report-server/src/reportBuilder/reportBuilder.ts +++ b/packages/report-server/src/reportBuilder/reportBuilder.ts @@ -8,7 +8,7 @@ import { FetchReportQuery, StandardOrCustomReportConfig } from '../types'; import { configValidator } from './configValidator'; import { buildContext, ReqContext } from './context'; import { buildFetch, FetchResponse } from './fetch'; -import { buildTransform } from './transform'; +import { buildTransform, TransformTable } from './transform'; import { buildOutput } from './output'; import { Row } from './types'; import { OutputType } from './output/functions/outputBuilders'; @@ -64,7 +64,7 @@ export class ReportBuilder { const context = await buildContext(this.config.transform, this.reqContext, data, query); const transform = buildTransform(this.config.transform, context); - const transformedData = transform(data.results); + const transformedData = transform(TransformTable.fromRows(data.results)); const outputContext = { ...this.config.fetch }; const output = buildOutput(this.config.output, outputContext, aggregator); diff --git a/packages/report-server/src/reportBuilder/transform/aliases/entityMetadataAliases.ts b/packages/report-server/src/reportBuilder/transform/aliases/entityMetadataAliases.ts index 3802b70eb0..f32bcbc192 100644 --- a/packages/report-server/src/reportBuilder/transform/aliases/entityMetadataAliases.ts +++ b/packages/report-server/src/reportBuilder/transform/aliases/entityMetadataAliases.ts @@ -4,7 +4,7 @@ */ import { Context } from '../../context'; -import { Row } from '../../types'; +import { TransformTable } from '../table'; /** * [ @@ -18,7 +18,7 @@ import { Row } from '../../types'; * ] */ export const insertNumberOfFacilitiesColumn = { - transform: (context: Context) => (rows: Row[]) => { + transform: (context: Context) => (table: TransformTable) => { const { facilityCountByOrgUnit } = context; if (facilityCountByOrgUnit === undefined) { @@ -27,21 +27,23 @@ export const insertNumberOfFacilitiesColumn = { ); } - return rows.map(row => { - const { organisationUnit, ...restOfRow } = row; - + const organisationUnitValues = table.getColumnValues('organisationUnit'); + const numberOfFacilitiesColumnValues = organisationUnitValues.map(organisationUnit => { if (typeof organisationUnit !== 'string') { throw new Error( `'organisationUnit' type must be string, but got: ${typeof organisationUnit}`, ); } - return { - numberOfFacilities: facilityCountByOrgUnit[organisationUnit], - organisationUnit, - ...restOfRow, - }; + return facilityCountByOrgUnit[organisationUnit]; }); + + return table.upsertColumns([ + { + columnName: 'numberOfFacilities', + values: numberOfFacilitiesColumnValues, + }, + ]); }, dependencies: ['facilityCountByOrgUnit'], }; diff --git a/packages/report-server/src/reportBuilder/transform/aliases/keyValueByFieldAliases.ts b/packages/report-server/src/reportBuilder/transform/aliases/keyValueByFieldAliases.ts index 8a1f36b700..50301c84fd 100644 --- a/packages/report-server/src/reportBuilder/transform/aliases/keyValueByFieldAliases.ts +++ b/packages/report-server/src/reportBuilder/transform/aliases/keyValueByFieldAliases.ts @@ -3,37 +3,48 @@ * Copyright (c) 2017 - 2020 Beyond Essential Systems Pty Ltd */ -import { Row } from '../../types'; +import { TransformTable } from '../table'; + +const keyValueByField = (table: TransformTable, field: string) => { + const newColumns = new Set( + table.getColumns().filter(columnName => columnName !== field && columnName !== 'value'), + ); // Do this to preserve the existing column order + + const newRows = table + .getRows() + .map(({ [field]: valueOfField, value: valueOfValueColumn, ...restOfRow }) => { + const newRow = restOfRow; + if (valueOfField !== undefined && valueOfValueColumn !== undefined) { + const columnName = `${valueOfField}`; + newRow[columnName] = valueOfValueColumn; + newColumns.add(columnName); + } + return newRow; + }); + + return new TransformTable(Array.from(newColumns), newRows); +}; /** * { period: '20200101', organisationUnit: 'TO', dataElement: 'CH01', value: 7 } * => { period: '20200101', organisationUnit: 'TO', CH01: 7 } */ -export const keyValueByDataElementName = () => (rows: Row[]) => { - return rows.map(row => { - const { dataElement, value, ...restOfRow } = row; - return { [dataElement as string]: value, ...restOfRow }; - }); +export const keyValueByDataElementName = () => (table: TransformTable) => { + return keyValueByField(table, 'dataElement'); }; /** * { period: '20200101', organisationUnit: 'TO', dataElement: 'CH01', value: 7 } * => { period: '20200101', TO: 7, dataElement: 'CH01' } */ -export const keyValueByOrgUnit = () => (rows: Row[]) => { - return rows.map(row => { - const { organisationUnit, value, ...restOfRow } = row; - return { [organisationUnit as string]: value, ...restOfRow }; - }); +export const keyValueByOrgUnit = () => (table: TransformTable) => { + return keyValueByField(table, 'organisationUnit'); }; /** * { period: '20200101', organisationUnit: 'TO', dataElement: 'CH01', value: 7 } * => { 20200101: 7, organisationUnit: 'TO', dataElement: 'CH01' } */ -export const keyValueByPeriod = () => (rows: Row[]) => { - return rows.map(row => { - const { period, value, ...restOfRow } = row; - return { [period as string]: value, ...restOfRow }; - }); +export const keyValueByPeriod = () => (table: TransformTable) => { + return keyValueByField(table, 'period'); }; diff --git a/packages/report-server/src/reportBuilder/transform/aliases/summaryAliases.ts b/packages/report-server/src/reportBuilder/transform/aliases/summaryAliases.ts index a3cf20a48c..65cd15f62b 100644 --- a/packages/report-server/src/reportBuilder/transform/aliases/summaryAliases.ts +++ b/packages/report-server/src/reportBuilder/transform/aliases/summaryAliases.ts @@ -3,6 +3,7 @@ * Copyright (c) 2017 - 2020 Beyond Essential Systems Pty Ltd */ +import { TransformTable } from '../table'; import { Row } from '../../types'; /** @@ -14,17 +15,19 @@ import { Row } from '../../types'; * { facilityNameA: '100%', facilityNameB: '50%', facilityNameC: '0%', facilityNameD: '100%' }] */ -const detectColumnsToSummarise = (rows: Row[]) => { - const { columnsWithOnlyYorN: columnsToSummarise } = rows.reduce( +const detectColumnsToSummarise = (table: TransformTable) => { + const { columnsWithOnlyYorN: columnsToSummarise } = table.getRows().reduce( ({ columnsWithOnlyYorN, columnsWithOtherValues }, row) => { - Object.entries(row).forEach(([column, value]) => { - if (columnsWithOtherValues.has(column)) { + Object.entries(row).forEach(([columnName, value]) => { + if (columnsWithOtherValues.has(columnName)) { /* do nothing */ } else if (value === 'Y' || value === 'N') { - columnsWithOnlyYorN.add(column); + columnsWithOnlyYorN.add(columnName); + } else if (value === undefined) { + /* do nothing */ } else { - columnsWithOnlyYorN.delete(column); - columnsWithOtherValues.add(column); + columnsWithOnlyYorN.delete(columnName); + columnsWithOtherValues.add(columnName); } }); return { columnsWithOnlyYorN, columnsWithOtherValues }; @@ -39,41 +42,44 @@ const addPercentage = (numerator: number, denominator: number) => { return `${((numerator / denominator) * 100).toFixed(1)}%`; }; -const addSummaryColumn = (row: Row, columnsToSummarise: string[]) => { - const numerator = Object.entries(row).filter( - ([key, value]) => columnsToSummarise.includes(key) && value === 'N', - ).length; - const denominator = Object.entries(row).filter( - ([column, value]) => columnsToSummarise.includes(column) && (value === 'N' || value === 'Y'), - ).length; - const summaryColumn = addPercentage(numerator, denominator); - const updatedRow = row; - updatedRow.summaryColumn = summaryColumn; - return updatedRow; +const getSummaryColumn = (table: TransformTable, columnsToSummarise: string[]) => { + return table.getRows().map(row => { + const numerator = Object.entries(row).filter( + ([columnName, value]) => columnsToSummarise.includes(columnName) && value === 'N', + ).length; + const denominator = Object.entries(row).filter( + ([columnName, value]) => + columnsToSummarise.includes(columnName) && (value === 'N' || value === 'Y'), + ).length; + return addPercentage(numerator, denominator); + }); }; -const getSummaryRow = (rows: Row[], columnsToSummarise: string[]) => { - const arrayOfColumns = columnsToSummarise.map((column: string) => { - const { numerator, denominator } = rows.reduce( - (accumulator: Record, row: Row) => { - if (row[column] === 'N') { - accumulator.numerator += 1; - accumulator.denominator += 1; - } else if (row[column] === 'Y') { - accumulator.denominator += 1; - } - return accumulator; - }, - { numerator: 0, denominator: 0 }, - ); - return [column, addPercentage(numerator, denominator)]; - }); - return Object.fromEntries(arrayOfColumns); +const getSummaryRow = (table: TransformTable, columnsToSummarise: string[]) => { + return Object.fromEntries( + columnsToSummarise.map((columnName: string) => { + const { numerator, denominator } = table.getRows().reduce( + (accumulator: Record, row: Row) => { + if (row[columnName] === 'N') { + accumulator.numerator += 1; + accumulator.denominator += 1; + } else if (row[columnName] === 'Y') { + accumulator.denominator += 1; + } + return accumulator; + }, + { numerator: 0, denominator: 0 }, + ); + return [columnName, addPercentage(numerator, denominator)]; + }), + ); }; -export const insertSummaryRowAndColumn = () => (rows: Row[]) => { - const columnsToSummarise = detectColumnsToSummarise(rows); - const rowsWithSummaryColumn = rows.map(row => addSummaryColumn(row, columnsToSummarise)); - const summaryRow = getSummaryRow(rows, columnsToSummarise); - return [...rowsWithSummaryColumn, summaryRow]; +export const insertSummaryRowAndColumn = () => (table: TransformTable) => { + const columnsToSummarise = detectColumnsToSummarise(table); + const summaryColumn = getSummaryColumn(table, columnsToSummarise); + const summaryRow = getSummaryRow(table, columnsToSummarise); + return table + .upsertColumns([{ columnName: 'summaryColumn', values: summaryColumn }]) + .insertRows([{ row: summaryRow }]); }; diff --git a/packages/report-server/src/reportBuilder/transform/functions/excludeColumns.ts b/packages/report-server/src/reportBuilder/transform/functions/excludeColumns.ts index 043e6ef676..4c5072a797 100644 --- a/packages/report-server/src/reportBuilder/transform/functions/excludeColumns.ts +++ b/packages/report-server/src/reportBuilder/transform/functions/excludeColumns.ts @@ -4,41 +4,24 @@ */ import { yup } from '@tupaia/utils'; -import { Context } from '../../context'; -import { TransformParser } from '../parser'; -import { buildWhere } from './where'; -import { Row } from '../../types'; import { starSingleOrMultipleColumnsValidator } from './transformValidators'; import { getColumnMatcher } from './helpers'; +import { TransformTable } from '../table'; type ExcludeColumnsParams = { shouldIncludeColumn: (field: string) => boolean; - where: (parser: TransformParser) => boolean; }; export const paramsValidator = yup.object().shape({ columns: starSingleOrMultipleColumnsValidator, - where: yup.string(), }); -const excludeColumns = (rows: Row[], params: ExcludeColumnsParams, context: Context): Row[] => { - const parser = new TransformParser(rows, context); - return rows.map(row => { - const matchesWhere = params.where(parser); - if (!matchesWhere) { - parser.next(); - return row; - } - const newRow: Row = {}; - Object.entries(row).forEach(([field, value]) => { - if (params.shouldIncludeColumn(field)) { - newRow[field] = value; - } - }); - - parser.next(); - return newRow; - }); +const excludeColumns = (table: TransformTable, params: ExcludeColumnsParams) => { + const columnsToDelete = table + .getColumns() + .filter(columnName => !params.shouldIncludeColumn(columnName)); + + return table.dropColumns(columnsToDelete); }; const buildParams = (params: unknown): ExcludeColumnsParams => { @@ -50,10 +33,10 @@ const buildParams = (params: unknown): ExcludeColumnsParams => { const columnMatcher = getColumnMatcher(columns); const shouldIncludeColumn = (column: string) => !columnMatcher(column); - return { shouldIncludeColumn, where: buildWhere(params) }; + return { shouldIncludeColumn }; }; -export const buildExcludeColumns = (params: unknown, context: Context) => { +export const buildExcludeColumns = (params: unknown) => { const builtParams = buildParams(params); - return (rows: Row[]) => excludeColumns(rows, builtParams, context); + return (table: TransformTable) => excludeColumns(table, builtParams); }; diff --git a/packages/report-server/src/reportBuilder/transform/functions/excludeRows.ts b/packages/report-server/src/reportBuilder/transform/functions/excludeRows.ts index 4d7d6092e5..c9e4a90194 100644 --- a/packages/report-server/src/reportBuilder/transform/functions/excludeRows.ts +++ b/packages/report-server/src/reportBuilder/transform/functions/excludeRows.ts @@ -7,7 +7,7 @@ import { yup } from '@tupaia/utils'; import { TransformParser } from '../parser'; import { buildWhere } from './where'; import { Context } from '../../context'; -import { Row } from '../../types'; +import { TransformTable } from '../table'; type ExcludeRowsParams = { where: (parser: TransformParser) => boolean; @@ -17,13 +17,18 @@ export const paramsValidator = yup.object().shape({ where: yup.string(), }); -const excludeRows = (rows: Row[], params: ExcludeRowsParams, context: Context): Row[] => { - const parser = new TransformParser(rows, context); - return rows.filter(() => { - const filterResult = !params.where(parser); +const excludeRows = (table: TransformTable, params: ExcludeRowsParams, context: Context) => { + const parser = new TransformParser(table, context); + const rowsToDelete: number[] = []; + table.getRows().forEach((_, index) => { + const shouldDeleteRow = params.where(parser); parser.next(); - return filterResult; + if (shouldDeleteRow) { + rowsToDelete.push(index); + } }); + + return table.dropRows(rowsToDelete); }; const buildParams = (params: unknown): ExcludeRowsParams => { @@ -33,5 +38,5 @@ const buildParams = (params: unknown): ExcludeRowsParams => { export const buildExcludeRows = (params: unknown, context: Context) => { const builtParams = buildParams(params); - return (rows: Row[]) => excludeRows(rows, builtParams, context); + return (table: TransformTable) => excludeRows(table, builtParams, context); }; diff --git a/packages/report-server/src/reportBuilder/transform/functions/gatherColumns.ts b/packages/report-server/src/reportBuilder/transform/functions/gatherColumns.ts index ef532a2694..f3cc4e9605 100644 --- a/packages/report-server/src/reportBuilder/transform/functions/gatherColumns.ts +++ b/packages/report-server/src/reportBuilder/transform/functions/gatherColumns.ts @@ -4,7 +4,8 @@ */ import { yup } from '@tupaia/utils'; -import { Row, FieldValue } from '../../types'; +import { TransformTable } from '../table'; +import { Row } from '../../types'; import { getColumnMatcher } from './helpers'; import { gatherColumnsValidator } from './transformValidators'; @@ -16,25 +17,30 @@ export const paramsValidator = yup.object().shape({ keep: gatherColumnsValidator, }); -const gatherColumns = (rows: Row[], params: GatherColumnsParams): Row[] => { +const gatherColumns = (table: TransformTable, params: GatherColumnsParams) => { const { shouldKeepColumn } = params; - return rows + const columnNames = table.getColumns(); + const columnsToKeep = columnNames.filter(columnName => shouldKeepColumn(columnName)); + const columnsToGather = columnNames.filter(columnName => !shouldKeepColumn(columnName)); + const newColumnNames = [...columnsToKeep, 'value', 'columnName']; + + const newRowData = table + .getRows() .map(row => { - const keptFields: Record = {}; - const gatherFields: string[] = []; - - Object.entries(row).forEach(([key, value]) => { - if (shouldKeepColumn(key)) { - keptFields[key] = value; - } else { - gatherFields.push(key); - } - }); - - return gatherFields.map(key => ({ ...keptFields, value: row[key], columnName: key })); + const keptData: Row = Object.fromEntries( + columnsToKeep.map(columnName => [columnName, row[columnName]]), + ); + + return columnsToGather.map(columnName => ({ + ...keptData, + value: row[columnName], + columnName, + })); }) .flat(); + + return new TransformTable(newColumnNames, newRowData); }; const buildParams = (params: unknown): GatherColumnsParams => { @@ -53,5 +59,5 @@ const buildParams = (params: unknown): GatherColumnsParams => { export const buildGatherColumns = (params: unknown) => { const builtParams = buildParams(params); - return (rows: Row[]) => gatherColumns(rows, builtParams); + return (table: TransformTable) => gatherColumns(table, builtParams); }; diff --git a/packages/report-server/src/reportBuilder/transform/functions/index.ts b/packages/report-server/src/reportBuilder/transform/functions/index.ts index 7c77ccc403..690e945bf9 100644 --- a/packages/report-server/src/reportBuilder/transform/functions/index.ts +++ b/packages/report-server/src/reportBuilder/transform/functions/index.ts @@ -4,7 +4,6 @@ */ import { Context } from '../../context'; -import { Row } from '../../types'; import { buildInsertColumns, @@ -26,8 +25,13 @@ import { buildGatherColumns, paramsValidator as gatherColumnsParamsValidator, } from './gatherColumns'; +import { buildOrderColumns, orderColumnsSchema } from './orderColumns'; +import { TransformTable } from '../table'; -type TransformBuilder = (params: unknown, context: Context) => (rows: Row[]) => Row[]; +type TransformBuilder = ( + params: unknown, + context: Context, +) => (table: TransformTable) => TransformTable; export const transformBuilders: Record = { insertColumns: buildInsertColumns, @@ -38,6 +42,7 @@ export const transformBuilders: Record = { excludeRows: buildExcludeRows, insertRows: buildInsertRows, gatherColumns: buildGatherColumns, + orderColumns: buildOrderColumns, }; export const transformSchemas: Record< @@ -55,4 +60,5 @@ export const transformSchemas: Record< excludeRows: excludeRowsParamsValidator.describe(), insertRows: insertRowsParamsValidator.describe(), gatherColumns: gatherColumnsParamsValidator.describe(), + orderColumns: orderColumnsSchema, }; diff --git a/packages/report-server/src/reportBuilder/transform/functions/insertColumns.ts b/packages/report-server/src/reportBuilder/transform/functions/insertColumns.ts index 439783b676..f049d0c313 100644 --- a/packages/report-server/src/reportBuilder/transform/functions/insertColumns.ts +++ b/packages/report-server/src/reportBuilder/transform/functions/insertColumns.ts @@ -6,10 +6,11 @@ import { yup } from '@tupaia/utils'; import { Context } from '../../context'; -import { Row } from '../../types'; +import { FieldValue } from '../../types'; import { TransformParser } from '../parser'; import { buildWhere } from './where'; import { mapStringToStringValidator } from './transformValidators'; +import { TransformTable } from '../table'; type InsertColumnsParams = { columns: { [key: string]: string }; @@ -21,22 +22,36 @@ export const paramsValidator = yup.object().shape({ where: yup.string(), }); -const insertColumns = (rows: Row[], params: InsertColumnsParams, context: Context): Row[] => { - const parser = new TransformParser(rows, context); - return rows.map(row => { - const returnNewRow = params.where(parser); - if (!returnNewRow) { +const insertColumns = (table: TransformTable, params: InsertColumnsParams, context: Context) => { + const parser = new TransformParser(table, context); + const newColumns: Record = {}; + table.getRows().forEach((_, rowIndex) => { + const shouldEditThisRow = params.where(parser); + if (!shouldEditThisRow) { parser.next(); - return row; + return; } - const newRow: Row = { ...row }; + Object.entries(params.columns).forEach(([key, expression]) => { - newRow[parser.evaluate(key)] = parser.evaluate(expression); + const columnName = parser.evaluate(key); + const columnValue = parser.evaluate(expression); + if (!newColumns[columnName]) { + newColumns[columnName] = table.hasColumn(columnName) + ? table.getColumnValues(columnName) // Upserting a column, so fill with current column values + : new Array(table.length()).fill(undefined); // Creating a new column, so fill with undefined + } + newColumns[columnName].splice(rowIndex, 1, columnValue); }); parser.next(); - return newRow; }); + + const columnUpserts = Object.entries(newColumns).map(([columnName, values]) => ({ + columnName, + values, + })); + + return table.upsertColumns(columnUpserts); }; const buildParams = (params: unknown): InsertColumnsParams => { @@ -56,5 +71,5 @@ const buildParams = (params: unknown): InsertColumnsParams => { export const buildInsertColumns = (params: unknown, context: Context) => { const builtParams = buildParams(params); - return (rows: Row[]) => insertColumns(rows, builtParams, context); + return (table: TransformTable) => insertColumns(table, builtParams, context); }; diff --git a/packages/report-server/src/reportBuilder/transform/functions/insertRows.ts b/packages/report-server/src/reportBuilder/transform/functions/insertRows.ts index e08ce71c01..7752c3cfbf 100644 --- a/packages/report-server/src/reportBuilder/transform/functions/insertRows.ts +++ b/packages/report-server/src/reportBuilder/transform/functions/insertRows.ts @@ -4,13 +4,14 @@ */ import { yup } from '@tupaia/utils'; -import { yupTsUtils } from '@tupaia/tsutils'; +import { isDefined, yupTsUtils } from '@tupaia/tsutils'; import { Row } from '../../types'; import { Context } from '../../context'; import { TransformParser } from '../parser'; import { buildWhere } from './where'; import { mapStringToStringValidator } from './transformValidators'; +import { TransformTable } from '../table'; type InsertParams = { columns: { [key: string]: string }; @@ -37,31 +38,33 @@ export const paramsValidator = yup.object().shape({ }, [positionValidator]), }); -const insertRows = (rows: Row[], params: InsertParams, context: Context): Row[] => { - const returnArray = [...rows]; - const parser = new TransformParser(rows, context); - const rowsToInsert = returnArray.map(() => { - const shouldInsertNewRow = params.where(parser); - if (!shouldInsertNewRow) { +const insertRows = (table: TransformTable, params: InsertParams, context: Context) => { + const parser = new TransformParser(table, context); + let insertCount = 0; + const rowInserts = table + .getRows() + .map((_, index) => { + const shouldInsertNewRow = params.where(parser); + if (!shouldInsertNewRow) { + parser.next(); + return undefined; + } + const newRow: Row = {}; + Object.entries(params.columns).forEach(([key, expression]) => { + const columnName = parser.evaluate(key); + const value = parser.evaluate(expression); + newRow[columnName] = value; + }); + parser.next(); - return undefined; - } - const newRow: Row = {}; - Object.entries(params.columns).forEach(([key, expression]) => { - newRow[parser.evaluate(key)] = parser.evaluate(expression); - }); + return { + row: newRow, + index: params.positioner(index, insertCount++), + }; + }) + .filter(isDefined); - parser.next(); - return newRow; - }); - let insertCount = 0; - rowsToInsert.forEach((newRow, index) => { - if (newRow !== undefined) { - returnArray.splice(params.positioner(index, insertCount), 0, newRow); - insertCount++; - } - }); - return returnArray; + return table.insertRows(rowInserts); }; const buildParams = (params: unknown): InsertParams => { @@ -82,5 +85,5 @@ const buildParams = (params: unknown): InsertParams => { export const buildInsertRows = (params: unknown, context: Context) => { const builtParams = buildParams(params); - return (rows: Row[]) => insertRows(rows, builtParams, context); + return (table: TransformTable) => insertRows(table, builtParams, context); }; diff --git a/packages/report-server/src/reportBuilder/transform/functions/mergeRows/createGroupKey.ts b/packages/report-server/src/reportBuilder/transform/functions/mergeRows/createGroupKey.ts index 9d363209eb..8dca88aa96 100644 --- a/packages/report-server/src/reportBuilder/transform/functions/mergeRows/createGroupKey.ts +++ b/packages/report-server/src/reportBuilder/transform/functions/mergeRows/createGroupKey.ts @@ -15,6 +15,6 @@ export const buildCreateGroupKey = (groupBy: undefined | string | string[]) => { return `${row[groupBy]}`; } - return groupBy.map(field => row[field]).join('___'); + return groupBy.map(columnName => row[columnName]).join('___'); }; }; diff --git a/packages/report-server/src/reportBuilder/transform/functions/mergeRows/mergeRows.ts b/packages/report-server/src/reportBuilder/transform/functions/mergeRows/mergeRows.ts index 73eeafb60c..1cde67feda 100644 --- a/packages/report-server/src/reportBuilder/transform/functions/mergeRows/mergeRows.ts +++ b/packages/report-server/src/reportBuilder/transform/functions/mergeRows/mergeRows.ts @@ -10,10 +10,11 @@ import { Context } from '../../../context'; import { TransformParser } from '../../parser'; import { mergeStrategies } from './mergeStrategies'; import { buildWhere } from '../where'; -import { Row, FieldValue } from '../../../types'; +import { FieldValue, Row } from '../../../types'; import { buildCreateGroupKey } from './createGroupKey'; import { buildGetMergeStrategy } from './getMergeStrategy'; import { starSingleOrMultipleColumnsValidator } from '../transformValidators'; +import { TransformTable } from '../../table'; type MergeRowsParams = { createGroupKey: (row: Row) => string; @@ -61,15 +62,15 @@ export const paramsValidator = yup.object().shape({ * [{orgUnit: TO, BCD1: 4 }, {orgUnit: TO, BCD1: 7 }], groupBy orgUnit => group: { BCD1: [4, 7] } */ type Group = { - [fieldKey: string]: FieldValue[]; + [columnName: string]: FieldValue[]; }; -const groupRows = (rows: Row[], params: MergeRowsParams, context: Context) => { +const groupRows = (table: TransformTable, params: MergeRowsParams, context: Context) => { const groupsByKey: Record = {}; - const parser = new TransformParser(rows, context); + const parser = new TransformParser(table, context); const ungroupedRows: Row[] = []; // Rows that don't match the 'where' clause are left ungrouped - rows.forEach((row: Row) => { + table.getRows().forEach((row: Row) => { if (!params.where(parser)) { ungroupedRows.push(row); parser.next(); @@ -91,33 +92,34 @@ const addRowToGroup = (groupsByKey: Record, groupKey: string, row const group = groupsByKey[groupKey]; - Object.keys(row).forEach((field: string) => { - if (!group[field]) { + Object.entries(row).forEach(([columnName, value]) => { + if (!group[columnName]) { // eslint-disable-next-line no-param-reassign - group[field] = []; + group[columnName] = []; } - group[field].push(row[field]); + group[columnName].push(value); }); }; -const mergeGroups = (groups: Group[], params: MergeRowsParams): Row[] => { +const mergeGroups = (groups: Group[], params: MergeRowsParams) => { return groups.map(group => { const mergedRow: Row = {}; - Object.entries(group).forEach(([fieldKey, fieldValue]) => { - const mergeStrategy = params.getMergeStrategy(fieldKey); - const mergedValue = mergeStrategies[mergeStrategy](fieldValue); + Object.entries(group).forEach(([columnName, groupValues]) => { + const mergeStrategy = params.getMergeStrategy(columnName); + const mergedValue = mergeStrategies[mergeStrategy](groupValues); if (mergedValue !== undefined) { - mergedRow[fieldKey] = mergedValue; + mergedRow[columnName] = mergedValue; } }); return mergedRow; }); }; -const mergeRows = (rows: Row[], params: MergeRowsParams, context: Context): Row[] => { - const { groups, ungroupedRows } = groupRows(rows, params, context); +const mergeRows = (table: TransformTable, params: MergeRowsParams, context: Context) => { + const { groups, ungroupedRows } = groupRows(table, params, context); const mergedRows = mergeGroups(groups, params); - return mergedRows.concat(ungroupedRows); + const newRowData = mergedRows.concat(ungroupedRows); + return new TransformTable(table.getColumns(), newRowData); }; const buildParams = (params: unknown): MergeRowsParams => { @@ -134,5 +136,5 @@ const buildParams = (params: unknown): MergeRowsParams => { export const buildMergeRows = (params: unknown, context: Context) => { const builtParams = buildParams(params); - return (rows: Row[]) => mergeRows(rows, builtParams, context); + return (table: TransformTable) => mergeRows(table, builtParams, context); }; diff --git a/packages/report-server/src/reportBuilder/transform/functions/mergeRows/mergeStrategies.ts b/packages/report-server/src/reportBuilder/transform/functions/mergeRows/mergeStrategies.ts index 32c5813d54..8778e5d290 100644 --- a/packages/report-server/src/reportBuilder/transform/functions/mergeRows/mergeStrategies.ts +++ b/packages/report-server/src/reportBuilder/transform/functions/mergeRows/mergeStrategies.ts @@ -3,16 +3,9 @@ * Copyright (c) 2017 - 2020 Beyond Essential Systems Pty Ltd */ +import { isDefined, isNotNullish } from '@tupaia/tsutils'; import { FieldValue } from '../../../types'; -const isNotUndefined = (value: T): value is Exclude => { - return value !== undefined; -}; - -const isNotNullOrUndefined = (value: T): value is NonNullable => { - return value !== undefined && value !== null; -}; - // checkIsNum([1, 2, 3, null, undefined]) = 1, 2, 3] const checkIsNum = (values: FieldValue[]): number[] => { const assertIsNumber = (value: FieldValue): value is number => { @@ -21,7 +14,7 @@ const checkIsNum = (values: FieldValue[]): number[] => { } return true; }; - const filteredUndefinedAndNullValues = values.filter(isNotNullOrUndefined); + const filteredUndefinedAndNullValues = values.filter(isNotNullish); return filteredUndefinedAndNullValues.filter(assertIsNumber); }; @@ -42,19 +35,19 @@ const average = (values: FieldValue[]): number | undefined => { const checkedValues = checkIsNum(values); const numerator = sum(checkedValues); const denominator = count(checkedValues); - if (isNotUndefined(numerator) && denominator !== 0) { + if (isDefined(numerator) && denominator !== 0) { return numerator / denominator; } return undefined; }; const count = (values: FieldValue[]): number => { - return values.length; + return values.filter(isDefined).length; }; // helper of max() and min(), e.g.: customSort([1, 2, 3, null]) = [null, 1, 2, 3] const customSort = (values: FieldValue[]): FieldValue[] => { - const filteredUndefinedValues = values.filter(isNotUndefined); + const filteredUndefinedValues = values.filter(isDefined); return filteredUndefinedValues.sort((a, b) => { if (a === null) { return -1; @@ -79,7 +72,7 @@ const min = (values: FieldValue[]): FieldValue => { }; const unique = (values: FieldValue[]): FieldValue => { - const distinctValues = [...new Set(values.filter(isNotUndefined))]; + const distinctValues = [...new Set(values.filter(isDefined))]; return distinctValues.length === 1 ? distinctValues[0] : 'NO_UNIQUE_VALUE'; }; @@ -90,15 +83,15 @@ const exclude = (values: FieldValue[]): FieldValue => { }; const first = (values: FieldValue[]): FieldValue => { - return values.find(isNotUndefined); + return values.find(isDefined); }; const last = (values: FieldValue[]): FieldValue => { - return values.slice().reverse().find(isNotUndefined); + return values.slice().reverse().find(isDefined); }; const single = (values: FieldValue[]): FieldValue => { - const definedValues = values.filter(isNotUndefined); + const definedValues = values.filter(isDefined); if (definedValues.length === 0) { return undefined; } diff --git a/packages/report-server/src/reportBuilder/transform/functions/orderColumns/index.ts b/packages/report-server/src/reportBuilder/transform/functions/orderColumns/index.ts new file mode 100644 index 0000000000..c5709eb14e --- /dev/null +++ b/packages/report-server/src/reportBuilder/transform/functions/orderColumns/index.ts @@ -0,0 +1,6 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2022 Beyond Essential Systems Pty Ltd + */ + +export { buildOrderColumns, orderColumnsSchema } from './orderColumns'; diff --git a/packages/report-server/src/reportBuilder/transform/functions/orderColumns/orderColumns.ts b/packages/report-server/src/reportBuilder/transform/functions/orderColumns/orderColumns.ts new file mode 100644 index 0000000000..dc17838a62 --- /dev/null +++ b/packages/report-server/src/reportBuilder/transform/functions/orderColumns/orderColumns.ts @@ -0,0 +1,105 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2022 Beyond Essential Systems Pty Ltd + */ + +import { isDefined } from '@tupaia/tsutils'; +import { yup } from '@tupaia/utils'; +import { TransformTable } from '../../table'; +import { sortByFunctions } from './sortByFunctions'; + +type OrderColumnsParams = { + columnOrder?: string[]; + sorter?: (column1: string, column2: string) => number; +}; + +export const paramsValidator = yup.object().shape({ + order: yup.array().of(yup.string().required()), + sortBy: yup + .mixed() + .oneOf(Object.keys(sortByFunctions) as (keyof typeof sortByFunctions)[]), +}); + +const orderColumns = (table: TransformTable, params: OrderColumnsParams) => { + const { columnOrder = ['*'], sorter } = params; + + if (sorter) { + return new TransformTable(table.getColumns().sort(sorter), table.getRows()); + } + + if (!columnOrder.includes('*')) { + columnOrder.push('*'); // Add wildcard to the end if it's not specified + } + + const existingColumns = table.getColumns(); + const newColumns = new Array(existingColumns.length).fill(undefined); + const orderedColumns = existingColumns.filter(column => columnOrder.includes(column)); + const wildcardColumns = existingColumns.filter(column => !columnOrder.includes(column)); + + // Filter out columns that are not in the table + const order = columnOrder.filter(column => existingColumns.includes(column) || column === '*'); + const indexOfWildcard = order.indexOf('*'); + + wildcardColumns.forEach((column, index) => { + newColumns[indexOfWildcard + index] = column; + }); + + orderedColumns.forEach(column => { + const indexInOrder = order.indexOf(column); + const indexInNewColumns = + indexInOrder < indexOfWildcard + ? indexInOrder // It's before the wildcard, just use the index + : indexInOrder + orderedColumns.length - 1; // It's after the wildcard, so account for all wildcard columns (minus wildcard itself) + newColumns[indexInNewColumns] = column; + }); + + const validatedColumns = newColumns.map(column => { + if (!isDefined(column)) { + throw new Error('Missing columns when determining new column order'); + } + + return column; + }); + + return new TransformTable(validatedColumns, table.getRows()); +}; + +const buildParams = (params: unknown): OrderColumnsParams => { + const validatedParams = paramsValidator.validateSync(params); + + const { order, sortBy } = validatedParams; + + if (order && sortBy) { + throw new Error('Cannot provide explicit column order and a sort by function'); + } + + if (order) { + return { + columnOrder: Array.from(new Set(order)), + }; + } + + if (sortBy) { + return { + sorter: sortByFunctions[sortBy], + }; + } + + throw new Error('Must provide either explicit column order or a sort by function'); +}; + +export const buildOrderColumns = (params: unknown) => { + const builtParams = buildParams(params); + return (table: TransformTable) => orderColumns(table, builtParams); +}; + +export const orderColumnsSchema = { + ...paramsValidator.describe(), + fields: { + ...paramsValidator.describe().fields, + // Override sortBy describe, as viz-builder doesn't support mixed schemas + sortBy: { + enum: Object.keys(sortByFunctions), + }, + }, +}; diff --git a/packages/report-server/src/reportBuilder/transform/functions/orderColumns/sortByFunctions.ts b/packages/report-server/src/reportBuilder/transform/functions/orderColumns/sortByFunctions.ts new file mode 100644 index 0000000000..82e656428a --- /dev/null +++ b/packages/report-server/src/reportBuilder/transform/functions/orderColumns/sortByFunctions.ts @@ -0,0 +1,36 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2022 Beyond Essential Systems Pty Ltd + */ + +import { Moment } from 'moment'; +import { displayStringToMoment, periodToMoment, isInvalidMoment } from '@tupaia/utils'; + +const alphabetic = (column1: string, column2: string) => column1.localeCompare(column2); + +const sortMoment = (moment1: Moment, moment2: Moment) => { + if (isInvalidMoment(moment1) && isInvalidMoment(moment2)) return 0; + if (isInvalidMoment(moment1)) return 1; + if (isInvalidMoment(moment2)) return -1; + if (moment1.isSame(moment2)) return 0; + if (moment1.isBefore(moment2)) return -1; + return 1; +}; + +const date = (column1: string, column2: string) => { + const col1Moment = displayStringToMoment(column1); + const col2Moment = displayStringToMoment(column2); + return sortMoment(col1Moment, col2Moment); +}; + +const period = (column1: string, column2: string) => { + const col1Moment = periodToMoment(column1); + const col2Moment = periodToMoment(column2); + return sortMoment(col1Moment, col2Moment); +}; + +export const sortByFunctions = { + alphabetic, + date, + period, +}; diff --git a/packages/report-server/src/reportBuilder/transform/functions/sortRows.ts b/packages/report-server/src/reportBuilder/transform/functions/sortRows.ts index 0fd9341578..b43283949d 100644 --- a/packages/report-server/src/reportBuilder/transform/functions/sortRows.ts +++ b/packages/report-server/src/reportBuilder/transform/functions/sortRows.ts @@ -6,9 +6,10 @@ import { yup, orderBy } from '@tupaia/utils'; import { yupTsUtils } from '@tupaia/tsutils'; -import { TransformParser } from '../parser'; import { Row } from '../../types'; import { starSingleOrMultipleColumnsValidator } from './transformValidators'; +import { TransformTable } from '../table'; +import { TransformParser } from '../parser'; type SortParams = { by: string | string[]; @@ -76,16 +77,20 @@ const getCustomRowSortFunction = (expression: string, direction: 'asc' | 'desc') }; }; -const sortRows = (rows: Row[], params: SortParams): Row[] => { +const sortRows = (table: TransformTable, params: SortParams) => { const { by, direction } = params; + if (typeof by === 'string' && TransformParser.isExpression(by)) { const firstDirection = Array.isArray(direction) ? direction[0] : direction; - return rows.sort(getCustomRowSortFunction(by, firstDirection)); + const sortedRows = table.getRows().sort(getCustomRowSortFunction(by, firstDirection)); + return new TransformTable(table.getColumns(), sortedRows); } const arrayBy = typeof by === 'string' ? [by] : by; const arrayDirection = typeof direction === 'string' ? [direction] : direction; - return orderBy(rows, arrayBy, arrayDirection); + const sortedRows = orderBy(table.getRows(), arrayBy, arrayDirection); + + return new TransformTable(table.getColumns(), sortedRows); }; const buildParams = (params: unknown): SortParams => { @@ -105,5 +110,5 @@ const buildParams = (params: unknown): SortParams => { export const buildSortRows = (params: unknown) => { const builtSortParams = buildParams(params); - return (rows: Row[]) => sortRows(rows, builtSortParams); + return (table: TransformTable) => sortRows(table, builtSortParams); }; diff --git a/packages/report-server/src/reportBuilder/transform/functions/updateColumns.ts b/packages/report-server/src/reportBuilder/transform/functions/updateColumns.ts index 0669eb8b4b..d86ebff0a7 100644 --- a/packages/report-server/src/reportBuilder/transform/functions/updateColumns.ts +++ b/packages/report-server/src/reportBuilder/transform/functions/updateColumns.ts @@ -6,7 +6,7 @@ import { yup } from '@tupaia/utils'; import { Context } from '../../context'; -import { Row } from '../../types'; +import { FieldValue, Row } from '../../types'; import { TransformParser } from '../parser'; import { buildWhere } from './where'; import { @@ -14,6 +14,7 @@ import { starSingleOrMultipleColumnsValidator, } from './transformValidators'; import { getColumnMatcher } from './helpers'; +import { TransformTable } from '../table'; type UpdateColumnsParams = { insert: { [key: string]: string }; @@ -28,33 +29,54 @@ export const paramsValidator = yup.object().shape({ where: yup.string(), }); -const updateColumns = (rows: Row[], params: UpdateColumnsParams, context: Context): Row[] => { - const parser = new TransformParser(rows, context); - return rows.map(row => { - const returnNewRow = params.where(parser); - if (!returnNewRow) { +const updateColumns = (table: TransformTable, params: UpdateColumnsParams, context: Context) => { + const parser = new TransformParser(table, context); + const newColumns: Record = {}; + const skippedRows: Record = {}; + const columnNames = table.getColumns(); + table.getRows().forEach((row, rowIndex) => { + const shouldEditThisRow = params.where(parser); + if (!shouldEditThisRow) { + skippedRows[rowIndex] = row; parser.next(); - return row; + return; } - const newRow: Row = {}; - Object.entries(params.insert).forEach(([key, expression]) => { - newRow[parser.evaluate(key)] = parser.evaluate(expression); - }); - Object.entries(row).forEach(([field, value]) => { - if (field in newRow) { - // Field already defined - return; - } - - if (params.shouldIncludeColumn(field)) { - newRow[field] = value; + Object.entries(params.insert).forEach(([key, expression]) => { + const columnName = parser.evaluate(key); + const columnValue = parser.evaluate(expression); + if (!newColumns[columnName]) { + newColumns[columnName] = table.hasColumn(columnName) + ? table.getColumnValues(columnName) // Upserting a column, so fill with current column values + : new Array(table.length()).fill(undefined); // Creating a new column, so fill with undefined } + newColumns[columnName].splice(rowIndex, 1, columnValue); }); parser.next(); - return newRow; }); + + // Drop columns that should no longer be in the table + const columnsToDelete = columnNames.filter(columnName => !params.shouldIncludeColumn(columnName)); + + // Insert the new columns + const columnUpserts = Object.entries(newColumns).map(([columnName, values]) => ({ + columnName, + values, + })); + + // Drop, then re-insert the original skipped rows + const rowsToDrop = Object.keys(skippedRows).map(rowIndexString => parseInt(rowIndexString)); + const rowReinserts = Object.entries(skippedRows).map(([rowIndexString, row]) => ({ + row, + index: parseInt(rowIndexString), + })); + + return table + .dropColumns(columnsToDelete) + .upsertColumns(columnUpserts) + .dropRows(rowsToDrop) + .insertRows(rowReinserts); }; const buildParams = (params: unknown): UpdateColumnsParams => { @@ -80,5 +102,5 @@ const buildParams = (params: unknown): UpdateColumnsParams => { export const buildUpdateColumns = (params: unknown, context: Context) => { const builtParams = buildParams(params); - return (rows: Row[]) => updateColumns(rows, builtParams, context); + return (table: TransformTable) => updateColumns(table, builtParams, context); }; diff --git a/packages/report-server/src/reportBuilder/transform/index.ts b/packages/report-server/src/reportBuilder/transform/index.ts index 40cb439c78..9a14b83f15 100644 --- a/packages/report-server/src/reportBuilder/transform/index.ts +++ b/packages/report-server/src/reportBuilder/transform/index.ts @@ -5,4 +5,5 @@ export { buildTransform } from './transform'; export { contextFunctionDependencies, TransformParser } from './parser'; +export { TransformTable } from './table'; export { contextAliasDependencies } from './aliases'; diff --git a/packages/report-server/src/reportBuilder/transform/parser/TransformParser.ts b/packages/report-server/src/reportBuilder/transform/parser/TransformParser.ts index 05bfd39a25..c64ad8d660 100644 --- a/packages/report-server/src/reportBuilder/transform/parser/TransformParser.ts +++ b/packages/report-server/src/reportBuilder/transform/parser/TransformParser.ts @@ -6,6 +6,7 @@ import { ExpressionParser } from '@tupaia/expression-parser'; import { Context } from '../../context'; +import { TransformTable } from '../table'; import { Row, FieldValue } from '../../types'; import { customFunctions, @@ -49,10 +50,10 @@ export class TransformParser extends ExpressionParser { // eslint-disable-next-line react/static-property-placement private context?: Context; - public constructor(rows: Row[] = [], context?: Context) { + public constructor(table: TransformTable = new TransformTable(), context?: Context) { super(new TransformScope()); - this.rows = rows; + this.rows = table.getRows(); this.lookups = { params: context?.query || {}, current: {}, @@ -64,7 +65,7 @@ export class TransformParser extends ExpressionParser { table: this.rows, }; - if (rows.length > 0) { + if (this.rows.length > 0) { this.lookups.current = this.rows[this.currentRow]; this.lookups.next = this.rows[this.currentRow + 1] || {}; this.rows.forEach(row => addRowToLookup(row, this.lookups.all)); diff --git a/packages/report-server/src/reportBuilder/transform/parser/functions/utils.ts b/packages/report-server/src/reportBuilder/transform/parser/functions/utils.ts index 7959f8b01e..cbabfb4731 100644 --- a/packages/report-server/src/reportBuilder/transform/parser/functions/utils.ts +++ b/packages/report-server/src/reportBuilder/transform/parser/functions/utils.ts @@ -15,7 +15,7 @@ export const convertToPeriod = (period: string, targetType: string): string => { return baseConvertToPeriod(period, targetType); }; -export const periodToTimestamp = (period: string): string => { +export const periodToTimestamp = (period: string): number => { return basePeriodToTimestamp(period); }; diff --git a/packages/report-server/src/reportBuilder/transform/table/TransformTable.ts b/packages/report-server/src/reportBuilder/transform/table/TransformTable.ts new file mode 100644 index 0000000000..829ef15a52 --- /dev/null +++ b/packages/report-server/src/reportBuilder/transform/table/TransformTable.ts @@ -0,0 +1,213 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2021 Beyond Essential Systems Pty Ltd + */ + +import { FieldValue, Row } from '../../types'; + +/** + * Data structure for managing tabular data during report building process. + * + * Designed to be immutable, so that operations on the table return a new copy of the table + * with the operations applied (rather than mutating the original table) + * + * Operations on table are all bulk operations for efficiency reasons + */ +export class TransformTable { + private readonly columns: string[]; + private readonly rows: Row[]; + + public constructor(columns: string[] = [], rows: Row[] = []) { + this.columns = columns; + this.rows = rows; + } + + /** + * Convenience constructor. Often we find ourselves wanting to convert an array of objects + * into a TransformTable + */ + public static fromRows(rowObjects: Row[], columnOrder?: string[]) { + const columnsInRows = columnOrder || Array.from(new Set(rowObjects.map(Object.keys).flat())); + return new TransformTable(columnsInRows, rowObjects); + } + + public getColumns() { + return [...this.columns]; // Spread to protect a caller mutating the original array + } + + public getRows() { + return this.rows.map(row => ({ ...row })); // Spread to protect a caller mutating the original array + } + + public length() { + return this.rows.length; + } + + public width() { + return this.columns.length; + } + + public indexOfColumn(columnName: string) { + return this.columns.indexOf(columnName); + } + + public hasColumn(columnName: string) { + return this.indexOfColumn(columnName) > -1; + } + + public hasIndex(index: number) { + return index > -1 && index < this.length(); + } + + public getColumnValues(columnName: string) { + if (!this.hasColumn(columnName)) { + throw new Error( + `Cannot get values for column name: ${columnName}, it does not exist in the table`, + ); + } + + return this.rows.map(row => row[columnName]); + } + + /** + * + * @returns A new TransformTable with the rows inserted + */ + public insertRows(inserts: { row: Row; index?: number }[]) { + if (inserts.length === 0) { + return this; + } + + const newTable = this.clone(); + inserts.forEach(({ row, index = newTable.length() }) => { + if (index !== newTable.length() && !newTable.hasIndex(index)) { + throw new Error( + `Error inserting row, index must be within 0:${newTable.length()}, but was ${index}`, + ); + } + + const newRowWithExistingColumns: Row = {}; + const newRowWithNewColumns: Row = {}; + Object.entries(row).forEach(([columnName, value]) => { + if (newTable.hasColumn(columnName)) { + newRowWithExistingColumns[columnName] = value; + } else { + newRowWithNewColumns[columnName] = value; + } + }); + + // STEP 1: Insert a row with values for all existing columns + newTable.rows.splice(index, 0, newRowWithExistingColumns); + + // STEP 2: Insert a column for each new column being created in this row + Object.entries(newRowWithNewColumns).forEach(([columnName, value]) => { + // Create a new column with all values being undefined except the new row value + const newColumnWithExistingRows = new Array(newTable.length()) + .fill(0) + .map((_, rowIndex) => (rowIndex === index ? value : undefined)); + newTable.writeColumnToTable(columnName, newColumnWithExistingRows); + }); + }); + + return newTable; + } + + /** + * + * @returns A new TransformTable with the columns upserted + */ + public upsertColumns(upserts: { columnName: string; values: FieldValue[] }[]) { + if (upserts.length === 0) { + return this; + } + + const newTable = this.clone(); + upserts.forEach(({ columnName, values }) => newTable.writeColumnToTable(columnName, values)); + + return newTable; + } + + /** + * Upserts the column to the table. Mutates the existing table. For internal use only + * @param columnName to edit/create + * @param values column values + */ + private writeColumnToTable(columnName: string, values: FieldValue[]) { + if (values.length !== this.length()) { + throw new Error( + `Error upserting column, incorrect column length (required: ${this.length()}, but got: ${ + values.length + }`, + ); + } + + const isExistingColumn = this.hasColumn(columnName); + if (!isExistingColumn) { + this.columns.push(columnName); + } + + values.forEach((value, index) => { + if (isExistingColumn) { + delete this.rows[index][columnName]; + } + if (value !== undefined) { + this.rows[index][columnName] = value; + } + }); + } + + /** + * @returns A new TransformTable with the columns dropped + */ + public dropColumns(columnNames: string[]) { + if (columnNames.length === 0) { + return this; + } + + const newTable = this.clone(); + columnNames.forEach(columnName => { + if (!newTable.hasColumn(columnName)) { + // Column does not exist, so do nothing + return; + } + + const indexOfColumn = newTable.indexOfColumn(columnName); + newTable.columns.splice(indexOfColumn, 1); + newTable.rows.forEach(row => { + // eslint-disable-next-line no-param-reassign + delete row[columnName]; + }); + }); + + return newTable; + } + + /** + * @param indexes indexes to drop + * @returns A new TransformTable with the rows dropped + */ + public dropRows(indexes: number[]) { + if (indexes.length === 0) { + return this; + } + + const newTable = this.clone(); + const sortDescending = (num1: number, num2: number) => num2 - num1; + [...indexes].sort(sortDescending).forEach(index => { + if (!newTable.hasIndex(index)) { + // Row does not exist, so do nothing + return; + } + + newTable.rows.splice(index, 1); + }); + return newTable; + } + + private clone() { + return new TransformTable( + [...this.columns], + this.rows.map(row => ({ ...row })), + ); + } +} diff --git a/packages/report-server/src/reportBuilder/transform/table/index.ts b/packages/report-server/src/reportBuilder/transform/table/index.ts new file mode 100644 index 0000000000..cbc06ca934 --- /dev/null +++ b/packages/report-server/src/reportBuilder/transform/table/index.ts @@ -0,0 +1,6 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2022 Beyond Essential Systems Pty Ltd + */ + +export { TransformTable } from './TransformTable'; diff --git a/packages/report-server/src/reportBuilder/transform/transform.ts b/packages/report-server/src/reportBuilder/transform/transform.ts index ca79a5077b..0b06083334 100644 --- a/packages/report-server/src/reportBuilder/transform/transform.ts +++ b/packages/report-server/src/reportBuilder/transform/transform.ts @@ -5,15 +5,15 @@ import { yup } from '@tupaia/utils'; -import { Row } from '../types'; import { Context } from '../context'; import { transformBuilders } from './functions'; import { aliases } from './aliases'; +import { TransformTable } from './table'; type BuiltTransformParams = { title?: string; name: string; - apply: (rows: Row[]) => Row[]; + apply: (table: TransformTable) => TransformTable; }; const transformParamsValidator = yup.lazy((value: unknown) => { @@ -36,18 +36,18 @@ const transformParamsValidator = yup.lazy((value: unknown) => { const paramsValidator = yup.array().required(); -const transform = (rows: Row[], transformSteps: BuiltTransformParams[]): Row[] => { - let transformedRows: Row[] = rows; +const transform = (table: TransformTable, transformSteps: BuiltTransformParams[]) => { + let transformedTable: TransformTable = table; transformSteps.forEach((transformStep: BuiltTransformParams, index: number) => { try { - transformedRows = transformStep.apply(transformedRows); + transformedTable = transformStep.apply(transformedTable); } catch (e) { const titlePart = transformStep.title ? ` (${transformStep.title})` : ''; const errorMessagePrefix = `Error in transform[${index + 1}]${titlePart}: `; throw new Error(`${errorMessagePrefix}${(e as Error).message}`); } }); - return transformedRows; + return transformedTable; }; const buildParams = (params: unknown, context: Context): BuiltTransformParams => { @@ -77,5 +77,5 @@ export const buildTransform = (params: unknown, context: Context = {}) => { const validatedParams = paramsValidator.validateSync(params); const builtParams = validatedParams.map(param => buildParams(param, context)); - return (rows: Row[]) => transform(rows, builtParams); + return (table: TransformTable) => transform(table, builtParams); }; diff --git a/packages/tsutils/src/index.ts b/packages/tsutils/src/index.ts index 01841c8cf0..7a2a9c6f3f 100644 --- a/packages/tsutils/src/index.ts +++ b/packages/tsutils/src/index.ts @@ -1,4 +1,5 @@ -export * from './validation'; -export * from './types'; export * from './downloadPageAsPDF'; export * from './hashStringToInt'; +export * from './typeGuards'; +export * from './types'; +export * from './validation'; diff --git a/packages/tsutils/src/typeGuards.ts b/packages/tsutils/src/typeGuards.ts new file mode 100644 index 0000000000..5ed3c05fbe --- /dev/null +++ b/packages/tsutils/src/typeGuards.ts @@ -0,0 +1,9 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2022 Beyond Essential Systems Pty Ltd + */ + +export const isDefined = (value: T): value is Exclude => value !== undefined; + +export const isNotNullish = (val: T): val is Exclude => + val !== undefined && val !== null; diff --git a/packages/utils/src/period/period.js b/packages/utils/src/period/period.js index c1bd7b402b..befc3a1af0 100644 --- a/packages/utils/src/period/period.js +++ b/packages/utils/src/period/period.js @@ -4,6 +4,8 @@ */ import get from 'lodash.get'; +// eslint-disable-next-line no-unused-vars +import { Moment } from 'moment'; // Used in jsdoc import { utcMoment } from '../datetime'; import { reduceToDictionary } from '../object'; @@ -154,11 +156,23 @@ export const parsePeriodType = periodTypeString => { return periodType; }; +/** + * Parse period into a moment object + * @param {string} period + * @returns {Moment} moment object + */ export const periodToMoment = period => { const periodType = periodToType(period); return utcMoment(period, periodTypeToFormat(periodType)); }; +/** + * Checks if moment is invalid + * @param {Moment} moment + * @returns + */ +export const isInvalidMoment = moment => moment.format().toUpperCase() === 'INVALID DATE'; + export const periodToDateString = (period, isEndPeriod) => { const mutatingMoment = periodToMoment(period); const periodType = periodToType(period); @@ -193,6 +207,32 @@ export const periodToDisplayString = (period, targetType) => { return periodToMoment(period).format(periodTypeToDisplayFormat(formattedPeriodType)); }; +/** + * Parse display string into a moment object + * @param {string} displayString + * @param {string} [targetType] + * @returns {Moment} moment object + */ +export const displayStringToMoment = (displayString, targetType) => { + const validatedTargetType = targetType ? parsePeriodType(targetType) : null; + if (validatedTargetType) { + return utcMoment(displayString, PERIOD_TYPES[validatedTargetType].displayFormat, true); + } + + const allDisplayFormats = Object.values(PERIOD_TYPE_CONFIG).map( + ({ displayFormat }) => displayFormat, + ); + + for (let i = 0; i < allDisplayFormats.length; i++) { + const moment = utcMoment(displayString, allDisplayFormats[i], true); + if (!isInvalidMoment(moment)) { + return moment; + } + } + + return utcMoment(displayString); +}; + /** * @param {string} period A data period * @param {string} targetType Type of period to convert to diff --git a/tupaia-packages.code-workspace b/tupaia-packages.code-workspace index 08b5ef7128..f458d882cf 100644 --- a/tupaia-packages.code-workspace +++ b/tupaia-packages.code-workspace @@ -197,6 +197,7 @@ "Tupaia", "updaters", "upsert", + "Upserts", "vars" ], "eslint.validate": ["typescript", "typescriptreact"], diff --git a/yarn.lock b/yarn.lock index 8e18be2733..52a348cb32 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5164,6 +5164,7 @@ __metadata: resolution: "@tupaia/data-api@workspace:packages/data-api" dependencies: "@tupaia/database": 1.0.0 + "@tupaia/tsutils": 1.0.0 "@tupaia/utils": 1.0.0 "@types/lodash.groupby": ^4.6.0 db-migrate: ^0.11.5 @@ -5586,6 +5587,7 @@ __metadata: lodash.keyby: ^4.6.0 lodash.pick: ^4.4.0 mathjs: ^9.4.0 + moment: ^2.24.0 winston: ^3.2.1 languageName: unknown linkType: soft From 08996018d0cf9b94247aec9f1d39d8b4ef551b2f Mon Sep 17 00:00:00 2001 From: Rohan Port <59544282+rohan-bes@users.noreply.github.com> Date: Tue, 11 Oct 2022 15:27:11 +1100 Subject: [PATCH 07/12] MAUI-1267: Allow deleting entities via the Admin Panel (#4183) --- .../src/pages/resources/EntitiesPage.js | 14 +++-- .../src/apiV2/DeleteHandler/DeleteHandler.js | 6 +-- .../src/apiV2/entities/DeleteEntity.js | 51 +++++++++++++++++++ .../src/apiV2/{ => entities}/GETEntities.js | 28 ++++------ .../apiV2/entities/assertEntityPermissions.js | 15 ++++++ .../src/apiV2/entities/index.js | 3 ++ packages/central-server/src/apiV2/index.js | 4 +- .../surveyResponses/GETSurveyResponses.js | 2 +- 8 files changed, 95 insertions(+), 28 deletions(-) create mode 100644 packages/central-server/src/apiV2/entities/DeleteEntity.js rename packages/central-server/src/apiV2/{ => entities}/GETEntities.js (61%) create mode 100644 packages/central-server/src/apiV2/entities/assertEntityPermissions.js diff --git a/packages/admin-panel/src/pages/resources/EntitiesPage.js b/packages/admin-panel/src/pages/resources/EntitiesPage.js index 7dc19b6089..c28c510e18 100644 --- a/packages/admin-panel/src/pages/resources/EntitiesPage.js +++ b/packages/admin-panel/src/pages/resources/EntitiesPage.js @@ -8,7 +8,7 @@ import PropTypes from 'prop-types'; import { ResourcePage } from './ResourcePage'; import { SURVEY_RESPONSE_COLUMNS, ANSWER_COLUMNS } from './SurveyResponsesPage'; -const ENTITY_ENDPOINT = 'entities'; +const ENTITIES_ENDPOINT = 'entities'; export const ENTITIES_COLUMNS = [ { source: 'id', show: false }, @@ -38,7 +38,7 @@ const FIELDS = [ source: 'id', type: 'edit', actionConfig: { - editEndpoint: ENTITY_ENDPOINT, + editEndpoint: ENTITIES_ENDPOINT, title: 'Edit Entity', fields: [ { @@ -48,6 +48,14 @@ const FIELDS = [ ], }, }, + { + Header: 'Delete', + source: 'id', + type: 'delete', + actionConfig: { + endpoint: ENTITIES_ENDPOINT, + }, + }, ]; const EXPANSION_CONFIG = [ @@ -95,7 +103,7 @@ const IMPORT_CONFIG = { export const EntitiesPage = ({ getHeaderEl }) => ( 0) { + throw new Error( + `There are still survey responses for this entity, please clean them up before deleting this entity`, + ); + } + + // Check for children (they should be given a new parent first) + const hierarchies = await this.models.entityHierarchy.all(); + const hierarchiesWhereEntityHasChildren = []; + for (let i = 0; i < hierarchies.length; i++) { + const hierarchy = hierarchies[i]; + const children = await entity.getChildren(hierarchy.id); + if (children.length > 0) { + hierarchiesWhereEntityHasChildren.push(hierarchy); + } + } + + if (hierarchiesWhereEntityHasChildren.length > 0) { + throw new Error( + `This entity still has children in the following hierarchies [${hierarchiesWhereEntityHasChildren.map( + hierarchy => hierarchy.name, + )}], please delete them or re-import them with a new parent before deleting this entity`, + ); + } + + // Delete any entity_relations where this entity is the leaf node, as they prevent deleting the entity otherwise + await this.models.entityRelation.delete({ child_id: this.recordId }); + + await super.deleteRecord(); + } +} diff --git a/packages/central-server/src/apiV2/GETEntities.js b/packages/central-server/src/apiV2/entities/GETEntities.js similarity index 61% rename from packages/central-server/src/apiV2/GETEntities.js rename to packages/central-server/src/apiV2/entities/GETEntities.js index a183a6d9b4..627cf9c5c0 100644 --- a/packages/central-server/src/apiV2/GETEntities.js +++ b/packages/central-server/src/apiV2/entities/GETEntities.js @@ -3,21 +3,11 @@ * Copyright (c) 2017 - 2020 Beyond Essential Systems Pty Ltd */ -import { GETHandler } from './GETHandler'; -import { assertAnyPermissions, assertBESAdminAccess, hasBESAdminAccess } from '../permissions'; -import { mergeFilter } from './utilities'; -import { assertCountryPermissions } from './GETCountries'; - -export const assertEntityPermissions = async (accessPolicy, models, entityId) => { - const entity = await models.entity.findById(entityId); - if (!entity) { - throw new Error(`No entity exists with id ${entityId}`); - } - if (!accessPolicy.allows(entity.country_code)) { - throw new Error('You do not have permissions for this entity'); - } - return true; -}; +import { GETHandler } from '../GETHandler'; +import { assertAnyPermissions, assertBESAdminAccess, hasBESAdminAccess } from '../../permissions'; +import { mergeFilter } from '../utilities'; +import { assertCountryPermissions } from '../GETCountries'; +import { assertEntityPermissions } from './assertEntityPermissions'; export class GETEntities extends GETHandler { permissionsFilteredInternally = true; @@ -25,7 +15,9 @@ export class GETEntities extends GETHandler { async findSingleRecord(entityId, options) { const entityPermissionChecker = accessPolicy => assertEntityPermissions(accessPolicy, this.models, entityId); - await this.assertPermissions(assertAnyPermissions([assertBESAdminAccess, entityPermissionChecker])); + await this.assertPermissions( + assertAnyPermissions([assertBESAdminAccess, entityPermissionChecker]), + ); return super.findSingleRecord(entityId, options); } @@ -46,7 +38,9 @@ export class GETEntities extends GETHandler { async getPermissionsViaParentFilter(criteria, options) { const countryPermissionChecker = accessPolicy => assertCountryPermissions(accessPolicy, this.models, this.parentRecordId); - await this.assertPermissions(assertAnyPermissions([assertBESAdminAccess, countryPermissionChecker])); + await this.assertPermissions( + assertAnyPermissions([assertBESAdminAccess, countryPermissionChecker]), + ); const country = await this.models.country.findById(this.parentRecordId); const dbConditions = { 'entity.country_code': country.code, ...criteria }; diff --git a/packages/central-server/src/apiV2/entities/assertEntityPermissions.js b/packages/central-server/src/apiV2/entities/assertEntityPermissions.js new file mode 100644 index 0000000000..2a8b440598 --- /dev/null +++ b/packages/central-server/src/apiV2/entities/assertEntityPermissions.js @@ -0,0 +1,15 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2020 Beyond Essential Systems Pty Ltd + */ + +export const assertEntityPermissions = async (accessPolicy, models, entityId) => { + const entity = await models.entity.findById(entityId); + if (!entity) { + throw new Error(`No entity exists with id ${entityId}`); + } + if (!accessPolicy.allows(entity.country_code)) { + throw new Error('You do not have permissions for this entity'); + } + return true; +}; diff --git a/packages/central-server/src/apiV2/entities/index.js b/packages/central-server/src/apiV2/entities/index.js index b150972401..1504412ff7 100644 --- a/packages/central-server/src/apiV2/entities/index.js +++ b/packages/central-server/src/apiV2/entities/index.js @@ -3,4 +3,7 @@ * Copyright (c) 2017 - 2021 Beyond Essential Systems Pty Ltd */ +export { DeleteEntity } from './DeleteEntity'; export { EditEntity } from './EditEntity'; +export { GETEntities } from './GETEntities'; +export { assertEntityPermissions } from './assertEntityPermissions'; diff --git a/packages/central-server/src/apiV2/index.js b/packages/central-server/src/apiV2/index.js index 7b0a9eb120..c2cc3aec0a 100644 --- a/packages/central-server/src/apiV2/index.js +++ b/packages/central-server/src/apiV2/index.js @@ -31,7 +31,6 @@ import { GETDisasters } from './GETDisasters'; import { GETDataElements, EditDataElements, DeleteDataElements } from './dataElements'; import { GETDataGroups, EditDataGroups, DeleteDataGroups } from './dataGroups'; import { GETDataTables } from './dataTables'; -import { GETEntities } from './GETEntities'; import { GETEntityTypes } from './GETEntityTypes'; import { GETFeedItems } from './GETFeedItems'; import { GETGeographicalAreas } from './GETGeographicalAreas'; @@ -90,7 +89,7 @@ import { EditUserEntityPermissions, GETUserEntityPermissions, } from './userEntityPermissions'; -import { EditEntity } from './entities'; +import { EditEntity, GETEntities, DeleteEntity } from './entities'; import { EditAccessRequests, GETAccessRequests } from './accessRequests'; import { postChanges } from './postChanges'; import { changePassword } from './changePassword'; @@ -309,6 +308,7 @@ apiV2.delete('/surveyResponses/:parentRecordId/answers/:recordId', useRouteHandl apiV2.delete('/dataElements/:recordId', useRouteHandler(DeleteDataElements)); apiV2.delete('/dataGroups/:recordId', useRouteHandler(DeleteDataGroups)); apiV2.delete('/disasters/:recordId', useRouteHandler(BESAdminDeleteHandler)); +apiV2.delete('/entities/:recordId', useRouteHandler(DeleteEntity)); apiV2.delete('/feedItems/:recordId', useRouteHandler(BESAdminDeleteHandler)); apiV2.delete('/options/:recordId', useRouteHandler(DeleteOptions)); apiV2.delete('/optionSets/:recordId', useRouteHandler(DeleteOptionSets)); diff --git a/packages/central-server/src/apiV2/surveyResponses/GETSurveyResponses.js b/packages/central-server/src/apiV2/surveyResponses/GETSurveyResponses.js index 4b37571b34..5e949025b3 100644 --- a/packages/central-server/src/apiV2/surveyResponses/GETSurveyResponses.js +++ b/packages/central-server/src/apiV2/surveyResponses/GETSurveyResponses.js @@ -9,7 +9,7 @@ import { createSurveyResponseDBFilter, } from './assertSurveyResponsePermissions'; import { assertAnyPermissions, assertBESAdminAccess, hasBESAdminAccess } from '../../permissions'; -import { assertEntityPermissions } from '../GETEntities'; +import { assertEntityPermissions } from '../entities'; import { getQueryOptionsForColumns } from '../GETHandler/helpers'; /** From 85e0604f7998f83d4d1c3ea4ae0b768945d5d7c9 Mon Sep 17 00:00:00 2001 From: Rohan Port <59544282+rohan-bes@users.noreply.github.com> Date: Tue, 11 Oct 2022 15:27:37 +1100 Subject: [PATCH 08/12] RN-689: Fix the world entity not syncing with permission based syncs (#4216) --- .../meditrakSync/permissionsBasedMeditrakSyncQuery.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/central-server/src/apiV2/utilities/meditrakSync/permissionsBasedMeditrakSyncQuery.js b/packages/central-server/src/apiV2/utilities/meditrakSync/permissionsBasedMeditrakSyncQuery.js index fb764af036..6f28627656 100644 --- a/packages/central-server/src/apiV2/utilities/meditrakSync/permissionsBasedMeditrakSyncQuery.js +++ b/packages/central-server/src/apiV2/utilities/meditrakSync/permissionsBasedMeditrakSyncQuery.js @@ -18,6 +18,8 @@ import { } from './supportsPermissionsBasedSync'; const recordTypesToAlwaysSync = ['country', 'permission_group']; +const entityTypesToAlwaysSync = ['world', 'country']; + /** * Since all countries, permission_groups, and country entities regardless of permissions */ @@ -25,8 +27,8 @@ export const permissionsFreeChanges = since => { return { query: `change_time > ? AND (record_type IN ${SqlQuery.record( recordTypesToAlwaysSync, - )} OR entity_type = ?)`, - params: [since, ...recordTypesToAlwaysSync, 'country'], + )} OR entity_type IN ${SqlQuery.record(entityTypesToAlwaysSync)})`, + params: [since, ...recordTypesToAlwaysSync, ...entityTypesToAlwaysSync], }; }; From a9f84ddf16a74ed3c4865cdd60dca116a95d1823 Mon Sep 17 00:00:00 2001 From: Igor Date: Fri, 14 Oct 2022 14:48:55 +1100 Subject: [PATCH 09/12] mSupply Setup (#4164) * Add swapFwToFj flag * Create Unfpa, FPBS countries * Superset allow connect via proxy * Add sub_district and district (#4182) * Add package needle * Updates to superset-api for proxy * Add superset_instances in migration * Add support for startDate and endDate parameters in SupersetService - Manually filtering data on our end for now as the chart/:id/data api doesn't support query parameters * Add DataElementDataService routes * Add DataElementDataSources to admin panel * Add user permission import * Fix import data element data service * Update service_type list * Add option to disable feed scraper Co-authored-by: Biao Li <31789355+billli0@users.noreply.github.com> Co-authored-by: Rohan Port --- packages/admin-panel/src/common.js | 106 +++++++++ .../resources/DataElementDataServicesPage.js | 89 ++++++++ .../src/pages/resources/DataSourcesPage.js | 95 +-------- .../src/pages/resources/PermissionsPage.js | 8 + packages/admin-panel/src/routes.js | 6 + .../import/importDataElementDataServices.js | 77 +++++++ .../src/apiV2/import/importUserPermissions.js | 99 +++++++++ .../central-server/src/apiV2/import/index.js | 12 ++ packages/central-server/src/apiV2/index.js | 4 + .../constructNewRecordValidationRules.js | 8 + .../src/database/models/DataElement.js | 10 +- .../central-server/src/social/feedScraper.js | 7 + .../src/services/superset/SupersetService.js | 18 +- ...024924-AddUnfpaFacilities-modifies-data.js | 201 ++++++++++++++++++ ...25155-AddSupersetMappings-modifies-data.js | 52 +++++ packages/superset-api/package.json | 5 + packages/superset-api/src/SupersetApi.ts | 90 +++++--- .../apiV1/measureBuilders/valueForOrgGroup.js | 10 +- yarn.lock | 29 ++- 19 files changed, 797 insertions(+), 129 deletions(-) create mode 100644 packages/admin-panel/src/common.js create mode 100644 packages/admin-panel/src/pages/resources/DataElementDataServicesPage.js create mode 100644 packages/central-server/src/apiV2/import/importDataElementDataServices.js create mode 100644 packages/central-server/src/apiV2/import/importUserPermissions.js create mode 100644 packages/database/src/migrations/20220916024924-AddUnfpaFacilities-modifies-data.js create mode 100644 packages/database/src/migrations/20220925225155-AddSupersetMappings-modifies-data.js diff --git a/packages/admin-panel/src/common.js b/packages/admin-panel/src/common.js new file mode 100644 index 0000000000..6875abca92 --- /dev/null +++ b/packages/admin-panel/src/common.js @@ -0,0 +1,106 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2021 Beyond Essential Systems Pty Ltd + */ + +import React from 'react'; + +export const SERVICE_TYPE_OPTIONS = [ + { + label: 'Data Lake', + value: 'data-lake', + }, + { + label: 'DHIS', + value: 'dhis', + }, + { + label: 'Indicator', + value: 'indicator', + }, + { + label: 'Kobo', + value: 'kobo', + }, + { + label: 'Superset', + value: 'superset', + }, + { + label: 'Tupaia', + value: 'tupaia', + }, + { + label: 'Weather', + value: 'weather', + }, +]; + +export const DATA_ELEMENT_FIELD_EDIT_CONFIG = { + type: 'json', + default: '{}', + visibilityCriteria: { + service_type: values => ['dhis', 'superset'].includes(values.service_type), + }, + getJsonFieldSchema: () => [ + { + label: 'DHIS Server', + fieldName: 'dhisInstanceCode', + optionsEndpoint: 'dhisInstances', + optionLabelKey: 'dhisInstances.code', + optionValueKey: 'dhisInstances.code', + visibilityCriteria: { service_type: 'dhis' }, + }, + { + label: 'Data element code', + fieldName: 'dataElementCode', + visibilityCriteria: { service_type: 'dhis' }, + }, + { + label: 'Category option combo code', + fieldName: 'categoryOptionCombo', + visibilityCriteria: { service_type: 'dhis' }, + }, + { + label: 'Superset Instance', + fieldName: 'supersetInstanceCode', + visibilityCriteria: { service_type: 'superset' }, + }, + { + label: 'Superset Chart ID', + fieldName: 'supersetChartId', + visibilityCriteria: { service_type: 'superset' }, + }, + { + label: 'Superset Item Code (optional)', + fieldName: 'supersetItemCode', + visibilityCriteria: { service_type: 'superset' }, + }, + ], +}; + +export const DataSourceConfigView = row => { + const localStyles = { + config: { + dt: { + float: 'left', + clear: 'left', + width: '175px', + textAlign: 'right', + marginRight: '5px', + }, + }, + }; + + const blankString = ''; + const entries = Object.entries(row.value) + .filter(([, value]) => value !== blankString) + .map(([key, value]) => ( + +
{key}:
+
{value ? value.toString() : '""'}
+
+ )); + + return
{entries}
; +}; diff --git a/packages/admin-panel/src/pages/resources/DataElementDataServicesPage.js b/packages/admin-panel/src/pages/resources/DataElementDataServicesPage.js new file mode 100644 index 0000000000..5fa4af26bf --- /dev/null +++ b/packages/admin-panel/src/pages/resources/DataElementDataServicesPage.js @@ -0,0 +1,89 @@ +/** + * Tupaia MediTrak + * Copyright (c) 2017 Beyond Essential Systems Pty Ltd + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { ResourcePage } from './ResourcePage'; +import { + DATA_ELEMENT_FIELD_EDIT_CONFIG, + SERVICE_TYPE_OPTIONS, + DataSourceConfigView, +} from '../../common'; + +const FIELDS = [ + { + Header: 'Data Element', + source: 'data_element_code', + }, + { + Header: 'Country Code', + source: 'country_code', + }, + { + Header: 'Service Type', + source: 'service_type', + editConfig: { + options: SERVICE_TYPE_OPTIONS, + }, + }, + { + Header: 'Data Service Configuration', + source: 'service_config', + Cell: DataSourceConfigView, + editConfig: DATA_ELEMENT_FIELD_EDIT_CONFIG, + }, +]; + +const COLUMNS = [ + ...FIELDS, + { + Header: 'Edit', + type: 'edit', + source: 'id', + actionConfig: { + editEndpoint: 'dataElementDataServices', + fields: FIELDS, + }, + }, + { + Header: 'Delete', + source: 'id', + type: 'delete', + actionConfig: { + endpoint: `dataElementDataServices`, + }, + }, +]; + +const IMPORT_CONFIG = { + title: 'Import Mapping', + actionConfig: { + importEndpoint: 'dataElementDataServices', + }, +}; + +const CREATE_CONFIG = { + title: 'New Mapping', + actionConfig: { + editEndpoint: 'dataElementDataServices', + fields: FIELDS, + }, +}; + +export const DataElementDataServicesPage = ({ getHeaderEl, ...props }) => ( + +); + +DataElementDataServicesPage.propTypes = { + getHeaderEl: PropTypes.func.isRequired, +}; diff --git a/packages/admin-panel/src/pages/resources/DataSourcesPage.js b/packages/admin-panel/src/pages/resources/DataSourcesPage.js index 3c1c9fdba4..0bdf22354f 100644 --- a/packages/admin-panel/src/pages/resources/DataSourcesPage.js +++ b/packages/admin-panel/src/pages/resources/DataSourcesPage.js @@ -6,63 +6,11 @@ import React from 'react'; import PropTypes from 'prop-types'; import { ResourcePage } from './ResourcePage'; - -const SERVICE_TYPE_OPTIONS = [ - { - label: 'Data Lake', - value: 'data-lake', - }, - { - label: 'DHIS', - value: 'dhis', - }, - { - label: 'Indicator', - value: 'indicator', - }, - { - label: 'Kobo', - value: 'kobo', - }, - { - label: 'Superset', - value: 'superset', - }, - { - label: 'Tupaia', - value: 'tupaia', - }, - { - label: 'Weather', - value: 'weather', - }, -]; - -const localStyles = { - config: { - dt: { - float: 'left', - clear: 'left', - width: '175px', - textAlign: 'right', - marginRight: '5px', - }, - }, -}; - -const DataSourceConfigView = row => { - const blankString = ''; - const entries = Object.entries(row.value) - .filter(([, value]) => value !== blankString) - .map(([key, value]) => ( - -
{key}:
-
{value ? value.toString() : '""'}
-
- )); - - return
{entries}
; -}; +import { + DataSourceConfigView, + DATA_ELEMENT_FIELD_EDIT_CONFIG, + SERVICE_TYPE_OPTIONS, +} from '../../common'; const getButtonsConfig = (fields, recordType) => [ { @@ -103,38 +51,7 @@ const DATA_ELEMENT_FIELDS = [ Header: 'Data Service Configuration', source: 'config', Cell: DataSourceConfigView, - editConfig: { - type: 'json', - default: '{}', - visibilityCriteria: { - service_type: values => ['dhis', 'superset'].includes(values.service_type), - }, - getJsonFieldSchema: () => [ - { - label: 'DHIS Server', - fieldName: 'dhisInstanceCode', - optionsEndpoint: 'dhisInstances', - optionLabelKey: 'dhisInstances.code', - optionValueKey: 'dhisInstances.code', - visibilityCriteria: { service_type: 'dhis' }, - }, - { - label: 'Data element code', - fieldName: 'dataElementCode', - visibilityCriteria: { service_type: 'dhis' }, - }, - { - label: 'Category option combo code', - fieldName: 'categoryOptionCombo', - visibilityCriteria: { service_type: 'dhis' }, - }, - { - label: 'Superset Chart ID', - fieldName: 'supersetChartId', - visibilityCriteria: { service_type: 'superset' }, - }, - ], - }, + editConfig: DATA_ELEMENT_FIELD_EDIT_CONFIG, }, { Header: 'Permission Groups', diff --git a/packages/admin-panel/src/pages/resources/PermissionsPage.js b/packages/admin-panel/src/pages/resources/PermissionsPage.js index 363b9e74dd..5f9fba3dca 100644 --- a/packages/admin-panel/src/pages/resources/PermissionsPage.js +++ b/packages/admin-panel/src/pages/resources/PermissionsPage.js @@ -100,6 +100,13 @@ const CREATE_CONFIG = { }, }; +const IMPORT_CONFIG = { + title: 'Import User Permissions', + actionConfig: { + importEndpoint: 'userPermissions', + }, +}; + // When creating, return an array of records for bulk editing on the server // When editing, just process a single record as normal const processDataForSave = (fieldsToSave, recordData) => { @@ -140,6 +147,7 @@ export const PermissionsPage = ({ getHeaderEl, ...props }) => ( endpoint={PERMISSIONS_ENDPOINT} columns={FIELDS} createConfig={CREATE_CONFIG} + importConfig={IMPORT_CONFIG} getHeaderEl={getHeaderEl} {...props} onProcessDataForSave={processDataForSave} diff --git a/packages/admin-panel/src/routes.js b/packages/admin-panel/src/routes.js index 7e370031c6..9075f414cf 100644 --- a/packages/admin-panel/src/routes.js +++ b/packages/admin-panel/src/routes.js @@ -33,6 +33,7 @@ import { SyncGroupsPage, DataTablesPage, } from './pages/resources'; +import { DataElementDataServicesPage } from './pages/resources/DataElementDataServicesPage'; export const ROUTES = [ { @@ -75,6 +76,11 @@ export const ROUTES = [ to: '/sync-groups', component: SyncGroupsPage, }, + { + label: 'Data Mapping', + to: '/data-mapping', + component: DataElementDataServicesPage, + }, ], }, { diff --git a/packages/central-server/src/apiV2/import/importDataElementDataServices.js b/packages/central-server/src/apiV2/import/importDataElementDataServices.js new file mode 100644 index 0000000000..b0eecbb739 --- /dev/null +++ b/packages/central-server/src/apiV2/import/importDataElementDataServices.js @@ -0,0 +1,77 @@ +/** + * Tupaia MediTrak + * Copyright (c) 2017 Beyond Essential Systems Pty Ltd + */ + +import xlsx from 'xlsx'; +import { + respond, + ImportValidationError, + UploadError, + ObjectValidator, + hasContent, + constructIsOneOf, + constructRecordExistsWithCode, +} from '@tupaia/utils'; +import { assertBESAdminAccess } from '../../permissions'; +import { DATA_SOURCE_SERVICE_TYPES } from '../../database/models/DataElement'; + +const extractItems = filePath => { + const { Sheets: sheets } = xlsx.readFile(filePath); + return xlsx.utils.sheet_to_json(Object.values(sheets)[0]); +}; + +async function create(transactingModels, items) { + const validator = new ObjectValidator({ + data_element_code: [constructRecordExistsWithCode(transactingModels.dataElement)], + country_code: [hasContent], + service_type: [constructIsOneOf(DATA_SOURCE_SERVICE_TYPES)], + service_config: [hasContent], + }); + + let excelRowNumber = 0; + + const constructImportValidationError = (message, field) => + new ImportValidationError(message, excelRowNumber, field); + + for (const item of items) { + excelRowNumber++; + await validator.validate(item, constructImportValidationError); + + const { data_element_code, country_code } = item; + + const existingRecord = await transactingModels.dataElementDataService.findOne({ + data_element_code, + country_code, + }); + if (existingRecord) { + await transactingModels.dataElementDataService.update( + { data_element_code, country_code }, + item, + ); + } else { + await transactingModels.dataElementDataService.create(item); + } + } +} + +/** + * Responds to POST requests to the /import/dataElementDataServices endpoint + */ +export async function importDataElementDataServices(req, res) { + const { models } = req; + + await req.assertPermissions(assertBESAdminAccess); + + let items; + try { + items = extractItems(req.file.path); + } catch (error) { + throw new UploadError(error); + } + + await models.wrapInTransaction(async transactingModels => { + await create(transactingModels, items); + }); + respond(res, { message: 'Imported mapping' }); +} diff --git a/packages/central-server/src/apiV2/import/importUserPermissions.js b/packages/central-server/src/apiV2/import/importUserPermissions.js new file mode 100644 index 0000000000..8c978d946d --- /dev/null +++ b/packages/central-server/src/apiV2/import/importUserPermissions.js @@ -0,0 +1,99 @@ +/** + * Tupaia MediTrak + * Copyright (c) 2017 Beyond Essential Systems Pty Ltd + */ + +import xlsx from 'xlsx'; +import { + respond, + ImportValidationError, + UploadError, + ObjectValidator, + constructRecordExistsWithCode, + constructRecordExistsWithField, + DatabaseError, +} from '@tupaia/utils'; +import { assertBESAdminAccess } from '../../permissions'; + +const extractItems = filePath => { + const { Sheets: sheets } = xlsx.readFile(filePath); + return xlsx.utils.sheet_to_json(Object.values(sheets)[0]); +}; + +async function create(transactingModels, items) { + const validator = new ObjectValidator({ + user_email: [constructRecordExistsWithField(transactingModels.user, 'email')], + entity_code: [ + constructRecordExistsWithCode(transactingModels.entity), + async entityCode => { + const entity = await transactingModels.entity.findOne({ code: entityCode }); + if (entity.type !== 'country') { + throw new Error( + `Only country level permissions are currently supported. Entity "${entity.code}" is: "${entity.type}"`, + ); + } + }, + ], + permission_group_name: [ + constructRecordExistsWithField(transactingModels.permissionGroup, 'name'), + ], + }); + + let excelRowNumber = 0; + + const constructImportValidationError = (message, field) => + new ImportValidationError(message, excelRowNumber, field); + + for (const item of items) { + excelRowNumber++; + await validator.validate(item, constructImportValidationError); + + const { user_email, entity_code, permission_group_name } = item; + + const user = await transactingModels.user.findOne({ email: user_email }); + const entity = await transactingModels.entity.findOne({ code: entity_code }); + const permissionGroup = await transactingModels.permissionGroup.findOne({ + name: permission_group_name, + }); + + const existingRecord = await transactingModels.userEntityPermission.findOne({ + user_id: user.id, + entity_id: entity.id, + permission_group_id: permissionGroup.id, + }); + if (existingRecord) { + // Already added + console.info( + `User permission ${user.id} / ${entity.id} / ${permissionGroup.id} already exists, skipping`, + ); + continue; + } else { + await transactingModels.userEntityPermission.create({ + user_id: user.id, + entity_id: entity.id, + permission_group_id: permissionGroup.id, + }); + } + } +} + +/** + * Responds to POST requests to the /import/userPermissions endpoint + */ +export async function importUserPermissions(req, res) { + const { models } = req; + + await req.assertPermissions(assertBESAdminAccess); + + let items; + try { + items = extractItems(req.file.path); + } catch (error) { + throw new UploadError(error); + } + + await models.wrapInTransaction(async transactingModels => { + await create(transactingModels, items); + respond(res, { message: `Imported User Permissions` }); + }); +} diff --git a/packages/central-server/src/apiV2/import/index.js b/packages/central-server/src/apiV2/import/index.js index 4e3274fa1e..fb1a526193 100644 --- a/packages/central-server/src/apiV2/import/index.js +++ b/packages/central-server/src/apiV2/import/index.js @@ -17,6 +17,8 @@ import { importSurveyResponses, constructImportEmail } from './importSurveyRespo import { importDisaster } from './importDisaster'; import { getTempDirectory } from '../../utilities'; import { importDataElements } from './importDataElements'; +import { importDataElementDataServices } from './importDataElementDataServices'; +import { importUserPermissions } from './importUserPermissions'; // create upload handler const upload = multer({ @@ -51,5 +53,15 @@ importRoutes.post( importRoutes.post('/disasters', upload.single('disasters'), catchAsyncErrors(importDisaster)); importRoutes.post('/users', upload.single('users'), catchAsyncErrors(importUsers)); importRoutes.post('/optionSets', upload.single('optionSets'), catchAsyncErrors(importOptionSets)); +importRoutes.post( + '/dataElementDataServices', + upload.single('dataElementDataServices'), + catchAsyncErrors(importDataElementDataServices), +); +importRoutes.post( + '/userPermissions', + upload.single('userPermissions'), + catchAsyncErrors(importUserPermissions), +); export { importRoutes }; diff --git a/packages/central-server/src/apiV2/index.js b/packages/central-server/src/apiV2/index.js index c2cc3aec0a..c39e8dfcc4 100644 --- a/packages/central-server/src/apiV2/index.js +++ b/packages/central-server/src/apiV2/index.js @@ -228,6 +228,7 @@ apiV2.get('/dhisInstances/:recordId?', useRouteHandler(BESAdminGETHandler)); apiV2.get('/dataServiceSyncGroups/:recordId?', useRouteHandler(GETSyncGroups)); apiV2.get('/dataServiceSyncGroups/:recordId/logs', useRouteHandler(GETSyncGroupLogs)); apiV2.get('/dataServiceSyncGroups/:recordId/logs/count', useRouteHandler(GETSyncGroupLogsCount)); +apiV2.get('/dataElementDataServices/:recordId?', useRouteHandler(BESAdminGETHandler)); /** * POST routes @@ -262,6 +263,7 @@ apiV2.post('/userFavouriteDashboardItems', useRouteHandler(POSTUpdateUserFavouri apiV2.post('/projects', useRouteHandler(CreateProject)); apiV2.post('/dataServiceSyncGroups', useRouteHandler(CreateSyncGroups)); apiV2.post('/dataServiceSyncGroups/:recordId/sync', useRouteHandler(ManuallySyncSyncGroup)); +apiV2.post('/dataElementDataServices', useRouteHandler(BESAdminCreateHandler)); /** * PUT routes @@ -295,6 +297,7 @@ apiV2.put('/projects/:recordId', useRouteHandler(BESAdminEditHandler)); apiV2.put('/entities/:recordId', useRouteHandler(EditEntity)); apiV2.put('/me', catchAsyncErrors(editUser)); apiV2.put('/dataServiceSyncGroups/:recordId', useRouteHandler(EditSyncGroups)); +apiV2.put('/dataElementDataServices/:recordId', useRouteHandler(BESAdminEditHandler)); /** * DELETE routes @@ -325,6 +328,7 @@ apiV2.delete( ); apiV2.delete('/indicators/:recordId', useRouteHandler(BESAdminDeleteHandler)); apiV2.delete('/dataServiceSyncGroups/:recordId', useRouteHandler(DeleteSyncGroups)); +apiV2.delete('/dataElementDataServices/:recordId', useRouteHandler(BESAdminDeleteHandler)); apiV2.use(handleError); // error handler must come last diff --git a/packages/central-server/src/apiV2/utilities/constructNewRecordValidationRules.js b/packages/central-server/src/apiV2/utilities/constructNewRecordValidationRules.js index f82f76da9b..270a479c16 100644 --- a/packages/central-server/src/apiV2/utilities/constructNewRecordValidationRules.js +++ b/packages/central-server/src/apiV2/utilities/constructNewRecordValidationRules.js @@ -19,6 +19,7 @@ import { isValidPassword, isNumber, ValidationError, + constructRecordExistsWithCode, } from '@tupaia/utils'; import { DATA_SOURCE_SERVICE_TYPES } from '../../database/models/DataElement'; @@ -265,6 +266,13 @@ export const constructForSingle = (models, recordType) => { dashboard_group_name: [isAString], default_measure: [constructRecordExistsWithField(models.mapOverlay, 'id')], }; + case TYPES.DATA_ELEMENT_DATA_SERVICE: + return { + data_element_code: [constructRecordExistsWithCode(models.dataElement)], + country_code: [hasContent], + service_type: [constructIsOneOf(DATA_SOURCE_SERVICE_TYPES)], + service_config: [hasContent], + }; default: throw new ValidationError(`${recordType} is not a valid POST endpoint`); } diff --git a/packages/central-server/src/database/models/DataElement.js b/packages/central-server/src/database/models/DataElement.js index b5a079d95c..8acc95722a 100644 --- a/packages/central-server/src/database/models/DataElement.js +++ b/packages/central-server/src/database/models/DataElement.js @@ -8,7 +8,15 @@ import { DataElementModel as CommonDataElementModel, } from '@tupaia/database'; -export const DATA_SOURCE_SERVICE_TYPES = ['dhis', 'tupaia', 'data-lake', 'superset']; +export const DATA_SOURCE_SERVICE_TYPES = [ + 'data-lake', + 'dhis', + 'indicator', + 'kobo', + 'superset', + 'tupaia', + 'weather', +]; const getSurveyDateCode = surveyCode => `${surveyCode}SurveyDate`; diff --git a/packages/central-server/src/social/feedScraper.js b/packages/central-server/src/social/feedScraper.js index e484f698be..793a0e7e49 100644 --- a/packages/central-server/src/social/feedScraper.js +++ b/packages/central-server/src/social/feedScraper.js @@ -10,6 +10,13 @@ const POLL_TIME = 5 * 60 * 1000; // Run every 5 minutes. let isRunning = false; export const startFeedScraper = models => { + // Start recursive sync loop (enabled by default) + if (process.env.FEED_SCRAPER_DISABLE === 'true') { + // eslint-disable-next-line no-console + console.log('Feed scraper is disabled'); + return; + } + setInterval(async () => { try { if (!isRunning) { diff --git a/packages/data-broker/src/services/superset/SupersetService.js b/packages/data-broker/src/services/superset/SupersetService.js index ece27730f5..71453cce59 100644 --- a/packages/data-broker/src/services/superset/SupersetService.js +++ b/packages/data-broker/src/services/superset/SupersetService.js @@ -42,7 +42,7 @@ export class SupersetService extends Service { * @private */ async pullAnalytics(dataSources, options) { - const { dataServiceMapping } = options; + const { dataServiceMapping, startDate, endDate } = options; let mergedResults = []; for (const [supersetInstanceCode, instanceDataSources] of Object.entries( this.groupBySupersetInstanceCode(dataSources, dataServiceMapping), @@ -61,6 +61,7 @@ export class SupersetService extends Service { chartId, chartDataSources, dataServiceMapping, + { startDate, endDate }, ); mergedResults = mergedResults.concat(results); } @@ -78,10 +79,16 @@ export class SupersetService extends Service { * @param {string} chartId * @param {DataElement[]} dataElements * @param {DataServiceMapping} dataServiceMapping + * @param {{ startDate?: string; endDate?: string }} options * @return {Promise} analytic results * @private */ - async pullForApiForChart(api, chartId, dataElements, dataServiceMapping) { + async pullForApiForChart(api, chartId, dataElements, dataServiceMapping, options) { + const { startDate, endDate } = options; + + const startDateMoment = startDate ? moment(startDate).startOf('day') : undefined; + const endDateMoment = endDate ? moment(endDate).endOf('day') : undefined; + const response = await api.chartData(chartId); const { data } = response.result[0]; @@ -99,12 +106,17 @@ export class SupersetService extends Service { dataElement = mappingMatchingSupersetItemCode.dataSource; } } + if (!dataElement) continue; // unneeded data + const dataDateMoment = moment(date); + if (startDateMoment && dataDateMoment.isBefore(startDateMoment)) continue; // before date range + if (endDateMoment && dataDateMoment.isAfter(endDateMoment)) continue; // after date range + results.push({ dataElement: dataElement.code, organisationUnit: storeCode, - period: moment(date).format('YYYYMMDD'), + period: dataDateMoment.format('YYYYMMDD'), value, }); } diff --git a/packages/database/src/migrations/20220916024924-AddUnfpaFacilities-modifies-data.js b/packages/database/src/migrations/20220916024924-AddUnfpaFacilities-modifies-data.js new file mode 100644 index 0000000000..3b8d871022 --- /dev/null +++ b/packages/database/src/migrations/20220916024924-AddUnfpaFacilities-modifies-data.js @@ -0,0 +1,201 @@ +'use strict'; + +const { insertObject, generateId, codeToId, nameToId } = require('../utilities'); + +var dbm; +var type; +var seed; + +/** + * We receive the dbmigrate dependency from dbmigrate initially. + * This enables us to not have to rely on NODE_PATH. + */ +exports.setup = function (options, seedLink) { + dbm = options.dbmigrate; + type = dbm.dataType; + seed = seedLink; +}; + +const NEW_COUNTRIES = [ + { + name: 'UNFPA Warehouse', + code: 'UW', // Made up country code "Unfpa Warehouse" = "UW" + }, + { + name: 'FPBS Warehouse', + code: 'FW', // Made up country code "FPBS Warehouse" = "UW" + }, +]; + +const NEW_FACILITIES = [ + { + code: 'UNFPA PSRO Warehouse', + name: 'UNFPA PSRO Warehouse', + countryCode: 'UW', + parentCode: 'UNFPA PSRO SUB_DISTRICT', + }, + { + code: 'FPBS', + name: 'Fiji Pharmaceutical and Biomedical Services Warehouse', + countryCode: 'FW', + parentCode: 'UNFPA FPBS SUB_DISTRICT', + }, +]; + +const NEW_DISTRICTS = [ + { + code: 'UNFPA PSRO DISTRICT', + name: 'UNFPA PSRO DISTRICT', + parentCode: 'UW', + }, + { + code: 'UNFPA FPBS DISTRICT', + name: 'UNFPA FPBS DISTRICT', + parentCode: 'FW', + }, +]; + +const NEW_SUB_DISTRICTS = [ + { + code: 'UNFPA PSRO SUB_DISTRICT', + name: 'UNFPA PSRO SUB_DISTRICT', + countryCode: 'UW', + parentCode: 'UNFPA PSRO DISTRICT', + }, + { + code: 'UNFPA FPBS SUB_DISTRICT', + name: 'UNFPA FPBS SUB_DISTRICT', + countryCode: 'FW', + parentCode: 'UNFPA FPBS DISTRICT', + }, +]; + +const createCountry = async (db, { code, name }) => { + await insertObject(db, 'country', { + id: generateId(), + name, + code, + }); + const worldEntityId = await codeToId(db, 'entity', 'World'); + const newEntityId = generateId(); + await insertObject(db, 'entity', { + id: newEntityId, + code, + parent_id: worldEntityId, + name, + type: 'country', + country_code: code, + }); + const unfpaHierarchyId = await nameToId(db, 'entity_hierarchy', 'unfpa'); + const unfpaProjectEntityId = await codeToId(db, 'entity', 'unfpa'); + await insertObject(db, 'entity_relation', { + id: generateId(), + parent_id: unfpaProjectEntityId, + child_id: newEntityId, + entity_hierarchy_id: unfpaHierarchyId, + }); +}; + +const createFacility = async (db, { code, name, countryCode, parentCode }) => { + const parentId = await codeToId(db, 'entity', parentCode); + const unfpaHierarchyId = await nameToId(db, 'entity_hierarchy', 'unfpa'); + + const newEntityId = generateId(); + await insertObject(db, 'entity', { + id: newEntityId, + code, + name, + parent_id: parentId, + type: 'facility', + country_code: countryCode, + }); + await insertObject(db, 'entity_relation', { + id: generateId(), + parent_id: parentId, + child_id: newEntityId, + entity_hierarchy_id: unfpaHierarchyId, + }); +}; + +const createSubDistrict = async (db, { code, name, countryCode, parentCode }) => { + const parentId = await codeToId(db, 'entity', parentCode); + const unfpaHierarchyId = await nameToId(db, 'entity_hierarchy', 'unfpa'); + + const newEntityId = generateId(); + await insertObject(db, 'entity', { + id: newEntityId, + code, + name, + parent_id: parentId, + type: 'sub_district', + country_code: countryCode, + }); + await insertObject(db, 'entity_relation', { + id: generateId(), + parent_id: parentId, + child_id: newEntityId, + entity_hierarchy_id: unfpaHierarchyId, + }); +}; + +// Do not need to be added to entity_relation, as not for other countries' districts +const createDistrict = async (db, { code, name, parentCode }) => { + const parentId = await codeToId(db, 'entity', parentCode); + + const newEntityId = generateId(); + await insertObject(db, 'entity', { + id: newEntityId, + code, + name, + parent_id: parentId, + type: 'district', + country_code: parentCode, + }); +}; + +const rollbackCreateCountry = async (db, code) => { + db.runSql(`DELETE FROM country WHERE code = '${code}'`); + const entityId = await codeToId(db, 'entity', code); + db.runSql(`DELETE FROM entity_relation WHERE child_id = '${entityId}'`); + db.runSql(`DELETE FROM entity WHERE id = '${entityId}'`); +}; + +const rollbackCreateEntities = async (db, code) => { + const entityId = await codeToId(db, 'entity', code); + await db.runSql(`DELETE FROM entity_relation WHERE child_id = '${entityId}'`); + await db.runSql(`DELETE FROM entity WHERE id = '${entityId}'`); +}; + +exports.up = async function (db) { + for (const newCountry of NEW_COUNTRIES) { + await createCountry(db, newCountry); + } + for (const newDistrict of NEW_DISTRICTS) { + await createDistrict(db, newDistrict); + } + for (const newSubDistrict of NEW_SUB_DISTRICTS) { + await createSubDistrict(db, newSubDistrict); + } + for (const newFacility of NEW_FACILITIES) { + await createFacility(db, newFacility); + } +}; + +exports.down = async function (db) { + for (const newFacility of NEW_FACILITIES) { + await rollbackCreateEntities(db, newFacility.code); + } + for (const newSubDistrict of NEW_SUB_DISTRICTS) { + await rollbackCreateEntities(db, newSubDistrict.code); + } + for (const newDistrict of NEW_DISTRICTS) { + await rollbackCreateEntities(db, newDistrict.code); + } + for (const newCountry of NEW_COUNTRIES) { + await rollbackCreateCountry(db, newCountry.code); + } +}; + +exports._meta = { + version: 1, +}; diff --git a/packages/database/src/migrations/20220925225155-AddSupersetMappings-modifies-data.js b/packages/database/src/migrations/20220925225155-AddSupersetMappings-modifies-data.js new file mode 100644 index 0000000000..e15afc98c6 --- /dev/null +++ b/packages/database/src/migrations/20220925225155-AddSupersetMappings-modifies-data.js @@ -0,0 +1,52 @@ +'use strict'; + +import { generateId, insertObject } from '../utilities'; + +var dbm; +var type; +var seed; + +/** + * We receive the dbmigrate dependency from dbmigrate initially. + * This enables us to not have to rely on NODE_PATH. + */ +exports.setup = function (options, seedLink) { + dbm = options.dbmigrate; + type = dbm.dataType; + seed = seedLink; +}; + +const SUPERSET_INSTANCES = [ + { + id: generateId(), + code: 'msupply-fj', + config: { + baseUrl: 'https://superset-fiji.msupply.org:8088', + insecure: true, + }, + }, + { + id: generateId(), + code: 'msupply-fj-unfpa', + config: { + baseUrl: 'https://superset-fiji-unfpa.msupply.org:8088', + insecure: true, + }, + }, +]; + +exports.up = async function (db) { + for (const instance of SUPERSET_INSTANCES) { + await insertObject(db, 'superset_instance', instance); + } +}; + +exports.down = async function (db) { + for (const instance of SUPERSET_INSTANCES) { + await db.runSql(`DELETE FROM superset_instance WHERE code = '${instance.code}'`); + } +}; + +exports._meta = { + version: 1, +}; diff --git a/packages/superset-api/package.json b/packages/superset-api/package.json index e6715deddc..cc9489d9fb 100644 --- a/packages/superset-api/package.json +++ b/packages/superset-api/package.json @@ -19,6 +19,11 @@ }, "dependencies": { "@tupaia/utils": "1.0.0", + "https-proxy-agent": "^5.0.1", + "needle": "^3.1.0", "winston": "^3.3.3" + }, + "devDependencies": { + "@types/needle": "^2.5.3" } } diff --git a/packages/superset-api/src/SupersetApi.ts b/packages/superset-api/src/SupersetApi.ts index 7bcb1e8815..af2f08efef 100644 --- a/packages/superset-api/src/SupersetApi.ts +++ b/packages/superset-api/src/SupersetApi.ts @@ -2,24 +2,29 @@ * Tupaia * Copyright (c) 2017 - 2021 Beyond Essential Systems Pty Ltd */ -import { fetchWithTimeout } from '@tupaia/utils'; import { ChartDataResponseSchema, SecurityLoginRequestBodySchema, SecurityLoginResponseBodySchema, } from './types'; -import { Agent as HttpsAgent } from 'https'; import winston from 'winston'; +import { HttpsProxyAgent } from 'https-proxy-agent'; +import needle from 'needle'; +import type { + NeedleHttpVerbs, + BodyData as NeedleBodyData, + NeedleOptions, + NeedleResponse, +} from 'needle'; const MAX_RETRIES = 1; -const MAX_FETCH_WAIT_TIME = 45 * 1000; // 45 seconds export class SupersetApi { protected serverName: string; protected baseUrl: string; protected insecure: boolean; - protected insecureAgent: HttpsAgent; protected accessToken: string | null = null; + protected proxyAgent?: HttpsProxyAgent; public constructor(serverName: string, baseUrl: string, insecure: boolean = false) { if (!serverName) throw new Error('Argument serverName required'); @@ -27,7 +32,11 @@ export class SupersetApi { this.serverName = serverName; this.baseUrl = baseUrl; this.insecure = insecure; - this.insecureAgent = new HttpsAgent({ rejectUnauthorized: false }); + const proxyUrl = this.getServerVariable('SUPERSET_API_PROXY_URL'); + if (proxyUrl) { + winston.info(`Superset using proxy`); + this.proxyAgent = new HttpsProxyAgent(proxyUrl); + } } public async chartData(chartId: number): Promise { @@ -45,42 +54,39 @@ export class SupersetApi { } const fetchConfig: any = { - method: 'GET', headers: { Authorization: `Bearer ${this.accessToken}`, 'Content-Type': 'application/json', }, }; - if (this.insecure) fetchConfig.agent = this.insecureAgent; - winston.info(`Superset fetch ${this.insecure ? '(insecure) ' : ' '}${url}`); - - const result = await fetchWithTimeout(url, fetchConfig, MAX_FETCH_WAIT_TIME); - - if (result.status !== 200) { - const bodyText = await result.text(); + const result = await this.apiRequest('get', url, undefined, fetchConfig); - if (result.status === 422 || result.status === 401) { - winston.info(`Superset Auth error, response: ${bodyText}`); + if (result.statusCode !== 200) { + if (result.statusCode === 422 || result.statusCode === 401) { + winston.info(`Superset Auth error, response: ${result.body}`); await this.refreshAccessToken(); return this.fetch(url, numRetries + 1); } throw new Error( - `Error response from Superset API. Status: ${result.status}, body: ${bodyText}`, + `Error response from Superset API. Status: ${result.statusCode}, body: ${result.body}`, ); } - return await result.json(); + return result.body as T; } - protected async refreshAccessToken() { - const getServerVariable = (variableName: string) => + protected getServerVariable(variableName: string) { + return ( process.env[`${this.serverName.toUpperCase()}_${variableName}`] || process.env[variableName] || - ''; + '' + ); + } - const username = getServerVariable('SUPERSET_API_USERNAME'); - const password = getServerVariable('SUPERSET_API_PASSWORD'); + protected async refreshAccessToken() { + const username = this.getServerVariable('SUPERSET_API_USERNAME'); + const password = this.getServerVariable('SUPERSET_API_PASSWORD'); const body: SecurityLoginRequestBodySchema = { username, @@ -91,24 +97,42 @@ export class SupersetApi { const url = `${this.baseUrl}/api/v1/security/login`; const fetchConfig: any = { - method: 'POST', - body: JSON.stringify(body), headers: { 'Content-Type': 'application/json' }, }; - if (this.insecure) fetchConfig.agent = this.insecureAgent; - - winston.info(`Superset refresh access token ${this.insecure ? '(insecure) ' : ' '}${url}`); - const result = await fetchWithTimeout(url, fetchConfig, MAX_FETCH_WAIT_TIME); + const result = await this.apiRequest('post', url, body, fetchConfig); - if (result.status !== 200) { - const bodyText = await result.text(); + if (result.statusCode !== 200) { throw new Error( - `Superset failed to refresh access token. Status: ${result.status}, body: ${bodyText}`, + `Superset failed to refresh access token. Status: ${result.statusCode}, body: ${result.body}`, ); } - const resultBody: SecurityLoginResponseBodySchema = await result.json(); + const { access_token } = result.body as SecurityLoginResponseBodySchema; + this.accessToken = access_token; + } - this.accessToken = resultBody.access_token; + protected async apiRequest( + method: NeedleHttpVerbs, + url: string, + reqBody: NeedleBodyData = {}, + options: NeedleOptions = {}, + ): Promise { + // We use the `needle` package instead of the built-in `node-fetch` package + // because the fetch package does not let us set rejectUnauthorized=false + // to avoid SSL issues. It only lets us set agent, but we need to be able + // to set a proxy as the agent instead. So we have to use something that lets + // us do this, and needle is one such package. + const opts: any = { + ...options, + }; + if (this.insecure) opts.rejectUnauthorized = false; + if (this.proxyAgent) opts.agent = this.proxyAgent; + winston.info(`Superset request ${method} ${url}`); + + // TODO: opts.rejectUnauthorized not working, bad workaround for now + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; + const x = await needle(method, url, reqBody, opts); + process.env.NODE_TLS_REJECT_UNAUTHORIZED = undefined; + return x; } } diff --git a/packages/web-config-server/src/apiV1/measureBuilders/valueForOrgGroup.js b/packages/web-config-server/src/apiV1/measureBuilders/valueForOrgGroup.js index ab9386cac6..9f01c2c1c4 100644 --- a/packages/web-config-server/src/apiV1/measureBuilders/valueForOrgGroup.js +++ b/packages/web-config-server/src/apiV1/measureBuilders/valueForOrgGroup.js @@ -55,11 +55,19 @@ class ValueForOrgGroupMeasureBuilder extends DataBuilder { organisationUnitCode: this.entity.code, }); - const analytics = results.map(result => ({ + let analytics = results.map(result => ({ ...result, value: result.value === undefined ? '' : result.value.toString(), })); + // Temp solution for RN-523 + if (this.config.swapFwToFj) { + analytics = analytics.map(analytic => ({ + ...analytic, + organisationUnit: analytic.organisationUnit === 'FPBS' ? 'FJ' : analytic.organisationUnit, + })); + } + // If we group multiple data element codes, dataElementCode is usually 'value' const customDataKey = this.config.dataElementCodes ? dataElementCode : null; diff --git a/yarn.lock b/yarn.lock index 52a348cb32..5df23253bf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5624,6 +5624,9 @@ __metadata: resolution: "@tupaia/superset-api@workspace:packages/superset-api" dependencies: "@tupaia/utils": 1.0.0 + "@types/needle": ^2.5.3 + https-proxy-agent: ^5.0.1 + needle: ^3.1.0 winston: ^3.3.3 languageName: unknown linkType: soft @@ -6360,6 +6363,15 @@ __metadata: languageName: node linkType: hard +"@types/needle@npm:^2.5.3": + version: 2.5.3 + resolution: "@types/needle@npm:2.5.3" + dependencies: + "@types/node": "*" + checksum: d3bf1d456be52f974ed204e06a9fa98c82480f1fa715e8c5944000277d64d5fd16fad9634272ce6c666846bc6892f5839084f923bde58cb2243e4fbab35cb276 + languageName: node + linkType: hard + "@types/node-fetch@npm:^2.5.7": version: 2.5.12 resolution: "@types/node-fetch@npm:2.5.12" @@ -17877,7 +17889,7 @@ __metadata: languageName: node linkType: hard -"https-proxy-agent@npm:5.0.1": +"https-proxy-agent@npm:5.0.1, https-proxy-agent@npm:^5.0.1": version: 5.0.1 resolution: "https-proxy-agent@npm:5.0.1" dependencies: @@ -17967,7 +17979,7 @@ __metadata: languageName: node linkType: hard -"iconv-lite@npm:^0.6.2": +"iconv-lite@npm:^0.6.2, iconv-lite@npm:^0.6.3": version: 0.6.3 resolution: "iconv-lite@npm:0.6.3" dependencies: @@ -24400,6 +24412,19 @@ __metadata: languageName: node linkType: hard +"needle@npm:^3.1.0": + version: 3.1.0 + resolution: "needle@npm:3.1.0" + dependencies: + debug: ^3.2.6 + iconv-lite: ^0.6.3 + sax: ^1.2.4 + bin: + needle: bin/needle + checksum: 662c8a019d0b2b30137f43e1641aa03d96f9da7ce0d3951af8d6d23c1526c123a992d82fcf9f4e68cba6a52e361a7decfb2c71a56cc0e60230248e5a3520f6ad + languageName: node + linkType: hard + "negotiator@npm:0.6.2": version: 0.6.2 resolution: "negotiator@npm:0.6.2" From b51b41926d4ea41497d9e890868c4d1a1cf02142 Mon Sep 17 00:00:00 2001 From: Biao Li <31789355+billli0@users.noreply.github.com> Date: Mon, 17 Oct 2022 11:10:51 +1100 Subject: [PATCH 10/12] Revert "RN-592: Add Multiline To FreeText Question Field (#4207)" (#4222) This reverts commit e76d2da6098ed536cfd0604c6b78faab464d117f. --- .../assessment/specificQuestions/FreeTextQuestion.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/meditrak-app/app/assessment/specificQuestions/FreeTextQuestion.js b/packages/meditrak-app/app/assessment/specificQuestions/FreeTextQuestion.js index 2c810a852f..02681b1019 100644 --- a/packages/meditrak-app/app/assessment/specificQuestions/FreeTextQuestion.js +++ b/packages/meditrak-app/app/assessment/specificQuestions/FreeTextQuestion.js @@ -50,7 +50,7 @@ export class FreeTextQuestion extends Component { this.onFocus()} @@ -58,7 +58,6 @@ export class FreeTextQuestion extends Component { onSubmitEditing={onSubmitEditing} onChangeText={onChangeAnswer} placeholderTextColor={getThemeColorOneFaded(0.7)} - multiline={true} {...restOfTextInputProps} /> @@ -83,7 +82,7 @@ FreeTextQuestion.defaultProps = { const localStyles = StyleSheet.create({ wrapper: { position: 'relative', - marginVertical: 20, + marginVertical: 10, borderBottomWidth: 1, borderBottomColor: getThemeColorOneFaded(0.2), }, @@ -94,9 +93,10 @@ const localStyles = StyleSheet.create({ color: THEME_TEXT_COLOR_ONE, fontFamily: THEME_FONT_FAMILY, fontSize: THEME_FONT_SIZE_ONE, - lineHeight: 25, paddingVertical: 10, - paddingEnd: 28, + }, + textInputFixedHeight: { + height: 40, }, icon: { position: 'absolute', From fc30fc50b21a572cb21d63faddd5d07174b24560 Mon Sep 17 00:00:00 2001 From: Rohan Port <59544282+rohan-bes@users.noreply.github.com> Date: Mon, 17 Oct 2022 15:45:21 +1100 Subject: [PATCH 11/12] [no-issue]: Fix upserting dynamic columns (#4226) --- .../transform/updateColumns.test.ts | 20 +++++++++++++++++++ .../transform/functions/insertColumns.ts | 19 ++++++++++++------ .../transform/functions/updateColumns.ts | 4 +--- 3 files changed, 34 insertions(+), 9 deletions(-) diff --git a/packages/report-server/src/__tests__/reportBuilder/transform/updateColumns.test.ts b/packages/report-server/src/__tests__/reportBuilder/transform/updateColumns.test.ts index 39dbe6cf0e..798c65de54 100644 --- a/packages/report-server/src/__tests__/reportBuilder/transform/updateColumns.test.ts +++ b/packages/report-server/src/__tests__/reportBuilder/transform/updateColumns.test.ts @@ -186,4 +186,24 @@ describe('updateColumns', () => { ]), ); }); + + it('can upsert a column dynamically', () => { + const transform = buildTransform([ + { + transform: 'updateColumns', + insert: { + '=$name': '=$value', + }, + exclude: '*', + }, + ]); + expect( + transform( + TransformTable.fromRows([ + { name: 'value', value: 7 }, + { name: 'total', value: 10 }, + ]), + ), + ).toStrictEqual(TransformTable.fromRows([{ value: 7 }, { total: 10 }])); + }); }); diff --git a/packages/report-server/src/reportBuilder/transform/functions/insertColumns.ts b/packages/report-server/src/reportBuilder/transform/functions/insertColumns.ts index f049d0c313..26504c1775 100644 --- a/packages/report-server/src/reportBuilder/transform/functions/insertColumns.ts +++ b/packages/report-server/src/reportBuilder/transform/functions/insertColumns.ts @@ -6,7 +6,7 @@ import { yup } from '@tupaia/utils'; import { Context } from '../../context'; -import { FieldValue } from '../../types'; +import { FieldValue, Row } from '../../types'; import { TransformParser } from '../parser'; import { buildWhere } from './where'; import { mapStringToStringValidator } from './transformValidators'; @@ -25,9 +25,11 @@ export const paramsValidator = yup.object().shape({ const insertColumns = (table: TransformTable, params: InsertColumnsParams, context: Context) => { const parser = new TransformParser(table, context); const newColumns: Record = {}; - table.getRows().forEach((_, rowIndex) => { + const skippedRows: Record = {}; + table.getRows().forEach((row, rowIndex) => { const shouldEditThisRow = params.where(parser); if (!shouldEditThisRow) { + skippedRows[rowIndex] = row; parser.next(); return; } @@ -36,9 +38,7 @@ const insertColumns = (table: TransformTable, params: InsertColumnsParams, conte const columnName = parser.evaluate(key); const columnValue = parser.evaluate(expression); if (!newColumns[columnName]) { - newColumns[columnName] = table.hasColumn(columnName) - ? table.getColumnValues(columnName) // Upserting a column, so fill with current column values - : new Array(table.length()).fill(undefined); // Creating a new column, so fill with undefined + newColumns[columnName] = new Array(table.length()).fill(undefined); } newColumns[columnName].splice(rowIndex, 1, columnValue); }); @@ -51,7 +51,14 @@ const insertColumns = (table: TransformTable, params: InsertColumnsParams, conte values, })); - return table.upsertColumns(columnUpserts); + // Drop, then re-insert the original skipped rows + const rowsToDrop = Object.keys(skippedRows).map(rowIndexString => parseInt(rowIndexString)); + const rowReinserts = Object.entries(skippedRows).map(([rowIndexString, row]) => ({ + row, + index: parseInt(rowIndexString), + })); + + return table.upsertColumns(columnUpserts).dropRows(rowsToDrop).insertRows(rowReinserts); }; const buildParams = (params: unknown): InsertColumnsParams => { diff --git a/packages/report-server/src/reportBuilder/transform/functions/updateColumns.ts b/packages/report-server/src/reportBuilder/transform/functions/updateColumns.ts index d86ebff0a7..58751f491b 100644 --- a/packages/report-server/src/reportBuilder/transform/functions/updateColumns.ts +++ b/packages/report-server/src/reportBuilder/transform/functions/updateColumns.ts @@ -46,9 +46,7 @@ const updateColumns = (table: TransformTable, params: UpdateColumnsParams, conte const columnName = parser.evaluate(key); const columnValue = parser.evaluate(expression); if (!newColumns[columnName]) { - newColumns[columnName] = table.hasColumn(columnName) - ? table.getColumnValues(columnName) // Upserting a column, so fill with current column values - : new Array(table.length()).fill(undefined); // Creating a new column, so fill with undefined + newColumns[columnName] = new Array(table.length()).fill(undefined); } newColumns[columnName].splice(rowIndex, 1, columnValue); }); From d27f734b072a1015f057b93ba3c5b340a7edd539 Mon Sep 17 00:00:00 2001 From: Rohan Port <59544282+rohan-bes@users.noreply.github.com> Date: Mon, 17 Oct 2022 16:53:43 +1100 Subject: [PATCH 12/12] [no-issue]: Fixed unexpected errors when performing 'insertNumberOfFacilities' alias transform on an empty table (#4227) --- .../reportBuilder/transform/aliases/entityMetadataAliases.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/report-server/src/reportBuilder/transform/aliases/entityMetadataAliases.ts b/packages/report-server/src/reportBuilder/transform/aliases/entityMetadataAliases.ts index f32bcbc192..47634f045e 100644 --- a/packages/report-server/src/reportBuilder/transform/aliases/entityMetadataAliases.ts +++ b/packages/report-server/src/reportBuilder/transform/aliases/entityMetadataAliases.ts @@ -27,6 +27,10 @@ export const insertNumberOfFacilitiesColumn = { ); } + if (table.length() === 0) { + return table; // Skip if the table is empty + } + const organisationUnitValues = table.getColumnValues('organisationUnit'); const numberOfFacilitiesColumnValues = organisationUnitValues.map(organisationUnit => { if (typeof organisationUnit !== 'string') {