Skip to content

Commit

Permalink
MAUI-859: Enable Project Creation (#4036)
Browse files Browse the repository at this point in the history
* Add createConfig and projects endpoint

* Add create project endpoint

* Add rollback to createProject, write tests

* Add rollback transaction

* Remove unused import

* Pull data from create functions

* add validation for whitespace

* Adjust config based on changes

* Fix error message

Co-authored-by: Sima-BES <87400368+Sima-BES@users.noreply.github.com>
  • Loading branch information
chris-pollard and Sima-BES authored Sep 16, 2022
1 parent 4edddd1 commit 4aacba3
Show file tree
Hide file tree
Showing 7 changed files with 491 additions and 3 deletions.
71 changes: 68 additions & 3 deletions packages/admin-panel/src/pages/resources/ProjectsPage.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
import React from 'react';
import PropTypes from 'prop-types';
import { ResourcePage } from './ResourcePage';
import { prettyArray } from '../../utilities';
import { ArrayFilter } from '../../table/columnTypes/columnFilters';

const PROJECTS_ENDPOINT = 'projects';

const FIELDS = [
{
Expand All @@ -22,12 +26,22 @@ const FIELDS = [
source: 'dashboard_group_name',
type: 'tooltip',
editConfig: {
optionsEndpoint: 'dashboardGroups',
optionsEndpoint: 'dashboards',
optionLabelKey: 'name',
optionValueKey: 'name',
sourceKey: 'dashboard_group_name',
},
},
{
Header: 'Map Overlay Code',
source: 'default_measure',
editConfig: {
optionsEndpoint: 'mapOverlays',
optionLabelKey: 'code',
optionValueKey: 'id',
sourceKey: 'default_measure',
},
},
{
Header: 'Permission Group',
source: 'permission_groups',
Expand All @@ -54,7 +68,9 @@ const FIELDS = [
Header: 'Config',
source: 'config',
type: 'jsonTooltip',
editConfig: { type: 'jsonEditor' },
editConfig: {
type: 'jsonEditor',
},
secondaryLabel: 'eg. { "tileSets": "osm,satellite,terrain", "permanentRegionLabels": true }',
},
{
Expand All @@ -64,6 +80,41 @@ const FIELDS = [
},
];

const NEW_PROJECT_COLUMNS = [
{
Header: 'Name',
source: 'name',
},
...FIELDS,

{
Header: 'Country Code/s',
source: 'country.code',
Filter: ArrayFilter,
Cell: ({ value }) => prettyArray(value),
editConfig: {
optionsEndpoint: 'countries',
optionLabelKey: 'country.code',
optionValueKey: 'country.id',
sourceKey: 'countries',
allowMultipleValues: true,
},
},
{
Header: 'Canonical Types (leave blank for default)',
source: 'entityType',
Filter: ArrayFilter,
Cell: ({ value }) => prettyArray(value),
editConfig: {
optionsEndpoint: 'entityTypes',
optionLabelKey: 'entityType',
optionValueKey: 'entityType',
sourceKey: 'entityTypes',
allowMultipleValues: true,
},
},
];

const COLUMNS = [
...FIELDS,
{
Expand All @@ -78,8 +129,22 @@ const COLUMNS = [
},
];

const CREATE_CONFIG = {
title: 'Create a new project',
actionConfig: {
editEndpoint: PROJECTS_ENDPOINT,
fields: NEW_PROJECT_COLUMNS,
},
};

export const ProjectsPage = ({ getHeaderEl }) => (
<ResourcePage title="Projects" endpoint="projects" columns={COLUMNS} getHeaderEl={getHeaderEl} />
<ResourcePage
title="Projects"
endpoint="projects"
columns={COLUMNS}
getHeaderEl={getHeaderEl}
createConfig={CREATE_CONFIG}
/>
);

ProjectsPage.propTypes = {
Expand Down
21 changes: 21 additions & 0 deletions packages/central-server/src/apiV2/GETEntityTypes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* Tupaia
* Copyright (c) 2017 - 2020 Beyond Essential Systems Pty Ltd
*/

import { respond } from '@tupaia/utils';

export const GETEntityTypes = async (req, res, next) => {
const { models } = req;
try {
const { types } = await models.entity;
const entityTypes = Object.values(types)
.filter(value => value !== 'world')
.map(value => {
return { entityType: value };
});
respond(res, entityTypes);
} catch (error) {
next(error);
}
};
4 changes: 4 additions & 0 deletions packages/central-server/src/apiV2/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { GETDataElements, EditDataElements, DeleteDataElements } from './dataEle
import { GETDataGroups, EditDataGroups, DeleteDataGroups } from './dataGroups';
import { GETDataTables } from './dataTables';
import { GETEntities } from './GETEntities';
import { GETEntityTypes } from './GETEntityTypes';
import { GETFeedItems } from './GETFeedItems';
import { GETGeographicalAreas } from './GETGeographicalAreas';
import { GETSurveyGroups } from './GETSurveyGroups';
Expand All @@ -44,6 +45,7 @@ import { DeleteSurveys, EditSurveys, GETSurveys } from './surveys';
import { GETProjects } from './GETProjects';
import { DeleteDashboardItem, EditDashboardItem, GETDashboardItems } from './dashboardItems';
import { CreateDashboard, DeleteDashboard, EditDashboard, GETDashboards } from './dashboards';
import { CreateProject } from './projects';
import {
DeleteDashboardRelation,
EditDashboardRelation,
Expand Down Expand Up @@ -188,6 +190,7 @@ apiV2.get('/mapOverlayGroupRelations/:recordId?', useRouteHandler(GETMapOverlayG
apiV2.get('/surveys/:recordId?', useRouteHandler(GETSurveys));
apiV2.get('/countries/:parentRecordId/surveys', useRouteHandler(GETSurveys));
apiV2.get('/countries/:parentRecordId/entities', useRouteHandler(GETEntities));
apiV2.get('/entityTypes', allowAnyone(GETEntityTypes));
apiV2.get('/surveyGroups/:recordId?', useRouteHandler(GETSurveyGroups));
apiV2.get('/surveyResponses/:parentRecordId/answers', useRouteHandler(GETAnswers));
apiV2.get('/surveyResponses/:recordId?', useRouteHandler(GETSurveyResponses));
Expand Down Expand Up @@ -256,6 +259,7 @@ apiV2.post('/dashboardRelations', useRouteHandler(CreateDashboardRelation));
apiV2.post('/dashboardVisualisations', useRouteHandler(CreateDashboardVisualisation));
apiV2.post('/mapOverlayVisualisations', useRouteHandler(CreateMapOverlayVisualisation));
apiV2.post('/mapOverlayGroupRelations', useRouteHandler(CreateMapOverlayGroupRelation));
apiV2.post('/projects', useRouteHandler(CreateProject));
apiV2.post('/dataServiceSyncGroups', useRouteHandler(CreateSyncGroups));
apiV2.post('/dataServiceSyncGroups/:recordId/sync', useRouteHandler(ManuallySyncSyncGroup));

Expand Down
124 changes: 124 additions & 0 deletions packages/central-server/src/apiV2/projects/CreateProject.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* Tupaia
* Copyright (c) 2017 - 2021 Beyond Essential Systems Pty Ltd
*/

import { snake } from 'case';
import { CreateHandler } from '../CreateHandler';
import { assertAnyPermissions, assertBESAdminAccess } from '../../permissions';

/**
* Handles POST endpoints:
* - /projects
*/

export class CreateProject extends CreateHandler {
async assertUserHasAccess() {
await this.assertPermissions(
assertAnyPermissions([assertBESAdminAccess], 'You need BES Admin to create new projects'),
);
}

async createRecord() {
const {
code: rawProjectCode,
name,
description,
sort_order,
image_url,
logo_url,
permission_groups,
countries,
entityTypes,
default_measure,
dashboard_group_name,
} = this.newRecordData;

const projectCode = snake(rawProjectCode);

await this.models.wrapInTransaction(async transactingModels => {
const { id: projectEntityId } = await this.createProjectEntity(
transactingModels,
projectCode,
name,
);

const { id: projectEntityHierarchyId } = await this.createEntityHierarchy(
transactingModels,
projectCode,
entityTypes,
);

await this.createProjectEntityRelations(transactingModels, projectCode, countries);
const { name: dashboardGroupName } = await this.createProjectDashboard(
transactingModels,
dashboard_group_name,
projectCode,
);

return transactingModels.project.create({
code: projectCode,
description,
sort_order,
image_url,
logo_url,
permission_groups,
default_measure,
dashboard_group_name: dashboardGroupName,
entity_id: projectEntityId,
entity_hierarchy_id: projectEntityHierarchyId,
});
});
}

async createProjectEntity(models, projectCode, name) {
const worldCode = 'World';
const { id: worldId } = await models.entity.findOne({ code: worldCode });

return models.entity.create({
name,
code: projectCode,
parent_id: worldId,
type: 'project',
});
}

async createProjectEntityRelations(models, projectCode, countries) {
const { id: projectEntityId } = await models.entity.findOne({
code: projectCode,
});
const { id: entityHierarchyId } = await models.entityHierarchy.findOne({
name: projectCode,
});

for (const countryId of countries) {
const { code: countryCode } = await models.country.findOne({
id: countryId,
});
const { id: entityId } = await models.entity.findOne({
code: countryCode,
type: 'country',
});
await models.entityRelation.create({
parent_id: projectEntityId,
child_id: entityId,
entity_hierarchy_id: entityHierarchyId,
});
}
}

async createProjectDashboard(models, dashboard_group_name, projectCode) {
return models.dashboard.create({
code: `${projectCode}_project`,
name: dashboard_group_name,
root_entity_code: projectCode,
});
}

async createEntityHierarchy(models, projectCode, entityTypes) {
return models.entityHierarchy.create({
name: projectCode,
canonical_types: entityTypes ? `{${entityTypes.join(',')}}` : '{}',
});
}
}
6 changes: 6 additions & 0 deletions packages/central-server/src/apiV2/projects/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* Tupaia
* Copyright (c) 2017 - 2021 Beyond Essential Systems Pty Ltd
*/

export { CreateProject } from './CreateProject';
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,79 @@ export const constructForSingle = (models, recordType) => {
service_type: [constructIsOneOf(Object.values(models.dataServiceSyncGroup.SERVICE_TYPES))],
config: [hasContent],
};
case TYPES.PROJECT:
return {
code: [
constructRecordNotExistsWithField(models.project, 'code'),
isAString,
async code => {
if (code.trim() === '') {
throw new Error('The code should contain words');
}
return true;
},
],
name: [
isAString,
async name => {
if (name.trim() === '') {
throw new Error('The name should contain words');
}
const entityWithName = await models.entity.findOne({
type: 'project',
name,
});
if (entityWithName) {
throw new Error('A project already exists with this name.');
}
return true;
},
],
countries: [
async countries => {
const countryEntities = await models.country.find({
id: countries,
});
if (countryEntities.length !== countries.length) {
throw new Error('One or more provided countries do not exist');
}
return true;
},
],
permission_groups: [
hasContent,
async permissionGroupNames => {
const permissionGroups = await models.permissionGroup.find({
name: permissionGroupNames,
});
if (permissionGroupNames.length !== permissionGroups.length) {
throw new Error('Some provided permission groups do not exist');
}
return true;
},
],
description: [isAString],
sort_order: [constructIsEmptyOr(isNumber)],
image_url: [isAString],
logo_url: [isAString],
entityTypes: [
async selectedEntityTypes => {
if (!selectedEntityTypes) {
return true;
}
const entityDataTypes = await models.entity.types;
const filteredEntityTypes = Object.values(entityDataTypes).filter(type =>
selectedEntityTypes.includes(type),
);
if (selectedEntityTypes.length !== filteredEntityTypes.length) {
throw new Error('Some provided entity types do not exist');
}
return true;
},
],
dashboard_group_name: [isAString],
default_measure: [constructRecordExistsWithField(models.mapOverlay, 'id')],
};
default:
throw new ValidationError(`${recordType} is not a valid POST endpoint`);
}
Expand Down
Loading

0 comments on commit 4aacba3

Please sign in to comment.