diff --git a/awx/ui_next/src/components/Workflow/workflowReducer.js b/awx/ui_next/src/components/Workflow/workflowReducer.js index 6d5e5bf635..bb221cdbcb 100644 --- a/awx/ui_next/src/components/Workflow/workflowReducer.js +++ b/awx/ui_next/src/components/Workflow/workflowReducer.js @@ -183,6 +183,7 @@ function createNode(state, node) { fullUnifiedJobTemplate: node.nodeResource, isInvalidLinkTarget: false, promptValues: node.promptValues, + all_parents_must_converge: node.all_parents_must_converge, }); // Ensures that root nodes appear to always run @@ -657,10 +658,19 @@ function updateLink(state, linkType) { function updateNode(state, editedNode) { const { nodeToEdit, nodes } = state; - const { nodeResource, launchConfig, promptValues } = editedNode; + const { + nodeResource, + launchConfig, + promptValues, + all_parents_must_converge, + } = editedNode; const newNodes = [...nodes]; 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.isEdited = true; matchingNode.launchConfig = launchConfig; diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.jsx index 7ae47b6c72..85c1603493 100644 --- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.jsx +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.jsx @@ -59,6 +59,11 @@ const NodeDefaultLabel = styled.p` white-space: nowrap; `; +const ConvergenceLabel = styled.p` + font-size: 12px; + color: #ffffff; +`; + Elapsed.displayName = 'Elapsed'; function WorkflowOutputNode({ i18n, mouseEnter, mouseLeave, node }) { @@ -100,6 +105,30 @@ function WorkflowOutputNode({ i18n, mouseEnter, mouseLeave, node }) { onMouseEnter={mouseEnter} onMouseLeave={mouseLeave} > + {(node.all_parents_must_converge || + node?.originalNodeObject?.all_parents_must_converge) && ( + <> + + + {i18n._(t`ALL`)} + + > + )} { expect(dispatch).toHaveBeenCalledWith({ node: { + all_parents_must_converge: false, linkType: 'success', nodeResource: { id: 448, diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeEditModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeEditModal.jsx index 428986e2fe..8298294324 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeEditModal.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeEditModal.jsx @@ -17,11 +17,13 @@ function NodeEditModal({ i18n }) { nodeType, timeoutMinutes, timeoutSeconds, + convergence, ...rest } = values; let node; if (values.nodeType === 'workflow_approval_template') { node = { + all_parents_must_converge: convergence === 'all', nodeResource: { description: approvalDescription, name: approvalName, @@ -32,6 +34,7 @@ function NodeEditModal({ i18n }) { } else { node = { nodeResource, + all_parents_must_converge: convergence === 'all', }; if (nodeType === 'job_template' || nodeType === 'workflow_job_template') { node.promptValues = { diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeEditModal.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeEditModal.test.jsx index 08b046d27b..2a9ee455b7 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeEditModal.test.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeEditModal.test.jsx @@ -63,6 +63,7 @@ describe('NodeEditModal', () => { }); expect(dispatch).toHaveBeenCalledWith({ node: { + all_parents_must_converge: false, nodeResource: { id: 448, name: 'Test JT', type: 'job_template' }, }, type: 'UPDATE_NODE', diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeModal.jsx index 9883ccec19..631ee9cc31 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeModal.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeModal.jsx @@ -101,7 +101,6 @@ function NodeModalForm({ values.extra_data = extraVars && parseVariableField(extraVars); delete values.extra_vars; } - onSave(values, launchConfig); }; @@ -357,6 +356,7 @@ const NodeModal = ({ onSave, i18n, askLinkType, title }) => { approvalDescription: '', timeoutMinutes: 0, timeoutSeconds: 0, + convergence: 'any', linkType: 'success', nodeResource: nodeToEdit?.fullUnifiedJobTemplate || null, nodeType: nodeToEdit?.fullUnifiedJobTemplate?.type || 'job_template', diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeModal.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeModal.test.jsx index 0123f66518..b07af7268b 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeModal.test.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeModal.test.jsx @@ -307,6 +307,7 @@ describe('NodeModal', () => { }); expect(onSave).toBeCalledWith( { + convergence: 'any', linkType: 'always', nodeType: 'job_template', inventory: { name: 'Foo Inv', id: 1 }, @@ -345,6 +346,7 @@ describe('NodeModal', () => { }); expect(onSave).toBeCalledWith( { + convergence: 'any', linkType: 'failure', nodeResource: { id: 1, @@ -383,6 +385,7 @@ describe('NodeModal', () => { }); expect(onSave).toBeCalledWith( { + convergence: 'any', linkType: 'failure', nodeResource: { id: 1, @@ -422,6 +425,7 @@ describe('NodeModal', () => { }); expect(onSave).toBeCalledWith( { + convergence: 'any', linkType: 'success', nodeResource: { id: 1, @@ -506,6 +510,7 @@ describe('NodeModal', () => { }); expect(onSave).toBeCalledWith( { + convergence: 'any', approvalDescription: 'Test Approval Description', approvalName: 'Test Approval', linkType: 'always', @@ -605,6 +610,7 @@ describe('NodeModal', () => { expect(onSave).toBeCalledWith( { + convergence: 'any', approvalDescription: 'Test Approval Description', approvalName: 'Test Approval', linkType: 'success', @@ -668,6 +674,7 @@ describe('NodeModal', () => { }); expect(onSave).toBeCalledWith( { + convergence: 'any', linkType: 'success', nodeResource: { id: 1, diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/NodeTypeStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/NodeTypeStep.jsx index 8b653582a9..028db20329 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/NodeTypeStep.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/NodeTypeStep.jsx @@ -1,13 +1,25 @@ import 'styled-components/macro'; -import React from 'react'; +import React, { useState } from 'react'; import { withI18n } from '@lingui/react'; import { t, Trans } from '@lingui/macro'; import styled from 'styled-components'; 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 { FormFullWidthLayout } from '../../../../../../components/FormLayout'; +import { + FormColumnLayout, + FormFullWidthLayout, +} from '../../../../../../components/FormLayout'; +import Popover from '../../../../../../components/Popover'; import AnsibleSelect from '../../../../../../components/AnsibleSelect'; import InventorySourcesList from './InventorySourcesList'; import JobTemplatesList from './JobTemplatesList'; @@ -44,6 +56,9 @@ function NodeTypeStep({ i18n }) { const [timeoutSecondsField, , timeoutSecondsHelpers] = useField( 'timeoutSeconds' ); + const [convergenceField, , convergenceFieldHelpers] = useField('convergence'); + + const [isConvergenceOpen, setIsConvergenceOpen] = useState(false); const isValid = !approvalNameMeta.touched || !approvalNameMeta.error; return ( @@ -101,6 +116,7 @@ function NodeTypeStep({ i18n }) { approvalDescriptionHelpers.setValue(''); timeoutMinutesHelpers.setValue(0); timeoutSecondsHelpers.setValue(0); + convergenceFieldHelpers.setValue('any'); }} /> @@ -129,61 +145,107 @@ function NodeTypeStep({ i18n }) { onUpdateNodeResource={nodeResourceHelpers.setValue} /> )} - {nodeTypeField.value === 'workflow_approval_template' && ( - - - - - + + {nodeTypeField.value === 'workflow_approval_template' && ( + + + + + + { + timeoutMinutesField.onChange(event); + }} + step="1" + type="number" + /> + + min + + { + timeoutSecondsField.onChange(event); + }} + step="1" + type="number" + /> + + sec + + + + + )} + + {i18n._( + t`Preconditions for running this node when there are multiple parents. Refer to the` + )}{' '} + + {i18n._(t`documentation`)} + {' '} + {i18n._(t`for more info.`)} + > + } + /> + } + > + { + convergenceFieldHelpers.setValue(selection); + setIsConvergenceOpen(false); + }} + aria-label={i18n._(t`Convergence select`)} + id="convergence-select" > - - { - timeoutMinutesField.onChange(event); - }} - step="1" - type="number" - /> - - min - - { - timeoutSecondsField.onChange(event); - }} - step="1" - type="number" - /> - - sec - - - - - - )} + + {i18n._(t`Any`)} + + + {i18n._(t`All`)} + + + + + > ); } diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/NodeTypeStep.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/NodeTypeStep.test.jsx index 580ba1ee1e..235544486f 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/NodeTypeStep.test.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/NodeTypeStep.test.jsx @@ -177,6 +177,7 @@ describe('NodeTypeStep', () => { approvalDescription: '', timeoutMinutes: 0, timeoutSeconds: 0, + convergence: 'any', }} > diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/useNodeTypeStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/useNodeTypeStep.jsx index d7c83097e9..5931d2041b 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/useNodeTypeStep.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/useNodeTypeStep.jsx @@ -86,5 +86,6 @@ function getInitialValues() { timeoutMinutes: 0, timeoutSeconds: 0, nodeType: 'job_template', + convergence: 'any', }; } diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeViewModal.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeViewModal.test.jsx index 8af36ff31f..03eed26ad0 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeViewModal.test.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeViewModal.test.jsx @@ -282,6 +282,7 @@ describe('NodeViewModal', () => { description: '', type: 'workflow_approval_template', timeout: 0, + all_parents_must_converge: false, }, }, }; diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/useWorkflowNodeSteps.js b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/useWorkflowNodeSteps.js index e882a93329..9d60376c64 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/useWorkflowNodeSteps.js +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/useWorkflowNodeSteps.js @@ -39,6 +39,11 @@ const getNodeToEditDefaultValues = ( const initialValues = { nodeResource: nodeToEdit?.fullUnifiedJobTemplate || null, nodeType: nodeToEdit?.fullUnifiedJobTemplate?.type || 'job_template', + convergence: + nodeToEdit?.all_parents_must_converge || + nodeToEdit?.originalNodeObject?.all_parents_must_converge + ? 'all' + : 'any', }; if ( @@ -228,7 +233,6 @@ export default function useWorkflowNodeSteps( useEffect(() => { if (launchConfig && surveyConfig && isReady) { let initialValues = {}; - if ( nodeToEdit && nodeToEdit?.fullUnifiedJobTemplate && @@ -264,10 +268,15 @@ export default function useWorkflowNodeSteps( ); } + if (initialValues.convergence === 'all') { + formikValues.convergence = 'all'; + } + resetForm({ errors, values: { ...initialValues, + convergence: formikValues.convergence, nodeResource: formikValues.nodeResource, nodeType: formikValues.nodeType, linkType: formikValues.linkType, diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx index 5e0b8e2006..b94bd7ac44 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx @@ -369,27 +369,24 @@ function Visualizer({ template, i18n }) { node.fullUnifiedJobTemplate.type === 'workflow_approval_template' ) { nodeRequests.push( - WorkflowJobTemplatesAPI.createNode(template.id, {}).then( - ({ data }) => { - node.originalNodeObject = data; - originalLinkMap[node.id] = { - id: data.id, - success_nodes: [], - failure_nodes: [], - always_nodes: [], - }; - approvalTemplateRequests.push( - WorkflowJobTemplateNodesAPI.createApprovalTemplate( - data.id, - { - name: node.fullUnifiedJobTemplate.name, - description: node.fullUnifiedJobTemplate.description, - timeout: node.fullUnifiedJobTemplate.timeout, - } - ) - ); - } - ) + WorkflowJobTemplatesAPI.createNode(template.id, { + all_parents_must_converge: node.all_parents_must_converge, + }).then(({ data }) => { + node.originalNodeObject = data; + originalLinkMap[node.id] = { + id: data.id, + success_nodes: [], + failure_nodes: [], + always_nodes: [], + }; + approvalTemplateRequests.push( + WorkflowJobTemplateNodesAPI.createApprovalTemplate(data.id, { + name: node.fullUnifiedJobTemplate.name, + description: node.fullUnifiedJobTemplate.description, + timeout: node.fullUnifiedJobTemplate.timeout, + }) + ); + }) ); } else { nodeRequests.push( @@ -397,6 +394,7 @@ function Visualizer({ template, i18n }) { ...node.promptValues, inventory: node.promptValues?.inventory?.id || null, unified_job_template: node.fullUnifiedJobTemplate.id, + all_parents_must_converge: node.all_parents_must_converge, }).then(({ data }) => { node.originalNodeObject = data; originalLinkMap[node.id] = { @@ -427,27 +425,47 @@ function Visualizer({ template, i18n }) { node.originalNodeObject.summary_fields.unified_job_template .unified_job_type === 'workflow_approval' ) { - 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 { - approvalTemplateRequests.push( - WorkflowJobTemplateNodesAPI.createApprovalTemplate( + nodeRequests.push( + WorkflowJobTemplateNodesAPI.replace( node.originalNodeObject.id, { - name: node.fullUnifiedJobTemplate.name, - description: node.fullUnifiedJobTemplate.description, - timeout: node.fullUnifiedJobTemplate.timeout, + all_parents_must_converge: node.all_parents_must_converge, } - ) + ).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 { @@ -456,6 +474,7 @@ function Visualizer({ template, i18n }) { ...node.promptValues, inventory: node.promptValues?.inventory?.id || null, unified_job_template: node.fullUnifiedJobTemplate.id, + all_parents_must_converge: node.all_parents_must_converge, }).then(() => { const { added: addedCredentials, diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.test.jsx index c9a5795929..dcd0c64524 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.test.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.test.jsx @@ -419,6 +419,7 @@ describe('Visualizer', () => { ).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 () => { workflowReducer.mockImplementation(state => { const newState = { @@ -459,6 +460,17 @@ describe('Visualizer', () => { results: [], }, }); + WorkflowJobTemplateNodesAPI.replace.mockResolvedValue({ + data: { + id: 9000, + summary_fields: { + unified_job_template: { + unified_job_type: 'workflow_approval', + id: 1, + }, + }, + }, + }); WorkflowApprovalTemplatesAPI.update.mockRejectedValue(new Error()); await act(async () => { wrapper = mountWithContexts( @@ -475,6 +487,7 @@ describe('Visualizer', () => { wrapper.find('Button#visualizer-save').simulate('click'); }); wrapper.update(); + expect(WorkflowJobTemplateNodesAPI.replace).toHaveBeenCalledTimes(1); expect(WorkflowApprovalTemplatesAPI.update).toHaveBeenCalledTimes(1); expect( wrapper.find('AlertModal[title="Error saving the workflow!"]').length diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx index 65bd1c5008..a92be897f8 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx @@ -44,6 +44,12 @@ const NodeResourceName = styled.p` text-overflow: ellipsis; white-space: nowrap; `; + +const ConvergenceLabel = styled.p` + font-size: 12px; + color: #ffffff; +`; + NodeResourceName.displayName = 'NodeResourceName'; function VisualizerNode({ @@ -244,6 +250,38 @@ function VisualizerNode({ node.id ].y - nodePositions[1].y})`} > + {(node.all_parents_must_converge || + node?.originalNodeObject?.all_parents_must_converge) && ( + <> + + + {i18n._(t`ALL`)} + + > + )}