From adffa293461a42a87259e8cd4b88162baa7f61ed Mon Sep 17 00:00:00 2001 From: nixocio Date: Thu, 20 Aug 2020 17:46:28 -0400 Subject: [PATCH] Add/Edit Container Groups Add/Edit container groups. See: https://github.com/ansible/awx/issues/7955 --- .../screens/InstanceGroup/ContainerGroup.jsx | 2 +- .../ContainerGroupAdd/ContainerGroupAdd.jsx | 96 ++++++++++- .../ContainerGroupAdd.test.jsx | 94 +++++++++++ .../ContainerGroupEdit/ContainerGroupEdit.jsx | 89 +++++++++- .../ContainerGroupEdit.test.jsx | 155 ++++++++++++++++++ .../shared/ContainerGroupForm.jsx | 144 ++++++++++++++++ .../shared/ContainerGroupForm.test.jsx | 141 ++++++++++++++++ 7 files changed, 711 insertions(+), 10 deletions(-) create mode 100644 awx/ui_next/src/screens/InstanceGroup/ContainerGroupAdd/ContainerGroupAdd.test.jsx create mode 100644 awx/ui_next/src/screens/InstanceGroup/ContainerGroupEdit/ContainerGroupEdit.test.jsx create mode 100644 awx/ui_next/src/screens/InstanceGroup/shared/ContainerGroupForm.jsx create mode 100644 awx/ui_next/src/screens/InstanceGroup/shared/ContainerGroupForm.test.jsx diff --git a/awx/ui_next/src/screens/InstanceGroup/ContainerGroup.jsx b/awx/ui_next/src/screens/InstanceGroup/ContainerGroup.jsx index 265c27c379..7b9454e721 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 => ( +
+ + + {submitError && } + + +
+ )} +
+ ); +} + +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(); + }); +});