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 655ececfe5..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,59 +17,133 @@ class JobTemplateEdit extends Component { super(props); this.state = { - error: '', + hasContentLoading: true, + contentError: null, + formSubmitError: null, + relatedCredentials: [], }; + const { + template: { id, type }, + } = props; + this.detailsUrl = `/templates/${type}/${id}/details`; + 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; - const disassociatedLabels = removedLabels.forEach(removedLabel => - JobTemplatesAPI.disassociateLabel(id, removedLabel) - ); - const associatedLabels = newLabels - .filter(newLabel => !newLabel.organization) - .forEach(newLabel => JobTemplatesAPI.associateLabel(id, newLabel)); - const generatedLabels = newLabels - .filter(newLabel => newLabel.organization) - .forEach(newLabel => JobTemplatesAPI.generateLabel(id, newLabel)); + this.setState({ formSubmitError: null }); try { - await Promise.all([ - JobTemplatesAPI.update(id, { ...values }), - disassociatedLabels, - associatedLabels, - generatedLabels, - ]); - history.push(`/templates/${type}/${id}/details`); - } catch (error) { - this.setState({ error }); + await JobTemplatesAPI.update(id, { ...values }); + await Promise.all([this.submitLabels(newLabels, removedLabels)]); + history.push(this.detailsUrl); + } catch (formSubmitError) { + this.setState({ formSubmitError }); } } - handleCancel() { + async submitLabels(newLabels, removedLabels) { const { - template: { id, type }, - history, + template: { id }, } = this.props; - history.push(`/templates/${type}/${id}/details`); + const disassociationPromises = removedLabels.map(label => + JobTemplatesAPI.disassociateLabel(id, label) + ); + const associationPromises = newLabels + .filter(label => !label.organization) + .map(label => JobTemplatesAPI.associateLabel(id, label)); + const creationPromises = newLabels + .filter(label => label.organization) + .map(label => JobTemplatesAPI.generateLabel(id, label)); + + const results = await Promise.all([ + ...disassociationPromises, + ...associationPromises, + ...creationPromises, + ]); + return results; + } + + handleCancel() { + const { history } = this.props; + history.push(this.detailsUrl); } 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) { - const { - template: { id, type }, - } = this.props; - return ; + return ; } return ( @@ -76,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 1448887e95..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', () => { - 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', @@ -47,7 +109,7 @@ describe('', () => { { disassociate: true, id: 2 }, ]; - wrapper.find('JobTemplateForm').prop('handleSubmit')( + await wrapper.find('JobTemplateForm').prop('handleSubmit')( updatedTemplateData, newLabels, removedLabels @@ -56,20 +118,25 @@ describe('', () => { expect(JobTemplatesAPI.disassociateLabel).toHaveBeenCalledTimes(2); expect(JobTemplatesAPI.associateLabel).toHaveBeenCalledTimes(2); expect(JobTemplatesAPI.generateLabel).toHaveBeenCalledTimes(2); + 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/InventoriesLookup.jsx b/awx/ui_next/src/screens/Template/shared/InventoriesLookup.jsx index a3d03b81eb..3bef9f782a 100644 --- a/awx/ui_next/src/screens/Template/shared/InventoriesLookup.jsx +++ b/awx/ui_next/src/screens/Template/shared/InventoriesLookup.jsx @@ -6,8 +6,8 @@ import { FormGroup, Tooltip } from '@patternfly/react-core'; import { QuestionCircleIcon } from '@patternfly/react-icons'; import { InventoriesAPI } from '@api'; +import { Inventory } from '@types'; import Lookup from '@components/Lookup'; -import { Inventory } from '../../../types'; const getInventories = async params => InventoriesAPI.read(params); diff --git a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx index fc2ff53556..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, @@ -74,12 +67,11 @@ class JobTemplateForm extends Component { this.loadLabels(QSConfig); } - // The function below assumes that the user has no more than 400 - // labels. For the vast majority of users this will be more thans - // enough.This can be updated to allow more than 400 labels if we - // decide it is necessary. - async loadLabels(QueryConfig) { + // This function assumes that the user has no more than 400 + // labels. For the vast majority of users this will be more thans + // enough.This can be updated to allow more than 400 labels if we + // decide it is necessary. this.setState({ contentError: null, hasContentLoading: true }); let loadedLabels; try { @@ -181,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(); }); });