diff --git a/.circleci/config.yml b/.circleci/config.yml
index 268ceb57c3..6f6c32f6b1 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -427,7 +427,7 @@ parameters:
default: "mb/TTAHUB-3007/no-disallowed-urls"
type: string
sandbox_git_branch: # change to feature branch to test deployment
- default: "al-ttahub-add-fei-root-cause-to-review"
+ default: "jp/3005/ipd-courses-widget"
type: string
prod_new_relic_app_id:
default: "877570491"
diff --git a/frontend/src/pages/ResourcesDashboard/__tests__/index.js b/frontend/src/pages/ResourcesDashboard/__tests__/index.js
index 19346a05d2..703f7eb563 100644
--- a/frontend/src/pages/ResourcesDashboard/__tests__/index.js
+++ b/frontend/src/pages/ResourcesDashboard/__tests__/index.js
@@ -49,6 +49,9 @@ const resourcesDefault = {
participant: {
numParticipants: '765',
},
+ ipdCourses: {
+ percentReports: '4.65%',
+ },
},
resourcesUse: {
headers: ['Jan-22'],
@@ -116,6 +119,9 @@ const resourcesRegion1 = {
participant: {
numParticipants: '665',
},
+ ipdCourses: {
+ percentReports: '4.65%',
+ },
},
resourcesUse: {
headers: ['Jan-22'],
@@ -183,6 +189,9 @@ const resourcesRegion2 = {
participant: {
numParticipants: '565',
},
+ ipdCourses: {
+ percentReports: '4.65%',
+ },
},
resourcesUse: {
headers: ['Jan-22'],
@@ -442,6 +451,10 @@ describe('Resource Dashboard page', () => {
expect(screen.getAllByText(/^[ \t]*reports with resources[ \t]*$/i)[0]).toBeInTheDocument();
expect(screen.getByText(/6,135 of 17,914/i)).toBeInTheDocument();
+ // iPD courses
+ expect(screen.getByText(/4.65%/i)).toBeInTheDocument();
+ expect(screen.getAllByText(/^[ \t]*reports citing ipd courses[ \t]*$/i)[0]).toBeInTheDocument();
+
expect(screen.getByText(/.66%/i)).toBeInTheDocument();
expect(screen.getAllByText(/^[ \t]*eclkc resources[ \t]*$/i)[0]).toBeInTheDocument();
expect(screen.getByText(/818 of 365/i)).toBeInTheDocument();
diff --git a/frontend/src/pages/ResourcesDashboard/index.js b/frontend/src/pages/ResourcesDashboard/index.js
index ab0ffe09a7..1a7941f80e 100644
--- a/frontend/src/pages/ResourcesDashboard/index.js
+++ b/frontend/src/pages/ResourcesDashboard/index.js
@@ -307,6 +307,7 @@ export default function ResourcesDashboard() {
'ECLKC Resources',
'Recipients reached',
'Participants reached',
+ 'Reports citing iPD courses',
]}
showTooltips
/>
diff --git a/frontend/src/widgets/ResourcesDashboardOverview.js b/frontend/src/widgets/ResourcesDashboardOverview.js
index 87e0984286..5d0bbead26 100644
--- a/frontend/src/widgets/ResourcesDashboardOverview.js
+++ b/frontend/src/widgets/ResourcesDashboardOverview.js
@@ -1,9 +1,14 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Grid } from '@trussworks/react-uswds';
+import { Link } from 'react-router-dom';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
- faLink, faCube, faUser, faUserFriends,
+ faLink,
+ faCube,
+ faUser,
+ faUserFriends,
+ faFolder,
} from '@fortawesome/free-solid-svg-icons';
import './ResourcesDashboardOverview.css';
@@ -14,6 +19,7 @@ import colors from '../colors';
export function Field({
label1,
label2,
+ route,
data,
icon,
iconColor,
@@ -37,8 +43,15 @@ export function Field({
buttonLabel={`${tooltipText} click to visually reveal this information`}
tooltipText={tooltipText}
/>
- ) : label1}
+ ) : (
+ {label1}
+ )}
{label2}
+ {route && (
+
+ {route.label}
+
+ )}
);
@@ -58,12 +71,17 @@ Field.propTypes = {
backgroundColor: PropTypes.string.isRequired,
tooltipText: PropTypes.string,
showTooltip: PropTypes.bool,
+ route: PropTypes.shape({
+ to: PropTypes.string,
+ label: PropTypes.string,
+ }),
};
Field.defaultProps = {
tooltipText: '',
showTooltip: false,
label2: '',
+ route: null,
};
const DASHBOARD_FIELDS = {
'Reports with resources': {
@@ -124,6 +142,24 @@ const DASHBOARD_FIELDS = {
/>
),
},
+ 'Reports citing iPD courses': {
+ render: (data) => (
+
+ ),
+ },
};
export function ResourcesDashboardOverviewWidget({
@@ -180,6 +216,9 @@ ResourcesDashboardOverviewWidget.defaultProps = {
participant: {
numParticipants: '0',
},
+ ipdCourses: {
+ percentReports: '0%',
+ },
},
loading: false,
showTooltips: false,
@@ -188,6 +227,7 @@ ResourcesDashboardOverviewWidget.defaultProps = {
'ECLKC Resources',
'Recipients reached',
'Participants reached',
+ 'Reports citing iPD courses',
],
};
diff --git a/frontend/src/widgets/__tests__/ResourcesDashboardOverview.js b/frontend/src/widgets/__tests__/ResourcesDashboardOverview.js
index 43e0522dd8..d4f38dcc7d 100644
--- a/frontend/src/widgets/__tests__/ResourcesDashboardOverview.js
+++ b/frontend/src/widgets/__tests__/ResourcesDashboardOverview.js
@@ -1,12 +1,20 @@
/* eslint-disable jest/no-disabled-tests */
import '@testing-library/jest-dom';
import React from 'react';
+import { Router } from 'react-router-dom';
+import { createMemoryHistory } from 'history';
import { render, screen } from '@testing-library/react';
import { ResourcesDashboardOverviewWidget } from '../ResourcesDashboardOverview';
-const renderResourcesDashboardOverview = (props) => (
- render()
-);
+const renderResourcesDashboardOverview = (props) => {
+ const history = createMemoryHistory();
+
+ render(
+
+
+ ,
+ );
+};
describe('Resource Dashboard Overview Widget', () => {
it('handles undefined data', async () => {
@@ -16,6 +24,7 @@ describe('Resource Dashboard Overview Widget', () => {
expect(screen.getByText(/eclkc resources/i)).toBeInTheDocument();
expect(screen.getByText(/recipients reached/i)).toBeInTheDocument();
expect(screen.getByText(/participants reached/i)).toBeInTheDocument();
+ expect(screen.getByText(/reports citing ipd courses/i)).toBeInTheDocument();
});
it('shows the correct data', async () => {
@@ -36,14 +45,19 @@ describe('Resource Dashboard Overview Widget', () => {
participant: {
numParticipants: '765',
},
+ ipdCourses: {
+ percentReports: '88.88%',
+ },
};
renderResourcesDashboardOverview({ data });
- expect(await screen.findByText(/^[ \t]*reports with resources\r?\n?[ \t]*8,135 of 19,914/i)).toBeVisible();
- expect(await screen.findByText(/^[ \t]*eclkc resources\n?[ \t]*1,819 of 2,365/i)).toBeVisible();
- expect(await screen.findByText(/248/i)).toBeVisible();
+ expect(await screen.findByText(/8,135 of 19,914/)).toBeVisible();
+ expect(await screen.findByText(/1,819 of 2,365/)).toBeVisible();
+ expect(await screen.findByText(/248/)).toBeVisible();
expect(await screen.findByText(/recipients reached/i)).toBeVisible();
- expect(await screen.findByText(/765/i)).toBeVisible();
+ expect(await screen.findByText(/765/)).toBeVisible();
expect(await screen.findByText(/participants reached/i)).toBeVisible();
+ expect(await screen.findByText(/88.88%/)).toBeVisible();
+ expect(await screen.findByText(/reports citing ipd courses/i)).toBeVisible();
});
});
diff --git a/src/goalServices/createOrUpdateGoals.test.js b/src/goalServices/createOrUpdateGoals.test.js
index f58d47849e..99c5fede35 100644
--- a/src/goalServices/createOrUpdateGoals.test.js
+++ b/src/goalServices/createOrUpdateGoals.test.js
@@ -48,6 +48,7 @@ describe('createOrUpdateGoals', () => {
status: 'Draft',
grantId: grants[0].id,
source: GOAL_SOURCES[0],
+ createdVia: 'activityReport',
});
objective = await Objective.create({
@@ -113,7 +114,6 @@ describe('createOrUpdateGoals', () => {
grantId: goal.grantId,
status: 'Draft',
};
-
newGoals = await createOrUpdateGoals([
{
...basicGoal,
diff --git a/src/goalServices/goals.js b/src/goalServices/goals.js
index ae6aece909..7142516a2e 100644
--- a/src/goalServices/goals.js
+++ b/src/goalServices/goals.js
@@ -484,6 +484,7 @@ export async function createOrUpdateGoals(goals) {
status: 'Draft', // if we are creating a goal for the first time, it should be set to 'Draft'
isFromSmartsheetTtaPlan: false,
rtrOrder: rtrOrder + 1,
+ createdVia: 'rtr',
});
}
@@ -504,10 +505,6 @@ export async function createOrUpdateGoals(goals) {
if (newGoal.status !== status) {
newGoal.set({ status });
}
-
- if (!newGoal.createdVia || newGoal.createdVia !== createdVia) {
- newGoal.set({ createdVia: createdVia || (newGoal.isFromSmartsheetTtaPlan ? 'imported' : 'rtr') });
- }
}
// end date and source can be updated if the goal is not closed
diff --git a/src/lib/transform.js b/src/lib/transform.js
index bd6f86cf9c..b2e7244fec 100644
--- a/src/lib/transform.js
+++ b/src/lib/transform.js
@@ -1,5 +1,6 @@
import moment from 'moment';
import md5 from 'md5';
+import { uniq } from 'lodash';
import { convert } from 'html-to-text';
import { DATE_FORMAT } from '../constants';
@@ -31,6 +32,7 @@ function transformSimpleValue(instance, field) {
value = value.sort().join('\n');
}
const obj = {};
+
Object.defineProperty(obj, field, {
value,
enumerable: true,
@@ -80,7 +82,7 @@ function transformRelatedModel(field, prop) {
}
// we sort the values
const value = records.map((r) => (r[prop] || '')).sort().join('\n');
- Object.defineProperty(obj, field, {
+ Object.defineProperty(obj, `${field}`, {
value,
enumerable: true,
});
@@ -90,6 +92,35 @@ function transformRelatedModel(field, prop) {
return transformer;
}
+/**
+ *
+ * @param {string} field
+ * @param {Array} fieldDefs expected [{ subfield: string, label: string }]
+ * @param {*} prop
+ * @returns () => ({})
+ */
+function transformRelatedModelWithMultiFields(field, fieldDefs) {
+ function transformer(instance) {
+ const obj = {};
+ fieldDefs.forEach((fieldDef) => {
+ let records = instance[field];
+ if (records) {
+ if (!Array.isArray(records)) {
+ records = [records];
+ }
+ const value = records.map((r) => r[fieldDef.subfield]).join('\n');
+ Object.defineProperty(obj, fieldDef.label, {
+ value,
+ enumerable: true,
+ });
+ }
+ });
+ return obj;
+ }
+
+ return transformer;
+}
+
function transformCollaborators(joinTable, field, fieldName) {
function transformer(instance) {
const obj = {};
@@ -210,6 +241,7 @@ function makeGoalsObjectFromActivityReportGoals(goalRecords) {
name = null,
status = null,
createdVia = null,
+ source = null,
} = goal || {};
const goalNameIndex = Object.values(goals).findIndex((n) => n === name);
if (goalNameIndex === -1) {
@@ -217,6 +249,7 @@ function makeGoalsObjectFromActivityReportGoals(goalRecords) {
goals[`goal-${goalCsvRecordNumber}`] = name;
goals[`goal-${goalCsvRecordNumber}-status`] = status;
goals[`goal-${goalCsvRecordNumber}-created-from`] = createdVia;
+ goals[`goal-${goalCsvRecordNumber}-source`] = source;
goalCsvRecordNumber += 1;
return;
}
@@ -228,6 +261,20 @@ function makeGoalsObjectFromActivityReportGoals(goalRecords) {
return goals;
}
+function updateObjectiveWithRelatedModelData(
+ relation,
+ relationLabel,
+ relationKey,
+ accum,
+ objectiveId,
+) {
+ const relatedSimple = (relation || []).map((t) => t[relationKey]);
+ Object.defineProperty(accum, `objective-${objectiveId}-${relationLabel}`, {
+ value: relatedSimple.join('\n'),
+ enumerable: true,
+ });
+}
+
/*
* Create an object with goals and objectives. Used by transformGoalsAndObjectives
* @param {Array} objectiveRecords
@@ -243,7 +290,15 @@ function makeGoalsAndObjectivesObject(objectiveRecords) {
return objectiveRecords.reduce((prevAccum, objective) => {
const accum = { ...prevAccum };
const {
- goal, title, status, ttaProvided, topics, files, resources,
+ goal,
+ title,
+ status,
+ ttaProvided,
+ topics,
+ files,
+ resources,
+ courses,
+ supportType,
} = objective;
const goalId = goal ? goal.id : null;
const titleMd5 = md5(title);
@@ -275,6 +330,21 @@ function makeGoalsAndObjectivesObject(objectiveRecords) {
enumerable: true,
});
+ Object.defineProperty(accum, `goal-${goalNum}-source`, {
+ value: goal.source,
+ enumerable: true,
+ });
+
+ Object.defineProperty(accum, `goal-${goalNum}-standard-ohs-goal`, {
+ value: goal.isCurated ? 'Yes' : 'No',
+ enumerable: true,
+ });
+
+ Object.defineProperty(accum, `goal-${goalNum}-fei-root-causes`, {
+ value: goal.responses.map((response) => response.response).join('\n'),
+ enumerable: true,
+ });
+
// Created From.
Object.defineProperty(accum, `goal-${goalNum}-created-from`, {
value: goal.createdVia,
@@ -286,6 +356,14 @@ function makeGoalsAndObjectivesObject(objectiveRecords) {
// Make sure its not another objective for the same goal.
if (goalIds[goalName] && !goalIds[goalName].includes(goalId)) {
accum[`goal-${existingObjectiveTitle}-id`] = `${accum[`goal-${existingObjectiveTitle}-id`]}\n${goalId}`;
+ if (accum[`goal-${goalNum}-source`]) {
+ accum[`goal-${goalNum}-source`] = uniq([...(accum[`goal-${goalNum}-source`]).split('\n'), goal.source]).join('\n');
+ } else {
+ accum[`goal-${goalNum}-source`] = goal.source;
+ }
+ if (goal.isCurated) {
+ accum[`goal-${goalNum}-fei-root-causes`] = uniq([...accum[`goal-${goalNum}-fei-root-causes`].split('\n'), ...goal.responses.map((response) => response.response)]).join('\n');
+ }
goalIds[goalName].push(goalId);
}
return accum;
@@ -296,8 +374,6 @@ function makeGoalsAndObjectivesObject(objectiveRecords) {
goalNum = 1;
}
- // same with objective num
-
/**
* this will start other entity objectives at 1.1, which will prevent the creation
* of columns that don't fit the current schema (for example, objective-1.0)
@@ -313,34 +389,52 @@ function makeGoalsAndObjectivesObject(objectiveRecords) {
enumerable: true,
});
- // Activity Report Objective: Topics.
- const objTopics = topics.map((t) => t.name);
- Object.defineProperty(accum, `objective-${objectiveId}-topics`, {
- value: objTopics.join('\n'),
- enumerable: true,
- });
+ updateObjectiveWithRelatedModelData(
+ topics,
+ 'topics',
+ 'name',
+ accum,
+ objectiveId,
+ );
+
+ updateObjectiveWithRelatedModelData(
+ courses,
+ 'courses',
+ 'name',
+ accum,
+ objectiveId,
+ );
+
+ updateObjectiveWithRelatedModelData(
+ resources,
+ 'resourcesLinks',
+ 'url',
+ accum,
+ objectiveId,
+ );
+
+ updateObjectiveWithRelatedModelData(
+ files,
+ 'nonResourceLinks',
+ 'originalFileName',
+ accum,
+ objectiveId,
+ );
- // Activity Report Objective: Resources Links.
- const objResources = resources.map((r) => r.url);
- Object.defineProperty(accum, `objective-${objectiveId}-resourcesLinks`, {
- value: objResources.join('\n'),
+ Object.defineProperty(accum, `objective-${objectiveId}-ttaProvided`, {
+ value: convert(ttaProvided),
enumerable: true,
});
- // Activity Report Objective: Non-Resource Links (Files).
- const objFiles = files.map((f) => f.originalFileName);
- Object.defineProperty(accum, `objective-${objectiveId}-nonResourceLinks`, {
- value: objFiles.join('\n'),
+ Object.defineProperty(accum, `objective-${objectiveId}-supportType`, {
+ value: supportType,
enumerable: true,
});
+
Object.defineProperty(accum, `objective-${objectiveId}-status`, {
value: status,
enumerable: true,
});
- Object.defineProperty(accum, `objective-${objectiveId}-ttaProvided`, {
- value: convert(ttaProvided),
- enumerable: true,
- });
// Add this objective to the tracked list.
processedObjectivesTitles.set(titleMd5, goalNum);
@@ -368,6 +462,8 @@ function transformGoalsAndObjectives(report) {
topics: aro.topics,
files: aro.files,
resources: aro.resources,
+ courses: aro.courses,
+ supportType: aro.supportType,
}
));
if (objectiveRecords) {
@@ -396,6 +492,7 @@ const arTransformers = [
'participants',
'topics',
'ttaType',
+ 'language',
'numberOfParticipants',
'deliveryMethod',
'duration',
@@ -408,8 +505,20 @@ const arTransformers = [
'nonECLKCResourcesUsed',
transformRelatedModel('files', 'originalFileName'),
transformGoalsAndObjectives,
- transformRelatedModel('recipientNextSteps', 'note'),
- transformRelatedModel('specialistNextSteps', 'note'),
+ transformRelatedModelWithMultiFields('recipientNextSteps', [{
+ subfield: 'note',
+ label: 'recipientNextSteps',
+ }, {
+ subfield: 'completeDate',
+ label: 'recipientNextStepsCompleteDate',
+ }]),
+ transformRelatedModelWithMultiFields('specialistNextSteps', [{
+ subfield: 'note',
+ label: 'specialistNextSteps',
+ }, {
+ subfield: 'completeDate',
+ label: 'specialistNextStepsCompleteDate',
+ }]),
transformHTML('context'),
transformHTML('additionalNotes'),
'lastSaved',
diff --git a/src/lib/transform.test.js b/src/lib/transform.test.js
index d8f4ade78b..eaebd42596 100644
--- a/src/lib/transform.test.js
+++ b/src/lib/transform.test.js
@@ -1,5 +1,5 @@
import { REPORT_STATUSES } from '@ttahub/common';
-import { OBJECTIVE_STATUS } from '../constants';
+import { CREATION_METHOD, OBJECTIVE_STATUS } from '../constants';
import {
ActivityReport,
User,
@@ -8,10 +8,13 @@ import {
ActivityReportGoal,
Grant,
Goal,
+ GoalFieldResponse,
+ GoalTemplate,
Recipient,
ActivityReportCollaborator,
ActivityReportObjective,
Resource,
+ Course,
Topic,
Objective,
File,
@@ -59,6 +62,12 @@ describe('activityReportToCsvRecord', () => {
grantId: 1,
timeframe: 'None',
createdVia: 'activityReport',
+ goalTemplate: {
+ creationMethod: CREATION_METHOD.AUTOMATIC,
+ id: 20_800,
+ },
+ source: 'RTTAPA development',
+ responses: [],
},
{
name: 'Goal 2',
@@ -67,6 +76,12 @@ describe('activityReportToCsvRecord', () => {
grantId: 1,
timeframe: 'None',
createdVia: 'rtr',
+ goalTemplate: {
+ creationMethod: CREATION_METHOD.AUTOMATIC,
+ id: 20_801,
+ },
+ source: 'RTTAPA development',
+ responses: [],
},
{
name: 'Goal 3',
@@ -75,6 +90,12 @@ describe('activityReportToCsvRecord', () => {
grantId: 1,
timeframe: 'None',
createdVia: 'imported',
+ goalTemplate: {
+ creationMethod: CREATION_METHOD.AUTOMATIC,
+ id: 20_802,
+ },
+ source: 'RTTAPA development',
+ responses: [],
},
{
name: 'Goal 3',
@@ -83,6 +104,12 @@ describe('activityReportToCsvRecord', () => {
grantId: 2,
timeframe: 'None',
createdVia: 'activityReport',
+ goalTemplate: {
+ creationMethod: CREATION_METHOD.AUTOMATIC,
+ id: 20_803,
+ },
+ source: 'RTTAPA development',
+ responses: [],
},
{
name: 'Goal 4',
@@ -91,6 +118,12 @@ describe('activityReportToCsvRecord', () => {
grantId: 3,
timeframe: 'None',
createdVia: 'activityReport',
+ goalTemplate: {
+ creationMethod: CREATION_METHOD.AUTOMATIC,
+ id: 20_804,
+ },
+ source: 'RTTAPA development',
+ responses: [],
},
// Same goal different recipient.
{
@@ -100,6 +133,12 @@ describe('activityReportToCsvRecord', () => {
grantId: 4,
timeframe: 'None',
createdVia: 'activityReport',
+ goalTemplate: {
+ creationMethod: CREATION_METHOD.AUTOMATIC,
+ id: 20_805,
+ },
+ source: 'RTTAPA development',
+ responses: [],
},
];
@@ -110,6 +149,7 @@ describe('activityReportToCsvRecord', () => {
ttaProvided: 'Training',
status: OBJECTIVE_STATUS.COMPLETE,
goal: mockGoals[0],
+ supportType: 'Maintaining',
},
{
id: 12,
@@ -117,6 +157,7 @@ describe('activityReportToCsvRecord', () => {
ttaProvided: 'Training',
status: OBJECTIVE_STATUS.COMPLETE,
goal: mockGoals[0],
+ supportType: 'Maintaining',
},
{
id: 13,
@@ -124,6 +165,7 @@ describe('activityReportToCsvRecord', () => {
ttaProvided: 'Training',
status: OBJECTIVE_STATUS.COMPLETE,
goal: mockGoals[1],
+ supportType: 'Maintaining',
},
{
id: 14,
@@ -131,6 +173,7 @@ describe('activityReportToCsvRecord', () => {
ttaProvided: 'Training',
status: OBJECTIVE_STATUS.COMPLETE,
goal: mockGoals[1],
+ supportType: 'Maintaining',
},
{
id: 15,
@@ -138,6 +181,7 @@ describe('activityReportToCsvRecord', () => {
ttaProvided: 'Training',
status: OBJECTIVE_STATUS.COMPLETE,
goal: mockGoals[1],
+ supportType: 'Maintaining',
},
{
id: 16,
@@ -145,6 +189,7 @@ describe('activityReportToCsvRecord', () => {
ttaProvided: 'Training',
status: OBJECTIVE_STATUS.COMPLETE,
goal: mockGoals[2],
+ supportType: 'Maintaining',
},
{
id: 17,
@@ -152,6 +197,7 @@ describe('activityReportToCsvRecord', () => {
ttaProvided: 'Training',
status: OBJECTIVE_STATUS.COMPLETE,
goal: mockGoals[2],
+ supportType: 'Maintaining',
},
// Duplicate Objective name for goal 4.
{
@@ -160,6 +206,7 @@ describe('activityReportToCsvRecord', () => {
ttaProvided: 'Training',
status: OBJECTIVE_STATUS.COMPLETE,
goal: mockGoals[4],
+ supportType: 'Maintaining',
},
{
id: 19,
@@ -167,6 +214,7 @@ describe('activityReportToCsvRecord', () => {
ttaProvided: 'Training',
status: OBJECTIVE_STATUS.COMPLETE,
goal: mockGoals[4],
+ supportType: 'Maintaining',
},
// Same as goal 1 different recipient.
{
@@ -175,6 +223,7 @@ describe('activityReportToCsvRecord', () => {
ttaProvided: 'Training',
status: OBJECTIVE_STATUS.COMPLETE,
goal: mockGoals[5],
+ supportType: 'Maintaining',
},
{
id: 21,
@@ -182,6 +231,7 @@ describe('activityReportToCsvRecord', () => {
ttaProvided: 'Training',
status: OBJECTIVE_STATUS.COMPLETE,
goal: mockGoals[5],
+ supportType: 'Maintaining',
},
];
@@ -192,7 +242,6 @@ describe('activityReportToCsvRecord', () => {
userId: 3,
user: {
name: 'Test Approver 1',
-
},
},
{
@@ -415,7 +464,6 @@ describe('activityReportToCsvRecord', () => {
as: 'approvers',
include: [{ model: User, as: 'user' }],
},
-
{
model: ActivityReportObjective,
as: 'activityReportObjectives',
@@ -423,6 +471,22 @@ describe('activityReportToCsvRecord', () => {
{
model: Objective,
as: 'objective',
+ include: [
+ {
+ model: Goal,
+ as: 'goal',
+ include: [
+ {
+ model: GoalTemplate,
+ as: 'goalTemplate',
+ },
+ {
+ model: GoalFieldResponse,
+ as: 'responses',
+ },
+ ],
+ },
+ ],
},
{
model: Resource,
@@ -436,10 +500,15 @@ describe('activityReportToCsvRecord', () => {
model: File,
as: 'files',
},
+ {
+ model: Course,
+ as: 'courses',
+ },
],
},
],
});
+
const output = await activityReportToCsvRecord(report.toJSON());
const {
creatorName,
@@ -471,6 +540,7 @@ describe('activityReportToCsvRecord', () => {
topics: [{ name: 'Topic 1' }],
resources: [{ url: 'https://test.gov' }],
files: [{ originalFileName: 'TestFile.docx' }],
+ courses: [{ name: 'Other' }],
}));
const output = makeGoalsAndObjectivesObject(objectives);
@@ -479,10 +549,15 @@ describe('activityReportToCsvRecord', () => {
'goal-1': 'Goal 1',
'goal-1-status': 'Not Started',
'goal-1-created-from': 'activityReport',
+ 'goal-1-fei-root-causes': '',
+ 'goal-1-source': 'RTTAPA development',
+ 'goal-1-standard-ohs-goal': 'No',
'objective-1.1': 'Objective 1.1',
'objective-1.1-topics': 'Topic 1',
'objective-1.1-resourcesLinks': 'https://test.gov',
'objective-1.1-nonResourceLinks': 'TestFile.docx',
+ 'objective-1.1-courses': 'Other',
+ 'objective-1.1-supportType': 'Maintaining',
'objective-1.1-ttaProvided': 'Training',
'objective-1.1-status': 'Complete',
'objective-1.2': 'Objective 1.2',
@@ -491,54 +566,129 @@ describe('activityReportToCsvRecord', () => {
'objective-1.2-nonResourceLinks': 'TestFile.docx',
'objective-1.2-ttaProvided': 'Training',
'objective-1.2-status': 'Complete',
+ 'objective-1.2-courses': 'Other',
+ 'objective-1.2-supportType': 'Maintaining',
'goal-2-id': '2081',
'goal-2': 'Goal 2',
'goal-2-status': 'Not Started',
'goal-2-created-from': 'rtr',
+ 'goal-2-fei-root-causes': '',
+ 'goal-2-source': 'RTTAPA development',
+ 'goal-2-standard-ohs-goal': 'No',
'objective-2.1': 'Objective 2.1',
'objective-2.1-topics': 'Topic 1',
'objective-2.1-resourcesLinks': 'https://test.gov',
'objective-2.1-nonResourceLinks': 'TestFile.docx',
'objective-2.1-ttaProvided': 'Training',
'objective-2.1-status': 'Complete',
+ 'objective-2.1-courses': 'Other',
+ 'objective-2.1-supportType': 'Maintaining',
'objective-2.2': 'Objective 2.2',
'objective-2.2-topics': 'Topic 1',
'objective-2.2-resourcesLinks': 'https://test.gov',
'objective-2.2-nonResourceLinks': 'TestFile.docx',
'objective-2.2-ttaProvided': 'Training',
'objective-2.2-status': 'Complete',
+ 'objective-2.2-courses': 'Other',
+ 'objective-2.2-supportType': 'Maintaining',
'objective-2.3': 'Objective 2.3',
'objective-2.3-topics': 'Topic 1',
'objective-2.3-resourcesLinks': 'https://test.gov',
'objective-2.3-nonResourceLinks': 'TestFile.docx',
'objective-2.3-ttaProvided': 'Training',
'objective-2.3-status': 'Complete',
+ 'objective-2.3-courses': 'Other',
+ 'objective-2.3-supportType': 'Maintaining',
'goal-3-id': '2082',
'goal-3': 'Goal 3',
'goal-3-status': 'Not Started',
'goal-3-created-from': 'imported',
+ 'goal-3-fei-root-causes': '',
+ 'goal-3-source': 'RTTAPA development',
+ 'goal-3-standard-ohs-goal': 'No',
'objective-3.1': 'Objective 3.1',
'objective-3.1-topics': 'Topic 1',
'objective-3.1-resourcesLinks': 'https://test.gov',
'objective-3.1-nonResourceLinks': 'TestFile.docx',
'objective-3.1-ttaProvided': 'Training',
'objective-3.1-status': 'Complete',
+ 'objective-3.1-courses': 'Other',
+ 'objective-3.1-supportType': 'Maintaining',
'goal-4-id': '2084',
'goal-4': 'Goal 4',
'goal-4-status': 'Not Started',
'goal-4-created-from': 'activityReport',
+ 'goal-4-fei-root-causes': '',
+ 'goal-4-source': 'RTTAPA development',
+ 'goal-4-standard-ohs-goal': 'No',
'objective-4.1': 'Objective 3.1',
'objective-4.1-topics': 'Topic 1',
'objective-4.1-resourcesLinks': 'https://test.gov',
'objective-4.1-nonResourceLinks': 'TestFile.docx',
'objective-4.1-ttaProvided': 'Training',
'objective-4.1-status': 'Complete',
+ 'objective-4.1-courses': 'Other',
+ 'objective-4.1-supportType': 'Maintaining',
'objective-4.2': 'Objective 4.2',
'objective-4.2-topics': 'Topic 1',
'objective-4.2-resourcesLinks': 'https://test.gov',
'objective-4.2-nonResourceLinks': 'TestFile.docx',
'objective-4.2-ttaProvided': 'Training',
'objective-4.2-status': 'Complete',
+ 'objective-4.2-courses': 'Other',
+ 'objective-4.2-supportType': 'Maintaining',
+ });
+ });
+
+ it('handles a null goal source', () => {
+ const objectives = mockObjectives.map((mo, i) => {
+ if (i === 0) {
+ return {
+ ...mo,
+ title: 'same title',
+ goal: {
+ ...mo.goal,
+ source: null,
+ name: 'same name',
+ },
+ topics: [{ name: 'Topic 1' }],
+ resources: [{ url: 'https://test.gov' }],
+ files: [{ originalFileName: 'TestFile.docx' }],
+ courses: [{ name: 'Other' }],
+ };
+ }
+
+ return {
+ ...mo,
+ title: 'same title',
+ goal: {
+ ...mo.goal,
+ name: 'same name',
+ },
+ topics: [{ name: 'Topic 1' }],
+ resources: [{ url: 'https://test.gov' }],
+ files: [{ originalFileName: 'TestFile.docx' }],
+ courses: [{ name: 'Other' }],
+ };
+ });
+
+ const output = makeGoalsAndObjectivesObject(objectives);
+ expect(output).toEqual({
+ 'goal-1-id': '2080\n2081\n2082\n2084\n2085',
+ 'goal-1': 'same name',
+ 'goal-1-status': 'Not Started',
+ 'goal-1-created-from': 'activityReport',
+ 'goal-1-fei-root-causes': '',
+ 'goal-1-source': 'RTTAPA development',
+ 'goal-1-standard-ohs-goal': 'No',
+ 'objective-1.1': 'same title',
+ 'objective-1.1-topics': 'Topic 1',
+ 'objective-1.1-resourcesLinks': 'https://test.gov',
+ 'objective-1.1-nonResourceLinks': 'TestFile.docx',
+ 'objective-1.1-courses': 'Other',
+ 'objective-1.1-supportType': 'Maintaining',
+ 'objective-1.1-ttaProvided': 'Training',
+ 'objective-1.1-status': 'Complete',
});
});
diff --git a/src/migrations/20240625135148-reset-bad-created-vias.js b/src/migrations/20240625135148-reset-bad-created-vias.js
new file mode 100644
index 0000000000..63fef0f001
--- /dev/null
+++ b/src/migrations/20240625135148-reset-bad-created-vias.js
@@ -0,0 +1,23 @@
+const {
+ prepMigration,
+} = require('../lib/migration');
+
+module.exports = {
+ up: async (queryInterface) => queryInterface.sequelize.transaction(
+ async (transaction) => {
+ await prepMigration(queryInterface, transaction, __filename);
+ await queryInterface.sequelize.query(/* sql */`
+ UPDATE "Goals"
+ SET "createdVia" = 'merge'
+ WHERE id IN (69403, 78365) AND "createdVia" = 'imported'; -- Nathan helpfully provided me with these IDs based on the audit log
+ `, { transaction });
+ },
+ ),
+
+ down: async (queryInterface) => queryInterface.sequelize.transaction(
+ async (transaction) => {
+ await prepMigration(queryInterface, transaction, __filename);
+ // No need to put back bad data
+ },
+ ),
+};
diff --git a/src/models/goal.js b/src/models/goal.js
index 92620b56cc..71ada9f158 100644
--- a/src/models/goal.js
+++ b/src/models/goal.js
@@ -1,5 +1,5 @@
const { Model } = require('sequelize');
-const { CLOSE_SUSPEND_REASONS, GOAL_SOURCES } = require('@ttahub/common');
+const { GOAL_SOURCES } = require('@ttahub/common');
const { formatDate } = require('../lib/modelHelpers');
const {
beforeValidate,
@@ -8,7 +8,7 @@ const {
afterUpdate,
afterDestroy,
} = require('./hooks/goal');
-const { GOAL_CREATED_VIA } = require('../constants');
+const { GOAL_CREATED_VIA, CREATION_METHOD } = require('../constants');
export const RTTAPA_ENUM = ['Yes', 'No'];
@@ -79,6 +79,13 @@ export default (sequelize, DataTypes) => {
return `G-${id}`;
},
},
+ isCurated: {
+ type: DataTypes.VIRTUAL,
+ get() {
+ const { goalTemplate } = this;
+ return goalTemplate?.creationMethod === CREATION_METHOD.CURATED;
+ },
+ },
grantId: {
type: DataTypes.INTEGER,
allowNull: false,
diff --git a/src/routes/activityReports/handlers.js b/src/routes/activityReports/handlers.js
index 35f66e021e..a9b6ae6233 100644
--- a/src/routes/activityReports/handlers.js
+++ b/src/routes/activityReports/handlers.js
@@ -134,6 +134,10 @@ async function sendActivityReportCSV(reports, res) {
key: 'ttaType',
header: 'TTA type',
},
+ {
+ key: 'language',
+ header: 'Language',
+ },
{
key: 'deliveryMethod',
header: 'Delivery method',
@@ -172,10 +176,18 @@ async function sendActivityReportCSV(reports, res) {
key: 'specialistNextSteps',
header: 'Specialist next steps',
},
+ {
+ key: 'specialistNextStepsCompleteDate',
+ header: 'Specialist next steps anticipated completion date',
+ },
{
key: 'recipientNextSteps',
header: 'Recipient next steps',
},
+ {
+ key: 'recipientNextStepsCompleteDate',
+ header: 'Recipient next steps anticipated completion date',
+ },
{
key: 'createdAt',
header: 'Created date',
diff --git a/src/routes/courses/index.ts b/src/routes/courses/index.ts
index f23f1ec1f3..cc849358fb 100644
--- a/src/routes/courses/index.ts
+++ b/src/routes/courses/index.ts
@@ -11,9 +11,9 @@ import transactionWrapper from '../transactionWrapper';
const router = express.Router();
router.get('/', transactionWrapper(allCourses));
+router.get('/dashboard', transactionWrapper(getCourseUrlWidgetDataWithCache));
router.get('/:id', transactionWrapper(getCourseById));
router.put('/:id', transactionWrapper(updateCourseById));
router.post('/', transactionWrapper(createCourseByName));
router.delete('/:id', transactionWrapper(deleteCourseById));
-router.get('/dashboard', transactionWrapper(getCourseUrlWidgetDataWithCache));
export default router;
diff --git a/src/services/activityReports.js b/src/services/activityReports.js
index c0c0346903..1e2dcfe398 100644
--- a/src/services/activityReports.js
+++ b/src/services/activityReports.js
@@ -20,6 +20,8 @@ import {
Recipient,
OtherEntity,
Goal,
+ GoalTemplate,
+ GoalFieldResponse,
User,
NextStep,
Objective,
@@ -31,6 +33,7 @@ import {
Topic,
CollaboratorRole,
Role,
+ Course,
} from '../models';
import {
removeUnusedGoalsObjectivesFromReport,
@@ -1163,7 +1166,7 @@ async function getDownloadableActivityReports(where, separate = true) {
{
model: ActivityReportObjective,
as: 'activityReportObjectives',
- attributes: ['ttaProvided', 'status'],
+ attributes: ['ttaProvided', 'status', 'supportType'],
order: [['objective', 'goal', 'id'], ['objective', 'id']],
separate,
include: [{
@@ -1173,6 +1176,15 @@ async function getDownloadableActivityReports(where, separate = true) {
model: Goal,
as: 'goal',
required: false,
+ include: [{
+ model: GoalFieldResponse,
+ as: 'responses',
+ attributes: ['response'],
+ }, {
+ model: GoalTemplate,
+ as: 'goalTemplate',
+ attributes: ['creationMethod'],
+ }],
},
],
attributes: ['id', 'title', 'status'],
@@ -1190,6 +1202,10 @@ async function getDownloadableActivityReports(where, separate = true) {
model: File,
as: 'files',
},
+ {
+ model: Course,
+ as: 'courses',
+ },
],
},
{
@@ -1259,14 +1275,14 @@ async function getDownloadableActivityReports(where, separate = true) {
},
{
model: NextStep,
- attributes: ['note', 'id'],
+ attributes: ['note', 'id', 'completeDate'],
as: 'specialistNextSteps',
separate,
required: false,
},
{
model: NextStep,
- attributes: ['note', 'id'],
+ attributes: ['note', 'id', 'completeDate'],
as: 'recipientNextSteps',
separate,
required: false,
diff --git a/src/services/dashboards/resource.js b/src/services/dashboards/resource.js
index dc887099ac..18e15436a2 100644
--- a/src/services/dashboards/resource.js
+++ b/src/services/dashboards/resource.js
@@ -418,7 +418,23 @@ async function GenerateFlatTempTables(reportIds, tblNames) {
JOIN ${tblNames.createdResourcesTempTableName} arorr
ON aror."resourceId" = arorr.id;
- -- 7.) Create date headers.
+ -- 7.) Create flat reports with courses temp table.
+ DROP TABLE IF EXISTS ${tblNames.createdAroCoursesTempTableName};
+ SELECT
+ DISTINCT
+ ar.id AS "activityReportId",
+ c.id AS "courseId",
+ c.name
+ INTO TEMP ${tblNames.createdAroCoursesTempTableName}
+ FROM ${tblNames.createdArTempTableName} ar
+ JOIN "ActivityReportObjectives" aro
+ ON ar."id" = aro."activityReportId"
+ JOIN "ActivityReportObjectiveCourses" aroc
+ ON aro.id = aroc."activityReportObjectiveId"
+ JOIN "Courses" c
+ ON aroc."courseId" = c.id;
+
+ -- 8.) Create date headers.
DROP TABLE IF EXISTS ${tblNames.createdFlatResourceHeadersTempTableName};
SELECT
generate_series(
@@ -591,10 +607,27 @@ function getOverview(tblNames, totalReportCount) {
END AS "resourcesPct"
FROM ${tblNames.createdAroResourcesTempTableName};
`;
+
const pctOfReportsWithResources = sequelize.query(pctOfResourcesSql, {
type: QueryTypes.SELECT,
});
+ const pctOfReportsWithCoursesSql = /* sql */`
+ SELECT
+ count(DISTINCT "activityReportId")::decimal AS "reportsWithCoursesCount",
+ ${totalReportCount}::decimal AS "totalReportsCount",
+ CASE WHEN ${totalReportCount} = 0 THEN
+ 0
+ ELSE
+ (count(DISTINCT "activityReportId") / ${totalReportCount}::decimal * 100)::decimal(5,2)
+ END AS "coursesPct"
+ FROM ${tblNames.createdAroCoursesTempTableName};
+ `;
+
+ const pctOfReportsWithCourses = sequelize.query(pctOfReportsWithCoursesSql, {
+ type: QueryTypes.SELECT,
+ });
+
// - Number of Reports with ECLKC Resources Pct -
const pctOfECKLKCResources = sequelize.query(/* sql */`
WITH eclkc AS (
@@ -633,6 +666,7 @@ function getOverview(tblNames, totalReportCount) {
numberOfParticipants,
numberOfRecipients,
pctOfReportsWithResources,
+ pctOfReportsWithCourses,
pctOfECKLKCResources,
dateHeaders,
};
@@ -674,6 +708,7 @@ export async function resourceFlatData(scopes) {
const uuid = uuidv4().replaceAll('-', '_');
const createdArTempTableName = `Z_temp_resdb_ar__${uuid}`;
const createdAroResourcesTempTableName = `Z_temp_resdb_aror__${uuid}`;
+ const createdAroCoursesTempTableName = `Z_temp_resdb_aroc__${uuid}`;
const createdResourcesTempTableName = `Z_temp_resdb_res__${uuid}`;
const createdAroTopicsTempTableName = `Z_temp_resdb_arot__${uuid}`;
const createdTopicsTempTableName = `Z_temp_resdb_topics__${uuid}`;
@@ -683,6 +718,7 @@ export async function resourceFlatData(scopes) {
const tempTableNames = {
createdArTempTableName,
createdAroResourcesTempTableName,
+ createdAroCoursesTempTableName,
createdResourcesTempTableName,
createdAroTopicsTempTableName,
createdTopicsTempTableName,
@@ -706,6 +742,7 @@ export async function resourceFlatData(scopes) {
numberOfParticipants,
numberOfRecipients,
pctOfReportsWithResources,
+ pctOfReportsWithCourses,
pctOfECKLKCResources,
dateHeaders,
} = getOverview(tempTableNames, totalReportCount);
@@ -717,6 +754,7 @@ export async function resourceFlatData(scopes) {
numberOfParticipants,
numberOfRecipients,
pctOfReportsWithResources,
+ pctOfReportsWithCourses,
pctOfECKLKCResources,
dateHeaders,
] = await Promise.all(
@@ -726,6 +764,7 @@ export async function resourceFlatData(scopes) {
numberOfParticipants,
numberOfRecipients,
pctOfReportsWithResources,
+ pctOfReportsWithCourses,
pctOfECKLKCResources,
dateHeaders,
],
@@ -733,7 +772,7 @@ export async function resourceFlatData(scopes) {
// 5.) Restructure Overview.
const overView = {
- numberOfParticipants, numberOfRecipients, pctOfReportsWithResources, pctOfECKLKCResources,
+ numberOfParticipants, numberOfRecipients, pctOfReportsWithResources, pctOfECKLKCResources, pctOfReportsWithCourses,
};
// 6.) Return the data.
@@ -2102,6 +2141,9 @@ export function restructureOverview(data) {
num: formatNumber(data.overView.pctOfECKLKCResources[0].allCount),
percentEclkc: `${formatNumber(data.overView.pctOfECKLKCResources[0].eclkcPct, 2)}%`,
},
+ ipdCourses: {
+ percentReports: `${formatNumber(data.overView.pctOfReportsWithCourses[0].coursesPct, 2)}%`,
+ },
};
}
diff --git a/src/services/dashboards/resourceFlat.test.js b/src/services/dashboards/resourceFlat.test.js
index 6ce38b7d9a..8fff6c6c51 100644
--- a/src/services/dashboards/resourceFlat.test.js
+++ b/src/services/dashboards/resourceFlat.test.js
@@ -829,6 +829,7 @@ describe('Resources dashboard', () => {
numberOfParticipants: [{ participants: '44' }],
numberOfRecipients: [{ recipients: '1' }],
pctOfECKLKCResources: [{ eclkcCount: '2', allCount: '3', eclkcPct: '66.6667' }],
+ pctOfReportsWithCourses: [{ coursesPct: '80.0000', reportsWithCoursesCount: '4', totalReportsCount: '5' }],
},
};
@@ -851,6 +852,9 @@ describe('Resources dashboard', () => {
num: '3',
percentEclkc: '66.67%',
},
+ ipdCourses: {
+ percentReports: '80.00%',
+ },
});
});
});