mirror of
https://github.com/ansible/awx.git
synced 2026-03-22 19:35:02 -02:30
Add launch button to workflow visualizer
This commit is contained in:
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user