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
7 changed files with 82 additions and 48 deletions

View File

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

View File

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

View File

@@ -1,7 +1,5 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { Route } from 'react-router-dom';
import { import {
JobTemplatesAPI, JobTemplatesAPI,
UnifiedJobTemplatesAPI, UnifiedJobTemplatesAPI,
@@ -308,36 +306,4 @@ describe('<TemplateList />', () => {
el => el.props().isOpen === true && el.props().title === 'Error!' 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 <VisualizerToolbar
onClose={handleVisualizerClose} onClose={handleVisualizerClose}
onSave={handleVisualizerSave} onSave={handleVisualizerSave}
hasUnsavedChanges={unsavedChanges}
template={template} template={template}
/> />
{links.length > 0 ? ( {links.length > 0 ? (

View File

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

View File

@@ -13,6 +13,11 @@ const save = jest.fn();
const template = { const template = {
id: 1, id: 1,
name: 'Test JT', name: 'Test JT',
summary_fields: {
user_capabilities: {
start: true,
},
},
}; };
const workflowContext = { const workflowContext = {
nodes: [], nodes: [],
@@ -41,6 +46,7 @@ describe('VisualizerToolbar', () => {
onClose={close} onClose={close}
onSave={save} onSave={save}
template={template} template={template}
hasUnsavedChanges={false}
/> />
</WorkflowStateContext.Provider> </WorkflowStateContext.Provider>
</WorkflowDispatchContext.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', () => { test('Save button calls expected function', () => {
wrapper.find('button[aria-label="Save"]').simulate('click'); wrapper.find('button[aria-label="Save"]').simulate('click');
expect(save).toHaveBeenCalled(); expect(save).toHaveBeenCalled();