Merge pull request #5255 from mabashian/ui-next-users-list

Adds Users list, forms and details

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot] 2019-11-11 20:04:05 +00:00 committed by GitHub
commit 5d27c28b47
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 2062 additions and 35 deletions

View File

@ -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 (
<Field
name={name}
validate={validate}
render={({ field, form }) => {
const isValid =
form && (!form.touched[field.name] || !form.errors[field.name]);
return (
<FormGroup
fieldId={id}
helperTextInvalid={form.errors[field.name]}
isRequired={isRequired}
isValid={isValid}
label={label}
>
<InputGroup>
<Tooltip
content={
inputType === 'password' ? i18n._(t`Show`) : i18n._(t`Hide`)
}
>
<Button
variant={ButtonVariant.control}
aria-label={i18n._(t`Toggle Password`)}
onClick={handlePasswordToggle}
>
{inputType === 'password' && <EyeSlashIcon />}
{inputType === 'text' && <EyeIcon />}
</Button>
</Tooltip>
<TextInput
id={id}
isRequired={isRequired}
isValid={isValid}
type={inputType}
{...field}
onChange={(value, event) => {
field.onChange(event);
}}
/>
</InputGroup>
</FormGroup>
);
}}
/>
);
}
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);

View File

@ -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(
<Formik
initialValues={{
password: '',
}}
render={() => (
<PasswordField id="test-password" name="password" label="Password" />
)}
/>
);
expect(wrapper).toHaveLength(1);
});
test('properly responds to show/hide toggles', async () => {
const wrapper = mountWithContexts(
<Formik
initialValues={{
password: '',
}}
render={() => (
<PasswordField id="test-password" name="password" label="Password" />
)}
/>
);
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);
});
});

View File

@ -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';

View File

