From ad10f3581e9a1076403496f3075e68f02b53c2c8 Mon Sep 17 00:00:00 2001 From: nixocio Date: Thu, 18 Jun 2020 10:12:08 -0400 Subject: [PATCH] Add feature to add Credential Type Add feature to add Credential Type. See: https://github.com/ansible/awx/issues/7325 --- .../CredentialAdd/CredentialAdd.test.jsx | 2 +- .../CredentialTypeAdd/CredentialTypeAdd.jsx | 37 ++++- .../CredentialTypeAdd.test.jsx | 98 +++++++++++++ .../shared/CredentialTypeForm.jsx | 101 ++++++++++++++ .../shared/CredentialTypeForm.test.jsx | 132 ++++++++++++++++++ .../screens/CredentialType/shared/data.json | 62 ++++++++ .../screens/CredentialType/shared/index.js | 1 + .../src/screens/Host/HostAdd/HostAdd.jsx | 3 +- awx/ui_next/src/util/yaml.js | 13 ++ awx/ui_next/src/util/yaml.test.js | 43 +++++- 10 files changed, 487 insertions(+), 5 deletions(-) create mode 100644 awx/ui_next/src/screens/CredentialType/CredentialTypeAdd/CredentialTypeAdd.test.jsx create mode 100644 awx/ui_next/src/screens/CredentialType/shared/CredentialTypeForm.jsx create mode 100644 awx/ui_next/src/screens/CredentialType/shared/CredentialTypeForm.test.jsx create mode 100644 awx/ui_next/src/screens/CredentialType/shared/data.json create mode 100644 awx/ui_next/src/screens/CredentialType/shared/index.js diff --git a/awx/ui_next/src/screens/Credential/CredentialAdd/CredentialAdd.test.jsx b/awx/ui_next/src/screens/Credential/CredentialAdd/CredentialAdd.test.jsx index 41d78f6a7d..6d77aeba8b 100644 --- a/awx/ui_next/src/screens/Credential/CredentialAdd/CredentialAdd.test.jsx +++ b/awx/ui_next/src/screens/Credential/CredentialAdd/CredentialAdd.test.jsx @@ -179,7 +179,7 @@ describe('', () => { expect(history.location.pathname).toBe('/credentials/13/details'); }); - test('handleCancel should return the user back to the inventories list', async () => { + test('handleCancel should return the user back to the credentials list', async () => { await waitForElement(wrapper, 'isLoading', el => el.length === 0); wrapper.find('Button[aria-label="Cancel"]').simulate('click'); expect(history.location.pathname).toEqual('/credentials'); diff --git a/awx/ui_next/src/screens/CredentialType/CredentialTypeAdd/CredentialTypeAdd.jsx b/awx/ui_next/src/screens/CredentialType/CredentialTypeAdd/CredentialTypeAdd.jsx index 4f3907e7bd..6ec7f4781d 100644 --- a/awx/ui_next/src/screens/CredentialType/CredentialTypeAdd/CredentialTypeAdd.jsx +++ b/awx/ui_next/src/screens/CredentialType/CredentialTypeAdd/CredentialTypeAdd.jsx @@ -1,11 +1,44 @@ -import React from 'react'; +import React, { useState } from 'react'; import { Card, PageSection } from '@patternfly/react-core'; +import { useHistory } from 'react-router-dom'; + +import CredentialTypeForm from '../shared/CredentialTypeForm'; +import { CardBody } from '../../../components/Card'; +import { CredentialTypesAPI } from '../../../api'; +import { parseVariableField } from '../../../util/yaml'; function CredentialTypeAdd() { + const history = useHistory(); + const [submitError, setSubmitError] = useState(null); + + const handleSubmit = async values => { + try { + const { data: response } = await CredentialTypesAPI.create({ + ...values, + injectors: parseVariableField(values.injectors), + inputs: parseVariableField(values.inputs), + kind: 'cloud', + }); + history.push(`/credential_types/${response.id}/details`); + } catch (error) { + setSubmitError(error); + } + }; + + const handleCancel = () => { + history.push(`/credential_types`); + }; + return ( -
Credentials Type Add
+ + +
); diff --git a/awx/ui_next/src/screens/CredentialType/CredentialTypeAdd/CredentialTypeAdd.test.jsx b/awx/ui_next/src/screens/CredentialType/CredentialTypeAdd/CredentialTypeAdd.test.jsx new file mode 100644 index 0000000000..d6f990c715 --- /dev/null +++ b/awx/ui_next/src/screens/CredentialType/CredentialTypeAdd/CredentialTypeAdd.test.jsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; + +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import { CredentialTypesAPI } from '../../../api'; +import CredentialTypeAdd from './CredentialTypeAdd'; + +jest.mock('../../../api'); + +const credentialTypeData = { + name: 'Foo', + description: 'Bar', + kind: 'cloud', + inputs: JSON.stringify({ + fields: [ + { + id: 'username', + type: 'string', + label: 'Jenkins username', + }, + { + id: 'password', + type: 'string', + label: 'Jenkins password', + secret: true, + }, + ], + required: ['username', 'password'], + }), + injectors: JSON.stringify({ + extra_vars: { + Jenkins_password: '{{ password }}', + Jenkins_username: '{{ username }}', + }, + }), +}; + +CredentialTypesAPI.create.mockResolvedValue({ + data: { + id: 42, + }, +}); + +describe('', () => { + let wrapper; + let history; + + beforeEach(async () => { + history = createMemoryHistory({ + initialEntries: ['/credential_types'], + }); + 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('CredentialTypeForm').prop('onSubmit')(credentialTypeData); + }); + wrapper.update(); + expect(CredentialTypesAPI.create).toHaveBeenCalledWith({ + ...credentialTypeData, + inputs: JSON.parse(credentialTypeData.inputs), + injectors: JSON.parse(credentialTypeData.injectors), + }); + expect(history.location.pathname).toBe('/credential_types/42/details'); + }); + + test('handleCancel should return the user back to the credential types list', async () => { + wrapper.find('Button[aria-label="Cancel"]').simulate('click'); + expect(history.location.pathname).toEqual('/credential_types'); + }); + + test('failed form submission should show an error message', async () => { + const error = { + response: { + data: { detail: 'An error occurred' }, + }, + }; + CredentialTypesAPI.create.mockImplementationOnce(() => + Promise.reject(error) + ); + await act(async () => { + wrapper.find('CredentialTypeForm').invoke('onSubmit')(credentialTypeData); + }); + wrapper.update(); + expect(wrapper.find('FormSubmitError').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/CredentialType/shared/CredentialTypeForm.jsx b/awx/ui_next/src/screens/CredentialType/shared/CredentialTypeForm.jsx new file mode 100644 index 0000000000..6f86ad2462 --- /dev/null +++ b/awx/ui_next/src/screens/CredentialType/shared/CredentialTypeForm.jsx @@ -0,0 +1,101 @@ +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 { VariablesField } from '../../../components/CodeMirrorInput'; +import FormField, { FormSubmitError } from '../../../components/FormField'; +import FormActionGroup from '../../../components/FormActionGroup'; +import { required } from '../../../util/validators'; +import { + FormColumnLayout, + FormFullWidthLayout, +} from '../../../components/FormLayout'; + +function CredentialTypeFormFields({ i18n }) { + return ( + <> + + + + + + + + + + ); +} + +function CredentialTypeForm({ + credentialType = {}, + onSubmit, + onCancel, + submitError, + ...rest +}) { + const initialValues = { + name: credentialType.name || '', + description: credentialType.description || '', + inputs: credentialType.inputs || '---', + injectors: credentialType.injectors || '---', + }; + return ( + onSubmit(values)}> + {formik => ( +
+ + + + + +
+ )} +
+ ); +} + +CredentialTypeForm.propTypes = { + credentialType: shape({}), + onCancel: func.isRequired, + onSubmit: func.isRequired, + submitError: shape({}), +}; + +CredentialTypeForm.defaultProps = { + credentialType: {}, + submitError: null, +}; + +export default withI18n()(CredentialTypeForm); diff --git a/awx/ui_next/src/screens/CredentialType/shared/CredentialTypeForm.test.jsx b/awx/ui_next/src/screens/CredentialType/shared/CredentialTypeForm.test.jsx new file mode 100644 index 0000000000..4683ec6c5c --- /dev/null +++ b/awx/ui_next/src/screens/CredentialType/shared/CredentialTypeForm.test.jsx @@ -0,0 +1,132 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; + +import CredentialTypeForm from './CredentialTypeForm'; + +jest.mock('../../../api'); + +const credentialType = { + id: 28, + type: 'credential_type', + url: '/api/v2/credential_types/28/', + summary_fields: { + created_by: { + id: 1, + username: 'admin', + first_name: '', + last_name: '', + }, + modified_by: { + id: 1, + username: 'admin', + first_name: '', + last_name: '', + }, + user_capabilities: { + edit: true, + delete: true, + }, + }, + created: '2020-06-18T14:48:47.869002Z', + modified: '2020 - 06 - 18T14: 48: 47.869017Z', + name: 'Jenkins Credential', + description: 'Jenkins Credential', + kind: 'cloud', + namespace: null, + managed_by_tower: false, + inputs: JSON.stringify({ + fields: [ + { + id: 'username', + type: 'string', + label: 'Jenkins username', + }, + { + id: 'password', + type: 'string', + label: 'Jenkins password', + secret: true, + }, + ], + required: ['username', 'password'], + }), + injectors: JSON.stringify({ + extra_vars: { + Jenkins_password: '{{ password }}', + Jenkins_username: '{{ username }}', + }, + }), +}; + +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="Description"]').length).toBe(1); + expect( + wrapper.find('VariablesField[label="Input configuration"]').length + ).toBe(1); + expect( + wrapper.find('VariablesField[label="Injector configuration"]').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#credential-type-name').simulate('change', { + target: { value: 'Foo', name: 'name' }, + }); + wrapper.find('input#credential-type-description').simulate('change', { + target: { value: 'New description', name: 'description' }, + }); + }); + wrapper.update(); + expect(wrapper.find('input#credential-type-name').prop('value')).toEqual( + 'Foo' + ); + expect( + wrapper.find('input#credential-type-description').prop('value') + ).toEqual('New description'); + }); + + 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(); + }); +}); diff --git a/awx/ui_next/src/screens/CredentialType/shared/data.json b/awx/ui_next/src/screens/CredentialType/shared/data.json new file mode 100644 index 0000000000..7fa00723ae --- /dev/null +++ b/awx/ui_next/src/screens/CredentialType/shared/data.json @@ -0,0 +1,62 @@ +{ + "id": 28, + "type": "credential_type", + "url": "/api/v2/credential_types/28/", + "related": { + "named_url": "/api/v2/credential_types/Jenkins Credential+cloud/", + "created_by": "/api/v2/users/1/", + "modified_by": "/api/v2/users/1/", + "credentials": "/api/v2/credential_types/28/credentials/", + "activity_stream": "/api/v2/credential_types/28/activity_stream/" + }, + "summary_fields": { + "created_by": { + "id": 1, + "username": "admin", + "first_name": "", + "last_name": "" + }, + "modified_by": { + "id": 1, + "username": "admin", + "first_name": "", + "last_name": "" + }, + "user_capabilities": { + "edit": true, + "delete": true + } + }, + "created": "2020-06-18T14:48:47.869002Z", + "modified": "2020-06-18T14:48:47.869017Z", + "name": "Jenkins Credential", + "description": "Jenkins Credential", + "kind": "cloud", + "namespace": null, + "managed_by_tower": false, + "inputs": { + "fields": [ + { + "id": "username", + "type": "string", + "label": "Jenkins username" + }, + { + "id": "password", + "type": "string", + "label": "Jenkins password", + "secret": true + } + ], + "required": [ + "username", + "password" + ] + }, + "injectors": { + "extra_vars": { + "Jenkins_password": "{{ password }}", + "Jenkins_username": "{{ username }}" + } + } +} diff --git a/awx/ui_next/src/screens/CredentialType/shared/index.js b/awx/ui_next/src/screens/CredentialType/shared/index.js new file mode 100644 index 0000000000..6084152bbf --- /dev/null +++ b/awx/ui_next/src/screens/CredentialType/shared/index.js @@ -0,0 +1 @@ +export { default } from './CredentialTypeForm'; diff --git a/awx/ui_next/src/screens/Host/HostAdd/HostAdd.jsx b/awx/ui_next/src/screens/Host/HostAdd/HostAdd.jsx index 17055bcced..a8751be5a5 100644 --- a/awx/ui_next/src/screens/Host/HostAdd/HostAdd.jsx +++ b/awx/ui_next/src/screens/Host/HostAdd/HostAdd.jsx @@ -1,8 +1,9 @@ import React, { useState } from 'react'; import { useHistory } from 'react-router-dom'; import { PageSection, Card } from '@patternfly/react-core'; -import { CardBody } from '../../../components/Card'; + import HostForm from '../../../components/HostForm'; +import { CardBody } from '../../../components/Card'; import { HostsAPI } from '../../../api'; function HostAdd() { diff --git a/awx/ui_next/src/util/yaml.js b/awx/ui_next/src/util/yaml.js index e4e6cd71b3..d9b5d8ed1b 100644 --- a/awx/ui_next/src/util/yaml.js +++ b/awx/ui_next/src/util/yaml.js @@ -35,3 +35,16 @@ export function isJson(jsonString) { return typeof value === 'object' && value !== null; } + +export function parseVariableField(variableField) { + if (variableField === '---' || variableField === '{}') { + variableField = {}; + } else { + if (!isJson(variableField)) { + variableField = yamlToJson(variableField); + } + variableField = JSON.parse(variableField); + } + + return variableField; +} diff --git a/awx/ui_next/src/util/yaml.test.js b/awx/ui_next/src/util/yaml.test.js index 2f2b46b31e..d2bb924fb4 100644 --- a/awx/ui_next/src/util/yaml.test.js +++ b/awx/ui_next/src/util/yaml.test.js @@ -1,4 +1,4 @@ -import { yamlToJson, jsonToYaml } from './yaml'; +import { yamlToJson, jsonToYaml, parseVariableField } from './yaml'; describe('yamlToJson', () => { test('should convert to json', () => { @@ -68,3 +68,44 @@ two: two expect(() => jsonToYaml('bad data')).toThrow(); }); }); + +describe('parseVariableField', () => { + const injector = ` + extra_vars: + password: '{{ password }}' + username: '{{ username }}' +`; + + const input = `{ + "fields": [ + { + "id": "username", + "type": "string", + "label": "Username" + }, + { + "id": "password", + "type": "string", + "label": "Password", + "secret": true + } + ] +}`; + test('should convert empty yaml to an object', () => { + expect(parseVariableField('---')).toEqual({}); + }); + + test('should convert empty json to an object', () => { + expect(parseVariableField('{}')).toEqual({}); + }); + + test('should convert yaml string to json object', () => { + expect(parseVariableField(injector)).toEqual( + JSON.parse(yamlToJson(injector)) + ); + }); + + test('should convert json string to json object', () => { + expect(parseVariableField(input)).toEqual(JSON.parse(input)); + }); +});