From 18b4128b7bed133344ffacfaf07f186f71e35583 Mon Sep 17 00:00:00 2001 From: Jasper Lai <33956381+jaskfla@users.noreply.github.com> Date: Thu, 20 Jun 2024 14:12:04 +1200 Subject: [PATCH 1/9] tweak(adminPanel): RN-1273: Map overlay relations by code (#5646) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * `child_code` in `MapOverlayGroupRelations.find` * use `generateInstance` * comment new methods * reorder a few symbols * temp: clearer console log * use TSDoc * avoid arrow notation * consistent param names * remove console logs * private remark comment * clearer comment * format JSDoc * update error log * document `mapOverlayGroupRelations` endpoints * use `static joins` * fix invalid criteria * restore criteria, fix invalid options? * Switch to using 'customColumnSelector' for child_code field * remove overriding `find` and `generateInstance` * use `processColumnSelector` * adjust inline comment * clarifying JSDoc * update comment, use `for`…`of` * handling for `COALESCE` * fix import * update Admin Panel columns, edit/create configs * remove console log * fix cascading test failures * RN-1273: Ensure DatabaseModel find methods use fully qualified field names * comment unhandled edge case * beefier TSDoc --------- Co-authored-by: Rohan Port --- .../mapOverlayGroupRelations.js | 114 +++++++++++------- packages/central-server/examples.http | 18 +++ .../src/apiV2/GETHandler/GETHandler.js | 10 +- .../src/apiV2/GETHandler/helpers.js | 7 +- .../GETMapOverlayGroupRelations.js | 15 ++- packages/database/src/DatabaseModel.js | 53 +++++++- packages/database/src/DatabaseRecord.js | 73 ++++++----- packages/database/src/TupaiaDatabase.js | 67 +++++++--- .../modelClasses/MapOverlayGroupRelation.js | 29 ++++- 9 files changed, 275 insertions(+), 111 deletions(-) diff --git a/packages/admin-panel/src/routes/visualisations/mapOverlayGroupRelations.js b/packages/admin-panel/src/routes/visualisations/mapOverlayGroupRelations.js index ba07e79bcf..056380b0aa 100644 --- a/packages/admin-panel/src/routes/visualisations/mapOverlayGroupRelations.js +++ b/packages/admin-panel/src/routes/visualisations/mapOverlayGroupRelations.js @@ -7,56 +7,84 @@ const RESOURCE_NAME = { singular: 'map overlay group relation' }; export const RELATION_ENDPOINT = 'mapOverlayGroupRelations'; -const FIELDS = [ - { - Header: 'Map overlay group code', - source: 'map_overlay_group.code', - - editConfig: { - optionsEndpoint: 'mapOverlayGroups', - optionLabelKey: 'map_overlay_group.code', - optionValueKey: 'map_overlay_group.id', - sourceKey: 'map_overlay_group_id', - }, +const mapOverlayGroupCode = { + Header: 'Map overlay group code', + source: 'map_overlay_group.code', + editConfig: { + optionsEndpoint: 'mapOverlayGroups', + optionLabelKey: 'map_overlay_group.code', + optionValueKey: 'map_overlay_group.id', + sourceKey: 'map_overlay_group_id', }, - { - Header: 'Child ID', - source: 'child_id', +}; - editConfig: { - optionsEndpoint: 'mapOverlays', - optionLabelKey: 'mapOverlay.id', - optionValueKey: 'mapOverlay.id', - canCreateNewOptions: true, - sourceKey: 'child_id', - }, +const childType = { + Header: 'Child type', + width: 160, + source: 'child_type', + editConfig: { + options: [ + { + label: 'Map overlay', + value: 'mapOverlay', + }, + { + label: 'Map overlay group', + value: 'mapOverlayGroup', + }, + ], }, - { - Header: 'Child type', - width: 160, - source: 'child_type', +}; - editConfig: { - options: [ - { - label: 'Map overlay', - value: 'mapOverlay', - }, - { - label: 'Map overlay group', - value: 'mapOverlayGroup', - }, - ], - }, +const childCode = { + Header: 'Child Code', + source: 'child_code', +}; + +const childMapOverlayCode = { + Header: 'Child map overlay code', + id: 'child_map_overlay_code', + source: 'child_code', + editConfig: { + optionsEndpoint: 'mapOverlays', + optionLabelKey: 'mapOverlay.code', + optionValueKey: 'mapOverlay.id', + sourceKey: 'child_id', + visibilityCriteria: { child_type: 'mapOverlay' }, }, - { - Header: 'Sort order', - source: 'sort_order', +}; + +const childMapOverlayGroupCode = { + Header: 'Child map overlay group code', + id: 'child_map_overlay_group_code', + source: 'child_code', + editConfig: { + optionsEndpoint: 'mapOverlayGroups', + optionLabelKey: 'mapOverlayGroups.code', + optionValueKey: 'mapOverlayGroups.id', + sourceKey: 'child_id', + visibilityCriteria: { child_type: 'mapOverlayGroup' }, }, +}; + +const sortOrder = { + Header: 'Sort order', + source: 'sort_order', +}; + +const EDIT_FIELDS = [ + mapOverlayGroupCode, + childType, + childMapOverlayCode, + childMapOverlayGroupCode, + sortOrder, ]; const COLUMNS = [ - ...FIELDS, + mapOverlayGroupCode, + childType, + childCode, + sortOrder, { Header: 'Edit', type: 'edit', @@ -64,7 +92,7 @@ const COLUMNS = [ actionConfig: { title: `Edit ${RESOURCE_NAME.singular}`, editEndpoint: 'mapOverlayGroupRelations', - fields: FIELDS, + fields: EDIT_FIELDS, }, }, { @@ -79,7 +107,7 @@ const COLUMNS = [ const CREATE_CONFIG = { actionConfig: { editEndpoint: RELATION_ENDPOINT, - fields: FIELDS, + fields: EDIT_FIELDS, }, }; diff --git a/packages/central-server/examples.http b/packages/central-server/examples.http index 75a4fa96af..559912818d 100644 --- a/packages/central-server/examples.http +++ b/packages/central-server/examples.http @@ -49,6 +49,24 @@ GET {{baseUrl}}/export/optionSet/5c625e01f013d60db42a5763 HTTP/1.1 Authorization: {{user-authorization}} Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet +### Get all map overlay groups + +GET {{baseUrl}}/mapOverlayGroups +content-type: {{contentType}} +Authorization: {{user-authorization}} + +### Get all map overlay group relations + +GET {{baseUrl}}/mapOverlayGroupRelations +content-type: {{contentType}} +Authorization: {{user-authorization}} + +### Get map overlay group relation by ID + +GET {{baseUrl}}/mapOverlayGroupRelations/5f2c7ddb61f76a513a0000bf +content-type: {{contentType}} +Authorization: {{user-authorization}} + ### Get survey responses GET {{baseUrl}}/surveyResponses?pageSize=5&columns=["entity.name", "country.name"] HTTP/2.0 diff --git a/packages/central-server/src/apiV2/GETHandler/GETHandler.js b/packages/central-server/src/apiV2/GETHandler/GETHandler.js index 6749d16291..92a006f6da 100644 --- a/packages/central-server/src/apiV2/GETHandler/GETHandler.js +++ b/packages/central-server/src/apiV2/GETHandler/GETHandler.js @@ -7,8 +7,8 @@ import { respond } from '@tupaia/utils'; import { CRUDHandler } from '../CRUDHandler'; import { getQueryOptionsForColumns, - fullyQualifyColumnSelector, processColumns, + processColumnSelector, processColumnSelectorKeys, generateLinkHeader, } from './helpers'; @@ -78,14 +78,14 @@ export class GETHandler extends CRUDHandler { // add any user requested sorting to the start of the sort clause if (sortString) { const sortKeys = JSON.parse(sortString); - const fullyQualifiedSortKeys = sortKeys.map(sortKey => - fullyQualifyColumnSelector(this.models, sortKey, this.recordType), + const processedSortKeys = sortKeys.map(sortKey => + processColumnSelector(this.models, sortKey, this.recordType), ); // if 'distinct', we can't order by any columns that aren't included in the distinct selection if (distinct) { - dbQueryOptions.sort = fullyQualifiedSortKeys; + dbQueryOptions.sort = processedSortKeys; } else { - dbQueryOptions.sort.unshift(...fullyQualifiedSortKeys); + dbQueryOptions.sort.unshift(...processedSortKeys); } } diff --git a/packages/central-server/src/apiV2/GETHandler/helpers.js b/packages/central-server/src/apiV2/GETHandler/helpers.js index 0b057fc85b..523692fc1a 100644 --- a/packages/central-server/src/apiV2/GETHandler/helpers.js +++ b/packages/central-server/src/apiV2/GETHandler/helpers.js @@ -42,7 +42,10 @@ export const generateLinkHeader = (resource, pageString, lastPage, originalQuery return formatLinkHeader(linkHeader); }; -export const fullyQualifyColumnSelector = (models, unprocessedColumnSelector, baseRecordType) => { +/** + * Used only by {@link processColumnSelector}, so not exported. + */ +const fullyQualifyColumnSelector = (models, unprocessedColumnSelector, baseRecordType) => { const [resource, column] = unprocessedColumnSelector.includes('.') ? unprocessedColumnSelector.split('.') : [baseRecordType, unprocessedColumnSelector]; @@ -55,7 +58,7 @@ export const fullyQualifyColumnSelector = (models, unprocessedColumnSelector, ba export const processColumnSelectorKeys = (models, object, recordType) => { const processedObject = {}; Object.entries(object).forEach(([columnSelector, value]) => { - processedObject[fullyQualifyColumnSelector(models, columnSelector, recordType)] = value; + processedObject[processColumnSelector(models, columnSelector, recordType)] = value; }); return processedObject; }; diff --git a/packages/central-server/src/apiV2/mapOverlayGroupRelations/GETMapOverlayGroupRelations.js b/packages/central-server/src/apiV2/mapOverlayGroupRelations/GETMapOverlayGroupRelations.js index cf825d1674..38eeabbffb 100644 --- a/packages/central-server/src/apiV2/mapOverlayGroupRelations/GETMapOverlayGroupRelations.js +++ b/packages/central-server/src/apiV2/mapOverlayGroupRelations/GETMapOverlayGroupRelations.js @@ -30,10 +30,6 @@ export class GETMapOverlayGroupRelations extends GETHandler { nearTableKey: 'map_overlay_group_relation.map_overlay_group_id', farTableKey: 'map_overlay_group.id', }, - map_overlay: { - nearTableKey: 'map_overlay_group_relation.child_id', - farTableKey: 'map_overlay.id', - }, }; async findSingleRecord(mapOverlayGroupRelationId, options) { @@ -54,6 +50,17 @@ export class GETMapOverlayGroupRelations extends GETHandler { return mapOverlayGroupRelation; } + async getDbQueryOptions() { + const { multiJoin, sort, ...restOfOptions } = await super.getDbQueryOptions(); + return { + ...restOfOptions, + // Strip table prefix from `child_code` as it’s a `customColumn` + sort: sort.map(s => s.replace('map_overlay_group_relation.child_code', 'child_code')), + // Appending the multi-join from the Record class so that we can fetch the `child_code` + multiJoin: multiJoin.concat(this.models.mapOverlayGroupRelation.DatabaseRecordClass.joins), + }; + } + async getPermissionsFilter(criteria, options) { const dbConditions = await createMapOverlayGroupRelationDBFilter( this.accessPolicy, diff --git a/packages/database/src/DatabaseModel.js b/packages/database/src/DatabaseModel.js index 75a7b60295..6911b36884 100644 --- a/packages/database/src/DatabaseModel.js +++ b/packages/database/src/DatabaseModel.js @@ -4,6 +4,7 @@ */ import { DatabaseError, reduceToDictionary } from '@tupaia/utils'; import { runDatabaseFunctionInBatches } from './utilities/runDatabaseFunctionInBatches'; +import { QUERY_CONJUNCTIONS } from './TupaiaDatabase'; export class DatabaseModel { otherModels = {}; @@ -100,16 +101,26 @@ export class DatabaseModel { // A helper for the 'xById' methods, which disambiguates the id field to ensure joins are handled getIdClause(id) { return { - [`${this.databaseRecord}.id`]: id, + [this.fullyQualifyColumn('id')]: id, }; } + // A helper function to ensure that we're using fully qualified column names to avoid ambiguous references when joins are being used + fullyQualifyColumn(column) { + if (column.includes('.')) { + // Already fully qualified + return column; + } + + return `${this.databaseRecord}.${column}`; + } + async getColumnsForQuery() { // Alias field names to the table to prevent errors when joining other tables // with same column names. const fieldNames = await this.fetchFieldNames(); return fieldNames.map(fieldName => { - const qualifiedName = `${this.databaseRecord}.${fieldName}`; + const qualifiedName = this.fullyQualifyColumn(fieldName); const customSelector = this.customColumnSelectors && this.customColumnSelectors[fieldName]; if (customSelector) { return { [fieldName]: customSelector(qualifiedName) }; @@ -136,6 +147,30 @@ export class DatabaseModel { return { ...options, ...customQueryOptions }; } + async getDbConditions(dbConditions = {}) { + const fieldNames = await this.fetchFieldNames(); + const fullyQualifiedConditions = {}; + + const whereClauses = Object.entries(dbConditions); + for (let i = 0; i < whereClauses.length; i++) { + const [field, value] = whereClauses[i]; + if (field === QUERY_CONJUNCTIONS.AND || field === QUERY_CONJUNCTIONS.OR) { + // Recursively proccess AND and OR conditions + fullyQualifiedConditions[field] = await this.getDbConditions(value); + } else if (field === QUERY_CONJUNCTIONS.RAW) { + // Don't touch RAW conditions + fullyQualifiedConditions[field] = value; + } else { + const fullyQualifiedField = fieldNames.includes(field) + ? this.fullyQualifyColumn(field) + : field; + fullyQualifiedConditions[fullyQualifiedField] = value; + } + } + + return fullyQualifiedConditions; + } + /** * @param {...any} args * @returns {Promise} Count of records matching args @@ -171,7 +206,12 @@ export class DatabaseModel { async findOne(dbConditions, customQueryOptions = {}) { const queryOptions = await this.getQueryOptions(customQueryOptions); - const result = await this.database.findOne(this.databaseRecord, dbConditions, queryOptions); + const processedDbConditions = await this.getDbConditions(dbConditions); + const result = await this.database.findOne( + this.databaseRecord, + processedDbConditions, + queryOptions, + ); if (!result) return null; return this.generateInstance(result); } @@ -184,7 +224,12 @@ export class DatabaseModel { */ async find(dbConditions, customQueryOptions = {}) { const queryOptions = await this.getQueryOptions(customQueryOptions); - const dbResults = await this.database.find(this.databaseRecord, dbConditions, queryOptions); + const processedDbConditions = await this.getDbConditions(dbConditions); + const dbResults = await this.database.find( + this.databaseRecord, + processedDbConditions, + queryOptions, + ); return Promise.all(dbResults.map(result => this.generateInstance(result))); } diff --git a/packages/database/src/DatabaseRecord.js b/packages/database/src/DatabaseRecord.js index a4a23d4bd9..fdfc32de11 100644 --- a/packages/database/src/DatabaseRecord.js +++ b/packages/database/src/DatabaseRecord.js @@ -2,7 +2,7 @@ * Tupaia * Copyright (c) 2017 - 2020 Beyond Essential Systems Pty Ltd */ -import { TypeValidationError, stripFields } from '@tupaia/utils'; +import { stripFields, TypeValidationError } from '@tupaia/utils'; export class DatabaseRecord { static databaseRecord = null; // The database table name @@ -33,42 +33,41 @@ export class DatabaseRecord { */ static fieldValidators = new Map(); - /* - Joins are executed on every model query and give developers the ability to - add extra fields that are necessary for a model to be meaningful. - - format: - static joins = [ - { - fields: { - ['field code in joined database']: 'field code to map to in model', - }, - joinWith: 'table to join with', - joinCondition: [`{table to join with}.id`, `${this.databaseRecord}.${field to join on}`], - } - ] - - eg: - static joins = [ - { - fields: { - code: 'country_code', - }, - joinWith: RECORDS.COUNTRY, - joinCondition: [`${RECORDS.COUNTRY}.id`, `${this.databaseRecord}.country_id`], - }, - { - fields: { - code: 'foo_bar_field', - }, - joinWith: RECORDS.FOO_BAR, - joinCondition: [`${RECORDS.FOO_BAR}.id`, `${this.databaseRecord}.foo_bar_id`], - } - ] - - Will add the fields `country_code` and `foo_bar_field` to the model using the value - from the join query. - */ + /** + * Joins are executed on every model query and give developers the ability to + * add extra fields that are necessary for a model to be meaningful. + * + * Format: + * ```js + * static joins = [ + * { + * fields: { + * ['field code in joined database']: 'field code to map to in model', + * }, + * joinWith: 'table to join with', + * joinCondition: [`{table to join with}.id`, `${this.databaseRecord}.${field to join on}`], + * } + * ] + * ``` + * + * @example Add the fields `country_code` and `foo_bar_field` to the model using the value from the join query. + * static joins = [ + * { + * fields: { + * code: 'country_code', + * }, + * joinWith: RECORDS.COUNTRY, + * joinCondition: [`${RECORDS.COUNTRY}.id`, `${this.databaseRecord}.country_id`], + * }, + * { + * fields: { + * code: 'foo_bar_field', + * }, + * joinWith: RECORDS.FOO_BAR, + * joinCondition: [`${RECORDS.FOO_BAR}.id`, `${this.databaseRecord}.foo_bar_id`], + * } + * ] + */ static joins = []; constructor(model, fieldValues) { diff --git a/packages/database/src/TupaiaDatabase.js b/packages/database/src/TupaiaDatabase.js index 7d4be24721..149fee76bb 100644 --- a/packages/database/src/TupaiaDatabase.js +++ b/packages/database/src/TupaiaDatabase.js @@ -58,6 +58,18 @@ const COMPARATORS = { ILIKE: 'ilike', }; +/** + * We only support specific functions in SELECT statements to avoid SQL injection. + * + * @privateRemarks Because of the (appropriately) conservative way Knex handles identifiers in + * parameterised queries, supported functions may need custom handling in {@link getColSelector}. + * Otherwise, Knex will attempt to interpret function calls as identifiers. For example, + * `COALESCE(foo, bar)` would otherwise become `"COALESCE(FOO", "bar)"`. + * + * @see getColSelector + */ +const supportedFunctions = ['ST_AsGeoJSON', 'COALESCE']; + // no math here, just hand-tuned to be as low as possible while // keeping all the tests passing const HANDLER_DEBOUNCE_DURATION = 250; @@ -536,11 +548,16 @@ function buildQuery(connection, queryConfig, where = {}, options = {}) { return { [alias]: connection.raw(`??::${castAs}`, [alias]) }; } - // special case to handle selecting geojson - avoid generic handling of functions to keep - // out sql injection vulnerabilities - if (selector.includes('ST_AsGeoJSON')) { - const [, columnSelector] = selector.match(/ST_AsGeoJSON\((.*)\)/); - return { [alias]: connection.raw('ST_AsGeoJSON(??)', [columnSelector]) }; + // Special case to handle allowlisted SQL functions, namely for selecting GeoJSON and COALESCE + // attributes. Avoid generic handling of functions to keep out SQL injection vulnerabilities. + for (const func of supportedFunctions) { + if (selector.includes(func)) { + const [, argsString] = selector.match(new RegExp(`${func}\\((.*)\\)`)); + const args = argsString.split(',').map(arg => arg.trim()); + return { + [alias]: connection.raw(`${func}(${args.map(() => '??').join(',')})`, [...args]), + }; + } } return { [alias]: connection.raw('??', [selector]) }; @@ -683,17 +700,39 @@ function addJoin(baseQuery, recordType, joinOptions) { }); } +/** + * @privateRemarks + * This sanitisation step fails if the input uses both JSON operators and the `COALESCE` function. + * + * @see supportedFunctions + */ function getColSelector(connection, inputColStr) { const jsonOperatorPattern = /->>?/g; - if (!jsonOperatorPattern.test(inputColStr)) return inputColStr; + if (jsonOperatorPattern.test(inputColStr)) { + const params = inputColStr.split(jsonOperatorPattern); + const allButFirst = params.slice(1); + const lastIndexOfLookupAsText = inputColStr.lastIndexOf('->>'); + const lastIndexOfLookupAsJson = inputColStr.lastIndexOf('->'); + const selector = lastIndexOfLookupAsText >= lastIndexOfLookupAsJson ? '#>>' : '#>'; - const params = inputColStr.split(jsonOperatorPattern); - const allButFirst = params.slice(1); - const lastIndexOfLookupAsText = inputColStr.lastIndexOf('->>'); - const lastIndexOfLookupAsJson = inputColStr.lastIndexOf('->'); - const selector = lastIndexOfLookupAsText >= lastIndexOfLookupAsJson ? '#>>' : '#>'; + // Turn `config->item->>colour` into `config #>> '{item,colour}'` + // For some reason, Knex fails when we try to convert it to `config->'item'->>'colour'` + return connection.raw(`?? ${selector} '{${allButFirst.map(() => '??').join(',')}}'`, params); + } + + /** + * Special handling of COALESCE() - one of the {@link supportedFunctions} - to treat its arguments + * as identifiers individually rather than trying to treat ‘COALESCE(foo, bar)’ as a single + * identifier. + */ + const coalescePattern = /^COALESCE\(.+\)$/; + if (coalescePattern.test(inputColStr)) { + const [, argsString] = inputColStr.match(/^COALESCE\((.+)\)$/); + const bindings = argsString.split(',').map(arg => arg.trim()); + const identifiers = bindings.map(() => '??'); + + return connection.raw(`COALESCE(${identifiers})`, bindings); + } - // Turn `config->item->>colour` into `config #>> '{item,colour}'` - // For some reason, Knex fails when we try to convert it to `config->'item'->>'colour'` - return connection.raw(`?? ${selector} '{${allButFirst.map(() => '??').join(',')}}'`, params); + return inputColStr; } diff --git a/packages/database/src/modelClasses/MapOverlayGroupRelation.js b/packages/database/src/modelClasses/MapOverlayGroupRelation.js index 5d15d9efdd..08ddad8d06 100644 --- a/packages/database/src/modelClasses/MapOverlayGroupRelation.js +++ b/packages/database/src/modelClasses/MapOverlayGroupRelation.js @@ -1,11 +1,12 @@ -/** +/* * Tupaia - * Copyright (c) 2017 - 2020 Beyond Essential Systems Pty Ltd + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ import { DatabaseModel } from '../DatabaseModel'; import { DatabaseRecord } from '../DatabaseRecord'; import { RECORDS } from '../records'; +import { JOIN_TYPES } from '../TupaiaDatabase'; const MAP_OVERLAY = 'mapOverlay'; const MAP_OVERLAY_GROUP = 'mapOverlayGroup'; @@ -17,6 +18,26 @@ const RELATION_CHILD_TYPES = { export class MapOverlayGroupRelationRecord extends DatabaseRecord { static databaseRecord = RECORDS.MAP_OVERLAY_GROUP_RELATION; + static joins = [ + { + joinType: JOIN_TYPES.LEFT, + joinWith: RECORDS.MAP_OVERLAY, + joinAs: 'child_map_overlay', + joinCondition: [`${RECORDS.MAP_OVERLAY_GROUP_RELATION}.child_id`, `child_map_overlay.id`], + fields: { code: 'code' }, + }, + { + joinType: JOIN_TYPES.LEFT, + joinWith: RECORDS.MAP_OVERLAY_GROUP, + joinAs: 'child_map_overlay_group', + joinCondition: [ + `${RECORDS.MAP_OVERLAY_GROUP_RELATION}.child_id`, + `child_map_overlay_group.id`, + ], + fields: { code: 'code' }, + }, + ]; + async findChildRelations() { return this.model.find({ map_overlay_group_id: this.child_id }); } @@ -31,6 +52,10 @@ export class MapOverlayGroupRelationModel extends DatabaseModel { return RELATION_CHILD_TYPES; } + customColumnSelectors = { + child_code: () => 'COALESCE(child_map_overlay.code, child_map_overlay_group.code)', + }; + async findTopLevelMapOverlayGroupRelations() { const rootMapOverlayGroup = await this.otherModels.mapOverlayGroup.findRootMapOverlayGroup(); From a08237f120c9fd6c04f328f4ecb705ae432ae047 Mon Sep 17 00:00:00 2001 From: Jasper Lai <33956381+jaskfla@users.noreply.github.com> Date: Fri, 21 Jun 2024 16:59:10 +1200 Subject: [PATCH 2/9] feat(adminPanel): MDEV-236: delete action for drilled-down relations (#5742) * delete relation when drilled down from Dashboards * drilled-down map overlay relation delete action * use const value --- .../src/routes/visualisations/dashboards.js | 12 +++++++++++- .../visualisations/mapOverlayGroupRelations.js | 4 ++-- .../src/routes/visualisations/mapOverlayGroups.js | 9 +++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/admin-panel/src/routes/visualisations/dashboards.js b/packages/admin-panel/src/routes/visualisations/dashboards.js index 8f26d3a049..3c06692108 100644 --- a/packages/admin-panel/src/routes/visualisations/dashboards.js +++ b/packages/admin-panel/src/routes/visualisations/dashboards.js @@ -3,7 +3,10 @@ * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ -import { RESOURCE_NAME as DASHBOARD_RELATION_RESOURCE_NAME } from './dashboardRelations'; +import { + DASHBOARD_RELATION_ENDPOINT, + RESOURCE_NAME as DASHBOARD_RELATION_RESOURCE_NAME, +} from './dashboardRelations'; const RESOURCE_NAME = { singular: 'dashboard' }; @@ -112,6 +115,13 @@ const RELATION_COLUMNS = [ fields: RELATION_FIELDS, }, }, + { + Header: 'Delete', + type: 'delete', + actionConfig: { + endpoint: DASHBOARD_RELATION_ENDPOINT, + }, + }, ]; const CREATE_CONFIG = { diff --git a/packages/admin-panel/src/routes/visualisations/mapOverlayGroupRelations.js b/packages/admin-panel/src/routes/visualisations/mapOverlayGroupRelations.js index 056380b0aa..e1e3175563 100644 --- a/packages/admin-panel/src/routes/visualisations/mapOverlayGroupRelations.js +++ b/packages/admin-panel/src/routes/visualisations/mapOverlayGroupRelations.js @@ -91,7 +91,7 @@ const COLUMNS = [ source: 'id', actionConfig: { title: `Edit ${RESOURCE_NAME.singular}`, - editEndpoint: 'mapOverlayGroupRelations', + editEndpoint: RELATION_ENDPOINT, fields: EDIT_FIELDS, }, }, @@ -99,7 +99,7 @@ const COLUMNS = [ Header: 'Delete', type: 'delete', actionConfig: { - endpoint: 'mapOverlayGroupRelations', + endpoint: RELATION_ENDPOINT, }, }, ]; diff --git a/packages/admin-panel/src/routes/visualisations/mapOverlayGroups.js b/packages/admin-panel/src/routes/visualisations/mapOverlayGroups.js index ec5291c371..2b0b96c920 100644 --- a/packages/admin-panel/src/routes/visualisations/mapOverlayGroups.js +++ b/packages/admin-panel/src/routes/visualisations/mapOverlayGroups.js @@ -3,6 +3,8 @@ * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ +import { RELATION_ENDPOINT } from './mapOverlayGroupRelations'; + const RESOURCE_NAME = { singular: 'map overlay group' }; const MAP_OVERLAY_GROUPS_ENDPOINT = 'mapOverlayGroups'; @@ -82,6 +84,13 @@ export const RELATION_COLUMNS = [ fields: RELATION_FIELDS, }, }, + { + Header: 'Delete', + type: 'delete', + actionConfig: { + endpoint: RELATION_ENDPOINT, + }, + }, ]; const CREATE_CONFIG = { From 99f32761fb7af0218b30801d5fdd85e3b4d66f8e Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Tue, 25 Jun 2024 09:04:13 +1200 Subject: [PATCH 3/9] feat(datatrakWeb): RN-1335: Setup skeleton backend for tasking (#5732) * Add migration for table * Remove status type if exists * Create task db model * Add to db exports * Generate types * Fix migrate-down * Get tasks endpoint * Create task endpoint * WIP edit tasks * Edit task endpoint * Allow assignee to be empty * Don't cascade user accounts * Generate types * Add assignee name field for easy filtering and indexes * Generate types * Change schema for repeating * Fix tests * Fix tests * Make due date nullable * Rename repeat_schedule field * Fix tests * Fix validation rules * Link status and assignee name to records at the model level * Generate types --- packages/central-server/examples.http | 7 + packages/central-server/src/apiV2/index.js | 6 +- .../src/apiV2/tasks/CreateTask.js | 26 +++ .../src/apiV2/tasks/EditTask.js | 20 ++ .../src/apiV2/tasks/GETTasks.js | 40 ++++ .../src/apiV2/tasks/assertTaskPermissions.js | 123 ++++++++++ .../central-server/src/apiV2/tasks/index.js | 8 + .../constructNewRecordValidationRules.js | 31 +++ .../src/tests/apiV2/tasks/CreateTask.test.js | 139 ++++++++++++ .../src/tests/apiV2/tasks/EditTask.test.js | 211 ++++++++++++++++++ .../src/tests/apiV2/tasks/GETTasks.test.js | 198 ++++++++++++++++ packages/database/package.json | 1 + packages/database/src/DatabaseModel.js | 1 + packages/database/src/TupaiaDatabase.js | 10 + ...7005418-CreateTaskTable-modifies-schema.js | 84 +++++++ packages/database/src/modelClasses/Task.js | 74 ++++++ packages/database/src/modelClasses/index.js | 3 + packages/database/src/records.js | 1 + packages/types/src/schemas/schemas.ts | 120 ++++++++++ packages/types/src/types/models.ts | 31 +++ yarn.lock | 1 + 21 files changed, 1133 insertions(+), 2 deletions(-) create mode 100644 packages/central-server/src/apiV2/tasks/CreateTask.js create mode 100644 packages/central-server/src/apiV2/tasks/EditTask.js create mode 100644 packages/central-server/src/apiV2/tasks/GETTasks.js create mode 100644 packages/central-server/src/apiV2/tasks/assertTaskPermissions.js create mode 100644 packages/central-server/src/apiV2/tasks/index.js create mode 100644 packages/central-server/src/tests/apiV2/tasks/CreateTask.test.js create mode 100644 packages/central-server/src/tests/apiV2/tasks/EditTask.test.js create mode 100644 packages/central-server/src/tests/apiV2/tasks/GETTasks.test.js create mode 100644 packages/database/src/migrations/20240617005418-CreateTaskTable-modifies-schema.js create mode 100644 packages/database/src/modelClasses/Task.js diff --git a/packages/central-server/examples.http b/packages/central-server/examples.http index 559912818d..d19bec7a89 100644 --- a/packages/central-server/examples.http +++ b/packages/central-server/examples.http @@ -112,3 +112,10 @@ Authorization: {{user-authorization}} GET {{baseUrl}}/{{version}}/entityHierarchy/5e9d06e261f76a30c4000002 HTTP/2.0 content-type: {{contentType}} Authorization: {{user-authorization}} + + +### Get tasks + +GET {{baseUrl}}/tasks?columns=["task_status","assignee_name"] +content-type: {{contentType}} +Authorization: {{user-authorization}} diff --git a/packages/central-server/src/apiV2/index.js b/packages/central-server/src/apiV2/index.js index e7d0e91733..bf2a97dc7f 100644 --- a/packages/central-server/src/apiV2/index.js +++ b/packages/central-server/src/apiV2/index.js @@ -144,6 +144,7 @@ import { GETDashboardMailingListEntries, } from './dashboardMailingListEntries'; import { EditEntityHierarchy, GETEntityHierarchy } from './entityHierarchy'; +import { CreateTask, EditTask, GETTasks } from './tasks'; // quick and dirty permission wrapper for open endpoints const allowAnyone = routeHandler => (req, res, next) => { @@ -267,7 +268,7 @@ apiV2.get( apiV2.get('/entityHierarchy/:recordId?', useRouteHandler(GETEntityHierarchy)); apiV2.get('/landingPages/:recordId?', useRouteHandler(GETLandingPages)); apiV2.get('/suggestSurveyCode', catchAsyncErrors(suggestSurveyCode)); - +apiV2.get('/tasks/:recordId?', useRouteHandler(GETTasks)); /** * POST routes */ @@ -314,7 +315,7 @@ apiV2.post('/landingPages', useRouteHandler(CreateLandingPage)); apiV2.post('/surveys', multipartJson(), useRouteHandler(CreateSurvey)); apiV2.post('/dhisInstances', useRouteHandler(BESAdminCreateHandler)); apiV2.post('/supersetInstances', useRouteHandler(BESAdminCreateHandler)); - +apiV2.post('/tasks', useRouteHandler(CreateTask)); /** * PUT routes */ @@ -352,6 +353,7 @@ apiV2.put('/landingPages/:recordId', useRouteHandler(EditLandingPage)); apiV2.put('/surveys/:recordId', multipartJson(), useRouteHandler(EditSurvey)); apiV2.put('/dhisInstances/:recordId', useRouteHandler(BESAdminEditHandler)); apiV2.put('/supersetInstances/:recordId', useRouteHandler(BESAdminEditHandler)); +apiV2.put('/tasks/:recordId', useRouteHandler(EditTask)); /** * DELETE routes diff --git a/packages/central-server/src/apiV2/tasks/CreateTask.js b/packages/central-server/src/apiV2/tasks/CreateTask.js new file mode 100644 index 0000000000..d1eda45751 --- /dev/null +++ b/packages/central-server/src/apiV2/tasks/CreateTask.js @@ -0,0 +1,26 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import { CreateHandler } from '../CreateHandler'; +import { assertAnyPermissions, assertBESAdminAccess } from '../../permissions'; +import { assertUserHasPermissionToCreateTask } from './assertTaskPermissions'; +/** + * Handles POST endpoints: + * - /tasks + */ + +export class CreateTask extends CreateHandler { + async assertUserHasAccess() { + const createPermissionChecker = accessPolicy => + assertUserHasPermissionToCreateTask(accessPolicy, this.models, this.newRecordData); + + await this.assertPermissions( + assertAnyPermissions([assertBESAdminAccess, createPermissionChecker]), + ); + } + + async createRecord() { + await this.insertRecord(); + } +} diff --git a/packages/central-server/src/apiV2/tasks/EditTask.js b/packages/central-server/src/apiV2/tasks/EditTask.js new file mode 100644 index 0000000000..543a051620 --- /dev/null +++ b/packages/central-server/src/apiV2/tasks/EditTask.js @@ -0,0 +1,20 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { EditHandler } from '../EditHandler'; +import { assertAnyPermissions, assertBESAdminAccess } from '../../permissions'; +import { assertUserCanEditTask } from './assertTaskPermissions'; + +export class EditTask extends EditHandler { + async assertUserHasAccess() { + const permissionChecker = accessPolicy => + assertUserCanEditTask(accessPolicy, this.models, this.recordId, this.updatedFields); + await this.assertPermissions(assertAnyPermissions([assertBESAdminAccess, permissionChecker])); + } + + async editRecord() { + await this.updateRecord(); + } +} diff --git a/packages/central-server/src/apiV2/tasks/GETTasks.js b/packages/central-server/src/apiV2/tasks/GETTasks.js new file mode 100644 index 0000000000..846a2a7c01 --- /dev/null +++ b/packages/central-server/src/apiV2/tasks/GETTasks.js @@ -0,0 +1,40 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { assertAnyPermissions, assertBESAdminAccess } from '../../permissions'; +import { GETHandler } from '../GETHandler'; +import { assertUserHasTaskPermissions, createTaskDBFilter } from './assertTaskPermissions'; + +export class GETTasks extends GETHandler { + permissionsFilteredInternally = true; + + async getPermissionsFilter(criteria, options) { + return createTaskDBFilter(this.accessPolicy, this.models, criteria, options); + } + + async findSingleRecord(projectId, options) { + const taskPermissionChecker = accessPolicy => + assertUserHasTaskPermissions(accessPolicy, this.models, projectId); + await this.assertPermissions( + assertAnyPermissions([assertBESAdminAccess, taskPermissionChecker]), + ); + + return super.findSingleRecord(projectId, options); + } + + async getDbQueryOptions() { + const { multiJoin, sort, ...restOfOptions } = await super.getDbQueryOptions(); + + return { + ...restOfOptions, + // Strip table prefix from `task_status` and `assignee_name` as these are customColumns + sort: sort.map(s => + s.replace('task.task_status', 'task_status').replace('task.assignee_name', 'assignee_name'), + ), + // Appending the multi-join from the Record class so that we can fetch the `task_status` and `assignee_name` + multiJoin: multiJoin.concat(this.models.task.DatabaseRecordClass.joins), + }; + } +} diff --git a/packages/central-server/src/apiV2/tasks/assertTaskPermissions.js b/packages/central-server/src/apiV2/tasks/assertTaskPermissions.js new file mode 100644 index 0000000000..b0cfb93a19 --- /dev/null +++ b/packages/central-server/src/apiV2/tasks/assertTaskPermissions.js @@ -0,0 +1,123 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { RECORDS } from '@tupaia/database'; +import { hasBESAdminAccess } from '../../permissions'; +import { fetchCountryCodesByPermissionGroupId, mergeFilter, mergeMultiJoin } from '../utilities'; + +const getUserSurveys = async (models, accessPolicy, projectId) => { + const query = {}; + if (projectId) { + query.project_id = projectId; + } + const userSurveys = await models.survey.findByAccessPolicy(accessPolicy, query, { + columns: ['id'], + }); + return userSurveys; +}; + +export const createTaskDBFilter = async (accessPolicy, models, criteria, options) => { + if (hasBESAdminAccess(accessPolicy)) { + return { dbConditions: criteria, dbOptions: options }; + } + const { projectId, ...dbConditions } = { ...criteria }; + const dbOptions = { ...options }; + + const countryCodesByPermissionGroupId = await fetchCountryCodesByPermissionGroupId( + accessPolicy, + models, + ); + + const surveys = await getUserSurveys(models, accessPolicy, projectId); + + dbConditions['entity.country_code'] = mergeFilter( + { + comparator: 'IN', + comparisonValue: Object.values(countryCodesByPermissionGroupId).flat(), + }, + dbConditions['entity.country_code'], + ); + + dbConditions['task.survey_id'] = mergeFilter( + { + comparator: 'IN', + comparisonValue: surveys.map(survey => survey.id), + }, + dbConditions['task.survey_id'], + ); + + dbOptions.multiJoin = mergeMultiJoin( + [ + { + joinWith: RECORDS.ENTITY, + joinCondition: [`${RECORDS.ENTITY}.id`, `${RECORDS.TASK}.entity_id`], + }, + ], + dbOptions.multiJoin, + ); + return { dbConditions, dbOptions }; +}; + +export const assertUserHasTaskPermissions = async (accessPolicy, models, taskId) => { + const task = await models.task.findById(taskId); + if (!task) { + throw new Error(`No task found with id ${taskId}`); + } + + const entity = await task.entity(); + if (!accessPolicy.allows(entity.country_code)) { + throw new Error('Need to have access to the country of the task'); + } + + const userSurveys = await getUserSurveys(models, accessPolicy); + const survey = userSurveys.find(({ id }) => id === task.survey_id); + if (!survey) { + throw new Error('Need to have access to the survey of the task'); + } + + return true; +}; + +export const assertUserHasPermissionToCreateTask = async (accessPolicy, models, taskData) => { + const { entity_id: entityId, survey_id: surveyId } = taskData; + + const entity = await models.entity.findById(entityId); + if (!entity) { + throw new Error(`No entity found with id ${entityId}`); + } + + if (!accessPolicy.allows(entity.country_code)) { + throw new Error('Need to have access to the country of the task'); + } + + const userSurveys = await getUserSurveys(models, accessPolicy); + const survey = userSurveys.find(({ id }) => id === surveyId); + if (!survey) { + throw new Error('Need to have access to the survey of the task'); + } + + return true; +}; + +export const assertUserCanEditTask = async (accessPolicy, models, taskId, newRecordData) => { + await assertUserHasTaskPermissions(accessPolicy, models, taskId); + if (newRecordData.entity_id) { + const entity = await models.entity.findById(newRecordData.entity_id); + if (!entity) { + throw new Error(`No entity found with id ${newRecordData.entity_id}`); + } + if (!accessPolicy.allows(entity.country_code)) { + throw new Error('Need to have access to the new entity of the task'); + } + } + if (newRecordData.survey_id) { + const userSurveys = await getUserSurveys(models, accessPolicy); + const survey = userSurveys.find(({ id }) => id === newRecordData.survey_id); + if (!survey) { + throw new Error('Need to have access to the new survey of the task'); + } + } + return true; +}; diff --git a/packages/central-server/src/apiV2/tasks/index.js b/packages/central-server/src/apiV2/tasks/index.js new file mode 100644 index 0000000000..d3d91ef92c --- /dev/null +++ b/packages/central-server/src/apiV2/tasks/index.js @@ -0,0 +1,8 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +export { GETTasks } from './GETTasks'; +export { CreateTask } from './CreateTask'; +export { EditTask } from './EditTask'; diff --git a/packages/central-server/src/apiV2/utilities/constructNewRecordValidationRules.js b/packages/central-server/src/apiV2/utilities/constructNewRecordValidationRules.js index ff31ea6122..567a1a7f90 100644 --- a/packages/central-server/src/apiV2/utilities/constructNewRecordValidationRules.js +++ b/packages/central-server/src/apiV2/utilities/constructNewRecordValidationRules.js @@ -440,6 +440,37 @@ export const constructForSingle = (models, recordType) => { code: [isAString], config: [hasContent], }; + case RECORDS.TASK: + return { + entity_id: [constructRecordExistsWithId(models.entity)], + survey_id: [constructRecordExistsWithId(models.survey)], + assignee_id: [constructIsEmptyOr(constructRecordExistsWithId(models.user))], + due_date: [ + (value, { status }) => { + if (status !== 'repeating' && !value) { + throw new Error('Due date is required for non-recurring tasks'); + } + return true; + }, + ], + repeat_schedule: [ + (value, { status }) => { + if (status === 'repeating' && !value) { + throw new Error('Repeat frequency is required for recurring tasks'); + } + return true; + }, + ], + status: [ + (value, { repeat_schedule: repeatSchedule }) => { + if (repeatSchedule) return true; + if (!value) { + throw new Error('Status is required'); + } + return true; + }, + ], + }; default: throw new ValidationError(`${recordType} is not a valid POST endpoint`); } diff --git a/packages/central-server/src/tests/apiV2/tasks/CreateTask.test.js b/packages/central-server/src/tests/apiV2/tasks/CreateTask.test.js new file mode 100644 index 0000000000..c7c79ea9a0 --- /dev/null +++ b/packages/central-server/src/tests/apiV2/tasks/CreateTask.test.js @@ -0,0 +1,139 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { expect } from 'chai'; +import { + buildAndInsertSurveys, + findOrCreateDummyCountryEntity, + findOrCreateDummyRecord, + generateId, +} from '@tupaia/database'; +import { TestableApp, resetTestData } from '../../testUtilities'; +import { BES_ADMIN_PERMISSION_GROUP } from '../../../permissions'; + +describe('Permissions checker for CreateTask', async () => { + const BES_ADMIN_POLICY = { + DL: [BES_ADMIN_PERMISSION_GROUP], + }; + + const DEFAULT_POLICY = { + TO: ['Donor'], + }; + + const app = new TestableApp(); + const { models } = app; + let surveys; + const facilities = [ + { + id: generateId(), + code: 'TEST_FACILITY_1', + name: 'Test Facility 1', + country_code: 'TO', + }, + { + id: generateId(), + code: 'TEST_FACILITY_2', + name: 'Test Facility 2', + country_code: 'DL', + }, + ]; + const assignee = { + id: generateId(), + first_name: 'Peter', + last_name: 'Pan', + }; + + const BASE_TASK = { + assignee_id: assignee.id, + repeat_schedule: '{}', + due_date: new Date('2021-12-31'), + status: 'to_do', + }; + + before(async () => { + const { country: tongaCountry } = await findOrCreateDummyCountryEntity(models, { + code: 'TO', + name: 'Tonga', + }); + + const { country: dlCountry } = await findOrCreateDummyCountryEntity(models, { + code: 'DL', + name: 'Demo Land', + }); + + const donorPermission = await findOrCreateDummyRecord(models.permissionGroup, { + name: 'Donor', + }); + const BESAdminPermission = await findOrCreateDummyRecord(models.permissionGroup, { + name: 'Admin', + }); + + await Promise.all(facilities.map(facility => findOrCreateDummyRecord(models.entity, facility))); + + surveys = await buildAndInsertSurveys(models, [ + { + code: 'TEST_SURVEY_1', + name: 'Test Survey 1', + permission_group_id: BESAdminPermission.id, + country_ids: [tongaCountry.id, dlCountry.id], + }, + { + code: 'TEST_SURVEY_2', + name: 'Test Survey 2', + permission_group_id: donorPermission.id, + country_ids: [tongaCountry.id], + }, + ]); + + await findOrCreateDummyRecord(models.user, assignee); + }); + + afterEach(() => { + app.revokeAccess(); + }); + + after(async () => { + await resetTestData(); + }); + + describe('POST /tasks', async () => { + it('Sufficient permissions: allows a user to create a task if they have BES Admin permission', async () => { + await app.grantAccess(BES_ADMIN_POLICY); + const { body: result } = await app.post('tasks', { + body: { + ...BASE_TASK, + entity_id: facilities[0].id, + survey_id: surveys[0].survey.id, + }, + }); + + expect(result.message).to.equal('Successfully created tasks'); + }); + + it('Insufficient permissions: Does not allow user to create a task for an entity they do not have access to', async () => { + await app.grantAccess(DEFAULT_POLICY); + const { body: result } = await app.post('tasks', { + body: { + ...BASE_TASK, + entity_id: facilities[1].id, + survey_id: surveys[1].survey.id, + }, + }); + expect(result).to.have.keys('error'); + }); + + it('Insufficient permissions: Does not allow user to create a task for a survey they do not have access to', async () => { + await app.grantAccess(DEFAULT_POLICY); + const { body: result } = await app.post('tasks', { + body: { + ...BASE_TASK, + entity_id: facilities[0].id, + survey_id: surveys[0].survey.id, + }, + }); + expect(result).to.have.keys('error'); + }); + }); +}); diff --git a/packages/central-server/src/tests/apiV2/tasks/EditTask.test.js b/packages/central-server/src/tests/apiV2/tasks/EditTask.test.js new file mode 100644 index 0000000000..1bda0247a9 --- /dev/null +++ b/packages/central-server/src/tests/apiV2/tasks/EditTask.test.js @@ -0,0 +1,211 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { expect } from 'chai'; +import { + buildAndInsertSurveys, + findOrCreateDummyCountryEntity, + findOrCreateDummyRecord, + generateId, +} from '@tupaia/database'; +import { TestableApp, resetTestData } from '../../testUtilities'; +import { BES_ADMIN_PERMISSION_GROUP } from '../../../permissions'; + +const rollbackRecordChange = async (models, records) => { + await Promise.all(records.map(record => models.task.delete({ id: record.id }))); +}; + +const createTasks = async (tasksToCreate, models) => { + await Promise.all(tasksToCreate.map(task => findOrCreateDummyRecord(models.task, task))); +}; + +describe('Permissions checker for EditTask', async () => { + const BES_ADMIN_POLICY = { + DL: [BES_ADMIN_PERMISSION_GROUP], + TO: [BES_ADMIN_PERMISSION_GROUP], + }; + + const DEFAULT_POLICY = { + DL: ['Donor'], + }; + + const app = new TestableApp(); + const { models } = app; + let surveys; + const facilities = [ + { + id: generateId(), + code: 'TEST_FACILITY_1', + name: 'Test Facility 1', + country_code: 'TO', + }, + { + id: generateId(), + code: 'TEST_FACILITY_2', + name: 'Test Facility 2', + country_code: 'DL', + }, + ]; + + const assignee = { + id: generateId(), + first_name: 'Peter', + last_name: 'Pan', + }; + + const dueDate = new Date('2021-12-31'); + + let tasks; + + before(async () => { + const { country: tongaCountry } = await findOrCreateDummyCountryEntity(models, { + code: 'TO', + name: 'Tonga', + }); + + const { country: dlCountry } = await findOrCreateDummyCountryEntity(models, { + code: 'DL', + name: 'Demo Land', + }); + + const donorPermission = await findOrCreateDummyRecord(models.permissionGroup, { + name: 'Donor', + }); + const BESAdminPermission = await findOrCreateDummyRecord(models.permissionGroup, { + name: 'Admin', + }); + + await Promise.all(facilities.map(facility => findOrCreateDummyRecord(models.entity, facility))); + + surveys = await buildAndInsertSurveys(models, [ + { + code: 'TEST_SURVEY_1', + name: 'Test Survey 1', + permission_group_id: BESAdminPermission.id, + country_ids: [tongaCountry.id, dlCountry.id], + }, + { + code: 'TEST_SURVEY_2', + name: 'Test Survey 2', + permission_group_id: donorPermission.id, + country_ids: [dlCountry.id], + }, + ]); + + tasks = [ + { + id: generateId(), + survey_id: surveys[0].survey.id, + entity_id: facilities[0].id, + due_date: dueDate, + status: 'to_do', + }, + { + id: generateId(), + survey_id: surveys[1].survey.id, + entity_id: facilities[1].id, + assignee_id: assignee.id, + due_date: dueDate, + status: 'to_do', + }, + ]; + + await findOrCreateDummyRecord(models.user, assignee); + }); + + beforeEach(async () => { + await createTasks(tasks, models); + }); + + afterEach(async () => { + await rollbackRecordChange(models, tasks); + app.revokeAccess(); + }); + + after(async () => { + await resetTestData(); + }); + + describe('PUT /tasks/:id', async () => { + it('Sufficient permissions: allows a user to edit a task if they have BES Admin permission', async () => { + await app.grantAccess(BES_ADMIN_POLICY); + await app.put(`tasks/${tasks[1].id}`, { + body: { + entity_id: facilities[0].id, + survey_id: surveys[0].survey.id, + }, + }); + const result = await models.task.find({ + id: tasks[1].id, + }); + expect(result[0].entity_id).to.equal(facilities[0].id); + expect(result[0].survey_id).to.equal(surveys[0].survey.id); + }); + + it('Sufficient permissions: allows a user to edit a task if they have access to the task, and entity and survey are not being updated', async () => { + await app.grantAccess(DEFAULT_POLICY); + await app.put(`tasks/${tasks[1].id}`, { + body: { + status: 'completed', + }, + }); + const result = await models.task.find({ + id: tasks[1].id, + }); + expect(result[0].status).to.equal('completed'); + }); + + it('Sufficient permissions: allows a user to edit a task if they have access to the task, and the entity and survey that are being linked to the task', async () => { + await app.grantAccess({ + DL: ['Donor'], + TO: ['Donor'], + }); + await app.put(`tasks/${tasks[1].id}`, { + body: { + survey_id: surveys[1].survey.id, + entity_id: facilities[1].id, + }, + }); + const result = await models.task.find({ + id: tasks[1].id, + }); + expect(result[0].entity_id).to.equal(facilities[1].id); + expect(result[0].survey_id).to.equal(surveys[1].survey.id); + }); + + it('Insufficient permissions: throws an error if the user does not have access to the task being edited', async () => { + await app.grantAccess(DEFAULT_POLICY); + const { body: result } = await app.put(`tasks/${tasks[0].id}`, { + body: { + status: 'completed', + }, + }); + expect(result).to.have.keys('error'); + expect(result.error).to.include('Need to have access to the country of the task'); + }); + + it('Insufficient permissions: throws an error if the user does not have access to the survey being linked to the task', async () => { + await app.grantAccess(DEFAULT_POLICY); + const { body: result } = await app.put(`tasks/${tasks[1].id}`, { + body: { + survey_id: surveys[0].survey.id, + }, + }); + expect(result).to.have.keys('error'); + expect(result.error).to.include('Need to have access to the new survey of the task'); + }); + + it('Insufficient permissions: throws an error if the user does not have access to the entity being linked to the task', async () => { + await app.grantAccess(DEFAULT_POLICY); + const { body: result } = await app.put(`tasks/${tasks[1].id}`, { + body: { + entity_id: facilities[0].id, + }, + }); + expect(result).to.have.keys('error'); + expect(result.error).to.include('Need to have access to the new entity of the task'); + }); + }); +}); diff --git a/packages/central-server/src/tests/apiV2/tasks/GETTasks.test.js b/packages/central-server/src/tests/apiV2/tasks/GETTasks.test.js new file mode 100644 index 0000000000..9f6be0c21b --- /dev/null +++ b/packages/central-server/src/tests/apiV2/tasks/GETTasks.test.js @@ -0,0 +1,198 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { expect } from 'chai'; +import { + buildAndInsertSurveys, + findOrCreateDummyCountryEntity, + findOrCreateDummyRecord, + generateId, +} from '@tupaia/database'; +import { TestableApp, resetTestData } from '../../testUtilities'; +import { BES_ADMIN_PERMISSION_GROUP } from '../../../permissions'; + +describe('Permissions checker for GETTasks', async () => { + const BES_ADMIN_POLICY = { + DL: [BES_ADMIN_PERMISSION_GROUP], + }; + + const DEFAULT_POLICY = { + DL: ['Donor'], + TO: ['Donor'], + }; + + const PUBLIC_POLICY = { + DL: ['Public'], + }; + + const app = new TestableApp(); + const { models } = app; + let tasks; + + before(async () => { + const { country: tongaCountry } = await findOrCreateDummyCountryEntity(models, { + code: 'TO', + name: 'Tonga', + }); + + const { country: dlCountry } = await findOrCreateDummyCountryEntity(models, { + code: 'DL', + name: 'Demo Land', + }); + + const donorPermission = await findOrCreateDummyRecord(models.permissionGroup, { + name: 'Donor', + }); + const BESAdminPermission = await findOrCreateDummyRecord(models.permissionGroup, { + name: 'Admin', + }); + + const facilities = [ + { + id: generateId(), + code: 'TEST_FACILITY_1', + name: 'Test Facility 1', + country_code: tongaCountry.code, + }, + { + id: generateId(), + code: 'TEST_FACILITY_2', + name: 'Test Facility 2', + country_code: dlCountry.code, + }, + ]; + + await Promise.all(facilities.map(facility => findOrCreateDummyRecord(models.entity, facility))); + + const surveys = await buildAndInsertSurveys(models, [ + { + code: 'TEST_SURVEY_1', + name: 'Test Survey 1', + permission_group_id: BESAdminPermission.id, + country_ids: [tongaCountry.id, dlCountry.id], + }, + { + code: 'TEST_SURVEY_2', + name: 'Test Survey 2', + permission_group_id: donorPermission.id, + country_ids: [tongaCountry.id, dlCountry.id], + }, + ]); + + const assignee = { + id: generateId(), + first_name: 'Minnie', + last_name: 'Mouse', + }; + await findOrCreateDummyRecord(models.user, assignee); + + const dueDate = new Date('2021-12-31'); + + tasks = [ + { + id: generateId(), + survey_id: surveys[0].survey.id, + entity_id: facilities[0].id, + due_date: dueDate, + status: 'to_do', + repeat_schedule: null, + }, + { + id: generateId(), + survey_id: surveys[1].survey.id, + entity_id: facilities[1].id, + assignee_id: assignee.id, + due_date: null, + repeat_schedule: '{}', + status: null, + }, + ]; + + await Promise.all( + tasks.map(task => + findOrCreateDummyRecord( + models.task, + { + 'task.id': task.id, + }, + task, + ), + ), + ); + }); + + afterEach(() => { + app.revokeAccess(); + }); + + after(async () => { + await resetTestData(); + }); + + describe('GET /tasks/:id', async () => { + it('Sufficient permissions: returns a requested task when user has BES admin permissions', async () => { + await app.grantAccess(BES_ADMIN_POLICY); + const { body: result } = await app.get(`tasks/${tasks[0].id}`); + expect(result.id).to.equal(tasks[0].id); + }); + + it('Sufficient permissions: returns a requested task when user has permissions', async () => { + await app.grantAccess(DEFAULT_POLICY); + const { body: result } = await app.get(`tasks/${tasks[1].id}`); + + expect(result.id).to.equal(tasks[1].id); + }); + + it('Insufficient permissions: throws an error if requesting task when user does not have permissions', async () => { + await app.grantAccess(DEFAULT_POLICY); + const { body: result } = await app.get(`tasks/${tasks[0].id}`); + + expect(result).to.have.keys('error'); + }); + }); + + describe('GET /tasks', async () => { + it('Sufficient permissions: returns all tasks if the user has BES admin access', async () => { + const columnsString = JSON.stringify([ + 'id', + 'task_status', + 'assignee_name', + 'due_date', + 'repeat_schedule', + ]); + await app.grantAccess(BES_ADMIN_POLICY); + const { body: results } = await app.get(`tasks?columns=${columnsString}`); + expect(results.length).to.equal(tasks.length); + + results.forEach((result, index) => { + const task = tasks[index]; + expect(result.id).to.equal(task.id); + if (task.assignee_id) { + expect(result.assignee_name).to.equal('Minnie Mouse'); + } + if (task.status === 'to_do') { + expect(result.task_status).to.equal('overdue'); + } else expect(result.task_status).to.equal('repeating'); + }); + }); + + it('Sufficient permissions: returns tasks when user has permissions', async () => { + const columnsString = JSON.stringify(['assignee_name', 'id']); + await app.grantAccess(DEFAULT_POLICY); + const { body: results } = await app.get(`tasks?columns=${columnsString}`); + + expect(results.length).to.equal(1); + expect(results[0].assignee_name).to.equal('Minnie Mouse'); + expect(results[0].id).to.equal(tasks[1].id); + }); + + it('Insufficient permissions: returns an empty array if users do not have access to any tasks', async () => { + await app.grantAccess(PUBLIC_POLICY); + const { body: results } = await app.get('tasks'); + + expect(results).to.be.empty; + }); + }); +}); diff --git a/packages/database/package.json b/packages/database/package.json index 97abedd1e8..be3254d692 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -36,6 +36,7 @@ "@tupaia/auth": "workspace:*", "@tupaia/tsutils": "workspace:*", "@tupaia/utils": "workspace:*", + "date-fns": "^2.29.2", "db-migrate": "^0.11.5", "db-migrate-pg": "^1.2.2", "dotenv": "^16.4.5", diff --git a/packages/database/src/DatabaseModel.js b/packages/database/src/DatabaseModel.js index 6911b36884..02c355ded1 100644 --- a/packages/database/src/DatabaseModel.js +++ b/packages/database/src/DatabaseModel.js @@ -77,6 +77,7 @@ export class DatabaseModel { async fetchFieldNames() { if (!this.fieldNames) { const schema = await this.fetchSchema(); + this.fieldNames = Object.keys(schema); } return this.fieldNames; diff --git a/packages/database/src/TupaiaDatabase.js b/packages/database/src/TupaiaDatabase.js index 149fee76bb..18d2d4887c 100644 --- a/packages/database/src/TupaiaDatabase.js +++ b/packages/database/src/TupaiaDatabase.js @@ -560,6 +560,12 @@ function buildQuery(connection, queryConfig, where = {}, options = {}) { } } + // Special case to handle CASE statements, otherwise they get interpreted as column names + const CASE_PATTERN = /^CASE/; + if (CASE_PATTERN.test(selector)) { + return { [alias]: connection.raw(selector) }; + } + return { [alias]: connection.raw('??', [selector]) }; }); query = addWhereClause(connection, query[queryMethod](queryMethodParameter || columns), where); @@ -733,6 +739,10 @@ function getColSelector(connection, inputColStr) { return connection.raw(`COALESCE(${identifiers})`, bindings); } + const casePattern = /^CASE/; + if (casePattern.test(inputColStr)) { + return connection.raw(inputColStr); + } return inputColStr; } diff --git a/packages/database/src/migrations/20240617005418-CreateTaskTable-modifies-schema.js b/packages/database/src/migrations/20240617005418-CreateTaskTable-modifies-schema.js new file mode 100644 index 0000000000..ea0ec10b69 --- /dev/null +++ b/packages/database/src/migrations/20240617005418-CreateTaskTable-modifies-schema.js @@ -0,0 +1,84 @@ +'use strict'; + +var dbm; +var type; +var seed; + +const createFK = (columnName, table, shouldCascade) => { + const rules = shouldCascade + ? { + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + } + : {}; + return { + name: `task_${columnName}_fk`, + table, + mapping: 'id', + rules, + }; +}; + +const createStatusEnum = db => { + return db.runSql(` + DROP TYPE IF EXISTS TASK_STATUS; + CREATE TYPE TASK_STATUS AS ENUM('to_do', 'cancelled', 'completed'); + + `); +}; + +const createTaskTable = db => { + return db.createTable('task', { + columns: { + id: { type: 'text', primaryKey: true }, + survey_id: { + type: 'text', + notNull: true, + foreignKey: createFK('survey_id', 'survey', true), + }, + entity_id: { + type: 'text', + notNull: true, + foreignKey: createFK('entity_id', 'entity', true), + }, + assignee_id: { + type: 'text', + foreignKey: createFK('assignee_id', 'user_account', false), + }, + repeat_schedule: { type: 'jsonb' }, + due_date: { type: 'timestamp' }, + status: { type: 'TASK_STATUS' }, + }, + ifNotExists: true, + }); +}; + +/** + * We receive the dbmigrate dependency from dbmigrate initially. + * This enables us to not have to rely on NODE_PATH. + */ +exports.setup = function (options, seedLink) { + dbm = options.dbmigrate; + type = dbm.dataType; + seed = seedLink; +}; + +exports.up = async function (db) { + await createStatusEnum(db); + await createTaskTable(db); + + return db.runSql(` + CREATE INDEX task_survey_id_idx ON task USING btree (survey_id); + CREATE INDEX task_entity_id_idx ON task USING btree (entity_id); + CREATE INDEX task_assignee_id_idx ON task USING btree (assignee_id); + `); +}; + +exports.down = async function (db) { + await db.dropTable('task'); + return db.runSql('DROP TYPE TASK_STATUS;'); +}; + +exports._meta = { + version: 1, +}; diff --git a/packages/database/src/modelClasses/Task.js b/packages/database/src/modelClasses/Task.js new file mode 100644 index 0000000000..394b261e68 --- /dev/null +++ b/packages/database/src/modelClasses/Task.js @@ -0,0 +1,74 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { format } from 'date-fns'; +import { DatabaseModel } from '../DatabaseModel'; +import { DatabaseRecord } from '../DatabaseRecord'; +import { RECORDS } from '../records'; +import { JOIN_TYPES } from '../TupaiaDatabase'; + +export class TaskRecord extends DatabaseRecord { + static databaseRecord = RECORDS.TASK; + + static joins = [ + { + joinWith: RECORDS.ENTITY, + joinCondition: ['entity_id', `${RECORDS.ENTITY}.id`], + fields: { code: 'entity_code', name: 'entity_name', country_code: 'entity_country_code' }, + }, + { + joinWith: RECORDS.USER_ACCOUNT, + joinAs: 'assignee', + joinType: JOIN_TYPES.LEFT, + joinCondition: ['assignee_id', 'assignee.id'], + fields: { first_name: 'assignee_first_name', last_name: 'assignee_last_name' }, + }, + { + joinWith: RECORDS.SURVEY, + joinCondition: ['survey_id', `${RECORDS.SURVEY}.id`], + fields: { name: 'survey_name', code: 'survey_code' }, + }, + ]; + + async entity() { + return this.otherModels.entity.findById(this.entity_id); + } + + async assignee() { + return this.otherModels.userAccount.findById(this.assignee_id); + } + + async survey() { + return this.otherModels.survey.findById(this.survey_id); + } +} + +export class TaskModel extends DatabaseModel { + get DatabaseRecordClass() { + return TaskRecord; + } + + customColumnSelectors = { + task_status: () => + `CASE + WHEN status = 'cancelled' then 'cancelled' + WHEN status = 'completed' then 'completed' + WHEN (status = 'to_do' OR status IS NULL) THEN + CASE + WHEN repeat_schedule IS NOT NULL THEN 'repeating' + WHEN due_date IS NULL THEN 'to_do' + WHEN due_date < '${format(new Date(), 'yyyy-MM-dd')}' THEN 'overdue' + ELSE 'to_do' + END + ELSE 'to_do' + END`, + assignee_name: () => + `CASE + WHEN assignee_id IS NULL THEN NULL + WHEN assignee.last_name IS NULL THEN assignee.first_name + ELSE assignee.first_name || ' ' || assignee.last_name + END`, + }; +} diff --git a/packages/database/src/modelClasses/index.js b/packages/database/src/modelClasses/index.js index c2239a8cb9..57e288b195 100644 --- a/packages/database/src/modelClasses/index.js +++ b/packages/database/src/modelClasses/index.js @@ -59,6 +59,7 @@ import { DataServiceEntityModel } from './DataServiceEntity'; import { DhisInstanceModel } from './DhisInstance'; import { DataElementDataServiceModel } from './DataElementDataService'; import { SupersetInstanceModel } from './SupersetInstance'; +import { TaskModel } from './Task'; // export all models to be used in constructing a ModelRegistry export const modelClasses = { @@ -114,6 +115,7 @@ export const modelClasses = { SurveyScreen: SurveyScreenModel, SurveyScreenComponent: SurveyScreenComponentModel, SyncGroupLog: SyncGroupLogModel, + Task: TaskModel, User: UserModel, UserEntityPermission: UserEntityPermissionModel, UserFavouriteDashboardItem: UserFavouriteDashboardItemModel, @@ -181,3 +183,4 @@ export { export { DashboardRelationRecord, DashboardRelationModel } from './DashboardRelation'; export { OneTimeLoginRecord, OneTimeLoginModel } from './OneTimeLogin'; export { AnswerModel, AnswerRecord } from './Answer'; +export { TaskModel, TaskRecord } from './Task'; diff --git a/packages/database/src/records.js b/packages/database/src/records.js index 2db952c5fc..1fecc79aa4 100644 --- a/packages/database/src/records.js +++ b/packages/database/src/records.js @@ -62,6 +62,7 @@ export const RECORDS = { SURVEY_SCREEN: 'survey_screen', SURVEY: 'survey', SYNC_GROUP_LOG: 'sync_group_log', + TASK: 'task', USER_ACCOUNT: 'user_account', USER_ENTITY_PERMISSION: 'user_entity_permission', USER_FAVOURITE_DASHBOARD_ITEM: 'user_favourite_dashboard_item', diff --git a/packages/types/src/schemas/schemas.ts b/packages/types/src/schemas/schemas.ts index 322bb3367d..ba3806fe23 100644 --- a/packages/types/src/schemas/schemas.ts +++ b/packages/types/src/schemas/schemas.ts @@ -85599,6 +85599,117 @@ export const SyncGroupLogUpdateSchema = { "additionalProperties": false } +export const TaskSchema = { + "type": "object", + "properties": { + "assignee_id": { + "type": "string" + }, + "due_date": { + "type": "string", + "format": "date-time" + }, + "entity_id": { + "type": "string" + }, + "id": { + "type": "string" + }, + "repeat_schedule": { + "type": "object", + "properties": {} + }, + "status": { + "enum": [ + "cancelled", + "completed", + "to_do" + ], + "type": "string" + }, + "survey_id": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "entity_id", + "id", + "survey_id" + ] +} + +export const TaskCreateSchema = { + "type": "object", + "properties": { + "assignee_id": { + "type": "string" + }, + "due_date": { + "type": "string", + "format": "date-time" + }, + "entity_id": { + "type": "string" + }, + "repeat_schedule": { + "type": "object", + "properties": {} + }, + "status": { + "enum": [ + "cancelled", + "completed", + "to_do" + ], + "type": "string" + }, + "survey_id": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "entity_id", + "survey_id" + ] +} + +export const TaskUpdateSchema = { + "type": "object", + "properties": { + "assignee_id": { + "type": "string" + }, + "due_date": { + "type": "string", + "format": "date-time" + }, + "entity_id": { + "type": "string" + }, + "id": { + "type": "string" + }, + "repeat_schedule": { + "type": "object", + "properties": {} + }, + "status": { + "enum": [ + "cancelled", + "completed", + "to_do" + ], + "type": "string" + }, + "survey_id": { + "type": "string" + } + }, + "additionalProperties": false +} + export const TupaiaWebSessionSchema = { "type": "object", "properties": { @@ -86139,6 +86250,15 @@ export const VerifiedEmailSchema = { "type": "string" } +export const TaskStatusSchema = { + "enum": [ + "cancelled", + "completed", + "to_do" + ], + "type": "string" +} + export const SyncGroupSyncStatusSchema = { "enum": [ "ERROR", diff --git a/packages/types/src/types/models.ts b/packages/types/src/types/models.ts index 4f89612965..4c168136d8 100644 --- a/packages/types/src/types/models.ts +++ b/packages/types/src/types/models.ts @@ -1533,6 +1533,32 @@ export interface SyncGroupLogUpdate { 'sync_group_code'?: string; 'timestamp'?: Date | null; } +export interface Task { + 'assignee_id'?: string | null; + 'due_date'?: Date | null; + 'entity_id': string; + 'id': string; + 'repeat_schedule'?: {} | null; + 'status'?: TaskStatus | null; + 'survey_id': string; +} +export interface TaskCreate { + 'assignee_id'?: string | null; + 'due_date'?: Date | null; + 'entity_id': string; + 'repeat_schedule'?: {} | null; + 'status'?: TaskStatus | null; + 'survey_id': string; +} +export interface TaskUpdate { + 'assignee_id'?: string | null; + 'due_date'?: Date | null; + 'entity_id'?: string; + 'id'?: string; + 'repeat_schedule'?: {} | null; + 'status'?: TaskStatus | null; + 'survey_id'?: string; +} export interface TupaiaWebSession { 'access_policy': {}; 'access_token': string; @@ -1665,6 +1691,11 @@ export enum VerifiedEmail { 'new_user' = 'new_user', 'verified' = 'verified', } +export enum TaskStatus { + 'to_do' = 'to_do', + 'cancelled' = 'cancelled', + 'completed' = 'completed', +} export enum SyncGroupSyncStatus { 'IDLE' = 'IDLE', 'SYNCING' = 'SYNCING', diff --git a/yarn.lock b/yarn.lock index ccd1940394..7e16ce827b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12005,6 +12005,7 @@ __metadata: "@tupaia/auth": "workspace:*" "@tupaia/tsutils": "workspace:*" "@tupaia/utils": "workspace:*" + date-fns: ^2.29.2 db-migrate: ^0.11.5 db-migrate-pg: ^1.2.2 dotenv: ^16.4.5 From 34ae8d3f13079f28364a05594c40877b8ebb6b62 Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Tue, 25 Jun 2024 09:05:18 +1200 Subject: [PATCH 4/9] refactor(adminPanel): RN-1335: Move filterable table into ui-components (#5733) * Add migration for table * Remove status type if exists * Create task db model * Add to db exports * Generate types * Fix migrate-down * Get tasks endpoint * Create task endpoint * WIP edit tasks * Edit task endpoint * Heading and route * Add alignment for children * Allow assignee to be empty * Don't cascade user accounts * Generate types * Tasks route * Request tasks * WIP table * Working table * Working table * Fix error type * Remove unused actions * Fix sorting * Undo unneeded changes * revert * Revert "Tasks route" This reverts commit 1511c374923817b7af7808d57b70888cbb0a88f0. * Revert "Add alignment for children" This reverts commit ff86345bdc9770b1c9dd552b8271906d05e77ac1. * Revert "Heading and route" This reverts commit bbbc6aeec3a7db440bab74e9a111ca2ca8be00d6. * Remove extra file * Only show filter row when applicable * Add assignee name field for easy filtering and indexes * Generate types * Change schema for repeating * Fix tests * Fix tests * Make due date nullable * Rename repeat_schedule field * Fix tests * Fix validation rules * Link status and assignee name to records at the model level * Generate types --- .../src/pages/resources/ResourcePage.jsx | 2 - .../src/routes/surveys/dataMapping.js | 2 +- .../admin-panel/src/table/ColumnFilter.jsx | 35 -- .../src/table/DataFetchingTable/Cells.jsx | 122 +------ .../DataFetchingTable/DataFetchingTable.jsx | 316 +++++------------- .../table/DataFetchingTable/FilterCell.jsx | 81 ----- .../src/table/ExpansionContainer.jsx | 72 ---- packages/admin-panel/src/table/actions.js | 22 -- packages/admin-panel/src/table/constants.js | 6 - packages/admin-panel/src/table/reducer.js | 14 +- .../src/components/FilterableTable/Cells.tsx | 131 ++++++++ .../components/FilterableTable/FilterCell.tsx | 113 +++++++ .../FilterableTable/FilterableTable.tsx | 223 ++++++++++++ .../FilterableTable/Pagination.tsx} | 37 +- .../src/components/FilterableTable/index.ts | 6 + .../ui-components/src/components/index.ts | 1 + .../src/types/react-table-config.d.ts | 19 +- 17 files changed, 603 insertions(+), 599 deletions(-) delete mode 100644 packages/admin-panel/src/table/ColumnFilter.jsx delete mode 100644 packages/admin-panel/src/table/DataFetchingTable/FilterCell.jsx delete mode 100644 packages/admin-panel/src/table/ExpansionContainer.jsx create mode 100644 packages/ui-components/src/components/FilterableTable/Cells.tsx create mode 100644 packages/ui-components/src/components/FilterableTable/FilterCell.tsx create mode 100644 packages/ui-components/src/components/FilterableTable/FilterableTable.tsx rename packages/{admin-panel/src/table/DataFetchingTable/Pagination.jsx => ui-components/src/components/FilterableTable/Pagination.tsx} (51%) create mode 100644 packages/ui-components/src/components/FilterableTable/index.ts diff --git a/packages/admin-panel/src/pages/resources/ResourcePage.jsx b/packages/admin-panel/src/pages/resources/ResourcePage.jsx index f8cdf66eb7..4ee422e40f 100644 --- a/packages/admin-panel/src/pages/resources/ResourcePage.jsx +++ b/packages/admin-panel/src/pages/resources/ResourcePage.jsx @@ -153,7 +153,6 @@ ResourcePage.propTypes = { exportConfig: PropTypes.object, deleteConfig: PropTypes.object, ExportModalComponent: PropTypes.elementType, - TableComponent: PropTypes.elementType, LinksComponent: PropTypes.elementType, title: PropTypes.string, baseFilter: PropTypes.object, @@ -179,7 +178,6 @@ ResourcePage.defaultProps = { exportConfig: {}, deleteConfig: {}, ExportModalComponent: null, - TableComponent: null, LinksComponent: null, onProcessDataForSave: null, title: null, diff --git a/packages/admin-panel/src/routes/surveys/dataMapping.js b/packages/admin-panel/src/routes/surveys/dataMapping.js index 4bcf1e3cf1..6e908d485f 100644 --- a/packages/admin-panel/src/routes/surveys/dataMapping.js +++ b/packages/admin-panel/src/routes/surveys/dataMapping.js @@ -28,7 +28,7 @@ const FIELDS = [ }, }, { - Header: 'Data service sonfiguration', + Header: 'Data service configuration', source: 'service_config', Cell: DataSourceConfigView, editConfig: DATA_ELEMENT_FIELD_EDIT_CONFIG, diff --git a/packages/admin-panel/src/table/ColumnFilter.jsx b/packages/admin-panel/src/table/ColumnFilter.jsx deleted file mode 100644 index df144900e6..0000000000 --- a/packages/admin-panel/src/table/ColumnFilter.jsx +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Tupaia - * Copyright (c) 2017 - 2020 Beyond Essential Systems Pty Ltd - */ - -import React from 'react'; -import { TextField } from '@tupaia/ui-components'; -import PropTypes from 'prop-types'; -import { labelToId } from '../utilities'; - -export const ColumnFilter = ({ column, filter, onChange }) => ( - onChange(event.target.value)} - id={`dataTableColumnFilter-${labelToId(column?.id)}`} - /> -); - -ColumnFilter.propTypes = { - column: PropTypes.shape({ - id: PropTypes.string, - }), - filter: PropTypes.shape({ - value: PropTypes.string, - }), - onChange: PropTypes.func, -}; - -ColumnFilter.defaultProps = { - column: null, - filter: null, - onChange: null, -}; diff --git a/packages/admin-panel/src/table/DataFetchingTable/Cells.jsx b/packages/admin-panel/src/table/DataFetchingTable/Cells.jsx index 61a686504c..906d17facc 100644 --- a/packages/admin-panel/src/table/DataFetchingTable/Cells.jsx +++ b/packages/admin-panel/src/table/DataFetchingTable/Cells.jsx @@ -5,46 +5,9 @@ import React from 'react'; import PropTypes from 'prop-types'; -import styled, { css } from 'styled-components'; -import { TableCell as MuiTableCell } from '@material-ui/core'; +import styled from 'styled-components'; import { Link } from 'react-router-dom'; -const Cell = styled(MuiTableCell)` - font-size: 0.75rem; - padding: 0; - border: none; - position: relative; - &:first-child { - padding-inline-start: 1.5rem; - } - &:last-child { - padding-inline-end: 1rem; - } -`; - -const CellContentWrapper = styled.div` - padding: 0.7rem; - ${({ $isButtonColumn }) => - $isButtonColumn && - css` - padding-inline: 0; - padding-block: 0; - text-align: center; - `} - height: 100%; - display: flex; - align-items: center; - - tr:not(:last-child) & { - border-bottom: 1px solid ${({ theme }) => theme.palette.grey[400]}; - } - td:first-child & { - padding-inline-start: 0.2rem; - } - - line-height: 1.5; -`; - // Flex does not support ellipsis so we need to have another container to handle the ellipsis const CellContentContainer = styled.div` white-space: nowrap; @@ -53,84 +16,6 @@ const CellContentContainer = styled.div` width: 100%; `; -const HeaderCell = styled(Cell)` - color: ${({ theme }) => theme.palette.text.secondary}; - font-weight: ${({ theme }) => theme.typography.fontWeightMedium}; - background-color: ${({ theme }) => theme.palette.background.paper}; - border-bottom: 1px solid ${({ theme }) => theme.palette.grey[400]}; - padding-block: 0.7rem; - padding-inline: 0.7rem 0; - display: flex; - position: initial; // override this because we have 2 sticky header rows so we will apply sticky to the thead element - background-color: ${({ theme }) => theme.palette.background.paper}; - .MuiTableSortLabel-icon { - opacity: 0.5; - } - .MuiTableSortLabel-active .MuiTableSortLabel-icon { - opacity: 1; - } -`; - -const ColResize = styled.div.attrs({ - onClick: e => { - // suppress other events when resizing - e.preventDefault(); - e.stopPropagation(); - }, -})` - width: 2rem; - height: 100%; - cursor: col-resize; -`; - -export const HeaderDisplayCell = ({ children, canResize, getResizerProps, ...props }) => { - return ( - - {children} - {canResize && } - - ); -}; - -HeaderDisplayCell.propTypes = { - children: PropTypes.node, - width: PropTypes.string, - canResize: PropTypes.bool, - getResizerProps: PropTypes.func, -}; - -HeaderDisplayCell.defaultProps = { - width: null, - children: null, - canResize: false, - getResizerProps: () => {}, -}; - -export const TableCell = ({ children, width, isButtonColumn, url, ...props }) => { - return ( - - - - {children} - - - - ); -}; - -TableCell.propTypes = { - children: PropTypes.node.isRequired, - width: PropTypes.number, - isButtonColumn: PropTypes.bool, - url: PropTypes.string, -}; - -TableCell.defaultProps = { - width: null, - isButtonColumn: false, - url: null, -}; - const CellLink = styled(Link)` color: inherit; text-decoration: none; @@ -162,7 +47,6 @@ export const DisplayCell = ({ getNestedViewLink, basePath, isButtonColumn, - ...props }) => { const generateLink = () => { if (isButtonColumn || (!detailUrl && !getNestedViewLink)) return null; @@ -176,9 +60,9 @@ export const DisplayCell = ({ }; const url = generateLink(); return ( - + {children} - + ); }; diff --git a/packages/admin-panel/src/table/DataFetchingTable/DataFetchingTable.jsx b/packages/admin-panel/src/table/DataFetchingTable/DataFetchingTable.jsx index 9e16e4fe92..30c30a908c 100644 --- a/packages/admin-panel/src/table/DataFetchingTable/DataFetchingTable.jsx +++ b/packages/admin-panel/src/table/DataFetchingTable/DataFetchingTable.jsx @@ -5,20 +5,10 @@ import React, { memo, useEffect, useMemo } from 'react'; import styled from 'styled-components'; import { connect } from 'react-redux'; -import { useTable, usePagination, useSortBy, useResizeColumns, useFlexLayout } from 'react-table'; -import { - TableHead, - TableContainer as MuiTableContainer, - TableRow, - TableBody, - Table, - Typography, - TableSortLabel, -} from '@material-ui/core'; -import { KeyboardArrowDown } from '@material-ui/icons'; +import { Typography } from '@material-ui/core'; import queryString from 'query-string'; import PropTypes from 'prop-types'; -import { Alert } from '@tupaia/ui-components'; +import { Alert, FilterableTable } from '@tupaia/ui-components'; import { generateConfigForColumnType } from '../columnTypes'; import { getIsFetchingData, getTableState } from '../selectors'; import { getIsChangingDataOnServer } from '../../dataChangeListener'; @@ -27,16 +17,13 @@ import { changeFilters, changePage, changePageSize, - changeResizedColumns, changeSorting, clearError, confirmAction, refreshData, } from '../actions'; -import { FilterCell } from './FilterCell'; -import { Pagination } from './Pagination'; -import { DisplayCell, HeaderDisplayCell } from './Cells'; import { ConfirmDeleteModal } from '../../widgets'; +import { DisplayCell } from './Cells'; const ErrorAlert = styled(Alert).attrs({ severity: 'error', @@ -44,28 +31,6 @@ const ErrorAlert = styled(Alert).attrs({ margin: 0.5rem; `; -const TableContainer = styled(MuiTableContainer)` - position: relative; - flex: 1; - overflow: auto; - table { - min-width: 45rem; - } - // Because we want two header rows to be sticky, we need to set the position of the thead to sticky - thead { - position: sticky; - top: 0; - z-index: 2; - background-color: ${({ theme }) => theme.palette.background.paper}; - } - tr { - display: flex; - - .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline { - border-color: ${({ theme }) => theme.palette.primary.main}; - } -`; - const Wrapper = styled.div` display: flex; flex-direction: column; @@ -87,12 +52,26 @@ const MessageWrapper = styled.div` background-color: rgba(255, 255, 255, 0.5); `; +const ButtonCell = styled.div` + display: flex; + justify-content: space-between; + align-items: center; +`; + +const SingleButtonWrapper = styled.div` + width: ${({ $width }) => $width}px; + .cell-content:has(&) { + padding-block: 0; + padding-inline-end: 0; + } +`; + const formatColumnForReactTable = (originalColumn, reduxId) => { const { source, type, actionConfig, filterable, ...restOfColumn } = originalColumn; const id = source || type; return { id, - accessor: id?.includes('.') ? row => row[source] : id, // react-table doesn't like .'s + accessor: id?.includes('.') ? originalRow => originalRow[source] : id, // react-table doesn't like .'s actionConfig, reduxId, type, @@ -134,69 +113,56 @@ const DataFetchingTableComponent = memo( baseFilter, basePath, resourceName, - defaultSorting, actionLabel = 'Action', }) => { - const formattedColumns = useMemo( - () => columns.map(column => formatColumnForReactTable(column)), - [JSON.stringify(columns)], - ); + const formattedColumns = useMemo(() => { + const cols = columns.map(column => formatColumnForReactTable(column)); + // for the columns that are not buttons, display them using a custom wrapper + const nonButtonColumns = cols + .filter(col => !col.isButtonColumn) + .map(col => ({ + ...col, + // eslint-disable-next-line react/prop-types + Cell: ({ value, ...props }) => ( + + {/** Columns can have custom Cells. If they do, render these, otherwise render the value */} + {col.Cell ? col.Cell({ value, ...props }) : value} + + ), + })); + const buttonColumns = cols.filter(col => col.isButtonColumn); + if (!buttonColumns.length) return nonButtonColumns; - const memoisedData = useMemo(() => data, [JSON.stringify(data)]); - - const { - getTableProps, - getTableBodyProps, - headerGroups, - prepareRow, - rows, - pageCount, - gotoPage, - setPageSize, - visibleColumns, - setSortBy, - // Get the state from the instance - state: { pageIndex: tablePageIndex, pageSize: tablePageSize, sortBy: tableSorting }, - } = useTable( - { - columns: formattedColumns, - data: memoisedData, - initialState: { - pageIndex, - pageSize, - sortBy: sorting, - hiddenColumns: columns - .filter(column => column.show === false) - .map(column => column.source ?? column.type), + const buttonWidths = buttonColumns.reduce((acc, { width }) => acc + (width || 60), 0); + // Group all button columns into a single column so they can be displayed together under a single header + const singleButtonColumn = { + Header: actionLabel, + maxWidth: buttonWidths, + width: buttonWidths, + // eslint-disable-next-line react/prop-types + Cell: ({ row }) => { + return ( + + {buttonColumns.map(({ Cell, accessor, ...col }) => { + return ( + + + + ); + })} + + ); }, - manualPagination: true, - pageCount: numberOfPages, - manualSortBy: true, - }, - useSortBy, - usePagination, - useFlexLayout, - useResizeColumns, - ); - - // Listen for changes in pagination and use the state to fetch our new data - useEffect(() => { - onPageChange(tablePageIndex); - }, [tablePageIndex]); - - useEffect(() => { - onPageSizeChange(tablePageSize); - gotoPage(0); - }, [tablePageSize]); - - useEffect(() => { - onSortedChange(tableSorting); - gotoPage(0); - }, [tableSorting]); - - useEffect(() => { - onRefreshData(); - }, [filters, pageIndex, pageSize, sorting]); + }; + return [...nonButtonColumns, singleButtonColumn]; + }, [JSON.stringify(columns)]); useEffect(() => { if (!isChangingDataOnServer && !errorMessage) { @@ -214,23 +180,16 @@ const DataFetchingTableComponent = memo( } else { initialiseTable(); } - gotoPage(0); - setSortBy(defaultSorting ?? []); // reset sorting when table is re-initialised }, [endpoint, JSON.stringify(baseFilter)]); - const onChangeFilters = newFilters => { - onFilteredChange(newFilters); - gotoPage(0); - }; + useEffect(() => { + onRefreshData(); + }, [filters, pageIndex, pageSize, JSON.stringify(sorting)]); const isLoading = isFetchingData || isChangingDataOnServer; - const displayFilterRow = visibleColumns.some(column => column.filterable !== false); - const { singular = 'record' } = resourceName; - const actionColumns = visibleColumns.filter(column => column.isButtonColumn); - return ( {errorMessage && {errorMessage}} @@ -244,119 +203,25 @@ const DataFetchingTableComponent = memo( No data to display )} - - - - {headerGroups.map(({ getHeaderGroupProps, headers }, index) => ( - // eslint-disable-next-line react/no-array-index-key - - {headers.map( - ( - { - getHeaderProps, - render, - isSorted, - isSortedDesc, - getSortByToggleProps, - canSort, - getResizerProps, - canResize, - isButtonColumn, - }, - i, - ) => { - // we will make a separate 'Actions' header - if (isButtonColumn) return null; - return ( - - {render('Header')} - {canSort && ( - - )} - - ); - }, - )} - {actionColumns.length > 0 && ( - 1 - ? actionColumns.reduce((acc, column) => acc + column.totalWidth, 0) - : 'auto' - } - isButtonColumn - > - {actionLabel} - - )} - - ))} - - {displayFilterRow && - visibleColumns.map(column => { - return ( - - ); - })} - - - - {rows.map((row, index) => { - prepareRow(row); - return ( - // eslint-disable-next-line react/no-array-index-key - - {row.cells.map(({ getCellProps, render }, i) => { - const col = visibleColumns[i]; - return ( - - {render('Cell')} - - ); - })} - - ); - })} - -
-
- column.show === false) + .map(column => column.source ?? column.type)} + onChangePage={onPageChange} + onChangePageSize={onPageSizeChange} + onChangeSorting={onSortedChange} + refreshData={onRefreshData} + errorMessage={errorMessage} totalRecords={totalRecords} /> @@ -387,24 +252,19 @@ DataFetchingTableComponent.propTypes = { isFetchingData: PropTypes.bool.isRequired, isChangingDataOnServer: PropTypes.bool.isRequired, numberOfPages: PropTypes.number, - TableComponent: PropTypes.elementType, onCancelAction: PropTypes.func.isRequired, onConfirmAction: PropTypes.func.isRequired, onFilteredChange: PropTypes.func.isRequired, onPageChange: PropTypes.func.isRequired, onPageSizeChange: PropTypes.func.isRequired, onRefreshData: PropTypes.func.isRequired, - onResizedChange: PropTypes.func.isRequired, onSortedChange: PropTypes.func.isRequired, initialiseTable: PropTypes.func.isRequired, pageIndex: PropTypes.number.isRequired, pageSize: PropTypes.number.isRequired, - reduxId: PropTypes.string.isRequired, - resizedColumns: PropTypes.arrayOf(PropTypes.shape({})).isRequired, sorting: PropTypes.array.isRequired, nestingLevel: PropTypes.number, deleteConfig: PropTypes.object, - actionColumns: PropTypes.arrayOf(PropTypes.shape({})), totalRecords: PropTypes.number, detailUrl: PropTypes.string, getHasNestedView: PropTypes.func, @@ -413,7 +273,6 @@ DataFetchingTableComponent.propTypes = { baseFilter: PropTypes.object, basePath: PropTypes.string, resourceName: PropTypes.object, - defaultSorting: PropTypes.array, actionLabel: PropTypes.string, }; @@ -424,8 +283,6 @@ DataFetchingTableComponent.defaultProps = { numberOfPages: 0, nestingLevel: 0, deleteConfig: {}, - TableComponent: undefined, - actionColumns: [], totalRecords: 0, detailUrl: '', getHasNestedView: null, @@ -433,7 +290,6 @@ DataFetchingTableComponent.defaultProps = { baseFilter: null, basePath: '', resourceName: {}, - defaultSorting: [], actionLabel: 'Action', }; @@ -453,7 +309,6 @@ const mapDispatchToProps = (dispatch, { reduxId }) => ({ dispatch(changePageSize(reduxId, newPageSize, newPageIndex)), onSortedChange: newSorting => dispatch(changeSorting(reduxId, newSorting)), onFilteredChange: newFilters => dispatch(changeFilters(reduxId, newFilters)), - onResizedChange: newResized => dispatch(changeResizedColumns(reduxId, newResized)), }); const mergeProps = (stateProps, { dispatch, ...dispatchProps }, ownProps) => { @@ -469,6 +324,7 @@ const mergeProps = (stateProps, { dispatch, ...dispatchProps }, ownProps) => { const onRefreshData = () => dispatch(refreshData(reduxId, endpoint, columns, baseFilter, stateProps)); const initialiseTable = (filters = defaultFilters) => { + dispatch(changePageSize(reduxId, 20, 0)); dispatch(changeSorting(reduxId, defaultSorting)); dispatch(changeFilters(reduxId, filters)); // will trigger a data fetch afterwards dispatch(clearError()); diff --git a/packages/admin-panel/src/table/DataFetchingTable/FilterCell.jsx b/packages/admin-panel/src/table/DataFetchingTable/FilterCell.jsx deleted file mode 100644 index c0ad5ad4f4..0000000000 --- a/packages/admin-panel/src/table/DataFetchingTable/FilterCell.jsx +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Tupaia - * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd - */ -import React from 'react'; -import PropTypes from 'prop-types'; -import styled from 'styled-components'; -import { DefaultFilter } from '../columnTypes/columnFilters'; -import { HeaderDisplayCell } from './Cells'; - -const FilterWrapper = styled.div` - .MuiFormControl-root { - margin-block-end: 0; - } - .MuiInputBase-input, - .MuiOutlinedInput-root { - font-size: inherit; - } - .MuiSvgIcon-root { - font-size: 1.25rem; - } - .MuiInputBase-input { - padding-block: 0.6rem; - padding-inline: 0.6rem; - line-height: 1.2; - } - .MuiAutocomplete-popperDisablePortal, - .MuiPaper-root, - .MuiAutocomplete-option, - .MuiAutocomplete-popperDisablePortal, - .MuiPaper-root, - .MuiAutocomplete-option { - font-size: inherit; - } - .MuiAutocomplete-listbox { - padding-block: 0.3rem; - } - .MuiAutocomplete-option { - padding-block: 0.5rem; - } -`; - -export const FilterCell = ({ column, filters, onFilteredChange, ...props }) => { - const { id, Filter } = column; - const existingFilter = filters?.find(f => f.id === id); - const handleUpdate = value => { - const updatedFilters = existingFilter - ? filters.map(f => (f.id === id ? { ...f, value } : f)) - : [...filters, { id, value }]; - - onFilteredChange(updatedFilters); - }; - if (!column.filterable) return ; - - return ( - - - {Filter ? ( - - ) : ( - handleUpdate(e.target.value)} - aria-label={`Search ${column.Header}`} - /> - )} - - - ); -}; - -FilterCell.propTypes = { - filters: PropTypes.arrayOf(PropTypes.shape({})).isRequired, - onFilteredChange: PropTypes.func.isRequired, - column: PropTypes.shape({ - id: PropTypes.string.isRequired, - Header: PropTypes.string.isRequired, - Filter: PropTypes.func, - filterable: PropTypes.bool, - }).isRequired, -}; diff --git a/packages/admin-panel/src/table/ExpansionContainer.jsx b/packages/admin-panel/src/table/ExpansionContainer.jsx deleted file mode 100644 index d8297896cf..0000000000 --- a/packages/admin-panel/src/table/ExpansionContainer.jsx +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Tupaia MediTrak - * Copyright (c) 2018 Beyond Essential Systems Pty Ltd - */ -import React from 'react'; -import styled from 'styled-components'; - -const Container = styled.div` - position: relative; - padding: 0 20px 20px; - - .ReactTable { - margin-top: 12px; - margin-bottom: 12px; - z-index: 1; - } - - .rt-thead.-header { - border-top: none; - border-left: none; - border-right: none; - } - - .rt-tr { - padding-right: 5px; - } -`; - -const SHADOW = 'rgba(0, 0, 0, 0.2)'; - -const Top = styled.div` - position: absolute; - top: -72px; - left: 0; - right: 0; - height: 8px; - box-shadow: 0 -6px 10px ${SHADOW}; -`; - -const Bottom = styled(Top)` - top: auto; - bottom: 0; - box-shadow: 0 6px 10px ${SHADOW}; -`; - -const Left = styled.div` - position: absolute; - left: 0; - bottom: 0; - top: -72px; - width: 8px; - content: ''; - box-shadow: -6px 0 10px ${SHADOW}; -`; - -const Right = styled(Left)` - right: 0; - left: auto; - content: ''; - box-shadow: 6px 0 10px ${SHADOW}; -`; - -// eslint-disable-next-line react/prop-types -export const ExpansionContainer = ({ children }) => ( - - - - - {children} - - -); diff --git a/packages/admin-panel/src/table/actions.js b/packages/admin-panel/src/table/actions.js index 6c3921f7e4..02604f2376 100644 --- a/packages/admin-panel/src/table/actions.js +++ b/packages/admin-panel/src/table/actions.js @@ -12,15 +12,12 @@ import { ACTION_CONFIRM, ACTION_REQUEST, CLEAR_ERROR, - COLUMNS_RESIZE, DATA_CHANGE_ERROR, DATA_CHANGE_REQUEST, DATA_CHANGE_SUCCESS, DATA_FETCH_ERROR, DATA_FETCH_REQUEST, DATA_FETCH_SUCCESS, - EXPANSIONS_CHANGE, - EXPANSIONS_TAB_CHANGE, FILTERS_CHANGE, PAGE_INDEX_CHANGE, PAGE_SIZE_CHANGE, @@ -42,31 +39,12 @@ export const changePageSize = (reduxId, pageSize, pageIndex) => ({ reduxId, }); -export const changeExpansions = (reduxId, expansions) => ({ - type: EXPANSIONS_CHANGE, - expansions, - reduxId, -}); - -export const changeExpansionsTab = (reduxId, rowId, tabValue) => ({ - type: EXPANSIONS_TAB_CHANGE, - reduxId, - rowId, - tabValue, -}); - export const changeFilters = (reduxId, filters) => ({ type: FILTERS_CHANGE, filters, reduxId, }); -export const changeResizedColumns = (reduxId, resizedColumns) => ({ - type: COLUMNS_RESIZE, - resizedColumns, - reduxId, -}); - export const changeSorting = (reduxId, sorting) => ({ type: SORTING_CHANGE, sorting, diff --git a/packages/admin-panel/src/table/constants.js b/packages/admin-panel/src/table/constants.js index 4c36037f98..198546cdcf 100644 --- a/packages/admin-panel/src/table/constants.js +++ b/packages/admin-panel/src/table/constants.js @@ -16,9 +16,6 @@ export const DATA_CHANGE_ERROR = 'DATA_CHANGE_ERROR'; export const PAGE_INDEX_CHANGE = 'PAGE_INDEX_CHANGE'; export const PAGE_SIZE_CHANGE = 'PAGE_SIZE_CHANGE'; export const FILTERS_CHANGE = 'FILTERS_CHANGE'; -export const EXPANSIONS_CHANGE = 'EXPANSIONS_CHANGE'; -export const EXPANSIONS_TAB_CHANGE = 'EXPANSIONS_TAB_CHANGE'; -export const COLUMNS_RESIZE = 'COLUMNS_RESIZE'; export const SORTING_CHANGE = 'SORTING_CHANGE'; export const CLEAR_ERROR = 'CLEAR_ERROR'; @@ -40,7 +37,4 @@ export const DEFAULT_TABLE_STATE = { pageSize: 20, filters: [], sorting: [], - expansions: {}, - expansionTabStates: {}, - resizedColumns: [], }; diff --git a/packages/admin-panel/src/table/reducer.js b/packages/admin-panel/src/table/reducer.js index ad2d9bbb1f..d7fbbb9f5a 100644 --- a/packages/admin-panel/src/table/reducer.js +++ b/packages/admin-panel/src/table/reducer.js @@ -16,9 +16,6 @@ import { PAGE_INDEX_CHANGE, PAGE_SIZE_CHANGE, FILTERS_CHANGE, - EXPANSIONS_CHANGE, - EXPANSIONS_TAB_CHANGE, - COLUMNS_RESIZE, SORTING_CHANGE, DATA_CHANGE_REQUEST, DEFAULT_TABLE_STATE, @@ -77,17 +74,12 @@ const stateChanges = { }), [FILTERS_CHANGE]: payload => ({ ...payload, + pageIndex: 0, }), - [EXPANSIONS_CHANGE]: payload => payload, - [EXPANSIONS_TAB_CHANGE]: ({ rowId, tabValue }, currentState) => ({ - expansionTabStates: { - ...currentState.expansionTabStates, - [rowId]: tabValue, - }, - }), - [COLUMNS_RESIZE]: payload => payload, + [SORTING_CHANGE]: payload => ({ ...payload, + pageIndex: 0, }), [CLEAR_ERROR]: () => ({ errorMessage: DEFAULT_TABLE_STATE.errorMessage, diff --git a/packages/ui-components/src/components/FilterableTable/Cells.tsx b/packages/ui-components/src/components/FilterableTable/Cells.tsx new file mode 100644 index 0000000000..a8c442048b --- /dev/null +++ b/packages/ui-components/src/components/FilterableTable/Cells.tsx @@ -0,0 +1,131 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import React, { ReactNode } from 'react'; +import styled from 'styled-components'; +import { TableCell as MuiTableCell } from '@material-ui/core'; +import { Link } from 'react-router-dom'; +import { TableResizerProps } from 'react-table'; + +const Cell = styled(MuiTableCell)<{ + $maxWidth?: number; +}>` + font-size: 0.75rem; + padding: 0; + border: none; + position: relative; + max-width: ${({ $maxWidth }) => ($maxWidth ? `${$maxWidth}px` : 'auto')}; + &:first-child { + padding-inline-start: 1.5rem; + } + &:last-child { + padding-inline-end: 1rem; + } +`; + +const CellContentWrapper = styled.div` + padding: 0.7rem; + height: 100%; + display: flex; + align-items: center; + + tr:not(:last-child) & { + border-bottom: 1px solid ${({ theme }) => theme.palette.grey[400]}; + } + td:first-child & { + padding-inline-start: 0.2rem; + } + + line-height: 1.5; +`; + +// Flex does not support ellipsis so we need to have another container to handle the ellipsis +const CellContentContainer = styled.div` + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + width: 100%; +`; + +const HeaderCell = styled(Cell)` + color: ${({ theme }) => theme.palette.text.secondary}; + font-weight: ${({ theme }) => theme.typography.fontWeightMedium}; + background-color: ${({ theme }) => theme.palette.background.paper}; + border-bottom: 1px solid ${({ theme }) => theme.palette.grey[400]}; + padding-block: 0.7rem; + padding-inline: 0.7rem 0; + display: flex; + position: initial; // override this because we have 2 sticky header rows so we will apply sticky to the thead element + background-color: ${({ theme }) => theme.palette.background.paper}; + .MuiTableSortLabel-icon { + opacity: 0.5; + } + .MuiTableSortLabel-active .MuiTableSortLabel-icon { + opacity: 1; + } +`; + +const CellLink = styled(Link)` + color: inherit; + text-decoration: none; + &:hover { + tr:has(&) td > * { + background-color: ${({ theme }) => `${theme.palette.primary.main}18`}; // 18 is 10% opacity + } + } +`; + +const ColResize = styled.div.attrs({ + onClick: (e: React.DragEvent) => { + // suppress other events when resizing + e.preventDefault(); + e.stopPropagation(); + }, +})` + width: 2rem; + height: 100%; + cursor: col-resize; +`; + +export interface HeaderDisplayCellProps { + children?: ReactNode; + canResize?: boolean; + getResizerProps?: (props?: Partial) => TableResizerProps; + maxWidth?: number; +} + +export const HeaderDisplayCell = ({ + children, + canResize, + getResizerProps = () => ({}), + maxWidth, + ...props +}: HeaderDisplayCellProps) => { + return ( + + {children} + {canResize && } + + ); +}; + +interface TableCellProps { + children: ReactNode; + width?: string; + row: Record; + maxWidth?: number; +} +export const TableCell = ({ children, width, row, maxWidth, ...props }: TableCellProps) => { + const url = row?.original?.url; + return ( + + + + {children} + + + + ); +}; diff --git a/packages/ui-components/src/components/FilterableTable/FilterCell.tsx b/packages/ui-components/src/components/FilterableTable/FilterCell.tsx new file mode 100644 index 0000000000..cbe6ee77b5 --- /dev/null +++ b/packages/ui-components/src/components/FilterableTable/FilterCell.tsx @@ -0,0 +1,113 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import React from 'react'; +import styled from 'styled-components'; +import { HeaderDisplayCell, HeaderDisplayCellProps } from './Cells'; +import { TextField } from '../Inputs'; +import { Search } from '@material-ui/icons'; +import { ColumnInstance } from 'react-table'; + +const FilterWrapper = styled.div` + .MuiFormControl-root { + margin-block-end: 0; + } + .MuiInputBase-input, + .MuiOutlinedInput-root { + font-size: inherit; + } + .MuiSvgIcon-root { + font-size: 1.25rem; + } + .MuiInputBase-input { + padding-block: 0.6rem; + padding-inline: 0.6rem; + line-height: 1.2; + } + .MuiAutocomplete-popperDisablePortal, + .MuiPaper-root, + .MuiAutocomplete-option, + .MuiAutocomplete-popperDisablePortal, + .MuiPaper-root, + .MuiAutocomplete-option { + font-size: inherit; + } + .MuiAutocomplete-listbox { + padding-block: 0.3rem; + } + .MuiAutocomplete-option { + padding-block: 0.5rem; + } +`; + +export const DefaultFilter = styled(TextField).attrs(props => ({ + InputProps: { + ...props.InputProps, + startAdornment: , + }, + placeholder: 'Search...', +}))` + margin-block-end: 0; + font-size: inherit; + width: 100%; + min-width: 6rem; + max-width: 100%; + // The following is overriding the padding in ui-components to make sure all filters match styling + .MuiInputBase-input, + .MuiInputBase-input.MuiAutocomplete-input.MuiInputBase-inputAdornedEnd { + padding-block: 0.6rem; + padding-inline: 0.2rem; + } + .MuiInputBase-root, + .MuiAutocomplete-inputRoot.MuiInputBase-adornedEnd.MuiOutlinedInput-adornedEnd { + padding-inline-start: 0.3rem; + } + .MuiSvgIcon-root { + color: ${({ theme }) => theme.palette.text.secondary}; + } +`; + +export type Filters = Record[]; + +export interface FilterCellProps extends Partial { + column: ColumnInstance> & { + Filter?: React.ComponentType<{ + column: ColumnInstance>; + filter?: any; + onChange: (value: any) => void; + }>; + filterable?: boolean; + }; + filters: Filters; + onChangeFilters: (filters: Filters) => void; +} + +export const FilterCell = ({ column, filters, onChangeFilters, ...props }: FilterCellProps) => { + const { id, Filter } = column; + const existingFilter = filters?.find(f => f.id === id); + const handleUpdate = (value: any) => { + const updatedFilters = existingFilter + ? filters.map(f => (f.id === id ? { ...f, value } : f)) + : [...filters, { id, value }]; + + onChangeFilters(updatedFilters); + }; + if (!column.filterable) return ; + + return ( + + + {Filter ? ( + + ) : ( + handleUpdate(e.target.value)} + aria-label={`Search ${column.Header}`} + /> + )} + + + ); +}; diff --git a/packages/ui-components/src/components/FilterableTable/FilterableTable.tsx b/packages/ui-components/src/components/FilterableTable/FilterableTable.tsx new file mode 100644 index 0000000000..901cef2cfc --- /dev/null +++ b/packages/ui-components/src/components/FilterableTable/FilterableTable.tsx @@ -0,0 +1,223 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import React, { useMemo } from 'react'; +import { + TableContainer as MuiTableContainer, + Table, + TableBody, + TableHead, + TableRow, + TableSortLabel, +} from '@material-ui/core'; +import styled from 'styled-components'; +import { Column, useFlexLayout, useResizeColumns, useTable, SortingRule } from 'react-table'; +import { KeyboardArrowDown } from '@material-ui/icons'; +import { HeaderDisplayCell, TableCell } from './Cells'; +import { FilterCell, FilterCellProps, Filters } from './FilterCell'; +import { Pagination } from './Pagination'; + +const TableContainer = styled(MuiTableContainer)` + position: relative; + flex: 1; + overflow: auto; + table { + min-width: 45rem; + } + // Because we want two header rows to be sticky, we need to set the position of the thead to sticky + thead { + position: sticky; + top: 0; + z-index: 2; + background-color: ${({ theme }) => theme.palette.background.paper}; + } + tr { + display: flex; + + .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline { + border-color: ${({ theme }) => theme.palette.primary.main}; + } +`; + +type SortBy = { + id: string; + desc: boolean; +}; + +interface FilterableTableProps { + columns: Column>[]; + data?: Record[]; + pageIndex?: number; + pageSize?: number; + sorting?: SortBy[]; + numberOfPages?: number; + hiddenColumns?: string[]; + onChangePage: (pageIndex: number) => void; + onChangePageSize: (pageSize: number) => void; + onChangeSorting: (sorting: SortingRule>[]) => void; + refreshData: () => void; + isLoading: boolean; + errorMessage: string; + onChangeFilters: FilterCellProps['onChangeFilters']; + filters?: Filters; + totalRecords: number; +} + +export const FilterableTable = ({ + columns, + data, + pageIndex = 0, + pageSize = 20, + sorting = [], + numberOfPages = 1, + hiddenColumns = [], + onChangePage, + onChangePageSize, + onChangeSorting, + onChangeFilters, + filters = [], + totalRecords, +}: FilterableTableProps) => { + const memoisedData = useMemo(() => data ?? [], [data]); + const { + getTableProps, + getTableBodyProps, + headerGroups, + prepareRow, + rows, + pageCount, + visibleColumns, + // Get the state from the instance + } = useTable( + { + columns, + data: memoisedData, + initialState: { + sortBy: sorting, + hiddenColumns, + }, + manualPagination: true, + pageCount: numberOfPages, + manualSortBy: true, + }, + useFlexLayout, + useResizeColumns, + ); + + const displayFilterRow = visibleColumns.some(column => column.filterable !== false); + + const updateSorting = (id: string) => { + const currentSorting = sorting.find(sort => sort.id === id); + const getNewSorting = () => { + // if the column is not sorted, add it to the sorting array, ascending first + if (!currentSorting) return [...sorting, { id, desc: false }]; + // If the column is sorted descending, remove it from the sorting array, so it can have an 'off' state + if (currentSorting.desc) return sorting.filter(sort => sort.id !== id); + // If the column is sorted ascending, toggle it to descending + return sorting.map(sort => (sort.id === id ? { ...sort, desc: !sort.desc } : sort)); + }; + + const newSorting = getNewSorting(); + + onChangeSorting(newSorting); + }; + + const getSortedConfig = (id: string) => { + return sorting.find(sort => sort.id === id); + }; + + return ( + <> + + + + {headerGroups.map(({ getHeaderGroupProps, headers }, index) => ( + // eslint-disable-next-line react/no-array-index-key + + {headers.map( + ({ + getHeaderProps, + render, + disableSortBy, + getResizerProps, + canResize, + maxWidth, + id, + }) => { + const sortedConfig = getSortedConfig(id); + return ( + + {render('Header')} + {!disableSortBy && ( + updateSorting(id)} + /> + )} + + ); + }, + )} + + ))} + {displayFilterRow && ( + + {visibleColumns.map(column => { + return ( + + ); + })} + + )} + + + {rows.map(row => { + prepareRow(row); + return ( + + {row.cells.map(({ getCellProps, render }, i) => { + const col = visibleColumns[i]; + return ( + + {render('Cell')} + + ); + })} + + ); + })} + +
+
+ + + ); +}; diff --git a/packages/admin-panel/src/table/DataFetchingTable/Pagination.jsx b/packages/ui-components/src/components/FilterableTable/Pagination.tsx similarity index 51% rename from packages/admin-panel/src/table/DataFetchingTable/Pagination.jsx rename to packages/ui-components/src/components/FilterableTable/Pagination.tsx index 869d73930a..4018e8c36d 100644 --- a/packages/admin-panel/src/table/DataFetchingTable/Pagination.jsx +++ b/packages/ui-components/src/components/FilterableTable/Pagination.tsx @@ -3,9 +3,8 @@ * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ import React from 'react'; -import PropTypes from 'prop-types'; import styled from 'styled-components'; -import { Pagination as UIPagination } from '@tupaia/ui-components'; +import { Pagination as UIPagination } from '../Pagination'; const Wrapper = styled.div` .pagination-wrapper { @@ -18,31 +17,35 @@ const Wrapper = styled.div` } `; -export const Pagination = ({ page, pageCount, gotoPage, pageSize, setPageSize, totalRecords }) => { +interface PaginationProps { + page: number; + pageCount: number; + onChangePage: (page: number) => void; + pageSize: number; + onChangePageSize: (pageSize: number) => void; + totalRecords: number; +} + +export const Pagination = ({ + page, + pageCount, + onChangePage, + pageSize, + onChangePageSize, + totalRecords, +}: PaginationProps) => { if (!totalRecords) return null; - const handleChangePage = newPage => { - gotoPage(newPage); - }; return ( ); }; - -Pagination.propTypes = { - page: PropTypes.number.isRequired, - pageCount: PropTypes.number.isRequired, - gotoPage: PropTypes.func.isRequired, - pageSize: PropTypes.number.isRequired, - setPageSize: PropTypes.func.isRequired, - totalRecords: PropTypes.number.isRequired, -}; diff --git a/packages/ui-components/src/components/FilterableTable/index.ts b/packages/ui-components/src/components/FilterableTable/index.ts new file mode 100644 index 0000000000..46e0977b1c --- /dev/null +++ b/packages/ui-components/src/components/FilterableTable/index.ts @@ -0,0 +1,6 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +export { FilterableTable } from './FilterableTable'; diff --git a/packages/ui-components/src/components/index.ts b/packages/ui-components/src/components/index.ts index 5173f316ec..a325f03fb1 100644 --- a/packages/ui-components/src/components/index.ts +++ b/packages/ui-components/src/components/index.ts @@ -23,6 +23,7 @@ export * from './EnvBanner'; export * from './ErrorBoundary'; export * from './FavouriteButton'; export * from './FetchLoader'; +export * from './FilterableTable'; export * from './HomeButton'; export * from './HorizontalTree'; export * from './IconButton'; diff --git a/packages/ui-components/src/types/react-table-config.d.ts b/packages/ui-components/src/types/react-table-config.d.ts index f35c1b6256..d739fad5e7 100644 --- a/packages/ui-components/src/types/react-table-config.d.ts +++ b/packages/ui-components/src/types/react-table-config.d.ts @@ -3,18 +3,31 @@ import { UseSortByColumnProps, UseSortByOptions, UseSortByState, + UseTableRowProps as BaseUseTableRowProps, } from 'react-table'; +type RowT = Record & { + url?: string; +}; + declare module 'react-table' { - export interface TableOptions + export interface TableOptions> extends UseExpandedOptions, UseSortByOptions, + UsePaginationOptions, // note that having Record here allows you to add anything to the options, this matches the spirit of the // underlying js library, but might be cleaner if it's replaced by a more specific type that matches your // feature set, this is a safe default. Record {} - export interface TableState extends UseSortByState {} + export interface TableState> + extends UseSortByState, + UsePaginationState {} - export interface ColumnInstance extends UseSortByColumnProps {} + export interface ColumnInstance> + extends UseSortByColumnProps, + UseResizeColumnsColumnProps { + filterable?: boolean; + disableSortBy?: boolean; + } } From ecec730ab5d813265e109b3c3d9d25d13f6fdfc7 Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Wed, 26 Jun 2024 13:45:35 +1200 Subject: [PATCH 5/9] feat(adminPanel): RN-1190: Update countries input type to be checkboxes (#5654) * WIP * WIP * Styling * Connected editor * WIP * WIP * WIP * Add buttons * Working edit view * Required styling * Update EditSurveyPage.jsx * Change loading method * File input styling * Reset editor state on close * Required styling * Fix build error * Add tests * Update EditSurveyPage.jsx * Update packages/admin-panel/src/editor/FieldsEditor.jsx Co-authored-by: Jasper Lai <33956381+jaskfla@users.noreply.github.com> * Update packages/admin-panel/src/importExport/ImportModal.jsx Co-authored-by: Jasper Lai <33956381+jaskfla@users.noreply.github.com> * PR fixes * reset file value on clear edits * PR fixes * Sentence casing * Survey question format * Validate fields when editing and creating a survey * Scroll to error on error message appearing * Add aria-hidden for when there is no message to show * Testing fixes * Remove ability to clear edits * Styling * Fix data tables editor not opening * WIP * FIx checkbox tooltip styles * Fix edit landing page issue * Styling * WIP * Working * Fix incorrect typing * Fix colours * Update proptypes * fix endpoint * Apply checkbox list to permissions * Ad margin to beneath selector * Change projects to use names * Add no results message --------- Co-authored-by: Jasper Lai <33956381+jaskfla@users.noreply.github.com> Co-authored-by: Andrew --- .../admin-panel/src/editor/FieldsEditor.jsx | 9 +- .../src/routes/projects/projects.js | 7 +- .../admin-panel/src/routes/surveys/surveys.js | 5 +- .../src/routes/users/permissions.js | 34 +-- .../admin-panel/src/routes/users/users.jsx | 4 +- packages/admin-panel/src/theme/theme.js | 13 +- .../src/widgets/Checkbox/Checkbox.jsx | 15 ++ .../widgets/Checkbox/CheckboxCheckedIcon.jsx | 28 ++ .../Checkbox/CheckboxUncheckedIcon.jsx | 24 ++ .../admin-panel/src/widgets/Checkbox/index.js | 6 + .../widgets/InputField/CheckboxListField.jsx | 245 ++++++++++++++++++ .../src/widgets/InputField/InputField.jsx | 2 +- .../InputField/registerInputFields.jsx | 25 +- .../src/components/Inputs/InputLabel.tsx | 4 +- 14 files changed, 387 insertions(+), 34 deletions(-) create mode 100644 packages/admin-panel/src/widgets/Checkbox/Checkbox.jsx create mode 100644 packages/admin-panel/src/widgets/Checkbox/CheckboxCheckedIcon.jsx create mode 100644 packages/admin-panel/src/widgets/Checkbox/CheckboxUncheckedIcon.jsx create mode 100644 packages/admin-panel/src/widgets/Checkbox/index.js create mode 100644 packages/admin-panel/src/widgets/InputField/CheckboxListField.jsx diff --git a/packages/admin-panel/src/editor/FieldsEditor.jsx b/packages/admin-panel/src/editor/FieldsEditor.jsx index 5d92aa62bb..c60d0c5ba8 100644 --- a/packages/admin-panel/src/editor/FieldsEditor.jsx +++ b/packages/admin-panel/src/editor/FieldsEditor.jsx @@ -49,6 +49,9 @@ export const FieldsEditor = ({ fields, recordData, onEditField, onSetFormFile }) if (accessor) { return accessor(recordData); } + if (editConfig.sourceKey) { + return recordData[editConfig.sourceKey]; + } return recordData[source]; }; @@ -123,11 +126,7 @@ export const FieldsEditor = ({ fields, recordData, onEditField, onSetFormFile }) return [...result, field]; }, []); - return ( - - {visibleFormItems.map(getFieldInput)} - - ); + return {visibleFormItems.map(getFieldInput)}; }; FieldsEditor.propTypes = { diff --git a/packages/admin-panel/src/routes/projects/projects.js b/packages/admin-panel/src/routes/projects/projects.js index feb653beee..827144f03c 100644 --- a/packages/admin-panel/src/routes/projects/projects.js +++ b/packages/admin-panel/src/routes/projects/projects.js @@ -112,16 +112,17 @@ const NEW_PROJECT_COLUMNS = [ }, ...CREATE_FIELDS, { - Header: 'Country code(s)', + Header: 'Countries', source: 'country.code', Filter: ArrayFilter, Cell: ({ value }) => prettyArray(value), editConfig: { optionsEndpoint: 'countries', - optionLabelKey: 'country.code', + optionLabelKey: 'country.name', optionValueKey: 'country.id', sourceKey: 'countries', - allowMultipleValues: true, + pageSize: 'ALL', + type: 'checkboxList', }, }, { diff --git a/packages/admin-panel/src/routes/surveys/surveys.js b/packages/admin-panel/src/routes/surveys/surveys.js index 6f7886fb4b..e3d8b50b0c 100644 --- a/packages/admin-panel/src/routes/surveys/surveys.js +++ b/packages/admin-panel/src/routes/surveys/surveys.js @@ -54,14 +54,15 @@ const SURVEY_FIELDS = { country_ids: { Header: 'Countries', source: 'countryNames', // TODO: cleanup as part of RN-910 + required: true, editConfig: { + type: 'checkboxList', sourceKey: 'countryNames', optionsEndpoint: 'countries', optionLabelKey: 'name', optionValueKey: 'name', - allowMultipleValues: true, labelTooltip: 'Select the countries this survey should be available in', - required: true, + pageSize: 'ALL', }, }, permission_group_id: { diff --git a/packages/admin-panel/src/routes/users/permissions.js b/packages/admin-panel/src/routes/users/permissions.js index 02bd3d7d18..a4a27aaf07 100644 --- a/packages/admin-panel/src/routes/users/permissions.js +++ b/packages/admin-panel/src/routes/users/permissions.js @@ -7,16 +7,24 @@ import { getPluralForm } from '../../pages/resources/resourceName'; const RESOURCE_NAME = { singular: 'permission' }; +const EntityField = { + Header: 'Entity', + source: 'entity.name', + editConfig: { + optionsEndpoint: 'entities', + type: 'checkboxList', + baseFilter: { type: 'country' }, + optionLabelKey: 'entity.name', + optionValueKey: 'entity.id', + allowMultipleValues: true, + labelTooltip: 'Select the countries for which this permission applies', + pageSize: 'ALL', + sourceKey: 'entity_id', + }, +}; export const PERMISSIONS_ENDPOINT = 'userEntityPermissions'; export const PERMISSIONS_COLUMNS = [ - { - Header: 'Entity', - source: 'entity.name', - editConfig: { - optionsEndpoint: 'entities', - baseFilter: { type: 'country' }, - }, - }, + EntityField, { Header: 'Permission group', source: 'permission_group.name', @@ -77,15 +85,7 @@ const CREATE_CONFIG = { allowMultipleValues: true, }, }, - { - Header: 'Entity', - source: 'entity.name', - editConfig: { - optionsEndpoint: 'entities', - baseFilter: { type: 'country' }, - allowMultipleValues: true, - }, - }, + EntityField, { Header: 'Permission group', source: 'permission_group.name', diff --git a/packages/admin-panel/src/routes/users/users.jsx b/packages/admin-panel/src/routes/users/users.jsx index 20e5da65d7..de5ea01bc9 100644 --- a/packages/admin-panel/src/routes/users/users.jsx +++ b/packages/admin-panel/src/routes/users/users.jsx @@ -134,7 +134,9 @@ const CREATE_CONFIG = { optionsEndpoint: 'countries', optionLabelKey: 'name', optionValueKey: 'name', - secondaryLabel: 'Select the country to grant this user access to', + labelTooltip: 'Select the country to grant this user access to', + type: 'checkboxList', + pageSize: 'ALL', }, }, { diff --git a/packages/admin-panel/src/theme/theme.js b/packages/admin-panel/src/theme/theme.js index 9cd4c7627b..2e9f25a92c 100644 --- a/packages/admin-panel/src/theme/theme.js +++ b/packages/admin-panel/src/theme/theme.js @@ -47,7 +47,7 @@ const palette = { 300: COLORS.GREY_E2, 400: COLORS.GREY_DE, 500: COLORS.GREY_9F, - 600: COLORS.GREY_72, + 600: COLORS.GREY_B8, }, background: { default: COLORS.LIGHTGREY, @@ -180,8 +180,15 @@ const overrides = { '@global': { label: { fontWeight: 500, - '& .MuiSvgIcon-root': { - color: palette.text.secondary, // tooltip icon color + }, + }, + }, + MuiSvgIcon: { + root: { + 'label &': { + color: palette.text.secondary, + '&.checkbox': { + fill: 'transparent', }, }, }, diff --git a/packages/admin-panel/src/widgets/Checkbox/Checkbox.jsx b/packages/admin-panel/src/widgets/Checkbox/Checkbox.jsx new file mode 100644 index 0000000000..10d1a6a0a1 --- /dev/null +++ b/packages/admin-panel/src/widgets/Checkbox/Checkbox.jsx @@ -0,0 +1,15 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import React from 'react'; +import styled from 'styled-components'; +import MuiCheckbox from '@material-ui/core/Checkbox'; +import { CheckboxCheckedIcon } from './CheckboxCheckedIcon'; +import { CheckboxUncheckedIcon } from './CheckboxUncheckedIcon'; + +export const Checkbox = styled(MuiCheckbox).attrs(props => ({ + icon: , + checkedIcon: , +}))``; diff --git a/packages/admin-panel/src/widgets/Checkbox/CheckboxCheckedIcon.jsx b/packages/admin-panel/src/widgets/Checkbox/CheckboxCheckedIcon.jsx new file mode 100644 index 0000000000..7c1d0a4749 --- /dev/null +++ b/packages/admin-panel/src/widgets/Checkbox/CheckboxCheckedIcon.jsx @@ -0,0 +1,28 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import React from 'react'; +import { SvgIcon, useTheme } from '@material-ui/core'; + +export const CheckboxCheckedIcon = props => { + const theme = useTheme(); + return ( + + + + + + ); +}; diff --git a/packages/admin-panel/src/widgets/Checkbox/CheckboxUncheckedIcon.jsx b/packages/admin-panel/src/widgets/Checkbox/CheckboxUncheckedIcon.jsx new file mode 100644 index 0000000000..b11e5a0bd4 --- /dev/null +++ b/packages/admin-panel/src/widgets/Checkbox/CheckboxUncheckedIcon.jsx @@ -0,0 +1,24 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import React from 'react'; +import { SvgIcon } from '@material-ui/core'; +import { useTheme } from 'styled-components'; + +export const CheckboxUncheckedIcon = props => { + const theme = useTheme(); + return ( + + + + ); +}; diff --git a/packages/admin-panel/src/widgets/Checkbox/index.js b/packages/admin-panel/src/widgets/Checkbox/index.js new file mode 100644 index 0000000000..5704832350 --- /dev/null +++ b/packages/admin-panel/src/widgets/Checkbox/index.js @@ -0,0 +1,6 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +export { Checkbox } from './Checkbox'; diff --git a/packages/admin-panel/src/widgets/InputField/CheckboxListField.jsx b/packages/admin-panel/src/widgets/InputField/CheckboxListField.jsx new file mode 100644 index 0000000000..882116b822 --- /dev/null +++ b/packages/admin-panel/src/widgets/InputField/CheckboxListField.jsx @@ -0,0 +1,245 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import React, { useMemo, useState } from 'react'; +import PropTypes from 'prop-types'; +import { useQuery } from 'react-query'; +import styled from 'styled-components'; +import { + FormControl as MuiFormControl, + FormControlLabel, + FormGroup as MuiFormGroup, + FormLabel, + TextField, + Typography, +} from '@material-ui/core'; +import { Search } from '@material-ui/icons'; +import { InputLabel } from '@tupaia/ui-components'; +import { get } from '../../VizBuilderApp/api/api'; +import { Checkbox } from '../Checkbox'; +import { convertSearchTermToFilter, useDebounce } from '../../utilities'; + +const useOptions = ( + endpoint, + baseFilter, + searchTerm, + labelColumn, + valueColumn, + distinct = null, + pageSize, +) => { + return useQuery( + ['options', endpoint, baseFilter, searchTerm, labelColumn, valueColumn, distinct, pageSize], + async () => { + const filter = convertSearchTermToFilter({ ...baseFilter, [labelColumn]: searchTerm }); + return get(endpoint, { + params: { + filter: JSON.stringify(filter), + sort: JSON.stringify([`${labelColumn} ASC`]), + columns: JSON.stringify([labelColumn, valueColumn]), + pageSize, + distinct, + }, + }); + }, + { enabled: !!endpoint }, + ); +}; + +const Container = styled.div` + height: 100%; + max-height: 15rem; + overflow-y: auto; + border: 1px solid ${({ theme }) => theme.palette.grey['400']}; + padding-inline: 1rem; + padding-block-start: 0.2rem; + border-radius: 4px; + margin-block-start: 0.5rem; + margin-block-end: 1.2rem; +`; + +const FormGroup = styled(MuiFormGroup)` + flex: 1; +`; + +const Legend = styled(FormLabel).attrs({ + component: 'legend', +})` + &.Mui-focused { + color: ${({ theme }) => theme.palette.text.primary}; + } +`; + +const FormControl = styled(MuiFormControl)` + width: 100%; + .MuiSvgIcon-fontSizeSmall { + font-size: 1rem; + } +`; + +const SearchField = styled(TextField).attrs({ + fullWidth: true, + placeholder: 'Search', + color: 'primary', + ariaLabel: 'Search', + InputProps: { + startAdornment: , + }, +})` + margin-block-end: 0.5rem; + font-size: 0.875rem; + .MuiSvgIcon-root { + font-size: 1rem; + color: ${({ theme }) => theme.palette.grey['600']}; + } + .MuiInputBase-input { + padding-block: 0.6rem; + padding-inline-start: 0.2rem; + } + .MuiInput-underline { + &:before { + border-bottom: 1px solid ${({ theme }) => theme.palette.grey['400']}; + } + &:hover { + &:before { + border-color: ${({ theme }) => theme.palette.text.secondary}; + border-width: 1px; + } + } + &:focus-visible { + outline: none; + &:after { + border-width: 1px; + } + } + } +`; + +// this wraps the legend and any tooltip +const LegendWrapper = styled.div` + display: flex; +`; + +const NoResults = styled(Typography)` + margin-block-end: 0.5rem; +`; + +export const CheckboxListField = ({ + endpoint, + baseFilter, + optionLabelKey, + optionValueKey, + label, + required, + distinct = null, + pageSize = 10, + value, + onChange, + tooltip, + error, +}) => { + const [searchTerm, setSearchTerm] = useState(''); + const debouncedSearchTerm = useDebounce(searchTerm, 300); + + const { data: options, isLoading } = useOptions( + endpoint, + baseFilter, + debouncedSearchTerm, + optionLabelKey, + optionValueKey, + distinct, + pageSize, + ); + + const selectedValue = value || []; + + const handleOnChangeValue = checkedValue => { + let updatedValue = [...selectedValue]; + if (selectedValue.includes(checkedValue)) { + updatedValue = selectedValue.filter(v => v !== checkedValue); + } else { + updatedValue = [...selectedValue, checkedValue]; + } + onChange(updatedValue); + }; + + const optionsList = useMemo(() => { + return ( + options?.map(option => ({ + ...option, + checked: selectedValue?.includes(option[optionValueKey]), + value: option[optionValueKey], + label: option[optionLabelKey], + })) ?? [] + ); + }, [options, selectedValue]); + + return ( + + + + + + + setSearchTerm(event.target.value)} /> + + {isLoading &&
Loading...
} + {!optionsList?.length && !isLoading && No results found} + + {optionsList?.map(option => ( + handleOnChangeValue(option.value)} + checked={option.checked === true} + /> + } + label={option[optionLabelKey]} + /> + ))} +
+
+
+ ); +}; + +CheckboxListField.propTypes = { + endpoint: PropTypes.string.isRequired, + baseFilter: PropTypes.object, + optionLabelKey: PropTypes.string.isRequired, + optionValueKey: PropTypes.string.isRequired, + distinct: PropTypes.bool, + label: PropTypes.string.isRequired, + required: PropTypes.bool, + pageSize: PropTypes.number, + value: PropTypes.array, + onChange: PropTypes.func.isRequired, + tooltip: PropTypes.string, + error: PropTypes.bool, +}; + +CheckboxListField.defaultProps = { + baseFilter: {}, + distinct: null, + required: false, + pageSize: 10, + value: [], + tooltip: null, + error: false, +}; diff --git a/packages/admin-panel/src/widgets/InputField/InputField.jsx b/packages/admin-panel/src/widgets/InputField/InputField.jsx index b3101b3636..0f25efd201 100644 --- a/packages/admin-panel/src/widgets/InputField/InputField.jsx +++ b/packages/admin-panel/src/widgets/InputField/InputField.jsx @@ -16,7 +16,7 @@ const getInputType = ({ options, optionsEndpoint, type }) => { if (options && type !== 'radio') { return 'enum'; } - if (optionsEndpoint) { + if (optionsEndpoint && type !== 'checkboxList') { return 'autocomplete'; } return type; diff --git a/packages/admin-panel/src/widgets/InputField/registerInputFields.jsx b/packages/admin-panel/src/widgets/InputField/registerInputFields.jsx index f5acfbf893..764ba2a5f1 100644 --- a/packages/admin-panel/src/widgets/InputField/registerInputFields.jsx +++ b/packages/admin-panel/src/widgets/InputField/registerInputFields.jsx @@ -25,6 +25,9 @@ import { ReduxAutocomplete } from '../../autocomplete'; import { JsonInputField } from './JsonInputField'; import { JsonEditor } from './JsonEditor'; import { FileUploadField } from './FileUploadField'; +import { CheckboxListField } from './CheckboxListField'; +import { CheckboxUncheckedIcon } from '../Checkbox/CheckboxUncheckedIcon'; +import { CheckboxCheckedIcon } from '../Checkbox/CheckboxCheckedIcon'; // "InputField" is treated as a dynamic factory, where different input types can be supported // depending on what is injected at runtime. This is the standard set of injections, which is the @@ -91,6 +94,25 @@ export const registerInputFields = () => { required={props.required} /> )); + registerInputField('checkboxList', props => ( + props.onChange(props.inputKey, inputValue)} + disabled={props.disabled} + baseFilter={props.baseFilter} + pageSize={props.pageSize} + tooltip={props.labelTooltip} + distinct={props.distinct} + /> + )); registerInputField('json', props => ( { helperText={props.secondaryLabel} tooltip={props.labelTooltip} required={props.required} - color="secondary" + icon={} + checkedIcon={} /> )); diff --git a/packages/ui-components/src/components/Inputs/InputLabel.tsx b/packages/ui-components/src/components/Inputs/InputLabel.tsx index ff17ba1b10..3904faf2c1 100644 --- a/packages/ui-components/src/components/Inputs/InputLabel.tsx +++ b/packages/ui-components/src/components/Inputs/InputLabel.tsx @@ -55,6 +55,7 @@ interface InputLabelProps { className?: string; htmlFor?: string; TooltipIcon?: ComponentType; + labelProps?: Record; } export const InputLabel = ({ @@ -64,13 +65,14 @@ export const InputLabel = ({ className, htmlFor, TooltipIcon = HelpOutline, + labelProps = {}, }: InputLabelProps) => { // If no label, don't render anything, so there isn't an empty label tag in the DOM if (!label) return null; return ( // allows us to pass in a custom element to render as, e.g. a span if it is going to be contained in a label element, for example when using MUI's TextField component. Otherwise defaults to a label element so that it can be a standalone label <> - + {label} {tooltip && ( From cfe8d1a41fcbd70f8d7c307cb82456489007b03c Mon Sep 17 00:00:00 2001 From: Jasper Lai <33956381+jaskfla@users.noreply.github.com> Date: Thu, 27 Jun 2024 09:47:26 +1200 Subject: [PATCH 6/9] fix(adminPanel): RN-1273: child codes in drilled-down map overlay group relations (#5743) * use child codes in map overlay group drilldown * remove old property * use sentence case * dedupe `FIELDS` * de-genericise edit modal title in drilldown * show noneditable parent map overlay group * same deduping refactor for Dashboards page --- .../visualisations/dashboardRelations.js | 28 ++-- .../src/routes/visualisations/dashboards.js | 45 +----- .../mapOverlayGroupRelations.js | 132 +++++++++--------- .../routes/visualisations/mapOverlayGroups.js | 58 +++----- 4 files changed, 107 insertions(+), 156 deletions(-) diff --git a/packages/admin-panel/src/routes/visualisations/dashboardRelations.js b/packages/admin-panel/src/routes/visualisations/dashboardRelations.js index f11ec7dbd8..d92835d207 100644 --- a/packages/admin-panel/src/routes/visualisations/dashboardRelations.js +++ b/packages/admin-panel/src/routes/visualisations/dashboardRelations.js @@ -8,10 +8,10 @@ import { prettyArray } from '../../utilities'; export const RESOURCE_NAME = { singular: 'dashboard relation' }; -// export for use on users page export const DASHBOARD_RELATION_ENDPOINT = 'dashboardRelations'; -export const DASHBOARD_RELATION_COLUMNS = [ - { + +export const FIELDS = { + DASHBOARD_CODE: { Header: 'Dashboard code', source: 'dashboard.code', editConfig: { @@ -21,7 +21,7 @@ export const DASHBOARD_RELATION_COLUMNS = [ sourceKey: 'dashboard_id', }, }, - { + DASHBOARD_ITEM_CODE: { Header: 'Dashboard item code', source: 'dashboard_item.code', editConfig: { @@ -31,7 +31,7 @@ export const DASHBOARD_RELATION_COLUMNS = [ sourceKey: 'child_id', }, }, - { + PERMISSION_GROUPS: { Header: 'Permission groups', source: 'permission_groups', Filter: ArrayFilter, @@ -44,7 +44,7 @@ export const DASHBOARD_RELATION_COLUMNS = [ allowMultipleValues: true, }, }, - { + ENTITY_TYPES: { Header: 'Entity types', source: 'entity_types', Filter: ArrayFilter, @@ -58,13 +58,13 @@ export const DASHBOARD_RELATION_COLUMNS = [ secondaryLabel: 'Input the entity types you want. e.g: ‘country’, ‘sub_district’', }, }, - { + ATTRIBUTES_FILTER: { Header: 'Attributes filter', source: 'attributes_filter', type: 'jsonTooltip', editConfig: { type: 'jsonEditor' }, }, - { + PROJECT_CODES: { Header: 'Project codes', source: 'project_codes', Filter: ArrayFilter, @@ -77,10 +77,20 @@ export const DASHBOARD_RELATION_COLUMNS = [ allowMultipleValues: true, }, }, - { + SORT_ORDER: { Header: 'Sort order', source: 'sort_order', }, +}; + +export const DASHBOARD_RELATION_COLUMNS = [ + FIELDS.DASHBOARD_CODE, + FIELDS.DASHBOARD_ITEM_CODE, + FIELDS.PERMISSION_GROUPS, + FIELDS.ENTITY_TYPES, + FIELDS.ATTRIBUTES_FILTER, + FIELDS.PROJECT_CODES, + FIELDS.SORT_ORDER, ]; const COLUMNS = [ diff --git a/packages/admin-panel/src/routes/visualisations/dashboards.js b/packages/admin-panel/src/routes/visualisations/dashboards.js index 3c06692108..6c8a00dc85 100644 --- a/packages/admin-panel/src/routes/visualisations/dashboards.js +++ b/packages/admin-panel/src/routes/visualisations/dashboards.js @@ -5,6 +5,7 @@ import { DASHBOARD_RELATION_ENDPOINT, + FIELDS as DASHBOARD_RELATION_FIELDS, RESOURCE_NAME as DASHBOARD_RELATION_RESOURCE_NAME, } from './dashboardRelations'; @@ -64,44 +65,10 @@ const RELATION_FIELDS = [ source: 'dashboard_item.code', editable: false, }, - { - Header: 'Permission groups', - source: 'permission_groups', - editConfig: { - optionsEndpoint: 'permissionGroups', - optionLabelKey: 'name', - optionValueKey: 'name', - sourceKey: 'permission_groups', - allowMultipleValues: true, - }, - }, - { - Header: 'Entity types', - source: 'entity_types', - editConfig: { - type: 'autocomplete', - allowMultipleValues: true, - canCreateNewOptions: true, - optionLabelKey: 'entityTypes', - optionValueKey: 'entityTypes', - secondaryLabel: 'Input the entity types you want. e.g. ‘country’, ‘sub_district’', - }, - }, - { - Header: 'Project codes', - source: 'project_codes', - editConfig: { - optionsEndpoint: 'projects', - optionLabelKey: 'code', - optionValueKey: 'code', - sourceKey: 'project_codes', - allowMultipleValues: true, - }, - }, - { - Header: 'Sort order', - source: 'sort_order', - }, + DASHBOARD_RELATION_FIELDS.PERMISSION_GROUPS, + DASHBOARD_RELATION_FIELDS.ENTITY_TYPES, + DASHBOARD_RELATION_FIELDS.PROJECT_CODES, + DASHBOARD_RELATION_FIELDS.SORT_ORDER, ]; const RELATION_COLUMNS = [ @@ -111,7 +78,7 @@ const RELATION_COLUMNS = [ type: 'edit', source: 'id', actionConfig: { - editEndpoint: 'dashboardRelations', + editEndpoint: DASHBOARD_RELATION_ENDPOINT, fields: RELATION_FIELDS, }, }, diff --git a/packages/admin-panel/src/routes/visualisations/mapOverlayGroupRelations.js b/packages/admin-panel/src/routes/visualisations/mapOverlayGroupRelations.js index e1e3175563..4a4c730436 100644 --- a/packages/admin-panel/src/routes/visualisations/mapOverlayGroupRelations.js +++ b/packages/admin-panel/src/routes/visualisations/mapOverlayGroupRelations.js @@ -7,84 +7,80 @@ const RESOURCE_NAME = { singular: 'map overlay group relation' }; export const RELATION_ENDPOINT = 'mapOverlayGroupRelations'; -const mapOverlayGroupCode = { - Header: 'Map overlay group code', - source: 'map_overlay_group.code', - editConfig: { - optionsEndpoint: 'mapOverlayGroups', - optionLabelKey: 'map_overlay_group.code', - optionValueKey: 'map_overlay_group.id', - sourceKey: 'map_overlay_group_id', +export const FIELDS = { + MAP_OVERLAY_GROUP_CODE: { + Header: 'Map overlay group code', + source: 'map_overlay_group.code', + editConfig: { + optionsEndpoint: 'mapOverlayGroups', + optionLabelKey: 'map_overlay_group.code', + optionValueKey: 'map_overlay_group.id', + sourceKey: 'map_overlay_group_id', + }, }, -}; - -const childType = { - Header: 'Child type', - width: 160, - source: 'child_type', - editConfig: { - options: [ - { - label: 'Map overlay', - value: 'mapOverlay', - }, - { - label: 'Map overlay group', - value: 'mapOverlayGroup', - }, - ], + CHILD_TYPE: { + Header: 'Child type', + source: 'child_type', + editConfig: { + options: [ + { + label: 'Map overlay', + value: 'mapOverlay', + }, + { + label: 'Map overlay group', + value: 'mapOverlayGroup', + }, + ], + }, }, -}; - -const childCode = { - Header: 'Child Code', - source: 'child_code', -}; - -const childMapOverlayCode = { - Header: 'Child map overlay code', - id: 'child_map_overlay_code', - source: 'child_code', - editConfig: { - optionsEndpoint: 'mapOverlays', - optionLabelKey: 'mapOverlay.code', - optionValueKey: 'mapOverlay.id', - sourceKey: 'child_id', - visibilityCriteria: { child_type: 'mapOverlay' }, + CHILD_CODE: { + Header: 'Child code', + source: 'child_code', }, -}; - -const childMapOverlayGroupCode = { - Header: 'Child map overlay group code', - id: 'child_map_overlay_group_code', - source: 'child_code', - editConfig: { - optionsEndpoint: 'mapOverlayGroups', - optionLabelKey: 'mapOverlayGroups.code', - optionValueKey: 'mapOverlayGroups.id', - sourceKey: 'child_id', - visibilityCriteria: { child_type: 'mapOverlayGroup' }, + CHILD_MAP_OVERLAY_CODE: { + Header: 'Child map overlay code', + id: 'child_map_overlay_code', + source: 'child_code', + editConfig: { + optionsEndpoint: 'mapOverlays', + optionLabelKey: 'mapOverlay.code', + optionValueKey: 'mapOverlay.id', + sourceKey: 'child_id', + visibilityCriteria: { child_type: 'mapOverlay' }, + }, + }, + CHILD_MAP_OVERLAY_GROUP_CODE: { + Header: 'Child map overlay group code', + id: 'child_map_overlay_group_code', + source: 'child_code', + editConfig: { + optionsEndpoint: 'mapOverlayGroups', + optionLabelKey: 'mapOverlayGroups.code', + optionValueKey: 'mapOverlayGroups.id', + sourceKey: 'child_id', + visibilityCriteria: { child_type: 'mapOverlayGroup' }, + }, + }, + SORT_ORDER: { + Header: 'Sort order', + source: 'sort_order', }, -}; - -const sortOrder = { - Header: 'Sort order', - source: 'sort_order', }; const EDIT_FIELDS = [ - mapOverlayGroupCode, - childType, - childMapOverlayCode, - childMapOverlayGroupCode, - sortOrder, + FIELDS.MAP_OVERLAY_GROUP_CODE, + FIELDS.CHILD_TYPE, + FIELDS.CHILD_MAP_OVERLAY_CODE, + FIELDS.CHILD_MAP_OVERLAY_GROUP_CODE, + FIELDS.SORT_ORDER, ]; const COLUMNS = [ - mapOverlayGroupCode, - childType, - childCode, - sortOrder, + FIELDS.MAP_OVERLAY_GROUP_CODE, + FIELDS.CHILD_TYPE, + FIELDS.CHILD_CODE, + FIELDS.SORT_ORDER, { Header: 'Edit', type: 'edit', diff --git a/packages/admin-panel/src/routes/visualisations/mapOverlayGroups.js b/packages/admin-panel/src/routes/visualisations/mapOverlayGroups.js index 2b0b96c920..f28661ef5c 100644 --- a/packages/admin-panel/src/routes/visualisations/mapOverlayGroups.js +++ b/packages/admin-panel/src/routes/visualisations/mapOverlayGroups.js @@ -3,7 +3,7 @@ * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ -import { RELATION_ENDPOINT } from './mapOverlayGroupRelations'; +import { FIELDS as RELATION_FIELDS, RELATION_ENDPOINT } from './mapOverlayGroupRelations'; const RESOURCE_NAME = { singular: 'map overlay group' }; @@ -38,50 +38,28 @@ const COLUMNS = [ }, ]; -export const RELATION_FIELDS = [ - { - Header: 'Child ID', - source: 'child_id', - - editConfig: { - optionsEndpoint: 'mapOverlays', - optionLabelKey: 'mapOverlay.id', - optionValueKey: 'mapOverlay.id', - sourceKey: 'child_id', - }, - }, - { - Header: 'Child type', - source: 'child_type', - - editConfig: { - options: [ - { - label: 'Map overlay', - value: 'mapOverlay', - }, - { - label: 'Map overlay group', - value: 'mapOverlayGroup', - }, - ], - }, - }, - { - Header: 'Sort order', - source: 'sort_order', - }, -]; - export const RELATION_COLUMNS = [ - ...RELATION_FIELDS, + RELATION_FIELDS.CHILD_TYPE, + RELATION_FIELDS.CHILD_CODE, + RELATION_FIELDS.SORT_ORDER, { Header: 'Edit', type: 'edit', source: 'id', actionConfig: { - editEndpoint: 'mapOverlayGroupRelations', - fields: RELATION_FIELDS, + title: 'Edit map overlay group relation', + editEndpoint: RELATION_ENDPOINT, + fields: [ + { + Header: 'Map overlay group code', + source: 'map_overlay_group.code', + editable: false, + }, + RELATION_FIELDS.CHILD_TYPE, + RELATION_FIELDS.CHILD_MAP_OVERLAY_CODE, + RELATION_FIELDS.CHILD_MAP_OVERLAY_GROUP_CODE, + RELATION_FIELDS.SORT_ORDER, + ], }, }, { @@ -108,7 +86,7 @@ export const mapOverlayGroups = { createConfig: CREATE_CONFIG, nestedViews: [ { - title: 'Map Overlay Group Relations', + title: 'Map overlay group relations', columns: RELATION_COLUMNS, endpoint: 'mapOverlayGroups/{id}/mapOverlayGroupRelations', path: '/:id/map-overlay-group-relations', From 969183b6b70cb2feac4abcfb1319fa02923d36c5 Mon Sep 17 00:00:00 2001 From: Tom Caiger Date: Mon, 1 Jul 2024 11:55:43 +1200 Subject: [PATCH 7/9] fix(adminPanel): Fix admin panel dashboard relations table (#5749) * fix admin panel dashboard relations table * Update helpers.js * Update GETDashboardRelations.js --- .../central-server/src/apiV2/GETHandler/helpers.js | 5 +---- .../dashboardRelations/GETDashboardRelations.js | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/central-server/src/apiV2/GETHandler/helpers.js b/packages/central-server/src/apiV2/GETHandler/helpers.js index 523692fc1a..09e9000e50 100644 --- a/packages/central-server/src/apiV2/GETHandler/helpers.js +++ b/packages/central-server/src/apiV2/GETHandler/helpers.js @@ -42,10 +42,7 @@ export const generateLinkHeader = (resource, pageString, lastPage, originalQuery return formatLinkHeader(linkHeader); }; -/** - * Used only by {@link processColumnSelector}, so not exported. - */ -const fullyQualifyColumnSelector = (models, unprocessedColumnSelector, baseRecordType) => { +export const fullyQualifyColumnSelector = (models, unprocessedColumnSelector, baseRecordType) => { const [resource, column] = unprocessedColumnSelector.includes('.') ? unprocessedColumnSelector.split('.') : [baseRecordType, unprocessedColumnSelector]; diff --git a/packages/central-server/src/apiV2/dashboardRelations/GETDashboardRelations.js b/packages/central-server/src/apiV2/dashboardRelations/GETDashboardRelations.js index 7b41ffb714..3a385404ab 100644 --- a/packages/central-server/src/apiV2/dashboardRelations/GETDashboardRelations.js +++ b/packages/central-server/src/apiV2/dashboardRelations/GETDashboardRelations.js @@ -4,6 +4,7 @@ */ import { RECORDS } from '@tupaia/database'; +import { fullyQualifyColumnSelector } from '../GETHandler/helpers'; import { GETHandler } from '../GETHandler'; import { assertAnyPermissions, assertBESAdminAccess } from '../../permissions'; import { assertDashboardGetPermissions } from '../dashboards'; @@ -34,6 +35,19 @@ export class GETDashboardRelations extends GETHandler { }, }; + getDbQueryCriteria() { + const { filter: filterString } = this.req.query; + const filter = filterString ? JSON.parse(filterString) : {}; + const processedObject = {}; + Object.entries(filter).forEach(([columnSelector, value]) => { + // We don't want to use the customColumnSelectors for dashboard relations since they are not + // compatible with the database query so just use fullyQualifyColumnSelector + processedObject[fullyQualifyColumnSelector(this.models, columnSelector, this.recordType)] = + value; + }); + return processedObject; + } + async findSingleRecord(dashboardRelationId, options) { const dashboardRelation = await super.findSingleRecord(dashboardRelationId, options); From 6db50d8ed963ea3aded262983ff6cbd077daa434 Mon Sep 17 00:00:00 2001 From: Tom Caiger Date: Mon, 1 Jul 2024 16:39:16 +1200 Subject: [PATCH 8/9] fix(adminPanel): RN-1190: Fix permissions editing (#5753) Update permissions.js --- packages/admin-panel/src/routes/users/permissions.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/admin-panel/src/routes/users/permissions.js b/packages/admin-panel/src/routes/users/permissions.js index a4a27aaf07..5358aefb4f 100644 --- a/packages/admin-panel/src/routes/users/permissions.js +++ b/packages/admin-panel/src/routes/users/permissions.js @@ -24,7 +24,14 @@ const EntityField = { }; export const PERMISSIONS_ENDPOINT = 'userEntityPermissions'; export const PERMISSIONS_COLUMNS = [ - EntityField, + { + Header: 'Entity', + source: 'entity.name', + editConfig: { + optionsEndpoint: 'entities', + baseFilter: { type: 'country' }, + }, + }, { Header: 'Permission group', source: 'permission_group.name', From 9f2506b853d799d78854593dcfb61e615785836f Mon Sep 17 00:00:00 2001 From: Tom Caiger Date: Thu, 4 Jul 2024 09:21:44 +1200 Subject: [PATCH 9/9] fix(lesmis): RN-1335: Fix DataFetchingTable Action Label (#5755) * Update DataFetchingTable.jsx * Update users.jsx --- packages/admin-panel/src/routes/users/users.jsx | 4 +--- .../src/table/DataFetchingTable/DataFetchingTable.jsx | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/admin-panel/src/routes/users/users.jsx b/packages/admin-panel/src/routes/users/users.jsx index de5ea01bc9..20e5da65d7 100644 --- a/packages/admin-panel/src/routes/users/users.jsx +++ b/packages/admin-panel/src/routes/users/users.jsx @@ -134,9 +134,7 @@ const CREATE_CONFIG = { optionsEndpoint: 'countries', optionLabelKey: 'name', optionValueKey: 'name', - labelTooltip: 'Select the country to grant this user access to', - type: 'checkboxList', - pageSize: 'ALL', + secondaryLabel: 'Select the country to grant this user access to', }, }, { diff --git a/packages/admin-panel/src/table/DataFetchingTable/DataFetchingTable.jsx b/packages/admin-panel/src/table/DataFetchingTable/DataFetchingTable.jsx index 30c30a908c..e7eb941249 100644 --- a/packages/admin-panel/src/table/DataFetchingTable/DataFetchingTable.jsx +++ b/packages/admin-panel/src/table/DataFetchingTable/DataFetchingTable.jsx @@ -113,7 +113,7 @@ const DataFetchingTableComponent = memo( baseFilter, basePath, resourceName, - actionLabel = 'Action', + actionLabel, }) => { const formattedColumns = useMemo(() => { const cols = columns.map(column => formatColumnForReactTable(column)); @@ -143,7 +143,7 @@ const DataFetchingTableComponent = memo( const buttonWidths = buttonColumns.reduce((acc, { width }) => acc + (width || 60), 0); // Group all button columns into a single column so they can be displayed together under a single header const singleButtonColumn = { - Header: actionLabel, + Header: actionLabel || 'Action', maxWidth: buttonWidths, width: buttonWidths, // eslint-disable-next-line react/prop-types