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/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/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/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..7e3a47c0e1 100644 --- a/packages/admin-panel-server/src/routes/FetchReportPreviewDataRoute.ts +++ b/packages/admin-panel-server/src/routes/FetchReportPreviewDataRoute.ts @@ -27,8 +27,10 @@ export type FetchReportPreviewDataRequest = Request< testData?: unknown[]; }, { - entityCode: string; - hierarchy: string; + entityCode?: string; + hierarchy?: string; + startDate?: string; + endDate?: string; permissionGroup?: string; previewMode?: PreviewMode; dashboardItemOrMapOverlay: DashboardItemOrMapOverlayParam; @@ -39,40 +41,31 @@ export class FetchReportPreviewDataRoute extends Route = {}; + if (hierarchy) parameters.hierarchy = hierarchy; + if (entityCode) parameters.organisationUnitCodes = entityCode; + if (startDate) parameters.startDate = startDate; + 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 = () => { - const { entityCode, hierarchy } = this.req.query; - const { previewConfig, testData } = this.req.body; + const { previewConfig } = this.req.body; if (!previewConfig) { throw new Error('Requires preview config to fetch preview data'); } - - if (!testData) { - if (!hierarchy) { - throw new Error('Requires hierarchy or test data to fetch preview data'); - } - if (!entityCode) { - throw new Error('Requires entity or test data to fetch preview data'); - } - } }; private getReportConfig = () => { 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 e7897a1a17..0000000000 --- a/packages/admin-panel/src/VizBuilderApp/components/DataLibrary/AggregationDataLibrary.js +++ /dev/null @@ -1,91 +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 => - aggregate.map(({ id, type: code, ...restOfConfig }, index) => ({ - id: id || `${code}-${index}`, // id used by drag and drop function - code, - ...restOfConfig, - })); - -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/component/TransformSelectedOptionWithJsonEditor.js b/packages/admin-panel/src/VizBuilderApp/components/DataLibrary/component/TransformSelectedOptionWithJsonEditor.js index 19c5d113bd..c508156bad 100644 --- a/packages/admin-panel/src/VizBuilderApp/components/DataLibrary/component/TransformSelectedOptionWithJsonEditor.js +++ b/packages/admin-panel/src/VizBuilderApp/components/DataLibrary/component/TransformSelectedOptionWithJsonEditor.js @@ -15,6 +15,8 @@ const getDefaultValueByType = type => { return []; case 'object': return {}; + case 'boolean': + return false; default: return null; } @@ -38,7 +40,7 @@ export const TransformSelectedOptionWithJsonEditor = ({ (value.oneOf && value.oneOf[0].type) || (value.enum && typeof value.enum[0]) || (value.oneOf && typeof value.oneOf[0].enum[0]); - const defaultValue = getDefaultValueByType(type); + const defaultValue = value.defaultValue || getDefaultValueByType(type); return [key, defaultValue]; }), 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,18 @@ export const PreviewOptions = () => { setLocation(value.code); }; + const handleChangeStartDate = date => { + const newDate = date ? date.toISOString() : null; + setSelectedStartDate(newDate); + setStartDate(newDate); + }; + + const handleChangeEndDate = date => { + const newDate = date ? date.toISOString() : null; + setSelectedEndDate(newDate); + setEndDate(newDate); + }; + const handleUploadData = async file => { const response = await uploadTestData(file); setShowData(false); @@ -203,6 +233,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 23b8ab02a7..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,13 +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(({ isDisabled, id, schema, ...restOfConfig }) => ({ - ...restOfConfig, - })) - : 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) @@ -156,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..254f857ba0 --- /dev/null +++ b/packages/admin-panel/src/dataTables/components/PreviewFilters/filters/DatePicker.js @@ -0,0 +1,63 @@ +/* + * 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); + // Convert date to UTC as server uses UTC timezone + const onChangeDate = localDate => { + if (!localDate) { + return; + } + const UTCDate = new Date( + Date.UTC(localDate.getFullYear(), localDate.getMonth(), localDate.getDate()), + ); + onChange(UTCDate); + }; + + 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); + }} + /> + + +