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));
+ });
+});