diff --git a/awx/ui_next/src/components/FormField/PasswordField.jsx b/awx/ui_next/src/components/FormField/PasswordField.jsx new file mode 100644 index 0000000000..5ea33884ad --- /dev/null +++ b/awx/ui_next/src/components/FormField/PasswordField.jsx @@ -0,0 +1,85 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Field } from 'formik'; +import { + Button, + ButtonVariant, + FormGroup, + InputGroup, + TextInput, + Tooltip, +} from '@patternfly/react-core'; +import { EyeIcon, EyeSlashIcon } from '@patternfly/react-icons'; + +function PasswordField(props) { + const { id, name, label, validate, isRequired, i18n } = props; + const [inputType, setInputType] = useState('password'); + + const handlePasswordToggle = () => { + setInputType(inputType === 'text' ? 'password' : 'text'); + }; + + return ( + { + const isValid = + form && (!form.touched[field.name] || !form.errors[field.name]); + return ( + + + + + + { + field.onChange(event); + }} + /> + + + ); + }} + /> + ); +} + +PasswordField.propTypes = { + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + validate: PropTypes.func, + isRequired: PropTypes.bool, +}; + +PasswordField.defaultProps = { + validate: () => {}, + isRequired: false, +}; + +export default withI18n()(PasswordField); diff --git a/awx/ui_next/src/components/FormField/PasswordField.test.jsx b/awx/ui_next/src/components/FormField/PasswordField.test.jsx new file mode 100644 index 0000000000..5fb2ec434a --- /dev/null +++ b/awx/ui_next/src/components/FormField/PasswordField.test.jsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import { sleep } from '@testUtils/testUtils'; +import { Formik } from 'formik'; +import PasswordField from './PasswordField'; + +describe('PasswordField', () => { + test('renders the expected content', () => { + const wrapper = mountWithContexts( + ( + + )} + /> + ); + expect(wrapper).toHaveLength(1); + }); + + test('properly responds to show/hide toggles', async () => { + const wrapper = mountWithContexts( + ( + + )} + /> + ); + expect(wrapper.find('input').prop('type')).toBe('password'); + expect(wrapper.find('EyeSlashIcon').length).toBe(1); + expect(wrapper.find('EyeIcon').length).toBe(0); + wrapper.find('button').simulate('click'); + await sleep(1); + expect(wrapper.find('input').prop('type')).toBe('text'); + expect(wrapper.find('EyeSlashIcon').length).toBe(0); + expect(wrapper.find('EyeIcon').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/components/FormField/index.js b/awx/ui_next/src/components/FormField/index.js index 4ce1944f17..2b23e65900 100644 --- a/awx/ui_next/src/components/FormField/index.js +++ b/awx/ui_next/src/components/FormField/index.js @@ -1,3 +1,4 @@ export { default } from './FormField'; export { default as CheckboxField } from './CheckboxField'; export { default as FieldTooltip } from './FieldTooltip'; +export { default as PasswordField } from './PasswordField'; diff --git a/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.jsx b/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.jsx index dd595013e1..c9d1b01247 100644 --- a/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.jsx +++ b/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.jsx @@ -1,5 +1,13 @@ import React, { Fragment } from 'react'; -import { func, bool, number, string, arrayOf, shape } from 'prop-types'; +import { + func, + bool, + number, + string, + arrayOf, + shape, + checkPropTypes, +} from 'prop-types'; import { Button, Tooltip } from '@patternfly/react-core'; import { TrashAltIcon } from '@patternfly/react-icons'; import styled from 'styled-components'; @@ -22,9 +30,40 @@ const DeleteButton = styled(Button)` } `; +const requireNameOrUsername = props => { + const { name, username } = props; + if (!name && !username) { + return new Error( + `One of 'name' or 'username' is required by ItemToDelete component.` + ); + } + if (name) { + checkPropTypes( + { + name: string, + }, + { name: props.name }, + 'prop', + 'ItemToDelete' + ); + } + if (username) { + checkPropTypes( + { + username: string, + }, + { username: props.username }, + 'prop', + 'ItemToDelete' + ); + } + return null; +}; + const ItemToDelete = shape({ id: number.isRequired, - name: string.isRequired, + name: requireNameOrUsername, + username: requireNameOrUsername, summary_fields: shape({ user_capabilities: shape({ delete: bool.isRequired, @@ -148,7 +187,7 @@ class ToolbarDeleteButton extends React.Component {
{itemsToDelete.map(item => ( - {item.name} + {item.name || item.username}
))} diff --git a/awx/ui_next/src/components/Sort/Sort.jsx b/awx/ui_next/src/components/Sort/Sort.jsx index 9cdf75776e..cc53ebdcce 100644 --- a/awx/ui_next/src/components/Sort/Sort.jsx +++ b/awx/ui_next/src/components/Sort/Sort.jsx @@ -127,7 +127,7 @@ class Sort extends React.Component { return ( - {sortDropdownItems.length > 1 && ( + {sortDropdownItems.length > 0 && ( {i18n._(t`Sort By`)} ', () => { +describe('', () => { let wrapper; const mockData = { name: 'foo', diff --git a/awx/ui_next/src/screens/Team/TeamList/TeamListItem.jsx b/awx/ui_next/src/screens/Team/TeamList/TeamListItem.jsx index cd6244153b..350b44cb61 100644 --- a/awx/ui_next/src/screens/Team/TeamList/TeamListItem.jsx +++ b/awx/ui_next/src/screens/Team/TeamList/TeamListItem.jsx @@ -49,7 +49,7 @@ class TeamListItem extends React.Component { {team.summary_fields.organization && ( - + {i18n._(t`Organization`)} + + + + ); + + if (!isInitialized) { + cardHeader = null; + } + + if (!match) { + cardHeader = null; + } + + if (location.pathname.endsWith('edit')) { + cardHeader = null; + } + + if (!hasContentLoading && contentError) { + return ( + + + + {contentError.response.status === 404 && ( + + {i18n._(`User not found.`)}{' '} + {i18n._(`View all Users.`)} + + )} + + + + ); + } + + return ( + + + {cardHeader} + + + {user && ( + } + /> + )} + {user && ( + } + /> + )} + } + /> + } + /> + {user && ( + ( + + this needs a different access list from regular resources + like proj, inv, jt + + )} + /> + )} + } + /> + + !hasContentLoading && ( + + {match.params.id && ( + + {i18n._(`View User Details`)} + + )} + + ) + } + /> + , + + + + ); + } +} + +export default withI18n()(withRouter(User)); +export { User as _User }; diff --git a/awx/ui_next/src/screens/User/User.test.jsx b/awx/ui_next/src/screens/User/User.test.jsx new file mode 100644 index 0000000000..3d2c366d19 --- /dev/null +++ b/awx/ui_next/src/screens/User/User.test.jsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { createMemoryHistory } from 'history'; +import { UsersAPI } from '@api'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; +import mockDetails from './data.user.json'; +import User from './User'; + +jest.mock('@api'); + +const mockMe = { + is_super_user: true, + is_system_auditor: false, +}; + +async function getUsers() { + return { + count: 1, + next: null, + previous: null, + data: { + results: [mockDetails], + }, + }; +} + +describe('', () => { + test('initially renders succesfully', () => { + UsersAPI.readDetail.mockResolvedValue({ data: mockDetails }); + UsersAPI.read.mockImplementation(getUsers); + mountWithContexts( {}} me={mockMe} />); + }); + + test('notifications tab shown for admins', async () => { + UsersAPI.readDetail.mockResolvedValue({ data: mockDetails }); + UsersAPI.read.mockImplementation(getUsers); + + const wrapper = mountWithContexts( + {}} me={mockMe} /> + ); + await waitForElement(wrapper, '.pf-c-tabs__item', el => el.length === 5); + }); + + test('should show content error when user attempts to navigate to erroneous route', async () => { + const history = createMemoryHistory({ + initialEntries: ['/users/1/foobar'], + }); + const wrapper = mountWithContexts( + {}} me={mockMe} />, + { + context: { + router: { + history, + route: { + location: history.location, + match: { + params: { id: 1 }, + url: '/users/1/foobar', + path: '/users/1/foobar', + }, + }, + }, + }, + } + ); + await waitForElement(wrapper, 'ContentError', el => el.length === 1); + }); +}); diff --git a/awx/ui_next/src/screens/User/UserAdd/UserAdd.jsx b/awx/ui_next/src/screens/User/UserAdd/UserAdd.jsx new file mode 100644 index 0000000000..4b9587086e --- /dev/null +++ b/awx/ui_next/src/screens/User/UserAdd/UserAdd.jsx @@ -0,0 +1,62 @@ +import React, { useState } from 'react'; +import { withRouter } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import styled from 'styled-components'; +import { + Card as _Card, + CardBody, + CardHeader, + PageSection, + Tooltip, +} from '@patternfly/react-core'; +import CardCloseButton from '@components/CardCloseButton'; +import UserForm from '../shared/UserForm'; +import { UsersAPI } from '@api'; + +const Card = styled(_Card)` + --pf-c-card--child--PaddingLeft: 0; + --pf-c-card--child--PaddingRight: 0; +`; + +function UserAdd({ history, i18n }) { + const [formSubmitError, setFormSubmitError] = useState(null); + + const handleSubmit = async values => { + setFormSubmitError(null); + try { + const { + data: { id }, + } = await UsersAPI.create(values); + history.push(`/users/${id}/details`); + } catch (error) { + setFormSubmitError(error); + } + }; + + const handleCancel = () => { + history.push(`/users`); + }; + + return ( + + + + + + + + + + + {formSubmitError ? ( +
formSubmitError
+ ) : ( + '' + )} +
+
+ ); +} + +export default withI18n()(withRouter(UserAdd)); diff --git a/awx/ui_next/src/screens/User/UserAdd/UserAdd.test.jsx b/awx/ui_next/src/screens/User/UserAdd/UserAdd.test.jsx new file mode 100644 index 0000000000..49ec8baa11 --- /dev/null +++ b/awx/ui_next/src/screens/User/UserAdd/UserAdd.test.jsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; +import UserAdd from './UserAdd'; +import { UsersAPI } from '@api'; + +jest.mock('@api'); +let wrapper; + +describe('', () => { + test('handleSubmit should post to api', async () => { + await act(async () => { + wrapper = mountWithContexts(); + }); + const updatedUserData = { + username: 'sysadmin', + email: 'sysadmin@ansible.com', + first_name: 'System', + last_name: 'Administrator', + password: 'password', + organization: 1, + is_superuser: true, + is_system_auditor: false, + }; + await act(async () => { + wrapper.find('UserForm').prop('handleSubmit')(updatedUserData); + }); + expect(UsersAPI.create).toHaveBeenCalledWith(updatedUserData); + }); + + test('should navigate to users list when cancel is clicked', async () => { + const history = createMemoryHistory({}); + await act(async () => { + wrapper = mountWithContexts(, { + context: { router: { history } }, + }); + }); + await act(async () => { + wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); + }); + expect(history.location.pathname).toEqual('/users'); + }); + + test('should navigate to users list when close (x) is clicked', async () => { + const history = createMemoryHistory({}); + await act(async () => { + wrapper = mountWithContexts(, { + context: { router: { history } }, + }); + }); + await act(async () => { + wrapper.find('button[aria-label="Close"]').prop('onClick')(); + }); + expect(history.location.pathname).toEqual('/users'); + }); + + test('successful form submission should trigger redirect', async () => { + const history = createMemoryHistory({}); + const userData = { + username: 'sysadmin', + email: 'sysadmin@ansible.com', + first_name: 'System', + last_name: 'Administrator', + password: 'password', + organization: 1, + is_superuser: true, + is_system_auditor: false, + }; + UsersAPI.create.mockResolvedValueOnce({ + data: { + id: 5, + ...userData, + }, + }); + await act(async () => { + wrapper = mountWithContexts(, { + context: { router: { history } }, + }); + }); + await waitForElement(wrapper, 'button[aria-label="Save"]'); + await act(async () => { + wrapper.find('UserForm').prop('handleSubmit')(userData); + }); + expect(history.location.pathname).toEqual('/users/5/details'); + }); +}); diff --git a/awx/ui_next/src/screens/User/UserAdd/index.js b/awx/ui_next/src/screens/User/UserAdd/index.js new file mode 100644 index 0000000000..fb54496e6d --- /dev/null +++ b/awx/ui_next/src/screens/User/UserAdd/index.js @@ -0,0 +1 @@ +export { default } from './UserAdd'; diff --git a/awx/ui_next/src/screens/User/UserDetail/UserDetail.jsx b/awx/ui_next/src/screens/User/UserDetail/UserDetail.jsx new file mode 100644 index 0000000000..71b4de6e99 --- /dev/null +++ b/awx/ui_next/src/screens/User/UserDetail/UserDetail.jsx @@ -0,0 +1,77 @@ +import React, { Component } from 'react'; +import { Link, withRouter } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { CardBody as PFCardBody, Button } from '@patternfly/react-core'; +import styled from 'styled-components'; + +import { DetailList, Detail } from '@components/DetailList'; +import { formatDateString } from '@util/dates'; + +const CardBody = styled(PFCardBody)` + padding-top: 20px; +`; + +class UserDetail extends Component { + render() { + const { + user: { + id, + username, + email, + first_name, + last_name, + last_login, + created, + is_superuser, + is_system_auditor, + summary_fields, + }, + i18n, + } = this.props; + + let user_type; + if (is_superuser) { + user_type = i18n._(t`System Administrator`); + } else if (is_system_auditor) { + user_type = i18n._(t`System Auditor`); + } else { + user_type = i18n._(t`Normal User`); + } + + return ( + + + + + + + + {last_login && ( + + )} + + + {summary_fields.user_capabilities.edit && ( +
+ +
+ )} +
+ ); + } +} + +export default withI18n()(withRouter(UserDetail)); diff --git a/awx/ui_next/src/screens/User/UserDetail/UserDetail.test.jsx b/awx/ui_next/src/screens/User/UserDetail/UserDetail.test.jsx new file mode 100644 index 0000000000..7e3b379e48 --- /dev/null +++ b/awx/ui_next/src/screens/User/UserDetail/UserDetail.test.jsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { createMemoryHistory } from 'history'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; +import UserDetail from './UserDetail'; +import mockDetails from '../data.user.json'; + +describe('', () => { + test('initially renders succesfully', () => { + mountWithContexts(); + }); + + test('should render Details', () => { + const wrapper = mountWithContexts(, { + context: { + linguiPublisher: { + i18n: { + _: key => { + if (key.values) { + Object.entries(key.values).forEach(([k, v]) => { + key.id = key.id.replace(new RegExp(`\\{${k}\\}`), v); + }); + } + return key.id; + }, + }, + }, + }, + }); + function assertDetail(label, value) { + expect(wrapper.find(`Detail[label="${label}"] dt`).text()).toBe(label); + expect(wrapper.find(`Detail[label="${label}"] dd`).text()).toBe(value); + } + assertDetail('Username', mockDetails.username); + assertDetail('Email', mockDetails.email); + assertDetail('First Name', mockDetails.first_name); + assertDetail('Last Name', mockDetails.last_name); + assertDetail('User Type', 'System Administrator'); + assertDetail('Last Login', `11/4/2019, 11:12:36 PM`); + assertDetail('Created', `10/28/2019, 3:01:07 PM`); + }); + + test('should show edit button for users with edit permission', async done => { + const wrapper = mountWithContexts(); + const editButton = await waitForElement( + wrapper, + 'UserDetail Button[aria-label="edit"]' + ); + expect(editButton.text()).toEqual('Edit'); + expect(editButton.prop('to')).toBe(`/users/${mockDetails.id}/edit`); + done(); + }); + + test('should hide edit button for users without edit permission', async done => { + const wrapper = mountWithContexts( + + ); + await waitForElement(wrapper, 'UserDetail'); + expect(wrapper.find('UserDetail Button[aria-label="edit"]').length).toBe(0); + done(); + }); + + test('edit button should navigate to user edit', () => { + const history = createMemoryHistory(); + const wrapper = mountWithContexts(, { + context: { router: { history } }, + }); + expect(wrapper.find('Button[aria-label="edit"]').length).toBe(1); + wrapper + .find('Button[aria-label="edit"] Link') + .simulate('click', { button: 0 }); + expect(history.location.pathname).toEqual('/users/1/edit'); + }); +}); diff --git a/awx/ui_next/src/screens/User/UserDetail/index.js b/awx/ui_next/src/screens/User/UserDetail/index.js new file mode 100644 index 0000000000..8b649932e1 --- /dev/null +++ b/awx/ui_next/src/screens/User/UserDetail/index.js @@ -0,0 +1 @@ +export { default } from './UserDetail'; diff --git a/awx/ui_next/src/screens/User/UserEdit/UserEdit.jsx b/awx/ui_next/src/screens/User/UserEdit/UserEdit.jsx new file mode 100644 index 0000000000..95ead75efa --- /dev/null +++ b/awx/ui_next/src/screens/User/UserEdit/UserEdit.jsx @@ -0,0 +1,36 @@ +import React, { useState } from 'react'; +import { withRouter } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { CardBody } from '@patternfly/react-core'; +import UserForm from '../shared/UserForm'; +import { UsersAPI } from '@api'; + +function UserEdit({ user, history }) { + const [formSubmitError, setFormSubmitError] = useState(null); + + const handleSubmit = async values => { + try { + await UsersAPI.update(user.id, values); + history.push(`/users/${user.id}/details`); + } catch (error) { + setFormSubmitError(error); + } + }; + + const handleCancel = () => { + history.push(`/users/${user.id}/details`); + }; + + return ( + + + {formSubmitError ?
error
: null} +
+ ); +} + +export default withI18n()(withRouter(UserEdit)); diff --git a/awx/ui_next/src/screens/User/UserEdit/UserEdit.test.jsx b/awx/ui_next/src/screens/User/UserEdit/UserEdit.test.jsx new file mode 100644 index 0000000000..237d200afa --- /dev/null +++ b/awx/ui_next/src/screens/User/UserEdit/UserEdit.test.jsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { UsersAPI } from '@api'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import UserEdit from './UserEdit'; + +jest.mock('@api'); +let wrapper; + +describe('', () => { + const mockData = { + id: 1, + username: 'sysadmin', + email: 'sysadmin@ansible.com', + first_name: 'System', + last_name: 'Administrator', + password: 'password', + organization: 1, + is_superuser: true, + is_system_auditor: false, + }; + + test('handleSubmit should call api update', async () => { + await act(async () => { + wrapper = mountWithContexts(); + }); + + const updatedUserData = { + ...mockData, + username: 'Foo', + }; + await act(async () => { + wrapper.find('UserForm').prop('handleSubmit')(updatedUserData); + }); + + expect(UsersAPI.update).toHaveBeenCalledWith(1, updatedUserData); + }); + + test('should navigate to user detail when cancel is clicked', async () => { + const history = createMemoryHistory({}); + await act(async () => { + wrapper = mountWithContexts(, { + context: { router: { history } }, + }); + }); + + await act(async () => { + wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); + }); + + expect(history.location.pathname).toEqual('/users/1/details'); + }); +}); diff --git a/awx/ui_next/src/screens/User/UserEdit/index.js b/awx/ui_next/src/screens/User/UserEdit/index.js new file mode 100644 index 0000000000..07337fbcff --- /dev/null +++ b/awx/ui_next/src/screens/User/UserEdit/index.js @@ -0,0 +1 @@ +export { default } from './UserEdit'; diff --git a/awx/ui_next/src/screens/User/UserList/UserList.jsx b/awx/ui_next/src/screens/User/UserList/UserList.jsx new file mode 100644 index 0000000000..1edd5aa9cf --- /dev/null +++ b/awx/ui_next/src/screens/User/UserList/UserList.jsx @@ -0,0 +1,228 @@ +import React, { Component, Fragment } from 'react'; +import { withRouter } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Card, PageSection } from '@patternfly/react-core'; + +import { UsersAPI } from '@api'; +import AlertModal from '@components/AlertModal'; +import DataListToolbar from '@components/DataListToolbar'; +import ErrorDetail from '@components/ErrorDetail'; +import PaginatedDataList, { + ToolbarAddButton, + ToolbarDeleteButton, +} from '@components/PaginatedDataList'; +import { getQSConfig, parseQueryString } from '@util/qs'; + +import UserListItem from './UserListItem'; + +const QS_CONFIG = getQSConfig('user', { + page: 1, + page_size: 20, + order_by: 'username', +}); + +class UsersList extends Component { + constructor(props) { + super(props); + + this.state = { + hasContentLoading: true, + contentError: null, + deletionError: null, + users: [], + selected: [], + itemCount: 0, + actions: null, + }; + + this.handleSelectAll = this.handleSelectAll.bind(this); + this.handleSelect = this.handleSelect.bind(this); + this.handleUserDelete = this.handleUserDelete.bind(this); + this.handleDeleteErrorClose = this.handleDeleteErrorClose.bind(this); + this.loadUsers = this.loadUsers.bind(this); + } + + componentDidMount() { + this.loadUsers(); + } + + componentDidUpdate(prevProps) { + const { location } = this.props; + if (location !== prevProps.location) { + this.loadUsers(); + } + } + + handleSelectAll(isSelected) { + const { users } = this.state; + + const selected = isSelected ? [...users] : []; + this.setState({ selected }); + } + + handleSelect(row) { + const { selected } = this.state; + + if (selected.some(s => s.id === row.id)) { + this.setState({ selected: selected.filter(s => s.id !== row.id) }); + } else { + this.setState({ selected: selected.concat(row) }); + } + } + + handleDeleteErrorClose() { + this.setState({ deletionError: null }); + } + + async handleUserDelete() { + const { selected } = this.state; + + this.setState({ hasContentLoading: true }); + try { + await Promise.all(selected.map(org => UsersAPI.destroy(org.id))); + } catch (err) { + this.setState({ deletionError: err }); + } finally { + await this.loadUsers(); + } + } + + async loadUsers() { + const { location } = this.props; + const { actions: cachedActions } = this.state; + const params = parseQueryString(QS_CONFIG, location.search); + + let optionsPromise; + if (cachedActions) { + optionsPromise = Promise.resolve({ data: { actions: cachedActions } }); + } else { + optionsPromise = UsersAPI.readOptions(); + } + + const promises = Promise.all([UsersAPI.read(params), optionsPromise]); + + this.setState({ contentError: null, hasContentLoading: true }); + try { + const [ + { + data: { count, results }, + }, + { + data: { actions }, + }, + ] = await promises; + this.setState({ + actions, + itemCount: count, + users: results, + selected: [], + }); + } catch (err) { + this.setState({ contentError: err }); + } finally { + this.setState({ hasContentLoading: false }); + } + } + + render() { + const { + actions, + itemCount, + contentError, + hasContentLoading, + deletionError, + selected, + users, + } = this.state; + const { match, i18n } = this.props; + + const canAdd = + actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); + const isAllSelected = + selected.length === users.length && selected.length > 0; + + return ( + + + + ( + , + canAdd ? ( + + ) : null, + ]} + /> + )} + renderItem={o => ( + row.id === o.id)} + onSelect={() => this.handleSelect(o)} + /> + )} + emptyStateControls={ + canAdd ? ( + + ) : null + } + /> + + + + {i18n._(t`Failed to delete one or more users.`)} + + + + ); + } +} + +export { UsersList as _UsersList }; +export default withI18n()(withRouter(UsersList)); diff --git a/awx/ui_next/src/screens/User/UserList/UserList.test.jsx b/awx/ui_next/src/screens/User/UserList/UserList.test.jsx new file mode 100644 index 0000000000..cc54d9d0f5 --- /dev/null +++ b/awx/ui_next/src/screens/User/UserList/UserList.test.jsx @@ -0,0 +1,295 @@ +import React from 'react'; +import { UsersAPI } from '@api'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; + +import UsersList, { _UsersList } from './UserList'; + +jest.mock('@api'); + +let wrapper; +const loadUsers = jest.spyOn(_UsersList.prototype, 'loadUsers'); +const mockUsers = [ + { + id: 1, + type: 'user', + url: '/api/v2/users/1/', + related: { + teams: '/api/v2/users/1/teams/', + organizations: '/api/v2/users/1/organizations/', + admin_of_organizations: '/api/v2/users/1/admin_of_organizations/', + projects: '/api/v2/users/1/projects/', + credentials: '/api/v2/users/1/credentials/', + roles: '/api/v2/users/1/roles/', + activity_stream: '/api/v2/users/1/activity_stream/', + access_list: '/api/v2/users/1/access_list/', + tokens: '/api/v2/users/1/tokens/', + authorized_tokens: '/api/v2/users/1/authorized_tokens/', + personal_tokens: '/api/v2/users/1/personal_tokens/', + }, + summary_fields: { + user_capabilities: { + edit: true, + delete: true, + }, + }, + created: '2019-10-28T15:01:07.218634Z', + username: 'admin', + first_name: 'Admin', + last_name: 'User', + email: 'admin@ansible.com', + is_superuser: true, + is_system_auditor: false, + ldap_dn: '', + last_login: '2019-11-05T18:12:57.367622Z', + external_account: null, + auth: [], + }, + { + id: 9, + type: 'user', + url: '/api/v2/users/9/', + related: { + teams: '/api/v2/users/9/teams/', + organizations: '/api/v2/users/9/organizations/', + admin_of_organizations: '/api/v2/users/9/admin_of_organizations/', + projects: '/api/v2/users/9/projects/', + credentials: '/api/v2/users/9/credentials/', + roles: '/api/v2/users/9/roles/', + activity_stream: '/api/v2/users/9/activity_stream/', + access_list: '/api/v2/users/9/access_list/', + tokens: '/api/v2/users/9/tokens/', + authorized_tokens: '/api/v2/users/9/authorized_tokens/', + personal_tokens: '/api/v2/users/9/personal_tokens/', + }, + summary_fields: { + user_capabilities: { + edit: true, + delete: false, + }, + }, + created: '2019-11-04T18:52:13.565525Z', + username: 'systemauditor', + first_name: 'System', + last_name: 'Auditor', + email: 'systemauditor@ansible.com', + is_superuser: false, + is_system_auditor: true, + ldap_dn: '', + last_login: null, + external_account: null, + auth: [], + }, +]; + +beforeAll(() => { + UsersAPI.read.mockResolvedValue({ + data: { + count: mockUsers.length, + results: mockUsers, + }, + }); +}); + +afterEach(() => { + jest.clearAllMocks(); + wrapper.unmount(); +}); + +describe('UsersList with full permissions', () => { + beforeAll(() => { + UsersAPI.readOptions.mockResolvedValue({ + data: { + actions: { + GET: {}, + POST: {}, + }, + }, + }); + }); + + beforeEach(() => { + wrapper = mountWithContexts(); + }); + + test('initially renders successfully', () => { + mountWithContexts( + + ); + }); + + test('Users are retrieved from the api and the components finishes loading', async () => { + await waitForElement( + wrapper, + 'UsersList', + el => el.state('hasContentLoading') === true + ); + expect(loadUsers).toHaveBeenCalled(); + await waitForElement( + wrapper, + 'UsersList', + el => el.state('hasContentLoading') === false + ); + }); + + test('Selects one team when row is checked', async () => { + await waitForElement( + wrapper, + 'UsersList', + el => el.state('hasContentLoading') === false + ); + expect( + wrapper + .find('input[type="checkbox"]') + .findWhere(n => n.prop('checked') === true).length + ).toBe(0); + wrapper + .find('UserListItem') + .at(0) + .find('DataListCheck') + .props() + .onChange(true); + wrapper.update(); + expect( + wrapper + .find('input[type="checkbox"]') + .findWhere(n => n.prop('checked') === true).length + ).toBe(1); + }); + + test('Select all checkbox selects and unselects all rows', async () => { + await waitForElement( + wrapper, + 'UsersList', + el => el.state('hasContentLoading') === false + ); + expect( + wrapper + .find('input[type="checkbox"]') + .findWhere(n => n.prop('checked') === true).length + ).toBe(0); + wrapper + .find('Checkbox#select-all') + .props() + .onChange(true); + wrapper.update(); + expect( + wrapper + .find('input[type="checkbox"]') + .findWhere(n => n.prop('checked') === true).length + ).toBe(3); + wrapper + .find('Checkbox#select-all') + .props() + .onChange(false); + wrapper.update(); + expect( + wrapper + .find('input[type="checkbox"]') + .findWhere(n => n.prop('checked') === true).length + ).toBe(0); + }); + + test('delete button is disabled if user does not have delete capabilities on a selected user', async () => { + wrapper.find('UsersList').setState({ + users: mockUsers, + itemCount: 2, + isInitialized: true, + selected: mockUsers.slice(0, 1), + }); + await waitForElement( + wrapper, + 'ToolbarDeleteButton * button', + el => el.getDOMNode().disabled === false + ); + wrapper.find('UsersList').setState({ + selected: mockUsers, + }); + await waitForElement( + wrapper, + 'ToolbarDeleteButton * button', + el => el.getDOMNode().disabled === true + ); + }); + + test('api is called to delete users for each selected user.', () => { + UsersAPI.destroy = jest.fn(); + wrapper.find('UsersList').setState({ + users: mockUsers, + itemCount: 2, + isInitialized: true, + isModalOpen: true, + selected: mockUsers, + }); + wrapper.find('ToolbarDeleteButton').prop('onDelete')(); + expect(UsersAPI.destroy).toHaveBeenCalledTimes(2); + }); + + test('error is shown when user not successfully deleted from api', async () => { + UsersAPI.destroy.mockRejectedValue( + new Error({ + response: { + config: { + method: 'delete', + url: '/api/v2/users/1', + }, + data: 'An error occurred', + }, + }) + ); + wrapper.find('UsersList').setState({ + users: mockUsers, + itemCount: 1, + isInitialized: true, + isModalOpen: true, + selected: mockUsers.slice(0, 1), + }); + wrapper.find('ToolbarDeleteButton').prop('onDelete')(); + await waitForElement( + wrapper, + 'Modal', + el => el.props().isOpen === true && el.props().title === 'Error!' + ); + }); + + test('Add button shown for users with ability to POST', async () => { + await waitForElement( + wrapper, + 'UsersList', + el => el.state('hasContentLoading') === true + ); + await waitForElement( + wrapper, + 'UsersList', + el => el.state('hasContentLoading') === false + ); + expect(wrapper.find('ToolbarAddButton').length).toBe(1); + }); +}); + +describe('UsersList without full permissions', () => { + test('Add button hidden for users without ability to POST', async () => { + UsersAPI.readOptions.mockResolvedValue({ + data: { + actions: { + GET: {}, + }, + }, + }); + + wrapper = mountWithContexts(); + await waitForElement( + wrapper, + 'UsersList', + el => el.state('hasContentLoading') === true + ); + await waitForElement( + wrapper, + 'UsersList', + el => el.state('hasContentLoading') === false + ); + expect(wrapper.find('ToolbarAddButton').length).toBe(0); + }); +}); diff --git a/awx/ui_next/src/screens/User/UserList/UserListItem.jsx b/awx/ui_next/src/screens/User/UserList/UserListItem.jsx new file mode 100644 index 0000000000..c1d05223d5 --- /dev/null +++ b/awx/ui_next/src/screens/User/UserList/UserListItem.jsx @@ -0,0 +1,85 @@ +import React, { Fragment } from 'react'; +import { string, bool, func } from 'prop-types'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { + DataListItem, + DataListItemRow, + DataListItemCells, + Tooltip, +} from '@patternfly/react-core'; +import { Link } from 'react-router-dom'; +import { PencilAltIcon } from '@patternfly/react-icons'; + +import ActionButtonCell from '@components/ActionButtonCell'; +import DataListCell from '@components/DataListCell'; +import DataListCheck from '@components/DataListCheck'; +import ListActionButton from '@components/ListActionButton'; +import VerticalSeparator from '@components/VerticalSeparator'; +import { User } from '@types'; + +class UserListItem extends React.Component { + static propTypes = { + user: User.isRequired, + detailUrl: string.isRequired, + isSelected: bool.isRequired, + onSelect: func.isRequired, + }; + + render() { + const { user, isSelected, onSelect, detailUrl, i18n } = this.props; + const labelId = `check-action-${user.id}`; + return ( + + + + + + + {user.username} + +
, + + {user.first_name && ( + + {i18n._(t`First Name`)} + {user.first_name} + + )} + , + + {user.last_name && ( + + {i18n._(t`Last Name`)} + {user.last_name} + + )} + , + + {user.summary_fields.user_capabilities.edit && ( + + + + + + )} + , + ]} + /> + + + ); + } +} +export default withI18n()(UserListItem); diff --git a/awx/ui_next/src/screens/User/UserList/UserListItem.test.jsx b/awx/ui_next/src/screens/User/UserList/UserListItem.test.jsx new file mode 100644 index 0000000000..b3cda6b8a1 --- /dev/null +++ b/awx/ui_next/src/screens/User/UserList/UserListItem.test.jsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { I18nProvider } from '@lingui/react'; + +import { mountWithContexts } from '@testUtils/enzymeHelpers'; + +import mockDetails from '../data.user.json'; +import UserListItem from './UserListItem'; + +let wrapper; + +afterEach(() => { + wrapper.unmount(); +}); + +describe('UserListItem with full permissions', () => { + beforeEach(() => { + wrapper = mountWithContexts( + + + {}} + /> + + + ); + }); + test('initially renders succesfully', () => { + expect(wrapper.length).toBe(1); + }); + test('edit button shown to users with edit capabilities', () => { + expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy(); + }); +}); + +describe('UserListItem without full permissions', () => { + test('edit button hidden from users without edit capabilities', () => { + wrapper = mountWithContexts( + + + {}} + /> + + + ); + expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy(); + }); +}); diff --git a/awx/ui_next/src/screens/User/UserList/index.js b/awx/ui_next/src/screens/User/UserList/index.js new file mode 100644 index 0000000000..737ead84c1 --- /dev/null +++ b/awx/ui_next/src/screens/User/UserList/index.js @@ -0,0 +1,2 @@ +export { default as UserList } from './UserList'; +export { default as UserListItem } from './UserListItem'; diff --git a/awx/ui_next/src/screens/User/UserOrganizations/UserOrganizations.jsx b/awx/ui_next/src/screens/User/UserOrganizations/UserOrganizations.jsx new file mode 100644 index 0000000000..231208429d --- /dev/null +++ b/awx/ui_next/src/screens/User/UserOrganizations/UserOrganizations.jsx @@ -0,0 +1,10 @@ +import React, { Component } from 'react'; +import { CardBody } from '@patternfly/react-core'; + +class UserAdd extends Component { + render() { + return Coming soon :); + } +} + +export default UserAdd; diff --git a/awx/ui_next/src/screens/User/UserOrganizations/index.js b/awx/ui_next/src/screens/User/UserOrganizations/index.js new file mode 100644 index 0000000000..7fa3e0744b --- /dev/null +++ b/awx/ui_next/src/screens/User/UserOrganizations/index.js @@ -0,0 +1 @@ +export { default } from './UserOrganizations'; diff --git a/awx/ui_next/src/screens/User/UserTeams/UserTeams.jsx b/awx/ui_next/src/screens/User/UserTeams/UserTeams.jsx new file mode 100644 index 0000000000..231208429d --- /dev/null +++ b/awx/ui_next/src/screens/User/UserTeams/UserTeams.jsx @@ -0,0 +1,10 @@ +import React, { Component } from 'react'; +import { CardBody } from '@patternfly/react-core'; + +class UserAdd extends Component { + render() { + return Coming soon :); + } +} + +export default UserAdd; diff --git a/awx/ui_next/src/screens/User/UserTeams/index.js b/awx/ui_next/src/screens/User/UserTeams/index.js new file mode 100644 index 0000000000..4a3f035b44 --- /dev/null +++ b/awx/ui_next/src/screens/User/UserTeams/index.js @@ -0,0 +1 @@ +export { default } from './UserTeams'; diff --git a/awx/ui_next/src/screens/User/UserTokens/UserTokens.jsx b/awx/ui_next/src/screens/User/UserTokens/UserTokens.jsx new file mode 100644 index 0000000000..231208429d --- /dev/null +++ b/awx/ui_next/src/screens/User/UserTokens/UserTokens.jsx @@ -0,0 +1,10 @@ +import React, { Component } from 'react'; +import { CardBody } from '@patternfly/react-core'; + +class UserAdd extends Component { + render() { + return Coming soon :); + } +} + +export default UserAdd; diff --git a/awx/ui_next/src/screens/User/UserTokens/index.js b/awx/ui_next/src/screens/User/UserTokens/index.js new file mode 100644 index 0000000000..8ea0743daa --- /dev/null +++ b/awx/ui_next/src/screens/User/UserTokens/index.js @@ -0,0 +1 @@ +export { default } from './UserTokens'; diff --git a/awx/ui_next/src/screens/User/Users.jsx b/awx/ui_next/src/screens/User/Users.jsx index 7c3d336d80..35bdc5e30a 100644 --- a/awx/ui_next/src/screens/User/Users.jsx +++ b/awx/ui_next/src/screens/User/Users.jsx @@ -1,26 +1,81 @@ import React, { Component, Fragment } from 'react'; +import { Route, withRouter, Switch } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { - PageSection, - PageSectionVariants, - Title, -} from '@patternfly/react-core'; + +import { Config } from '@contexts/Config'; +import Breadcrumbs from '@components/Breadcrumbs/Breadcrumbs'; + +import UsersList from './UserList/UserList'; +import UserAdd from './UserAdd/UserAdd'; +import User from './User'; class Users extends Component { - render() { + constructor(props) { + super(props); + + const { i18n } = props; + + this.state = { + breadcrumbConfig: { + '/users': i18n._(t`Users`), + '/users/add': i18n._(t`Create New User`), + }, + }; + } + + setBreadcrumbConfig = user => { const { i18n } = this.props; - const { light } = PageSectionVariants; + + if (!user) { + return; + } + + const breadcrumbConfig = { + '/users': i18n._(t`Users`), + '/users/add': i18n._(t`Create New User`), + [`/users/${user.id}`]: `${user.username}`, + [`/users/${user.id}/edit`]: i18n._(t`Edit Details`), + [`/users/${user.id}/details`]: i18n._(t`Details`), + [`/users/${user.id}/access`]: i18n._(t`Access`), + [`/users/${user.id}/teams`]: i18n._(t`Teams`), + [`/users/${user.id}/organizations`]: i18n._(t`Organizations`), + [`/users/${user.id}/tokens`]: i18n._(t`Tokens`), + }; + + this.setState({ breadcrumbConfig }); + }; + + render() { + const { match, history, location } = this.props; + const { breadcrumbConfig } = this.state; return ( - - {i18n._(t`Users`)} - - + + + } /> + ( + + {({ me }) => ( + + )} + + )} + /> + } /> + ); } } -export default withI18n()(Users); +export { Users as _Users }; +export default withI18n()(withRouter(Users)); diff --git a/awx/ui_next/src/screens/User/Users.test.jsx b/awx/ui_next/src/screens/User/Users.test.jsx index 736a78ee72..a6368f51cd 100644 --- a/awx/ui_next/src/screens/User/Users.test.jsx +++ b/awx/ui_next/src/screens/User/Users.test.jsx @@ -1,29 +1,33 @@ import React from 'react'; +import { createMemoryHistory } from 'history'; import { mountWithContexts } from '@testUtils/enzymeHelpers'; import Users from './Users'; describe('', () => { - let pageWrapper; - let pageSections; - let title; - - beforeEach(() => { - pageWrapper = mountWithContexts(); - pageSections = pageWrapper.find('PageSection'); - title = pageWrapper.find('Title'); + test('initially renders succesfully', () => { + mountWithContexts(); }); - afterEach(() => { - pageWrapper.unmount(); - }); + test('should display a breadcrumb heading', () => { + const history = createMemoryHistory({ + initialEntries: ['/users'], + }); + const match = { path: '/users', url: '/users', isExact: true }; - test('initially renders without crashing', () => { - expect(pageWrapper.length).toBe(1); - expect(pageSections.length).toBe(2); - expect(title.length).toBe(1); - expect(title.props().size).toBe('2xl'); - expect(pageSections.first().props().variant).toBe('light'); + const wrapper = mountWithContexts(, { + context: { + router: { + history, + route: { + location: history.location, + match, + }, + }, + }, + }); + expect(wrapper.find('BreadcrumbHeading').length).toBe(1); + wrapper.unmount(); }); }); diff --git a/awx/ui_next/src/screens/User/data.user.json b/awx/ui_next/src/screens/User/data.user.json new file mode 100644 index 0000000000..3ab136a9b6 --- /dev/null +++ b/awx/ui_next/src/screens/User/data.user.json @@ -0,0 +1,35 @@ +{ + "id": 1, + "type": "user", + "url": "/api/v2/users/1/", + "related": { + "teams": "/api/v2/users/1/teams/", + "organizations": "/api/v2/users/1/organizations/", + "admin_of_organizations": "/api/v2/users/1/admin_of_organizations/", + "projects": "/api/v2/users/1/projects/", + "credentials": "/api/v2/users/1/credentials/", + "roles": "/api/v2/users/1/roles/", + "activity_stream": "/api/v2/users/1/activity_stream/", + "access_list": "/api/v2/users/1/access_list/", + "tokens": "/api/v2/users/1/tokens/", + "authorized_tokens": "/api/v2/users/1/authorized_tokens/", + "personal_tokens": "/api/v2/users/1/personal_tokens/" + }, + "summary_fields": { + "user_capabilities": { + "edit": true, + "delete": false + } + }, + "created": "2019-10-28T15:01:07.218634Z", + "username": "admin", + "first_name": "Admin", + "last_name": "User", + "email": "admin@ansible.com", + "is_superuser": true, + "is_system_auditor": false, + "ldap_dn": "", + "last_login": "2019-11-04T23:12:36.777783Z", + "external_account": null, + "auth": [] +} \ No newline at end of file diff --git a/awx/ui_next/src/screens/User/shared/UserForm.jsx b/awx/ui_next/src/screens/User/shared/UserForm.jsx new file mode 100644 index 0000000000..c4fa6131f9 --- /dev/null +++ b/awx/ui_next/src/screens/User/shared/UserForm.jsx @@ -0,0 +1,205 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Formik, Field } from 'formik'; +import { Form, FormGroup } from '@patternfly/react-core'; +import AnsibleSelect from '@components/AnsibleSelect'; +import FormActionGroup from '@components/FormActionGroup/FormActionGroup'; +import FormField, { PasswordField } from '@components/FormField'; +import FormRow from '@components/FormRow'; +import OrganizationLookup from '@components/Lookup/OrganizationLookup'; +import { required, requiredEmail } from '@util/validators'; + +function UserForm(props) { + const { user, handleCancel, handleSubmit, i18n } = props; + const [organization, setOrganization] = useState(null); + + const userTypeOptions = [ + { + value: 'normal', + key: 'normal', + label: i18n._(t`Normal User`), + isDisabled: false, + }, + { + value: 'auditor', + key: 'auditor', + label: i18n._(t`System Auditor`), + isDisabled: false, + }, + { + value: 'administrator', + key: 'administrator', + label: i18n._(t`System Administrator`), + isDisabled: false, + }, + ]; + + const handleValidateAndSubmit = (values, { setErrors }) => { + if (values.password !== values.confirm_password) { + setErrors({ + confirm_password: i18n._( + t`This value does not match the password you entered previously. Please confirm that password.` + ), + }); + } else { + values.is_superuser = values.user_type === 'administrator'; + values.is_system_auditor = values.user_type === 'auditor'; + if (!values.password || values.password === '') { + delete values.password; + } + delete values.confirm_password; + handleSubmit(values); + } + }; + + let userType; + if (user.is_superuser) { + userType = 'administrator'; + } else if (user.is_system_auditor) { + userType = 'auditor'; + } else { + userType = 'normal'; + } + + return ( + ( +
+ + + + undefined + } + isRequired={!user.id} + /> + undefined + } + isRequired={!user.id} + /> + + + + + {!user.id && ( + ( + form.setFieldTouched('organization')} + onChange={value => { + form.setFieldValue('organization', value.id); + setOrganization(value); + }} + value={organization} + required + /> + )} + /> + )} + { + const isValid = + !form.touched.user_type || !form.errors.user_type; + return ( + + + + ); + }} + /> + + + + )} + /> + ); +} + +UserForm.propTypes = { + handleCancel: PropTypes.func.isRequired, + handleSubmit: PropTypes.func.isRequired, + user: PropTypes.shape({}), +}; + +UserForm.defaultProps = { + user: {}, +}; + +export default withI18n()(UserForm); diff --git a/awx/ui_next/src/screens/User/shared/UserForm.test.jsx b/awx/ui_next/src/screens/User/shared/UserForm.test.jsx new file mode 100644 index 0000000000..4bea4ef649 --- /dev/null +++ b/awx/ui_next/src/screens/User/shared/UserForm.test.jsx @@ -0,0 +1,157 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; +import { sleep } from '@testUtils/testUtils'; +import UserForm from './UserForm'; +import { UsersAPI } from '@api'; +import mockData from '../data.user.json'; + +jest.mock('@api'); + +describe('', () => { + let wrapper; + + const userOptionsResolve = { + data: { + actions: { + GET: {}, + POST: {}, + }, + }, + }; + + beforeEach(async () => { + await UsersAPI.readOptions.mockImplementation(() => userOptionsResolve); + }); + + afterEach(() => { + wrapper.unmount(); + jest.clearAllMocks(); + }); + + test('initially renders successfully', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + + expect(wrapper.find('UserForm').length).toBe(1); + }); + + test('add form displays all form fields', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + expect(wrapper.find('FormGroup[label="Username"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Email"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="First Name"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Last Name"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Password"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Confirm Password"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Organization"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="User Type"]').length).toBe(1); + }); + + test('edit form hides org field', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + expect(wrapper.find('FormGroup[label="Organization"]').length).toBe(0); + }); + + test('inputs should update form value on change', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + const form = wrapper.find('Formik'); + act(() => { + wrapper.find('OrganizationLookup').invoke('onBlur')(); + wrapper.find('OrganizationLookup').invoke('onChange')({ + id: 1, + name: 'organization', + }); + }); + expect(form.state('values').organization).toEqual(1); + }); + + test('password fields are required on add', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + + const passwordFields = wrapper.find('PasswordField'); + + expect(passwordFields.length).toBe(2); + expect(passwordFields.at(0).prop('isRequired')).toBe(true); + expect(passwordFields.at(1).prop('isRequired')).toBe(true); + }); + + test('password fields are not required on edit', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + + const passwordFields = wrapper.find('PasswordField'); + + expect(passwordFields.length).toBe(2); + expect(passwordFields.at(0).prop('isRequired')).toBe(false); + expect(passwordFields.at(1).prop('isRequired')).toBe(false); + }); + + test('should call handleSubmit when Submit button is clicked', async () => { + const handleSubmit = jest.fn(); + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + expect(handleSubmit).not.toHaveBeenCalled(); + wrapper.find('button[aria-label="Save"]').simulate('click'); + await sleep(1); + expect(handleSubmit).toBeCalled(); + }); + + test('should call handleCancel when Cancel button is clicked', async () => { + const handleCancel = jest.fn(); + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + expect(handleCancel).not.toHaveBeenCalled(); + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); + expect(handleCancel).toBeCalled(); + }); +}); diff --git a/awx/ui_next/src/screens/User/shared/index.js b/awx/ui_next/src/screens/User/shared/index.js new file mode 100644 index 0000000000..ee4362b5c2 --- /dev/null +++ b/awx/ui_next/src/screens/User/shared/index.js @@ -0,0 +1,2 @@ +/* eslint-disable-next-line import/prefer-default-export */ +export { default as UserForm } from './UserForm'; diff --git a/awx/ui_next/src/types.js b/awx/ui_next/src/types.js index 6675854c55..a519b7ab08 100644 --- a/awx/ui_next/src/types.js +++ b/awx/ui_next/src/types.js @@ -210,3 +210,22 @@ export const Team = shape({ name: string.isRequired, organization: number, }); + +export const User = shape({ + id: number.isRequired, + type: oneOf(['user']), + url: string, + related: shape(), + summary_fields: shape({ + user_capabilities: objectOf(bool), + }), + created: string, + username: string, + first_name: string, + last_name: string, + email: string.isRequired, + is_superuser: bool, + is_system_auditor: bool, + ldap_dn: string, + last_login: string, +}); diff --git a/awx/ui_next/src/util/validators.jsx b/awx/ui_next/src/util/validators.jsx index 8367f3e248..52edfd3f40 100644 --- a/awx/ui_next/src/util/validators.jsx +++ b/awx/ui_next/src/util/validators.jsx @@ -28,3 +28,15 @@ export function minMaxValue(min, max, i18n) { return undefined; }; } + +export function requiredEmail(i18n) { + return value => { + if (!value) { + return i18n._(t`This field must not be blank`); + } + if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i.test(value)) { + return i18n._(t`Invalid email address`); + } + return undefined; + }; +}