load related credentials when editing

This commit is contained in:
Jake McDermott
2019-08-13 10:07:46 -04:00
parent a8511f967b
commit 55376bfd13
6 changed files with 230 additions and 72 deletions

View File

@@ -10,6 +10,7 @@ class JobTemplates extends InstanceGroupsMixin(Base) {
this.readLaunch = this.readLaunch.bind(this); this.readLaunch = this.readLaunch.bind(this);
this.associateLabel = this.associateLabel.bind(this); this.associateLabel = this.associateLabel.bind(this);
this.disassociateLabel = this.disassociateLabel.bind(this); this.disassociateLabel = this.disassociateLabel.bind(this);
this.readCredentials = this.readCredentials.bind(this);
this.generateLabel = this.generateLabel.bind(this); this.generateLabel = this.generateLabel.bind(this);
} }
@@ -32,6 +33,10 @@ class JobTemplates extends InstanceGroupsMixin(Base) {
generateLabel(orgId, label) { generateLabel(orgId, label) {
return this.http.post(`${this.baseUrl}${orgId}/labels/`, 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; export default JobTemplates;

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { mountWithContexts } from '@testUtils/enzymeHelpers'; import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import JobTemplateAdd from './JobTemplateAdd'; import JobTemplateAdd from './JobTemplateAdd';
import { JobTemplatesAPI } from '../../../api'; import { JobTemplatesAPI, LabelsAPI } from '@api';
jest.mock('@api'); jest.mock('@api');
@@ -20,6 +20,10 @@ describe('<JobTemplateAdd />', () => {
}, },
}; };
beforeEach(() => {
LabelsAPI.read.mockResolvedValue({ data: { results: [] } });
});
afterEach(() => { afterEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
}); });
@@ -29,8 +33,9 @@ describe('<JobTemplateAdd />', () => {
expect(wrapper.find('JobTemplateForm').length).toBe(1); 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(<JobTemplateAdd />); const wrapper = mountWithContexts(<JobTemplateAdd />);
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
expect(wrapper.find('input#template-description').text()).toBe( expect(wrapper.find('input#template-description').text()).toBe(
defaultProps.description defaultProps.description
); );
@@ -47,6 +52,7 @@ describe('<JobTemplateAdd />', () => {
<option>Check</option>, <option>Check</option>,
]) ])
).toEqual(true); ).toEqual(true);
expect(wrapper.find('input#template-name').text()).toBe(defaultProps.name); expect(wrapper.find('input#template-name').text()).toBe(defaultProps.name);
expect(wrapper.find('input#template-playbook').text()).toBe( expect(wrapper.find('input#template-playbook').text()).toBe(
defaultProps.playbook defaultProps.playbook
@@ -54,6 +60,7 @@ describe('<JobTemplateAdd />', () => {
expect(wrapper.find('input#template-project').text()).toBe( expect(wrapper.find('input#template-project').text()).toBe(
defaultProps.project defaultProps.project
); );
done();
}); });
test('handleSubmit should post to api', async done => { test('handleSubmit should post to api', async done => {
@@ -72,7 +79,8 @@ describe('<JobTemplateAdd />', () => {
}, },
}); });
const wrapper = mountWithContexts(<JobTemplateAdd />); const wrapper = mountWithContexts(<JobTemplateAdd />);
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); expect(JobTemplatesAPI.create).toHaveBeenCalledWith(jobTemplateData);
done(); done();
}); });
@@ -107,15 +115,16 @@ describe('<JobTemplateAdd />', () => {
done(); 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 = { const history = {
push: jest.fn(), push: jest.fn(),
}; };
const wrapper = mountWithContexts(<JobTemplateAdd />, { const wrapper = mountWithContexts(<JobTemplateAdd />, {
context: { router: { history } }, context: { router: { history } },
}); });
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
expect(history.push).toHaveBeenCalledWith('/templates'); expect(history.push).toHaveBeenCalledWith('/templates');
done();
}); });
}); });

View File

