diff --git a/.circleci/config.yml b/.circleci/config.yml index fdc81ef706..3cc717589d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -381,7 +381,7 @@ jobs: cloudgov_password: CLOUDGOV_PROD_PASSWORD cloudgov_space: CLOUDGOV_PROD_SPACE deploy_config_file: deployment_config/prod_vars.yml - new_relic_license: PROD_NEW_RELIC_LICENSE_KEY + new_relic_license: NEW_RELIC_LICENSE_KEY session_secret: PROD_SESSION_SECRET hses_data_file_url: PROD_HSES_DATA_FILE_URL hses_data_username: PROD_HSES_DATA_USERNAME diff --git a/deployment_config/prod_vars.yml b/deployment_config/prod_vars.yml index 0d9386a74b..60cd92a694 100644 --- a/deployment_config/prod_vars.yml +++ b/deployment_config/prod_vars.yml @@ -4,7 +4,7 @@ web_memory: 512M worker_instances: 1 worker_memory: 512M LOG_LEVEL: info -AUTH_BASE: TKTK +AUTH_BASE: https://hses.ohs.acf.hhs.gov # This env variable should go away soon in favor of TTA_SMART_HUB_URI REDIRECT_URI_HOST: https://ttahub.ohs.acf.hhs.gov TTA_SMART_HUB_URI: https://ttahub.ohs.acf.hhs.gov diff --git a/frontend/package.json b/frontend/package.json index 04e203fed5..094b4e2845 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -25,6 +25,7 @@ "react-hook-form": "^6.15.0", "react-idle-timer": "^4.4.2", "react-input-autosize": "^3.0.0", + "react-js-pagination": "^3.0.3", "react-responsive": "^8.1.1", "react-router": "^5.2.0", "react-router-dom": "^5.2.0", diff --git a/frontend/public/index.html b/frontend/public/index.html index c3d622a25d..4b5aa40a3e 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -3,6 +3,8 @@ + + { return report.json(); }; -export const getReports = async () => { - const reports = await get(activityReportUrl); +export const getReports = async (sortBy = 'updatedAt', sortDir = 'desc', offset = 0, limit = 10) => { + const reports = await get(`${activityReportUrl}?sortBy=${sortBy}&sortDir=${sortDir}&offset=${offset}&limit=${limit}`); return reports.json(); }; diff --git a/frontend/src/images/blue-circle.png b/frontend/src/images/blue-circle.png new file mode 100644 index 0000000000..389c93a175 Binary files /dev/null and b/frontend/src/images/blue-circle.png differ diff --git a/frontend/src/pages/Landing/__tests__/index.js b/frontend/src/pages/Landing/__tests__/index.js index 646cf02257..a8b3e8632d 100644 --- a/frontend/src/pages/Landing/__tests__/index.js +++ b/frontend/src/pages/Landing/__tests__/index.js @@ -1,14 +1,14 @@ import '@testing-library/jest-dom'; import React from 'react'; import { - render, screen, + render, screen, fireEvent, waitFor, } from '@testing-library/react'; import { MemoryRouter } from 'react-router'; import fetchMock from 'fetch-mock'; import UserContext from '../../../UserContext'; import Landing from '../index'; -import activityReports from '../mocks'; +import activityReports, { activityReportsSorted, generateXFakeReports } from '../mocks'; const renderLanding = (user) => { render( @@ -22,7 +22,10 @@ const renderLanding = (user) => { describe('Landing Page', () => { beforeEach(() => { - fetchMock.get('/api/activity-reports', activityReports); + fetchMock.get( + '/api/activity-reports?sortBy=updatedAt&sortDir=desc&offset=0&limit=10', + { count: 2, rows: activityReports }, + ); fetchMock.get('/api/activity-reports/alerts', []); const user = { name: 'test@test.com', @@ -133,7 +136,7 @@ describe('Landing Page', () => { test('displays the correct last saved dates', async () => { const lastSavedDates = await screen.findAllByText(/02\/04\/2021/i); - expect(lastSavedDates.length).toBe(2); + expect(lastSavedDates.length).toBe(1); }); test('displays the correct statuses', async () => { @@ -145,9 +148,11 @@ describe('Landing Page', () => { }); test('displays the options buttons', async () => { - const optionButtons = await screen.findAllByRole('button', /.../i); + const optionButtons = await screen.findAllByRole('button', { + name: /edit activity report r14-ar-2/i, + }); - expect(optionButtons.length).toBe(2); + expect(optionButtons.length).toBe(1); }); test('displays the new activity report button', async () => { @@ -157,6 +162,212 @@ describe('Landing Page', () => { }); }); +describe('Landing Page sorting', () => { + afterEach(() => fetchMock.restore()); + + beforeEach(() => { + fetchMock.get('/api/activity-reports/alerts', []); + fetchMock.get( + '/api/activity-reports?sortBy=updatedAt&sortDir=desc&offset=0&limit=10', + { count: 2, rows: activityReports }, + ); + const user = { + name: 'test@test.com', + permissions: [ + { + scopeId: 3, + regionId: 1, + }, + ], + }; + + renderLanding(user); + }); + + it('clicking status column header will sort by status', async () => { + const statusColumnHeader = await screen.findByText(/status/i); + fetchMock.reset(); + fetchMock.get('/api/activity-reports/alerts', []); + fetchMock.get( + '/api/activity-reports?sortBy=status&sortDir=asc&offset=0&limit=10', + { count: 2, rows: activityReportsSorted }, + ); + + fireEvent.click(statusColumnHeader); + await waitFor(() => expect(screen.getAllByRole('cell')[6]).toHaveTextContent(/needs action/i)); + await waitFor(() => expect(screen.getAllByRole('cell')[14]).toHaveTextContent(/draft/i)); + + fetchMock.get( + '/api/activity-reports?sortBy=status&sortDir=desc&offset=0&limit=10', + { count: 2, rows: activityReports }, + ); + + fireEvent.click(statusColumnHeader); + await waitFor(() => expect(screen.getAllByRole('cell')[6]).toHaveTextContent(/draft/i)); + await waitFor(() => expect(screen.getAllByRole('cell')[14]).toHaveTextContent(/needs action/i)); + }); + + it('clicking Last saved column header will sort by updatedAt', async () => { + const columnHeader = await screen.findByText(/last saved/i); + + fetchMock.get( + '/api/activity-reports?sortBy=updatedAt&sortDir=asc&offset=0&limit=10', + { count: 2, rows: activityReportsSorted }, + ); + + fireEvent.click(columnHeader); + await waitFor(() => expect(screen.getAllByRole('cell')[5]).toHaveTextContent(/02\/04\/2021/i)); + await waitFor(() => expect(screen.getAllByRole('cell')[13]).toHaveTextContent(/02\/05\/2021/i)); + }); + + it('clicking Collaborators column header will sort by collaborators', async () => { + const columnHeader = await screen.findByText(/collaborator\(s\)/i); + + fetchMock.get( + '/api/activity-reports?sortBy=collaborators&sortDir=asc&offset=0&limit=10', + { count: 2, rows: activityReportsSorted }, + ); + + fireEvent.click(columnHeader); + await waitFor(() => expect(screen.getAllByRole('cell')[4]).toHaveTextContent('Cucumber User, GSHermione Granger, SS')); + await waitFor(() => expect(screen.getAllByRole('cell')[12]).toHaveTextContent('Orange, GSHermione Granger, SS')); + }); + + it('clicking Topics column header will sort by topics', async () => { + const columnHeader = await screen.findByText(/topic\(s\)/i); + + fetchMock.get( + '/api/activity-reports?sortBy=topics&sortDir=asc&offset=0&limit=10', + { count: 2, rows: activityReportsSorted }, + ); + + fireEvent.click(columnHeader); + await waitFor(() => expect(screen.getAllByRole('cell')[3]).toHaveTextContent('')); + await waitFor(() => expect(screen.getAllByRole('cell')[11]).toHaveTextContent('Behavioral / Mental HealthCLASS: Instructional Support')); + }); + + it('clicking Creator column header will sort by author', async () => { + const columnHeader = await screen.findByText(/creator/i); + + fetchMock.get( + '/api/activity-reports?sortBy=author&sortDir=asc&offset=0&limit=10', + { count: 2, rows: activityReportsSorted }, + ); + + fireEvent.click(columnHeader); + await waitFor(() => expect(screen.getAllByRole('cell')[2]).toHaveTextContent('Kiwi, GS')); + await waitFor(() => expect(screen.getAllByRole('cell')[10]).toHaveTextContent('Kiwi, TTAC')); + }); + + it('clicking Start date column header will sort by start date', async () => { + const columnHeader = await screen.findByText(/start date/i); + + fetchMock.get( + '/api/activity-reports?sortBy=startDate&sortDir=asc&offset=0&limit=10', + { count: 2, rows: activityReportsSorted }, + ); + + fireEvent.click(columnHeader); + await waitFor(() => expect(screen.getAllByRole('cell')[1]).toHaveTextContent('02/01/2021')); + await waitFor(() => expect(screen.getAllByRole('cell')[9]).toHaveTextContent('02/08/2021')); + }); + + it('clicking Grantee column header will sort by grantee', async () => { + const columnHeader = await screen.findByRole('button', { + name: /grantee\. activate to sort ascending/i, + }); + + fetchMock.get( + '/api/activity-reports?sortBy=activityRecipients&sortDir=asc&offset=0&limit=10', + { count: 2, rows: activityReportsSorted }, + ); + + fireEvent.click(columnHeader); + await waitFor(() => expect(screen.getAllByRole('cell')[0]).toHaveTextContent('Johnston-RomagueraJohnston-RomagueraGrantee Name')); + }); + + it('clicking Report id column header will sort by region and id', async () => { + const columnHeader = await screen.findByText(/report id/i); + + fetchMock.get( + '/api/activity-reports?sortBy=regionId&sortDir=asc&offset=0&limit=10', + { count: 2, rows: activityReportsSorted }, + ); + + fireEvent.click(columnHeader); + await waitFor(() => expect(screen.getAllByRole('link')[4]).toHaveTextContent('R14-AR-2')); + await waitFor(() => expect(screen.getAllByRole('link')[5]).toHaveTextContent('R14-AR-1')); + }); + + it('Pagination links are visible', async () => { + const prevLink = await screen.findByRole('link', { + name: /go to previous page/i, + }); + const pageOne = await screen.findByRole('link', { + name: /go to page number 1/i, + }); + const nextLink = await screen.findByRole('link', { + name: /go to next page/i, + }); + + expect(prevLink).toBeVisible(); + expect(pageOne).toBeVisible(); + expect(nextLink).toBeVisible(); + }); + + it('clicking on pagination page works', async () => { + const pageOne = await screen.findByRole('link', { + name: /go to page number 1/i, + }); + fetchMock.reset(); + fetchMock.get('/api/activity-reports/alerts', []); + fetchMock.get( + '/api/activity-reports?sortBy=updatedAt&sortDir=desc&offset=0&limit=10', + { count: 2, rows: activityReportsSorted }, + ); + + fireEvent.click(pageOne); + await waitFor(() => expect(screen.getAllByRole('cell')[5]).toHaveTextContent(/02\/05\/2021/i)); + await waitFor(() => expect(screen.getAllByRole('cell')[13]).toHaveTextContent(/02\/04\/2021/i)); + }); + + it('clicking on the second page updates to, from and total', async () => { + expect(generateXFakeReports(10).length).toBe(10); + await screen.findByRole('link', { + name: /go to page number 1/i, + }); + fetchMock.reset(); + fetchMock.get('/api/activity-reports/alerts', []); + fetchMock.get( + '/api/activity-reports?sortBy=updatedAt&sortDir=desc&offset=0&limit=10', + { count: 17, rows: generateXFakeReports(10) }, + ); + const user = { + name: 'test@test.com', + permissions: [ + { + scopeId: 3, + regionId: 1, + }, + ], + }; + + renderLanding(user); + + const pageTwo = await screen.findByRole('link', { + name: /go to page number 2/i, + }); + + fetchMock.get( + '/api/activity-reports?sortBy=updatedAt&sortDir=desc&offset=10&limit=10', + { count: 17, rows: generateXFakeReports(10) }, + ); + + fireEvent.click(pageTwo); + await waitFor(() => expect(screen.getByText(/11-17 of 17/i)).toBeVisible()); + }); +}); + describe('Landing Page error', () => { afterEach(() => fetchMock.restore()); @@ -165,7 +376,7 @@ describe('Landing Page error', () => { }); it('handles errors by displaying an error message', async () => { - fetchMock.get('/api/activity-reports', 500); + fetchMock.get('/api/activity-reports?sortBy=updatedAt&sortDir=desc&offset=0&limit=10', 500); const user = { name: 'test@test.com', permissions: [ @@ -182,7 +393,10 @@ describe('Landing Page error', () => { }); it('displays an empty row if there are no reports', async () => { - fetchMock.get('/api/activity-reports', []); + fetchMock.get( + '/api/activity-reports?sortBy=updatedAt&sortDir=desc&offset=0&limit=10', + { count: 0, rows: [] }, + ); const user = { name: 'test@test.com', permissions: [ @@ -200,7 +414,10 @@ describe('Landing Page error', () => { }); it('does not displays new activity report button without permission', async () => { - fetchMock.get('/api/activity-reports', activityReports); + fetchMock.get( + '/api/activity-reports?sortBy=updatedAt&sortDir=desc&offset=0&limit=10', + { count: 2, rows: activityReports }, + ); const user = { name: 'test@test.com', permissions: [ diff --git a/frontend/src/pages/Landing/index.css b/frontend/src/pages/Landing/index.css index 4c14c2a755..09e947c677 100644 --- a/frontend/src/pages/Landing/index.css +++ b/frontend/src/pages/Landing/index.css @@ -1,22 +1,85 @@ .landing { max-width: fit-content; + font-family: 'Merriweather', serif; } - -.pagination ul { - display: table-caption; - padding-left: 15px; - padding-right: 15px; + +.pagination { + float: right; + white-space: nowrap; + margin-top: -9px; + margin-bottom: 0px; + padding-left: 20px; } .pagination li { display: inline-block; - padding-left: 5px; - padding-right: 5px; + margin-right: 7px; +} + +.pagination li a { +color: #3C4146; +text-align: center; +margin-top: -5px; +text-decoration: none; +} + +.pagination li.active { + background-image: url(../../images/blue-circle.png); + background-repeat: no-repeat; + background-position: center center; + padding: 10px; +} + +.landing .disabled { + display: none; +} + +.pagination li.active a { + padding: 3.5px; + margin-left: 1.3px; + font-weight: bold; + color: white; + outline: none; +} + +div.smart-hub--total-count { + background-color: #ebe6e6; + align-self: center; + display: inline; +} + +.smart-hub--link-next { + text-decoration-line: underline !important; + color: #0166AB !important; + margin-left: 12px; +} + +.smart-hub--link-prev { + text-decoration-line: underline !important; + color: #0166AB !important; + padding-left: 20px; + margin-right: 12px; +} + +.smart-hub--link-pagination { + text-decoration: none; + padding: 2px; +} + +.landing .smart-hub--table-nav { + float: right; + font-size: 16px; + font-weight: 500; + font-family: Source Sans Pro Web, Helvetica Neue, Helvetica, Roboto, Arial, sans-serif; + margin-right: 20px; + margin-top: 27px; + margin-bottom: -90px; } h1.landing { font-size: 45px; - font-family: 'Times New Roman', Times, serif; + font-family: 'Merriweather', serif; + font-weight: 700; white-space: nowrap; margin-right: 30px; } @@ -26,9 +89,11 @@ h1.landing { } .landing .usa-table caption { - font-size: 19px; + font-size: 21px; + font-weight: 900; padding: 14px 0px 17px 20px; margin-bottom: -0.25rem; + margin-top: 1px; } .landing .usa-table thead th { @@ -38,6 +103,7 @@ h1.landing { border-top: solid 1.5px; border-color: #ECEEF1; border-bottom: none; + cursor: pointer; } .landing .usa-table--borderless th:first-child { @@ -63,6 +129,11 @@ h1.landing { vertical-align: middle; } +.landing .usa-alert .usa-alert--error { + margin-bottom: 20px; + background-color: #148439; +} + .usa-table tr:nth-child(odd) { background-color:#F8F8F8; } @@ -134,18 +205,30 @@ h1.landing { min-width: 220px; } -thead th.ascending::after { +.smart-hub--create-new-report { + padding-top: 30px; +} + +thead th > .asc::after { content: "▲"; display: inline-block; margin-left: 0.25em; } -thead th.descending::after { +thead th > .desc::after { content: "▼"; display: inline-block; margin-left: 0.25em; } +a.asc { + outline: none !important; +} + +a.desc { + outline: none !important; +} + .landing .usa-alert { padding: 20px; } @@ -171,3 +254,12 @@ thead th.descending::after { #beginNew { padding-bottom: 15px; } + +#arTblDesc { + position:absolute; + left:-10000px; + top:auto; + width:1px; + height:1px; + overflow:hidden; +} diff --git a/frontend/src/pages/Landing/index.js b/frontend/src/pages/Landing/index.js index 216b58feb9..fe92759c7d 100644 --- a/frontend/src/pages/Landing/index.js +++ b/frontend/src/pages/Landing/index.js @@ -1,3 +1,4 @@ +/* eslint-disable jsx-a11y/anchor-is-valid */ import React, { useState, useEffect } from 'react'; import { Tag, Table, Alert, Grid, @@ -6,6 +7,7 @@ import { Helmet } from 'react-helmet'; import { Link, useHistory } from 'react-router-dom'; import SimpleBar from 'simplebar-react'; import 'simplebar/dist/simplebar.min.css'; +import Pagination from 'react-js-pagination'; import UserContext from '../../UserContext'; import Container from '../../components/Container'; @@ -46,12 +48,14 @@ function renderReports(reports, history) { status, } = report; - const recipientsTitle = activityRecipients.reduce( + const authorName = author ? author.fullName : ''; + + const recipientsTitle = activityRecipients && activityRecipients.reduce( (result, ar) => `${result + (ar.grant ? ar.grant.grantee.name : ar.name)}\n`, '', ); - const recipients = activityRecipients.map((ar) => ( + const recipients = activityRecipients && activityRecipients.map((ar) => ( )); - const collaboratorsTitle = collaborators.reduce( + const collaboratorsTitle = collaborators && collaborators.reduce( (result, collaborator) => `${result + collaborator.fullName}\n`, '', ); - const collaboratorsWithTags = collaborators.map((collaborator) => ( + const collaboratorsWithTags = collaborators && collaborators.map((collaborator) => ( {startDate} - - {author.fullName} + + {authorName} @@ -143,20 +147,61 @@ function renderReports(reports, history) { }); } +export function renderTotal(offset, perPage, activePage, reportsCount) { + const from = offset >= reportsCount ? 0 : offset + 1; + const offsetTo = perPage * activePage; + let to; + if (offsetTo > reportsCount) { + to = reportsCount; + } else { + to = offsetTo; + } + return `${from}-${to} of ${reportsCount}`; +} + function Landing() { const history = useHistory(); const [isLoaded, setIsLoaded] = useState(false); const [reports, updateReports] = useState([]); const [reportAlerts, updateReportAlerts] = useState([]); const [error, updateError] = useState(); + const [sortConfig, setSortConfig] = React.useState({ + sortBy: 'updatedAt', + direction: 'desc', + }); + const [offset, setOffset] = useState(0); + const [perPage] = useState(10); + const [activePage, setActivePage] = useState(1); + const [reportsCount, setReportsCount] = useState(0); + + const requestSort = (sortBy) => { + let direction = 'asc'; + if ( + sortConfig + && sortConfig.sortBy === sortBy + && sortConfig.direction === 'asc' + ) { + direction = 'desc'; + } + setActivePage(1); + setOffset(0); + setSortConfig({ sortBy, direction }); + }; useEffect(() => { async function fetchReports() { - setIsLoaded(false); try { - const reps = await getReports(); + const { count, rows } = await getReports( + sortConfig.sortBy, + sortConfig.direction, + offset, + perPage, + ); const alerts = await getReportAlerts(); - updateReports(reps); + updateReports(rows); + if (count) { + setReportsCount(count); + } updateReportAlerts(alerts); } catch (e) { // eslint-disable-next-line no-console @@ -166,7 +211,48 @@ function Landing() { setIsLoaded(true); } fetchReports(); - }, []); + }, [sortConfig, offset, perPage]); + + const getClassNamesFor = (name) => (sortConfig.sortBy === name ? sortConfig.direction : ''); + + const renderColumnHeader = (displayName, name) => { + const sortClassName = getClassNamesFor(name); + let fullAriaSort; + switch (sortClassName) { + case 'asc': + fullAriaSort = 'ascending'; + break; + case 'desc': + fullAriaSort = 'descending'; + break; + default: + fullAriaSort = 'none'; + break; + } + return ( + + { + requestSort(name); + }} + onKeyPress={() => requestSort(name)} + className={sortClassName} + aria-label={`${displayName}. Activate to sort ${ + sortClassName === 'asc' ? 'descending' : 'ascending' + }`} + > + {displayName} + + + ); + }; + + const handlePageChange = (pageNumber) => { + setActivePage(pageNumber); + setOffset((pageNumber - 1) * perPage); + }; if (!isLoaded) { return
Loading...
; @@ -185,7 +271,9 @@ function Landing() {

Activity Reports

- {reportAlerts && reportAlerts.length > 0 && hasReadWrite(user) && } + {reportAlerts + && reportAlerts.length > 0 + && hasReadWrite(user) && } @@ -198,18 +286,47 @@ function Landing() { + + + {renderTotal(offset, perPage, activePage, reportsCount)} + + + - + - - - - - - - - + {renderColumnHeader('Report ID', 'regionId')} + {renderColumnHeader('Grantee', 'activityRecipients')} + {renderColumnHeader('Start date', 'startDate')} + {renderColumnHeader('Creator', 'author')} + {renderColumnHeader('Topic(s)', 'topics')} + {renderColumnHeader('Collaborator(s)', 'collaborators')} + {renderColumnHeader('Last saved', 'updatedAt')} + {renderColumnHeader('Status', 'status')} diff --git a/frontend/src/pages/Landing/mocks.js b/frontend/src/pages/Landing/mocks.js index afb6b21433..9d95052a5b 100644 --- a/frontend/src/pages/Landing/mocks.js +++ b/frontend/src/pages/Landing/mocks.js @@ -1,7 +1,7 @@ const activityReports = [ { startDate: '02/08/2021', - lastSaved: '02/04/2021', + lastSaved: '02/05/2021', id: 1, displayId: 'R14-AR-1', regionId: 14, @@ -109,4 +109,193 @@ const activityReports = [ ], }, ]; + +export const activityReportsSorted = [ + { + startDate: '02/01/2021', + lastSaved: '02/04/2021', + id: 2, + displayId: 'R14-AR-2', + regionId: 14, + topics: [], + status: 'needs_action', + activityRecipients: [ + { + activityRecipientId: 3, + name: 'QRIS System', + id: 31, + grant: null, + nonGrantee: { + id: 3, + name: 'QRIS System', + createdAt: '2021-02-03T21:00:57.149Z', + updatedAt: '2021-02-03T21:00:57.149Z', + }, + }, + ], + author: { + fullName: 'Kiwi, GS', + name: 'Kiwi', + role: 'Grants Specialist', + homeRegionId: 14, + }, + collaborators: [ + { + fullName: 'Cucumber User, GS', + name: 'Cucumber User', + role: 'Grantee Specialist', + }, + { + fullName: 'Hermione Granger, SS', + name: 'Hermione Granger', + role: 'System Specialist', + }, + ], + }, + { + startDate: '02/08/2021', + lastSaved: '02/05/2021', + id: 1, + displayId: 'R14-AR-1', + regionId: 14, + topics: ['Behavioral / Mental Health', 'CLASS: Instructional Support'], + status: 'draft', + activityRecipients: [ + { + activityRecipientId: 5, + name: 'Johnston-Romaguera - 14CH00003', + id: 1, + grant: { + id: 5, + number: '14CH00003', + grantee: { + name: 'Johnston-Romaguera', + }, + }, + nonGrantee: null, + }, + { + activityRecipientId: 4, + name: 'Johnston-Romaguera - 14CH00002', + id: 2, + grant: { + id: 4, + number: '14CH00002', + grantee: { + name: 'Johnston-Romaguera', + }, + }, + nonGrantee: null, + }, + { + activityRecipientId: 1, + name: 'Grantee Name - 14CH1234', + id: 3, + grant: { + id: 1, + number: '14CH1234', + grantee: { + name: 'Grantee Name', + }, + }, + nonGrantee: null, + }, + ], + author: { + fullName: 'Kiwi, TTAC', + name: 'Kiwi', + role: 'Grants Specialist', + homeRegionId: 14, + }, + collaborators: [ + { + fullName: 'Orange, GS', + name: 'Orange', + role: 'Grants Specialist', + }, + { + fullName: 'Hermione Granger, SS', + name: 'Hermione Granger', + role: 'System Specialist', + }, + ], + }, +]; + +export const generateXFakeReports = (count) => { + const result = []; + for (let i = 1; i <= count; i += 1) { + result.push( + { + startDate: '02/08/2021', + lastSaved: '02/05/2021', + id: i, + displayId: 'R14-AR-1', + regionId: 14, + topics: ['Behavioral / Mental Health', 'CLASS: Instructional Support'], + status: 'draft', + activityRecipients: [ + { + activityRecipientId: 5, + name: 'Johnston-Romaguera - 14CH00003', + id: 1, + grant: { + id: 5, + number: '14CH00003', + grantee: { + name: 'Johnston-Romaguera', + }, + }, + nonGrantee: null, + }, + { + activityRecipientId: 4, + name: 'Johnston-Romaguera - 14CH00002', + id: 2, + grant: { + id: 4, + number: '14CH00002', + grantee: { + name: 'Johnston-Romaguera', + }, + }, + nonGrantee: null, + }, + { + activityRecipientId: 1, + name: 'Grantee Name - 14CH1234', + id: 3, + grant: { + id: 1, + number: '14CH1234', + grantee: { + name: 'Grantee Name', + }, + }, + nonGrantee: null, + }, + ], + author: { + fullName: 'Kiwi, GS', + name: 'Kiwi', + role: 'Grants Specialist', + homeRegionId: 14, + }, + collaborators: [ + { + fullName: 'Orange, GS', + name: 'Orange', + role: 'Grants Specialist', + }, + { + fullName: 'Hermione Granger, SS', + name: 'Hermione Granger', + role: 'System Specialist', + }, + ], + }, + ); + } + return result; +}; export default activityReports; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 8767578669..ad6e7d58b0 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -2941,6 +2941,13 @@ bindings@^1.5.0: dependencies: file-uri-to-path "1.0.0" +block-stream@*: + version "0.0.9" + resolved "https://registry.yarnpkg.com/block-stream/-/block-stream-0.0.9.tgz#13ebfe778a03205cfe03751481ebb4b3300c126a" + integrity sha1-E+v+d4oDIFz+A3UUgeu0szAMEmo= + dependencies: + inherits "~2.0.0" + bluebird@^3.5.5: version "3.7.2" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" @@ -3433,7 +3440,7 @@ classlist-polyfill@^1.0.3: resolved "https://registry.yarnpkg.com/classlist-polyfill/-/classlist-polyfill-1.2.0.tgz#935bc2dfd9458a876b279617514638bcaa964a2e" integrity sha1-k1vC39lFiodrJ5YXUUY4vKqWSi4= -classnames@^2.0.0: +classnames@^2.0.0, classnames@^2.2.5: version "2.2.6" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce" integrity sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q== @@ -5491,6 +5498,16 @@ fsevents@^2.1.2, fsevents@^2.1.3, fsevents@~2.3.1: resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== +fstream@1.0.12, fstream@^1.0.12: + version "1.0.12" + resolved "https://registry.yarnpkg.com/fstream/-/fstream-1.0.12.tgz#4e8ba8ee2d48be4f7d0de505455548eae5932045" + integrity sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg== + dependencies: + graceful-fs "^4.1.2" + inherits "~2.0.0" + mkdirp ">=0.5 0" + rimraf "2" + function-bind@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" @@ -6143,7 +6160,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.1, inherits@~2.0.3: +inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.0, inherits@~2.0.1, inherits@~2.0.3: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -7765,7 +7782,7 @@ mixin-deep@^1.2.0: for-in "^1.0.2" is-extendable "^1.0.1" -mkdirp@^0.5.1, mkdirp@^0.5.3, mkdirp@^0.5.5, mkdirp@~0.5.1: +"mkdirp@>=0.5 0", mkdirp@^0.5.1, mkdirp@^0.5.3, mkdirp@^0.5.5, mkdirp@~0.5.1: version "0.5.5" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def" integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ== @@ -8309,6 +8326,11 @@ p-try@^2.0.0: resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== +paginator@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/paginator/-/paginator-1.0.0.tgz#7565702af9ab9616dca61fc22c70eba2a4357265" + integrity sha1-dWVwKvmrlhbcph/CLHDroqQ1cmU= + pako@~1.0.5: version "1.0.11" resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" @@ -9328,7 +9350,7 @@ prop-types-exact@^1.2.0: object.assign "^4.1.0" reflect.ownkeys "^0.2.0" -prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2: +"prop-types@15.x.x - 16.x.x", prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2: version "15.7.2" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ== @@ -9620,6 +9642,18 @@ react-is@^17.0.1: resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.1.tgz#5b3531bd76a645a4c9fb6e693ed36419e3301339" integrity sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA== +react-js-pagination@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/react-js-pagination/-/react-js-pagination-3.0.3.tgz#bc5504ad9fda2a13f59eca91c7b07966a39e1776" + integrity sha512-podyA6Rd0uxc8uQakXWXxnonoOPI6NnFOROXfc6qPKNYm44s+Bgpn0JkyflcfbHf/GFKahnL8JN8rxBHZiBskg== + dependencies: + classnames "^2.2.5" + fstream "1.0.12" + paginator "^1.0.0" + prop-types "15.x.x - 16.x.x" + react "15.x.x - 16.x.x" + tar "2.2.2" + react-moment-proptypes@^1.6.0: version "1.7.0" resolved "https://registry.yarnpkg.com/react-moment-proptypes/-/react-moment-proptypes-1.7.0.tgz#89881479840a76c13574a86e3bb214c4ba564e7a" @@ -9849,6 +9883,15 @@ react-with-styles@^4.1.0: prop-types "^15.7.2" react-with-direction "^1.3.1" +"react@15.x.x - 16.x.x": + version "16.14.0" + resolved "https://registry.yarnpkg.com/react/-/react-16.14.0.tgz#94d776ddd0aaa37da3eda8fc5b6b18a4c9a3114d" + integrity sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + prop-types "^15.6.2" + react@^17.0.1: version "17.0.1" resolved "https://registry.yarnpkg.com/react/-/react-17.0.1.tgz#6e0600416bd57574e3f86d92edba3d9008726127" @@ -10252,7 +10295,7 @@ rgba-regex@^1.0.0: resolved "https://registry.yarnpkg.com/rgba-regex/-/rgba-regex-1.0.0.tgz#43374e2e2ca0968b0ef1523460b7d730ff22eeb3" integrity sha1-QzdOLiyglosO8VI0YLfXMP8i7rM= -rimraf@^2.5.4, rimraf@^2.6.3: +rimraf@2, rimraf@^2.5.4, rimraf@^2.6.3: version "2.7.1" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== @@ -11190,6 +11233,15 @@ tapable@^1.0.0, tapable@^1.1.3: resolved "https://registry.yarnpkg.com/tapable/-/tapable-1.1.3.tgz#a1fccc06b58db61fd7a45da2da44f5f3a3e67ba2" integrity sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA== +tar@2.2.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/tar/-/tar-2.2.2.tgz#0ca8848562c7299b8b446ff6a4d60cdbb23edc40" + integrity sha512-FCEhQ/4rE1zYv9rYXJw/msRqsnmlje5jHP6huWeBZ704jUTy02c5AZyWujpMR1ax6mVw9NyJMfuK2CMDWVIfgA== + dependencies: + block-stream "*" + fstream "^1.0.12" + inherits "2" + tar@^6.0.2: version "6.1.0" resolved "https://registry.yarnpkg.com/tar/-/tar-6.1.0.tgz#d1724e9bcc04b977b18d5c573b333a2207229a83" diff --git a/src/app.js b/src/app.js index 416008f31a..cf0127b07c 100644 --- a/src/app.js +++ b/src/app.js @@ -12,7 +12,7 @@ import { CronJob } from 'cron'; import { hsesAuth } from './middleware/authMiddleware'; import updateGrantsGrantees from './lib/updateGrantsGrantees'; -import findOrCreateUser from './services/accessValidation'; +import findOrCreateUser, { getUserReadRegions } from './services/accessValidation'; import { logger, auditLogger, requestLogger } from './logger'; @@ -82,6 +82,7 @@ app.get(oauth2CallbackPath, async (req, res) => { }); req.session.userId = dbUser.id; + req.session.readRegions = await getUserReadRegions(dbUser.id); auditLogger.info(`User ${dbUser.id} logged in`); logger.debug(`referrer path: ${req.session.referrerPath}`); diff --git a/src/middleware/authMiddleware.js b/src/middleware/authMiddleware.js index 2fd7bdc4d9..09b99aa6ec 100644 --- a/src/middleware/authMiddleware.js +++ b/src/middleware/authMiddleware.js @@ -1,7 +1,7 @@ import {} from 'dotenv/config'; import ClientOAuth2 from 'client-oauth2'; import { auditLogger } from '../logger'; -import { validateUserAuthForAccess } from '../services/accessValidation'; +import { validateUserAuthForAccess, getUserReadRegions } from '../services/accessValidation'; export const hsesAuth = new ClientOAuth2({ clientId: process.env.AUTH_CLIENT_ID, @@ -45,6 +45,7 @@ export default async function authMiddleware(req, res, next) { if (process.env.NODE_ENV !== 'production' && process.env.BYPASS_AUTH === 'true') { auditLogger.warn(`Bypassing authentication in authMiddleware - using User ${process.env.CURRENT_USER_ID}`); req.session.userId = process.env.CURRENT_USER_ID; + req.session.readRegions = await getUserReadRegions(process.env.CURRENT_USER_ID); } let userId = null; if (req.session) { diff --git a/src/models/activityReport.js b/src/models/activityReport.js index 166bdf8325..d9f485d38d 100644 --- a/src/models/activityReport.js +++ b/src/models/activityReport.js @@ -167,6 +167,23 @@ export default (sequelize, DataTypes) => { return moment(this.updatedAt).format('MM/DD/YYYY'); }, }, + sortedTopics: { + type: DataTypes.VIRTUAL, + get() { + if (!this.topics) { + return []; + } + return this.topics.sort((a, b) => { + if (a < b) { + return -1; + } + if (a > b) { + return 1; + } + return 0; + }); + }, + }, }, { sequelize, modelName: 'ActivityReport', diff --git a/src/routes/activityReports/handlers.js b/src/routes/activityReports/handlers.js index c3dd3948ff..1fd7d3090a 100644 --- a/src/routes/activityReports/handlers.js +++ b/src/routes/activityReports/handlers.js @@ -178,11 +178,12 @@ export async function getReport(req, res) { * @param {*} res - response */ export async function getReports(req, res) { - const reports = await activityReports(); - if (!reports) { + const { readRegions } = req.session; + const reportsWithCount = await activityReports(readRegions, req.query); + if (!reportsWithCount) { res.sendStatus(404); } else { - res.json(reports); + res.json(reportsWithCount); } } diff --git a/src/routes/activityReports/handlers.test.js b/src/routes/activityReports/handlers.test.js index 1959df928f..1f62c27f12 100644 --- a/src/routes/activityReports/handlers.test.js +++ b/src/routes/activityReports/handlers.test.js @@ -7,9 +7,17 @@ import { submitReport, reviewReport, resetToDraft, + getReports, + getReportAlerts, } from './handlers'; import { - activityReportById, createOrUpdate, possibleRecipients, review, setStatus, + activityReportById, + createOrUpdate, + possibleRecipients, + review, + setStatus, + activityReports, + activityReportAlerts, } from '../../services/activityReports'; import { userById, usersWithPermissions } from '../../services/users'; import ActivityReport from '../../policies/activityReport'; @@ -21,6 +29,8 @@ jest.mock('../../services/activityReports', () => ({ possibleRecipients: jest.fn(), review: jest.fn(), setStatus: jest.fn(), + activityReports: jest.fn(), + activityReportAlerts: jest.fn(), })); jest.mock('../../services/users', () => ({ @@ -301,4 +311,50 @@ describe('Activity Report handlers', () => { expect(mockResponse.sendStatus).toHaveBeenCalledWith(403); }); }); + + describe('getReports', () => { + const request = { + ...mockRequest, + query: { }, + }; + + it('returns the reports', async () => { + activityReports.mockResolvedValue({ count: 1, rows: [report] }); + userById.mockResolvedValue({ + id: 1, + }); + + await getReports(request, mockResponse); + expect(mockResponse.json).toHaveBeenCalledWith({ count: 1, rows: [report] }); + }); + + it('handles a list of reports that are not found', async () => { + activityReports.mockResolvedValue(null); + await getReports(request, mockResponse); + expect(mockResponse.sendStatus).toHaveBeenCalledWith(404); + }); + }); + + describe('getReportAlerts', () => { + const request = { + ...mockRequest, + query: { }, + }; + + it('returns my alerts', async () => { + activityReportAlerts.mockResolvedValue([report]); + userById.mockResolvedValue({ + id: 1, + }); + + await getReportAlerts(request, mockResponse); + expect(mockResponse.json).toHaveBeenCalledWith([report]); + }); + + it('handles a list of alerts that are not found', async () => { + activityReportAlerts.mockResolvedValue(null); + await getReportAlerts(request, mockResponse); + expect(mockResponse.sendStatus).toHaveBeenCalledWith(404); + }); + }); }); diff --git a/src/services/accessValidation.js b/src/services/accessValidation.js index 5443d67f07..51611de6fd 100644 --- a/src/services/accessValidation.js +++ b/src/services/accessValidation.js @@ -1,3 +1,4 @@ +import { Op } from 'sequelize'; import { User, Permission, sequelize } from '../models'; import { auditLogger as logger } from '../logger'; import SCOPES from '../middleware/scopeConstants'; @@ -70,3 +71,23 @@ export async function validateUserAuthForAdmin(userId) { throw error; } } + +export async function getUserReadRegions(userId) { + try { + const readRegions = await Permission.findAll({ + attributes: ['regionId'], + where: { + userId, + [Op.or]: [ + { scopeId: SCOPES.READ_WRITE_REPORTS }, + { scopeId: SCOPES.READ_REPORTS }, + { scopeId: SCOPES.APPROVE_REPORTS }, + ], + }, + }); + return readRegions ? readRegions.map((p) => p.regionId) : []; + } catch (error) { + logger.error(`${JSON.stringify({ ...logContext })} - Read region retrieval error - ${error}`); + throw error; + } +} diff --git a/src/services/accessValidation.test.js b/src/services/accessValidation.test.js index 4938155283..53431b0657 100644 --- a/src/services/accessValidation.test.js +++ b/src/services/accessValidation.test.js @@ -1,10 +1,16 @@ import moment from 'moment'; import db, { User, Permission, sequelize } from '../models'; -import findOrCreateUser, { validateUserAuthForAccess, validateUserAuthForAdmin } from './accessValidation'; +import findOrCreateUser, { + validateUserAuthForAccess, + validateUserAuthForAdmin, + getUserReadRegions, +} from './accessValidation'; import { auditLogger } from '../logger'; import SCOPES from '../middleware/scopeConstants'; -const { SITE_ACCESS, ADMIN } = SCOPES; +const { + SITE_ACCESS, ADMIN, READ_REPORTS, READ_WRITE_REPORTS, +} = SCOPES; jest.mock('../logger', () => ({ auditLogger: { @@ -221,4 +227,43 @@ describe('accessValidation', () => { await expect(validateUserAuthForAdmin(undefined)).rejects.toThrow(); }); }); + + describe('getUserReadRegions', () => { + it('returns an array of regions user has permissions to', async () => { + await setupUser(mockUser); + await Permission.create({ + scopeId: READ_REPORTS, + userId: mockUser.id, + regionId: 14, + }); + await Permission.create({ + scopeId: READ_WRITE_REPORTS, + userId: mockUser.id, + regionId: 13, + }); + + const regions = await getUserReadRegions(mockUser.id); + + expect(regions[0]).toBe(14); + expect(regions[1]).toBe(13); + }); + + it('returns an empty array if user has no permissions', async () => { + await setupUser(mockUser); + + const regions = await getUserReadRegions(mockUser.id); + expect(regions.length).toBe(0); + }); + + it('returns an empty array if a user does not exist in database', async () => { + await User.destroy({ where: { id: mockUser.id } }); + + const regions = await getUserReadRegions(mockUser.id); + expect(regions.length).toBe(0); + }); + + it('Throws on error', async () => { + await expect(getUserReadRegions(undefined)).rejects.toThrow(); + }); + }); }); diff --git a/src/services/activityReports.js b/src/services/activityReports.js index a589f7fedb..8d64cbba4b 100644 --- a/src/services/activityReports.js +++ b/src/services/activityReports.js @@ -237,48 +237,144 @@ export function activityReportById(activityReportId) { ], }); } - -export function activityReports() { - return ActivityReport.findAll({ - attributes: ['id', 'displayId', 'startDate', 'lastSaved', 'topics', 'status', 'regionId'], - include: [ - { - model: ActivityRecipient, - attributes: ['id', 'name', 'activityRecipientId'], - as: 'activityRecipients', - 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, - }, +/** + * Retrieves activity reports in sorted slices + * using sequelize.literal for several associated fields based on the following + * https://github.com/sequelize/sequelize/issues/11288 + * + * @param {*} sortBy - field to sort by; default updatedAt + * @param {*} sortDir - order: either ascending or descending; default desc + * @param {*} offset - offset from the start of the total sorted results + * @param {*} limit - size of the slice + * @returns {Promise} - returns a promise with total reports count and the reports slice + */ +export function activityReports(readRegions, { + sortBy = 'updatedAt', sortDir = 'desc', offset = 0, limit = 10, +}) { + let result = ''; + const regions = readRegions || []; + const orderBy = () => { + switch (sortBy) { + case 'author': + result = [[ + sequelize.literal(`authorName ${sortDir}`), + ]]; + break; + case 'collaborators': + result = [[ + sequelize.literal(`collaboratorName ${sortDir} NULLS LAST`), + ]]; + break; + case 'topics': + result = [[ + sequelize.literal(`topics ${sortDir}`), + ]]; + break; + case 'regionId': + result = [[ + 'regionId', + sortDir, ], - }, - { - model: User, - attributes: ['name', 'role', 'fullName', 'homeRegionId'], - as: 'author', - }, - { - model: User, - attributes: ['id', 'name', 'role', 'fullName'], - as: 'collaborators', - through: { attributes: [] }, - }, - ], - }); + [ + 'id', + sortDir, + ]]; + break; + case 'activityRecipients': + result = [ + [ + sequelize.literal(`granteeName ${sortDir}`), + ], + [ + sequelize.literal(`nonGranteeName ${sortDir}`), + ]]; + break; + case 'status': + case 'startDate': + case 'updatedAt': + result = [[sortBy, sortDir]]; + break; + default: + break; + } + return result; + }; + return ActivityReport.findAndCountAll( + { + where: { regionId: regions }, + attributes: [ + 'id', + 'displayId', + 'startDate', + 'lastSaved', + 'topics', + 'status', + 'regionId', + 'updatedAt', + 'sortedTopics', + sequelize.literal( + '(SELECT name as authorName FROM "Users" WHERE "Users"."id" = "ActivityReport"."userId")', + ), + sequelize.literal( + '(SELECT name as collaboratorName FROM "Users" join "ActivityReportCollaborators" on "Users"."id" = "ActivityReportCollaborators"."userId" and "ActivityReportCollaborators"."activityReportId" = "ActivityReport"."id" limit 1)', + ), + sequelize.literal( + // eslint-disable-next-line quotes + `(SELECT "NonGrantees".name as nonGranteeName from "NonGrantees" INNER JOIN "ActivityRecipients" ON "ActivityReport"."id" = "ActivityRecipients"."activityReportId" AND "ActivityRecipients"."nonGranteeId" = "NonGrantees".id order by nonGranteeName ${sortDir} limit 1)`, + ), + sequelize.literal( + // eslint-disable-next-line quotes + `(SELECT "Grantees".name as granteeName FROM "Grantees" INNER JOIN "ActivityRecipients" ON "ActivityReport"."id" = "ActivityRecipients"."activityReportId" JOIN "Grants" ON "Grants"."id" = "ActivityRecipients"."grantId" AND "Grantees"."id" = "Grants"."granteeId" order by granteeName ${sortDir} limit 1)`, + ), + ], + include: [ + { + model: ActivityRecipient, + attributes: ['id', 'name', 'activityRecipientId'], + as: 'activityRecipients', + 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, + }, + ], + }, + { + model: User, + attributes: ['name', 'role', 'fullName', 'homeRegionId'], + as: 'author', + }, + { + model: User, + attributes: ['id', 'name', 'role', 'fullName'], + as: 'collaborators', + through: { attributes: [] }, + }, + ], + order: orderBy(), + offset, + limit, + distinct: true, + }, + { + subQuery: false, + }, + ); } /** * Retrieves alerts based on the following logic: diff --git a/src/services/activityReports.test.js b/src/services/activityReports.test.js index f2308702ee..5ef539f205 100644 --- a/src/services/activityReports.test.js +++ b/src/services/activityReports.test.js @@ -2,7 +2,7 @@ import db, { ActivityReport, ActivityRecipient, User, Grantee, NonGrantee, Grant, NextStep, Region, } from '../models'; import { - createOrUpdate, activityReportById, possibleRecipients, + createOrUpdate, activityReportById, possibleRecipients, activityReports, activityReportAlerts, } from './activityReports'; import { REPORT_STATUSES } from '../constants'; @@ -40,13 +40,13 @@ describe('Activity Reports DB service', () => { }); afterAll(async () => { + await NextStep.destroy({ where: {} }); await ActivityRecipient.destroy({ where: {} }); await ActivityReport.destroy({ where: {} }); await User.destroy({ where: { id: mockUser.id } }); await NonGrantee.destroy({ where: { id: RECIPIENT_ID } }); await Grant.destroy({ where: { id: RECIPIENT_ID } }); await Grantee.destroy({ where: { id: RECIPIENT_ID } }); - await NextStep.destroy({ where: {} }); await Region.destroy({ where: { id: 17 } }); db.sequelize.close(); }); @@ -238,6 +238,110 @@ describe('Activity Reports DB service', () => { }); }); + describe('activityReports retrieval and sorting', () => { + it('retrieves reports with default sort by updatedAt', async () => { + const report = await ActivityReport.create(reportObject); + + const { count, rows } = await activityReports([1], {}); + expect(rows.length).toBe(10); + expect(count).toBeDefined(); + expect(rows[0].id).toBe(report.id); + }); + + it('retrieves reports sorted by author', async () => { + const mockUserTwo = { + id: 1002, + homeRegionId: 1, + name: 'a user', + hsesUsername: 'user', + hsesUserId: '1002', + }; + await User.findOrCreate({ + where: { + id: mockUserTwo.id, + }, + defaults: mockUserTwo, + }); + reportObject.userId = mockUserTwo.id; + await ActivityReport.create(reportObject); + + const { rows } = await activityReports([1], { + sortBy: 'author', sortDir: 'asc', offset: 0, limit: 2, + }); + expect(rows.length).toBe(2); + expect(rows[0].author.name).toBe('a user'); + }); + + it('retrieves reports sorted by collaborators', async () => { + await ActivityReport.create(reportObject); + + const { rows } = await activityReports([1], { + sortBy: 'collaborators', sortDir: 'asc', offset: 0, limit: 12, + }); + expect(rows.length).toBe(12); + expect(rows[0].collaborators[0].name).toBe('user'); + }); + + it('retrieves reports sorted by id', async () => { + reportObject.regionId = 2; + await ActivityReport.create(reportObject); + + const { rows } = await activityReports([1, 2], { + sortBy: 'regionId', sortDir: 'desc', offset: 0, limit: 12, + }); + expect(rows.length).toBe(12); + expect(rows[0].regionId).toBe(2); + }); + + it('retrieves reports sorted by activity recipients', async () => { + reportObject.regionId = 2; + await ActivityReport.create(reportObject); + + const { rows } = await activityReports([1, 2], { + sortBy: 'activityRecipients', sortDir: 'asc', offset: 0, limit: 12, + }); + expect(rows.length).toBe(12); + expect(rows[0].activityRecipients[0].activityRecipientId).toBe(RECIPIENT_ID); + }); + + it('retrieves reports sorted by sorted topics', async () => { + reportObject.topics = ['topic d', 'topic c']; + await ActivityReport.create(reportObject); + reportObject.topics = ['topic b', 'topic a']; + await ActivityReport.create(reportObject); + + const { rows } = await activityReports([1, 2], { + sortBy: 'topics', sortDir: 'asc', offset: 0, limit: 12, + }); + expect(rows.length).toBe(12); + expect(rows[0].sortedTopics[0]).toBe('topic a'); + expect(rows[0].sortedTopics[1]).toBe('topic b'); + expect(rows[1].sortedTopics[0]).toBe('topic c'); + expect(rows[0].topics[0]).toBe('topic a'); + expect(rows[0].topics[1]).toBe('topic b'); + expect(rows[1].topics[0]).toBe('topic c'); + }); + + it('retrieves myalerts', async () => { + const mockUserTwo = { + id: 1002, + homeRegionId: 1, + name: 'a user', + }; + await User.findOrCreate({ + where: { + id: mockUserTwo.id, + }, + defaults: mockUserTwo, + }); + reportObject.userId = mockUserTwo.id; + await ActivityReport.create(reportObject); + + const result = await activityReportAlerts(mockUserTwo.id); + expect(result[0].userId).toBe(mockUserTwo.id); + }); + }); + describe('possibleRecipients', () => { it('retrieves correct recipients in region', async () => { const region = 17;
Activity reports + Activity reports +

with sorting and pagination

+
Report IDGranteeStart dateCreatorTopic(s)Collaborator(s)Last savedStatus