@ -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 {
<br />
{itemsToDelete.map(item => (
<span key={item.id}>
<strong>{item.name}</strong>
<strong>{item.name || item.username}</strong>
<br />
</span>
))}

View File

@ -127,7 +127,7 @@ class Sort extends React.Component {
return (
<React.Fragment>
{sortDropdownItems.length > 1 && (
{sortDropdownItems.length > 0 && (
<React.Fragment>
<SortBy>{i18n._(t`Sort By`)}</SortBy>
<Dropdown

View File

@ -7,7 +7,7 @@ import { CredentialTypesAPI, ProjectsAPI } from '@api';
jest.mock('@api');
describe('<ProjectAdd />', () => {
describe('<ProjectForm />', () => {
let wrapper;
const mockData = {
name: 'foo',

View File

@ -49,7 +49,7 @@ class TeamListItem extends React.Component {
<DataListCell key="organization">
{team.summary_fields.organization && (
<Fragment>
<b style={{ marginRight: '20px' }}>
<b css={{ marginRight: '20px' }}>
{i18n._(t`Organization`)}
</b>
<Link

View File

@ -0,0 +1,198 @@
import React, { Component } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Switch, Route, withRouter, Redirect, Link } from 'react-router-dom';
import {
Card,
CardHeader as PFCardHeader,
PageSection,
} from '@patternfly/react-core';
import styled from 'styled-components';
import CardCloseButton from '@components/CardCloseButton';
import RoutedTabs from '@components/RoutedTabs';
import ContentError from '@components/ContentError';
import UserDetail from './UserDetail';
import UserEdit from './UserEdit';
import UserOrganizations from './UserOrganizations';
import UserTeams from './UserTeams';
import UserTokens from './UserTokens';
import { UsersAPI } from '@api';
const CardHeader = styled(PFCardHeader)`
--pf-c-card--first-child--PaddingTop: 0;
--pf-c-card--child--PaddingLeft: 0;
--pf-c-card--child--PaddingRight: 0;
position: relative;
`;
class User extends Component {
constructor(props) {
super(props);
this.state = {
user: null,
hasContentLoading: true,
contentError: null,
isInitialized: false,
};
this.loadUser = this.loadUser.bind(this);
}
async componentDidMount() {
await this.loadUser();
this.setState({ isInitialized: true });
}
async componentDidUpdate(prevProps) {
const { location, match } = this.props;
const url = `/users/${match.params.id}/`;
if (
prevProps.location.pathname.startsWith(url) &&
prevProps.location !== location &&
location.pathname === `${url}details`
) {
await this.loadUser();
}
}
async loadUser() {
const { match, setBreadcrumb } = this.props;
const id = parseInt(match.params.id, 10);
this.setState({ contentError: null, hasContentLoading: true });
try {
const { data } = await UsersAPI.readDetail(id);
setBreadcrumb(data);
this.setState({ user: data });
} catch (err) {
this.setState({ contentError: err });
} finally {
this.setState({ hasContentLoading: false });
}
}
render() {
const { location, match, history, i18n } = this.props;
const { user, contentError, hasContentLoading, isInitialized } = this.state;
const tabsArray = [
{ name: i18n._(t`Details`), link: `${match.url}/details`, id: 0 },
{
name: i18n._(t`Organizations`),
link: `${match.url}/organizations`,
id: 1,
},
{ name: i18n._(t`Teams`), link: `${match.url}/teams`, id: 2 },
{ name: i18n._(t`Access`), link: `${match.url}/access`, id: 3 },
{ name: i18n._(t`Tokens`), link: `${match.url}/tokens`, id: 4 },
];
let cardHeader = (
<CardHeader style={{ padding: 0 }}>
<RoutedTabs
match={match}
history={history}
labeltext={i18n._(t`User detail tabs`)}
tabsArray={tabsArray}
/>
<CardCloseButton linkTo="/users" />
</CardHeader>
);
if (!isInitialized) {
cardHeader = null;
}
if (!match) {
cardHeader = null;
}
if (location.pathname.endsWith('edit')) {
cardHeader = null;
}
if (!hasContentLoading && contentError) {
return (
<PageSection>
<Card className="awx-c-card">
<ContentError error={contentError}>
{contentError.response.status === 404 && (
<span>
{i18n._(`User not found.`)}{' '}
<Link to="/users">{i18n._(`View all Users.`)}</Link>
</span>
)}
</ContentError>
</Card>
</PageSection>
);
}
return (
<PageSection>
<Card className="awx-c-card">
{cardHeader}
<Switch>
<Redirect from="/users/:id" to="/users/:id/details" exact />
{user && (
<Route
path="/users/:id/edit"
render={() => <UserEdit match={match} user={user} />}
/>
)}
{user && (
<Route
path="/users/:id/details"
render={() => <UserDetail match={match} user={user} />}
/>
)}
<Route
path="/users/:id/organizations"
render={() => <UserOrganizations id={Number(match.params.id)} />}
/>
<Route
path="/users/:id/teams"
render={() => <UserTeams id={Number(match.params.id)} />}
/>
{user && (
<Route
path="/users/:id/access"
render={() => (
<span>
this needs a different access list from regular resources
like proj, inv, jt
</span>
)}
/>
)}
<Route
path="/users/:id/tokens"
render={() => <UserTokens id={Number(match.params.id)} />}
/>
<Route
key="not-found"
path="*"
render={() =>
!hasContentLoading && (
<ContentError isNotFound>
{match.params.id && (
<Link to={`/users/${match.params.id}/details`}>
{i18n._(`View User Details`)}
</Link>
)}
</ContentError>
)
}
/>
,
</Switch>
</Card>
</PageSection>
);
}
}
export default withI18n()(withRouter(User));
export { User as _User };

View File

@ -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('<User />', () => {
test('initially renders succesfully', () => {
UsersAPI.readDetail.mockResolvedValue({ data: mockDetails });
UsersAPI.read.mockImplementation(getUsers);
mountWithContexts(<User setBreadcrumb={() => {}} me={mockMe} />);
});
test('notifications tab shown for admins', async () => {
UsersAPI.readDetail.mockResolvedValue({ data: mockDetails });
UsersAPI.read.mockImplementation(getUsers);
const wrapper = mountWithContexts(
<User setBreadcrumb={() => {}} 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(
<User setBreadcrumb={() => {}} 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);
});
});

View File

@ -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 (
<PageSection>
<Card>
<CardHeader css="text-align: right">
<Tooltip content={i18n._(t`Close`)} position="top">
<CardCloseButton onClick={handleCancel} />
</Tooltip>
</CardHeader>
<CardBody>
<UserForm handleCancel={handleCancel} handleSubmit={handleSubmit} />
</CardBody>
{formSubmitError ? (
<div className="formSubmitError">formSubmitError</div>
) : (
''
)}
</Card>
</PageSection>
);
}
export default withI18n()(withRouter(UserAdd));

View File

@ -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('<UserAdd />', () => {
test('handleSubmit should post to api', async () => {
await act(async () => {
wrapper = mountWithContexts(<UserAdd />);
});
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(<UserAdd />, {
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(<UserAdd />, {
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(<UserAdd />, {
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');
});
});

View File

@ -0,0 +1 @@
export { default } from './UserAdd';

View File

@ -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 (
<CardBody>
<DetailList>
<Detail label={i18n._(t`Username`)} value={username} />
<Detail label={i18n._(t`Email`)} value={email} />
<Detail label={i18n._(t`First Name`)} value={`${first_name}`} />
<Detail label={i18n._(t`Last Name`)} value={`${last_name}`} />
<Detail label={i18n._(t`User Type`)} value={`${user_type}`} />
{last_login && (
<Detail
label={i18n._(t`Last Login`)}
value={formatDateString(last_login)}
/>
)}
<Detail
label={i18n._(t`Created`)}
value={formatDateString(created)}
/>
</DetailList>
{summary_fields.user_capabilities.edit && (
<div css="margin-top: 10px; text-align: right;">
<Button
aria-label={i18n._(t`edit`)}
component={Link}
to={`/users/${id}/edit`}
>
{i18n._(t`Edit`)}
</Button>
</div>
)}
</CardBody>
);
}
}
export default withI18n()(withRouter(UserDetail));

View File

@ -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('<UserDetail />', () => {
test('initially renders succesfully', () => {
mountWithContexts(<UserDetail user={mockDetails} />);
});
test('should render Details', () => {
const wrapper = mountWithContexts(<UserDetail user={mockDetails} />, {
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(<UserDetail user={mockDetails} />);
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(
<UserDetail
user={{
...mockDetails,
summary_fields: {
user_capabilities: {
edit: false,
},
},
}}
/>
);
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(<UserDetail user={mockDetails} />, {
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');
});
});

View File

@ -0,0 +1 @@
export { default } from './UserDetail';

View File

@ -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 (
<CardBody>
<UserForm
user={user}
handleCancel={handleCancel}
handleSubmit={handleSubmit}
/>
{formSubmitError ? <div> error </div> : null}
</CardBody>
);
}
export default withI18n()(withRouter(UserEdit));

View File

@ -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('<UserEdit />', () => {
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(<UserEdit user={mockData} />);
});
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(<UserEdit user={mockData} />, {
context: { router: { history } },
});
});
await act(async () => {
wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
});
expect(history.location.pathname).toEqual('/users/1/details');
});
});

View File

@ -0,0 +1 @@
export { default } from './UserEdit';

View File

@ -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 (
<Fragment>
<PageSection>
<Card>
<PaginatedDataList
contentError={contentError}
hasContentLoading={hasContentLoading}
items={users}
itemCount={itemCount}
pluralizedItemName="Users"
qsConfig={QS_CONFIG}
toolbarColumns={[
{
name: i18n._(t`Username`),
key: 'username',
isSortable: true,
isSearchable: true,
},
{
name: i18n._(t`First Name`),
key: 'first_name',
isSortable: true,
isSearchable: true,
},
{
name: i18n._(t`Last Name`),
key: 'last_name',
isSortable: true,
isSearchable: true,
},
]}
renderToolbar={props => (
<DataListToolbar
{...props}
showSelectAll
isAllSelected={isAllSelected}
onSelectAll={this.handleSelectAll}
qsConfig={QS_CONFIG}
additionalControls={[
<ToolbarDeleteButton
key="delete"
onDelete={this.handleUserDelete}
itemsToDelete={selected}
pluralizedItemName="Users"
/>,
canAdd ? (
<ToolbarAddButton key="add" linkTo={`${match.url}/add`} />
) : null,
]}
/>
)}
renderItem={o => (
<UserListItem
key={o.id}
user={o}
detailUrl={`${match.url}/${o.id}`}
isSelected={selected.some(row => row.id === o.id)}
onSelect={() => this.handleSelect(o)}
/>
)}
emptyStateControls={
canAdd ? (
<ToolbarAddButton key="add" linkTo={`${match.url}/add`} />
) : null
}
/>
</Card>
</PageSection>
<AlertModal
isOpen={deletionError}
variant="danger"
title={i18n._(t`Error!`)}
onClose={this.handleDeleteErrorClose}
>
{i18n._(t`Failed to delete one or more users.`)}
<ErrorDetail error={deletionError} />
</AlertModal>
</Fragment>
);
}
}
export { UsersList as _UsersList };
export default withI18n()(withRouter(UsersList));

View File

@ -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(<UsersList />);
});
test('initially renders successfully', () => {
mountWithContexts(
<UsersList
match={{ path: '/users', url: '/users' }}
location={{ search: '', pathname: '/users' }}
/>
);
});
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(<UsersList />);
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);
});
});

View File

@ -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 (
<DataListItem key={user.id} aria-labelledby={labelId}>
<DataListItemRow>
<DataListCheck
id={`select-user-${user.id}`}
checked={isSelected}
onChange={onSelect}
aria-labelledby={labelId}
/>
<DataListItemCells
dataListCells={[
<DataListCell key="divider">
<VerticalSeparator />
<Link to={`${detailUrl}`} id={labelId}>
<b>{user.username}</b>
</Link>
</DataListCell>,
<DataListCell key="first-name">
{user.first_name && (
<Fragment>
<b css={{ marginRight: '20px' }}>{i18n._(t`First Name`)}</b>
{user.first_name}
</Fragment>
)}
</DataListCell>,
<DataListCell key="last-name">
{user.last_name && (
<Fragment>
<b css={{ marginRight: '20px' }}>{i18n._(t`Last Name`)}</b>
{user.last_name}
</Fragment>
)}
</DataListCell>,
<ActionButtonCell lastcolumn="true" key="action">
{user.summary_fields.user_capabilities.edit && (
<Tooltip content={i18n._(t`Edit User`)} position="top">
<ListActionButton
variant="plain"
component={Link}
to={`/users/${user.id}/edit`}
>
<PencilAltIcon />
</ListActionButton>
</Tooltip>
)}
</ActionButtonCell>,
]}
/>
</DataListItemRow>
</DataListItem>
);
}
}
export default withI18n()(UserListItem);

View File

@ -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(
<I18nProvider>
<MemoryRouter initialEntries={['/users']} initialIndex={0}>
<UserListItem
user={mockDetails}
detailUrl="/user/1"
isSelected
onSelect={() => {}}
/>
</MemoryRouter>
</I18nProvider>
);
});
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(
<I18nProvider>
<MemoryRouter initialEntries={['/users']} initialIndex={0}>
<UserListItem
user={{
...mockDetails,
summary_fields: {
user_capabilities: {
edit: false,
},
},
}}
detailUrl="/user/1"
isSelected
onSelect={() => {}}
/>
</MemoryRouter>
</I18nProvider>
);
expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy();
});
});

