From 55376bfd136e5aa269c1b37d841e26087f3be9d3 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Tue, 13 Aug 2019 10:07:46 -0400 Subject: [PATCH] load related credentials when editing --- awx/ui_next/src/api/models/JobTemplates.js | 5 + .../JobTemplateAdd/JobTemplateAdd.test.jsx | 21 ++- .../JobTemplateEdit/JobTemplateEdit.jsx | 82 ++++++++++- .../JobTemplateEdit/JobTemplateEdit.test.jsx | 132 +++++++++++++----- .../Template/shared/JobTemplateForm.jsx | 34 ++--- .../Template/shared/JobTemplateForm.test.jsx | 28 ++-- 6 files changed, 230 insertions(+), 72 deletions(-) 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(); }); });