From ef913b1d2a5d9fde9170d9d29afaaf5bc385617c Mon Sep 17 00:00:00 2001 From: Igor Date: Fri, 17 Mar 2023 15:22:37 +1100 Subject: [PATCH 01/21] RN-817 Fix inline aggregations (#4385) * Fix inline aggregations * Fix inline aggregations --- .../DataLibrary/AggregationDataLibrary.js | 27 ++++++++++++++----- .../src/VizBuilderApp/context/VizConfig.js | 15 ++++++++--- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/packages/admin-panel/src/VizBuilderApp/components/DataLibrary/AggregationDataLibrary.js b/packages/admin-panel/src/VizBuilderApp/components/DataLibrary/AggregationDataLibrary.js index e7897a1a17..55c4f5eafc 100644 --- a/packages/admin-panel/src/VizBuilderApp/components/DataLibrary/AggregationDataLibrary.js +++ b/packages/admin-panel/src/VizBuilderApp/components/DataLibrary/AggregationDataLibrary.js @@ -10,12 +10,27 @@ import { prefetchAggregationOptions, useSearchAggregationOptions } from '../../a import { AggregateSelectedOptionWithJsonEditor } from './component'; // Converts internal value array to Viz config.aggregate data structure -const aggregateToValue = aggregate => - aggregate.map(({ id, type: code, ...restOfConfig }, index) => ({ - id: id || `${code}-${index}`, // id used by drag and drop function - code, - ...restOfConfig, - })); +const aggregateToValue = aggregate => { + const value = []; + let index = 0; + for (const agg of aggregate) { + if (typeof agg === 'string') { + value.push({ + id: `${agg}-${index}`, // id used by drag and drop function + code: agg, + }); + } else if (typeof agg === 'object') { + const { id, type: code, ...restOfConfig } = agg; + value.push({ + id: id || `${code}-${index}`, // id used by drag and drop function + code, + ...restOfConfig, + }); + } + index++; + } + return value; +}; const valueToAggregate = value => value.map(({ id, code, isDisabled = false, ...restOfConfig }, index) => ({ diff --git a/packages/admin-panel/src/VizBuilderApp/context/VizConfig.js b/packages/admin-panel/src/VizBuilderApp/context/VizConfig.js index 23b8ab02a7..e49803ec66 100644 --- a/packages/admin-panel/src/VizBuilderApp/context/VizConfig.js +++ b/packages/admin-panel/src/VizBuilderApp/context/VizConfig.js @@ -139,9 +139,18 @@ const amendStepsToBaseConfig = visualisation => { const { aggregate, transform } = { ...data }; // Remove frontend config (isDisabled, id, schema) in aggregation steps. const filteredAggregate = Array.isArray(aggregate) - ? aggregate.map(({ isDisabled, id, schema, ...restOfConfig }) => ({ - ...restOfConfig, - })) + ? aggregate.map(agg => { + if (typeof agg === 'string') { + return { + type: agg, + }; + } + if (typeof agg === 'object') { + const { isDisabled, id, schema, ...restOfConfig } = agg; + return restOfConfig; + } + return agg; + }) : aggregate; // Remove frontend configs (isDisabled, id, schema) in transform steps. If it is an alias return as a string. From d56ba32d99a5f1febe1e6e7deed634052ff47d99 Mon Sep 17 00:00:00 2001 From: Biao Li <31789355+biaoli0@users.noreply.github.com> Date: Fri, 17 Mar 2023 17:03:52 +1100 Subject: [PATCH 02/21] RN-756 Set default value for measureLevel (#4370) --- packages/web-frontend/src/selectors/measureSelectors.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web-frontend/src/selectors/measureSelectors.js b/packages/web-frontend/src/selectors/measureSelectors.js index 84f7eec640..ada9b668cf 100644 --- a/packages/web-frontend/src/selectors/measureSelectors.js +++ b/packages/web-frontend/src/selectors/measureSelectors.js @@ -248,7 +248,7 @@ export const selectAreMeasuresOnTheSameEntityLevel = createSelector( } const measureLevelsFromMeasures = Object.values(measureInfo) - .map(({ measureLevel }) => measureLevel) + .map(({ measureLevel = [] }) => measureLevel) .flat(); if (measureLevelsFromMeasures.length <= 1) { return true; From 31b4b1bca483ea0b740575a960a4ea865a48577b Mon Sep 17 00:00:00 2001 From: Biao Li <31789355+biaoli0@users.noreply.github.com> Date: Mon, 20 Mar 2023 13:25:15 +1100 Subject: [PATCH 03/21] No issue: Fix error when importing legacy report (#4390) --- .../dashboardVisualisations/EditDashboardVisualisation.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/central-server/src/apiV2/dashboardVisualisations/EditDashboardVisualisation.js b/packages/central-server/src/apiV2/dashboardVisualisations/EditDashboardVisualisation.js index e5e71b4793..9136a5cd48 100644 --- a/packages/central-server/src/apiV2/dashboardVisualisations/EditDashboardVisualisation.js +++ b/packages/central-server/src/apiV2/dashboardVisualisations/EditDashboardVisualisation.js @@ -84,7 +84,10 @@ export class EditDashboardVisualisation extends EditHandler { async editRecord() { const { report } = this.req.body; - await assertPermissionGroupAccess(this.accessPolicy, report.permission_group); + // Skip permission check as legacy report has no permission group + if (report.permission_group) { + assertPermissionGroupAccess(this.accessPolicy, report.permission_group); + } return this.models.wrapInTransaction(async transactingModels => { const dashboardItemRecord = this.getDashboardItemRecord(); const reportRecord = this.getReportRecord(); From 847ff9f02adc300b271e414c038a299b94296f71 Mon Sep 17 00:00:00 2001 From: Kostas Karvounis Date: Wed, 22 Mar 2023 09:14:10 +1100 Subject: [PATCH 04/21] OSC-19: Add GET entityRelations endpoint in central-server (#4382) --- .../entityRelations/GETEntityRelations.js | 29 +++ .../assertEntityRelationPermissions.js | 54 +++++ .../src/apiV2/entityRelations/index.js | 6 + packages/central-server/src/apiV2/index.js | 6 +- .../GETEntityRelations.test.js | 196 ++++++++++++++++++ 5 files changed, 289 insertions(+), 2 deletions(-) create mode 100644 packages/central-server/src/apiV2/entityRelations/GETEntityRelations.js create mode 100644 packages/central-server/src/apiV2/entityRelations/assertEntityRelationPermissions.js create mode 100644 packages/central-server/src/apiV2/entityRelations/index.js create mode 100644 packages/central-server/src/tests/apiV2/entityRelations/GETEntityRelations.test.js diff --git a/packages/central-server/src/apiV2/entityRelations/GETEntityRelations.js b/packages/central-server/src/apiV2/entityRelations/GETEntityRelations.js new file mode 100644 index 0000000000..489b04ff8b --- /dev/null +++ b/packages/central-server/src/apiV2/entityRelations/GETEntityRelations.js @@ -0,0 +1,29 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + */ + +import { assertAnyPermissions, assertBESAdminAccess } from '../../permissions'; +import { GETHandler } from '../GETHandler'; +import { + assertEntityRelationPermissions, + createEntityRelationDbFilter, +} from './assertEntityRelationPermissions'; + +export class GETEntityRelations extends GETHandler { + permissionsFilteredInternally = true; + + async findSingleRecord(entityRelationId, options) { + const entityRelationPermissionChecker = accessPolicy => + assertEntityRelationPermissions(accessPolicy, this.models, entityRelationId); + await this.assertPermissions( + assertAnyPermissions([assertBESAdminAccess, entityRelationPermissionChecker]), + ); + + return super.findSingleRecord(entityRelationId, options); + } + + async getPermissionsFilter(criteria, options) { + return createEntityRelationDbFilter(this.accessPolicy, criteria, options); + } +} diff --git a/packages/central-server/src/apiV2/entityRelations/assertEntityRelationPermissions.js b/packages/central-server/src/apiV2/entityRelations/assertEntityRelationPermissions.js new file mode 100644 index 0000000000..6fc38cb13d --- /dev/null +++ b/packages/central-server/src/apiV2/entityRelations/assertEntityRelationPermissions.js @@ -0,0 +1,54 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + */ + +import { TYPES } from '@tupaia/database'; +import { hasBESAdminAccess } from '../../permissions'; +import { mergeFilter, mergeMultiJoin } from '../utilities'; + +export const assertEntityRelationPermissions = async (accessPolicy, models, entityRelationId) => { + const entityRelation = await models.entityRelation.findById(entityRelationId); + if (!entityRelation) { + throw new Error(`No entity relation exists with id ${entityRelationId}`); + } + + const child = await models.entity.findById(entityRelation.child_id); + if (!accessPolicy.allows(child.country_code)) { + throw new Error('You do not have permissions for this entity relation'); + } + + return true; +}; + +export const createEntityRelationDbFilter = (accessPolicy, criteria, options) => { + const dbConditions = { ...criteria }; + const dbOptions = { ...options }; + + if (!hasBESAdminAccess(accessPolicy)) { + // Our permissions check logic is simplified based on the following assumptions: + // 1. Each child is associated to a non-empty subset of its parent's countries + // 2. Projects cannot be children of other entities + // + // All valid relations should satisfy the above invariants. + // Note that invalid relations can expose parents that the user doesn't have permissions to, + // e.g. entityInCountryA -> countryB, where the user only has permissions to countryB + dbConditions['child.country_code'] = mergeFilter( + accessPolicy.getEntitiesAllowed(), + dbConditions['child.country_code'], + ); + + dbOptions.multiJoin = mergeMultiJoin( + [ + { + joinWith: TYPES.ENTITY, + joinAs: 'child', + joinCondition: ['entity_relation.child_id', 'child.id'], + }, + ], + dbOptions.multiJoin, + ); + } + + return { dbConditions, dbOptions }; +}; diff --git a/packages/central-server/src/apiV2/entityRelations/index.js b/packages/central-server/src/apiV2/entityRelations/index.js new file mode 100644 index 0000000000..81178cdc9f --- /dev/null +++ b/packages/central-server/src/apiV2/entityRelations/index.js @@ -0,0 +1,6 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + */ + +export { GETEntityRelations } from './GETEntityRelations'; diff --git a/packages/central-server/src/apiV2/index.js b/packages/central-server/src/apiV2/index.js index 20040b90c5..949533f62d 100644 --- a/packages/central-server/src/apiV2/index.js +++ b/packages/central-server/src/apiV2/index.js @@ -23,8 +23,8 @@ import { countChanges } from './countChanges'; import { getChanges } from './getChanges'; import { BESAdminCreateHandler } from './CreateHandler'; import { BESAdminDeleteHandler } from './DeleteHandler'; -import { BESAdminEditHandler } from './EditHandler'; -import { BESAdminGETHandler, TupaiaAdminGETHandler } from './GETHandler'; +import { BESAdminEditHandler, TupaiaAdminGETHandler } from './EditHandler'; +import { BESAdminGETHandler } from './GETHandler'; import { GETCountries } from './GETCountries'; import { GETClinics } from './GETClinics'; import { GETDisasters } from './GETDisasters'; @@ -55,6 +55,7 @@ import { CreateDashboardVisualisation, EditDashboardVisualisation, } from './dashboardVisualisations'; +import { GETEntityRelations } from './entityRelations'; import { DeleteLegacyReport, EditLegacyReport, GETLegacyReports } from './legacyReports'; import { DeleteMapOverlays, EditMapOverlays, GETMapOverlays } from './mapOverlays'; import { @@ -220,6 +221,7 @@ apiV2.get('/dataTables/:recordId?', useRouteHandler(GETDataTables)); apiV2.get('/dataElementDataGroups', useRouteHandler(GETDataElementDataGroups)); apiV2.get('/entities/:recordId?', useRouteHandler(GETEntities)); apiV2.get('/entities/:parentRecordId/surveyResponses', useRouteHandler(GETSurveyResponses)); +apiV2.get('/entityRelations/:recordId?', useRouteHandler(GETEntityRelations)); apiV2.get('/countries/:recordId?', useRouteHandler(GETCountries)); apiV2.get('/clinics/:recordId?', useRouteHandler(GETClinics)); apiV2.get('/facilities/:recordId?', useRouteHandler(GETClinics)); diff --git a/packages/central-server/src/tests/apiV2/entityRelations/GETEntityRelations.test.js b/packages/central-server/src/tests/apiV2/entityRelations/GETEntityRelations.test.js new file mode 100644 index 0000000000..b1d470f6c4 --- /dev/null +++ b/packages/central-server/src/tests/apiV2/entityRelations/GETEntityRelations.test.js @@ -0,0 +1,196 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + */ + +import { expect } from 'chai'; + +import { findOrCreateDummyRecord } from '@tupaia/database'; +import { BES_ADMIN_PERMISSION_GROUP } from '../../../permissions'; +import { TestableApp } from '../../testUtilities'; + +describe('GET entity relations', () => { + const FIJI_POLICY = { + FJ: ['Fiji Admin'], + }; + const BES_ADMIN_POLICY = { + DL: [BES_ADMIN_PERMISSION_GROUP], + }; + + const app = new TestableApp(); + const { models } = app; + let exploreHierarchy; + let exploreToFiji; + let fijiToDistrict; + let kiribatiToDistrict; + + const findOrCreateEntitiesAndRelations = async ({ parent, child, entityHierarchyId }) => { + const parentEntity = await findOrCreateDummyRecord(models.entity, parent); + const childEntity = await findOrCreateDummyRecord(models.entity, child); + const entityRelation = await findOrCreateDummyRecord(models.entityRelation, { + parent_id: parentEntity.id, + child_id: childEntity.id, + entity_hierarchy_id: entityHierarchyId, + }); + + return { + parent: parentEntity, + child: childEntity, + relation: entityRelation, + }; + }; + + before(async () => { + exploreHierarchy = await findOrCreateDummyRecord(models.entityHierarchy, { + name: 'explore', + }); + + exploreToFiji = await findOrCreateEntitiesAndRelations({ + parent: { code: 'explore', country_code: null, type: 'project' }, + child: { code: 'FJ', country_code: 'FJ', type: 'country' }, + entityHierarchyId: exploreHierarchy.id, + }); + fijiToDistrict = await findOrCreateEntitiesAndRelations({ + parent: { code: 'FJ', country_code: 'FJ', type: 'country' }, + child: { code: 'FJ_Eastern', country_code: 'FJ', type: 'district' }, + entityHierarchyId: exploreHierarchy.id, + }); + kiribatiToDistrict = await findOrCreateEntitiesAndRelations({ + parent: { code: 'KI', country_code: 'KI', type: 'country' }, + child: { code: 'KI_Phoenix Islands', country_code: 'KI', type: 'district' }, + entityHierarchyId: exploreHierarchy.id, + }); + }); + + afterEach(() => { + app.revokeAccess(); + }); + + describe('GET /entityRelations/:id', () => { + it('Returns a record if relation id exists and user has permissions: hierarchy -> country relation', async () => { + await app.grantAccess(FIJI_POLICY); + const { body: result } = await app.get(`entityRelations/${exploreToFiji.relation.id}`); + + const expected = { + id: exploreToFiji.relation.id, + parent_id: exploreToFiji.parent.id, + child_id: exploreToFiji.child.id, + entity_hierarchy_id: exploreHierarchy.id, + }; + expect(result).to.deep.equal(expected); + }); + + it('Returns a record if relation id exists and user has permissions: country -> sub-country relation', async () => { + await app.grantAccess(FIJI_POLICY); + const { body: result } = await app.get(`entityRelations/${fijiToDistrict.relation.id}`); + + const expected = { + id: fijiToDistrict.relation.id, + parent_id: fijiToDistrict.parent.id, + child_id: fijiToDistrict.child.id, + entity_hierarchy_id: exploreHierarchy.id, + }; + expect(result).to.deep.equal(expected); + }); + + it('Always returns a record for a BES Admin', async () => { + await app.grantAccess(BES_ADMIN_POLICY); + const { body: result } = await app.get(`entityRelations/${fijiToDistrict.relation.id}`); + + const expected = { + id: fijiToDistrict.relation.id, + parent_id: fijiToDistrict.parent.id, + child_id: fijiToDistrict.child.id, + entity_hierarchy_id: exploreHierarchy.id, + }; + expect(result).to.deep.equal(expected); + }); + + it('Returns an error if resource id is invalid', async () => { + await app.grantAccess(FIJI_POLICY); + const { body: result } = await app.get(`entityRelations/invalid`); + + expect(result.error).to.include('No entity relation exists'); + }); + + it('Returns an error if user does not have access to a country child entity', async () => { + const policy = { + KI: ['Kiribati Admin'], + }; + await app.grantAccess(policy); + const { body: result } = await app.get(`entityRelations/${exploreToFiji.relation.id}`); + + expect(result.error).to.include('You do not have permissions for this entity relation'); + }); + + it('Returns an error if user does not have access to a sub-country child entity', async () => { + const policy = { + KI: ['Kiribati Admin'], + }; + await app.grantAccess(policy); + const { body: result } = await app.get(`entityRelations/${fijiToDistrict.relation.id}`); + + expect(result.error).to.include('You do not have permissions for this entity relation'); + }); + }); + + describe('GET /entityRelations', () => { + it('Returns the relations the users has permissions to', async () => { + await app.grantAccess(FIJI_POLICY); + const { body: result } = await app.get('entityRelations'); + + const expected = [ + { + id: fijiToDistrict.relation.id, + parent_id: fijiToDistrict.parent.id, + child_id: fijiToDistrict.child.id, + entity_hierarchy_id: exploreHierarchy.id, + }, + { + id: exploreToFiji.relation.id, + parent_id: exploreToFiji.parent.id, + child_id: exploreToFiji.child.id, + entity_hierarchy_id: exploreHierarchy.id, + }, + ]; + expect(result).to.deep.equalInAnyOrder(expected); + }); + + it('Returns an empty list if user has no permissions to the requested relations', async () => { + await app.grantAccess({ + DL: ['Public'], + }); + const { body: result } = await app.get('entityRelations'); + + expect(result).to.deep.equal([]); + }); + + it('Supports custom filters', async () => { + await app.grantAccess({ + FJ: ['Fiji Admin'], + KI: ['Kiribati Admin'], + }); + const filterString = `filter={"child_id":{"comparator":"in","comparisonValue":["${[ + fijiToDistrict.child.id, + kiribatiToDistrict.child.id, + ].join('","')}"]}}`; + const { body: result } = await app.get(`entityRelations?${filterString}`); + + const expected = [ + { + id: fijiToDistrict.relation.id, + parent_id: fijiToDistrict.parent.id, + child_id: fijiToDistrict.child.id, + entity_hierarchy_id: exploreHierarchy.id, + }, + { + id: kiribatiToDistrict.relation.id, + parent_id: kiribatiToDistrict.parent.id, + child_id: kiribatiToDistrict.child.id, + entity_hierarchy_id: exploreHierarchy.id, + }, + ]; + expect(result).to.deep.equalInAnyOrder(expected); + }); + }); +}); From 50e16c65d46716baab3b842cf258e9603d847086 Mon Sep 17 00:00:00 2001 From: Kostas Karvounis Date: Thu, 23 Mar 2023 13:04:34 +1100 Subject: [PATCH 05/21] [no-issue]: Fix wrong import (#4394) --- packages/central-server/src/apiV2/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/central-server/src/apiV2/index.js b/packages/central-server/src/apiV2/index.js index 949533f62d..9dfa1d334a 100644 --- a/packages/central-server/src/apiV2/index.js +++ b/packages/central-server/src/apiV2/index.js @@ -23,8 +23,8 @@ import { countChanges } from './countChanges'; import { getChanges } from './getChanges'; import { BESAdminCreateHandler } from './CreateHandler'; import { BESAdminDeleteHandler } from './DeleteHandler'; -import { BESAdminEditHandler, TupaiaAdminGETHandler } from './EditHandler'; -import { BESAdminGETHandler } from './GETHandler'; +import { BESAdminEditHandler } from './EditHandler'; +import { BESAdminGETHandler, TupaiaAdminGETHandler } from './GETHandler'; import { GETCountries } from './GETCountries'; import { GETClinics } from './GETClinics'; import { GETDisasters } from './GETDisasters'; From 9f2c85c4dfb2c72302f73d0010ec6cfe4defdcb8 Mon Sep 17 00:00:00 2001 From: Rohan Port <59544282+rohan-bes@users.noreply.github.com> Date: Thu, 30 Mar 2023 09:58:06 +1100 Subject: [PATCH 06/21] RN-699: Enforce permissions checking on both Data Elements and Entities in data-broker (#4235) --- packages/access-policy/src/AccessPolicy.js | 132 ++++++++++++++ .../src/__tests__/AccessPolicy.test.js | 2 +- packages/access-policy/src/index.js | 128 +------------ packages/data-broker/package.json | 2 + packages/data-broker/src/DataBroker.ts | 169 +++++++++++++----- .../DataBroker/DataBroker.fixtures.ts | 11 +- .../__tests__/DataBroker/DataBroker.stubs.ts | 70 +++++--- .../__tests__/DataBroker/DataBroker.test.ts | 129 ++++++++++++- packages/data-broker/src/services/Service.ts | 1 + yarn.lock | 2 + 10 files changed, 432 insertions(+), 214 deletions(-) create mode 100644 packages/access-policy/src/AccessPolicy.js diff --git a/packages/access-policy/src/AccessPolicy.js b/packages/access-policy/src/AccessPolicy.js new file mode 100644 index 0000000000..83726723d1 --- /dev/null +++ b/packages/access-policy/src/AccessPolicy.js @@ -0,0 +1,132 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2020 Beyond Essential Systems Pty Ltd + */ + +export class AccessPolicy { + constructor(policy) { + this.policy = typeof policy === 'string' ? JSON.parse(policy) : policy; + this.validate(); + this.cachedPermissionGroupSets = {}; + } + + validate() { + if (!this.policy) { + throw new Error('Cannot instantiate an AccessPolicy without providing the policy details'); + } + const permissionGroupLists = Object.values(this.policy); + if (permissionGroupLists.length === 0) { + throw new Error('At least one entity should be specified in an access policy'); + } + if (permissionGroupLists.some(permissionGroups => !Array.isArray(permissionGroups))) { + throw new Error( + 'Each entity should contain an array of permissionGroups for which the user has access', + ); + } + } + + /** + * Check if the user has access to a given permission group for the given entity + * + * @param {string} [entity] + * @param {string} [permissionGroup] + * + * @returns {boolean} Whether or not the user has access to any of the entities, optionally for + * the given permission group + */ + allows(entity, permissionGroup) { + return this.allowsSome([entity], permissionGroup); + } + + /** + * Check if the user has access to a given permission group for all of a given set of entities. + * @param {*} entities + * @param {*} permissionGroup + */ + allowsAll(entities, permissionGroup) { + if (!entities || !entities.length) { + return false; + } + return entities.every(entity => this.allows(entity, permissionGroup)); + } + + /** + * Check if the user has access to a given permission group for any of a given set of entities e.g. + * - has access to some of the given entities with the given permission group + * accessPolicy.allowsSome(['DL', 'DL_North'], 'Donor'); + * + * - has access to the given entities with some permission group + * accessPolicy.allowsSome(['DL']); + * + * @param {string[]} [entities] + * @param {string} [permissionGroup] + * + * @returns {boolean} Whether or not the user has access to any of the entities, optionally for + * the given permission group + */ + allowsSome(entities, permissionGroup) { + if (!entities && !permissionGroup) { + return false; + } + if (!permissionGroup) { + return entities.some(entityCode => !!this.policy[entityCode]); + } + + const allowedPermissionGroups = this.getPermissionGroupsSet(entities); + return allowedPermissionGroups.has(permissionGroup); + } + + /** + * Returns true if the access policy grants access to any entity with the specified permission + * group + * @param {string} permissionGroup + */ + allowsAnywhere(permissionGroup) { + if (!permissionGroup) { + throw new Error('Must provide a permission group when checking allowsAnywhere'); + } + return this.getPermissionGroupsSet().has(permissionGroup); + } + + /** + * Return permission groups the user has access to for the given entities (or all permission + * groups they can access if no entities provided) + * + * @param {string[]} [entities] + * + * @returns {string[]} The permission groups, e.g ['Admin', 'Donor'] + */ + getPermissionGroups(entities) { + return [...this.getPermissionGroupsSet(entities)]; + } + + getPermissionGroupsSet(requestedEntities) { + // if no specific entities were requested, fetch the permissions for all of them + const entities = requestedEntities || Object.keys(this.policy); + // cache this part, as it is run often and is the most expensive operation + const cacheKey = `permissions-${entities.join('-')}`; + if (!this.cachedPermissionGroupSets[cacheKey]) { + const permissionGroups = new Set(); + entities.forEach(entityCode => { + if (this.policy[entityCode]) { + this.policy[entityCode].forEach(r => permissionGroups.add(r)); + } + }); + this.cachedPermissionGroupSets[cacheKey] = permissionGroups; + } + return this.cachedPermissionGroupSets[cacheKey]; + } + + /** + * Return entities the user has access to the given permission group for + * + * @param {string} [permissionGroup] + * + * @returns entities[] The entity objects + */ + getEntitiesAllowed(permissionGroup) { + const allEntityCodes = Object.keys(this.policy); + if (!permissionGroup) return allEntityCodes; + return allEntityCodes.filter(e => this.allows(e, permissionGroup)); + } +} diff --git a/packages/access-policy/src/__tests__/AccessPolicy.test.js b/packages/access-policy/src/__tests__/AccessPolicy.test.js index 91ee0b3664..fc56392074 100644 --- a/packages/access-policy/src/__tests__/AccessPolicy.test.js +++ b/packages/access-policy/src/__tests__/AccessPolicy.test.js @@ -3,7 +3,7 @@ * Copyright (c) 2017 Beyond Essential Systems Pty Ltd */ -import { AccessPolicy } from '..'; +import { AccessPolicy } from '../AccessPolicy'; const policy = { DL: ['Public'], diff --git a/packages/access-policy/src/index.js b/packages/access-policy/src/index.js index 83726723d1..46f6a0e493 100644 --- a/packages/access-policy/src/index.js +++ b/packages/access-policy/src/index.js @@ -3,130 +3,4 @@ * Copyright (c) 2017 - 2020 Beyond Essential Systems Pty Ltd */ -export class AccessPolicy { - constructor(policy) { - this.policy = typeof policy === 'string' ? JSON.parse(policy) : policy; - this.validate(); - this.cachedPermissionGroupSets = {}; - } - - validate() { - if (!this.policy) { - throw new Error('Cannot instantiate an AccessPolicy without providing the policy details'); - } - const permissionGroupLists = Object.values(this.policy); - if (permissionGroupLists.length === 0) { - throw new Error('At least one entity should be specified in an access policy'); - } - if (permissionGroupLists.some(permissionGroups => !Array.isArray(permissionGroups))) { - throw new Error( - 'Each entity should contain an array of permissionGroups for which the user has access', - ); - } - } - - /** - * Check if the user has access to a given permission group for the given entity - * - * @param {string} [entity] - * @param {string} [permissionGroup] - * - * @returns {boolean} Whether or not the user has access to any of the entities, optionally for - * the given permission group - */ - allows(entity, permissionGroup) { - return this.allowsSome([entity], permissionGroup); - } - - /** - * Check if the user has access to a given permission group for all of a given set of entities. - * @param {*} entities - * @param {*} permissionGroup - */ - allowsAll(entities, permissionGroup) { - if (!entities || !entities.length) { - return false; - } - return entities.every(entity => this.allows(entity, permissionGroup)); - } - - /** - * Check if the user has access to a given permission group for any of a given set of entities e.g. - * - has access to some of the given entities with the given permission group - * accessPolicy.allowsSome(['DL', 'DL_North'], 'Donor'); - * - * - has access to the given entities with some permission group - * accessPolicy.allowsSome(['DL']); - * - * @param {string[]} [entities] - * @param {string} [permissionGroup] - * - * @returns {boolean} Whether or not the user has access to any of the entities, optionally for - * the given permission group - */ - allowsSome(entities, permissionGroup) { - if (!entities && !permissionGroup) { - return false; - } - if (!permissionGroup) { - return entities.some(entityCode => !!this.policy[entityCode]); - } - - const allowedPermissionGroups = this.getPermissionGroupsSet(entities); - return allowedPermissionGroups.has(permissionGroup); - } - - /** - * Returns true if the access policy grants access to any entity with the specified permission - * group - * @param {string} permissionGroup - */ - allowsAnywhere(permissionGroup) { - if (!permissionGroup) { - throw new Error('Must provide a permission group when checking allowsAnywhere'); - } - return this.getPermissionGroupsSet().has(permissionGroup); - } - - /** - * Return permission groups the user has access to for the given entities (or all permission - * groups they can access if no entities provided) - * - * @param {string[]} [entities] - * - * @returns {string[]} The permission groups, e.g ['Admin', 'Donor'] - */ - getPermissionGroups(entities) { - return [...this.getPermissionGroupsSet(entities)]; - } - - getPermissionGroupsSet(requestedEntities) { - // if no specific entities were requested, fetch the permissions for all of them - const entities = requestedEntities || Object.keys(this.policy); - // cache this part, as it is run often and is the most expensive operation - const cacheKey = `permissions-${entities.join('-')}`; - if (!this.cachedPermissionGroupSets[cacheKey]) { - const permissionGroups = new Set(); - entities.forEach(entityCode => { - if (this.policy[entityCode]) { - this.policy[entityCode].forEach(r => permissionGroups.add(r)); - } - }); - this.cachedPermissionGroupSets[cacheKey] = permissionGroups; - } - return this.cachedPermissionGroupSets[cacheKey]; - } - - /** - * Return entities the user has access to the given permission group for - * - * @param {string} [permissionGroup] - * - * @returns entities[] The entity objects - */ - getEntitiesAllowed(permissionGroup) { - const allEntityCodes = Object.keys(this.policy); - if (!permissionGroup) return allEntityCodes; - return allEntityCodes.filter(e => this.allows(e, permissionGroup)); - } -} +export { AccessPolicy } from './AccessPolicy'; diff --git a/packages/data-broker/package.json b/packages/data-broker/package.json index 1126e13f57..5d0f368b81 100644 --- a/packages/data-broker/package.json +++ b/packages/data-broker/package.json @@ -21,6 +21,7 @@ "test:debug": "yarn test --debug" }, "dependencies": { + "@tupaia/access-policy": "3.0.0", "@tupaia/data-api": "1.0.0", "@tupaia/data-lake-api": "1.0.0", "@tupaia/database": "1.0.0", @@ -28,6 +29,7 @@ "@tupaia/indicators": "1.0.0", "@tupaia/kobo-api": "1.0.0", "@tupaia/superset-api": "1.0.0", + "@tupaia/tsutils": "1.0.0", "@tupaia/utils": "1.0.0", "@tupaia/weather-api": "1.0.0", "case": "^1.5.5", diff --git a/packages/data-broker/src/DataBroker.ts b/packages/data-broker/src/DataBroker.ts index 65949cefea..8d7c3b5d84 100644 --- a/packages/data-broker/src/DataBroker.ts +++ b/packages/data-broker/src/DataBroker.ts @@ -4,6 +4,7 @@ */ import { lower } from 'case'; +import groupBy from 'lodash.groupby'; import type { AccessPolicy } from '@tupaia/access-policy'; import { ModelRegistry, TupaiaDatabase } from '@tupaia/database'; @@ -14,13 +15,13 @@ import { Analytic, AnalyticResults as RawAnalyticResults, DataBrokerModelRegistry, - DataElement, DataSource, DataSourceTypeInstance, DataSourceType, EventResults, ServiceType, SyncGroupResults, + DataElement, } from './types'; import { DATA_SOURCE_TYPES, EMPTY_ANALYTICS_RESULTS } from './utils'; import { DataServiceMapping } from './services/DataServiceMapping'; @@ -57,7 +58,12 @@ type ResultMerger = type Fetcher = (dataSourceSpec: FetchConditions) => Promise; -type PermissionChecker = ((dataSources: DataSource[]) => Promise) | (() => boolean); +type PermissionChecker = ( + dataSources: DataSource[], + organisationUnitCodes?: string[], +) => Promise; + +type ValidatedOptions = { organisationUnitCodes?: string[] } & Record; let modelRegistry: DataBrokerModelRegistry; @@ -68,15 +74,27 @@ const getModelRegistry = () => { return modelRegistry; }; -const getPermissionListWithWildcard = async (accessPolicy?: AccessPolicy) => { +const getPermissionListWithWildcard = (accessPolicy?: AccessPolicy, countryCodes?: string[]) => { // Get the users permission groups as a list of codes if (!accessPolicy) { return ['*']; } - const userPermissionGroups = accessPolicy.getPermissionGroups(); + const userPermissionGroups = accessPolicy.getPermissionGroups(countryCodes); return ['*', ...userPermissionGroups]; }; +const setOrganisationUnitCodes = ( + options: Record & { + organisationUnitCode?: string; + organisationUnitCodes?: string[]; + }, +) => { + const { organisationUnitCode, organisationUnitCodes, ...restOfOptions } = options; + const orgUnitCodes = + organisationUnitCodes || (organisationUnitCode ? [organisationUnitCode] : undefined); + return { ...restOfOptions, organisationUnitCodes: orgUnitCodes }; +}; + export class DataBroker { public readonly context: Context; @@ -85,7 +103,6 @@ export class DataBroker { private readonly resultMergers: Record; private readonly fetchers: Record; private readonly permissionCheckers: Record; - private userPermissions: string[] | undefined; public constructor(context = {}) { this.context = context; @@ -111,11 +128,8 @@ export class DataBroker { }; } - private async getUserPermissions() { - if (!this.userPermissions) { - this.userPermissions = await getPermissionListWithWildcard(this.context.accessPolicy); - } - return this.userPermissions; + private getUserPermissions(countryCodes?: string[]) { + return getPermissionListWithWildcard(this.context.accessPolicy, countryCodes); } public async close() { @@ -144,46 +158,105 @@ export class DataBroker { return syncGroups.map(sg => ({ ...sg, type: this.getDataSourceTypes().SYNC_GROUP })); }; - private checkDataElementPermissions = async (dataElements: DataSource[]) => { - const userPermissions = await this.getUserPermissions(); - if (userPermissions.includes(BES_ADMIN_PERMISSION_GROUP)) { - return true; + private getOrganisationUnitsByCountry = async (organisationUnitCodes: string[]) => { + const orgUnits = await this.models.entity.find({ code: organisationUnitCodes }); + const organisationUnitCodesByCountryCodes = Object.fromEntries( + Object.entries(groupBy(orgUnits, 'country_code')).map(([countryCode, orgUnitsInCountry]) => [ + countryCode, + orgUnitsInCountry.map(({ code }) => code), + ]), + ); + return organisationUnitCodesByCountryCodes; + }; + + private checkDataElementPermissions = async ( + dataElements: DataSource[], + organisationUnitCodes?: string[], + ) => { + const allUserPermissions = this.getUserPermissions(); + if (allUserPermissions.includes(BES_ADMIN_PERMISSION_GROUP)) { + return organisationUnitCodes; } - const missingPermissions = []; - for (const element of dataElements as DataElement[]) { - if ( - element.permission_groups.length <= 0 || - element.permission_groups.some(code => userPermissions.includes(code)) - ) { - continue; + + const getDataElementsWithMissingPermissions = (permissions: string[]) => + (dataElements as DataElement[]) + .filter(element => element.permission_groups.length > 0) + .filter(element => !element.permission_groups.some(group => permissions.includes(group))) + .map(element => element.code); + + if (!organisationUnitCodes) { + const missingPermissions = getDataElementsWithMissingPermissions(allUserPermissions); + if (missingPermissions.length > 0) { + throw new Error( + `Missing permissions to the following data elements: ${missingPermissions}`, + ); } - missingPermissions.push(element.code); + + return organisationUnitCodes; } - if (missingPermissions.length === 0) { - return true; + + const organisationUnitsByCountry = await this.getOrganisationUnitsByCountry( + organisationUnitCodes, + ); + const countryCodes = Object.keys(organisationUnitsByCountry); + + let organisationUnitsWithPermission: string[] = []; + const countriesMissingPermission = Object.fromEntries( + dataElements.map(({ code }) => [code, [] as string[]]), + ); + countryCodes.forEach(country => { + const missingPermissions = getDataElementsWithMissingPermissions( + this.getUserPermissions([country]), + ); + if (missingPermissions.length === 0) { + // Have access to all data elements for country + organisationUnitsWithPermission = organisationUnitsWithPermission.concat( + organisationUnitsByCountry[country], + ); + } + + missingPermissions.forEach(dataElement => + countriesMissingPermission[dataElement].push(country), + ); + }); + + if (organisationUnitsWithPermission.length === 0) { + const dataElementsWithNoAccess = Object.entries(countriesMissingPermission) + .filter(([, countries]) => countries.length === countryCodes.length) + .map(([dataElement]) => dataElement); + throw new Error( + `Missing permissions to the following data elements:\n${dataElementsWithNoAccess}`, + ); } - throw new Error(`Missing permissions to the following data elements: ${missingPermissions}`); + + return organisationUnitsWithPermission; }; - private checkDataGroupPermissions = async (dataGroups: DataSource[]) => { + private checkDataGroupPermissions = async ( + dataGroups: DataSource[], + organisationUnitCodes?: string[], + ) => { const missingPermissions = []; for (const group of dataGroups) { const dataElements = await this.models.dataGroup.getDataElementsInDataGroup(group.code); try { - await this.checkDataElementPermissions(dataElements); + await this.checkDataElementPermissions(dataElements, organisationUnitCodes); } catch { missingPermissions.push(group.code); } } if (missingPermissions.length === 0) { - return true; + return organisationUnitCodes; } throw new Error(`Missing permissions to the following data groups: ${missingPermissions}`); }; // No check for syncGroups currently - private checkSyncGroupPermissions = () => { - return true; + private checkSyncGroupPermissions = async ( + syncGroups: DataSource[], + organisationUnitCodes?: string[], + ) => { + return organisationUnitCodes; }; private async fetchDataSources(dataSourceSpec: DataSourceSpec) { @@ -265,17 +338,14 @@ export class DataBroker { public async pull(dataSourceSpec: DataSourceSpec, options: Record = {}) { const dataSources = await this.fetchDataSources(dataSourceSpec); const { type } = dataSourceSpec; - const { organisationUnitCode, organisationUnitCodes } = options; - const orgUnitCodes = - (organisationUnitCodes as string[]) || - (organisationUnitCode ? [organisationUnitCode as string] : null); + const validatedOptions = setOrganisationUnitCodes(options); - const pulls = await this.getPulls(dataSources, orgUnitCodes); + const pulls = await this.getPulls(dataSources, validatedOptions.organisationUnitCodes); const nestedResults = await Promise.all( pulls.map(({ dataSources: dataSourcesForThisPull, serviceType, dataServiceMapping }) => { return this.pullForServiceAndType( dataSourcesForThisPull, - options, + validatedOptions, type, serviceType, dataServiceMapping, @@ -293,16 +363,24 @@ export class DataBroker { private pullForServiceAndType = async ( dataSources: DataSource[], - options: Record, + options: ValidatedOptions, type: DataSourceType, serviceType: ServiceType, dataServiceMapping: DataServiceMapping, ) => { + const { organisationUnitCodes } = options; const permissionChecker = this.permissionCheckers[type]; - // Permission checkers will throw if they fail - await permissionChecker(dataSources); + // Permission checkers will throw if no access to any organisationUnits + const organisationUnitCodesWithAccess = await permissionChecker( + dataSources, + organisationUnitCodes, + ); const service = this.createService(serviceType); - return service.pull(dataSources, type, { ...options, dataServiceMapping }); + return service.pull(dataSources, type, { + ...options, + dataServiceMapping, + organisationUnitCodes: organisationUnitCodesWithAccess, + }); }; private mergeAnalytics = ( @@ -394,7 +472,7 @@ export class DataBroker { private async getPulls( dataSources: DataSourceTypeInstance[], - orgUnitCodes: string[] | null, + orgUnitCodes?: string[], ): Promise< { dataSources: DataSource[]; @@ -403,7 +481,7 @@ export class DataBroker { }[] > { // Special case where no org unit is provided - if (orgUnitCodes === null) { + if (!orgUnitCodes) { const pulls = []; const mapping = await this.dataServiceResolver.getMapping(dataSources); for (const serviceType of mapping.uniqueServiceTypes()) { @@ -416,8 +494,6 @@ export class DataBroker { return pulls; } - const orgUnits = await this.models.entity.find({ code: orgUnitCodes }); - // Note: each service will pull for ALL org units and ALL data sources. // This will likely lead to problems in the future, for now this is ok because // our services happily ignore extra org units, and our vizes do not ask for @@ -428,10 +504,7 @@ export class DataBroker { // First we get the mapping for each country, then if any two countries have the // exact same mapping we simply combine them - const orgUnitCountryCodes = orgUnits - .map(orgUnit => orgUnit.country_code) - .filter(countryCode => countryCode !== null && countryCode !== undefined) as string[]; - const countryCodes = [...new Set(orgUnitCountryCodes)]; + const countryCodes = Object.keys(await this.getOrganisationUnitsByCountry(orgUnitCodes)); if (countryCodes.length === 1) { // No special logic needed, exit early diff --git a/packages/data-broker/src/__tests__/DataBroker/DataBroker.fixtures.ts b/packages/data-broker/src/__tests__/DataBroker/DataBroker.fixtures.ts index f798414447..196cf67ef8 100644 --- a/packages/data-broker/src/__tests__/DataBroker/DataBroker.fixtures.ts +++ b/packages/data-broker/src/__tests__/DataBroker/DataBroker.fixtures.ts @@ -14,6 +14,11 @@ export const DATA_ELEMENTS = dataElementTypes({ TUPAIA_01: { code: 'TUPAIA_01', service_type: 'tupaia' }, MAPPED_01: { code: 'MAPPED_01', service_type: 'dhis' }, MAPPED_02: { code: 'MAPPED_02', service_type: 'dhis' }, + RESTRICTED_01: { + code: 'RESTRICTED_01', + service_type: 'tupaia', + permission_groups: ['Admin'], + }, }); export const DATA_GROUPS = dataGroupTypes({ DHIS_PROGRAM_01: { code: 'DHIS_PROGRAM_01', service_type: 'dhis' }, @@ -59,7 +64,11 @@ export const DATA_BY_SERVICE = { ], }, tupaia: { - analytics: [{ dataElement: 'TUPAIA_01', organisationUnit: 'TO', period: '20210101', value: 3 }], + analytics: [ + { dataElement: 'TUPAIA_01', organisationUnit: 'TO', period: '20210101', value: 3 }, + { dataElement: 'RESTRICTED_01', organisationUnit: 'TO', period: '20210101', value: 4 }, + { dataElement: 'RESTRICTED_01', organisationUnit: 'FJ', period: '20210101', value: 5 }, + ], eventsByProgram: { TUPAIA_PROGRAM_01: [ { diff --git a/packages/data-broker/src/__tests__/DataBroker/DataBroker.stubs.ts b/packages/data-broker/src/__tests__/DataBroker/DataBroker.stubs.ts index ef91cf6131..7663da5d0e 100644 --- a/packages/data-broker/src/__tests__/DataBroker/DataBroker.stubs.ts +++ b/packages/data-broker/src/__tests__/DataBroker/DataBroker.stubs.ts @@ -41,36 +41,50 @@ export class MockService extends Service { return this; } - public pull = jest.fn().mockImplementation((dataSources: DataSource[], type: DataSourceType) => { - const { analytics, eventsByProgram, dataElements } = this.mockData; - const dataSourceCodes = dataSources.map(({ code }) => code); + public pull = jest + .fn() + .mockImplementation( + ( + dataSources: DataSource[], + type: DataSourceType, + options: { organisationUnitCodes?: string[] }, + ) => { + const { analytics, eventsByProgram, dataElements } = this.mockData; + const { organisationUnitCodes } = options; + const dataSourceCodes = dataSources.map(({ code }) => code); - switch (type) { - case 'dataElement': { - const results = analytics.filter(({ dataElement }) => - dataSourceCodes.includes(dataElement), - ); - const selectedDataElements = dataElements.filter(({ code }) => - dataSourceCodes.includes(code), - ); - const dataElementCodeToName = reduceToDictionary(selectedDataElements, 'code', 'name'); + switch (type) { + case 'dataElement': { + let results = analytics.filter(({ dataElement }) => + dataSourceCodes.includes(dataElement), + ); + if (organisationUnitCodes) { + results = results.filter(({ organisationUnit }) => + organisationUnitCodes.includes(organisationUnit), + ); + } + const selectedDataElements = dataElements.filter(({ code }) => + dataSourceCodes.includes(code), + ); + const dataElementCodeToName = reduceToDictionary(selectedDataElements, 'code', 'name'); - return { - results, - metadata: { - dataElementCodeToName, - }, - }; - } - case 'dataGroup': { - return Object.entries(eventsByProgram) - .filter(([program]) => dataSourceCodes.includes(program)) - .flatMap(([, events]) => events); - } - default: - throw new Error(`Invalid data source type: ${type}`); - } - }); + return { + results, + metadata: { + dataElementCodeToName, + }, + }; + } + case 'dataGroup': { + return Object.entries(eventsByProgram) + .filter(([program]) => dataSourceCodes.includes(program)) + .flatMap(([, events]) => events); + } + default: + throw new Error(`Invalid data source type: ${type}`); + } + }, + ); public push = jest.fn(); diff --git a/packages/data-broker/src/__tests__/DataBroker/DataBroker.test.ts b/packages/data-broker/src/__tests__/DataBroker/DataBroker.test.ts index 19eb6c62f8..a955997e45 100644 --- a/packages/data-broker/src/__tests__/DataBroker/DataBroker.test.ts +++ b/packages/data-broker/src/__tests__/DataBroker/DataBroker.test.ts @@ -3,6 +3,7 @@ * Copyright (c) 2017 - 2020 Beyond Essential Systems Pty Ltd */ +import { AccessPolicy } from '@tupaia/access-policy'; import { DataBroker } from '../../DataBroker'; import { Service } from '../../services/Service'; import { DataElement, ServiceType } from '../../types'; @@ -33,7 +34,7 @@ describe('DataBroker', () => { tupaia: new MockService(mockModels).setMockData(DATA_BY_SERVICE.tupaia), }; const createServiceMock = stubCreateService(SERVICES); - const options = { organisationUnitCode: 'TO' }; + const options = { organisationUnitCodes: ['TO'] }; it('getDataSourceTypes()', () => { expect(new DataBroker().getDataSourceTypes()).toStrictEqual(DATA_SOURCE_TYPES); @@ -185,22 +186,22 @@ describe('DataBroker', () => { const assertServicePulledDataElementsOnce = ( service: Service, dataElements: DataElement[], - expectedOptions: Record, + expectedOrganisationUnitCodes: string[], ) => expect(service.pull).toHaveBeenCalledOnceWith( dataElements, 'dataElement', - expect.objectContaining(expectedOptions), + expect.objectContaining({ + organisationUnitCodes: expect.arrayContaining(expectedOrganisationUnitCodes), + }), ); it('pulls from different data services for the same data element', async () => { const dataBroker = new DataBroker(); - const multipleCountryOptions = { - organisationUnitCodes: ['FJ_FACILITY_01', 'TO_FACILITY_01'], - }; + const multipleCountryFacilities = ['FJ_FACILITY_01', 'TO_FACILITY_01']; await dataBroker.pull( { code: ['MAPPED_01', 'MAPPED_02'], type: 'dataElement' }, - multipleCountryOptions, + { organisationUnitCodes: multipleCountryFacilities }, ); expect(createServiceMock).toHaveBeenCalledTimes(2); @@ -218,12 +219,12 @@ describe('DataBroker', () => { assertServicePulledDataElementsOnce( SERVICES.dhis, [DATA_ELEMENTS.MAPPED_01, DATA_ELEMENTS.MAPPED_02], // all data elements - multipleCountryOptions, // all org units + multipleCountryFacilities, // all org units ); assertServicePulledDataElementsOnce( SERVICES.tupaia, [DATA_ELEMENTS.MAPPED_01, DATA_ELEMENTS.MAPPED_02], // all data elements - multipleCountryOptions, // all org units + multipleCountryFacilities, // all org units ); }); }); @@ -329,6 +330,116 @@ describe('DataBroker', () => { ).toResolve()); }); + describe('permissions', () => { + it("throws an error if fetching for a data element the user doesn't have required permissions for", async () => { + await expect( + new DataBroker({ accessPolicy: new AccessPolicy({ DL: ['Public'] }) }).pull( + { + code: ['RESTRICTED_01'], + type: 'dataElement', + }, + {}, + ), + ).toBeRejectedWith('Missing permissions to the following data elements: RESTRICTED_01'); + }); + + it("throws an error if fetching for a data element in an entity the user doesn't have required permissions for", async () => { + await expect( + new DataBroker({ accessPolicy: new AccessPolicy({ DL: ['Admin'] }) }).pull( + { + code: ['RESTRICTED_01'], + type: 'dataElement', + }, + { organisationUnitCodes: ['TO'] }, + ), + ).toBeRejectedWith('Missing permissions to the following data elements:\nRESTRICTED_01'); + }); + + it("throws an error if any of data elements in fetch the user doesn't have required permissions for", async () => { + await expect( + new DataBroker({ accessPolicy: new AccessPolicy({ TO: ['Public'] }) }).pull( + { + code: ['TUPAIA_01', 'RESTRICTED_01'], + type: 'dataElement', + }, + { organisationUnitCodes: ['TO'] }, + ), + ).toBeRejectedWith(`Missing permissions to the following data elements:\nRESTRICTED_01`); + }); + + it("doesn't throw if the user has BES Admin access", async () => { + await expect( + new DataBroker({ accessPolicy: new AccessPolicy({ DL: ['BES Admin'] }) }).pull( + { + code: ['RESTRICTED_01'], + type: 'dataElement', + }, + { organisationUnitCodes: ['TO'] }, + ), + ).toResolve(); + }); + + it("doesn't throw if the user has access to the data element in the requested entity", async () => { + const results = await new DataBroker({ + accessPolicy: new AccessPolicy({ TO: ['Admin'] }), + }).pull( + { + code: ['RESTRICTED_01'], + type: 'dataElement', + }, + { organisationUnitCodes: ['TO'] }, + ); + expect(results).toEqual({ + results: [ + { + analytics: [ + { + dataElement: 'RESTRICTED_01', + organisationUnit: 'TO', + period: '20210101', + value: 4, + }, + ], + numAggregationsProcessed: 0, + }, + ], + metadata: { + dataElementCodeToName: {}, + }, + }); + }); + + it('just returns data for entities that the user have appropriate access to', async () => { + const results = await new DataBroker({ + accessPolicy: new AccessPolicy({ FJ: ['Admin'] }), + }).pull( + { + code: ['RESTRICTED_01'], + type: 'dataElement', + }, + { organisationUnitCodes: ['TO', 'FJ'] }, + ); + expect(results).toEqual({ + results: [ + { + analytics: [ + { + dataElement: 'RESTRICTED_01', + organisationUnit: 'FJ', + period: '20210101', + value: 5, + }, + ], + numAggregationsProcessed: 0, + }, + ], + metadata: { + dataElementCodeToName: {}, + }, + }); + }); + }); + describe('analytics', () => { it('single code', async () => { const dataBroker = new DataBroker(); diff --git a/packages/data-broker/src/services/Service.ts b/packages/data-broker/src/services/Service.ts index 8e17e6d99d..8b1ad4162c 100644 --- a/packages/data-broker/src/services/Service.ts +++ b/packages/data-broker/src/services/Service.ts @@ -28,6 +28,7 @@ export type DeleteOptions = { export type PullOptions = { dataServiceMapping: DataServiceMapping; + organisationUnitCodes?: string[]; }; export type PullMetadataOptions = { diff --git a/yarn.lock b/yarn.lock index ac816fc42c..fa0dc1d103 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5240,6 +5240,7 @@ __metadata: version: 0.0.0-use.local resolution: "@tupaia/data-broker@workspace:packages/data-broker" dependencies: + "@tupaia/access-policy": 3.0.0 "@tupaia/data-api": 1.0.0 "@tupaia/data-lake-api": 1.0.0 "@tupaia/database": 1.0.0 @@ -5247,6 +5248,7 @@ __metadata: "@tupaia/indicators": 1.0.0 "@tupaia/kobo-api": 1.0.0 "@tupaia/superset-api": 1.0.0 + "@tupaia/tsutils": 1.0.0 "@tupaia/utils": 1.0.0 "@tupaia/weather-api": 1.0.0 "@types/lodash.flatten": ^4.4.0 From 7f8f3cec344303cb4e37f24956b17a09b5d4f542 Mon Sep 17 00:00:00 2001 From: Biao Li <31789355+biaoli0@users.noreply.github.com> Date: Fri, 31 Mar 2023 10:39:44 +1100 Subject: [PATCH 07/21] MauiDev-116 Add cicd check for out of date (#4409) --- codeship-services.yml | 7 +- codeship-steps.yml | 2 + packages/devops/ci/validation.Dockerfile | 21 +++++ packages/devops/scripts/ci/utils.sh | 10 +++ .../devops/scripts/ci/validateBranchName.sh | 11 --- .../scripts/ci/validateNewMigrations.sh | 77 +++++++++++++++++++ 6 files changed, 112 insertions(+), 16 deletions(-) create mode 100644 packages/devops/ci/validation.Dockerfile create mode 100755 packages/devops/scripts/ci/validateNewMigrations.sh diff --git a/codeship-services.yml b/codeship-services.yml index e3bd8e2aa7..8baf662feb 100644 --- a/codeship-services.yml +++ b/codeship-services.yml @@ -1,13 +1,10 @@ # 'validation' service checks: # - branch name # - that there are no exclusive tests -# - TODO: strip this down to a tiny image that just checks branch name, and use eslint no-exclusive-tests flag -# at lint step after RN-201 +# - TODO: use eslint no-exclusive-tests flag at lint step after RN-201 validation: build: - dockerfile: ./packages/devops/ci/tupaia-ci-cd.Dockerfile - cached: true - default_cache_branch: 'dev' + dockerfile: ./packages/devops/ci/validation.Dockerfile encrypted_dockercfg_path: ./packages/devops/ci/dockercfg.encrypted testing: diff --git a/codeship-steps.yml b/codeship-steps.yml index 5262c85153..9d51f983ff 100644 --- a/codeship-steps.yml +++ b/codeship-steps.yml @@ -8,6 +8,8 @@ steps: - name: Validate branch name command: './packages/devops/scripts/ci/validateBranchName.sh' + - name: Validate new migrations + command: './packages/devops/scripts/ci/validateNewMigrations.sh' - name: Validate tests command: './packages/devops/scripts/ci/validateTests.sh' - type: serial diff --git a/packages/devops/ci/validation.Dockerfile b/packages/devops/ci/validation.Dockerfile new file mode 100644 index 0000000000..6cba1ceac0 --- /dev/null +++ b/packages/devops/ci/validation.Dockerfile @@ -0,0 +1,21 @@ +FROM node:14.19.3-alpine3.15 + +# install features not available in base alpine distro +RUN apk --no-cache add \ + bash \ + git + +# set Yarn v3 +RUN yarn set version berry + +# set the workdir so that all following commands run within /tupaia +WORKDIR /tupaia + +# copy everything else from the repo +COPY . ./ + +# run yarn without building, so we can cache node_modules without code changes invalidating this layer +RUN SKIP_BUILD_INTERNAL_DEPENDENCIES=true yarn install --frozen-lockfile + +# /scripts/node/validateTests.js use utils package +RUN yarn workspace @tupaia/utils build \ No newline at end of file diff --git a/packages/devops/scripts/ci/utils.sh b/packages/devops/scripts/ci/utils.sh index 275ded3988..f0aa34140c 100755 --- a/packages/devops/scripts/ci/utils.sh +++ b/packages/devops/scripts/ci/utils.sh @@ -36,3 +36,13 @@ function get_max_length() { echo $max } + +function get_branch_name() { + local branch_name="$CI_BRANCH" + if [[ $branch_name == "" ]]; then + # Get currently checked out branch + branch_name=$(git rev-parse --abbrev-ref HEAD) + fi + + echo $branch_name +} \ No newline at end of file diff --git a/packages/devops/scripts/ci/validateBranchName.sh b/packages/devops/scripts/ci/validateBranchName.sh index 8ecf7e688d..e4e008d3a6 100755 --- a/packages/devops/scripts/ci/validateBranchName.sh +++ b/packages/devops/scripts/ci/validateBranchName.sh @@ -13,17 +13,6 @@ MAX_SUBDOMAIN_SUFFIX_LENGTH=$(get_max_length "${SUBDOMAIN_SUFFIXES[@]}") MAX_BRANCH_NAME_LENGTH=$((MAX_SUBDOMAIN_LENGTH - ${MAX_SUBDOMAIN_SUFFIX_LENGTH} - 1)) # Subtract 1 for the connecting `-` # As of 11/08/21, MAX_BRANCH_NAME_LENGTH = 64 - 17 - 1 = 46 (Longest subdomain "tonga-aggregation") - -function get_branch_name() { - local branch_name="$CI_BRANCH" - if [[ $branch_name == "" ]]; then - # Get currently checked out branch - branch_name=$(git rev-parse --abbrev-ref HEAD) - fi - - echo $branch_name -} - function validate_name_ending() { local branch_name=$1 diff --git a/packages/devops/scripts/ci/validateNewMigrations.sh b/packages/devops/scripts/ci/validateNewMigrations.sh new file mode 100755 index 0000000000..4cf3a478c4 --- /dev/null +++ b/packages/devops/scripts/ci/validateNewMigrations.sh @@ -0,0 +1,77 @@ +#!/bin/bash -e + +DIR=$(dirname "$0") +ROOT="${DIR}/../../../../" + +. ${DIR}/utils.sh + +function get_date_command() { + if [[ $(uname) == "Darwin" ]]; then + echo "gdate" # install gdate on MacOs: brew install coreutils + else + echo "date" + fi +} + +date_command=$(get_date_command) + +function convert_timestamp_to_date() { + local timestamp=$1 + local date=$($date_command -d @$timestamp '+%Y-%m-%d') + echo $date +} + + +function check_migration_outdated() { + local migration_name=$1 + + included_date_offset=$((90*24*60*60*1000)) # include migrations up to 90 days old + included_migrations_timestamp=$(( $($date_command +%s) - included_date_offset / 1000 )) + valid_migration_date=$(convert_timestamp_to_date "$included_migrations_timestamp") + + year=${migration_name:33:4} + month=${migration_name:37:2} + day=${migration_name:39:2} + migration_timestamp=$($date_command -d "${year}-${month}-${day}" +%s) + + if (( $migration_timestamp < $included_migrations_timestamp )); then + log_error "❌ New migration should be created after $valid_migration_date. Invalid migration name: '$migration_name'" + fi +} + +function validate_migrations(){ + local current_branch_name=$1 + local origin_branch_name=$2 + local migrations_dir="${ROOT}/packages/database/src/migrations" + local new_migration_names_in_string=$(git diff --diff-filter=A --name-only $origin_branch_name $current_branch_name $migrations_dir) + local errors=""; + + while read -r migration_name; do + if [[ "$migration_name" == "" ]]; then + break + fi + errors="$errors$(check_migration_outdated "$migration_name")" + done <<< "$new_migration_names_in_string" + + if [[ "$errors" != "" ]]; then + echo $errors; + exit 1; + fi +} + +current_branch_name=$(get_branch_name) +origin_branch_name="master" + +# Prevent error The authenticity of host 'github.com' can't be established. +# Long version: the git origin copied from codeship is using ssh, but the container doesn't have ssh setup. The quick way is to swith to https. +git remote remove origin +git remote add origin https://github.com/beyondessential/tupaia.git +# Remove this sub module because it uses ssh +git rm $ROOT/packages/data-api/scripts/pg-mv-fast-refresh + +git fetch --quiet +git fetch origin $origin_branch_name:$origin_branch_name --quiet +validate_migrations $current_branch_name $origin_branch_name + +log_success "✔ New migrations are valid!" +exit 0 \ No newline at end of file From bbef7a667a929cdfaa75c866d6f71f24a9bd0fe7 Mon Sep 17 00:00:00 2001 From: Kostas Karvounis Date: Mon, 3 Apr 2023 15:48:44 +1000 Subject: [PATCH 08/21] [no-issue]: Fix test database setup script (#4365) --- packages/database/scripts/setupTestDatabase.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/database/scripts/setupTestDatabase.sh b/packages/database/scripts/setupTestDatabase.sh index 6d5bf21e60..041d994e04 100755 --- a/packages/database/scripts/setupTestDatabase.sh +++ b/packages/database/scripts/setupTestDatabase.sh @@ -14,6 +14,7 @@ fi PGPASSWORD=$DB_PG_PASSWORD psql -h $DB_URL -p $DB_PORT -U $DB_PG_USER -c "DROP DATABASE IF EXISTS $DB_NAME" PGPASSWORD=$DB_PG_PASSWORD psql -h $DB_URL -p $DB_PORT -U $DB_PG_USER -c "CREATE DATABASE $DB_NAME WITH OWNER $DB_USER" +PGPASSWORD=$DB_PG_PASSWORD psql -h $DB_URL -p $DB_PORT -U $DB_PG_USER -c "CREATE EXTENSION IF NOT EXISTS postgis" PGPASSWORD=$DB_PG_PASSWORD psql -h $DB_URL -p $DB_PORT -U $DB_PG_USER -c "ALTER USER $DB_USER WITH SUPERUSER" PGPASSWORD=$DB_PASSWORD psql -h $DB_URL -p $DB_PORT -U $DB_USER -d $DB_NAME -f ./src/__tests__/testData/testDataDump.sql PGPASSWORD=$DB_PG_PASSWORD psql -h $DB_URL -p $DB_PORT -U $DB_PG_USER -c "ALTER USER $DB_USER WITH NOSUPERUSER" @@ -27,7 +28,7 @@ DB_NAME=$DB_NAME yarn workspace @tupaia/data-api build-analytics-table echo "Deleting migrations that target data modifications, as there is no data to migrate on the test database" rm -rf ./src/migrations-backup -mkdir ./src/migrations-backup +mkdir ./src/migrations-backup cp -r ./src/migrations/* ./src/migrations-backup/ rm ./src/migrations/*modifies-data.js DB_NAME=$DB_NAME yarn migrate From 8529846f0a203407db972a2c21244f6ff2998b49 Mon Sep 17 00:00:00 2001 From: Igor Date: Tue, 4 Apr 2023 10:26:32 +1000 Subject: [PATCH 09/21] Data Tables Phase 1 (#4318) * RN-728 Create basic data table (#4313) * Update redux dev tools config Required after Chrome update See https://github.com/reduxjs/redux-devtools/tree/main/extension#installation * Re-generate types * Add DataTable create route * Allow EditModal to have custom content, extra dialog props * Add DataTable create modal * Use snake_case in @types pkg * Fix test mock * RN-729 RN-731 RN-732 Data table frontend components (#4312) * RN-730 edit data table (#4336) * Simplify redux actions around edit modal * Data Table editing * Update create data table backend after permission group id -> name change * RN-733 Add preview to datatable (#4338) * Add fetch preview data endpoint * Reuse getColumns * Constrain permission_groups and type * Fetch preview data endpoint generates columns and rows * Add QueryClientProvider * Adjust CSS style * Add preview panel * Preview fetch button * Update yarn.lock * Wrap limit and count into query Enhance fetching preview query performance * Add migration to alter constraints * Remove BES permission check Data table server check permissions when fetching * Add DataTablePreviewRequest type It can be used by: admin-panel-server, data-table-server, and api-client * Apply DataTablePreviewRequest type to FetchPreview route * extracted reusable functions * Refactor fetch preview endpoints in admin-panel-server * Minor refactor * RN-628: Convert 'fetch' to be a viz-builder transform step (#4214) * RN-628: Override default mathjs config to treat array like data as arrays rather than matrix * RN-628: Converted 'fetch' step to the 'fetchData' transform * RN-628: Reworked viz-builder UI to just use transforms * RN-628: Added migration to convert reports fetch step to a fetchData transform * RN-628: Fixed/updated tests * RN-628: Added unit tests for expressions in fetchData * RN-628: Fixed up yarn.lock * RN-628: Fixed tests after rebase * RN-628: Allowing multi-joins * RN-628: Fixed up tests after rebase * RN-628: Fixed up tests after rebase * RN-628: Renamed migration to be more recent * RN-644: Rework report-server to pull from data-tables (#4341) * RN-644: Switch report-server to fetch from data-table-server * RN-644: Add migration to convert reports to using data-table-server * RN-644: Implemented converting project entities to child countries in Analytics and Events DataTableServices - Was previously handled by QueryBuilder in the report-server * RN-644: Updated tests in report-server * RN-644: Implemented pulling events for all data-elements if no data-elements supplied * RN-644: PR Cleanups - Improved tests - Updated buildContext to just take Row[] as new data * RN-644: Fixed up tests * RN-644: Renamed migration to a later date * No Issue - Fix project to country mapping bug (#4348) * Fix mapProjectEntitiesToCountries * Clarify project code with comment * Update http example --------- Co-authored-by: Chris Pollard * Add valid property names to new config (#4354) * RN-782: Fix dataTableCode Migration (#4355) * Merge branch 'dev' into data-tables-ph1 * Fix: Wrap aggregations into object (#4361) * Remove 'data-table-api' from url when creating apiClient baseUrls * Convert data group code to string (#4362) * Fix data table content overflow (#4367) * RN-768 RN-769 RN-770 RN-771 RN-783 Add data element data group metadata and rewrite vizs(#4350) * Fix data table ph1 (#4366) * Convert external database connections to drop list (#4371) * RN-688: Remove Output Context (#4375) * RN-688: Remove Output Context * update rawDataExport test * Fix issues found in RN-729 (#4379) * Add permission check on externalDatabaseConnections endpoint As discussed, users who have permissions to the external database connection should have read access to view the connections, but edit/delete permissions for an external database connection are BES Admin only * Use Autocomplete from ui-components The difference is the custom Autocomplete component in admin-panel will fetch by search term for options, which didn't allow to show the list when an option is selected. * Remove mock up externalDatabaseConnectionCode * Add delete button for external database connections * Fix RN-783 testing issues (#4376) * Set lookup even with no row Previously if no row, the lookup won't set, which will block fetching metadata using '=@all.dataElements'. Link to issue: https://linear.app/bes/issue/RN-783#comment-744f2fe2 * Remove unused options metadata Link to issue: https://linear.app/bes/issue/RN-783#comment-984ca258 * Fix unit tests * RN-802 Default parameters on preview (#4387) * RN-818 Diff default startdate (#4389) * Restore default startDate behaviour * Update tests * Use defaults again * Update packages/data-table-server/src/dataTableService/services/AnalyticsDataTableService.ts Co-authored-by: Rohan Port <59544282+rohan-bes@users.noreply.github.com> * Update packages/data-table-server/src/dataTableService/services/AnalyticsDataTableService.ts Co-authored-by: Rohan Port <59544282+rohan-bes@users.noreply.github.com> * Update * DRY --------- Co-authored-by: Rohan Port <59544282+rohan-bes@users.noreply.github.com> * Assign new aggregations in migration (#4391) * RN-773 Rewrite viz using entity relations (#4374) * Organise files for clarity * Minor: rethrow same error to keep stack * Minor: add more detail to error messages * Refactor: move tsutils/types.ts into @tupaia/types * Add ajvValidate convenience fn * Refactor: use ajv validation in middleware * Fix: context applies on fully joined dataset rather than new data from fetch * Remove insertNumberOfFacilitiesColumn transform * Remove minItems: 1 requirement Was a passing thought on an improvement, but the tests tell me otherwise * Migrate reports using insertNumberOfFacilitiesColumn * RN-809 Rewrite WISH Vizes (#4386) * Update wish baseline reports * Rewrite WISH matrix reports * Rewrite matrices with multiple data elements * Update migration * RN-810 Convert RH orgUnitCodeToName Reports (#4392) * Add entities table fetch to matrix reports * Include more reports * Update 20230222233428-RewriteVizsUsedDataElementCodeToName-modifies-schema.js (#4393) * Prevent fetchData error when entityCodes undefined (#4395) * Prevent fetchData error when entityCodes undefined * Fix expression * Fix some bugs for data tables found in demo (#4396) * Add missing import JsonEditor * Make ancestorType parameter optional in entity_relations data-table * Fix issue-11 in rn-783 (#4398) & Empty joins should still join tables * Add exitOnNoData flag to fetchData (#4399) * Fix request schema (#4400) * RN-783 [fix]: Reworked fetchDataElementMetadata in DhisService to pull category option combos and combine with data element name (#4403) * fix orgunit replace for events (#4401) * Add column sort to fix Palau matrix viz (#4407) * Fixup broken date offset vizes (#4406) * update individual transforms (#4405) * Fix hierarchy field on change error * RN-831 Fix wrong variable name on OrganisationUnitCodesField * RN-819 Convert additional reports (#4412) * Refactor: rename type for clarity * Add entity_attributes data table type * Cleanup: remove orgUnitAttribute function * Cleanup: remove orgUnitCodeToId function * Fix type issue * Add entity_attributes DT * RN-156: Added startDate and endDate selectors to the viz-builder (#4408) --------- Co-authored-by: Biao Li Co-authored-by: Biao Li <31789355+biaoli0@users.noreply.github.com> Co-authored-by: Rohan Port <59544282+rohan-bes@users.noreply.github.com> Co-authored-by: Chris Pollard Co-authored-by: Rohan Port --- .eslintrc | 3 +- .prettierignore | 3 + packages/admin-panel-server/examples.http | 30 + .../DashboardVisualisationExtractor.test.ts | 65 +- .../admin-panel-server/src/app/createApp.ts | 18 +- .../routes/FetchAggregationOptionsRoute.ts | 21 - .../FetchDataTableBuiltInParamsRoute.ts | 24 + .../routes/FetchDataTablePreviewDataRoute.ts | 27 + .../src/routes/FetchReportPreviewDataRoute.ts | 24 +- .../admin-panel-server/src/routes/index.ts | 3 +- .../DashboardVisualisationExtractor.ts | 12 +- .../MapOverlayVisualisationExtractor.ts | 12 +- .../mapOverlayVisualisation/types.ts | 2 +- .../src/viz-builder/reportConfigValidator.ts | 62 +- .../src/viz-builder/types.ts | 5 - .../utils/extractDataFromReport.ts | 5 +- .../src/viz-builder/validators.ts | 4 +- packages/admin-panel/package.json | 1 - .../src/VizBuilderApp/api/queries/index.js | 2 - .../VizBuilderApp/api/queries/useLocations.js | 24 +- .../api/queries/useReportPreview.js | 4 + .../queries/useSearchAggregationOptions.js | 27 - .../api/queries/useSearchDataSources.js | 33 - .../DataLibrary/AggregationDataLibrary.js | 106 -- .../DataLibrary/DataElementDataLibrary.js | 127 -- .../components/DataLibrary/index.js | 2 - .../src/VizBuilderApp/components/Panel.js | 98 +- .../components/PreviewOptions.js | 56 +- .../components/PreviewSection.js | 36 +- .../src/VizBuilderApp/context/VizConfig.js | 38 +- .../src/VizBuilderApp/views/Main.js | 2 + .../src/autocomplete/Autocomplete.js | 275 ++-- .../src/autocomplete/ReduxAutocomplete.js | 168 +++ .../admin-panel/src/autocomplete/index.js | 1 + .../src/dataTables/DataTableEditFields.js | 268 ++++ .../admin-panel/src/dataTables/PlayButton.js | 35 + .../PreviewFilters/PreviewFilters.js | 53 + .../PreviewFilters/filters/ArrayField.js | 46 + .../PreviewFilters/filters/BooleanField.js | 54 + .../filters/DataElementCodesField.js | 33 + .../filters/DataGroupCodeField.js | 32 + .../PreviewFilters/filters/DatePicker.js | 53 + .../filters/FilterTypeOptions.js | 34 + .../PreviewFilters/filters/HierarchyField.js | 39 + .../PreviewFilters/filters/NumberField.js | 46 + .../filters/OrganisationUnitCodesField.js | 57 + .../PreviewFilters/filters/TextField.js | 36 + .../PreviewFilters/filters/index.js | 6 + .../components/PreviewFilters/index.js | 7 + .../components/editing/ParameterItem.js | 148 +++ .../components/editing/ParameterList.js | 57 + .../dataTables/components/editing/index.js | 8 + .../dataTables/components/editing/types.js | 25 + .../config/SqlDataTableConfigEditFields.js | 74 ++ .../src/dataTables/config/index.js | 6 + .../src/dataTables/onProcessDataForSave.js | 22 + .../admin-panel/src/dataTables/query/index.js | 8 + .../dataTables/query/useDataTablePreview.js | 67 + .../query/useExternalDatabaseConnections.js | 23 + .../query/useFetchDataTableBuiltInParams.js | 31 + .../admin-panel/src/dataTables/useParams.js | 122 ++ .../src/dataTables/useRuntimeParams.js | 33 + .../src/dataTables/useSqlEditor.js | 23 + packages/admin-panel/src/editor/EditButton.js | 2 +- packages/admin-panel/src/editor/EditModal.js | 29 +- packages/admin-panel/src/editor/actions.js | 30 +- packages/admin-panel/src/editor/reducer.js | 7 +- .../SurveyResponsesExportModal.js | 8 +- packages/admin-panel/src/index.js | 42 +- packages/admin-panel/src/library.js | 2 +- .../src/pages/resources/DataTablesPage.js | 40 +- .../ExternalDatabaseConnectionsPage.js | 8 + .../src/utilities/StoreProvider.js | 6 +- .../admin-panel/src/utilities/getColumns.js | 32 + packages/admin-panel/src/utilities/index.js | 1 + .../widgets/InputField/registerInputFields.js | 4 +- packages/aggregator/src/Aggregator.js | 8 + packages/api-client/package.json | 1 + .../src/connections/DataTableApi.ts | 18 + .../api-client/src/connections/ReportApi.ts | 4 - .../src/connections/mocks/MockDataTableApi.ts | 34 +- packages/api-client/src/constants.ts | 2 +- packages/central-server/package.json | 1 + .../assertDataElementPermissions.js | 6 +- .../src/apiV2/dataTables/CreateDataTables.js | 26 + .../src/apiV2/dataTables/index.js | 1 + .../GETExternalDatabaseConnections.js | 41 + .../externalDatabaseConnections/index.js | 1 + packages/central-server/src/apiV2/index.js | 14 +- .../constructNewRecordValidationRules.js | 20 + .../getPermissionListWithWildcard.js | 9 + .../src/apiV2/utilities/index.js | 1 + .../dhis/translators/DhisTranslator.test.ts | 38 +- .../services/kobo/KoBoService.test.ts | 9 - .../pullers/DataElementsMetadataPuller.ts | 24 +- .../dhis/translators/DhisTranslator.ts | 21 +- .../translators/InboundAnalyticsTranslator.ts | 3 +- .../dhis/translators/formatDataElementName.ts | 11 + packages/data-table-server/examples.http | 61 + packages/data-table-server/package.json | 1 + .../dataTableService/DataTableService.test.ts | 5 + .../AnalyticsDataTableService.test.ts | 49 +- ...ataElementMetaDataDataTableService.test.ts | 94 ++ .../DataGroupMetaDataDataTableService.test.ts | 130 ++ .../services/EntitiesDataTableService.test.ts | 16 +- .../EntityAttributesDataTableService.test.ts | 90 ++ .../EntityRelationsDataTableService.test.ts | 15 +- .../services/EventsDataTableService.test.ts | 89 +- .../dataTableService/services/fixtures.ts | 8 + .../data-table-server/src/app/createApp.ts | 21 +- .../src/dataTableService/DataTableService.ts | 24 +- .../DataTableServiceBuilder.ts | 7 + .../dataTableService/getDataTableService.ts | 21 + .../src/dataTableService/index.ts | 1 + .../services/AnalyticsDataTableService.ts | 16 +- .../DataElementMetadataDataTableService.ts | 56 + .../DataGroupMetaDataDataTableService.ts | 83 ++ .../EntityAttributesDataTableService.ts | 59 + .../EntityRelationsDataTableService.ts | 6 +- .../services/EventsDataTableService.ts | 38 +- .../services/SqlDataTableService.ts | 25 +- .../src/dataTableService/services/index.ts | 3 + .../services/utils/getDefaultDates.ts | 11 + .../dataTableService/services/utils/index.ts | 7 + .../utils/mapProjectEntitiesToCountries.ts | 35 + .../utils/dataTableParamsToYupSchema.ts | 15 +- .../src/dataTableService/utils/index.ts | 1 + .../dataTableService/utils/removeSemicolon.ts | 12 + .../utils/yupSchemaToDataTableParams.ts | 26 +- .../attachDataTableFromPreviewToContext.ts | 52 + .../middleware/attachDataTableToContext.ts | 23 +- .../src/middleware/helpers/index.ts | 6 + .../middleware/helpers/validatePermissions.ts | 22 + .../data-table-server/src/middleware/index.ts | 1 + .../data-table-server/src/models/DataTable.ts | 10 +- .../data-table-server/src/models/Entity.ts | 17 + .../data-table-server/src/models/index.ts | 1 + .../src/routes/FetchPreviewData.ts | 38 + .../data-table-server/src/routes/index.ts | 1 + packages/data-table-server/src/types.ts | 3 +- packages/database/src/DatabaseModel.js | 6 + ...issionGroupsInDataTable-modifies-schema.js | 29 + ...tchIntoTransformInReports-modifies-data.js | 132 ++ ...rtsToPullFromDataTables-modifies-schema.js | 103 ++ ...00-AddNewDataTableTypes-modifies-schema.js | 77 ++ ...53-AddMetadataInDataTable-modifies-data.js | 40 + ...edDataElementCodeToName-modifies-schema.js | 187 +++ ...neVizesWithOrgUnitContext-modifies-data.js | 112 ++ ...tNumberOfFacilitiesColumn-modifies-data.js | 235 ++++ ...riteWishFijiMatrixReports-modifies-data.js | 216 ++++ ...ortsWithMultiDataElements-modifies-data.js | 305 +++++ ...itCodeToNameMatrixReports-modifies-data.js | 169 +++ ...AttributesDataTableType-modifies-schema.js | 30 + ...002338-FixDateOffsetVizes-modifies-data.js | 180 +++ ...EntityAttributesDataTable-modifies-data.js | 38 + packages/dhis-api/src/DhisApi.js | 15 + packages/entity-server/package.json | 1 + .../format/formatEntityForResponse.ts | 1 - .../middleware/attachEntityContext.ts | 13 +- .../src/routes/hierarchy/middleware/filter.ts | 2 +- .../src/routes/hierarchy/types.ts | 17 +- .../src/expression-parser/ExpressionParser.js | 5 +- .../getSurveyResponsesExportModal.js | 8 +- .../src/models/FeedItem.ts | 2 +- .../src/routes/AuthRoute.ts | 2 +- .../src/routes/ChangePasswordRoute.ts | 2 +- .../src/routes/RegisterUserRoute.ts | 2 +- .../src/routes/social/SocialFeedRoute.ts | 2 +- packages/report-server/examples.http | 49 +- packages/report-server/package.json | 2 + .../src/__tests__/reportBuilder.test.ts | 35 +- .../reportBuilder/configValidator.test.ts | 201 +-- .../context/buildContext.test.ts | 164 +-- .../customReport/testCustomReport.test.ts | 16 +- .../customReport/tongaCovidRawData.test.ts | 22 +- .../reportBuilder/output/default.test.ts | 3 +- .../reportBuilder/output/matrix.test.ts | 13 - .../output/rawDataExport.test.ts | 20 +- .../reportBuilder/output/rows.test.ts | 2 +- .../output/rowsAndColumns.test.ts | 3 +- .../reportBuilder/query/QueryBuilder.test.ts | 546 -------- .../reportBuilder/testUtils/apiClientMock.ts | 12 + .../testUtils/buildTestTransform.ts | 11 + .../reportBuilder/testUtils/contextMock.ts | 10 + .../reportBuilder/testUtils/index.ts | 6 + .../reportBuilder/transform/aliases.test.ts | 72 +- .../transform/excludeColumns.test.ts | 15 +- .../transform/excludeRows.test.ts | 9 +- .../transform/fetchData/fetchData.fixtures.ts | 142 ++ .../transform/fetchData/fetchData.test.ts | 383 ++++++ .../transform/fetchData/getContext.ts | 48 + .../transform/gatherColumns.test.ts | 27 +- .../transform/insertColumns.test.ts | 39 +- .../transform/insertRows.test.ts | 75 +- .../reportBuilder/transform/mergeRows.test.ts | 113 +- .../transform/orderColumns.test.ts | 47 +- .../transform/parser/functions.test.ts | 94 +- .../transform/parser/parser.test.ts | 31 +- .../reportBuilder/transform/sortRows.test.ts | 35 +- .../reportBuilder/transform/transform.test.ts | 17 +- .../transform/updateColumns.test.ts | 75 +- packages/report-server/src/app/createApp.ts | 6 - .../src/reportBuilder/configValidator.ts | 63 +- .../src/reportBuilder/context/buildContext.ts | 70 +- .../src/reportBuilder/context/index.ts | 4 +- .../src/reportBuilder/context/types.ts | 31 +- .../src/reportBuilder/customReports/index.ts | 5 +- .../customReports/testCustomReport.ts | 5 +- .../customReports/tongaCovidRawData.ts | 16 +- .../src/reportBuilder/fetch/fetch.ts | 38 - .../fetch/functions/dataElement.ts | 54 - .../fetch/functions/dataGroup.ts | 75 -- .../reportBuilder/fetch/functions/index.ts | 12 - .../src/reportBuilder/fetch/types.ts | 13 - .../report-server/src/reportBuilder/index.ts | 1 - .../output/functions/outputBuilders.ts | 2 +- .../functions/rawDataExport/rawDataExport.ts | 22 +- .../rawDataExport/rawDataExportBuilder.ts | 37 +- .../output/functions/rawDataExport/types.ts | 4 - .../src/reportBuilder/output/output.ts | 12 +- .../src/reportBuilder/output/types.ts | 3 - .../src/reportBuilder/query/QueryBuilder.ts | 32 - .../query/buildOrganisationUnitParams.ts | 90 -- .../reportBuilder/query/buildPeriodParams.ts | 72 -- .../src/reportBuilder/query/index.ts | 7 - .../src/reportBuilder/query/types.ts | 16 - .../src/reportBuilder/reportBuilder.ts | 35 +- .../aliases/entityMetadataAliases.ts | 53 - .../reportBuilder/transform/aliases/index.ts | 6 +- .../functions/fetchData/createJoin.ts | 43 + .../functions/fetchData/fetchData.ts | 84 ++ .../functions/fetchData}/index.ts | 3 +- .../transform/functions/index.ts | 5 +- .../functions/mergeRows/mergeStrategies.ts | 6 +- .../transform/parser/TransformParser.ts | 15 +- .../transform/parser/functions/basic.ts | 12 + .../transform/parser/functions/context.ts | 43 - .../transform/parser/functions/index.ts | 20 +- .../transform/parser/functions/math.ts | 16 + .../src/reportBuilder/transform/transform.ts | 28 +- .../routes/FetchAggregationOptionsRoute.ts | 27 - .../src/routes/FetchReportRoute.ts | 22 +- .../src/routes/TestReportRoute.ts | 26 +- packages/report-server/src/routes/index.ts | 4 - packages/report-server/src/types.ts | 6 +- packages/server-boilerplate/package.json | 1 + .../server-boilerplate/src/models/types.ts | 2 +- packages/tsutils/package.json | 1 + packages/tsutils/src/index.ts | 1 - packages/tsutils/src/validation/ajv/getAjv.ts | 32 +- packages/tsutils/src/validation/ajv/index.ts | 1 + .../tsutils/src/validation/ajv/validate.ts | 18 + packages/types/config/models/config.json | 1 - packages/types/src/schemas/schemas.ts | 1138 ++++++----------- .../types/src/types/models-extra/report.ts | 27 - packages/types/src/types/models.ts | 435 +++---- .../MeditrakSurveyResponseRequest.ts | 2 +- .../DataTablePreviewRequest.ts | 17 + packages/types/src/types/requests/index.ts | 3 +- packages/types/src/utils/index.ts | 1 + .../src/types.ts => types/src/utils/utils.ts} | 8 + packages/ui-components/package.json | 2 + .../src/components/DataTable/DataTable.js | 9 +- .../src/components/Inputs/SQLQueryEditor.js | 137 ++ .../src/components/Inputs/TextField.js | 1 + .../src/components/Inputs/index.js | 1 + packages/web-frontend/package.json | 1 + yarn.lock | 158 ++- 268 files changed, 7657 insertions(+), 4221 deletions(-) create mode 100644 .prettierignore delete mode 100644 packages/admin-panel-server/src/routes/FetchAggregationOptionsRoute.ts create mode 100644 packages/admin-panel-server/src/routes/FetchDataTableBuiltInParamsRoute.ts create mode 100644 packages/admin-panel-server/src/routes/FetchDataTablePreviewDataRoute.ts delete mode 100644 packages/admin-panel/src/VizBuilderApp/api/queries/useSearchAggregationOptions.js delete mode 100644 packages/admin-panel/src/VizBuilderApp/api/queries/useSearchDataSources.js delete mode 100644 packages/admin-panel/src/VizBuilderApp/components/DataLibrary/AggregationDataLibrary.js delete mode 100644 packages/admin-panel/src/VizBuilderApp/components/DataLibrary/DataElementDataLibrary.js create mode 100644 packages/admin-panel/src/autocomplete/ReduxAutocomplete.js create mode 100644 packages/admin-panel/src/dataTables/DataTableEditFields.js create mode 100644 packages/admin-panel/src/dataTables/PlayButton.js create mode 100644 packages/admin-panel/src/dataTables/components/PreviewFilters/PreviewFilters.js create mode 100644 packages/admin-panel/src/dataTables/components/PreviewFilters/filters/ArrayField.js create mode 100644 packages/admin-panel/src/dataTables/components/PreviewFilters/filters/BooleanField.js create mode 100644 packages/admin-panel/src/dataTables/components/PreviewFilters/filters/DataElementCodesField.js create mode 100644 packages/admin-panel/src/dataTables/components/PreviewFilters/filters/DataGroupCodeField.js create mode 100644 packages/admin-panel/src/dataTables/components/PreviewFilters/filters/DatePicker.js create mode 100644 packages/admin-panel/src/dataTables/components/PreviewFilters/filters/FilterTypeOptions.js create mode 100644 packages/admin-panel/src/dataTables/components/PreviewFilters/filters/HierarchyField.js create mode 100644 packages/admin-panel/src/dataTables/components/PreviewFilters/filters/NumberField.js create mode 100644 packages/admin-panel/src/dataTables/components/PreviewFilters/filters/OrganisationUnitCodesField.js create mode 100644 packages/admin-panel/src/dataTables/components/PreviewFilters/filters/TextField.js create mode 100644 packages/admin-panel/src/dataTables/components/PreviewFilters/filters/index.js create mode 100644 packages/admin-panel/src/dataTables/components/PreviewFilters/index.js create mode 100644 packages/admin-panel/src/dataTables/components/editing/ParameterItem.js create mode 100644 packages/admin-panel/src/dataTables/components/editing/ParameterList.js create mode 100644 packages/admin-panel/src/dataTables/components/editing/index.js create mode 100644 packages/admin-panel/src/dataTables/components/editing/types.js create mode 100644 packages/admin-panel/src/dataTables/config/SqlDataTableConfigEditFields.js create mode 100644 packages/admin-panel/src/dataTables/config/index.js create mode 100644 packages/admin-panel/src/dataTables/onProcessDataForSave.js create mode 100644 packages/admin-panel/src/dataTables/query/index.js create mode 100644 packages/admin-panel/src/dataTables/query/useDataTablePreview.js create mode 100644 packages/admin-panel/src/dataTables/query/useExternalDatabaseConnections.js create mode 100644 packages/admin-panel/src/dataTables/query/useFetchDataTableBuiltInParams.js create mode 100644 packages/admin-panel/src/dataTables/useParams.js create mode 100644 packages/admin-panel/src/dataTables/useRuntimeParams.js create mode 100644 packages/admin-panel/src/dataTables/useSqlEditor.js create mode 100644 packages/admin-panel/src/utilities/getColumns.js create mode 100644 packages/central-server/src/apiV2/dataTables/CreateDataTables.js create mode 100644 packages/central-server/src/apiV2/externalDatabaseConnections/GETExternalDatabaseConnections.js create mode 100644 packages/central-server/src/apiV2/utilities/getPermissionListWithWildcard.js create mode 100644 packages/data-broker/src/services/dhis/translators/formatDataElementName.ts create mode 100644 packages/data-table-server/src/__tests__/dataTableService/services/DataElementMetaDataDataTableService.test.ts create mode 100644 packages/data-table-server/src/__tests__/dataTableService/services/DataGroupMetaDataDataTableService.test.ts create mode 100644 packages/data-table-server/src/__tests__/dataTableService/services/EntityAttributesDataTableService.test.ts create mode 100644 packages/data-table-server/src/dataTableService/getDataTableService.ts create mode 100644 packages/data-table-server/src/dataTableService/services/DataElementMetadataDataTableService.ts create mode 100644 packages/data-table-server/src/dataTableService/services/DataGroupMetaDataDataTableService.ts create mode 100644 packages/data-table-server/src/dataTableService/services/EntityAttributesDataTableService.ts create mode 100644 packages/data-table-server/src/dataTableService/services/utils/getDefaultDates.ts create mode 100644 packages/data-table-server/src/dataTableService/services/utils/index.ts create mode 100644 packages/data-table-server/src/dataTableService/services/utils/mapProjectEntitiesToCountries.ts create mode 100644 packages/data-table-server/src/dataTableService/utils/removeSemicolon.ts create mode 100644 packages/data-table-server/src/middleware/attachDataTableFromPreviewToContext.ts create mode 100644 packages/data-table-server/src/middleware/helpers/index.ts create mode 100644 packages/data-table-server/src/middleware/helpers/validatePermissions.ts create mode 100644 packages/data-table-server/src/models/Entity.ts create mode 100644 packages/data-table-server/src/routes/FetchPreviewData.ts create mode 100644 packages/database/src/migrations/20230214033100-ChangeConstraintOnTypeAndPermissionGroupsInDataTable-modifies-schema.js create mode 100644 packages/database/src/migrations/20230216140701-MoveFetchIntoTransformInReports-modifies-data.js create mode 100644 packages/database/src/migrations/20230216141720-ConvertReportsToPullFromDataTables-modifies-schema.js create mode 100644 packages/database/src/migrations/20230217031100-AddNewDataTableTypes-modifies-schema.js create mode 100644 packages/database/src/migrations/20230219232553-AddMetadataInDataTable-modifies-data.js create mode 100644 packages/database/src/migrations/20230222233428-RewriteVizsUsedDataElementCodeToName-modifies-schema.js create mode 100644 packages/database/src/migrations/20230314005038-RewriteWishFijiBaselineVizesWithOrgUnitContext-modifies-data.js create mode 100644 packages/database/src/migrations/20230314042110-MigrateInsertNumberOfFacilitiesColumn-modifies-data.js create mode 100644 packages/database/src/migrations/20230316041331-RewriteWishFijiMatrixReports-modifies-data.js create mode 100644 packages/database/src/migrations/20230316061147-RewriteWishFijiMatrixReportsWithMultiDataElements-modifies-data.js create mode 100644 packages/database/src/migrations/20230320011713-ConvertOrgUnitCodeToNameMatrixReports-modifies-data.js create mode 100644 packages/database/src/migrations/20230321045049-AddEntityAttributesDataTableType-modifies-schema.js create mode 100644 packages/database/src/migrations/20230328002338-FixDateOffsetVizes-modifies-data.js create mode 100644 packages/database/src/migrations/20230402232608-AddEntityAttributesDataTable-modifies-data.js delete mode 100644 packages/report-server/src/__tests__/reportBuilder/query/QueryBuilder.test.ts create mode 100644 packages/report-server/src/__tests__/reportBuilder/testUtils/apiClientMock.ts create mode 100644 packages/report-server/src/__tests__/reportBuilder/testUtils/buildTestTransform.ts create mode 100644 packages/report-server/src/__tests__/reportBuilder/testUtils/contextMock.ts create mode 100644 packages/report-server/src/__tests__/reportBuilder/testUtils/index.ts create mode 100644 packages/report-server/src/__tests__/reportBuilder/transform/fetchData/fetchData.fixtures.ts create mode 100644 packages/report-server/src/__tests__/reportBuilder/transform/fetchData/fetchData.test.ts create mode 100644 packages/report-server/src/__tests__/reportBuilder/transform/fetchData/getContext.ts delete mode 100644 packages/report-server/src/reportBuilder/fetch/fetch.ts delete mode 100644 packages/report-server/src/reportBuilder/fetch/functions/dataElement.ts delete mode 100644 packages/report-server/src/reportBuilder/fetch/functions/dataGroup.ts delete mode 100644 packages/report-server/src/reportBuilder/fetch/functions/index.ts delete mode 100644 packages/report-server/src/reportBuilder/fetch/types.ts delete mode 100644 packages/report-server/src/reportBuilder/output/types.ts delete mode 100644 packages/report-server/src/reportBuilder/query/QueryBuilder.ts delete mode 100644 packages/report-server/src/reportBuilder/query/buildOrganisationUnitParams.ts delete mode 100644 packages/report-server/src/reportBuilder/query/buildPeriodParams.ts delete mode 100644 packages/report-server/src/reportBuilder/query/index.ts delete mode 100644 packages/report-server/src/reportBuilder/query/types.ts delete mode 100644 packages/report-server/src/reportBuilder/transform/aliases/entityMetadataAliases.ts create mode 100644 packages/report-server/src/reportBuilder/transform/functions/fetchData/createJoin.ts create mode 100644 packages/report-server/src/reportBuilder/transform/functions/fetchData/fetchData.ts rename packages/report-server/src/reportBuilder/{fetch => transform/functions/fetchData}/index.ts (50%) delete mode 100644 packages/report-server/src/routes/FetchAggregationOptionsRoute.ts create mode 100644 packages/tsutils/src/validation/ajv/validate.ts rename packages/types/src/types/requests/{ => central-server}/MeditrakSurveyResponseRequest.ts (96%) create mode 100644 packages/types/src/types/requests/data-table-server/DataTablePreviewRequest.ts rename packages/{tsutils/src/types.ts => types/src/utils/utils.ts} (91%) create mode 100644 packages/ui-components/src/components/Inputs/SQLQueryEditor.js diff --git a/.eslintrc b/.eslintrc index ec03f79d36..c840949b52 100644 --- a/.eslintrc +++ b/.eslintrc @@ -47,7 +47,8 @@ "packages/report-server/**", "packages/server-boilerplate/**", "packages/supserset-api/**", - "packages/tsutils/**" + "packages/tsutils/**", + "packages/types/**" ], "extends": "@beyondessential/ts", "parserOptions": { diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000000..c7b24f8a45 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,3 @@ +# Ignore auto-generated files +packages/types/src/schemas/schemas.ts +packages/types/src/types/models.ts diff --git a/packages/admin-panel-server/examples.http b/packages/admin-panel-server/examples.http index de1d5c2d36..fe805ad344 100644 --- a/packages/admin-panel-server/examples.http +++ b/packages/admin-panel-server/examples.http @@ -174,3 +174,33 @@ Authorization: {{authorization}} } } } + +### Fetch Data Table Preview Data +POST {{baseUrl}}/fetchDataTablePreviewData +content-type: {{contentType}} +Authorization: {{authorization}} + +{ + "previewConfig": { + "code": "data_lake_db_test", + "type": "sql", + "config": { + "sql": "SELECT * FROM analytics WHERE entity_code LIKE :entityCode", + "externalDatabaseConnectionCode": "DATA_LAKE_DB", + "additionalParams": [ + { + "name": "entityCode", + "config": { + "type": "string" + } + } + ] + }, + "runtimeParams": { + "entityCode": "AU_SA%" + }, + "permission_groups": [ + "*" + ] + } +} \ No newline at end of file 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 1c163f69cf..95e7f3d112 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 @@ -33,33 +33,9 @@ describe('DashboardVisualisationExtractor', () => { }); describe('getReport() - draftReport', () => { - it('throws error if viz does not have data.fetch', () => { - const extractor = new DashboardVisualisationExtractor( - { code: 'viz', data: { transform: [] }, presentation: {} }, - yup.object(), - draftReportValidator, - ); - - const getReport = () => extractor.getReport(); - - expect(getReport).toThrow('fetch is a required field'); - }); - - it('throws error if viz does not have data.fetch.dataElements or data.fetch.dataGroups', () => { - const extractor = new DashboardVisualisationExtractor( - { code: 'viz', data: { fetch: {}, transform: [] }, presentation: {} }, - yup.object(), - draftReportValidator, - ); - - const getReport = () => extractor.getReport(); - - expect(getReport).toThrow('Requires "dataGroups" or "dataElements"'); - }); - it('throws error if viz does not have data.transform', () => { const extractor = new DashboardVisualisationExtractor( - { code: 'viz', data: { fetch: { dataElements: ['BCD1'] } }, presentation: {} }, + { code: 'viz', data: {}, presentation: {} }, yup.object(), draftReportValidator, ); @@ -72,7 +48,7 @@ describe('DashboardVisualisationExtractor', () => { it('throws error if viz does not have code', () => { const extractor = new DashboardVisualisationExtractor( { - data: { fetch: { dataElements: ['BCD1'] }, transform: [] }, + data: { transform: [] }, presentation: {}, }, yup.object(), @@ -88,7 +64,7 @@ describe('DashboardVisualisationExtractor', () => { const extractor = new DashboardVisualisationExtractor( { code: 'viz', - data: { fetch: { dataElements: ['BCD1'] }, transform: [] }, + data: { transform: [] }, presentation: {}, }, yup.object(), @@ -100,9 +76,6 @@ describe('DashboardVisualisationExtractor', () => { expect(report).toEqual({ code: 'viz', config: { - fetch: { - dataElements: ['BCD1'], - }, transform: [], }, }); @@ -296,11 +269,6 @@ describe('DashboardVisualisationExtractor', () => { code: 'viz', name: 'My Viz', data: { - fetch: { - dataElements: ['BCD1', 'BCD2'], - organisationUnits: ['$requested', 'TO'], - startDate: '20210101', - }, aggregate: ['SUM_EACH_WEEK'], transform: ['keyValueByDataElementName'], }, @@ -324,12 +292,6 @@ describe('DashboardVisualisationExtractor', () => { expect(report).toEqual({ code: 'viz', config: { - fetch: { - dataElements: ['BCD1', 'BCD2'], - organisationUnits: ['$requested', 'TO'], - startDate: '20210101', - aggregations: ['SUM_EACH_WEEK'], - }, transform: ['keyValueByDataElementName'], output: { type: 'bar', @@ -347,9 +309,6 @@ describe('DashboardVisualisationExtractor', () => { code: 'viz', name: 'My Viz', data: { - fetch: { - dataElements: ['BCD1', 'BCD2'], - }, transform: ['keyValueByDataElementName'], }, presentation: { @@ -372,9 +331,6 @@ describe('DashboardVisualisationExtractor', () => { expect(report).toEqual({ code: 'viz', config: { - fetch: { - dataElements: ['BCD1', 'BCD2'], - }, transform: ['keyValueByDataElementName'], output: { type: 'bar', @@ -392,9 +348,6 @@ describe('DashboardVisualisationExtractor', () => { code: 'viz', name: 'My Viz', data: { - fetch: { - dataElements: ['BCD1', 'BCD2'], - }, transform: ['keyValueByDataElementName'], }, presentation: { @@ -417,9 +370,6 @@ describe('DashboardVisualisationExtractor', () => { expect(report).toEqual({ code: 'viz', config: { - fetch: { - dataElements: ['BCD1', 'BCD2'], - }, transform: ['keyValueByDataElementName'], output: { type: 'rowsAndColumns', @@ -437,9 +387,6 @@ describe('DashboardVisualisationExtractor', () => { code: 'viz', name: 'My Viz', data: { - fetch: { - dataElements: ['BCD1', 'BCD2'], - }, transform: ['keyValueByDataElementName'], }, presentation: { @@ -477,9 +424,6 @@ describe('DashboardVisualisationExtractor', () => { code: 'viz', name: 'My Viz', data: { - fetch: { - dataElements: ['BCD1', 'BCD2'], - }, transform: ['keyValueByDataElementName'], }, presentation: { @@ -501,9 +445,6 @@ describe('DashboardVisualisationExtractor', () => { report: { code: 'viz', config: { - fetch: { - dataElements: ['BCD1', 'BCD2'], - }, transform: ['keyValueByDataElementName'], output: { type: 'bar', diff --git a/packages/admin-panel-server/src/app/createApp.ts b/packages/admin-panel-server/src/app/createApp.ts index eeec88caab..7da5c13076 100644 --- a/packages/admin-panel-server/src/app/createApp.ts +++ b/packages/admin-panel-server/src/app/createApp.ts @@ -25,6 +25,8 @@ import { FetchMapOverlayVisualisationRoute, FetchReportPreviewDataRequest, FetchReportPreviewDataRoute, + FetchDataTablePreviewDataRequest, + FetchDataTablePreviewDataRoute, ImportDashboardVisualisationRequest, ImportDashboardVisualisationRoute, SaveDashboardVisualisationRequest, @@ -36,10 +38,10 @@ import { UserRoute, ImportMapOverlayVisualisationRequest, ImportMapOverlayVisualisationRoute, - FetchAggregationOptionsRequest, - FetchAggregationOptionsRoute, FetchTransformSchemasRequest, FetchTransformSchemasRoute, + FetchDataTableBuiltInParamsRequest, + FetchDataTableBuiltInParamsRoute, } from '../routes'; import { authHandlerProvider } from '../auth'; @@ -82,6 +84,14 @@ export function createApp() { 'dashboardVisualisation/:dashboardVisualisationId', handleWith(FetchDashboardVisualisationRoute), ) + .post( + 'fetchDataTablePreviewData', + handleWith(FetchDataTablePreviewDataRoute), + ) + .get( + 'fetchDataTableBuiltInParams', + handleWith(FetchDataTableBuiltInParamsRoute), + ) .get( 'export/dashboardVisualisation/:dashboardVisualisationId', handleWith(ExportDashboardVisualisationRoute), @@ -117,10 +127,6 @@ export function createApp() { 'mapOverlayVisualisation/:mapOverlayVisualisationId', handleWith(FetchMapOverlayVisualisationRoute), ) - .get( - 'fetchAggregationOptions', - handleWith(FetchAggregationOptionsRoute), - ) .get( 'fetchTransformSchemas', handleWith(FetchTransformSchemasRoute), diff --git a/packages/admin-panel-server/src/routes/FetchAggregationOptionsRoute.ts b/packages/admin-panel-server/src/routes/FetchAggregationOptionsRoute.ts deleted file mode 100644 index 9f8822643f..0000000000 --- a/packages/admin-panel-server/src/routes/FetchAggregationOptionsRoute.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Tupaia - * Copyright (c) 2017 - 2021 Beyond Essential Systems Pty Ltd - * - */ - -import { Request } from 'express'; - -import { Route } from '@tupaia/server-boilerplate'; - -export type FetchAggregationOptionsRequest = Request< - Record, - Record[], - Record ->; - -export class FetchAggregationOptionsRoute extends Route { - public async buildResponse() { - return this.req.ctx.services.report.fetchAggregationOptions(); - } -} diff --git a/packages/admin-panel-server/src/routes/FetchDataTableBuiltInParamsRoute.ts b/packages/admin-panel-server/src/routes/FetchDataTableBuiltInParamsRoute.ts new file mode 100644 index 0000000000..1f2c76f45d --- /dev/null +++ b/packages/admin-panel-server/src/routes/FetchDataTableBuiltInParamsRoute.ts @@ -0,0 +1,24 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2021 Beyond Essential Systems Pty Ltd + * + */ + +import { Request } from 'express'; + +import { Route } from '@tupaia/server-boilerplate'; + +export type FetchDataTableBuiltInParamsRequest = Request< + Record, + Record, + Record, + { dataTableType: string } +>; + +export class FetchDataTableBuiltInParamsRoute extends Route { + public async buildResponse() { + const { dataTableType } = this.req.query; + + return this.req.ctx.services.dataTable.getBuiltInParameters(dataTableType); + } +} diff --git a/packages/admin-panel-server/src/routes/FetchDataTablePreviewDataRoute.ts b/packages/admin-panel-server/src/routes/FetchDataTablePreviewDataRoute.ts new file mode 100644 index 0000000000..79d4024e51 --- /dev/null +++ b/packages/admin-panel-server/src/routes/FetchDataTablePreviewDataRoute.ts @@ -0,0 +1,27 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2021 Beyond Essential Systems Pty Ltd + * + */ + +import { Request } from 'express'; + +import { Route } from '@tupaia/server-boilerplate'; +import type { DataTablePreviewRequest } from '@tupaia/types'; + +export type FetchDataTablePreviewDataRequest = Request< + Record, + Record, + { + previewConfig: DataTablePreviewRequest; + }, + Record +>; + +export class FetchDataTablePreviewDataRoute extends Route { + public async buildResponse() { + const { previewConfig } = this.req.body; + + return this.req.ctx.services.dataTable.fetchPreviewData(previewConfig); + } +} diff --git a/packages/admin-panel-server/src/routes/FetchReportPreviewDataRoute.ts b/packages/admin-panel-server/src/routes/FetchReportPreviewDataRoute.ts index 5b38ee2d24..b9d8c90774 100644 --- a/packages/admin-panel-server/src/routes/FetchReportPreviewDataRoute.ts +++ b/packages/admin-panel-server/src/routes/FetchReportPreviewDataRoute.ts @@ -29,6 +29,8 @@ export type FetchReportPreviewDataRequest = Request< { entityCode: string; hierarchy: string; + startDate?: string; + endDate?: string; permissionGroup?: string; previewMode?: PreviewMode; dashboardItemOrMapOverlay: DashboardItemOrMapOverlayParam; @@ -39,22 +41,20 @@ export class FetchReportPreviewDataRoute extends Route = { hierarchy, organisationUnitCodes: entityCode }; + if (startDate) parameters.startDate = startDate; + if (endDate) parameters.endDate = endDate; + if (permissionGroup) parameters.permissionGroup = permissionGroup; + + return this.req.ctx.services.report.testReport(parameters, { + testData, + testConfig: reportConfig, + }); } private validate = () => { diff --git a/packages/admin-panel-server/src/routes/index.ts b/packages/admin-panel-server/src/routes/index.ts index a47a73adba..1accf1012b 100644 --- a/packages/admin-panel-server/src/routes/index.ts +++ b/packages/admin-panel-server/src/routes/index.ts @@ -8,7 +8,8 @@ export * from './dashboardVisualisations'; export * from './mapOverlayVisualisations'; export * from './FetchHierarchyEntitiesRoute'; export * from './FetchReportPreviewDataRoute'; -export * from './FetchAggregationOptionsRoute'; export * from './FetchReportSchemasRoute'; +export * from './FetchDataTablePreviewDataRoute'; +export * from './FetchDataTableBuiltInParamsRoute'; export * from './UploadTestDataRoute'; export * from './UserRoute'; 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 f24cb8398c..092843b84a 100644 --- a/packages/admin-panel-server/src/viz-builder/dashboardVisualisation/DashboardVisualisationExtractor.ts +++ b/packages/admin-panel-server/src/viz-builder/dashboardVisualisation/DashboardVisualisationExtractor.ts @@ -89,21 +89,11 @@ export class DashboardVisualisationExtractor< }; } - const { fetch: vizFetch, aggregate, transform } = validatedData; - - const fetch = omitBy( - { - ...vizFetch, - aggregations: aggregate, - }, - isNil, - ); + const { transform } = validatedData; const output = getVizOutputConfig(previewMode, presentation); - const config = omitBy( { - fetch, transform, output, }, 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 a04229dd60..f3777eba39 100644 --- a/packages/admin-panel-server/src/viz-builder/mapOverlayVisualisation/MapOverlayVisualisationExtractor.ts +++ b/packages/admin-panel-server/src/viz-builder/mapOverlayVisualisation/MapOverlayVisualisationExtractor.ts @@ -84,21 +84,11 @@ export class MapOverlayVisualisationExtractor< const { code, reportPermissionGroup: permissionGroup, data, presentation } = this.visualisation; const validatedData = baseVisualisationDataValidator.validateSync(data); - const { fetch: vizFetch, aggregate, transform } = validatedData; - - const fetch = omitBy( - { - ...vizFetch, - aggregations: aggregate, - }, - isNil, - ); + const { transform } = validatedData; const output = getVizOutputConfig(previewMode, presentation); - const config = omitBy( { - fetch, transform, output, }, diff --git a/packages/admin-panel-server/src/viz-builder/mapOverlayVisualisation/types.ts b/packages/admin-panel-server/src/viz-builder/mapOverlayVisualisation/types.ts index 547bdb855f..1f382d0838 100644 --- a/packages/admin-panel-server/src/viz-builder/mapOverlayVisualisation/types.ts +++ b/packages/admin-panel-server/src/viz-builder/mapOverlayVisualisation/types.ts @@ -3,7 +3,7 @@ * Copyright (c) 2017 - 2021 Beyond Essential Systems Pty Ltd */ -import { CamelKeysToSnake, LegacyReport, Report, VizData } from '../types'; +import { CamelKeysToSnake, Report, VizData } from '../types'; type Presentation = Record; diff --git a/packages/admin-panel-server/src/viz-builder/reportConfigValidator.ts b/packages/admin-panel-server/src/viz-builder/reportConfigValidator.ts index cd23ec2752..c17b733af7 100644 --- a/packages/admin-panel-server/src/viz-builder/reportConfigValidator.ts +++ b/packages/admin-panel-server/src/viz-builder/reportConfigValidator.ts @@ -3,74 +3,16 @@ * Copyright (c) 2017 - 2020 Beyond Essential Systems Pty Ltd */ -import { yup, yupUtils } from '@tupaia/utils'; +import { yup } from '@tupaia/utils'; // This is a copy of packages/report-server/src/reportBuilder/configValidator.ts // TODO make DRY: https://linear.app/bes/issue/PHX-108/make-report-validation-dry -const { polymorphic } = yupUtils; - -const periodTypeValidator = yup.mixed().oneOf(['day', 'week', 'month', 'quarter', 'year']); - -const createDataSourceValidator = (sourceType: 'dataElement' | 'dataGroup') => { - const otherSourceKey = sourceType === 'dataElement' ? 'dataGroups' : 'dataElements'; - - return yup - .array() - .of(yup.string().required()) - .when(['$testData', otherSourceKey], { - is: ($testData: unknown, otherDataSource: string[]) => - !$testData && (!otherDataSource || otherDataSource.length === 0), - then: yup - .array() - .of(yup.string().required()) - .required('Requires "dataGroups" or "dataElements"') - .min(1), - }); -}; - -const dataElementValidator = createDataSourceValidator('dataElement'); -const dataGroupValidator = createDataSourceValidator('dataGroup'); - -const aggregationValidator = polymorphic({ - string: yup.string().min(1), - object: yup.object().shape({ - type: yup.string().min(1).required(), - config: yup.object(), - }), -}); - -const dateSpecsValidator = polymorphic({ - object: yup - .object() - .shape({ - unit: periodTypeValidator.required(), - offset: yup.number(), - modifier: yup.mixed().oneOf(['start_of', 'end_of']), - modifierUnit: periodTypeValidator, - }) - .default(undefined), - string: yup.string().min(4), -}); - const customReportConfigValidator = yup.object().shape({ customReport: yup.string().required(), }); -const standardConfigValidator = yup.object().shape({ - fetch: yup - .object() - .shape( - { - dataElements: dataElementValidator, - dataGroups: dataGroupValidator, - aggregations: yup.array().of(aggregationValidator as any), - startDate: dateSpecsValidator, - endDate: dateSpecsValidator, - }, - [['dataElements', 'dataGroups']], - ) - .required(), +export const standardConfigValidator = yup.object().shape({ transform: yup.array().required(), output: yup.object(), }); diff --git a/packages/admin-panel-server/src/viz-builder/types.ts b/packages/admin-panel-server/src/viz-builder/types.ts index d2cfaeb00e..4516f373c4 100644 --- a/packages/admin-panel-server/src/viz-builder/types.ts +++ b/packages/admin-panel-server/src/viz-builder/types.ts @@ -7,11 +7,6 @@ import { StandardOrCustomReportConfig } from '@tupaia/report-server'; import { Report as BaseReportType } from '@tupaia/types'; export type VizData = { - dataElements: BaseReportType['config']['fetch']['dataElements']; - dataGroups: BaseReportType['config']['fetch']['dataGroups']; - startDate?: BaseReportType['config']['fetch']['startDate']; - endDate?: BaseReportType['config']['fetch']['endDate']; - aggregations: BaseReportType['config']['fetch']['aggregations']; transform: BaseReportType['config']['transform']; }; diff --git a/packages/admin-panel-server/src/viz-builder/utils/extractDataFromReport.ts b/packages/admin-panel-server/src/viz-builder/utils/extractDataFromReport.ts index 075c87e2b9..3bcc8fbec8 100644 --- a/packages/admin-panel-server/src/viz-builder/utils/extractDataFromReport.ts +++ b/packages/admin-panel-server/src/viz-builder/utils/extractDataFromReport.ts @@ -12,7 +12,6 @@ export const extractDataFromReport = (report: Report) => { return { customReport: config.customReport }; } - const { fetch, transform } = config; - const { aggregations, ...restOfFetch } = fetch; - return { fetch: restOfFetch, aggregate: aggregations, transform }; + const { transform } = config; + return { transform }; }; diff --git a/packages/admin-panel-server/src/viz-builder/validators.ts b/packages/admin-panel-server/src/viz-builder/validators.ts index ebd5b0ea64..62ad69c354 100644 --- a/packages/admin-panel-server/src/viz-builder/validators.ts +++ b/packages/admin-panel-server/src/viz-builder/validators.ts @@ -14,9 +14,7 @@ const baseCustomReportDataValidator = yup.object().shape({ customReport: yup.string().required(), }); -const baseStandardReportDataValidator = yup.object().shape({ - fetch: yup.object().required(), -}); +export const baseStandardReportDataValidator = yup.object().shape({}); export const baseVisualisationDataValidator = yup.lazy< typeof baseStandardReportDataValidator | typeof baseCustomReportDataValidator diff --git a/packages/admin-panel/package.json b/packages/admin-panel/package.json index 92bb9c469d..60671a8a17 100644 --- a/packages/admin-panel/package.json +++ b/packages/admin-panel/package.json @@ -76,7 +76,6 @@ "uuid": "^3.2.1" }, "devDependencies": { - "@tupaia/utils": "1.0.0", "cross-env": "^7.0.2", "cypress-file-upload": "^5.0.8", "npm-run-all": "^4.1.5" diff --git a/packages/admin-panel/src/VizBuilderApp/api/queries/index.js b/packages/admin-panel/src/VizBuilderApp/api/queries/index.js index 5a67bf9c21..50c875fe87 100644 --- a/packages/admin-panel/src/VizBuilderApp/api/queries/index.js +++ b/packages/admin-panel/src/VizBuilderApp/api/queries/index.js @@ -9,7 +9,5 @@ export * from './useUser'; export * from './useDashboardVisualisation'; export * from './useMapOverlays'; export * from './useCountries'; -export * from './useSearchDataSources'; -export * from './useSearchAggregationOptions'; export * from './useSearchPermissionGroups'; export * from './useSearchTransformSchemas'; diff --git a/packages/admin-panel/src/VizBuilderApp/api/queries/useLocations.js b/packages/admin-panel/src/VizBuilderApp/api/queries/useLocations.js index f47a178487..465786ed05 100644 --- a/packages/admin-panel/src/VizBuilderApp/api/queries/useLocations.js +++ b/packages/admin-panel/src/VizBuilderApp/api/queries/useLocations.js @@ -2,18 +2,38 @@ * Tupaia * Copyright (c) 2017 - 2021 Beyond Essential Systems Pty Ltd */ +import { useEffect } from 'react'; import { useQuery } from 'react-query'; +import debounce from 'lodash.debounce'; + import { get } from '../api'; import { DEFAULT_REACT_QUERY_OPTIONS } from '../constants'; -export const useLocations = (project, search) => +const useQueryResponse = (project, search) => useQuery( ['hierarchy', project, search], () => project - ? get(`hierarchy/${project}/${project}`, { params: { search, fields: 'name,code' } }) + ? get(`hierarchy/${project}/${project}`, { + params: { search, fields: 'name,code' }, + }) : [], { ...DEFAULT_REACT_QUERY_OPTIONS, + enabled: false, // disable this query from automatically running, used for debounce }, ); + +export const useLocations = (project, search) => { + const queryResponse = useQueryResponse(project, search); + const { refetch } = queryResponse; + useEffect(() => { + const debouncedSearch = debounce(() => { + refetch(); + }, 100); + debouncedSearch(); + return debouncedSearch.cancel; + }, [project, search]); + + return queryResponse; +}; diff --git a/packages/admin-panel/src/VizBuilderApp/api/queries/useReportPreview.js b/packages/admin-panel/src/VizBuilderApp/api/queries/useReportPreview.js index 8e7189a7ad..da479710a6 100644 --- a/packages/admin-panel/src/VizBuilderApp/api/queries/useReportPreview.js +++ b/packages/admin-panel/src/VizBuilderApp/api/queries/useReportPreview.js @@ -10,6 +10,8 @@ export const useReportPreview = ({ visualisation, project, location, + startDate, + endDate, testData, enabled, onSettled, @@ -23,6 +25,8 @@ export const useReportPreview = ({ params: { entityCode: location, hierarchy: project, + startDate, + endDate, dashboardItemOrMapOverlay, previewMode, permissionGroup: visualisation.permissionGroup || visualisation.reportPermissionGroup, diff --git a/packages/admin-panel/src/VizBuilderApp/api/queries/useSearchAggregationOptions.js b/packages/admin-panel/src/VizBuilderApp/api/queries/useSearchAggregationOptions.js deleted file mode 100644 index 09b1ab7b0c..0000000000 --- a/packages/admin-panel/src/VizBuilderApp/api/queries/useSearchAggregationOptions.js +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Tupaia - * Copyright (c) 2017 - 2022 Beyond Essential Systems Pty Ltd - */ -import { stringifyQuery } from '@tupaia/utils'; -import { useQuery, QueryClient } from 'react-query'; -import { get } from '../api'; -import { DEFAULT_REACT_QUERY_OPTIONS } from '../constants'; - -const QUERY_KEY = ['aggregationOptions']; - -const queryClient = new QueryClient(); - -const getAggregationOptions = async () => { - const endpoint = stringifyQuery(undefined, 'fetchAggregationOptions', {}); - return get(endpoint); -}; - -export const prefetchAggregationOptions = async () => { - await queryClient.prefetchQuery(QUERY_KEY, getAggregationOptions); -}; - -export const useSearchAggregationOptions = () => - useQuery(QUERY_KEY, getAggregationOptions, { - ...DEFAULT_REACT_QUERY_OPTIONS, - keepPreviousData: true, - }); diff --git a/packages/admin-panel/src/VizBuilderApp/api/queries/useSearchDataSources.js b/packages/admin-panel/src/VizBuilderApp/api/queries/useSearchDataSources.js deleted file mode 100644 index d2cec5e89e..0000000000 --- a/packages/admin-panel/src/VizBuilderApp/api/queries/useSearchDataSources.js +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Tupaia - * Copyright (c) 2017 - 2021 Beyond Essential Systems Pty Ltd - */ -import { stringifyQuery } from '@tupaia/utils'; -import { useQuery } from 'react-query'; -import { get } from '../api'; -import { DEFAULT_REACT_QUERY_OPTIONS } from '../constants'; - -export const useSearchDataSources = ({ search, type = 'dataElement', maxResults = 100 }) => { - const result = useQuery( - [`${type}s`, search], - async () => { - const endpoint = stringifyQuery(undefined, `${type}s`, { - columns: JSON.stringify(['code']), - filter: JSON.stringify({ - code: { comparator: 'ilike', comparisonValue: `%${search}%`, castAs: 'text' }, - }), - pageSize: maxResults, - }); - return get(endpoint); - }, - { - ...DEFAULT_REACT_QUERY_OPTIONS, - keepPreviousData: true, - }, - ); - const mappedData = result?.data?.map(value => ({ ...value, type })); - return { - ...result, - data: mappedData, - }; -}; diff --git a/packages/admin-panel/src/VizBuilderApp/components/DataLibrary/AggregationDataLibrary.js b/packages/admin-panel/src/VizBuilderApp/components/DataLibrary/AggregationDataLibrary.js deleted file mode 100644 index 55c4f5eafc..0000000000 --- a/packages/admin-panel/src/VizBuilderApp/components/DataLibrary/AggregationDataLibrary.js +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Tupaia - * Copyright (c) 2017 - 2022 Beyond Essential Systems Pty Ltd - */ - -import React, { useState } from 'react'; -import PropTypes from 'prop-types'; -import { DataLibrary } from '@tupaia/ui-components'; -import { prefetchAggregationOptions, useSearchAggregationOptions } from '../../api'; -import { AggregateSelectedOptionWithJsonEditor } from './component'; - -// Converts internal value array to Viz config.aggregate data structure -const aggregateToValue = aggregate => { - const value = []; - let index = 0; - for (const agg of aggregate) { - if (typeof agg === 'string') { - value.push({ - id: `${agg}-${index}`, // id used by drag and drop function - code: agg, - }); - } else if (typeof agg === 'object') { - const { id, type: code, ...restOfConfig } = agg; - value.push({ - id: id || `${code}-${index}`, // id used by drag and drop function - code, - ...restOfConfig, - }); - } - index++; - } - return value; -}; - -const valueToAggregate = value => - value.map(({ id, code, isDisabled = false, ...restOfConfig }, index) => ({ - id: id || `${code}-${index}`, // option from selectable options does not have id. - type: code, - isDisabled, - ...restOfConfig, - })); - -export const AggregationDataLibrary = ({ aggregate, onAggregateChange, onInvalidChange }) => { - const [inputValue, setInputValue] = useState(''); - - const value = aggregateToValue(aggregate); - const { data: options, isFetching } = useSearchAggregationOptions(); - - const onChange = (event, newValue) => onAggregateChange(valueToAggregate(newValue)); - - const onRemove = (event, option) => { - onChange( - event, - value.filter(item => option.id !== item.id), - ); - }; - - return ( - (event ? setInputValue(newInputValue) : false)} - isLoading={isFetching} - onMouseEnter={prefetchAggregationOptions} - optionComponent={(option, setIsDragDisabled) => ( - code === option.code)} - onChange={newValue => { - const newSelectedAggregations = Array.from(value); - const index = newSelectedAggregations.findIndex( - aggregation => aggregation.id === option.id, - ); - newSelectedAggregations[index] = { - ...newSelectedAggregations[index], - ...newValue, - }; - onAggregateChange(valueToAggregate(newSelectedAggregations)); - }} - onRemove={onRemove} - setIsDragDisabled={setIsDragDisabled} - onInvalidChange={onInvalidChange} - /> - )} - allowAddMultipleTimes - supportsDisableAll - /> - ); -}; - -AggregationDataLibrary.propTypes = { - aggregate: PropTypes.oneOfType([ - PropTypes.arrayOf( - PropTypes.shape({ - type: PropTypes.string.isRequired, - config: PropTypes.object, - isDisabled: PropTypes.bool, - }), - ), - PropTypes.string, - ]).isRequired, - onAggregateChange: PropTypes.func.isRequired, - onInvalidChange: PropTypes.func.isRequired, -}; diff --git a/packages/admin-panel/src/VizBuilderApp/components/DataLibrary/DataElementDataLibrary.js b/packages/admin-panel/src/VizBuilderApp/components/DataLibrary/DataElementDataLibrary.js deleted file mode 100644 index edcaa5763b..0000000000 --- a/packages/admin-panel/src/VizBuilderApp/components/DataLibrary/DataElementDataLibrary.js +++ /dev/null @@ -1,127 +0,0 @@ -/* - * Tupaia - * Copyright (c) 2017 - 2021 Beyond Essential Systems Pty Ltd - */ - -import React, { useState } from 'react'; -import generateId from 'uuid/v1'; -import { BaseSelectedOption, DataLibrary } from '@tupaia/ui-components'; -import PropTypes from 'prop-types'; -import { useSearchDataSources } from '../../api'; -import { useDebounce } from '../../../utilities'; - -const DATA_TYPES = { - DATA_ELEMENT: 'Data Elements', - DATA_GROUP: 'Data Groups', -}; - -const MAX_RESULTS = 20; - -// Converts internal value array to Viz `fetch` data structure -const valueToFetch = value => { - const newFetch = {}; - - const dataElements = value - .filter(item => item.type === 'dataElement') - .map(dataElement => dataElement.code); - - const dataGroups = value - .filter(item => item.type === 'dataGroup') - .map(dataGroup => dataGroup.code); - - if (dataElements.length > 0) newFetch.dataElements = dataElements; - if (dataGroups.length > 0) newFetch.dataGroups = dataGroups; - - return newFetch; -}; - -const fetchToValue = fetch => { - let newValue = []; - - if (fetch.dataElements) { - newValue = [ - ...newValue, - ...fetch.dataElements.map(deCode => ({ - id: generateId(), - code: deCode, - type: 'dataElement', - })), - ]; - } - if (fetch.dataGroups) { - newValue = [ - ...newValue, - ...fetch.dataGroups.map(dgCode => ({ - id: generateId(), - code: dgCode, - type: 'dataGroup', - })), - ]; - } - - return newValue; -}; - -export const DataElementDataLibrary = ({ fetch, onFetchChange }) => { - const value = fetchToValue(fetch); - - const [dataType, setDataType] = useState(DATA_TYPES.DATA_ELEMENT); - - const [inputValue, setInputValue] = useState(''); - - const debouncedInputValue = useDebounce(inputValue, 200); - - const { - data: dataElementSearchResults = [], - isFetching: isFetchingDataElements, - } = useSearchDataSources({ - search: debouncedInputValue, - type: 'dataElement', - maxResults: MAX_RESULTS, - }); - const { - data: dataGroupSearchResults = [], - isFetching: isFetchingDataGroups, - } = useSearchDataSources({ - search: debouncedInputValue, - type: 'dataGroup', - maxResults: MAX_RESULTS, - }); - - const options = { - [DATA_TYPES.DATA_ELEMENT]: inputValue ? dataElementSearchResults : [], - [DATA_TYPES.DATA_GROUP]: inputValue ? dataGroupSearchResults : [], - }; - - const onChange = (event, newValue) => onFetchChange(valueToFetch(newValue)); - const onRemove = (event, option) => { - onChange( - event, - value.filter(item => option !== item), - ); - }; - - return ( - setDataType(newValue)} - value={value} - onChange={onChange} - inputValue={inputValue} - onInputChange={(event, newInputValue) => (event ? setInputValue(newInputValue) : false)} - isLoading={isFetchingDataElements || isFetchingDataGroups} - searchPageSize={MAX_RESULTS} - optionComponent={option => } - /> - ); -}; - -DataElementDataLibrary.propTypes = { - fetch: PropTypes.shape({ - dataElements: PropTypes.arrayOf(PropTypes.string).isRequired, - dataGroups: PropTypes.arrayOf(PropTypes.string), - }).isRequired, - onFetchChange: PropTypes.func.isRequired, -}; diff --git a/packages/admin-panel/src/VizBuilderApp/components/DataLibrary/index.js b/packages/admin-panel/src/VizBuilderApp/components/DataLibrary/index.js index 2f11318c55..fd602b148b 100644 --- a/packages/admin-panel/src/VizBuilderApp/components/DataLibrary/index.js +++ b/packages/admin-panel/src/VizBuilderApp/components/DataLibrary/index.js @@ -3,6 +3,4 @@ * Copyright (c) 2017 - 2021 Beyond Essential Systems Pty Ltd */ -export * from './DataElementDataLibrary'; -export * from './AggregationDataLibrary'; export * from './TransformDataLibrary'; diff --git a/packages/admin-panel/src/VizBuilderApp/components/Panel.js b/packages/admin-panel/src/VizBuilderApp/components/Panel.js index a0101d919a..0e386e620d 100644 --- a/packages/admin-panel/src/VizBuilderApp/components/Panel.js +++ b/packages/admin-panel/src/VizBuilderApp/components/Panel.js @@ -2,21 +2,14 @@ * Tupaia * Copyright (c) 2017 - 2021 Beyond Essential Systems Pty Ltd */ -import React, { useState } from 'react'; +import React from 'react'; import styled from 'styled-components'; -import MuiTab from '@material-ui/core/Tab'; -import ChevronRight from '@material-ui/icons/ChevronRight'; -import MuiTabs from '@material-ui/core/Tabs'; import { FlexColumn, FlexSpaceBetween, JsonEditor } from '@tupaia/ui-components'; import { TabPanel } from './TabPanel'; import { PlayButton } from './PlayButton'; import { JsonToggleButton } from './JsonToggleButton'; import { useTabPanel, useVizConfig, useVisualisation, useVizConfigError } from '../context'; -import { - DataElementDataLibrary, - AggregationDataLibrary, - TransformDataLibrary, -} from './DataLibrary'; +import { TransformDataLibrary } from './DataLibrary'; const Container = styled(FlexColumn)` position: relative; @@ -31,34 +24,6 @@ const PanelNav = styled(FlexSpaceBetween)` padding-right: 1rem; `; -const Tabs = styled(MuiTabs)` - width: 100%; - padding-right: 1.6rem; - - .MuiTabs-indicator { - height: 5px; - } -`; - -const Tab = styled(MuiTab)` - position: relative; - font-size: 15px; - line-height: 140%; - font-weight: 400; - min-width: 100px; - padding-top: 20px; - padding-bottom: 20px; - overflow: visible; - - .MuiSvgIcon-root { - position: absolute; - top: 50%; - transform: translate(50%, -50%); - right: 0; - color: ${({ theme }) => theme.palette.grey['400']}; - } -`; - const PanelTabPanel = styled.div` flex: 1; display: flex; @@ -76,9 +41,8 @@ const PanelTabPanel = styled.div` `; export const Panel = () => { - const { hasDataError, setDataError } = useVizConfigError(); + const { setDataError } = useVizConfigError(); const { jsonToggleEnabled } = useTabPanel(); - const [tab, setTab] = useState(0); const [ { visualisation: { data: dataWithConfig }, @@ -90,10 +54,6 @@ export const Panel = () => { visualisation: { data: vizData }, } = useVisualisation(); - const handleChange = (event, newValue) => { - setTab(newValue); - }; - const handleInvalidChange = errMsg => { setDataError(errMsg); }; @@ -103,10 +63,6 @@ export const Panel = () => { setDataError(null); }; - const isTabDisabled = tabId => { - return tab !== tabId && hasDataError; - }; - const isCustomReport = 'customReport' in vizData; // Custom report vizes don't support any configuration so just show the name if (isCustomReport) { @@ -128,56 +84,10 @@ export const Panel = () => { return ( - - } /> - } /> - - - - {jsonToggleEnabled ? ( - setTabValue('fetch', value)} - onInvalidChange={handleInvalidChange} - {...jsonEditorProps} - /> - ) : ( - { - setTabValue('fetch', value); - }} - /> - )} - - - {jsonToggleEnabled ? ( - setTabValue('aggregate', value)} - onInvalidChange={handleInvalidChange} - {...jsonEditorProps} - /> - ) : ( - { - setTabValue('aggregate', value); - }} - onInvalidChange={handleInvalidChange} - /> - )} - - + {jsonToggleEnabled ? ( { - setLocationSearch(newValue); - }, - [200], - )} + onInputChange={(event, newValue) => { + setLocationSearch(newValue); + }} getOptionLabel={option => option.name} renderOption={option => {option.name}} onChange={onChange} @@ -132,9 +145,14 @@ export const PreviewOptions = () => { const [locationSearch, setLocationSearch] = useState(''); const [selectedProjectOption, setSelectedProjectOption] = useState(null); const [selectedLocationOption, setSelectedLocationOption] = useState(null); + const [selectedStartDate, setSelectedStartDate] = useState(null); + const [selectedEndDate, setSelectedEndDate] = useState(null); const [fileName, setFileName] = useState(''); const [isImportModalOpen, setIsImportModalOpen] = useState(false); - const [{ project, location }, { setProject, setLocation, setTestData }] = useVizConfig(); + const [ + { project, location }, + { setProject, setLocation, setStartDate, setEndDate, setTestData }, + ] = useVizConfig(); const { mutateAsync: uploadTestData } = useUploadTestData(); const handleSelectProject = (event, value) => { @@ -155,6 +173,16 @@ export const PreviewOptions = () => { setLocation(value.code); }; + const handleChangeStartDate = date => { + setSelectedStartDate(date.toISOString()); + setStartDate(date.toISOString()); + }; + + const handleChangeEndDate = date => { + setSelectedEndDate(date.toISOString()); + setEndDate(date.toISOString()); + }; + const handleUploadData = async file => { const response = await uploadTestData(file); setShowData(false); @@ -203,6 +231,16 @@ export const PreviewOptions = () => { setLocationSearch={setLocationSearch} onChange={handleSelectLocation} /> + + { setIsImportModalOpen(true); diff --git a/packages/admin-panel/src/VizBuilderApp/components/PreviewSection.js b/packages/admin-panel/src/VizBuilderApp/components/PreviewSection.js index 751684b2ea..5d7f46ab5e 100644 --- a/packages/admin-panel/src/VizBuilderApp/components/PreviewSection.js +++ b/packages/admin-panel/src/VizBuilderApp/components/PreviewSection.js @@ -13,6 +13,7 @@ import { TabPanel } from './TabPanel'; import { useReportPreview } from '../api'; import { usePreviewData, useVisualisation, useVizConfig, useVizConfigError } from '../context'; import { IdleMessage } from './IdleMessage'; +import { getColumns } from '../../utilities'; const PreviewTabs = styled(MuiTabs)` background: white; @@ -105,41 +106,16 @@ const TABS = { const getTab = index => Object.values(TABS).find(tab => tab.index === index); -const convertValueToPrimitive = val => { - if (val === null) return val; - switch (typeof val) { - case 'object': - return JSON.stringify(val); - case 'function': - return '[Function]'; - default: - return val; - } -}; - -const getColumns = ({ columns: columnKeys = [] }) => { - const indexColumn = { - Header: '#', - id: 'index', - accessor: (_row, i) => i + 1, - }; - const columns = columnKeys.map(columnKey => { - return { - Header: columnKey, - accessor: row => convertValueToPrimitive(row[columnKey]), - }; - }); - - return [indexColumn, ...columns]; -}; - export const PreviewSection = () => { const [tab, setTab] = useState(0); const { fetchEnabled, setFetchEnabled, showData } = usePreviewData(); const { hasPresentationError, setPresentationError } = useVizConfigError(); - const [{ project, location, testData, visualisation }, { setPresentation }] = useVizConfig(); + const [ + { project, location, startDate, endDate, testData, visualisation }, + { setPresentation }, + ] = useVizConfig(); const { visualisationForFetchingData } = useVisualisation(); const [viewContent, setViewContent] = useState(null); @@ -156,6 +132,8 @@ export const PreviewSection = () => { visualisation: visualisationForFetchingData, project, location, + startDate, + endDate, testData, enabled: fetchEnabled, onSettled: () => { diff --git a/packages/admin-panel/src/VizBuilderApp/context/VizConfig.js b/packages/admin-panel/src/VizBuilderApp/context/VizConfig.js index e49803ec66..e1c1c82802 100644 --- a/packages/admin-panel/src/VizBuilderApp/context/VizConfig.js +++ b/packages/admin-panel/src/VizBuilderApp/context/VizConfig.js @@ -12,6 +12,8 @@ import React, { useReducer, createContext, useContext } from 'react'; const initialConfigState = { project: null, location: null, + startDate: null, + endDate: null, testData: null, visualisation: { id: null, @@ -19,11 +21,6 @@ const initialConfigState = { code: null, permissionGroup: null, data: { - fetch: { - dataElements: [], - dataGroups: [], - }, - aggregate: [], transform: [], }, presentation: {}, @@ -32,6 +29,8 @@ const initialConfigState = { const SET_PROJECT = 'SET_PROJECT'; const SET_LOCATION = 'SET_LOCATION'; +const SET_START_DATE = 'SET_START_DATE'; +const SET_END_DATE = 'SET_END_DATE'; const SET_TEST_DATA = 'SET_TEST_DATA'; const SET_VISUALISATION = 'SET_VISUALISATION'; const SET_VISUALISATION_VALUE = 'SET_VISUALISATION_VALUE'; @@ -49,6 +48,12 @@ function configReducer(state, action) { case SET_PROJECT: { return set(state, 'project', action.value); } + case SET_START_DATE: { + return set(state, 'startDate', action.value); + } + case SET_END_DATE: { + return set(state, 'endDate', action.value); + } case SET_TEST_DATA: { return set(state, 'testData', action.value); } @@ -95,6 +100,8 @@ const useConfigStore = () => { const setLocation = value => dispatch({ type: SET_LOCATION, value }); const setProject = value => dispatch({ type: SET_PROJECT, value }); + const setStartDate = value => dispatch({ type: SET_START_DATE, value }); + const setEndDate = value => dispatch({ type: SET_END_DATE, value }); const setTestData = value => dispatch({ type: SET_TEST_DATA, value }); const setVisualisation = value => { if (!value.data.transform) { @@ -123,6 +130,8 @@ const useConfigStore = () => { { setLocation, setProject, + setStartDate, + setEndDate, setTestData, setVisualisation, setVisualisationValue, @@ -136,22 +145,7 @@ const VisualisationContext = createContext(initialConfigState.visualisation); const amendStepsToBaseConfig = visualisation => { const { data } = { ...visualisation }; - const { aggregate, transform } = { ...data }; - // Remove frontend config (isDisabled, id, schema) in aggregation steps. - const filteredAggregate = Array.isArray(aggregate) - ? aggregate.map(agg => { - if (typeof agg === 'string') { - return { - type: agg, - }; - } - if (typeof agg === 'object') { - const { isDisabled, id, schema, ...restOfConfig } = agg; - return restOfConfig; - } - return agg; - }) - : aggregate; + const { transform } = data; // Remove frontend configs (isDisabled, id, schema) in transform steps. If it is an alias return as a string. const filteredTransform = Array.isArray(transform) @@ -165,7 +159,7 @@ const amendStepsToBaseConfig = visualisation => { }) : transform; - const filteredData = { ...data, aggregate: filteredAggregate, transform: filteredTransform }; + const filteredData = { ...data, transform: filteredTransform }; return { ...visualisation, data: filteredData }; }; diff --git a/packages/admin-panel/src/VizBuilderApp/views/Main.js b/packages/admin-panel/src/VizBuilderApp/views/Main.js index e66db7718f..58cb1e5500 100644 --- a/packages/admin-panel/src/VizBuilderApp/views/Main.js +++ b/packages/admin-panel/src/VizBuilderApp/views/Main.js @@ -31,6 +31,8 @@ const Container = styled(MuiContainer)` const RightCol = styled(FlexColumn)` padding-left: 30px; flex: 1; + // To solve overflow problem of data table content, use min-width: 0 https://stackoverflow.com/a/66689926 + min-width: 0; `; const StyledAlert = styled(SmallAlert)` diff --git a/packages/admin-panel/src/autocomplete/Autocomplete.js b/packages/admin-panel/src/autocomplete/Autocomplete.js index 81e87b5715..96240552cb 100644 --- a/packages/admin-panel/src/autocomplete/Autocomplete.js +++ b/packages/admin-panel/src/autocomplete/Autocomplete.js @@ -1,18 +1,14 @@ -/** - * Tupaia MediTrak - * Copyright (c) 2018 Beyond Essential Systems Pty Ltd +/* + * Tupaia + * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd */ import React from 'react'; import PropTypes from 'prop-types'; -import throttle from 'lodash.throttle'; -import { connect } from 'react-redux'; -import MuiChip from '@material-ui/core/Chip'; import { createFilterOptions } from '@material-ui/lab/Autocomplete'; import styled from 'styled-components'; -import { Autocomplete as AutocompleteBase } from '@tupaia/ui-components'; -import { getAutocompleteState } from './selectors'; -import { changeSelection, changeSearchTerm, clearState } from './actions'; +import MuiChip from '@material-ui/core/Chip'; +import { Autocomplete as UIAutocomplete } from '@tupaia/ui-components'; const Chip = styled(MuiChip)` &:first-child { @@ -20,179 +16,118 @@ const Chip = styled(MuiChip)` } `; -const getPlaceholder = (placeholder, selection) => { - if (selection && selection.length) { - return null; - } - - if (placeholder) { - return Array.isArray(placeholder) ? placeholder.join(', ') : placeholder; - } - return 'Start typing to search'; -}; - -const filter = createFilterOptions(); - -const AutocompleteComponent = React.memo( - ({ +export const Autocomplete = props => { + const { + value, + label, + options, + getOptionSelected, + getOptionLabel, + isLoading, onChangeSelection, onChangeSearchTerm, - selection, - isLoading, - results, - label, - onClearState, - optionLabelKey, - allowMultipleValues, - canCreateNewOptions, searchTerm, placeholder, helperText, - }) => { - React.useEffect(() => { - onChangeSearchTerm(''); - - return () => { - onClearState(); - }; - }, []); - - let value = selection; - - // If value is null and multiple is true mui autocomplete will crash - if (allowMultipleValues && selection === null && !searchTerm) { - value = []; - } - - return ( - - option[optionLabelKey] === selected[optionLabelKey] - } - getOptionLabel={option => (option && option[optionLabelKey] ? option[optionLabelKey] : '')} - loading={isLoading} - onChange={onChangeSelection} - onInputChange={throttle((event, newValue) => onChangeSearchTerm(newValue), 50)} - inputValue={searchTerm} - placeholder={getPlaceholder(placeholder, selection)} - helperText={helperText} - muiProps={{ - filterOptions: (options, params) => { - const filtered = filter(options, params); - - // Suggest the creation of a new value - if (canCreateNewOptions && params.inputValue !== '') { + canCreateNewOptions, + allowMultipleValues, + optionLabelKey, + muiProps, + } = props; + + const muiPropsForCreateNewOptions = canCreateNewOptions + ? { + filterOptions: (autocompleteOptions, params) => { + const filter = createFilterOptions(); + const filtered = filter(autocompleteOptions, params); + const { inputValue } = params; + + // Suggest the creation of a new value + if (inputValue !== '') { + if (optionLabelKey) { filtered.push({ - [optionLabelKey]: params.inputValue, + [optionLabelKey]: inputValue, }); - } - - return filtered; - }, - freeSolo: canCreateNewOptions, - disableClearable: allowMultipleValues, - multiple: allowMultipleValues, - selectOnFocus: canCreateNewOptions, - clearOnBlur: canCreateNewOptions, - handleHomeEndKeys: canCreateNewOptions, - renderTags: (values, getTagProps) => - values.map((option, index) => ( - - )), - }} - /> - ); - }, -); + } else filtered.push(inputValue); + } + + return filtered; + }, + freeSolo: true, + selectOnFocus: true, + clearOnBlur: true, + handleHomeEndKeys: true, + } + : {}; + + const muiPropsForMultipleValues = allowMultipleValues + ? { + disableClearable: true, + multiple: true, + renderTags: (values, getTagProps) => + values.map((option, index) => ( + + )), + } + : {}; + + const extraMuiProps = { + ...muiPropsForCreateNewOptions, + ...muiPropsForMultipleValues, + ...muiProps, + }; + + return ( + onChangeSearchTerm(newValue)} + inputValue={searchTerm} + placeholder={placeholder} + helperText={helperText} + muiProps={extraMuiProps} + /> + ); +}; -AutocompleteComponent.propTypes = { - allowMultipleValues: PropTypes.bool, - canCreateNewOptions: PropTypes.bool, - isLoading: PropTypes.bool.isRequired, - onChangeSearchTerm: PropTypes.func.isRequired, +Autocomplete.propTypes = { + // eslint-disable-next-line react/forbid-prop-types + value: PropTypes.any, + label: PropTypes.string, + options: PropTypes.array.isRequired, + getOptionSelected: PropTypes.func.isRequired, + getOptionLabel: PropTypes.func.isRequired, + isLoading: PropTypes.bool, onChangeSelection: PropTypes.func.isRequired, - onClearState: PropTypes.func.isRequired, - optionLabelKey: PropTypes.string.isRequired, - results: PropTypes.arrayOf(PropTypes.object), + onChangeSearchTerm: PropTypes.func, searchTerm: PropTypes.string, - placeholder: PropTypes.oneOfType([PropTypes.array, PropTypes.string]), - label: PropTypes.string, + placeholder: PropTypes.string, helperText: PropTypes.string, - selection: PropTypes.oneOfType([PropTypes.array, PropTypes.object]), + canCreateNewOptions: PropTypes.bool, + allowMultipleValues: PropTypes.bool, + optionLabelKey: PropTypes.string, + muiProps: PropTypes.object, }; -AutocompleteComponent.defaultProps = { - allowMultipleValues: false, - selection: [], - results: [], +Autocomplete.defaultProps = { + value: null, + label: '', + isLoading: false, + searchTerm: '', + placeholder: '', + helperText: '', canCreateNewOptions: false, - searchTerm: null, - placeholder: null, - label: null, - helperText: null, -}; - -const mapStateToProps = (state, { reduxId }) => { - const { selection, searchTerm, results, isLoading, fetchId } = getAutocompleteState( - state, - reduxId, - ); - return { selection, searchTerm, results, isLoading, fetchId }; + allowMultipleValues: false, + muiProps: {}, + optionLabelKey: null, + onChangeSearchTerm: () => {}, }; - -const mapDispatchToProps = ( - dispatch, - { - endpoint, - optionLabelKey, - optionValueKey, - reduxId, - onChange, - parentRecord = {}, - allowMultipleValues, - baseFilter, - pageSize, - }, -) => ({ - onChangeSelection: (event, newSelection, reason) => { - if (newSelection === null) { - onChange(null); - } else if (allowMultipleValues) { - const newValues = newSelection.map(selected => selected[optionValueKey]); - if (reason === 'create-option') { - newValues[newValues.length - 1] = event.target.value; - } - onChange(newValues); - } else { - onChange(newSelection[optionValueKey]); - } - - // @see https://material-ui.com/api/autocomplete for a description of reasons - if (reason === 'create-option') { - const newValues = newSelection; - newValues[newValues.length - 1] = { [optionLabelKey]: event.target.value }; - dispatch(changeSelection(reduxId, newValues)); - } else { - dispatch(changeSelection(reduxId, newSelection)); - } - }, - onChangeSearchTerm: newSearchTerm => - dispatch( - changeSearchTerm( - reduxId, - endpoint, - optionLabelKey, - optionValueKey, - newSearchTerm, - parentRecord, - baseFilter, - pageSize, - ), - ), - onClearState: () => dispatch(clearState(reduxId)), -}); - -export const Autocomplete = connect(mapStateToProps, mapDispatchToProps)(AutocompleteComponent); diff --git a/packages/admin-panel/src/autocomplete/ReduxAutocomplete.js b/packages/admin-panel/src/autocomplete/ReduxAutocomplete.js new file mode 100644 index 0000000000..c256528fe4 --- /dev/null +++ b/packages/admin-panel/src/autocomplete/ReduxAutocomplete.js @@ -0,0 +1,168 @@ +/** + * Tupaia MediTrak + * Copyright (c) 2018 Beyond Essential Systems Pty Ltd + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { getAutocompleteState } from './selectors'; +import { changeSelection, changeSearchTerm, clearState } from './actions'; +import { Autocomplete } from './Autocomplete'; + +const getPlaceholder = (placeholder, selection) => { + if (selection && selection.length) { + return null; + } + + if (placeholder) { + return Array.isArray(placeholder) ? placeholder.join(', ') : placeholder; + } + return 'Start typing to search'; +}; + +const ReduxAutocompleteComponent = React.memo( + ({ + onChangeSelection, + onChangeSearchTerm, + selection, + isLoading, + results, + label, + onClearState, + optionLabelKey, + allowMultipleValues, + canCreateNewOptions, + searchTerm, + placeholder, + helperText, + }) => { + React.useEffect(() => { + onChangeSearchTerm(''); + + return () => { + onClearState(); + }; + }, []); + + let value = selection; + + // If value is null and multiple is true mui autocomplete will crash + if (allowMultipleValues && selection === null && !searchTerm) { + value = []; + } + + return ( + + option[optionLabelKey] === selected[optionLabelKey] + } + getOptionLabel={option => (option && option[optionLabelKey] ? option[optionLabelKey] : '')} + isLoading={isLoading} + onChangeSelection={onChangeSelection} + onChangeSearchTerm={onChangeSearchTerm} + searchTerm={searchTerm} + placeholder={getPlaceholder(placeholder, selection)} + helperText={helperText} + canCreateNewOptions={canCreateNewOptions} + allowMultipleValues={allowMultipleValues} + optionLabelKey={optionLabelKey} + /> + ); + }, +); + +ReduxAutocompleteComponent.propTypes = { + allowMultipleValues: PropTypes.bool, + canCreateNewOptions: PropTypes.bool, + isLoading: PropTypes.bool.isRequired, + onChangeSearchTerm: PropTypes.func.isRequired, + onChangeSelection: PropTypes.func.isRequired, + onClearState: PropTypes.func.isRequired, + optionLabelKey: PropTypes.string.isRequired, + results: PropTypes.arrayOf(PropTypes.object), + searchTerm: PropTypes.string, + placeholder: PropTypes.oneOfType([PropTypes.array, PropTypes.string]), + label: PropTypes.string, + helperText: PropTypes.string, + selection: PropTypes.oneOfType([PropTypes.array, PropTypes.object]), +}; + +ReduxAutocompleteComponent.defaultProps = { + allowMultipleValues: false, + selection: [], + results: [], + canCreateNewOptions: false, + searchTerm: null, + placeholder: null, + label: null, + helperText: null, +}; + +const mapStateToProps = (state, { reduxId }) => { + const { selection, searchTerm, results, isLoading, fetchId } = getAutocompleteState( + state, + reduxId, + ); + return { selection, searchTerm, results, isLoading, fetchId }; +}; + +const mapDispatchToProps = ( + dispatch, + { + endpoint, + optionLabelKey, + optionValueKey, + reduxId, + onChange, + parentRecord = {}, + allowMultipleValues, + baseFilter, + pageSize, + }, +) => ({ + onChangeSelection: (event, newSelection, reason) => { + if (newSelection === null) { + onChange(null); + } else if (allowMultipleValues) { + const newValues = newSelection.map(selected => selected[optionValueKey]); + if (reason === 'create-option') { + newValues[newValues.length - 1] = event.target.value; + } + onChange(newValues); + } else { + onChange(newSelection[optionValueKey]); + } + + // @see https://material-ui.com/api/autocomplete for a description of reasons + if (reason === 'create-option') { + const newValues = newSelection; + newValues[newValues.length - 1] = { [optionLabelKey]: event.target.value }; + dispatch(changeSelection(reduxId, newValues)); + } else { + dispatch(changeSelection(reduxId, newSelection)); + } + }, + onChangeSearchTerm: newSearchTerm => + dispatch( + changeSearchTerm( + reduxId, + endpoint, + optionLabelKey, + optionValueKey, + newSearchTerm, + parentRecord, + baseFilter, + pageSize, + ), + ), + onClearState: () => dispatch(clearState(reduxId)), +}); + +export const ReduxAutocomplete = connect( + mapStateToProps, + mapDispatchToProps, +)(ReduxAutocompleteComponent); diff --git a/packages/admin-panel/src/autocomplete/index.js b/packages/admin-panel/src/autocomplete/index.js index e200c9df0b..d6848d58e4 100644 --- a/packages/admin-panel/src/autocomplete/index.js +++ b/packages/admin-panel/src/autocomplete/index.js @@ -3,5 +3,6 @@ * Copyright (c) 2018 Beyond Essential Systems Pty Ltd */ +export { ReduxAutocomplete } from './ReduxAutocomplete'; export { Autocomplete } from './Autocomplete'; export { reducer } from './reducer'; diff --git a/packages/admin-panel/src/dataTables/DataTableEditFields.js b/packages/admin-panel/src/dataTables/DataTableEditFields.js new file mode 100644 index 0000000000..87e424f8cf --- /dev/null +++ b/packages/admin-panel/src/dataTables/DataTableEditFields.js @@ -0,0 +1,268 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2021 Beyond Essential Systems Pty Ltd + */ + +import React, { useEffect, useMemo, useState } from 'react'; +import styled from 'styled-components'; +import { + Select, + TextField, + DataTable, + FetchLoader, + Autocomplete as ExternalDatabaseConnectionAutocomplete, +} from '@tupaia/ui-components'; +import Grid from '@material-ui/core/Grid'; +import PropTypes from 'prop-types'; +import { Accordion, AccordionDetails, AccordionSummary } from '@material-ui/core'; +import { DataTableType } from '@tupaia/types'; +import { PreviewFilters } from './components/PreviewFilters'; +import { ReduxAutocomplete } from '../autocomplete'; +import { SqlDataTableConfigEditFields } from './config'; +import { useParams } from './useParams'; +import { useDataTablePreview, useExternalDatabaseConnections } from './query'; +import { getColumns } from '../utilities'; +import { PlayButton } from './PlayButton'; + +const FieldWrapper = styled.div` + padding: 7.5px; +`; + +const StyledTable = styled(DataTable)` + min-height: 200px; + + table { + border-top: 1px solid ${({ theme }) => theme.palette.grey['400']}; + border-bottom: 1px solid ${({ theme }) => theme.palette.grey['400']}; + table-layout: auto; + + thead { + text-transform: none; + } + } +`; + +const dataTableTypeOptions = Object.values(DataTableType).map(type => ({ + label: type, + value: type, +})); + +const NoConfig = () => <>This Data Table type has no configuration options; + +const typeFieldsMap = { + ...Object.fromEntries(Object.values(DataTableType).map(type => [type, NoConfig])), + [DataTableType.sql]: SqlDataTableConfigEditFields, +}; + +export const DataTableEditFields = React.memo( + props => { + const { onEditField, recordData, isLoading: isDataLoading } = props; + if (isDataLoading) { + return
; + } + + const [fetchDisabled, setFetchDisabled] = useState(false); + const [haveTriedToFetch, setHaveTriedToFetch] = useState(false); // prevent to show error when entering the page + const { data: externalDatabaseConnections = [] } = useExternalDatabaseConnections(); + const { + builtInParams, + additionalParams, + runtimeParams, + upsertRuntimeParam, + onParamsAdd, + onParamsDelete, + onParamsChange, + } = useParams({ + onEditField, + recordData, + }); + + useEffect(() => { + const hasError = recordData?.config?.additionalParams?.some(p => p.hasError); + setFetchDisabled(hasError === undefined ? false : !!hasError); + }, [JSON.stringify(recordData)]); + + const { + data: reportData = { columns: [], rows: [], limit: 0, total: 0 }, + refetch, + isLoading, + isFetching, + isError, + error, + } = useDataTablePreview({ + previewConfig: recordData, + builtInParams, + additionalParams, + runtimeParams, + onSettled: () => { + setFetchDisabled(false); + }, + }); + + const fetchPreviewData = () => { + setHaveTriedToFetch(true); + refetch(); + }; + + const columns = useMemo(() => getColumns(reportData), [reportData]); + const rows = useMemo(() => reportData.rows, [reportData]); + + const ConfigComponent = typeFieldsMap[recordData.type] ?? null; + + const onChangeType = newType => { + if (newType === DataTableType.sql) { + onEditField('config', { + sql: "SELECT * FROM analytics WHERE entity_code = 'DL';", + externalDatabaseConnectionCode: null, + additionalParams: [], + }); + } else { + onEditField('config', {}); + } + onEditField('type', newType); + }; + + const onSqlConfigChange = (field, newValue) => { + if (recordData.type === DataTableType.sql) { + onEditField('config', { + ...recordData?.config, + [field]: newValue, + }); + } + }; + + return ( +
+ + Data Table + + + onEditField('code', event.target.value.trim())} + /> + + + onEditField('description', event.target.value)} + /> + + + onEditField('permission_groups', selectedValues)} + placeholder={recordData.permission_groups} + id="inputField-permission_groups" + reduxId="dataTableEditFields-permission_groups" + endpoint="permissionGroups" + optionLabelKey="name" + optionValueKey="name" + /> + + + { + onChange(event.target.value); + }} + /> + ); +}; + +BooleanField.propTypes = { + ...ParameterType, + onChange: PropTypes.func.isRequired, + value: PropTypes.bool, +}; + +BooleanField.defaultProps = { + value: null, +}; diff --git a/packages/admin-panel/src/dataTables/components/PreviewFilters/filters/DataElementCodesField.js b/packages/admin-panel/src/dataTables/components/PreviewFilters/filters/DataElementCodesField.js new file mode 100644 index 0000000000..229ead7677 --- /dev/null +++ b/packages/admin-panel/src/dataTables/components/PreviewFilters/filters/DataElementCodesField.js @@ -0,0 +1,33 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + */ + +import React from 'react'; + +import PropTypes from 'prop-types'; + +import { ParameterType } from '../../editing'; +import { ReduxAutocomplete } from '../../../../autocomplete'; + +export const DataElementCodesField = ({ name, onChange }) => { + return ( + onChange(selectedValues)} + id="inputField-dataElementCodes" + reduxId="dataTableEditFields-dataElementCodesField" + endpoint="dataElements" + optionLabelKey="code" + optionValueKey="code" + /> + ); +}; + +DataElementCodesField.propTypes = { + ...ParameterType, + onChange: PropTypes.func.isRequired, +}; diff --git a/packages/admin-panel/src/dataTables/components/PreviewFilters/filters/DataGroupCodeField.js b/packages/admin-panel/src/dataTables/components/PreviewFilters/filters/DataGroupCodeField.js new file mode 100644 index 0000000000..de43571956 --- /dev/null +++ b/packages/admin-panel/src/dataTables/components/PreviewFilters/filters/DataGroupCodeField.js @@ -0,0 +1,32 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + */ + +import React from 'react'; + +import PropTypes from 'prop-types'; + +import { ParameterType } from '../../editing'; +import { ReduxAutocomplete } from '../../../../autocomplete'; + +export const DataGroupCodeField = ({ name, onChange }) => { + return ( + onChange(selectedValues)} + id="inputField-dataGroupCode" + reduxId="dataTableEditFields-dataGroupCodeField" + endpoint="dataGroups" + optionLabelKey="code" + optionValueKey="code" + /> + ); +}; + +DataGroupCodeField.propTypes = { + ...ParameterType, + onChange: PropTypes.func.isRequired, +}; diff --git a/packages/admin-panel/src/dataTables/components/PreviewFilters/filters/DatePicker.js b/packages/admin-panel/src/dataTables/components/PreviewFilters/filters/DatePicker.js new file mode 100644 index 0000000000..feb8794dfb --- /dev/null +++ b/packages/admin-panel/src/dataTables/components/PreviewFilters/filters/DatePicker.js @@ -0,0 +1,53 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + */ + +import PropTypes from 'prop-types'; +import React from 'react'; +import format from 'date-fns/format'; + +import { DatePicker as BaseDatePicker } from '@tupaia/ui-components'; +import { ParameterType } from '../../editing'; + +const getDateValue = value => { + let dateValue = null; + + if (value instanceof Date) { + dateValue = value; + } + + if (typeof value === 'string') { + dateValue = new Date(value); + } + + if (!dateValue || dateValue.toString() === 'Invalid Date') { + return null; + } + + return dateValue; +}; + +export const DatePicker = ({ name, value, onChange, config }) => { + const dateValue = getDateValue(value); + const defaultDateValue = getDateValue(config?.hasDefaultValue && config?.defaultValue); + + return ( + + ); +}; + +DatePicker.propTypes = { + ...ParameterType, + onChange: PropTypes.func.isRequired, + value: PropTypes.oneOfType([PropTypes.instanceOf(Date), PropTypes.string]), +}; + +DatePicker.defaultProps = { + value: null, +}; diff --git a/packages/admin-panel/src/dataTables/components/PreviewFilters/filters/FilterTypeOptions.js b/packages/admin-panel/src/dataTables/components/PreviewFilters/filters/FilterTypeOptions.js new file mode 100644 index 0000000000..fe7ad35cbd --- /dev/null +++ b/packages/admin-panel/src/dataTables/components/PreviewFilters/filters/FilterTypeOptions.js @@ -0,0 +1,34 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + */ + +import { DatePicker } from './DatePicker'; +import { TextField } from './TextField'; +import { NumberField } from './NumberField'; +import { BooleanField } from './BooleanField'; +import { HierarchyField } from './HierarchyField'; +import { OrganisationUnitCodesField } from './OrganisationUnitCodesField'; +import { DataElementCodesField } from './DataElementCodesField'; +import { DataGroupCodeField } from './DataGroupCodeField'; +import { ArrayField } from './ArrayField'; + +export const FilterTypeOptions = [ + { label: 'Text', value: 'string', FilterComponent: TextField }, + { label: 'Date', value: 'date', FilterComponent: DatePicker }, + { label: 'Boolean', value: 'boolean', FilterComponent: BooleanField }, + { label: 'Number', value: 'number', FilterComponent: NumberField }, + { label: 'Hierarchy', value: 'hierarchy', FilterComponent: HierarchyField }, + { + label: 'Organisation Unit Codes', + value: 'organisationUnitCodes', + FilterComponent: OrganisationUnitCodesField, + }, + { + label: 'Data Element Codes', + value: 'dataElementCodes', + FilterComponent: DataElementCodesField, + }, + { label: 'Data Group Code', value: 'dataGroupCode', FilterComponent: DataGroupCodeField }, + { label: 'Array', value: 'array', FilterComponent: ArrayField }, +]; diff --git a/packages/admin-panel/src/dataTables/components/PreviewFilters/filters/HierarchyField.js b/packages/admin-panel/src/dataTables/components/PreviewFilters/filters/HierarchyField.js new file mode 100644 index 0000000000..c7de9b4ae5 --- /dev/null +++ b/packages/admin-panel/src/dataTables/components/PreviewFilters/filters/HierarchyField.js @@ -0,0 +1,39 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + */ + +import React from 'react'; + +import PropTypes from 'prop-types'; +import { Autocomplete } from '@tupaia/ui-components'; + +import { ParameterType } from '../../editing'; +import { useProjects } from '../../../../VizBuilderApp/api'; + +export const HierarchyField = ({ name, value, onChange, config }) => { + const { data: hierarchies = [], isLoading } = useProjects(); + + return ( + p['project.code'])} + disabled={isLoading} + onChange={(event, selectedValue) => onChange(selectedValue)} + /> + ); +}; + +HierarchyField.propTypes = { + ...ParameterType, + onChange: PropTypes.func.isRequired, + value: PropTypes.string, +}; + +HierarchyField.defaultProps = { + value: '', +}; diff --git a/packages/admin-panel/src/dataTables/components/PreviewFilters/filters/NumberField.js b/packages/admin-panel/src/dataTables/components/PreviewFilters/filters/NumberField.js new file mode 100644 index 0000000000..1aeaa7bf7e --- /dev/null +++ b/packages/admin-panel/src/dataTables/components/PreviewFilters/filters/NumberField.js @@ -0,0 +1,46 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + */ + +import PropTypes from 'prop-types'; +import React from 'react'; + +import { TextField as BaseTextField } from '@tupaia/ui-components'; +import { ParameterType } from '../../editing'; + +const getNumberValue = value => { + if (typeof value === 'number') { + return value; + } + return ''; +}; + +export const NumberField = ({ id, name, value, onChange, config }) => { + const defaultValue = getNumberValue(config?.hasDefaultValue && config?.defaultValue); + const numberValue = getNumberValue(value); + + return ( + { + onChange(+event.target.value); + }} + /> + ); +}; + +NumberField.propTypes = { + ...ParameterType, + onChange: PropTypes.func.isRequired, + value: PropTypes.number, +}; + +NumberField.defaultProps = { + value: null, +}; diff --git a/packages/admin-panel/src/dataTables/components/PreviewFilters/filters/OrganisationUnitCodesField.js b/packages/admin-panel/src/dataTables/components/PreviewFilters/filters/OrganisationUnitCodesField.js new file mode 100644 index 0000000000..c2c740c7fb --- /dev/null +++ b/packages/admin-panel/src/dataTables/components/PreviewFilters/filters/OrganisationUnitCodesField.js @@ -0,0 +1,57 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + */ + +import React, { useEffect, useState } from 'react'; + +import PropTypes from 'prop-types'; + +import { ParameterType } from '../../editing'; +import { useLocations } from '../../../../VizBuilderApp/api'; +import { Autocomplete } from '../../../../autocomplete'; + +export const OrganisationUnitCodesField = ({ name, onChange, runtimeParams }) => { + const { hierarchy = 'explore' } = runtimeParams; + const [searchTerm, setSearchTerm] = useState(''); + const [selectedOptions, setSelectedOptions] = useState([]); // [{code:"DL", name:"Demo Land"}] + const { data: locations = [], isLoading } = useLocations(hierarchy, searchTerm); + const limitedLocations = locations.slice(0, 20); // limit the options to 20 to stop the ui jamming + + useEffect(() => { + setSelectedOptions([]); + onChange([]); + }, [hierarchy]); // hierarchy determines entity relation visibility + + return ( + { + return option.code === selected.code; + }} + getOptionLabel={option => option.name} + isLoading={isLoading} + onChangeSelection={(event, selectedValues) => { + setSelectedOptions(selectedValues); + onChange(selectedValues.map(v => v.code)); + }} + onChangeSearchTerm={setSearchTerm} + searchTerm={searchTerm} + placeholder="type to search" + optionLabelKey="name" + allowMultipleValues + /> + ); +}; + +OrganisationUnitCodesField.propTypes = { + ...ParameterType, + onChange: PropTypes.func.isRequired, + runtimeParams: PropTypes.object, +}; + +OrganisationUnitCodesField.defaultProps = { + runtimeParams: {}, +}; diff --git a/packages/admin-panel/src/dataTables/components/PreviewFilters/filters/TextField.js b/packages/admin-panel/src/dataTables/components/PreviewFilters/filters/TextField.js new file mode 100644 index 0000000000..a7d5c775b4 --- /dev/null +++ b/packages/admin-panel/src/dataTables/components/PreviewFilters/filters/TextField.js @@ -0,0 +1,36 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + */ + +import PropTypes from 'prop-types'; +import React from 'react'; +import { TextField as BaseTextField } from '@tupaia/ui-components'; +import { ParameterType } from '../../editing'; + +export const TextField = ({ name, value, onChange, config }) => { + const defaultValue = config?.hasDefaultValue ? config?.defaultValue : ''; + + return ( + { + onChange(event.target.value); + }} + /> + ); +}; + +TextField.propTypes = { + ...ParameterType, + onChange: PropTypes.func.isRequired, + value: PropTypes.string, +}; + +TextField.defaultProps = { + value: '', +}; diff --git a/packages/admin-panel/src/dataTables/components/PreviewFilters/filters/index.js b/packages/admin-panel/src/dataTables/components/PreviewFilters/filters/index.js new file mode 100644 index 0000000000..b340b48e5f --- /dev/null +++ b/packages/admin-panel/src/dataTables/components/PreviewFilters/filters/index.js @@ -0,0 +1,6 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + */ + +export { FilterTypeOptions } from './FilterTypeOptions'; diff --git a/packages/admin-panel/src/dataTables/components/PreviewFilters/index.js b/packages/admin-panel/src/dataTables/components/PreviewFilters/index.js new file mode 100644 index 0000000000..8f420321d8 --- /dev/null +++ b/packages/admin-panel/src/dataTables/components/PreviewFilters/index.js @@ -0,0 +1,7 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + */ + +export { PreviewFilters } from './PreviewFilters'; +export { FilterTypeOptions } from './filters'; diff --git a/packages/admin-panel/src/dataTables/components/editing/ParameterItem.js b/packages/admin-panel/src/dataTables/components/editing/ParameterItem.js new file mode 100644 index 0000000000..e00199689d --- /dev/null +++ b/packages/admin-panel/src/dataTables/components/editing/ParameterItem.js @@ -0,0 +1,148 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + */ + +import PropTypes from 'prop-types'; +import React from 'react'; +import styled from 'styled-components'; +import { Divider as BaseDivider } from '@material-ui/core'; +import Grid from '@material-ui/core/Grid'; +import BaseDeleteOutlinedIcon from '@material-ui/icons/DeleteOutlined'; +import { + FlexStart, + Checkbox as BaseCheckbox, + Select, + TextField, + IconButton as BaseIconButton, +} from '@tupaia/ui-components'; +import { FilterTypeOptions } from '../PreviewFilters'; +import { DefaultValueType } from './types'; + +const Divider = styled(BaseDivider)` + margin-bottom: 20px; +`; + +const Checkbox = styled(BaseCheckbox)` + .MuiSvgIcon-root { + font-size: 0.9rem; + } + .MuiTypography-body1 { + font-size: 0.9rem; + } +`; + +const IconButton = styled(BaseIconButton)` + top: 35px; + width: 30%; + height: 20px; +`; +const DeleteOutlinedIcon = styled(BaseDeleteOutlinedIcon)` + font-size: 25px; +`; + +export const ParameterItem = props => { + const { + id, + name, + type, + hasDefaultValue, + defaultValue, + hasError, + error, + onDelete, + onChange, + } = props; + + const option = FilterTypeOptions.find(t => t.value === type) || {}; + const { FilterComponent } = option; + + return ( + + + + { + onChange(id, 'name', event.target.value); + }} + /> + + +