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
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 Config from './models/Config';
import InstanceGroups from './models/InstanceGroups'; import InstanceGroups from './models/InstanceGroups';
import Inventories from './models/Inventories'; import Inventories from './models/Inventories';
import InventorySources from './models/InventorySources';
import InventoryUpdates from './models/InventoryUpdates'; import InventoryUpdates from './models/InventoryUpdates';
import JobTemplates from './models/JobTemplates'; import JobTemplates from './models/JobTemplates';
import Jobs from './models/Jobs'; import Jobs from './models/Jobs';
@@ -23,6 +24,7 @@ const AdHocCommandsAPI = new AdHocCommands();
const ConfigAPI = new Config(); const ConfigAPI = new Config();
const InstanceGroupsAPI = new InstanceGroups(); const InstanceGroupsAPI = new InstanceGroups();
const InventoriesAPI = new Inventories(); const InventoriesAPI = new Inventories();
const InventorySourcesAPI = new InventorySources();
const InventoryUpdatesAPI = new InventoryUpdates(); const InventoryUpdatesAPI = new InventoryUpdates();
const JobTemplatesAPI = new JobTemplates(); const JobTemplatesAPI = new JobTemplates();
const JobsAPI = new Jobs(); const JobsAPI = new Jobs();
@@ -45,6 +47,7 @@ export {
ConfigAPI, ConfigAPI,
InstanceGroupsAPI, InstanceGroupsAPI,
InventoriesAPI, InventoriesAPI,
InventorySourcesAPI,
InventoryUpdatesAPI, InventoryUpdatesAPI,
JobTemplatesAPI, JobTemplatesAPI,
JobsAPI, 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 Base from '../Base';
import RelaunchMixin from '../mixins/Relaunch.mixin';
class AdHocCommands extends Base { class AdHocCommands extends RelaunchMixin(Base) {
constructor(http) { constructor(http) {
super(http); super(http);
this.baseUrl = '/api/v2/ad_hoc_commands/'; 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 Base from '../Base';
import LaunchUpdateMixin from '../mixins/LaunchUpdate.mixin';
class InventoryUpdates extends Base { class InventoryUpdates extends LaunchUpdateMixin(Base) {
constructor(http) { constructor(http) {
super(http); super(http);
this.baseUrl = '/api/v2/inventory_updates/'; this.baseUrl = '/api/v2/inventory_updates/';

View File

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

View File

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

View File

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

View File

@@ -1,16 +1,25 @@
import React, { Fragment } from 'react'; import React, { Fragment } from 'react';
import { withRouter } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
import { number } from 'prop-types'; import { number, shape } from 'prop-types';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import AlertModal from '@components/AlertModal'; import AlertModal from '@components/AlertModal';
import ErrorDetail from '@components/ErrorDetail'; import ErrorDetail from '@components/ErrorDetail';
import { JobTemplatesAPI } from '@api'; import {
AdHocCommandsAPI,
InventorySourcesAPI,
JobsAPI,
JobTemplatesAPI,
ProjectsAPI,
WorkflowJobsAPI,
} from '@api';
class LaunchButton extends React.Component { class LaunchButton extends React.Component {
static propTypes = { static propTypes = {
templateId: number.isRequired, resource: shape({
id: number.isRequired,
}).isRequired,
}; };
constructor(props) { constructor(props) {
@@ -22,6 +31,7 @@ class LaunchButton extends React.Component {
}; };
this.handleLaunch = this.handleLaunch.bind(this); this.handleLaunch = this.handleLaunch.bind(this);
this.handleRelaunch = this.handleRelaunch.bind(this);
this.handleLaunchErrorClose = this.handleLaunchErrorClose.bind(this); this.handleLaunchErrorClose = this.handleLaunchErrorClose.bind(this);
this.handlePromptErrorClose = this.handlePromptErrorClose.bind(this); this.handlePromptErrorClose = this.handlePromptErrorClose.bind(this);
} }
@@ -35,13 +45,56 @@ class LaunchButton extends React.Component {
} }
async handleLaunch() { async handleLaunch() {
const { history, templateId } = this.props; const { history, resource } = this.props;
try { try {
const { data: launchConfig } = await JobTemplatesAPI.readLaunch( const { data: launchConfig } = await JobTemplatesAPI.readLaunch(
templateId resource.id
); );
if (launchConfig.can_start_without_user_input) { 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`); history.push(`/jobs/${job.id}/details`);
} else { } else {
this.setState({ promptError: true }); this.setState({ promptError: true });
@@ -56,26 +109,33 @@ class LaunchButton extends React.Component {
const { i18n, children } = this.props; const { i18n, children } = this.props;
return ( return (
<Fragment> <Fragment>
{children(this.handleLaunch)} {children({
<AlertModal handleLaunch: this.handleLaunch,
isOpen={launchError} handleRelaunch: this.handleRelaunch,
variant="danger" })}
title={i18n._(t`Error!`)} {launchError && (
onClose={this.handleLaunchErrorClose} <AlertModal
> isOpen={launchError}
{i18n._(t`Failed to launch job.`)} variant="danger"
<ErrorDetail error={launchError} /> title={i18n._(t`Error!`)}
</AlertModal> onClose={this.handleLaunchErrorClose}
<AlertModal >
isOpen={promptError} {i18n._(t`Failed to launch job.`)}
variant="info" <ErrorDetail error={launchError} />
title={i18n._(t`Attention!`)} </AlertModal>
onClose={this.handlePromptErrorClose} )}
> {promptError && (
{i18n._( <AlertModal
t`Launching jobs with promptable fields is not supported at this time.` isOpen={promptError}
)} variant="info"
</AlertModal> title={i18n._(t`Attention!`)}
onClose={this.handlePromptErrorClose}
>
{i18n._(
t`Launching jobs with promptable fields is not supported at this time.`
)}
</AlertModal>
)}
</Fragment> </Fragment>
); );
} }

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; import { mountWithContexts } from '@testUtils/enzymeHelpers';
import { sleep } from '@testUtils/testUtils'; import { sleep } from '@testUtils/testUtils';
import LaunchButton from './LaunchButton'; import LaunchButton from './LaunchButton';
@@ -13,13 +13,19 @@ describe('LaunchButton', () => {
can_start_without_user_input: true, 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', () => { test('renders the expected content', () => {
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<LaunchButton templateId={1}>{children}</LaunchButton> <LaunchButton resource={resource}>{children}</LaunchButton>
); );
expect(wrapper).toHaveLength(1); expect(wrapper).toHaveLength(1);
}); });
@@ -33,7 +39,7 @@ describe('LaunchButton', () => {
}, },
}); });
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<LaunchButton templateId={1}>{children}</LaunchButton>, <LaunchButton resource={resource}>{children}</LaunchButton>,
{ {
context: { context: {
router: { history }, router: { history },
@@ -62,22 +68,17 @@ describe('LaunchButton', () => {
}) })
); );
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<LaunchButton templateId={1}>{children}</LaunchButton> <LaunchButton resource={resource}>{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
); );
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(); done();
}); });
}); });

View File

@@ -10,6 +10,7 @@ import { DetailList, Detail } from '@components/DetailList';
import { ChipGroup, Chip, CredentialChip } from '@components/Chip'; import { ChipGroup, Chip, CredentialChip } from '@components/Chip';
import { VariablesInput as _VariablesInput } from '@components/CodeMirrorInput'; import { VariablesInput as _VariablesInput } from '@components/CodeMirrorInput';
import ErrorDetail from '@components/ErrorDetail'; import ErrorDetail from '@components/ErrorDetail';
import LaunchButton from '@components/LaunchButton';
import { StatusIcon } from '@components/Sparkline'; import { StatusIcon } from '@components/Sparkline';
import { toTitleCase } from '@util/strings'; import { toTitleCase } from '@util/strings';
import { Job } from '../../../types'; import { Job } from '../../../types';
@@ -254,6 +255,16 @@ function JobDetail({ job, i18n, history }) {
/> />
)} )}
<ActionButtonWrapper> <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 <Button
variant="danger" variant="danger"
aria-label={i18n._(t`Delete`)} aria-label={i18n._(t`Delete`)}

View File

@@ -1,19 +1,35 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { import {
DataListItem, DataListItem,
DataListItemRow, DataListItemRow,
DataListItemCells, DataListItemCells,
Tooltip,
Button as PFButton,
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import { RocketIcon } from '@patternfly/react-icons';
import styled from 'styled-components';
import DataListCell from '@components/DataListCell'; import DataListCell from '@components/DataListCell';
import DataListCheck from '@components/DataListCheck'; import DataListCheck from '@components/DataListCheck';
import LaunchButton from '@components/LaunchButton';
import VerticalSeparator from '@components/VerticalSeparator'; import VerticalSeparator from '@components/VerticalSeparator';
import { toTitleCase } from '@util/strings'; import { toTitleCase } from '@util/strings';
import { JOB_TYPE_URL_SEGMENTS } from '../../../constants'; 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 { class JobListItem extends Component {
render() { render() {
const { job, isSelected, onSelect } = this.props; const { i18n, job, isSelected, onSelect } = this.props;
return ( return (
<DataListItem <DataListItem
@@ -41,6 +57,23 @@ class JobListItem extends Component {
</DataListCell>, </DataListCell>,
<DataListCell key="type">{toTitleCase(job.type)}</DataListCell>, <DataListCell key="type">{toTitleCase(job.type)}</DataListCell>,
<DataListCell key="finished">{job.finished}</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> </DataListItemRow>
@@ -49,4 +82,4 @@ class JobListItem extends Component {
} }
} }
export { JobListItem as _JobListItem }; export { JobListItem as _JobListItem };
export default JobListItem; export default withI18n()(JobListItem);

View File

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

View File

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

View File

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