diff --git a/.circleci/config.yml b/.circleci/config.yml index 75fe862a14..7deaf42fc9 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -165,10 +165,10 @@ parameters: type: string dev_git_branch: # change to feature branch to test deployment description: "Name of github branch that will deploy to dev" - default: "kw-disable-hses-staging" + default: "TTAHUB276" type: string sandbox_git_branch: # change to feature branch to test deployment - default: "kw-delete-reports-script" + default: "TTAHUB276" type: string prod_new_relic_app_id: default: "877570491" @@ -333,6 +333,15 @@ jobs: - run: name: Build backend assets command: yarn build + - when: + condition: + and: + - equal: [<< pipeline.project.git_url >>, << pipeline.parameters.prod_git_url >>] + - equal: [<< pipeline.git.branch >>, << pipeline.parameters.prod_git_branch >>] + steps: + - run: + name: Create production robot + command: ./bin/robot-factory - run: name: Build frontend assets command: yarn --cwd frontend run build diff --git a/bin/robot-factory b/bin/robot-factory new file mode 100755 index 0000000000..edc4740548 --- /dev/null +++ b/bin/robot-factory @@ -0,0 +1,9 @@ +#!/bin/bash + +echo 'Setting site to be indexable in robots.txt' + +cat >frontend/public/robots.txt </src/pages/NotFound/index.js", "/src/polyfills.js", "/src/pages/Widgets/index.js", - "/src/widgets/Example.js" + "/src/widgets/Example.js", + "/src/pages/RegionalDashboard/formatDateRange.js", + "/src/pages/RegionalDashboard/constants.js" ], "coverageThreshold": { "global": { diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt index e9e57dc4d4..b293488070 100644 --- a/frontend/public/robots.txt +++ b/frontend/public/robots.txt @@ -1,3 +1,6 @@ +# we disallow everything by default +# in production, we allow indexing by removing the slash from the disallow + # https://www.robotstxt.org/robotstxt.html User-agent: * -Disallow: +Disallow: / 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/Accordion.js b/frontend/src/components/Accordion.js new file mode 100644 index 0000000000..e60b2d4640 --- /dev/null +++ b/frontend/src/components/Accordion.js @@ -0,0 +1,122 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; + +export const AccordionItem = ({ + title, + id, + content, + expanded, + className, + handleToggle, + headingSize, +}) => { + const headingClasses = `usa-accordion__heading ${className}`; + const contentClasses = `usa-accordion__content usa-prose ${className}`; + const HeadingSizeTag = `h${headingSize}`; + return ( + <> + + + + + + ); +}; + +AccordionItem.propTypes = { + title: PropTypes.string.isRequired, + content: PropTypes.string.isRequired, + expanded: PropTypes.bool.isRequired, + id: PropTypes.string.isRequired, + className: PropTypes.string, + handleToggle: PropTypes.func, + headingSize: PropTypes.number.isRequired, +}; + +AccordionItem.defaultProps = { + className: '', + handleToggle: () => { }, +}; + +export const Accordion = ({ + bordered, + items, + multiselectable, + headingSize, +}) => { + const [openItems, setOpenState] = useState( + items.filter((i) => !!i.expanded).map((i) => i.id), + ); + + const classes = bordered ? 'usa-accordion usa-accordion--bordered' : 'usa-accordion'; + + const toggleItem = (itemId) => { + const newOpenItems = [...openItems]; + const itemIndex = openItems.indexOf(itemId); + const isMultiselectable = multiselectable; + + if (itemIndex > -1) { + newOpenItems.splice(itemIndex, 1); + } else if (isMultiselectable) { + newOpenItems.push(itemId); + } else { + newOpenItems.splice(0, newOpenItems.length); + newOpenItems.push(itemId); + } + setOpenState(newOpenItems); + }; + + return ( +
+ {items.map((item) => ( + -1} + handleToggle={() => { + toggleItem(item.id); + }} + headingSize={headingSize} + /> + ))} +
+ ); +}; + +Accordion.propTypes = { + bordered: PropTypes.bool, + multiselectable: PropTypes.bool, + items: PropTypes.arrayOf(PropTypes.shape(AccordionItem)).isRequired, + headingSize: PropTypes.number, +}; + +Accordion.defaultProps = { + bordered: false, + multiselectable: false, + headingSize: 2, +}; + +export default Accordion; diff --git a/frontend/src/components/ActivityReportsTable/ColumnHeader.js b/frontend/src/components/ActivityReportsTable/ColumnHeader.js new file mode 100644 index 0000000000..d833b53762 --- /dev/null +++ b/frontend/src/components/ActivityReportsTable/ColumnHeader.js @@ -0,0 +1,47 @@ +/* eslint-disable jsx-a11y/anchor-is-valid */ +import React from 'react'; +import PropTypes from 'prop-types'; + +function ColumnHeader({ + displayName, name, sortBy, sortDirection, onUpdateSort, +}) { + const getClassNamesFor = (n) => (sortBy === n ? sortDirection : ''); + const sortClassName = getClassNamesFor(name); + let fullAriaSort; + switch (sortClassName) { + case 'asc': + fullAriaSort = 'ascending'; + break; + case 'desc': + fullAriaSort = 'descending'; + break; + default: + fullAriaSort = 'none'; + break; + } + + return ( + + onUpdateSort(name)} + onKeyPress={() => onUpdateSort(name)} + className={`sortable ${sortClassName}`} + aria-label={`${displayName}. Activate to sort ${sortClassName === 'asc' ? 'descending' : 'ascending'}`} + > + {displayName} + + + ); +} + +ColumnHeader.propTypes = { + displayName: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + sortBy: PropTypes.string.isRequired, + sortDirection: PropTypes.string.isRequired, + onUpdateSort: PropTypes.func.isRequired, +}; + +export default ColumnHeader; diff --git a/frontend/src/components/ActivityReportsTable/ReportRow.js b/frontend/src/components/ActivityReportsTable/ReportRow.js new file mode 100644 index 0000000000..e5412280e0 --- /dev/null +++ b/frontend/src/components/ActivityReportsTable/ReportRow.js @@ -0,0 +1,149 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +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, +}) { + const { + id, + displayId, + activityRecipients, + startDate, + author, + topics, + collaborators, + lastSaved, + calculatedStatus, + approvedAt, + createdAt, + legacyId, + } = report; + + const history = useHistory(); + const authorName = author ? author.fullName : ''; + const recipients = activityRecipients && activityRecipients.map((ar) => ( + ar.grant ? ar.grant.grantee.name : ar.name + )); + + const collaboratorNames = collaborators && collaborators.map((collaborator) => ( + collaborator.fullName)); + + const viewOrEditLink = calculatedStatus === 'approved' ? `/activity-reports/view/${id}` : `/activity-reports/${id}`; + const linkTarget = legacyId ? `/activity-reports/legacy/${legacyId}` : viewOrEditLink; + + const menuItems = [ + { + label: 'View', + onClick: () => { history.push(linkTarget); }, + }, + ]; + + if (navigator.clipboard) { + menuItems.push({ + label: 'Copy URL', + onClick: async () => { + await navigator.clipboard.writeText(`${window.location.origin}${linkTarget}`); + }, + }); + } + + if (!legacyId) { + const downloadMenuItem = { + label: 'Download', + onClick: () => { + const downloadURL = getReportsDownloadURL([id]); + window.location.assign(downloadURL); + }, + }; + menuItems.push(downloadMenuItem); + } + + const contextMenuLabel = `Actions for activity report ${displayId}`; + + const selectId = `report-${id}`; + + return ( + + + + + + + {displayId} + + + + + + {startDate} + + + + {moment(createdAt).format(DATE_DISPLAY_FORMAT)} + + + + + + + {lastSaved} + {approvedAt && moment(approvedAt).format(DATE_DISPLAY_FORMAT)} + + + + + ); +} + +export const reportPropTypes = { + report: PropTypes.shape({ + id: PropTypes.number.isRequired, + displayId: PropTypes.string.isRequired, + activityRecipients: PropTypes.arrayOf(PropTypes.shape({ + name: PropTypes.string, + grant: PropTypes.shape({ + grantee: PropTypes.shape({ + name: PropTypes.string, + }), + }), + })).isRequired, + approvedAt: PropTypes.string, + createdAt: PropTypes.string, + startDate: PropTypes.string.isRequired, + author: PropTypes.shape({ + fullName: PropTypes.string, + homeRegionId: PropTypes.number, + name: PropTypes.string, + }).isRequired, + topics: 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, + }).isRequired, + openMenuUp: PropTypes.bool.isRequired, + handleReportSelect: PropTypes.func.isRequired, + isChecked: PropTypes.bool.isRequired, +}; + +ReportRow.propTypes = reportPropTypes; + +export default ReportRow; diff --git a/frontend/src/components/ActivityReportsTable/__tests__/ColumnHeader.js b/frontend/src/components/ActivityReportsTable/__tests__/ColumnHeader.js new file mode 100644 index 0000000000..001d729068 --- /dev/null +++ b/frontend/src/components/ActivityReportsTable/__tests__/ColumnHeader.js @@ -0,0 +1,58 @@ +import '@testing-library/jest-dom'; +import React from 'react'; +import { + render, screen, act, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import ColumnHeader from '../ColumnHeader'; + +describe('ActivityReportsTable ColumnHeader', () => { + const renderColumnHeader = (onUpdateSort = jest.fn(), sortDirection = 'asc') => { + const name = 'fanciest shoes'; + render( +
+ + + + + + +
+
, + ); + }; + + it('renders and calls on update sort', async () => { + const onUpdateSort = jest.fn(); + renderColumnHeader(onUpdateSort); + + const shoes = await screen.findByText('fanciest shoes'); + + await act(async () => userEvent.click(shoes)); + expect(onUpdateSort).toHaveBeenCalledWith('shoes'); + }); + + it('sorts on keypress', async () => { + const onUpdateSort = jest.fn(); + renderColumnHeader(onUpdateSort, 'desc'); + + const shoes = await screen.findByText('fanciest shoes'); + + await act(async () => userEvent.type(shoes, '{enter}')); + expect(onUpdateSort).toHaveBeenCalledTimes(2); + }); + + it('displays an unsortable column', async () => { + const onUpdateSort = jest.fn(); + renderColumnHeader(onUpdateSort, ''); + + const shoes = await screen.findByRole('columnheader', { name: /fanciest shoes/i }); + expect(shoes).toHaveAttribute('aria-sort', 'none'); + }); +}); diff --git a/frontend/src/components/ActivityReportsTable/__tests__/ReportRow.js b/frontend/src/components/ActivityReportsTable/__tests__/ReportRow.js new file mode 100644 index 0000000000..3ce66f9b69 --- /dev/null +++ b/frontend/src/components/ActivityReportsTable/__tests__/ReportRow.js @@ -0,0 +1,55 @@ +import '@testing-library/jest-dom'; +import React from 'react'; +import { + render, screen, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Router } from 'react-router'; +import { createMemoryHistory } from 'history'; +import ReportRow from '../ReportRow'; +import { generateXFakeReports } from '../mocks'; + +const history = createMemoryHistory(); + +const [report] = generateXFakeReports(1); + +describe('ReportRow', () => { + const renderReportRow = () => ( + render( + + + , + ) + ); + + beforeAll(async () => { + global.navigator.clipboard = jest.fn(); + global.navigator.clipboard.writeText = jest.fn(() => Promise.resolve()); + }); + + afterAll(() => { + delete global.navigator; + }); + + it('the view link works', async () => { + history.push = jest.fn(); + renderReportRow(); + userEvent.click(await screen.findByRole('button', { name: 'Actions for activity report R14-AR-1' })); + userEvent.click(await screen.findByRole('button', { name: /view/i })); + + expect(history.push).toHaveBeenCalled(); + }); + + it('you can copy', async () => { + renderReportRow(); + userEvent.click(await screen.findByRole('button', { name: 'Actions for activity report R14-AR-1' })); + userEvent.click(await screen.findByRole('button', { name: /copy url/i })); + + expect(navigator.clipboard.writeText).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/ActivityReportsTable/__tests__/index.js b/frontend/src/components/ActivityReportsTable/__tests__/index.js new file mode 100644 index 0000000000..73927fa702 --- /dev/null +++ b/frontend/src/components/ActivityReportsTable/__tests__/index.js @@ -0,0 +1,481 @@ +import '@testing-library/jest-dom'; +import React from 'react'; +import { + render, screen, fireEvent, waitFor, act, +} from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import fetchMock from 'fetch-mock'; + +import userEvent from '@testing-library/user-event'; +import UserContext from '../../../UserContext'; +import AriaLiveContext from '../../../AriaLiveContext'; +import ActivityReportsTable from '../index'; +import activityReports, { activityReportsSorted, generateXFakeReports } from '../mocks'; +import { getReportsDownloadURL, getAllReportsDownloadURL } from '../../../fetchers/helpers'; + +jest.mock('../../../fetchers/helpers'); + +const oldWindowLocation = window.location; + +const mockAnnounce = jest.fn(); + +const withRegionOne = '®ion.in[]=1'; +const base = '/api/activity-reports?sortBy=updatedAt&sortDir=desc&offset=0&limit=10'; +const defaultBaseUrlWithRegionOne = `${base}${withRegionOne}`; + +const mockFetchWithRegionOne = () => { + fetchMock.get(defaultBaseUrlWithRegionOne, { count: 2, rows: activityReports }); +}; + +const renderTable = (user) => { + render( + + + + {}} + tableCaption="Activity Reports" + /> + + + , + ); +}; + +describe('Table menus & selections', () => { + describe('Table row context menu', () => { + beforeAll(() => { + delete global.window.location; + + global.window.location = { + ...oldWindowLocation, + assign: jest.fn(), + }; + }); + + beforeEach(async () => { + fetchMock.reset(); + fetchMock.get( + defaultBaseUrlWithRegionOne, + { count: 10, rows: generateXFakeReports(10, ['approved']) }, + ); + const user = { + name: 'test@test.com', + homeRegionId: 1, + permissions: [ + { + scopeId: 3, + regionId: 1, + }, + { + scopeId: 2, + regionId: 1, + }, + ], + }; + + renderTable(user); + await screen.findByText('Activity Reports'); + }); + + afterEach(() => { + window.location.assign.mockReset(); + getReportsDownloadURL.mockClear(); + fetchMock.restore(); + }); + + afterAll(() => { + window.location = oldWindowLocation; + }); + + it('can trigger an activity report download', async () => { + const contextMenus = await screen.findAllByRole('button', { name: /actions for activity report /i }); + + await waitFor(() => { + expect(contextMenus.length).not.toBe(0); + }); + + const menu = contextMenus[0]; + + await waitFor(() => { + expect(menu).toBeVisible(); + }); + + fireEvent.click(menu); + + const viewButton = await screen.findByRole('button', { name: 'Download' }); + + await waitFor(() => { + expect(viewButton).toBeVisible(); + }); + + fireEvent.click(viewButton); + + await waitFor(() => { + expect(window.location.assign).toHaveBeenCalled(); + }); + }); + }); + + describe('Table row checkboxes', () => { + afterEach(() => fetchMock.restore()); + + beforeEach(async () => { + fetchMock.reset(); + fetchMock.get( + defaultBaseUrlWithRegionOne, + { count: 10, rows: generateXFakeReports(10) }, + ); + const user = { + name: 'test@test.com', + homeRegionId: 14, + permissions: [ + { + scopeId: 3, + regionId: 1, + }, + ], + }; + + renderTable(user); + await screen.findByText('Activity Reports'); + }); + + it('Can select and deselect a single checkbox', async () => { + const reportCheckboxes = await screen.findAllByRole('checkbox', { name: /select /i }); + + // Element 0 is 'Select all', so we want 1 or later + const singleReportCheck = reportCheckboxes[1]; + expect(singleReportCheck.value).toEqual('1'); + + fireEvent.click(singleReportCheck); + expect(singleReportCheck.checked).toBe(true); + + fireEvent.click(singleReportCheck); + expect(singleReportCheck.checked).toBe(false); + }); + }); + + describe('Table header checkbox', () => { + afterEach(() => fetchMock.restore()); + + beforeEach(async () => { + fetchMock.reset(); + fetchMock.get( + defaultBaseUrlWithRegionOne, + { count: 10, rows: generateXFakeReports(10) }, + ); + const user = { + name: 'test@test.com', + homeRegionId: 14, + permissions: [ + { + scopeId: 3, + regionId: 1, + }, + ], + }; + + renderTable(user); + await screen.findByText('Activity Reports'); + }); + + it('Selects all reports when checked', async () => { + const selectAllCheckbox = await screen.findByLabelText(/select or de-select all reports/i); + + fireEvent.click(selectAllCheckbox); + + await waitFor(() => { + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes).toHaveLength(11); // 1 selectAllCheckbox + 10 report checkboxes + checkboxes.forEach((c) => expect(c).toBeChecked()); + }); + }); + + it('De-selects all reports if all are already selected', async () => { + const selectAllCheckbox = await screen.findByLabelText(/select or de-select all reports/i); + + fireEvent.click(selectAllCheckbox); + + await waitFor(() => { + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes).toHaveLength(11); // 1 selectAllCheckbox + 10 report checkboxes + checkboxes.forEach((c) => expect(c).toBeChecked()); + }); + + fireEvent.click(selectAllCheckbox); + + await waitFor(() => { + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes).toHaveLength(11); // 1 selectAllCheckbox + 10 report checkboxes + checkboxes.forEach((c) => expect(c).not.toBeChecked()); + }); + }); + }); + + describe('Selected count badge', () => { + it('can de-select all reports', async () => { + fetchMock.reset(); + fetchMock.get( + defaultBaseUrlWithRegionOne, + { count: 10, rows: generateXFakeReports(10) }, + ); + const user = { + name: 'test@test.com', + homeRegionId: 14, + permissions: [ + { + scopeId: 3, + regionId: 1, + }, + ], + }; + + renderTable(user); + await screen.findByText('Activity Reports'); + const selectAllCheckbox = await screen.findByLabelText(/select or de-select all reports/i); + userEvent.click(selectAllCheckbox); + await waitFor(() => { + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes).toHaveLength(11); // 1 selectAllCheckbox + 10 report checkboxes + checkboxes.forEach((c) => expect(c).toBeChecked()); + }); + + const deselect = await screen.findByRole('button', { name: 'deselect all reports' }); + fireEvent.click(deselect); + await waitFor(() => { + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes).toHaveLength(11); // 1 selectAllCheckbox + 10 report checkboxes + checkboxes.forEach((c) => expect(c).not.toBeChecked()); + }); + }); + }); + + describe('download all reports button', () => { + afterAll(() => { + getAllReportsDownloadURL.mockClear(); + }); + + beforeAll(async () => { + fetchMock.reset(); + fetchMock.get( + defaultBaseUrlWithRegionOne, + { count: 10, rows: [] }, + ); + }); + + it('downloads all reports', async () => { + const user = { + name: 'test@test.com', + permissions: [ + { + scopeId: 3, + regionId: 1, + }, + ], + }; + + renderTable(user); + const reportMenu = await screen.findByLabelText(/reports menu/i); + userEvent.click(reportMenu); + const downloadButton = await screen.findByRole('menuitem', { name: /export table data/i }); + userEvent.click(downloadButton); + expect(getAllReportsDownloadURL).toHaveBeenCalledWith('region.in[]=1'); + }); + }); +}); + +describe('Table sorting', () => { + afterEach(() => fetchMock.restore()); + + beforeEach(async () => { + fetchMock.reset(); + mockFetchWithRegionOne(); + const user = { + name: 'test@test.com', + homeRegionId: 1, + permissions: [ + { + scopeId: 3, + regionId: 1, + }, + { + scopeId: 2, + regionId: 1, + }, + ], + }; + + renderTable(user); + await screen.findByText('Activity Reports'); + }); + + 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®ion.in[]=1', + { count: 2, rows: activityReportsSorted }, + ); + + fireEvent.click(columnHeader); + 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 () => { + const columnHeader = await screen.findByText(/collaborator\(s\)/i); + + fetchMock.get( + '/api/activity-reports?sortBy=collaborators&sortDir=asc&offset=0&limit=10®ion.in[]=1', + { count: 2, rows: activityReportsSorted }, + ); + + await act(async () => fireEvent.click(columnHeader)); + 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 () => { + const columnHeader = await screen.findByText(/topic\(s\)/i); + + fetchMock.get( + '/api/activity-reports?sortBy=topics&sortDir=asc&offset=0&limit=10®ion.in[]=1', + { count: 2, rows: activityReportsSorted }, + ); + + await act(async () => fireEvent.click(columnHeader)); + 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 () => { + const columnHeader = await screen.findByText(/creator/i); + + fetchMock.get( + '/api/activity-reports?sortBy=author&sortDir=asc&offset=0&limit=10®ion.in[]=1', + { count: 2, rows: activityReportsSorted }, + ); + + fireEvent.click(columnHeader); + await waitFor(() => expect(screen.getAllByRole('cell')[3]).toHaveTextContent('Kiwi, GS')); + await waitFor(() => expect(screen.getAllByRole('cell')[13]).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®ion.in[]=1', + { count: 2, rows: activityReportsSorted }, + ); + + fireEvent.click(columnHeader); + await waitFor(() => expect(screen.getAllByRole('cell')[2]).toHaveTextContent('02/01/2021')); + await waitFor(() => expect(screen.getAllByRole('cell')[12]).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®ion.in[]=1', + { count: 2, rows: activityReportsSorted }, + ); + + fireEvent.click(columnHeader); + await waitFor(() => expect(screen.getAllByRole('cell')[1]).toHaveTextContent('Johnston-Romaguera Johnston-Romaguera Grantee 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®ion.in[]=1', + { count: 2, rows: activityReportsSorted }, + ); + + expect((await screen.findAllByRole('link'))[3]).toHaveTextContent('R14-AR-1'); + expect((await screen.findAllByRole('link'))[4]).toHaveTextContent('R14-AR-2'); + fireEvent.click(columnHeader); + + await waitFor(() => screen.queryByText('Loading Data') === null); + expect((await screen.findAllByRole('link'))[3]).toHaveTextContent('R14-AR-2'); + expect((await screen.findAllByRole('link'))[4]).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( + defaultBaseUrlWithRegionOne, + { count: 2, rows: activityReportsSorted }, + ); + + fireEvent.click(pageOne); + 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 () => { + expect(generateXFakeReports(10).length).toBe(10); + await screen.findByRole('link', { + name: /go to page number 1/i, + }); + fetchMock.reset(); + fetchMock.get( + defaultBaseUrlWithRegionOne, + { count: 17, rows: generateXFakeReports(10) }, + ); + const user = { + name: 'test@test.com', + homeRegionId: 14, + permissions: [ + { + scopeId: 3, + regionId: 1, + }, + ], + }; + + renderTable(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®ion.in[]=1', + { count: 17, rows: generateXFakeReports(10) }, + ); + + fireEvent.click(pageTwo); + await waitFor(() => expect(screen.getByText(/11-17 of 17/i)).toBeVisible()); + }); +}); diff --git a/frontend/src/components/ActivityReportsTable/index.css b/frontend/src/components/ActivityReportsTable/index.css new file mode 100644 index 0000000000..0d413f937a --- /dev/null +++ b/frontend/src/components/ActivityReportsTable/index.css @@ -0,0 +1,5 @@ +.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 new file mode 100644 index 0000000000..d23f3c94d9 --- /dev/null +++ b/frontend/src/components/ActivityReportsTable/index.js @@ -0,0 +1,346 @@ +/* eslint-disable jsx-a11y/anchor-is-valid */ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { + Table, Button, Checkbox, Grid, Alert, +} from '@trussworks/react-uswds'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faTimesCircle } from '@fortawesome/free-solid-svg-icons'; + +import Pagination from 'react-js-pagination'; + +import { getReports } from '../../fetchers/activityReports'; +import { getReportsDownloadURL, getAllReportsDownloadURL } from '../../fetchers/helpers'; +import Container from '../Container'; +import Filter, { filtersToQueryString } from '../Filter'; +import ReportMenu from '../../pages/Landing/ReportMenu'; +import ReportRow from './ReportRow'; +import { REPORTS_PER_PAGE } from '../../Constants'; + +import './index.css'; + +const emptyReport = { + id: 0, + displayId: '', + activityRecipients: [], + startDate: '', + author: {}, + legacyId: '', + topics: [], + collaborators: [], + lastSaved: '', + calculatedStatus: '', +}; + +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 ActivityReportsTable({ + filters, + showFilter, + onUpdateFilters, + tableCaption, +}) { + const [reports, setReports] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [reportCheckboxes, setReportCheckboxes] = useState({}); + const [allReportsChecked, setAllReportsChecked] = useState(false); + const [offset, setOffset] = useState(0); + const [perPage] = useState(REPORTS_PER_PAGE); + const [activePage, setActivePage] = useState(1); + const [reportsCount, setReportsCount] = useState(0); + const [sortConfig, setSortConfig] = React.useState({ + sortBy: 'updatedAt', + direction: 'desc', + }); + + useEffect(() => { + async function fetchReports() { + setLoading(true); + const filterQuery = filtersToQueryString(filters); + try { + const { count, rows } = await getReports( + sortConfig.sortBy, + sortConfig.direction, + offset, + perPage, + filterQuery, + ); + setReports(rows); + setReportsCount(count || 0); + } catch (e) { + // eslint-disable-next-line no-console + console.log(e); + setError('Unable to fetch reports'); + } + setLoading(false); + } + fetchReports(); + }, [sortConfig, offset, perPage, filters]); + + const makeReportCheckboxes = (reportsArr, checked) => ( + reportsArr.reduce((obj, r) => ({ ...obj, [r.id]: checked }), {}) + ); + + // The all-reports checkbox can select/deselect all visible reports + const toggleSelectAll = (event) => { + const { target: { checked = null } = {} } = event; + + if (checked === true) { + setReportCheckboxes(makeReportCheckboxes(reports, true)); + setAllReportsChecked(true); + } else { + setReportCheckboxes(makeReportCheckboxes(reports, false)); + setAllReportsChecked(false); + } + }; + + // When reports are updated, make sure all checkboxes are unchecked + useEffect(() => { + setReportCheckboxes(makeReportCheckboxes(reports, false)); + }, [reports]); + + useEffect(() => { + const checkValues = Object.values(reportCheckboxes); + if (checkValues.every((v) => v === true)) { + setAllReportsChecked(true); + } else if (allReportsChecked === true) { + setAllReportsChecked(false); + } + }, [reportCheckboxes, allReportsChecked]); + + const handleReportSelect = (event) => { + const { target: { checked = null, value = null } = {} } = event; + if (checked === true) { + setReportCheckboxes({ ...reportCheckboxes, [value]: true }); + } else { + setReportCheckboxes({ ...reportCheckboxes, [value]: false }); + } + }; + + const handlePageChange = (pageNumber) => { + if (!loading) { + setActivePage(pageNumber); + setOffset((pageNumber - 1) * perPage); + } + }; + + const requestSort = (sortBy) => { + let direction = 'asc'; + if ( + sortConfig + && sortConfig.sortBy === sortBy + && sortConfig.direction === 'asc' + ) { + direction = 'desc'; + } + setActivePage(1); + setOffset(0); + setSortConfig({ sortBy, direction }); + }; + + const handleDownloadAllReports = () => { + const filterQuery = filtersToQueryString(filters); + const downloadURL = getAllReportsDownloadURL(filterQuery); + window.location.assign(downloadURL); + }; + + const handleDownloadClick = () => { + const toDownloadableReportIds = (accumulator, entry) => { + if (!reports) return accumulator; + const [key, value] = entry; + if (value === false) return accumulator; + accumulator.push(key); + return accumulator; + }; + + const downloadable = Object.entries(reportCheckboxes).reduce(toDownloadableReportIds, []); + if (downloadable.length) { + const downloadURL = getReportsDownloadURL(downloadable); + window.location.assign(downloadURL); + } + }; + + 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={`sortable ${sortClassName}`} + aria-label={`${displayName}. Activate to sort ${sortClassName === 'asc' ? 'descending' : 'ascending' + }`} + > + {displayName} + + + ); + }; + + const displayReports = reports.length ? reports : [emptyReport]; + const numberOfSelectedReports = Object.values(reportCheckboxes).filter((c) => c).length; + + return ( + <> + + {error && ( + + {error} + + )} + + + + {numberOfSelectedReports > 0 + && ( + + {numberOfSelectedReports} + {' '} + selected + {' '} + + + )} + {showFilter && } + 0} + onExportAll={handleDownloadAllReports} + onExportSelected={handleDownloadClick} + count={reportsCount} + /> + + + + + {renderTotal(offset, perPage, activePage, reportsCount)} + + + + +
+ + + + + + {renderColumnHeader('Report ID', 'regionId')} + {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('Approved date', 'approvedAt')} + + + + {displayReports.map((report, index) => ( + displayReports.length - 1} + /> + ))} + +
+ {tableCaption} +

with sorting and pagination

+
+ + +
+
+
+ + ); +} + +ActivityReportsTable.propTypes = { + filters: PropTypes.arrayOf( + PropTypes.shape({ + condition: PropTypes.string, + id: PropTypes.string, + query: PropTypes.string, + topic: PropTypes.string, + }), + ).isRequired, + showFilter: PropTypes.bool.isRequired, + onUpdateFilters: PropTypes.func, + tableCaption: PropTypes.string.isRequired, +}; + +ActivityReportsTable.defaultProps = { + onUpdateFilters: () => {}, +}; + +export default ActivityReportsTable; diff --git a/frontend/src/components/ActivityReportsTable/mocks.js b/frontend/src/components/ActivityReportsTable/mocks.js new file mode 100644 index 0000000000..9b91d954ce --- /dev/null +++ b/frontend/src/components/ActivityReportsTable/mocks.js @@ -0,0 +1,315 @@ +const activityReports = [ + { + startDate: '02/08/2021', + lastSaved: '02/05/2021', + id: 1, + displayId: 'R14-AR-1', + regionId: 14, + topics: ['Behavioral / Mental Health', 'CLASS: Instructional Support'], + calculatedStatus: 'draft', + pendingApprovals: '1 of 3', + approvers: [{ User: { fullName: 'Approver Manager 1' } }, { User: { fullName: 'Approver Manager 2' } }, { User: { fullName: 'Approver Manager 3' } }], + 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', + }, + ], + }, + { + startDate: '02/01/2021', + lastSaved: '02/04/2021', + id: 2, + displayId: 'R14-AR-2', + regionId: 14, + topics: [], + pendingApprovals: '2 of 2', + approvers: [{ User: { fullName: 'Approver Manager 4' } }, { User: { fullName: 'Approver Manager 5' } }], + calculatedStatus: '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', + }, + ], + }, +]; + +export const activityReportsSorted = [ + { + startDate: '02/01/2021', + lastSaved: '02/04/2021', + id: 2, + displayId: 'R14-AR-2', + regionId: 14, + topics: [], + calculatedStatus: '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'], + calculatedStatus: '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, status = []) => { + 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'], + calculatedStatus: status[i] || '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 const overviewRegionOne = { + numReports: '1', + numGrants: '2', + numNonGrantees: '2', + numTotalGrants: '4', + numParticipants: '3', + sumDuration: '0.5', +}; + +export default activityReports; diff --git a/frontend/src/components/AppWrapper.js b/frontend/src/components/AppWrapper.js index 6b3ad86362..2c1a6337b7 100644 --- a/frontend/src/components/AppWrapper.js +++ b/frontend/src/components/AppWrapper.js @@ -50,5 +50,5 @@ AppWrapper.propTypes = { AppWrapper.defaultProps = { authenticated: false, padded: true, - logout: () => null, + logout: () => {}, }; diff --git a/frontend/src/components/ButtonSelect.css b/frontend/src/components/ButtonSelect.css index 0af04d203b..8a154cfd2f 100644 --- a/frontend/src/components/ButtonSelect.css +++ b/frontend/src/components/ButtonSelect.css @@ -1,20 +1,3 @@ -.smart-hub--button-select-toggle-btn { - justify-content: space-between; - min-width: 140px; -} - -.smart-hub--button-select-menu { - background-color: hsl(0, 0%, 100%); - border-radius: 4px; - box-shadow: 0 0 0 1px hsla(0, 0%, 0%, 0.1),0 4px 11px hsla(0, 0%, 0%, 0.1); - margin-bottom: 8px; - margin-top: 8px; - position: absolute; - min-width: 200px; - z-index: 2; - box-sizing: border-box; -} - .smart-hub--button-select-menu-label:not(.sr-only) { display: block; margin-top: 1em; @@ -76,7 +59,6 @@ * The styles for the "style as select" prop */ .smart-hub--button-select-toggle-btn.usa-select { - border-radius: 5px; border: 1px solid black; box-shadow: none; color: black; diff --git a/frontend/src/components/ButtonSelect.js b/frontend/src/components/ButtonSelect.js index 8ba2d14bab..d0a96e69ab 100644 --- a/frontend/src/components/ButtonSelect.js +++ b/frontend/src/components/ButtonSelect.js @@ -1,15 +1,16 @@ import React, { useState, useEffect } from 'react'; import PropTypes from 'prop-types'; -import { - SingleDatePicker, isInclusivelyBeforeDay, -} from 'react-dates'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faCalendar, faCheck } from '@fortawesome/free-solid-svg-icons'; -import { Button } from '@trussworks/react-uswds'; -import { CUSTOM_DATE_RANGE } from '../pages/RegionalDashboard/constants'; -import { DATE_FMT, EARLIEST_INC_FILTER_DATE } from '../Constants'; +import { faCheck } from '@fortawesome/free-solid-svg-icons'; +import DropdownMenu from './DropdownMenu'; import './ButtonSelect.css'; -import triangleDown from '../images/triange_down.png'; +import './DateRangeSelect.css'; + +/** + * + * @param {*} props + * @returns + */ function ButtonSelect(props) { const { @@ -21,32 +22,12 @@ function ButtonSelect(props) { applied, labelText, ariaName, - hasDateRange, - updateDateRange, - startDatePickerId, - endDatePickerId, - dateRange, disabled, + className, } = props; const [checked, setChecked] = useState(applied); - const [selectedItem, setSelectedItem] = useState(); - const [menuIsOpen, setMenuIsOpen] = useState(false); - const [range, setRange] = useState(); - const [showDateError, setShowDateError] = useState(false); - - const [startDate, setStartDate] = useState(); - const [startDateFocused, setStartDateFocused] = useState(false); - const [endDate, setEndDate] = useState(); - const [endDateFocused, setEndDateFocused] = useState(false); - /** - * just so we always have something selected - * */ - useEffect(() => { - if (!selectedItem && !applied) { - setSelectedItem(initialValue); - } - }, [applied, initialValue, selectedItem]); + const [selectedItem, setSelectedItem] = useState(initialValue); /** * To calculate where the checkmark should go :) @@ -60,230 +41,55 @@ function ButtonSelect(props) { } }, [applied, selectedItem]); - /** - * we store the date range in here so that we can apply it up the chain - * when the calendar control is updated - */ - useEffect(() => { - if (!range) { - setRange(dateRange); - } - - setRange(`${startDate ? startDate.format(DATE_FMT) : ''}-${endDate ? endDate.format(DATE_FMT) : ''}`); - }, [range, dateRange, startDate, endDate]); - - /** when to focus on the start date input */ - useEffect(() => { - if (hasDateRange && selectedItem && selectedItem.value === CUSTOM_DATE_RANGE) { - const input = document.getElementById(startDatePickerId); - if (input) { - input.focus(); - } - } - }, [hasDateRange, selectedItem, startDatePickerId]); - /** * apply the selected item and close the menu * - * if we have a date picker and it's a custom range, also apply the - * new date range */ const onApplyClick = () => { - if (hasDateRange && selectedItem && selectedItem.value === CUSTOM_DATE_RANGE) { - const isValidDateRange = range.trim().split('-').filter((str) => str !== '').length === 2; - - if (!isValidDateRange) { - setShowDateError(true); - return; - } - - updateDateRange(range); - } - onApply(selectedItem); - setMenuIsOpen(false); - }; - - /** - * Close the menu on escape key - * @param {Event} e - */ - const onKeyDown = (e) => { - if (e.keyCode === 27) { - setMenuIsOpen(false); - } - }; - - /** - * Close the menu on blur, with some extenuating circumstance - * - * @param {Event} e - * @returns void - */ - const onBlur = (e) => { - // if we're within the same menu, do nothing - if (e.relatedTarget && e.relatedTarget.matches('.smart-hub--button-select-menu *')) { - return; - } - - // if we've a date range, also do nothing on blur when we click on those - if (e.target.matches('.CalendarDay, .DayPickerNavigation, .DayPickerNavigation_button')) { - return; - } - - setMenuIsOpen(false); - setShowDateError(false); - }; - - /** - * @param {Object} day moment object - * @param {String} startOrEnd either "start" or "end" - * returns bool - */ - - // eslint-disable-next-line no-unused-vars - const isOutsideRange = (day, startOrEnd) => { - if (startOrEnd === 'start') { - return isInclusivelyBeforeDay(day, EARLIEST_INC_FILTER_DATE) - || (endDate && day.isAfter(endDate)); - } - - return isInclusivelyBeforeDay(day, EARLIEST_INC_FILTER_DATE) - || (startDate && day.isBefore(startDate)); }; // get label text const label = options.find((option) => option.value === applied); - const buttonClasses = styleAsSelect ? 'usa-select' : 'usa-button'; - - const ariaLabel = `${menuIsOpen ? 'press escape to close ' : 'Open '} ${ariaName}`; + const ariaLabel = `toggle ${ariaName}`; return ( -
- - - { menuIsOpen && !disabled - ? ( -
- {labelText} -
- { options.map((option) => ( - - ))} - - { hasDateRange && selectedItem && selectedItem.value === CUSTOM_DATE_RANGE - ? ( -
- { showDateError ? ( -
-

- Reports are available from 09/01/2020. -
- Use the format MM/DD/YYYY. -

-
- ) : null } - {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} - -

mm/dd/yyyy

-
- isOutsideRange(day, 'start')} - onFocusChange={({ focused }) => { - if (!focused) { - setStartDateFocused(focused); - } - }} - onDateChange={(selectedDate) => { - setStartDate(selectedDate); - setStartDateFocused(false); - }} - date={startDate} - /> - -
- {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} - -

mm/dd/yyyy

-
- isOutsideRange(day, 'END')} - onFocusChange={({ focused }) => { - if (!focused) { - setEndDateFocused(focused); - } - }} - onDateChange={(selectedDate) => { - setEndDate(selectedDate); - setEndDateFocused(false); - }} - date={endDate} - /> - -
-
- ) : null } -
- -
- ) - : null } - -
- + +
+ + {labelText} + +
+ { options.map((option) => ( + + ))} +
+ +
+
); } @@ -301,26 +107,16 @@ ButtonSelect.propTypes = { applied: PropTypes.number.isRequired, ariaName: PropTypes.string.isRequired, disabled: PropTypes.bool, + className: PropTypes.string, // style as a select box styleAsSelect: PropTypes.bool, - - // props for handling the date range select - hasDateRange: PropTypes.bool, - updateDateRange: PropTypes.func, - dateRange: PropTypes.string, - startDatePickerId: PropTypes.string, - endDatePickerId: PropTypes.string, }; ButtonSelect.defaultProps = { styleAsSelect: false, - hasDateRange: false, - updateDateRange: () => {}, - dateRange: '', - startDatePickerId: '', - endDatePickerId: '', disabled: false, + className: '', }; export default ButtonSelect; diff --git a/frontend/src/components/CheckboxSelect.css b/frontend/src/components/CheckboxSelect.css index eca1a0649f..dff66d56b9 100644 --- a/frontend/src/components/CheckboxSelect.css +++ b/frontend/src/components/CheckboxSelect.css @@ -1,13 +1,8 @@ -.smart-hub--button-select-toggle-btn.usa-select.smart-hub--checkbox-select { +.smart-hub--checkbox-select span{ max-width: 180px; overflow:hidden; - padding-right: 1.5em; -} - -.smart-hub--checkbox-select span{ - overflow:hidden; - text-overflow: ellipsis; white-space: nowrap; + text-overflow: ellipsis; } .smart-hub--checkbox-select-toggle-all:after { @@ -17,4 +12,8 @@ height: 1px; margin: 1em auto; width: 100%; +} + +.smart-hub--checkbox-select label { + margin-top: 0.75em; } \ No newline at end of file diff --git a/frontend/src/components/CheckboxSelect.js b/frontend/src/components/CheckboxSelect.js index 28d83f00b3..121d68f769 100644 --- a/frontend/src/components/CheckboxSelect.js +++ b/frontend/src/components/CheckboxSelect.js @@ -1,10 +1,16 @@ -import React, { useState } from 'react'; +import React, { useState, createRef } from 'react'; import PropTypes from 'prop-types'; import { Checkbox } from '@trussworks/react-uswds'; -import triangleDown from '../images/triange_down.png'; import './CheckboxSelect.css'; - -export function renderCheckboxes(options, checkboxes, prefix, handleCheckboxSelect, onBlur) { +import DropdownMenu from './DropdownMenu'; + +export function renderCheckboxes( + options, + checkboxes, + prefix, + handleCheckboxSelect, + onBlur, +) { return options.map((option) => { const { label, value } = option; const selectId = `${prefix}-${value}`; @@ -39,12 +45,14 @@ export default function CheckboxSelect(props) { styleAsSelect, labelText, ariaName, + disabled, + hideToggleAll, } = props; const [toggleAllChecked, setToggleAllChecked] = useState(toggleAllInitial); const [checkboxes, setCheckboxes] = useState(makeCheckboxes(options, toggleAllChecked)); - const [menuIsOpen, setMenuIsOpen] = useState(false); - const [preventBlur, setPreventBlur] = useState(false); + + const menu = createRef(); // The all-reports checkbox can select/deselect all const toggleSelectAll = (event) => { @@ -77,85 +85,59 @@ export default function CheckboxSelect(props) { const onApplyClick = () => { const checked = Object.keys(checkboxes).filter((checkbox) => checkboxes[checkbox]); onApply(checked); - setMenuIsOpen(false); - }; - - /** - * Close the menu on escape key - * @param {Event} e - */ - const onKeyDown = (e) => { - if (e.keyCode === 27) { - setMenuIsOpen(false); - } }; - /** - * Close the menu on blur, with some extenuating circumstance - * - * @param {Event} e - * @returns - */ - const onBlur = () => { - if (preventBlur) { - return; + const canBlur = (e) => { + if (e.relatedTarget === menu.current) { + return false; } - - setMenuIsOpen(false); + return true; }; - function onMouseHandler(e) { - setPreventBlur(e.type === 'mouseenter'); - } - - function onFocusHandler() { - setPreventBlur(true); - } - const label = toggleAllChecked ? toggleAllText : `${options.filter((option) => checkboxes[option.value]).map((option) => option.label).join(', ')}`; // html id for toggle all const toggleAllHtmlId = `${labelId}-toggle-all`; - const buttonClasses = styleAsSelect ? 'usa-select' : 'usa-button'; - const ariaLabel = `${menuIsOpen ? 'press escape to close' : 'Open'} the ${ariaName}`; + const ariaLabel = `toggle the ${ariaName}`; return ( -
- - { menuIsOpen - ? ( -
- {labelText} -
-
- - -
- {renderCheckboxes(options, checkboxes, labelId, handleCheckboxSelect, onBlur)} -
- + +
+ {labelText} +
+ {!hideToggleAll && ( +
+ +
- ) - : null } - -
- + )} + {renderCheckboxes( + options, + checkboxes, + labelId, + handleCheckboxSelect, + )} + +
+ ); } @@ -172,12 +154,15 @@ CheckboxSelect.propTypes = { labelText: PropTypes.string.isRequired, onApply: PropTypes.func.isRequired, ariaName: PropTypes.string.isRequired, + disabled: PropTypes.bool, + hideToggleAll: PropTypes.bool, // style as a select box styleAsSelect: PropTypes.bool, - }; CheckboxSelect.defaultProps = { + disabled: false, styleAsSelect: false, + hideToggleAll: false, }; 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/pages/RegionalDashboard/components/DateRangeSelect.css b/frontend/src/components/DateRangeSelect.css similarity index 66% rename from frontend/src/pages/RegionalDashboard/components/DateRangeSelect.css rename to frontend/src/components/DateRangeSelect.css index 5ae842a902..66b1721324 100644 --- a/frontend/src/pages/RegionalDashboard/components/DateRangeSelect.css +++ b/frontend/src/components/DateRangeSelect.css @@ -1,3 +1,28 @@ +/** +** Date range picker styles +*/ + +.smart-hub--button-select-menu-date-picker { + border-top: 1px solid #eceef1; + padding: 0 1rem; +} + +.smart-hub--button-select-menu-date-picker-single { + display: flex; +} + +.smart-hub--button-select-menu-date-picker-single .SingleDatePicker{ + flex: 2; +} +.smart-hub--button-select-menu-date-picker-single .SingleDatePickerInput { + display: block; +} + +.smart-hub--button-select-menu .DateInput { + width: 100%; +} + + .smart-hub--date-range-select-toggle-btn { justify-content: space-between; min-width: 200px; diff --git a/frontend/src/components/DateRangeSelect.js b/frontend/src/components/DateRangeSelect.js new file mode 100644 index 0000000000..c7a7e169ad --- /dev/null +++ b/frontend/src/components/DateRangeSelect.js @@ -0,0 +1,345 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import moment from 'moment'; +import { + SingleDatePicker, isInclusivelyBeforeDay, +} from 'react-dates'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faCalendar, faCheck } from '@fortawesome/free-solid-svg-icons'; +import { Button } from '@trussworks/react-uswds'; +import DropdownMenu from './DropdownMenu'; +import { DATE_FORMAT } from './constants'; +import { DATE_FMT as DATETIME_DATE_FORMAT, EARLIEST_INC_FILTER_DATE } from '../Constants'; + +// I think we need 'em both +import './ButtonSelect.css'; +import './DateRangeSelect.css'; + +/** + * This function accepts a configuration object, the keys of which are all optional + * + * if either of these are true, the function will return the date string for that automatically + * lastThirtyDays + * yearToDate + * + * (Logically, if they are both true, that doesn't make sense, + * but last thirty days will be returned) + * + * withSpaces - Should there be spaces in between the two dates and the seperator + * + * sep - what character or string should seperate the two dates + * + * forDateTime: returns the string in DATETIME_DATE_FORMAT, otherwise DATE_FORMAT is used + * + * string - the string to be parsed to return a formatted date + * It's expected to be in DATETIME_DATE_FORMAT + * + * @param {Object} format + * @returns a date string + */ +export function formatDateRange(format = { + lastThirtyDays: false, + yearToDate: false, + withSpaces: false, + forDateTime: false, + sep: '-', + string: '', +}) { + const selectedFormat = format.forDateTime ? DATETIME_DATE_FORMAT : DATE_FORMAT; + + let { sep } = format; + + if (!format.sep) { + sep = '-'; + } + + let firstDay; + let secondDay; + + if (format.lastThirtyDays) { + secondDay = moment(); + firstDay = moment().subtract(30, 'days'); + } + + if (format.yearToDate) { + secondDay = moment(); + firstDay = moment().startOf('year'); + } + + if (format.string) { + const dates = format.string.split('-'); + + if (dates && dates.length > 1) { + firstDay = moment(dates[0], DATETIME_DATE_FORMAT); + secondDay = moment(dates[1], DATETIME_DATE_FORMAT); + } + } + + if (firstDay && secondDay) { + if (format.withSpaces) { + return `${firstDay.format(selectedFormat)} ${sep} ${secondDay.format(selectedFormat)}`; + } + + return `${firstDay.format(selectedFormat)}${sep}${secondDay.format(selectedFormat)}`; + } + + return ''; +} + +const OPTIONS = [ + { + label: 'Last 30 Days', + value: 1, + range: formatDateRange({ lastThirtyDays: true, forDateTime: true }), + }, + { + label: 'Custom Date Range', + value: 2, + range: '', + }, +]; + +const CUSTOM_DATE_RANGE = OPTIONS[1].value; + +/** + * + * @param {*} props + * @returns JSX object + */ + +function DateRangeSelect(props) { + const { + options, + styleAsSelect, + updateDateRange, + disabled, + } = props; + + const [selectedItem, setSelectedItem] = useState(1); + const [showDateError, setShowDateError] = useState(false); + + // to handle the internal range + const [startDate, setStartDate] = useState(); + const [startDateFocused, setStartDateFocused] = useState(false); + const [endDate, setEndDate] = useState(); + const [endDateFocused, setEndDateFocused] = useState(false); + + const startDatePickerId = 'startDatePicker'; + + /** when to focus on the start date input */ + useEffect(() => { + if (selectedItem && selectedItem === CUSTOM_DATE_RANGE) { + const input = document.getElementById(startDatePickerId); + if (input) { + input.focus(); + } + } + }, [selectedItem, startDatePickerId]); + + /** + * apply the selected item and close the menu + * + */ + const onApplyClick = () => { + if (selectedItem && selectedItem === CUSTOM_DATE_RANGE) { + const range = `${startDate ? startDate.format(DATETIME_DATE_FORMAT) : ''}-${endDate ? endDate.format(DATETIME_DATE_FORMAT) : ''}`; + const isValidDateRange = range.trim().split('-').filter((str) => str !== '').length === 2; + + if (!isValidDateRange) { + setShowDateError(true); + return; + } + + updateDateRange(range); + } else if (selectedItem) { + const option = options.find((o) => selectedItem === o.value); + if (option) { + const { range } = option; + updateDateRange(range); + } + } + }; + + /** + * Close the menu on blur, with some extenuating circumstance + * + * @param {Event} e + * @returns bool, whether or not to blur + */ + const canBlur = (e) => { + // if we're within the same menu, do nothing + if (e.relatedTarget && e.relatedTarget.matches('.smart-hub--button-select-menu *')) { + return false; + } + + // if we've a date range, also do nothing on blur when we click on those + if (e.target.matches('.CalendarDay, .DayPickerNavigation, .DayPickerNavigation_button')) { + return false; + } + + setShowDateError(false); + return true; + }; + + /** + * @param {Object} day moment object + * @param {String} startOrEnd either "start" or "end" + * returns bool + */ + + const isOutsideRange = (day, startOrEnd) => { + if (startOrEnd === 'start') { + return isInclusivelyBeforeDay(day, EARLIEST_INC_FILTER_DATE) + || (endDate && day.isAfter(endDate)); + } + + return isInclusivelyBeforeDay(day, EARLIEST_INC_FILTER_DATE) + || (startDate && day.isBefore(startDate)); + }; + + // get label text + const { label } = options.find((option) => option.value === selectedItem); + const ariaLabel = 'Toggle the date range select menu'; + + return ( + +
+ + Choose activity start date range + +
+ { options.map((option) => ( + + ))} + + { selectedItem && selectedItem === CUSTOM_DATE_RANGE + ? ( +
+ { showDateError ? ( +
+

+ Reports are available from 09/01/2020. +
+ Use the format MM/DD/YYYY. +

+
+ ) : null } + {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + +

mm/dd/yyyy

+
+ isOutsideRange(day, 'start')} + onFocusChange={({ focused }) => { + if (!focused) { + setStartDateFocused(focused); + } + }} + onDateChange={(selectedDate) => { + setStartDate(selectedDate); + setStartDateFocused(false); + }} + date={startDate} + /> + +
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + +

mm/dd/yyyy

+
+ isOutsideRange(day, 'END')} + onFocusChange={({ focused }) => { + if (!focused) { + setEndDateFocused(focused); + } + }} + onDateChange={(selectedDate) => { + setEndDate(selectedDate); + setEndDateFocused(false); + }} + date={endDate} + /> + +
+
+ ) : null } +
+
+
+ + ); +} + +const optionProp = PropTypes.shape({ + value: PropTypes.number, + label: PropTypes.string, + range: PropTypes.string, +}); + +DateRangeSelect.propTypes = { + // basic button select props + options: PropTypes.arrayOf(optionProp), + disabled: PropTypes.bool, + styleAsSelect: PropTypes.bool, + + // props for handling the date range select + updateDateRange: PropTypes.func.isRequired, +}; + +DateRangeSelect.defaultProps = { + styleAsSelect: false, + disabled: false, + options: OPTIONS, +}; + +export default DateRangeSelect; diff --git a/frontend/src/components/DeleteReportModal.css b/frontend/src/components/DeleteReportModal.css deleted file mode 100644 index 75e4094818..0000000000 --- a/frontend/src/components/DeleteReportModal.css +++ /dev/null @@ -1,26 +0,0 @@ -#deleteDialog div { - border-style: none; - padding: 3px 0px 17px 15px; - margin: 7px 0px 0px 3px; - text-align: left; - line-height: 20px; - font-size: 14px; -} - -#deleteDialog h2 { - font-size: 22px; - margin-top: 20px; - margin-bottom: -10px; -} - -#deleteDialog button { - margin-left: 0px; - margin-right: 14px; - padding: 9px 38px 9px 38px; - font-size: medium; - font-weight: 600; -} - -#deleteDialog .usa-button--secondary { - background-color: #D42240; -} diff --git a/frontend/src/components/DeleteReportModal.js b/frontend/src/components/DeleteReportModal.js deleted file mode 100644 index 25682a3851..0000000000 --- a/frontend/src/components/DeleteReportModal.js +++ /dev/null @@ -1,67 +0,0 @@ -import React, { useCallback, useEffect, useRef } from 'react'; -import PropTypes from 'prop-types'; -import { Button, Modal } from '@trussworks/react-uswds'; - -import { ESCAPE_KEY_CODES } from '../Constants'; -import './DeleteReportModal.css'; - -const DeleteModal = ({ - onDelete, onClose, closeModal, -}) => { - const modalRef = useRef(null); - - const onEscape = useCallback((event) => { - if (ESCAPE_KEY_CODES.includes(event.key)) { - closeModal(); - } - }, [closeModal]); - - useEffect(() => { - document.addEventListener('keydown', onEscape, false); - return () => { - document.removeEventListener('keydown', onEscape, false); - }; - }, [onEscape]); - - useEffect(() => { - const button = modalRef.current.querySelector('button'); - if (button) { - button.focus(); - } - }); - - return ( - - ); -}; - -DeleteModal.propTypes = { - onDelete: PropTypes.func.isRequired, - onClose: PropTypes.func.isRequired, - closeModal: PropTypes.func.isRequired, -}; - -export default DeleteModal; diff --git a/frontend/src/components/DropdownMenu.css b/frontend/src/components/DropdownMenu.css new file mode 100644 index 0000000000..9578d52f31 --- /dev/null +++ b/frontend/src/components/DropdownMenu.css @@ -0,0 +1,16 @@ +.smart-hub--dropdown-menu-toggle-btn { + justify-content: space-between; + min-width: 140px; +} + +.smart-hub--dropdown-menu--contents { + background-color: hsl(0, 0%, 100%); + border-radius: 4px; + box-shadow: 0 0 0 1px hsla(0, 0%, 0%, 0.1),0 4px 11px hsla(0, 0%, 0%, 0.1); + margin-bottom: 8px; + margin-top: 8px; + position: absolute; + min-width: 200px; + z-index: 2; + box-sizing: border-box; +} diff --git a/frontend/src/components/DropdownMenu.js b/frontend/src/components/DropdownMenu.js new file mode 100644 index 0000000000..e91896de51 --- /dev/null +++ b/frontend/src/components/DropdownMenu.js @@ -0,0 +1,155 @@ +import React, { useState, useRef } from 'react'; +import PropTypes from 'prop-types'; +import './DropdownMenu.css'; +import triangleDown from '../images/triange_down.png'; + +export default function DropdownMenu({ + buttonText, + buttonAriaLabel, + styleAsSelect, + canBlur, + children, + disabled, + applyButtonText, + applyButtonAria, + onApply, + className, + menuName, + onCancel, + showCancel, + cancelAriaLabel, + forwardedRef, +}) { + const [menuIsOpen, setMenuIsOpen] = useState(false); + const menuContents = useRef(); + + /** + * Close the menu on escape key + * @param {Event} e + */ + const onKeyDown = (e) => { + if (e.keyCode === 27) { + setMenuIsOpen(false); + } + }; + + /** + * Close the menu on blur, with some extenuating circumstance + * + * @param {Event} e + * @returns void + */ + const onBlur = (e) => { + // if we're within the same menu, do nothing + if (e.relatedTarget && menuContents.current.contains(e.relatedTarget)) { + return; + } + + if (canBlur(e)) { + setMenuIsOpen(false); + } + }; + + const onClick = () => { + setMenuIsOpen(!menuIsOpen); + }; + + const onApplyClick = () => { + onApply(); + setMenuIsOpen(false); + }; + + const onCancelClick = () => { + onCancel(); + setMenuIsOpen(false); + }; + + const buttonClasses = styleAsSelect ? 'usa-select' : 'usa-button'; + + // needs position relative for the menu to work properly + const classNames = `${className} smart-hub--dropdown-menu position-relative`; + + // just to make things a little less verbose below + function ApplyButton() { + return ( + + ); + } + return ( +
+ + + +
+ ); +} + +DropdownMenu.propTypes = { + children: PropTypes.node.isRequired, + buttonText: PropTypes.string.isRequired, + buttonAriaLabel: PropTypes.string, + disabled: PropTypes.bool, + styleAsSelect: PropTypes.bool, + canBlur: PropTypes.func, + applyButtonText: PropTypes.string, + applyButtonAria: PropTypes.string, + onApply: PropTypes.func.isRequired, + className: PropTypes.string, + menuName: PropTypes.string.isRequired, + showCancel: PropTypes.bool, + onCancel: PropTypes.func, + cancelAriaLabel: PropTypes.string, + forwardedRef: PropTypes.oneOfType([ + PropTypes.func, + PropTypes.shape({ current: PropTypes.instanceOf(Element) }), + ]), +}; + +DropdownMenu.defaultProps = { + className: 'margin-left-1', + buttonAriaLabel: '', + disabled: false, + styleAsSelect: false, + canBlur: () => true, + applyButtonAria: 'Apply', + applyButtonText: 'Apply', + showCancel: false, + cancelAriaLabel: '', + onCancel: () => {}, + forwardedRef: () => {}, +}; diff --git a/frontend/src/components/ExternalResourceModal.js b/frontend/src/components/ExternalResourceModal.js index 79d68f3f80..c4491aa9e5 100644 --- a/frontend/src/components/ExternalResourceModal.js +++ b/frontend/src/components/ExternalResourceModal.js @@ -1,75 +1,18 @@ -import React, { useCallback, useEffect, useRef } from 'react'; +import React, { useRef } from 'react'; import PropTypes from 'prop-types'; -import { - Button, Modal, Alert, useModal, connectModal, -} from '@trussworks/react-uswds'; - +import { Alert } from '@trussworks/react-uswds'; +import Modal from './Modal'; import { isValidURL, isInternalGovernmentLink } from '../utils'; -const ESCAPE_KEY_CODE = 27; - -const ExternalResourceModal = ({ onOpen, onClose }) => ( - External Resources Disclaimer} - actions={( - <> - - - - - )} - > - - Note: - {' '} - This link is hosted outside of an OHS-led system. - OHS does not have responsibility for external content or - the privacy policies of non-government websites. - - -); - -ExternalResourceModal.propTypes = { - onOpen: PropTypes.func.isRequired, - onClose: PropTypes.func.isRequired, -}; - const ExternalLink = ({ to, children }) => { const modalRef = useRef(null); - const { isOpen, openModal, closeModal } = useModal(); - - const onEscape = useCallback((event) => { - if (event.keyCode === ESCAPE_KEY_CODE) { - closeModal(); - } - }, [closeModal]); - - useEffect(() => { - document.addEventListener('keydown', onEscape, false); - return () => { - document.removeEventListener('keydown', onEscape, false); - }; - }, [onEscape]); - - useEffect(() => { - if (!modalRef.current) return; - - const button = modalRef.current.querySelector('button'); - if (button) { - button.focus(); - } - }); if (!isValidURL(to)) { return to; } - const onClick = () => { - closeModal(); + const openResource = () => { + modalRef.current.toggleModal(false); window.open(to, '_blank'); }; @@ -78,19 +21,28 @@ const ExternalLink = ({ to, children }) => { if (isInternalGovernmentLink(to)) { window.open(to, '_blank'); } else { - openModal(); + modalRef.current.toggleModal(true); } }; - const ConnectModal = connectModal(() => ( - - )); - return ( <> -
- -
+ + + Note: + {' '} + This link is hosted outside of an OHS-led system. + OHS does not have responsibility for external content or + the privacy policies of non-government websites. + + {children} {' '} @@ -103,8 +55,4 @@ ExternalLink.propTypes = { to: PropTypes.string.isRequired, children: PropTypes.node.isRequired, }; - -export { - ExternalResourceModal, - ExternalLink, -}; +export default ExternalLink; diff --git a/frontend/src/components/FileUploader.js b/frontend/src/components/FileUploader.js index 871e7edf16..be4bc32c4c 100644 --- a/frontend/src/components/FileUploader.js +++ b/frontend/src/components/FileUploader.js @@ -5,15 +5,14 @@ // react-dropzone examples all use prop spreading. Disabling the eslint no prop spreading // rules https://github.com/react-dropzone/react-dropzone /* eslint-disable react/jsx-props-no-spreading */ -import React, { useState, useRef, useEffect } from 'react'; +import React, { useState, useRef } from 'react'; import PropTypes from 'prop-types'; import { useDropzone } from 'react-dropzone'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faTrash } from '@fortawesome/free-solid-svg-icons'; -import { - Button, Alert, Modal, connectModal, -} from '@trussworks/react-uswds'; +import { Button, Alert } from '@trussworks/react-uswds'; import { uploadFile, deleteFile } from '../fetchers/File'; +import Modal from './Modal'; import './FileUploader.css'; @@ -111,17 +110,17 @@ function Dropzone(props) { Upload Resources {errorMessage - && ( - - This is an error - - )} + && ( + + This is an error + + )} {fileRejections.length > 0 - && ( - - - - )} + && ( + + + + )}
); } @@ -157,71 +156,65 @@ export const getStatus = (status) => { }; const DeleteFileModal = ({ - onFileRemoved, files, index, closeModal, + modalRef, onFileRemoved, files, index, }) => { - const deleteModal = useRef(null); - const onClose = () => { + const onDeleteFile = () => { onFileRemoved(index) - .then(closeModal()); + .then(modalRef.current.toggleModal(false)); }; - useEffect(() => { - deleteModal.current.querySelector('button').focus(); - }); + return ( -
+ <> Delete File} - actions={( - <> - - - - )} + modalRef={modalRef} + onOk={onDeleteFile} + modalId="DeleteFileModal" + title="Delete File" + okButtonText="Delete" + okButtonAriaLabel="This button will permanently delete the file." >

Are you sure you want to delete {' '} - {files[index].originalFileName} + { files[index] ? files[index].originalFileName : null } {' '} ?

This action cannot be undone.

-
+ ); }; DeleteFileModal.propTypes = { + modalRef: PropTypes.oneOfType([ + PropTypes.func, + PropTypes.shape(), + ]).isRequired, onFileRemoved: PropTypes.func.isRequired, - closeModal: PropTypes.func.isRequired, - index: PropTypes.number.isRequired, + index: PropTypes.number, files: PropTypes.arrayOf(PropTypes.object).isRequired, }; -const ConnectedDeleteFileModal = connectModal(DeleteFileModal); +DeleteFileModal.defaultProps = { + index: null, +}; const FileTable = ({ onFileRemoved, files }) => { const [index, setIndex] = useState(null); - const [isOpen, setIsOpen] = useState(false); - const closeModal = () => setIsOpen(false); - + const modalRef = useRef(); const handleDelete = (newIndex) => { setIndex(newIndex); - setIsOpen(true); + modalRef.current.toggleModal(true); }; return (
- @@ -273,7 +266,7 @@ const FileTable = ({ onFileRemoved, files }) => {
{files.length === 0 && ( -

No files uploaded

+

No files uploaded

)}
); diff --git a/frontend/src/components/IdleModal.js b/frontend/src/components/IdleModal.js index 5c25e0deb6..c49e12c448 100644 --- a/frontend/src/components/IdleModal.js +++ b/frontend/src/components/IdleModal.js @@ -4,12 +4,11 @@ milliseconds the logout prop is called. */ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import PropTypes from 'prop-types'; import { useIdleTimer } from 'react-idle-timer'; -import { - Button, Modal, connectModal, useModal, Alert, -} from '@trussworks/react-uswds'; +import { Alert } from '@trussworks/react-uswds'; +import Modal from './Modal'; // list of events to determine activity // https://github.com/SupremeTechnopriest/react-idle-timer#default-events @@ -28,7 +27,7 @@ const EVENTS = [ function IdleModal({ modalTimeout, logoutTimeout, logoutUser }) { const [inactiveTimeout, updateInactiveTimeout] = useState(); - const { isOpen, openModal, closeModal } = useModal(); + const modalRef = useRef(); const modalVisibleTime = logoutTimeout - modalTimeout; const timeoutMinutes = Math.floor(modalVisibleTime / 1000 / 60); @@ -41,28 +40,6 @@ function IdleModal({ modalTimeout, logoutTimeout, logoutUser }) { timeToLogoutMsg = `${timeoutMinutes} minutes`; } - const Connected = connectModal(() => ( - Are you still there?} - actions={( - - )} - > - - You will be automatically logged out due to inactivity in - {' '} - { timeToLogoutMsg } - {' '} - unless you become active again. - - Press any key to continue your session - - - - )); - // Make sure we clean up any timeout functions when this component // is unmounted useEffect(() => function cleanup() { @@ -73,15 +50,15 @@ function IdleModal({ modalTimeout, logoutTimeout, logoutUser }) { const onIdle = () => { const timer = setTimeout(() => { - closeModal(); + modalRef.current.toggleModal(false); logoutUser(true); }, modalVisibleTime); - openModal(); + modalRef.current.toggleModal(true); updateInactiveTimeout(timer); }; const onActive = () => { - closeModal(); + modalRef.current.toggleModal(false); clearTimeout(inactiveTimeout); }; @@ -94,7 +71,22 @@ function IdleModal({ modalTimeout, logoutTimeout, logoutUser }) { }); return ( - + {}} + modalId="IdleReportModal" + title="Are you still there?" + showOkButton={false} + cancelButtonText="Stay logged in" + > + + You will be automatically logged out due to inactivity in + {' '} + {timeToLogoutMsg} + {' '} + unless you become active again. + + ); } diff --git a/frontend/src/components/Modal.css b/frontend/src/components/Modal.css index 5d33eb0e3e..e7d67cb115 100644 --- a/frontend/src/components/Modal.css +++ b/frontend/src/components/Modal.css @@ -1,19 +1,16 @@ -#popup-modal div { - border-style: none; - padding: 3px 0px 17px 15px; - margin: 7px 0px 0px 3px; - text-align: left; - line-height: 1.2; - font-size: 14px; +.popup-modal h2 { + font-family: 'Source Sans Pro Web', 'Helvetica Neue', Helvetica, 'Roboto, Arial', sans-serif; } -#popup-modal h2 { - font-size: 22px; - margin-top: 20px; - margin-bottom: -10px; +.popup-modal .usa-button--secondary { + margin-left: 8px; } -#popup-modal button { +.popup-modal .usa-modal__main { + margin: 0px; +} + +.popup-modal button { margin-left: 0px; margin-right: 14px; padding: 9px 38px 9px 38px; @@ -21,6 +18,6 @@ font-weight: 600; } -#popup-modal .usa-button--secondary { - background-color: #D42240; -} +.popup-modal:not(.show-close-x) .usa-modal__close { + display: none; +} \ No newline at end of file diff --git a/frontend/src/components/Modal.js b/frontend/src/components/Modal.js index cf4114246f..f9238fc0c2 100644 --- a/frontend/src/components/Modal.js +++ b/frontend/src/components/Modal.js @@ -1,68 +1,81 @@ -import React, { useCallback, useEffect, useRef } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; -import { Button, Modal as TrussWorksModal } from '@trussworks/react-uswds'; -import { ESCAPE_KEY_CODES } from '../Constants'; +import { + Button, Modal as TrussWorksModal, ModalHeading, ModalFooter, ButtonGroup, ModalToggleButton, +} from '@trussworks/react-uswds'; import './Modal.css'; const Modal = ({ - onOk, onClose, closeModal, title, okButtonText, okButtonAriaLabel, children, -}) => { - const modalRef = useRef(null); - - const onEscape = useCallback((event) => { - if (ESCAPE_KEY_CODES.includes(event.key)) { - closeModal(); - } - }, [closeModal]); - - useEffect(() => { - document.addEventListener('keydown', onEscape, false); - return () => { - document.removeEventListener('keydown', onEscape, false); - }; - }, [onEscape]); - - useEffect(() => { - const button = modalRef.current.querySelector('button'); - if (button) { - button.focus(); - } - }); - - return ( - +); Modal.propTypes = { - onOk: PropTypes.func.isRequired, - onClose: PropTypes.func.isRequired, - closeModal: PropTypes.func.isRequired, + modalRef: PropTypes.oneOfType([ + PropTypes.func, + PropTypes.shape(), + ]).isRequired, + modalId: PropTypes.string.isRequired, + onOk: PropTypes.func, title: PropTypes.string.isRequired, - okButtonText: PropTypes.string.isRequired, + okButtonText: PropTypes.string, okButtonAriaLabel: PropTypes.string, + showOkButton: PropTypes.bool, + cancelButtonText: PropTypes.string, + showCloseX: PropTypes.bool, + isLarge: PropTypes.bool, children: PropTypes.node.isRequired, }; Modal.defaultProps = { + onOk: () => { }, okButtonAriaLabel: null, + okButtonText: '', + showCloseX: false, + showOkButton: true, + isLarge: false, + cancelButtonText: 'Cancel', + }; export default Modal; diff --git a/frontend/src/components/SpecialistSelect.js b/frontend/src/components/SpecialistSelect.js new file mode 100644 index 0000000000..d1d1068f19 --- /dev/null +++ b/frontend/src/components/SpecialistSelect.js @@ -0,0 +1,76 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import CheckboxSelect from './CheckboxSelect'; + +export const ROLES_MAP = [ + { + selectValue: 1, + value: 'Early Childhood Specialist', + label: 'Early Childhood Specialist (ECS)', + }, + { + selectValue: 2, + value: 'Family Engagement Specialist', + label: 'Family Engagement Specialist (FES)', + }, + { + selectValue: 3, + value: 'Grantee Specialist', + label: 'Grantee Specialist (GS)', + }, + { + selectValue: 4, + value: 'Health Specialist', + label: 'Health Specialist (HS)', + }, + { + selectValue: 5, + value: 'System Specialist', + label: 'System Specialist (SS)', + }, +]; + +export default function SpecialistSelect({ + onApplyRoles, labelId, hideToggleAll, toggleAllInitial, +}) { + const onApply = (selected) => { + const roleValues = selected.map((s) => parseInt(s, 10)); + + const roles = ROLES_MAP.filter( + (role) => roleValues.includes(role.selectValue), + ).map((role) => role.value); + + onApplyRoles(roles); + }; + + return ( + ({ + value: role.selectValue, + label: role.label, + })) + } + /> + ); +} + +SpecialistSelect.propTypes = { + labelId: PropTypes.string.isRequired, + onApplyRoles: PropTypes.func.isRequired, + toggleAllInitial: PropTypes.bool, + hideToggleAll: PropTypes.bool, +}; + +SpecialistSelect.defaultProps = { + toggleAllInitial: true, + hideToggleAll: false, +}; 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/Tooltip.js b/frontend/src/components/Tooltip.js index 3c280a5d5d..6dd6cb6d3d 100644 --- a/frontend/src/components/Tooltip.js +++ b/frontend/src/components/Tooltip.js @@ -4,7 +4,7 @@ import PropTypes from 'prop-types'; import './Tooltip.css'; export default function Tooltip({ - displayText, tooltipText, buttonLabel, screenReadDisplayText, + displayText, tooltipText, buttonLabel, screenReadDisplayText, hideUnderline, }) { const [showTooltip, setShowTooltip] = useState(false); @@ -21,16 +21,21 @@ export default function Tooltip({ {displayText} - + { + hideUnderline ? null + : ( + + ) + } @@ -54,8 +59,10 @@ Tooltip.propTypes = { ]).isRequired, buttonLabel: PropTypes.string.isRequired, screenReadDisplayText: PropTypes.bool, + hideUnderline: PropTypes.bool, }; Tooltip.defaultProps = { screenReadDisplayText: true, + hideUnderline: false, }; 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__/Accordion.js b/frontend/src/components/__tests__/Accordion.js new file mode 100644 index 0000000000..4728e86de1 --- /dev/null +++ b/frontend/src/components/__tests__/Accordion.js @@ -0,0 +1,375 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import { Accordion } from '../Accordion'; + +const testItems = [ + { + title: 'First Amendment', + content: ( +

+ Congress shall make no law respecting an establishment of religion, or + prohibiting the free exercise thereof; or abridging the freedom of + speech, or of the press; or the right of the people peaceably to + assemble, and to petition the Government for a redress of grievances. +

+ ), + expanded: false, + id: '123', + }, + { + title: 'Second Amendment', + content: ( + <> +

+ A well regulated Militia, being necessary to the security of a free + State, the right of the people to keep and bear Arms, shall not be + infringed. +

+ {' '} +
    +
  • This is a list item
  • +
  • Another list item
  • +
+ + ), + expanded: false, + id: 'abc', + }, + { + title: 'Third Amendment', + content: ( +

+ No Soldier shall, in time of peace be quartered in any house, without + the consent of the Owner, nor in time of war, but in a manner to be + prescribed by law. +

+ ), + expanded: false, + id: 'def', + }, + { + title: 'Fourth Amendment', + content: ( +

+ The right of the people to be secure in their persons, houses, papers, + and effects, against unreasonable searches and seizures, shall not be + violated, and no Warrants shall issue, but upon probable cause, + supported by Oath or affirmation, and particularly describing the place + to be searched, and the persons or things to be seized. +

+ ), + expanded: false, + id: '456', + }, + { + title: 'Fifth Amendment', + content: ( +

+ No person shall be held to answer for a capital, or otherwise infamous + crime, unless on a presentment or indictment of a Grand Jury, except in + cases arising in the land or naval forces, or in the Militia, when in + actual service in time of War or public danger; nor shall any person be + subject for the same offence to be twice put in jeopardy of life or + limb; nor shall be compelled in any criminal case to be a witness + against himself, nor be deprived of life, liberty, or property, without + due process of law; nor shall private property be taken for public use, + without just compensation. +

+ ), + expanded: false, + id: '789', + }, +]; + +describe('Accordion component', () => { + it('renders without errors', () => { + const { queryByTestId } = render(); + expect(queryByTestId('accordion')).toBeInTheDocument(); + }); + + it('renders a header and content for each item', () => { + const { getByTestId } = render(); + const accordionEl = getByTestId('accordion'); + expect(accordionEl.childElementCount).toBe(testItems.length * 2); + }); + + it('no items are open by default', () => { + const { getByTestId } = render(); + + expect(getByTestId(`accordionItem_${testItems[0].id}`)).not.toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[1].id}`)).not.toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[2].id}`)).not.toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[3].id}`)).not.toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[4].id}`)).not.toBeVisible(); + }); + + describe('when you toggle a closed item', () => { + it('opens', () => { + const { getByTestId, getByText } = render(); + + expect(getByTestId(`accordionItem_${testItems[0].id}`)).not.toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[1].id}`)).not.toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[2].id}`)).not.toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[3].id}`)).not.toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[4].id}`)).not.toBeVisible(); + + fireEvent.click(getByText(testItems[1].title)); + + expect(getByTestId(`accordionItem_${testItems[0].id}`)).not.toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[1].id}`)).toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[2].id}`)).not.toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[3].id}`)).not.toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[4].id}`)).not.toBeVisible(); + }); + }); + + describe('when you toggle an open item', () => { + it('closes', () => { + const { getByText, getByTestId } = render(); + + expect(getByTestId(`accordionItem_${testItems[0].id}`)).not.toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[1].id}`)).not.toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[2].id}`)).not.toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[3].id}`)).not.toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[4].id}`)).not.toBeVisible(); + + fireEvent.click(getByText(testItems[0].title)); + + expect(getByTestId(`accordionItem_${testItems[0].id}`)).toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[1].id}`)).not.toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[2].id}`)).not.toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[3].id}`)).not.toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[4].id}`)).not.toBeVisible(); + + fireEvent.click(getByText(testItems[0].title)); + + expect(getByTestId(`accordionItem_${testItems[0].id}`)).not.toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[1].id}`)).not.toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[2].id}`)).not.toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[3].id}`)).not.toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[4].id}`)).not.toBeVisible(); + }); + }); + + describe('when multiselectable is false (default behavior)', () => { + it('when an item is opened, clicking a different item closes the previously opened item', () => { + const { getByText, getByTestId } = render(); + + expect(getByTestId(`accordionItem_${testItems[0].id}`)).not.toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[1].id}`)).not.toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[2].id}`)).not.toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[3].id}`)).not.toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[4].id}`)).not.toBeVisible(); + + fireEvent.click(getByText(testItems[3].title)); + fireEvent.click(getByText(testItems[1].title)); + + expect(getByTestId(`accordionItem_${testItems[0].id}`)).not.toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[1].id}`)).toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[2].id}`)).not.toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[3].id}`)).not.toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[4].id}`)).not.toBeVisible(); + + fireEvent.click(getByText(testItems[4].title)); + fireEvent.click(getByText(testItems[2].title)); + + expect(getByTestId(`accordionItem_${testItems[0].id}`)).not.toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[1].id}`)).not.toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[2].id}`)).toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[3].id}`)).not.toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[4].id}`)).not.toBeVisible(); + }); + }); + + describe('when multiselectable is true', () => { + it('when an item is opened, previously open items remain open', () => { + const { getByText, getByTestId } = render( + , + ); + + expect(getByTestId(`accordionItem_${testItems[0].id}`)).not.toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[1].id}`)).not.toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[2].id}`)).not.toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[3].id}`)).not.toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[4].id}`)).not.toBeVisible(); + + fireEvent.click(getByText(testItems[0].title)); + fireEvent.click(getByText(testItems[1].title)); + + expect(getByTestId(`accordionItem_${testItems[0].id}`)).toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[1].id}`)).toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[2].id}`)).not.toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[3].id}`)).not.toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[4].id}`)).not.toBeVisible(); + + fireEvent.click(getByText(testItems[0].title)); + fireEvent.click(getByText(testItems[3].title)); + + expect(getByTestId(`accordionItem_${testItems[0].id}`)).not.toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[1].id}`)).toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[2].id}`)).not.toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[3].id}`)).toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[4].id}`)).not.toBeVisible(); + + fireEvent.click(getByText(testItems[2].title)); + fireEvent.click(getByText(testItems[4].title)); + + expect(getByTestId(`accordionItem_${testItems[0].id}`)).not.toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[1].id}`)).toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[2].id}`)).toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[3].id}`)).toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[4].id}`)).toBeVisible(); + }); + }); + + describe('with expanded items on mount', () => { + const testExpandedItems = [ + { + title: 'First Amendment', + content: ( +

+ Congress shall make no law respecting an establishment of religion, + or prohibiting the free exercise thereof; or abridging the freedom + of speech, or of the press; or the right of the people peaceably to + assemble, and to petition the Government for a redress of + grievances. +

+ ), + expanded: false, + id: '123', + }, + { + title: 'Second Amendment', + content: ( + <> +

+ A well regulated Militia, being necessary to the security of a + free State, the right of the people to keep and bear Arms, shall + not be infringed. +

+ {' '} +
    +
  • This is a list item
  • +
  • Another list item
  • +
+ + ), + expanded: true, + id: 'abc', + }, + { + title: 'Third Amendment', + content: ( +

+ No Soldier shall, in time of peace be quartered in any house, + without the consent of the Owner, nor in time of war, but in a + manner to be prescribed by law. +

+ ), + expanded: false, + id: 'def', + }, + { + title: 'Fourth Amendment', + content: ( +

+ The right of the people to be secure in their persons, houses, + papers, and effects, against unreasonable searches and seizures, + shall not be violated, and no Warrants shall issue, but upon + probable cause, supported by Oath or affirmation, and particularly + describing the place to be searched, and the persons or things to be + seized. +

+ ), + expanded: true, + id: '456', + }, + { + title: 'Fifth Amendment', + content: ( +

+ No person shall be held to answer for a capital, or otherwise + infamous crime, unless on a presentment or indictment of a Grand + Jury, except in cases arising in the land or naval forces, or in the + Militia, when in actual service in time of War or public danger; nor + shall any person be subject for the same offence to be twice put in + jeopardy of life or limb; nor shall be compelled in any criminal + case to be a witness against himself, nor be deprived of life, + liberty, or property, without due process of law; nor shall private + property be taken for public use, without just compensation. +

+ ), + expanded: true, + id: '789', + }, + ]; + + it('shows the expanded items by default', () => { + const { getByTestId } = render(); + + expect(getByTestId(`accordionItem_${testItems[0].id}`)).not.toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[1].id}`)).toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[2].id}`)).not.toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[3].id}`)).toBeVisible(); + expect(getByTestId(`accordionItem_${testItems[4].id}`)).toBeVisible(); + }); + }); + + describe('with custom classNames for Accordion Items', () => { + const customTestItems = [ + { + title: 'First Amendment', + content: ( +

+ Congress shall make no law respecting an establishment of religion, + or prohibiting the free exercise thereof; or abridging the freedom + of speech, or of the press; or the right of the people peaceably to + assemble, and to petition the Government for a redress of + grievances. +

+ ), + expanded: false, + id: '123', + className: 'myCustomAccordionItem', + }, + { + title: 'Second Amendment', + content: ( + <> +

+ A well regulated Militia, being necessary to the security of a + free State, the right of the people to keep and bear Arms, shall + not be infringed. +

+ {' '} +
    +
  • This is a list item
  • +
  • Another list item
  • +
+ + ), + expanded: false, + id: 'abc', + }, + { + title: 'Third Amendment', + content: ( +

+ No Soldier shall, in time of peace be quartered in any house, + without the consent of the Owner, nor in time of war, but in a + manner to be prescribed by law. +

+ ), + expanded: false, + id: 'def', + }, + ]; + + it('passes the class onto the given AccordionItem element', () => { + const { getByTestId } = render(); + expect(getByTestId(`accordionItem_${testItems[0].id}`)).toHaveClass( + 'myCustomAccordionItem', + ); + }); + }); +}); diff --git a/frontend/src/components/__tests__/ButtonSelect.js b/frontend/src/components/__tests__/ButtonSelect.js index b45e31b589..f49c88b97e 100644 --- a/frontend/src/components/__tests__/ButtonSelect.js +++ b/frontend/src/components/__tests__/ButtonSelect.js @@ -5,10 +5,9 @@ import React from 'react'; import { render, screen, fireEvent, } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import ButtonSelect from '../ButtonSelect'; -const renderButtonSelect = (onApply) => { +const renderButtonSelect = (onApply, applied = 1) => { const options = [ { label: 'Test', @@ -23,7 +22,6 @@ const renderButtonSelect = (onApply) => { const labelId = 'Test-Button-Select'; const labelText = 'Give me a test, guv'; const initialValue = options[0]; - const applied = options[0].value; render(
@@ -35,9 +33,7 @@ const renderButtonSelect = (onApply) => { initialValue={initialValue} applied={applied} ariaName="menu" - hasDateRange /> -
, ); @@ -49,42 +45,11 @@ describe('The Button Select component', () => { renderButtonSelect(onApply); const openMenu = screen.getByRole('button', { - name: /open menu/i, + name: /toggle menu/i, }); fireEvent.click(openMenu); - const custom = screen.getByRole('button', { - name: /select to view data from custom\. select apply filters button to apply selection/i, - }); - - fireEvent.click(custom); - - const sdcalendar = screen.getByRole('button', { - name: /open start date picker calendar/i, - }); - - fireEvent.click(sdcalendar); - - const [day1] = document.querySelectorAll('.SingleDatePicker_picker .CalendarDay'); - - fireEvent.click(day1); - - const edcalendar = screen.getByRole('button', { - name: /open end date picker calendar/i, - }); - - fireEvent.click(edcalendar); - const [, day2] = document.querySelectorAll('.SingleDatePicker_picker .CalendarDay'); - - fireEvent.click(day2); - - const startDate = screen.getByRole('textbox', { - name: /start date/i, - }); - - expect(startDate).toBeInTheDocument(); - const apply = screen.getByRole('button', { name: 'Apply filters for the menu', }); @@ -94,85 +59,15 @@ describe('The Button Select component', () => { expect(onApply).toHaveBeenCalled(); }); - it('shows an error message', async () => { + it('handles weird applied value', () => { const onApply = jest.fn(); - renderButtonSelect(onApply); + const applied = 3; + renderButtonSelect(onApply, applied); const openMenu = screen.getByRole('button', { - name: /open menu/i, - }); - - fireEvent.click(openMenu); - - const custom = screen.getByRole('button', { - name: /select to view data from custom\. select apply filters button to apply selection/i, + name: /toggle menu/i, }); - fireEvent.click(custom); - - const sdcalendar = screen.getByRole('button', { - name: /open start date picker calendar/i, - }); - - fireEvent.click(sdcalendar); - - const [day1] = document.querySelectorAll('.SingleDatePicker_picker .CalendarDay'); - - fireEvent.click(day1); - - const startDate = screen.getByRole('textbox', { - name: /start date/i, - }); - - expect(startDate).toBeInTheDocument(); - - const apply = screen.getByRole('button', { - name: 'Apply filters for the menu', - }); - - fireEvent.click(apply); - - const error = screen.getByText(/reports are available from 09\/01\/2020\.use the format mm\/dd\/yyyy\./i); - expect(error).toBeInTheDocument(); - }); - - it('handles blur', () => { - const onApply = jest.fn(); - renderButtonSelect(onApply); - - const openMenu = screen.getByRole('button', { - name: /open menu/i, - }); - - fireEvent.click(openMenu); - - const custom = screen.getByRole('button', { - name: /select to view data from custom\. select apply filters button to apply selection/i, - }); - - fireEvent.click(custom); - - const startDate = screen.getByRole('textbox', { - name: /start date/i, - }); - - // is this the best way to fire on blur? yikes - userEvent.tab(); - userEvent.tab(); - userEvent.tab(); - userEvent.tab(); - userEvent.tab(); - userEvent.tab(); - userEvent.tab(); - userEvent.tab(); - userEvent.tab(); - userEvent.tab(); - userEvent.tab(); - userEvent.tab(); - - const blanko = screen.getByRole('button', { name: /blanko/i }); - expect(blanko).toHaveFocus(); - - expect(startDate).not.toBeInTheDocument(); + expect(openMenu.textContent).toBe('Test'); }); }); diff --git a/frontend/src/components/__tests__/CheckboxSelect.js b/frontend/src/components/__tests__/CheckboxSelect.js index 00d2a57ad5..00abeeb94b 100644 --- a/frontend/src/components/__tests__/CheckboxSelect.js +++ b/frontend/src/components/__tests__/CheckboxSelect.js @@ -36,7 +36,7 @@ describe('Checkbox select', () => { it('renders properly and calls the on apply function', async () => { const onApply = jest.fn(); renderCheckboxSelect(onApply); - const button = screen.getByRole('button', { name: /open the dogs/i }); + const button = screen.getByRole('button', { name: /toggle the dogs/i }); userEvent.click(button); const pom = screen.getByRole('checkbox', { name: /select pomeranian/i }); userEvent.click(pom); @@ -48,7 +48,7 @@ describe('Checkbox select', () => { it('toggles all off', async () => { const onApply = jest.fn(); renderCheckboxSelect(onApply); - const button = screen.getByRole('button', { name: /open the dogs/i }); + const button = screen.getByRole('button', { name: /toggle the dogs/i }); userEvent.click(button); const pom = screen.getByRole('checkbox', { name: /select pomeranian/i }); expect(pom).toBeChecked(); @@ -57,26 +57,28 @@ describe('Checkbox select', () => { expect(pom).not.toBeChecked(); }); - it('closes on escape button', () => { + it('closes on escape button', async () => { const onApply = jest.fn(); renderCheckboxSelect(onApply); - const button = screen.getByRole('button', { name: /open the dogs/i }); + const button = screen.getByRole('button', { name: /toggle the dogs/i }); userEvent.click(button); const pom = screen.getByRole('checkbox', { name: /select pomeranian/i }); - expect(pom).toBeInTheDocument(); + + expect(pom).toBeVisible(); + fireEvent.keyDown(button, { key: 'Escape', code: 'Escape', keyCode: 27, charCode: 27, }); - expect(pom).not.toBeInTheDocument(); + expect(pom).not.toBeVisible(); }); it('handles blur', () => { const onApply = jest.fn(); renderCheckboxSelect(onApply); - const button = screen.getByRole('button', { name: /open the dogs/i }); + const button = screen.getByRole('button', { name: /toggle the dogs/i }); userEvent.click(button); const allDogs = screen.getByRole('checkbox', { name: /all dogs/i }); expect(allDogs).toBeInTheDocument(); @@ -87,7 +89,7 @@ describe('Checkbox select', () => { it('toggles all on', async () => { const onApply = jest.fn(); renderCheckboxSelect(onApply); - const button = screen.getByRole('button', { name: /open the dogs/i }); + const button = screen.getByRole('button', { name: /toggle the dogs/i }); userEvent.click(button); const allDogs = screen.getByRole('checkbox', { name: /all dogs/i }); userEvent.click(allDogs); @@ -102,7 +104,7 @@ describe('Checkbox select', () => { it('checks and unchecks and passes the right parameters to onApply', async () => { const onApply = jest.fn(); renderCheckboxSelect(onApply); - const button = screen.getByRole('button', { name: /open the dogs/i }); + const button = screen.getByRole('button', { name: /toggle the dogs/i }); userEvent.click(button); const allDogs = screen.getByRole('checkbox', { name: /all dogs/i }); userEvent.click(allDogs); diff --git a/frontend/src/components/__tests__/DateRangeSelect.js b/frontend/src/components/__tests__/DateRangeSelect.js new file mode 100644 index 0000000000..d16a9cdb57 --- /dev/null +++ b/frontend/src/components/__tests__/DateRangeSelect.js @@ -0,0 +1,117 @@ +import '@testing-library/jest-dom'; +import React from 'react'; +import { + render, screen, fireEvent, act, +} from '@testing-library/react'; +import DateRangeSelect, { formatDateRange } from '../DateRangeSelect'; + +describe('format date function', () => { + it('returns a formatted date string', () => { + const str = formatDateRange({ + lastThirtyDays: false, + string: '2021/06/07-2021/06/08', + withSpaces: true, + }); + + expect(str).toBe('06/07/2021 - 06/08/2021'); + }); + + it('returns a formatted date string without spaces', () => { + const str = formatDateRange({ + string: '2021/06/07-2021/06/08', + withSpaces: false, + }); + + expect(str).toBe('06/07/2021-06/08/2021'); + }); +}); + +describe('DateRangeSelect', () => { + const renderDateRangeSelect = (onApplyDateRange = jest.fn(), disabled = false) => { + render( + , + ); + }; + + it('renders correctly', () => { + renderDateRangeSelect(); + const button = screen.getByRole('button', { name: /Toggle the date range select menu/i }); + expect(button).toHaveTextContent('Last 30 Days'); + }); + + it('opens the list of options', () => { + renderDateRangeSelect(); + + const button = screen.getByRole('button', { name: /Toggle the date range select menu/i }); + fireEvent.click(button); + + const thirtyDays = screen.getByRole('button', { name: /select to view data from last 30 days\. select apply filters button to apply selection/i }); + expect(thirtyDays).toHaveTextContent('Last 30 Days'); + }); + + it('opens the start date calendar', async () => { + renderDateRangeSelect(); + const button = screen.getByRole('button', { name: /Toggle the date range select menu/i }); + + fireEvent.click(button); + + const custom = screen.getByRole('button', { name: /select to view data from custom date range\. select apply filters button to apply selection/i }); + fireEvent.click(custom); + + const startDateCalendar = screen.getByRole('button', { name: /open start date picker calendar/i }); + + act(() => { + fireEvent.click(startDateCalendar); + }); + + expect(document.querySelector('#startDatePicker')).toHaveFocus(); + }); + + it('allows the date range to be updated', () => { + const onApplyDateRange = jest.fn(); + renderDateRangeSelect(onApplyDateRange); + + const button = screen.getByRole('button', { name: /Toggle the date range select menu/i }); + fireEvent.click(button); + + const thirtyDays = screen.getByRole('button', { name: /select to view data from last 30 days\. select apply filters button to apply selection/i }); + fireEvent.click(thirtyDays); + + const applyFilters = screen.getByRole('button', { name: 'Apply date range filters' }); + fireEvent.click(applyFilters); + + expect(onApplyDateRange).toHaveBeenCalled(); + }); + + it('can be disabled', () => { + const onApplyDateRange = jest.fn(); + renderDateRangeSelect(onApplyDateRange, true); + const button = screen.getByRole('button', { name: /Toggle the date range select menu/i }); + expect(button).toBeDisabled(); + }); + + it('closes the menu with the escape key', () => { + renderDateRangeSelect(); + + // open menu + const button = screen.getByRole('button', { name: /Toggle the date range select menu/i }); + fireEvent.click(button); + + // expect text + const option = screen.getByRole('button', { name: /select to view data from last 30 days\. select apply filters button to apply selection/i }); + + button.focus(); + + expect(option).toBeVisible(); + + // close menu + fireEvent.keyDown(button, { key: 'Escape', code: 'Escape', keyCode: 27 }); + + // confirm menu is closed + expect(option).not.toBeVisible(); + }); +}); diff --git a/frontend/src/components/__tests__/DeleteReportModal.js b/frontend/src/components/__tests__/DeleteReportModal.js deleted file mode 100644 index bfedf09546..0000000000 --- a/frontend/src/components/__tests__/DeleteReportModal.js +++ /dev/null @@ -1,75 +0,0 @@ -import '@testing-library/jest-dom'; -import React from 'react'; -import { - render, screen, -} from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { useModal, connectModal, Button } from '@trussworks/react-uswds'; - -import DeleteReportModal from '../DeleteReportModal'; - -const SomeComponent = () => { - const { isOpen, openModal, closeModal } = useModal(); - const ConnectModal = connectModal(DeleteReportModal); - - return ( -
- {}} - onClose={() => {}} - closeModal={closeModal} - isOpen={isOpen} - /> - -
- ); -}; - -describe('DeleteReportModal', () => { - it('shows two buttons', async () => { - // Given a page with a modal - render( {}} - onClose={() => {}} - closeModal={() => {}} - isOpen - />); - // When the modal is triggered - const buttons = await screen.findAllByRole('button'); - - // Then we see our options - expect(buttons.length).toBe(2); - }); - - it('exits when escape key is pressed', async () => { - // Given a page with a modal - render(); - - // When the modal is triggered - const button = await screen.findByText('Open'); - userEvent.click(button); - - const modal = await screen.findByTestId('modal'); - expect(modal).toBeVisible(); - - // And the modal can closeclose the modal via the escape key - userEvent.type(modal, '{esc}', { skipClick: true }); - expect(screen.queryByTestId('modal')).not.toBeTruthy(); - }); - - it('does not escape when any other key is pressed', async () => { - // Given a page with a modal - render(); - - // When the modal is triggered - const button = await screen.findByText('Open'); - userEvent.click(button); - - const modal = await screen.findByTestId('modal'); - expect(modal).toBeVisible(); - - // And the modal can closeclose the modal via the escape key - userEvent.type(modal, '{enter}', { skipClick: true }); - expect(screen.queryByTestId('modal')).toBeTruthy(); - }); -}); diff --git a/frontend/src/components/__tests__/ExternalResourceModal.js b/frontend/src/components/__tests__/ExternalResourceModal.js index 83e8df99aa..59fae7e477 100644 --- a/frontend/src/components/__tests__/ExternalResourceModal.js +++ b/frontend/src/components/__tests__/ExternalResourceModal.js @@ -6,7 +6,7 @@ import { import userEvent from '@testing-library/user-event'; import join from 'url-join'; -import { ExternalLink } from '../ExternalResourceModal'; +import ExternalLink from '../ExternalResourceModal'; import { isExternalURL, isValidURL } from '../../utils'; import { GOVERNMENT_HOSTNAME_EXTENSION } from '../../Constants'; @@ -29,7 +29,8 @@ describe('External Resources', () => { userEvent.click(link); // Then we see the modal - expect(await screen.findByTestId('modal')).toBeVisible(); + const modal = document.querySelector('#ExternalResourceModal'); + expect(modal.firstChild).toHaveClass('is-visible'); }); it('closes modal when cancel button is pressed', async () => { @@ -39,12 +40,15 @@ describe('External Resources', () => { // When the users clicks it userEvent.click(link); - expect(await screen.findByTestId('modal')).toBeVisible(); + let modal = document.querySelector('#ExternalResourceModal'); + expect(modal.firstChild).toHaveClass('is-visible'); // Then the user can make the modal disappear via the cancel button const cancelButton = await screen.findByText('Cancel'); userEvent.click(cancelButton); - expect(screen.queryByTestId('modal')).not.toBeTruthy(); + + modal = document.querySelector('#ExternalResourceModal'); + expect(modal.firstChild).toHaveClass('is-hidden'); }); it('closes modal when escape key is pressed', async () => { @@ -54,16 +58,20 @@ describe('External Resources', () => { // When the users clicks it userEvent.click(link); - const modal = await screen.findByTestId('modal'); - expect(modal).toBeVisible(); + let modal = document.querySelector('#ExternalResourceModal'); + expect(modal.firstChild).toHaveClass('is-visible'); // Then they try to close with delete key - userEvent.type(modal, '{del}', { skipClick: true }); - expect(screen.queryByTestId('modal')).toBeTruthy(); + const modalWindow = await screen.findByRole('heading', { name: /external resources disclaimer/i, hidden: true }); + userEvent.type(modalWindow, '{del}', { skipClick: true }); + + modal = document.querySelector('#ExternalResourceModal'); + expect(modal.firstChild).toHaveClass('is-visible'); // And they can close the modal via the escape key userEvent.type(modal, '{esc}', { skipClick: true }); - expect(screen.queryByTestId('modal')).not.toBeTruthy(); + modal = document.querySelector('#ExternalResourceModal'); + expect(modal.firstChild).toHaveClass('is-hidden'); }); it('shows external link when ok is pressed', async () => { @@ -79,7 +87,8 @@ describe('External Resources', () => { userEvent.click(acceptButton); // Then we hide the modal - expect(screen.queryByTestId('modal')).not.toBeTruthy(); + const modal = document.querySelector('#ExternalResourceModal'); + expect(modal.firstChild).toHaveClass('is-hidden'); // And a new tab has been opened expect(windowSpy).toHaveBeenCalledWith('https://www.google.com', '_blank'); diff --git a/frontend/src/components/__tests__/IdleModal.js b/frontend/src/components/__tests__/IdleModal.js index 73a63c8afb..63309cd738 100644 --- a/frontend/src/components/__tests__/IdleModal.js +++ b/frontend/src/components/__tests__/IdleModal.js @@ -33,7 +33,8 @@ describe('IdleModal', () => { act(() => { jest.advanceTimersByTime(11); }); - expect(screen.getByTestId('modal')).toBeVisible(); + const modal = document.querySelector('#IdleReportModal'); + expect(modal.firstChild).toHaveClass('is-visible'); }); it('logout is called after logoutTimeout milliseconds of inactivity', () => { @@ -63,7 +64,9 @@ describe('IdleModal', () => { renderIdleModal(20, 10, logout); act(() => { jest.advanceTimersByTime(12); - expect(screen.getByTestId('modal')).toBeVisible(); + const modal = document.querySelector('#IdleReportModal'); + expect(modal.firstChild).toHaveClass('is-visible'); + const testDiv = screen.getByTestId('test'); userEvent.type(testDiv, 'test'); }); @@ -76,7 +79,7 @@ describe('IdleModal', () => { renderIdleModal(20, 10); act(() => { jest.advanceTimersByTime(11); - expect(screen.getByRole('alert').textContent).toContain('in less than a minute'); + expect(screen.getByTestId(/alert/i).textContent).toContain('in less than a minute'); }); }); @@ -84,7 +87,7 @@ describe('IdleModal', () => { renderIdleModal(1000 * 60 + 10, 10); act(() => { jest.advanceTimersByTime(11); - expect(screen.getByRole('alert').textContent).toContain('in a minute'); + expect(screen.getByTestId(/alert/i).textContent).toContain('in a minute'); }); }); @@ -92,7 +95,7 @@ describe('IdleModal', () => { renderIdleModal((1000 * 60 * 5) + 10, 10); act(() => { jest.advanceTimersByTime(11); - expect(screen.getByRole('alert').textContent).toContain('in 5 minutes'); + expect(screen.getByTestId(/alert/i).textContent).toContain('in 5 minutes'); }); }); }); diff --git a/frontend/src/components/__tests__/LandingLayout.js b/frontend/src/components/__tests__/LandingLayout.js new file mode 100644 index 0000000000..326247ed1b --- /dev/null +++ b/frontend/src/components/__tests__/LandingLayout.js @@ -0,0 +1,12 @@ +import '@testing-library/jest-dom'; +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +import LandingLayout from '../LandingLayout'; + +describe('LandingLayout', () => { + it('renders correctly', () => { + render(

Landing Layout

); + expect(screen.getByText('Landing Layout')).toBeVisible(); + }); +}); diff --git a/frontend/src/components/__tests__/Modal.js b/frontend/src/components/__tests__/Modal.js index 357469f780..08b9bbc878 100644 --- a/frontend/src/components/__tests__/Modal.js +++ b/frontend/src/components/__tests__/Modal.js @@ -1,74 +1,117 @@ +/* eslint-disable react/prop-types */ import '@testing-library/jest-dom'; -import React from 'react'; +import React, { useRef } from 'react'; import { render, screen, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { useModal, connectModal, Button } from '@trussworks/react-uswds'; +import { ModalToggleButton } from '@trussworks/react-uswds'; import Modal from '../Modal'; -const SomeComponent = () => { - const { isOpen, openModal, closeModal } = useModal(); - const ConnectModal = connectModal(Modal); +const ModalComponent = ( + { + onOk = () => { }, + modalIdValue = 'popup-modal', + title = 'Test Report Modal', + okButtonText = 'Ok', + okButtonAriaLabel = 'This button will ok the modal action.', + showOkButton = true, + cancelButtonText = 'Cancel', + showCloseX = false, + isLarge = false, + }, +) => { + const modalRef = useRef(); return (
- {}} - onClose={() => {}} - closeModal={closeModal} - isOpen={isOpen} - /> - + Open + Close + +
+ Are you sure you want to perform this action? +
+
); }; describe('Modal', () => { - it('shows two buttons', async () => { - // Given a page with a modal - render( {}} - onClose={() => {}} - closeModal={() => {}} - isOpen - />); - // When the modal is triggered - const buttons = await screen.findAllByRole('button'); - - // Then we see our options - expect(buttons.length).toBe(2); + it('renders correctly', async () => { + render(); + expect(await screen.findByRole('heading', { name: /test report modal/i, hidden: true })).toBeVisible(); + expect(await screen.findByText(/are you sure you want to perform this action\?/i)).toBeVisible(); + expect(await screen.findByRole('button', { name: /cancel/i })).toBeVisible(); + expect(await screen.findByRole('button', { name: /this button will ok the modal action\./i })).toBeVisible(); + }); + + it('correctly hides and shows', async () => { + render(); + + // Defaults modal to hidden. + let modalElement = document.querySelector('#popup-modal'); + expect(modalElement.firstChild).toHaveClass('is-hidden'); + + // Open modal. + const button = await screen.findByText('Open'); + userEvent.click(button); + + // Check modal is visible. + modalElement = document.querySelector('#popup-modal'); + expect(modalElement.firstChild).toHaveClass('is-visible'); }); it('exits when escape key is pressed', async () => { - // Given a page with a modal - render(); + render(); - // When the modal is triggered + // Open modal. const button = await screen.findByText('Open'); userEvent.click(button); - const modal = await screen.findByTestId('modal'); - expect(modal).toBeVisible(); + // Modal is visible. + let modalElement = document.querySelector('#popup-modal'); + expect(modalElement.firstChild).toHaveClass('is-visible'); + + // Press ESC. + userEvent.type(modalElement, '{esc}', { skipClick: true }); - // And the modal can closeclose the modal via the escape key - userEvent.type(modal, '{esc}', { skipClick: true }); - expect(screen.queryByTestId('modal')).not.toBeTruthy(); + // Check Modal is hidden. + modalElement = document.querySelector('#popup-modal'); + expect(modalElement.firstChild).toHaveClass('is-hidden'); }); it('does not escape when any other key is pressed', async () => { - // Given a page with a modal - render(); + render(); - // When the modal is triggered + // Open modal. const button = await screen.findByText('Open'); userEvent.click(button); - const modal = await screen.findByTestId('modal'); - expect(modal).toBeVisible(); + // Modal is open. + let modalElement = document.querySelector('#popup-modal'); + expect(modalElement.firstChild).toHaveClass('is-visible'); + + // Press ENTER. + userEvent.type(modalElement, '{enter}', { skipClick: true }); + + // Modal is still open. + modalElement = document.querySelector('#popup-modal'); + expect(modalElement.firstChild).toHaveClass('is-visible'); + }); - // And the modal can close the modal via the escape key - userEvent.type(modal, '{enter}', { skipClick: true }); - expect(screen.queryByTestId('modal')).toBeTruthy(); + it('hides ok button', async () => { + render(); + expect(screen.queryByRole('button', { name: /this button will ok the modal action\./i })).not.toBeInTheDocument(); }); }); diff --git a/frontend/src/components/__tests__/RegionalSelect.js b/frontend/src/components/__tests__/RegionalSelect.js index 89df19450c..781eb709b7 100644 --- a/frontend/src/components/__tests__/RegionalSelect.js +++ b/frontend/src/components/__tests__/RegionalSelect.js @@ -31,18 +31,15 @@ describe('Regional Select', () => { test('displays correct region in input', async () => { const onApplyRegion = jest.fn(); renderRegionalSelect(onApplyRegion); - const input = await screen.findByText(/all regions/i); - expect(input).toBeVisible(); + const button = screen.getByRole('button', { name: 'toggle regional select menu' }); + expect(button.textContent).toBe('All Regions'); }); test('changes input value on apply', async () => { const onApplyRegion = jest.fn(); renderRegionalSelect(onApplyRegion, false, 1); - const input = await screen.findByText(/region 1/i); - expect(input).toBeVisible(); - - fireEvent.click(input); - + const button = screen.getByRole('button', { name: /toggle regional select menu/i }); + fireEvent.click(button); fireEvent.click(screen.getByRole('button', { name: /select to view data from region 2\. select apply filters button to apply selection/i, })); diff --git a/frontend/src/components/__tests__/ToolTip.js b/frontend/src/components/__tests__/ToolTip.js new file mode 100644 index 0000000000..a99dd9657d --- /dev/null +++ b/frontend/src/components/__tests__/ToolTip.js @@ -0,0 +1,46 @@ +import '@testing-library/jest-dom'; +import React from 'react'; +import { + render, screen, +} from '@testing-library/react'; +import Tooltip from '../Tooltip'; + +describe('Tooltip', () => { + const renderTooltip = ( + displayText, + screenReadDisplayText, + buttonLabel, + tooltipText, + hideUnderline, + ) => { + render( +
+ +
, + ); + }; + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('renders correctly', async () => { + renderTooltip('my display text', false, 'my button label', 'my tool tip text', false); + expect(await screen.findByText(/my tool tip text/i)).toBeVisible(); + expect(await screen.findByText(/my button label/i)).toBeVisible(); + expect(await screen.findByText(/my display text/i)).toBeVisible(); + const button = await screen.findByRole('button', { name: /button label/i }); + await expect(button).toBeInTheDocument(); + await expect(document.querySelector('svg')).toBeInTheDocument(); + }); + it('renders without underline', async () => { + renderTooltip('my display text', false, 'my button label', 'my tool tip text', true); + await expect(document.querySelector('svg')).not.toBeInTheDocument(); + }); +}); 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/components/constants.js b/frontend/src/components/constants.js index f6cff95473..a532b6d4a6 100644 --- a/frontend/src/components/constants.js +++ b/frontend/src/components/constants.js @@ -4,7 +4,7 @@ export const BEFORE = 'Is before'; export const AFTER = 'Is after'; export const WITHIN = 'Is within'; export const IS = 'Is'; -export const ONE_OF = 'One of'; +export const IS_NOT = 'Is not'; export const SELECT_CONDITIONS = [CONTAINS, NOT_CONTAINS]; export const DATE_CONDITIONS = [BEFORE, AFTER, WITHIN]; @@ -17,3 +17,5 @@ export const QUERY_CONDITIONS = { [WITHIN]: 'win', [IS]: 'is', }; + +export const DATE_FORMAT = 'MM/DD/YYYY'; diff --git a/frontend/src/components/filter/FilterDateRange.js b/frontend/src/components/filter/FilterDateRange.js new file mode 100644 index 0000000000..98a133a67d --- /dev/null +++ b/frontend/src/components/filter/FilterDateRange.js @@ -0,0 +1,51 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import DateRangeSelect, { formatDateRange } from '../DateRangeSelect'; +import DatePicker from '../FilterDatePicker'; + +/** + * this date picker has bespoke date options + */ +const DATE_OPTIONS = [ + { + label: 'Year to Date', + value: 1, + range: formatDateRange({ yearToDate: true, forDateTime: true }), + }, + { + label: 'Custom Date Range', + value: 2, + range: '', + }, +]; + +export default function FilterDateRange({ + condition, + query, + updateSingleDate, + onApplyDateRange, +}) { + if (condition === 'Is within') { + return ( + + + + ); + } + return ( + + + + ); +} + +FilterDateRange.propTypes = { + condition: PropTypes.string.isRequired, + query: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]).isRequired, + updateSingleDate: PropTypes.func.isRequired, + onApplyDateRange: PropTypes.func.isRequired, +}; diff --git a/frontend/src/components/filter/FilterItem.css b/frontend/src/components/filter/FilterItem.css new file mode 100644 index 0000000000..d714176f7e --- /dev/null +++ b/frontend/src/components/filter/FilterItem.css @@ -0,0 +1,33 @@ +.ttahub-filter-menu-item { + min-width: 600px; +} + +.ttahub-filter-menu-item [name="topic"], +.ttahub-filter-menu-item [name="condition"], +.ttahub-filter-menu-item .smart-hub--button-select-toggle-btn.usa-select.smart-hub--checkbox-select, +.ttahub-filter-menu-item .smart-hub--button-select-toggle-btn.usa-select, +.ttahub-filter-menu-item .smart-hub--dropdown-menu-toggle-btn, +.ttahub-filter-menu-single-date-picker, +.ttahub-dummy-select { + max-width: none; + width: 200px; +} + +.ttahub-filter-menu-item .usa-select { + margin-top: 0.5em; +} + +.ttahub-filter-menu-single-date-picker { + border: 1px solid; +} + +.ttahub-filter-menu-item .SingleDatePickerInput__withBorder { + border: 0; +} + +.ttahub-filter-menu-item .SingleDatePicker .DateInput__small { + min-width: 160px; +} +.ttahub-filter-menu-item .SingleDatePicker .DateInput_input__small { + padding: 0.5rem; +} \ No newline at end of file diff --git a/frontend/src/components/filter/FilterItem.js b/frontend/src/components/filter/FilterItem.js new file mode 100644 index 0000000000..3526b18187 --- /dev/null +++ b/frontend/src/components/filter/FilterItem.js @@ -0,0 +1,173 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faTimesCircle } from '@fortawesome/free-solid-svg-icons'; +import FilterDateRange from './FilterDateRange'; +import { formatDateRange } from '../DateRangeSelect'; +import SpecialistSelect from '../SpecialistSelect'; +import { + DATE_CONDITIONS, + SELECT_CONDITIONS, +} from '../constants'; +import './FilterItem.css'; + +const YEAR_TO_DATE = formatDateRange({ + yearToDate: true, + forDateTime: true, +}); + +const filterProp = PropTypes.shape({ + topic: PropTypes.string, + condition: PropTypes.string, + query: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]), + id: PropTypes.string, +}); + +const DEFAULT_VALUES = { + startDate: { 'Is within': YEAR_TO_DATE, 'Is after': '', 'Is before': '' }, + role: { Contains: [], 'Does not contain': [] }, +}; + +/** + * The individual filter controls with the set of dropdowns + * + * @param {Object} props + * @returns a JSX object + */ +export default function FilterItem({ filter, onRemoveFilter, onUpdateFilter }) { + const { + id, + topic, + condition, + query, + } = filter; + + /** + * changing the condition should clear the query + * Having to do this, I set the default values to be empty where possible + * since that creates the least complicated and confusing logic in the + * function below + */ + const onUpdate = (name, value) => { + if (name === 'condition') { + // Set default value. + const defaultQuery = DEFAULT_VALUES[topic][value]; + onUpdateFilter(id, 'query', defaultQuery); + } + + onUpdateFilter(id, name, value); + }; + + const DummySelect = () => ( + onUpdate(e.target.name, e.target.value)} + className="usa-select margin-right-1" + > + + {possibleFilters.map(({ id: filterId, display }) => ( + + ))} + + { /* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + + + { selectedTopic && condition + ? selectedTopic.renderInput() + : } + + + ); +} + +FilterItem.propTypes = { + filter: filterProp.isRequired, + onRemoveFilter: PropTypes.func.isRequired, + onUpdateFilter: PropTypes.func.isRequired, +}; diff --git a/frontend/src/components/filter/FilterMenu.js b/frontend/src/components/filter/FilterMenu.js new file mode 100644 index 0000000000..14e4a5f7d9 --- /dev/null +++ b/frontend/src/components/filter/FilterMenu.js @@ -0,0 +1,120 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { v4 as uuidv4 } from 'uuid'; +import DropdownMenu from '../DropdownMenu'; +import FilterItem from './FilterItem'; + +// save this to cut down on repeated boilerplate in PropTypes +const filterProp = PropTypes.shape({ + topic: PropTypes.string, + condition: PropTypes.string, + query: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]), + id: PropTypes.string, +}); + +/** + * Renders the entire filter menu and contains the logic for toggling it's visibility + * @param {Object} props + * @returns JSX Object + */ +export default function FilterMenu({ filters, onApplyFilters }) { + const [items, setItems] = useState([...filters]); + + useEffect(() => { + // If filters where changes outside of this component update. + setItems(filters); + }, [filters]); + + const onApply = () => { + onApplyFilters(items.filter((item) => item.topic && item.condition && item.query)); + }; + + const onRemoveFilter = (id) => { + const newItems = [...items]; + const index = newItems.findIndex((item) => item.id === id); + + if (index !== -1) { + newItems.splice(index, 1); + setItems(newItems); + } + }; + + // reset state if we hit cancel + const onCancel = () => setItems([...filters]); + + const onUpdateFilter = (id, name, value, toggleAllChecked) => { + const newItems = [...items]; + const toUpdate = newItems.find((item) => item.id === id); + toUpdate[name] = value; + + if (name === 'topic') { + toUpdate.condition = ''; + toUpdate.query = ''; + } + toUpdate.toggleAllChecked = toggleAllChecked; + setItems(newItems); + }; + const onAddFilter = () => { + const newItems = [...items]; + const newItem = { + id: uuidv4(), + display: '', + conditions: [], + toggleAllChecked: true, + }; + newItems.push(newItem); + setItems(newItems); + }; + + const canBlur = (e) => { + if (e.relatedTarget && e.relatedTarget.matches('.ttahub-filter-menu')) { + return false; + } + + // if we've a date range, also do nothing on blur when we click on those. this is kind of an + // annoyance created because we have nested dropdownmenus + if (e.target.matches('.CalendarDay, .DayPickerNavigation, .DayPickerNavigation_button')) { + return false; + } + + return true; + }; + + return ( + +
+

Show results matching the following conditions.

+
+
    + {items.map((filter) => ( + + ))} +
+ +
+
+ +
+ ); +} + +FilterMenu.propTypes = { + filters: PropTypes.arrayOf(filterProp).isRequired, + onApplyFilters: PropTypes.func.isRequired, +}; diff --git a/frontend/src/components/filter/FilterPills.css b/frontend/src/components/filter/FilterPills.css new file mode 100644 index 0000000000..3899bee167 --- /dev/null +++ b/frontend/src/components/filter/FilterPills.css @@ -0,0 +1,11 @@ +.filter-pill-container .smart-hub--ellipsis { + display: inline; + width: 290px; + font-size: 16px; + vertical-align: top; +} + +.filter-pill-container .smart-hub--tooltip button { + display: inline; + line-height: 1.15; +} diff --git a/frontend/src/components/filter/FilterPills.js b/frontend/src/components/filter/FilterPills.js new file mode 100644 index 0000000000..724b503b2f --- /dev/null +++ b/frontend/src/components/filter/FilterPills.js @@ -0,0 +1,152 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faTimesCircle } from '@fortawesome/free-solid-svg-icons'; +import moment from 'moment'; +import { formatDateRange } from '../DateRangeSelect'; +import Tooltip from '../Tooltip'; +import './FilterPills.css'; + +const filterProp = PropTypes.shape({ + topic: PropTypes.string, + condition: PropTypes.string, + query: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]), + id: PropTypes.string, +}); + +/* Pill */ +export function Pill({ filter, isFirst, onRemoveFilter }) { + const { + id, + topic, + condition, + query, + } = filter; + + const filterNameLookup = [ + { + topic: 'role', + display: 'Specialist', + query: () => query.join(', '), + }, + { + topic: 'startDate', + display: 'Date Range', + query: () => { + if (query.includes('-')) { + return formatDateRange({ + string: query, + withSpaces: false, + }); + } + return moment(query, 'YYYY/MM/DD').format('MM/DD/YYYY'); + }, + }, + ]; + + const determineFilterName = () => { + const topicMatch = filterNameLookup.find((f) => f.topic === topic); + if (topicMatch) { + return topicMatch.display; + } + return topic; + }; + + let showToolTip = false; + + const truncateQuery = (queryToTruncate) => { + let queryToReturn = queryToTruncate; + if (queryToReturn.length > 40) { + queryToReturn = queryToReturn.substring(0, 40); + queryToReturn += '...'; + showToolTip = true; + } + return queryToReturn; + }; + + const determineQuery = (keepOriginalLength = true) => { + const queryMatch = filterNameLookup.find((f) => f.topic === topic); + if (queryMatch) { + const queryToReturn = queryMatch.query(); + return keepOriginalLength ? queryToReturn : truncateQuery(queryToReturn); + } + return query; + }; + + const filterName = determineFilterName(); + const queryValue = determineQuery(); + const ariaButtonText = `This button removes the filter: ${filterName} ${condition} ${queryValue}`; + const queryShortValue = determineQuery(false); + + return ( + + {isFirst ? null : AND } + + + {filterName} + + {' '} + {condition ? condition.toLowerCase() : null} + + + + {' '} + { + showToolTip + ? ( + + ) + : queryValue + } + + + + + + ); +} + +Pill.propTypes = { + filter: filterProp.isRequired, + isFirst: PropTypes.bool.isRequired, + onRemoveFilter: PropTypes.func.isRequired, +}; + +/* Filter Pills */ +export default function FilterPills({ filters, onRemoveFilter }) { + return ( + <> + { + filters.map((filter, index) => ( + + )) + } + + ); +} + +FilterPills.propTypes = { + filters: PropTypes.arrayOf(filterProp).isRequired, + onRemoveFilter: PropTypes.func.isRequired, +}; diff --git a/frontend/src/components/filter/__tests__/FilterDateRange.js b/frontend/src/components/filter/__tests__/FilterDateRange.js new file mode 100644 index 0000000000..8ab2079c6a --- /dev/null +++ b/frontend/src/components/filter/__tests__/FilterDateRange.js @@ -0,0 +1,35 @@ +import '@testing-library/jest-dom'; +import React from 'react'; +import { + render, + screen, +} from '@testing-library/react'; +import FilterDateRange from '../FilterDateRange'; + +describe('FilterDateRange', () => { + const renderFilterDateRange = (query) => { + const condition = 'Is after'; + + const updateSingleDate = jest.fn(); + const onApplyDateRange = jest.fn(); + + render( + , + ); + }; + + it('handles an string query', () => { + renderFilterDateRange('2021/10/31'); + expect(screen.getByRole('textbox', { name: /date/i }).value).toBe('10/31/2021'); + }); + + it('handles an array query', () => { + renderFilterDateRange(['Early childhood specialist']); + expect(screen.getByRole('textbox', { name: /date/i }).value).toBe(''); + }); +}); diff --git a/frontend/src/components/filter/__tests__/FilterItem.js b/frontend/src/components/filter/__tests__/FilterItem.js new file mode 100644 index 0000000000..27fc2318b7 --- /dev/null +++ b/frontend/src/components/filter/__tests__/FilterItem.js @@ -0,0 +1,118 @@ +import '@testing-library/jest-dom'; +import React from 'react'; +import { + render, + screen, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { formatDateRange } from '../../DateRangeSelect'; +import FilterItem from '../FilterItem'; + +describe('Filter menu item', () => { + const renderFilterItem = (filter, onRemoveFilter = jest.fn(), onUpdateFilter = jest.fn()) => { + render(); + }; + + it('updates topic & condition', async () => { + const filter = { + id: 'gibberish', topic: 'startDate', condition: 'Is after', query: '2021/01/01', + }; + const onRemove = jest.fn(); + const onUpdate = jest.fn(); + renderFilterItem(filter, onRemove, onUpdate); + + const topic = screen.getByRole('combobox', { name: 'topic' }); + userEvent.selectOptions(topic, 'role'); + expect(onUpdate).toHaveBeenCalledWith('gibberish', 'topic', 'role'); + + const condition = screen.getByRole('combobox', { name: 'condition' }); + userEvent.selectOptions(condition, 'Is within'); + expect(onUpdate).toHaveBeenCalledWith('gibberish', 'condition', 'Is within'); + }); + + it('displays a date filter correctly', () => { + const filter = { + id: 'gibberish', topic: 'startDate', condition: 'Is after', query: '2021/01/01', + }; + const onRemove = jest.fn(); + const onUpdate = jest.fn(); + renderFilterItem(filter, onRemove, onUpdate); + + const selector = screen.getByRole('combobox', { name: 'condition' }); + expect(selector).toBeVisible(); + expect(screen.getByRole('textbox', { name: /date/i })).toBeVisible(); + }); + + it('applies the proper date range', async () => { + const filter = { + id: 'c6d0b3a7-8d51-4265-908a-beaaf16f12d3', topic: 'startDate', condition: 'Is within', query: '2021/01/01-2021/10/28', + }; + const onRemove = jest.fn(); + const onUpdate = jest.fn(); + + renderFilterItem(filter, onRemove, onUpdate); + + const button = screen.getByRole('button', { + name: /Toggle the date range select menu/i, + }); + + userEvent.click(button); + + userEvent.click(await screen.findByRole('button', { + name: /select to view data from custom date range\. select apply filters button to apply selection/i, + })); + + const sd = screen.getByRole('textbox', { name: /start date/i }); + const ed = screen.getByRole('textbox', { name: /end date/i }); + + userEvent.type(sd, '01/01/2021'); + userEvent.type(ed, '01/02/2021'); + + userEvent.click(screen.getByRole('button', { name: /apply date range filters/i })); + expect(onUpdate).toHaveBeenCalledWith('c6d0b3a7-8d51-4265-908a-beaaf16f12d3', 'query', '2021/01/01-2021/01/02'); + + userEvent.click(button); + + userEvent.click(screen.getByRole('button', { + name: /select to view data from year to date\. select apply filters button to apply selection/i, + })); + + userEvent.click(screen.getByRole('button', { name: /apply date range filters/i })); + + const yearToDate = formatDateRange({ + yearToDate: true, + forDateTime: true, + }); + + expect(onUpdate).toHaveBeenCalledWith('c6d0b3a7-8d51-4265-908a-beaaf16f12d3', 'query', yearToDate); + }); + + it('display a specialist filter correctly', () => { + const filter = { + topic: 'role', + condition: 'Is within', + query: ['Early Childhood Specialist'], + id: 'gibberish', + }; + const onRemove = jest.fn(); + const onUpdate = jest.fn(); + renderFilterItem(filter, onRemove, onUpdate); + + const button = screen.getByRole('button', { name: /toggle the change filter by specialists menu/i }); + userEvent.click(button); + + const apply = screen.getByRole('button', { name: /apply filters for the change filter by specialists menu/i }); + userEvent.click(apply); + expect(onUpdate).toHaveBeenCalled(); + + userEvent.click(screen.getByRole('button', { + name: /remove Specialist Is within Early Childhood Specialist filter. click apply filters to make your changes/i, + })); + + expect(onRemove).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/filter/__tests__/FilterMenu.js b/frontend/src/components/filter/__tests__/FilterMenu.js new file mode 100644 index 0000000000..0690378bc7 --- /dev/null +++ b/frontend/src/components/filter/__tests__/FilterMenu.js @@ -0,0 +1,165 @@ +import '@testing-library/jest-dom'; +import React from 'react'; +import { + render, + screen, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import FilterMenu from '../FilterMenu'; + +describe('Filter Menu', () => { + afterAll(() => { + jest.restoreAllMocks(); + }); + + const renderFilterMenu = (filters = [], onApplyFilters = jest.fn()) => { + render( +
+

Filter menu

+
+ +
+
, + ); + }; + + it('toggles the menu', async () => { + renderFilterMenu(); + + const button = screen.getByRole('button', { + name: /filters/i, + }); + + userEvent.click(button); + const message = await screen.findByText('Show results matching the following conditions.'); + expect(message).toBeVisible(); + + const cancel = await screen.findByRole('button', { name: /discard changes and close filter menu/i }); + userEvent.click(cancel); + expect(message).not.toBeVisible(); + }); + + it('a filter can be updated and removed', () => { + const filters = [ + { + id: 'filter1234', + topic: 'startDate', + condition: 'Is within', + query: '2021/01/01-2021/11/05', + }, + ]; + + renderFilterMenu(filters); + + const button = screen.getByRole('button', { + name: /filters/i, + }); + + userEvent.click(button); + + const condition = screen.getByRole('combobox', { name: 'condition' }); + userEvent.selectOptions(condition, 'Is after'); + + const del = screen.getByRole('button', { name: /remove Date range Is after filter. click apply filters to make your changes/i }); + userEvent.click(del); + + expect(document.querySelectorAll('[name="topic"]').length).toBe(0); + }); + + it('filters out bad results', () => { + const filters = [ + { id: '1', topic: 'tt' }, + { id: '2', topic: 't', condition: 'dfs' }, + { + id: '3', topic: 'sdfs', condition: 'dfgfdg', query: 'dfdfg', + }, + ]; + const onApply = jest.fn(); + renderFilterMenu(filters, onApply); + + const button = screen.getByRole('button', { + name: /filters/i, + }); + + userEvent.click(button); + + const apply = screen.getByRole('button', { name: /apply filters to grantee record data/i }); + userEvent.click(apply); + + expect(onApply).toHaveBeenCalledWith([{ + id: '3', topic: 'sdfs', condition: 'dfgfdg', query: 'dfdfg', + }]); + }); + + it('clears the query if the topic is changed', async () => { + const filters = [ + { + id: 'filter1234', + topic: 'startDate', + condition: 'Is after', + query: '2021/10/31', + }, + ]; + + renderFilterMenu(filters); + + const button = screen.getByRole('button', { + name: /filters/i, + }); + + userEvent.click(button); + + const date = screen.getByRole('textbox', { name: /date/i }); + expect(date.value).toBe('10/31/2021'); + + const topic = screen.getByRole('combobox', { name: 'topic' }); + userEvent.selectOptions(topic, 'role'); + userEvent.selectOptions(topic, 'startDate'); + + await screen.findByRole('combobox', { name: 'select a topic and condition first and then select a query' }); + + expect(document.querySelectorAll('[name="topic"]').length).toBe(1); + + const addNew = screen.getByRole('button', { name: /Add new filter/i }); + userEvent.click(addNew); + + expect(document.querySelectorAll('[name="topic"]').length).toBe(2); + }); + + it('closes the menu on blur', async () => { + const filters = [ + { + id: 'filter-2', + display: '', + conditions: [], + topic: 'role', + query: [ + 'Family Engagement Specialist', + ], + condition: 'Contains', + }, + ]; + + renderFilterMenu(filters); + + const button = screen.getByRole('button', { + name: /filters/i, + }); + + userEvent.click(button); + + const message = await screen.findByText('Show results matching the following conditions.'); + userEvent.click(message); + + const specialists = await screen.findByRole('button', { name: /toggle the Change filter by specialists menu/i }); + userEvent.click(specialists); + + const check = await screen.findByRole('checkbox', { name: /select health specialist \(hs\)/i }); + userEvent.click(check); + + expect(message).toBeVisible(); + }); +}); diff --git a/frontend/src/components/filter/__tests__/FilterPills.js b/frontend/src/components/filter/__tests__/FilterPills.js new file mode 100644 index 0000000000..17c99c4d90 --- /dev/null +++ b/frontend/src/components/filter/__tests__/FilterPills.js @@ -0,0 +1,90 @@ +import '@testing-library/jest-dom'; +import React from 'react'; +import { + render, + screen, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import FilterPills from '../FilterPills'; + +describe('Filter Pills', () => { + afterAll(() => { + jest.restoreAllMocks(); + }); + + describe('filter menu button render tests', () => { + const renderFilterMenu = (filters = [], onRemoveFilter = jest.fn()) => { + render(); + }; + + it('renders correctly', async () => { + const filters = [{ + id: '1', + topic: 'role', + condition: 'Contains', + query: [], + }, + { + id: '2', + topic: 'startDate', + condition: 'Is within', + query: '2021/10/01-2021/10/31', + }]; + + renderFilterMenu(filters); + + // Role. + expect(await screen.findByText(/contains/i)).toBeVisible(); + expect(await screen.findByRole('button', { name: /this button removes the filter: specialist contains/i })).toBeVisible(); + + // Date. + expect(await screen.findByText(/date range/i)).toBeVisible(); + expect(await screen.findByText(/is within/i)).toBeVisible(); + expect(await screen.findByText(/10\/01\/2021-10\/31\/2021/i)).toBeVisible(); + expect(await screen.findByRole('button', { name: /this button removes the filter: date range is within 10\/01\/2021-10\/31\/2021/i })).toBeVisible(); + }); + + it('removes filters', async () => { + const filters = [{ + id: '1', + topic: 'role', + condition: 'Contains', + query: [], + }, + { + id: '2', + topic: 'startDate', + condition: 'Is after', + query: '2021/01/01', + }, + ]; + + const onRemoveFilter = jest.fn(); + renderFilterMenu(filters, onRemoveFilter); + + // All filter pills exist. + expect(await screen.findByText(/date range/i)).toBeVisible(); + + // Remove filter pill. + const remoteButton = await screen.findByRole('button', { name: /this button removes the filter: date range is after 01\/01\/2021/i }); + userEvent.click(remoteButton); + expect(onRemoveFilter).toHaveBeenCalledWith('2'); + }); + + it('renders long filter query with ellipsis', async () => { + const filters = [{ + id: '1', + topic: 'role', + condition: 'Contains', + query: ['Specialist 1', 'Specialist 2', 'Specialist 3', 'Specialist 4', 'Specialist 5', 'Specialist 6', 'Specialist 7'], + }, + ]; + + renderFilterMenu(filters); + expect(await (await screen.findAllByText(/specialist 1, specialist 2, specialist 3\.\.\./i)).length).toBe(2); + }); + }); +}); diff --git a/frontend/src/pages/ActivityReport/Pages/Review/HtmlReviewItem.js b/frontend/src/pages/ActivityReport/Pages/Review/HtmlReviewItem.js index 27ef38c55f..908d235a16 100644 --- a/frontend/src/pages/ActivityReport/Pages/Review/HtmlReviewItem.js +++ b/frontend/src/pages/ActivityReport/Pages/Review/HtmlReviewItem.js @@ -1,6 +1,5 @@ import React from 'react'; import PropTypes from 'prop-types'; -import _ from 'lodash'; import { useFormContext } from 'react-hook-form/dist/index.ie11'; import { Editor } from 'react-draft-wysiwyg'; import { getEditorState } from '../../../../utils'; @@ -10,7 +9,7 @@ import { getEditorState } from '../../../../utils'; * Reasoning for another component is to not overload `ReviewItem` */ -const HtmlReviewItem = ({ label, name, path }) => { +const HtmlReviewItem = ({ label, name }) => { const { watch } = useFormContext(); const value = watch(name); let values = value; @@ -19,10 +18,6 @@ const HtmlReviewItem = ({ label, name, path }) => { values = [value]; } - if (path) { - values = values.map((v) => _.get(v, path)); - } - values = values.map((v) => { const defaultEditorState = getEditorState(v || ''); return ( @@ -56,11 +51,7 @@ const HtmlReviewItem = ({ label, name, path }) => { HtmlReviewItem.propTypes = { label: PropTypes.string.isRequired, name: PropTypes.string.isRequired, - path: PropTypes.string, -}; -HtmlReviewItem.defaultProps = { - path: '', }; export default HtmlReviewItem; diff --git a/frontend/src/pages/ActivityReport/Pages/Review/ReviewItem.js b/frontend/src/pages/ActivityReport/Pages/Review/ReviewItem.js index 22d432b279..7828d2a023 100644 --- a/frontend/src/pages/ActivityReport/Pages/Review/ReviewItem.js +++ b/frontend/src/pages/ActivityReport/Pages/Review/ReviewItem.js @@ -4,7 +4,7 @@ import _ from 'lodash'; import { Link } from 'react-router-dom'; import { useFormContext } from 'react-hook-form/dist/index.ie11'; -import { ExternalLink } from '../../../../components/ExternalResourceModal'; +import ExternalLink from '../../../../components/ExternalResourceModal'; import { isValidURL, isExternalURL, isInternalGovernmentLink } from '../../../../utils'; const ReviewItem = ({ diff --git a/frontend/src/pages/ActivityReport/Pages/Review/index.js b/frontend/src/pages/ActivityReport/Pages/Review/index.js index 57ccdee2d9..4fe8be404c 100644 --- a/frontend/src/pages/ActivityReport/Pages/Review/index.js +++ b/frontend/src/pages/ActivityReport/Pages/Review/index.js @@ -1,15 +1,12 @@ import React, { useState, useEffect } from 'react'; import PropTypes from 'prop-types'; -import { - Accordion, -} from '@trussworks/react-uswds'; import { Helmet } from 'react-helmet'; - import Submitter from './Submitter'; import Approver from './Approver'; import PrintSummary from '../PrintSummary'; import { REPORT_STATUSES } from '../../../../Constants'; import './index.css'; +import { Accordion } from '../../../../components/Accordion'; const ReviewSubmit = ({ onSubmit, @@ -76,6 +73,7 @@ const ReviewSubmit = ({ Review and submit + {!isApprover && ( - + <> + + + )} {isApprover @@ -100,7 +101,9 @@ const ReviewSubmit = ({ formData={formData} isPendingApprover={isPendingApprover} > - + <> + + )} diff --git a/frontend/src/pages/ActivityReport/constants.js b/frontend/src/pages/ActivityReport/constants.js index 8d4a2865a4..09988858b9 100644 --- a/frontend/src/pages/ActivityReport/constants.js +++ b/frontend/src/pages/ActivityReport/constants.js @@ -1,3 +1,5 @@ +// Note that if this topic list is changed, it needs also to be changed in +// - src/constants.js export const reasons = [ 'Below Competitive Threshold (CLASS)', 'Below Quality Threshold (CLASS)', @@ -69,7 +71,7 @@ export const programTypes = [ ]; // Note that if this topic list is changed, it needs also to be changed in -// - src/widgets/topicFrequencyGraph.js +// - src/constants.js export const topics = [ 'Behavioral / Mental Health / Trauma', 'Child Assessment, Development, Screening', diff --git a/frontend/src/pages/ApprovedActivityReport/__tests__/index.js b/frontend/src/pages/ApprovedActivityReport/__tests__/index.js index e83ea02cdd..6f9e137777 100644 --- a/frontend/src/pages/ApprovedActivityReport/__tests__/index.js +++ b/frontend/src/pages/ApprovedActivityReport/__tests__/index.js @@ -30,6 +30,7 @@ describe('Activity report print and share view', () => { id: 2, status: '', note: 'note', User: { id: 2, fullName: 'John Smith' }, }, ], + targetPopulations: ['Mid size sedans'], specialistNextSteps: [], granteeNextSteps: [], participants: ['Commander of Pants', 'Princess of Castles'], diff --git a/frontend/src/pages/ApprovedActivityReport/index.js b/frontend/src/pages/ApprovedActivityReport/index.js index b47b92fbb0..a05812b52b 100644 --- a/frontend/src/pages/ApprovedActivityReport/index.js +++ b/frontend/src/pages/ApprovedActivityReport/index.js @@ -1,7 +1,7 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import PropTypes from 'prop-types'; import ReactRouterPropTypes from 'react-router-prop-types'; -import { Grid, useModal, connectModal } from '@trussworks/react-uswds'; +import { Grid, ModalToggleButton } from '@trussworks/react-uswds'; import { Redirect } from 'react-router-dom'; import moment from 'moment-timezone'; import { Helmet } from 'react-helmet'; @@ -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'; /** * @@ -125,6 +126,7 @@ export default function ApprovedActivityReport({ match, user }) { const [participantCount, setParticipantCount] = useState(''); const [reasons, setReasons] = useState(''); const [programType, setProgramType] = useState(''); + const [targetPopulations, setTargetPopulations] = useState(''); const [startDate, setStartDate] = useState(''); const [endDate, setEndDate] = useState(''); const [duration, setDuration] = useState(''); @@ -145,11 +147,10 @@ export default function ApprovedActivityReport({ match, user }) { const [granteeNextSteps, setGranteeNextSteps] = useState([]); const [specialistNextSteps, setSpecialistNextSteps] = useState([]); - const { isOpen, openModal, closeModal } = useModal(); - const ConnectModal = connectModal(Modal); - const [justUnlocked, updatedJustUnlocked] = useState(false); + const modalRef = useRef(); + useEffect(() => { const allowedRegions = allRegionsUserHasPermissionTo(user); @@ -176,6 +177,7 @@ export default function ApprovedActivityReport({ match, user }) { setDisplayId(report.displayId); setCreator(report.author.fullName); setCollaborators(report.collaborators); + setTargetPopulations(report.targetPopulations.map((population) => population).join(', ')); // Approvers. const approversNames = report.approvers.map((a) => a.User.fullName); @@ -192,8 +194,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)); @@ -271,7 +273,7 @@ export default function ApprovedActivityReport({ match, user }) { const onUnlock = async () => { await unlockReport(reportId); - closeModal(); + modalRef.current.toggleModal(false); updatedJustUnlocked(true); }; @@ -323,19 +325,16 @@ export default function ApprovedActivityReport({ match, user }) { : null} {navigator && navigator.clipboard - ? + ? : null} - + {user && user.permissions && canUnlockReports(user) - ? + ? Unlock report : null} - onUnlock()} - onClose={closeModal} - isOpen={isOpen} - openModal={openModal} - closeModal={closeModal} modalId="UnlockReportModal" title="Unlock Activity Report" okButtonText="Unlock" @@ -354,10 +353,10 @@ export default function ApprovedActivityReport({ match, user }) {
must be re-submitted for approval. -
+

- TTA Activity report + TTA activity report {' '} {displayId}

@@ -385,7 +384,8 @@ export default function ApprovedActivityReport({ match, user }) { [ recipientType, 'Reason', - 'Program Type', + 'Program type', + 'Target populations', 'Start date', 'End date', 'Topics', @@ -402,6 +402,7 @@ export default function ApprovedActivityReport({ match, user }) { recipients, reasons, programType, + targetPopulations, startDate, endDate, topics, diff --git a/frontend/src/pages/GranteeRecord/__tests__/index.js b/frontend/src/pages/GranteeRecord/__tests__/index.js index 0c7c15a3e3..4c443179f6 100644 --- a/frontend/src/pages/GranteeRecord/__tests__/index.js +++ b/frontend/src/pages/GranteeRecord/__tests__/index.js @@ -1,11 +1,15 @@ import '@testing-library/jest-dom'; import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { act } from 'react-dom/test-utils'; import fetchMock from 'fetch-mock'; import { Router } from 'react-router'; import { createMemoryHistory } from 'history'; import GranteeRecord from '../index'; +import { formatDateRange } from '../../../components/DateRangeSelect'; + +const yearToDate = formatDateRange({ yearToDate: true, forDateTime: true }); const memoryHistory = createMemoryHistory(); @@ -86,6 +90,16 @@ describe('grantee record page', () => { fetchMock.get('/api/user', user); fetchMock.get('/api/widgets/overview', overview); fetchMock.get('/api/widgets/overview?region.in[]=45&granteeId.in[]=1', overview); + fetchMock.get(`/api/widgets/overview?startDate.win=${yearToDate}®ion.in[]=45&granteeId.in[]=1`, overview); + fetchMock.get('/api/activity-reports?sortBy=updatedAt&sortDir=desc&offset=0&limit=10', { count: 0, rows: [] }); + fetchMock.get(`/api/activity-reports?sortBy=updatedAt&sortDir=desc&offset=0&limit=10&startDate.win=${yearToDate}®ion.in[]=45&granteeId.in[]=1`, { count: 0, rows: [] }); + fetchMock.get('/api/activity-reports?sortBy=updatedAt&sortDir=desc&offset=0&limit=10®ion.in[]=45&granteeId.in[]=1', { count: 0, rows: [] }); + fetchMock.get(`/api/activity-reports?sortBy=updatedAt&sortDir=desc&offset=0&limit=10&startDate.win=${yearToDate}`, { count: 0, rows: [] }); + fetchMock.get('/api/widgets/frequencyGraph', 200); + fetchMock.get('/api/widgets/frequencyGraph?region.in[]=45&granteeId.in[]=1', 200); + fetchMock.get(`/api/widgets/frequencyGraph?startDate.win=${yearToDate}`, 200); + fetchMock.get('/api/widgets/targetPopulationTable?region.in[]=45&granteeId.in[]=1', 200); + fetchMock.get(`/api/widgets/targetPopulationTable?startDate.win=${yearToDate}®ion.in[]=45&granteeId.in[]=1`, 200); }); afterEach(() => { fetchMock.restore(); @@ -135,7 +149,16 @@ describe('grantee record page', () => { fetchMock.get('/api/grantee/1?region.in[]=45', theMightyGrantee); memoryHistory.push('/grantee/1/tta-history?region=45'); act(() => renderGranteeRecord(memoryHistory)); - const arLabel = await screen.findByText(/the total number of approved activity reports\. click to visually reveal this information/i); - expect(arLabel).toBeInTheDocument(); + await waitFor(() => { + const ar = screen.getByText(/the total number of approved activity reports\. click to visually reveal this information/i); + expect(ar).toBeInTheDocument(); + }); + + const remove = screen.getByRole('button', { + name: /this button removes the filter: date range is within 01\/01\/2021/i, + }); + + act(() => userEvent.click(remove)); + expect(remove).not.toBeInTheDocument(); }); }); diff --git a/frontend/src/pages/GranteeRecord/index.js b/frontend/src/pages/GranteeRecord/index.js index 3423725bf7..0e56a89229 100644 --- a/frontend/src/pages/GranteeRecord/index.js +++ b/frontend/src/pages/GranteeRecord/index.js @@ -1,11 +1,13 @@ import React, { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import ReactRouterPropTypes from 'react-router-prop-types'; -import { v4 as uuidv4 } from 'uuid'; + import { Switch, Route } from 'react-router'; +import { Helmet } from 'react-helmet'; import { DECIMAL_BASE } from '../../Constants'; import { getGrantee } from '../../fetchers/grantee'; import GranteeTabs from './components/GranteeTabs'; + import { HTTPError } from '../../fetchers'; import './index.css'; import Profile from './pages/Profile'; @@ -23,27 +25,8 @@ export default function GranteeRecord({ match, location }) { 'grants.number': '', granteeId, }); - const [filters, setFilters] = useState([]); - const [error, setError] = useState(); - - useEffect(() => { - const filtersToApply = [ - { - id: uuidv4(), - topic: 'region', - condition: 'Contains', - query: regionId, - }, - { - id: uuidv4(), - topic: 'granteeId', - condition: 'Contains', - query: granteeId, - }, - ]; - setFilters(filtersToApply); - }, [granteeId, regionId]); + const [error, setError] = useState(); useEffect(() => { async function fetchGrantee(id, region) { @@ -75,32 +58,51 @@ export default function GranteeRecord({ match, location }) { return ( <> + + + Grantee Profile - + {' '} + {granteeName} + + { - error ? ( -
-
-

- {error} -

-
-
- ) : ( - <> -

{granteeName}

- - } - /> - } - /> - - - ) -} + error ? ( +
+
+

+ {error} +

+
+
+ ) : ( + <> +

{granteeName}

+ + ( + + )} + /> + ( + + )} + /> + + + ) + } ); } diff --git a/frontend/src/pages/GranteeRecord/pages/Profile.js b/frontend/src/pages/GranteeRecord/pages/Profile.js index cc79922c8e..e121def8c8 100644 --- a/frontend/src/pages/GranteeRecord/pages/Profile.js +++ b/frontend/src/pages/GranteeRecord/pages/Profile.js @@ -1,21 +1,31 @@ import React from 'react'; import PropTypes from 'prop-types'; +import { Helmet } from 'react-helmet'; import { Grid } from '@trussworks/react-uswds'; import GranteeSummary from '../components/GranteeSummary'; import GrantList from '../components/GrantsList'; -export default function Profile({ granteeSummary, regionId }) { +export default function Profile({ granteeSummary, regionId, granteeName }) { return ( -
- - - + <> + + + Grantee Profile - + {' '} + {granteeName} + + +
+ + + + + + + - - - - -
+
+ ); } @@ -31,4 +41,9 @@ Profile.propTypes = { }), ), }).isRequired, + granteeName: PropTypes.string, +}; + +Profile.defaultProps = { + granteeName: '', }; diff --git a/frontend/src/pages/GranteeRecord/pages/TTAHistory.css b/frontend/src/pages/GranteeRecord/pages/TTAHistory.css new file mode 100644 index 0000000000..e53e54bf7c --- /dev/null +++ b/frontend/src/pages/GranteeRecord/pages/TTAHistory.css @@ -0,0 +1,3 @@ +.ttahub-filter-menu [aria-label="open filters for this page"] + .smart-hub--dropdown-menu--contents { + min-width: 700px; +} \ No newline at end of file diff --git a/frontend/src/pages/GranteeRecord/pages/TTAHistory.js b/frontend/src/pages/GranteeRecord/pages/TTAHistory.js index 09ee6553d0..1508ec83e1 100644 --- a/frontend/src/pages/GranteeRecord/pages/TTAHistory.js +++ b/frontend/src/pages/GranteeRecord/pages/TTAHistory.js @@ -1,34 +1,134 @@ -import React from 'react'; +import React, { useState } from 'react'; import PropTypes from 'prop-types'; +import { Helmet } from 'react-helmet'; +import { Grid } from '@trussworks/react-uswds'; +import { v4 as uuidv4 } from 'uuid'; +import { formatDateRange } from '../../../components/DateRangeSelect'; +import ActivityReportsTable from '../../../components/ActivityReportsTable'; +import FrequencyGraph from '../../../widgets/FrequencyGraph'; import Overview from '../../../widgets/DashboardOverview'; +import FilterMenu from '../../../components/filter/FilterMenu'; +import FilterPills from '../../../components/filter/FilterPills'; +import TargetPopulationsTable from '../../../widgets/TargetPopulationsTable'; +import './TTAHistory.css'; + +const defaultDate = formatDateRange({ + yearToDate: true, + forDateTime: true, +}); + +function expandFilters(filters) { + const arr = []; + + filters.forEach((filter) => { + const { topic, query, condition } = filter; + if (Array.isArray(query)) { + query.forEach((q) => { + arr.push({ + topic, + condition, + query: q, + }); + }); + } else { + arr.push(filter); + } + }); + + return arr; +} + +export default function TTAHistory({ + granteeName, granteeId, regionId, +}) { + const [filters, setFilters] = useState([ + { + id: uuidv4(), + topic: 'startDate', + condition: 'Is within', + query: defaultDate, + }, + ]); + + const filtersToApply = [ + ...expandFilters(filters), + { + topic: 'region', + condition: 'Contains', + query: regionId, + }, + { + topic: 'granteeId', + condition: 'Contains', + query: granteeId, + }, + ]; + + const onRemoveFilter = (id) => { + const newFilters = [...filters]; + const index = newFilters.findIndex((item) => item.id === id); + if (index !== -1) { + newFilters.splice(index, 1); + setFilters(newFilters); + } + }; + + const onApply = (newFilters) => { + setFilters([ + ...newFilters, + ]); + }; -export default function TTAHistory({ filters }) { return ( -
- - -
+ <> + + + Grantee TTA History - + {' '} + {granteeName} + + +
+
+ + +
+ + + + + + + + + + +
+ ); } TTAHistory.propTypes = { - filters: PropTypes.arrayOf(PropTypes.shape({ - id: PropTypes.string, - topic: PropTypes.string, - condition: PropTypes.string, - query: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - })), + granteeName: PropTypes.string, + granteeId: PropTypes.string.isRequired, + regionId: PropTypes.string.isRequired, }; TTAHistory.defaultProps = { - filters: [], + granteeName: '', }; diff --git a/frontend/src/pages/GranteeRecord/pages/__tests__/TTAHistory.js b/frontend/src/pages/GranteeRecord/pages/__tests__/TTAHistory.js index 88a67dda7a..4d0f45e761 100644 --- a/frontend/src/pages/GranteeRecord/pages/__tests__/TTAHistory.js +++ b/frontend/src/pages/GranteeRecord/pages/__tests__/TTAHistory.js @@ -2,41 +2,41 @@ import '@testing-library/jest-dom'; import React from 'react'; import { render, screen, act } from '@testing-library/react'; import fetchMock from 'fetch-mock'; +import { Router } from 'react-router'; +import userEvent from '@testing-library/user-event'; +import { createMemoryHistory } from 'history'; import TTAHistory from '../TTAHistory'; +import { formatDateRange } from '../../../../components/DateRangeSelect'; + +const memoryHistory = createMemoryHistory(); +const yearToDate = formatDateRange({ yearToDate: true, forDateTime: true }); describe('Grantee Record - TTA History', () => { - const response = { + const overviewResponse = { numReports: '1', numGrants: '1', inPerson: '0', sumDuration: '1.0', numParticipants: '1', }; - const filtersToApply = [ - { - id: 1, - topic: 'region', - condition: 'Contains', - query: 400, - }, - { - id: 2, - topic: 'granteeId', - condition: 'Contains', - query: 100, - }, - { - id: 3, - topic: 'modelType', - condition: 'Is', - query: 'grant', - }, - ]; - - const renderTTAHistory = (filters = filtersToApply) => { - render(); + const tableResponse = { + count: 0, + rows: [], + }; + + const renderTTAHistory = () => { + render( + + + , + ); }; beforeEach(async () => { - const url = '/api/widgets/overview?region.in[]=400&granteeId.in[]=100&modelType.is=grant'; - fetchMock.get(url, response); + const overviewUrl = `/api/widgets/overview?startDate.win=${yearToDate}®ion.in[]=1&granteeId.in[]=401`; + const tableUrl = `/api/activity-reports?sortBy=updatedAt&sortDir=desc&offset=0&limit=10&startDate.win=${yearToDate}®ion.in[]=1&granteeId.in[]=401`; + fetchMock.get(overviewUrl, overviewResponse); + fetchMock.get(tableUrl, tableResponse); + + fetchMock.get(`/api/widgets/targetPopulationTable?startDate.win=${yearToDate}®ion.in[]=1&granteeId.in[]=401`, 200); + fetchMock.get(`/api/widgets/frequencyGraph?startDate.win=${yearToDate}®ion.in[]=1&granteeId.in[]=401`, 200); }); afterEach(() => { @@ -45,7 +45,35 @@ describe('Grantee Record - TTA History', () => { it('renders the TTA History page appropriately', async () => { act(() => renderTTAHistory()); - const onePointOh = await screen.findByText('1.0'); - expect(onePointOh).toBeInTheDocument(); + const overview = document.querySelector('.smart-hub--dashboard-overview'); + expect(overview).toBeTruthy(); + }); + + it('renders the activity reports table', async () => { + renderTTAHistory(); + const reports = await screen.findByText('Activity Reports'); + expect(reports).toBeInTheDocument(); + }); + + it('combines filters appropriately', async () => { + renderTTAHistory(); + + fetchMock.get('/api/activity-reports?sortBy=updatedAt&sortDir=desc&offset=0&limit=10&role.in[]=Family%20Engagement%20Specialist&role.in[]=Grantee%20Specialist®ion.in[]=1&granteeId.in[]=401', tableResponse); + fetchMock.get('/api/widgets/targetPopulationTable?role.in[]=Family%20Engagement%20Specialist&role.in[]=Grantee%20Specialist®ion.in[]=1&granteeId.in[]=401', 200); + fetchMock.get('/api/widgets/frequencyGraph?role.in[]=Family%20Engagement%20Specialist&role.in[]=Grantee%20Specialist®ion.in[]=1&granteeId.in[]=401', 200); + fetchMock.get('/api/widgets/overview?role.in[]=Family%20Engagement%20Specialist&role.in[]=Grantee%20Specialist®ion.in[]=1&granteeId.in[]=401', overviewResponse); + + await act(async () => userEvent.click(await screen.findByRole('button', { name: /open filters for this page/i }))); + await act(async () => userEvent.selectOptions(await screen.findByRole('combobox', { name: 'topic' }), 'role')); + await act(async () => userEvent.selectOptions(await screen.findByRole('combobox', { name: 'condition' }), 'Contains')); + await act(async () => userEvent.click(await screen.findByRole('button', { name: /toggle the Change filter by specialists menu/i }))); + await act(async () => userEvent.click(await screen.findByText(/family engagement specialist \(fes\)/i))); + await act(async () => userEvent.click(await screen.findByText(/grantee specialist \(gs\)/i))); + await act(async () => userEvent.click(await screen.findByRole('button', { name: /Apply filters for the Change filter by specialists menu/i }))); + await act(async () => userEvent.click(await screen.findByRole('button', { name: /apply filters to grantee record data/i }))); + + expect( + await screen.findByRole('button', { name: /this button removes the filter: Specialist Contains Family Engagement Specialist, Grantee Specialist/i }), + ).toBeVisible(); }); }); diff --git a/frontend/src/pages/GranteeSearch/__tests__/index.js b/frontend/src/pages/GranteeSearch/__tests__/index.js index e1fbd2cb54..0b9dbbc1a7 100644 --- a/frontend/src/pages/GranteeSearch/__tests__/index.js +++ b/frontend/src/pages/GranteeSearch/__tests__/index.js @@ -181,17 +181,6 @@ describe('the grantee search page', () => { jest.clearAllMocks(); }); - it('shows the correct heading and regional select text', () => { - const user = { - ...userBluePrint, - }; - - renderGranteeSearch(user); - expect(screen.getByRole('heading', { name: /grantee records/i })).toBeInTheDocument(); - const regionalSelect = screen.getByRole('button', { name: /open regional select menu/i }); - expect(regionalSelect).toBeInTheDocument(); - }); - it('shows the correct regional select text when user has all regions', () => { const user = { ...userBluePrint, @@ -200,8 +189,8 @@ describe('the grantee search page', () => { fetchMock.get('/api/grantee/search?s=®ion.in[]=1®ion.in[]=2&sortBy=name&direction=asc&offset=0', res); renderGranteeSearch(user); - - const regionalSelect = screen.getByRole('button', { name: /open regional select menu/i }); + expect(screen.getByRole('heading', { name: /grantee records/i })).toBeInTheDocument(); + const regionalSelect = screen.getByRole('button', { name: 'toggle regional select menu' }); expect(regionalSelect).toHaveTextContent('All Regions'); }); @@ -228,6 +217,31 @@ describe('the grantee search page', () => { expect(fetchMock.calls().length).toBe(2); }); + it('an error sets the result to nothing', async () => { + const user = { ...userBluePrint }; + + renderGranteeSearch(user); + + const button = screen.getByRole('button', { name: /search for matching grantees/i }); + const searchBox = screen.getByRole('searchbox'); + await waitFor(() => expect(button).not.toBeDisabled()); + + expect(document.querySelectorAll('tbody tr').length).toBe(12); + + fetchMock.restore(); + + const url = join(granteeUrl, 'search?s=ground%20control®ion.in[]=1&sortBy=name&direction=asc&offset=0'); + fetchMock.get(url, 500); + + act(() => { + userEvent.type(searchBox, query); + fireEvent.click(button); + }); + + await waitFor(() => expect(button).not.toBeDisabled()); + expect(document.querySelectorAll('tbody tr').length).toBe(0); + }); + it('the regional select works', async () => { const user = { ...userBluePrint }; @@ -238,7 +252,7 @@ describe('the grantee search page', () => { const searchBox = screen.getByRole('searchbox'); const button = screen.getByRole('button', { name: /search for matching grantees/i }); - const regionalSelect = screen.getByRole('button', { name: /open regional select menu/i }); + const regionalSelect = screen.getByRole('button', { name: 'toggle regional select menu' }); await waitFor(() => expect(button).not.toBeDisabled()); @@ -266,6 +280,8 @@ describe('the grantee search page', () => { it('the regional select works with all regions', async () => { const user = { ...userBluePrint, homeRegionId: 14 }; + fetchMock.get('/api/grantee/search?s=®ion.in[]=1®ion.in[]=2&sortBy=name&direction=asc&offset=0', res); + renderGranteeSearch(user); const url = join(granteeUrl, 'search?s=ground%20control®ion.in[]=1®ion.in[]=2&sortBy=name&direction=asc&offset=0'); fetchMock.get(url, res); @@ -300,6 +316,62 @@ describe('the grantee search page', () => { expect(document.querySelector('table.usa-table')).toBeInTheDocument(); }); + it('requests a sort', async () => { + const user = { ...userBluePrint }; + renderGranteeSearch(user); + + const url = join(granteeUrl, 'search', `?s=${encodeURIComponent(query)}`, '®ion.in[]=1&sortBy=name&direction=asc&offset=0'); + fetchMock.get(url, res); + + const searchBox = screen.getByRole('searchbox'); + const button = screen.getByRole('button', { name: /search for matching grantees/i }); + + await waitFor(() => expect(button).not.toBeDisabled()); + + act(() => { + userEvent.type(searchBox, query); + fireEvent.click(button); + }); + + const changeDirection = await screen.findByRole('button', { name: /grantee name\. activate to sort descending/i }); + const changeDirectionUrl = join(granteeUrl, 'search', `?s=${encodeURIComponent(query)}`, '®ion.in[]=1&sortBy=name&direction=desc&offset=0'); + + fetchMock.get(changeDirectionUrl, res); + + await waitFor(() => expect(changeDirection).not.toBeDisabled()); + + act(() => { + fireEvent.click(changeDirection); + }); + + const changeSort = await screen.findByRole('button', { name: /program specialist\. activate to sort ascending/i }); + const chageSortUrl = join( + granteeUrl, + 'search', + `?s=${encodeURIComponent(`${query}major tom`)}`, + '®ion.in[]=1&sortBy=programSpecialist&direction=desc&offset=0', + ); + + fetchMock.get(chageSortUrl, res); + + await waitFor(() => expect(changeSort).not.toBeDisabled()); + + act(() => { + userEvent.type(searchBox, 'major tom'); + fireEvent.click(changeSort); + }); + + const expectedMocks = [ + '/api/grantee/search?s=®ion.in[]=1&sortBy=name&direction=asc&offset=0', + '/api/grantee/search?s=ground%20control®ion.in[]=1&sortBy=name&direction=asc&offset=0', + '/api/grantee/search?s=ground%20control®ion.in[]=1&sortBy=name&direction=desc&offset=0', + '/api/grantee/search?s=ground%20controlmajor%20tom®ion.in[]=1&sortBy=programSpecialist&direction=desc&offset=0', + ]; + + const mocks = fetchMock.calls().map((call) => call[0]); + expect(mocks).toStrictEqual(expectedMocks); + }); + it('requests the next page', async () => { const user = { ...userBluePrint }; renderGranteeSearch(user); diff --git a/frontend/src/pages/GranteeSearch/index.css b/frontend/src/pages/GranteeSearch/index.css index 7dbfc590c3..f6a120848d 100644 --- a/frontend/src/pages/GranteeSearch/index.css +++ b/frontend/src/pages/GranteeSearch/index.css @@ -11,4 +11,11 @@ .ttahub-grantee-search--submit-button { border-radius: 0 0.25rem 0.25rem 0; +} + +.usa-table tr:nth-child(2n+1) { + background-color: #f8f8f8; +} +.usa-table--striped tbody tr:nth-child(2n+1) td, .usa-table--striped tbody tr:nth-child(2n+1) th { + background-color: transparent; } \ No newline at end of file diff --git a/frontend/src/pages/GranteeSearch/index.js b/frontend/src/pages/GranteeSearch/index.js index 5338417021..6f59105106 100644 --- a/frontend/src/pages/GranteeSearch/index.js +++ b/frontend/src/pages/GranteeSearch/index.js @@ -134,7 +134,9 @@ function GranteeSearch({ user }) {
)}
- + { /* eslint-disable-next-line jsx-a11y/label-has-associated-control */ } + + + + + { showAccessibleData + ? ( + + ) + : } + + ); +} + +FreqGraph.propTypes = { + data: PropTypes.oneOfType([ + PropTypes.arrayOf( + PropTypes.shape({ + topic: PropTypes.string, + count: PropTypes.number, + }), + ), PropTypes.shape({}), + ]), + loading: PropTypes.bool.isRequired, +}; + +FreqGraph.defaultProps = { + data: { [TOPIC_STR]: [], [REASON_STR]: [] }, +}; + +export default withWidgetData(FreqGraph, 'frequencyGraph'); diff --git a/frontend/src/widgets/ReasonList.css b/frontend/src/widgets/ReasonList.css index f7e72c132b..e69de29bb2 100644 --- a/frontend/src/widgets/ReasonList.css +++ b/frontend/src/widgets/ReasonList.css @@ -1,61 +0,0 @@ -.reason-list { - font-family: Source Sans Pro Web, Helvetica Neue, Helvetica, Roboto, Arial, - sans-serif; - height: calc(100% - 1.5rem); -} - -.reason-list .usa-table-container--scrollable { - min-height: 393px; - max-height: 393px; - overflow-y: auto; - overflow-x: auto; -} - -.reason-list .usa-table { - width: 100%; - height: 100%; -} - -.reason-list .usa-table caption { - margin-bottom: 26px; -} - -.reason-list .usa-table tr { - background-color: #fff; - border-bottom: 0px; - font-weight: bold; -} - -.reason-list .usa-table th { - border-bottom: 0px; - text-align: left; - padding: 0px; - font-weight: bold; - position: sticky; - top: 0; - background-color: white; -} - -.reason-list .usa-table th:nth-child(2), .reason-list .usa-table td:nth-child(2) { - padding-left: 60px; -} - -.reason-list .usa-table td { - border: none; - padding-top: 10px; - padding-bottom: 0px; - padding-left: 0px; - padding-right: 0px; - text-align: left; -} - -.reason-list .usa-table td:first-child { - width: 320px; - white-space: normal; -} - -.reason-list .usa-table p:first-child { - margin: 0px; - padding: 0px; -} - diff --git a/frontend/src/widgets/ReasonList.js b/frontend/src/widgets/ReasonList.js index 6e81e9a5b2..696c6f003b 100644 --- a/frontend/src/widgets/ReasonList.js +++ b/frontend/src/widgets/ReasonList.js @@ -1,53 +1,36 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { Table } from '@trussworks/react-uswds'; import withWidgetData from './withWidgetData'; -import Container from '../components/Container'; -import DateTime from '../components/DateTime'; -import './ReasonList.css'; -import FormatNumber from './WidgetHelper'; +import formatNumber from './WidgetHelper'; +import TableWidget from './TableWidget'; -function ReasonList({ data, dateTime, loading }) { - const renderReasonList = () => { - if (data && Array.isArray(data) && data.length > 0) { - return data.map((reason) => ( - - - {reason.name} - - - {FormatNumber(reason.count)} - - - )); - } - return null; - }; +const renderReasonList = (data) => { + if (data && Array.isArray(data) && data.length > 0) { + return data.map((reason) => ( + + + {reason.name} + + + {formatNumber(reason.count)} + + + )); + } + return null; +}; +function ReasonList({ data, dateTime, loading }) { return ( - -
- - - - - - - - - - { - renderReasonList() - } - -
-
-

Reasons in Activity Reports

- -
-
Reason# of Activities
-
-
+ ); } diff --git a/frontend/src/widgets/TableWidget.css b/frontend/src/widgets/TableWidget.css new file mode 100644 index 0000000000..c82d34c677 --- /dev/null +++ b/frontend/src/widgets/TableWidget.css @@ -0,0 +1,59 @@ +.smarthub-table-widget { + height: calc(100% - 1.5rem); +} + +.smarthub-table-widget .usa-table-container--scrollable { + min-height: 393px; + max-height: 393px; + overflow-y: auto; + overflow-x: auto; +} + +.smarthub-table-widget .usa-table { + width: 100%; + height: 100%; +} + +.smarthub-table-widget .usa-table caption { + margin-bottom: 26px; +} + +.smarthub-table-widget .usa-table tr { + background-color: #fff; + border-bottom: 0px; + font-weight: bold; +} + +.smarthub-table-widget .usa-table th { + border-bottom: 0px; + text-align: left; + padding: 0px; + font-weight: bold; + position: sticky; + top: 0; + background-color: white; +} + +.smarthub-table-widget .usa-table th:nth-child(2), .smarthub-table-widget .usa-table td:nth-child(2) { + padding-left: 60px; +} + +.smarthub-table-widget .usa-table td { + border: none; + padding-top: 10px; + padding-bottom: 0px; + padding-left: 0px; + padding-right: 0px; + text-align: left; +} + +.smarthub-table-widget .usa-table td:first-child { + width: 320px; + white-space: normal; +} + +.smarthub-table-widget .usa-table p:first-child { + margin: 0px; + padding: 0px; +} + diff --git a/frontend/src/widgets/TableWidget.js b/frontend/src/widgets/TableWidget.js new file mode 100644 index 0000000000..80f96042a3 --- /dev/null +++ b/frontend/src/widgets/TableWidget.js @@ -0,0 +1,72 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Table } from '@trussworks/react-uswds'; +import Container from '../components/Container'; +import DateTime from '../components/DateTime'; +import './TableWidget.css'; + +export default function TableWidget( + { + data, + headings, + dateTime, + showDateTime, + loading, + loadingLabel, + title, + renderData, + }, +) { + return ( + +
+ + + + + {headings.map((heading) => )} + + + + { renderData(data) } + +
+
+

{title}

+ {showDateTime && } +
+
{heading}
+
+
+ ); +} + +TableWidget.propTypes = { + data: PropTypes.oneOfType([ + PropTypes.arrayOf( + PropTypes.shape({ + name: PropTypes.string, + count: PropTypes.number, + }), + ), PropTypes.shape({}), + ]), + dateTime: PropTypes.shape({ + timestamp: PropTypes.string, + label: PropTypes.string, + }), + loading: PropTypes.bool.isRequired, + loadingLabel: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + headings: PropTypes.arrayOf(PropTypes.string).isRequired, + renderData: PropTypes.func.isRequired, + showDateTime: PropTypes.bool, +}; + +TableWidget.defaultProps = { + showDateTime: true, + dateTime: { + timestamp: '', + label: '', + }, + data: [], +}; diff --git a/frontend/src/widgets/TargetPopulationsTable.js b/frontend/src/widgets/TargetPopulationsTable.js new file mode 100644 index 0000000000..040b81b151 --- /dev/null +++ b/frontend/src/widgets/TargetPopulationsTable.js @@ -0,0 +1,61 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import withWidgetData from './withWidgetData'; +import formatNumber from './WidgetHelper'; +import TableWidget from './TableWidget'; + +const renderTargetPopulationTable = (data) => { + if (data && Array.isArray(data) && data.length > 0) { + return data.map((population) => ( + + + {population.name} + + + {formatNumber(population.count)} + + + )); + } + return null; +}; + +export function TargetPopulationTable({ data, loading }) { + return ( + + ); +} + +TargetPopulationTable.propTypes = { + data: PropTypes.oneOfType([ + PropTypes.arrayOf( + PropTypes.shape({ + name: PropTypes.string, + count: PropTypes.number, + }), + ), PropTypes.shape({}), + ]), + dateTime: PropTypes.shape({ + timestamp: PropTypes.string, + label: PropTypes.string, + }), + loading: PropTypes.bool.isRequired, +}; + +TargetPopulationTable.defaultProps = { + dateTime: { + timestamp: '', + label: '', + }, + data: [], +}; + +export default withWidgetData(TargetPopulationTable, 'targetPopulationTable'); diff --git a/frontend/src/widgets/TopicFrequencyGraph.js b/frontend/src/widgets/TopicFrequencyGraph.js index c6962ed44a..06349a7973 100644 --- a/frontend/src/widgets/TopicFrequencyGraph.js +++ b/frontend/src/widgets/TopicFrequencyGraph.js @@ -8,35 +8,7 @@ import DateTime from '../components/DateTime'; import AccessibleWidgetData from './AccessibleWidgetData'; import './TopicFrequencyGraph.css'; import ButtonSelect from '../components/ButtonSelect'; -import CheckboxSelect from '../components/CheckboxSelect'; - -export const ROLES_MAP = [ - { - selectValue: 1, - value: 'Early Childhood Specialist', - label: 'Early Childhood Specialist (ECS)', - }, - { - selectValue: 2, - value: 'Family Engagement Specialist', - label: 'Family Engagement Specialist (FES)', - }, - { - selectValue: 3, - value: 'Grantee Specialist', - label: 'Grantee Specialist (GS)', - }, - { - selectValue: 4, - value: 'Health Specialist', - label: 'Health Specialist (HS)', - }, - { - selectValue: 5, - value: 'System Specialist', - label: 'System Specialist (SS)', - }, -]; +import SpecialistSelect from '../components/SpecialistSelect'; export const SORT_ORDER = { DESC: 1, @@ -81,7 +53,7 @@ export function topicsWithLineBreaks(reason) { } export function TopicFrequencyGraphWidget({ - data, dateTime, updateRoles, loading, + data, dateTime, onApplyRoles, loading, }) { // whether to show the data as accessible widget data or not const [showAccessibleData, setShowAccessibleData] = useState(false); @@ -181,11 +153,6 @@ export function TopicFrequencyGraphWidget({ setOrder(selected.value); }; - const onApplyRoles = (selected) => { - // we may get these as a string, so we cast them to ints - updateRoles(selected.map((s) => parseInt(s, 10))); - }; - // toggle the data table function toggleType() { setShowAccessibleData(!showAccessibleData); @@ -225,21 +192,7 @@ export function TopicFrequencyGraphWidget({ ] } /> - ({ - value: role.selectValue, - label: role.label, - })) - } - /> +