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 => ( +
+ + + {submitError && } + + +
+ )} +
+ ); +} + +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(); + }); +});