@@ -1,9 +1,12 @@
/* eslint react/no-unused-state: 0 */
import React, { Component } from 'react'; import React, { Component } from 'react';
import { withRouter, Redirect } from 'react-router-dom'; import { withRouter, Redirect } from 'react-router-dom';
import { CardBody } from '@patternfly/react-core'; 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 { JobTemplatesAPI } from '@api';
import { JobTemplate } from '@types'; import { JobTemplate } from '@types';
import JobTemplateForm from '../shared/JobTemplateForm';
class JobTemplateEdit extends Component { class JobTemplateEdit extends Component {
static propTypes = { static propTypes = {
@@ -14,7 +17,10 @@ class JobTemplateEdit extends Component {
super(props); super(props);
this.state = { this.state = {
error: '', hasContentLoading: true,
contentError: null,
formSubmitError: null,
relatedCredentials: [],
}; };
const { const {
@@ -24,21 +30,75 @@ class JobTemplateEdit extends Component {
this.handleCancel = this.handleCancel.bind(this); this.handleCancel = this.handleCancel.bind(this);
this.handleSubmit = this.handleSubmit.bind(this); this.handleSubmit = this.handleSubmit.bind(this);
this.loadRelatedCredentials = this.loadRelatedCredentials.bind(this);
this.submitLabels = this.submitLabels.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 = []) { async handleSubmit(values, newLabels = [], removedLabels = []) {
const { const {
template: { id, type }, template: { id },
history, history,
} = this.props; } = this.props;
this.setState({ formSubmitError: null });
try { try {
await JobTemplatesAPI.update(id, { ...values }); await JobTemplatesAPI.update(id, { ...values });
await Promise.all([this.submitLabels(newLabels, removedLabels)]); await Promise.all([this.submitLabels(newLabels, removedLabels)]);
history.push(this.detailsUrl); history.push(this.detailsUrl);
} catch (error) { } catch (formSubmitError) {
this.setState({ error }); this.setState({ formSubmitError });
} }
} }
@@ -71,9 +131,17 @@ class JobTemplateEdit extends Component {
render() { render() {
const { template } = this.props; const { template } = this.props;
const { error } = this.state; const { contentError, formSubmitError, hasContentLoading } = this.state;
const canEdit = template.summary_fields.user_capabilities.edit; const canEdit = template.summary_fields.user_capabilities.edit;
if (hasContentLoading) {
return <ContentLoading />;
}
if (contentError) {
return <ContentError error={contentError} />;
}
if (!canEdit) { if (!canEdit) {
return <Redirect to={this.detailsUrl} />; return <Redirect to={this.detailsUrl} />;
} }
@@ -85,7 +153,7 @@ class JobTemplateEdit extends Component {
handleCancel={this.handleCancel} handleCancel={this.handleCancel}
handleSubmit={this.handleSubmit} handleSubmit={this.handleSubmit}
/> />
{error ? <div> error </div> : null} {formSubmitError ? <div> error </div> : null}
</CardBody> </CardBody>
); );
} }

View File

@@ -1,36 +1,98 @@
import React from 'react'; import React from 'react';
import { JobTemplatesAPI } from '@api'; import { JobTemplatesAPI, LabelsAPI } from '@api';
import { mountWithContexts } from '@testUtils/enzymeHelpers'; import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import JobTemplateEdit from './JobTemplateEdit'; import JobTemplateEdit from './JobTemplateEdit';
jest.mock('@api'); jest.mock('@api');
describe('<JobTemplateEdit />', () => { const mockJobTemplate = {
const mockData = { id: 1,
id: 1, name: 'Foo',
name: 'Foo', description: 'Bar',
description: 'Bar', job_type: 'run',
job_type: 'run', inventory: 2,
inventory: 2, project: 3,
project: 3, playbook: 'Baz',
playbook: 'Baz', type: 'job_template',
type: 'job_template', summary_fields: {
summary_fields: { user_capabilities: {
user_capabilities: { edit: true,
edit: true,
},
labels: {
results: [{ name: 'Sushi', id: 1 }, { name: 'Major', id: 2 }],
},
}, },
}; labels: {
results: [{ name: 'Sushi', id: 1 }, { name: 'Major', id: 2 }],
},
},
};
test('initially renders successfully', () => { const mockRelatedCredentials = {
mountWithContexts(<JobTemplateEdit template={mockData} />); 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('<JobTemplateEdit />', () => {
test('initially renders successfully', async done => {
const wrapper = mountWithContexts(
<JobTemplateEdit template={mockJobTemplate} />
);
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
done();
}); });
test('handleSubmit should call api update', async (done) => { test('handleSubmit should call api update', async done => {
const wrapper = mountWithContexts(<JobTemplateEdit template={mockData} />); const wrapper = mountWithContexts(
<JobTemplateEdit template={mockJobTemplate} />
);
await waitForElement(wrapper, 'JobTemplateForm', e => e.length === 1);
const updatedTemplateData = { const updatedTemplateData = {
name: 'new name', name: 'new name',
description: 'new description', description: 'new description',
@@ -59,18 +121,22 @@ describe('<JobTemplateEdit />', () => {
done(); done();
}); });
test('should navigate to job template detail when cancel is clicked', () => { test('should navigate to job template detail when cancel is clicked', async done => {
const history = { const history = { push: jest.fn() };
push: jest.fn(), const wrapper = mountWithContexts(
}; <JobTemplateEdit template={mockJobTemplate} />,
const wrapper = mountWithContexts(<JobTemplateEdit template={mockData} />, { { context: { router: { history } } }
context: { router: { history } }, );
}); const cancelButton = await waitForElement(
wrapper,
'button[aria-label="Cancel"]',
e => e.length === 1
);
expect(history.push).not.toHaveBeenCalled(); expect(history.push).not.toHaveBeenCalled();
wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); cancelButton.prop('onClick')();
expect(history.push).toHaveBeenCalledWith( expect(history.push).toHaveBeenCalledWith(
'/templates/job_template/1/details' '/templates/job_template/1/details'
); );
done();
}); });
}); });

View File

@@ -4,15 +4,10 @@ import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Formik, Field } from 'formik'; import { Formik, Field } from 'formik';
import { import { Form, FormGroup, Tooltip, Card } from '@patternfly/react-core';
Form,
FormGroup,
Tooltip,
PageSection,
Card,
} from '@patternfly/react-core';
import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons'; import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons';
import ContentError from '@components/ContentError'; import ContentError from '@components/ContentError';
import ContentLoading from '@components/ContentLoading';
import AnsibleSelect from '@components/AnsibleSelect'; import AnsibleSelect from '@components/AnsibleSelect';
import MultiSelect from '@components/MultiSelect'; import MultiSelect from '@components/MultiSelect';
import FormActionGroup from '@components/FormActionGroup'; import FormActionGroup from '@components/FormActionGroup';
@@ -44,9 +39,7 @@ class JobTemplateForm extends Component {
template: { template: {
name: '', name: '',
description: '', description: '',
inventory: '',
job_type: 'run', job_type: 'run',
project: '',
playbook: '', playbook: '',
summary_fields: { summary_fields: {
inventory: null, inventory: null,
@@ -180,23 +173,30 @@ class JobTemplateForm extends Component {
}, },
]; ];
if (!hasContentLoading && contentError) { if (hasContentLoading) {
return ( return (
<PageSection> <Card className="awx-c-card">
<Card className="awx-c-card"> <ContentLoading />
<ContentError error={contentError} /> </Card>
</Card>
</PageSection>
); );
} }
if (contentError) {
return (
<Card className="awx-c-card">
<ContentError error={contentError} />
</Card>
);
}
return ( return (
<Formik <Formik
initialValues={{ initialValues={{
name: template.name, name: template.name,
description: template.description, description: template.description,
job_type: template.job_type, job_type: template.job_type,
inventory: template.inventory, inventory: template.inventory || '',
project: template.project, project: template.project || '',
playbook: template.playbook, playbook: template.playbook,
labels: template.summary_fields.labels.results, labels: template.summary_fields.labels.results,
}} }}

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { mountWithContexts } from '@testUtils/enzymeHelpers'; import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import { sleep } from '@testUtils/testUtils'; import { sleep } from '@testUtils/testUtils';
import JobTemplateForm, { _JobTemplateForm } from './JobTemplateForm'; import JobTemplateForm, { _JobTemplateForm } from './JobTemplateForm';
import { LabelsAPI } from '@api'; import { LabelsAPI } from '@api';
@@ -35,7 +35,7 @@ describe('<JobTemplateForm />', () => {
jest.clearAllMocks(); jest.clearAllMocks();
}); });
test('initially renders successfully', () => { test('initially renders successfully', async done => {
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<JobTemplateForm <JobTemplateForm
template={mockData} template={mockData}
@@ -43,12 +43,14 @@ describe('<JobTemplateForm />', () => {
handleCancel={jest.fn()} handleCancel={jest.fn()}
/> />
); );
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
const component = wrapper.find('ChipGroup'); const component = wrapper.find('ChipGroup');
expect(LabelsAPI.read).toHaveBeenCalled(); expect(LabelsAPI.read).toHaveBeenCalled();
expect(component.find('span#pf-random-id-1').text()).toEqual('Sushi'); 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( const wrapper = mountWithContexts(
<JobTemplateForm <JobTemplateForm
template={mockData} template={mockData}
@@ -56,7 +58,7 @@ describe('<JobTemplateForm />', () => {
handleCancel={jest.fn()} handleCancel={jest.fn()}
/> />
); );
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
const form = wrapper.find('Formik'); const form = wrapper.find('Formik');
wrapper.find('input#template-name').simulate('change', { wrapper.find('input#template-name').simulate('change', {
target: { value: 'new foo', name: 'name' }, target: { value: 'new foo', name: 'name' },
@@ -83,9 +85,10 @@ describe('<JobTemplateForm />', () => {
target: { value: 'new baz type', name: 'playbook' }, target: { value: 'new baz type', name: 'playbook' },
}); });
expect(form.state('values').playbook).toEqual('new baz type'); 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 handleSubmit = jest.fn();
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<JobTemplateForm <JobTemplateForm
@@ -94,14 +97,15 @@ describe('<JobTemplateForm />', () => {
handleCancel={jest.fn()} handleCancel={jest.fn()}
/> />
); );
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
expect(handleSubmit).not.toHaveBeenCalled(); expect(handleSubmit).not.toHaveBeenCalled();
wrapper.find('button[aria-label="Save"]').simulate('click'); wrapper.find('button[aria-label="Save"]').simulate('click');
await sleep(1); await sleep(1);
expect(handleSubmit).toBeCalled(); 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 handleCancel = jest.fn();
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<JobTemplateForm <JobTemplateForm
@@ -110,12 +114,14 @@ describe('<JobTemplateForm />', () => {
handleCancel={handleCancel} handleCancel={handleCancel}
/> />
); );
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
expect(handleCancel).not.toHaveBeenCalled(); expect(handleCancel).not.toHaveBeenCalled();
wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
expect(handleCancel).toBeCalled(); 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( const handleNewLabel = jest.spyOn(
_JobTemplateForm.prototype, _JobTemplateForm.prototype,
'handleNewLabel' 'handleNewLabel'
@@ -128,6 +134,7 @@ describe('<JobTemplateForm />', () => {
handleCancel={jest.fn()} handleCancel={jest.fn()}
/> />
); );
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
const multiSelect = wrapper.find('MultiSelect'); const multiSelect = wrapper.find('MultiSelect');
const component = wrapper.find('JobTemplateForm'); const component = wrapper.find('JobTemplateForm');
@@ -141,8 +148,9 @@ describe('<JobTemplateForm />', () => {
{ name: 'Foo', organization: 1 }, { name: 'Foo', organization: 1 },
{ associate: true, id: 2, name: 'Bar' }, { 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( const wrapper = mountWithContexts(
<JobTemplateForm <JobTemplateForm
template={mockData} template={mockData}
@@ -150,6 +158,7 @@ describe('<JobTemplateForm />', () => {
handleCancel={jest.fn()} handleCancel={jest.fn()}
/> />
); );
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
const component = wrapper.find('JobTemplateForm'); const component = wrapper.find('JobTemplateForm');
// This asserts that the user generated a label or clicked // This asserts that the user generated a label or clicked
// on a label option, and then changed their mind and // on a label option, and then changed their mind and
@@ -162,5 +171,6 @@ describe('<JobTemplateForm />', () => {
component.instance().removeLabel({ name: 'Sushi', id: 1 }); component.instance().removeLabel({ name: 'Sushi', id: 1 });
expect(component.state().newLabels.length).toBe(0); expect(component.state().newLabels.length).toBe(0);
expect(component.state().removedLabels.length).toBe(1); expect(component.state().removedLabels.length).toBe(1);
done();
}); });
}); });