View File

@ -0,0 +1,2 @@
export { default as UserList } from './UserList';
export { default as UserListItem } from './UserListItem';

View File

@ -0,0 +1,10 @@
import React, { Component } from 'react';
import { CardBody } from '@patternfly/react-core';
class UserAdd extends Component {
render() {
return <CardBody>Coming soon :)</CardBody>;
}
}
export default UserAdd;

View File

@ -0,0 +1 @@
export { default } from './UserOrganizations';

View File

@ -0,0 +1,10 @@
import React, { Component } from 'react';
import { CardBody } from '@patternfly/react-core';
class UserAdd extends Component {
render() {
return <CardBody>Coming soon :)</CardBody>;
}
}
export default UserAdd;

View File

@ -0,0 +1 @@
export { default } from './UserTeams';

View File

@ -0,0 +1,10 @@
import React, { Component } from 'react';
import { CardBody } from '@patternfly/react-core';
class UserAdd extends Component {
render() {
return <CardBody>Coming soon :)</CardBody>;
}
}
export default UserAdd;

View File

@ -0,0 +1 @@
export { default } from './UserTokens';

View File

@ -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 (
<Fragment>
<PageSection variant={light} className="pf-m-condensed">
<Title size="2xl">{i18n._(t`Users`)}</Title>
</PageSection>
<PageSection />
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
<Switch>
<Route path={`${match.path}/add`} render={() => <UserAdd />} />
<Route
path={`${match.path}/:id`}
render={() => (
<Config>
{({ me }) => (
<User
history={history}
location={location}
setBreadcrumb={this.setBreadcrumbConfig}
me={me || {}}
/>
)}
</Config>
)}
/>
<Route path={`${match.path}`} render={() => <UsersList />} />
</Switch>
</Fragment>
);
}
}
export default withI18n()(Users);
export { Users as _Users };
export default withI18n()(withRouter(Users));

