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({ {startDate} - - {authorName} - + + {moment(createdAt).format(DATE_DISPLAY_FORMAT)} @@ -96,13 +101,7 @@ function ReportRow({ {lastSaved} - - - {calculatedStatus === 'needs_action' ? 'Needs action' : calculatedStatus} - - + {approvedAt && moment(approvedAt).format(DATE_DISPLAY_FORMAT)} @@ -122,6 +121,8 @@ export const reportPropTypes = { }), }), })).isRequired, + approvedAt: PropTypes.string, + createdAt: PropTypes.string, startDate: PropTypes.string.isRequired, author: PropTypes.shape({ fullName: PropTypes.string, @@ -129,7 +130,11 @@ export const reportPropTypes = { name: PropTypes.string, }).isRequired, topics: PropTypes.arrayOf(PropTypes.string).isRequired, - collaborators: PropTypes.arrayOf(PropTypes.string).isRequired, + collaborators: PropTypes.arrayOf( + PropTypes.shape({ + fullName: PropTypes.string, + }), + ).isRequired, lastSaved: PropTypes.string.isRequired, calculatedStatus: PropTypes.string.isRequired, legacyId: PropTypes.string, diff --git a/frontend/src/components/ActivityReportsTable/__tests__/index.js b/frontend/src/components/ActivityReportsTable/__tests__/index.js index 7c084ed238..406a7a1584 100644 --- a/frontend/src/components/ActivityReportsTable/__tests__/index.js +++ b/frontend/src/components/ActivityReportsTable/__tests__/index.js @@ -317,28 +317,6 @@ describe('Table sorting', () => { await screen.findByText('Activity Reports'); }); - it('clicking status column header will sort by status', async () => { - const statusColumnHeader = await screen.findByText(/status/i); - fetchMock.reset(); - fetchMock.get( - '/api/activity-reports?sortBy=calculatedStatus&sortDir=asc&offset=0&limit=10®ion.in[]=1', - { count: 2, rows: activityReportsSorted }, - ); - - fireEvent.click(statusColumnHeader); - await waitFor(() => expect(screen.getAllByRole('cell')[7]).toHaveTextContent(/needs action/i)); - await waitFor(() => expect(screen.getAllByRole('cell')[16]).toHaveTextContent(/draft/i)); - - fetchMock.get( - '/api/activity-reports?sortBy=calculatedStatus&sortDir=desc&offset=0&limit=10®ion.in[]=1', - { count: 2, rows: activityReports }, - ); - - fireEvent.click(statusColumnHeader); - await waitFor(() => expect(screen.getAllByRole('cell')[7]).toHaveTextContent(/draft/i)); - await waitFor(() => expect(screen.getAllByRole('cell')[16]).toHaveTextContent(/needs action/i)); - }); - it('clicking Last saved column header will sort by updatedAt', async () => { const columnHeader = await screen.findByText(/last saved/i); @@ -348,8 +326,8 @@ describe('Table sorting', () => { ); fireEvent.click(columnHeader); - await waitFor(() => expect(screen.getAllByRole('cell')[6]).toHaveTextContent(/02\/04\/2021/i)); - await waitFor(() => expect(screen.getAllByRole('cell')[15]).toHaveTextContent(/02\/05\/2021/i)); + await waitFor(() => expect(screen.getAllByRole('cell')[7]).toHaveTextContent(/02\/04\/2021/i)); + await waitFor(() => expect(screen.getAllByRole('cell')[17]).toHaveTextContent(/02\/05\/2021/i)); }); it('clicking Collaborators column header will sort by collaborators', async () => { @@ -361,8 +339,8 @@ describe('Table sorting', () => { ); await act(async () => fireEvent.click(columnHeader)); - await waitFor(() => expect(screen.getAllByRole('cell')[5]).toHaveTextContent('Cucumber User, GS Hermione Granger, SS')); - await waitFor(() => expect(screen.getAllByRole('cell')[14]).toHaveTextContent('Orange, GS Hermione Granger, SS')); + await waitFor(() => expect(screen.getAllByRole('cell')[6]).toHaveTextContent('Cucumber User, GS Hermione Granger, SS')); + await waitFor(() => expect(screen.getAllByRole('cell')[16]).toHaveTextContent('Orange, GS Hermione Granger, SS')); }); it('clicking Topics column header will sort by topics', async () => { @@ -374,8 +352,7 @@ describe('Table sorting', () => { ); await act(async () => fireEvent.click(columnHeader)); - await waitFor(() => expect(screen.getAllByRole('cell')[4]).toHaveTextContent('')); - await waitFor(() => expect(screen.getAllByRole('cell')[13]).toHaveTextContent(/Behavioral \/ Mental Health CLASS: Instructional Support click to visually reveal the topics for R14-AR-1$/i)); + await waitFor(() => expect(screen.getAllByRole('cell')[15]).toHaveTextContent(/Behavioral \/ Mental Health CLASS: Instructional Support click to visually reveal the topics for R14-AR-1$/i)); }); it('clicking Creator column header will sort by author', async () => { @@ -388,7 +365,7 @@ describe('Table sorting', () => { fireEvent.click(columnHeader); await waitFor(() => expect(screen.getAllByRole('cell')[3]).toHaveTextContent('Kiwi, GS')); - await waitFor(() => expect(screen.getAllByRole('cell')[12]).toHaveTextContent('Kiwi, TTAC')); + await waitFor(() => expect(screen.getAllByRole('cell')[13]).toHaveTextContent('Kiwi, TTAC')); }); it('clicking Start date column header will sort by start date', async () => { @@ -401,7 +378,7 @@ describe('Table sorting', () => { fireEvent.click(columnHeader); await waitFor(() => expect(screen.getAllByRole('cell')[2]).toHaveTextContent('02/01/2021')); - await waitFor(() => expect(screen.getAllByRole('cell')[11]).toHaveTextContent('02/08/2021')); + await waitFor(() => expect(screen.getAllByRole('cell')[12]).toHaveTextContent('02/08/2021')); }); it('clicking Grantee column header will sort by grantee', async () => { @@ -462,8 +439,8 @@ describe('Table sorting', () => { ); fireEvent.click(pageOne); - await waitFor(() => expect(screen.getAllByRole('cell')[6]).toHaveTextContent(/02\/05\/2021/i)); - await waitFor(() => expect(screen.getAllByRole('cell')[15]).toHaveTextContent(/02\/04\/2021/i)); + await waitFor(() => expect(screen.getAllByRole('cell')[7]).toHaveTextContent(/02\/05\/2021/i)); + await waitFor(() => expect(screen.getAllByRole('cell')[17]).toHaveTextContent(/02\/04\/2021/i)); }); it('clicking on the second page updates to, from and total', async () => { diff --git a/frontend/src/components/ActivityReportsTable/index.css b/frontend/src/components/ActivityReportsTable/index.css index b820f880e6..0d413f937a 100644 --- a/frontend/src/components/ActivityReportsTable/index.css +++ b/frontend/src/components/ActivityReportsTable/index.css @@ -1,3 +1,5 @@ -.usa-table-container--scrollable { +.usa-table-container--scrollable, +.usa-checkbox__label { margin-top: 0px; } + diff --git a/frontend/src/components/ActivityReportsTable/index.js b/frontend/src/components/ActivityReportsTable/index.js index 4ce9d5bfe0..35545ea4f9 100644 --- a/frontend/src/components/ActivityReportsTable/index.js +++ b/frontend/src/components/ActivityReportsTable/index.js @@ -299,10 +299,11 @@ function ActivityReportsTable({ {renderColumnHeader('Grantee', 'activityRecipients')} {renderColumnHeader('Start date', 'startDate')} {renderColumnHeader('Creator', 'author')} + {renderColumnHeader('Created date', 'createdAt')} {renderColumnHeader('Topic(s)', 'topics')} {renderColumnHeader('Collaborator(s)', 'collaborators')} {renderColumnHeader('Last saved', 'updatedAt')} - {renderColumnHeader('Status', 'calculatedStatus')} + {renderColumnHeader('Approved date', 'approvedAt')} diff --git a/frontend/src/components/DatePicker.js b/frontend/src/components/DatePicker.js index 261ea029d7..3ca5f6e472 100644 --- a/frontend/src/components/DatePicker.js +++ b/frontend/src/components/DatePicker.js @@ -16,11 +16,9 @@ import { SingleDatePicker } from 'react-dates'; import { OPEN_UP, OPEN_DOWN } from 'react-dates/constants'; import { Controller } from 'react-hook-form/dist/index.ie11'; import moment from 'moment'; - +import { DATE_DISPLAY_FORMAT } from '../Constants'; import './DatePicker.css'; -const dateFmt = 'MM/DD/YYYY'; - const DateInput = ({ control, minDate, name, disabled, maxDate, openUp, required, ariaName, maxDateInclusive, }) => { @@ -29,17 +27,17 @@ const DateInput = ({ const openDirection = openUp ? OPEN_UP : OPEN_DOWN; const isOutsideRange = (date) => { - const isBefore = minDate && date.isBefore(moment(minDate, dateFmt)); + const isBefore = minDate && date.isBefore(moment(minDate, DATE_DISPLAY_FORMAT)); // If max date is inclusive (maxDateInclusive == true) // allow the user to pick a start date that is the same as the maxDate // otherwise, only the day before is allowed let isAfter = false; if (maxDateInclusive) { - const newDate = moment(maxDate, dateFmt).add(1, 'days'); - isAfter = maxDate && date.isAfter(newDate, dateFmt); + const newDate = moment(maxDate, DATE_DISPLAY_FORMAT).add(1, 'days'); + isAfter = maxDate && date.isAfter(newDate, DATE_DISPLAY_FORMAT); } else { - isAfter = maxDate && date.isAfter(moment(maxDate, dateFmt)); + isAfter = maxDate && date.isAfter(moment(maxDate, DATE_DISPLAY_FORMAT)); } return isBefore || isAfter; @@ -52,7 +50,7 @@ const DateInput = ({
mm/dd/yyyy
{ - 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,