mirror of
https://github.com/ansible/awx.git
synced 2026-05-07 17:37:37 -02:30
Merge pull request #7426 from nixocio/ui_issue_7325
Add feature to add Credential Type Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
@@ -179,7 +179,7 @@ describe('<CredentialAdd />', () => {
|
|||||||
expect(history.location.pathname).toBe('/credentials/13/details');
|
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);
|
await waitForElement(wrapper, 'isLoading', el => el.length === 0);
|
||||||
wrapper.find('Button[aria-label="Cancel"]').simulate('click');
|
wrapper.find('Button[aria-label="Cancel"]').simulate('click');
|
||||||
expect(history.location.pathname).toEqual('/credentials');
|
expect(history.location.pathname).toEqual('/credentials');
|
||||||
|
|||||||
@@ -1,11 +1,44 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Card, PageSection } from '@patternfly/react-core';
|
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() {
|
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 (
|
return (
|
||||||
<PageSection>
|
<PageSection>
|
||||||
<Card>
|
<Card>
|
||||||
<div>Credentials Type Add</div>
|
<CardBody>
|
||||||
|
<CredentialTypeForm
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
submitError={submitError}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
/>
|
||||||
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
</PageSection>
|
</PageSection>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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('<CredentialTypeAdd/>', () => {
|
||||||
|
let wrapper;
|
||||||
|
let history;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
history = createMemoryHistory({
|
||||||
|
initialEntries: ['/credential_types'],
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(<CredentialTypeAdd />, {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
id="credential-type-name"
|
||||||
|
label={i18n._(t`Name`)}
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
validate={required(null, i18n)}
|
||||||
|
isRequired
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
id="credential-type-description"
|
||||||
|
label={i18n._(t`Description`)}
|
||||||
|
name="description"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<FormFullWidthLayout>
|
||||||
|
<VariablesField
|
||||||
|
tooltip={i18n._(
|
||||||
|
t`Enter inputs using either JSON or YAML syntax. Refer to the Ansible Tower documentation for example syntax.`
|
||||||
|
)}
|
||||||
|
id="credential-type-inputs-configuration"
|
||||||
|
name="inputs"
|
||||||
|
label={i18n._(t`Input configuration`)}
|
||||||
|
/>
|
||||||
|
</FormFullWidthLayout>
|
||||||
|
<FormFullWidthLayout>
|
||||||
|
<VariablesField
|
||||||
|
tooltip={i18n._(
|
||||||
|
t`Enter injectors using either JSON or YAML syntax. Refer to the Ansible Tower documentation for example syntax.`
|
||||||
|
)}
|
||||||
|
id="credential-type-injectors-configuration"
|
||||||
|
name="injectors"
|
||||||
|
label={i18n._(t`Injector configuration`)}
|
||||||
|
/>
|
||||||
|
</FormFullWidthLayout>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CredentialTypeForm({
|
||||||
|
credentialType = {},
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
submitError,
|
||||||
|
...rest
|
||||||
|
}) {
|
||||||
|
const initialValues = {
|
||||||
|
name: credentialType.name || '',
|
||||||
|
description: credentialType.description || '',
|
||||||
|
inputs: credentialType.inputs || '---',
|
||||||
|
injectors: credentialType.injectors || '---',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Formik initialValues={initialValues} onSubmit={values => onSubmit(values)}>
|
||||||
|
{formik => (
|
||||||
|
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
||||||
|
<FormColumnLayout>
|
||||||
|
<CredentialTypeFormFields {...rest} />
|
||||||
|
<FormSubmitError error={submitError} />
|
||||||
|
<FormActionGroup
|
||||||
|
onCancel={onCancel}
|
||||||
|
onSubmit={formik.handleSubmit}
|
||||||
|
/>
|
||||||
|
</FormColumnLayout>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
CredentialTypeForm.propTypes = {
|
||||||
|
credentialType: shape({}),
|
||||||
|
onCancel: func.isRequired,
|
||||||
|
onSubmit: func.isRequired,
|
||||||
|
submitError: shape({}),
|
||||||
|
};
|
||||||
|
|
||||||
|
CredentialTypeForm.defaultProps = {
|
||||||
|
credentialType: {},
|
||||||
|
submitError: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withI18n()(CredentialTypeForm);
|
||||||
@@ -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('<CredentialTypeForm/>', () => {
|
||||||
|
let wrapper;
|
||||||
|
let onCancel;
|
||||||
|
let onSubmit;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
onCancel = jest.fn();
|
||||||
|
onSubmit = jest.fn();
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<CredentialTypeForm
|
||||||
|
onCancel={onCancel}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
credentialType={credentialType}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
62
awx/ui_next/src/screens/CredentialType/shared/data.json
Normal file
62
awx/ui_next/src/screens/CredentialType/shared/data.json
Normal file
@@ -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 }}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
awx/ui_next/src/screens/CredentialType/shared/index.js
Normal file
1
awx/ui_next/src/screens/CredentialType/shared/index.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './CredentialTypeForm';
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useHistory } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
import { PageSection, Card } from '@patternfly/react-core';
|
import { PageSection, Card } from '@patternfly/react-core';
|
||||||
import { CardBody } from '../../../components/Card';
|
|
||||||
import HostForm from '../../../components/HostForm';
|
import HostForm from '../../../components/HostForm';
|
||||||
|
import { CardBody } from '../../../components/Card';
|
||||||
import { HostsAPI } from '../../../api';
|
import { HostsAPI } from '../../../api';
|
||||||
|
|
||||||
function HostAdd() {
|
function HostAdd() {
|
||||||
|
|||||||
@@ -35,3 +35,16 @@ export function isJson(jsonString) {
|
|||||||
|
|
||||||
return typeof value === 'object' && value !== null;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { yamlToJson, jsonToYaml } from './yaml';
|
import { yamlToJson, jsonToYaml, parseVariableField } from './yaml';
|
||||||
|
|
||||||
describe('yamlToJson', () => {
|
describe('yamlToJson', () => {
|
||||||
test('should convert to json', () => {
|
test('should convert to json', () => {
|
||||||
@@ -68,3 +68,44 @@ two: two
|
|||||||
expect(() => jsonToYaml('bad data')).toThrow();
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user