diff --git a/frontend/src/Constants.js b/frontend/src/Constants.js
index 88b1e6ee5c..7afc6955f9 100644
--- a/frontend/src/Constants.js
+++ b/frontend/src/Constants.js
@@ -103,4 +103,5 @@ export const ESCAPE_KEY_CODE = 27;
export const ESCAPE_KEY_CODES = ['Escape', 'Esc'];
export const DATE_FMT = 'YYYY/MM/DD';
+export const DATE_DISPLAY_FORMAT = 'MM/DD/YYYY';
export const EARLIEST_INC_FILTER_DATE = moment('2020-08-31');
diff --git a/frontend/src/components/ActivityReportsTable/ReportRow.js b/frontend/src/components/ActivityReportsTable/ReportRow.js
index 47e0b9ef27..e5412280e0 100644
--- a/frontend/src/components/ActivityReportsTable/ReportRow.js
+++ b/frontend/src/components/ActivityReportsTable/ReportRow.js
@@ -1,13 +1,13 @@
import React from 'react';
import PropTypes from 'prop-types';
-import {
- Tag, Checkbox,
-} from '@trussworks/react-uswds';
+import { Checkbox } from '@trussworks/react-uswds';
import { Link, useHistory } from 'react-router-dom';
-
+import moment from 'moment';
import ContextMenu from '../ContextMenu';
import { getReportsDownloadURL } from '../../fetchers/helpers';
import TooltipWithCollection from '../TooltipWithCollection';
+import Tooltip from '../Tooltip';
+import { DATE_DISPLAY_FORMAT } from '../../Constants';
function ReportRow({
report, openMenuUp, handleReportSelect, isChecked,
@@ -22,6 +22,8 @@ function ReportRow({
collaborators,
lastSaved,
calculatedStatus,
+ approvedAt,
+ createdAt,
legacyId,
} = report;
@@ -85,10 +87,13 @@ function ReportRow({
{
- const date = value ? moment(value, dateFmt) : null;
+ const date = value ? moment(value, DATE_DISPLAY_FORMAT) : null;
return (
{
- const newDate = d ? d.format(dateFmt) : d;
+ const newDate = d ? d.format(DATE_DISPLAY_FORMAT) : d;
onChange(newDate);
const input = document.getElementById(name);
if (input) input.focus();
diff --git a/frontend/src/components/Tooltip.css b/frontend/src/components/Tooltip.css
index 84e882b5eb..9c65eb6263 100644
--- a/frontend/src/components/Tooltip.css
+++ b/frontend/src/components/Tooltip.css
@@ -21,7 +21,7 @@
display: inline-block;
overflow-x: hidden;
overflow-y: visible;
- width: 173.5px;
+ max-width: 175px;
text-overflow: ellipsis;
vertical-align: middle;
}
diff --git a/frontend/src/components/TooltipWithCollection.js b/frontend/src/components/TooltipWithCollection.js
index 7cd02c5566..77fcc1d19f 100644
--- a/frontend/src/components/TooltipWithCollection.js
+++ b/frontend/src/components/TooltipWithCollection.js
@@ -30,7 +30,11 @@ export default function TooltipWithCollection({ collection, collectionTitle }) {
if (collection.length === 1) {
return (
- {tooltip}
+
);
}
diff --git a/frontend/src/components/__tests__/TooltipWithCollection.js b/frontend/src/components/__tests__/TooltipWithCollection.js
index 2eb9edd990..989dc1e479 100644
--- a/frontend/src/components/__tests__/TooltipWithCollection.js
+++ b/frontend/src/components/__tests__/TooltipWithCollection.js
@@ -43,8 +43,8 @@ describe('TooltipWithCollection', () => {
it('renders a single span when passed a one item array', async () => {
renderTooltip(['Jimbo']);
- const jimbo = screen.getByText('Jimbo');
+ const jimbo = screen.getAllByText('Jimbo')[1];
expect(jimbo).toBeVisible();
- expect(jimbo.parentElement).toHaveClass('smarthub-ellipsis');
+ expect(jimbo.parentElement.parentElement).toHaveClass('smart-hub--ellipsis');
});
});
diff --git a/frontend/src/pages/ApprovedActivityReport/index.js b/frontend/src/pages/ApprovedActivityReport/index.js
index b47b92fbb0..f3a695594a 100644
--- a/frontend/src/pages/ApprovedActivityReport/index.js
+++ b/frontend/src/pages/ApprovedActivityReport/index.js
@@ -11,6 +11,7 @@ import ViewTable from './components/ViewTable';
import { getReport, unlockReport } from '../../fetchers/activityReports';
import { allRegionsUserHasPermissionTo, canUnlockReports } from '../../permissions';
import Modal from '../../components/Modal';
+import { DATE_DISPLAY_FORMAT } from '../../Constants';
/**
*
@@ -192,8 +193,8 @@ export default function ApprovedActivityReport({ match, user }) {
setParticipantCount(newCount);
setReasons(formatSimpleArray(report.reason));
setProgramType(formatSimpleArray(report.programTypes));
- setStartDate(moment(report.startDate, 'MM/DD/YYYY').format('MMMM D, YYYY'));
- setEndDate(moment(report.endDate, 'MM/DD/YYYY').format('MMMM D, YYYY'));
+ setStartDate(moment(report.startDate, DATE_DISPLAY_FORMAT).format('MMMM D, YYYY'));
+ setEndDate(moment(report.endDate, DATE_DISPLAY_FORMAT).format('MMMM D, YYYY'));
setDuration(`${report.duration} hours`);
setMethod(formatMethod(report.ttaType, report.virtualDeliveryType));
setRequester(formatRequester(report.requester));
diff --git a/frontend/src/pages/Landing/MyAlerts.js b/frontend/src/pages/Landing/MyAlerts.js
index 77b858de56..7fb04655de 100644
--- a/frontend/src/pages/Landing/MyAlerts.js
+++ b/frontend/src/pages/Landing/MyAlerts.js
@@ -5,7 +5,7 @@ import {
Tag, Table, useModal, connectModal,
} from '@trussworks/react-uswds';
import { Link, useHistory } from 'react-router-dom';
-
+import moment from 'moment';
import DeleteReportModal from '../../components/DeleteReportModal';
import Container from '../../components/Container';
import ContextMenu from '../../components/ContextMenu';
@@ -44,6 +44,7 @@ function ReportsRow({ reports, removeAlert, message }) {
calculatedStatus,
pendingApprovals,
approvers,
+ createdAt,
} = report;
const justSubmitted = message && message.reportId === id;
@@ -92,9 +93,18 @@ function ReportsRow({ reports, removeAlert, message }) {
{startDate} |
-
- {author ? author.fullName : ''}
-
+ { author
+ ? (
+
+ ) : }
+ |
+
+ {moment(createdAt).format('MM/DD/YYYY')}
|
@@ -311,6 +321,7 @@ function MyAlerts(props) {
{renderColumnHeader('Grantee', 'activityRecipients')}
{renderColumnHeader('Start date', 'startDate')}
{renderColumnHeader('Creator', 'author')}
+ {renderColumnHeader('Created date', 'createdAt')}
{renderColumnHeader('Collaborator(s)', 'collaborators')}
{renderColumnHeader('Approvers(s)', 'approvals', true)}
{renderColumnHeader('Status', 'calculatedStatus')}
diff --git a/frontend/src/pages/Landing/__tests__/index.js b/frontend/src/pages/Landing/__tests__/index.js
index 7727057d1f..a1b3321665 100644
--- a/frontend/src/pages/Landing/__tests__/index.js
+++ b/frontend/src/pages/Landing/__tests__/index.js
@@ -64,6 +64,7 @@ describe('Landing Page', () => {
alerts: [],
});
fetchMock.get(overviewUrlWithRegionOne, overviewRegionOne);
+
const user = {
name: 'test@test.com',
permissions: [
@@ -217,14 +218,6 @@ describe('Landing Page', () => {
expect(lastSavedDates.length).toBe(1);
});
- test('displays the correct statuses', async () => {
- const draft = await screen.findByText(/draft/i);
- const needsAction = await screen.findByText(/needs action/i);
-
- expect(draft).toBeVisible();
- expect(needsAction).toBeVisible();
- });
-
test('displays the options buttons', async () => {
const optionButtons = await screen.findAllByRole('button', {
name: /actions for activity report r14-ar-2/i,
@@ -326,20 +319,20 @@ describe('My alerts sorting', () => {
it('is enabled for Status', async () => {
const statusColumnHeaders = await screen.findAllByText(/status/i);
- expect(statusColumnHeaders.length).toBe(2);
+ expect(statusColumnHeaders.length).toBe(1);
fetchMock.reset();
fireEvent.click(statusColumnHeaders[0]);
- await waitFor(() => expect(screen.getAllByRole('cell')[6]).toHaveTextContent(/draft/i));
- await waitFor(() => expect(screen.getAllByRole('cell')[14]).toHaveTextContent(/needs action/i));
+ await waitFor(() => expect(screen.getAllByRole('cell')[7]).toHaveTextContent(/draft/i));
+ await waitFor(() => expect(screen.getAllByRole('cell')[16]).toHaveTextContent(/needs action/i));
fetchMock.get('/api/activity-reports/alerts?sortBy=calculatedStatus&sortDir=desc&offset=0&limit=10®ion.in[]=1',
{ alertsCount: 2, alerts: activityReportsSorted });
fireEvent.click(statusColumnHeaders[0]);
- await waitFor(() => expect(screen.getAllByRole('cell')[6]).toHaveTextContent(/needs action/i));
- await waitFor(() => expect(screen.getAllByRole('cell')[14]).toHaveTextContent(/draft/i));
+ await waitFor(() => expect(screen.getAllByRole('cell')[7]).toHaveTextContent(/needs action/i));
+ await waitFor(() => expect(screen.getAllByRole('cell')[16]).toHaveTextContent(/draft/i));
});
it('is enabled for Report ID', async () => {
@@ -356,7 +349,7 @@ describe('My alerts sorting', () => {
fireEvent.click(columnHeaders[0]);
await waitFor(() => expect(screen.getAllByRole('cell')[0]).toHaveTextContent(/r14-ar-1/i));
- await waitFor(() => expect(screen.getAllByRole('cell')[8]).toHaveTextContent(/r14-ar-2/i));
+ await waitFor(() => expect(screen.getAllByRole('cell')[9]).toHaveTextContent(/r14-ar-2/i));
});
it('is enabled for Grantee', async () => {
@@ -376,7 +369,7 @@ describe('My alerts sorting', () => {
const textContent = /Johnston-Romaguera Johnston-Romaguera Grantee Name click to visually reveal the recipients for R14-AR-1$/i;
await waitFor(() => expect(screen.getAllByRole('cell')[1]).toHaveTextContent(textContent));
- await waitFor(() => expect(screen.getAllByRole('cell')[9]).toHaveTextContent(/qris system/i));
+ await waitFor(() => expect(screen.getAllByRole('cell')[10]).toHaveTextContent(/qris system/i));
});
it('is enabled for Start date', async () => {
@@ -394,7 +387,7 @@ describe('My alerts sorting', () => {
fireEvent.click(columnHeaders[0]);
await waitFor(() => expect(screen.getAllByRole('cell')[2]).toHaveTextContent(/02\/01\/2021/i));
- await waitFor(() => expect(screen.getAllByRole('cell')[10]).toHaveTextContent(/02\/08\/2021/i));
+ await waitFor(() => expect(screen.getAllByRole('cell')[11]).toHaveTextContent(/02\/08\/2021/i));
});
it('is enabled for Creator', async () => {
@@ -412,7 +405,7 @@ describe('My alerts sorting', () => {
fireEvent.click(columnHeaders[0]);
await waitFor(() => expect(screen.getAllByRole('cell')[3]).toHaveTextContent(/kiwi, gs/i));
- await waitFor(() => expect(screen.getAllByRole('cell')[11]).toHaveTextContent(/kiwi, ttac/i));
+ await waitFor(() => expect(screen.getAllByRole('cell')[12]).toHaveTextContent(/kiwi, ttac/i));
});
it('is enabled for Collaborator(s)', async () => {
@@ -427,8 +420,8 @@ describe('My alerts sorting', () => {
const firstCell = /Cucumber User, GS Hermione Granger, SS click to visually reveal the collaborators for R14-AR-2$/i;
const secondCell = /Orange, GS Hermione Granger, SS click to visually reveal the collaborators for R14-AR-1$/i;
- await waitFor(() => expect(screen.getAllByRole('cell')[4]).toHaveTextContent(firstCell));
- await waitFor(() => expect(screen.getAllByRole('cell')[12]).toHaveTextContent(secondCell));
+ await waitFor(() => expect(screen.getAllByRole('cell')[5]).toHaveTextContent(firstCell));
+ await waitFor(() => expect(screen.getAllByRole('cell')[14]).toHaveTextContent(secondCell));
});
});
@@ -478,7 +471,7 @@ describe('Landing Page error', () => {
};
renderLanding(user);
const rowCells = await screen.findAllByRole('cell');
- expect(rowCells.length).toBe(9);
+ expect(rowCells.length).toBe(10);
const grantee = rowCells[1];
expect(grantee).toHaveTextContent('');
});
diff --git a/frontend/src/pages/Landing/index.css b/frontend/src/pages/Landing/index.css
index 53a60971d7..84034f28cd 100644
--- a/frontend/src/pages/Landing/index.css
+++ b/frontend/src/pages/Landing/index.css
@@ -140,10 +140,16 @@ h1.landing {
}
.landing .usa-table td {
- font-size: 15px;
- white-space: nowrap;
background-color: transparent;
border-style: none;
+ font-size: 15px;
+ min-width: 120px;
+ white-space: nowrap;
+}
+
+.landing .usa-table td:first-child,
+.landing .usa-table td:last-child{
+ min-width: auto;
}
.landing .usa-table .usa-checkbox__label {
diff --git a/frontend/src/pages/RegionalDashboard/constants.js b/frontend/src/pages/RegionalDashboard/constants.js
index 712a08346d..b2a57f9465 100644
--- a/frontend/src/pages/RegionalDashboard/constants.js
+++ b/frontend/src/pages/RegionalDashboard/constants.js
@@ -1,4 +1,3 @@
-export const DATE_FORMAT = 'MM/DD/YYYY';
export const DATETIME_DATE_FORMAT = 'YYYY/MM/DD';
export const DATE_OPTIONS = [
diff --git a/frontend/src/pages/RegionalDashboard/formatDateRange.js b/frontend/src/pages/RegionalDashboard/formatDateRange.js
index 1b9244cefb..0e4a82926c 100644
--- a/frontend/src/pages/RegionalDashboard/formatDateRange.js
+++ b/frontend/src/pages/RegionalDashboard/formatDateRange.js
@@ -1,5 +1,6 @@
import moment from 'moment';
-import { DATETIME_DATE_FORMAT, DATE_FORMAT } from './constants';
+import { DATETIME_DATE_FORMAT } from './constants';
+import { DATE_DISPLAY_FORMAT as DATE_FORMAT } from '../../Constants';
export default function formatDateRange(format = {
lastThirtyDays: false, withSpaces: false, forDateTime: false, sep: '-', string: '',
diff --git a/src/constants.js b/src/constants.js
index a208f5a4d1..e0bd21baba 100644
--- a/src/constants.js
+++ b/src/constants.js
@@ -18,6 +18,8 @@ export const FILE_STATUSES = {
REJECTED: 'REJECTED',
};
+export const DATE_FORMAT = 'MM/DD/YYYY';
+
export const DECIMAL_BASE = 10;
export const REPORTS_PER_PAGE = 10;
diff --git a/src/lib/orderReportsBy.js b/src/lib/orderReportsBy.js
index 21fbb2d4fa..6d6131434b 100644
--- a/src/lib/orderReportsBy.js
+++ b/src/lib/orderReportsBy.js
@@ -42,6 +42,8 @@ const orderReportsBy = (sortBy, sortDir) => {
case 'calculatedStatus':
case 'startDate':
case 'updatedAt':
+ case 'approvedAt':
+ case 'createdAt':
result = [[sortBy, sortDir]];
break;
default:
diff --git a/src/lib/transform.js b/src/lib/transform.js
index c55a0d9db0..3fb3ac89bd 100644
--- a/src/lib/transform.js
+++ b/src/lib/transform.js
@@ -1,3 +1,23 @@
+import moment from 'moment';
+import { DATE_FORMAT } from '../constants';
+
+function transformDate(field) {
+ async function transformer(instance) {
+ let value = '';
+ const date = instance[field];
+ if (date) {
+ value = moment(date).format(DATE_FORMAT);
+ }
+ const obj = {};
+ Object.defineProperty(obj, field, {
+ value,
+ enumerable: true,
+ });
+ return Promise.resolve(obj);
+ }
+ return transformer;
+}
+
/**
* @param {string} field name to be retrieved
* @returns {function} Function that will return a simple value wrapped in a Promise
@@ -167,6 +187,8 @@ const arTransformers = [
'context',
'additionalNotes',
'lastSaved',
+ transformDate('createdAt'),
+ transformDate('approvedAt'),
];
/**
diff --git a/src/lib/transform.test.js b/src/lib/transform.test.js
index f5eae76208..7120ccde8a 100644
--- a/src/lib/transform.test.js
+++ b/src/lib/transform.test.js
@@ -113,6 +113,7 @@ describe('activityReportToCsvRecord', () => {
author: mockAuthor,
lastUpdatedBy: mockAuthor,
collaborators: mockCollaborators,
+ approvedAt: new Date(),
};
it('transforms arrays of strings into strings', async () => {
diff --git a/src/migrations/20211110181535-add-ar-approved-date.js b/src/migrations/20211110181535-add-ar-approved-date.js
new file mode 100644
index 0000000000..68f864b31e
--- /dev/null
+++ b/src/migrations/20211110181535-add-ar-approved-date.js
@@ -0,0 +1,18 @@
+module.exports = {
+ up: async (queryInterface, Sequelize) => {
+ await queryInterface.addColumn(
+ 'ActivityReports',
+ 'approvedAt',
+ {
+ comment: 'Timestamp when a report was approved',
+ type: Sequelize.DATE,
+ defaultValue: null,
+ allowNull: true,
+ },
+ );
+ },
+
+ down: async (queryInterface) => {
+ await queryInterface.removeColumn('ActivityReports', 'approvedAt');
+ },
+};
diff --git a/src/models/activityReport.js b/src/models/activityReport.js
index 4dfe0b3942..14b5e4d8b6 100644
--- a/src/models/activityReport.js
+++ b/src/models/activityReport.js
@@ -243,6 +243,10 @@ export default (sequelize, DataTypes) => {
return moment(this.updatedAt).format('MM/DD/YYYY');
},
},
+ approvedAt: {
+ allowNull: true,
+ type: DataTypes.DATE,
+ },
imported: {
type: DataTypes.JSONB,
comment: 'Storage for raw values from smartsheet CSV imports',
diff --git a/src/models/activityReportApprover.js b/src/models/activityReportApprover.js
index 915453e39f..2bb0c3de78 100644
--- a/src/models/activityReportApprover.js
+++ b/src/models/activityReportApprover.js
@@ -212,9 +212,21 @@ module.exports = (sequelize, DataTypes) => {
}).map((a) => a.status);
const newCalculatedStatus = calculateReportStatus(instance.status, approverStatuses);
- await sequelize.models.ActivityReport.update({
+
+ /*
+ * Here we check to see if the report will be approved and update the approvedAt
+ * as appropriate
+ */
+ const approvedAt = newCalculatedStatus === REPORT_STATUSES.APPROVED ? new Date() : null;
+
+ const updatedFields = approvedAt ? {
calculatedStatus: newCalculatedStatus,
- }, {
+ approvedAt,
+ } : {
+ calculatedStatus: newCalculatedStatus,
+ };
+
+ await sequelize.models.ActivityReport.update(updatedFields, {
where: { id: instance.activityReportId },
transaction: options.transaction,
hooks: false,
diff --git a/src/routes/activityReports/handlers.js b/src/routes/activityReports/handlers.js
index 3955e868ea..ae75c4fc4a 100644
--- a/src/routes/activityReports/handlers.js
+++ b/src/routes/activityReports/handlers.js
@@ -164,6 +164,14 @@ async function sendActivityReportCSV(reports, res) {
key: 'granteeNextSteps',
header: 'Grantee next steps',
},
+ {
+ key: 'createdAt',
+ header: 'Created date',
+ },
+ {
+ key: 'approvedAt',
+ header: 'Approved date',
+ },
{
key: 'lastSaved',
header: 'Last saved',
diff --git a/src/services/activityReportApprovers.test.js b/src/services/activityReportApprovers.test.js
index 7a6941301e..60fa1ee60e 100644
--- a/src/services/activityReportApprovers.test.js
+++ b/src/services/activityReportApprovers.test.js
@@ -106,6 +106,7 @@ describe('activityReportApprovers services', () => {
expect(approver.status).toEqual(APPROVER_STATUSES.NEEDS_ACTION);
});
const updatedReport = await activityReportById(report1.id);
+ expect(updatedReport.approvedAt).toBeNull();
expect(updatedReport.submissionStatus).toEqual(REPORT_STATUSES.SUBMITTED);
expect(updatedReport.calculatedStatus).toEqual(REPORT_STATUSES.NEEDS_ACTION);
});
@@ -124,6 +125,7 @@ describe('activityReportApprovers services', () => {
});
expect(approver.status).toEqual(APPROVER_STATUSES.APPROVED);
const updatedReport = await activityReportById(report2.id);
+ expect(updatedReport.approvedAt).toBeTruthy();
expect(updatedReport.submissionStatus).toEqual(REPORT_STATUSES.SUBMITTED);
expect(updatedReport.calculatedStatus).toEqual(REPORT_STATUSES.APPROVED);
});
diff --git a/src/services/activityReports.js b/src/services/activityReports.js
index 2cf928afbf..f2daea0816 100644
--- a/src/services/activityReports.js
+++ b/src/services/activityReports.js
@@ -337,6 +337,8 @@ export function activityReports(
'updatedAt',
'sortedTopics',
'legacyId',
+ 'createdAt',
+ 'approvedAt',
sequelize.literal(
'(SELECT name as authorName FROM "Users" WHERE "Users"."id" = "ActivityReport"."userId")',
),
@@ -461,6 +463,7 @@ export async function activityReportAlerts(userId, {
'calculatedStatus',
'regionId',
'userId',
+ 'createdAt',
sequelize.literal(
'(SELECT name as authorName FROM "Users" WHERE "Users"."id" = "ActivityReport"."userId")',
),
@@ -674,7 +677,10 @@ async function getDownloadableActivityReports(where) {
return ActivityReport.findAndCountAll(
{
where,
- attributes: { include: ['displayId'], exclude: ['imported', 'legacyId', 'oldManagerNotes', 'additionalNotes', 'approvers'] },
+ attributes: {
+ include: ['displayId', 'createdAt', 'approvedAt'],
+ exclude: ['imported', 'legacyId', 'oldManagerNotes', 'additionalNotes', 'approvers'],
+ },
include: [
{
model: Objective,
|