Merge pull request #9737 from jlmitch5/workflowConvergence

workflow convergence

Satisfies #7669
This adds the ability to set convergence from any nodes (default) to all nodes to the workflow feature.  Specifically:
There is an additional convergence dropdown located on the bottom of the first "node type" step for all node types:

This field defaults to "Any" on add node and whatever the api has the field set to on edit node.  It resets to any if you change the node type dropdown (even on edit when changing and then changing back to the original type...I can update that point depending on what UX is preferred).
tvo created a new link explicitly to the explanation in documentation of what the convergence setting does here, and I link to it in the help popover shown in the screenshot below.
Consistent with the old UI, When "All" is selected, a small tab is displayed with the label "ALL" in the node visualizer.  "Any" nodes do not get any sort of tab.

A slight tweak compared to the old ui...I set the tab's border and background to be the same color as the border of the node to create a consistent look for the node across various states and confirmed this behavior was good with @trahman73
OLD:



NEW:

Reviewed-by: Jake McDermott <yo@jakemcdermott.me>
Reviewed-by: John Hill <johill@redhat.com>
This commit is contained in:
softwarefactory-project-zuul[bot]
2021-04-02 16:53:50 +00:00
committed by GitHub
16 changed files with 299 additions and 99 deletions

View File

@@ -183,6 +183,7 @@ function createNode(state, node) {
fullUnifiedJobTemplate: node.nodeResource, fullUnifiedJobTemplate: node.nodeResource,
isInvalidLinkTarget: false, isInvalidLinkTarget: false,
promptValues: node.promptValues, promptValues: node.promptValues,
all_parents_must_converge: node.all_parents_must_converge,
}); });
// Ensures that root nodes appear to always run // Ensures that root nodes appear to always run
@@ -657,10 +658,19 @@ function updateLink(state, linkType) {
function updateNode(state, editedNode) { function updateNode(state, editedNode) {
const { nodeToEdit, nodes } = state; const { nodeToEdit, nodes } = state;
const { nodeResource, launchConfig, promptValues } = editedNode; const {
nodeResource,
launchConfig,
promptValues,
all_parents_must_converge,
} = editedNode;
const newNodes = [...nodes]; const newNodes = [...nodes];
const matchingNode = newNodes.find(node => node.id === nodeToEdit.id); const matchingNode = newNodes.find(node => node.id === nodeToEdit.id);
matchingNode.all_parents_must_converge = all_parents_must_converge;
if (matchingNode.originalNodeObject) {
delete matchingNode.originalNodeObject.all_parents_must_converge;
}
matchingNode.fullUnifiedJobTemplate = nodeResource; matchingNode.fullUnifiedJobTemplate = nodeResource;
matchingNode.isEdited = true; matchingNode.isEdited = true;
matchingNode.launchConfig = launchConfig; matchingNode.launchConfig = launchConfig;

View File

@@ -59,6 +59,11 @@ const NodeDefaultLabel = styled.p`
white-space: nowrap; white-space: nowrap;
`; `;
const ConvergenceLabel = styled.p`
font-size: 12px;
color: #ffffff;
`;
Elapsed.displayName = 'Elapsed'; Elapsed.displayName = 'Elapsed';
function WorkflowOutputNode({ i18n, mouseEnter, mouseLeave, node }) { function WorkflowOutputNode({ i18n, mouseEnter, mouseLeave, node }) {
@@ -100,6 +105,30 @@ function WorkflowOutputNode({ i18n, mouseEnter, mouseLeave, node }) {
onMouseEnter={mouseEnter} onMouseEnter={mouseEnter}
onMouseLeave={mouseLeave} onMouseLeave={mouseLeave}
> >
{(node.all_parents_must_converge ||
node?.originalNodeObject?.all_parents_must_converge) && (
<>
<rect
fill={borderColor}
height={wfConstants.nodeH / 4}
rx={2}
ry={2}
x={wfConstants.nodeW / 2 - wfConstants.nodeW / 10}
y={-wfConstants.nodeH / 4 + 2}
stroke={borderColor}
strokeWidth="2px"
width={wfConstants.nodeW / 5}
/>
<foreignObject
height={wfConstants.nodeH / 4}
width={wfConstants.nodeW / 5}
x={wfConstants.nodeW / 2 - wfConstants.nodeW / 10 + 7}
y={-wfConstants.nodeH / 4 - 1}
>
<ConvergenceLabel>{i18n._(t`ALL`)}</ConvergenceLabel>
</foreignObject>
</>
)}
<rect <rect
fill="#FFFFFF" fill="#FFFFFF"
height={wfConstants.nodeH} height={wfConstants.nodeH}

View File

@@ -19,6 +19,7 @@ function NodeAddModal({ i18n }) {
timeoutMinutes, timeoutMinutes,
timeoutSeconds, timeoutSeconds,
linkType, linkType,
convergence,
} = values; } = values;
if (values) { if (values) {
@@ -33,8 +34,11 @@ function NodeAddModal({ i18n }) {
const node = { const node = {
linkType, linkType,
all_parents_must_converge: convergence === 'all',
}; };
delete values.convergence;
delete values.linkType; delete values.linkType;
if (values.nodeType === 'workflow_approval_template') { if (values.nodeType === 'workflow_approval_template') {

View File

@@ -48,6 +48,7 @@ describe('NodeAddModal', () => {
expect(dispatch).toHaveBeenCalledWith({ expect(dispatch).toHaveBeenCalledWith({
node: { node: {
all_parents_must_converge: false,
linkType: 'success', linkType: 'success',
nodeResource: { nodeResource: {
id: 448, id: 448,

View File

@@ -17,11 +17,13 @@ function NodeEditModal({ i18n }) {
nodeType, nodeType,
timeoutMinutes, timeoutMinutes,
timeoutSeconds, timeoutSeconds,
convergence,
...rest ...rest
} = values; } = values;
let node; let node;
if (values.nodeType === 'workflow_approval_template') { if (values.nodeType === 'workflow_approval_template') {
node = { node = {
all_parents_must_converge: convergence === 'all',
nodeResource: { nodeResource: {
description: approvalDescription, description: approvalDescription,
name: approvalName, name: approvalName,
@@ -32,6 +34,7 @@ function NodeEditModal({ i18n }) {
} else { } else {
node = { node = {
nodeResource, nodeResource,
all_parents_must_converge: convergence === 'all',
}; };
if (nodeType === 'job_template' || nodeType === 'workflow_job_template') { if (nodeType === 'job_template' || nodeType === 'workflow_job_template') {
node.promptValues = { node.promptValues = {

View File

@@ -63,6 +63,7 @@ describe('NodeEditModal', () => {
}); });
expect(dispatch).toHaveBeenCalledWith({ expect(dispatch).toHaveBeenCalledWith({
node: { node: {
all_parents_must_converge: false,
nodeResource: { id: 448, name: 'Test JT', type: 'job_template' }, nodeResource: { id: 448, name: 'Test JT', type: 'job_template' },
}, },
type: 'UPDATE_NODE', type: 'UPDATE_NODE',

View File

@@ -101,7 +101,6 @@ function NodeModalForm({
values.extra_data = extraVars && parseVariableField(extraVars); values.extra_data = extraVars && parseVariableField(extraVars);
delete values.extra_vars; delete values.extra_vars;
} }
onSave(values, launchConfig); onSave(values, launchConfig);
}; };
@@ -357,6 +356,7 @@ const NodeModal = ({ onSave, i18n, askLinkType, title }) => {
approvalDescription: '', approvalDescription: '',
timeoutMinutes: 0, timeoutMinutes: 0,
timeoutSeconds: 0, timeoutSeconds: 0,
convergence: 'any',
linkType: 'success', linkType: 'success',
nodeResource: nodeToEdit?.fullUnifiedJobTemplate || null, nodeResource: nodeToEdit?.fullUnifiedJobTemplate || null,
nodeType: nodeToEdit?.fullUnifiedJobTemplate?.type || 'job_template', nodeType: nodeToEdit?.fullUnifiedJobTemplate?.type || 'job_template',

View File

@@ -307,6 +307,7 @@ describe('NodeModal', () => {
}); });
expect(onSave).toBeCalledWith( expect(onSave).toBeCalledWith(
{ {
convergence: 'any',
linkType: 'always', linkType: 'always',
nodeType: 'job_template', nodeType: 'job_template',
inventory: { name: 'Foo Inv', id: 1 }, inventory: { name: 'Foo Inv', id: 1 },
@@ -345,6 +346,7 @@ describe('NodeModal', () => {
}); });
expect(onSave).toBeCalledWith( expect(onSave).toBeCalledWith(
{ {
convergence: 'any',
linkType: 'failure', linkType: 'failure',
nodeResource: { nodeResource: {
id: 1, id: 1,
@@ -383,6 +385,7 @@ describe('NodeModal', () => {
}); });
expect(onSave).toBeCalledWith( expect(onSave).toBeCalledWith(
{ {
convergence: 'any',
linkType: 'failure', linkType: 'failure',
nodeResource: { nodeResource: {
id: 1, id: 1,
@@ -422,6 +425,7 @@ describe('NodeModal', () => {
}); });
expect(onSave).toBeCalledWith( expect(onSave).toBeCalledWith(
{ {
convergence: 'any',
linkType: 'success', linkType: 'success',
nodeResource: { nodeResource: {
id: 1, id: 1,
@@ -506,6 +510,7 @@ describe('NodeModal', () => {
}); });
expect(onSave).toBeCalledWith( expect(onSave).toBeCalledWith(
{ {
convergence: 'any',
approvalDescription: 'Test Approval Description', approvalDescription: 'Test Approval Description',
approvalName: 'Test Approval', approvalName: 'Test Approval',
linkType: 'always', linkType: 'always',
@@ -605,6 +610,7 @@ describe('NodeModal', () => {
expect(onSave).toBeCalledWith( expect(onSave).toBeCalledWith(
{ {
convergence: 'any',
approvalDescription: 'Test Approval Description', approvalDescription: 'Test Approval Description',
approvalName: 'Test Approval', approvalName: 'Test Approval',
linkType: 'success', linkType: 'success',
@@ -668,6 +674,7 @@ describe('NodeModal', () => {
}); });
expect(onSave).toBeCalledWith( expect(onSave).toBeCalledWith(
{ {
convergence: 'any',
linkType: 'success', linkType: 'success',
nodeResource: { nodeResource: {
id: 1, id: 1,

View File

@@ -1,13 +1,25 @@
import 'styled-components/macro'; import 'styled-components/macro';
import React from 'react'; import React, { useState } from 'react';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t, Trans } from '@lingui/macro'; import { t, Trans } from '@lingui/macro';
import styled from 'styled-components'; import styled from 'styled-components';
import { useField } from 'formik'; import { useField } from 'formik';
import { Alert, Form, FormGroup, TextInput } from '@patternfly/react-core'; import {
Alert,
Form,
FormGroup,
TextInput,
Select,
SelectVariant,
SelectOption,
} from '@patternfly/react-core';
import { required } from '../../../../../../util/validators'; import { required } from '../../../../../../util/validators';
import { FormFullWidthLayout } from '../../../../../../components/FormLayout'; import {
FormColumnLayout,
FormFullWidthLayout,
} from '../../../../../../components/FormLayout';
import Popover from '../../../../../../components/Popover';
import AnsibleSelect from '../../../../../../components/AnsibleSelect'; import AnsibleSelect from '../../../../../../components/AnsibleSelect';
import InventorySourcesList from './InventorySourcesList'; import InventorySourcesList from './InventorySourcesList';
import JobTemplatesList from './JobTemplatesList'; import JobTemplatesList from './JobTemplatesList';
@@ -44,6 +56,9 @@ function NodeTypeStep({ i18n }) {
const [timeoutSecondsField, , timeoutSecondsHelpers] = useField( const [timeoutSecondsField, , timeoutSecondsHelpers] = useField(
'timeoutSeconds' 'timeoutSeconds'
); );
const [convergenceField, , convergenceFieldHelpers] = useField('convergence');
const [isConvergenceOpen, setIsConvergenceOpen] = useState(false);
const isValid = !approvalNameMeta.touched || !approvalNameMeta.error; const isValid = !approvalNameMeta.touched || !approvalNameMeta.error;
return ( return (
@@ -101,6 +116,7 @@ function NodeTypeStep({ i18n }) {
approvalDescriptionHelpers.setValue(''); approvalDescriptionHelpers.setValue('');
timeoutMinutesHelpers.setValue(0); timeoutMinutesHelpers.setValue(0);
timeoutSecondsHelpers.setValue(0); timeoutSecondsHelpers.setValue(0);
convergenceFieldHelpers.setValue('any');
}} }}
/> />
</div> </div>
@@ -129,61 +145,108 @@ function NodeTypeStep({ i18n }) {
onUpdateNodeResource={nodeResourceHelpers.setValue} onUpdateNodeResource={nodeResourceHelpers.setValue}
/> />
)} )}
{nodeTypeField.value === 'workflow_approval_template' && ( <Form css="margin-top: 20px;">
<Form css="margin-top: 20px;"> <FormColumnLayout>
<FormFullWidthLayout> {nodeTypeField.value === 'workflow_approval_template' && (
<FormField <FormFullWidthLayout>
name="approvalName" <FormField
id="approval-name" name="approvalName"
isRequired id="approval-name"
validate={required(null, i18n)} isRequired
validated={isValid ? 'default' : 'error'} validate={required(null, i18n)}
label={i18n._(t`Name`)} validated={isValid ? 'default' : 'error'}
/> label={i18n._(t`Name`)}
<FormField />
name="approvalDescription" <FormField
id="approval-description" name="approvalDescription"
label={i18n._(t`Description`)} id="approval-description"
/> label={i18n._(t`Description`)}
<FormGroup />
label={i18n._(t`Timeout`)} <FormGroup
fieldId="approval-timeout" label={i18n._(t`Timeout`)}
name="timeout" fieldId="approval-timeout"
name="timeout"
>
<div css="display: flex;align-items: center;">
<TimeoutInput
{...timeoutMinutesField}
aria-label={i18n._(t`Timeout minutes`)}
id="approval-timeout-minutes"
min="0"
onChange={(value, event) => {
timeoutMinutesField.onChange(event);
}}
step="1"
type="number"
/>
<TimeoutLabel>
<Trans>min</Trans>
</TimeoutLabel>
<TimeoutInput
{...timeoutSecondsField}
aria-label={i18n._(t`Timeout seconds`)}
id="approval-timeout-seconds"
min="0"
onChange={(value, event) => {
timeoutSecondsField.onChange(event);
}}
step="1"
type="number"
/>
<TimeoutLabel>
<Trans>sec</Trans>
</TimeoutLabel>
</div>
</FormGroup>
</FormFullWidthLayout>
)}
<FormGroup
fieldId="convergence"
label={i18n._(t`Convergence`)}
isRequired
labelIcon={
<Popover
content={
<>
{i18n._(
t`Preconditions for running this node when there are multiple parents. Refer to the`
)}{' '}
<a
href="https://docs.ansible.com/ansible-tower/latest/html/userguide/workflow_templates.html#convergence-node"
target="_blank"
rel="noopener noreferrer"
>
{i18n._(t`documentation`)}
</a>{' '}
{i18n._(t`for more info.`)}
</>
}
/>
}
>
<Select
variant={SelectVariant.single}
isOpen={isConvergenceOpen}
selections={convergenceField.value}
onToggle={setIsConvergenceOpen}
onSelect={(event, selection) => {
convergenceFieldHelpers.setValue(selection);
setIsConvergenceOpen(false);
}}
aria-label={i18n._(t`Convergence select`)}
className="convergenceSelect"
ouiaId="convergenceSelect"
> >
<div css="display: flex;align-items: center;"> <SelectOption key="any" value="any" id="select-option-any">
<TimeoutInput {i18n._(t`Any`)}
{...timeoutMinutesField} </SelectOption>
aria-label={i18n._(t`Timeout minutes`)} <SelectOption key="all" value="all" id="select-option-all">
id="approval-timeout-minutes" {i18n._(t`All`)}
min="0" </SelectOption>
onChange={(value, event) => { </Select>
timeoutMinutesField.onChange(event); </FormGroup>
}} </FormColumnLayout>
step="1" </Form>
type="number"
/>
<TimeoutLabel>
<Trans>min</Trans>
</TimeoutLabel>
<TimeoutInput
{...timeoutSecondsField}
aria-label={i18n._(t`Timeout seconds`)}
id="approval-timeout-seconds"
min="0"
onChange={(value, event) => {
timeoutSecondsField.onChange(event);
}}
step="1"
type="number"
/>
<TimeoutLabel>
<Trans>sec</Trans>
</TimeoutLabel>
</div>
</FormGroup>
</FormFullWidthLayout>
</Form>
)}
</> </>
); );
} }

View File

@@ -177,6 +177,7 @@ describe('NodeTypeStep', () => {
approvalDescription: '', approvalDescription: '',
timeoutMinutes: 0, timeoutMinutes: 0,
timeoutSeconds: 0, timeoutSeconds: 0,
convergence: 'any',
}} }}
> >
<NodeTypeStep /> <NodeTypeStep />

View File

@@ -86,5 +86,6 @@ function getInitialValues() {
timeoutMinutes: 0, timeoutMinutes: 0,
timeoutSeconds: 0, timeoutSeconds: 0,
nodeType: 'job_template', nodeType: 'job_template',
convergence: 'any',
}; };
} }

View File

@@ -282,6 +282,7 @@ describe('NodeViewModal', () => {
description: '', description: '',
type: 'workflow_approval_template', type: 'workflow_approval_template',
timeout: 0, timeout: 0,
all_parents_must_converge: false,
}, },
}, },
}; };

View File

@@ -39,6 +39,11 @@ const getNodeToEditDefaultValues = (
const initialValues = { const initialValues = {
nodeResource: nodeToEdit?.fullUnifiedJobTemplate || null, nodeResource: nodeToEdit?.fullUnifiedJobTemplate || null,
nodeType: nodeToEdit?.fullUnifiedJobTemplate?.type || 'job_template', nodeType: nodeToEdit?.fullUnifiedJobTemplate?.type || 'job_template',
convergence:
nodeToEdit?.all_parents_must_converge ||
nodeToEdit?.originalNodeObject?.all_parents_must_converge
? 'all'
: 'any',
}; };
if ( if (
@@ -228,7 +233,6 @@ export default function useWorkflowNodeSteps(
useEffect(() => { useEffect(() => {
if (launchConfig && surveyConfig && isReady) { if (launchConfig && surveyConfig && isReady) {
let initialValues = {}; let initialValues = {};
if ( if (
nodeToEdit && nodeToEdit &&
nodeToEdit?.fullUnifiedJobTemplate && nodeToEdit?.fullUnifiedJobTemplate &&
@@ -264,10 +268,15 @@ export default function useWorkflowNodeSteps(
); );
} }
if (initialValues.convergence === 'all') {
formikValues.convergence = 'all';
}
resetForm({ resetForm({
errors, errors,
values: { values: {
...initialValues, ...initialValues,
convergence: formikValues.convergence,
nodeResource: formikValues.nodeResource, nodeResource: formikValues.nodeResource,
nodeType: formikValues.nodeType, nodeType: formikValues.nodeType,
linkType: formikValues.linkType, linkType: formikValues.linkType,

View File

@@ -369,27 +369,24 @@ function Visualizer({ template, i18n }) {
node.fullUnifiedJobTemplate.type === 'workflow_approval_template' node.fullUnifiedJobTemplate.type === 'workflow_approval_template'
) { ) {
nodeRequests.push( nodeRequests.push(
WorkflowJobTemplatesAPI.createNode(template.id, {}).then( WorkflowJobTemplatesAPI.createNode(template.id, {
({ data }) => { all_parents_must_converge: node.all_parents_must_converge,
node.originalNodeObject = data; }).then(({ data }) => {
originalLinkMap[node.id] = { node.originalNodeObject = data;
id: data.id, originalLinkMap[node.id] = {
success_nodes: [], id: data.id,
failure_nodes: [], success_nodes: [],
always_nodes: [], failure_nodes: [],
}; always_nodes: [],
approvalTemplateRequests.push( };
WorkflowJobTemplateNodesAPI.createApprovalTemplate( approvalTemplateRequests.push(
data.id, WorkflowJobTemplateNodesAPI.createApprovalTemplate(data.id, {
{ name: node.fullUnifiedJobTemplate.name,
name: node.fullUnifiedJobTemplate.name, description: node.fullUnifiedJobTemplate.description,
description: node.fullUnifiedJobTemplate.description, timeout: node.fullUnifiedJobTemplate.timeout,
timeout: node.fullUnifiedJobTemplate.timeout, })
} );
) })
);
}
)
); );
} else { } else {
nodeRequests.push( nodeRequests.push(
@@ -397,6 +394,7 @@ function Visualizer({ template, i18n }) {
...node.promptValues, ...node.promptValues,
inventory: node.promptValues?.inventory?.id || null, inventory: node.promptValues?.inventory?.id || null,
unified_job_template: node.fullUnifiedJobTemplate.id, unified_job_template: node.fullUnifiedJobTemplate.id,
all_parents_must_converge: node.all_parents_must_converge,
}).then(({ data }) => { }).then(({ data }) => {
node.originalNodeObject = data; node.originalNodeObject = data;
originalLinkMap[node.id] = { originalLinkMap[node.id] = {
@@ -427,27 +425,47 @@ function Visualizer({ template, i18n }) {
node.originalNodeObject.summary_fields.unified_job_template node.originalNodeObject.summary_fields.unified_job_template
.unified_job_type === 'workflow_approval' .unified_job_type === 'workflow_approval'
) { ) {
approvalTemplateRequests.push( nodeRequests.push(
WorkflowApprovalTemplatesAPI.update( WorkflowJobTemplateNodesAPI.replace(
node.originalNodeObject.summary_fields.unified_job_template
.id,
{
name: node.fullUnifiedJobTemplate.name,
description: node.fullUnifiedJobTemplate.description,
timeout: node.fullUnifiedJobTemplate.timeout,
}
)
);
} else {
approvalTemplateRequests.push(
WorkflowJobTemplateNodesAPI.createApprovalTemplate(
node.originalNodeObject.id, node.originalNodeObject.id,
{ {
name: node.fullUnifiedJobTemplate.name, all_parents_must_converge: node.all_parents_must_converge,
description: node.fullUnifiedJobTemplate.description,
timeout: node.fullUnifiedJobTemplate.timeout,
} }
) ).then(({ data }) => {
node.originalNodeObject = data;
approvalTemplateRequests.push(
WorkflowApprovalTemplatesAPI.update(
node.originalNodeObject.summary_fields
.unified_job_template.id,
{
name: node.fullUnifiedJobTemplate.name,
description: node.fullUnifiedJobTemplate.description,
timeout: node.fullUnifiedJobTemplate.timeout,
}
)
);
})
);
} else {
nodeRequests.push(
WorkflowJobTemplateNodesAPI.replace(
node.originalNodeObject.id,
{
all_parents_must_converge: node.all_parents_must_converge,
}
).then(({ data }) => {
node.originalNodeObject = data;
approvalTemplateRequests.push(
WorkflowJobTemplateNodesAPI.createApprovalTemplate(
node.originalNodeObject.id,
{
name: node.fullUnifiedJobTemplate.name,
description: node.fullUnifiedJobTemplate.description,
timeout: node.fullUnifiedJobTemplate.timeout,
}
)
);
})
); );
} }
} else { } else {
@@ -456,6 +474,7 @@ function Visualizer({ template, i18n }) {
...node.promptValues, ...node.promptValues,
inventory: node.promptValues?.inventory?.id || null, inventory: node.promptValues?.inventory?.id || null,
unified_job_template: node.fullUnifiedJobTemplate.id, unified_job_template: node.fullUnifiedJobTemplate.id,
all_parents_must_converge: node.all_parents_must_converge,
}).then(() => { }).then(() => {
const { const {
added: addedCredentials, added: addedCredentials,

View File

@@ -419,6 +419,7 @@ describe('Visualizer', () => {
).toBe(1); ).toBe(1);
}); });
// TODO: figure out why this test is failing, the scenario passes in the ui
test('Error shown when saving fails due to approval template edit error', async () => { test('Error shown when saving fails due to approval template edit error', async () => {
workflowReducer.mockImplementation(state => { workflowReducer.mockImplementation(state => {
const newState = { const newState = {
@@ -459,6 +460,17 @@ describe('Visualizer', () => {
results: [], results: [],
}, },
}); });
WorkflowJobTemplateNodesAPI.replace.mockResolvedValue({
data: {
id: 9000,
summary_fields: {
unified_job_template: {
unified_job_type: 'workflow_approval',
id: 1,
},
},
},
});
WorkflowApprovalTemplatesAPI.update.mockRejectedValue(new Error()); WorkflowApprovalTemplatesAPI.update.mockRejectedValue(new Error());
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
@@ -475,6 +487,7 @@ describe('Visualizer', () => {
wrapper.find('Button#visualizer-save').simulate('click'); wrapper.find('Button#visualizer-save').simulate('click');
}); });
wrapper.update(); wrapper.update();
expect(WorkflowJobTemplateNodesAPI.replace).toHaveBeenCalledTimes(1);
expect(WorkflowApprovalTemplatesAPI.update).toHaveBeenCalledTimes(1); expect(WorkflowApprovalTemplatesAPI.update).toHaveBeenCalledTimes(1);
expect( expect(
wrapper.find('AlertModal[title="Error saving the workflow!"]').length wrapper.find('AlertModal[title="Error saving the workflow!"]').length

View File

@@ -44,6 +44,12 @@ const NodeResourceName = styled.p`
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
`; `;
const ConvergenceLabel = styled.p`
font-size: 12px;
color: #ffffff;
`;
NodeResourceName.displayName = 'NodeResourceName'; NodeResourceName.displayName = 'NodeResourceName';
function VisualizerNode({ function VisualizerNode({
@@ -244,6 +250,38 @@ function VisualizerNode({
node.id node.id
].y - nodePositions[1].y})`} ].y - nodePositions[1].y})`}
> >
{(node.all_parents_must_converge ||
node?.originalNodeObject?.all_parents_must_converge) && (
<>
<rect
fill={
hovering && addingLink && !node.isInvalidLinkTarget
? '#007ABC'
: '#93969A'
}
height={wfConstants.nodeH / 4}
rx={2}
ry={2}
x={wfConstants.nodeW / 2 - wfConstants.nodeW / 10}
y={-wfConstants.nodeH / 4 + 2}
stroke={
hovering && addingLink && !node.isInvalidLinkTarget
? '#007ABC'
: '#93969A'
}
strokeWidth="2px"
width={wfConstants.nodeW / 5}
/>
<foreignObject
height={wfConstants.nodeH / 4}
width={wfConstants.nodeW / 5}
x={wfConstants.nodeW / 2 - wfConstants.nodeW / 10 + 7}
y={-wfConstants.nodeH / 4 - 1}
>
<ConvergenceLabel>{i18n._(t`ALL`)}</ConvergenceLabel>
</foreignObject>
</>
)}
<rect <rect
fill="#FFFFFF" fill="#FFFFFF"
height={wfConstants.nodeH} height={wfConstants.nodeH}