mirror of
https://github.com/ansible/awx.git
synced 2026-03-03 09:48:51 -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:
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 } from './FormField';
|
||||||
export { default as CheckboxField } from './CheckboxField';
|
export { default as CheckboxField } from './CheckboxField';
|
||||||
export { default as FieldTooltip } from './FieldTooltip';
|
export { default as FieldTooltip } from './FieldTooltip';
|
||||||
|
export { default as PasswordField } from './PasswordField';
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import React, { Fragment } from 'react';
|
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 { Button, Tooltip } from '@patternfly/react-core';
|
||||||
import { TrashAltIcon } from '@patternfly/react-icons';
|
import { TrashAltIcon } from '@patternfly/react-icons';
|
||||||
import styled from 'styled-components';
|
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({
|
const ItemToDelete = shape({
|
||||||
id: number.isRequired,
|
id: number.isRequired,
|
||||||
name: string.isRequired,
|
name: requireNameOrUsername,
|
||||||
|
username: requireNameOrUsername,
|
||||||
summary_fields: shape({
|
summary_fields: shape({
|
||||||
user_capabilities: shape({
|
user_capabilities: shape({
|
||||||
delete: bool.isRequired,
|
delete: bool.isRequired,
|
||||||
@@ -148,7 +187,7 @@ class ToolbarDeleteButton extends React.Component {
|
|||||||
<br />
|
<br />
|
||||||
{itemsToDelete.map(item => (
|
{itemsToDelete.map(item => (
|
||||||
<span key={item.id}>
|
<span key={item.id}>
|
||||||
<strong>{item.name}</strong>
|
<strong>{item.name || item.username}</strong>
|
||||||
<br />
|
<br />
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ class Sort extends React.Component {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
{sortDropdownItems.length > 1 && (
|
{sortDropdownItems.length > 0 && (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<SortBy>{i18n._(t`Sort By`)}</SortBy>
|
<SortBy>{i18n._(t`Sort By`)}</SortBy>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { CredentialTypesAPI, ProjectsAPI } from '@api';
|
|||||||
|
|
||||||
jest.mock('@api');
|
jest.mock('@api');
|
||||||
|
|
||||||
describe('<ProjectAdd />', () => {
|
describe('<ProjectForm />', () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
const mockData = {
|
const mockData = {
|
||||||
name: 'foo',
|
name: 'foo',
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ class TeamListItem extends React.Component {
|
|||||||
<DataListCell key="organization">
|
<DataListCell key="organization">
|
||||||
{team.summary_fields.organization && (
|
{team.summary_fields.organization && (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<b style={{ marginRight: '20px' }}>
|
<b css={{ marginRight: '20px' }}>
|
||||||
{i18n._(t`Organization`)}
|
{i18n._(t`Organization`)}
|
||||||
</b>
|
</b>
|
||||||
<Link
|
<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 React, { Component, Fragment } from 'react';
|
||||||
|
import { Route, withRouter, Switch } from 'react-router-dom';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import {
|
|
||||||
PageSection,
|
import { Config } from '@contexts/Config';
|
||||||
PageSectionVariants,
|
import Breadcrumbs from '@components/Breadcrumbs/Breadcrumbs';
|
||||||
Title,
|
|
||||||
} from '@patternfly/react-core';
|
import UsersList from './UserList/UserList';
|
||||||
|
import UserAdd from './UserAdd/UserAdd';
|
||||||
|
import User from './User';
|
||||||
|
|
||||||
class Users extends Component {
|
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 { 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 (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<PageSection variant={light} className="pf-m-condensed">
|
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
|
||||||
<Title size="2xl">{i18n._(t`Users`)}</Title>
|
<Switch>
|
||||||
</PageSection>
|
<Route path={`${match.path}/add`} render={() => <UserAdd />} />
|
||||||
<PageSection />
|
<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>
|
</Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withI18n()(Users);
|
export { Users as _Users };
|
||||||
|
export default withI18n()(withRouter(Users));
|
||||||
|
|||||||
@@ -1,29 +1,33 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { createMemoryHistory } from 'history';
|
||||||
|
|
||||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||||
|
|
||||||
import Users from './Users';
|
import Users from './Users';
|
||||||
|
|
||||||
describe('<Users />', () => {
|
describe('<Users />', () => {
|
||||||
let pageWrapper;
|
test('initially renders succesfully', () => {
|
||||||
let pageSections;
|
mountWithContexts(<Users />);
|
||||||
let title;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
pageWrapper = mountWithContexts(<Users />);
|
|
||||||
pageSections = pageWrapper.find('PageSection');
|
|
||||||
title = pageWrapper.find('Title');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
test('should display a breadcrumb heading', () => {
|
||||||
pageWrapper.unmount();
|
const history = createMemoryHistory({
|
||||||
});
|
initialEntries: ['/users'],
|
||||||
|
});
|
||||||
|
const match = { path: '/users', url: '/users', isExact: true };
|
||||||
|
|
||||||
test('initially renders without crashing', () => {
|
const wrapper = mountWithContexts(<Users />, {
|
||||||
expect(pageWrapper.length).toBe(1);
|
context: {
|
||||||
expect(pageSections.length).toBe(2);
|
router: {
|
||||||
expect(title.length).toBe(1);
|
history,
|
||||||
expect(title.props().size).toBe('2xl');
|
route: {
|
||||||
expect(pageSections.first().props().variant).toBe('light');
|
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,
|
name: string.isRequired,
|
||||||
organization: number,
|
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;
|
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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user