mirror of
https://github.com/ansible/awx.git
synced 2026-01-11 18:09:57 -03:30
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:
commit
5d27c28b47
85
awx/ui_next/src/components/FormField/PasswordField.jsx
Normal file
85
awx/ui_next/src/components/FormField/PasswordField.jsx
Normal 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);
|
||||
42
awx/ui_next/src/components/FormField/PasswordField.test.jsx
Normal file
42
awx/ui_next/src/components/FormField/PasswordField.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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';
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -7,7 +7,7 @@ import { CredentialTypesAPI, ProjectsAPI } from '@api';
|
||||
|
||||
jest.mock('@api');
|
||||
|
||||
describe('<ProjectAdd />', () => {
|
||||
describe('<ProjectForm />', () => {
|
||||
let wrapper;
|
||||
const mockData = {
|
||||
name: 'foo',
|
||||
|
||||
@ -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
|
||||
|
||||
198
awx/ui_next/src/screens/User/User.jsx
Normal file
198
awx/ui_next/src/screens/User/User.jsx
Normal 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 };
|
||||
67
awx/ui_next/src/screens/User/User.test.jsx
Normal file
67
awx/ui_next/src/screens/User/User.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
62
awx/ui_next/src/screens/User/UserAdd/UserAdd.jsx
Normal file
62
awx/ui_next/src/screens/User/UserAdd/UserAdd.jsx
Normal 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));
|
||||
87
awx/ui_next/src/screens/User/UserAdd/UserAdd.test.jsx
Normal file
87
awx/ui_next/src/screens/User/UserAdd/UserAdd.test.jsx
Normal 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');
|
||||
});
|
||||
});
|
||||
1
awx/ui_next/src/screens/User/UserAdd/index.js
Normal file
1
awx/ui_next/src/screens/User/UserAdd/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './UserAdd';
|
||||
77
awx/ui_next/src/screens/User/UserDetail/UserDetail.jsx
Normal file
77
awx/ui_next/src/screens/User/UserDetail/UserDetail.jsx
Normal 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));
|
||||
82
awx/ui_next/src/screens/User/UserDetail/UserDetail.test.jsx
Normal file
82
awx/ui_next/src/screens/User/UserDetail/UserDetail.test.jsx
Normal 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');
|
||||
});
|
||||
});
|
||||
1
awx/ui_next/src/screens/User/UserDetail/index.js
Normal file
1
awx/ui_next/src/screens/User/UserDetail/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './UserDetail';
|
||||
36
awx/ui_next/src/screens/User/UserEdit/UserEdit.jsx
Normal file
36
awx/ui_next/src/screens/User/UserEdit/UserEdit.jsx
Normal 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));
|
||||
54
awx/ui_next/src/screens/User/UserEdit/UserEdit.test.jsx
Normal file
54
awx/ui_next/src/screens/User/UserEdit/UserEdit.test.jsx
Normal 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');
|
||||
});
|
||||
});
|
||||
1
awx/ui_next/src/screens/User/UserEdit/index.js
Normal file
1
awx/ui_next/src/screens/User/UserEdit/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './UserEdit';
|
||||
228
awx/ui_next/src/screens/User/UserList/UserList.jsx
Normal file
228
awx/ui_next/src/screens/User/UserList/UserList.jsx
Normal 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));
|
||||
295
awx/ui_next/src/screens/User/UserList/UserList.test.jsx
Normal file
295
awx/ui_next/src/screens/User/UserList/UserList.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
85
awx/ui_next/src/screens/User/UserList/UserListItem.jsx
Normal file
85
awx/ui_next/src/screens/User/UserList/UserListItem.jsx
Normal 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);
|
||||
62
awx/ui_next/src/screens/User/UserList/UserListItem.test.jsx
Normal file
62
awx/ui_next/src/screens/User/UserList/UserListItem.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
2
awx/ui_next/src/screens/User/UserList/index.js
Normal file
2
awx/ui_next/src/screens/User/UserList/index.js
Normal file
@ -0,0 +1,2 @@
|
||||
export { default as UserList } from './UserList';
|
||||
export { default as UserListItem } from './UserListItem';
|
||||
@ -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;
|
||||
1
awx/ui_next/src/screens/User/UserOrganizations/index.js
Normal file
1
awx/ui_next/src/screens/User/UserOrganizations/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './UserOrganizations';
|
||||
10
awx/ui_next/src/screens/User/UserTeams/UserTeams.jsx
Normal file
10
awx/ui_next/src/screens/User/UserTeams/UserTeams.jsx
Normal 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;
|
||||
1
awx/ui_next/src/screens/User/UserTeams/index.js
Normal file
1
awx/ui_next/src/screens/User/UserTeams/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './UserTeams';
|
||||
10
awx/ui_next/src/screens/User/UserTokens/UserTokens.jsx
Normal file
10
awx/ui_next/src/screens/User/UserTokens/UserTokens.jsx
Normal 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;
|
||||
1
awx/ui_next/src/screens/User/UserTokens/index.js
Normal file
1
awx/ui_next/src/screens/User/UserTokens/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './UserTokens';
|
||||
@ -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));
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
35
awx/ui_next/src/screens/User/data.user.json
Normal file
35
awx/ui_next/src/screens/User/data.user.json
Normal 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": []
|
||||
}
|
||||
205
awx/ui_next/src/screens/User/shared/UserForm.jsx
Normal file
205
awx/ui_next/src/screens/User/shared/UserForm.jsx
Normal 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);
|
||||
157
awx/ui_next/src/screens/User/shared/UserForm.test.jsx
Normal file
157
awx/ui_next/src/screens/User/shared/UserForm.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
2
awx/ui_next/src/screens/User/shared/index.js
Normal file
2
awx/ui_next/src/screens/User/shared/index.js
Normal file
@ -0,0 +1,2 @@
|
||||
/* eslint-disable-next-line import/prefer-default-export */
|
||||
export { default as UserForm } from './UserForm';
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user