diff --git a/awx/ui_next/src/screens/InstanceGroup/ContainerGroup.jsx b/awx/ui_next/src/screens/InstanceGroup/ContainerGroup.jsx
index db62e9506e..424a64fb1a 100644
--- a/awx/ui_next/src/screens/InstanceGroup/ContainerGroup.jsx
+++ b/awx/ui_next/src/screens/InstanceGroup/ContainerGroup.jsx
@@ -111,7 +111,7 @@ function ContainerGroup({ i18n, setBreadcrumb }) {
{instanceGroup && (
<>
-
+
diff --git a/awx/ui_next/src/screens/InstanceGroup/ContainerGroupAdd/ContainerGroupAdd.jsx b/awx/ui_next/src/screens/InstanceGroup/ContainerGroupAdd/ContainerGroupAdd.jsx
index f4dd75787e..6dffae83d3 100644
--- a/awx/ui_next/src/screens/InstanceGroup/ContainerGroupAdd/ContainerGroupAdd.jsx
+++ b/awx/ui_next/src/screens/InstanceGroup/ContainerGroupAdd/ContainerGroupAdd.jsx
@@ -1,11 +1,103 @@
-import React from 'react';
+import React, { useState, useEffect, useCallback } from 'react';
import { Card, PageSection } from '@patternfly/react-core';
+import { useHistory } from 'react-router-dom';
+
+import { CardBody } from '../../../components/Card';
+import { InstanceGroupsAPI } from '../../../api';
+import useRequest from '../../../util/useRequest';
+import ContentError from '../../../components/ContentError';
+import ContentLoading from '../../../components/ContentLoading';
+import { jsonToYaml, isJsonString } from '../../../util/yaml';
+
+import ContainerGroupForm from '../shared/ContainerGroupForm';
function ContainerGroupAdd() {
+ const history = useHistory();
+ const [submitError, setSubmitError] = useState(null);
+
+ const getPodSpecValue = value => {
+ if (isJsonString(value)) {
+ value = jsonToYaml(value);
+ }
+ if (value !== jsonToYaml(JSON.stringify(initialPodSpec))) {
+ return value;
+ }
+ return null;
+ };
+
+ const handleSubmit = async values => {
+ try {
+ const { data: response } = await InstanceGroupsAPI.create({
+ name: values.name,
+ credential: values?.credential?.id,
+ pod_spec_override: values.override
+ ? getPodSpecValue(values.pod_spec_override)
+ : null,
+ });
+ history.push(`/instance_groups/container_group/${response.id}/details`);
+ } catch (error) {
+ setSubmitError(error);
+ }
+ };
+
+ const handleCancel = () => {
+ history.push(`/instance_groups`);
+ };
+
+ const {
+ error: fetchError,
+ isLoading,
+ request: fetchInitialPodSpec,
+ result: initialPodSpec,
+ } = useRequest(
+ useCallback(async () => {
+ const { data } = await InstanceGroupsAPI.readOptions();
+ return data.actions.POST.pod_spec_override.default;
+ }, []),
+ {
+ initialPodSpec: {},
+ }
+ );
+
+ useEffect(() => {
+ fetchInitialPodSpec();
+ }, [fetchInitialPodSpec]);
+
+ if (fetchError) {
+ return (
+
+
+
+
+
+
+
+ );
+ }
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+
+
+ );
+ }
+
return (
- Add container group
+
+
+
);
diff --git a/awx/ui_next/src/screens/InstanceGroup/ContainerGroupAdd/ContainerGroupAdd.test.jsx b/awx/ui_next/src/screens/InstanceGroup/ContainerGroupAdd/ContainerGroupAdd.test.jsx
new file mode 100644
index 0000000000..b8bbb301ab
--- /dev/null
+++ b/awx/ui_next/src/screens/InstanceGroup/ContainerGroupAdd/ContainerGroupAdd.test.jsx
@@ -0,0 +1,94 @@
+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 ContainerGroupAdd from './ContainerGroupAdd';
+
+jest.mock('../../../api');
+
+const initialPodSpec = {
+ default: {
+ apiVersion: 'v1',
+ kind: 'Pod',
+ metadata: {
+ namespace: 'default',
+ },
+ spec: {
+ containers: [
+ {
+ image: 'ansible/ansible-runner',
+ tty: true,
+ stdin: true,
+ imagePullPolicy: 'Always',
+ args: ['sleep', 'infinity'],
+ },
+ ],
+ },
+ },
+};
+
+const instanceGroupCreateData = {
+ name: 'Fuz',
+ credential: { id: 71, name: 'CG' },
+ pod_spec_override:
+ 'apiVersion: v1\nkind: Pod\nmetadata:\n namespace: default\nspec:\n containers:\n - image: ansible/ansible-runner\n tty: true\n stdin: true\n imagePullPolicy: Always\n args:\n - sleep\n - infinity\n - test',
+};
+
+InstanceGroupsAPI.create.mockResolvedValue({
+ data: {
+ id: 123,
+ },
+});
+
+InstanceGroupsAPI.readOptions.mockResolvedValue({
+ data: {
+ results: initialPodSpec,
+ },
+});
+
+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('ContainerGroupForm').prop('onSubmit')({
+ ...instanceGroupCreateData,
+ override: true,
+ });
+ });
+ wrapper.update();
+ expect(InstanceGroupsAPI.create).toHaveBeenCalledWith({
+ ...instanceGroupCreateData,
+ credential: 71,
+ });
+ expect(wrapper.find('FormSubmitError').length).toBe(0);
+ expect(history.location.pathname).toBe(
+ '/instance_groups/container_group/123/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');
+ });
+});
diff --git a/awx/ui_next/src/screens/InstanceGroup/ContainerGroupEdit/ContainerGroupEdit.jsx b/awx/ui_next/src/screens/InstanceGroup/ContainerGroupEdit/ContainerGroupEdit.jsx
index 5df56a03f3..3bf66a9d4d 100644
--- a/awx/ui_next/src/screens/InstanceGroup/ContainerGroupEdit/ContainerGroupEdit.jsx
+++ b/awx/ui_next/src/screens/InstanceGroup/ContainerGroupEdit/ContainerGroupEdit.jsx
@@ -1,13 +1,88 @@
-import React from 'react';
+import React, { useState, useEffect, useCallback } from 'react';
+import { useHistory } from 'react-router-dom';
import { Card, PageSection } from '@patternfly/react-core';
-function ContainerGroupEdit() {
+import { CardBody } from '../../../components/Card';
+import { InstanceGroupsAPI } from '../../../api';
+import ContainerGroupForm from '../shared/ContainerGroupForm';
+import useRequest from '../../../util/useRequest';
+import ContentError from '../../../components/ContentError';
+import ContentLoading from '../../../components/ContentLoading';
+
+function ContainerGroupEdit({ instanceGroup }) {
+ const history = useHistory();
+ const [submitError, setSubmitError] = useState(null);
+ const detailsIUrl = `/instance_groups/container_group/${instanceGroup.id}/details`;
+
+ const {
+ error: fetchError,
+ isLoading,
+ request: fetchInitialPodSpec,
+ result: initialPodSpec,
+ } = useRequest(
+ useCallback(async () => {
+ const { data } = await InstanceGroupsAPI.readOptions();
+ return data.actions.POST.pod_spec_override.default;
+ }, []),
+ {
+ initialPodSpec: {},
+ }
+ );
+
+ useEffect(() => {
+ fetchInitialPodSpec();
+ }, [fetchInitialPodSpec]);
+
+ const handleSubmit = async values => {
+ try {
+ await InstanceGroupsAPI.update(instanceGroup.id, {
+ name: values.name,
+ credential: values.credential.id,
+ pod_spec_override: values.override ? values.pod_spec_override : null,
+ });
+ history.push(detailsIUrl);
+ } catch (error) {
+ setSubmitError(error);
+ }
+ };
+ const handleCancel = () => {
+ history.push(detailsIUrl);
+ };
+
+ if (fetchError) {
+ return (
+
+
+
+
+
+
+
+ );
+ }
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+
+
+ );
+ }
+
return (
-
-
- Edit container group
-
-
+
+
+
);
}
diff --git a/awx/ui_next/src/screens/InstanceGroup/ContainerGroupEdit/ContainerGroupEdit.test.jsx b/awx/ui_next/src/screens/InstanceGroup/ContainerGroupEdit/ContainerGroupEdit.test.jsx
new file mode 100644
index 0000000000..d63996dde8
--- /dev/null
+++ b/awx/ui_next/src/screens/InstanceGroup/ContainerGroupEdit/ContainerGroupEdit.test.jsx
@@ -0,0 +1,155 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { createMemoryHistory } from 'history';
+
+import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
+import { InstanceGroupsAPI, CredentialsAPI } from '../../../api';
+import ContainerGroupEdit from './ContainerGroupEdit';
+
+jest.mock('../../../api');
+
+const instanceGroup = {
+ id: 123,
+ type: 'instance_group',
+ url: '/api/v2/instance_groups/123/',
+ related: {
+ named_url: '/api/v2/instance_groups/Foo/',
+ jobs: '/api/v2/instance_groups/123/jobs/',
+ instances: '/api/v2/instance_groups/123/instances/',
+ credential: '/api/v2/credentials/71/',
+ },
+ name: 'Foo',
+ created: '2020-09-02T17:20:01.214170Z',
+ modified: '2020-09-02T17:20:01.214236Z',
+ capacity: 0,
+ committed_capacity: 0,
+ consumed_capacity: 0,
+ percent_capacity_remaining: 0.0,
+ jobs_running: 0,
+ jobs_total: 0,
+ instances: 0,
+ controller: null,
+ is_controller: false,
+ is_isolated: false,
+ is_containerized: true,
+ credential: 71,
+ policy_instance_percentage: 0,
+ policy_instance_minimum: 0,
+ policy_instance_list: [],
+ pod_spec_override: '',
+ summary_fields: {
+ credential: {
+ id: 71,
+ name: 'CG',
+ description: 'a',
+ kind: 'kubernetes_bearer_token',
+ cloud: false,
+ kubernetes: true,
+ credential_type_id: 17,
+ },
+ user_capabilities: {
+ edit: true,
+ delete: true,
+ },
+ },
+};
+
+const updatedInstanceGroup = {
+ name: 'Bar',
+ credential: { id: 12, name: 'CGX' },
+};
+
+const initialPodSpec = {
+ default: {
+ apiVersion: 'v1',
+ kind: 'Pod',
+ metadata: {
+ namespace: 'default',
+ },
+ spec: {
+ containers: [
+ {
+ image: 'ansible/ansible-runner',
+ tty: true,
+ stdin: true,
+ imagePullPolicy: 'Always',
+ args: ['sleep', 'infinity'],
+ },
+ ],
+ },
+ },
+};
+
+InstanceGroupsAPI.readOptions.mockResolvedValue({
+ data: {
+ results: initialPodSpec,
+ },
+});
+
+CredentialsAPI.read.mockResolvedValue({
+ data: {
+ results: [
+ {
+ id: 71,
+ name: 'Test',
+ },
+ ],
+ },
+});
+
+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('initially renders successfully', async () => {
+ expect(wrapper.find('ContainerGroupEdit').length).toBe(1);
+ });
+
+ test('called InstanceGroupsAPI.readOptions', async () => {
+ expect(InstanceGroupsAPI.readOptions).toHaveBeenCalledTimes(1);
+ });
+
+ test('handleCancel returns the user to container group detail', async () => {
+ await act(async () => {
+ wrapper.find('Button[aria-label="Cancel"]').simulate('click');
+ });
+ expect(history.location.pathname).toEqual(
+ '/instance_groups/container_group/123/details'
+ );
+ });
+
+ test('handleSubmit should call the api and redirect to details page', async () => {
+ await act(async () => {
+ wrapper.find('ContainerGroupForm').prop('onSubmit')({
+ ...updatedInstanceGroup,
+ override: false,
+ });
+ });
+ wrapper.update();
+ expect(InstanceGroupsAPI.update).toHaveBeenCalledWith(123, {
+ ...updatedInstanceGroup,
+ credential: 12,
+ pod_spec_override: null,
+ });
+ expect(history.location.pathname).toEqual(
+ '/instance_groups/container_group/123/details'
+ );
+ });
+});
diff --git a/awx/ui_next/src/screens/InstanceGroup/shared/ContainerGroupForm.jsx b/awx/ui_next/src/screens/InstanceGroup/shared/ContainerGroupForm.jsx
new file mode 100644
index 0000000000..472e1b4ca2
--- /dev/null
+++ b/awx/ui_next/src/screens/InstanceGroup/shared/ContainerGroupForm.jsx
@@ -0,0 +1,144 @@
+import React from 'react';
+import { func, shape } from 'prop-types';
+import { Formik, useField } from 'formik';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { Form, FormGroup } from '@patternfly/react-core';
+import { jsonToYaml } from '../../../util/yaml';
+
+import FormField, {
+ FormSubmitError,
+ CheckboxField,
+} from '../../../components/FormField';
+import FormActionGroup from '../../../components/FormActionGroup';
+import { required } from '../../../util/validators';
+import {
+ FormColumnLayout,
+ FormFullWidthLayout,
+ FormCheckboxLayout,
+ SubFormLayout,
+} from '../../../components/FormLayout';
+import CredentialLookup from '../../../components/Lookup/CredentialLookup';
+import { VariablesField } from '../../../components/CodeMirrorInput';
+
+function ContainerGroupFormFields({ i18n }) {
+ const [credentialField, credentialMeta, credentialHelpers] = useField(
+ 'credential'
+ );
+ const [overrideField] = useField('override');
+
+ return (
+ <>
+
+ credentialHelpers.setTouched()}
+ onChange={value => {
+ credentialHelpers.setValue(value);
+ }}
+ value={credentialField.value}
+ required
+ tooltip={i18n._(
+ t`Credential to authenticate with Kubernetes or OpenShift. Must be of type "Kubernetes/OpenShift API Bearer Token”.`
+ )}
+ />
+
+
+
+
+
+
+
+ {overrideField.value && (
+
+
+
+
+
+ )}
+ >
+ );
+}
+
+function ContainerGroupForm({
+ initialPodSpec,
+ instanceGroup,
+ onSubmit,
+ onCancel,
+ submitError,
+ ...rest
+}) {
+ const isCheckboxChecked = Boolean(instanceGroup?.pod_spec_override) || false;
+
+ const initialValues = {
+ name: instanceGroup?.name || '',
+ credential: instanceGroup?.summary_fields?.credential,
+ pod_spec_override: isCheckboxChecked
+ ? instanceGroup?.pod_spec_override
+ : jsonToYaml(JSON.stringify(initialPodSpec)),
+ override: isCheckboxChecked,
+ };
+
+ return (
+ {
+ onSubmit(values);
+ }}
+ >
+ {formik => (
+
+ )}
+
+ );
+}
+
+ContainerGroupForm.propTypes = {
+ instanceGroup: shape({}),
+ onCancel: func.isRequired,
+ onSubmit: func.isRequired,
+ submitError: shape({}),
+ initialPodSpec: shape({}),
+};
+
+ContainerGroupForm.defaultProps = {
+ instanceGroup: {},
+ submitError: null,
+ initialPodSpec: {},
+};
+
+export default withI18n()(ContainerGroupForm);
diff --git a/awx/ui_next/src/screens/InstanceGroup/shared/ContainerGroupForm.test.jsx b/awx/ui_next/src/screens/InstanceGroup/shared/ContainerGroupForm.test.jsx
new file mode 100644
index 0000000000..be034ddc12
--- /dev/null
+++ b/awx/ui_next/src/screens/InstanceGroup/shared/ContainerGroupForm.test.jsx
@@ -0,0 +1,141 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
+
+import ContainerGroupForm from './ContainerGroupForm';
+
+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,
+ },
+ },
+};
+
+const initialPodSpec = {
+ default: {
+ apiVersion: 'v1',
+ kind: 'Pod',
+ metadata: {
+ namespace: 'default',
+ },
+ spec: {
+ containers: [
+ {
+ image: 'ansible/ansible-runner',
+ tty: true,
+ stdin: true,
+ imagePullPolicy: 'Always',
+ args: ['sleep', 'infinity'],
+ },
+ ],
+ },
+ },
+};
+
+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('VariablesField[label="Custom pod spec"]').length).toBe(
+ 0
+ );
+ expect(
+ wrapper
+ .find('Checkbox[aria-label="Customize pod specification"]')
+ .prop('isChecked')
+ ).toBeFalsy();
+ expect(wrapper.find('CredentialLookup').prop('value')).toBeFalsy();
+ });
+
+ test('should update form values', () => {
+ act(() => {
+ wrapper.find('CredentialLookup').invoke('onBlur')();
+ wrapper.find('CredentialLookup').invoke('onChange')({
+ id: 99,
+ name: 'credential',
+ });
+ wrapper.find('TextInputBase#container-group-name').simulate('change', {
+ target: { value: 'new Foo', name: 'name' },
+ });
+ });
+ wrapper.update();
+ expect(wrapper.find('CredentialLookup').prop('value')).toEqual({
+ id: 99,
+ name: 'credential',
+ });
+ expect(
+ wrapper.find('TextInputBase#container-group-name').prop('value')
+ ).toEqual('new Foo');
+ });
+
+ 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 call handleCancel when Cancel button is clicked', async () => {
+ expect(onCancel).not.toHaveBeenCalled();
+ wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
+ expect(onCancel).toBeCalled();
+ });
+});