diff --git a/awx/ui_next/src/api/index.js b/awx/ui_next/src/api/index.js index 2de6a235e0..c3cfc1167f 100644 --- a/awx/ui_next/src/api/index.js +++ b/awx/ui_next/src/api/index.js @@ -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, diff --git a/awx/ui_next/src/api/models/Tokens.js b/awx/ui_next/src/api/models/Tokens.js new file mode 100644 index 0000000000..5dd490808d --- /dev/null +++ b/awx/ui_next/src/api/models/Tokens.js @@ -0,0 +1,10 @@ +import Base from '../Base'; + +class Tokens extends Base { + constructor(http) { + super(http); + this.baseUrl = '/api/v2/tokens/'; + } +} + +export default Tokens; diff --git a/awx/ui_next/src/api/models/Users.js b/awx/ui_next/src/api/models/Users.js index 3d4ec4aac9..97c7a6976c 100644 --- a/awx/ui_next/src/api/models/Users.js +++ b/awx/ui_next/src/api/models/Users.js @@ -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, diff --git a/awx/ui_next/src/components/Lookup/ApplicationLookup.jsx b/awx/ui_next/src/components/Lookup/ApplicationLookup.jsx new file mode 100644 index 0000000000..2d43c0491b --- /dev/null +++ b/awx/ui_next/src/components/Lookup/ApplicationLookup.jsx @@ -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 ( + + ( + dispatch({ type: 'SELECT_ITEM', item })} + deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })} + /> + )} + /> + + + ); +} +ApplicationLookup.propTypes = { + label: node.isRequired, + onChange: func.isRequired, + value: Application, +}; + +ApplicationLookup.defaultProps = { + value: null, +}; + +export default withI18n()(withRouter(ApplicationLookup)); diff --git a/awx/ui_next/src/components/Lookup/ApplicationLookup.test.jsx b/awx/ui_next/src/components/Lookup/ApplicationLookup.test.jsx new file mode 100644 index 0000000000..5d2e2e33a0 --- /dev/null +++ b/awx/ui_next/src/components/Lookup/ApplicationLookup.test.jsx @@ -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( + {}} + /> + ); + }); + expect(wrapper.find('ApplicationLookup')).toHaveLength(1); + }); + + test('should fetch applications', async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + expect(ApplicationsAPI.read).toHaveBeenCalledTimes(1); + }); + + test('should display label', async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + const title = wrapper.find('FormGroup .pf-c-form__label-text'); + expect(title.text()).toEqual('Application'); + }); +}); diff --git a/awx/ui_next/src/components/Lookup/index.js b/awx/ui_next/src/components/Lookup/index.js index 9321fb08e9..fb99cd5681 100644 --- a/awx/ui_next/src/components/Lookup/index.js +++ b/awx/ui_next/src/components/Lookup/index.js @@ -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'; diff --git a/awx/ui_next/src/screens/User/User.jsx b/awx/ui_next/src/screens/User/User.jsx index af60199e69..282a193ab8 100644 --- a/awx/ui_next/src/screens/User/User.jsx +++ b/awx/ui_next/src/screens/User/User.jsx @@ -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 }) { )} - + diff --git a/awx/ui_next/src/screens/User/UserTokenAdd/UserTokenAdd.jsx b/awx/ui_next/src/screens/User/UserTokenAdd/UserTokenAdd.jsx new file mode 100644 index 0000000000..606171c028 --- /dev/null +++ b/awx/ui_next/src/screens/User/UserTokenAdd/UserTokenAdd.jsx @@ -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 ( + + + + ); +} +export default UserTokenAdd; diff --git a/awx/ui_next/src/screens/User/UserTokenAdd/UserTokenAdd.test.jsx b/awx/ui_next/src/screens/User/UserTokenAdd/UserTokenAdd.test.jsx new file mode 100644 index 0000000000..4323663c45 --- /dev/null +++ b/awx/ui_next/src/screens/User/UserTokenAdd/UserTokenAdd.test.jsx @@ -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('', () => { + test('handleSubmit should post to api', async () => { + await act(async () => { + wrapper = mountWithContexts(); + }); + 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(, { + 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(, { + 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(, { + 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'); + }); +}); diff --git a/awx/ui_next/src/screens/User/UserTokenAdd/index.js b/awx/ui_next/src/screens/User/UserTokenAdd/index.js new file mode 100644 index 0000000000..d8a9b4a1f7 --- /dev/null +++ b/awx/ui_next/src/screens/User/UserTokenAdd/index.js @@ -0,0 +1 @@ +export { default } from './UserTokenAdd'; diff --git a/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.jsx b/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.jsx index cb5e4057c5..4b1198c5a9 100644 --- a/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.jsx +++ b/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.jsx @@ -40,7 +40,7 @@ function UserTokenListItem({ i18n, token, isSelected, onSelect }) { > {token.summary_fields?.application?.name ? ( - {i18n._(t`Application:`)} + {i18n._(t`Application`)} {token.summary_fields.application.name} ) : ( diff --git a/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.test.jsx b/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.test.jsx index fe009e4b8a..a91e2d1632 100644 --- a/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.test.jsx +++ b/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.test.jsx @@ -53,7 +53,7 @@ describe('', () => { 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' ); diff --git a/awx/ui_next/src/screens/User/UserTokens/UserTokens.jsx b/awx/ui_next/src/screens/User/UserTokens/UserTokens.jsx new file mode 100644 index 0000000000..dc072a6546 --- /dev/null +++ b/awx/ui_next/src/screens/User/UserTokens/UserTokens.jsx @@ -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 ( + + + + + + + + + ); +} + +export default withI18n()(UserTokens); diff --git a/awx/ui_next/src/screens/User/UserTokens/index.js b/awx/ui_next/src/screens/User/UserTokens/index.js new file mode 100644 index 0000000000..8ea0743daa --- /dev/null +++ b/awx/ui_next/src/screens/User/UserTokens/index.js @@ -0,0 +1 @@ +export { default } from './UserTokens'; diff --git a/awx/ui_next/src/screens/User/Users.jsx b/awx/ui_next/src/screens/User/Users.jsx index 575b997f48..6f21f8be10 100644 --- a/awx/ui_next/src/screens/User/Users.jsx +++ b/awx/ui_next/src/screens/User/Users.jsx @@ -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] diff --git a/awx/ui_next/src/screens/User/shared/UserTokenForm.jsx b/awx/ui_next/src/screens/User/shared/UserTokenForm.jsx new file mode 100644 index 0000000000..bfc5f0f08f --- /dev/null +++ b/awx/ui_next/src/screens/User/shared/UserTokenForm.jsx @@ -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 ( + <> + + { + applicationHelpers.setValue(value); + }} + label={ + + {i18n._(t`Application`)} + + + } + touched={applicationMeta.touched} + /> + + + + + } + > + { + scopeHelpers.setValue(value); + }} + /> + + + ); +} + +function UserTokenForm({ + handleCancel, + handleSubmit, + submitError, + i18n, + token = {}, +}) { + return ( + + {formik => ( +
+ + + {submitError && } + { + formik.handleSubmit(); + }} + /> + +
+ )} +
+ ); +} +export default withI18n()(UserTokenForm); diff --git a/awx/ui_next/src/screens/User/shared/UserTokenForm.test.jsx b/awx/ui_next/src/screens/User/shared/UserTokenForm.test.jsx new file mode 100644 index 0000000000..ddfcbd6cb4 --- /dev/null +++ b/awx/ui_next/src/screens/User/shared/UserTokenForm.test.jsx @@ -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('', () => { + let wrapper; + beforeEach(() => {}); + afterEach(() => { + wrapper.unmount(); + jest.clearAllMocks(); + }); + + test('initially renders successfully', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + + expect(wrapper.find('UserTokenForm').length).toBe(1); + }); + + test('add form displays all form fields', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + 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( + + ); + }); + 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( + + ); + }); + 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( + + ); + }); + 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( + + ); + }); + 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(); + }); +}); diff --git a/awx/ui_next/src/screens/User/shared/index.js b/awx/ui_next/src/screens/User/shared/index.js index ee4362b5c2..4a93f427bd 100644 --- a/awx/ui_next/src/screens/User/shared/index.js +++ b/awx/ui_next/src/screens/User/shared/index.js @@ -1,2 +1,3 @@ /* eslint-disable-next-line import/prefer-default-export */ export { default as UserForm } from './UserForm'; +export { default as UserTokenForm } from './UserTokenForm';