Adds User TokenAdd Functionality

This commit is contained in:
Alex Corey 2020-07-06 11:23:37 -04:00
parent fb3271da3c
commit bbc4522063
18 changed files with 646 additions and 5 deletions

View File

@ -24,6 +24,7 @@ import Roles from './models/Roles';
import Schedules from './models/Schedules';
import SystemJobs from './models/SystemJobs';
import Teams from './models/Teams';
import Tokens from './models/Tokens';
import UnifiedJobTemplates from './models/UnifiedJobTemplates';
import UnifiedJobs from './models/UnifiedJobs';
import Users from './models/Users';
@ -58,6 +59,7 @@ const RolesAPI = new Roles();
const SchedulesAPI = new Schedules();
const SystemJobsAPI = new SystemJobs();
const TeamsAPI = new Teams();
const TokensAPI = new Tokens();
const UnifiedJobTemplatesAPI = new UnifiedJobTemplates();
const UnifiedJobsAPI = new UnifiedJobs();
const UsersAPI = new Users();
@ -93,6 +95,7 @@ export {
SchedulesAPI,
SystemJobsAPI,
TeamsAPI,
TokensAPI,
UnifiedJobTemplatesAPI,
UnifiedJobsAPI,
UsersAPI,

View 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;

View File

@ -12,6 +12,10 @@ class Users extends Base {
});
}
createToken(userId, data) {
return this.http.post(`${this.baseUrl}${userId}/authorized_tokens/`, data);
}
disassociateRole(userId, roleId) {
return this.http.post(`${this.baseUrl}${userId}/roles/`, {
id: roleId,

View 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));

View 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');
});
});

View File

@ -4,3 +4,4 @@ export { default as InventoryLookup } from './InventoryLookup';
export { default as ProjectLookup } from './ProjectLookup';
export { default as MultiCredentialsLookup } from './MultiCredentialsLookup';
export { default as CredentialLookup } from './CredentialLookup';
export { default as ApplicationLookup } from './ApplicationLookup';

View File

@ -20,7 +20,7 @@ import UserDetail from './UserDetail';
import UserEdit from './UserEdit';
import UserOrganizations from './UserOrganizations';
import UserTeams from './UserTeams';
import UserTokenList from './UserTokenList';
import UserTokens from './UserTokens';
import UserAccessList from './UserAccess/UserAccessList';
function User({ i18n, setBreadcrumb, me }) {
@ -80,7 +80,7 @@ function User({ i18n, setBreadcrumb, me }) {
}
let showCardHeader = true;
if (['edit'].some(name => location.pathname.includes(name))) {
if (['edit', 'add'].some(name => location.pathname.includes(name))) {
showCardHeader = false;
}
@ -131,7 +131,7 @@ function User({ i18n, setBreadcrumb, me }) {
</Route>
)}
<Route path="/users/:id/tokens">
<UserTokenList id={Number(match.params.id)} />
<UserTokens id={Number(match.params.id)} />
</Route>
<Route key="not-found" path="*">
<ContentError isNotFound>

View 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;

View File

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

View File

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

View File

@ -40,7 +40,7 @@ function UserTokenListItem({ i18n, token, isSelected, onSelect }) {
>
{token.summary_fields?.application?.name ? (
<span>
<NameLabel>{i18n._(t`Application:`)}</NameLabel>
<NameLabel>{i18n._(t`Application`)}</NameLabel>
{token.summary_fields.application.name}
</span>
) : (

View File

@ -53,7 +53,7 @@ describe('<UserTokenListItem />', () => {
expect(wrapper.find('DataListCheck').prop('checked')).toBe(false);
expect(
wrapper.find('PFDataListCell[aria-label="application name"]').text()
).toBe('Application:app');
).toBe('Applicationapp');
expect(wrapper.find('PFDataListCell[aria-label="scope"]').text()).toBe(
'ScopeRead'
);

View 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);

View File

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

View File

@ -33,6 +33,7 @@ function Users({ i18n }) {
[`/users/${user.id}/teams`]: i18n._(t`Teams`),
[`/users/${user.id}/organizations`]: i18n._(t`Organizations`),
[`/users/${user.id}/tokens`]: i18n._(t`Tokens`),
[`/users/${user.id}/tokens/add`]: i18n._(t`Create user token`),
});
},
[i18n]

View 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);

View 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();
});
});

View File

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