diff --git a/awx/ui_next/src/api/models/JobTemplates.js b/awx/ui_next/src/api/models/JobTemplates.js
index 44eef5529b..646f168df8 100644
--- a/awx/ui_next/src/api/models/JobTemplates.js
+++ b/awx/ui_next/src/api/models/JobTemplates.js
@@ -10,6 +10,7 @@ class JobTemplates extends InstanceGroupsMixin(Base) {
this.readLaunch = this.readLaunch.bind(this);
this.associateLabel = this.associateLabel.bind(this);
this.disassociateLabel = this.disassociateLabel.bind(this);
+ this.readCredentials = this.readCredentials.bind(this);
this.generateLabel = this.generateLabel.bind(this);
}
@@ -32,6 +33,10 @@ class JobTemplates extends InstanceGroupsMixin(Base) {
generateLabel(orgId, label) {
return this.http.post(`${this.baseUrl}${orgId}/labels/`, label);
}
+
+ readCredentials(id, params) {
+ return this.http.get(`${this.baseUrl}${id}/credentials/`, { params });
+ }
}
export default JobTemplates;
diff --git a/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.test.jsx b/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.test.jsx
index e9e579b673..8869abd5f8 100644
--- a/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.test.jsx
+++ b/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.test.jsx
@@ -1,7 +1,7 @@
import React from 'react';
-import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import JobTemplateAdd from './JobTemplateAdd';
-import { JobTemplatesAPI } from '../../../api';
+import { JobTemplatesAPI, LabelsAPI } from '@api';
jest.mock('@api');
@@ -20,6 +20,10 @@ describe('', () => {
},
};
+ beforeEach(() => {
+ LabelsAPI.read.mockResolvedValue({ data: { results: [] } });
+ });
+
afterEach(() => {
jest.clearAllMocks();
});
@@ -29,8 +33,9 @@ describe('', () => {
expect(wrapper.find('JobTemplateForm').length).toBe(1);
});
- test('should render Job Template Form with default values', () => {
+ test('should render Job Template Form with default values', async done => {
const wrapper = mountWithContexts();
+ await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
expect(wrapper.find('input#template-description').text()).toBe(
defaultProps.description
);
@@ -47,6 +52,7 @@ describe('', () => {
,
])
).toEqual(true);
+
expect(wrapper.find('input#template-name').text()).toBe(defaultProps.name);
expect(wrapper.find('input#template-playbook').text()).toBe(
defaultProps.playbook
@@ -54,6 +60,7 @@ describe('', () => {
expect(wrapper.find('input#template-project').text()).toBe(
defaultProps.project
);
+ done();
});
test('handleSubmit should post to api', async done => {
@@ -72,7 +79,8 @@ describe('', () => {
},
});
const wrapper = mountWithContexts();
- await wrapper.find('JobTemplateForm').prop('handleSubmit')(jobTemplateData);
+ await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
+ wrapper.find('JobTemplateForm').prop('handleSubmit')(jobTemplateData);
expect(JobTemplatesAPI.create).toHaveBeenCalledWith(jobTemplateData);
done();
});
@@ -107,15 +115,16 @@ describe('', () => {
done();
});
- test('should navigate to templates list when cancel is clicked', () => {
+ test('should navigate to templates list when cancel is clicked', async done => {
const history = {
push: jest.fn(),
};
const wrapper = mountWithContexts(, {
context: { router: { history } },
});
-
+ await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
expect(history.push).toHaveBeenCalledWith('/templates');
+ done();
});
});
diff --git a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx
index ebd450171d..c759257e28 100644
--- a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx
+++ b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx
@@ -1,9 +1,12 @@
+/* eslint react/no-unused-state: 0 */
import React, { Component } from 'react';
import { withRouter, Redirect } from 'react-router-dom';
import { CardBody } from '@patternfly/react-core';
-import JobTemplateForm from '../shared/JobTemplateForm';
+import ContentError from '@components/ContentError';
+import ContentLoading from '@components/ContentLoading';
import { JobTemplatesAPI } from '@api';
import { JobTemplate } from '@types';
+import JobTemplateForm from '../shared/JobTemplateForm';
class JobTemplateEdit extends Component {
static propTypes = {
@@ -14,7 +17,10 @@ class JobTemplateEdit extends Component {
super(props);
this.state = {
- error: '',
+ hasContentLoading: true,
+ contentError: null,
+ formSubmitError: null,
+ relatedCredentials: [],
};
const {
@@ -24,21 +30,75 @@ class JobTemplateEdit extends Component {
this.handleCancel = this.handleCancel.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
+ this.loadRelatedCredentials = this.loadRelatedCredentials.bind(this);
this.submitLabels = this.submitLabels.bind(this);
}
+ componentDidMount() {
+ this.loadRelated();
+ }
+
+ async loadRelated() {
+ this.setState({ contentError: null, hasContentLoading: true });
+ try {
+ const [
+ relatedCredentials,
+ // relatedProjectPlaybooks,
+ ] = await Promise.all([
+ this.loadRelatedCredentials(),
+ // this.loadRelatedProjectPlaybooks(),
+ ]);
+ this.setState({
+ relatedCredentials,
+ });
+ } catch (contentError) {
+ this.setState({ contentError });
+ } finally {
+ this.setState({ hasContentLoading: false });
+ }
+ }
+
+ async loadRelatedCredentials() {
+ const {
+ template: { id },
+ } = this.props;
+ const params = {
+ page: 1,
+ page_size: 200,
+ order_by: 'name',
+ };
+ try {
+ const {
+ data: { results: credentials = [] },
+ } = await JobTemplatesAPI.readCredentials(id, params);
+ return credentials;
+ } catch (err) {
+ if (err.status !== 403) throw err;
+
+ this.setState({ hasRelatedCredentialAccess: false });
+ const {
+ template: {
+ summary_fields: { credentials = [] },
+ },
+ } = this.props;
+
+ return credentials;
+ }
+ }
+
async handleSubmit(values, newLabels = [], removedLabels = []) {
const {
- template: { id, type },
+ template: { id },
history,
} = this.props;
+ this.setState({ formSubmitError: null });
try {
await JobTemplatesAPI.update(id, { ...values });
await Promise.all([this.submitLabels(newLabels, removedLabels)]);
history.push(this.detailsUrl);
- } catch (error) {
- this.setState({ error });
+ } catch (formSubmitError) {
+ this.setState({ formSubmitError });
}
}
@@ -71,9 +131,17 @@ class JobTemplateEdit extends Component {
render() {
const { template } = this.props;
- const { error } = this.state;
+ const { contentError, formSubmitError, hasContentLoading } = this.state;
const canEdit = template.summary_fields.user_capabilities.edit;
+ if (hasContentLoading) {
+ return ;
+ }
+
+ if (contentError) {
+ return ;
+ }
+
if (!canEdit) {
return ;
}
@@ -85,7 +153,7 @@ class JobTemplateEdit extends Component {
handleCancel={this.handleCancel}
handleSubmit={this.handleSubmit}
/>
- {error ?
error
: null}
+ {formSubmitError ? error
: null}
);
}
diff --git a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.test.jsx b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.test.jsx
index 0f8a49feb7..0b9ce09b83 100644
--- a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.test.jsx
+++ b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.test.jsx
@@ -1,36 +1,98 @@
import React from 'react';
-import { JobTemplatesAPI } from '@api';
-import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import { JobTemplatesAPI, LabelsAPI } from '@api';
+import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import JobTemplateEdit from './JobTemplateEdit';
jest.mock('@api');
-describe('', () => {
- const mockData = {
- id: 1,
- name: 'Foo',
- description: 'Bar',
- job_type: 'run',
- inventory: 2,
- project: 3,
- playbook: 'Baz',
- type: 'job_template',
- summary_fields: {
- user_capabilities: {
- edit: true,
- },
- labels: {
- results: [{ name: 'Sushi', id: 1 }, { name: 'Major', id: 2 }],
- },
+const mockJobTemplate = {
+ id: 1,
+ name: 'Foo',
+ description: 'Bar',
+ job_type: 'run',
+ inventory: 2,
+ project: 3,
+ playbook: 'Baz',
+ type: 'job_template',
+ summary_fields: {
+ user_capabilities: {
+ edit: true,
},
- };
+ labels: {
+ results: [{ name: 'Sushi', id: 1 }, { name: 'Major', id: 2 }],
+ },
+ },
+};
- test('initially renders successfully', () => {
- mountWithContexts();
+const mockRelatedCredentials = {
+ count: 2,
+ next: null,
+ previous: null,
+ results: [
+ {
+ id: 1,
+ type: 'credential',
+ url: '/api/v2/credentials/1/',
+ related: {},
+ summary_fields: {
+ user_capabilities: {
+ edit: true,
+ delete: true,
+ copy: true,
+ use: true,
+ },
+ },
+ created: '2016-08-24T20:20:44.411607Z',
+ modified: '2019-06-18T16:14:00.109434Z',
+ name: 'Test Vault Credential',
+ description: 'Credential with access to vaulted data.',
+ organization: 1,
+ credential_type: 3,
+ inputs: { vault_password: '$encrypted$' },
+ },
+ {
+ id: 2,
+ type: 'credential',
+ url: '/api/v2/credentials/2/',
+ related: {},
+ summary_fields: {
+ user_capabilities: {
+ edit: true,
+ delete: true,
+ copy: true,
+ use: true,
+ },
+ },
+ created: '2016-08-24T20:20:44.411607Z',
+ modified: '2017-07-11T15:58:39.103659Z',
+ name: 'Test Machine Credential',
+ description: 'Credential with access to internal machines.',
+ organization: 1,
+ credential_type: 1,
+ inputs: { ssh_key_data: '$encrypted$' },
+ },
+ ],
+};
+
+JobTemplatesAPI.readCredentials.mockResolvedValue({
+ data: mockRelatedCredentials,
+});
+LabelsAPI.read.mockResolvedValue({ data: { results: [] } });
+
+describe('', () => {
+ test('initially renders successfully', async done => {
+ const wrapper = mountWithContexts(
+
+ );
+ await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
+ done();
});
- test('handleSubmit should call api update', async (done) => {
- const wrapper = mountWithContexts();
+ test('handleSubmit should call api update', async done => {
+ const wrapper = mountWithContexts(
+
+ );
+ await waitForElement(wrapper, 'JobTemplateForm', e => e.length === 1);
const updatedTemplateData = {
name: 'new name',
description: 'new description',
@@ -59,18 +121,22 @@ describe('', () => {
done();
});
- test('should navigate to job template detail when cancel is clicked', () => {
- const history = {
- push: jest.fn(),
- };
- const wrapper = mountWithContexts(, {
- context: { router: { history } },
- });
-
+ test('should navigate to job template detail when cancel is clicked', async done => {
+ const history = { push: jest.fn() };
+ const wrapper = mountWithContexts(
+ ,
+ { context: { router: { history } } }
+ );
+ const cancelButton = await waitForElement(
+ wrapper,
+ 'button[aria-label="Cancel"]',
+ e => e.length === 1
+ );
expect(history.push).not.toHaveBeenCalled();
- wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
+ cancelButton.prop('onClick')();
expect(history.push).toHaveBeenCalledWith(
'/templates/job_template/1/details'
);
+ done();
});
});
diff --git a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx
index 7d921b254a..c12e50fd5a 100644
--- a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx
+++ b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx
@@ -4,15 +4,10 @@ import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Formik, Field } from 'formik';
-import {
- Form,
- FormGroup,
- Tooltip,
- PageSection,
- Card,
-} from '@patternfly/react-core';
+import { Form, FormGroup, Tooltip, Card } from '@patternfly/react-core';
import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons';
import ContentError from '@components/ContentError';
+import ContentLoading from '@components/ContentLoading';
import AnsibleSelect from '@components/AnsibleSelect';
import MultiSelect from '@components/MultiSelect';
import FormActionGroup from '@components/FormActionGroup';
@@ -44,9 +39,7 @@ class JobTemplateForm extends Component {
template: {
name: '',
description: '',
- inventory: '',
job_type: 'run',
- project: '',
playbook: '',
summary_fields: {
inventory: null,
@@ -180,23 +173,30 @@ class JobTemplateForm extends Component {
},
];
- if (!hasContentLoading && contentError) {
+ if (hasContentLoading) {
return (
-
-
-
-
-
+
+
+
);
}
+
+ if (contentError) {
+ return (
+
+
+
+ );
+ }
+
return (
', () => {
jest.clearAllMocks();
});
- test('initially renders successfully', () => {
+ test('initially renders successfully', async done => {
const wrapper = mountWithContexts(
', () => {
handleCancel={jest.fn()}
/>
);
+ await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
const component = wrapper.find('ChipGroup');
expect(LabelsAPI.read).toHaveBeenCalled();
expect(component.find('span#pf-random-id-1').text()).toEqual('Sushi');
+ done();
});
- test('should update form values on input changes', async () => {
+ test('should update form values on input changes', async done => {
const wrapper = mountWithContexts(
', () => {
handleCancel={jest.fn()}
/>
);
-
+ await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
const form = wrapper.find('Formik');
wrapper.find('input#template-name').simulate('change', {
target: { value: 'new foo', name: 'name' },
@@ -83,9 +85,10 @@ describe('', () => {
target: { value: 'new baz type', name: 'playbook' },
});
expect(form.state('values').playbook).toEqual('new baz type');
+ done();
});
- test('should call handleSubmit when Submit button is clicked', async () => {
+ test('should call handleSubmit when Submit button is clicked', async done => {
const handleSubmit = jest.fn();
const wrapper = mountWithContexts(
', () => {
handleCancel={jest.fn()}
/>
);
-
+ await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
expect(handleSubmit).not.toHaveBeenCalled();
wrapper.find('button[aria-label="Save"]').simulate('click');
await sleep(1);
expect(handleSubmit).toBeCalled();
+ done();
});
- test('should call handleCancel when Cancel button is clicked', () => {
+ test('should call handleCancel when Cancel button is clicked', async done => {
const handleCancel = jest.fn();
const wrapper = mountWithContexts(
', () => {
handleCancel={handleCancel}
/>
);
+ await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
expect(handleCancel).not.toHaveBeenCalled();
wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
expect(handleCancel).toBeCalled();
+ done();
});
- test('handleNewLabel should arrange new labels properly', async () => {
+ test('handleNewLabel should arrange new labels properly', async done => {
const handleNewLabel = jest.spyOn(
_JobTemplateForm.prototype,
'handleNewLabel'
@@ -128,6 +134,7 @@ describe('', () => {
handleCancel={jest.fn()}
/>
);
+ await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
const multiSelect = wrapper.find('MultiSelect');
const component = wrapper.find('JobTemplateForm');
@@ -141,8 +148,9 @@ describe('', () => {
{ name: 'Foo', organization: 1 },
{ associate: true, id: 2, name: 'Bar' },
]);
+ done();
});
- test('disassociateLabel should arrange new labels properly', async () => {
+ test('disassociateLabel should arrange new labels properly', async done => {
const wrapper = mountWithContexts(
', () => {
handleCancel={jest.fn()}
/>
);
+ await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
const component = wrapper.find('JobTemplateForm');
// This asserts that the user generated a label or clicked
// on a label option, and then changed their mind and
@@ -162,5 +171,6 @@ describe('', () => {
component.instance().removeLabel({ name: 'Sushi', id: 1 });
expect(component.state().newLabels.length).toBe(0);
expect(component.state().removedLabels.length).toBe(1);
+ done();
});
});