diff --git a/awx/ui_next/src/screens/InstanceGroup/InstanceGroupAdd/InstanceGroupAdd.jsx b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupAdd/InstanceGroupAdd.jsx
index f80b53be8e..3f14129826 100644
--- a/awx/ui_next/src/screens/InstanceGroup/InstanceGroupAdd/InstanceGroupAdd.jsx
+++ b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupAdd/InstanceGroupAdd.jsx
@@ -1,11 +1,38 @@
-import React from 'react';
+import React, { useState } from 'react';
import { Card, PageSection } from '@patternfly/react-core';
+import { useHistory } from 'react-router-dom';
+
+import InstanceGroupForm from '../shared/InstanceGroupForm';
+import { CardBody } from '../../../components/Card';
+import { InstanceGroupsAPI } from '../../../api';
function InstanceGroupAdd() {
+ const history = useHistory();
+ const [submitError, setSubmitError] = useState(null);
+
+ const handleSubmit = async values => {
+ try {
+ const { data: response } = await InstanceGroupsAPI.create(values);
+ history.push(`/instance_groups/${response.id}/details`);
+ } catch (error) {
+ setSubmitError(error);
+ }
+ };
+
+ const handleCancel = () => {
+ history.push(`/instance_groups`);
+ };
+
return (
- Add instance group
+
+
+
);
diff --git a/awx/ui_next/src/screens/InstanceGroup/InstanceGroupAdd/InstanceGroupAdd.test.jsx b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupAdd/InstanceGroupAdd.test.jsx
new file mode 100644
index 0000000000..4b2d879398
--- /dev/null
+++ b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupAdd/InstanceGroupAdd.test.jsx
@@ -0,0 +1,101 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { createMemoryHistory } from 'history';
+
+import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
+import { InstanceGroupsAPI } from '../../../api';
+import InstanceGroupAdd from './InstanceGroupAdd';
+
+jest.mock('../../../api');
+
+const instanceGroupData = {
+ id: 42,
+ type: 'instance_group',
+ url: '/api/v2/instance_groups/42/',
+ related: {
+ jobs: '/api/v2/instance_groups/42/jobs/',
+ instances: '/api/v2/instance_groups/7/instances/',
+ },
+ name: 'Bar',
+ created: '2020-07-21T18:41:02.818081Z',
+ modified: '2020-07-24T20:32:03.121079Z',
+ capacity: 24,
+ committed_capacity: 0,
+ consumed_capacity: 0,
+ percent_capacity_remaining: 100.0,
+ jobs_running: 0,
+ jobs_total: 0,
+ instances: 1,
+ controller: null,
+ is_controller: false,
+ is_isolated: false,
+ is_containerized: false,
+ credential: null,
+ policy_instance_percentage: 46,
+ policy_instance_minimum: 12,
+ policy_instance_list: [],
+ pod_spec_override: '',
+ summary_fields: {
+ user_capabilities: {
+ edit: true,
+ delete: true,
+ },
+ },
+};
+
+InstanceGroupsAPI.create.mockResolvedValue({
+ data: {
+ id: 42,
+ },
+});
+
+describe('', () => {
+ let wrapper;
+ let history;
+
+ beforeEach(async () => {
+ history = createMemoryHistory({
+ initialEntries: ['/instance_groups'],
+ });
+ await act(async () => {
+ wrapper = mountWithContexts(, {
+ context: { router: { history } },
+ });
+ });
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ wrapper.unmount();
+ });
+
+ test('handleSubmit should call the api and redirect to details page', async () => {
+ await act(async () => {
+ wrapper.find('InstanceGroupForm').prop('onSubmit')(instanceGroupData);
+ });
+ wrapper.update();
+ expect(InstanceGroupsAPI.create).toHaveBeenCalledWith(instanceGroupData);
+ expect(history.location.pathname).toBe('/instance_groups/42/details');
+ });
+
+ test('handleCancel should return the user back to the instance group list', async () => {
+ wrapper.find('Button[aria-label="Cancel"]').simulate('click');
+ expect(history.location.pathname).toEqual('/instance_groups');
+ });
+
+ test('failed form submission should show an error message', async () => {
+ const error = {
+ response: {
+ data: { detail: 'An error occurred' },
+ },
+ };
+ InstanceGroupsAPI.create.mockImplementationOnce(() =>
+ Promise.reject(error)
+ );
+ await act(async () => {
+ wrapper.find('InstanceGroupForm').invoke('onSubmit')(instanceGroupData);
+ });
+ wrapper.update();
+ expect(wrapper.find('FormSubmitError').length).toBe(1);
+ });
+});
diff --git a/awx/ui_next/src/screens/InstanceGroup/shared/InstanceGroupForm.jsx b/awx/ui_next/src/screens/InstanceGroup/shared/InstanceGroupForm.jsx
new file mode 100644
index 0000000000..a2477d2f53
--- /dev/null
+++ b/awx/ui_next/src/screens/InstanceGroup/shared/InstanceGroupForm.jsx
@@ -0,0 +1,92 @@
+import React from 'react';
+import { func, shape } from 'prop-types';
+import { Formik } from 'formik';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { Form } from '@patternfly/react-core';
+
+import FormField, { FormSubmitError } from '../../../components/FormField';
+import FormActionGroup from '../../../components/FormActionGroup';
+import { required, minMaxValue } from '../../../util/validators';
+import { FormColumnLayout } from '../../../components/FormLayout';
+
+function InstanceGroupFormFields({ i18n }) {
+ return (
+ <>
+
+
+
+ >
+ );
+}
+
+function InstanceGroupForm({
+ instanceGroup = {},
+ onSubmit,
+ onCancel,
+ submitError,
+ ...rest
+}) {
+ const initialValues = {
+ name: instanceGroup.name || '',
+ policy_instance_minimum: instanceGroup.policy_instance_minimum || 0,
+ policy_instance_percentage: instanceGroup.policy_instance_percentage || 0,
+ };
+ return (
+ onSubmit(values)}>
+ {formik => (
+
+ )}
+
+ );
+}
+
+InstanceGroupForm.propTypes = {
+ instanceGroup: shape({}),
+ onCancel: func.isRequired,
+ onSubmit: func.isRequired,
+ submitError: shape({}),
+};
+
+InstanceGroupForm.defaultProps = {
+ instanceGroup: {},
+ submitError: null,
+};
+
+export default withI18n()(InstanceGroupForm);
diff --git a/awx/ui_next/src/screens/InstanceGroup/shared/InstanceGroupForm.test.jsx b/awx/ui_next/src/screens/InstanceGroup/shared/InstanceGroupForm.test.jsx
new file mode 100644
index 0000000000..233ce7f849
--- /dev/null
+++ b/awx/ui_next/src/screens/InstanceGroup/shared/InstanceGroupForm.test.jsx
@@ -0,0 +1,120 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
+
+import InstanceGroupForm from './InstanceGroupForm';
+
+jest.mock('../../../api');
+
+const instanceGroup = {
+ id: 7,
+ type: 'instance_group',
+ url: '/api/v2/instance_groups/7/',
+ related: {
+ jobs: '/api/v2/instance_groups/7/jobs/',
+ instances: '/api/v2/instance_groups/7/instances/',
+ },
+ name: 'Bar',
+ created: '2020-07-21T18:41:02.818081Z',
+ modified: '2020-07-24T20:32:03.121079Z',
+ capacity: 24,
+ committed_capacity: 0,
+ consumed_capacity: 0,
+ percent_capacity_remaining: 100.0,
+ jobs_running: 0,
+ jobs_total: 0,
+ instances: 1,
+ controller: null,
+ is_controller: false,
+ is_isolated: false,
+ is_containerized: false,
+ credential: null,
+ policy_instance_percentage: 46,
+ policy_instance_minimum: 12,
+ policy_instance_list: [],
+ pod_spec_override: '',
+ summary_fields: {
+ user_capabilities: {
+ edit: true,
+ delete: true,
+ },
+ },
+};
+
+describe('', () => {
+ let wrapper;
+ let onCancel;
+ let onSubmit;
+
+ beforeEach(async () => {
+ onCancel = jest.fn();
+ onSubmit = jest.fn();
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ wrapper.unmount();
+ });
+
+ test('Initially renders successfully', () => {
+ expect(wrapper.length).toBe(1);
+ });
+
+ test('should display form fields properly', () => {
+ expect(wrapper.find('FormGroup[label="Name"]').length).toBe(1);
+ expect(
+ wrapper.find('FormGroup[label="Policy instance minimum"]').length
+ ).toBe(1);
+ expect(
+ wrapper.find('FormGroup[label="Policy instance percentage"]').length
+ ).toBe(1);
+ });
+
+ test('should call onSubmit when form submitted', async () => {
+ expect(onSubmit).not.toHaveBeenCalled();
+ await act(async () => {
+ wrapper.find('button[aria-label="Save"]').simulate('click');
+ });
+ expect(onSubmit).toHaveBeenCalledTimes(1);
+ });
+
+ test('should update form values', () => {
+ act(() => {
+ wrapper.find('input#instance-group-name').simulate('change', {
+ target: { value: 'Foo', name: 'name' },
+ });
+ wrapper
+ .find('input#instance-group-policy-instance-minimum')
+ .simulate('change', {
+ target: { value: 10, name: 'policy_instance_minimum' },
+ });
+ });
+ wrapper.update();
+ expect(wrapper.find('input#instance-group-name').prop('value')).toEqual(
+ 'Foo'
+ );
+ expect(
+ wrapper.find('input#instance-group-policy-instance-minimum').prop('value')
+ ).toEqual(10);
+ expect(
+ wrapper
+ .find('input#instance-group-policy-instance-percentage')
+ .prop('value')
+ ).toEqual(46);
+ });
+
+ test('should call handleCancel when Cancel button is clicked', async () => {
+ expect(onCancel).not.toHaveBeenCalled();
+ wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
+ expect(onCancel).toBeCalled();
+ });
+});