From 023b5391b0cec0d289e8aaa2c66a66dfecfffe0d Mon Sep 17 00:00:00 2001 From: Josh Salisbury Date: Mon, 21 Sep 2020 11:44:34 -0500 Subject: [PATCH 1/5] 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 --- 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 | 54 +++++ frontend/src/pages/Admin/UserPermissions.js | 150 ++++++++++++ frontend/src/pages/Admin/UserSection.js | 91 ++++++++ .../Admin/__tests__/PermissionHelpers.js | 86 +++++++ .../src/pages/Admin/__tests__/UserInfo.js | 67 ++++++ .../pages/Admin/__tests__/UserPermissions.js | 59 +++++ .../src/pages/Admin/__tests__/UserSection.js | 54 +++++ 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 | 217 ++++++++++++++++++ frontend/src/setupTests.js | 10 + frontend/src/testHelpers.js | 13 ++ frontend/yarn.lock | 99 ++++++-- src/index.js | 4 +- 30 files changed, 1370 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..0ccb6f27ff --- /dev/null +++ b/frontend/src/pages/Admin/UserInfo.js @@ -0,0 +1,54 @@ +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, + firstName: PropTypes.string, + lastName: 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..dc62bf5291 --- /dev/null +++ b/frontend/src/pages/Admin/UserSection.js @@ -0,0 +1,91 @@ +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, + firstName: PropTypes.string, + lastName: 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..7978998153 --- /dev/null +++ b/frontend/src/pages/Admin/__tests__/UserInfo.js @@ -0,0 +1,67 @@ +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 firstName', () => { + expect(screen.getByLabelText('First Name')).toHaveValue(''); + }); + + test('has a blank lastName', async () => { + expect(screen.getByLabelText('Last 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', + firstName: 'first', + lastName: 'last', + region: '1', + jobTitle: 'Grantee Specialist', + }; + + render( {}} />); + }); + + test('has correct email', async () => { + expect(screen.getByLabelText('Email')).toHaveValue('email'); + }); + + test('has correct firstName', () => { + expect(screen.getByLabelText('First Name')).toHaveValue('first'); + }); + + test('has correct lastName', async () => { + expect(screen.getByLabelText('Last Name')).toHaveValue('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..cb4c07c9ce --- /dev/null +++ b/frontend/src/pages/Admin/__tests__/UserSection.js @@ -0,0 +1,54 @@ +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', + firstName: 'first', + lastName: '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('First Name'); + expect(inputBox).toHaveValue('first'); + userEvent.type(inputBox, '{selectall}{backspace}new name'); + expect(screen.getByLabelText('First 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..d551843358 --- /dev/null +++ b/frontend/src/pages/Admin/index.js @@ -0,0 +1,217 @@ +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, + firstName: undefined, + lastName: undefined, + permissions: undefined, + region: undefined, + }, + { + id: 2, + email: 'hermionegranger@hogwarts.com', + jobTitle: 'Systems Specialist', + firstName: 'Hermione', + lastName: 'Granger', + permissions: [ + { + region: 0, + scope: 'SITE_ACCESS', + }, + { + region: 1, + scope: 'READ_WRITE_REPORTS', + }, + ], + region: '1', + }, + { + id: 3, + email: 'harrypotter@hogwarts.com', + jobTitle: 'Grantee Specialist', + firstName: 'Harry', + lastName: '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: '4', + }, + { + id: 4, + email: 'ronweasley@hogwarts.com', + jobTitle: 'Program Specialist', + firstName: 'Ron', + lastName: '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 first and last name + * or email address if the user doesn't have a first name or last name. + */ +function renderUserNav(users) { + return users.map((user) => { + const { + firstName, lastName, email, id, + } = user; + let display = email; + if (firstName) { + display = `${firstName} ${lastName}`; + } + 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 (first/last 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, firstName, lastName } = u; + return `${email}${firstName}${lastName}`.includes(userSearch); + }); + + return ( +
    + + + + + + +
    + +
    +
    + + {!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 25d155ad48..187aade685 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 5dbca45e5e62d32f31c28cf9ba3ddf8c068ccb54 Mon Sep 17 00:00:00 2001 From: Josh Salisbury Date: Mon, 21 Sep 2020 12:03:54 -0500 Subject: [PATCH 2/5] Add title to admin page --- frontend/src/pages/Admin/index.js | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/pages/Admin/index.js b/frontend/src/pages/Admin/index.js index d551843358..44dec79975 100644 --- a/frontend/src/pages/Admin/index.js +++ b/frontend/src/pages/Admin/index.js @@ -183,6 +183,7 @@ function Admin(props) { return (
    +

    User Administration

    From 979e45af1695d490a4d4468f72e374bb3879475a Mon Sep 17 00:00:00 2001 From: Josh Salisbury Date: Tue, 22 Sep 2020 12:53:20 -0500 Subject: [PATCH 3/5] Change first/lastname field to full name --- frontend/src/pages/Admin/UserInfo.js | 13 +++------ frontend/src/pages/Admin/UserSection.js | 3 +- .../src/pages/Admin/__tests__/UserInfo.js | 19 ++++--------- .../src/pages/Admin/__tests__/UserSection.js | 9 +++--- frontend/src/pages/Admin/index.js | 28 ++++++++----------- 5 files changed, 26 insertions(+), 46 deletions(-) diff --git a/frontend/src/pages/Admin/UserInfo.js b/frontend/src/pages/Admin/UserInfo.js index 0ccb6f27ff..68bcefcc70 100644 --- a/frontend/src/pages/Admin/UserInfo.js +++ b/frontend/src/pages/Admin/UserInfo.js @@ -19,13 +19,9 @@ function UserInfo({ user, onUserChange }) { - - - - - - - + + + @@ -43,8 +39,7 @@ function UserInfo({ user, onUserChange }) { UserInfo.propTypes = { user: PropTypes.shape({ email: PropTypes.string, - firstName: PropTypes.string, - lastName: PropTypes.string, + fullName: PropTypes.string, region: PropTypes.string, jobTitle: PropTypes.string, }).isRequired, diff --git a/frontend/src/pages/Admin/UserSection.js b/frontend/src/pages/Admin/UserSection.js index dc62bf5291..e18a979b42 100644 --- a/frontend/src/pages/Admin/UserSection.js +++ b/frontend/src/pages/Admin/UserSection.js @@ -77,8 +77,7 @@ UserSection.propTypes = { user: PropTypes.shape({ id: PropTypes.number, email: PropTypes.string, - firstName: PropTypes.string, - lastName: PropTypes.string, + fullName: PropTypes.string, region: PropTypes.string, jobTitle: PropTypes.string, permissions: PropTypes.arrayOf(PropTypes.shape({ diff --git a/frontend/src/pages/Admin/__tests__/UserInfo.js b/frontend/src/pages/Admin/__tests__/UserInfo.js index 7978998153..d87f46ee87 100644 --- a/frontend/src/pages/Admin/__tests__/UserInfo.js +++ b/frontend/src/pages/Admin/__tests__/UserInfo.js @@ -14,12 +14,8 @@ describe('UserInfo', () => { expect(screen.getByLabelText('Email')).toHaveValue(''); }); - test('has a blank firstName', () => { - expect(screen.getByLabelText('First Name')).toHaveValue(''); - }); - - test('has a blank lastName', async () => { - expect(screen.getByLabelText('Last Name')).toHaveValue(''); + test('has a blank fullName', () => { + expect(screen.getByLabelText('Full Name')).toHaveValue(''); }); test('has the default region', () => { @@ -35,8 +31,7 @@ describe('UserInfo', () => { beforeEach(() => { const user = { email: 'email', - firstName: 'first', - lastName: 'last', + fullName: 'first last', region: '1', jobTitle: 'Grantee Specialist', }; @@ -48,12 +43,8 @@ describe('UserInfo', () => { expect(screen.getByLabelText('Email')).toHaveValue('email'); }); - test('has correct firstName', () => { - expect(screen.getByLabelText('First Name')).toHaveValue('first'); - }); - - test('has correct lastName', async () => { - expect(screen.getByLabelText('Last Name')).toHaveValue('last'); + test('has correct fullName', () => { + expect(screen.getByLabelText('Full Name')).toHaveValue('first last'); }); test('has correct region', () => { diff --git a/frontend/src/pages/Admin/__tests__/UserSection.js b/frontend/src/pages/Admin/__tests__/UserSection.js index cb4c07c9ce..8afe7d5fc9 100644 --- a/frontend/src/pages/Admin/__tests__/UserSection.js +++ b/frontend/src/pages/Admin/__tests__/UserSection.js @@ -10,8 +10,7 @@ describe('UserSection', () => { const user = { id: 1, email: 'email', - firstName: 'first', - lastName: 'last', + fullName: 'first last', jobTitle: 'Grantee Specialist', region: '1', permissions: [ @@ -30,10 +29,10 @@ describe('UserSection', () => { }); it('properly controls user info', () => { - const inputBox = screen.getByLabelText('First Name'); - expect(inputBox).toHaveValue('first'); + const inputBox = screen.getByLabelText('Full Name'); + expect(inputBox).toHaveValue('first last'); userEvent.type(inputBox, '{selectall}{backspace}new name'); - expect(screen.getByLabelText('First Name')).toHaveValue('new name'); + expect(screen.getByLabelText('Full Name')).toHaveValue('new name'); }); it('properly controls global permissions', () => { diff --git a/frontend/src/pages/Admin/index.js b/frontend/src/pages/Admin/index.js index 44dec79975..83cc79bd86 100644 --- a/frontend/src/pages/Admin/index.js +++ b/frontend/src/pages/Admin/index.js @@ -14,8 +14,7 @@ const fetchedUsers = [ id: 1, email: 'dumbledore@hogwarts.com', jobTitle: undefined, - firstName: undefined, - lastName: undefined, + fullName: undefined, permissions: undefined, region: undefined, }, @@ -23,8 +22,7 @@ const fetchedUsers = [ id: 2, email: 'hermionegranger@hogwarts.com', jobTitle: 'Systems Specialist', - firstName: 'Hermione', - lastName: 'Granger', + fullName: 'Hermione Granger', permissions: [ { region: 0, @@ -41,8 +39,7 @@ const fetchedUsers = [ id: 3, email: 'harrypotter@hogwarts.com', jobTitle: 'Grantee Specialist', - firstName: 'Harry', - lastName: 'Potter', + fullName: 'Harry Potter', permissions: [ { region: 0, @@ -99,8 +96,7 @@ const fetchedUsers = [ id: 4, email: 'ronweasley@hogwarts.com', jobTitle: 'Program Specialist', - firstName: 'Ron', - lastName: 'Weasley', + fullName: 'Ron Weasley', permissions: [ { region: 0, @@ -116,17 +112,17 @@ const fetchedUsers = [ ]; /** - * Render the left hand user navigation in the Admin UI. Use the user's first and last name - * or email address if the user doesn't have a first name or last name. + * 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 { - firstName, lastName, email, id, + fullName, email, id, } = user; let display = email; - if (firstName) { - display = `${firstName} ${lastName}`; + if (fullName) { + display = fullName; } return {display}; }); @@ -135,7 +131,7 @@ function renderUserNav(users) { /** * 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 (first/last name, + * 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). */ @@ -176,8 +172,8 @@ function Admin(props) { } const filteredUsers = _.filter(users, (u) => { - const { email, firstName, lastName } = u; - return `${email}${firstName}${lastName}`.includes(userSearch); + const { email, fullName } = u; + return `${email}${fullName}`.includes(userSearch); }); return ( From 9f60125ac72507baa1563fdcdeb1c2a462236169 Mon Sep 17 00:00:00 2001 From: Josh Salisbury Date: Tue, 22 Sep 2020 16:55:26 -0500 Subject: [PATCH 4/5] Set Harry's region to 1 This is to closer match the current permissions for the user --- frontend/src/pages/Admin/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/Admin/index.js b/frontend/src/pages/Admin/index.js index 83cc79bd86..fdebb4d9e0 100644 --- a/frontend/src/pages/Admin/index.js +++ b/frontend/src/pages/Admin/index.js @@ -90,7 +90,7 @@ const fetchedUsers = [ scope: 'READ_REPORTS', }, ], - region: '4', + region: '1', }, { id: 4, From b44569b02bb00fe69bdc6cd5304bcde8a3257fd2 Mon Sep 17 00:00:00 2001 From: Josh Salisbury Date: Thu, 24 Sep 2020 09:35:52 -0500 Subject: [PATCH 5/5] Add comment about region 0 --- frontend/src/pages/Admin/index.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/src/pages/Admin/index.js b/frontend/src/pages/Admin/index.js index fdebb4d9e0..28191fb17a 100644 --- a/frontend/src/pages/Admin/index.js +++ b/frontend/src/pages/Admin/index.js @@ -25,6 +25,10 @@ const fetchedUsers = [ 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', },