From ab1e991e0136df8c42508f132585a86adff00e1e Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Mon, 15 Jun 2020 14:04:33 -0400 Subject: [PATCH] adds application add functionality and applicatiion form --- .../ApplicationAdd/ApplicationAdd.jsx | 89 +++++++- .../ApplicationAdd/ApplicationAdd.test.jsx | 190 ++++++++++++++++++ .../Application/shared/ApplicationForm.jsx | 177 ++++++++++++++++ .../shared/ApplicationForm.test.jsx | 181 +++++++++++++++++ 4 files changed, 632 insertions(+), 5 deletions(-) create mode 100644 awx/ui_next/src/screens/Application/ApplicationAdd/ApplicationAdd.test.jsx create mode 100644 awx/ui_next/src/screens/Application/shared/ApplicationForm.jsx create mode 100644 awx/ui_next/src/screens/Application/shared/ApplicationForm.test.jsx diff --git a/awx/ui_next/src/screens/Application/ApplicationAdd/ApplicationAdd.jsx b/awx/ui_next/src/screens/Application/ApplicationAdd/ApplicationAdd.jsx index a25c690a7b..0537fa813c 100644 --- a/awx/ui_next/src/screens/Application/ApplicationAdd/ApplicationAdd.jsx +++ b/awx/ui_next/src/screens/Application/ApplicationAdd/ApplicationAdd.jsx @@ -1,15 +1,94 @@ -import React from 'react'; -import { Card, PageSection } from '@patternfly/react-core'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useHistory } from 'react-router-dom'; -function ApplicatonAdd() { +import { Card, PageSection } from '@patternfly/react-core'; +import useRequest from '../../../util/useRequest'; +import ContentError from '../../../components/ContentError'; +import ApplicationForm from '../shared/ApplicationForm'; +import { ApplicationsAPI } from '../../../api'; +import { CardBody } from '../../../components/Card'; + +function ApplicationAdd() { + const history = useHistory(); + const [submitError, setSubmitError] = useState(null); + + const { + error, + request: fetchOptions, + result: { authorizationOptions, clientTypeOptions }, + } = useRequest( + useCallback(async () => { + const { + data: { + actions: { + GET: { + authorization_grant_type: { choices: authChoices }, + client_type: { choices: clientChoices }, + }, + }, + }, + } = await ApplicationsAPI.readOptions(); + + const authorization = authChoices.map(choice => ({ + value: choice[0], + label: choice[1], + key: choice[0], + })); + const clientType = clientChoices.map(choice => ({ + value: choice[0], + label: choice[1], + key: choice[0], + })); + + return { + authorizationOptions: authorization, + clientTypeOptions: clientType, + }; + }, []), + { + authorizationOptions: [], + clientTypeOptions: [], + } + ); + const handleSubmit = async ({ ...values }) => { + values.organization = values.organization.id; + try { + const { + data: { id }, + } = await ApplicationsAPI.create(values); + history.push(`/applications/${id}/details`); + } catch (err) { + setSubmitError(err); + } + }; + + const handleCancel = () => { + history.push(`/applications`); + }; + + useEffect(() => { + fetchOptions(); + }, [fetchOptions]); + + if (error) { + return ; + } return ( <> -
Applications Add
+ + +
); } -export default ApplicatonAdd; +export default ApplicationAdd; diff --git a/awx/ui_next/src/screens/Application/ApplicationAdd/ApplicationAdd.test.jsx b/awx/ui_next/src/screens/Application/ApplicationAdd/ApplicationAdd.test.jsx new file mode 100644 index 0000000000..bcf39fe64c --- /dev/null +++ b/awx/ui_next/src/screens/Application/ApplicationAdd/ApplicationAdd.test.jsx @@ -0,0 +1,190 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { + mountWithContexts, + waitForElement, +} from '../../../../testUtils/enzymeHelpers'; + +import { ApplicationsAPI } from '../../../api'; +import ApplicationAdd from './ApplicationAdd'; + +jest.mock('../../../api/models/Applications'); +jest.mock('../../../api/models/Organizations'); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + history: () => ({ + location: '/applications/add', + }), +})); +const options = { + data: { + actions: { + GET: { + client_type: { + choices: [ + ['confidential', 'Confidential'], + ['public', 'Public'], + ], + }, + authorization_grant_type: { + choices: [ + ['authorization-code', 'Authorization code'], + ['password', 'Resource owner password-based'], + ], + }, + }, + }, + }, +}; + +describe('', () => { + let wrapper; + test('should render properly', async () => { + ApplicationsAPI.readOptions.mockResolvedValue(options); + await act(async () => { + wrapper = mountWithContexts(); + }); + expect(wrapper.find('ApplicationAdd').length).toBe(1); + expect(wrapper.find('ApplicationForm').length).toBe(1); + expect(ApplicationsAPI.readOptions).toBeCalled(); + }); + + test('expect values to be updated and submitted properly', async () => { + const history = createMemoryHistory({ + initialEntries: ['/applications/add'], + }); + ApplicationsAPI.readOptions.mockResolvedValue(options); + + ApplicationsAPI.create.mockResolvedValue({ data: { id: 8 } }); + await act(async () => { + wrapper = mountWithContexts(, { + context: { router: { history } }, + }); + }); + + await act(async () => { + wrapper.find('input#name').simulate('change', { + target: { value: 'new foo', name: 'name' }, + }); + wrapper.find('input#description').simulate('change', { + target: { value: 'new bar', name: 'description' }, + }); + wrapper + .find('AnsibleSelect[name="authorization_grant_type"]') + .prop('onChange')({}, 'authorization code'); + + wrapper.find('input#redirect_uris').simulate('change', { + target: { value: 'https://www.google.com', name: 'redirect_uris' }, + }); + wrapper.find('AnsibleSelect[name="client_type"]').prop('onChange')( + {}, + 'confidential' + ); + wrapper.find('OrganizationLookup').invoke('onChange')({ + id: 1, + name: 'organization', + }); + }); + + wrapper.update(); + expect(wrapper.find('input#name').prop('value')).toBe('new foo'); + expect(wrapper.find('input#description').prop('value')).toBe('new bar'); + expect(wrapper.find('InnerChipGroup').length).toBe(1); + expect(wrapper.find('InnerChipGroup').text()).toBe('organization'); + expect( + wrapper + .find('AnsibleSelect[name="authorization_grant_type"]') + .prop('value') + ).toBe('authorization code'); + expect( + wrapper.find('AnsibleSelect[name="client_type"]').prop('value') + ).toBe('confidential'); + expect(wrapper.find('input#redirect_uris').prop('value')).toBe( + 'https://www.google.com' + ); + await act(async () => { + wrapper.find('Formik').prop('onSubmit')({ + authorization_grant_type: 'authorization-code', + client_type: 'confidential', + description: 'bar', + name: 'foo', + organization: { id: 1 }, + redirect_uris: 'http://www.google.com', + }); + }); + + expect(ApplicationsAPI.create).toBeCalledWith({ + authorization_grant_type: 'authorization-code', + client_type: 'confidential', + description: 'bar', + name: 'foo', + organization: 1, + redirect_uris: 'http://www.google.com', + }); + expect(history.location.pathname).toBe('/applications/8/details'); + }); + + test('should cancel form properly', async () => { + const history = createMemoryHistory({ + initialEntries: ['/applications/add'], + }); + ApplicationsAPI.readOptions.mockResolvedValue(options); + + ApplicationsAPI.create.mockResolvedValue({ data: { id: 8 } }); + await act(async () => { + wrapper = mountWithContexts(, { + context: { router: { history } }, + }); + }); + await act(async () => { + wrapper.find('Button[aria-label="Cancel"]').prop('onClick')(); + }); + expect(history.location.pathname).toBe('/applications'); + }); + + test('should throw error on submit', async () => { + const error = { + response: { + config: { + method: 'patch', + url: '/api/v2/applications/', + }, + data: { detail: 'An error occurred' }, + }, + }; + ApplicationsAPI.create.mockRejectedValue(error); + ApplicationsAPI.readOptions.mockResolvedValue(options); + await act(async () => { + wrapper = mountWithContexts(); + }); + await act(async () => { + wrapper.find('Formik').prop('onSubmit')({ + id: 1, + organization: { id: 1 }, + }); + }); + + waitForElement(wrapper, 'FormSubmitError', el => el.length > 0); + }); + test('should render content error on failed read options request', async () => { + ApplicationsAPI.readOptions.mockRejectedValue( + new Error({ + response: { + config: { + method: 'options', + }, + data: 'An error occurred', + status: 403, + }, + }) + ); + await act(async () => { + wrapper = mountWithContexts(); + }); + + wrapper.update(); + expect(wrapper.find('ContentError').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/Application/shared/ApplicationForm.jsx b/awx/ui_next/src/screens/Application/shared/ApplicationForm.jsx new file mode 100644 index 0000000000..2ffb2ebfb8 --- /dev/null +++ b/awx/ui_next/src/screens/Application/shared/ApplicationForm.jsx @@ -0,0 +1,177 @@ +import React from 'react'; +import { useRouteMatch } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Formik, useField } from 'formik'; +import { Form, FormGroup } from '@patternfly/react-core'; +import PropTypes from 'prop-types'; + +import { required } from '../../../util/validators'; +import FormField, { + FormSubmitError, + FieldTooltip, +} from '../../../components/FormField'; +import { FormColumnLayout } from '../../../components/FormLayout'; +import FormActionGroup from '../../../components/FormActionGroup/FormActionGroup'; +import OrganizationLookup from '../../../components/Lookup/OrganizationLookup'; +import AnsibleSelect from '../../../components/AnsibleSelect'; + +function ApplicationFormFields({ + i18n, + authorizationOptions, + clientTypeOptions, +}) { + const match = useRouteMatch(); + const [organizationField, organizationMeta, organizationHelpers] = useField({ + name: 'organization', + validate: required(null, i18n), + }); + const [ + authorizationTypeField, + authorizationTypeMeta, + authorizationTypeHelpers, + ] = useField({ + name: 'authorization_grant_type', + validate: required(null, i18n), + }); + + const [clientTypeField, clientTypeMeta, clientTypeHelpers] = useField({ + name: 'client_type', + validate: required(null, i18n), + }); + return ( + <> + + + organizationHelpers.setTouched()} + onChange={value => { + organizationHelpers.setValue(value); + }} + value={organizationField.value} + required + /> + + + { + authorizationTypeHelpers.setValue(value); + }} + /> + + + + + { + clientTypeHelpers.setValue(value); + }} + /> + + + ); +} +function ApplicationForm({ + onCancel, + onSubmit, + i18n, + submitError, + application, + authorizationOptions, + clientTypeOptions, +}) { + const initialValues = { + name: application?.name || '', + description: application?.description || '', + organization: application?.summary_fields?.organization || null, + authorization_grant_type: application?.authorization_grant_type || '', + redirect_uris: application?.redirect_uris || '', + client_type: application?.client_type || '', + }; + + return ( + onSubmit(values)}> + {formik => ( +
+ + + {submitError && } + + +
+ )} +
+ ); +} + +ApplicationForm.propTypes = { + onSubmit: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired, + authorizationOptions: PropTypes.arrayOf(PropTypes.object).isRequired, + clientTypeOptions: PropTypes.arrayOf(PropTypes.object).isRequired, +}; + +export default withI18n()(ApplicationForm); diff --git a/awx/ui_next/src/screens/Application/shared/ApplicationForm.test.jsx b/awx/ui_next/src/screens/Application/shared/ApplicationForm.test.jsx new file mode 100644 index 0000000000..406c3d5ace --- /dev/null +++ b/awx/ui_next/src/screens/Application/shared/ApplicationForm.test.jsx @@ -0,0 +1,181 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import { OrganizationsAPI } from '../../../api'; +import ApplicationForm from './ApplicationForm'; + +jest.mock('../../../api'); + +const authorizationOptions = [ + { + key: 'authorization-code', + label: 'Authorization code', + value: 'authorization-code', + }, + { + key: 'password', + label: 'Resource owner password-based', + value: 'password', + }, +]; + +const clientTypeOptions = [ + { key: 'confidential', label: 'Confidential', value: 'confidential' }, + { key: 'public', label: 'Public', value: 'public' }, +]; + +describe(' { + let wrapper; + test('should mount properly', async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} + application={{}} + onCancel={() => {}} + authorizationOptions={authorizationOptions} + clientTypeOptions={clientTypeOptions} + /> + ); + }); + expect(wrapper.find('ApplicationForm').length).toBe(1); + }); + + test('all fields should render successsfully', async () => { + OrganizationsAPI.read.mockResolvedValue({ + results: [{ id: 1 }, { id: 2 }], + }); + await act(async () => { + wrapper = mountWithContexts( + {}} + application={{}} + onCancel={() => {}} + authorizationOptions={authorizationOptions} + clientTypeOptions={clientTypeOptions} + /> + ); + }); + expect(wrapper.find('input#name').length).toBe(1); + expect(wrapper.find('input#description').length).toBe(1); + expect( + wrapper.find('AnsibleSelect[name="authorization_grant_type"]').length + ).toBe(1); + expect(wrapper.find('input#redirect_uris').length).toBe(1); + expect(wrapper.find('AnsibleSelect[name="client_type"]').length).toBe(1); + expect(wrapper.find('OrganizationLookup').length).toBe(1); + }); + + test('should update field values', async () => { + OrganizationsAPI.read.mockResolvedValue({ + results: [{ id: 1 }, { id: 2 }], + }); + await act(async () => { + wrapper = mountWithContexts( + {}} + application={{}} + onCancel={() => {}} + authorizationOptions={authorizationOptions} + clientTypeOptions={clientTypeOptions} + /> + ); + await act(async () => { + wrapper.find('input#name').simulate('change', { + target: { value: 'new foo', name: 'name' }, + }); + wrapper.find('input#description').simulate('change', { + target: { value: 'new bar', name: 'description' }, + }); + wrapper + .find('AnsibleSelect[name="authorization_grant_type"]') + .prop('onChange')({}, 'authorization-code'); + + wrapper.find('input#redirect_uris').simulate('change', { + target: { value: 'https://www.google.com', name: 'redirect_uris' }, + }); + wrapper.find('AnsibleSelect[name="client_type"]').prop('onChange')( + {}, + 'confidential' + ); + wrapper.find('OrganizationLookup').invoke('onChange')({ + id: 3, + name: 'organization', + }); + }); + }); + wrapper.update(); + expect(wrapper.find('input#name').prop('value')).toBe('new foo'); + expect(wrapper.find('input#description').prop('value')).toBe('new bar'); + expect(wrapper.find('InnerChipGroup').length).toBe(1); + expect(wrapper.find('InnerChipGroup').text()).toBe('organization'); + expect( + wrapper + .find('AnsibleSelect[name="authorization_grant_type"]') + .prop('value') + ).toBe('authorization-code'); + expect( + wrapper.find('AnsibleSelect[name="client_type"]').prop('value') + ).toBe('confidential'); + expect( + wrapper.find('FormField[label="Redirect URIs"]').prop('isRequired') + ).toBe(true); + expect(wrapper.find('input#redirect_uris').prop('value')).toBe( + 'https://www.google.com' + ); + }); + test('should call onCancel', async () => { + OrganizationsAPI.read.mockResolvedValue({ + results: [{ id: 1 }, { id: 2 }], + }); + const onSubmit = jest.fn(); + const onCancel = jest.fn(); + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + wrapper.find('Button[aria-label="Cancel"]').prop('onClick')(); + expect(onCancel).toBeCalled(); + }); + test('should call onSubmit', async () => { + OrganizationsAPI.read.mockResolvedValue({ + results: [{ id: 1 }, { id: 2 }], + }); + const onSubmit = jest.fn(); + const onCancel = jest.fn(); + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + wrapper.find('Formik').prop('onSubmit')({ + authorization_grant_type: 'authorization-code', + client_type: 'confidential', + description: 'bar', + name: 'foo', + organization: 1, + redirect_uris: 'http://www.google.com', + }); + expect(onSubmit).toBeCalledWith({ + authorization_grant_type: 'authorization-code', + client_type: 'confidential', + description: 'bar', + name: 'foo', + organization: 1, + redirect_uris: 'http://www.google.com', + }); + }); +});