Merge pull request #4792 from mabashian/relaunch-jobs

Add relaunch to Jobs list and Job Details views

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot] 2019-09-27 19:18:48 +00:00 committed by GitHub
commit d2a5af44de
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 210 additions and 67 deletions

View File

@ -2,6 +2,7 @@ import AdHocCommands from './models/AdHocCommands';
import Config from './models/Config';
import InstanceGroups from './models/InstanceGroups';
import Inventories from './models/Inventories';
import InventorySources from './models/InventorySources';
import InventoryUpdates from './models/InventoryUpdates';
import JobTemplates from './models/JobTemplates';
import Jobs from './models/Jobs';
@ -23,6 +24,7 @@ const AdHocCommandsAPI = new AdHocCommands();
const ConfigAPI = new Config();
const InstanceGroupsAPI = new InstanceGroups();
const InventoriesAPI = new Inventories();
const InventorySourcesAPI = new InventorySources();
const InventoryUpdatesAPI = new InventoryUpdates();
const JobTemplatesAPI = new JobTemplates();
const JobsAPI = new Jobs();
@ -45,6 +47,7 @@ export {
ConfigAPI,
InstanceGroupsAPI,
InventoriesAPI,
InventorySourcesAPI,
InventoryUpdatesAPI,
JobTemplatesAPI,
JobsAPI,

View File

@ -0,0 +1,12 @@
const LaunchUpdateMixin = parent =>
class extends parent {
launchUpdate(id, data) {
return this.http.post(`${this.baseUrl}${id}/update/`, data);
}
readLaunchUpdate(id) {
return this.http.get(`${this.baseUrl}${id}/update/`);
}
};
export default LaunchUpdateMixin;

View File

@ -0,0 +1,12 @@
const RelaunchMixin = parent =>
class extends parent {
relaunch(id, data) {
return this.http.post(`${this.baseUrl}${id}/relaunch/`, data);
}
readRelaunch(id) {
return this.http.get(`${this.baseUrl}${id}/relaunch/`);
}
};
export default RelaunchMixin;

View File

@ -1,6 +1,7 @@
import Base from '../Base';
import RelaunchMixin from '../mixins/Relaunch.mixin';
class AdHocCommands extends Base {
class AdHocCommands extends RelaunchMixin(Base) {
constructor(http) {
super(http);
this.baseUrl = '/api/v2/ad_hoc_commands/';

View File

@ -0,0 +1,11 @@
import Base from '../Base';
import LaunchUpdateMixin from '../mixins/LaunchUpdate.mixin';
class InventorySources extends LaunchUpdateMixin(Base) {
constructor(http) {
super(http);
this.baseUrl = '/api/v2/inventory_sources/';
}
}
export default InventorySources;

View File

@ -1,6 +1,7 @@
import Base from '../Base';
import LaunchUpdateMixin from '../mixins/LaunchUpdate.mixin';
class InventoryUpdates extends Base {
class InventoryUpdates extends LaunchUpdateMixin(Base) {
constructor(http) {
super(http);
this.baseUrl = '/api/v2/inventory_updates/';

View File

@ -1,4 +1,5 @@
import Base from '../Base';
import RelaunchMixin from '../mixins/Relaunch.mixin';
const BASE_URLS = {
playbook: '/jobs/',
@ -9,7 +10,7 @@ const BASE_URLS = {
workflow: '/workflow_jobs/',
};
class Jobs extends Base {
class Jobs extends RelaunchMixin(Base) {
constructor(http) {
super(http);
this.baseUrl = '/api/v2/jobs/';

View File

@ -1,6 +1,7 @@
import Base from '../Base';
import LaunchUpdateMixin from '../mixins/LaunchUpdate.mixin';
class Projects extends Base {
class Projects extends LaunchUpdateMixin(Base) {
constructor(http) {
super(http);
this.baseUrl = '/api/v2/projects/';

View File

@ -1,6 +1,7 @@
import Base from '../Base';
import RelaunchMixin from '../mixins/Relaunch.mixin';
class WorkflowJobs extends Base {
class WorkflowJobs extends RelaunchMixin(Base) {
constructor(http) {
super(http);
this.baseUrl = '/api/v2/workflow_jobs/';

View File

@ -1,16 +1,25 @@
import React, { Fragment } from 'react';
import { withRouter } from 'react-router-dom';
import { number } from 'prop-types';
import { number, shape } from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import AlertModal from '@components/AlertModal';
import ErrorDetail from '@components/ErrorDetail';
import { JobTemplatesAPI } from '@api';
import {
AdHocCommandsAPI,
InventorySourcesAPI,
JobsAPI,
JobTemplatesAPI,
ProjectsAPI,
WorkflowJobsAPI,
} from '@api';
class LaunchButton extends React.Component {
static propTypes = {
templateId: number.isRequired,
resource: shape({
id: number.isRequired,
}).isRequired,
};
constructor(props) {
@ -22,6 +31,7 @@ class LaunchButton extends React.Component {
};
this.handleLaunch = this.handleLaunch.bind(this);
this.handleRelaunch = this.handleRelaunch.bind(this);
this.handleLaunchErrorClose = this.handleLaunchErrorClose.bind(this);
this.handlePromptErrorClose = this.handlePromptErrorClose.bind(this);
}
@ -35,13 +45,56 @@ class LaunchButton extends React.Component {
}
async handleLaunch() {
const { history, templateId } = this.props;
const { history, resource } = this.props;
try {
const { data: launchConfig } = await JobTemplatesAPI.readLaunch(
templateId
resource.id
);
if (launchConfig.can_start_without_user_input) {
const { data: job } = await JobTemplatesAPI.launch(templateId);
const { data: job } = await JobTemplatesAPI.launch(resource.id);
history.push(`/jobs/${job.id}/details`);
} else {
this.setState({ promptError: true });
}
} catch (err) {
this.setState({ launchError: err });
}
}
async handleRelaunch() {
const { history, resource } = this.props;
let readRelaunch;
let relaunch;
if (resource.type === 'inventory_update') {
// We'll need to handle the scenario where the src no longer exists
readRelaunch = InventorySourcesAPI.readLaunchUpdate(
resource.inventory_source
);
relaunch = InventorySourcesAPI.launchUpdate(resource.inventory_source);
} else if (resource.type === 'project_update') {
// We'll need to handle the scenario where the project no longer exists
readRelaunch = ProjectsAPI.readLaunchUpdate(resource.project);
relaunch = ProjectsAPI.launchUpdate(resource.project);
} else if (resource.type === 'workflow_job') {
readRelaunch = WorkflowJobsAPI.readRelaunch(resource.id);
relaunch = WorkflowJobsAPI.relaunch(resource.id);
} else if (resource.type === 'ad_hoc_command') {
readRelaunch = AdHocCommandsAPI.readRelaunch(resource.id);
relaunch = AdHocCommandsAPI.relaunch(resource.id);
} else if (resource.type === 'job') {
readRelaunch = JobsAPI.readRelaunch(resource.id);
relaunch = JobsAPI.relaunch(resource.id);
}
try {
const { data: relaunchConfig } = await readRelaunch;
if (
!relaunchConfig.passwords_needed_to_start ||
relaunchConfig.passwords_needed_to_start.length === 0
) {
const { data: job } = await relaunch;
history.push(`/jobs/${job.id}/details`);
} else {
this.setState({ promptError: true });
@ -56,26 +109,33 @@ class LaunchButton extends React.Component {
const { i18n, children } = this.props;
return (
<Fragment>
{children(this.handleLaunch)}
<AlertModal
isOpen={launchError}
variant="danger"
title={i18n._(t`Error!`)}
onClose={this.handleLaunchErrorClose}
>
{i18n._(t`Failed to launch job.`)}
<ErrorDetail error={launchError} />
</AlertModal>
<AlertModal
isOpen={promptError}
variant="info"
title={i18n._(t`Attention!`)}
onClose={this.handlePromptErrorClose}
>
{i18n._(
t`Launching jobs with promptable fields is not supported at this time.`
)}
</AlertModal>
{children({
handleLaunch: this.handleLaunch,
handleRelaunch: this.handleRelaunch,
})}
{launchError && (
<AlertModal
isOpen={launchError}
variant="danger"
title={i18n._(t`Error!`)}
onClose={this.handleLaunchErrorClose}
>
{i18n._(t`Failed to launch job.`)}
<ErrorDetail error={launchError} />
</AlertModal>
)}
{promptError && (
<AlertModal
isOpen={promptError}
variant="info"
title={i18n._(t`Attention!`)}
onClose={this.handlePromptErrorClose}
>
{i18n._(
t`Launching jobs with promptable fields is not supported at this time.`
)}
</AlertModal>
)}
</Fragment>
);
}

View File

@ -1,5 +1,5 @@
import React from 'react';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import { sleep } from '@testUtils/testUtils';
import LaunchButton from './LaunchButton';
@ -13,13 +13,19 @@ describe('LaunchButton', () => {
can_start_without_user_input: true,
},
});
const children = handleLaunch => (
<button type="submit" onClick={handleLaunch} />
const children = ({ handleLaunch }) => (
<button type="submit" onClick={() => handleLaunch()} />
);
const resource = {
id: 1,
type: 'job_template',
};
test('renders the expected content', () => {
const wrapper = mountWithContexts(
<LaunchButton templateId={1}>{children}</LaunchButton>
<LaunchButton resource={resource}>{children}</LaunchButton>
);
expect(wrapper).toHaveLength(1);
});
@ -33,7 +39,7 @@ describe('LaunchButton', () => {
},
});
const wrapper = mountWithContexts(
<LaunchButton templateId={1}>{children}</LaunchButton>,
<LaunchButton resource={resource}>{children}</LaunchButton>,
{
context: {
router: { history },
@ -62,22 +68,17 @@ describe('LaunchButton', () => {
})
);
const wrapper = mountWithContexts(
<LaunchButton templateId={1}>{children}</LaunchButton>
);
const button = wrapper.find('button');
button.prop('onClick')();
await waitForElement(
wrapper,
'Modal.at-c-alertModal--danger',
el => el.props().isOpen === true && el.props().title === 'Error!'
);
const modalCloseButton = wrapper.find('ModalBoxCloseButton');
modalCloseButton.simulate('click');
await waitForElement(
wrapper,
'Modal.at-c-alertModal--danger',
el => el.props().isOpen === false
<LaunchButton resource={resource}>{children}</LaunchButton>
);
expect(wrapper.find('Modal').length).toBe(0);
wrapper.find('button').prop('onClick')();
await sleep(0);
wrapper.update();
expect(wrapper.find('Modal').length).toBe(1);
wrapper.find('ModalBoxCloseButton').simulate('click');
await sleep(0);
wrapper.update();
expect(wrapper.find('Modal').length).toBe(0);
done();
});
});

View File

@ -10,6 +10,7 @@ import { DetailList, Detail } from '@components/DetailList';
import { ChipGroup, Chip, CredentialChip } from '@components/Chip';
import { VariablesInput as _VariablesInput } from '@components/CodeMirrorInput';
import ErrorDetail from '@components/ErrorDetail';
import LaunchButton from '@components/LaunchButton';
import { StatusIcon } from '@components/Sparkline';
import { toTitleCase } from '@util/strings';
import { Job } from '../../../types';
@ -254,6 +255,16 @@ function JobDetail({ job, i18n, history }) {
/>
)}
<ActionButtonWrapper>
{job.type !== 'system_job' &&
job.summary_fields.user_capabilities.start && (
<LaunchButton resource={job} aria-label={i18n._(t`Relaunch`)}>
{({ handleRelaunch }) => (
<Button type="submit" onClick={handleRelaunch}>
{i18n._(t`Relaunch`)}
</Button>
)}
</LaunchButton>
)}
<Button
variant="danger"
aria-label={i18n._(t`Delete`)}

View File

@ -1,19 +1,35 @@
import React, { Component } from 'react';
import { Link } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
DataListItem,
DataListItemRow,
DataListItemCells,
Tooltip,
Button as PFButton,
} from '@patternfly/react-core';
import { RocketIcon } from '@patternfly/react-icons';
import styled from 'styled-components';
import DataListCell from '@components/DataListCell';
import DataListCheck from '@components/DataListCheck';
import LaunchButton from '@components/LaunchButton';
import VerticalSeparator from '@components/VerticalSeparator';
import { toTitleCase } from '@util/strings';
import { JOB_TYPE_URL_SEGMENTS } from '../../../constants';
const StyledButton = styled(PFButton)`
padding: 5px 8px;
border: none;
&:hover {
background-color: #0066cc;
color: white;
}
`;
class JobListItem extends Component {
render() {
const { job, isSelected, onSelect } = this.props;
const { i18n, job, isSelected, onSelect } = this.props;
return (
<DataListItem
@ -41,6 +57,23 @@ class JobListItem extends Component {
</DataListCell>,
<DataListCell key="type">{toTitleCase(job.type)}</DataListCell>,
<DataListCell key="finished">{job.finished}</DataListCell>,
<DataListCell lastcolumn="true" key="relaunch">
{job.type !== 'system_job' &&
job.summary_fields.user_capabilities.start && (
<Tooltip content={i18n._(t`Relaunch`)} position="top">
<LaunchButton resource={job}>
{({ handleRelaunch }) => (
<StyledButton
variant="plain"
onClick={handleRelaunch}
>
<RocketIcon />
</StyledButton>
)}
</LaunchButton>
</Tooltip>
)}
</DataListCell>,
]}
/>
</DataListItemRow>
@ -49,4 +82,4 @@ class JobListItem extends Component {
}
}
export { JobListItem as _JobListItem };
export default JobListItem;
export default withI18n()(JobListItem);

View File

@ -16,6 +16,11 @@ describe('<JobListItem />', () => {
id: 1,
name: 'Job',
type: 'project update',
summary_fields: {
user_capabilities: {
start: true,
},
},
}}
detailUrl="/organization/1"
isSelected

View File

@ -280,14 +280,8 @@ class JobTemplateDetail extends Component {
</Button>
)}
{canLaunch && (
<LaunchButton
variant="secondary"
component={Link}
to="/templates"
templateId={template.id}
aria-label={i18n._(t`Launch`)}
>
{handleLaunch => (
<LaunchButton resource={template} aria-label={i18n._(t`Launch`)}>
{({ handleLaunch }) => (
<Button
variant="secondary"
type="submit"

View File

@ -63,12 +63,8 @@ class TemplateListItem extends Component {
<DataListCell lastcolumn="true" key="launch">
{canLaunch && template.type === 'job_template' && (
<Tooltip content={i18n._(t`Launch`)} position="top">
<LaunchButton
component={Link}
to="/templates"
templateId={template.id}
>
{handleLaunch => (
<LaunchButton resource={template}>
{({ handleLaunch }) => (
<StyledButton variant="plain" onClick={handleLaunch}>
<RocketIcon />
</StyledButton>