mirror of
https://github.com/ansible/awx.git
synced 2026-03-06 03:01:06 -03:30
Adds User TokenAdd Functionality
This commit is contained in:
@@ -24,6 +24,7 @@ import Roles from './models/Roles';
|
|||||||
import Schedules from './models/Schedules';
|
import Schedules from './models/Schedules';
|
||||||
import SystemJobs from './models/SystemJobs';
|
import SystemJobs from './models/SystemJobs';
|
||||||
import Teams from './models/Teams';
|
import Teams from './models/Teams';
|
||||||
|
import Tokens from './models/Tokens';
|
||||||
import UnifiedJobTemplates from './models/UnifiedJobTemplates';
|
import UnifiedJobTemplates from './models/UnifiedJobTemplates';
|
||||||
import UnifiedJobs from './models/UnifiedJobs';
|
import UnifiedJobs from './models/UnifiedJobs';
|
||||||
import Users from './models/Users';
|
import Users from './models/Users';
|
||||||
@@ -58,6 +59,7 @@ const RolesAPI = new Roles();
|
|||||||
const SchedulesAPI = new Schedules();
|
const SchedulesAPI = new Schedules();
|
||||||
const SystemJobsAPI = new SystemJobs();
|
const SystemJobsAPI = new SystemJobs();
|
||||||
const TeamsAPI = new Teams();
|
const TeamsAPI = new Teams();
|
||||||
|
const TokensAPI = new Tokens();
|
||||||
const UnifiedJobTemplatesAPI = new UnifiedJobTemplates();
|
const UnifiedJobTemplatesAPI = new UnifiedJobTemplates();
|
||||||
const UnifiedJobsAPI = new UnifiedJobs();
|
const UnifiedJobsAPI = new UnifiedJobs();
|
||||||
const UsersAPI = new Users();
|
const UsersAPI = new Users();
|
||||||
@@ -93,6 +95,7 @@ export {
|
|||||||
SchedulesAPI,
|
SchedulesAPI,
|
||||||
SystemJobsAPI,
|
SystemJobsAPI,
|
||||||
TeamsAPI,
|
TeamsAPI,
|
||||||
|
TokensAPI,
|
||||||
UnifiedJobTemplatesAPI,
|
UnifiedJobTemplatesAPI,
|
||||||
UnifiedJobsAPI,
|
UnifiedJobsAPI,
|
||||||
UsersAPI,
|
UsersAPI,
|
||||||
|
|||||||
10
awx/ui_next/src/api/models/Tokens.js
Normal file
10
awx/ui_next/src/api/models/Tokens.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import Base from '../Base';
|
||||||
|
|
||||||
|
class Tokens extends Base {
|
||||||
|
constructor(http) {
|
||||||
|
super(http);
|
||||||
|
this.baseUrl = '/api/v2/tokens/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Tokens;
|
||||||
@@ -12,6 +12,10 @@ class Users extends Base {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createToken(userId, data) {
|
||||||
|
return this.http.post(`${this.baseUrl}${userId}/authorized_tokens/`, data);
|
||||||
|
}
|
||||||
|
|
||||||
disassociateRole(userId, roleId) {
|
disassociateRole(userId, roleId) {
|
||||||
return this.http.post(`${this.baseUrl}${userId}/roles/`, {
|
return this.http.post(`${this.baseUrl}${userId}/roles/`, {
|
||||||
id: roleId,
|
id: roleId,
|
||||||
|
|||||||
106
awx/ui_next/src/components/Lookup/ApplicationLookup.jsx
Normal file
106
awx/ui_next/src/components/Lookup/ApplicationLookup.jsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import React, { useCallback, useEffect } from 'react';
|
||||||
|
import { func, node } from 'prop-types';
|
||||||
|
import { withRouter, useLocation } from 'react-router-dom';
|
||||||
|
import { withI18n } from '@lingui/react';
|
||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import { FormGroup } from '@patternfly/react-core';
|
||||||
|
import { ApplicationsAPI } from '../../api';
|
||||||
|
import { Application } from '../../types';
|
||||||
|
import { getQSConfig, parseQueryString } from '../../util/qs';
|
||||||
|
import Lookup from './Lookup';
|
||||||
|
import OptionsList from '../OptionsList';
|
||||||
|
import useRequest from '../../util/useRequest';
|
||||||
|
import LookupErrorMessage from './shared/LookupErrorMessage';
|
||||||
|
|
||||||
|
const QS_CONFIG = getQSConfig('applications', {
|
||||||
|
page: 1,
|
||||||
|
page_size: 5,
|
||||||
|
order_by: 'name',
|
||||||
|
});
|
||||||
|
|
||||||
|
function ApplicationLookup({ i18n, onChange, value, label }) {
|
||||||
|
const location = useLocation();
|
||||||
|
const {
|
||||||
|
error,
|
||||||
|
result: { applications, itemCount },
|
||||||
|
request: fetchApplications,
|
||||||
|
} = useRequest(
|
||||||
|
useCallback(async () => {
|
||||||
|
const params = parseQueryString(QS_CONFIG, location.search);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { results, count },
|
||||||
|
} = await ApplicationsAPI.read(params);
|
||||||
|
return { applications: results, itemCount: count };
|
||||||
|
}, [location]),
|
||||||
|
{ applications: [], itemCount: 0 }
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
fetchApplications();
|
||||||
|
}, [fetchApplications]);
|
||||||
|
return (
|
||||||
|
<FormGroup fieldId="application" label={label}>
|
||||||
|
<Lookup
|
||||||
|
id="application"
|
||||||
|
header={i18n._(t`Application`)}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
qsConfig={QS_CONFIG}
|
||||||
|
renderOptionsList={({ state, dispatch, canDelete }) => (
|
||||||
|
<OptionsList
|
||||||
|
value={state.selectedItems}
|
||||||
|
options={applications}
|
||||||
|
optionCount={itemCount}
|
||||||
|
header={i18n._(t`Applications`)}
|
||||||
|
qsConfig={QS_CONFIG}
|
||||||
|
searchColumns={[
|
||||||
|
{
|
||||||
|
name: i18n._(t`Name`),
|
||||||
|
key: 'name',
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: i18n._(t`Description`),
|
||||||
|
key: 'description',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
sortColumns={[
|
||||||
|
{
|
||||||
|
name: i18n._(t`Name`),
|
||||||
|
key: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: i18n._(t`Created`),
|
||||||
|
key: 'created',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: i18n._(t`Organization`),
|
||||||
|
key: 'organization',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: i18n._(t`Description`),
|
||||||
|
key: 'description',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
readOnly={!canDelete}
|
||||||
|
name="application"
|
||||||
|
selectItem={item => dispatch({ type: 'SELECT_ITEM', item })}
|
||||||
|
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<LookupErrorMessage error={error} />
|
||||||
|
</FormGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ApplicationLookup.propTypes = {
|
||||||
|
label: node.isRequired,
|
||||||
|
onChange: func.isRequired,
|
||||||
|
value: Application,
|
||||||
|
};
|
||||||
|
|
||||||
|
ApplicationLookup.defaultProps = {
|
||||||
|
value: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withI18n()(withRouter(ApplicationLookup));
|
||||||
80
awx/ui_next/src/components/Lookup/ApplicationLookup.test.jsx
Normal file
80
awx/ui_next/src/components/Lookup/ApplicationLookup.test.jsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||||
|
import ApplicationLookup from './ApplicationLookup';
|
||||||
|
import { ApplicationsAPI } from '../../api';
|
||||||
|
|
||||||
|
jest.mock('../../api');
|
||||||
|
const application = {
|
||||||
|
id: 1,
|
||||||
|
name: 'app',
|
||||||
|
description: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchedApplications = {
|
||||||
|
count: 2,
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'app',
|
||||||
|
description: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
name: 'application that should not crach',
|
||||||
|
description: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
describe('ApplicationLookup', () => {
|
||||||
|
let wrapper;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
ApplicationsAPI.read.mockResolvedValueOnce(fetchedApplications);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should render successfully', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<ApplicationLookup
|
||||||
|
label="Application"
|
||||||
|
value={application}
|
||||||
|
onChange={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(wrapper.find('ApplicationLookup')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should fetch applications', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<ApplicationLookup
|
||||||
|
label="Application"
|
||||||
|
value={application}
|
||||||
|
onChange={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(ApplicationsAPI.read).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display label', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<ApplicationLookup
|
||||||
|
label="Application"
|
||||||
|
value={application}
|
||||||
|
onChange={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const title = wrapper.find('FormGroup .pf-c-form__label-text');
|
||||||
|
expect(title.text()).toEqual('Application');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,3 +4,4 @@ export { default as InventoryLookup } from './InventoryLookup';
|
|||||||
export { default as ProjectLookup } from './ProjectLookup';
|
export { default as ProjectLookup } from './ProjectLookup';
|
||||||
export { default as MultiCredentialsLookup } from './MultiCredentialsLookup';
|
export { default as MultiCredentialsLookup } from './MultiCredentialsLookup';
|
||||||
export { default as CredentialLookup } from './CredentialLookup';
|
export { default as CredentialLookup } from './CredentialLookup';
|
||||||
|
export { default as ApplicationLookup } from './ApplicationLookup';
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import UserDetail from './UserDetail';
|
|||||||
import UserEdit from './UserEdit';
|
import UserEdit from './UserEdit';
|
||||||
import UserOrganizations from './UserOrganizations';
|
import UserOrganizations from './UserOrganizations';
|
||||||
import UserTeams from './UserTeams';
|
import UserTeams from './UserTeams';
|
||||||
import UserTokenList from './UserTokenList';
|
import UserTokens from './UserTokens';
|
||||||
import UserAccessList from './UserAccess/UserAccessList';
|
import UserAccessList from './UserAccess/UserAccessList';
|
||||||
|
|
||||||
function User({ i18n, setBreadcrumb, me }) {
|
function User({ i18n, setBreadcrumb, me }) {
|
||||||
@@ -80,7 +80,7 @@ function User({ i18n, setBreadcrumb, me }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let showCardHeader = true;
|
let showCardHeader = true;
|
||||||
if (['edit'].some(name => location.pathname.includes(name))) {
|
if (['edit', 'add'].some(name => location.pathname.includes(name))) {
|
||||||
showCardHeader = false;
|
showCardHeader = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +131,7 @@ function User({ i18n, setBreadcrumb, me }) {
|
|||||||
</Route>
|
</Route>
|
||||||
)}
|
)}
|
||||||
<Route path="/users/:id/tokens">
|
<Route path="/users/:id/tokens">
|
||||||
<UserTokenList id={Number(match.params.id)} />
|
<UserTokens id={Number(match.params.id)} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route key="not-found" path="*">
|
<Route key="not-found" path="*">
|
||||||
<ContentError isNotFound>
|
<ContentError isNotFound>
|
||||||
|
|||||||
42
awx/ui_next/src/screens/User/UserTokenAdd/UserTokenAdd.jsx
Normal file
42
awx/ui_next/src/screens/User/UserTokenAdd/UserTokenAdd.jsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import React, { useCallback } from 'react';
|
||||||
|
import { useHistory, useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { CardBody } from '../../../components/Card';
|
||||||
|
import { TokensAPI, UsersAPI } from '../../../api';
|
||||||
|
import useRequest from '../../../util/useRequest';
|
||||||
|
import UserTokenFrom from '../shared/UserTokenForm';
|
||||||
|
|
||||||
|
function UserTokenAdd() {
|
||||||
|
const history = useHistory();
|
||||||
|
const { id: userId } = useParams();
|
||||||
|
const { error: submitError, request: handleSubmit } = useRequest(
|
||||||
|
useCallback(
|
||||||
|
async formData => {
|
||||||
|
if (formData.application) {
|
||||||
|
formData.application = formData.application?.id || null;
|
||||||
|
await UsersAPI.createToken(userId, formData);
|
||||||
|
} else {
|
||||||
|
await TokensAPI.create(formData);
|
||||||
|
}
|
||||||
|
|
||||||
|
history.push(`/users/${userId}/tokens`);
|
||||||
|
},
|
||||||
|
[history, userId]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
history.push(`/users/${userId}/tokens`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardBody>
|
||||||
|
<UserTokenFrom
|
||||||
|
handleCancel={handleCancel}
|
||||||
|
handleSubmit={handleSubmit}
|
||||||
|
submitError={submitError}
|
||||||
|
/>
|
||||||
|
</CardBody>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export default UserTokenAdd;
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import { createMemoryHistory } from 'history';
|
||||||
|
import {
|
||||||
|
mountWithContexts,
|
||||||
|
waitForElement,
|
||||||
|
} from '../../../../testUtils/enzymeHelpers';
|
||||||
|
import UserTokenAdd from './UserTokenAdd';
|
||||||
|
import { UsersAPI, TokensAPI } from '../../../api';
|
||||||
|
|
||||||
|
jest.mock('../../../api');
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
...jest.requireActual('react-router-dom'),
|
||||||
|
history: () => ({
|
||||||
|
location: '/user',
|
||||||
|
}),
|
||||||
|
useParams: () => ({ id: 1 }),
|
||||||
|
}));
|
||||||
|
let wrapper;
|
||||||
|
|
||||||
|
describe('<UserTokenAdd />', () => {
|
||||||
|
test('handleSubmit should post to api', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(<UserTokenAdd />);
|
||||||
|
});
|
||||||
|
UsersAPI.createToken.mockResolvedValueOnce({ data: { id: 1 } });
|
||||||
|
const tokenData = {
|
||||||
|
application: 1,
|
||||||
|
description: 'foo',
|
||||||
|
scope: 'read',
|
||||||
|
};
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('UserTokenForm').prop('handleSubmit')(tokenData);
|
||||||
|
});
|
||||||
|
expect(UsersAPI.createToken).toHaveBeenCalledWith(1, tokenData);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should navigate to tokens list when cancel is clicked', async () => {
|
||||||
|
const history = createMemoryHistory({});
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(<UserTokenAdd />, {
|
||||||
|
context: { router: { history } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
|
||||||
|
});
|
||||||
|
expect(history.location.pathname).toEqual('/users/1/tokens');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('successful form submission should trigger redirect', async () => {
|
||||||
|
const history = createMemoryHistory({});
|
||||||
|
const tokenData = {
|
||||||
|
application: 1,
|
||||||
|
description: 'foo',
|
||||||
|
scope: 'read',
|
||||||
|
};
|
||||||
|
UsersAPI.createToken.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
id: 2,
|
||||||
|
...tokenData,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(<UserTokenAdd />, {
|
||||||
|
context: { router: { history } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await waitForElement(wrapper, 'button[aria-label="Save"]');
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('UserTokenForm').prop('handleSubmit')(tokenData);
|
||||||
|
});
|
||||||
|
expect(history.location.pathname).toEqual('/users/1/tokens');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should successful submit form with application', async () => {
|
||||||
|
const history = createMemoryHistory({});
|
||||||
|
const tokenData = {
|
||||||
|
scope: 'read',
|
||||||
|
};
|
||||||
|
TokensAPI.create.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
id: 2,
|
||||||
|
...tokenData,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(<UserTokenAdd />, {
|
||||||
|
context: { router: { history } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await waitForElement(wrapper, 'button[aria-label="Save"]');
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('UserTokenForm').prop('handleSubmit')(tokenData);
|
||||||
|
});
|
||||||
|
expect(history.location.pathname).toEqual('/users/1/tokens');
|
||||||
|
});
|
||||||
|
});
|
||||||
1
awx/ui_next/src/screens/User/UserTokenAdd/index.js
Normal file
1
awx/ui_next/src/screens/User/UserTokenAdd/index.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './UserTokenAdd';
|
||||||
@@ -40,7 +40,7 @@ function UserTokenListItem({ i18n, token, isSelected, onSelect }) {
|
|||||||
>
|
>
|
||||||
{token.summary_fields?.application?.name ? (
|
{token.summary_fields?.application?.name ? (
|
||||||
<span>
|
<span>
|
||||||
<NameLabel>{i18n._(t`Application:`)}</NameLabel>
|
<NameLabel>{i18n._(t`Application`)}</NameLabel>
|
||||||
{token.summary_fields.application.name}
|
{token.summary_fields.application.name}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ describe('<UserTokenListItem />', () => {
|
|||||||
expect(wrapper.find('DataListCheck').prop('checked')).toBe(false);
|
expect(wrapper.find('DataListCheck').prop('checked')).toBe(false);
|
||||||
expect(
|
expect(
|
||||||
wrapper.find('PFDataListCell[aria-label="application name"]').text()
|
wrapper.find('PFDataListCell[aria-label="application name"]').text()
|
||||||
).toBe('Application:app');
|
).toBe('Applicationapp');
|
||||||
expect(wrapper.find('PFDataListCell[aria-label="scope"]').text()).toBe(
|
expect(wrapper.find('PFDataListCell[aria-label="scope"]').text()).toBe(
|
||||||
'ScopeRead'
|
'ScopeRead'
|
||||||
);
|
);
|
||||||
|
|||||||
22
awx/ui_next/src/screens/User/UserTokens/UserTokens.jsx
Normal file
22
awx/ui_next/src/screens/User/UserTokens/UserTokens.jsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { withI18n } from '@lingui/react';
|
||||||
|
import { Switch, Route, useParams } from 'react-router-dom';
|
||||||
|
import UserTokenAdd from '../UserTokenAdd';
|
||||||
|
import UserTokenList from '../UserTokenList';
|
||||||
|
|
||||||
|
function UserTokens() {
|
||||||
|
const { id: userId } = useParams();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Switch>
|
||||||
|
<Route key="add" path="/users/:id/tokens/add">
|
||||||
|
<UserTokenAdd id={Number(userId)} />
|
||||||
|
</Route>
|
||||||
|
<Route key="list" path="/users/:id/tokens">
|
||||||
|
<UserTokenList id={Number(userId)} />
|
||||||
|
</Route>
|
||||||
|
</Switch>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n()(UserTokens);
|
||||||
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';
|
||||||
@@ -33,6 +33,7 @@ function Users({ i18n }) {
|
|||||||
[`/users/${user.id}/teams`]: i18n._(t`Teams`),
|
[`/users/${user.id}/teams`]: i18n._(t`Teams`),
|
||||||
[`/users/${user.id}/organizations`]: i18n._(t`Organizations`),
|
[`/users/${user.id}/organizations`]: i18n._(t`Organizations`),
|
||||||
[`/users/${user.id}/tokens`]: i18n._(t`Tokens`),
|
[`/users/${user.id}/tokens`]: i18n._(t`Tokens`),
|
||||||
|
[`/users/${user.id}/tokens/add`]: i18n._(t`Create user token`),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[i18n]
|
[i18n]
|
||||||
|
|||||||
127
awx/ui_next/src/screens/User/shared/UserTokenForm.jsx
Normal file
127
awx/ui_next/src/screens/User/shared/UserTokenForm.jsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { withI18n } from '@lingui/react';
|
||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import { Formik, useField } from 'formik';
|
||||||
|
import { Form, FormGroup } from '@patternfly/react-core';
|
||||||
|
import AnsibleSelect from '../../../components/AnsibleSelect';
|
||||||
|
import FormActionGroup from '../../../components/FormActionGroup/FormActionGroup';
|
||||||
|
import FormField, {
|
||||||
|
FormSubmitError,
|
||||||
|
FieldTooltip,
|
||||||
|
} from '../../../components/FormField';
|
||||||
|
import ApplicationLookup from '../../../components/Lookup/ApplicationLookup';
|
||||||
|
import { required } from '../../../util/validators';
|
||||||
|
|
||||||
|
import { FormColumnLayout } from '../../../components/FormLayout';
|
||||||
|
|
||||||
|
function UserTokenFormFields({ i18n }) {
|
||||||
|
const [applicationField, applicationMeta, applicationHelpers] = useField(
|
||||||
|
'application'
|
||||||
|
);
|
||||||
|
|
||||||
|
const [scopeField, scopeMeta, scopeHelpers] = useField({
|
||||||
|
name: 'scope',
|
||||||
|
validate: required(i18n._(t`Please enter a value.`), i18n),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FormGroup
|
||||||
|
fieldId="application-lookup"
|
||||||
|
name="application"
|
||||||
|
validated={
|
||||||
|
!applicationMeta.touched || !applicationMeta.error
|
||||||
|
? 'default'
|
||||||
|
: 'error'
|
||||||
|
}
|
||||||
|
helperTextInvalid={applicationMeta.error}
|
||||||
|
>
|
||||||
|
<ApplicationLookup
|
||||||
|
value={applicationField.value}
|
||||||
|
onChange={value => {
|
||||||
|
applicationHelpers.setValue(value);
|
||||||
|
}}
|
||||||
|
label={
|
||||||
|
<span>
|
||||||
|
{i18n._(t`Application`)}
|
||||||
|
<FieldTooltip
|
||||||
|
content={i18n._(
|
||||||
|
t`Select the application that this token will belong to.`
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
touched={applicationMeta.touched}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
<FormField
|
||||||
|
id="token-description"
|
||||||
|
name="description"
|
||||||
|
type="text"
|
||||||
|
label={i18n._(t`Description`)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormGroup
|
||||||
|
name="scope"
|
||||||
|
fieldId="token-scope"
|
||||||
|
helperTextInvalid={scopeMeta.error}
|
||||||
|
isRequired
|
||||||
|
validated={!scopeMeta.touched || !scopeMeta.error ? 'default' : 'error'}
|
||||||
|
label={i18n._(t`Scope`)}
|
||||||
|
labelIcon={
|
||||||
|
<FieldTooltip
|
||||||
|
content={i18n._(t`Specify a scope for the token's access`)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AnsibleSelect
|
||||||
|
{...scopeField}
|
||||||
|
id="token-scope"
|
||||||
|
data={[
|
||||||
|
{ key: 'default', label: '', value: '' },
|
||||||
|
{ key: 'read', value: 'read', label: i18n._(t`Read`) },
|
||||||
|
{ key: 'write', value: 'write', label: i18n._(t`Write`) },
|
||||||
|
]}
|
||||||
|
onChange={(event, value) => {
|
||||||
|
scopeHelpers.setValue(value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserTokenForm({
|
||||||
|
handleCancel,
|
||||||
|
handleSubmit,
|
||||||
|
submitError,
|
||||||
|
i18n,
|
||||||
|
token = {},
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Formik
|
||||||
|
initialValues={{
|
||||||
|
description: token.description || '',
|
||||||
|
application: token.application || null,
|
||||||
|
scope: token.scope || '',
|
||||||
|
}}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
>
|
||||||
|
{formik => (
|
||||||
|
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
||||||
|
<FormColumnLayout>
|
||||||
|
<UserTokenFormFields i18n={i18n} />
|
||||||
|
{submitError && <FormSubmitError error={submitError} />}
|
||||||
|
<FormActionGroup
|
||||||
|
onCancel={handleCancel}
|
||||||
|
onSubmit={() => {
|
||||||
|
formik.handleSubmit();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormColumnLayout>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export default withI18n()(UserTokenForm);
|
||||||
144
awx/ui_next/src/screens/User/shared/UserTokenForm.test.jsx
Normal file
144
awx/ui_next/src/screens/User/shared/UserTokenForm.test.jsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import {
|
||||||
|
mountWithContexts,
|
||||||
|
waitForElement,
|
||||||
|
} from '../../../../testUtils/enzymeHelpers';
|
||||||
|
import UserTokenForm from './UserTokenForm';
|
||||||
|
import { sleep } from '../../../../testUtils/testUtils';
|
||||||
|
import { ApplicationsAPI } from '../../../api';
|
||||||
|
|
||||||
|
jest.mock('../../../api');
|
||||||
|
const applications = {
|
||||||
|
data: {
|
||||||
|
count: 2,
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'app',
|
||||||
|
description: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
name: 'application that should not crach',
|
||||||
|
description: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
describe('<UserTokenForm />', () => {
|
||||||
|
let wrapper;
|
||||||
|
beforeEach(() => {});
|
||||||
|
afterEach(() => {
|
||||||
|
wrapper.unmount();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initially renders successfully', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<UserTokenForm handleSubmit={jest.fn()} handleCancel={jest.fn()} />
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.find('UserTokenForm').length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('add form displays all form fields', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<UserTokenForm handleSubmit={jest.fn()} handleCancel={jest.fn()} />
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||||
|
expect(wrapper.find('FormGroup[name="application"]').length).toBe(1);
|
||||||
|
expect(wrapper.find('FormField[name="description"]').length).toBe(1);
|
||||||
|
expect(wrapper.find('FormGroup[name="scope"]').length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('inputs should update form value on change', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<UserTokenForm handleSubmit={jest.fn()} handleCancel={jest.fn()} />
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||||
|
wrapper.update();
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('ApplicationLookup').invoke('onChange')({
|
||||||
|
id: 1,
|
||||||
|
name: 'application',
|
||||||
|
});
|
||||||
|
wrapper.find('input[name="description"]').simulate('change', {
|
||||||
|
target: { value: 'new Bar', name: 'description' },
|
||||||
|
});
|
||||||
|
wrapper.find('AnsibleSelect[name="scope"]').prop('onChange')({}, 'read');
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(wrapper.find('ApplicationLookup').prop('value')).toEqual({
|
||||||
|
id: 1,
|
||||||
|
name: 'application',
|
||||||
|
});
|
||||||
|
expect(wrapper.find('input[name="description"]').prop('value')).toBe(
|
||||||
|
'new Bar'
|
||||||
|
);
|
||||||
|
expect(wrapper.find('AnsibleSelect#token-scope').prop('value')).toBe(
|
||||||
|
'read'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should call handleSubmit when Submit button is clicked', async () => {
|
||||||
|
ApplicationsAPI.read.mockResolvedValue(applications);
|
||||||
|
const handleSubmit = jest.fn();
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<UserTokenForm handleSubmit={handleSubmit} handleCancel={jest.fn()} />
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('AnsibleSelect[name="scope"]').prop('onChange')({}, 'read');
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('button[aria-label="Save"]').prop('onClick')();
|
||||||
|
});
|
||||||
|
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(
|
||||||
|
<UserTokenForm 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();
|
||||||
|
});
|
||||||
|
test('should throw error on submit without scope value', async () => {
|
||||||
|
ApplicationsAPI.read.mockResolvedValue(applications);
|
||||||
|
const handleSubmit = jest.fn();
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<UserTokenForm handleSubmit={handleSubmit} handleCancel={jest.fn()} />
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('button[aria-label="Save"]').prop('onClick')();
|
||||||
|
});
|
||||||
|
await sleep(1);
|
||||||
|
wrapper.update();
|
||||||
|
expect(
|
||||||
|
wrapper.find('FormGroup[name="scope"]').prop('helperTextInvalid')
|
||||||
|
).toBe('Please enter a value.');
|
||||||
|
expect(handleSubmit).not.toBeCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
/* eslint-disable-next-line import/prefer-default-export */
|
/* eslint-disable-next-line import/prefer-default-export */
|
||||||
export { default as UserForm } from './UserForm';
|
export { default as UserForm } from './UserForm';
|
||||||
|
export { default as UserTokenForm } from './UserTokenForm';
|
||||||
|
|||||||
Reference in New Issue
Block a user