View File

@ -1,29 +1,33 @@
import React from 'react';
import { createMemoryHistory } from 'history';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import Users from './Users';
describe('<Users />', () => {
let pageWrapper;
let pageSections;
let title;
beforeEach(() => {
pageWrapper = mountWithContexts(<Users />);
pageSections = pageWrapper.find('PageSection');
title = pageWrapper.find('Title');
test('initially renders succesfully', () => {
mountWithContexts(<Users />);
});
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(<Users />, {
context: {
router: {
history,
route: {
location: history.location,
match,
},
},
},
});
expect(wrapper.find('BreadcrumbHeading').length).toBe(1);
wrapper.unmount();
});
});

View File

@ -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": []
}

View File

@ -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 (
<Formik
initialValues={{
first_name: user.first_name || '',
last_name: user.last_name || '',
organization: user.organization || '',
email: user.email || '',
username: user.username || '',
password: '',
confirm_password: '',
user_type: userType,
}}
onSubmit={handleValidateAndSubmit}
render={formik => (
<Form
autoComplete="off"
onSubmit={formik.handleSubmit}
css="padding: 0 24px"
>
<FormRow>
<FormField
id="user-username"
label={i18n._(t`Username`)}
name="username"
type="text"
validate={required(null, i18n)}
isRequired
/>
<FormField
id="user-email"
label={i18n._(t`Email`)}
name="email"
validate={requiredEmail(i18n)}
isRequired
/>
<PasswordField
id="user-password"
label={i18n._(t`Password`)}
name="password"
validate={
!user.id
? required(i18n._(t`This field must not be blank`), i18n)
: () => undefined
}
isRequired={!user.id}
/>
<PasswordField
id="user-confirm-password"
label={i18n._(t`Confirm Password`)}
name="confirm_password"
validate={
!user.id
? required(i18n._(t`This field must not be blank`), i18n)
: () => undefined
}
isRequired={!user.id}
/>
</FormRow>
<FormRow>
<FormField
id="user-first-name"
label={i18n._(t`First Name`)}
name="first_name"
type="text"
/>
<FormField
id="user-last-name"
label={i18n._(t`Last Name`)}
name="last_name"
type="text"
/>
{!user.id && (
<Field
name="organization"
validate={required(
i18n._(t`Select a value for this field`),
i18n
)}
render={({ form }) => (
<OrganizationLookup
helperTextInvalid={form.errors.organization}
isValid={
!form.touched.organization || !form.errors.organization
}
onBlur={() => form.setFieldTouched('organization')}
onChange={value => {
form.setFieldValue('organization', value.id);
setOrganization(value);
}}
value={organization}
required
/>
)}
/>
)}
<Field
name="user_type"
render={({ form, field }) => {
const isValid =
!form.touched.user_type || !form.errors.user_type;
return (
<FormGroup
fieldId="user-type"
helperTextInvalid={form.errors.user_type}
isRequired
isValid={isValid}
label={i18n._(t`User Type`)}
>
<AnsibleSelect
isValid={isValid}
id="user-type"
data={userTypeOptions}
{...field}
/>
</FormGroup>
);
}}
/>
</FormRow>
<FormActionGroup
onCancel={handleCancel}
onSubmit={formik.handleSubmit}
/>
</Form>
)}
/>
);
}
UserForm.propTypes = {
handleCancel: PropTypes.func.isRequired,
handleSubmit: PropTypes.func.isRequired,
user: PropTypes.shape({}),
};
UserForm.defaultProps = {
user: {},
};
export default withI18n()(UserForm);

