From 4b4333abc006f3f1e963a9a110c9ac1d4100aeb4 Mon Sep 17 00:00:00 2001 From: Sarah-Jaine Szekeresh Date: Wed, 2 Dec 2020 18:55:28 -0600 Subject: [PATCH 01/25] begin ar --- src/models/activity.js | 90 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 src/models/activity.js diff --git a/src/models/activity.js b/src/models/activity.js new file mode 100644 index 0000000000..49eab4ce2a --- /dev/null +++ b/src/models/activity.js @@ -0,0 +1,90 @@ +import { Model } from 'sequelize'; + +const deliveryMethods = [ + 'in person', + 'virtual' +] + +const granteeRoles = [ + +]; + +const otherRoles = [ + +]; + +const requestors = [ + 'grantee', + 'regional office', +]; + +const types = [ + 'technical assistance', + 'training', +]; + +export default (sequelize, DataTypes) => { + class Activity extends Model { + static associate(models) { + Activity.belongsTo(models.User, { foreignKey: 'userId', as: 'author' }); + // Activity.belongsTo(models.User, { foreignKey: 'userId', as: 'author' }); + // Activity.belongsToMany(models.Scope, { + // through: models.Permission, foreignKey: 'userId', as: 'scopes', timestamps: false, + // }); + // Activity.hasMany(models.Permission, { foreignKey: 'userId', as: 'permissions' }); + } + } + Activity.init({ + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: DataTypes.INTEGER, + }, + attendees: { + type: DataTypes.INTEGER), + allowNull: false, + comment: 'total number of attendees', + } + deliveryMethod: { + type: DataTypes.ENUM(deliveryMethods), + allowNull: false, + }, + duration: { + type: DataTypes.DECIMAL(3, 1), + allowNull: false, + comment: 'length of activity in hours, rounded to nearest half hour', + }, + endDate: { + type: DataTypes.DATEONLY, + allowNull: false, + }, + granteeRolesInAttendance: { + type: DataTypes.ARRAY(Sequelize.ENUM(granteeRoles)) + allowNull: false, + comment: 'roles of grantees who attended the activity', + } + otherRolesInAttendance: { + type: DataTypes.ARRAY(Sequelize.ENUM(otherRoles)) + allowNull: false, + comment: 'roles of non-grantees who attended the activity', + } + requestor: { + type: DataTypes.ENUM(requestors), + allowNull: false, + }, + startDate: { + type: DataTypes.DATEONLY, + allowNull: false, + }, + type: { + type: DataTypes.ENUM(types), + allowNull: false, + }, + + }, { + sequelize, + modelName: 'Activity', + }); + return Activity; +}; From fa7b032c06df2372c30c0e289c41bf60853eeacc Mon Sep 17 00:00:00 2001 From: Sarah-Jaine Szekeresh Date: Thu, 3 Dec 2020 17:58:24 -0600 Subject: [PATCH 02/25] add ar validation checkRequiredForSubmission --- src/models/activity.js | 61 +++++++++++++++++++++++++++++++----------- 1 file changed, 45 insertions(+), 16 deletions(-) diff --git a/src/models/activity.js b/src/models/activity.js index 49eab4ce2a..5b4cf86b16 100644 --- a/src/models/activity.js +++ b/src/models/activity.js @@ -2,8 +2,8 @@ import { Model } from 'sequelize'; const deliveryMethods = [ 'in person', - 'virtual' -] + 'virtual', +]; const granteeRoles = [ @@ -13,11 +13,22 @@ const otherRoles = [ ]; +const participantTypes = [ + 'grantee', + 'non-grantee', +]; + const requestors = [ 'grantee', 'regional office', ]; +const statuses = [ + 'approved', + 'draft', + 'submitted', +]; + const types = [ 'technical assistance', 'training', @@ -42,49 +53,67 @@ export default (sequelize, DataTypes) => { type: DataTypes.INTEGER, }, attendees: { - type: DataTypes.INTEGER), - allowNull: false, + type: DataTypes.INTEGER, comment: 'total number of attendees', - } + }, deliveryMethod: { type: DataTypes.ENUM(deliveryMethods), - allowNull: false, }, duration: { type: DataTypes.DECIMAL(3, 1), - allowNull: false, comment: 'length of activity in hours, rounded to nearest half hour', }, endDate: { type: DataTypes.DATEONLY, - allowNull: false, }, granteeRolesInAttendance: { - type: DataTypes.ARRAY(Sequelize.ENUM(granteeRoles)) + type: DataTypes.ARRAY(Sequelize.ENUM(granteeRoles)), allowNull: false, comment: 'roles of grantees who attended the activity', - } + }, otherRolesInAttendance: { - type: DataTypes.ARRAY(Sequelize.ENUM(otherRoles)) - allowNull: false, + type: DataTypes.ARRAY(Sequelize.ENUM(otherRoles)), comment: 'roles of non-grantees who attended the activity', - } + }, + participantType: { + type: DataTypes.ENUM(participantTypes), + allowNull: false, + }, requestor: { type: DataTypes.ENUM(requestors), - allowNull: false, }, startDate: { type: DataTypes.DATEONLY, + }, + status: { + type: DataTypes.ENUM(statuses), allowNull: false, }, type: { type: DataTypes.ENUM(types), - allowNull: false, }, - }, { sequelize, modelName: 'Activity', + validate: { + checkRequiredForSubmission() { + if (this.status !== 'draft') { + const requiredForSubmission = [ + this.attendees, + this.deliveryMethod, + this.duration, + this.endDate, + this.granteeRolesInAttendance, + this.requestor, + this.startDate, + this.type, + ]; + if (requiredForSubmission.includes(null)) { + throw new Error('Missing field(s) required for activity report submission'); + } + } + }, + }, }); return Activity; }; From 05b1d98186858856c11a8cbd58459349a9f198d8 Mon Sep 17 00:00:00 2001 From: Sarah-Jaine Szekeresh Date: Fri, 4 Dec 2020 16:47:06 -0600 Subject: [PATCH 03/25] first pass at secondary tables --- src/models/activity.js | 126 ++++++++++++++++++++++++----- src/models/activityCollaborator.js | 12 +++ src/models/activityParticipant.js | 13 +++ src/models/grant.js | 23 ++++++ src/models/grantee.js | 13 +++ src/models/nonGrantee.js | 13 +++ 6 files changed, 182 insertions(+), 18 deletions(-) create mode 100644 src/models/activityCollaborator.js create mode 100644 src/models/activityParticipant.js create mode 100644 src/models/grant.js create mode 100644 src/models/grantee.js create mode 100644 src/models/nonGrantee.js diff --git a/src/models/activity.js b/src/models/activity.js index 5b4cf86b16..9e86487944 100644 --- a/src/models/activity.js +++ b/src/models/activity.js @@ -5,12 +5,33 @@ const deliveryMethods = [ 'virtual', ]; -const granteeRoles = [ - +const granteeParticipantRoles = [ + 'CEO / CFO / Executive', + 'Center Director / Site Director', + 'Coach', + 'Direct Service: Other', + 'Family Service Worker / Case Manager', + 'Fiscal Manager/Team', + 'Governing Body / Tribal Council / Policy Council', + 'Home Visitor', + 'Manager / Coordinator / Specialist', + 'Parent / Guardian', + 'Program Director (HS / EHS)', + 'Program Support / Administrative Assistant', + 'Teacher / Infant-Toddler Caregiver', + 'Volunteer', ]; -const otherRoles = [ - +const otherParticipantRoles = [ + 'HSCO', + 'Local/State Agency(ies)', + 'OCC Regional Office', + 'OHS Regional Office', + 'Regional Head Start Association', + 'Regional TTA Team / Specialists', + 'State Early Learning System', + 'State Head Start Association', + 'Other', ]; const participantTypes = [ @@ -18,6 +39,27 @@ const participantTypes = [ 'non-grantee', ]; +const programTypes = [ + 'Early Head Start (ages 0-3)', + 'EHS-CCP', + 'Head Start (ages 3-5)', +] + +const reasons = [ + 'Change in Scope', + 'Coordination / Planning', + 'Full Enrollment', + 'Grantee TTA Plan / Agreement', + 'New Grantee', + 'New Director or Management', + 'New Program Option', + 'Ongoing Quality Improvement', + 'School Readiness Goals', + 'Monitoring | Area of Concern', + 'Monitoring | Noncompliance', + 'Monitoring | Deficiency', +]; + const requestors = [ 'grantee', 'regional office', @@ -29,6 +71,50 @@ const statuses = [ 'submitted', ]; +const targetPopulations = [ + 'Affected by Child Welfare Involvement', + 'Affected by Disaster', + 'Affected by Substance Use', + 'Children with Disabilities', + 'Children experiencing Homelessness', + 'Dual-Language Learners', + 'Pregnant Women', +]; + +const topics = [ + 'Behavioral / Mental Health', + 'Child Assessment, Development, Screening', + 'CLASS: Classroom Management', + 'CLASS: Emotional Support', + 'CLASS: Instructional Support', + 'Coaching', + 'Communication', + 'Community and Self-Assessment', + 'Culture & Language', + 'Curriculum (Early Childhood or Parenting', + 'Data and Evaluation', + 'ERSEA', + 'Environmental Health and Safety', + 'Facilities', + 'Family Support Services', + 'Fiscal / Budget', + 'Five-Year Grant', + 'Human Resources', + 'Leadership / Governance', + 'Nutrition', + 'Oral Health', + 'Parent and Family Engagement', + 'Partnerships and Community Engagement', + 'Physical Health and Screenings', + 'Pregnancy', + 'Program Planning and Services', + 'QIP', + 'Recordkeeping and Reporting', + 'Safety Practices', + 'Transportation', + 'Other', +]; + const types = [ 'technical assistance', 'training', @@ -38,20 +124,11 @@ export default (sequelize, DataTypes) => { class Activity extends Model { static associate(models) { Activity.belongsTo(models.User, { foreignKey: 'userId', as: 'author' }); - // Activity.belongsTo(models.User, { foreignKey: 'userId', as: 'author' }); - // Activity.belongsToMany(models.Scope, { - // through: models.Permission, foreignKey: 'userId', as: 'scopes', timestamps: false, - // }); + Activity.belongsTo(models.User, { foreignKey: 'userId', as: 'recentEditor' }); // Activity.hasMany(models.Permission, { foreignKey: 'userId', as: 'permissions' }); } } Activity.init({ - id: { - allowNull: false, - autoIncrement: true, - primaryKey: true, - type: DataTypes.INTEGER, - }, attendees: { type: DataTypes.INTEGER, comment: 'total number of attendees', @@ -66,19 +143,26 @@ export default (sequelize, DataTypes) => { endDate: { type: DataTypes.DATEONLY, }, - granteeRolesInAttendance: { - type: DataTypes.ARRAY(Sequelize.ENUM(granteeRoles)), + granteeParticipantRole: { + type: DataTypes.ARRAY(Sequelize.ENUM(granteeParticipantRoles)), allowNull: false, comment: 'roles of grantees who attended the activity', }, - otherRolesInAttendance: { - type: DataTypes.ARRAY(Sequelize.ENUM(otherRoles)), + otherParticipantRole: { + type: DataTypes.ARRAY(Sequelize.ENUM(otherParticipantRoles)), comment: 'roles of non-grantees who attended the activity', }, participantType: { type: DataTypes.ENUM(participantTypes), allowNull: false, }, + programType: { + type: DataTypes.ENUM(programTypes), + allowNull: false, + }, + reason: { + type: DataTypes.ARRAY(Sequelize.ENUM(reasons)), + }, requestor: { type: DataTypes.ENUM(requestors), }, @@ -89,6 +173,12 @@ export default (sequelize, DataTypes) => { type: DataTypes.ENUM(statuses), allowNull: false, }, + targetPopulation: { + type: DataTypes.ARRAY(Sequelize.ENUM(targetPopulations)), + }, + topic : { + type: DataTypes.ARRAY(Sequelize.ENUM(topics)), + }, type: { type: DataTypes.ENUM(types), }, diff --git a/src/models/activityCollaborator.js b/src/models/activityCollaborator.js new file mode 100644 index 0000000000..f0afcccb93 --- /dev/null +++ b/src/models/activityCollaborator.js @@ -0,0 +1,12 @@ +import { Model } from 'sequelize'; + +export default (sequelize, DataTypes) => { + class ActivityCollaborator extends Model { + static associate(models) { + ActivityCollaborator.belongsTo(models.Activity, { foreignKey: 'activityId'}); + ActivityCollaborator.belongsTo(models.User, { foreignKey: 'userId'}); + } + } + ActivityCollaborator.init(); + return ActivityCollaborator; +}; diff --git a/src/models/activityParticipant.js b/src/models/activityParticipant.js new file mode 100644 index 0000000000..c342516d6c --- /dev/null +++ b/src/models/activityParticipant.js @@ -0,0 +1,13 @@ +import { Model } from 'sequelize'; + +export default (sequelize, DataTypes) => { + class ActivityParticipant extends Model { + static associate(models) { + ActivityParticipant.belongsTo(models.Activity, { foreignKey: 'activityId'}); + ActivityParticipant.belongsTo(models.Grant, { foreignKey: 'grantId'}); + ActivityParticipant.belongsTo(models.NonGrantee, { foreignKey: 'nonGranteeId'}); + } + } + ActivityParticipant.init(); + return ActivityParticipant; +}; diff --git a/src/models/grant.js b/src/models/grant.js new file mode 100644 index 0000000000..2c42651fd5 --- /dev/null +++ b/src/models/grant.js @@ -0,0 +1,23 @@ +module.exports = (sequelize, DataTypes) => { + class Grant extends Model { + static associate(models) { + Grant.belongsTo(models.Grantee, { foreignKey: 'granteeId'}); + Grant.belongsTo(models.Region, { foreignKey: 'regionId'}); + } + } + Grant.init({ + isActive: { + type: DataTypes.BOOLEAN, + allowNull: false, + }, + number: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + }, + }, { + sequelize, + modelName: 'Grant', + }); + return Grant; +}; diff --git a/src/models/grantee.js b/src/models/grantee.js new file mode 100644 index 0000000000..ea7d1867cd --- /dev/null +++ b/src/models/grantee.js @@ -0,0 +1,13 @@ +module.exports = (sequelize, DataTypes) => { + Grantee.init({ + name: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + }, + }, { + sequelize, + modelName: 'Grantee', + }); + return Grantee; +}; diff --git a/src/models/nonGrantee.js b/src/models/nonGrantee.js new file mode 100644 index 0000000000..f361a92e19 --- /dev/null +++ b/src/models/nonGrantee.js @@ -0,0 +1,13 @@ +module.exports = (sequelize, DataTypes) => { + NonGrantee.init({ + name: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + }, + }, { + sequelize, + modelName: 'NonGrantee', + }); + return NonGrantee; +}; From e55067170f3d39514f860ca314437a94d1ab4efd Mon Sep 17 00:00:00 2001 From: Sarah-Jaine Szekeresh Date: Tue, 8 Dec 2020 15:52:00 -0600 Subject: [PATCH 04/25] remove multiselects --- src/models/activity.js | 186 ++++------------------------- src/models/activityCollaborator.js | 11 +- src/models/activityParticipant.js | 20 +++- 3 files changed, 46 insertions(+), 171 deletions(-) diff --git a/src/models/activity.js b/src/models/activity.js index 9e86487944..7fb89e269c 100644 --- a/src/models/activity.js +++ b/src/models/activity.js @@ -1,205 +1,67 @@ import { Model } from 'sequelize'; -const deliveryMethods = [ - 'in person', - 'virtual', -]; - -const granteeParticipantRoles = [ - 'CEO / CFO / Executive', - 'Center Director / Site Director', - 'Coach', - 'Direct Service: Other', - 'Family Service Worker / Case Manager', - 'Fiscal Manager/Team', - 'Governing Body / Tribal Council / Policy Council', - 'Home Visitor', - 'Manager / Coordinator / Specialist', - 'Parent / Guardian', - 'Program Director (HS / EHS)', - 'Program Support / Administrative Assistant', - 'Teacher / Infant-Toddler Caregiver', - 'Volunteer', -]; - -const otherParticipantRoles = [ - 'HSCO', - 'Local/State Agency(ies)', - 'OCC Regional Office', - 'OHS Regional Office', - 'Regional Head Start Association', - 'Regional TTA Team / Specialists', - 'State Early Learning System', - 'State Head Start Association', - 'Other', -]; - -const participantTypes = [ - 'grantee', - 'non-grantee', -]; - -const programTypes = [ - 'Early Head Start (ages 0-3)', - 'EHS-CCP', - 'Head Start (ages 3-5)', -] - -const reasons = [ - 'Change in Scope', - 'Coordination / Planning', - 'Full Enrollment', - 'Grantee TTA Plan / Agreement', - 'New Grantee', - 'New Director or Management', - 'New Program Option', - 'Ongoing Quality Improvement', - 'School Readiness Goals', - 'Monitoring | Area of Concern', - 'Monitoring | Noncompliance', - 'Monitoring | Deficiency', -]; - -const requestors = [ - 'grantee', - 'regional office', -]; - -const statuses = [ - 'approved', - 'draft', - 'submitted', -]; - -const targetPopulations = [ - 'Affected by Child Welfare Involvement', - 'Affected by Disaster', - 'Affected by Substance Use', - 'Children with Disabilities', - 'Children experiencing Homelessness', - 'Dual-Language Learners', - 'Pregnant Women', -]; - -const topics = [ - 'Behavioral / Mental Health', - 'Child Assessment, Development, Screening', - 'CLASS: Classroom Management', - 'CLASS: Emotional Support', - 'CLASS: Instructional Support', - 'Coaching', - 'Communication', - 'Community and Self-Assessment', - 'Culture & Language', - 'Curriculum (Early Childhood or Parenting', - 'Data and Evaluation', - 'ERSEA', - 'Environmental Health and Safety', - 'Facilities', - 'Family Support Services', - 'Fiscal / Budget', - 'Five-Year Grant', - 'Human Resources', - 'Leadership / Governance', - 'Nutrition', - 'Oral Health', - 'Parent and Family Engagement', - 'Partnerships and Community Engagement', - 'Physical Health and Screenings', - 'Pregnancy', - 'Program Planning and Services', - 'QIP', - 'Recordkeeping and Reporting', - 'Safety Practices', - 'Transportation', - 'Other', -]; - -const types = [ - 'technical assistance', - 'training', -]; - export default (sequelize, DataTypes) => { class Activity extends Model { static associate(models) { Activity.belongsTo(models.User, { foreignKey: 'userId', as: 'author' }); Activity.belongsTo(models.User, { foreignKey: 'userId', as: 'recentEditor' }); - // Activity.hasMany(models.Permission, { foreignKey: 'userId', as: 'permissions' }); + Activity.hasMany(models.ActivityCollaborator); + Activity.hasMany(models.ActivityParticipant); } } Activity.init({ attendees: { + comment: 'total count of attendees', type: DataTypes.INTEGER, - comment: 'total number of attendees', }, deliveryMethod: { - type: DataTypes.ENUM(deliveryMethods), + comment: 'constrained to specific values but not enforced by db', + type: DataTypes.STRING, }, duration: { - type: DataTypes.DECIMAL(3, 1), comment: 'length of activity in hours, rounded to nearest half hour', + type: DataTypes.DECIMAL(3, 1), }, endDate: { type: DataTypes.DATEONLY, }, - granteeParticipantRole: { - type: DataTypes.ARRAY(Sequelize.ENUM(granteeParticipantRoles)), - allowNull: false, - comment: 'roles of grantees who attended the activity', - }, - otherParticipantRole: { - type: DataTypes.ARRAY(Sequelize.ENUM(otherParticipantRoles)), - comment: 'roles of non-grantees who attended the activity', - }, participantType: { - type: DataTypes.ENUM(participantTypes), allowNull: false, - }, - programType: { - type: DataTypes.ENUM(programTypes), - allowNull: false, - }, - reason: { - type: DataTypes.ARRAY(Sequelize.ENUM(reasons)), + comment: 'constrained to specific values but not enforced by db', + type: DataTypes.STRING, }, requestor: { - type: DataTypes.ENUM(requestors), + comment: 'constrained to specific values but not enforced by db', + type: DataTypes.STRING, }, startDate: { type: DataTypes.DATEONLY, }, status: { - type: DataTypes.ENUM(statuses), allowNull: false, + comment: 'constrained to specific values but not enforced by db', + type: DataTypes.STRING, }, - targetPopulation: { - type: DataTypes.ARRAY(Sequelize.ENUM(targetPopulations)), - }, - topic : { - type: DataTypes.ARRAY(Sequelize.ENUM(topics)), - }, - type: { - type: DataTypes.ENUM(types), + ttaType: { + comment: 'constrained to specific values but not enforced by db', + type: DataTypes.STRING, }, }, { sequelize, modelName: 'Activity', validate: { checkRequiredForSubmission() { + const requiredForSubmission = [ + this.deliveryMethod, + this.duration, + this.endDate, + this.requestor, + this.startDate, + this.ttaType, + ]; if (this.status !== 'draft') { - const requiredForSubmission = [ - this.attendees, - this.deliveryMethod, - this.duration, - this.endDate, - this.granteeRolesInAttendance, - this.requestor, - this.startDate, - this.type, - ]; if (requiredForSubmission.includes(null)) { - throw new Error('Missing field(s) required for activity report submission'); + throw new Error('Missing required field(s)'); } } }, diff --git a/src/models/activityCollaborator.js b/src/models/activityCollaborator.js index f0afcccb93..b303cfa8d8 100644 --- a/src/models/activityCollaborator.js +++ b/src/models/activityCollaborator.js @@ -1,12 +1,15 @@ import { Model } from 'sequelize'; -export default (sequelize, DataTypes) => { +export default (sequelize) => { class ActivityCollaborator extends Model { static associate(models) { - ActivityCollaborator.belongsTo(models.Activity, { foreignKey: 'activityId'}); - ActivityCollaborator.belongsTo(models.User, { foreignKey: 'userId'}); + ActivityCollaborator.belongsTo(models.Activity, { foreignKey: 'activityId' }); + ActivityCollaborator.belongsTo(models.User, { foreignKey: 'userId' }); } } - ActivityCollaborator.init(); + ActivityCollaborator.init({}, { + sequelize, + modelName: 'ActivityCollaborator', + }); return ActivityCollaborator; }; diff --git a/src/models/activityParticipant.js b/src/models/activityParticipant.js index c342516d6c..f622966411 100644 --- a/src/models/activityParticipant.js +++ b/src/models/activityParticipant.js @@ -1,13 +1,23 @@ import { Model } from 'sequelize'; -export default (sequelize, DataTypes) => { +export default (sequelize) => { class ActivityParticipant extends Model { static associate(models) { - ActivityParticipant.belongsTo(models.Activity, { foreignKey: 'activityId'}); - ActivityParticipant.belongsTo(models.Grant, { foreignKey: 'grantId'}); - ActivityParticipant.belongsTo(models.NonGrantee, { foreignKey: 'nonGranteeId'}); + ActivityParticipant.belongsTo(models.Activity, { foreignKey: 'activityId' }); + ActivityParticipant.belongsTo(models.Grant, { foreignKey: 'grantId' }); + ActivityParticipant.belongsTo(models.NonGrantee, { foreignKey: 'nonGranteeId' }); } } - ActivityParticipant.init(); + ActivityParticipant.init({}, { + sequelize, + modelName: 'ActivityParticipant', + validate: { + oneNull() { + if (![this.grantId, this.nonGranteeId].includes(null)) { + throw new Error('Can not specify both grantId and nonGranteeId'); + } + }, + }, + }); return ActivityParticipant; }; From fddb56b60ad58ae6eaa12b0a8bdafe61e0a191ce Mon Sep 17 00:00:00 2001 From: Josh Salisbury Date: Wed, 23 Dec 2020 13:15:15 -0600 Subject: [PATCH 05/25] stash --- frontend/package.json | 1 + frontend/src/App.js | 4 +- frontend/src/components/DatePicker.js | 4 +- frontend/src/components/Header.js | 2 +- .../components/Navigator/__tests__/index.js | 3 + .../components/Navigator/components/Form.js | 7 ++ .../Navigator/components/SideNav.css | 15 +++ .../Navigator/components/SideNav.js | 18 +++- frontend/src/components/Navigator/index.js | 9 +- frontend/src/fetchers/activityReports.js | 17 +++- .../ActivityReport/Pages/activitySummary.js | 48 +++++----- .../ActivityReport/Pages/topicsResources.js | 12 +-- .../pages/ActivityReport/__tests__/index.js | 75 ++++++++------- frontend/src/pages/ActivityReport/index.js | 91 +++++++++++++------ frontend/src/setupTests.js | 2 + frontend/yarn.lock | 5 + src/routes/activityReports/handlers.js | 46 ++++++++++ src/routes/activityReports/index.js | 4 +- 18 files changed, 262 insertions(+), 101 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index a4d89d55aa..76255b1d97 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,6 +29,7 @@ "react-router-prop-types": "^1.0.5", "react-scripts": "^3.4.4", "react-select": "^3.1.0", + "react-select-simple-value": "^1.2.1", "react-stickynode": "^3.0.4", "react-with-direction": "^1.3.1", "url-join": "^4.0.1", diff --git a/frontend/src/App.js b/frontend/src/App.js index d31e27cbe5..a2194d8ade 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -78,9 +78,9 @@ function App() { )} /> ( - + )} /> Home , - + Activity Reports , diff --git a/frontend/src/components/Navigator/__tests__/index.js b/frontend/src/components/Navigator/__tests__/index.js index d02ab393a4..adc55113f4 100644 --- a/frontend/src/components/Navigator/__tests__/index.js +++ b/frontend/src/components/Navigator/__tests__/index.js @@ -62,6 +62,7 @@ describe('Navigator', () => { updatePage={updatePage} currentPage={currentPage} onFormSubmit={onSubmit} + onSave={() => {}} /> , ); @@ -86,6 +87,8 @@ describe('Navigator', () => { const onSubmit = jest.fn(); renderNavigator('review', onSubmit); userEvent.click(screen.getByRole('button', { name: 'Continue' })); + await waitFor(() => screen.findByTestId('review')); + userEvent.click(screen.getByTestId('review')); await waitFor(() => expect(onSubmit).toHaveBeenCalled()); }); diff --git a/frontend/src/components/Navigator/components/Form.js b/frontend/src/components/Navigator/components/Form.js index 52082ac54f..8f8775a2f6 100644 --- a/frontend/src/components/Navigator/components/Form.js +++ b/frontend/src/components/Navigator/components/Form.js @@ -31,6 +31,13 @@ function Form({ return onUnmount; }, [saveForm]); + useEffect(() => { + const interval = setInterval(() => { + saveForm(getValuesRef.current()); + }, 1000 * 10); + return () => clearInterval(interval); + }, [saveForm]); + const hookForm = useForm({ mode: 'onChange', defaultValues: initialData, diff --git a/frontend/src/components/Navigator/components/SideNav.css b/frontend/src/components/Navigator/components/SideNav.css index 685fc85f03..0479b303a6 100644 --- a/frontend/src/components/Navigator/components/SideNav.css +++ b/frontend/src/components/Navigator/components/SideNav.css @@ -81,6 +81,21 @@ transition: 0.2s ease-in-out; } +.smart-hub--save-alert { + margin-top: 12px; +} + +.smart-hub--save-alert p { + font-family: SourceSansPro; + font-size: 14px; + color: #21272d; + line-height: 24px; +} + +.smart-hub--save-alert::before { + width: 4px; +} + .smart-hub--navigator-item:first-child .smart-hub--navigator-link-active { border-top-right-radius: 4px; } diff --git a/frontend/src/components/Navigator/components/SideNav.js b/frontend/src/components/Navigator/components/SideNav.js index 70234c3235..745348ba85 100644 --- a/frontend/src/components/Navigator/components/SideNav.js +++ b/frontend/src/components/Navigator/components/SideNav.js @@ -5,8 +5,9 @@ */ import React from 'react'; import PropTypes from 'prop-types'; +import moment from 'moment'; import Sticky from 'react-stickynode'; -import { Tag } from '@trussworks/react-uswds'; +import { Tag, Alert } from '@trussworks/react-uswds'; import { useMediaQuery } from 'react-responsive'; import { NavLink } from 'react-router-dom'; @@ -32,7 +33,7 @@ const tagClass = (state) => { }; function SideNav({ - pages, skipTo, skipToMessage, + pages, skipTo, skipToMessage, lastSaveTime, }) { const isMobile = useMediaQuery({ maxWidth: 640 }); const navItems = () => pages.map((page) => ( @@ -64,6 +65,14 @@ function SideNav({ {navItems()} + {lastSaveTime + && ( + + This report was automatically saved on + {' '} + {lastSaveTime.format('MM/DD/YYYY [at] h:mm a')} + + )} ); } @@ -78,6 +87,11 @@ SideNav.propTypes = { ).isRequired, skipTo: PropTypes.string.isRequired, skipToMessage: PropTypes.string.isRequired, + lastSaveTime: PropTypes.instanceOf(moment), +}; + +SideNav.defaultProps = { + lastSaveTime: undefined, }; export default SideNav; diff --git a/frontend/src/components/Navigator/index.js b/frontend/src/components/Navigator/index.js index 104eaa5d2b..c45e9b81bd 100644 --- a/frontend/src/components/Navigator/index.js +++ b/frontend/src/components/Navigator/index.js @@ -5,6 +5,7 @@ */ import React, { useState, useCallback } from 'react'; import PropTypes from 'prop-types'; +import moment from 'moment'; import _ from 'lodash'; import { Grid } from '@trussworks/react-uswds'; @@ -26,9 +27,11 @@ function Navigator({ currentPage, updatePage, additionalData, + onSave, }) { const [formData, updateFormData] = useState(defaultValues); const [pageState, updatePageState] = useState(initialPageState); + const [lastSaveTime, updateLastSaveTime] = useState(); const page = pages.find((p) => p.path === currentPage); const submittedNavState = submitted ? SUBMITTED : null; const allComplete = _.every(pageState, (state) => state === COMPLETE); @@ -52,7 +55,9 @@ function Navigator({ const onSaveForm = useCallback((newData) => { updateFormData((oldData) => ({ ...oldData, ...newData })); - }, [updateFormData]); + updateLastSaveTime(moment()); + onSave({ ...formData, ...newData }); + }, [updateFormData, onSave]); const onContinue = () => { const newNavigatorState = { ...pageState }; @@ -71,6 +76,7 @@ function Navigator({ @@ -116,6 +122,7 @@ Navigator.propTypes = { currentPage: PropTypes.string.isRequired, updatePage: PropTypes.func.isRequired, additionalData: PropTypes.shape({}), + onSave: PropTypes.func.isRequired, }; Navigator.defaultProps = { diff --git a/frontend/src/fetchers/activityReports.js b/frontend/src/fetchers/activityReports.js index 243ac7b9be..c5c5a52ac2 100644 --- a/frontend/src/fetchers/activityReports.js +++ b/frontend/src/fetchers/activityReports.js @@ -17,8 +17,8 @@ export const fetchApprovers = async () => { return res.json(); }; -export const submitReport = async (data, extraData) => { - const url = join(activityReportUrl, 'submit'); +export const submitReport = async (reportId, data, extraData) => { + const url = join(activityReportUrl, reportId, 'submit'); await fetch(url, { method: 'POST', credentials: 'same-origin', @@ -28,3 +28,16 @@ export const submitReport = async (data, extraData) => { }), }); }; + +export const saveReport = async (reportId, data) => { + await fetch(join(activityReportUrl, reportId), { + method: 'POST', + credentials: 'same-origin', + body: data, + }); +}; + +export const getReport = async (reportId) => { + const report = await fetch(join(activityReportUrl, reportId)); + return report.json(); +}; diff --git a/frontend/src/pages/ActivityReport/Pages/activitySummary.js b/frontend/src/pages/ActivityReport/Pages/activitySummary.js index 5cb9fda917..6d567f78cf 100644 --- a/frontend/src/pages/ActivityReport/Pages/activitySummary.js +++ b/frontend/src/pages/ActivityReport/Pages/activitySummary.js @@ -63,9 +63,9 @@ const ActivitySummary = ({ control, getValues, }) => { - const participantSelection = watch('participant-category'); - const startDate = watch('start-date'); - const endDate = watch('end-date'); + const participantSelection = watch('participantCategory'); + const startDate = watch('startDate'); + const endDate = watch('endDate'); const disableParticipant = participantSelection === ''; const nonGranteeSelected = participantSelection === 'non-grantee'; @@ -105,7 +105,7 @@ const ActivitySummary = ({
What TTA was provided? - {renderCheckbox('activity-type', 'training', 'Training')} - {renderCheckbox('activity-type', 'technical-assistance', 'Technical Assistance')} + {renderCheckbox('activityType', 'training', 'Training')} + {renderCheckbox('activityType', 'technical-assistance', 'Technical Assistance')}
@@ -258,7 +258,7 @@ const ActivitySummary = ({
Number of grantee participants involved @@ -316,13 +316,13 @@ const sections = [ title: 'Who was the activity for?', anchor: 'activity-for', items: [ - { label: 'Grantee or Non-grantee', name: 'participant-category' }, + { label: 'Grantee or Non-grantee', name: 'participantCategory' }, { label: 'Grantee name(s)', name: 'grantees' }, { label: 'Grantee number(s)', name: '' }, - { label: 'Collaborating specialist(s)', name: 'other-users' }, + { label: 'Collaborating specialist(s)', name: 'otherUsers' }, { label: 'CDI', name: 'cdi' }, - { label: 'Program type(s)', name: 'program-types' }, - { label: 'Target Populations addressed', name: 'target-populations' }, + { label: 'Program type(s)', name: 'programTypes' }, + { label: 'Target Populations addressed', name: 'targetPopulations' }, ], }, { @@ -338,8 +338,8 @@ const sections = [ title: 'Activity date', anchor: 'date', items: [ - { label: 'Start date', name: 'start-date' }, - { label: 'End date', name: 'end-date' }, + { label: 'Start date', name: 'startDate' }, + { label: 'End date', name: 'endDate' }, { label: 'Duration', name: 'duration' }, ], }, @@ -347,8 +347,8 @@ const sections = [ title: 'Training or Technical Assistance', anchor: 'tta', items: [ - { label: 'TTA Provided', name: 'activity-type' }, - { label: 'Conducted', name: 'activity-method' }, + { label: 'TTA Provided', name: 'activityType' }, + { label: 'Conducted', name: 'activityMethod' }, ], }, { @@ -356,7 +356,7 @@ const sections = [ anchor: 'other-participants', items: [ { label: 'Grantee participants', name: 'participants' }, - { label: 'Number of participants', name: 'number-of-participants' }, + { label: 'Number of participants', name: 'numberOfParticipants' }, ], }, ]; diff --git a/frontend/src/pages/ActivityReport/Pages/topicsResources.js b/frontend/src/pages/ActivityReport/Pages/topicsResources.js index b72f2b617b..0437ae866f 100644 --- a/frontend/src/pages/ActivityReport/Pages/topicsResources.js +++ b/frontend/src/pages/ActivityReport/Pages/topicsResources.js @@ -41,20 +41,20 @@ const TopicsResources = ({
-
- + ({ - 'activity-method': 'in-person', - 'activity-type': ['training'], + activityMethod: 'in-person', + activityType: ['training'], duration: '1', - 'end-date': moment().format('MM/DD/YYYY'), + endDate: moment().format('MM/DD/YYYY'), grantees: ['Grantee Name 1'], - 'number-of-participants': '1', - 'participant-category': 'grantee', + numberOfParticipants: '1', + participantCategory: 'grantee', participants: ['CEO / CFO / Executive'], - 'program-types': ['type 1'], + programTypes: ['type 1'], requester: 'grantee', - 'resources-used': 'eclkcurl', - 'start-date': moment().format('MM/DD/YYYY'), - 'target-populations': ['target 1'], + resourcesUsed: 'eclkcurl', + startDate: moment().format('MM/DD/YYYY'), + targetPopulations: ['target 1'], topics: 'first', }); const history = createMemoryHistory(); -const renderActivityReport = (data = {}, location = 'activity-summary') => { +const renderActivityReport = (data = {}, location = 'activity-summary', reportId = 'test') => { + fetch.mockResponse(JSON.stringify({ + report: data, + additionalData: {}, + pageState: {}, + })); + render( , ); }; describe('ActivityReport', () => { - it('defaults to activity summary if no page is in the url', () => { + beforeEach(() => { + fetch.resetMocks(); + }); + + it('defaults to activity summary if no page is in the url', async () => { renderActivityReport({}, null); - expect(history.location.pathname).toEqual('/activity-reports/activity-summary'); + await waitFor(() => expect(history.location.pathname).toEqual('/activity-reports/test/activity-summary')); }); describe('grantee select', () => { describe('changes the participant selection to', () => { it('Grantee', async () => { renderActivityReport(); + await screen.findByText('New activity report for Region 14'); const information = await screen.findByRole('group', { name: 'Who was the activity for?' }); - const grantee = within(information).getByLabelText('Grantee'); + const grantee = await within(information).findByLabelText('Grantee'); fireEvent.click(grantee); - const granteeSelectbox = await screen.findByRole('textbox', { name: 'Grantee name(s)' }); + const granteeSelectbox = await screen.findByLabelText('Grantee name(s)'); reactSelectEvent.openMenu(granteeSelectbox); expect(await screen.findByText(withText('Grantee Name 1'))).toBeVisible(); }); it('Non-grantee', async () => { renderActivityReport(); + await screen.findByText('New activity report for Region 14'); const information = await screen.findByRole('group', { name: 'Who was the activity for?' }); - const nonGrantee = within(information).getByLabelText('Non-Grantee'); + const nonGrantee = await within(information).findByLabelText('Non-Grantee'); fireEvent.click(nonGrantee); - const granteeSelectbox = await screen.findByRole('textbox', { name: 'Grantee name(s)' }); + const granteeSelectbox = await screen.findByLabelText('Non-grantee name(s)'); reactSelectEvent.openMenu(granteeSelectbox); expect(await screen.findByText(withText('QRIS System'))).toBeVisible(); }); }); - it('when non-grantee is selected', async () => { - renderActivityReport(); - const enabled = screen.getByRole('textbox', { name: 'Grantee name(s)' }); - expect(enabled).toBeDisabled(); + it('clears selection when non-grantee is selected', async () => { + const data = formData(); + renderActivityReport(data); + await screen.findByText('New activity report for Region 14'); const information = await screen.findByRole('group', { name: 'Who was the activity for?' }); - const grantee = within(information).getByLabelText('Grantee'); - fireEvent.click(grantee); - const disabled = await screen.findByRole('textbox', { name: 'Grantee name(s)' }); - expect(disabled).not.toBeDisabled(); + const enabled = await within(information).findByText('Grantee Name 1'); + expect(enabled).not.toBeDisabled(); + const nonGrantee = await within(information).findByLabelText('Non-Grantee'); + + fireEvent.click(nonGrantee); + expect(await within(information).findByLabelText('Non-grantee name(s)')).toHaveValue(''); }); }); describe('method checkboxes', () => { it('require a single selection for the form to be valid', async () => { const data = formData(); - delete data['activity-method']; + delete data.activityMethod; renderActivityReport(data); expect(await screen.findByText('Continue')).toBeDisabled(); const box = await screen.findByLabelText('Virtual'); fireEvent.click(box); - await waitFor(() => expect(screen.getByText('Continue')).not.toBeDisabled()); + expect(await screen.findByText('Continue')).not.toBeDisabled(); }); }); describe('tta checkboxes', () => { it('requires a single selection for the form to be valid', async () => { const data = formData(); - delete data['activity-type']; + delete data.activityType; renderActivityReport(data); expect(await screen.findByText('Continue')).toBeDisabled(); const box = await screen.findByLabelText('Training'); fireEvent.click(box); - await waitFor(() => expect(screen.getByText('Continue')).not.toBeDisabled()); + expect(await screen.findByText('Continue')).not.toBeDisabled(); }); }); }); diff --git a/frontend/src/pages/ActivityReport/index.js b/frontend/src/pages/ActivityReport/index.js index 5845b6b4ea..e9875af505 100644 --- a/frontend/src/pages/ActivityReport/index.js +++ b/frontend/src/pages/ActivityReport/index.js @@ -2,7 +2,7 @@ Activity report. Makes use of the navigator to split the long form into multiple pages. Each "page" is defined in the `./Pages` directory. */ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import _ from 'lodash'; import PropTypes from 'prop-types'; import { Helmet } from 'react-helmet'; @@ -14,58 +14,90 @@ import Navigator from '../../components/Navigator'; import './index.css'; import { NOT_STARTED } from '../../components/Navigator/constants'; -import { submitReport } from '../../fetchers/activityReports'; +import { submitReport, saveReport, getReport } from '../../fetchers/activityReports'; const defaultValues = { - 'activity-method': [], - 'activity-type': [], + activityMethod: [], + activityType: [], attachments: [], cdi: '', duration: '', - 'end-date': null, + endDate: null, grantees: [], - 'number-of-participants': '', - 'other-users': [], - 'participant-category': '', + numberOfParticipants: '', + otherUsers: [], + participantCategory: '', participants: [], - 'program-types': [], + programTypes: [], reason: [], requester: '', - 'resources-used': '', - 'start-date': null, - 'target-populations': [], + resourcesUsed: '', + startDate: null, + targetPopulations: [], topics: [], }; -const additionalNotes = 'this is an additional note'; -const approvingManagers = [2]; - const pagesByPos = _.keyBy(pages.filter((p) => !p.review), (page) => page.position); -const initialPageState = _.mapValues(pagesByPos, () => NOT_STARTED); +const defaultPageState = _.mapValues(pagesByPos, () => NOT_STARTED); -function ActivityReport({ initialData, match }) { - const [submitted, updateSubmitted] = useState(false); +function ActivityReport({ match }) { + const { params: { currentPage, activityReportId } } = match; const history = useHistory(); - const { params: { currentPage } } = match; + + const [submitted, updateSubmitted] = useState(false); + const [loading, updateLoading] = useState(true); + const [initialPageState, updateInitialPageSate] = useState(defaultPageState); + const [initialFormData, updateInitialFormData] = useState(defaultValues); + const [initialAdditionalData, updateAdditionalData] = useState({}); + + useEffect(() => { + const fetch = async () => { + if (activityReportId !== 'new') { + const { report, pageState, additionalData } = await getReport(activityReportId); + updateInitialFormData(report); + updateInitialPageSate(pageState); + updateAdditionalData(additionalData); + updateLoading(false); + } else { + updateInitialFormData(defaultValues); + updateInitialPageSate(defaultPageState); + updateAdditionalData({}); + updateLoading(false); + } + }; + fetch(); + }, [activityReportId]); + + if (loading) { + return ( +
+ loading... +
+ ); + } + + if (!currentPage) { + return ( + + ); + } + + const onSave = async (data) => { + await saveReport(activityReportId, data); + }; const onFormSubmit = async (data, extraData) => { // eslint-disable-next-line no-console console.log('Submit form data', data, extraData); - await submitReport(data, extraData); + await submitReport(activityReportId, data, extraData); updateSubmitted(true); }; const updatePage = (position) => { const page = pages.find((p) => p.position === position); - history.push(`/activity-reports/${page.path}`); + history.push(`/activity-reports/${activityReportId}/${page.path}`); }; - if (!currentPage) { - return ( - - ); - } - return ( <> @@ -75,10 +107,11 @@ function ActivityReport({ initialData, match }) { currentPage={currentPage} submitted={submitted} initialPageState={initialPageState} - defaultValues={{ ...defaultValues, ...initialData }} + defaultValues={{ ...defaultValues, ...initialFormData }} pages={pages} - additionalData={{ additionalNotes, approvingManagers }} + additionalData={initialAdditionalData} onFormSubmit={onFormSubmit} + onSave={onSave} /> ); diff --git a/frontend/src/setupTests.js b/frontend/src/setupTests.js index bdf8fdc424..de047acd5a 100644 --- a/frontend/src/setupTests.js +++ b/frontend/src/setupTests.js @@ -9,8 +9,10 @@ // learn more: https://github.com/testing-library/jest-dom import '@testing-library/jest-dom/extend-expect'; import 'react-dates/initialize'; +import mocks from 'jest-fetch-mock'; // See https://github.com/testing-library/dom-testing-library/releases/tag/v7.0.0 // 'MutationObserver shim removed' import MutationObserver from '@sheerun/mutationobserver-shim'; window.MutationObserver = MutationObserver; +mocks.enableMocks(); diff --git a/frontend/yarn.lock b/frontend/yarn.lock index b0369aad21..b931e03e41 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -9762,6 +9762,11 @@ react-select-event@^5.1.0: dependencies: "@testing-library/dom" ">=7" +react-select-simple-value@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/react-select-simple-value/-/react-select-simple-value-1.2.1.tgz#d228acd37eb87f871fd4e40cc81457d529c2cbf5" + integrity sha512-t1xHuPGkRcOXnOjjAuYChG41d2UgmIaeeMo7QJCqJ43H0fw+9b09b98C3//zJHWqGXcBA4WXvxzU0B8kvIlWjw== + react-select@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/react-select/-/react-select-3.1.1.tgz#156a5b4a6c22b1e3d62a919cb1fd827adb4060bc" diff --git a/src/routes/activityReports/handlers.js b/src/routes/activityReports/handlers.js index bf1ba50b44..74631814b5 100644 --- a/src/routes/activityReports/handlers.js +++ b/src/routes/activityReports/handlers.js @@ -69,3 +69,49 @@ export async function submitReport(req, res) { console.log('submit'); res.sendStatus(204); } + +export async function saveReport(req, res) { + // Temporary until saving of report is implemented + // eslint-disable-next-line no-console + console.log('save'); + res.sendStatus(204); +} + +export async function getReport(req, res) { + const additionalData = { + additionalNotes: 'this is an additional note', + approvingManagers: [2], + }; + + const report = { + activityMethod: 'in-person', + activityType: ['training'], + duration: '1', + endDate: '11/11/2020', + grantees: ['Grantee Name 1'], + numberOfParticipants: '1', + participantCategory: 'grantee', + participants: ['other participant 1'], + reason: ['reason 1'], + otherUsers: ['user 1'], + programTypes: ['program type 1'], + requester: 'grantee', + resourcesUsed: 'eclkcurl', + startDate: '11/11/2020', + targetPopulations: ['target pop 1'], + topics: ['first'], + }; + + const pageState = { + 1: 'Complete', + 2: 'Complete', + 3: 'Complete', + 4: 'Complete', + }; + + res.json({ + report, + pageState, + additionalData, + }); +} diff --git a/src/routes/activityReports/index.js b/src/routes/activityReports/index.js index 024c71a3bf..0838ecb11f 100644 --- a/src/routes/activityReports/index.js +++ b/src/routes/activityReports/index.js @@ -1,6 +1,6 @@ import express from 'express'; import { - getApprovers, submitReport, + getApprovers, submitReport, saveReport, getReport, } from './handlers'; const router = express.Router(); @@ -9,7 +9,9 @@ const router = express.Router(); * API for activity reports */ +router.post('/', saveReport); router.get('/approvers', getApprovers); router.post('/submit', submitReport); +router.get('/:reportId', getReport); export default router; From 7d4965db7f3e2a4723bd4db742c3efb23553c90b Mon Sep 17 00:00:00 2001 From: Sarah-Jaine Szekeresh Date: Wed, 30 Dec 2020 12:55:12 -0600 Subject: [PATCH 06/25] nongrantee extends --- src/models/nonGrantee.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/models/nonGrantee.js b/src/models/nonGrantee.js index f361a92e19..703cf34d9a 100644 --- a/src/models/nonGrantee.js +++ b/src/models/nonGrantee.js @@ -1,4 +1,16 @@ +const { Model } = require('sequelize'); + +/** + * NonGrantee table. + * + * @param {} sequelize + * @param {*} DataTypes + */ module.exports = (sequelize, DataTypes) => { + class NonGrantee extends Model { + static associate() { + } + } NonGrantee.init({ name: { type: DataTypes.STRING, From 5df666a609694f895c84d68e339267b7c7e2ca9e Mon Sep 17 00:00:00 2001 From: Josh Salisbury Date: Wed, 13 Jan 2021 13:47:22 -0600 Subject: [PATCH 07/25] Add activity report endpoints --- docs/openapi/index.yaml | 89 ++++++- .../activity-reports/activity-reports-id.yaml | 41 +++ .../activity-reports/activity-reports.yaml | 18 ++ .../paths/activity-reports/participants.yaml | 23 ++ docs/openapi/paths/index.yaml | 8 +- package.json | 3 +- src/lib/activityReports.js | 140 +++++++++++ src/lib/apiErrorHandler.js | 4 +- src/lib/users.js | 36 +++ .../20210106152317-create-non-grantee.js | 30 +++ .../20210106160931-create-activity-reports.js | 114 +++++++++ ...0106160953-create-activity-participants.js | 53 ++++ src/models/activity.js | 71 ------ src/models/activityCollaborator.js | 15 -- src/models/activityParticipant.js | 43 +++- src/models/activityReport.js | 104 ++++++++ src/models/grant.js | 15 +- src/models/grantee.js | 8 +- src/models/nonGrantee.js | 9 +- src/routes/activityReports/handlers.js | 140 ++++++----- src/routes/activityReports/handlers.test.js | 236 ++++++++++++++++++ src/routes/activityReports/index.js | 10 +- src/routes/admin/user.js | 14 +- src/routes/apiDirectory.js | 2 +- src/seeders/20210107170250-participants.js | 133 ++++++++++ yarn.lock | 2 +- 26 files changed, 1160 insertions(+), 201 deletions(-) create mode 100644 docs/openapi/paths/activity-reports/activity-reports-id.yaml create mode 100644 docs/openapi/paths/activity-reports/activity-reports.yaml create mode 100644 docs/openapi/paths/activity-reports/participants.yaml create mode 100644 src/lib/activityReports.js create mode 100644 src/lib/users.js create mode 100644 src/migrations/20210106152317-create-non-grantee.js create mode 100644 src/migrations/20210106160931-create-activity-reports.js create mode 100644 src/migrations/20210106160953-create-activity-participants.js delete mode 100644 src/models/activity.js delete mode 100644 src/models/activityCollaborator.js create mode 100644 src/models/activityReport.js create mode 100644 src/routes/activityReports/handlers.test.js create mode 100644 src/seeders/20210107170250-participants.js diff --git a/docs/openapi/index.yaml b/docs/openapi/index.yaml index b580498bd5..423fc0cd19 100644 --- a/docs/openapi/index.yaml +++ b/docs/openapi/index.yaml @@ -16,6 +16,70 @@ paths: $ref: "./paths/index.yaml" components: schemas: + activityReport: + type: object + properties: + id: + type: number + userId: + type: number + lastUpdatedById: + type: number + resourcesUsed: + type: string + additionalNotes: + type: string + numberOfParticipants: + type: number + deliveryMethod: + type: string + duration: + type: number + endDate: + type: string + startDate: + type: string + participantType: + type: string + requester: + type: string + programTypes: + type: array + items: + type: string + targetPopulations: + type: array + items: + type: string + reason: + type: array + items: + type: string + participants: + type: array + items: + type: string + topics: + type: array + items: + type: string + pageState: + type: object + properties: + 1: + type: string + 2: + type: string + 3: + type: string + 4: + type: string + status: + type: string + ttaType: + type: array + items: + type: string approvingUser: type: object properties: @@ -23,6 +87,22 @@ components: type: number name: type: string + participant: + type: object + properties: + participantId: + type: number + name: + type: string + permission: + type: object + properties: + userId: + type: number + scopeId: + type: number + regionId: + type: number user: type: object properties: @@ -44,12 +124,3 @@ components: type: array items: $ref: '#/components/schemas/permission' - permission: - type: object - properties: - userId: - type: number - scopeId: - type: number - regionId: - type: number diff --git a/docs/openapi/paths/activity-reports/activity-reports-id.yaml b/docs/openapi/paths/activity-reports/activity-reports-id.yaml new file mode 100644 index 0000000000..9483b0888c --- /dev/null +++ b/docs/openapi/paths/activity-reports/activity-reports-id.yaml @@ -0,0 +1,41 @@ +get: + tags: + - activity-reports + summary: Retrieve an activity report + parameters: + - in: path + name: activityReportId + required: true + schema: + type: number + responses: + 200: + description: The activity report with an Id of {activityReportId} + content: + application/json: + schema: + $ref: '../../index.yaml#/components/schemas/activityReport' +put: + tags: + - activity-reports + summary: Update an activity report + requestBody: + description: A new activity report + required: true + content: + application/json: + schema: + $ref: '../../index.yaml#/components/schemas/activityReport' + parameters: + - in: path + name: activityReportId + required: true + schema: + type: number + responses: + 200: + description: The updated activity report + content: + application/json: + schema: + $ref: '../../index.yaml#/components/schemas/activityReport' diff --git a/docs/openapi/paths/activity-reports/activity-reports.yaml b/docs/openapi/paths/activity-reports/activity-reports.yaml new file mode 100644 index 0000000000..e5ea38db56 --- /dev/null +++ b/docs/openapi/paths/activity-reports/activity-reports.yaml @@ -0,0 +1,18 @@ +post: + tags: + - activity-reports + summary: Create a new activity report + requestBody: + description: A new activity report + required: true + content: + application/json: + schema: + $ref: '../../index.yaml#/components/schemas/activityReport' + responses: + 200: + description: Successfully created activity report + content: + application/json: + schema: + $ref: '../../index.yaml#/components/schemas/activityReport' diff --git a/docs/openapi/paths/activity-reports/participants.yaml b/docs/openapi/paths/activity-reports/participants.yaml new file mode 100644 index 0000000000..4ccd0048f0 --- /dev/null +++ b/docs/openapi/paths/activity-reports/participants.yaml @@ -0,0 +1,23 @@ +get: + tags: + - activity-reports + summary: > + Get possible participants for an activity report + description: > + A participant is either a grant or nonGrantee. + responses: + 200: + description: The possible participants + content: + application/json: + schema: + type: object + properties: + grants: + type: array + items: + $ref: '../../index.yaml#/components/schemas/participant' + nonGrantees: + type: array + items: + $ref: '../../index.yaml#/components/schemas/participant' diff --git a/docs/openapi/paths/index.yaml b/docs/openapi/paths/index.yaml index e4a5a33507..b793e06ba9 100644 --- a/docs/openapi/paths/index.yaml +++ b/docs/openapi/paths/index.yaml @@ -10,5 +10,11 @@ $ref: './admin.yaml' '/admin/users': $ref: './adminAllUsers.yaml' +'/activity-reports': + $ref: './activity-reports/activity-reports.yaml' '/activity-reports/approvers': - $ref: './activity-reports/approvers.yaml' \ No newline at end of file + $ref: './activity-reports/approvers.yaml' +'/activity-reports/participants': + $ref: './activity-reports/participants.yaml' +'/activity-reports/{activityReportId}': + $ref: './activity-reports/activity-reports-id.yaml' diff --git a/package.json b/package.json index 0141e9b358..42d0968b3d 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "server:debug": "nodemon src/index.js --exec babel-node --inspect", "client": "yarn --cwd frontend start", "test": "jest src", - "test:ci": "cross-env JEST_JUNIT_OUTPUT_DIR=reports JEST_JUNIT_OUTPUT_NAME=unit.xml POSTGRES_USERNAME=postgres POSTGRES_DB=ttasmarthub CI=true jest src tools --coverage --reporters=default --reporters=jest-junit", + "test:ci": "cross-env JEST_JUNIT_OUTPUT_DIR=reports JEST_JUNIT_OUTPUT_NAME=unit.xml POSTGRES_USERNAME=postgres POSTGRES_DB=ttasmarthub CI=true jest src tools --runInBand --coverage --reporters=default --reporters=jest-junit", "test:all": "yarn test:ci && yarn --cwd frontend test:ci", "lint": "eslint src", "lint:ci": "eslint -f eslint-formatter-multiple src", @@ -146,6 +146,7 @@ "http-codes": "^1.0.0", "lodash": "^4.17.20", "memorystore": "^1.6.2", + "moment": "^2.29.1", "newrelic": "^7.0.1", "pg": "^8.3.3", "puppeteer": "^5.3.1", diff --git a/src/lib/activityReports.js b/src/lib/activityReports.js new file mode 100644 index 0000000000..94083ec269 --- /dev/null +++ b/src/lib/activityReports.js @@ -0,0 +1,140 @@ +import _ from 'lodash'; +import { Op } from 'sequelize'; + +import { + ActivityReport, + sequelize, + ActivityParticipant, + Grant, + Grantee, + NonGrantee, +} from '../models'; + +async function saveReportParticipants( + activityReportId, + participantIds, + participantType, + transaction, +) { + await ActivityParticipant.destroy({ + where: { + activityReportId: { + [Op.eq]: activityReportId, + }, + }, + transaction, + }); + + await Promise.all(participantIds.map(async (participantId) => { + const activityParticipant = { + activityReportId, + }; + + if (participantType === 'grantee') { + activityParticipant.grantId = participantId; + } else if (participantType === 'non-grantee') { + activityParticipant.nonGranteeId = participantId; + } + + return ActivityParticipant.create(activityParticipant, { transaction }); + })); +} + +async function update(newReport, activityReportId, transaction) { + const result = await ActivityReport.update(newReport, { + where: { + id: { + [Op.eq]: activityReportId, + }, + }, + returning: true, + plain: true, + transaction, + fields: _.keys(newReport), + }); + return result[1]; +} + +async function create(report, transaction) { + return ActivityReport.create(report, { transaction }); +} + +export function activityReportById(activityReportId) { + return ActivityReport.findOne({ + where: { + id: { + [Op.eq]: activityReportId, + }, + }, + include: [ + { + model: ActivityParticipant, + attributes: ['id', 'name', 'participantId'], + as: 'activityParticipants', + required: false, + include: [ + { + model: Grant, + attributes: ['id', 'number'], + as: 'grant', + required: false, + include: [{ + model: Grantee, + as: 'grantee', + attributes: ['name'], + }], + }, + { + model: NonGrantee, + as: 'nonGrantee', + required: false, + }, + ], + }, + ], + }); +} + +export async function createOrUpdate(newActivityReport, activityReportId) { + let savedReport; + await sequelize.transaction(async (transaction) => { + if (activityReportId) { + savedReport = await update(newActivityReport, activityReportId, transaction); + } else { + savedReport = await create(newActivityReport, transaction); + } + + if (newActivityReport.activityParticipants) { + const { participantType, id } = savedReport; + const participantIds = newActivityReport.activityParticipants.map((g) => g.participantId); + await saveReportParticipants(id, participantIds, participantType, transaction); + } + }); + return activityReportById(savedReport.id); +} + +export async function reportParticipants() { + const rawGrants = await Grant.findAll({ + attributes: ['id', 'name', 'number'], + include: [{ + model: Grantee, + as: 'grantee', + }], + }); + + const grants = rawGrants.map((g) => ({ + participantId: g.id, + name: g.name, + })); + + const nonGrantees = await NonGrantee.findAll({ + raw: true, + attributes: [['id', 'participantId'], 'name'], + }); + return { grants, nonGrantees }; +} + +export async function reportExists(activityReportId) { + const report = await ActivityReport.findOne({ where: { id: activityReportId } }); + return !_.isNull(report); +} diff --git a/src/lib/apiErrorHandler.js b/src/lib/apiErrorHandler.js index 566a7611ee..7eb9aa8cc1 100644 --- a/src/lib/apiErrorHandler.js +++ b/src/lib/apiErrorHandler.js @@ -21,7 +21,7 @@ async function handleSequelizeError(req, res, error, logContext) { responseBody: { ...error, errorStack: error.stack }, responseCode: INTERNAL_SERVER_ERROR, }); - logger.error(`${logContext.namespace} id: ${requestErrorId} Sequelize error`); + logger.error(`${logContext.namespace} id: ${requestErrorId} Sequelize error ${error.stack}`); } catch (err) { logger.error(`${logContext.namespace} - Sequelize error - unable to save to db - ${error}`); } @@ -35,7 +35,7 @@ export const handleError = async (req, res, error, logContext) => { if (error instanceof Sequelize.Error) { await handleSequelizeError(req, res, error, logContext); } else { - logger.error(`${logContext.namespace} - UNEXPECTED ERROR - ${error}`); + logger.error(`${logContext.namespace} - UNEXPECTED ERROR - ${error.stack}`); res.status(INTERNAL_SERVER_ERROR).end(); } }; diff --git a/src/lib/users.js b/src/lib/users.js new file mode 100644 index 0000000000..89be60d0f9 --- /dev/null +++ b/src/lib/users.js @@ -0,0 +1,36 @@ +import { Op } from 'sequelize'; + +import { + User, + Permission, +} from '../models'; + +export async function userById(userId) { + return User.findOne({ + attributes: ['id', 'name', 'hsesUserId', 'email', 'phoneNumber', 'homeRegionId', 'role'], + where: { + id: { + [Op.eq]: userId, + }, + }, + include: [ + { model: Permission, as: 'permissions', attributes: ['userId', 'scopeId', 'regionId'] }, + ], + }); +} + +export async function usersWithPermissions(regions, scopes) { + return User.findAll({ + attributes: ['id', 'name'], + raw: true, + where: { + [Op.and]: [ + { '$permissions.scopeId$': scopes }, + { '$permissions.regionId$': regions }, + ], + }, + include: [ + { model: Permission, as: 'permissions', attributes: [] }, + ], + }); +} diff --git a/src/migrations/20210106152317-create-non-grantee.js b/src/migrations/20210106152317-create-non-grantee.js new file mode 100644 index 0000000000..66108dee45 --- /dev/null +++ b/src/migrations/20210106152317-create-non-grantee.js @@ -0,0 +1,30 @@ +module.exports = { + up: async (queryInterface, Sequelize) => { + queryInterface.createTable('NonGrantees', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER, + }, + name: { + type: Sequelize.STRING, + allowNull: false, + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE, + defaultValue: Sequelize.fn('NOW'), + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE, + defaultValue: Sequelize.fn('NOW'), + }, + }); + }, + + down: async (queryInterface) => { + queryInterface.dropTable('NonGrantees'); + }, +}; diff --git a/src/migrations/20210106160931-create-activity-reports.js b/src/migrations/20210106160931-create-activity-reports.js new file mode 100644 index 0000000000..7e3c2f0290 --- /dev/null +++ b/src/migrations/20210106160931-create-activity-reports.js @@ -0,0 +1,114 @@ +module.exports = { + up: async (queryInterface, Sequelize) => { + queryInterface.createTable('ActivityReports', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER, + }, + resourcesUsed: { + allowNull: true, + type: Sequelize.STRING, + }, + additionalNotes: { + allowNull: true, + type: Sequelize.STRING, + }, + numberOfParticipants: { + allowNull: true, + type: Sequelize.INTEGER, + }, + deliveryMethod: { + allowNull: true, + type: Sequelize.STRING, + }, + duration: { + allowNull: true, + type: Sequelize.DECIMAL(3, 1), + }, + endDate: { + allowNull: true, + type: Sequelize.DATEONLY, + }, + startDate: { + allowNull: true, + type: Sequelize.DATEONLY, + }, + participantType: { + allowNull: true, + type: Sequelize.STRING, + }, + requester: { + allowNull: true, + type: Sequelize.STRING, + }, + status: { + allowNull: false, + type: Sequelize.STRING, + }, + programTypes: { + allowNull: true, + type: Sequelize.ARRAY(Sequelize.STRING), + }, + targetPopulations: { + allowNull: true, + type: Sequelize.ARRAY(Sequelize.STRING), + }, + reason: { + allowNull: true, + type: Sequelize.ARRAY(Sequelize.STRING), + }, + participants: { + allowNull: true, + type: Sequelize.ARRAY(Sequelize.STRING), + }, + topics: { + allowNull: true, + type: Sequelize.ARRAY(Sequelize.STRING), + }, + ttaType: { + allowNull: true, + type: Sequelize.ARRAY(Sequelize.STRING), + }, + pageState: { + allowNull: true, + type: Sequelize.JSON, + }, + userId: { + allowNull: false, + type: Sequelize.INTEGER, + references: { + model: { + tableName: 'Users', + }, + key: 'id', + }, + }, + lastUpdatedById: { + allowNull: false, + type: Sequelize.INTEGER, + references: { + model: { + tableName: 'Users', + }, + key: 'id', + }, + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE, + defaultValue: Sequelize.fn('NOW'), + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE, + defaultValue: Sequelize.fn('NOW'), + }, + }); + }, + + down: async (queryInterface) => { + queryInterface.dropTable('ActivityReports'); + }, +}; diff --git a/src/migrations/20210106160953-create-activity-participants.js b/src/migrations/20210106160953-create-activity-participants.js new file mode 100644 index 0000000000..8d12b0a4f4 --- /dev/null +++ b/src/migrations/20210106160953-create-activity-participants.js @@ -0,0 +1,53 @@ +module.exports = { + up: async (queryInterface, Sequelize) => { + queryInterface.createTable('ActivityParticipants', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER, + }, + activityReportId: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: { + tableName: 'ActivityReports', + }, + }, + }, + grantId: { + type: Sequelize.INTEGER, + allowNull: true, + references: { + model: { + tableName: 'Grants', + }, + }, + }, + nonGranteeId: { + type: Sequelize.INTEGER, + allowNull: true, + references: { + model: { + tableName: 'NonGrantees', + }, + }, + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE, + defaultValue: Sequelize.fn('NOW'), + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE, + defaultValue: Sequelize.fn('NOW'), + }, + }); + }, + + down: async (queryInterface) => { + queryInterface.dropTable('ActivityParticipants'); + }, +}; diff --git a/src/models/activity.js b/src/models/activity.js deleted file mode 100644 index 7fb89e269c..0000000000 --- a/src/models/activity.js +++ /dev/null @@ -1,71 +0,0 @@ -import { Model } from 'sequelize'; - -export default (sequelize, DataTypes) => { - class Activity extends Model { - static associate(models) { - Activity.belongsTo(models.User, { foreignKey: 'userId', as: 'author' }); - Activity.belongsTo(models.User, { foreignKey: 'userId', as: 'recentEditor' }); - Activity.hasMany(models.ActivityCollaborator); - Activity.hasMany(models.ActivityParticipant); - } - } - Activity.init({ - attendees: { - comment: 'total count of attendees', - type: DataTypes.INTEGER, - }, - deliveryMethod: { - comment: 'constrained to specific values but not enforced by db', - type: DataTypes.STRING, - }, - duration: { - comment: 'length of activity in hours, rounded to nearest half hour', - type: DataTypes.DECIMAL(3, 1), - }, - endDate: { - type: DataTypes.DATEONLY, - }, - participantType: { - allowNull: false, - comment: 'constrained to specific values but not enforced by db', - type: DataTypes.STRING, - }, - requestor: { - comment: 'constrained to specific values but not enforced by db', - type: DataTypes.STRING, - }, - startDate: { - type: DataTypes.DATEONLY, - }, - status: { - allowNull: false, - comment: 'constrained to specific values but not enforced by db', - type: DataTypes.STRING, - }, - ttaType: { - comment: 'constrained to specific values but not enforced by db', - type: DataTypes.STRING, - }, - }, { - sequelize, - modelName: 'Activity', - validate: { - checkRequiredForSubmission() { - const requiredForSubmission = [ - this.deliveryMethod, - this.duration, - this.endDate, - this.requestor, - this.startDate, - this.ttaType, - ]; - if (this.status !== 'draft') { - if (requiredForSubmission.includes(null)) { - throw new Error('Missing required field(s)'); - } - } - }, - }, - }); - return Activity; -}; diff --git a/src/models/activityCollaborator.js b/src/models/activityCollaborator.js deleted file mode 100644 index b303cfa8d8..0000000000 --- a/src/models/activityCollaborator.js +++ /dev/null @@ -1,15 +0,0 @@ -import { Model } from 'sequelize'; - -export default (sequelize) => { - class ActivityCollaborator extends Model { - static associate(models) { - ActivityCollaborator.belongsTo(models.Activity, { foreignKey: 'activityId' }); - ActivityCollaborator.belongsTo(models.User, { foreignKey: 'userId' }); - } - } - ActivityCollaborator.init({}, { - sequelize, - modelName: 'ActivityCollaborator', - }); - return ActivityCollaborator; -}; diff --git a/src/models/activityParticipant.js b/src/models/activityParticipant.js index f622966411..2e1cfc0f7b 100644 --- a/src/models/activityParticipant.js +++ b/src/models/activityParticipant.js @@ -1,19 +1,50 @@ import { Model } from 'sequelize'; -export default (sequelize) => { +export default (sequelize, DataTypes) => { class ActivityParticipant extends Model { static associate(models) { - ActivityParticipant.belongsTo(models.Activity, { foreignKey: 'activityId' }); - ActivityParticipant.belongsTo(models.Grant, { foreignKey: 'grantId' }); - ActivityParticipant.belongsTo(models.NonGrantee, { foreignKey: 'nonGranteeId' }); + ActivityParticipant.belongsTo(models.ActivityReport, { foreignKey: 'activityReportId' }); + ActivityParticipant.belongsTo(models.Grant, { foreignKey: 'grantId', as: 'grant' }); + ActivityParticipant.belongsTo(models.NonGrantee, { foreignKey: 'nonGranteeId', as: 'nonGrantee' }); } } - ActivityParticipant.init({}, { + ActivityParticipant.init({ + activityReportId: { + allowNull: false, + type: DataTypes.INTEGER, + }, + grantId: { + allowNull: true, + type: DataTypes.INTEGER, + }, + nonGranteeId: { + allowNull: true, + type: DataTypes.INTEGER, + }, + participantId: { + type: DataTypes.VIRTUAL, + get() { + if (this.grant) { + return this.grant.id; + } + return this.nonGrantee.id; + }, + }, + name: { + type: DataTypes.VIRTUAL, + get() { + if (this.grant) { + return this.grant.name; + } + return this.nonGrantee.name; + }, + }, + }, { sequelize, modelName: 'ActivityParticipant', validate: { oneNull() { - if (![this.grantId, this.nonGranteeId].includes(null)) { + if (this.grantId && this.nonGranteeId) { throw new Error('Can not specify both grantId and nonGranteeId'); } }, diff --git a/src/models/activityReport.js b/src/models/activityReport.js new file mode 100644 index 0000000000..416deac06e --- /dev/null +++ b/src/models/activityReport.js @@ -0,0 +1,104 @@ +import { Model } from 'sequelize'; +import moment from 'moment'; + +function formatDate(fieldName) { + const raw = this.getDataValue(fieldName); + if (raw) { + return moment(raw).format('MM/DD/YYYY'); + } + return null; +} + +export default (sequelize, DataTypes) => { + class ActivityReport extends Model { + static associate(models) { + ActivityReport.belongsTo(models.User, { foreignKey: 'userId', as: 'author' }); + ActivityReport.belongsTo(models.User, { foreignKey: 'lastUpdatedById', as: 'lastUpdatedBy' }); + ActivityReport.hasMany(models.ActivityParticipant, { foreignKey: 'activityReportId', as: 'activityParticipants' }); + } + } + ActivityReport.init({ + userId: { + type: DataTypes.INTEGER, + }, + lastUpdatedById: { + type: DataTypes.INTEGER, + }, + resourcesUsed: { + type: DataTypes.STRING, + }, + additionalNotes: { + type: DataTypes.STRING, + }, + numberOfParticipants: { + type: DataTypes.INTEGER, + }, + deliveryMethod: { + type: DataTypes.STRING, + }, + duration: { + type: DataTypes.DECIMAL(3, 1), + }, + endDate: { + type: DataTypes.DATEONLY, + get: formatDate, + }, + startDate: { + type: DataTypes.DATEONLY, + get: formatDate, + }, + participantType: { + allowNull: false, + type: DataTypes.STRING, + }, + requester: { + type: DataTypes.STRING, + }, + programTypes: { + type: DataTypes.ARRAY(DataTypes.STRING), + }, + targetPopulations: { + type: DataTypes.ARRAY(DataTypes.STRING), + }, + reason: { + type: DataTypes.ARRAY(DataTypes.STRING), + }, + participants: { + type: DataTypes.ARRAY(DataTypes.STRING), + }, + topics: { + type: DataTypes.ARRAY(DataTypes.STRING), + }, + pageState: { + type: DataTypes.JSON, + }, + status: { + allowNull: false, + type: DataTypes.STRING, + validate: { + checkRequiredForSubmission() { + const requiredForSubmission = [ + this.deliveryMethod, + this.duration, + this.endDate, + this.requestor, + this.startDate, + this.ttaType, + ]; + if (this.status !== 'draft') { + if (requiredForSubmission.includes(null)) { + throw new Error('Missing required field(s)'); + } + } + }, + }, + }, + ttaType: { + type: DataTypes.ARRAY(DataTypes.STRING), + }, + }, { + sequelize, + modelName: 'ActivityReport', + }); + return ActivityReport; +}; diff --git a/src/models/grant.js b/src/models/grant.js index 9cade1f2d4..ef4a9b0e24 100644 --- a/src/models/grant.js +++ b/src/models/grant.js @@ -12,7 +12,7 @@ module.exports = (sequelize, DataTypes) => { class Grant extends Model { static associate(models) { Grant.belongsTo(models.Region, { foreignKey: 'regionId' }); - Grant.belongsTo(models.Grantee, { foreignKey: 'granteeId' }); + Grant.belongsTo(models.Grantee, { foreignKey: 'granteeId', as: 'grantee' }); Grant.belongsToMany(models.Goal, { through: models.GrantGoal, foreignKey: 'grantId', as: 'goals' }); } } @@ -22,9 +22,16 @@ module.exports = (sequelize, DataTypes) => { allowNull: false, unique: true, }, - status: DataTypes.STRING, - startDate: DataTypes.DATE, - endDate: DataTypes.DATE, + granteeId: { + type: DataTypes.INTEGER, + allowNull: false, + }, + name: { + type: DataTypes.VIRTUAL, + get() { + return `${this.grantee.name} - ${this.number}`; + }, + }, }, { sequelize, modelName: 'Grant', diff --git a/src/models/grantee.js b/src/models/grantee.js index 568a024785..3b94560942 100644 --- a/src/models/grantee.js +++ b/src/models/grantee.js @@ -15,9 +15,11 @@ module.exports = (sequelize, DataTypes) => { } } Grantee.init({ - name: DataTypes.STRING, - allowNull: false, - unique: true, + name: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + }, }, { sequelize, modelName: 'Grantee', diff --git a/src/models/nonGrantee.js b/src/models/nonGrantee.js index 703cf34d9a..cbfef39ac8 100644 --- a/src/models/nonGrantee.js +++ b/src/models/nonGrantee.js @@ -1,15 +1,16 @@ -const { Model } = require('sequelize'); +const { + Model, +} = require('sequelize'); /** - * NonGrantee table. + * NonGrantee table * * @param {} sequelize * @param {*} DataTypes */ module.exports = (sequelize, DataTypes) => { class NonGrantee extends Model { - static associate() { - } + static associate() {} } NonGrantee.init({ name: { diff --git a/src/routes/activityReports/handlers.js b/src/routes/activityReports/handlers.js index 74631814b5..dee0069fd5 100644 --- a/src/routes/activityReports/handlers.js +++ b/src/routes/activityReports/handlers.js @@ -1,9 +1,9 @@ -import { Op } from 'sequelize'; -import { - User, Permission, -} from '../../models'; import handleErrors from '../../lib/apiErrorHandler'; import SCOPES from '../../middleware/scopeConstants'; +import { + reportParticipants, activityReportById, createOrUpdate, reportExists, +} from '../../lib/activityReports'; +import { userById, usersWithPermissions } from '../../lib/users'; const { READ_WRITE_REPORTS, APPROVE_REPORTS } = SCOPES; @@ -13,18 +13,6 @@ const logContext = { namespace, }; -const userById = async (userId) => User.findOne({ - attributes: ['id'], - where: { - id: { - [Op.eq]: userId, - }, - }, - include: [ - { model: Permission, as: 'permissions', attributes: ['scopeId', 'regionId'] }, - ], -}); - /** * Gets all users that have approve permissions for the current user's * regions. @@ -39,18 +27,7 @@ export async function getApprovers(req, res) { .map((p) => p.regionId); try { - const users = await User.findAll({ - attributes: ['id', 'name'], - where: { - [Op.and]: [ - { '$permissions.scopeId$': APPROVE_REPORTS }, - { '$permissions.regionId$': reportRegions }, - ], - }, - include: [ - { model: Permission, as: 'permissions', attributes: [] }, - ], - }); + const users = await usersWithPermissions(reportRegions, [APPROVE_REPORTS]); res.json(users); } catch (error) { await handleErrors(req, res, error, logContext); @@ -70,48 +47,79 @@ export async function submitReport(req, res) { res.sendStatus(204); } -export async function saveReport(req, res) { - // Temporary until saving of report is implemented - // eslint-disable-next-line no-console - console.log('save'); - res.sendStatus(204); +export async function getParticipants(req, res) { + const participants = await reportParticipants(); + res.json(participants); } +/** + * Retrieve an activity report + * + * @param {*} req - request + * @param {*} res - response + */ export async function getReport(req, res) { - const additionalData = { - additionalNotes: 'this is an additional note', - approvingManagers: [2], - }; + const { activityReportId } = req.params; + const report = await activityReportById(activityReportId); + if (!report) { + res.sendStatus(404); + } else { + res.json(report); + } +} + +/** + * save an activity report + * + * @param {*} req - request + * @param {*} res - response + */ +export async function saveReport(req, res) { + try { + const newReport = req.body; + if (!newReport) { + res.sendStatus(400); + return; + } + const userId = parseInt(req.session.userId, 10); + const { activityReportId } = req.params; + + if (!await reportExists(activityReportId)) { + res.sendStatus(404); + return; + } + + newReport.userId = userId; + newReport.lastUpdatedById = userId; - const report = { - activityMethod: 'in-person', - activityType: ['training'], - duration: '1', - endDate: '11/11/2020', - grantees: ['Grantee Name 1'], - numberOfParticipants: '1', - participantCategory: 'grantee', - participants: ['other participant 1'], - reason: ['reason 1'], - otherUsers: ['user 1'], - programTypes: ['program type 1'], - requester: 'grantee', - resourcesUsed: 'eclkcurl', - startDate: '11/11/2020', - targetPopulations: ['target pop 1'], - topics: ['first'], - }; + const report = await createOrUpdate(newReport, activityReportId); + res.json(report); + } catch (error) { + await handleErrors(req, res, error, logContext); + } +} - const pageState = { - 1: 'Complete', - 2: 'Complete', - 3: 'Complete', - 4: 'Complete', - }; +/** + * create an activity report + * + * @param {*} req - request + * @param {*} res - response + */ +export async function createReport(req, res) { + try { + const newReport = req.body; + if (!newReport) { + res.sendStatus(400); + return; + } + const userId = parseInt(req.session.userId, 10); + newReport.status = 'draft'; + newReport.userId = userId; + newReport.lastUpdatedById = userId; - res.json({ - report, - pageState, - additionalData, - }); + const report = await createOrUpdate(newReport); + res.json(report); + } catch (error) { + await handleErrors(req, res, error, logContext); + } } diff --git a/src/routes/activityReports/handlers.test.js b/src/routes/activityReports/handlers.test.js new file mode 100644 index 0000000000..8bc2c3ab29 --- /dev/null +++ b/src/routes/activityReports/handlers.test.js @@ -0,0 +1,236 @@ +import db, { + ActivityReport, ActivityParticipant, User, Permission, Grant, Grantee, NonGrantee, +} from '../../models'; +import { + getApprovers, saveReport, createReport, getReport, getParticipants, +} from './handlers'; + +import SCOPES from '../../middleware/scopeConstants'; + +const mockUser = { + id: 100, + homeRegionId: 1, + permissions: [ + { + userId: 100, + regionId: 1, + scopeId: SCOPES.READ_WRITE_REPORTS, + }, + { + userId: 100, + regionId: 2, + scopeId: SCOPES.READ_WRITE_REPORTS, + }, + ], +}; + +const mockSession = jest.fn(); +mockSession.userId = mockUser.id; + +const mockResponse = { + json: jest.fn(), + sendStatus: jest.fn(), + status: jest.fn(() => ({ + end: jest.fn(), + })), +}; + +const reportObject = { + participantType: 'gratnee', + status: 'draft', + userId: mockUser.id, + lastUpdatedById: mockUser.id, + resourcesUsed: 'test', +}; + +describe('Activity Report handlers', () => { + beforeEach(async () => { + await User.create(mockUser, { include: [{ model: Permission, as: 'permissions' }] }); + }); + + afterEach(async () => { + await ActivityParticipant.destroy({ where: {} }); + await ActivityReport.destroy({ where: {} }); + await User.destroy({ where: {} }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(async () => { + db.sequelize.close(); + }); + + describe('getApprovers', () => { + const approverOne = { + id: 50, + name: 'region 1', + permissions: [ + { + userId: 50, + regionId: 1, + scopeId: SCOPES.APPROVE_REPORTS, + }, + ], + }; + const approverTwo = { + id: 51, + name: 'region 2', + permissions: [ + { + userId: 51, + regionId: 2, + scopeId: SCOPES.APPROVE_REPORTS, + }, + ], + }; + const approverThree = { + id: 53, + name: 'region 3', + permissions: [ + { + userId: 51, + regionId: 3, + scopeId: SCOPES.APPROVE_REPORTS, + }, + ], + }; + + beforeEach(async () => { + await User.create(approverOne, { include: [{ model: Permission, as: 'permissions' }] }); + await User.create(approverTwo, { include: [{ model: Permission, as: 'permissions' }] }); + await User.create(approverThree, { include: [{ model: Permission, as: 'permissions' }] }); + }); + + afterEach(async () => { + await User.destroy({ where: {} }); + }); + + it("returns a list of users that have approving permissions on the user's regions", async () => { + await getApprovers({ session: mockSession }, mockResponse); + expect(mockResponse.json).toHaveBeenCalledWith([{ id: 50, name: 'region 1' }, { id: 51, name: 'region 2' }]); + }); + }); + + describe('saveReport', () => { + it('updates an already saved report', async () => { + const res = await ActivityReport.create(reportObject); + const request = { + session: mockSession, + params: { activityReportId: res.dataValues.id }, + body: { + resourcesUsed: 'updated', + }, + }; + const expected = { + id: res.dataValues.id, + ...reportObject, + resourcesUsed: 'updated', + }; + + await saveReport(request, mockResponse); + expect(mockResponse.json).toHaveBeenCalledWith(expect.objectContaining(expected)); + }); + + it('handles reports that are not found', async () => { + const request = { + session: mockSession, + params: { activityReportId: 1000 }, + body: {}, + }; + await saveReport(request, mockResponse); + expect(mockResponse.sendStatus).toHaveBeenCalledWith(404); + }); + + it('handles empty requests', async () => { + const request = { + session: mockSession, + params: { activityReportId: 1000 }, + }; + await saveReport(request, mockResponse); + expect(mockResponse.sendStatus).toHaveBeenCalledWith(400); + }); + }); + + describe('createReport', () => { + afterAll(async () => { + await Grant.destroy({ where: {} }); + await Grantee.destroy({ where: {} }); + }); + + it('creates a new report', async () => { + const grantee = await Grantee.create({ name: 'test' }); + const grant = await Grant.create({ number: 1, granteeId: grantee.id }); + const beginningARCount = await ActivityReport.count(); + const report = { + participantType: 'grantee', + activityParticipants: [{ participantId: grant.id }], + }; + const request = { + body: report, + session: mockSession, + }; + + await createReport(request, mockResponse); + const endARCount = await ActivityReport.count(); + expect(endARCount - beginningARCount).toBe(1); + }); + + it('handles empty requests', async () => { + const request = { + session: mockSession, + }; + await createReport(request, mockResponse); + expect(mockResponse.sendStatus).toHaveBeenCalledWith(400); + }); + }); + + describe('getReport', () => { + it('sends a previously saved activity report', async () => { + const res = await ActivityReport.create(reportObject); + const request = { + session: mockSession, + params: { activityReportId: res.dataValues.id }, + }; + const expected = { + id: res.dataValues.id, + ...reportObject, + }; + + await getReport(request, mockResponse); + expect(mockResponse.json).toHaveBeenCalledWith(expect.objectContaining(expected)); + }); + + it('handles reports that are not found', async () => { + const request = { + session: mockSession, + params: { activityReportId: 1000 }, + }; + await getReport(request, mockResponse); + expect(mockResponse.sendStatus).toHaveBeenCalledWith(404); + }); + }); + + describe('getParticipants', () => { + afterEach(async () => { + Grantee.destroy({ where: {} }); + Grant.destroy({ where: {} }); + NonGrantee.destroy({ where: {} }); + }); + + it('retrieves grantees as well as nonGrantees', async () => { + const grantee = await Grantee.create({ name: 'test' }); + const grant = await Grant.create({ number: 1, granteeId: grantee.id }); + const nonGrantee = await NonGrantee.create({ name: 'nonGrantee' }); + + const expected = { + grants: [{ participantId: grant.id, name: 'test - 1' }], + nonGrantees: [{ participantId: nonGrantee.id, name: 'nonGrantee' }], + }; + + await getParticipants({ session: mockSession }, mockResponse); + expect(mockResponse.json).toHaveBeenCalledWith(expected); + }); + }); +}); diff --git a/src/routes/activityReports/index.js b/src/routes/activityReports/index.js index 0838ecb11f..9db4ac0c03 100644 --- a/src/routes/activityReports/index.js +++ b/src/routes/activityReports/index.js @@ -1,6 +1,6 @@ import express from 'express'; import { - getApprovers, submitReport, saveReport, getReport, + getApprovers, submitReport, saveReport, createReport, getReport, getParticipants, } from './handlers'; const router = express.Router(); @@ -9,9 +9,11 @@ const router = express.Router(); * API for activity reports */ -router.post('/', saveReport); +router.post('/', createReport); router.get('/approvers', getApprovers); -router.post('/submit', submitReport); -router.get('/:reportId', getReport); +router.get('/participants', getParticipants); +router.get('/:activityReportId', getReport); +router.put('/:activityReportId', saveReport); +router.post('/:activityReportId/submit', submitReport); export default router; diff --git a/src/routes/admin/user.js b/src/routes/admin/user.js index e8bf1481e7..d1019fb4d1 100644 --- a/src/routes/admin/user.js +++ b/src/routes/admin/user.js @@ -1,7 +1,7 @@ -import { Op } from 'sequelize'; import { User, Permission, sequelize, } from '../../models'; +import { userById } from '../../lib/users'; import handleErrors from '../../lib/apiErrorHandler'; const namespace = 'SERVICE:USER'; @@ -10,18 +10,6 @@ const logContext = { namespace, }; -export const userById = async (userId) => User.findOne({ - attributes: ['id', 'name', 'hsesUserId', 'email', 'phoneNumber', 'homeRegionId', 'role'], - where: { - id: { - [Op.eq]: userId, - }, - }, - include: [ - { model: Permission, as: 'permissions', attributes: ['userId', 'scopeId', 'regionId'] }, - ], -}); - /** * Gets one user from the database. * diff --git a/src/routes/apiDirectory.js b/src/routes/apiDirectory.js index ecc59d8fc9..d969c3ebc3 100644 --- a/src/routes/apiDirectory.js +++ b/src/routes/apiDirectory.js @@ -6,7 +6,7 @@ import authMiddleware, { login } from '../middleware/authMiddleware'; import handleErrors from '../lib/apiErrorHandler'; import adminRouter from './user'; import activityReportsRouter from './activityReports'; -import { userById } from './admin/user'; +import { userById } from '../lib/users'; export const loginPath = '/login'; diff --git a/src/seeders/20210107170250-participants.js b/src/seeders/20210107170250-participants.js new file mode 100644 index 0000000000..c62ee9c6e4 --- /dev/null +++ b/src/seeders/20210107170250-participants.js @@ -0,0 +1,133 @@ +const now = new Date().toISOString(); + +const grantees = [ + { + id: 1, + name: 'Stroman, Cronin and Boehm', + createdAt: now, + updatedAt: now, + }, + { + id: 2, + name: 'Johnston-Romaguera', + createdAt: now, + updatedAt: now, + }, + { + id: 3, + name: 'Stroman, Cronin and Boehm', + createdAt: now, + updatedAt: now, + }, +]; + +const grants = [ + { + granteeId: 1, + number: '14CH11111', + createdAt: now, + updatedAt: now, + }, + { + granteeId: 1, + number: '14CH22222', + createdAt: now, + updatedAt: now, + }, + { + granteeId: 1, + number: '14CH33333', + createdAt: now, + updatedAt: now, + }, + { + granteeId: 2, + number: '14CH44444', + createdAt: now, + updatedAt: now, + }, + { + granteeId: 2, + number: '14CH55555', + createdAt: now, + updatedAt: now, + }, + { + granteeId: 2, + number: '14CH66666', + createdAt: now, + updatedAt: now, + }, + { + granteeId: 3, + number: '14CH77777', + createdAt: now, + updatedAt: now, + }, + { + granteeId: 3, + number: '14CH88888', + createdAt: now, + updatedAt: now, + }, + { + granteeId: 3, + number: '14CH99999', + createdAt: now, + updatedAt: now, + }, +]; + +const nonGrantees = [ + { + name: 'CCDF / Child Care Administrator', + }, + { + name: 'Head Start Collaboration Office', + }, + { + name: 'QRIS System', + }, + { + name: 'Regional Head Start Association', + }, + { + name: 'Regional TTA/Other Specialists', + }, + { + name: 'State CCR&R', + }, + { + name: 'State Early Learning Standards', + }, + { + name: 'State Education System', + }, + { + name: 'State Health System', + }, + { + name: 'State Head Start Association', + }, + { + name: 'State Professional Development / Continuing Education', + }, +]; + +module.exports = { + up: async (queryInterface) => { + await queryInterface.bulkDelete('Grants', null, {}); + await queryInterface.bulkDelete('Grantees', null, {}); + await queryInterface.bulkDelete('NonGrantees', null, {}); + + await queryInterface.bulkInsert('Grantees', grantees, {}); + await queryInterface.bulkInsert('Grants', grants, {}); + await queryInterface.bulkInsert('NonGrantees', nonGrantees, {}); + }, + + down: async (queryInterface) => { + await queryInterface.bulkDelete('Grants', null, {}); + await queryInterface.bulkDelete('Grantees', null, {}); + await queryInterface.bulkDelete('NonGrantees', null, {}); + }, +}; diff --git a/yarn.lock b/yarn.lock index be53ea81d9..3f40bc7768 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7085,7 +7085,7 @@ moment-timezone@^0.5.21: dependencies: moment ">= 2.9.0" -"moment@>= 2.9.0", moment@^2.24.0: +"moment@>= 2.9.0", moment@^2.24.0, moment@^2.29.1: version "2.29.1" resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3" integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ== From 432110ddd819bbf2a7700397d74ad49bd5292cb2 Mon Sep 17 00:00:00 2001 From: Josh Salisbury Date: Wed, 13 Jan 2021 13:54:50 -0600 Subject: [PATCH 08/25] Moving around seeders Grants/grantees are already seeded, changing participants to just be nonGrantees --- package.json | 3 - src/seeders/20210107170250-nonGrantees.js | 45 +++++++ src/seeders/20210107170250-participants.js | 133 --------------------- 3 files changed, 45 insertions(+), 136 deletions(-) create mode 100644 src/seeders/20210107170250-nonGrantees.js delete mode 100644 src/seeders/20210107170250-participants.js diff --git a/package.json b/package.json index 865719ac4f..5c6bf620e6 100644 --- a/package.json +++ b/package.json @@ -148,11 +148,8 @@ "http-codes": "^1.0.0", "lodash": "^4.17.20", "memorystore": "^1.6.2", -<<<<<<< HEAD "moment": "^2.29.1", -======= "mz": "^2.7.0", ->>>>>>> main "newrelic": "^7.0.1", "pg": "^8.3.3", "puppeteer": "^5.3.1", diff --git a/src/seeders/20210107170250-nonGrantees.js b/src/seeders/20210107170250-nonGrantees.js new file mode 100644 index 0000000000..7bee280c9d --- /dev/null +++ b/src/seeders/20210107170250-nonGrantees.js @@ -0,0 +1,45 @@ +const nonGrantees = [ + { + name: 'CCDF / Child Care Administrator', + }, + { + name: 'Head Start Collaboration Office', + }, + { + name: 'QRIS System', + }, + { + name: 'Regional Head Start Association', + }, + { + name: 'Regional TTA/Other Specialists', + }, + { + name: 'State CCR&R', + }, + { + name: 'State Early Learning Standards', + }, + { + name: 'State Education System', + }, + { + name: 'State Health System', + }, + { + name: 'State Head Start Association', + }, + { + name: 'State Professional Development / Continuing Education', + }, +]; + +module.exports = { + up: async (queryInterface) => { + await queryInterface.bulkInsert('NonGrantees', nonGrantees, {}); + }, + + down: async (queryInterface) => { + await queryInterface.bulkDelete('NonGrantees', null, {}); + }, +}; diff --git a/src/seeders/20210107170250-participants.js b/src/seeders/20210107170250-participants.js deleted file mode 100644 index c62ee9c6e4..0000000000 --- a/src/seeders/20210107170250-participants.js +++ /dev/null @@ -1,133 +0,0 @@ -const now = new Date().toISOString(); - -const grantees = [ - { - id: 1, - name: 'Stroman, Cronin and Boehm', - createdAt: now, - updatedAt: now, - }, - { - id: 2, - name: 'Johnston-Romaguera', - createdAt: now, - updatedAt: now, - }, - { - id: 3, - name: 'Stroman, Cronin and Boehm', - createdAt: now, - updatedAt: now, - }, -]; - -const grants = [ - { - granteeId: 1, - number: '14CH11111', - createdAt: now, - updatedAt: now, - }, - { - granteeId: 1, - number: '14CH22222', - createdAt: now, - updatedAt: now, - }, - { - granteeId: 1, - number: '14CH33333', - createdAt: now, - updatedAt: now, - }, - { - granteeId: 2, - number: '14CH44444', - createdAt: now, - updatedAt: now, - }, - { - granteeId: 2, - number: '14CH55555', - createdAt: now, - updatedAt: now, - }, - { - granteeId: 2, - number: '14CH66666', - createdAt: now, - updatedAt: now, - }, - { - granteeId: 3, - number: '14CH77777', - createdAt: now, - updatedAt: now, - }, - { - granteeId: 3, - number: '14CH88888', - createdAt: now, - updatedAt: now, - }, - { - granteeId: 3, - number: '14CH99999', - createdAt: now, - updatedAt: now, - }, -]; - -const nonGrantees = [ - { - name: 'CCDF / Child Care Administrator', - }, - { - name: 'Head Start Collaboration Office', - }, - { - name: 'QRIS System', - }, - { - name: 'Regional Head Start Association', - }, - { - name: 'Regional TTA/Other Specialists', - }, - { - name: 'State CCR&R', - }, - { - name: 'State Early Learning Standards', - }, - { - name: 'State Education System', - }, - { - name: 'State Health System', - }, - { - name: 'State Head Start Association', - }, - { - name: 'State Professional Development / Continuing Education', - }, -]; - -module.exports = { - up: async (queryInterface) => { - await queryInterface.bulkDelete('Grants', null, {}); - await queryInterface.bulkDelete('Grantees', null, {}); - await queryInterface.bulkDelete('NonGrantees', null, {}); - - await queryInterface.bulkInsert('Grantees', grantees, {}); - await queryInterface.bulkInsert('Grants', grants, {}); - await queryInterface.bulkInsert('NonGrantees', nonGrantees, {}); - }, - - down: async (queryInterface) => { - await queryInterface.bulkDelete('Grants', null, {}); - await queryInterface.bulkDelete('Grantees', null, {}); - await queryInterface.bulkDelete('NonGrantees', null, {}); - }, -}; From fa1f120c21021615b3fdf83a2641888a52c75ae3 Mon Sep 17 00:00:00 2001 From: Josh Salisbury Date: Wed, 13 Jan 2021 17:16:44 -0600 Subject: [PATCH 09/25] Fix test Tests interactions with the DB are tricky because it is kind of a free for all at this point. We will want to come back around and clean up testing in the future --- src/models/grantee.js | 2 +- src/routes/activityReports/handlers.test.js | 165 ++++++++++++++------ src/seeders/20201209172017-approvers.js | 4 +- 3 files changed, 119 insertions(+), 52 deletions(-) diff --git a/src/models/grantee.js b/src/models/grantee.js index 3b94560942..573e93be8e 100644 --- a/src/models/grantee.js +++ b/src/models/grantee.js @@ -18,7 +18,7 @@ module.exports = (sequelize, DataTypes) => { name: { type: DataTypes.STRING, allowNull: false, - unique: true, + unique: false, }, }, { sequelize, diff --git a/src/routes/activityReports/handlers.test.js b/src/routes/activityReports/handlers.test.js index 8bc2c3ab29..407bb13308 100644 --- a/src/routes/activityReports/handlers.test.js +++ b/src/routes/activityReports/handlers.test.js @@ -1,5 +1,5 @@ import db, { - ActivityReport, ActivityParticipant, User, Permission, Grant, Grantee, NonGrantee, + ActivityReport, ActivityParticipant, User, Permission, } from '../../models'; import { getApprovers, saveReport, createReport, getReport, getParticipants, @@ -13,12 +13,12 @@ const mockUser = { permissions: [ { userId: 100, - regionId: 1, + regionId: 5, scopeId: SCOPES.READ_WRITE_REPORTS, }, { userId: 100, - regionId: 2, + regionId: 6, scopeId: SCOPES.READ_WRITE_REPORTS, }, ], @@ -44,72 +44,70 @@ const reportObject = { }; describe('Activity Report handlers', () => { - beforeEach(async () => { - await User.create(mockUser, { include: [{ model: Permission, as: 'permissions' }] }); + let user; + + beforeAll(async () => { + user = await User.create(mockUser, { include: [{ model: Permission, as: 'permissions' }] }); }); - afterEach(async () => { + afterAll(async () => { await ActivityParticipant.destroy({ where: {} }); await ActivityReport.destroy({ where: {} }); - await User.destroy({ where: {} }); + await User.destroy({ where: { id: user.id } }); + db.sequelize.close(); }); afterEach(() => { jest.clearAllMocks(); }); - afterAll(async () => { - db.sequelize.close(); - }); - describe('getApprovers', () => { const approverOne = { id: 50, - name: 'region 1', + name: 'region 5', permissions: [ { userId: 50, - regionId: 1, + regionId: 5, scopeId: SCOPES.APPROVE_REPORTS, }, ], }; const approverTwo = { id: 51, - name: 'region 2', - permissions: [ - { - userId: 51, - regionId: 2, - scopeId: SCOPES.APPROVE_REPORTS, - }, - ], - }; - const approverThree = { - id: 53, - name: 'region 3', + name: 'region 6', permissions: [ { userId: 51, - regionId: 3, + regionId: 6, scopeId: SCOPES.APPROVE_REPORTS, }, ], }; + const approvers = [ + { + id: 50, + name: 'region 5', + }, + { + id: 51, + name: 'region 6', + }, + ]; + beforeEach(async () => { await User.create(approverOne, { include: [{ model: Permission, as: 'permissions' }] }); await User.create(approverTwo, { include: [{ model: Permission, as: 'permissions' }] }); - await User.create(approverThree, { include: [{ model: Permission, as: 'permissions' }] }); }); afterEach(async () => { - await User.destroy({ where: {} }); + await User.destroy({ where: { id: [50, 51] } }); }); it("returns a list of users that have approving permissions on the user's regions", async () => { await getApprovers({ session: mockSession }, mockResponse); - expect(mockResponse.json).toHaveBeenCalledWith([{ id: 50, name: 'region 1' }, { id: 51, name: 'region 2' }]); + expect(mockResponse.json).toHaveBeenCalledWith(approvers); }); }); @@ -154,18 +152,11 @@ describe('Activity Report handlers', () => { }); describe('createReport', () => { - afterAll(async () => { - await Grant.destroy({ where: {} }); - await Grantee.destroy({ where: {} }); - }); - it('creates a new report', async () => { - const grantee = await Grantee.create({ name: 'test' }); - const grant = await Grant.create({ number: 1, granteeId: grantee.id }); const beginningARCount = await ActivityReport.count(); const report = { participantType: 'grantee', - activityParticipants: [{ participantId: grant.id }], + activityParticipants: [{ participantId: 1 }], }; const request = { body: report, @@ -213,24 +204,100 @@ describe('Activity Report handlers', () => { }); describe('getParticipants', () => { - afterEach(async () => { - Grantee.destroy({ where: {} }); - Grant.destroy({ where: {} }); - NonGrantee.destroy({ where: {} }); - }); + const expectedNonGrantees = [ + { + name: 'CCDF / Child Care Administrator', + participantId: 1, + }, + { + name: 'Head Start Collaboration Office', + participantId: 2, + }, + { + name: 'QRIS System', + participantId: 3, + }, + { + name: 'Regional Head Start Association', + participantId: 4, + }, + { + name: 'Regional TTA/Other Specialists', + participantId: 5, + }, + { + name: 'State CCR&R', + participantId: 6, + }, + { + name: 'State Early Learning Standards', + participantId: 7, + }, + { + name: 'State Education System', + participantId: 8, + }, + { + name: 'State Health System', + participantId: 9, + }, + { + name: 'State Head Start Association', + participantId: 10, + }, + { + name: 'State Professional Development / Continuing Education', + participantId: 11, + }, + ]; - it('retrieves grantees as well as nonGrantees', async () => { - const grantee = await Grantee.create({ name: 'test' }); - const grant = await Grant.create({ number: 1, granteeId: grantee.id }); - const nonGrantee = await NonGrantee.create({ name: 'nonGrantee' }); + const expectedGrants = [ + { + participantId: 1, + name: 'Grantee Name - 14CH1234', + }, + { + participantId: 2, + name: 'Stroman, Cronin and Boehm - 14CH10000', + }, + { + participantId: 3, + name: 'Jakubowski-Keebler - 14CH00001', + }, + { + participantId: 4, + name: 'Johnston-Romaguera - 14CH00002', + }, + { + participantId: 5, + name: 'Johnston-Romaguera - 14CH00003', + }, + { + participantId: 6, + name: 'Agency 1, Inc. - 09CH011111', + }, + { + participantId: 7, + name: 'Agency 2, Inc. - 09CH022222', + }, + { + participantId: 8, + name: 'Agency 3, Inc. - 09CH033333', + }, + { + participantId: 9, + name: 'Agency 4, Inc. - 09HP044444', + }, + ]; + it('retrieves grantees as well as nonGrantees', async () => { const expected = { - grants: [{ participantId: grant.id, name: 'test - 1' }], - nonGrantees: [{ participantId: nonGrantee.id, name: 'nonGrantee' }], + grants: expectedGrants, + nonGrantees: expectedNonGrantees, }; await getParticipants({ session: mockSession }, mockResponse); - expect(mockResponse.json).toHaveBeenCalledWith(expected); + expect(mockResponse.json).toHaveBeenCalledWith(expect.objectContaining(expected)); }); }); }); diff --git a/src/seeders/20201209172017-approvers.js b/src/seeders/20201209172017-approvers.js index c2732c1d2b..48be10fcd3 100644 --- a/src/seeders/20201209172017-approvers.js +++ b/src/seeders/20201209172017-approvers.js @@ -23,12 +23,12 @@ const permissions = [ }, { userId: 3, - regionId: 1, + regionId: 2, scopeId: APPROVE_REPORTS, }, { userId: 4, - regionId: 2, + regionId: 3, scopeId: APPROVE_REPORTS, }, ]; From 04e2024bfe1c93f5a579e787cf47eec8851023da Mon Sep 17 00:00:00 2001 From: kryswisnaskas Date: Thu, 14 Jan 2021 10:06:26 -0500 Subject: [PATCH 10/25] Change express-session to cookie-session --- src/app.js | 31 +++++++++++++++---------------- src/routes/apiDirectory.js | 2 +- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/src/app.js b/src/app.js index 6d4738f588..7bd6887008 100644 --- a/src/app.js +++ b/src/app.js @@ -2,8 +2,7 @@ import {} from 'dotenv/config'; import express from 'express'; import helmet from 'helmet'; import axios from 'axios'; -import session from 'express-session'; -import memorystore from 'memorystore'; +import cookieSession from 'cookie-session'; import path from 'path'; import join from 'url-join'; import { INTERNAL_SERVER_ERROR } from 'http-codes'; @@ -16,26 +15,21 @@ import findOrCreateUser from './services/accessValidation'; import logger, { requestLogger } from './logger'; const app = express(); -const MemoryStore = memorystore(session); + const oauth2CallbackPath = '/oauth2-client/login/oauth2/code/'; app.use(requestLogger); app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use(helmet()); -app.use(session({ - secret: process.env.SESSION_SECRET, - key: 'session', - cookie: { - httpOnly: true, - sameSite: 'lax', - }, - rolling: true, - store: new MemoryStore({ // Potentially change this to a different store - checkPeriod: 86400000, // prune expired entries every 24h - }), - saveUninitialized: false, - resave: false, + +app.use(cookieSession({ + name: 'session', + keys: [process.env.SESSION_SECRET], + + // Cookie Options + httpOnly: false, + sameSite: 'lax', })); if (process.env.NODE_ENV === 'production') { @@ -65,6 +59,7 @@ app.get(oauth2CallbackPath, async (req, res) => { }); req.session.userId = dbUser.id; + logger.info(`role: ${req.session.userId} ${req.session.referrerPath}`); res.redirect(join(process.env.TTA_SMART_HUB_URI, req.session.referrerPath)); } catch (error) { @@ -72,6 +67,10 @@ app.get(oauth2CallbackPath, async (req, res) => { res.status(INTERNAL_SERVER_ERROR).end(); } }); +app.use((req, res, next) => { + req.session.nowInMinutes = Math.floor(Date.now() / 60e3); + next(); +}); if (process.env.NODE_ENV === 'production') { app.use('*', (req, res) => { diff --git a/src/routes/apiDirectory.js b/src/routes/apiDirectory.js index ecc59d8fc9..786be0c8cb 100644 --- a/src/routes/apiDirectory.js +++ b/src/routes/apiDirectory.js @@ -34,7 +34,7 @@ router.get('/user', async (req, res) => { }); router.get('/logout', (req, res) => { - req.session.destroy(); + req.session = null; res.sendStatus(204); }); From 9c5b32cfcf53d1f720b04fdef759975ba785ed8b Mon Sep 17 00:00:00 2001 From: kryswisnaskas Date: Thu, 14 Jan 2021 10:19:27 -0500 Subject: [PATCH 11/25] Change express-session to cookie-session --- package.json | 3 +-- yarn.lock | 60 +++++++++++++++++++++++++--------------------------- 2 files changed, 30 insertions(+), 33 deletions(-) diff --git a/package.json b/package.json index 560d2b6447..efb591ccbb 100644 --- a/package.json +++ b/package.json @@ -136,18 +136,17 @@ "axios": "^0.21.1", "chromedriver": "^87.0.0", "client-oauth2": "^4.3.3", + "cookie-session": "^1.4.0", "cron": "^1.8.2", "csv-parse": "^4.14.1", "cucumber-html-reporter": "^5.2.0", "dotenv": "^8.2.0", "express": "^4.17.1", - "express-session": "^1.17.1", "express-unless": "^0.5.0", "express-winston": "^4.0.5", "helmet": "^4.1.0", "http-codes": "^1.0.0", "lodash": "^4.17.20", - "memorystore": "^1.6.2", "mz": "^2.7.0", "newrelic": "^7.0.1", "pg": "^8.3.3", diff --git a/yarn.lock b/yarn.lock index bd3eb15fe8..064739ed91 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3099,6 +3099,15 @@ convert-source-map@^1.1.0, convert-source-map@^1.4.0, convert-source-map@^1.6.0, dependencies: safe-buffer "~5.1.1" +cookie-session@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/cookie-session/-/cookie-session-1.4.0.tgz#c325aea685ceb9c8e4fd00b0313a46d547747380" + integrity sha512-0hhwD+BUIwMXQraiZP/J7VP2YFzqo6g4WqZlWHtEHQ22t0MeZZrNBSCxC1zcaLAs8ApT3BzAKizx9gW/AP9vNA== + dependencies: + cookies "0.8.0" + debug "2.6.9" + on-headers "~1.0.2" + cookie-signature@1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" @@ -3114,6 +3123,14 @@ cookiejar@^2.1.0: resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.2.tgz#dd8a235530752f988f9a0844f3fc589e3111125c" integrity sha512-Mw+adcfzPxcPeI+0WlvRrr/3lGVO0bD75SxX6811cxSh1Wbxx7xZBGK1eVtDf6si8rg2lhnUjsVLMFMfbRIuwA== +cookies@0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/cookies/-/cookies-0.8.0.tgz#1293ce4b391740a8406e3c9870e828c4b54f3f90" + integrity sha512-8aPsApQfebXnuI+537McwYsDtjVxGm8gTIzQI3FDW6t5t/DAhERxtnbEPN/8RX+uZthoz4eCOgloXaE5cYyNow== + dependencies: + depd "~2.0.0" + keygrip "~1.1.0" + copy-descriptor@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" @@ -4196,20 +4213,6 @@ expect@^26.6.2: jest-message-util "^26.6.2" jest-regex-util "^26.0.0" -express-session@^1.17.1: - version "1.17.1" - resolved "https://registry.yarnpkg.com/express-session/-/express-session-1.17.1.tgz#36ecbc7034566d38c8509885c044d461c11bf357" - integrity sha512-UbHwgqjxQZJiWRTMyhvWGvjBQduGCSBDhhZXYenziMFjxst5rMV+aJZ6hKPHZnPyHGsrqRICxtX8jtEbm/z36Q== - dependencies: - cookie "0.4.0" - cookie-signature "1.0.6" - debug "2.6.9" - depd "~2.0.0" - on-headers "~1.0.2" - parseurl "~1.3.3" - safe-buffer "5.2.0" - uid-safe "~2.1.5" - express-unless@^0.5.0: version "0.5.0" resolved "https://registry.yarnpkg.com/express-unless/-/express-unless-0.5.0.tgz#c2ece477f4155089143dbb869d07c57c5eb6ab9b" @@ -6646,6 +6649,13 @@ jws@^4.0.0: jwa "^2.0.0" safe-buffer "^5.0.1" +keygrip@~1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/keygrip/-/keygrip-1.1.0.tgz#871b1681d5e159c62a445b0c74b615e0917e7226" + integrity sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ== + dependencies: + tsscmp "1.0.6" + keyv@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9" @@ -8349,11 +8359,6 @@ querystring@0.2.0: resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620" integrity sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA= -random-bytes@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/random-bytes/-/random-bytes-1.0.0.tgz#4f68a1dc0ae58bd3fb95848c30324db75d64360b" - integrity sha1-T2ih3Arli9P7lYSMMDJNt11kNgs= - randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5: version "2.1.0" resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" @@ -8886,11 +8891,6 @@ safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== -safe-buffer@5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.0.tgz#b74daec49b1148f88c64b68d49b1e815c1f2f519" - integrity sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg== - safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.0, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" @@ -10059,6 +10059,11 @@ tslib@^2.0.0, tslib@^2.0.3: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.0.3.tgz#8e0741ac45fc0c226e58a17bfc3e64b9bc6ca61c" integrity sha512-uZtkfKblCEQtZKBF6EBXVZeQNl82yqtDQdv+eck8u7tdPxjLu2/lp5/uPW+um2tpuxINHWy3GhiccY7QgEaVHQ== +tsscmp@1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb" + integrity sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA== + tsutils@^3.17.1: version "3.17.1" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.17.1.tgz#ed719917f11ca0dee586272b2ac49e015a2dd759" @@ -10150,13 +10155,6 @@ uglify-js@^3.1.4: resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.12.2.tgz#c7ae89da0ed1bb58999c7fce07190b347fdbdaba" integrity sha512-rWYleAvfJPjduYCt+ELvzybNah/zIkRteGXIBO8X0lteRZPGladF61hFi8tU7qKTsF7u6DUQCtT9k00VlFOgkg== -uid-safe@~2.1.5: - version "2.1.5" - resolved "https://registry.yarnpkg.com/uid-safe/-/uid-safe-2.1.5.tgz#2b3d5c7240e8fc2e58f8aa269e5ee49c0857bd3a" - integrity sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA== - dependencies: - random-bytes "~1.0.0" - umzug@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/umzug/-/umzug-2.3.0.tgz#0ef42b62df54e216b05dcaf627830a6a8b84a184" From 7affa8f4381202a22d3d6bbf3e3c439859184586 Mon Sep 17 00:00:00 2001 From: Josh Salisbury Date: Thu, 14 Jan 2021 09:46:40 -0600 Subject: [PATCH 12/25] Fix axe/cucumber/backend tests --- axe-urls | 10 +- .../features/steps/activityReportSteps.js | 2 +- cucumber/features/steps/homePageSteps.js | 4 +- .../ActivityReport/Pages/topicsResources.js | 2 +- src/routes/activityReports/handlers.test.js | 100 +----------------- 5 files changed, 10 insertions(+), 108 deletions(-) diff --git a/axe-urls b/axe-urls index 42b0286b4b..8cd81113d3 100644 --- a/axe-urls +++ b/axe-urls @@ -1,7 +1,7 @@ http://localhost:3000, -http://localhost:3000/activity-reports/activity-summary, -http://localhost:3000/activity-reports/topics-resources, -http://localhost:3000/activity-reports/goals-objectives, -http://localhost:3000/activity-reports/next-steps, -http://localhost:3000/activity-reports/review, +http://localhost:3000/activity-reports/new/activity-summary, +http://localhost:3000/activity-reports/new/topics-resources, +http://localhost:3000/activity-reports/new/goals-objectives, +http://localhost:3000/activity-reports/new/next-steps, +http://localhost:3000/activity-reports/new/review, http://localhost:3000/admin diff --git a/cucumber/features/steps/activityReportSteps.js b/cucumber/features/steps/activityReportSteps.js index b524bae1bc..df6cc36f34 100644 --- a/cucumber/features/steps/activityReportSteps.js +++ b/cucumber/features/steps/activityReportSteps.js @@ -7,7 +7,7 @@ const scope = require('../support/scope'); Given('I am on the activity reports page', async () => { const page = scope.context.currentPage; - const selector = 'a[href$="activity-reports"]'; + const selector = 'a[href$="activity-reports/new"]'; await Promise.all([ page.waitForNavigation(), page.click(selector), diff --git a/cucumber/features/steps/homePageSteps.js b/cucumber/features/steps/homePageSteps.js index 7a53b3e102..38dfd55d64 100644 --- a/cucumber/features/steps/homePageSteps.js +++ b/cucumber/features/steps/homePageSteps.js @@ -22,7 +22,7 @@ Given('I am logged in', async () => { const loginLinkSelector = 'a[href$="api/login"]'; // const homeLinkSelector = 'a[href$="/"]'; - const activityReportsSelector = 'a[href$="activity-reports"]'; + const activityReportsSelector = 'a[href$="activity-reports/new"]'; await page.goto(scope.uri); await page.waitForSelector('em'); // Page title @@ -52,7 +52,7 @@ Then('I see {string} message', async (string) => { Then('I see {string} link', async (string) => { const page = scope.context.currentPage; - const selector = 'a[href$="activity-reports"]'; + const selector = 'a[href$="activity-reports/new"]'; await page.waitForSelector(selector); const value = await page.$eval(selector, (el) => el.textContent); diff --git a/frontend/src/pages/ActivityReport/Pages/topicsResources.js b/frontend/src/pages/ActivityReport/Pages/topicsResources.js index 724b5f2347..2212808e2e 100644 --- a/frontend/src/pages/ActivityReport/Pages/topicsResources.js +++ b/frontend/src/pages/ActivityReport/Pages/topicsResources.js @@ -47,7 +47,7 @@ const TopicsResources = ({ Enter the URL for OHS resource(s) used. https://eclkc.ohs.acf.hhs.gov/ { expect(mockResponse.sendStatus).toHaveBeenCalledWith(404); }); }); - - describe('getParticipants', () => { - const expectedNonGrantees = [ - { - name: 'CCDF / Child Care Administrator', - participantId: 1, - }, - { - name: 'Head Start Collaboration Office', - participantId: 2, - }, - { - name: 'QRIS System', - participantId: 3, - }, - { - name: 'Regional Head Start Association', - participantId: 4, - }, - { - name: 'Regional TTA/Other Specialists', - participantId: 5, - }, - { - name: 'State CCR&R', - participantId: 6, - }, - { - name: 'State Early Learning Standards', - participantId: 7, - }, - { - name: 'State Education System', - participantId: 8, - }, - { - name: 'State Health System', - participantId: 9, - }, - { - name: 'State Head Start Association', - participantId: 10, - }, - { - name: 'State Professional Development / Continuing Education', - participantId: 11, - }, - ]; - - const expectedGrants = [ - { - participantId: 1, - name: 'Grantee Name - 14CH1234', - }, - { - participantId: 2, - name: 'Stroman, Cronin and Boehm - 14CH10000', - }, - { - participantId: 3, - name: 'Jakubowski-Keebler - 14CH00001', - }, - { - participantId: 4, - name: 'Johnston-Romaguera - 14CH00002', - }, - { - participantId: 5, - name: 'Johnston-Romaguera - 14CH00003', - }, - { - participantId: 6, - name: 'Agency 1, Inc. - 09CH011111', - }, - { - participantId: 7, - name: 'Agency 2, Inc. - 09CH022222', - }, - { - participantId: 8, - name: 'Agency 3, Inc. - 09CH033333', - }, - { - participantId: 9, - name: 'Agency 4, Inc. - 09HP044444', - }, - ]; - - it('retrieves grantees as well as nonGrantees', async () => { - const expected = { - grants: expectedGrants, - nonGrantees: expectedNonGrantees, - }; - - await getParticipants({ session: mockSession }, mockResponse); - expect(mockResponse.json).toHaveBeenCalledWith(expect.objectContaining(expected)); - }); - }); }); From 293ef60d7face6b72eafb1c84f72bd475b1f082a Mon Sep 17 00:00:00 2001 From: Josh Salisbury Date: Thu, 14 Jan 2021 09:55:22 -0600 Subject: [PATCH 13/25] Revert "stash" This reverts commit fddb56b60ad58ae6eaa12b0a8bdafe61e0a191ce. --- frontend/package.json | 1 - frontend/src/components/DatePicker.js | 4 +- frontend/src/components/Header.js | 2 +- .../components/Navigator/__tests__/index.js | 3 - .../components/Navigator/components/Form.js | 7 -- .../Navigator/components/SideNav.css | 15 --- .../Navigator/components/SideNav.js | 18 +--- frontend/src/components/Navigator/index.js | 9 +- frontend/src/fetchers/activityReports.js | 17 +--- .../ActivityReport/Pages/activitySummary.js | 48 +++++----- .../ActivityReport/Pages/topicsResources.js | 14 +-- .../pages/ActivityReport/__tests__/index.js | 75 +++++++-------- frontend/src/pages/ActivityReport/index.js | 91 ++++++------------- frontend/src/setupTests.js | 2 - frontend/yarn.lock | 5 - 15 files changed, 99 insertions(+), 212 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 76255b1d97..a4d89d55aa 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,7 +29,6 @@ "react-router-prop-types": "^1.0.5", "react-scripts": "^3.4.4", "react-select": "^3.1.0", - "react-select-simple-value": "^1.2.1", "react-stickynode": "^3.0.4", "react-with-direction": "^1.3.1", "url-join": "^4.0.1", diff --git a/frontend/src/components/DatePicker.js b/frontend/src/components/DatePicker.js index beab959f6c..b1f66b1343 100644 --- a/frontend/src/components/DatePicker.js +++ b/frontend/src/components/DatePicker.js @@ -89,8 +89,8 @@ DateInput.propTypes = { }; DateInput.defaultProps = { - minDate: '', - maxDate: '', + minDate: undefined, + maxDate: undefined, disabled: false, openUp: false, required: true, diff --git a/frontend/src/components/Header.js b/frontend/src/components/Header.js index 280396361c..023f3251af 100644 --- a/frontend/src/components/Header.js +++ b/frontend/src/components/Header.js @@ -14,7 +14,7 @@ function Header({ authenticated, admin }) { Home , - + Activity Reports , ]; diff --git a/frontend/src/components/Navigator/__tests__/index.js b/frontend/src/components/Navigator/__tests__/index.js index adc55113f4..d02ab393a4 100644 --- a/frontend/src/components/Navigator/__tests__/index.js +++ b/frontend/src/components/Navigator/__tests__/index.js @@ -62,7 +62,6 @@ describe('Navigator', () => { updatePage={updatePage} currentPage={currentPage} onFormSubmit={onSubmit} - onSave={() => {}} /> , ); @@ -87,8 +86,6 @@ describe('Navigator', () => { const onSubmit = jest.fn(); renderNavigator('review', onSubmit); userEvent.click(screen.getByRole('button', { name: 'Continue' })); - await waitFor(() => screen.findByTestId('review')); - userEvent.click(screen.getByTestId('review')); await waitFor(() => expect(onSubmit).toHaveBeenCalled()); }); diff --git a/frontend/src/components/Navigator/components/Form.js b/frontend/src/components/Navigator/components/Form.js index 8f8775a2f6..52082ac54f 100644 --- a/frontend/src/components/Navigator/components/Form.js +++ b/frontend/src/components/Navigator/components/Form.js @@ -31,13 +31,6 @@ function Form({ return onUnmount; }, [saveForm]); - useEffect(() => { - const interval = setInterval(() => { - saveForm(getValuesRef.current()); - }, 1000 * 10); - return () => clearInterval(interval); - }, [saveForm]); - const hookForm = useForm({ mode: 'onChange', defaultValues: initialData, diff --git a/frontend/src/components/Navigator/components/SideNav.css b/frontend/src/components/Navigator/components/SideNav.css index 0479b303a6..685fc85f03 100644 --- a/frontend/src/components/Navigator/components/SideNav.css +++ b/frontend/src/components/Navigator/components/SideNav.css @@ -81,21 +81,6 @@ transition: 0.2s ease-in-out; } -.smart-hub--save-alert { - margin-top: 12px; -} - -.smart-hub--save-alert p { - font-family: SourceSansPro; - font-size: 14px; - color: #21272d; - line-height: 24px; -} - -.smart-hub--save-alert::before { - width: 4px; -} - .smart-hub--navigator-item:first-child .smart-hub--navigator-link-active { border-top-right-radius: 4px; } diff --git a/frontend/src/components/Navigator/components/SideNav.js b/frontend/src/components/Navigator/components/SideNav.js index 745348ba85..70234c3235 100644 --- a/frontend/src/components/Navigator/components/SideNav.js +++ b/frontend/src/components/Navigator/components/SideNav.js @@ -5,9 +5,8 @@ */ import React from 'react'; import PropTypes from 'prop-types'; -import moment from 'moment'; import Sticky from 'react-stickynode'; -import { Tag, Alert } from '@trussworks/react-uswds'; +import { Tag } from '@trussworks/react-uswds'; import { useMediaQuery } from 'react-responsive'; import { NavLink } from 'react-router-dom'; @@ -33,7 +32,7 @@ const tagClass = (state) => { }; function SideNav({ - pages, skipTo, skipToMessage, lastSaveTime, + pages, skipTo, skipToMessage, }) { const isMobile = useMediaQuery({ maxWidth: 640 }); const navItems = () => pages.map((page) => ( @@ -65,14 +64,6 @@ function SideNav({ {navItems()} - {lastSaveTime - && ( - - This report was automatically saved on - {' '} - {lastSaveTime.format('MM/DD/YYYY [at] h:mm a')} - - )} ); } @@ -87,11 +78,6 @@ SideNav.propTypes = { ).isRequired, skipTo: PropTypes.string.isRequired, skipToMessage: PropTypes.string.isRequired, - lastSaveTime: PropTypes.instanceOf(moment), -}; - -SideNav.defaultProps = { - lastSaveTime: undefined, }; export default SideNav; diff --git a/frontend/src/components/Navigator/index.js b/frontend/src/components/Navigator/index.js index c45e9b81bd..104eaa5d2b 100644 --- a/frontend/src/components/Navigator/index.js +++ b/frontend/src/components/Navigator/index.js @@ -5,7 +5,6 @@ */ import React, { useState, useCallback } from 'react'; import PropTypes from 'prop-types'; -import moment from 'moment'; import _ from 'lodash'; import { Grid } from '@trussworks/react-uswds'; @@ -27,11 +26,9 @@ function Navigator({ currentPage, updatePage, additionalData, - onSave, }) { const [formData, updateFormData] = useState(defaultValues); const [pageState, updatePageState] = useState(initialPageState); - const [lastSaveTime, updateLastSaveTime] = useState(); const page = pages.find((p) => p.path === currentPage); const submittedNavState = submitted ? SUBMITTED : null; const allComplete = _.every(pageState, (state) => state === COMPLETE); @@ -55,9 +52,7 @@ function Navigator({ const onSaveForm = useCallback((newData) => { updateFormData((oldData) => ({ ...oldData, ...newData })); - updateLastSaveTime(moment()); - onSave({ ...formData, ...newData }); - }, [updateFormData, onSave]); + }, [updateFormData]); const onContinue = () => { const newNavigatorState = { ...pageState }; @@ -76,7 +71,6 @@ function Navigator({ @@ -122,7 +116,6 @@ Navigator.propTypes = { currentPage: PropTypes.string.isRequired, updatePage: PropTypes.func.isRequired, additionalData: PropTypes.shape({}), - onSave: PropTypes.func.isRequired, }; Navigator.defaultProps = { diff --git a/frontend/src/fetchers/activityReports.js b/frontend/src/fetchers/activityReports.js index c5c5a52ac2..243ac7b9be 100644 --- a/frontend/src/fetchers/activityReports.js +++ b/frontend/src/fetchers/activityReports.js @@ -17,8 +17,8 @@ export const fetchApprovers = async () => { return res.json(); }; -export const submitReport = async (reportId, data, extraData) => { - const url = join(activityReportUrl, reportId, 'submit'); +export const submitReport = async (data, extraData) => { + const url = join(activityReportUrl, 'submit'); await fetch(url, { method: 'POST', credentials: 'same-origin', @@ -28,16 +28,3 @@ export const submitReport = async (reportId, data, extraData) => { }), }); }; - -export const saveReport = async (reportId, data) => { - await fetch(join(activityReportUrl, reportId), { - method: 'POST', - credentials: 'same-origin', - body: data, - }); -}; - -export const getReport = async (reportId) => { - const report = await fetch(join(activityReportUrl, reportId)); - return report.json(); -}; diff --git a/frontend/src/pages/ActivityReport/Pages/activitySummary.js b/frontend/src/pages/ActivityReport/Pages/activitySummary.js index 6d567f78cf..5cb9fda917 100644 --- a/frontend/src/pages/ActivityReport/Pages/activitySummary.js +++ b/frontend/src/pages/ActivityReport/Pages/activitySummary.js @@ -63,9 +63,9 @@ const ActivitySummary = ({ control, getValues, }) => { - const participantSelection = watch('participantCategory'); - const startDate = watch('startDate'); - const endDate = watch('endDate'); + const participantSelection = watch('participant-category'); + const startDate = watch('start-date'); + const endDate = watch('end-date'); const disableParticipant = participantSelection === ''; const nonGranteeSelected = participantSelection === 'non-grantee'; @@ -105,7 +105,7 @@ const ActivitySummary = ({
What TTA was provided? - {renderCheckbox('activityType', 'training', 'Training')} - {renderCheckbox('activityType', 'technical-assistance', 'Technical Assistance')} + {renderCheckbox('activity-type', 'training', 'Training')} + {renderCheckbox('activity-type', 'technical-assistance', 'Technical Assistance')}
@@ -258,7 +258,7 @@ const ActivitySummary = ({
Number of grantee participants involved @@ -316,13 +316,13 @@ const sections = [ title: 'Who was the activity for?', anchor: 'activity-for', items: [ - { label: 'Grantee or Non-grantee', name: 'participantCategory' }, + { label: 'Grantee or Non-grantee', name: 'participant-category' }, { label: 'Grantee name(s)', name: 'grantees' }, { label: 'Grantee number(s)', name: '' }, - { label: 'Collaborating specialist(s)', name: 'otherUsers' }, + { label: 'Collaborating specialist(s)', name: 'other-users' }, { label: 'CDI', name: 'cdi' }, - { label: 'Program type(s)', name: 'programTypes' }, - { label: 'Target Populations addressed', name: 'targetPopulations' }, + { label: 'Program type(s)', name: 'program-types' }, + { label: 'Target Populations addressed', name: 'target-populations' }, ], }, { @@ -338,8 +338,8 @@ const sections = [ title: 'Activity date', anchor: 'date', items: [ - { label: 'Start date', name: 'startDate' }, - { label: 'End date', name: 'endDate' }, + { label: 'Start date', name: 'start-date' }, + { label: 'End date', name: 'end-date' }, { label: 'Duration', name: 'duration' }, ], }, @@ -347,8 +347,8 @@ const sections = [ title: 'Training or Technical Assistance', anchor: 'tta', items: [ - { label: 'TTA Provided', name: 'activityType' }, - { label: 'Conducted', name: 'activityMethod' }, + { label: 'TTA Provided', name: 'activity-type' }, + { label: 'Conducted', name: 'activity-method' }, ], }, { @@ -356,7 +356,7 @@ const sections = [ anchor: 'other-participants', items: [ { label: 'Grantee participants', name: 'participants' }, - { label: 'Number of participants', name: 'numberOfParticipants' }, + { label: 'Number of participants', name: 'number-of-participants' }, ], }, ]; diff --git a/frontend/src/pages/ActivityReport/Pages/topicsResources.js b/frontend/src/pages/ActivityReport/Pages/topicsResources.js index 2212808e2e..822f80643a 100644 --- a/frontend/src/pages/ActivityReport/Pages/topicsResources.js +++ b/frontend/src/pages/ActivityReport/Pages/topicsResources.js @@ -41,20 +41,20 @@ const TopicsResources = ({
-
- + ({ - activityMethod: 'in-person', - activityType: ['training'], + 'activity-method': 'in-person', + 'activity-type': ['training'], duration: '1', - endDate: moment().format('MM/DD/YYYY'), + 'end-date': moment().format('MM/DD/YYYY'), grantees: ['Grantee Name 1'], - numberOfParticipants: '1', - participantCategory: 'grantee', + 'number-of-participants': '1', + 'participant-category': 'grantee', participants: ['CEO / CFO / Executive'], - programTypes: ['type 1'], + 'program-types': ['type 1'], requester: 'grantee', - resourcesUsed: 'eclkcurl', - startDate: moment().format('MM/DD/YYYY'), - targetPopulations: ['target 1'], + 'resources-used': 'eclkcurl', + 'start-date': moment().format('MM/DD/YYYY'), + 'target-populations': ['target 1'], topics: 'first', }); const history = createMemoryHistory(); -const renderActivityReport = (data = {}, location = 'activity-summary', reportId = 'test') => { - fetch.mockResponse(JSON.stringify({ - report: data, - additionalData: {}, - pageState: {}, - })); - +const renderActivityReport = (data = {}, location = 'activity-summary') => { render( , ); }; describe('ActivityReport', () => { - beforeEach(() => { - fetch.resetMocks(); - }); - - it('defaults to activity summary if no page is in the url', async () => { + it('defaults to activity summary if no page is in the url', () => { renderActivityReport({}, null); - await waitFor(() => expect(history.location.pathname).toEqual('/activity-reports/test/activity-summary')); + expect(history.location.pathname).toEqual('/activity-reports/activity-summary'); }); describe('grantee select', () => { describe('changes the participant selection to', () => { it('Grantee', async () => { renderActivityReport(); - await screen.findByText('New activity report for Region 14'); const information = await screen.findByRole('group', { name: 'Who was the activity for?' }); - const grantee = await within(information).findByLabelText('Grantee'); + const grantee = within(information).getByLabelText('Grantee'); fireEvent.click(grantee); - const granteeSelectbox = await screen.findByLabelText('Grantee name(s)'); + const granteeSelectbox = await screen.findByRole('textbox', { name: 'Grantee name(s)' }); reactSelectEvent.openMenu(granteeSelectbox); expect(await screen.findByText(withText('Grantee Name 1'))).toBeVisible(); }); it('Non-grantee', async () => { renderActivityReport(); - await screen.findByText('New activity report for Region 14'); const information = await screen.findByRole('group', { name: 'Who was the activity for?' }); - const nonGrantee = await within(information).findByLabelText('Non-Grantee'); + const nonGrantee = within(information).getByLabelText('Non-Grantee'); fireEvent.click(nonGrantee); - const granteeSelectbox = await screen.findByLabelText('Non-grantee name(s)'); + const granteeSelectbox = await screen.findByRole('textbox', { name: 'Grantee name(s)' }); reactSelectEvent.openMenu(granteeSelectbox); expect(await screen.findByText(withText('QRIS System'))).toBeVisible(); }); }); - it('clears selection when non-grantee is selected', async () => { - const data = formData(); - renderActivityReport(data); - await screen.findByText('New activity report for Region 14'); + it('when non-grantee is selected', async () => { + renderActivityReport(); + const enabled = screen.getByRole('textbox', { name: 'Grantee name(s)' }); + expect(enabled).toBeDisabled(); const information = await screen.findByRole('group', { name: 'Who was the activity for?' }); - const enabled = await within(information).findByText('Grantee Name 1'); - expect(enabled).not.toBeDisabled(); - const nonGrantee = await within(information).findByLabelText('Non-Grantee'); - - fireEvent.click(nonGrantee); - expect(await within(information).findByLabelText('Non-grantee name(s)')).toHaveValue(''); + const grantee = within(information).getByLabelText('Grantee'); + fireEvent.click(grantee); + const disabled = await screen.findByRole('textbox', { name: 'Grantee name(s)' }); + expect(disabled).not.toBeDisabled(); }); }); describe('method checkboxes', () => { it('require a single selection for the form to be valid', async () => { const data = formData(); - delete data.activityMethod; + delete data['activity-method']; renderActivityReport(data); expect(await screen.findByText('Continue')).toBeDisabled(); const box = await screen.findByLabelText('Virtual'); fireEvent.click(box); - expect(await screen.findByText('Continue')).not.toBeDisabled(); + await waitFor(() => expect(screen.getByText('Continue')).not.toBeDisabled()); }); }); describe('tta checkboxes', () => { it('requires a single selection for the form to be valid', async () => { const data = formData(); - delete data.activityType; + delete data['activity-type']; renderActivityReport(data); expect(await screen.findByText('Continue')).toBeDisabled(); const box = await screen.findByLabelText('Training'); fireEvent.click(box); - expect(await screen.findByText('Continue')).not.toBeDisabled(); + await waitFor(() => expect(screen.getByText('Continue')).not.toBeDisabled()); }); }); }); diff --git a/frontend/src/pages/ActivityReport/index.js b/frontend/src/pages/ActivityReport/index.js index e9875af505..5845b6b4ea 100644 --- a/frontend/src/pages/ActivityReport/index.js +++ b/frontend/src/pages/ActivityReport/index.js @@ -2,7 +2,7 @@ Activity report. Makes use of the navigator to split the long form into multiple pages. Each "page" is defined in the `./Pages` directory. */ -import React, { useState, useEffect } from 'react'; +import React, { useState } from 'react'; import _ from 'lodash'; import PropTypes from 'prop-types'; import { Helmet } from 'react-helmet'; @@ -14,90 +14,58 @@ import Navigator from '../../components/Navigator'; import './index.css'; import { NOT_STARTED } from '../../components/Navigator/constants'; -import { submitReport, saveReport, getReport } from '../../fetchers/activityReports'; +import { submitReport } from '../../fetchers/activityReports'; const defaultValues = { - activityMethod: [], - activityType: [], + 'activity-method': [], + 'activity-type': [], attachments: [], cdi: '', duration: '', - endDate: null, + 'end-date': null, grantees: [], - numberOfParticipants: '', - otherUsers: [], - participantCategory: '', + 'number-of-participants': '', + 'other-users': [], + 'participant-category': '', participants: [], - programTypes: [], + 'program-types': [], reason: [], requester: '', - resourcesUsed: '', - startDate: null, - targetPopulations: [], + 'resources-used': '', + 'start-date': null, + 'target-populations': [], topics: [], }; -const pagesByPos = _.keyBy(pages.filter((p) => !p.review), (page) => page.position); -const defaultPageState = _.mapValues(pagesByPos, () => NOT_STARTED); +const additionalNotes = 'this is an additional note'; +const approvingManagers = [2]; -function ActivityReport({ match }) { - const { params: { currentPage, activityReportId } } = match; - const history = useHistory(); +const pagesByPos = _.keyBy(pages.filter((p) => !p.review), (page) => page.position); +const initialPageState = _.mapValues(pagesByPos, () => NOT_STARTED); +function ActivityReport({ initialData, match }) { const [submitted, updateSubmitted] = useState(false); - const [loading, updateLoading] = useState(true); - const [initialPageState, updateInitialPageSate] = useState(defaultPageState); - const [initialFormData, updateInitialFormData] = useState(defaultValues); - const [initialAdditionalData, updateAdditionalData] = useState({}); - - useEffect(() => { - const fetch = async () => { - if (activityReportId !== 'new') { - const { report, pageState, additionalData } = await getReport(activityReportId); - updateInitialFormData(report); - updateInitialPageSate(pageState); - updateAdditionalData(additionalData); - updateLoading(false); - } else { - updateInitialFormData(defaultValues); - updateInitialPageSate(defaultPageState); - updateAdditionalData({}); - updateLoading(false); - } - }; - fetch(); - }, [activityReportId]); - - if (loading) { - return ( -
- loading... -
- ); - } - - if (!currentPage) { - return ( - - ); - } - - const onSave = async (data) => { - await saveReport(activityReportId, data); - }; + const history = useHistory(); + const { params: { currentPage } } = match; const onFormSubmit = async (data, extraData) => { // eslint-disable-next-line no-console console.log('Submit form data', data, extraData); - await submitReport(activityReportId, data, extraData); + await submitReport(data, extraData); updateSubmitted(true); }; const updatePage = (position) => { const page = pages.find((p) => p.position === position); - history.push(`/activity-reports/${activityReportId}/${page.path}`); + history.push(`/activity-reports/${page.path}`); }; + if (!currentPage) { + return ( + + ); + } + return ( <> @@ -107,11 +75,10 @@ function ActivityReport({ match }) { currentPage={currentPage} submitted={submitted} initialPageState={initialPageState} - defaultValues={{ ...defaultValues, ...initialFormData }} + defaultValues={{ ...defaultValues, ...initialData }} pages={pages} - additionalData={initialAdditionalData} + additionalData={{ additionalNotes, approvingManagers }} onFormSubmit={onFormSubmit} - onSave={onSave} /> ); diff --git a/frontend/src/setupTests.js b/frontend/src/setupTests.js index de047acd5a..bdf8fdc424 100644 --- a/frontend/src/setupTests.js +++ b/frontend/src/setupTests.js @@ -9,10 +9,8 @@ // learn more: https://github.com/testing-library/jest-dom import '@testing-library/jest-dom/extend-expect'; import 'react-dates/initialize'; -import mocks from 'jest-fetch-mock'; // See https://github.com/testing-library/dom-testing-library/releases/tag/v7.0.0 // 'MutationObserver shim removed' import MutationObserver from '@sheerun/mutationobserver-shim'; window.MutationObserver = MutationObserver; -mocks.enableMocks(); diff --git a/frontend/yarn.lock b/frontend/yarn.lock index b931e03e41..b0369aad21 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -9762,11 +9762,6 @@ react-select-event@^5.1.0: dependencies: "@testing-library/dom" ">=7" -react-select-simple-value@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/react-select-simple-value/-/react-select-simple-value-1.2.1.tgz#d228acd37eb87f871fd4e40cc81457d529c2cbf5" - integrity sha512-t1xHuPGkRcOXnOjjAuYChG41d2UgmIaeeMo7QJCqJ43H0fw+9b09b98C3//zJHWqGXcBA4WXvxzU0B8kvIlWjw== - react-select@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/react-select/-/react-select-3.1.1.tgz#156a5b4a6c22b1e3d62a919cb1fd827adb4060bc" From 36b176bb5e09bcf1b97bedae7a98a2ff9ce19c2d Mon Sep 17 00:00:00 2001 From: Josh Salisbury Date: Thu, 14 Jan 2021 10:00:17 -0600 Subject: [PATCH 14/25] Continuing to revert frontend changes --- axe-urls | 10 +++++----- cucumber/features/steps/activityReportSteps.js | 2 +- cucumber/features/steps/homePageSteps.js | 4 ++-- frontend/src/App.js | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/axe-urls b/axe-urls index 8cd81113d3..42b0286b4b 100644 --- a/axe-urls +++ b/axe-urls @@ -1,7 +1,7 @@ http://localhost:3000, -http://localhost:3000/activity-reports/new/activity-summary, -http://localhost:3000/activity-reports/new/topics-resources, -http://localhost:3000/activity-reports/new/goals-objectives, -http://localhost:3000/activity-reports/new/next-steps, -http://localhost:3000/activity-reports/new/review, +http://localhost:3000/activity-reports/activity-summary, +http://localhost:3000/activity-reports/topics-resources, +http://localhost:3000/activity-reports/goals-objectives, +http://localhost:3000/activity-reports/next-steps, +http://localhost:3000/activity-reports/review, http://localhost:3000/admin diff --git a/cucumber/features/steps/activityReportSteps.js b/cucumber/features/steps/activityReportSteps.js index df6cc36f34..b524bae1bc 100644 --- a/cucumber/features/steps/activityReportSteps.js +++ b/cucumber/features/steps/activityReportSteps.js @@ -7,7 +7,7 @@ const scope = require('../support/scope'); Given('I am on the activity reports page', async () => { const page = scope.context.currentPage; - const selector = 'a[href$="activity-reports/new"]'; + const selector = 'a[href$="activity-reports"]'; await Promise.all([ page.waitForNavigation(), page.click(selector), diff --git a/cucumber/features/steps/homePageSteps.js b/cucumber/features/steps/homePageSteps.js index 38dfd55d64..7a53b3e102 100644 --- a/cucumber/features/steps/homePageSteps.js +++ b/cucumber/features/steps/homePageSteps.js @@ -22,7 +22,7 @@ Given('I am logged in', async () => { const loginLinkSelector = 'a[href$="api/login"]'; // const homeLinkSelector = 'a[href$="/"]'; - const activityReportsSelector = 'a[href$="activity-reports/new"]'; + const activityReportsSelector = 'a[href$="activity-reports"]'; await page.goto(scope.uri); await page.waitForSelector('em'); // Page title @@ -52,7 +52,7 @@ Then('I see {string} message', async (string) => { Then('I see {string} link', async (string) => { const page = scope.context.currentPage; - const selector = 'a[href$="activity-reports/new"]'; + const selector = 'a[href$="activity-reports"]'; await page.waitForSelector(selector); const value = await page.$eval(selector, (el) => el.textContent); diff --git a/frontend/src/App.js b/frontend/src/App.js index 88b9b04c9f..6a0b0cf381 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -74,7 +74,7 @@ function App() { )} /> ( )} From 59c25478f63b64f6e8035693d2b85adeb794e4bd Mon Sep 17 00:00:00 2001 From: Josh Salisbury Date: Thu, 14 Jan 2021 10:05:23 -0600 Subject: [PATCH 15/25] Revert last of the frontend changes --- frontend/src/App.js | 2 +- frontend/src/pages/ActivityReport/Pages/topicsResources.js | 2 +- frontend/src/pages/ActivityReport/index.js | 4 ---- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/frontend/src/App.js b/frontend/src/App.js index 6a0b0cf381..f12dcc30f4 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -76,7 +76,7 @@ function App() { ( - + )} /> {admin diff --git a/frontend/src/pages/ActivityReport/Pages/topicsResources.js b/frontend/src/pages/ActivityReport/Pages/topicsResources.js index 822f80643a..cb71828b5f 100644 --- a/frontend/src/pages/ActivityReport/Pages/topicsResources.js +++ b/frontend/src/pages/ActivityReport/Pages/topicsResources.js @@ -98,7 +98,7 @@ const sections = [ title: 'Resources', anchor: 'resources', items: [ - { label: 'Resources used', name: 'resources used', path: 'name' }, + { label: 'Resources used', name: 'resources-used' }, { label: 'Other resources', name: 'other-resources', path: 'name' }, ], }, diff --git a/frontend/src/pages/ActivityReport/index.js b/frontend/src/pages/ActivityReport/index.js index 5845b6b4ea..6fc643fe84 100644 --- a/frontend/src/pages/ActivityReport/index.js +++ b/frontend/src/pages/ActivityReport/index.js @@ -37,9 +37,6 @@ const defaultValues = { topics: [], }; -const additionalNotes = 'this is an additional note'; -const approvingManagers = [2]; - const pagesByPos = _.keyBy(pages.filter((p) => !p.review), (page) => page.position); const initialPageState = _.mapValues(pagesByPos, () => NOT_STARTED); @@ -77,7 +74,6 @@ function ActivityReport({ initialData, match }) { initialPageState={initialPageState} defaultValues={{ ...defaultValues, ...initialData }} pages={pages} - additionalData={{ additionalNotes, approvingManagers }} onFormSubmit={onFormSubmit} /> From 203feae499d239b8b2c78fae247a9176d951607d Mon Sep 17 00:00:00 2001 From: Josh Salisbury Date: Thu, 14 Jan 2021 11:09:45 -0600 Subject: [PATCH 16/25] Add grant fields that had been removed --- src/models/grant.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/models/grant.js b/src/models/grant.js index ef4a9b0e24..9188968530 100644 --- a/src/models/grant.js +++ b/src/models/grant.js @@ -22,6 +22,9 @@ module.exports = (sequelize, DataTypes) => { allowNull: false, unique: true, }, + status: DataTypes.STRING, + startDate: DataTypes.DATE, + endDate: DataTypes.DATE, granteeId: { type: DataTypes.INTEGER, allowNull: false, From 244742a7565c513e460c5d1aeb7e3816b8f4be4c Mon Sep 17 00:00:00 2001 From: kryswisnaskas Date: Thu, 14 Jan 2021 15:23:26 -0500 Subject: [PATCH 17/25] Modify cookie options --- src/app.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app.js b/src/app.js index 7bd6887008..03f1f4b170 100644 --- a/src/app.js +++ b/src/app.js @@ -28,7 +28,6 @@ app.use(cookieSession({ keys: [process.env.SESSION_SECRET], // Cookie Options - httpOnly: false, sameSite: 'lax', })); From 534cfb264dbd986fdde96de77976d04919e766e4 Mon Sep 17 00:00:00 2001 From: kryswisnaskas Date: Fri, 15 Jan 2021 10:25:19 -0500 Subject: [PATCH 18/25] Remove unneeded code --- src/app.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/app.js b/src/app.js index 03f1f4b170..6d609c7039 100644 --- a/src/app.js +++ b/src/app.js @@ -66,10 +66,6 @@ app.get(oauth2CallbackPath, async (req, res) => { res.status(INTERNAL_SERVER_ERROR).end(); } }); -app.use((req, res, next) => { - req.session.nowInMinutes = Math.floor(Date.now() / 60e3); - next(); -}); if (process.env.NODE_ENV === 'production') { app.use('*', (req, res) => { From ef0cfba5c6ecd2eaeb42bd6e706dff9ca87c3cf3 Mon Sep 17 00:00:00 2001 From: Josh Salisbury Date: Fri, 15 Jan 2021 11:32:53 -0600 Subject: [PATCH 19/25] Move DB access layer to `services`. fix typo --- src/routes/activityReports/handlers.js | 4 ++-- src/routes/activityReports/handlers.test.js | 2 +- src/routes/admin/user.js | 2 +- src/routes/apiDirectory.js | 2 +- src/{lib => services}/activityReports.js | 0 src/{lib => services}/users.js | 0 6 files changed, 5 insertions(+), 5 deletions(-) rename src/{lib => services}/activityReports.js (100%) rename src/{lib => services}/users.js (100%) diff --git a/src/routes/activityReports/handlers.js b/src/routes/activityReports/handlers.js index dee0069fd5..5c5ee7e533 100644 --- a/src/routes/activityReports/handlers.js +++ b/src/routes/activityReports/handlers.js @@ -2,8 +2,8 @@ import handleErrors from '../../lib/apiErrorHandler'; import SCOPES from '../../middleware/scopeConstants'; import { reportParticipants, activityReportById, createOrUpdate, reportExists, -} from '../../lib/activityReports'; -import { userById, usersWithPermissions } from '../../lib/users'; +} from '../../services/activityReports'; +import { userById, usersWithPermissions } from '../../services/users'; const { READ_WRITE_REPORTS, APPROVE_REPORTS } = SCOPES; diff --git a/src/routes/activityReports/handlers.test.js b/src/routes/activityReports/handlers.test.js index fb95f3e4b8..9d96a9d6e2 100644 --- a/src/routes/activityReports/handlers.test.js +++ b/src/routes/activityReports/handlers.test.js @@ -36,7 +36,7 @@ const mockResponse = { }; const reportObject = { - participantType: 'gratnee', + participantType: 'grantee', status: 'draft', userId: mockUser.id, lastUpdatedById: mockUser.id, diff --git a/src/routes/admin/user.js b/src/routes/admin/user.js index d1019fb4d1..7df2297319 100644 --- a/src/routes/admin/user.js +++ b/src/routes/admin/user.js @@ -1,7 +1,7 @@ import { User, Permission, sequelize, } from '../../models'; -import { userById } from '../../lib/users'; +import { userById } from '../../services/users'; import handleErrors from '../../lib/apiErrorHandler'; const namespace = 'SERVICE:USER'; diff --git a/src/routes/apiDirectory.js b/src/routes/apiDirectory.js index d969c3ebc3..da9579a68e 100644 --- a/src/routes/apiDirectory.js +++ b/src/routes/apiDirectory.js @@ -6,7 +6,7 @@ import authMiddleware, { login } from '../middleware/authMiddleware'; import handleErrors from '../lib/apiErrorHandler'; import adminRouter from './user'; import activityReportsRouter from './activityReports'; -import { userById } from '../lib/users'; +import { userById } from '../services/users'; export const loginPath = '/login'; diff --git a/src/lib/activityReports.js b/src/services/activityReports.js similarity index 100% rename from src/lib/activityReports.js rename to src/services/activityReports.js diff --git a/src/lib/users.js b/src/services/users.js similarity index 100% rename from src/lib/users.js rename to src/services/users.js From f62c137f0b7ea2e53edcde913012aab20d37a5c6 Mon Sep 17 00:00:00 2001 From: Josh Salisbury Date: Fri, 15 Jan 2021 12:02:50 -0600 Subject: [PATCH 20/25] Add activity reports/participants and nongrantees to model --- docs/logical_data_model.md | 52 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/docs/logical_data_model.md b/docs/logical_data_model.md index fac2677734..dfa490f544 100644 --- a/docs/logical_data_model.md +++ b/docs/logical_data_model.md @@ -1,13 +1,15 @@ Logical Data Model ================== - + UML Source ---------- ``` @startuml +scale 0.75 + ' avoid problems with angled crows feet skinparam linetype ortho @@ -121,6 +123,47 @@ class GrantGoal { * updatedAt : timestamp } +class NonGrantee { + * id : integer <> + * name : string + * createdAt : timestamp + * updatedAt : timestamp +} + +class ActivityReport { + * id : integer <> + resourcesUsed : string + additionalNotes : string + numberOfParticipants : integer + deliveryMethod : string + duration : decimal + endDate : date + startDate : date + participantType : string + requester : string + * status : string + programTypes : array + targetPopulations : array + reason : array + participants : array + topics : array + ttaType : array + pageState : json + * userId : integer(32) REFERENCES public.Users.id + * lastUpdatedById : integer(32) REFERENCES public.Users.id + * createdAt : timestamp + * updatedAt : timestamp +} + +class ActivityParticipant { + * id : integer <> + * activityReportId : integer(32) REFERENCES public.ActivityReport.id + grantId : integer(32) REFERENCES public.Grant.id + nonGranteeId : integer(32) REFERENCES public.NonGrantee.id + * createdAt : timestamp + * updatedAt : timestamp +} + User ||-o{ Region User }o--|{ Permission Scope }o--|{ Permission @@ -135,13 +178,18 @@ Topic .. TopicGoal Goal .. TopicGoal Grantee ||--|{ Grant Region ||--|{ Grant + +User ||-o{ ActivityReport +ActivityReport ||-o{ ActivityParticipant +Grant ||-{ ActivityParticipant +NonGrantee ||-{ ActivityParticipant @enduml ``` Instructions ------------ -1. [Edit this diagram with plantuml.com](http://www.plantuml.com/plantuml/uml/nLL1Jzmy4BtdLypj-uh47jeRGeYMBXKNX83-06yyPAtQ7dSyAK8i_xrnBAp0BjJQLEebgppFl9dtPh8-ZQ9OUcUR_q3z8gCXCAqikWZtHjQWV6THGyjq7-4EKPhum_YWM3cmngCy10HYMLFJj5R52DyZCZms00Smi1s1yOBT01qVT-YHbQ0-EHcErn5ZYhr8GL7O-6v0lN8uVGzhydZPknNo53u0TChOAIH6B1u1-jubTd9uZPqXVp5fulzF7p_0zV9yURsyF5lUGEXNrhIB71WNHe_KBMFgyhCCYM8SHb4kZ2Tzq3jEdfwbPvuQyGUlX1QAcvO2xiC0e36sR88OyWMT9USAsPaOgoNr4Rd2xJJquZNlEQEOVAf86_ty6vFzsG_OafaurhX18RqawoQpwDbCNntAclGKuLpc2-c7bt0CWtPy2pyZZGMVAPJihlLxRsLlAPYsFgUKoNbLcyLaQuQTMCki9T5Vw3_bZ5tCNv1lf6oT1m13glHnYYJ2EtuLPk8vaxjnYYMk4ULMr9LLQQ5MH1QLdzVnAFozjhlXhCBqHF6lJ6Vb1IltTNwbD-jiNK_x_gjrML2Dwpao9sujL49OlYh1LqBHwsrqjZ5f1Ie-v7q4lhIcDYzbxRF3-tgHhZJOR0xf4V94C_94XuURnyazskJhVHlFMRiEo9RuyQjfnYSyRKOeDTvagqfO6KcJ7bjefbmi8FrEANT2kMXXAyYqS59UIZnBUO6TejUzixy1) +1. [Edit this diagram with plantuml.com](http://www.plantuml.com/plantuml/umla/nLRRRkCs47tNL_2jxG8nYXP5WKMms4kzHHxg6dRo0RGuaTcI77OuIc34-VUQ8YABiP8oLe5ziQ4pzyF3oyV0ahYrfWgrD21-dFtwIrLz9-GTQYKyuSQ03U9UyrP8rnXGeYQy3-8MWAlmjtPUahJ2Q0Uyyo2GU8jLLHiPWhW9GEAX4kATq4gy5zenD0Veyh811oGPr8SF1_CsG8Y-Lz4fC6dN750dBGo__HOTB5gx0Jh2CJTOgSqGOiq6tWjmhOtPqS8A6etkQj34zp__z8DOpR_EL_F5b_bQ-7PZT3rBZc6cLPUw9eXTVk9386iBWQNrdQNrQiJo-3HwobCo_Bjyq6EATOqUpiaWX89Ga_QiqVNef76MG5Q7K3nI6u0As8wBtdDDPwvHY3mLX7Ly_Xyh-qzxmEP4IA643VJnGwCRhb1BUlXfWRUOgPzIcS-eTZaSFBe0B-5VK46FJnmKpPZyNfFiDNfTbyVqPL9SaR883PHe1CrnhJcclrB_5TDrCLqWlwCqPHm84LXo6uP8J7XBpznq-4ferrOIXoq0NxDS6Xa5jK4q8DsaZjEolTPsSx0La1vJV2lIIJg6GhrEh_HYdPFhwTnJhKa3bR2UF5FWYQ0YGloRP7WsA3XrYauc9agWO2EVCs1EJMbSYZfBmnEuMA1BNPzvWKye_QbcVQTvjmAFn2Nb2GAsL7TliEmEaahfU759iq26J5L9w7_UBYMnhhMFJ6TxGO7HTq2xFqvkDjNsrw62MbjfCZN6uJBPzf0_rhhUUHYvBP-_34ztWoTiIDeO7t5997UNoHgfO4aDy19zQxeUntm8P41tOiZw6ubB1-AeWMMQPoHZ0sjE9FmLq9tzjZ8oy4tIpUVTMuCdIt4WahBj83CD5_IRYpur_gPZ8eMuVjiMn1tt-0IY8j5Ylx_01v4kW8GyuiN5_c7mxAwwr-i8dgB636Xw_-wIhT8hwt7VGV6SgrB_lLj_WbVnvmHDAMSp4V-7AKUXLFIOBKTIuJXvN-9fb0mR4fGlSfL_PZwPv5AnQ1yt7zVnPP-Fu5HhpRy0) 2. Copy and paste the final UML into the UML Source section 3. Update the img src and edit link target to the current values From fc3fb8d0c60f62586657d3633c4ae3cfc3e88d0b Mon Sep 17 00:00:00 2001 From: Ryan Ahearn Date: Fri, 15 Jan 2021 15:07:08 -0500 Subject: [PATCH 21/25] Change db operations order for dev & sandbox deploy --- .circleci/config.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 115b486bfa..2e710561b8 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -324,7 +324,7 @@ jobs: name: Return database to neutral, then migrate and seed command: | cf run-task tta-smarthub-sandbox \ - "yarn db:seed:undo:prod && yarn db:migrate:undo:prod && yarn db:migrate:prod && yarn db:seed:prod" + "yarn db:migrate:undo:prod && yarn db:migrate:prod && yarn db:seed:prod" - when: # dev condition: and: @@ -347,7 +347,7 @@ jobs: - run: name: Undo database seeding, then migrate and seed command: | - cf run-task tta-smarthub-dev "yarn db:seed:undo:prod && yarn db:migrate:prod && yarn db:seed:prod" + cf run-task tta-smarthub-dev "yarn db:migrate:prod && yarn db:seed:undo:prod && yarn db:seed:prod" - when: # staging condition: and: From 36898a7f5affd9942d55ff4e88be668671789fbd Mon Sep 17 00:00:00 2001 From: Josh Salisbury Date: Mon, 18 Jan 2021 15:24:36 -0600 Subject: [PATCH 22/25] Rename "activityParticipant" to "activityRecipient" Remove `--runInBand`, it appears to no longer be needed to prevent a race with tests that hit the database --- docs/openapi/index.yaml | 6 +-- ...icipants.yaml => activity-recipients.yaml} | 0 docs/openapi/paths/index.yaml | 4 +- package.json | 2 +- ...10118203723-rename-activity-participant.js | 10 ++++ ...ityParticipant.js => activityRecipient.js} | 16 +++---- src/models/activityReport.js | 4 +- src/routes/activityReports/handlers.js | 8 ++-- src/routes/activityReports/handlers.test.js | 10 ++-- src/routes/activityReports/index.js | 4 +- src/services/activityReports.js | 46 ++++++++++--------- 11 files changed, 61 insertions(+), 49 deletions(-) rename docs/openapi/paths/activity-reports/{participants.yaml => activity-recipients.yaml} (100%) create mode 100644 src/migrations/20210118203723-rename-activity-participant.js rename src/models/{activityParticipant.js => activityRecipient.js} (66%) diff --git a/docs/openapi/index.yaml b/docs/openapi/index.yaml index 423fc0cd19..09a9762f84 100644 --- a/docs/openapi/index.yaml +++ b/docs/openapi/index.yaml @@ -39,7 +39,7 @@ components: type: string startDate: type: string - participantType: + activityRecipientType: type: string requester: type: string @@ -87,10 +87,10 @@ components: type: number name: type: string - participant: + activityRecipient: type: object properties: - participantId: + activityRecipientId: type: number name: type: string diff --git a/docs/openapi/paths/activity-reports/participants.yaml b/docs/openapi/paths/activity-reports/activity-recipients.yaml similarity index 100% rename from docs/openapi/paths/activity-reports/participants.yaml rename to docs/openapi/paths/activity-reports/activity-recipients.yaml diff --git a/docs/openapi/paths/index.yaml b/docs/openapi/paths/index.yaml index b793e06ba9..badf4b1b18 100644 --- a/docs/openapi/paths/index.yaml +++ b/docs/openapi/paths/index.yaml @@ -14,7 +14,7 @@ $ref: './activity-reports/activity-reports.yaml' '/activity-reports/approvers': $ref: './activity-reports/approvers.yaml' -'/activity-reports/participants': - $ref: './activity-reports/participants.yaml' +'/activity-reports/activity-recipients': + $ref: './activity-reports/activity-recipients.yaml' '/activity-reports/{activityReportId}': $ref: './activity-reports/activity-reports-id.yaml' diff --git a/package.json b/package.json index 5c6bf620e6..692e5daed4 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "server:debug": "nodemon src/index.js --exec babel-node --inspect", "client": "yarn --cwd frontend start", "test": "jest src", - "test:ci": "cross-env JEST_JUNIT_OUTPUT_DIR=reports JEST_JUNIT_OUTPUT_NAME=unit.xml POSTGRES_USERNAME=postgres POSTGRES_DB=ttasmarthub CURRENT_USER_ID=5 CI=true jest src tools --runInBand --coverage --reporters=default --reporters=jest-junit", + "test:ci": "cross-env JEST_JUNIT_OUTPUT_DIR=reports JEST_JUNIT_OUTPUT_NAME=unit.xml POSTGRES_USERNAME=postgres POSTGRES_DB=ttasmarthub CURRENT_USER_ID=5 CI=true jest src tools --coverage --reporters=default --reporters=jest-junit", "test:all": "yarn test:ci && yarn --cwd frontend test:ci", "lint": "eslint src", "lint:ci": "eslint -f eslint-formatter-multiple src", diff --git a/src/migrations/20210118203723-rename-activity-participant.js b/src/migrations/20210118203723-rename-activity-participant.js new file mode 100644 index 0000000000..a687fecce6 --- /dev/null +++ b/src/migrations/20210118203723-rename-activity-participant.js @@ -0,0 +1,10 @@ +module.exports = { + up: async (queryInterface) => queryInterface.sequelize.transaction(async (t) => { + await queryInterface.renameTable('ActivityParticipants', 'ActivityRecipients', { transaction: t }); + await queryInterface.renameColumn('ActivityReports', 'participantType', 'activityRecipientType', { transaction: t }); + }), + down: async (queryInterface) => queryInterface.sequelize.transaction(async (t) => { + await queryInterface.renameTable('ActivityRecipients', 'ActivityParticipants', { transaction: t }); + await queryInterface.renameColumn('ActivityReports', 'activityRecipientType', 'participantType', { transaction: t }); + }), +}; diff --git a/src/models/activityParticipant.js b/src/models/activityRecipient.js similarity index 66% rename from src/models/activityParticipant.js rename to src/models/activityRecipient.js index 2e1cfc0f7b..ee3ba7679b 100644 --- a/src/models/activityParticipant.js +++ b/src/models/activityRecipient.js @@ -1,14 +1,14 @@ import { Model } from 'sequelize'; export default (sequelize, DataTypes) => { - class ActivityParticipant extends Model { + class ActivityRecipient extends Model { static associate(models) { - ActivityParticipant.belongsTo(models.ActivityReport, { foreignKey: 'activityReportId' }); - ActivityParticipant.belongsTo(models.Grant, { foreignKey: 'grantId', as: 'grant' }); - ActivityParticipant.belongsTo(models.NonGrantee, { foreignKey: 'nonGranteeId', as: 'nonGrantee' }); + ActivityRecipient.belongsTo(models.ActivityReport, { foreignKey: 'activityReportId' }); + ActivityRecipient.belongsTo(models.Grant, { foreignKey: 'grantId', as: 'grant' }); + ActivityRecipient.belongsTo(models.NonGrantee, { foreignKey: 'nonGranteeId', as: 'nonGrantee' }); } } - ActivityParticipant.init({ + ActivityRecipient.init({ activityReportId: { allowNull: false, type: DataTypes.INTEGER, @@ -21,7 +21,7 @@ export default (sequelize, DataTypes) => { allowNull: true, type: DataTypes.INTEGER, }, - participantId: { + activityRecipientId: { type: DataTypes.VIRTUAL, get() { if (this.grant) { @@ -41,7 +41,7 @@ export default (sequelize, DataTypes) => { }, }, { sequelize, - modelName: 'ActivityParticipant', + modelName: 'ActivityRecipient', validate: { oneNull() { if (this.grantId && this.nonGranteeId) { @@ -50,5 +50,5 @@ export default (sequelize, DataTypes) => { }, }, }); - return ActivityParticipant; + return ActivityRecipient; }; diff --git a/src/models/activityReport.js b/src/models/activityReport.js index 416deac06e..6d94d9a000 100644 --- a/src/models/activityReport.js +++ b/src/models/activityReport.js @@ -14,7 +14,7 @@ export default (sequelize, DataTypes) => { static associate(models) { ActivityReport.belongsTo(models.User, { foreignKey: 'userId', as: 'author' }); ActivityReport.belongsTo(models.User, { foreignKey: 'lastUpdatedById', as: 'lastUpdatedBy' }); - ActivityReport.hasMany(models.ActivityParticipant, { foreignKey: 'activityReportId', as: 'activityParticipants' }); + ActivityReport.hasMany(models.ActivityRecipient, { foreignKey: 'activityReportId', as: 'activityRecipients' }); } } ActivityReport.init({ @@ -47,7 +47,7 @@ export default (sequelize, DataTypes) => { type: DataTypes.DATEONLY, get: formatDate, }, - participantType: { + activityRecipientType: { allowNull: false, type: DataTypes.STRING, }, diff --git a/src/routes/activityReports/handlers.js b/src/routes/activityReports/handlers.js index 5c5ee7e533..ac044e57fc 100644 --- a/src/routes/activityReports/handlers.js +++ b/src/routes/activityReports/handlers.js @@ -1,7 +1,7 @@ import handleErrors from '../../lib/apiErrorHandler'; import SCOPES from '../../middleware/scopeConstants'; import { - reportParticipants, activityReportById, createOrUpdate, reportExists, + activityRecipients, activityReportById, createOrUpdate, reportExists, } from '../../services/activityReports'; import { userById, usersWithPermissions } from '../../services/users'; @@ -47,9 +47,9 @@ export async function submitReport(req, res) { res.sendStatus(204); } -export async function getParticipants(req, res) { - const participants = await reportParticipants(); - res.json(participants); +export async function getActivityRecipients(req, res) { + const recipients = await activityRecipients(); + res.json(recipients); } /** diff --git a/src/routes/activityReports/handlers.test.js b/src/routes/activityReports/handlers.test.js index 9d96a9d6e2..ffd2dd2a03 100644 --- a/src/routes/activityReports/handlers.test.js +++ b/src/routes/activityReports/handlers.test.js @@ -1,5 +1,5 @@ import db, { - ActivityReport, ActivityParticipant, User, Permission, + ActivityReport, ActivityRecipient, User, Permission, } from '../../models'; import { getApprovers, saveReport, createReport, getReport, @@ -36,7 +36,7 @@ const mockResponse = { }; const reportObject = { - participantType: 'grantee', + activityRecipientType: 'grantee', status: 'draft', userId: mockUser.id, lastUpdatedById: mockUser.id, @@ -51,7 +51,7 @@ describe('Activity Report handlers', () => { }); afterAll(async () => { - await ActivityParticipant.destroy({ where: {} }); + await ActivityRecipient.destroy({ where: {} }); await ActivityReport.destroy({ where: {} }); await User.destroy({ where: { id: user.id } }); db.sequelize.close(); @@ -155,8 +155,8 @@ describe('Activity Report handlers', () => { it('creates a new report', async () => { const beginningARCount = await ActivityReport.count(); const report = { - participantType: 'grantee', - activityParticipants: [{ participantId: 1 }], + activityRecipientType: 'grantee', + activityRecipients: [{ activityRecipientId: 1 }], }; const request = { body: report, diff --git a/src/routes/activityReports/index.js b/src/routes/activityReports/index.js index 9db4ac0c03..7683d23c8a 100644 --- a/src/routes/activityReports/index.js +++ b/src/routes/activityReports/index.js @@ -1,6 +1,6 @@ import express from 'express'; import { - getApprovers, submitReport, saveReport, createReport, getReport, getParticipants, + getApprovers, submitReport, saveReport, createReport, getReport, getActivityRecipients, } from './handlers'; const router = express.Router(); @@ -11,7 +11,7 @@ const router = express.Router(); router.post('/', createReport); router.get('/approvers', getApprovers); -router.get('/participants', getParticipants); +router.get('/activity-recipients', getActivityRecipients); router.get('/:activityReportId', getReport); router.put('/:activityReportId', saveReport); router.post('/:activityReportId/submit', submitReport); diff --git a/src/services/activityReports.js b/src/services/activityReports.js index 94083ec269..ac55a67100 100644 --- a/src/services/activityReports.js +++ b/src/services/activityReports.js @@ -4,19 +4,19 @@ import { Op } from 'sequelize'; import { ActivityReport, sequelize, - ActivityParticipant, + ActivityRecipient, Grant, Grantee, NonGrantee, } from '../models'; -async function saveReportParticipants( +async function saveReportRecipients( activityReportId, - participantIds, - participantType, + activityRecipientIds, + activityRecipientType, transaction, ) { - await ActivityParticipant.destroy({ + await ActivityRecipient.destroy({ where: { activityReportId: { [Op.eq]: activityReportId, @@ -25,18 +25,18 @@ async function saveReportParticipants( transaction, }); - await Promise.all(participantIds.map(async (participantId) => { - const activityParticipant = { + await Promise.all(activityRecipientIds.map(async (activityRecipientId) => { + const activityRecipient = { activityReportId, }; - if (participantType === 'grantee') { - activityParticipant.grantId = participantId; - } else if (participantType === 'non-grantee') { - activityParticipant.nonGranteeId = participantId; + if (activityRecipientType === 'grantee') { + activityRecipient.grantId = activityRecipientId; + } else if (activityRecipientType === 'non-grantee') { + activityRecipient.nonGranteeId = activityRecipientId; } - return ActivityParticipant.create(activityParticipant, { transaction }); + return ActivityRecipient.create(activityRecipient, { transaction }); })); } @@ -68,9 +68,9 @@ export function activityReportById(activityReportId) { }, include: [ { - model: ActivityParticipant, - attributes: ['id', 'name', 'participantId'], - as: 'activityParticipants', + model: ActivityRecipient, + attributes: ['id', 'name', 'activityRecipientId'], + as: 'activityRecipients', required: false, include: [ { @@ -104,16 +104,18 @@ export async function createOrUpdate(newActivityReport, activityReportId) { savedReport = await create(newActivityReport, transaction); } - if (newActivityReport.activityParticipants) { - const { participantType, id } = savedReport; - const participantIds = newActivityReport.activityParticipants.map((g) => g.participantId); - await saveReportParticipants(id, participantIds, participantType, transaction); + if (newActivityReport.activityRecipients) { + const { activityRecipientType, id } = savedReport; + const activityRecipientIds = newActivityReport.activityRecipients.map( + (g) => g.activityRecipientId, + ); + await saveReportRecipients(id, activityRecipientIds, activityRecipientType, transaction); } }); return activityReportById(savedReport.id); } -export async function reportParticipants() { +export async function activityRecipients() { const rawGrants = await Grant.findAll({ attributes: ['id', 'name', 'number'], include: [{ @@ -123,13 +125,13 @@ export async function reportParticipants() { }); const grants = rawGrants.map((g) => ({ - participantId: g.id, + activityRecipientId: g.id, name: g.name, })); const nonGrantees = await NonGrantee.findAll({ raw: true, - attributes: [['id', 'participantId'], 'name'], + attributes: [['id', 'activityRecipientId'], 'name'], }); return { grants, nonGrantees }; } From 6020468145289d268a45713fc0f68554210726b1 Mon Sep 17 00:00:00 2001 From: kryswisnaskas Date: Tue, 19 Jan 2021 09:30:11 -0500 Subject: [PATCH 23/25] Updated comment --- src/app.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app.js b/src/app.js index 6d609c7039..5835bf97e8 100644 --- a/src/app.js +++ b/src/app.js @@ -27,7 +27,7 @@ app.use(cookieSession({ name: 'session', keys: [process.env.SESSION_SECRET], - // Cookie Options + // Cookie Options. httpOnly is set by default to true for https sameSite: 'lax', })); From 664ebc1ec918b9705b9e3a8d512283e5ec373467 Mon Sep 17 00:00:00 2001 From: Josh Salisbury Date: Tue, 19 Jan 2021 09:47:32 -0600 Subject: [PATCH 24/25] Add descriptions to api doc models Found a test that was removing all request errors, which was causing failures when tests were run in parrallel. Was able to update the test enabling parallel test runs --- docs/openapi/index.yaml | 28 ++++++++++++++++++++++++++++ src/services/requestErrors.test.js | 5 +---- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/docs/openapi/index.yaml b/docs/openapi/index.yaml index 09a9762f84..39ec9938cd 100644 --- a/docs/openapi/index.yaml +++ b/docs/openapi/index.yaml @@ -18,53 +18,74 @@ components: schemas: activityReport: type: object + description: > + This object represents a report on an activity that a specialist has completed + with 1 to many grants or non-grantees properties: id: type: number userId: type: number + description: The id of the author of the report lastUpdatedById: type: number + description: The id of the last user to update the report resourcesUsed: type: string + description: A list of URLs used in the activity additionalNotes: type: string + description: > + Any additional info user's wish to convey about the report that is not + captured elsewhere numberOfParticipants: type: number + description: The number of people present at the activity deliveryMethod: type: string + description: How the activity was connected, virtual vs in person duration: type: number + description: How long the activity took to complete, in hours endDate: type: string startDate: type: string activityRecipientType: type: string + description: The recipient type for the report, grant or non-grantee requester: type: string + description: Who requested the TTA, grantee or regional office programTypes: type: array + description: The type of grantee program, Head Start, Early Head Start, etc... items: type: string targetPopulations: type: array + description: The target of the activity report items: type: string reason: type: array + description: Why the activity was conducted items: type: string participants: type: array + description: The roles of the people attending the TTA items: type: string topics: type: array + description: Topics covered during the TTA items: type: string pageState: type: object + enum: ['Not Started', 'In progress', 'Completed'] + description: The state of each page of the form properties: 1: type: string @@ -76,12 +97,16 @@ components: type: string status: type: string + enum: ['draft', 'submitted'] + description: The current state of the report ttaType: type: array + description: The type of the activity, Training or Technical Assistance (or both) items: type: string approvingUser: type: object + description: Users with permission to approve reports properties: id: type: number @@ -89,6 +114,7 @@ components: type: string activityRecipient: type: object + description: A grant or non-grantee that can be added to an activity report properties: activityRecipientId: type: number @@ -96,6 +122,7 @@ components: type: string permission: type: object + description: A combination of region, scope and user allowing that user to perform an action in a region properties: userId: type: number @@ -105,6 +132,7 @@ components: type: number user: type: object + description: A TTA Smart Hub user properties: id: type: number diff --git a/src/services/requestErrors.test.js b/src/services/requestErrors.test.js index de1ebf3737..56dc64ac1e 100644 --- a/src/services/requestErrors.test.js +++ b/src/services/requestErrors.test.js @@ -5,11 +5,7 @@ import db, { import createRequestError from './requestErrors'; describe('createRequestError', () => { - beforeEach(async () => { - await RequestErrors.destroy({ where: {} }); - }); afterAll(async () => { - await RequestErrors.destroy({ where: {} }); db.sequelize.close(); }); @@ -45,6 +41,7 @@ describe('createRequestError', () => { expect(retrievedRequestError.responseCode).toEqual(responseCode); expect(retrievedRequestError.createdAt).toBeDefined(); expect(retrievedRequestError.updatedAt).toBeDefined(); + await RequestErrors.destroy({ where: { id: requestErrorId } }); }); it('Throws on error', async () => { From fc133b5defc743d035e615f611136bde88cf5642 Mon Sep 17 00:00:00 2001 From: Josh Salisbury Date: Tue, 19 Jan 2021 10:14:08 -0600 Subject: [PATCH 25/25] Update summary of endpoints in API docs. Update activity-recipients --- .../paths/activity-reports/activity-recipients.yaml | 10 +++++----- docs/openapi/paths/activity-reports/approvers.yaml | 3 ++- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/openapi/paths/activity-reports/activity-recipients.yaml b/docs/openapi/paths/activity-reports/activity-recipients.yaml index 4ccd0048f0..779bb12500 100644 --- a/docs/openapi/paths/activity-reports/activity-recipients.yaml +++ b/docs/openapi/paths/activity-reports/activity-recipients.yaml @@ -2,12 +2,12 @@ get: tags: - activity-reports summary: > - Get possible participants for an activity report + Potential activity report participants description: > - A participant is either a grant or nonGrantee. + An activity recipient is either a grant or nonGrantee. responses: 200: - description: The possible participants + description: The possible activity recipients content: application/json: schema: @@ -16,8 +16,8 @@ get: grants: type: array items: - $ref: '../../index.yaml#/components/schemas/participant' + $ref: '../../index.yaml#/components/schemas/activityRecipient' nonGrantees: type: array items: - $ref: '../../index.yaml#/components/schemas/participant' + $ref: '../../index.yaml#/components/schemas/activityRecipient' diff --git a/docs/openapi/paths/activity-reports/approvers.yaml b/docs/openapi/paths/activity-reports/approvers.yaml index 5074389f0c..82b1798c6b 100644 --- a/docs/openapi/paths/activity-reports/approvers.yaml +++ b/docs/openapi/paths/activity-reports/approvers.yaml @@ -1,7 +1,8 @@ get: tags: - activity-reports - operationId: getApproves + summary: Potential activity report approvers + operationId: getApprovers description: Gets all approvers for any region the current user has write permissions responses: 200: