diff --git a/awx/ui_next/src/api/models/WorkflowJobTemplateNodes.js b/awx/ui_next/src/api/models/WorkflowJobTemplateNodes.js index 512316a1ab..b628312d03 100644 --- a/awx/ui_next/src/api/models/WorkflowJobTemplateNodes.js +++ b/awx/ui_next/src/api/models/WorkflowJobTemplateNodes.js @@ -55,6 +55,19 @@ class WorkflowJobTemplateNodes extends Base { readCredentials(id) { return this.http.get(`${this.baseUrl}${id}/credentials/`); } + + associateCredentials(id, credentialId) { + return this.http.post(`${this.baseUrl}${id}/credentials/`, { + id: credentialId, + }); + } + + disassociateCredentials(id, credentialId) { + return this.http.post(`${this.baseUrl}${id}/credentials/`, { + id: credentialId, + disassociate: true, + }); + } } export default WorkflowJobTemplateNodes; diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.jsx index bc94fea258..d2ee608401 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.jsx @@ -28,10 +28,9 @@ function PreviewStep({ resource, config, survey, formErrors, i18n }) { const surveyValues = getSurveyValues(values); const overrides = { ...values }; - if (config.ask_variables_on_launch || config.survey_enabled) { const initialExtraVars = config.ask_variables_on_launch - ? values.extra_vars || '---' + ? overrides.extra_vars || '---' : resource.extra_vars; if (survey && survey.spec) { const passwordFields = survey.spec @@ -45,6 +44,10 @@ function PreviewStep({ resource, config, survey, formErrors, i18n }) { overrides.extra_vars = initialExtraVars; } } + // Api expects extra vars to be merged with the survey data. + // We put the extra_data key/value pair on the values object here + // so that we don't have to do this loop again inside of the NodeAddModal.jsx + values.extra_data = overrides.extra_vars; return ( diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.test.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.test.jsx index cd780e1519..b596e866ea 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.test.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.test.jsx @@ -104,31 +104,4 @@ describe('PreviewStep', () => { extra_vars: 'one: 1', }); }); - - test('should remove survey with empty array value', async () => { - let wrapper; - await act(async () => { - wrapper = mountWithContexts( - - - - ); - }); - - const detail = wrapper.find('PromptDetail'); - expect(detail).toHaveLength(1); - expect(detail.prop('resource')).toEqual(resource); - expect(detail.prop('overrides')).toEqual({ - extra_vars: 'one: 1', - }); - }); }); diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialsStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialsStep.jsx index b5506bde31..77278363df 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialsStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialsStep.jsx @@ -4,9 +4,15 @@ import CredentialsStep from './CredentialsStep'; const STEP_ID = 'credentials'; -export default function useCredentialsStep(config, i18n) { +export default function useCredentialsStep(config, i18n, resource) { + const validate = () => { + return {}; + }; + return { step: getStep(config, i18n), + initialValues: getInitialValues(config, resource), + validate, isReady: true, contentError: null, formError: null, @@ -24,7 +30,18 @@ function getStep(config, i18n) { } return { id: STEP_ID, + key: 4, name: i18n._(t`Credentials`), component: , + enableNext: true, + }; +} + +function getInitialValues(config, resource) { + if (!config.ask_credential_on_launch) { + return {}; + } + return { + credentials: resource?.summary_fields?.credentials || [], }; } diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx index 4724be21e0..ba047878cf 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx @@ -6,14 +6,17 @@ import StepName from './StepName'; const STEP_ID = 'inventory'; -export default function useInventoryStep(config, visitedSteps, i18n) { +export default function useInventoryStep(config, i18n, visitedSteps, resource) { const [, meta] = useField('inventory'); + const formError = + Object.keys(visitedSteps).includes(STEP_ID) && (!meta.value || meta.error); return { - step: getStep(config, meta, i18n, visitedSteps), + step: getStep(config, i18n, formError), + initialValues: getInitialValues(config, resource), isReady: true, contentError: null, - formError: !meta.value, + formError: config.ask_inventory_on_launch && formError, setTouched: setFieldsTouched => { setFieldsTouched({ inventory: true, @@ -21,24 +24,25 @@ export default function useInventoryStep(config, visitedSteps, i18n) { }, }; } -function getStep(config, meta, i18n, visitedSteps) { +function getStep(config, i18n, formError) { if (!config.ask_inventory_on_launch) { return null; } return { id: STEP_ID, key: 3, - name: ( - - {i18n._(t`Inventory`)} - - ), + name: {i18n._(t`Inventory`)}, component: , enableNext: true, }; } + +function getInitialValues(config, resource) { + if (!config.ask_inventory_on_launch) { + return {}; + } + + return { + inventory: resource?.summary_fields?.inventory, + }; +} diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx index c03565b358..88773dbfce 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx @@ -1,12 +1,14 @@ import React from 'react'; import { t } from '@lingui/macro'; +import { jsonToYaml, parseVariableField } from '../../../util/yaml'; import OtherPromptsStep from './OtherPromptsStep'; const STEP_ID = 'other'; -export default function useOtherPrompt(config, i18n) { +export default function useOtherPrompt(config, i18n, resource) { return { step: getStep(config, i18n), + initialValues: getInitialValues(config, resource), isReady: true, contentError: null, formError: null, @@ -30,8 +32,10 @@ function getStep(config, i18n) { } return { id: STEP_ID, + key: 5, name: i18n._(t`Other Prompts`), component: , + enableNext: true, }; } @@ -47,3 +51,49 @@ function shouldShowPrompt(config) { config.ask_diff_mode_on_launch ); } + +function getInitialValues(config, resource) { + if (!config) { + return {}; + } + + const getVariablesData = () => { + if (resource?.extra_data) { + return jsonToYaml(JSON.stringify(resource?.extra_data)); + } + if (resource?.extra_vars) { + if (resource.extra_vars !== '---') { + return jsonToYaml( + JSON.stringify(parseVariableField(resource?.extra_vars)) + ); + } + } + return '---'; + }; + const initialValues = {}; + if (config.ask_job_type_on_launch) { + initialValues.job_type = resource?.job_type || ''; + } + if (config.ask_limit_on_launch) { + initialValues.limit = resource?.limit || ''; + } + if (config.ask_verbosity_on_launch) { + initialValues.verbosity = resource?.verbosity || 0; + } + if (config.ask_tags_on_launch) { + initialValues.job_tags = resource?.job_tags || ''; + } + if (config.ask_skip_tags_on_launch) { + initialValues.skip_tags = resource?.skip_tags || ''; + } + if (config.ask_variables_on_launch) { + initialValues.extra_vars = getVariablesData(); + } + if (config.ask_scm_branch_on_launch) { + initialValues.scm_branch = resource?.scm_branch || ''; + } + if (config.ask_diff_mode_on_launch) { + initialValues.diff_mode = resource?.diff_mode || false; + } + return initialValues; +} diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx index e033f7eb93..9777032055 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx @@ -1,5 +1,4 @@ import React from 'react'; -import { useFormikContext } from 'formik'; import { t } from '@lingui/macro'; import PreviewStep from './PreviewStep'; @@ -7,47 +6,33 @@ const STEP_ID = 'preview'; export default function usePreviewStep( config, + i18n, resource, survey, hasErrors, - i18n + needsPreviewStep ) { - const { values: formikValues, errors } = useFormikContext(); - - const formErrorsContent = []; - if (config.ask_inventory_on_launch && !formikValues.inventory) { - formErrorsContent.push({ - inventory: true, - }); - } - const hasSurveyError = Object.keys(errors).find(e => e.includes('survey')); - if ( - config.survey_enabled && - (config.variables_needed_to_start || - config.variables_needed_to_start.length === 0) && - hasSurveyError - ) { - formErrorsContent.push({ - survey: true, - }); - } - + const showStep = needsPreviewStep && resource && Object.keys(config).length > 0; return { - step: { - id: STEP_ID, - name: i18n._(t`Preview`), - component: ( - - ), - enableNext: !hasErrors, - nextButtonText: i18n._(t`Launch`), - }, + step: showStep + ? { + id: STEP_ID, + key: 7, + name: i18n._(t`Preview`), + component: ( + + ), + enableNext: !hasErrors, + nextButtonText: i18n._(t`Launch`), + } + : null, initialValues: {}, + validate: () => ({}), isReady: true, error: null, setTouched: () => {}, diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx index 7137536f09..bbc545fcdd 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx @@ -8,7 +8,7 @@ import StepName from './StepName'; const STEP_ID = 'survey'; -export default function useSurveyStep(config, visitedSteps, i18n) { +export default function useSurveyStep(config, i18n, visitedSteps, resource) { const { values } = useFormikContext(); const { result: survey, request: fetchSurvey, isLoading, error } = useRequest( useCallback(async () => { @@ -28,11 +28,11 @@ export default function useSurveyStep(config, visitedSteps, i18n) { fetchSurvey(); }, [fetchSurvey]); + const errors = {}; const validate = () => { if (!config.survey_enabled || !survey || !survey.spec) { return {}; } - const errors = {}; survey.spec.forEach(question => { const errMessage = validateField( question, @@ -47,12 +47,13 @@ export default function useSurveyStep(config, visitedSteps, i18n) { }; const formError = Object.keys(validate()).length > 0; return { - step: getStep(config, survey, formError, i18n, visitedSteps), - formError, - initialValues: getInitialValues(config, survey), + step: getStep(config, survey, validate, i18n, visitedSteps), + initialValues: getInitialValues(config, survey, resource), + validate, survey, isReady: !isLoading && !!survey, contentError: error, + formError, setTouched: setFieldsTouched => { if (!survey || !survey.spec) { return; @@ -84,24 +85,25 @@ function validateField(question, value, i18n) { ); } } - if ( - question.required && - ((!value && value !== 0) || (Array.isArray(value) && value.length === 0)) - ) { + if (question.required && !value && value !== 0) { return i18n._(t`This field must not be blank`); } return null; } -function getStep(config, survey, hasErrors, i18n, visitedSteps) { +function getStep(config, survey, validate, i18n, visitedSteps) { if (!config.survey_enabled) { return null; } + return { id: STEP_ID, key: 6, name: ( {i18n._(t`Survey`)} @@ -110,23 +112,33 @@ function getStep(config, survey, hasErrors, i18n, visitedSteps) { enableNext: true, }; } -function getInitialValues(config, survey) { + +function getInitialValues(config, survey, resource) { if (!config.survey_enabled || !survey) { return {}; } - const surveyValues = {}; - survey.spec.forEach(question => { - if (question.type === 'multiselect') { - if (question.default === '') { - surveyValues[`survey_${question.variable}`] = []; + + const values = {}; + if (survey && survey.spec) { + survey.spec.forEach(question => { + if (question.type === 'multiselect') { + values[`survey_${question.variable}`] = question.default.split('\n'); } else { - surveyValues[`survey_${question.variable}`] = question.default.split( - '\n' - ); + values[`survey_${question.variable}`] = question.default; } - } else { - surveyValues[`survey_${question.variable}`] = question.default; - } - }); - return surveyValues; + if (resource?.extra_data) { + Object.entries(resource?.extra_data).forEach(([key, value]) => { + if (key === question.variable) { + if (question.type === 'multiselect') { + values[`survey_${question.variable}`] = value.split('\n'); + } else { + values[`survey_${question.variable}`] = value; + } + } + }); + } + }); + } + + return values; } diff --git a/awx/ui_next/src/components/LaunchPrompt/useLaunchSteps.js b/awx/ui_next/src/components/LaunchPrompt/useLaunchSteps.js index caa32fe8ba..0f318dd3e1 100644 --- a/awx/ui_next/src/components/LaunchPrompt/useLaunchSteps.js +++ b/awx/ui_next/src/components/LaunchPrompt/useLaunchSteps.js @@ -9,10 +9,10 @@ import usePreviewStep from './steps/usePreviewStep'; export default function useLaunchSteps(config, resource, i18n) { const [visited, setVisited] = useState({}); const steps = [ - useInventoryStep(config, visited, i18n), + useInventoryStep(config, i18n, visited ), useCredentialsStep(config, i18n), useOtherPromptsStep(config, i18n), - useSurveyStep(config, visited, i18n), + useSurveyStep(config, i18n, visited ), ]; const { resetForm, values: formikValues } = useFormikContext(); const hasErrors = steps.some(step => step.formError); @@ -21,10 +21,11 @@ export default function useLaunchSteps(config, resource, i18n) { steps.push( usePreviewStep( config, + i18n, resource, steps[surveyStepIndex]?.survey, hasErrors, - i18n + ) ); diff --git a/awx/ui_next/src/components/PromptDetail/PromptDetail.jsx b/awx/ui_next/src/components/PromptDetail/PromptDetail.jsx index 8d02bd2cf3..e6eccdbe6a 100644 --- a/awx/ui_next/src/components/PromptDetail/PromptDetail.jsx +++ b/awx/ui_next/src/components/PromptDetail/PromptDetail.jsx @@ -53,6 +53,7 @@ function buildResourceLink(resource) { function hasPromptData(launchData) { return ( + launchData.survey_enabled || launchData.ask_credential_on_launch || launchData.ask_diff_mode_on_launch || launchData.ask_inventory_on_launch || diff --git a/awx/ui_next/src/components/PromptDetail/PromptJobTemplateDetail.jsx b/awx/ui_next/src/components/PromptDetail/PromptJobTemplateDetail.jsx index 5a2a9399e8..2f6a13c6ac 100644 --- a/awx/ui_next/src/components/PromptDetail/PromptJobTemplateDetail.jsx +++ b/awx/ui_next/src/components/PromptDetail/PromptJobTemplateDetail.jsx @@ -72,7 +72,7 @@ function PromptJobTemplateDetail({ i18n, resource }) { ? 'smart_inventory' : 'inventory'; - const recentJobs = summary_fields.recent_jobs.map(job => ({ + const recentJobs = summary_fields?.recent_jobs?.map(job => ({ ...job, type: 'job', })); diff --git a/awx/ui_next/src/components/PromptDetail/PromptWFJobTemplateDetail.jsx b/awx/ui_next/src/components/PromptDetail/PromptWFJobTemplateDetail.jsx index 888f2f78d3..8c644e0251 100644 --- a/awx/ui_next/src/components/PromptDetail/PromptWFJobTemplateDetail.jsx +++ b/awx/ui_next/src/components/PromptDetail/PromptWFJobTemplateDetail.jsx @@ -40,14 +40,14 @@ function PromptWFJobTemplateDetail({ i18n, resource }) { ? 'smart_inventory' : 'inventory'; - const recentJobs = summary_fields.recent_jobs.map(job => ({ + const recentJobs = summary_fields?.recent_jobs?.map(job => ({ ...job, type: 'job', })); return ( <> - {summary_fields.recent_jobs?.length > 0 && ( + {summary_fields?.recent_jobs?.length > 0 && ( } label={i18n._(t`Activity`)} @@ -84,7 +84,7 @@ function PromptWFJobTemplateDetail({ i18n, resource }) { value={toTitleCase(webhook_service)} /> - {related.webhook_receiver && ( + {related?.webhook_receiver && ( node.id === nodeToEdit.id); matchingNode.unifiedJobTemplate = editedNode.nodeResource; matchingNode.isEdited = true; + matchingNode.promptValues = editedNode.promptValues; return { ...state, diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeAddModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeAddModal.jsx index 18ee5fcdc9..62d0c9705c 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeAddModal.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeAddModal.jsx @@ -6,18 +6,49 @@ import { WorkflowStateContext, } from '../../../../../contexts/Workflow'; import NodeModal from './NodeModal'; +import { getAddedAndRemoved } from '../../../../../util/lists'; function NodeAddModal({ i18n }) { const dispatch = useContext(WorkflowDispatchContext); const { addNodeSource } = useContext(WorkflowStateContext); - const addNode = (resource, linkType) => { + const addNode = (values, linkType, config) => { + const { approvalName, approvalDescription, approvalTimeout } = values; + if (values) { + const { added, removed } = getAddedAndRemoved( + config?.defaults?.credentials, + values?.credentials + ); + + values.inventory = values?.inventory?.id; + values.addedCredentials = added; + values.removedCredentials = removed; + } + let node; + if (values.nodeType === 'approval') { + node = { + nodeResource: { + description: approvalDescription, + name: approvalName, + timeout: approvalTimeout, + type: 'workflow_approval_template', + }, + }; + } else { + node = { + linkType, + nodeResource: values.nodeResource, + }; + if ( + values?.nodeType === 'job_template' || + values?.nodeType === 'workflow_job_template' + ) { + node.promptValues = values; + } + } dispatch({ type: 'CREATE_NODE', - node: { - linkType, - nodeResource: resource, - }, + node, }); }; diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeAddModal.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeAddModal.test.jsx index 723de11c73..17dd6433c6 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeAddModal.test.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeAddModal.test.jsx @@ -1,6 +1,9 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; -import { mountWithContexts } from '../../../../../../testUtils/enzymeHelpers'; +import { + mountWithContexts, + waitForElement, +} from '../../../../../../testUtils/enzymeHelpers'; import { WorkflowDispatchContext, WorkflowStateContext, @@ -13,6 +16,9 @@ const nodeResource = { id: 448, type: 'job_template', name: 'Test JT', + summary_fields: { + credentials: [], + }, }; const workflowContext = { @@ -20,23 +26,34 @@ const workflowContext = { }; describe('NodeAddModal', () => { - test('Node modal confirmation dispatches as expected', () => { + test('Node modal confirmation dispatches as expected', async () => { const wrapper = mountWithContexts( - + {}} askLinkType title="Add Node" /> ); - act(() => { - wrapper.find('NodeModal').prop('onSave')(nodeResource, 'success'); + waitForElement( + wrapper, + 'WizardNavItem[content="ContentLoading"]', + el => el.length === 0 + ); + await act(async () => { + wrapper.find('NodeModal').prop('onSave')({ nodeResource }, 'success', {}); }); + expect(dispatch).toHaveBeenCalledWith({ - type: 'CREATE_NODE', node: { linkType: 'success', - nodeResource, + nodeResource: { + id: 448, + name: 'Test JT', + summary_fields: { credentials: [] }, + type: 'job_template', + }, }, + type: 'CREATE_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 489b40042b..276312580b 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 @@ -1,86 +1,66 @@ import 'styled-components/macro'; -import React, { useContext, useState } from 'react'; +import React, { useContext, useState, useEffect, useCallback } from 'react'; import { useHistory } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; +import { Formik, useFormikContext } from 'formik'; + import { bool, node, func } from 'prop-types'; import { Button, WizardContextConsumer, WizardFooter, + Form, } from '@patternfly/react-core'; +import ContentError from '../../../../../components/ContentError'; +import ContentLoading from '../../../../../components/ContentLoading'; + +import useRequest, { + useDismissableError, +} from '../../../../../util/useRequest'; import { WorkflowDispatchContext, WorkflowStateContext, } from '../../../../../contexts/Workflow'; +import { + JobTemplatesAPI, + WorkflowJobTemplatesAPI, + WorkflowJobTemplateNodesAPI, +} from '../../../../../api'; import Wizard from '../../../../../components/Wizard'; -import { NodeTypeStep } from './NodeTypeStep'; -import RunStep from './RunStep'; +import useWorkflowNodeSteps from './useWorkflowNodeSteps'; +import AlertModal from '../../../../../components/AlertModal'; + import NodeNextButton from './NodeNextButton'; -function NodeModal({ askLinkType, i18n, onSave, title }) { +function canLaunchWithoutPrompt(nodeType, launchData) { + if (nodeType !== 'workflow_job_template' && nodeType !== 'job_template') { + return true; + } + 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 || + launchData.variables_needed_to_start.length === 0) + ); +} + +function NodeModalForm({ askLinkType, i18n, onSave, title, credentialError }) { const history = useHistory(); const dispatch = useContext(WorkflowDispatchContext); const { nodeToEdit } = useContext(WorkflowStateContext); + const { + values, + setTouched, + validateForm, + setFieldValue, + resetForm, + } = useFormikContext(); - let defaultApprovalDescription = ''; - let defaultApprovalName = ''; - let defaultApprovalTimeout = 0; - let defaultNodeResource = null; - let defaultNodeType = 'job_template'; - if (nodeToEdit && nodeToEdit.unifiedJobTemplate) { - if ( - nodeToEdit && - nodeToEdit.unifiedJobTemplate && - (nodeToEdit.unifiedJobTemplate.type || - nodeToEdit.unifiedJobTemplate.unified_job_type) - ) { - const ujtType = - nodeToEdit.unifiedJobTemplate.type || - nodeToEdit.unifiedJobTemplate.unified_job_type; - switch (ujtType) { - case 'job_template': - case 'job': - defaultNodeType = 'job_template'; - defaultNodeResource = nodeToEdit.unifiedJobTemplate; - break; - case 'project': - case 'project_update': - defaultNodeType = 'project_sync'; - defaultNodeResource = nodeToEdit.unifiedJobTemplate; - break; - case 'inventory_source': - case 'inventory_update': - defaultNodeType = 'inventory_source_sync'; - defaultNodeResource = nodeToEdit.unifiedJobTemplate; - break; - case 'workflow_job_template': - case 'workflow_job': - defaultNodeType = 'workflow_job_template'; - defaultNodeResource = nodeToEdit.unifiedJobTemplate; - break; - case 'workflow_approval_template': - case 'workflow_approval': - defaultNodeType = 'approval'; - defaultApprovalName = nodeToEdit.unifiedJobTemplate.name; - defaultApprovalDescription = - nodeToEdit.unifiedJobTemplate.description; - defaultApprovalTimeout = nodeToEdit.unifiedJobTemplate.timeout; - break; - default: - } - } - } - const [approvalDescription, setApprovalDescription] = useState( - defaultApprovalDescription - ); - const [approvalName, setApprovalName] = useState(defaultApprovalName); - const [approvalTimeout, setApprovalTimeout] = useState( - defaultApprovalTimeout - ); - const [linkType, setLinkType] = useState('success'); - const [nodeResource, setNodeResource] = useState(defaultNodeResource); - const [nodeType, setNodeType] = useState(defaultNodeType); const [triggerNext, setTriggerNext] = useState(0); const clearQueryParams = () => { @@ -92,21 +72,82 @@ function NodeModal({ askLinkType, i18n, onSave, title }) { ); history.replace(`${history.location.pathname}?${otherParts.join('&')}`); }; + useEffect(() => { + if (values?.nodeResource?.summary_fields?.credentials?.length > 0) { + setFieldValue( + 'credentials', + values.nodeResource.summary_fields.credentials + ); + } + if (nodeToEdit?.unified_job_type === 'workflow_job') { + setFieldValue('nodeType', 'workflow_job_template'); + } + if (nodeToEdit?.unified_job_type === 'job') { + setFieldValue('nodeType', 'job_template'); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [nodeToEdit, values.nodeResource]); + + const { + request: readLaunchConfig, + error: launchConfigError, + result: launchConfig, + isLoading, + } = useRequest( + useCallback(async () => { + const readLaunch = (type, id) => + type === 'workflow_job_template' + ? WorkflowJobTemplatesAPI.readLaunch(id) + : JobTemplatesAPI.readLaunch(id); + if ( + (values?.nodeType === 'workflow_job_template' && + values.nodeResource?.unified_job_type === 'job') || + (values?.nodeType === 'job_template' && + values.nodeResource?.unified_job_type === 'workflow_job') + ) { + return {}; + } + if ( + values.nodeType === 'workflow_job_template' || + values.nodeType === 'job_template' + ) { + if (values.nodeResource) { + const { data } = await readLaunch( + values.nodeType, + values?.nodeResource?.id + ); + + return data; + } + } + return {}; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [values.nodeResource, values.nodeType]), + {} + ); + + useEffect(() => { + readLaunchConfig(); + }, [readLaunchConfig, values.nodeResource, values.nodeType]); + + const { + steps: promptSteps, + initialValues, + isReady, + visitStep, + visitAllSteps, + contentError, + } = useWorkflowNodeSteps( + launchConfig, + i18n, + values.nodeResource, + askLinkType, + !canLaunchWithoutPrompt(values.nodeType, launchConfig) + ); const handleSaveNode = () => { clearQueryParams(); - - const resource = - nodeType === 'approval' - ? { - description: approvalDescription, - name: approvalName, - timeout: approvalTimeout, - type: 'workflow_approval_template', - } - : nodeResource; - - onSave(resource, askLinkType ? linkType : null); + onSave(values, askLinkType ? values.linkType : null, launchConfig); }; const handleCancel = () => { @@ -114,53 +155,24 @@ function NodeModal({ askLinkType, i18n, onSave, title }) { dispatch({ type: 'CANCEL_NODE_MODAL' }); }; - const handleNodeTypeChange = newNodeType => { - setNodeType(newNodeType); - setNodeResource(null); - setApprovalName(''); - setApprovalDescription(''); - setApprovalTimeout(0); - }; - - const steps = [ - ...(askLinkType - ? [ - { - name: i18n._(t`Run Type`), - key: 'run_type', - component: ( - - ), - enableNext: linkType !== null, - }, - ] - : []), - { - name: i18n._(t`Node Type`), - key: 'node_resource', - enableNext: - (nodeType !== 'approval' && nodeResource !== null) || - (nodeType === 'approval' && approvalName !== ''), - component: ( - - ), - }, - ]; - - steps.forEach((step, n) => { - step.id = n + 1; - }); + const { error, dismissError } = useDismissableError( + launchConfigError || contentError || credentialError + ); + useEffect(() => { + if (isReady) { + resetForm({ + values: { + ...initialValues, + nodeResource: values.nodeResource, + nodeType: values.nodeType || 'job_template', + linkType: values.linkType || 'success', + verbosity: initialValues?.verbosity?.toString(), + }, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [launchConfig, values.nodeType, isReady, values.nodeResource]); + const steps = [...(isReady ? [...promptSteps] : [])]; const CustomFooter = ( @@ -173,12 +185,13 @@ function NodeModal({ askLinkType, i18n, onSave, title }) { onNext={onNext} onClick={() => setTriggerNext(triggerNext + 1)} buttonText={ - activeStep.key === 'node_resource' + activeStep.id === steps[steps?.length - 1]?.id || + activeStep.name === 'Preview' ? i18n._(t`Save`) : i18n._(t`Next`) } /> - {activeStep && activeStep.id !== 1 && ( + {activeStep && activeStep.id !== steps[0]?.id && ( @@ -196,21 +209,123 @@ function NodeModal({ askLinkType, i18n, onSave, title }) { ); - const wizardTitle = nodeResource ? `${title} | ${nodeResource.name}` : title; + const wizardTitle = values.nodeResource + ? `${title} | ${values.nodeResource.name}` + : title; + if (error && !isLoading) { + return ( + { + dismissError(); + }} + > + + + ); + } return ( { + handleSaveNode(); + }} + onGoToStep={async (nextStep, prevStep) => { + if (nextStep.id === 'preview') { + visitAllSteps(setTouched); + } else { + visitStep(prevStep.prevId); + } + await validateForm(); + }} + steps={ + isReady + ? steps + : [ + { + name: i18n._(t`Content Loading`), + component: , + }, + ] + } css="overflow: scroll" title={wizardTitle} + onNext={async (nextStep, prevStep) => { + if (nextStep.id === 'preview') { + visitAllSteps(setTouched); + } else { + visitStep(prevStep.prevId); + } + await validateForm(); + }} /> ); } +const NodeModal = ({ onSave, i18n, askLinkType, title }) => { + const { nodeToEdit } = useContext(WorkflowStateContext); + const onSaveForm = (values, linkType, config) => { + onSave(values, linkType, config); + }; + const { request: fetchCredentials, result, error } = useRequest( + useCallback(async () => { + const { + data: { results }, + } = await WorkflowJobTemplateNodesAPI.readCredentials( + nodeToEdit.originalNodeObject.id + ); + return results; + }, [nodeToEdit]) + ); + useEffect(() => { + if (nodeToEdit?.originalNodeObject?.related?.credentials) { + fetchCredentials(); + } + }, [fetchCredentials, nodeToEdit]); + + return ( + onSaveForm} + > + {formik => ( +
+ + + )} +
+ ); +}; + NodeModal.propTypes = { askLinkType: bool.isRequired, onSave: func.isRequired, 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 aa904e9069..9c95475941 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 @@ -36,6 +36,10 @@ describe('NodeModal', () => { name: 'Test Job Template', type: 'job_template', url: '/api/v2/job_templates/1', + summary_fields: { + recent_jobs: [], + }, + related: { webhook_receiver: '' }, }, ], }, @@ -49,6 +53,69 @@ describe('NodeModal', () => { related_search_fields: [], }, }); + JobTemplatesAPI.readLaunch.mockResolvedValue({ + data: { + can_start_without_user_input: false, + passwords_needed_to_start: [], + ask_scm_branch_on_launch: false, + ask_variables_on_launch: true, + ask_tags_on_launch: true, + ask_diff_mode_on_launch: true, + ask_skip_tags_on_launch: true, + ask_job_type_on_launch: true, + ask_limit_on_launch: false, + ask_verbosity_on_launch: true, + ask_inventory_on_launch: true, + ask_credential_on_launch: true, + survey_enabled: true, + variables_needed_to_start: ['a'], + credential_needed_to_start: false, + inventory_needed_to_start: false, + job_template_data: { + name: 'A User-2 has admin permission', + id: 25, + description: '', + }, + defaults: { + extra_vars: '---', + diff_mode: false, + limit: '', + job_tags: '', + skip_tags: '', + job_type: 'run', + verbosity: 0, + inventory: { + name: ' Inventory 1 Org 0', + id: 1, + }, + credentials: [ + { + id: 2, + name: ' Credential 2 User 1', + credential_type: 1, + passwords_needed: [], + }, + { + id: 8, + name: 'vault cred', + credential_type: 3, + passwords_needed: [], + vault_id: '', + }, + ], + scm_branch: '', + }, + }, + }); + JobTemplatesAPI.readSurvey.mockResolvedValue({ + data: { + name: '', + description: '', + spec: [{ question_name: 'Foo', required: true }], + type: 'text', + variable: 'bar', + }, + }); ProjectsAPI.read.mockResolvedValue({ data: { count: 1, @@ -116,6 +183,33 @@ describe('NodeModal', () => { }, }); }); + WorkflowJobTemplatesAPI.readLaunch.mockResolvedValue({ + data: { + ask_inventory_on_launch: false, + ask_limit_on_launch: false, + ask_scm_branch_on_launch: false, + can_start_without_user_input: false, + defaults: { + extra_vars: '---', + inventory: { + name: null, + id: null, + }, + limit: '', + scm_branch: '', + }, + survey_enabled: false, + variables_needed_to_start: [], + node_templates_missing: [], + node_prompts_rejected: [272, 273], + workflow_job_template_data: { + name: 'jt', + id: 53, + description: '', + }, + ask_variables_on_launch: false, + }, + }); afterAll(() => { jest.clearAllMocks(); }); @@ -137,7 +231,7 @@ describe('NodeModal', () => { await waitForElement(wrapper, 'PFWizard'); }); - afterAll(() => { + afterEach(() => { wrapper.unmount(); }); @@ -150,17 +244,41 @@ describe('NodeModal', () => { }); wrapper.update(); wrapper.find('Radio').simulate('click'); + await act(async () => { + wrapper.find('button#next-node-modal').simulate('click'); + }); + + wrapper.update(); + + expect(JobTemplatesAPI.readLaunch).toBeCalledWith(1); + expect(JobTemplatesAPI.readSurvey).toBeCalledWith(25); + wrapper.update(); + expect(wrapper.find('NodeNextButton').prop('buttonText')).toBe('Next'); + wrapper + .find('WizardNavItem[content="Preview"]') + .find('a') + .prop('onClick')(); + wrapper.update(); + await act(async () => { wrapper.find('button#next-node-modal').simulate('click'); }); expect(onSave).toBeCalledWith( { - id: 1, - name: 'Test Job Template', - type: 'job_template', - url: '/api/v2/job_templates/1', + linkType: 'always', + nodeResource: { + id: 1, + name: 'Test Job Template', + related: { webhook_receiver: '' }, + summary_fields: { recent_jobs: [] }, + type: 'job_template', + url: '/api/v2/job_templates/1', + }, + nodeType: 'job_template', + verbosity: undefined, }, - 'always' + 'always', + {} ); }); @@ -177,17 +295,24 @@ describe('NodeModal', () => { }); wrapper.update(); wrapper.find('Radio').simulate('click'); + wrapper.update(); await act(async () => { wrapper.find('button#next-node-modal').simulate('click'); }); expect(onSave).toBeCalledWith( { - id: 1, - name: 'Test Project', - type: 'project', - url: '/api/v2/projects/1', + linkType: 'failure', + nodeResource: { + id: 1, + name: 'Test Project', + type: 'project', + url: '/api/v2/projects/1', + }, + nodeType: 'project_sync', + verbosity: undefined, }, - 'failure' + 'failure', + {} ); }); @@ -207,17 +332,24 @@ describe('NodeModal', () => { }); wrapper.update(); wrapper.find('Radio').simulate('click'); + wrapper.update(); await act(async () => { wrapper.find('button#next-node-modal').simulate('click'); }); expect(onSave).toBeCalledWith( { - id: 1, - name: 'Test Inventory Source', - type: 'inventory_source', - url: '/api/v2/inventory_sources/1', + linkType: 'failure', + nodeResource: { + id: 1, + name: 'Test Inventory Source', + type: 'inventory_source', + url: '/api/v2/inventory_sources/1', + }, + nodeType: 'inventory_source_sync', + verbosity: undefined, }, - 'failure' + 'failure', + {} ); }); @@ -233,18 +365,48 @@ describe('NodeModal', () => { ); }); wrapper.update(); - wrapper.find('Radio').simulate('click'); + await act(async () => wrapper.find('Radio').simulate('click')); + wrapper.update(); + + await act(async () => { + wrapper.find('button#next-node-modal').simulate('click'); + }); + wrapper.update(); + await act(async () => { wrapper.find('button#next-node-modal').simulate('click'); }); expect(onSave).toBeCalledWith( { - id: 1, - name: 'Test Workflow Job Template', - type: 'workflow_job_template', - url: '/api/v2/workflow_job_templates/1', + linkType: 'success', + nodeResource: { + id: 1, + name: 'Test Workflow Job Template', + type: 'workflow_job_template', + url: '/api/v2/workflow_job_templates/1', + }, + nodeType: 'workflow_job_template', + verbosity: undefined, }, - 'success' + 'success', + { + ask_inventory_on_launch: false, + ask_limit_on_launch: false, + ask_scm_branch_on_launch: false, + ask_variables_on_launch: false, + can_start_without_user_input: false, + defaults: { + extra_vars: '---', + inventory: { id: null, name: null }, + limit: '', + scm_branch: '', + }, + node_prompts_rejected: [272, 273], + node_templates_missing: [], + survey_enabled: false, + variables_needed_to_start: [], + workflow_job_template_data: { description: '', id: 53, name: 'jt' }, + } ); }); @@ -263,10 +425,13 @@ describe('NodeModal', () => { await act(async () => { wrapper.find('input#approval-name').simulate('change', { - target: { value: 'Test Approval', name: 'name' }, + target: { value: 'Test Approval', name: 'approvalName' }, }); wrapper.find('input#approval-description').simulate('change', { - target: { value: 'Test Approval Description', name: 'description' }, + target: { + value: 'Test Approval Description', + name: 'approvalDescription', + }, }); wrapper.find('input#approval-timeout-minutes').simulate('change', { target: { value: 5, name: 'timeoutMinutes' }, @@ -301,12 +466,16 @@ describe('NodeModal', () => { }); expect(onSave).toBeCalledWith( { - description: 'Test Approval Description', - name: 'Test Approval', + approvalDescription: 'Test Approval Description', + approvalName: 'Test Approval', + linkType: 'always', + nodeResource: undefined, + nodeType: 'approval', timeout: 330, - type: 'workflow_approval_template', + verbosity: undefined, }, - 'always' + 'always', + {} ); }); @@ -318,13 +487,15 @@ describe('NodeModal', () => { }); }); describe('Edit existing node', () => { + let newWrapper; afterEach(() => { - wrapper.unmount(); + newWrapper.unmount(); + jest.clearAllMocks(); }); test('Can successfully change project sync node to workflow approval node', async () => { await act(async () => { - wrapper = mountWithContexts( + newWrapper = mountWithContexts( { ); }); - await waitForElement(wrapper, 'PFWizard'); - expect(wrapper.find('AnsibleSelect').prop('value')).toBe('project_sync'); + await waitForElement(newWrapper, 'PFWizard'); + newWrapper.update(); + expect(newWrapper.find('AnsibleSelect').prop('value')).toBe( + 'project_sync' + ); await act(async () => { - wrapper.find('AnsibleSelect').prop('onChange')(null, 'approval'); + newWrapper.find('AnsibleSelect').prop('onChange')(null, 'approval'); }); - wrapper.update(); + newWrapper.update(); await act(async () => { - wrapper.find('input#approval-name').simulate('change', { - target: { value: 'Test Approval', name: 'name' }, + newWrapper.find('input#approval-name').simulate('change', { + target: { value: 'Test Approval', name: 'approvalName' }, }); - wrapper.find('input#approval-description').simulate('change', { - target: { value: 'Test Approval Description', name: 'description' }, + newWrapper.find('input#approval-description').simulate('change', { + target: { + value: 'Test Approval Description', + name: 'approvalDescription', + }, }); - wrapper.find('input#approval-timeout-minutes').simulate('change', { + newWrapper.find('input#approval-timeout-minutes').simulate('change', { target: { value: 5, name: 'timeoutMinutes' }, }); }); @@ -369,42 +546,46 @@ describe('NodeModal', () => { // They both update the same state variable in the parent so triggering // them syncronously creates flakey test results. await act(async () => { - wrapper.find('input#approval-timeout-seconds').simulate('change', { + newWrapper.find('input#approval-timeout-seconds').simulate('change', { target: { value: 30, name: 'timeoutSeconds' }, }); }); - wrapper.update(); + newWrapper.update(); - expect(wrapper.find('input#approval-name').prop('value')).toBe( + expect(newWrapper.find('input#approval-name').prop('value')).toBe( 'Test Approval' ); - expect(wrapper.find('input#approval-description').prop('value')).toBe( + expect(newWrapper.find('input#approval-description').prop('value')).toBe( 'Test Approval Description' ); - expect(wrapper.find('input#approval-timeout-minutes').prop('value')).toBe( - 5 - ); - expect(wrapper.find('input#approval-timeout-seconds').prop('value')).toBe( - 30 - ); + expect( + newWrapper.find('input#approval-timeout-minutes').prop('value') + ).toBe(5); + expect( + newWrapper.find('input#approval-timeout-seconds').prop('value') + ).toBe(30); await act(async () => { - wrapper.find('button#next-node-modal').simulate('click'); + newWrapper.find('button#next-node-modal').simulate('click'); }); expect(onSave).toBeCalledWith( { - description: 'Test Approval Description', - name: 'Test Approval', + approvalDescription: 'Test Approval Description', + approvalName: 'Test Approval', + linkType: 'success', + nodeResource: undefined, + nodeType: 'approval', timeout: 330, - type: 'workflow_approval_template', + verbosity: undefined, }, - null + null, + {} ); }); test('Can successfully change approval node to workflow job template node', async () => { await act(async () => { - wrapper = mountWithContexts( + newWrapper = mountWithContexts( { ); }); - await waitForElement(wrapper, 'PFWizard'); - expect(wrapper.find('AnsibleSelect').prop('value')).toBe('approval'); + await waitForElement(newWrapper, 'PFWizard'); + expect(newWrapper.find('AnsibleSelect').prop('value')).toBe('approval'); await act(async () => { - wrapper.find('AnsibleSelect').prop('onChange')( + newWrapper.find('AnsibleSelect').prop('onChange')( null, 'workflow_job_template' ); }); - wrapper.update(); - wrapper.find('Radio').simulate('click'); + newWrapper.update(); + await act(async () => newWrapper.find('Radio').simulate('click')); + newWrapper.update(); + await act(async () => { - wrapper.find('button#next-node-modal').simulate('click'); + newWrapper.find('button#next-node-modal').simulate('click'); + }); + newWrapper.update(); + await act(async () => { + newWrapper.find('button#next-node-modal').simulate('click'); }); expect(onSave).toBeCalledWith( { - id: 1, - name: 'Test Workflow Job Template', - type: 'workflow_job_template', - url: '/api/v2/workflow_job_templates/1', + linkType: 'success', + nodeResource: { + id: 1, + name: 'Test Workflow Job Template', + type: 'workflow_job_template', + url: '/api/v2/workflow_job_templates/1', + }, + nodeType: 'workflow_job_template', + verbosity: undefined, }, - null + null, + { + ask_inventory_on_launch: false, + ask_limit_on_launch: false, + ask_scm_branch_on_launch: false, + ask_variables_on_launch: false, + can_start_without_user_input: false, + defaults: { + extra_vars: '---', + inventory: { id: null, name: null }, + limit: '', + scm_branch: '', + }, + node_prompts_rejected: [272, 273], + node_templates_missing: [], + survey_enabled: false, + variables_needed_to_start: [], + workflow_job_template_data: { description: '', id: 53, name: 'jt' }, + } ); }); }); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeNextButton.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeNextButton.jsx index 43ae2b681e..2664cefd19 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeNextButton.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeNextButton.jsx @@ -1,5 +1,5 @@ import React, { useEffect } from 'react'; -import { func, number, shape, string } from 'prop-types'; +import { func, oneOfType, number, shape, string } from 'prop-types'; import { Button } from '@patternfly/react-core'; function NodeNextButton({ @@ -34,7 +34,7 @@ NodeNextButton.propTypes = { buttonText: string.isRequired, onClick: func.isRequired, onNext: func.isRequired, - triggerNext: number.isRequired, + triggerNext: oneOfType([string, number]).isRequired, }; export default NodeNextButton; 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 e9ee7928bb..c97c29ae6e 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,17 +1,19 @@ 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 { func, number, shape, string } from 'prop-types'; import styled from 'styled-components'; -import { Formik, Field } from 'formik'; +import { useField } from 'formik'; import { Form, FormGroup, TextInput } from '@patternfly/react-core'; +import { required } from '../../../../../../util/validators'; + import { FormFullWidthLayout } from '../../../../../../components/FormLayout'; import AnsibleSelect from '../../../../../../components/AnsibleSelect'; import InventorySourcesList from './InventorySourcesList'; import JobTemplatesList from './JobTemplatesList'; import ProjectsList from './ProjectsList'; import WorkflowJobTemplatesList from './WorkflowJobTemplatesList'; +import FormField from '../../../../../../components/FormField'; const TimeoutInput = styled(TextInput)` width: 200px; @@ -25,19 +27,16 @@ const TimeoutLabel = styled.p` margin-left: 10px; `; -function NodeTypeStep({ - description, - i18n, - name, - nodeResource, - nodeType, - timeout, - onUpdateDescription, - onUpdateName, - onUpdateNodeResource, - onUpdateNodeType, - onUpdateTimeout, -}) { +function NodeTypeStep({ i18n }) { + const [timeoutMinutes, setTimeoutMinutes] = useState(0); + const [timeoutSeconds, setTimeoutSeconds] = useState(0); + const [nodeTypeField, , nodeTypeHelpers] = useField('nodeType'); + const [nodeResourceField, , nodeResourceHelpers] = useField('nodeResource'); + const [, approvalNameMeta, approvalNameHelpers] = useField('approvalName'); + const [, , approvalDescriptionHelpers] = useField('approvalDescription'); + const [, , timeoutHelpers] = useField('timeout'); + + const isValid = !approvalNameMeta.touched || !approvalNameMeta.error; return ( <>
@@ -78,189 +77,114 @@ function NodeTypeStep({ isDisabled: false, }, ]} - value={nodeType} + value={nodeTypeField.value} onChange={(e, val) => { - onUpdateNodeType(val); + nodeTypeHelpers.setValue(val); + nodeResourceHelpers.setValue(null); + approvalNameHelpers.setValue(''); + approvalDescriptionHelpers.setValue(''); + timeoutHelpers.setValue(0); }} />
- {nodeType === 'job_template' && ( + {nodeTypeField.value === 'job_template' && ( )} - {nodeType === 'project_sync' && ( + {nodeTypeField.value === 'project_sync' && ( )} - {nodeType === 'inventory_source_sync' && ( + {nodeTypeField.value === 'inventory_source_sync' && ( )} - {nodeType === 'workflow_job_template' && ( + {nodeTypeField.value === 'workflow_job_template' && ( )} - {nodeType === 'approval' && ( - - {() => ( -
- - - {({ field, form }) => { - const isValid = - form && - (!form.touched[field.name] || !form.errors[field.name]); - - return ( - - { - onUpdateName(evt.target.value); - field.onChange(evt); - }} - /> - + {nodeTypeField.value === 'approval' && ( + + + + + +
+ { + if (!evt.target.value || evt.target.value === '') { + evt.target.value = 0; + } + setTimeoutMinutes(evt.target.value); + timeoutHelpers.setValue( + Number(evt.target.value) * 60 + Number(timeoutSeconds) ); }} - - - {({ field }) => ( - - { - onUpdateDescription(evt.target.value); - field.onChange(evt); - }} - /> - - )} - - -
- - {({ field, form }) => ( - <> - { - if ( - !evt.target.value || - evt.target.value === '' - ) { - evt.target.value = 0; - } - onUpdateTimeout( - Number(evt.target.value) * 60 + - Number(form.values.timeoutSeconds) - ); - field.onChange(evt); - }} - /> - - min - - - )} - - - {({ field, form }) => ( - <> - { - if ( - !evt.target.value || - evt.target.value === '' - ) { - evt.target.value = 0; - } - onUpdateTimeout( - Number(evt.target.value) + - Number(form.values.timeoutMinutes) * 60 - ); - field.onChange(evt); - }} - /> - - sec - - - )} - -
-
- - - )} - + /> + + min + + { + if (!evt.target.value || evt.target.value === '') { + evt.target.value = 0; + } + setTimeoutSeconds(evt.target.value); + + timeoutHelpers.setValue( + Number(evt.target.value) + Number(timeoutMinutes) * 60 + ); + }} + /> + + sec + +
+
+
+ )} ); } - -NodeTypeStep.propTypes = { - description: string, - name: string, - nodeResource: shape(), - nodeType: string, - timeout: number, - onUpdateDescription: func.isRequired, - onUpdateName: func.isRequired, - onUpdateNodeResource: func.isRequired, - onUpdateNodeType: func.isRequired, - onUpdateTimeout: func.isRequired, -}; - -NodeTypeStep.defaultProps = { - description: '', - name: '', - nodeResource: null, - nodeType: 'job_template', - timeout: 0, -}; - export default withI18n()(NodeTypeStep); 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 b7b3bfe0d5..9ccd37ad7f 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 @@ -1,5 +1,6 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; +import { Formik } from 'formik'; import { mountWithContexts } from '../../../../../../../testUtils/enzymeHelpers'; import { InventorySourcesAPI, @@ -7,18 +8,13 @@ import { ProjectsAPI, WorkflowJobTemplatesAPI, } from '../../../../../../api'; + import NodeTypeStep from './NodeTypeStep'; -jest.mock('../../../../../../api/models/InventorySources'); -jest.mock('../../../../../../api/models/JobTemplates'); -jest.mock('../../../../../../api/models/Projects'); -jest.mock('../../../../../../api/models/WorkflowJobTemplates'); - -const onUpdateDescription = jest.fn(); -const onUpdateName = jest.fn(); -const onUpdateNodeResource = jest.fn(); -const onUpdateNodeType = jest.fn(); -const onUpdateTimeout = jest.fn(); +jest.mock('../../../../api/models/InventorySources'); +jest.mock('../../../../api/models/JobTemplates'); +jest.mock('../../../../api/models/Projects'); +jest.mock('../../../../api/models/WorkflowJobTemplates'); describe('NodeTypeStep', () => { beforeAll(() => { @@ -118,63 +114,35 @@ describe('NodeTypeStep', () => { let wrapper; await act(async () => { wrapper = mountWithContexts( - + + + ); }); wrapper.update(); expect(wrapper.find('AnsibleSelect').prop('value')).toBe('job_template'); expect(wrapper.find('JobTemplatesList').length).toBe(1); - wrapper.find('Radio').simulate('click'); - expect(onUpdateNodeResource).toHaveBeenCalledWith({ - id: 1, - name: 'Test Job Template', - type: 'job_template', - url: '/api/v2/job_templates/1', - }); }); test('It shows the project list when node type is project sync', async () => { let wrapper; await act(async () => { wrapper = mountWithContexts( - + + + ); }); wrapper.update(); expect(wrapper.find('AnsibleSelect').prop('value')).toBe('project_sync'); expect(wrapper.find('ProjectsList').length).toBe(1); - wrapper.find('Radio').simulate('click'); - expect(onUpdateNodeResource).toHaveBeenCalledWith({ - id: 1, - name: 'Test Project', - type: 'project', - url: '/api/v2/projects/1', - }); }); test('It shows the inventory source list when node type is inventory source sync', async () => { let wrapper; await act(async () => { wrapper = mountWithContexts( - + + + ); }); wrapper.update(); @@ -182,26 +150,14 @@ describe('NodeTypeStep', () => { 'inventory_source_sync' ); expect(wrapper.find('InventorySourcesList').length).toBe(1); - wrapper.find('Radio').simulate('click'); - expect(onUpdateNodeResource).toHaveBeenCalledWith({ - id: 1, - name: 'Test Inventory Source', - type: 'inventory_source', - url: '/api/v2/inventory_sources/1', - }); }); test('It shows the workflow job template list when node type is workflow job template', async () => { let wrapper; await act(async () => { wrapper = mountWithContexts( - + + + ); }); wrapper.update(); @@ -209,67 +165,57 @@ describe('NodeTypeStep', () => { 'workflow_job_template' ); expect(wrapper.find('WorkflowJobTemplatesList').length).toBe(1); - wrapper.find('Radio').simulate('click'); - expect(onUpdateNodeResource).toHaveBeenCalledWith({ - id: 1, - name: 'Test Workflow Job Template', - type: 'workflow_job_template', - url: '/api/v2/workflow_job_templates/1', - }); }); test('It shows the approval form fields when node type is approval', async () => { let wrapper; await act(async () => { wrapper = mountWithContexts( - + + + ); }); wrapper.update(); expect(wrapper.find('AnsibleSelect').prop('value')).toBe('approval'); - expect(wrapper.find('input#approval-name').length).toBe(1); - expect(wrapper.find('input#approval-description').length).toBe(1); - expect(wrapper.find('input#approval-timeout-minutes').length).toBe(1); - expect(wrapper.find('input#approval-timeout-seconds').length).toBe(1); + expect(wrapper.find('FormField[label="Name"]').length).toBe(1); + expect(wrapper.find('FormField[label="Description"]').length).toBe(1); + expect(wrapper.find('input[name="timeoutMinutes"]').length).toBe(1); + expect(wrapper.find('input[name="timeoutSeconds"]').length).toBe(1); await act(async () => { wrapper.find('input#approval-name').simulate('change', { - target: { value: 'Test Approval', name: 'name' }, + target: { value: 'Test Approval', name: 'approvalName' }, }); - }); - - expect(onUpdateName).toHaveBeenCalledWith('Test Approval'); - - await act(async () => { wrapper.find('input#approval-description').simulate('change', { - target: { value: 'Test Approval Description', name: 'description' }, + target: { + value: 'Test Approval Description', + name: 'approvalDescription', + }, }); - }); - - expect(onUpdateDescription).toHaveBeenCalledWith( - 'Test Approval Description' - ); - - await act(async () => { - wrapper.find('input#approval-timeout-minutes').simulate('change', { + wrapper.find('input[name="timeoutMinutes"]').simulate('change', { target: { value: 5, name: 'timeoutMinutes' }, }); - }); - - expect(onUpdateTimeout).toHaveBeenCalledWith(300); - - await act(async () => { - wrapper.find('input#approval-timeout-seconds').simulate('change', { + wrapper.find('input[name="timeoutSeconds"]').simulate('change', { target: { value: 30, name: 'timeoutSeconds' }, }); }); - expect(onUpdateTimeout).toHaveBeenCalledWith(330); + wrapper.update(); + + expect(wrapper.find('input#approval-name').prop('value')).toBe( + 'Test Approval' + ); + expect(wrapper.find('input#approval-description').prop('value')).toBe( + 'Test Approval Description' + ); + expect(wrapper.find('input[name="timeoutMinutes"]').prop('value')).toBe(5); + expect(wrapper.find('input[name="timeoutSeconds"]').prop('value')).toBe(30); }); }); 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 new file mode 100644 index 0000000000..ea60283228 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/useNodeTypeStep.jsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { t } from '@lingui/macro'; +import { useField } from 'formik'; +import NodeTypeStep from './NodeTypeStep'; + +const STEP_ID = 'nodeType'; + +export default function useNodeTypeStep(i18n, resource) { + const [, meta] = useField('nodeType'); + const [approvalNameField] = useField('approvalName'); + const [nodeTypeField, ,] = useField('nodeType'); + const [nodeResouceField] = useField('nodeResource'); + + return { + step: getStep( + meta, + i18n, + nodeTypeField, + approvalNameField, + nodeResouceField + ), + initialValues: getInitialValues(resource), + isReady: true, + contentError: null, + formError: meta.error, + setTouched: setFieldsTouched => { + setFieldsTouched({ + inventory: true, + }); + }, + }; +} +function getStep( + meta, + i18n, + nodeTypeField, + approvalNameField, + nodeResouceField +) { + const isEnabled = () => { + if ( + (nodeTypeField.value !== 'approval' && nodeResouceField.value === null) || + (nodeTypeField.value === 'approval' && + approvalNameField.value === undefined) + ) { + return false; + } + return true; + }; + return { + id: STEP_ID, + key: 3, + name: i18n._(t`Node Type`), + component: , + enableNext: isEnabled(), + }; +} + +function getInitialValues(resource) { + let typeOfNode; + if ( + !resource?.unifiedJobTemplate?.type && + !resource?.unifiedJobTemplate?.unified_job_type + ) { + return { nodeType: 'job_template' }; + } + const { + unifiedJobTemplate: { type, unified_job_type }, + } = resource; + const unifiedType = type || unified_job_type; + + if (unifiedType === 'job' || unifiedType === 'job_template') + typeOfNode = { + nodeType: 'job_template', + nodeResource: + resource.originalNodeObject.summary_fields.unified_job_template, + }; + if (unifiedType === 'project' || unifiedType === 'project_update') { + typeOfNode = { nodeType: 'project_sync' }; + } + if ( + unifiedType === 'inventory_source' || + unifiedType === 'inventory_update' + ) { + typeOfNode = { nodeType: 'inventory_source_sync' }; + } + if ( + unifiedType === 'workflow_job' || + unifiedType === 'workflow_job_template' + ) { + typeOfNode = { nodeType: 'workflow_job_template' }; + } + if ( + unifiedType === 'workflow_approval_template' || + unifiedType === 'workflow_approval' + ) { + typeOfNode = { + nodeType: 'approval', + }; + } + return typeOfNode; +} diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/RunStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/RunStep.jsx index f7316ee8d6..6700e08937 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/RunStep.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/RunStep.jsx @@ -1,8 +1,8 @@ import React from 'react'; import { withI18n } from '@lingui/react'; +import { useField } from 'formik'; import { t } from '@lingui/macro'; import styled from 'styled-components'; -import { func, string } from 'prop-types'; import { Title } from '@patternfly/react-core'; import SelectableCard from '../../../../../components/SelectableCard'; @@ -16,11 +16,12 @@ const Grid = styled.div` width: 100%; `; -function RunStep({ i18n, linkType, onUpdateLinkType }) { +function RunStep({ i18n }) { + const [field, , helpers] = useField('linkType'); return ( <> - {i18n._(t`Run`)} + {i18n._(t`Don't Run`)}

{i18n._( @@ -30,39 +31,33 @@ function RunStep({ i18n, linkType, onUpdateLinkType }) { onUpdateLinkType('success')} + onClick={() => helpers.setValue('success')} /> onUpdateLinkType('failure')} + onClick={() => helpers.setValue('failure')} /> onUpdateLinkType('always')} + onClick={() => helpers.setValue('always')} /> ); } - -RunStep.propTypes = { - linkType: string.isRequired, - onUpdateLinkType: func.isRequired, -}; - export default withI18n()(RunStep); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/RunStep.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/RunStep.test.jsx index b822601482..e8ff15780c 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/RunStep.test.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/RunStep.test.jsx @@ -1,15 +1,18 @@ import React from 'react'; +import { Formik } from 'formik'; +import { act } from 'react-dom/test-utils'; + import { mountWithContexts } from '../../../../../../testUtils/enzymeHelpers'; import RunStep from './RunStep'; let wrapper; -const linkType = 'always'; -const onUpdateLinkType = jest.fn(); describe('RunStep', () => { beforeAll(() => { wrapper = mountWithContexts( - + + + ); }); @@ -18,23 +21,20 @@ describe('RunStep', () => { }); test('Default selected card matches default link type when present', () => { - expect(wrapper.find('#link-type-success').props().isSelected).toBe(false); + expect(wrapper.find('#link-type-success').props().isSelected).toBe(true); expect(wrapper.find('#link-type-failure').props().isSelected).toBe(false); + expect(wrapper.find('#link-type-always').props().isSelected).toBe(false); + }); + + test('Clicking success card makes expected callback', async () => { + await act(async () => wrapper.find('#link-type-always').simulate('click')); + wrapper.update(); expect(wrapper.find('#link-type-always').props().isSelected).toBe(true); }); - test('Clicking success card makes expected callback', () => { - wrapper.find('#link-type-success').simulate('click'); - expect(onUpdateLinkType).toHaveBeenCalledWith('success'); - }); - - test('Clicking failure card makes expected callback', () => { - wrapper.find('#link-type-failure').simulate('click'); - expect(onUpdateLinkType).toHaveBeenCalledWith('failure'); - }); - - test('Clicking always card makes expected callback', () => { - wrapper.find('#link-type-always').simulate('click'); - expect(onUpdateLinkType).toHaveBeenCalledWith('always'); + test('Clicking failure card makes expected callback', async () => { + await act(async () => wrapper.find('#link-type-failure').simulate('click')); + wrapper.update(); + expect(wrapper.find('#link-type-failure').props().isSelected).toBe(true); }); }); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/useRunTypeStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/useRunTypeStep.jsx new file mode 100644 index 0000000000..186f5acf01 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/useRunTypeStep.jsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { t } from '@lingui/macro'; +import { useField } from 'formik'; + +import RunStep from './RunStep'; + +const STEP_ID = 'runType'; + +export default function useRunTypeStep(i18n, askLinkType) { + const [, meta] = useField('linkType'); + + return { + step: getStep(askLinkType, meta, i18n), + initialValues: askLinkType ? { linkType: 'success' } : {}, + isReady: true, + contentError: null, + formError: meta.error, + setTouched: setFieldsTouched => { + setFieldsTouched({ + inventory: true, + }); + }, + }; +} +function getStep(askLinkType, meta, i18n) { + if (!askLinkType) { + return null; + } + return { + id: STEP_ID, + key: 1, + name: i18n._(t`Run Type`), + component: , + enableNext: meta.value !== '', + }; +} 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 new file mode 100644 index 0000000000..0dfe16b3b9 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/useWorkflowNodeSteps.js @@ -0,0 +1,93 @@ +import { + useState, + useEffect +} from 'react'; +import { + useFormikContext +} from 'formik'; +import useInventoryStep from '../../../../../components/LaunchPrompt/steps/useInventoryStep'; +import useCredentialsStep from '../../../../../components/LaunchPrompt/steps/useCredentialsStep'; +import useOtherPromptsStep from '../../../../../components/LaunchPrompt/steps/useOtherPromptsStep'; +import useSurveyStep from '../../../../../components/LaunchPrompt/steps/useSurveyStep'; +import usePreviewStep from '../../../../../components/LaunchPrompt/steps/usePreviewStep'; +import useNodeTypeStep from './NodeTypeStep/useNodeTypeStep'; +import useRunTypeStep from './useRunTypeStep'; + +export default function useWorkflowNodeSteps( + config, + i18n, + resource, + askLinkType, + needsPreviewStep +) { + const [visited, setVisited] = useState({}); + const steps = [ + useRunTypeStep(i18n, askLinkType), + useNodeTypeStep(i18n, resource), + useInventoryStep(config, i18n, visited, resource), + useCredentialsStep(config, i18n, resource), + useOtherPromptsStep(config, i18n, resource), + useSurveyStep(config, i18n, visited, resource), + ]; + const { + resetForm, + values: formikValues + } = useFormikContext(); + const hasErrors = steps.some(step => step.formError); + const surveyStepIndex = steps.findIndex(step => step.survey); + steps.push( + usePreviewStep( + config, + i18n, + resource, + steps[surveyStepIndex]?.survey, + hasErrors, + needsPreviewStep + ) + ); + + const pfSteps = steps.map(s => s.step).filter(s => s != null); + const isReady = !steps.some(s => !s.isReady); + const initialValues = steps.reduce((acc, cur) => { + return { + ...acc, + ...cur.initialValues, + }; + }, {}); + useEffect(() => { + if (surveyStepIndex > -1 && isReady) { + resetForm({ + values: { + ...formikValues, + ...steps[surveyStepIndex].initialValues, + }, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isReady]); + + const stepWithError = steps.find(s => s.contentError); + const contentError = stepWithError ? stepWithError.contentError : null; + + return { + steps: pfSteps, + initialValues, + isReady, + visitStep: stepId => + setVisited({ + ...visited, + [stepId]: true, + }), + visitAllSteps: setFieldsTouched => { + setVisited({ + inventory: true, + credentials: true, + other: true, + survey: true, + preview: true, + }); + steps.forEach(s => s.setTouched(setFieldsTouched)); + }, + contentError, + }; +} diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx index 311d95cec9..1af3651c54 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx @@ -259,6 +259,8 @@ function Visualizer({ template, i18n }) { const approvalTemplateRequests = []; const originalLinkMap = {}; const deletedNodeIds = []; + const associateCredentialRequests = []; + const disassociateCredentialRequests = []; nodes.forEach(node => { // node with id=1 is the artificial start node if (node.id === 1) { @@ -308,6 +310,7 @@ function Visualizer({ template, i18n }) { } else { nodeRequests.push( WorkflowJobTemplatesAPI.createNode(template.id, { + ...node.promptValues, unified_job_template: node.unifiedJobTemplate.id, }).then(({ data }) => { node.originalNodeObject = data; @@ -317,6 +320,26 @@ function Visualizer({ template, i18n }) { failure_nodes: [], always_nodes: [], }; + if (node.promptValues?.removedCredentials?.length > 0) { + node.promptValues.removedCredentials.forEach(cred => { + disassociateCredentialRequests.push( + WorkflowJobTemplateNodesAPI.disassociateCredentials( + data.id, + cred.id + ) + ); + }); + } + if (node.promptValues?.addedCredentials?.length > 0) { + node.promptValues.addedCredentials.forEach(cred => { + associateCredentialRequests.push( + WorkflowJobTemplateNodesAPI.associateCredentials( + data.id, + cred.id + ) + ); + }); + } }) ); } @@ -355,9 +378,30 @@ function Visualizer({ template, i18n }) { } else { nodeRequests.push( WorkflowJobTemplateNodesAPI.update(node.originalNodeObject.id, { + ...node.promptValues, unified_job_template: node.unifiedJobTemplate.id, }) ); + if (node?.promptValues?.addedCredentials?.length > 0) { + node.promptValues.addedCredentials.forEach(cred => + associateCredentialRequests.push( + WorkflowJobTemplateNodesAPI.associateCredentials( + node.originalNodeObject.id, + cred.id + ) + ) + ); + } + if (node?.promptValues?.removedCredentials?.length > 0) { + node.promptValues.removedCredentials.forEach(cred => + disassociateCredentialRequests.push( + WorkflowJobTemplateNodesAPI.disassociateCredentials( + node.originalNodeObject.id, + cred.id + ) + ) + ); + } } } }); @@ -372,6 +416,9 @@ function Visualizer({ template, i18n }) { ); await Promise.all(associateNodes(newLinks, originalLinkMap)); + await Promise.all(disassociateCredentialRequests); + await Promise.all(associateCredentialRequests); + history.push(`/templates/workflow_job_template/${template.id}/details`); };