Add launch button to workflow visualizer

This commit is contained in:
Marliana Lara 2020-03-24 17:34:10 -04:00
parent d941f11ccd
commit abfeb735f0
No known key found for this signature in database
GPG Key ID: 38C73B40DFA809EE
7 changed files with 82 additions and 48 deletions

View File

@ -16,6 +16,18 @@ import {
WorkflowJobTemplatesAPI,
} from '@api';
function canLaunchWithoutPrompt(launchData) {
return (
launchData.can_start_without_user_input &&
!launchData.ask_inventory_on_launch &&
!launchData.ask_variables_on_launch &&
!launchData.ask_limit_on_launch &&
!launchData.ask_scm_branch_on_launch &&
!launchData.survey_enabled &&
launchData.variables_needed_to_start.length === 0
);
}
class LaunchButton extends React.Component {
static propTypes = {
resource: shape({
@ -47,19 +59,23 @@ class LaunchButton extends React.Component {
async handleLaunch() {
const { history, resource } = this.props;
const readLaunch =
resource.type === 'workflow_job_template'
? WorkflowJobTemplatesAPI.readLaunch(resource.id)
: JobTemplatesAPI.readLaunch(resource.id);
const launchJob =
resource.type === 'workflow_job_template'
? WorkflowJobTemplatesAPI.launch(resource.id)
: JobTemplatesAPI.launch(resource.id);
try {
const { data: launchConfig } = await readLaunch;
if (launchConfig.can_start_without_user_input) {
if (canLaunchWithoutPrompt(launchConfig)) {
const { data: job } = await launchJob;
history.push(
`/${
resource.type === 'workflow_job_template' ? 'jobs/workflow' : 'jobs'
@ -107,7 +123,7 @@ class LaunchButton extends React.Component {
relaunchConfig.passwords_needed_to_start.length === 0
) {
const { data: job } = await relaunch;
history.push(`/jobs/${job.id}`);
history.push(`/jobs/${job.id}/output`);
} else {
this.setState({ promptError: true });
}

View File

@ -13,6 +13,12 @@ describe('LaunchButton', () => {
JobTemplatesAPI.readLaunch.mockResolvedValue({
data: {
can_start_without_user_input: true,
ask_inventory_on_launch: false,
ask_variables_on_launch: false,
ask_limit_on_launch: false,
ask_scm_branch_on_launch: false,
survey_enabled: false,
variables_needed_to_start: [],
},
});

View File

@ -1,5 +1,5 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useParams, useLocation } from 'react-router-dom';
import { useLocation } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Card, PageSection } from '@patternfly/react-core';
@ -31,7 +31,6 @@ const QS_CONFIG = getQSConfig('template', {
});
function TemplateList({ i18n }) {
const { id: projectId } = useParams();
const location = useLocation();
const [selected, setSelected] = useState([]);
@ -44,9 +43,6 @@ function TemplateList({ i18n }) {
} = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, location.search);
if (location.pathname.startsWith('/projects') && projectId) {
params.jobtemplate__project = projectId;
}
const results = await Promise.all([
UnifiedJobTemplatesAPI.read(params),
JobTemplatesAPI.readOptions(),
@ -58,7 +54,7 @@ function TemplateList({ i18n }) {
jtActions: results[1].data.actions,
wfjtActions: results[2].data.actions,
};
}, [location, projectId]),
}, [location]),
{
templates: [],
count: 0,
@ -228,7 +224,7 @@ function TemplateList({ i18n }) {
key={template.id}
value={template.name}
template={template}
detailUrl={`${location.pathname}/${template.type}/${template.id}`}
detailUrl={`/templates/${template.type}/${template.id}`}
onSelect={() => handleSelect(template)}
isSelected={selected.some(row => row.id === template.id)}
/>

View File

@ -1,7 +1,5 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { Route } from 'react-router-dom';
import {
JobTemplatesAPI,
UnifiedJobTemplatesAPI,
@ -308,36 +306,4 @@ describe('<TemplateList />', () => {
el => el.props().isOpen === true && el.props().title === 'Error!'
);
});
test('Calls API with jobtemplate__project id', async () => {
const history = createMemoryHistory({
initialEntries: ['/projects/6/job_templates'],
});
const wrapper = mountWithContexts(
<Route
path="/projects/:id/job_templates"
component={() => <TemplateList />}
/>,
{
context: {
router: {
history,
route: {
location: history.location,
match: { params: { id: 6 } },
},
},
},
}
);
await act(async () => {
await waitForElement(wrapper, 'ContentLoading', el => el.length === 1);
});
expect(UnifiedJobTemplatesAPI.read).toBeCalledWith({
jobtemplate__project: '6',
order_by: 'name',
page: 1,
page_size: 20,
type: 'job_template,workflow_job_template',
});
});
});

View File

@ -431,6 +431,7 @@ function Visualizer({ template, i18n }) {
<VisualizerToolbar
onClose={handleVisualizerClose}
onSave={handleVisualizerSave}
hasUnsavedChanges={unsavedChanges}
template={template}
/>
{links.length > 0 ? (

View File

@ -5,7 +5,7 @@ import {
} from '@contexts/Workflow';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { func, shape } from 'prop-types';
import { bool, func, shape } from 'prop-types';
import {
Badge as PFBadge,
Button,
@ -20,6 +20,7 @@ import {
TrashAltIcon,
WrenchIcon,
} from '@patternfly/react-icons';
import LaunchButton from '@components/LaunchButton';
import styled from 'styled-components';
const Badge = styled(PFBadge)`
@ -45,12 +46,20 @@ const ActionButton = styled(Button)`
`;
ActionButton.displayName = 'ActionButton';
function VisualizerToolbar({ i18n, onClose, onSave, template }) {
function VisualizerToolbar({
i18n,
onClose,
onSave,
template,
hasUnsavedChanges,
}) {
const dispatch = useContext(WorkflowDispatchContext);
const { nodes, showLegend, showTools } = useContext(WorkflowStateContext);
const totalNodes = nodes.reduce((n, node) => n + !node.isDeleted, 0) - 1;
const canLaunch =
template.summary_fields?.user_capabilities?.start && !hasUnsavedChanges;
return (
<div id="visualizer-toolbar">
@ -92,9 +101,18 @@ function VisualizerToolbar({ i18n, onClose, onSave, template }) {
>
<BookIcon />
</ActionButton>
<ActionButton id="visualizer-launch" variant="plain" isDisabled>
<RocketIcon />
</ActionButton>
<LaunchButton resource={template} aria-label={i18n._(t`Launch`)}>
{({ handleLaunch }) => (
<ActionButton
id="visualizer-launch"
variant="plain"
isDisabled={!canLaunch}
onClick={handleLaunch}
>
<RocketIcon />
</ActionButton>
)}
</LaunchButton>
<Tooltip content={i18n._(t`Delete All Nodes`)} position="bottom">
<ActionButton
id="visualizer-delete-all"
@ -138,6 +156,7 @@ VisualizerToolbar.propTypes = {
onClose: func.isRequired,
onSave: func.isRequired,
template: shape().isRequired,
hasUnsavedChanges: bool.isRequired,
};
export default withI18n()(VisualizerToolbar);

View File

@ -13,6 +13,11 @@ const save = jest.fn();
const template = {
id: 1,
name: 'Test JT',
summary_fields: {
user_capabilities: {
start: true,
},
},
};
const workflowContext = {
nodes: [],
@ -41,6 +46,7 @@ describe('VisualizerToolbar', () => {
onClose={close}
onSave={save}
template={template}
hasUnsavedChanges={false}
/>
</WorkflowStateContext.Provider>
</WorkflowDispatchContext.Provider>
@ -82,6 +88,30 @@ describe('VisualizerToolbar', () => {
});
});
test('Launch button should be disabled when there are unsaved changes', () => {
expect(wrapper.find('LaunchButton button').prop('disabled')).toEqual(false);
const nodes = [
{
id: 1,
},
];
const disabledToolbar = mountWithContexts(
<WorkflowDispatchContext.Provider value={dispatch}>
<WorkflowStateContext.Provider value={{ ...workflowContext, nodes }}>
<VisualizerToolbar
onClose={close}
onSave={save}
template={template}
hasUnsavedChanges
/>
</WorkflowStateContext.Provider>
</WorkflowDispatchContext.Provider>
);
expect(
disabledToolbar.find('LaunchButton button').prop('disabled')
).toEqual(true);
});
test('Save button calls expected function', () => {
wrapper.find('button[aria-label="Save"]').simulate('click');
expect(save).toHaveBeenCalled();