diff --git a/frontend/package.json b/frontend/package.json index c7aeb40326..901c15a1b1 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) => (Report ID | -Grantee | -Start date | -Creator | -Topic(s) | -Collaborator(s) | -Last saved | -Status | + {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')}
---|