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 => (
+
+ )}
+
+ );
+}
+
+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',
+ });
+ });
+});