View File

@ -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('<UserForm />', () => {
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(
<UserForm handleSubmit={jest.fn()} handleCancel={jest.fn()} />
);
});
expect(wrapper.find('UserForm').length).toBe(1);
});
test('add form displays all form fields', async () => {
await act(async () => {
wrapper = mountWithContexts(
<UserForm handleSubmit={jest.fn()} handleCancel={jest.fn()} />
);
});
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(
<UserForm
handleSubmit={jest.fn()}
handleCancel={jest.fn()}
user={mockData}
/>
);
});
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(
<UserForm handleSubmit={jest.fn()} handleCancel={jest.fn()} />
);
});
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(
<UserForm handleSubmit={jest.fn()} handleCancel={jest.fn()} />
);
});
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(
<UserForm
user={mockData}
handleSubmit={jest.fn()}
handleCancel={jest.fn()}
/>
);
});
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(
<UserForm
user={mockData}
handleSubmit={handleSubmit}
handleCancel={jest.fn()}
/>
);
});
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(
<UserForm
user={mockData}
handleSubmit={jest.fn()}
handleCancel={handleCancel}
/>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(handleCancel).not.toHaveBeenCalled();
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
expect(handleCancel).toBeCalled();
});
});

View File

@ -0,0 +1,2 @@
/* eslint-disable-next-line import/prefer-default-export */
export { default as UserForm } from './UserForm';

View File

@ -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,
});

View File

@ -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;
};
}