From 5f665946e31047a1f593c862dd308b741d78162c Mon Sep 17 00:00:00 2001 From: Josh Salisbury Date: Thu, 24 Sep 2020 09:52:53 -0500 Subject: [PATCH 1/3] Add Admin UI (#30) * Add Admin UI The admin UI allows setting of user info and permissions. The user info is the user's name, email, job title and their region. Permissions are modeled to be flexible. A user permission is made up of a region and a scope. A scope represents the ability to do something specific, like READ_REPORTS. Combined with the region the permission system should be flexible enough to handle user's that need permissions that cross regions. Not yet done: * Any API work, including saving/modeling/REST endpoints * Checking/usage of permissions/user info in any other part of the TTAHUB * Add title to admin page * Change first/lastname field to full name * Set Harry's region to 1 This is to closer match the current permissions for the user * Add comment about region 0 --- frontend/package.json | 8 +- frontend/src/App.js | 7 +- frontend/src/Constants.js | 89 +++++++ frontend/src/components/Header.js | 4 +- .../src/components/IndeterminateCheckbox.css | 8 + .../src/components/IndeterminateCheckbox.js | 51 ++++ frontend/src/components/JobTitleDropdown.js | 36 +++ frontend/src/components/RegionDropdown.js | 36 +++ .../__tests__/IndeterminateCheckbox.js | 33 +++ .../components/__tests__/JobTitleDropdown.js | 18 ++ .../components/__tests__/RegionDropdown.js | 18 ++ frontend/src/images/minus.svg | 1 + frontend/src/pages/Admin/PermissionHelpers.js | 64 +++++ frontend/src/pages/Admin/UserInfo.js | 49 ++++ frontend/src/pages/Admin/UserPermissions.js | 150 ++++++++++++ frontend/src/pages/Admin/UserSection.js | 90 ++++++++ .../Admin/__tests__/PermissionHelpers.js | 86 +++++++ .../src/pages/Admin/__tests__/UserInfo.js | 58 +++++ .../pages/Admin/__tests__/UserPermissions.js | 59 +++++ .../src/pages/Admin/__tests__/UserSection.js | 53 +++++ frontend/src/pages/Admin/__tests__/index.js | 52 +++++ .../Admin/components/CurrentPermissions.js | 21 ++ .../components/PermissionCheckboxLabel.js | 19 ++ .../__tests__/CurrentPermissions.js | 20 ++ .../__tests__/PermissionCheckboxLabel.js | 13 ++ frontend/src/pages/Admin/index.js | 218 ++++++++++++++++++ frontend/src/setupTests.js | 10 + frontend/src/testHelpers.js | 13 ++ frontend/yarn.lock | 99 ++++++-- src/index.js | 4 +- 30 files changed, 1355 insertions(+), 32 deletions(-) create mode 100644 frontend/src/Constants.js create mode 100644 frontend/src/components/IndeterminateCheckbox.css create mode 100644 frontend/src/components/IndeterminateCheckbox.js create mode 100644 frontend/src/components/JobTitleDropdown.js create mode 100644 frontend/src/components/RegionDropdown.js create mode 100644 frontend/src/components/__tests__/IndeterminateCheckbox.js create mode 100644 frontend/src/components/__tests__/JobTitleDropdown.js create mode 100644 frontend/src/components/__tests__/RegionDropdown.js create mode 100644 frontend/src/images/minus.svg create mode 100644 frontend/src/pages/Admin/PermissionHelpers.js create mode 100644 frontend/src/pages/Admin/UserInfo.js create mode 100644 frontend/src/pages/Admin/UserPermissions.js create mode 100644 frontend/src/pages/Admin/UserSection.js create mode 100644 frontend/src/pages/Admin/__tests__/PermissionHelpers.js create mode 100644 frontend/src/pages/Admin/__tests__/UserInfo.js create mode 100644 frontend/src/pages/Admin/__tests__/UserPermissions.js create mode 100644 frontend/src/pages/Admin/__tests__/UserSection.js create mode 100644 frontend/src/pages/Admin/__tests__/index.js create mode 100644 frontend/src/pages/Admin/components/CurrentPermissions.js create mode 100644 frontend/src/pages/Admin/components/PermissionCheckboxLabel.js create mode 100644 frontend/src/pages/Admin/components/__tests__/CurrentPermissions.js create mode 100644 frontend/src/pages/Admin/components/__tests__/PermissionCheckboxLabel.js create mode 100644 frontend/src/pages/Admin/index.js create mode 100644 frontend/src/testHelpers.js diff --git a/frontend/package.json b/frontend/package.json index 4e1d218930..6b01c7cf69 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,11 +6,13 @@ "@testing-library/jest-dom": "^4.2.4", "@trussworks/react-uswds": "^1.9.1", "http-proxy-middleware": "^1.0.5", + "lodash": "^4.17.20", "prop-types": "^15.7.2", "react": "^16.13.1", "react-dom": "^16.13.1", "react-router": "^5.2.0", "react-router-dom": "^5.2.0", + "react-router-prop-types": "^1.0.5", "react-scripts": "3.4.1", "uswds": "^2.8.1" }, @@ -85,9 +87,10 @@ ] }, "devDependencies": { - "@testing-library/dom": "^7.21.7", + "@sheerun/mutationobserver-shim": "^0.3.3", + "@testing-library/dom": "^7.24.2", "@testing-library/react": "^10.4.9", - "@testing-library/user-event": "^7.1.2", + "@testing-library/user-event": "^12.1.5", "cross-env": "^7.0.2", "eslint": "^6.8.0", "eslint-config-airbnb": "^18.2.0", @@ -97,6 +100,7 @@ "eslint-plugin-jsx-a11y": "^6.3.1", "eslint-plugin-react": "^7.20.5", "eslint-plugin-react-hooks": "^4.0.8", + "history": "^5.0.0", "jest-junit": "^11.1.0" } } diff --git a/frontend/src/App.js b/frontend/src/App.js index 3b5adcb624..0445a072e3 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -7,6 +7,7 @@ import { import { Alert } from '@trussworks/react-uswds'; import Header from './components/Header'; +import Admin from './pages/Admin'; function App() { return ( @@ -21,11 +22,7 @@ function App() { World! - -
- Hello second Page! -
-
+ ); diff --git a/frontend/src/Constants.js b/frontend/src/Constants.js new file mode 100644 index 0000000000..cb67e2cc6d --- /dev/null +++ b/frontend/src/Constants.js @@ -0,0 +1,89 @@ +export const REGIONAL_SCOPES = [ + { + name: 'READ_WRITE_REPORTS', + description: 'Can view and create/edit reports in the region', + }, + { + name: 'READ_REPORTS', + description: 'Can view reports in the region', + }, + { + name: 'THIRD_SCOPE', + description: 'A third scope used as an example', + }, + { + name: 'FORTH_SCOPE', + description: 'Another testing scope that will soon be deleted', + }, +]; + +export const GLOBAL_SCOPES = [ + { + name: 'SITE_ACCESS', + description: 'User can login and view the TTAHUB site', + }, + { + name: 'ADMIN', + description: 'User can view the admin panel and change user permissions (including their own)', + }, +]; + +export const JOB_TITLES = [ + 'Program Specialist', + 'Early Childhood Specialist', + 'Grantee Specialist', + 'Family Engagement Specialist', + 'Health Specialist', + 'Systems Specialist', +]; + +export const REGIONS = [ + { + number: 1, + name: 'Boston', + }, + { + number: 2, + name: 'New York City', + }, + { + number: 3, + name: 'Philadelphia', + }, + { + number: 4, + name: 'Atlanta', + }, + { + number: 5, + name: 'Chicago', + }, + { + number: 6, + name: 'Dallas', + }, + { + number: 7, + name: 'Kansas City', + }, + { + number: 8, + name: 'Denver', + }, + { + number: 9, + name: 'San Francisco', + }, + { + number: 10, + name: 'Seattle', + }, + { + number: 11, + name: 'AIAN', + }, + { + number: 12, + name: 'MSHS', + }, +]; diff --git a/frontend/src/components/Header.js b/frontend/src/components/Header.js index ff584feb90..f5ad9e9632 100644 --- a/frontend/src/components/Header.js +++ b/frontend/src/components/Header.js @@ -13,8 +13,8 @@ function Header() { Home , - - Second Page + + Admin , ]; diff --git a/frontend/src/components/IndeterminateCheckbox.css b/frontend/src/components/IndeterminateCheckbox.css new file mode 100644 index 0000000000..1d3346c8df --- /dev/null +++ b/frontend/src/components/IndeterminateCheckbox.css @@ -0,0 +1,8 @@ +.usa-checkbox__input:indeterminate+.usa-checkbox__label::before { + background-image: url(../images/minus.svg); + background-repeat: no-repeat; + background-position: center center; + background-size: .75rem auto; + background-color: #949494; + box-shadow: 0 0 0 2px #949494; +} \ No newline at end of file diff --git a/frontend/src/components/IndeterminateCheckbox.js b/frontend/src/components/IndeterminateCheckbox.js new file mode 100644 index 0000000000..5ef4d530e9 --- /dev/null +++ b/frontend/src/components/IndeterminateCheckbox.js @@ -0,0 +1,51 @@ +import React, { useEffect, useRef } from 'react'; +import PropTypes from 'prop-types'; +import './IndeterminateCheckbox.css'; + +function Checkbox({ + id, name, label, checked, indeterminate, disabled, onChange, +}) { + const indeterminateRef = useRef(); + useEffect(() => { + indeterminateRef.current.indeterminate = indeterminate; + }); + + const onLocalChange = (e) => { + onChange(e, indeterminate); + }; + + return ( + <> + + + + ); +} + +Checkbox.propTypes = { + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + label: PropTypes.node.isRequired, + checked: PropTypes.bool.isRequired, + indeterminate: PropTypes.bool, + disabled: PropTypes.bool, + onChange: PropTypes.func.isRequired, +}; + +Checkbox.defaultProps = { + indeterminate: false, + disabled: false, +}; + +export default Checkbox; diff --git a/frontend/src/components/JobTitleDropdown.js b/frontend/src/components/JobTitleDropdown.js new file mode 100644 index 0000000000..f257fdeee7 --- /dev/null +++ b/frontend/src/components/JobTitleDropdown.js @@ -0,0 +1,36 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + Label, Dropdown, +} from '@trussworks/react-uswds'; + +import { JOB_TITLES } from '../Constants'; + +function JobTitleDropdown({ + id, name, value, onChange, +}) { + return ( + <> + + + + {JOB_TITLES.map((jobTitle) => ( + + ))} + + + ); +} + +JobTitleDropdown.propTypes = { + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + value: PropTypes.string, + onChange: PropTypes.func.isRequired, +}; + +JobTitleDropdown.defaultProps = { + value: 'default', +}; + +export default JobTitleDropdown; diff --git a/frontend/src/components/RegionDropdown.js b/frontend/src/components/RegionDropdown.js new file mode 100644 index 0000000000..69bd4b9846 --- /dev/null +++ b/frontend/src/components/RegionDropdown.js @@ -0,0 +1,36 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + Label, Dropdown, +} from '@trussworks/react-uswds'; + +import { REGIONS } from '../Constants'; + +function RegionDropdown({ + id, name, value, onChange, +}) { + return ( + <> + + + + {REGIONS.map(({ number, name: description }) => ( + + ))} + + + ); +} + +RegionDropdown.propTypes = { + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + value: PropTypes.string, + onChange: PropTypes.func.isRequired, +}; + +RegionDropdown.defaultProps = { + value: 'default', +}; + +export default RegionDropdown; diff --git a/frontend/src/components/__tests__/IndeterminateCheckbox.js b/frontend/src/components/__tests__/IndeterminateCheckbox.js new file mode 100644 index 0000000000..e4f7a77f67 --- /dev/null +++ b/frontend/src/components/__tests__/IndeterminateCheckbox.js @@ -0,0 +1,33 @@ +import '@testing-library/jest-dom'; +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; + +import IndeterminateCheckbox from '../IndeterminateCheckbox'; + +describe('IndeterminateCheckbox', () => { + test('indeterminate can be false', () => { + render( {}} />); + expect(screen.getByLabelText('false').indeterminate).toBeFalsy(); + }); + + test('indeterminate can be true', () => { + render( {}} />); + expect(screen.getByLabelText('true').indeterminate).toBeTruthy(); + }); + + test('can be disabled', () => { + render( {}} />); + expect(screen.getByLabelText('checkbox')).toBeDisabled(); + }); + + test('onChange includes indeterminate', () => { + let result = false; + const onChange = (e, indeterminate) => { + result = indeterminate; + }; + render(); + const checkbox = screen.getByLabelText('checkbox'); + fireEvent.click(checkbox); + expect(result).toBeTruthy(); + }); +}); diff --git a/frontend/src/components/__tests__/JobTitleDropdown.js b/frontend/src/components/__tests__/JobTitleDropdown.js new file mode 100644 index 0000000000..97d94af51a --- /dev/null +++ b/frontend/src/components/__tests__/JobTitleDropdown.js @@ -0,0 +1,18 @@ +import '@testing-library/jest-dom'; +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +import JobTitleDropdown from '../JobTitleDropdown'; + +describe('JobTitleDropdown', () => { + test('defaults to the correct option', () => { + render( {}} />); + expect(screen.getByLabelText('Job Title').value).toBe('default'); + }); + + test('default option is not selectable', () => { + render( {}} />); + const item = screen.getByLabelText('Job Title').options.namedItem('default'); + expect(item.hidden).toBeTruthy(); + }); +}); diff --git a/frontend/src/components/__tests__/RegionDropdown.js b/frontend/src/components/__tests__/RegionDropdown.js new file mode 100644 index 0000000000..f8219f1bfd --- /dev/null +++ b/frontend/src/components/__tests__/RegionDropdown.js @@ -0,0 +1,18 @@ +import '@testing-library/jest-dom'; +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +import RegionDropdown from '../RegionDropdown'; + +describe('RegionalDropdown', () => { + test('defaults to the correct option', () => { + render( {}} />); + expect(screen.getByLabelText('Region').value).toBe('default'); + }); + + test('default option is not selectable', () => { + render( {}} />); + const item = screen.getByLabelText('Region').options.namedItem('default'); + expect(item.hidden).toBeTruthy(); + }); +}); diff --git a/frontend/src/images/minus.svg b/frontend/src/images/minus.svg new file mode 100644 index 0000000000..02fff80011 --- /dev/null +++ b/frontend/src/images/minus.svg @@ -0,0 +1 @@ +minus \ No newline at end of file diff --git a/frontend/src/pages/Admin/PermissionHelpers.js b/frontend/src/pages/Admin/PermissionHelpers.js new file mode 100644 index 0000000000..fe1fa96320 --- /dev/null +++ b/frontend/src/pages/Admin/PermissionHelpers.js @@ -0,0 +1,64 @@ +import { REGIONAL_SCOPES, GLOBAL_SCOPES, REGIONS } from '../../Constants'; + +/** + * Returns an object that has every regional scope as a key and a value of 'false' + * @returns {Object} An object with SCOPEs as keys and bool as values + */ +export function createScopeObject() { + return REGIONAL_SCOPES.reduce((acc, cur) => { + acc[cur.name] = false; + return acc; + }, {}); +} + +/** + * Return an object representing what permissions the user has per region. + * + * If a user has READ_REPORTS access on region 1 the resulting object will + * look like {"1": {"READ_REPORTS": true}} + * @param {*} - user object + * @returns {Object>}} + */ +export function userRegionalPermissions(user) { + const regionalPermissions = REGIONS.reduce((acc, cur) => { + acc[cur.number] = createScopeObject(); + return acc; + }, {}); + + if (!user.permissions) { + return regionalPermissions; + } + + user.permissions.filter((p) => ( + p.region !== 0 + )).forEach(({ region, scope }) => { + regionalPermissions[region][scope] = true; + }); + return regionalPermissions; +} + +/** + * This method returns an object representing the global permissions for the + * user. + * + * If a user has SITE_ACCESS resulting object will look like {"SITE_ACCESS": true} + * @param {*} - user object + * @returns {Object>} + */ +export function userGlobalPermissions(user) { + const globals = GLOBAL_SCOPES.reduce((acc, cur) => { + acc[cur.name] = false; + return acc; + }, {}); + + if (!user.permissions) { + return globals; + } + + user.permissions.filter((p) => ( + p.region === 0 + )).forEach(({ scope }) => { + globals[scope] = true; + }); + return globals; +} diff --git a/frontend/src/pages/Admin/UserInfo.js b/frontend/src/pages/Admin/UserInfo.js new file mode 100644 index 0000000000..68bcefcc70 --- /dev/null +++ b/frontend/src/pages/Admin/UserInfo.js @@ -0,0 +1,49 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + Label, TextInput, Grid, Fieldset, +} from '@trussworks/react-uswds'; + +import RegionDropdown from '../../components/RegionDropdown'; +import JobTitleDropdown from '../../components/JobTitleDropdown'; + +/** + * This component is the top half of the UserSection on the admin page. It displays and allows + * editing of basic user information. + */ +function UserInfo({ user, onUserChange }) { + return ( +
+ + + + + + + + + + + + + + + + + + +
+ ); +} + +UserInfo.propTypes = { + user: PropTypes.shape({ + email: PropTypes.string, + fullName: PropTypes.string, + region: PropTypes.string, + jobTitle: PropTypes.string, + }).isRequired, + onUserChange: PropTypes.func.isRequired, +}; + +export default UserInfo; diff --git a/frontend/src/pages/Admin/UserPermissions.js b/frontend/src/pages/Admin/UserPermissions.js new file mode 100644 index 0000000000..330056efe0 --- /dev/null +++ b/frontend/src/pages/Admin/UserPermissions.js @@ -0,0 +1,150 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import _ from 'lodash'; +import { + Checkbox, Grid, Fieldset, +} from '@trussworks/react-uswds'; + +import { GLOBAL_SCOPES, REGIONAL_SCOPES } from '../../Constants'; +import PermissionCheckboxLabel from './components/PermissionCheckboxLabel'; +import CurrentPermissions from './components/CurrentPermissions'; +import RegionDropdown from '../../components/RegionDropdown'; +import { createScopeObject } from './PermissionHelpers'; + +/** + * Display the current permissions for the selected user + * + * The permission object coming into this method is keyed off the region. We want to key + * the user's current permissions off the scope when displaying. This method creates a + * new permission object map with the schema . So a user with + * "READ_REPORTS" on region 1, 2 and 3 this object will be {"READ_REPORTS": ["1","2","3"]} + * @param {Object>}} permissions + */ +function renderUserPermissions(permissions) { + const currentPermissions = REGIONAL_SCOPES.reduce((acc, cur) => { + acc[cur.name] = []; + return acc; + }, {}); + + _.forEach(permissions, (scopes, region) => { + // Grab the scopes that are true. I.E. from {"READ_REPORTS": true, "READ_WRITE_REPORTS": true, + // "SCOPE": false} to {"READ_REPORTS": true, "READ_WRITE_REPORTS": true} + const trueScopes = _.pickBy(scopes); + // _.keys gives us an array of keys of the object, so ["READ_REPORTS", "READ_WRITE_REPORTS"] + _.keys(trueScopes).forEach((scope) => { + currentPermissions[scope].push(region); + }); + }); + + // regions.length being zero means the user does not have the scope in any region. Remove the + // scope to keep the UI less cluttered + const prunedPermissions = _.pickBy(currentPermissions, (regions) => ( + regions.length > 0 + )); + + return _.map(prunedPermissions, (regions, scope) => ( + + )); +} + +/** + * This component is the lower half of the UserSection. It is responsible for displaying permissions + * and passing any updates up to the UserSection component. The Admin can set permissions for a + * single region at a time. + */ +function UserPermissions({ + userId, + globalPermissions, + onGlobalPermissionChange, + regionalPermissions, + onRegionalPermissionChange, +}) { + // State of the region select dropdown + const [selectedRegion, updateSelectedRegion] = useState(); + // State of the regional permissions checkboxes, I.E. {"READ_REPORTS": true, ...} + const [permissionsForRegion, updatePermissionsForRegion] = useState(createScopeObject()); + const enablePermissions = selectedRegion !== undefined; + + useEffect(() => { + updateSelectedRegion(); + updatePermissionsForRegion(createScopeObject()); + }, [userId]); + + useEffect(() => { + updatePermissionsForRegion({ + ...createScopeObject(), + ...regionalPermissions[selectedRegion], + }); + }, [regionalPermissions, selectedRegion]); + + const onSelectedRegionChange = (e) => { + const { value } = e.target; + updatePermissionsForRegion({ ...permissionsForRegion, ...regionalPermissions[value] }); + updateSelectedRegion(value); + }; + + const onPermissionsForRegionChange = (e) => { + const newRegionPermissions = { ...permissionsForRegion, [e.target.name]: e.target.checked }; + onRegionalPermissionChange({ + ...regionalPermissions, + [selectedRegion]: { ...newRegionPermissions }, + }); + }; + + return ( + <> +
+ + {GLOBAL_SCOPES.map(({ name, description }) => ( + + )} + name={name} + disabled={false} + /> + + ))} + +
+
+

Current Permissions

+
    + {renderUserPermissions(regionalPermissions)} +
+ + + {REGIONAL_SCOPES.map(({ name, description }) => ( + + )} + /> + + ))} + +
+ + ); +} + +UserPermissions.propTypes = { + userId: PropTypes.number, + globalPermissions: PropTypes.objectOf(PropTypes.bool).isRequired, + onGlobalPermissionChange: PropTypes.func.isRequired, + regionalPermissions: PropTypes.objectOf(PropTypes.objectOf(PropTypes.bool)).isRequired, + onRegionalPermissionChange: PropTypes.func.isRequired, +}; + +UserPermissions.defaultProps = { + userId: null, +}; + +export default UserPermissions; diff --git a/frontend/src/pages/Admin/UserSection.js b/frontend/src/pages/Admin/UserSection.js new file mode 100644 index 0000000000..e18a979b42 --- /dev/null +++ b/frontend/src/pages/Admin/UserSection.js @@ -0,0 +1,90 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { + Form, Button, +} from '@trussworks/react-uswds'; + +import UserInfo from './UserInfo'; +import UserPermissions from './UserPermissions'; +import { userGlobalPermissions, userRegionalPermissions } from './PermissionHelpers'; + +/** + * The user section of the Admin UI. Creating new users (and editing existing) is done + * inside this component. This component holds all the state for the user that is currently + * being edited. + */ +function UserSection({ user }) { + const [formUser, updateUser] = useState(); + const [globalPermissions, updateGlobalPermissions] = useState({}); + const [regionalPermissions, updateRegionalPermissions] = useState(); + + useEffect(() => { + updateUser(user); + updateGlobalPermissions(userGlobalPermissions(user)); + updateRegionalPermissions(userRegionalPermissions(user)); + }, [user]); + + const onUserChange = (e) => { + updateUser({ + ...formUser, + [e.target.name]: e.target.value, + }); + }; + + const onGlobalPermissionChange = (e) => { + updateGlobalPermissions({ + ...globalPermissions, + [e.target.name]: e.target.checked, + }); + }; + + const onRegionalPermissionChange = (updatedRegionalPermissions) => { + updateRegionalPermissions({ + ...regionalPermissions, + ...updatedRegionalPermissions, + }); + }; + + if (!formUser) { + return ( +
+ Loading... +
+ ); + } + + return ( +
+ + + + + ); +} + +UserSection.propTypes = { + user: PropTypes.shape({ + id: PropTypes.number, + email: PropTypes.string, + fullName: PropTypes.string, + region: PropTypes.string, + jobTitle: PropTypes.string, + permissions: PropTypes.arrayOf(PropTypes.shape({ + region: PropTypes.number.isRequired, + scope: PropTypes.string.isRequired, + })), + }).isRequired, +}; + +export default UserSection; diff --git a/frontend/src/pages/Admin/__tests__/PermissionHelpers.js b/frontend/src/pages/Admin/__tests__/PermissionHelpers.js new file mode 100644 index 0000000000..9cf744b81f --- /dev/null +++ b/frontend/src/pages/Admin/__tests__/PermissionHelpers.js @@ -0,0 +1,86 @@ +import _ from 'lodash'; +import { createScopeObject, userRegionalPermissions, userGlobalPermissions } from '../PermissionHelpers'; +import { REGIONAL_SCOPES } from '../../../Constants'; + +describe('PermissionHelpers', () => { + describe('createScopeObject', () => { + it('creates an object with scopes as keys and false as values', () => { + const obj = createScopeObject(); + REGIONAL_SCOPES.forEach((scope) => { + expect(obj[scope]).toBeFalsy(); + }); + }); + }); + + describe('userRegionalPermissions', () => { + it('returns an all false object for a user with no permissions', () => { + const regionalPermissions = userRegionalPermissions({}); + + expect(Object.keys(regionalPermissions).length).toBe(12); + _.forEach(regionalPermissions, (scopes) => { + expect(_.every(scopes, (scope) => scope === false)).toBeTruthy(); + }); + }); + + describe('for a user with permissions', () => { + let regionalPermissions; + + beforeEach(() => { + const user = { + permissions: [ + { + scope: 'READ_REPORTS', + region: 1, + }, + ], + }; + + regionalPermissions = userRegionalPermissions(user); + }); + + it('flags regional permissions the user has as true', () => { + expect(regionalPermissions['1'].READ_REPORTS).toBeTruthy(); + }); + + it('flags regional permissions the user does not have as false', () => { + expect(regionalPermissions['1'].READ_WRITE_REPORTS).toBeFalsy(); + }); + + it('flags regional permissions for the correct region', () => { + expect(regionalPermissions['2'].READ_REPORTS).toBeFalsy(); + }); + }); + }); + + describe('userGlobalPermissions', () => { + it('returns an all false object for a user with no scopes', () => { + const globalPermissions = userGlobalPermissions({}); + expect(Object.keys(globalPermissions).length).not.toBe(0); + expect(_.every(globalPermissions, (p) => p === false)).toBeTruthy(); + }); + + describe('for a user with permissions', () => { + let globalPermissions; + + beforeEach(() => { + const user = { + permissions: [ + { + scope: 'ADMIN', + region: 0, + }, + ], + }; + globalPermissions = userGlobalPermissions(user); + }); + + it('flags global permissions the user has as true', () => { + expect(globalPermissions.ADMIN).toBeTruthy(); + }); + + it('flags global permissions the user does not have as false', () => { + expect(globalPermissions.SITE_ACCESS).toBeFalsy(); + }); + }); + }); +}); diff --git a/frontend/src/pages/Admin/__tests__/UserInfo.js b/frontend/src/pages/Admin/__tests__/UserInfo.js new file mode 100644 index 0000000000..d87f46ee87 --- /dev/null +++ b/frontend/src/pages/Admin/__tests__/UserInfo.js @@ -0,0 +1,58 @@ +import '@testing-library/jest-dom'; +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +import UserInfo from '../UserInfo'; + +describe('UserInfo', () => { + describe('with an empty user object', () => { + beforeEach(() => { + render( {}} />); + }); + + test('has a blank email', async () => { + expect(screen.getByLabelText('Email')).toHaveValue(''); + }); + + test('has a blank fullName', () => { + expect(screen.getByLabelText('Full Name')).toHaveValue(''); + }); + + test('has the default region', () => { + expect(screen.getByLabelText('Region')).toHaveValue('default'); + }); + + test('has the default jobTitle', () => { + expect(screen.getByLabelText('Job Title')).toHaveValue('default'); + }); + }); + + describe('with a full user object', () => { + beforeEach(() => { + const user = { + email: 'email', + fullName: 'first last', + region: '1', + jobTitle: 'Grantee Specialist', + }; + + render( {}} />); + }); + + test('has correct email', async () => { + expect(screen.getByLabelText('Email')).toHaveValue('email'); + }); + + test('has correct fullName', () => { + expect(screen.getByLabelText('Full Name')).toHaveValue('first last'); + }); + + test('has correct region', () => { + expect(screen.getByLabelText('Region')).toHaveValue('1'); + }); + + test('has correct jobTitle', () => { + expect(screen.getByLabelText('Job Title')).toHaveValue('Grantee Specialist'); + }); + }); +}); diff --git a/frontend/src/pages/Admin/__tests__/UserPermissions.js b/frontend/src/pages/Admin/__tests__/UserPermissions.js new file mode 100644 index 0000000000..ada6ccc9ee --- /dev/null +++ b/frontend/src/pages/Admin/__tests__/UserPermissions.js @@ -0,0 +1,59 @@ +import '@testing-library/jest-dom'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { render, screen, within } from '@testing-library/react'; + +import UserPermissions from '../UserPermissions'; +import { withText } from '../../../testHelpers'; + +describe('UserPermissions', () => { + describe('with no permissions', () => { + beforeEach(() => { + render( {}} + onGlobalPermissionChange={() => {}} + />); + }); + + it('has no checkboxes checked', () => { + screen.getAllByRole('checkbox').forEach((cb) => { + expect(cb).not.toBeChecked(); + }); + }); + }); + + describe('with permissions', () => { + beforeEach(() => { + render( {}} + onGlobalPermissionChange={() => {}} + />); + }); + + it('has correct global permissions checked', () => { + const checkbox = screen.getByRole('checkbox', { checked: true }); + expect(checkbox.name).toBe('SITE_ACCESS'); + }); + + it('displays the current regional permissions', () => { + expect(screen.getByText(withText('READ_REPORTS: Region 1'))).toBeVisible(); + }); + + describe('when a region is selected', () => { + it('the correct regional scopes are shown as checked', () => { + userEvent.selectOptions(screen.getByLabelText('Region'), '1'); + const fieldset = screen.getByRole('group', { name: 'Regional Permissions' }); + const checkbox = within(fieldset).getByRole('checkbox', { checked: true }); + expect(checkbox).toBeChecked(); + }); + }); + }); +}); diff --git a/frontend/src/pages/Admin/__tests__/UserSection.js b/frontend/src/pages/Admin/__tests__/UserSection.js new file mode 100644 index 0000000000..8afe7d5fc9 --- /dev/null +++ b/frontend/src/pages/Admin/__tests__/UserSection.js @@ -0,0 +1,53 @@ +import '@testing-library/jest-dom'; +import React from 'react'; +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import UserSection from '../UserSection'; + +describe('UserSection', () => { + beforeEach(() => { + const user = { + id: 1, + email: 'email', + fullName: 'first last', + jobTitle: 'Grantee Specialist', + region: '1', + permissions: [ + { + region: 0, + scope: 'SITE_ACCESS', + }, + { + region: 1, + scope: 'READ_REPORTS', + }, + ], + }; + + render(); + }); + + it('properly controls user info', () => { + const inputBox = screen.getByLabelText('Full Name'); + expect(inputBox).toHaveValue('first last'); + userEvent.type(inputBox, '{selectall}{backspace}new name'); + expect(screen.getByLabelText('Full Name')).toHaveValue('new name'); + }); + + it('properly controls global permissions', () => { + const checkbox = screen.getByRole('checkbox', { checked: true }); + expect(checkbox).toBeChecked(); + userEvent.click(checkbox); + expect(checkbox).not.toBeChecked(); + }); + + it('properly controls regional permissions', () => { + const permissions = screen.getByRole('group', { name: 'Regional Permissions' }); + userEvent.selectOptions(within(permissions).getByLabelText('Region'), '1'); + const checkbox = within(permissions).getByRole('checkbox', { checked: true }); + expect(checkbox).toBeChecked(); + userEvent.click(checkbox); + expect(checkbox).not.toBeChecked(); + }); +}); diff --git a/frontend/src/pages/Admin/__tests__/index.js b/frontend/src/pages/Admin/__tests__/index.js new file mode 100644 index 0000000000..bc8aeec471 --- /dev/null +++ b/frontend/src/pages/Admin/__tests__/index.js @@ -0,0 +1,52 @@ +import '@testing-library/jest-dom'; +import React from 'react'; +import { Router } from 'react-router'; +import { + render, screen, waitFor, within, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createMemoryHistory } from 'history'; +import Admin from '../index'; + +describe('UserInfo', () => { + const history = createMemoryHistory(); + + describe('with no user selected', () => { + beforeEach(() => { + render(); + }); + + it('user list is filterable', async () => { + const filter = await waitFor(() => screen.getByLabelText('Filter Users')); + userEvent.type(filter, 'Harry'); + const sideNav = screen.getByTestId('sidenav'); + const links = within(sideNav).getAllByRole('link'); + expect(links.length).toBe(1); + expect(links[0]).toHaveTextContent('Harry Potter'); + }); + + it('new user button properly sets url', async () => { + const newUser = await waitFor(() => screen.getByText('Create New User')); + userEvent.click(newUser); + expect(history.location.pathname).toBe('/admin/new'); + }); + + it('allows a user to be selected', async () => { + const button = await waitFor(() => screen.getByText('Harry Potter')); + userEvent.click(button); + expect(history.location.pathname).toBe('/admin/3'); + }); + }); + + it('displays a new user', async () => { + render(); + const userInfo = await waitFor(() => screen.getByRole('group', { name: 'User Info' })); + expect(userInfo).toBeVisible(); + }); + + it('displays an existing user', async () => { + render(); + const userInfo = await waitFor(() => screen.getByRole('group', { name: 'User Info' })); + expect(userInfo).toBeVisible(); + }); +}); diff --git a/frontend/src/pages/Admin/components/CurrentPermissions.js b/frontend/src/pages/Admin/components/CurrentPermissions.js new file mode 100644 index 0000000000..3bd2113254 --- /dev/null +++ b/frontend/src/pages/Admin/components/CurrentPermissions.js @@ -0,0 +1,21 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +function CurrentPermissions({ regions, scope }) { + const regionsStr = regions.length === 1 ? 'Region' : 'Regions'; + const regionMsg = `${regionsStr} ${regions.join(', ')}`; + return ( +
  • + {scope} + {': '} + {regionMsg} +
  • + ); +} + +CurrentPermissions.propTypes = { + regions: PropTypes.arrayOf(PropTypes.string).isRequired, + scope: PropTypes.string.isRequired, +}; + +export default CurrentPermissions; diff --git a/frontend/src/pages/Admin/components/PermissionCheckboxLabel.js b/frontend/src/pages/Admin/components/PermissionCheckboxLabel.js new file mode 100644 index 0000000000..146866d08d --- /dev/null +++ b/frontend/src/pages/Admin/components/PermissionCheckboxLabel.js @@ -0,0 +1,19 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +function PermissionCheckboxLabel({ name, description }) { + return ( + <> + {name} + {': '} + {description} + + ); +} + +PermissionCheckboxLabel.propTypes = { + name: PropTypes.string.isRequired, + description: PropTypes.string.isRequired, +}; + +export default PermissionCheckboxLabel; diff --git a/frontend/src/pages/Admin/components/__tests__/CurrentPermissions.js b/frontend/src/pages/Admin/components/__tests__/CurrentPermissions.js new file mode 100644 index 0000000000..ff6f1e5b4d --- /dev/null +++ b/frontend/src/pages/Admin/components/__tests__/CurrentPermissions.js @@ -0,0 +1,20 @@ +import '@testing-library/jest-dom'; +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +import CurrentPermissions from '../CurrentPermissions'; +import { withText } from '../../../../testHelpers'; + +describe('CurrentPermissions', () => { + test('renders single region', () => { + render(); + expect(screen.getByText(withText('TEST_SCOPE: Region 1'))).toBeVisible(); + }); + + test('renders multiple regions', () => { + render(); + expect( + screen.getByText(withText('TEST_SCOPE: Regions 1, 2')), + ).toBeVisible(); + }); +}); diff --git a/frontend/src/pages/Admin/components/__tests__/PermissionCheckboxLabel.js b/frontend/src/pages/Admin/components/__tests__/PermissionCheckboxLabel.js new file mode 100644 index 0000000000..ca4b184aed --- /dev/null +++ b/frontend/src/pages/Admin/components/__tests__/PermissionCheckboxLabel.js @@ -0,0 +1,13 @@ +import '@testing-library/jest-dom'; +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +import PermissionCheckboxLabel from '../PermissionCheckboxLabel'; +import { withText } from '../../../../testHelpers'; + +describe('PermissionCheckboxLabel', () => { + test('renders correct text', () => { + render(); + expect(screen.getByText(withText('TEST_SCOPE: test description'))).toBeVisible(); + }); +}); diff --git a/frontend/src/pages/Admin/index.js b/frontend/src/pages/Admin/index.js new file mode 100644 index 0000000000..28191fb17a --- /dev/null +++ b/frontend/src/pages/Admin/index.js @@ -0,0 +1,218 @@ +import React, { useState, useEffect } from 'react'; +import { useHistory } from 'react-router-dom'; +import ReactRouterPropTypes from 'react-router-prop-types'; +import _ from 'lodash'; +import { + GridContainer, Label, TextInput, Grid, SideNav, Button, +} from '@trussworks/react-uswds'; +import UserSection from './UserSection'; +import NavLink from '../../components/NavLink'; + +// Fake return from an API +const fetchedUsers = [ + { + id: 1, + email: 'dumbledore@hogwarts.com', + jobTitle: undefined, + fullName: undefined, + permissions: undefined, + region: undefined, + }, + { + id: 2, + email: 'hermionegranger@hogwarts.com', + jobTitle: 'Systems Specialist', + fullName: 'Hermione Granger', + permissions: [ + { + // Region 0 is used to flag permissions as being "global" (or not associated to a region) + // and will hopefully be changed in the future to something a little less magical. Future + // work will solidify the schema of both global and regional permissions which will require + // updates to any code that uses "region 0". + region: 0, + scope: 'SITE_ACCESS', + }, + { + region: 1, + scope: 'READ_WRITE_REPORTS', + }, + ], + region: '1', + }, + { + id: 3, + email: 'harrypotter@hogwarts.com', + jobTitle: 'Grantee Specialist', + fullName: 'Harry Potter', + permissions: [ + { + region: 0, + scope: 'ADMIN', + }, + { + region: 0, + scope: 'SITE_ACCESS', + }, + { + region: 1, + scope: 'READ_REPORTS', + }, + { + region: 1, + scope: 'READ_WRITE_REPORTS', + }, + { + region: 2, + scope: 'READ_REPORTS', + }, + { + region: 3, + scope: 'READ_REPORTS', + }, + { + region: 4, + scope: 'READ_REPORTS', + }, + { + region: 5, + scope: 'READ_REPORTS', + }, + { + region: 6, + scope: 'READ_REPORTS', + }, + { + region: 7, + scope: 'READ_REPORTS', + }, + { + region: 8, + scope: 'READ_REPORTS', + }, + { + region: 9, + scope: 'READ_REPORTS', + }, + ], + region: '1', + }, + { + id: 4, + email: 'ronweasley@hogwarts.com', + jobTitle: 'Program Specialist', + fullName: 'Ron Weasley', + permissions: [ + { + region: 0, + scope: 'SITE_ACCESS', + }, + { + region: 5, + scope: 'READ_WRITE_REPORTS', + }, + ], + region: '5', + }, +]; + +/** + * Render the left hand user navigation in the Admin UI. Use the user's full name + * or email address if the user doesn't have a full name. + */ +function renderUserNav(users) { + return users.map((user) => { + const { + fullName, email, id, + } = user; + let display = email; + if (fullName) { + display = fullName; + } + return {display}; + }); +} + +/** + * Admin UI page component. It is split into two main sections, the user list and the + * user section. The user list can be filtered to make searching for users easier. The + * user section contains all info on the user that can be updated (full name, + * permissions, etc...). This component handles fetching of users from the API and will + * be responsible for sending updates/creates back to the API (not yet implemented). + */ +function Admin(props) { + const { match: { params: { userId } } } = props; + const [isLoaded, setIsLoaded] = useState(false); + const [users, updateUsers] = useState([]); + const [userSearch, updateUserSearch] = useState(''); + const history = useHistory(); + + useEffect(() => { + // Mock the API call. The setTimeout will be removed once we hit a real API + setTimeout(() => { + setIsLoaded(true); + updateUsers(fetchedUsers); + }, 400); + }, []); + + const onUserSearchChange = (e) => { + updateUserSearch(e.target.value); + }; + + if (!isLoaded) { + return ( +
    + Loading... +
    + ); + } + + let user; + if (userId === 'new') { + user = {}; + } else if (userId) { + user = users.find((u) => ( + u.id === parseInt(userId, 10) + )); + } + + const filteredUsers = _.filter(users, (u) => { + const { email, fullName } = u; + return `${email}${fullName}`.includes(userSearch); + }); + + return ( +
    + +

    User Administration

    + + + + + +
    + +
    +
    + + {!user + && ( +

    + Select a user... +

    + )} + {user + && ( + + )} +
    +
    +
    +
    + ); +} + +Admin.propTypes = { + match: ReactRouterPropTypes.match.isRequired, +}; + +export default Admin; diff --git a/frontend/src/setupTests.js b/frontend/src/setupTests.js index 74b1a275a0..03c4a3629c 100644 --- a/frontend/src/setupTests.js +++ b/frontend/src/setupTests.js @@ -1,5 +1,15 @@ +// This is a test file so ignore eslint error about packages +// being in dev dependencies instead of dependencies + +/* eslint-disable import/no-extraneous-dependencies */ + // jest-dom adds custom jest matchers for asserting on DOM nodes. // allows you to do things like: // expect(element).toHaveTextContent(/react/i) // learn more: https://github.com/testing-library/jest-dom import '@testing-library/jest-dom/extend-expect'; +// See https://github.com/testing-library/dom-testing-library/releases/tag/v7.0.0 +// 'MutationObserver shim removed' +import MutationObserver from '@sheerun/mutationobserver-shim'; + +window.MutationObserver = MutationObserver; diff --git a/frontend/src/testHelpers.js b/frontend/src/testHelpers.js new file mode 100644 index 0000000000..a40bed82e7 --- /dev/null +++ b/frontend/src/testHelpers.js @@ -0,0 +1,13 @@ +// Disable eslint rule making this a default export. This file will, +// I'm sure, accumulate more helper functions + +/* eslint-disable import/prefer-default-export */ +export const withText = (text) => (content, node) => { + const hasText = (n) => n.textContent === text; + const nodeHasText = hasText(node); + const childrenDontHaveText = Array.from(node.children).every( + (child) => !hasText(child), + ); + + return nodeHasText && childrenDontHaveText; +}; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index a8ceb8f9af..c940b72c80 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -1118,7 +1118,7 @@ dependencies: regenerator-runtime "^0.13.4" -"@babel/runtime@>=7.0.0", "@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.3.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4": +"@babel/runtime@>=7.0.0", "@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.3.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4": version "7.11.2" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.11.2.tgz#f549c13c754cc40b87644b9fa9f09a6a95fe0736" integrity sha512-TeWkU52so0mPtDcaCTxNBI/IHiz0pZgr8VEFqXFtZWpYD08ZB6FaSwVAS8MKRQAP3bYKiVjwysOJgMFY28o6Tw== @@ -1373,6 +1373,17 @@ "@types/yargs" "^15.0.0" chalk "^3.0.0" +"@jest/types@^26.3.0": + version "26.3.0" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-26.3.0.tgz#97627bf4bdb72c55346eef98e3b3f7ddc4941f71" + integrity sha512-BDPG23U0qDeAvU4f99haztXwdAg3hz4El95LkAM+tHAqqhiVzRpEGHHU8EDxT/AnxOrA65YjLBwDahdJ9pTLJQ== + dependencies: + "@types/istanbul-lib-coverage" "^2.0.0" + "@types/istanbul-reports" "^3.0.0" + "@types/node" "*" + "@types/yargs" "^15.0.0" + chalk "^4.0.0" + "@mrmlnc/readdir-enhanced@^2.2.1": version "2.2.1" resolved "https://registry.yarnpkg.com/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz#524af240d1a360527b730475ecfa1344aa540dde" @@ -1407,6 +1418,11 @@ "@nodelib/fs.scandir" "2.1.3" fastq "^1.6.0" +"@sheerun/mutationobserver-shim@^0.3.3": + version "0.3.3" + resolved "https://registry.yarnpkg.com/@sheerun/mutationobserver-shim/-/mutationobserver-shim-0.3.3.tgz#5405ee8e444ed212db44e79351f0c70a582aae25" + integrity sha512-DetpxZw1fzPD5xUBrIAoplLChO2VB8DlL5Gg+I1IR9b2wPqYIca2WSUxL5g1vLeR4MsQq1NeWriXAVffV+U1Fw== + "@svgr/babel-plugin-add-jsx-attribute@^4.2.0": version "4.2.0" resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-4.2.0.tgz#dadcb6218503532d6884b210e7f3c502caaa44b1" @@ -1510,27 +1526,29 @@ "@svgr/plugin-svgo" "^4.3.1" loader-utils "^1.2.3" -"@testing-library/dom@^7.21.7": - version "7.21.7" - resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-7.21.7.tgz#23c57c361db5e961afa3e6f3f15bd57fbda01187" - integrity sha512-GVNrLAt0yq7Squz1HrW8IiDVKP5jeWSv9cpgQJsfmXYXLFPpaFoRxn+H/NcUitVXyb0J62PkpVWjMe5b0fvYrQ== +"@testing-library/dom@^7.22.3": + version "7.22.3" + resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-7.22.3.tgz#12c0b1b97115e7731da6a86b4574eae401cb9ac5" + integrity sha512-IK6/eL1Xza/0goDKrwnBvlM06L+5eL9b1o+hUhX7HslfUvMETh0TYgXEr2LVpsVkHiOhRmUbUyml95KV/VlRNw== dependencies: "@babel/runtime" "^7.10.3" "@types/aria-query" "^4.2.0" aria-query "^4.2.2" - dom-accessibility-api "^0.4.6" + dom-accessibility-api "^0.5.1" pretty-format "^25.5.0" -"@testing-library/dom@^7.22.3": - version "7.22.3" - resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-7.22.3.tgz#12c0b1b97115e7731da6a86b4574eae401cb9ac5" - integrity sha512-IK6/eL1Xza/0goDKrwnBvlM06L+5eL9b1o+hUhX7HslfUvMETh0TYgXEr2LVpsVkHiOhRmUbUyml95KV/VlRNw== +"@testing-library/dom@^7.24.2": + version "7.24.2" + resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-7.24.2.tgz#6d2b7dd21efbd5358b98c2777fc47c252f3ae55e" + integrity sha512-ERxcZSoHx0EcN4HfshySEWmEf5Kkmgi+J7O79yCJ3xggzVlBJ2w/QjJUC+EBkJJ2OeSw48i3IoePN4w8JlVUIA== dependencies: + "@babel/code-frame" "^7.10.4" "@babel/runtime" "^7.10.3" "@types/aria-query" "^4.2.0" aria-query "^4.2.2" + chalk "^4.1.0" dom-accessibility-api "^0.5.1" - pretty-format "^25.5.0" + pretty-format "^26.4.2" "@testing-library/jest-dom@^4.2.4": version "4.2.4" @@ -1555,10 +1573,12 @@ "@babel/runtime" "^7.10.3" "@testing-library/dom" "^7.22.3" -"@testing-library/user-event@^7.1.2": - version "7.2.1" - resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-7.2.1.tgz#2ad4e844175a3738cb9e7064be5ea070b8863a1c" - integrity sha512-oZ0Ib5I4Z2pUEcoo95cT1cr6slco9WY7yiPpG+RGNkj8YcYgJnM7pXmYmorNOReh8MIGcKSqXyeGjxnr8YiZbA== +"@testing-library/user-event@^12.1.5": + version "12.1.5" + resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-12.1.5.tgz#b2a94b8b3c1a908cc1c425623b11de63b20bb837" + integrity sha512-FzTnKvb0KC4T84G6uTx971ja8OOqLlsprzWbeRyd8f1MDwbcLIFgx0Hyr56izY9m9y2KwHGVKeVEkTPslw32lw== + dependencies: + "@babel/runtime" "^7.10.2" "@trussworks/react-uswds@^1.9.1": version "1.9.1" @@ -1655,6 +1675,13 @@ "@types/istanbul-lib-coverage" "*" "@types/istanbul-lib-report" "*" +"@types/istanbul-reports@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/istanbul-reports/-/istanbul-reports-3.0.0.tgz#508b13aa344fa4976234e75dddcc34925737d821" + integrity sha512-nwKNbvnwJ2/mndE9ItP/zc2TCzw6uuodnF4EHYWD+gCQDVBuRQL5UzbZD0/ezy1iKsFU2ZQiDqg4M9dN4+wZgA== + dependencies: + "@types/istanbul-lib-report" "*" + "@types/json-schema@^7.0.3", "@types/json-schema@^7.0.4": version "7.0.5" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.5.tgz#dcce4430e64b443ba8945f0290fb564ad5bac6dd" @@ -1680,6 +1707,11 @@ resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== +"@types/prop-types@^15.7.3": + version "15.7.3" + resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.3.tgz#2ab0d5da2e5815f94b0b9d4b95d1e5f243ab2ca7" + integrity sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw== + "@types/q@^1.5.1": version "1.5.4" resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.4.tgz#15925414e0ad2cd765bfef58842f7e26a7accb24" @@ -2961,7 +2993,7 @@ chalk@^3.0.0: ansi-styles "^4.1.0" supports-color "^7.1.0" -chalk@^4.1.0: +chalk@^4.0.0, chalk@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.0.tgz#4e14870a618d9e2edd97dd8345fd9d9dc315646a" integrity sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A== @@ -3979,11 +4011,6 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" -dom-accessibility-api@^0.4.6: - version "0.4.7" - resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.4.7.tgz#31d01c113af49f323409b3ed09e56967aba485a8" - integrity sha512-5+GzhTpCQYHz4NjL8loYTDVBnXIjNLBadWQBKxXk+osFEplLt3EsSYBu2YZcdZ8QqrvCHgW6TSMGMbmgfhrn2g== - dom-accessibility-api@^0.5.1: version "0.5.1" resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.1.tgz#152f5e88583d900977119223e3e76c2d93d23830" @@ -5430,6 +5457,13 @@ history@^4.9.0: tiny-warning "^1.0.0" value-equal "^1.0.1" +history@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/history/-/history-5.0.0.tgz#0cabbb6c4bbf835addb874f8259f6d25101efd08" + integrity sha512-3NyRMKIiFSJmIPdq7FxkNMJkQ7ZEtVblOQ38VtKaA0zZMW1Eo6Q6W8oDKEflr1kNNTItSnk4JMCO1deeSgbLLg== + dependencies: + "@babel/runtime" "^7.7.6" + hmac-drbg@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1" @@ -7059,6 +7093,11 @@ lodash.uniq@^4.5.0: resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.19.tgz#e48ddedbe30b3321783c5b4301fbd353bc1e4a4b" integrity sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ== +lodash@^4.17.20: + version "4.17.20" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" + integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== + loglevel@^1.6.6: version "1.6.8" resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.6.8.tgz#8a25fb75d092230ecd4457270d80b54e28011171" @@ -8917,6 +8956,16 @@ pretty-format@^25.5.0: ansi-styles "^4.0.0" react-is "^16.12.0" +pretty-format@^26.4.2: + version "26.4.2" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-26.4.2.tgz#d081d032b398e801e2012af2df1214ef75a81237" + integrity sha512-zK6Gd8zDsEiVydOCGLkoBoZuqv8VTiHyAbKznXe/gaph/DAeZOmit9yMfgIz5adIgAMMs5XfoYSwAX3jcCO1tA== + dependencies: + "@jest/types" "^26.3.0" + ansi-regex "^5.0.0" + ansi-styles "^4.0.0" + react-is "^16.12.0" + process-nextick-args@~2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" @@ -9181,6 +9230,14 @@ react-router-dom@^5.2.0: tiny-invariant "^1.0.2" tiny-warning "^1.0.0" +react-router-prop-types@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/react-router-prop-types/-/react-router-prop-types-1.0.5.tgz#2e671d8412a793106bf70dc15c9ecc83ea4bc15b" + integrity sha512-q1xlFU2ol2U5zeVbA5hyBuxD3scHenqgMgCTuJQUanA2SyG8A3Fb1S6DleOo1cnGJB5Q05hnLge64kRj+xsuPA== + dependencies: + "@types/prop-types" "^15.7.3" + prop-types "^15.7.2" + react-router@5.2.0, react-router@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/react-router/-/react-router-5.2.0.tgz#424e75641ca8747fbf76e5ecca69781aa37ea293" diff --git a/src/index.js b/src/index.js index 57435cdbe3..b27be5f820 100644 --- a/src/index.js +++ b/src/index.js @@ -7,6 +7,7 @@ import memorystore from 'memorystore'; import unless from 'express-unless'; import _ from 'lodash'; import path from 'path'; +import logger from './logger'; import authMiddleware, { hsesAuth } from './middleware/authMiddleware'; @@ -52,8 +53,7 @@ router.get(oauth2CallbackPath, async (req, res) => { const { authorities } = data; req.session.userId = 1; // temporary req.session.role = _.get(authorities[0], 'authority'); - // TODO: replace with logging message - console.log(`role: ${req.session.role}`); + logger.info(`role: ${req.session.role}`); res.redirect(req.session.originalUrl); } catch (error) { // console.log(error); From 6448f317d68fad1ce835dfc75c555d6cc1f37486 Mon Sep 17 00:00:00 2001 From: Ryan Ahearn Date: Thu, 24 Sep 2020 11:07:35 -0400 Subject: [PATCH 2/3] Bump bl from 4.0.2 to 4.0.3 in /frontend (#28) Bumps [bl](https://github.com/rvagg/bl) from 4.0.2 to 4.0.3. - [Release notes](https://github.com/rvagg/bl/releases) - [Commits](https://github.com/rvagg/bl/compare/v4.0.2...v4.0.3) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- frontend/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/yarn.lock b/frontend/yarn.lock index c940b72c80..cfaf67d711 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -2582,9 +2582,9 @@ bindings@^1.5.0: file-uri-to-path "1.0.0" bl@^4.0.1: - version "4.0.2" - resolved "https://registry.yarnpkg.com/bl/-/bl-4.0.2.tgz#52b71e9088515d0606d9dd9cc7aa48dc1f98e73a" - integrity sha512-j4OH8f6Qg2bGuWfRiltT2HYGx0e1QcBTrK9KAHNMwMZdQnDZFk0ZSYIpADjYCB3U12nicC5tVJwSIhwOWjb4RQ== + version "4.0.3" + resolved "https://registry.yarnpkg.com/bl/-/bl-4.0.3.tgz#12d6287adc29080e22a705e5764b2a9522cdc489" + integrity sha512-fs4G6/Hu4/EE+F75J8DuN/0IpQqNjAdC7aEQv7Qt8MHGUH7Ckv2MwTEEeN9QehD0pfIDkMI1bkHYkKy7xHyKIg== dependencies: buffer "^5.5.0" inherits "^2.0.4" From bdf91be9c79d51c3776012c9aaf1411562267a1f Mon Sep 17 00:00:00 2001 From: Josh Salisbury Date: Thu, 24 Sep 2020 14:45:36 -0500 Subject: [PATCH 3/3] Region dropdown shows central office. Remove unnecessary key prop (#34) * Region dropdown shows central office. Remove unnecessary key prop * Add missing semicolon * Use "co" as the central office region. 13 is already sometimes used --- frontend/src/components/RegionDropdown.js | 6 +++++- .../src/components/__tests__/JobTitleDropdown.js | 2 +- frontend/src/components/__tests__/RegionDropdown.js | 12 +++++++++++- frontend/src/pages/Admin/UserInfo.js | 2 +- .../src/pages/Admin/components/CurrentPermissions.js | 2 +- frontend/src/pages/Admin/index.js | 2 +- 6 files changed, 20 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/RegionDropdown.js b/frontend/src/components/RegionDropdown.js index 69bd4b9846..f7377d98cc 100644 --- a/frontend/src/components/RegionDropdown.js +++ b/frontend/src/components/RegionDropdown.js @@ -7,7 +7,7 @@ import { import { REGIONS } from '../Constants'; function RegionDropdown({ - id, name, value, onChange, + id, name, value, onChange, includeCentralOffice, }) { return ( <> @@ -17,6 +17,8 @@ function RegionDropdown({ {REGIONS.map(({ number, name: description }) => ( ))} + {includeCentralOffice + && } ); @@ -27,10 +29,12 @@ RegionDropdown.propTypes = { name: PropTypes.string.isRequired, value: PropTypes.string, onChange: PropTypes.func.isRequired, + includeCentralOffice: PropTypes.bool, }; RegionDropdown.defaultProps = { value: 'default', + includeCentralOffice: false, }; export default RegionDropdown; diff --git a/frontend/src/components/__tests__/JobTitleDropdown.js b/frontend/src/components/__tests__/JobTitleDropdown.js index 97d94af51a..9f0fbef614 100644 --- a/frontend/src/components/__tests__/JobTitleDropdown.js +++ b/frontend/src/components/__tests__/JobTitleDropdown.js @@ -5,7 +5,7 @@ import { render, screen } from '@testing-library/react'; import JobTitleDropdown from '../JobTitleDropdown'; describe('JobTitleDropdown', () => { - test('defaults to the correct option', () => { + test('shows "select a job title" (the default) when no value is selected', () => { render( {}} />); expect(screen.getByLabelText('Job Title').value).toBe('default'); }); diff --git a/frontend/src/components/__tests__/RegionDropdown.js b/frontend/src/components/__tests__/RegionDropdown.js index f8219f1bfd..f086d1005a 100644 --- a/frontend/src/components/__tests__/RegionDropdown.js +++ b/frontend/src/components/__tests__/RegionDropdown.js @@ -5,7 +5,7 @@ import { render, screen } from '@testing-library/react'; import RegionDropdown from '../RegionDropdown'; describe('RegionalDropdown', () => { - test('defaults to the correct option', () => { + test('shows "select an option" (the default) when no value is selected', () => { render( {}} />); expect(screen.getByLabelText('Region').value).toBe('default'); }); @@ -15,4 +15,14 @@ describe('RegionalDropdown', () => { const item = screen.getByLabelText('Region').options.namedItem('default'); expect(item.hidden).toBeTruthy(); }); + + test('does not show central office when includeCentralOffice is not specified', () => { + render( {}} />); + expect(screen.queryByText('Central Office')).toBeNull(); + }); + + test('with prop includeCentralOffice has central office as an option', () => { + render( {}} />); + expect(screen.queryByText('Central Office')).toBeVisible(); + }); }); diff --git a/frontend/src/pages/Admin/UserInfo.js b/frontend/src/pages/Admin/UserInfo.js index 68bcefcc70..568b637622 100644 --- a/frontend/src/pages/Admin/UserInfo.js +++ b/frontend/src/pages/Admin/UserInfo.js @@ -26,7 +26,7 @@ function UserInfo({ user, onUserChange }) { - + diff --git a/frontend/src/pages/Admin/components/CurrentPermissions.js b/frontend/src/pages/Admin/components/CurrentPermissions.js index 3bd2113254..4e062394e4 100644 --- a/frontend/src/pages/Admin/components/CurrentPermissions.js +++ b/frontend/src/pages/Admin/components/CurrentPermissions.js @@ -5,7 +5,7 @@ function CurrentPermissions({ regions, scope }) { const regionsStr = regions.length === 1 ? 'Region' : 'Regions'; const regionMsg = `${regionsStr} ${regions.join(', ')}`; return ( -
  • +
  • {scope} {': '} {regionMsg} diff --git a/frontend/src/pages/Admin/index.js b/frontend/src/pages/Admin/index.js index 28191fb17a..12973b1bff 100644 --- a/frontend/src/pages/Admin/index.js +++ b/frontend/src/pages/Admin/index.js @@ -37,7 +37,7 @@ const fetchedUsers = [ scope: 'READ_WRITE_REPORTS', }, ], - region: '1', + region: 'co', }, { id: 3,