From 20231041e60e83e4e79c6d0811219f0a31acc638 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Mon, 12 Oct 2020 14:51:23 -0400 Subject: [PATCH 01/14] Adds Node Modal Promptability Adds steps for NodeType, RunType, Inventory, Credentials, updates Reducers, adds API calls, adds Add functionality to Visualizer; Adds other prompt step Adds SurveyStep refactors add node functionality --- .../api/models/WorkflowJobTemplateNodes.js | 13 + .../LaunchPrompt/steps/PreviewStep.jsx | 7 +- .../LaunchPrompt/steps/PreviewStep.test.jsx | 27 -- .../LaunchPrompt/steps/useCredentialsStep.jsx | 19 +- .../LaunchPrompt/steps/useInventoryStep.jsx | 32 +- .../steps/useOtherPromptsStep.jsx | 52 ++- .../LaunchPrompt/steps/usePreviewStep.jsx | 57 +-- .../LaunchPrompt/steps/useSurveyStep.jsx | 62 +-- .../components/LaunchPrompt/useLaunchSteps.js | 7 +- .../components/PromptDetail/PromptDetail.jsx | 1 + .../PromptDetail/PromptJobTemplateDetail.jsx | 2 +- .../PromptWFJobTemplateDetail.jsx | 6 +- .../components/Workflow/workflowReducer.js | 98 ++++- .../Modals/NodeModals/NodeAddModal.jsx | 41 +- .../Modals/NodeModals/NodeAddModal.test.jsx | 31 +- .../Modals/NodeModals/NodeModal.jsx | 369 ++++++++++++------ .../Modals/NodeModals/NodeModal.test.jsx | 342 ++++++++++++---- .../Modals/NodeModals/NodeNextButton.jsx | 4 +- .../NodeModals/NodeTypeStep/NodeTypeStep.jsx | 280 +++++-------- .../NodeTypeStep/NodeTypeStep.test.jsx | 152 +++----- .../NodeTypeStep/useNodeTypeStep.jsx | 102 +++++ .../Modals/NodeModals/RunStep.jsx | 25 +- .../Modals/NodeModals/RunStep.test.jsx | 34 +- .../Modals/NodeModals/useRunTypeStep.jsx | 36 ++ .../Modals/NodeModals/useWorkflowNodeSteps.js | 93 +++++ .../Visualizer.jsx | 47 +++ 26 files changed, 1285 insertions(+), 654 deletions(-) create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/useNodeTypeStep.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/useRunTypeStep.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/useWorkflowNodeSteps.js 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`); }; From 2545f14a93ed5fc0da59b1a6059055b3957fc84c Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Wed, 14 Oct 2020 15:49:08 -0400 Subject: [PATCH 02/14] refactoring for adding modal --- .../components/LaunchPrompt/LaunchPrompt.jsx | 26 +++-- .../LaunchPrompt/steps/useCredentialsStep.jsx | 58 +++++++++-- .../LaunchPrompt/steps/useInventoryStep.jsx | 9 +- .../steps/useOtherPromptsStep.jsx | 8 +- .../LaunchPrompt/steps/usePreviewStep.jsx | 3 +- .../components/LaunchPrompt/useLaunchSteps.js | 6 +- .../Modals/NodeModals/NodeAddModal.jsx | 1 + .../Modals/NodeModals/NodeModal.jsx | 95 ++++--------------- .../NodeTypeStep/useNodeTypeStep.jsx | 20 ++-- .../Modals/NodeModals/useWorkflowNodeSteps.js | 45 +++++---- 10 files changed, 139 insertions(+), 132 deletions(-) diff --git a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx index e1fc3bbdad..d3219c11d2 100644 --- a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx @@ -107,16 +107,22 @@ function LaunchPrompt({ config, resource = {}, onLaunch, onCancel, i18n }) { return ( onLaunch(values)} > diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialsStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialsStep.jsx index 77278363df..8373d0efca 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialsStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialsStep.jsx @@ -1,20 +1,64 @@ -import React from 'react'; +import React, { useCallback, useEffect } from 'react'; import { t } from '@lingui/macro'; +import useRequest from '../../../util/useRequest'; +import { + WorkflowJobTemplateNodesAPI, + JobTemplatesAPI, + WorkflowJobTemplatesAPI, +} from '../../../api'; + import CredentialsStep from './CredentialsStep'; const STEP_ID = 'credentials'; -export default function useCredentialsStep(config, i18n, resource) { +export default function useCredentialsStep( + config, + i18n, + selectedResource, + nodeToEdit +) { + const resource = nodeToEdit || selectedResource; + const { request: fetchCredentials, result, error, isLoading } = useRequest( + useCallback(async () => { + let credentials; + if (!nodeToEdit?.related?.credentials) { + return {}; + } + const { + data: { results }, + } = await WorkflowJobTemplateNodesAPI.readCredentials(nodeToEdit.id); + credentials = results; + if (results.length === 0 && config?.defaults?.credentials) { + const fetchCreds = config.job_template_data + ? JobTemplatesAPI.readDetail(config.job_template_data.id) + : WorkflowJobTemplatesAPI.readDetail( + config.workflow_job_template_data.id + ); + + const { + data: { + summary_fields: { credentials: defaultCreds }, + }, + } = await fetchCreds; + credentials = defaultCreds; + } + return credentials; + }, [nodeToEdit, config]) + ); + useEffect(() => { + fetchCredentials(); + }, [fetchCredentials, nodeToEdit]); + const validate = () => { return {}; }; return { step: getStep(config, i18n), - initialValues: getInitialValues(config, resource), + initialValues: getInitialValues(config, resource, result), validate, - isReady: true, - contentError: null, + isReady: !isLoading && !!result, + contentError: error, formError: null, setTouched: setFieldsTouched => { setFieldsTouched({ @@ -37,11 +81,11 @@ function getStep(config, i18n) { }; } -function getInitialValues(config, resource) { +function getInitialValues(config, resource, result) { if (!config.ask_credential_on_launch) { return {}; } return { - credentials: resource?.summary_fields?.credentials || [], + credentials: resource?.summary_fields?.credentials || result || [], }; } diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx index ba047878cf..22b19fcc22 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx @@ -6,8 +6,15 @@ import StepName from './StepName'; const STEP_ID = 'inventory'; -export default function useInventoryStep(config, i18n, visitedSteps, resource) { +export default function useInventoryStep( + config, + i18n, + visitedSteps, + selectedResource, + nodeToEdit +) { const [, meta] = useField('inventory'); + const resource = nodeToEdit || selectedResource; const formError = Object.keys(visitedSteps).includes(STEP_ID) && (!meta.value || meta.error); diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx index 88773dbfce..d3bf095c23 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx @@ -5,7 +5,13 @@ import OtherPromptsStep from './OtherPromptsStep'; const STEP_ID = 'other'; -export default function useOtherPrompt(config, i18n, resource) { +export default function useOtherPrompt( + config, + i18n, + selectedResource, + nodeToEdit +) { + const resource = nodeToEdit || selectedResource; return { step: getStep(config, i18n), initialValues: getInitialValues(config, resource), diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx index 9777032055..423ac3caf1 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx @@ -12,7 +12,8 @@ export default function usePreviewStep( hasErrors, needsPreviewStep ) { - const showStep = needsPreviewStep && resource && Object.keys(config).length > 0; + const showStep = + needsPreviewStep && resource && Object.keys(config).length > 0; return { step: showStep ? { diff --git a/awx/ui_next/src/components/LaunchPrompt/useLaunchSteps.js b/awx/ui_next/src/components/LaunchPrompt/useLaunchSteps.js index 0f318dd3e1..501cb91eea 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, i18n, visited ), + useInventoryStep(config, i18n, visited), useCredentialsStep(config, i18n), useOtherPromptsStep(config, i18n), - useSurveyStep(config, i18n, visited ), + useSurveyStep(config, i18n, visited), ]; const { resetForm, values: formikValues } = useFormikContext(); const hasErrors = steps.some(step => step.formError); @@ -25,7 +25,7 @@ export default function useLaunchSteps(config, resource, i18n) { resource, steps[surveyStepIndex]?.survey, hasErrors, - + true ) ); 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 62d0c9705c..bd4c159a59 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 @@ -24,6 +24,7 @@ function NodeAddModal({ i18n }) { values.addedCredentials = added; values.removedCredentials = removed; } + let node; if (values.nodeType === 'approval') { 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 276312580b..de598daf6f 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 @@ -22,11 +22,7 @@ import { WorkflowDispatchContext, WorkflowStateContext, } from '../../../../../contexts/Workflow'; -import { - JobTemplatesAPI, - WorkflowJobTemplatesAPI, - WorkflowJobTemplateNodesAPI, -} from '../../../../../api'; +import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '../../../../../api'; import Wizard from '../../../../../components/Wizard'; import useWorkflowNodeSteps from './useWorkflowNodeSteps'; import AlertModal from '../../../../../components/AlertModal'; @@ -57,8 +53,6 @@ function NodeModalForm({ askLinkType, i18n, onSave, title, credentialError }) { values, setTouched, validateForm, - setFieldValue, - resetForm, } = useFormikContext(); const [triggerNext, setTriggerNext] = useState(0); @@ -72,21 +66,6 @@ function NodeModalForm({ askLinkType, i18n, onSave, title, credentialError }) { ); 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, @@ -120,6 +99,7 @@ function NodeModalForm({ askLinkType, i18n, onSave, title, credentialError }) { return data; } } + return {}; // eslint-disable-next-line react-hooks/exhaustive-deps }, [values.nodeResource, values.nodeType]), @@ -132,7 +112,6 @@ function NodeModalForm({ askLinkType, i18n, onSave, title, credentialError }) { const { steps: promptSteps, - initialValues, isReady, visitStep, visitAllSteps, @@ -142,7 +121,8 @@ function NodeModalForm({ askLinkType, i18n, onSave, title, credentialError }) { i18n, values.nodeResource, askLinkType, - !canLaunchWithoutPrompt(values.nodeType, launchConfig) + !canLaunchWithoutPrompt(values.nodeType, launchConfig), + nodeToEdit ); const handleSaveNode = () => { @@ -158,21 +138,17 @@ function NodeModalForm({ askLinkType, i18n, onSave, title, credentialError }) { 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 steps = [ + ...(isReady + ? [...promptSteps] + : [ + { + name: i18n._(t`Content Loading`), + component: , + }, + ]), + ]; const CustomFooter = ( @@ -243,16 +219,7 @@ function NodeModalForm({ askLinkType, i18n, onSave, title, credentialError }) { } await validateForm(); }} - steps={ - isReady - ? steps - : [ - { - name: i18n._(t`Content Loading`), - component: , - }, - ] - } + steps={steps} css="overflow: scroll" title={wizardTitle} onNext={async (nextStep, prevStep) => { @@ -272,42 +239,13 @@ const NodeModal = ({ onSave, i18n, askLinkType, title }) => { 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} > @@ -317,7 +255,6 @@ const NodeModal = ({ onSave, i18n, askLinkType, title }) => { onSave={onSaveForm} i18n={i18n} title={title} - credentialError={error} askLinkType={askLinkType} /> 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 ea60283228..d4b58ffbbf 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 @@ -5,7 +5,7 @@ import NodeTypeStep from './NodeTypeStep'; const STEP_ID = 'nodeType'; -export default function useNodeTypeStep(i18n, resource) { +export default function useNodeTypeStep(i18n, resource, nodeToEdit) { const [, meta] = useField('nodeType'); const [approvalNameField] = useField('approvalName'); const [nodeTypeField, ,] = useField('nodeType'); @@ -13,13 +13,12 @@ export default function useNodeTypeStep(i18n, resource) { return { step: getStep( - meta, i18n, nodeTypeField, approvalNameField, nodeResouceField ), - initialValues: getInitialValues(resource), + initialValues: getInitialValues(nodeToEdit), isReady: true, contentError: null, formError: meta.error, @@ -31,7 +30,6 @@ export default function useNodeTypeStep(i18n, resource) { }; } function getStep( - meta, i18n, nodeTypeField, approvalNameField, @@ -56,24 +54,24 @@ function getStep( }; } -function getInitialValues(resource) { +function getInitialValues(nodeToEdit) { let typeOfNode; if ( - !resource?.unifiedJobTemplate?.type && - !resource?.unifiedJobTemplate?.unified_job_type + !nodeToEdit?.unifiedJobTemplate?.type && + !nodeToEdit?.unifiedJobTemplate?.unified_job_type ) { return { nodeType: 'job_template' }; } const { unifiedJobTemplate: { type, unified_job_type }, - } = resource; + } = nodeToEdit; const unifiedType = type || unified_job_type; if (unifiedType === 'job' || unifiedType === 'job_template') typeOfNode = { nodeType: 'job_template', nodeResource: - resource.originalNodeObject.summary_fields.unified_job_template, + nodeToEdit.originalNodeObject.summary_fields.unified_job_template, }; if (unifiedType === 'project' || unifiedType === 'project_update') { typeOfNode = { nodeType: 'project_sync' }; @@ -88,7 +86,9 @@ function getInitialValues(resource) { unifiedType === 'workflow_job' || unifiedType === 'workflow_job_template' ) { - typeOfNode = { nodeType: 'workflow_job_template' }; + typeOfNode = { + nodeType: 'workflow_job_template', + }; } if ( unifiedType === 'workflow_approval_template' || 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 0dfe16b3b9..98fd239593 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 @@ -1,10 +1,5 @@ -import { - useState, - useEffect -} from 'react'; -import { - useFormikContext -} from 'formik'; +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'; @@ -18,21 +13,25 @@ export default function useWorkflowNodeSteps( i18n, resource, askLinkType, - needsPreviewStep + needsPreviewStep, + nodeToEdit ) { 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), + useNodeTypeStep(i18n, nodeToEdit), + useInventoryStep( + config, + i18n, + visited, + resource, + nodeToEdit?.originalNodeObject + ), + useCredentialsStep(config, i18n, resource, nodeToEdit?.originalNodeObject), + useOtherPromptsStep(config, i18n, resource, nodeToEdit?.originalNodeObject), useSurveyStep(config, i18n, visited, resource), ]; - const { - resetForm, - values: formikValues - } = useFormikContext(); + const { resetForm, values: formikValues } = useFormikContext(); const hasErrors = steps.some(step => step.formError); const surveyStepIndex = steps.findIndex(step => step.survey); steps.push( @@ -55,16 +54,22 @@ export default function useWorkflowNodeSteps( }; }, {}); useEffect(() => { - if (surveyStepIndex > -1 && isReady) { + if (isReady) { resetForm({ values: { - ...formikValues, - ...steps[surveyStepIndex].initialValues, + ...initialValues, + nodeResource: formikValues.nodeResource, + nodeType: formikValues.nodeType || initialValues.nodeType, + linkType: formikValues.linkType || 'success', + verbosity: initialValues?.verbosity?.toString(), }, }); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isReady]); + }, [ + config, + isReady, + ]); const stepWithError = steps.find(s => s.contentError); const contentError = stepWithError ? stepWithError.contentError : null; From 60751dfa16fe0d1d5afc5e496e5c87b18714e27a Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Thu, 15 Oct 2020 15:29:58 -0400 Subject: [PATCH 03/14] adds edit functionality --- .../LaunchPrompt/steps/PreviewStep.jsx | 14 ++++++---- .../LaunchPrompt/steps/useSurveyStep.jsx | 18 ++++++++---- .../components/PromptDetail/PromptDetail.jsx | 2 +- .../Modals/NodeModals/NodeEditModal.jsx | 28 ++++++++++++++++--- .../Modals/NodeModals/NodeModal.jsx | 28 ++++++++++--------- .../NodeTypeStep/useNodeTypeStep.jsx | 16 ++--------- .../Modals/NodeModals/useWorkflowNodeSteps.js | 14 ++++++---- 7 files changed, 72 insertions(+), 48 deletions(-) diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.jsx index d2ee608401..c20f96d0e8 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.jsx @@ -6,6 +6,7 @@ import { t } from '@lingui/macro'; import { useFormikContext } from 'formik'; import { withI18n } from '@lingui/react'; import yaml from 'js-yaml'; +import { parseVariableField } from '../../../util/yaml'; import mergeExtraVars, { maskPasswords } from '../mergeExtraVars'; import getSurveyValues from '../getSurveyValues'; import PromptDetail from '../../PromptDetail'; @@ -27,11 +28,12 @@ function PreviewStep({ resource, config, survey, formErrors, i18n }) { const { values } = useFormikContext(); const surveyValues = getSurveyValues(values); - const overrides = { ...values }; + const overrides = { + ...values, + }; if (config.ask_variables_on_launch || config.survey_enabled) { - const initialExtraVars = config.ask_variables_on_launch - ? overrides.extra_vars || '---' - : resource.extra_vars; + const initialExtraVars = + config.ask_variables_on_launch && (overrides.extra_vars || '---'); if (survey && survey.spec) { const passwordFields = survey.spec .filter(q => q.type === 'password') @@ -47,8 +49,8 @@ function PreviewStep({ resource, config, survey, formErrors, i18n }) { // 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; - + values.extra_data = + overrides.extra_vars && parseVariableField(overrides?.extra_vars); return ( {formErrors && ( diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx index bbc545fcdd..988bf5da2a 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx @@ -8,7 +8,13 @@ import StepName from './StepName'; const STEP_ID = 'survey'; -export default function useSurveyStep(config, i18n, visitedSteps, resource) { +export default function useSurveyStep( + config, + i18n, + visitedSteps, + resource, + nodeToEdit +) { const { values } = useFormikContext(); const { result: survey, request: fetchSurvey, isLoading, error } = useRequest( useCallback(async () => { @@ -48,7 +54,7 @@ export default function useSurveyStep(config, i18n, visitedSteps, resource) { const formError = Object.keys(validate()).length > 0; return { step: getStep(config, survey, validate, i18n, visitedSteps), - initialValues: getInitialValues(config, survey, resource), + initialValues: getInitialValues(config, survey, nodeToEdit), validate, survey, isReady: !isLoading && !!survey, @@ -113,7 +119,7 @@ function getStep(config, survey, validate, i18n, visitedSteps) { }; } -function getInitialValues(config, survey, resource) { +function getInitialValues(config, survey, nodeToEdit) { if (!config.survey_enabled || !survey) { return {}; } @@ -126,11 +132,11 @@ function getInitialValues(config, survey, resource) { } else { values[`survey_${question.variable}`] = question.default; } - if (resource?.extra_data) { - Object.entries(resource?.extra_data).forEach(([key, value]) => { + if (nodeToEdit?.extra_data) { + Object.entries(nodeToEdit?.extra_data).forEach(([key, value]) => { if (key === question.variable) { if (question.type === 'multiselect') { - values[`survey_${question.variable}`] = value.split('\n'); + values[`survey_${question.variable}`] = value; } else { values[`survey_${question.variable}`] = value; } diff --git a/awx/ui_next/src/components/PromptDetail/PromptDetail.jsx b/awx/ui_next/src/components/PromptDetail/PromptDetail.jsx index e6eccdbe6a..a59fd7ba7f 100644 --- a/awx/ui_next/src/components/PromptDetail/PromptDetail.jsx +++ b/awx/ui_next/src/components/PromptDetail/PromptDetail.jsx @@ -143,7 +143,7 @@ function PromptDetail({ i18n, resource, launchConfig = {}, overrides = {} }) { value={toTitleCase(overrides.job_type)} /> )} - {overrides?.credentials && ( + {overrides?.credentials?.length > 0 && ( { + const updateNode = (values, linkType, config) => { + const { added, removed } = getAddedAndRemoved( + config?.defaults?.credentials, + values?.credentials + ); + if (added?.length > 0) { + values.addedCredentals = added; + } + if (removed?.length > 0) { + values.removedCredentals = removed; + } + values.inventory = values?.inventory?.id; + delete values.linkType; + const node = { + nodeResource: values.nodeResource, + }; + if ( + values?.nodeType === 'job_template' || + values?.nodeType === 'workflow_job_template' + ) { + node.promptValues = values; + } dispatch({ type: 'UPDATE_NODE', - node: { - nodeResource: resource, - }, + 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 de598daf6f..72bdb710e5 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 @@ -49,11 +49,7 @@ function NodeModalForm({ askLinkType, i18n, onSave, title, credentialError }) { const history = useHistory(); const dispatch = useContext(WorkflowDispatchContext); const { nodeToEdit } = useContext(WorkflowStateContext); - const { - values, - setTouched, - validateForm, - } = useFormikContext(); + const { values, setTouched, validateForm } = useFormikContext(); const [triggerNext, setTriggerNext] = useState(0); @@ -149,7 +145,11 @@ function NodeModalForm({ askLinkType, i18n, onSave, title, credentialError }) { }, ]), ]; - + const nextButtonText = activeStep => + activeStep.id === steps[steps?.length - 1]?.id || + activeStep.name === 'Preview' + ? i18n._(t`Save`) + : i18n._(t`Next`); const CustomFooter = ( @@ -158,23 +158,25 @@ function NodeModalForm({ askLinkType, i18n, onSave, title, credentialError }) { setTriggerNext(triggerNext + 1)} - buttonText={ - activeStep.id === steps[steps?.length - 1]?.id || - activeStep.name === 'Preview' - ? i18n._(t`Save`) - : i18n._(t`Next`) - } + buttonText={nextButtonText(activeStep)} /> {activeStep && activeStep.id !== steps[0]?.id && ( - )} 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 c97c29ae6e..adcbef80f1 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,5 +1,5 @@ import 'styled-components/macro'; -import React, { useState } from 'react'; +import React from 'react'; import { withI18n } from '@lingui/react'; import { t, Trans } from '@lingui/macro'; import styled from 'styled-components'; @@ -28,13 +28,16 @@ const TimeoutLabel = styled.p` `; 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 [timeoutMinutesField, , timeoutMinutesHelpers] = useField( + 'timeoutMinutes' + ); + const [timeoutSecondsField, , timeoutSecondsHelpers] = useField( + 'timeoutSeconds' + ); const isValid = !approvalNameMeta.touched || !approvalNameMeta.error; return ( @@ -47,14 +50,14 @@ function NodeTypeStep({ i18n }) { label={i18n._(t`Select a Node Type`)} data={[ { - key: 'approval', - value: 'approval', + key: 'workflow_approval_template', + value: 'workflow_approval_template', label: i18n._(t`Approval`), isDisabled: false, }, { - key: 'inventory_source_sync', - value: 'inventory_source_sync', + key: 'inventory_source', + value: 'inventory_source', label: i18n._(t`Inventory Source Sync`), isDisabled: false, }, @@ -65,8 +68,8 @@ function NodeTypeStep({ i18n }) { isDisabled: false, }, { - key: 'project_sync', - value: 'project_sync', + key: 'project', + value: 'project', label: i18n._(t`Project Sync`), isDisabled: false, }, @@ -83,7 +86,8 @@ function NodeTypeStep({ i18n }) { nodeResourceHelpers.setValue(null); approvalNameHelpers.setValue(''); approvalDescriptionHelpers.setValue(''); - timeoutHelpers.setValue(0); + timeoutMinutesHelpers.setValue(0); + timeoutSecondsHelpers.setValue(0); }} /> @@ -94,13 +98,13 @@ function NodeTypeStep({ i18n }) { onUpdateNodeResource={nodeResourceHelpers.setValue} /> )} - {nodeTypeField.value === 'project_sync' && ( + {nodeTypeField.value === 'project' && ( )} - {nodeTypeField.value === 'inventory_source_sync' && ( + {nodeTypeField.value === 'inventory_source' && ( )} - {nodeTypeField.value === 'approval' && ( + {nodeTypeField.value === 'workflow_approval_template' && ( @@ -137,44 +139,29 @@ function NodeTypeStep({ i18n }) { >

{ - if (!evt.target.value || evt.target.value === '') { - evt.target.value = 0; - } - setTimeoutMinutes(evt.target.value); - timeoutHelpers.setValue( - Number(evt.target.value) * 60 + Number(timeoutSeconds) - ); + onChange={(value, event) => { + timeoutMinutesField.onChange(event); }} + step="1" + type="number" /> 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 - ); + onChange={(value, event) => { + timeoutSecondsField.onChange(event); }} + step="1" + type="number" /> sec 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 3349191b4b..580ba1ee1e 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 @@ -123,31 +123,31 @@ describe('NodeTypeStep', () => { expect(wrapper.find('AnsibleSelect').prop('value')).toBe('job_template'); expect(wrapper.find('JobTemplatesList').length).toBe(1); }); - test('It shows the project list when node type is project sync', async () => { + test('It shows the project list when node type is project', async () => { let wrapper; await act(async () => { wrapper = mountWithContexts( - + ); }); wrapper.update(); - expect(wrapper.find('AnsibleSelect').prop('value')).toBe('project_sync'); + expect(wrapper.find('AnsibleSelect').prop('value')).toBe('project'); expect(wrapper.find('ProjectsList').length).toBe(1); }); - test('It shows the inventory source list when node type is inventory source sync', async () => { + test('It shows the inventory source list when node type is inventory source', async () => { let wrapper; await act(async () => { wrapper = mountWithContexts( - + ); }); wrapper.update(); expect(wrapper.find('AnsibleSelect').prop('value')).toBe( - 'inventory_source_sync' + 'inventory_source' ); expect(wrapper.find('InventorySourcesList').length).toBe(1); }); @@ -172,10 +172,11 @@ describe('NodeTypeStep', () => { wrapper = mountWithContexts( @@ -183,7 +184,9 @@ describe('NodeTypeStep', () => { ); }); wrapper.update(); - expect(wrapper.find('AnsibleSelect').prop('value')).toBe('approval'); + expect(wrapper.find('AnsibleSelect').prop('value')).toBe( + 'workflow_approval_template' + ); 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); 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 9ed7cd0c17..73c4e10fcf 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 @@ -5,7 +5,7 @@ import NodeTypeStep from './NodeTypeStep'; const STEP_ID = 'nodeType'; -export default function useNodeTypeStep(i18n, nodeToEdit) { +export default function useNodeTypeStep(i18n) { const [, meta] = useField('nodeType'); const [approvalNameField] = useField('approvalName'); const [nodeTypeField, ,] = useField('nodeType'); @@ -13,7 +13,7 @@ export default function useNodeTypeStep(i18n, nodeToEdit) { return { step: getStep(i18n, nodeTypeField, approvalNameField, nodeResourceField), - initialValues: getInitialValues(nodeToEdit), + initialValues: getInitialValues(), isReady: true, contentError: null, formError: meta.error, @@ -27,8 +27,9 @@ export default function useNodeTypeStep(i18n, nodeToEdit) { function getStep(i18n, nodeTypeField, approvalNameField, nodeResourceField) { const isEnabled = () => { if ( - (nodeTypeField.value !== 'approval' && nodeResourceField.value === null) || - (nodeTypeField.value === 'approval' && + (nodeTypeField.value !== 'workflow_approval_template' && + nodeResourceField.value === null) || + (nodeTypeField.value === 'workflow_approval_template' && approvalNameField.value === undefined) ) { return false; @@ -37,57 +38,18 @@ function getStep(i18n, nodeTypeField, approvalNameField, nodeResourceField) { }; return { id: STEP_ID, - key: 3, name: i18n._(t`Node Type`), component: , enableNext: isEnabled(), }; } -function getInitialValues(nodeToEdit) { - let typeOfNode; - if ( - !nodeToEdit?.unifiedJobTemplate?.type && - !nodeToEdit?.unifiedJobTemplate?.unified_job_type - ) { - return { nodeType: 'job_template' }; - } - const { - unifiedJobTemplate: { type, unified_job_type }, - } = nodeToEdit; - const unifiedType = type || unified_job_type; - - if (unifiedType === 'job' || unifiedType === 'job_template') - typeOfNode = { - nodeType: 'job_template', - nodeResource: - nodeToEdit.originalNodeObject?.summary_fields?.unified_job_template - || nodeToEdit.unifiedJobTemplate, - }; - 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; +function getInitialValues() { + return { + approvalName: '', + approvalDescription: '', + timeoutMinutes: 0, + timeoutSeconds: 0, + nodeType: 'job_template', + }; } diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeViewModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeViewModal.jsx index 96ff7809fa..d95c9dc274 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeViewModal.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeViewModal.jsx @@ -11,41 +11,27 @@ import ContentError from '../../../../../components/ContentError'; import ContentLoading from '../../../../../components/ContentLoading'; import PromptDetail from '../../../../../components/PromptDetail'; import useRequest from '../../../../../util/useRequest'; -import { - InventorySourcesAPI, - JobTemplatesAPI, - ProjectsAPI, - WorkflowJobTemplatesAPI, -} from '../../../../../api'; - -function getNodeType(node) { - const ujtType = node.type || node.unified_job_type; - switch (ujtType) { - case 'job_template': - case 'job': - return ['job_template', JobTemplatesAPI]; - case 'project': - case 'project_update': - return ['project_sync', ProjectsAPI]; - case 'inventory_source': - case 'inventory_update': - return ['inventory_source_sync', InventorySourcesAPI]; - case 'workflow_job_template': - case 'workflow_job': - return ['workflow_job_template', WorkflowJobTemplatesAPI]; - case 'workflow_approval_template': - case 'workflow_approval': - return ['approval', null]; - default: - return null; - } -} +import { jsonToYaml } from '../../../../../util/yaml'; +import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '../../../../../api'; +import getNodeType from '../../shared/WorkflowJobTemplateVisualizerUtils'; function NodeViewModal({ i18n, readOnly }) { const dispatch = useContext(WorkflowDispatchContext); const { nodeToView } = useContext(WorkflowStateContext); - const { unifiedJobTemplate } = nodeToView; - const [nodeType, nodeAPI] = getNodeType(unifiedJobTemplate); + const { + fullUnifiedJobTemplate, + originalNodeCredentials, + originalNodeObject, + promptValues, + } = nodeToView; + const [nodeType, nodeAPI] = getNodeType( + fullUnifiedJobTemplate || + originalNodeObject?.summary_fields?.unified_job_template + ); + + const id = + fullUnifiedJobTemplate?.id || + originalNodeObject?.summary_fields?.unified_job_template.id; const { result: launchConfig, @@ -56,39 +42,44 @@ function NodeViewModal({ i18n, readOnly }) { useCallback(async () => { const readLaunch = nodeType === 'workflow_job_template' - ? WorkflowJobTemplatesAPI.readLaunch(unifiedJobTemplate.id) - : JobTemplatesAPI.readLaunch(unifiedJobTemplate.id); + ? WorkflowJobTemplatesAPI.readLaunch(id) + : JobTemplatesAPI.readLaunch(id); const { data } = await readLaunch; return data; - }, [nodeType, unifiedJobTemplate.id]), + }, [nodeType, id]), {} ); const { - result: nodeDetail, - isLoading: isNodeDetailLoading, - error: nodeDetailError, - request: fetchNodeDetail, + result: relatedData, + isLoading: isRelatedDataLoading, + error: relatedDataError, + request: fetchRelatedData, } = useRequest( useCallback(async () => { - let { data } = await nodeAPI?.readDetail(unifiedJobTemplate.id); - - if (data?.type === 'job_template') { + const related = {}; + if ( + nodeType === 'job_template' && + !fullUnifiedJobTemplate.instance_groups + ) { const { data: { results = [] }, - } = await JobTemplatesAPI.readInstanceGroups(data.id); - data = Object.assign(data, { instance_groups: results }); + } = await JobTemplatesAPI.readInstanceGroups(fullUnifiedJobTemplate.id); + related.instance_groups = results; } - if (data?.related?.webhook_receiver) { + if ( + fullUnifiedJobTemplate?.related?.webhook_receiver && + !fullUnifiedJobTemplate.webhook_key + ) { const { data: { webhook_key }, - } = await nodeAPI?.readWebhookKey(data.id); - data = Object.assign(data, { webhook_key }); + } = await nodeAPI?.readWebhookKey(fullUnifiedJobTemplate.id); + related.webhook_key = webhook_key; } - return data; - }, [nodeAPI, unifiedJobTemplate.id]), + return related; + }, [nodeAPI, fullUnifiedJobTemplate, nodeType]), null ); @@ -97,21 +88,27 @@ function NodeViewModal({ i18n, readOnly }) { fetchLaunchConfig(); } - if (unifiedJobTemplate.unified_job_type && nodeType !== 'approval') { - fetchNodeDetail(); + if ( + fullUnifiedJobTemplate && + ((nodeType === 'job_template' && + !fullUnifiedJobTemplate.instance_groups) || + (fullUnifiedJobTemplate?.related?.webhook_receiver && + !fullUnifiedJobTemplate.webhook_key)) + ) { + fetchRelatedData(); } }, []); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { - if (nodeDetail) { + if (relatedData) { dispatch({ type: 'REFRESH_NODE', node: { - nodeResource: nodeDetail, + fullUnifiedJobTemplate: { ...fullUnifiedJobTemplate, ...relatedData }, }, }); } - }, [nodeDetail]); // eslint-disable-line react-hooks/exhaustive-deps + }, [relatedData]); // eslint-disable-line react-hooks/exhaustive-deps const handleEdit = () => { dispatch({ type: 'SET_NODE_TO_VIEW', value: null }); @@ -119,13 +116,74 @@ function NodeViewModal({ i18n, readOnly }) { }; let Content; - if (isLaunchConfigLoading || isNodeDetailLoading) { + if (isLaunchConfigLoading || isRelatedDataLoading) { Content = ; - } else if (launchConfigError || nodeDetailError) { - Content = ; - } else { + } else if (launchConfigError || relatedDataError) { + Content = ; + } else if (!fullUnifiedJobTemplate) { Content = ( - +

+ {i18n._(t`The resource associated with this node has been deleted.`)} +    + {!readOnly + ? i18n._(t`Click the Edit button below to reconfigure the node.`) + : ''} +

+ ); + } else { + let overrides = {}; + + if (promptValues) { + overrides = promptValues; + + if (launchConfig.ask_variables_on_launch || launchConfig.survey_enabled) { + overrides.extra_vars = jsonToYaml( + JSON.stringify(promptValues.extra_data) + ); + } + } else if ( + fullUnifiedJobTemplate.id === originalNodeObject?.unified_job_template + ) { + if (launchConfig.ask_inventory_on_launch) { + overrides.inventory = originalNodeObject.summary_fields.inventory; + } + if (launchConfig.ask_scm_branch_on_launch) { + overrides.scm_branch = originalNodeObject.scm_branch; + } + if (launchConfig.ask_variables_on_launch || launchConfig.survey_enabled) { + overrides.extra_vars = jsonToYaml( + JSON.stringify(originalNodeObject.extra_data) + ); + } + if (launchConfig.ask_tags_on_launch) { + overrides.job_tags = originalNodeObject.job_tags; + } + if (launchConfig.ask_diff_mode_on_launch) { + overrides.diff_mode = originalNodeObject.diff_mode; + } + if (launchConfig.ask_skip_tags_on_launch) { + overrides.skip_tags = originalNodeObject.skip_tags; + } + if (launchConfig.ask_job_type_on_launch) { + overrides.job_type = originalNodeObject.job_type; + } + if (launchConfig.ask_limit_on_launch) { + overrides.limit = originalNodeObject.limit; + } + if (launchConfig.ask_verbosity_on_launch) { + overrides.verbosity = originalNodeObject.verbosity.toString(); + } + if (launchConfig.ask_credential_on_launch) { + overrides.credentials = originalNodeCredentials || []; + } + } + + Content = ( + ); } @@ -133,7 +191,7 @@ function NodeViewModal({ i18n, readOnly }) { dispatch({ type: 'SET_NODE_TO_VIEW', value: null })} actions={ 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 024517bfe3..8af36ff31f 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 @@ -53,13 +53,16 @@ describe('NodeViewModal', () => { let wrapper; const workflowContext = { nodeToView: { - unifiedJobTemplate: { + fullUnifiedJobTemplate: { id: 1, name: 'Mock Node', description: '', unified_job_type: 'workflow_job', created: '2019-08-08T19:24:05.344276Z', modified: '2019-08-08T19:24:18.162949Z', + related: { + webhook_receiver: '/api/v2/workflow_job_templates/2/github/', + }, }, }, }; @@ -88,7 +91,6 @@ describe('NodeViewModal', () => { test('should fetch workflow template launch data', () => { expect(JobTemplatesAPI.readLaunch).not.toHaveBeenCalled(); - expect(JobTemplatesAPI.readDetail).not.toHaveBeenCalled(); expect(JobTemplatesAPI.readInstanceGroups).not.toHaveBeenCalled(); expect(WorkflowJobTemplatesAPI.readLaunch).toHaveBeenCalledWith(1); expect(WorkflowJobTemplatesAPI.readWebhookKey).toHaveBeenCalledWith(1); @@ -118,7 +120,7 @@ describe('NodeViewModal', () => { describe('Job template node', () => { const workflowContext = { nodeToView: { - unifiedJobTemplate: { + fullUnifiedJobTemplate: { id: 1, name: 'Mock Node', description: '', @@ -145,7 +147,6 @@ describe('NodeViewModal', () => { expect(WorkflowJobTemplatesAPI.readLaunch).not.toHaveBeenCalled(); expect(JobTemplatesAPI.readWebhookKey).not.toHaveBeenCalledWith(); expect(JobTemplatesAPI.readLaunch).toHaveBeenCalledWith(1); - expect(JobTemplatesAPI.readDetail).toHaveBeenCalledWith(1); expect(JobTemplatesAPI.readInstanceGroups).toHaveBeenCalledTimes(1); wrapper.unmount(); jest.clearAllMocks(); @@ -207,7 +208,7 @@ describe('NodeViewModal', () => { describe('Project node', () => { const workflowContext = { nodeToView: { - unifiedJobTemplate: { + fullUnifiedJobTemplate: { id: 1, name: 'Mock Node', description: '', @@ -237,4 +238,71 @@ describe('NodeViewModal', () => { jest.clearAllMocks(); }); }); + + describe('Inventory Source node', () => { + const workflowContext = { + nodeToView: { + fullUnifiedJobTemplate: { + id: 1, + name: 'Mock Node', + description: '', + type: 'inventory_source', + created: '2019-08-08T19:24:05.344276Z', + modified: '2019-08-08T19:24:18.162949Z', + }, + }, + }; + + test('should not fetch launch data', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + + + + + ); + }); + waitForLoaded(wrapper); + expect(WorkflowJobTemplatesAPI.readLaunch).not.toHaveBeenCalled(); + expect(JobTemplatesAPI.readLaunch).not.toHaveBeenCalled(); + expect(JobTemplatesAPI.readInstanceGroups).not.toHaveBeenCalled(); + wrapper.unmount(); + jest.clearAllMocks(); + }); + }); + + describe('Approval node', () => { + const workflowContext = { + nodeToView: { + fullUnifiedJobTemplate: { + id: 1, + name: 'Mock Node', + description: '', + type: 'workflow_approval_template', + timeout: 0, + }, + }, + }; + + test('should not fetch launch data', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + + + + + ); + }); + waitForLoaded(wrapper); + expect(WorkflowJobTemplatesAPI.readLaunch).not.toHaveBeenCalled(); + expect(JobTemplatesAPI.readLaunch).not.toHaveBeenCalled(); + expect(JobTemplatesAPI.readInstanceGroups).not.toHaveBeenCalled(); + wrapper.unmount(); + jest.clearAllMocks(); + }); + }); }); 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 index 186f5acf01..6417ee2826 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/useRunTypeStep.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/useRunTypeStep.jsx @@ -28,7 +28,6 @@ function getStep(askLinkType, meta, i18n) { } 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 index 1c49868a4b..345f28eef7 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 @@ -1,88 +1,263 @@ -import { useState, useEffect } from 'react'; +import { useContext, 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 { WorkflowStateContext } from '../../../../../contexts/Workflow'; +import { jsonToYaml } from '../../../../../util/yaml'; import useNodeTypeStep from './NodeTypeStep/useNodeTypeStep'; import useRunTypeStep from './useRunTypeStep'; +function showPreviewStep(nodeType, launchConfig) { + if ( + !['workflow_job_template', 'job_template'].includes(nodeType) || + Object.keys(launchConfig).length === 0 + ) { + return false; + } + return ( + !launchConfig.can_start_without_user_input || + launchConfig.ask_inventory_on_launch || + launchConfig.ask_variables_on_launch || + launchConfig.ask_limit_on_launch || + launchConfig.ask_scm_branch_on_launch || + launchConfig.survey_enabled || + (launchConfig.variables_needed_to_start && + launchConfig.variables_needed_to_start.length > 0) + ); +} + +const getNodeToEditDefaultValues = (launchConfig, surveyConfig, nodeToEdit) => { + const initialValues = { + nodeResource: nodeToEdit?.fullUnifiedJobTemplate || null, + nodeType: nodeToEdit?.fullUnifiedJobTemplate?.type || 'job_template', + }; + + if ( + nodeToEdit?.fullUnifiedJobTemplate?.type === 'workflow_approval_template' + ) { + const timeout = nodeToEdit.fullUnifiedJobTemplate.timeout || 0; + initialValues.approvalName = nodeToEdit.fullUnifiedJobTemplate.name || ''; + initialValues.approvalDescription = + nodeToEdit.fullUnifiedJobTemplate.description || ''; + initialValues.timeoutMinutes = Math.floor(timeout / 60); + initialValues.timeoutSeconds = timeout - Math.floor(timeout / 60) * 60; + + return initialValues; + } + + if (!launchConfig || launchConfig === {}) { + return initialValues; + } + + if (launchConfig.ask_inventory_on_launch) { + // We also need to handle the case where the UJT has been deleted. + if (nodeToEdit?.promptValues) { + initialValues.inventory = nodeToEdit?.promptValues?.inventory; + } else if (nodeToEdit?.originalNodeObject?.summary_fields?.inventory) { + initialValues.inventory = + nodeToEdit?.originalNodeObject?.summary_fields?.inventory; + } else { + initialValues.inventory = null; + } + } + + if (launchConfig.ask_credential_on_launch) { + if (nodeToEdit?.promptValues?.credentials) { + initialValues.credentials = nodeToEdit?.promptValues?.credentials; + } else if (nodeToEdit?.originalNodeCredentials) { + const defaultCredsWithoutOverrides = []; + + const credentialHasScheduleOverride = templateDefaultCred => { + let credentialHasOverride = false; + nodeToEdit.originalNodeCredentials.forEach(scheduleCred => { + if ( + templateDefaultCred.credential_type === scheduleCred.credential_type + ) { + if ( + (!templateDefaultCred.vault_id && + !scheduleCred.inputs.vault_id) || + (templateDefaultCred.vault_id && + scheduleCred.inputs.vault_id && + templateDefaultCred.vault_id === scheduleCred.inputs.vault_id) + ) { + credentialHasOverride = true; + } + } + }); + + return credentialHasOverride; + }; + + if (nodeToEdit?.fullUnifiedJobTemplate?.summary_fields?.credentials) { + nodeToEdit.fullUnifiedJobTemplate.summary_fields.credentials.forEach( + defaultCred => { + if (!credentialHasScheduleOverride(defaultCred)) { + defaultCredsWithoutOverrides.push(defaultCred); + } + } + ); + } + + initialValues.credentials = nodeToEdit.originalNodeCredentials.concat( + defaultCredsWithoutOverrides + ); + } else { + initialValues.credentials = []; + } + } + + const sourceOfValues = + nodeToEdit?.promptValues || nodeToEdit.originalNodeObject; + + if (launchConfig.ask_job_type_on_launch) { + initialValues.job_type = sourceOfValues?.job_type || ''; + } + if (launchConfig.ask_limit_on_launch) { + initialValues.limit = sourceOfValues?.limit || ''; + } + if (launchConfig.ask_verbosity_on_launch) { + initialValues.verbosity = sourceOfValues?.verbosity || 0; + } + if (launchConfig.ask_tags_on_launch) { + initialValues.job_tags = sourceOfValues?.job_tags || ''; + } + if (launchConfig.ask_skip_tags_on_launch) { + initialValues.skip_tags = sourceOfValues?.skip_tags || ''; + } + if (launchConfig.ask_scm_branch_on_launch) { + initialValues.scm_branch = sourceOfValues?.scm_branch || ''; + } + if (launchConfig.ask_diff_mode_on_launch) { + initialValues.diff_mode = sourceOfValues?.diff_mode || false; + } + + if (launchConfig.ask_variables_on_launch && launchConfig.survey_enabled) { + if (nodeToEdit?.promptValues?.extra_vars) { + initialValues.extra_vars = nodeToEdit.promptValues.extra_vars; + } else { + const newExtraData = { ...nodeToEdit.originalNodeObject.extra_data }; + if (surveyConfig.spec) { + surveyConfig.spec.forEach(question => { + if ( + Object.prototype.hasOwnProperty.call( + newExtraData, + question.variable + ) + ) { + delete newExtraData[question.variable]; + } + }); + } + + initialValues.extra_vars = jsonToYaml(JSON.stringify(newExtraData)); + } + } + + if (surveyConfig?.spec) { + surveyConfig.spec.forEach(question => { + if (question.type === 'multiselect') { + initialValues[`survey_${question.variable}`] = question.default.split( + '\n' + ); + } else { + initialValues[`survey_${question.variable}`] = question.default; + } + if (sourceOfValues?.extra_data) { + Object.entries(sourceOfValues?.extra_data).forEach(([key, value]) => { + if (key === question.variable) { + if (question.type === 'multiselect') { + initialValues[`survey_${question.variable}`] = value; + } else { + initialValues[`survey_${question.variable}`] = value; + } + } + }); + } + }); + } + + return initialValues; +}; + export default function useWorkflowNodeSteps( - config, + launchConfig, + surveyConfig, i18n, resource, - askLinkType, - needsPreviewStep, - nodeToEdit + askLinkType ) { + const { nodeToEdit } = useContext(WorkflowStateContext); + const { resetForm, values: formikValues } = useFormikContext(); const [visited, setVisited] = useState({}); + const steps = [ useRunTypeStep(i18n, askLinkType), - useNodeTypeStep(i18n, nodeToEdit), - useInventoryStep( - config, - i18n, - visited, - resource, - nodeToEdit - ), - useCredentialsStep(config, i18n, resource, nodeToEdit?.originalNodeObject), - useOtherPromptsStep(config, i18n, resource, nodeToEdit?.originalNodeObject), - useSurveyStep( - config, - i18n, - visited, - resource, - nodeToEdit?.originalNodeObject - ), + useNodeTypeStep(i18n), + useInventoryStep(launchConfig, resource, i18n, visited), + useCredentialsStep(launchConfig, resource, i18n), + useOtherPromptsStep(launchConfig, resource, i18n), + useSurveyStep(launchConfig, surveyConfig, resource, i18n, visited), ]; - const { resetForm, values: formikValues } = useFormikContext(); const hasErrors = steps.some(step => step.formError); - const surveyStepIndex = steps.findIndex(step => step.survey); steps.push( usePreviewStep( - config, + launchConfig, i18n, resource, - steps[surveyStepIndex]?.survey, + surveyConfig, hasErrors, - needsPreviewStep, - nodeToEdit?.originalNodeObject + showPreviewStep(formikValues.nodeType, launchConfig) ) ); 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 (isReady) { + if (launchConfig && surveyConfig && isReady) { + let initialValues = {}; + + if ( + nodeToEdit && + nodeToEdit?.fullUnifiedJobTemplate && + nodeToEdit?.fullUnifiedJobTemplate?.id === formikValues.nodeResource?.id + ) { + initialValues = getNodeToEditDefaultValues( + launchConfig, + surveyConfig, + nodeToEdit + ); + } else { + initialValues = steps.reduce((acc, cur) => { + return { + ...acc, + ...cur.initialValues, + }; + }, {}); + } + resetForm({ values: { ...initialValues, - nodeResource: formikValues.nodeResource || initialValues.nodeResource, - nodeType: formikValues.nodeType || initialValues.nodeType, - linkType: formikValues.linkType || 'success', + nodeResource: formikValues.nodeResource, + nodeType: formikValues.nodeType, + linkType: formikValues.linkType, verbosity: initialValues?.verbosity?.toString(), }, }); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [config, isReady]); + }, [launchConfig, surveyConfig, isReady]); const stepWithError = steps.find(s => s.contentError); const contentError = stepWithError ? stepWithError.contentError : null; return { steps: pfSteps, - initialValues, - isReady, visitStep: stepId => setVisited({ ...visited, diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx index 9b9505d52c..0a79f31edc 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx @@ -7,6 +7,7 @@ import { WorkflowDispatchContext, WorkflowStateContext, } from '../../../contexts/Workflow'; +import { getAddedAndRemoved } from '../../../util/lists'; import { layoutGraph } from '../../../components/Workflow/WorkflowUtils'; import ContentError from '../../../components/ContentError'; import ContentLoading from '../../../components/ContentLoading'; @@ -46,6 +47,45 @@ const Wrapper = styled.div` height: 100%; `; +const getAggregatedCredentials = ( + originalNodeOverride = [], + templateDefaultCredentials = [] +) => { + let theArray = []; + + const isCredentialOverriden = templateDefaultCred => { + let credentialHasOverride = false; + originalNodeOverride.forEach(overrideCred => { + if ( + templateDefaultCred.credential_type === overrideCred.credential_type + ) { + if ( + (!templateDefaultCred.vault_id && !overrideCred.inputs.vault_id) || + (templateDefaultCred.vault_id && + overrideCred.inputs.vault_id && + templateDefaultCred.vault_id === overrideCred.inputs.vault_id) + ) { + credentialHasOverride = true; + } + } + }); + + return credentialHasOverride; + }; + + if (templateDefaultCredentials.length > 0) { + templateDefaultCredentials.forEach(defaultCred => { + if (!isCredentialOverriden(defaultCred)) { + theArray.push(defaultCred); + } + }); + } + + theArray = theArray.concat(originalNodeOverride); + + return theArray; +}; + const fetchWorkflowNodes = async ( templateId, pageNo = 1, @@ -286,7 +326,7 @@ function Visualizer({ template, i18n }) { WorkflowJobTemplateNodesAPI.destroy(node.originalNodeObject.id) ); } else if (!node.isDeleted && !node.originalNodeObject) { - if (node.unifiedJobTemplate.type === 'workflow_approval_template') { + if (node.fullUnifiedJobTemplate.type === 'workflow_approval_template') { nodeRequests.push( WorkflowJobTemplatesAPI.createNode(template.id, {}).then( ({ data }) => { @@ -299,20 +339,20 @@ function Visualizer({ template, i18n }) { }; approvalTemplateRequests.push( WorkflowJobTemplateNodesAPI.createApprovalTemplate(data.id, { - name: node.unifiedJobTemplate.name, - description: node.unifiedJobTemplate.description, - timeout: node.unifiedJobTemplate.timeout, + name: node.fullUnifiedJobTemplate.name, + description: node.fullUnifiedJobTemplate.description, + timeout: node.fullUnifiedJobTemplate.timeout, }) ); } ) ); } else { - node.promptValues.inventory = node.promptValues?.inventory?.id nodeRequests.push( WorkflowJobTemplatesAPI.createNode(template.id, { - ...node.promptValues, - unified_job_template: node.unifiedJobTemplate.id, + ...node.promptValues, + inventory: node.promptValues?.inventory?.id || null, + unified_job_template: node.fullUnifiedJobTemplate.id, }).then(({ data }) => { node.originalNodeObject = data; originalLinkMap[node.id] = { @@ -345,11 +385,7 @@ function Visualizer({ template, i18n }) { ); } } else if (node.isEdited) { - if ( - node.unifiedJobTemplate && - (node.unifiedJobTemplate.unified_job_type === 'workflow_approval' || - node.unifiedJobTemplate.type === 'workflow_approval_template') - ) { + if (node.fullUnifiedJobTemplate.type === 'workflow_approval_template') { if ( node.originalNodeObject.summary_fields.unified_job_template .unified_job_type === 'workflow_approval' @@ -358,9 +394,9 @@ function Visualizer({ template, i18n }) { WorkflowApprovalTemplatesAPI.update( node.originalNodeObject.summary_fields.unified_job_template.id, { - name: node.unifiedJobTemplate.name, - description: node.unifiedJobTemplate.description, - timeout: node.unifiedJobTemplate.timeout, + name: node.fullUnifiedJobTemplate.name, + description: node.fullUnifiedJobTemplate.description, + timeout: node.fullUnifiedJobTemplate.timeout, } ) ); @@ -369,32 +405,45 @@ function Visualizer({ template, i18n }) { WorkflowJobTemplateNodesAPI.createApprovalTemplate( node.originalNodeObject.id, { - name: node.unifiedJobTemplate.name, - description: node.unifiedJobTemplate.description, - timeout: node.unifiedJobTemplate.timeout, + name: node.fullUnifiedJobTemplate.name, + description: node.fullUnifiedJobTemplate.description, + timeout: node.fullUnifiedJobTemplate.timeout, } ) ); } } else { nodeRequests.push( - WorkflowJobTemplateNodesAPI.update(node.originalNodeObject.id, { + WorkflowJobTemplateNodesAPI.replace(node.originalNodeObject.id, { ...node.promptValues, - unified_job_template: node.unifiedJobTemplate.id, + inventory: node.promptValues?.inventory?.id || null, + unified_job_template: node.fullUnifiedJobTemplate.id, }) ); - if (node?.promptValues?.addedCredentials?.length > 0) { - node.promptValues.addedCredentials.forEach(cred => + + const { + added: addedCredentials, + removed: removedCredentials, + } = getAddedAndRemoved( + getAggregatedCredentials( + node?.originalNodeCredentials, + node.launchConfig?.defaults?.credentials + ), + node.promptValues?.credentials + ); + + if (addedCredentials.length > 0) { + addedCredentials.forEach(cred => { associateCredentialRequests.push( WorkflowJobTemplateNodesAPI.associateCredentials( node.originalNodeObject.id, cred.id ) - ) - ); + ); + }); } - if (node?.promptValues?.removedCredentials?.length > 0) { - node.promptValues.removedCredentials.forEach(cred => + if (removedCredentials?.length > 0) { + removedCredentials.forEach(cred => disassociateCredentialRequests.push( WorkflowJobTemplateNodesAPI.disassociateCredentials( node.originalNodeObject.id, diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.test.jsx index d510fae4ac..2421df4191 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.test.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.test.jsx @@ -64,7 +64,7 @@ const workflowContext = { }, { id: 2, - unifiedJobTemplate: { + fullUnifiedJobTemplate: { name: 'Foo JT', type: 'job_template', }, diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx index e38a1df488..65bd1c5008 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx @@ -14,12 +14,16 @@ import { WorkflowDispatchContext, WorkflowStateContext, } from '../../../contexts/Workflow'; +import AlertModal from '../../../components/AlertModal'; +import ErrorDetail from '../../../components/ErrorDetail'; +import { WorkflowJobTemplateNodesAPI } from '../../../api'; import { constants as wfConstants } from '../../../components/Workflow/WorkflowUtils'; import { WorkflowActionTooltip, WorkflowActionTooltipItem, WorkflowNodeTypeLetter, } from '../../../components/Workflow'; +import getNodeType from './shared/WorkflowJobTemplateVisualizerUtils'; const NodeG = styled.g` pointer-events: ${props => (props.noPointerEvents ? 'none' : 'initial')}; @@ -52,13 +56,85 @@ function VisualizerNode({ }) { const ref = useRef(null); const [hovering, setHovering] = useState(false); + const [credentialsError, setCredentialsError] = useState(null); + const [detailError, setDetailError] = useState(null); const dispatch = useContext(WorkflowDispatchContext); - const { addingLink, addLinkSourceNode, nodePositions } = useContext( + const { addingLink, addLinkSourceNode, nodePositions, nodes } = useContext( WorkflowStateContext ); const isAddLinkSourceNode = addLinkSourceNode && addLinkSourceNode.id === node.id; + const handleCredentialsErrorClose = () => setCredentialsError(null); + const handleDetailErrorClose = () => setDetailError(null); + + const updateNode = async () => { + const updatedNodes = [...nodes]; + const updatedNode = updatedNodes.find(n => n.id === node.id); + if ( + !node.fullUnifiedJobTemplate && + node?.originalNodeObject?.summary_fields?.unified_job_template + ) { + const [, nodeAPI] = getNodeType( + node.originalNodeObject.summary_fields.unified_job_template + ); + try { + const { data: fullUnifiedJobTemplate } = await nodeAPI.readDetail( + node.originalNodeObject.unified_job_template + ); + updatedNode.fullUnifiedJobTemplate = fullUnifiedJobTemplate; + } catch (err) { + setDetailError(err); + return null; + } + } + + if ( + node?.originalNodeObject?.summary_fields?.unified_job_template + ?.unified_job_type === 'job' && + !node?.originalNodeCredentials + ) { + try { + const { + data: { results }, + } = await WorkflowJobTemplateNodesAPI.readCredentials( + node.originalNodeObject.id + ); + updatedNode.originalNodeCredentials = results; + } catch (err) { + setCredentialsError(err); + return null; + } + } + + dispatch({ + type: 'SET_NODES', + value: updatedNodes, + }); + + return updatedNode; + }; + + const handleEditClick = async () => { + updateHelpText(null); + setHovering(false); + const nodeToEdit = await updateNode(); + + if (nodeToEdit) { + dispatch({ type: 'SET_NODE_TO_EDIT', value: nodeToEdit }); + } + }; + + const handleViewClick = async () => { + updateHelpText(null); + setHovering(false); + const nodeToView = await updateNode(); + + if (nodeToView) { + dispatch({ type: 'SET_NODE_TO_VIEW', value: nodeToView }); + } + }; + const handleNodeMouseEnter = () => { ref.current.parentNode.appendChild(ref.current); setHovering(true); @@ -91,11 +167,7 @@ function VisualizerNode({ { - updateHelpText(null); - setHovering(false); - dispatch({ type: 'SET_NODE_TO_VIEW', value: node }); - }} + onClick={handleViewClick} onMouseEnter={() => updateHelpText(i18n._(t`View node details`))} onMouseLeave={() => updateHelpText(null)} > @@ -123,11 +195,7 @@ function VisualizerNode({ { - updateHelpText(null); - setHovering(false); - dispatch({ type: 'SET_NODE_TO_EDIT', value: node }); - }} + onClick={handleEditClick} onMouseEnter={() => updateHelpText(i18n._(t`Edit this node`))} onMouseLeave={() => updateHelpText(null)} > @@ -164,57 +232,83 @@ function VisualizerNode({ ]; return ( - - - updateNodeHelp(node), - onMouseLeave: () => updateNodeHelp(null), - })} - onClick={() => handleNodeClick()} - width="178" - x="1" - y="1" + <> + - - - {node.unifiedJobTemplate - ? node.unifiedJobTemplate.name - : i18n._(t`DELETED`)} - - - - {node.unifiedJobTemplate && } - {hovering && !addingLink && ( - + updateNodeHelp(node), + onMouseLeave: () => updateNodeHelp(null), + })} + onClick={() => handleNodeClick()} + width="178" + x="1" + y="1" + > + + + {node?.fullUnifiedJobTemplate?.name || + node?.originalNodeObject?.summary_fields?.unified_job_template + ?.name || + i18n._(t`DELETED`)} + + + + + {hovering && !addingLink && ( + + )} + + {detailError && ( + + {i18n._(t`Failed to retrieve full node resource object.`)} + + )} - + {credentialsError && ( + + {i18n._(t`Failed to retrieve node credentials.`)} + + + )} + ); } diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.test.jsx index 85409c3d3b..184b56373b 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.test.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.test.jsx @@ -1,10 +1,31 @@ import React from 'react'; +import { act } from 'react-dom/test-utils'; import { WorkflowDispatchContext, WorkflowStateContext, } from '../../../contexts/Workflow'; import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import { JobTemplatesAPI, WorkflowJobTemplateNodesAPI } from '../../../api'; import VisualizerNode from './VisualizerNode'; +import { asyncFlush } from '../../../setupTests'; + +jest.mock('../../../api/models/JobTemplates'); +jest.mock('../../../api/models/WorkflowJobTemplateNodes'); + +WorkflowJobTemplateNodesAPI.readCredentials.mockResolvedValue({ + data: { + results: [], + }, +}); + +const nodeWithJT = { + id: 2, + fullUnifiedJobTemplate: { + id: 77, + name: 'Automation JT', + type: 'job_template', + }, +}; const mockedContext = { addingLink: false, @@ -23,15 +44,7 @@ const mockedContext = { y: 40, }, }, -}; - -const nodeWithJT = { - id: 2, - unifiedJobTemplate: { - id: 77, - name: 'Automation JT', - type: 'job_template', - }, + nodes: [nodeWithJT], }; const dispatch = jest.fn(); @@ -47,8 +60,6 @@ describe('VisualizerNode', () => { {}} - mouseLeave={() => {}} node={nodeWithJT} readOnly={false} updateHelpText={updateHelpText} @@ -59,6 +70,9 @@ describe('VisualizerNode', () => { ); }); + afterEach(() => { + jest.clearAllMocks(); + }); afterAll(() => { wrapper.unmount(); }); @@ -67,10 +81,10 @@ describe('VisualizerNode', () => { }); test('Displays action tooltip on hover and updates help text on hover', () => { expect(wrapper.find('WorkflowActionTooltip').length).toBe(0); - wrapper.find('VisualizerNode').simulate('mouseenter'); + wrapper.find('g').simulate('mouseenter'); expect(wrapper.find('WorkflowActionTooltip').length).toBe(1); expect(wrapper.find('WorkflowActionTooltipItem').length).toBe(5); - wrapper.find('VisualizerNode').simulate('mouseleave'); + wrapper.find('g').simulate('mouseleave'); expect(wrapper.find('WorkflowActionTooltip').length).toBe(0); wrapper .find('foreignObject') @@ -85,7 +99,7 @@ describe('VisualizerNode', () => { }); test('Add tooltip action hover/click updates help text and dispatches properly', () => { - wrapper.find('VisualizerNode').simulate('mouseenter'); + wrapper.find('g').simulate('mouseenter'); wrapper.find('WorkflowActionTooltipItem#node-add').simulate('mouseenter'); expect(updateHelpText).toHaveBeenCalledWith('Add a new node'); wrapper.find('WorkflowActionTooltipItem#node-add').simulate('mouseleave'); @@ -98,8 +112,8 @@ describe('VisualizerNode', () => { expect(wrapper.find('WorkflowActionTooltip').length).toBe(0); }); - test('Edit tooltip action hover/click updates help text and dispatches properly', () => { - wrapper.find('VisualizerNode').simulate('mouseenter'); + test('Edit tooltip action hover/click updates help text and dispatches properly', async () => { + wrapper.find('g').simulate('mouseenter'); wrapper .find('WorkflowActionTooltipItem#node-edit') .simulate('mouseenter'); @@ -109,15 +123,27 @@ describe('VisualizerNode', () => { .simulate('mouseleave'); expect(updateHelpText).toHaveBeenCalledWith(null); wrapper.find('WorkflowActionTooltipItem#node-edit').simulate('click'); - expect(dispatch).toHaveBeenCalledWith({ - type: 'SET_NODE_TO_EDIT', - value: nodeWithJT, - }); + await asyncFlush(); + expect(dispatch).toHaveBeenCalledTimes(2); + expect(dispatch.mock.calls).toEqual([ + [ + { + type: 'SET_NODES', + value: [nodeWithJT], + }, + ], + [ + { + type: 'SET_NODE_TO_EDIT', + value: nodeWithJT, + }, + ], + ]); expect(wrapper.find('WorkflowActionTooltip').length).toBe(0); }); - test('Details tooltip action hover/click updates help text and dispatches properly', () => { - wrapper.find('VisualizerNode').simulate('mouseenter'); + test('Details tooltip action hover/click updates help text and dispatches properly', async () => { + wrapper.find('g').simulate('mouseenter'); wrapper .find('WorkflowActionTooltipItem#node-details') .simulate('mouseenter'); @@ -127,15 +153,27 @@ describe('VisualizerNode', () => { .simulate('mouseleave'); expect(updateHelpText).toHaveBeenCalledWith(null); wrapper.find('WorkflowActionTooltipItem#node-details').simulate('click'); - expect(dispatch).toHaveBeenCalledWith({ - type: 'SET_NODE_TO_VIEW', - value: nodeWithJT, - }); + await asyncFlush(); + expect(dispatch).toHaveBeenCalledTimes(2); + expect(dispatch.mock.calls).toEqual([ + [ + { + type: 'SET_NODES', + value: [nodeWithJT], + }, + ], + [ + { + type: 'SET_NODE_TO_VIEW', + value: nodeWithJT, + }, + ], + ]); expect(wrapper.find('WorkflowActionTooltip').length).toBe(0); }); test('Link tooltip action hover/click updates help text and dispatches properly', () => { - wrapper.find('VisualizerNode').simulate('mouseenter'); + wrapper.find('g').simulate('mouseenter'); wrapper .find('WorkflowActionTooltipItem#node-link') .simulate('mouseenter'); @@ -153,7 +191,7 @@ describe('VisualizerNode', () => { }); test('Delete tooltip action hover/click updates help text and dispatches properly', () => { - wrapper.find('VisualizerNode').simulate('mouseenter'); + wrapper.find('g').simulate('mouseenter'); wrapper .find('WorkflowActionTooltipItem#node-delete') .simulate('mouseenter'); @@ -201,12 +239,12 @@ describe('VisualizerNode', () => { }); test('Displays correct help text when hovering over node while adding link', () => { expect(wrapper.find('WorkflowActionTooltip').length).toBe(0); - wrapper.find('VisualizerNode').simulate('mouseenter'); + wrapper.find('g').simulate('mouseenter'); expect(wrapper.find('WorkflowActionTooltip').length).toBe(0); expect(updateHelpText).toHaveBeenCalledWith( 'Click to create a new link to this node.' ); - wrapper.find('VisualizerNode').simulate('mouseleave'); + wrapper.find('g').simulate('mouseleave'); expect(wrapper.find('WorkflowActionTooltip').length).toBe(0); expect(updateHelpText).toHaveBeenCalledWith(null); }); @@ -227,8 +265,6 @@ describe('VisualizerNode', () => { {}} - mouseLeave={() => {}} node={{ id: 2, }} @@ -243,4 +279,143 @@ describe('VisualizerNode', () => { expect(wrapper.find('NodeResourceName').text()).toBe('DELETED'); }); }); + describe('Node without full unified job template', () => { + let wrapper; + beforeEach(() => { + wrapper = mountWithContexts( + + + + + + + + ); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('Attempts to fetch full unified job template on view', async () => { + wrapper.find('g').simulate('mouseenter'); + await act(async () => { + wrapper + .find('WorkflowActionTooltipItem#node-details') + .simulate('click'); + }); + expect(JobTemplatesAPI.readDetail).toHaveBeenCalledWith(7); + }); + test('Displays error fetching full unified job template', async () => { + JobTemplatesAPI.readDetail.mockRejectedValueOnce( + new Error({ + response: { + config: { + method: 'get', + url: '/api/v2/job_templates/7', + }, + data: 'An error occurred', + status: 403, + }, + }) + ); + expect(wrapper.find('AlertModal').length).toBe(0); + wrapper.find('g').simulate('mouseenter'); + await act(async () => { + wrapper + .find('WorkflowActionTooltipItem#node-details') + .simulate('click'); + }); + wrapper.update(); + expect(wrapper.find('AlertModal').length).toBe(1); + }); + test('Attempts to fetch credentials on view', async () => { + JobTemplatesAPI.readDetail.mockResolvedValueOnce({ + data: { + id: 7, + name: 'Example', + }, + }); + wrapper.find('g').simulate('mouseenter'); + await act(async () => { + wrapper + .find('WorkflowActionTooltipItem#node-details') + .simulate('click'); + }); + expect(WorkflowJobTemplateNodesAPI.readCredentials).toHaveBeenCalledWith( + 49 + ); + }); + test('Displays error fetching credentials', async () => { + JobTemplatesAPI.readDetail.mockResolvedValueOnce({ + data: { + id: 7, + name: 'Example', + }, + }); + WorkflowJobTemplateNodesAPI.readCredentials.mockRejectedValueOnce( + new Error({ + response: { + config: { + method: 'get', + url: '/api/v2/workflow_job_template_nodes/49/credentials', + }, + data: 'An error occurred', + status: 403, + }, + }) + ); + expect(wrapper.find('AlertModal').length).toBe(0); + wrapper.find('g').simulate('mouseenter'); + await act(async () => { + wrapper + .find('WorkflowActionTooltipItem#node-details') + .simulate('click'); + }); + wrapper.update(); + expect(wrapper.find('AlertModal').length).toBe(1); + }); + }); }); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/shared/WorkflowJobTemplateVisualizerUtils.js b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/shared/WorkflowJobTemplateVisualizerUtils.js new file mode 100644 index 0000000000..64b494c3d5 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/shared/WorkflowJobTemplateVisualizerUtils.js @@ -0,0 +1,29 @@ +import { + InventorySourcesAPI, + JobTemplatesAPI, + ProjectsAPI, + WorkflowJobTemplatesAPI, +} from '../../../../api'; + +export default function getNodeType(node) { + const ujtType = node?.type || node?.unified_job_type; + switch (ujtType) { + case 'job_template': + case 'job': + return ['job_template', JobTemplatesAPI]; + case 'project': + case 'project_update': + return ['project_sync', ProjectsAPI]; + case 'inventory_source': + case 'inventory_update': + return ['inventory_source_sync', InventorySourcesAPI]; + case 'workflow_job_template': + case 'workflow_job': + return ['workflow_job_template', WorkflowJobTemplatesAPI]; + case 'workflow_approval_template': + case 'workflow_approval': + return ['approval', null]; + default: + return [null, null]; + } +} From 1be1fad61010167d5a0f6f174b35cc43863840b5 Mon Sep 17 00:00:00 2001 From: mabashian Date: Tue, 8 Dec 2020 15:57:42 -0500 Subject: [PATCH 07/14] Fixes display of multi-select survey question fields --- .../src/components/LaunchPrompt/steps/SurveyStep.jsx | 3 ++- .../src/components/LaunchPrompt/steps/useSurveyStep.jsx | 6 ++++-- .../src/screens/Template/Survey/SurveyPreviewModal.jsx | 4 +++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/SurveyStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/SurveyStep.jsx index cfdae4e957..a500b07b69 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/SurveyStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/SurveyStep.jsx @@ -130,7 +130,8 @@ function MultiSelectField({ question, i18n }) { { if (question.type === 'multiselect') { - values[`survey_${question.variable}`] = question.default.split('\n'); + values[`survey_${question.variable}`] = question.default + ? question.default.split('\n') + : []; } else { - values[`survey_${question.variable}`] = question.default; + values[`survey_${question.variable}`] = question.default || null; } if (resource?.extra_data) { Object.entries(resource.extra_data).forEach(([key, value]) => { diff --git a/awx/ui_next/src/screens/Template/Survey/SurveyPreviewModal.jsx b/awx/ui_next/src/screens/Template/Survey/SurveyPreviewModal.jsx index d542c1b56b..1b1086e6b2 100644 --- a/awx/ui_next/src/screens/Template/Survey/SurveyPreviewModal.jsx +++ b/awx/ui_next/src/screens/Template/Survey/SurveyPreviewModal.jsx @@ -104,7 +104,9 @@ function SurveyPreviewModal({ isReadOnly variant={SelectVariant.typeaheadMulti} isOpen={false} - selections={q.default.length > 0 && q.default.split('\n')} + selections={ + q.default.length > 0 ? q.default.split('\n') : [] + } onToggle={() => {}} aria-label={i18n._(t`Multi-Select`)} id={`survey-preview-multiSelect-${q.variable}`} From b7c729c96fd44cb5f984a3f68b1ad4df9e97a81e Mon Sep 17 00:00:00 2001 From: mabashian Date: Wed, 9 Dec 2020 10:29:16 -0500 Subject: [PATCH 08/14] Adds ID's to prompt wizard steps --- .../src/components/LaunchPrompt/LaunchPrompt.test.jsx | 10 +++++----- .../src/components/LaunchPrompt/steps/StepName.jsx | 6 +++--- .../LaunchPrompt/steps/useCredentialsStep.jsx | 8 ++++++-- .../components/LaunchPrompt/steps/useInventoryStep.jsx | 6 +++++- .../LaunchPrompt/steps/useOtherPromptsStep.jsx | 7 ++++++- .../components/LaunchPrompt/steps/usePreviewStep.jsx | 7 ++++++- .../components/LaunchPrompt/steps/useSurveyStep.jsx | 1 + .../Modals/NodeModals/NodeModal.test.jsx | 5 +---- .../Modals/NodeModals/NodeTypeStep/useNodeTypeStep.jsx | 7 ++++++- .../Modals/NodeModals/useRunTypeStep.jsx | 8 ++++++-- 10 files changed, 45 insertions(+), 20 deletions(-) diff --git a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.test.jsx b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.test.jsx index 707e0f5401..c320aac30b 100644 --- a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.test.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.test.jsx @@ -112,10 +112,10 @@ describe('LaunchPrompt', () => { expect(steps).toHaveLength(5); expect(steps[0].name.props.children).toEqual('Inventory'); - expect(steps[1].name).toEqual('Credentials'); - expect(steps[2].name).toEqual('Other Prompts'); + expect(steps[1].name.props.children).toEqual('Credentials'); + expect(steps[2].name.props.children).toEqual('Other prompts'); expect(steps[3].name.props.children).toEqual('Survey'); - expect(steps[4].name).toEqual('Preview'); + expect(steps[4].name.props.children).toEqual('Preview'); }); test('should add inventory step', async () => { @@ -161,7 +161,7 @@ describe('LaunchPrompt', () => { const steps = wizard.prop('steps'); expect(steps).toHaveLength(2); - expect(steps[0].name).toEqual('Credentials'); + expect(steps[0].name.props.children).toEqual('Credentials'); expect(isElementOfType(steps[0].component, CredentialsStep)).toEqual(true); expect(isElementOfType(steps[1].component, PreviewStep)).toEqual(true); }); @@ -185,7 +185,7 @@ describe('LaunchPrompt', () => { const steps = wizard.prop('steps'); expect(steps).toHaveLength(2); - expect(steps[0].name).toEqual('Other Prompts'); + expect(steps[0].name.props.children).toEqual('Other prompts'); expect(isElementOfType(steps[0].component, OtherPromptsStep)).toEqual(true); expect(isElementOfType(steps[1].component, PreviewStep)).toEqual(true); }); diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/StepName.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/StepName.jsx index 28bf5f0414..d7d37989cd 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/StepName.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/StepName.jsx @@ -14,13 +14,13 @@ const ExclamationCircleIcon = styled(PFExclamationCircleIcon)` margin-left: 10px; `; -function StepName({ hasErrors, children, i18n }) { +function StepName({ hasErrors, children, i18n, id }) { if (!hasErrors) { - return children; + return
{children}
; } return ( <> - + {children} + {i18n._(t`Credentials`)} + + ), component: , enableNext: true, }; diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx index dfdf645275..0d00a3b747 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx @@ -35,7 +35,11 @@ function getStep(launchConfig, i18n, formError) { } return { id: STEP_ID, - name: {i18n._(t`Inventory`)}, + name: ( + + {i18n._(t`Inventory`)} + + ), component: , enableNext: true, }; diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx index 9af74046fd..1f63397c17 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx @@ -2,6 +2,7 @@ import React from 'react'; import { t } from '@lingui/macro'; import { jsonToYaml, parseVariableField } from '../../../util/yaml'; import OtherPromptsStep from './OtherPromptsStep'; +import StepName from './StepName'; const STEP_ID = 'other'; @@ -43,7 +44,11 @@ function getStep(launchConfig, i18n) { return { id: STEP_ID, key: 5, - name: i18n._(t`Other Prompts`), + name: ( + + {i18n._(t`Other prompts`)} + + ), component: , enableNext: true, }; diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx index fae5fd91f3..77570fab0b 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx @@ -1,6 +1,7 @@ import React from 'react'; import { t } from '@lingui/macro'; import PreviewStep from './PreviewStep'; +import StepName from './StepName'; const STEP_ID = 'preview'; @@ -16,7 +17,11 @@ export default function usePreviewStep( step: showStep ? { id: STEP_ID, - name: i18n._(t`Preview`), + name: ( + + {i18n._(t`Preview`)} + + ), component: ( {i18n._(t`Survey`)} 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 55ed1b6fc8..1c227ef90d 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 @@ -274,10 +274,7 @@ describe('NodeModal', () => { wrapper.update(); expect(wrapper.find('NodeNextButton').prop('buttonText')).toBe('Next'); act(() => { - wrapper - .find('WizardNavItem[content="Preview"]') - .find('button') - .prop('onClick')(); + wrapper.find('StepName#preview-step').simulate('click'); }); wrapper.update(); await act(async () => { 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 73c4e10fcf..2b0dcd888d 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 @@ -2,6 +2,7 @@ import React from 'react'; import { t } from '@lingui/macro'; import { useField } from 'formik'; import NodeTypeStep from './NodeTypeStep'; +import StepName from '../../../../../../components/LaunchPrompt/steps/StepName'; const STEP_ID = 'nodeType'; @@ -38,7 +39,11 @@ function getStep(i18n, nodeTypeField, approvalNameField, nodeResourceField) { }; return { id: STEP_ID, - name: i18n._(t`Node Type`), + name: ( + + {i18n._(t`Node type`)} + + ), component: , enableNext: isEnabled(), }; 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 index 6417ee2826..2e117da77e 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/useRunTypeStep.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/useRunTypeStep.jsx @@ -1,8 +1,8 @@ import React from 'react'; import { t } from '@lingui/macro'; import { useField } from 'formik'; - import RunStep from './RunStep'; +import StepName from '../../../../../components/LaunchPrompt/steps/StepName'; const STEP_ID = 'runType'; @@ -28,7 +28,11 @@ function getStep(askLinkType, meta, i18n) { } return { id: STEP_ID, - name: i18n._(t`Run Type`), + name: ( + + {i18n._(t`Run type`)} + + ), component: , enableNext: meta.value !== '', }; From 7a3382dd76da3a04c560e681bfb5b435ab587782 Mon Sep 17 00:00:00 2001 From: mabashian Date: Wed, 9 Dec 2020 11:01:40 -0500 Subject: [PATCH 09/14] Fix initial multiplechoice survey question value when no default present. --- .../src/components/LaunchPrompt/steps/useSurveyStep.jsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx index 05719ad3a7..37e5454d13 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx @@ -111,8 +111,11 @@ function getInitialValues(launchConfig, surveyConfig, resource) { values[`survey_${question.variable}`] = question.default ? question.default.split('\n') : []; + } else if (question.type === 'multiplechoice') { + values[`survey_${question.variable}`] = + question.default || question.choices.split('\n')[0]; } else { - values[`survey_${question.variable}`] = question.default || null; + values[`survey_${question.variable}`] = question.default || ''; } if (resource?.extra_data) { Object.entries(resource.extra_data).forEach(([key, value]) => { From ca1e597a4dec9f9ef70dbe7a198bca4601b0cd8d Mon Sep 17 00:00:00 2001 From: mabashian Date: Wed, 9 Dec 2020 15:44:18 -0500 Subject: [PATCH 10/14] Adds blanket error handling to visualizer save process --- .../Visualizer.jsx | 490 ++++++++++-------- 1 file changed, 261 insertions(+), 229 deletions(-) diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx index 0a79f31edc..1deb449890 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx @@ -1,17 +1,21 @@ -import React, { useEffect, useReducer } from 'react'; +import React, { useCallback, useEffect, useReducer } from 'react'; import { useHistory } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import styled from 'styled-components'; import { shape } from 'prop-types'; +import { t } from '@lingui/macro'; import { WorkflowDispatchContext, WorkflowStateContext, } from '../../../contexts/Workflow'; import { getAddedAndRemoved } from '../../../util/lists'; +import AlertModal from '../../../components/AlertModal'; +import ErrorDetail from '../../../components/ErrorDetail'; import { layoutGraph } from '../../../components/Workflow/WorkflowUtils'; import ContentError from '../../../components/ContentError'; import ContentLoading from '../../../components/ContentLoading'; import workflowReducer from '../../../components/Workflow/workflowReducer'; +import useRequest, { useDismissableError } from '../../../util/useRequest'; import { DeleteAllNodesModal, UnsavedChangesModal } from './Modals'; import { LinkAddModal, @@ -246,232 +250,6 @@ function Visualizer({ template, i18n }) { return disassociateNodeRequests; }; - const generateLinkMapAndNewLinks = originalLinkMap => { - const linkMap = {}; - const newLinks = []; - - links.forEach(link => { - if (link.source.id !== 1) { - const realLinkSourceId = originalLinkMap[link.source.id].id; - const realLinkTargetId = originalLinkMap[link.target.id].id; - if (!linkMap[realLinkSourceId]) { - linkMap[realLinkSourceId] = {}; - } - linkMap[realLinkSourceId][realLinkTargetId] = link.linkType; - switch (link.linkType) { - case 'success': - if ( - !originalLinkMap[link.source.id].success_nodes.includes( - originalLinkMap[link.target.id].id - ) - ) { - newLinks.push(link); - } - break; - case 'failure': - if ( - !originalLinkMap[link.source.id].failure_nodes.includes( - originalLinkMap[link.target.id].id - ) - ) { - newLinks.push(link); - } - break; - case 'always': - if ( - !originalLinkMap[link.source.id].always_nodes.includes( - originalLinkMap[link.target.id].id - ) - ) { - newLinks.push(link); - } - break; - default: - } - } - }); - - return [linkMap, newLinks]; - }; - - const handleVisualizerSave = async () => { - const nodeRequests = []; - 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) { - return; - } - if (node.originalNodeObject && !node.isDeleted) { - const { - id, - success_nodes, - failure_nodes, - always_nodes, - } = node.originalNodeObject; - originalLinkMap[node.id] = { - id, - success_nodes, - failure_nodes, - always_nodes, - }; - } - if (node.isDeleted && node.originalNodeObject) { - deletedNodeIds.push(node.originalNodeObject.id); - nodeRequests.push( - WorkflowJobTemplateNodesAPI.destroy(node.originalNodeObject.id) - ); - } else if (!node.isDeleted && !node.originalNodeObject) { - if (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, - }) - ); - } - ) - ); - } else { - nodeRequests.push( - WorkflowJobTemplatesAPI.createNode(template.id, { - ...node.promptValues, - inventory: node.promptValues?.inventory?.id || null, - unified_job_template: node.fullUnifiedJobTemplate.id, - }).then(({ data }) => { - node.originalNodeObject = data; - originalLinkMap[node.id] = { - id: data.id, - success_nodes: [], - 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 - ) - ); - }); - } - }) - ); - } - } else if (node.isEdited) { - if (node.fullUnifiedJobTemplate.type === 'workflow_approval_template') { - if ( - 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( - node.originalNodeObject.id, - { - name: node.fullUnifiedJobTemplate.name, - description: node.fullUnifiedJobTemplate.description, - timeout: node.fullUnifiedJobTemplate.timeout, - } - ) - ); - } - } else { - nodeRequests.push( - WorkflowJobTemplateNodesAPI.replace(node.originalNodeObject.id, { - ...node.promptValues, - inventory: node.promptValues?.inventory?.id || null, - unified_job_template: node.fullUnifiedJobTemplate.id, - }) - ); - - const { - added: addedCredentials, - removed: removedCredentials, - } = getAddedAndRemoved( - getAggregatedCredentials( - node?.originalNodeCredentials, - node.launchConfig?.defaults?.credentials - ), - node.promptValues?.credentials - ); - - if (addedCredentials.length > 0) { - addedCredentials.forEach(cred => { - associateCredentialRequests.push( - WorkflowJobTemplateNodesAPI.associateCredentials( - node.originalNodeObject.id, - cred.id - ) - ); - }); - } - if (removedCredentials?.length > 0) { - removedCredentials.forEach(cred => - disassociateCredentialRequests.push( - WorkflowJobTemplateNodesAPI.disassociateCredentials( - node.originalNodeObject.id, - cred.id - ) - ) - ); - } - } - } - }); - - await Promise.all(nodeRequests); - // Creating approval templates needs to happen after the node has been created - // since we reference the node in the approval template request. - await Promise.all(approvalTemplateRequests); - const [linkMap, newLinks] = generateLinkMapAndNewLinks(originalLinkMap); - await Promise.all( - disassociateNodes(originalLinkMap, deletedNodeIds, linkMap) - ); - await Promise.all(associateNodes(newLinks, originalLinkMap)); - - await Promise.all(disassociateCredentialRequests); - await Promise.all(associateCredentialRequests); - - history.push(`/templates/workflow_job_template/${template.id}/details`); - }; - useEffect(() => { async function fetchData() { try { @@ -505,6 +283,249 @@ function Visualizer({ template, i18n }) { } }, [links, nodes]); + const { error: saveVisualizerError, request: saveVisualizer } = useRequest( + useCallback(async () => { + const nodeRequests = []; + const approvalTemplateRequests = []; + const originalLinkMap = {}; + const deletedNodeIds = []; + const associateCredentialRequests = []; + const disassociateCredentialRequests = []; + + const generateLinkMapAndNewLinks = () => { + const linkMap = {}; + const newLinks = []; + + links.forEach(link => { + if (link.source.id !== 1) { + const realLinkSourceId = originalLinkMap[link.source.id].id; + const realLinkTargetId = originalLinkMap[link.target.id].id; + if (!linkMap[realLinkSourceId]) { + linkMap[realLinkSourceId] = {}; + } + linkMap[realLinkSourceId][realLinkTargetId] = link.linkType; + switch (link.linkType) { + case 'success': + if ( + !originalLinkMap[link.source.id].success_nodes.includes( + originalLinkMap[link.target.id].id + ) + ) { + newLinks.push(link); + } + break; + case 'failure': + if ( + !originalLinkMap[link.source.id].failure_nodes.includes( + originalLinkMap[link.target.id].id + ) + ) { + newLinks.push(link); + } + break; + case 'always': + if ( + !originalLinkMap[link.source.id].always_nodes.includes( + originalLinkMap[link.target.id].id + ) + ) { + newLinks.push(link); + } + break; + default: + } + } + }); + + return [linkMap, newLinks]; + }; + + nodes.forEach(node => { + // node with id=1 is the artificial start node + if (node.id === 1) { + return; + } + if (node.originalNodeObject && !node.isDeleted) { + const { + id, + success_nodes, + failure_nodes, + always_nodes, + } = node.originalNodeObject; + originalLinkMap[node.id] = { + id, + success_nodes, + failure_nodes, + always_nodes, + }; + } + if (node.isDeleted && node.originalNodeObject) { + deletedNodeIds.push(node.originalNodeObject.id); + nodeRequests.push( + WorkflowJobTemplateNodesAPI.destroy(node.originalNodeObject.id) + ); + } else if (!node.isDeleted && !node.originalNodeObject) { + if ( + 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, + } + ) + ); + } + ) + ); + } else { + nodeRequests.push( + WorkflowJobTemplatesAPI.createNode(template.id, { + ...node.promptValues, + inventory: node.promptValues?.inventory?.id || null, + unified_job_template: node.fullUnifiedJobTemplate.id, + }).then(({ data }) => { + node.originalNodeObject = data; + originalLinkMap[node.id] = { + id: data.id, + success_nodes: [], + 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 + ) + ); + }); + } + }) + ); + } + } else if (node.isEdited) { + if ( + node.fullUnifiedJobTemplate.type === 'workflow_approval_template' + ) { + if ( + 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( + node.originalNodeObject.id, + { + name: node.fullUnifiedJobTemplate.name, + description: node.fullUnifiedJobTemplate.description, + timeout: node.fullUnifiedJobTemplate.timeout, + } + ) + ); + } + } else { + nodeRequests.push( + WorkflowJobTemplateNodesAPI.replace(node.originalNodeObject.id, { + ...node.promptValues, + inventory: node.promptValues?.inventory?.id || null, + unified_job_template: node.fullUnifiedJobTemplate.id, + }).then(() => { + const { + added: addedCredentials, + removed: removedCredentials, + } = getAddedAndRemoved( + getAggregatedCredentials( + node?.originalNodeCredentials, + node.launchConfig?.defaults?.credentials + ), + node.promptValues?.credentials + ); + + if (addedCredentials.length > 0) { + addedCredentials.forEach(cred => { + associateCredentialRequests.push( + WorkflowJobTemplateNodesAPI.associateCredentials( + node.originalNodeObject.id, + cred.id + ) + ); + }); + } + if (removedCredentials?.length > 0) { + removedCredentials.forEach(cred => + disassociateCredentialRequests.push( + WorkflowJobTemplateNodesAPI.disassociateCredentials( + node.originalNodeObject.id, + cred.id + ) + ) + ); + } + }) + ); + } + } + }); + + await Promise.all(nodeRequests); + // Creating approval templates needs to happen after the node has been created + // since we reference the node in the approval template request. + await Promise.all(approvalTemplateRequests); + const [linkMap, newLinks] = generateLinkMapAndNewLinks(originalLinkMap); + await Promise.all( + disassociateNodes(originalLinkMap, deletedNodeIds, linkMap) + ); + await Promise.all(associateNodes(newLinks, originalLinkMap)); + + await Promise.all(disassociateCredentialRequests); + await Promise.all(associateCredentialRequests); + + history.push(`/templates/workflow_job_template/${template.id}/details`); + }, [links, nodes, history, template.id]), + {} + ); + + const { + error: nodeRequestError, + dismissError: dismissNodeRequestError, + } = useDismissableError(saveVisualizerError); + if (isLoading) { return ( @@ -529,7 +550,7 @@ function Visualizer({ template, i18n }) { saveVisualizer(nodes)} hasUnsavedChanges={unsavedChanges} template={template} readOnly={readOnly} @@ -553,11 +574,22 @@ function Visualizer({ template, i18n }) { `/templates/workflow_job_template/${template.id}/details` ) } - onSaveAndExit={() => handleVisualizerSave()} + onSaveAndExit={() => saveVisualizer(nodes)} /> )} {showDeleteAllNodesModal && } {nodeToView && } + {nodeRequestError && ( + + {i18n._(t`There was an error saving the workflow.`)} + + + )}
); From 0ac38ef5fcd88473b10416525adbd36e8ed6c943 Mon Sep 17 00:00:00 2001 From: mabashian Date: Wed, 9 Dec 2020 15:44:33 -0500 Subject: [PATCH 11/14] Fix run step title --- .../WorkflowJobTemplateVisualizer/Modals/NodeModals/RunStep.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 6700e08937..da08b9ad71 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 @@ -21,7 +21,7 @@ function RunStep({ i18n }) { return ( <> - {i18n._(t`Don't Run`)} + {i18n._(t`Run`)}

{i18n._( From 6abc981a5eb21668e249b27a1b04e619cdfbbd17 Mon Sep 17 00:00:00 2001 From: mabashian Date: Wed, 9 Dec 2020 16:06:43 -0500 Subject: [PATCH 12/14] Wrap the sparkline down to the next line instead of overflowing --- awx/ui_next/src/components/Sparkline/Sparkline.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/awx/ui_next/src/components/Sparkline/Sparkline.jsx b/awx/ui_next/src/components/Sparkline/Sparkline.jsx index b76dc0b30c..306d73204b 100644 --- a/awx/ui_next/src/components/Sparkline/Sparkline.jsx +++ b/awx/ui_next/src/components/Sparkline/Sparkline.jsx @@ -16,6 +16,7 @@ const Link = styled(props => <_Link {...props} />)` const Wrapper = styled.div` display: inline-flex; + flex-wrap: wrap; `; /* eslint-enable react/jsx-pascal-case */ From d95373f2b77ea9655ba47899da82f9d75f4f1d2b Mon Sep 17 00:00:00 2001 From: mabashian Date: Thu, 10 Dec 2020 09:55:00 -0500 Subject: [PATCH 13/14] Fixes bug where page would crash on preview step if extra vars was malformed --- .../components/LaunchPrompt/LaunchPrompt.jsx | 4 ++-- .../LaunchPrompt/steps/PreviewStep.jsx | 10 ++++---- .../Modals/NodeModals/NodeModal.jsx | 24 ++++++++++++++++++- .../prompt}/getSurveyValues.js | 0 .../prompt}/mergeExtraVars.js | 0 .../prompt}/mergeExtraVars.test.js | 0 6 files changed, 29 insertions(+), 9 deletions(-) rename awx/ui_next/src/{components/LaunchPrompt => util/prompt}/getSurveyValues.js (100%) rename awx/ui_next/src/{components/LaunchPrompt => util/prompt}/mergeExtraVars.js (100%) rename awx/ui_next/src/{components/LaunchPrompt => util/prompt}/mergeExtraVars.test.js (100%) diff --git a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx index 991d527c81..00074a3e87 100644 --- a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx @@ -6,10 +6,10 @@ import { Formik, useFormikContext } from 'formik'; import ContentError from '../ContentError'; import ContentLoading from '../ContentLoading'; import { useDismissableError } from '../../util/useRequest'; -import mergeExtraVars from './mergeExtraVars'; +import mergeExtraVars from '../../util/prompt/mergeExtraVars'; +import getSurveyValues from '../../util/prompt/getSurveyValues'; import useLaunchSteps from './useLaunchSteps'; import AlertModal from '../AlertModal'; -import getSurveyValues from './getSurveyValues'; function PromptModalForm({ launchConfig, diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.jsx index 672956027d..58aa0d3777 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.jsx @@ -6,9 +6,10 @@ import { t } from '@lingui/macro'; import { useFormikContext } from 'formik'; import { withI18n } from '@lingui/react'; import yaml from 'js-yaml'; -import { parseVariableField } from '../../../util/yaml'; -import mergeExtraVars, { maskPasswords } from '../mergeExtraVars'; -import getSurveyValues from '../getSurveyValues'; +import mergeExtraVars, { + maskPasswords, +} from '../../../util/prompt/mergeExtraVars'; +import getSurveyValues from '../../../util/prompt/getSurveyValues'; import PromptDetail from '../../PromptDetail'; const ExclamationCircleIcon = styled(PFExclamationCircleIcon)` @@ -54,9 +55,6 @@ function PreviewStep({ } } - values.extra_data = - overrides.extra_vars && parseVariableField(overrides?.extra_vars); - return ( {formErrors && ( 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 9ae4539cb5..4d58da6dd0 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 @@ -4,7 +4,7 @@ import { useHistory } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Formik, useFormikContext } from 'formik'; - +import yaml from 'js-yaml'; import { bool, node, func } from 'prop-types'; import { Button, @@ -18,6 +18,9 @@ import ContentLoading from '../../../../../components/ContentLoading'; import useRequest, { useDismissableError, } from '../../../../../util/useRequest'; +import mergeExtraVars from '../../../../../util/prompt/mergeExtraVars'; +import getSurveyValues from '../../../../../util/prompt/getSurveyValues'; +import { parseVariableField } from '../../../../../util/yaml'; import { WorkflowDispatchContext, WorkflowStateContext, @@ -76,6 +79,25 @@ function NodeModalForm({ delete values.timeoutMinutes; delete values.timeoutSeconds; } + + if ( + ['job_template', 'workflow_job_template'].includes(values.nodeType) && + (launchConfig.ask_variables_on_launch || launchConfig.survey_enabled) + ) { + let extraVars; + const surveyValues = getSurveyValues(values); + const initialExtraVars = + launchConfig.ask_variables_on_launch && (values.extra_vars || '---'); + if (surveyConfig?.spec) { + extraVars = yaml.safeDump( + mergeExtraVars(initialExtraVars, surveyValues) + ); + } else { + extraVars = initialExtraVars; + } + values.extra_data = extraVars && parseVariableField(extraVars); + } + onSave(values, launchConfig); }; diff --git a/awx/ui_next/src/components/LaunchPrompt/getSurveyValues.js b/awx/ui_next/src/util/prompt/getSurveyValues.js similarity index 100% rename from awx/ui_next/src/components/LaunchPrompt/getSurveyValues.js rename to awx/ui_next/src/util/prompt/getSurveyValues.js diff --git a/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.js b/awx/ui_next/src/util/prompt/mergeExtraVars.js similarity index 100% rename from awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.js rename to awx/ui_next/src/util/prompt/mergeExtraVars.js diff --git a/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.test.js b/awx/ui_next/src/util/prompt/mergeExtraVars.test.js similarity index 100% rename from awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.test.js rename to awx/ui_next/src/util/prompt/mergeExtraVars.test.js From a345675a97dad93b3391c3792dfa4ed0ea732194 Mon Sep 17 00:00:00 2001 From: mabashian Date: Tue, 15 Dec 2020 13:20:23 -0500 Subject: [PATCH 14/14] Cleanup handling of extra_vars in workflow node prompting --- .../LaunchPrompt/steps/PreviewStep.jsx | 4 +- .../LaunchPrompt/steps/PreviewStep.test.jsx | 4 +- .../Modals/NodeModals/NodeModal.jsx | 3 +- .../Modals/NodeModals/NodeModal.test.jsx | 1 - .../Modals/NodeModals/useWorkflowNodeSteps.js | 30 ++++------ .../VisualizerToolbar.jsx | 60 +++++++++++-------- 6 files changed, 54 insertions(+), 48 deletions(-) diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.jsx index 58aa0d3777..c2242b44fa 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.jsx @@ -51,7 +51,9 @@ function PreviewStep({ mergeExtraVars(initialExtraVars, masked) ); } else { - overrides.extra_vars = initialExtraVars; + overrides.extra_vars = yaml.safeDump( + mergeExtraVars(initialExtraVars, {}) + ); } } 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 8e5d833bce..322deb8bd6 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.test.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.test.jsx @@ -126,7 +126,7 @@ describe('PreviewStep', () => { expect(detail).toHaveLength(1); expect(detail.prop('resource')).toEqual(resource); expect(detail.prop('overrides')).toEqual({ - extra_vars: 'one: 1', + extra_vars: 'one: 1\n', }); }); test('should remove survey with empty array value', async () => { @@ -152,7 +152,7 @@ describe('PreviewStep', () => { expect(detail).toHaveLength(1); expect(detail.prop('resource')).toEqual(resource); expect(detail.prop('overrides')).toEqual({ - extra_vars: 'one: 1', + extra_vars: 'one: 1\n', }); }); }); 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 4d58da6dd0..2b57effb35 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 @@ -93,9 +93,10 @@ function NodeModalForm({ mergeExtraVars(initialExtraVars, surveyValues) ); } else { - extraVars = initialExtraVars; + extraVars = yaml.safeDump(mergeExtraVars(initialExtraVars, {})); } values.extra_data = extraVars && parseVariableField(extraVars); + delete values.extra_vars; } onSave(values, launchConfig); 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 1c227ef90d..7bcf1ea6ee 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 @@ -305,7 +305,6 @@ describe('NodeModal', () => { verbosity: '0', job_tags: '', skip_tags: '', - extra_vars: '---', diff_mode: false, survey_bar: 'answer', nodeResource: mockJobTemplate, 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 345f28eef7..bd8a8c74e4 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 @@ -134,26 +134,18 @@ const getNodeToEditDefaultValues = (launchConfig, surveyConfig, nodeToEdit) => { initialValues.diff_mode = sourceOfValues?.diff_mode || false; } - if (launchConfig.ask_variables_on_launch && launchConfig.survey_enabled) { - if (nodeToEdit?.promptValues?.extra_vars) { - initialValues.extra_vars = nodeToEdit.promptValues.extra_vars; - } else { - const newExtraData = { ...nodeToEdit.originalNodeObject.extra_data }; - if (surveyConfig.spec) { - surveyConfig.spec.forEach(question => { - if ( - Object.prototype.hasOwnProperty.call( - newExtraData, - question.variable - ) - ) { - delete newExtraData[question.variable]; - } - }); - } - - initialValues.extra_vars = jsonToYaml(JSON.stringify(newExtraData)); + if (launchConfig.ask_variables_on_launch) { + const newExtraData = { ...sourceOfValues.extra_data }; + if (launchConfig.survey_enabled && surveyConfig.spec) { + surveyConfig.spec.forEach(question => { + if ( + Object.prototype.hasOwnProperty.call(newExtraData, question.variable) + ) { + delete newExtraData[question.variable]; + } + }); } + initialValues.extra_vars = jsonToYaml(JSON.stringify(newExtraData)); } if (surveyConfig?.spec) { diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerToolbar.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerToolbar.jsx index 26c80f338f..1170858f83 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerToolbar.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerToolbar.jsx @@ -79,8 +79,9 @@ function VisualizerToolbar({ {totalNodes} - + 0 && showLegend} isDisabled={totalNodes === 0} @@ -90,8 +91,9 @@ function VisualizerToolbar({ - + 0 && showTools} isDisabled={totalNodes === 0} @@ -101,33 +103,43 @@ function VisualizerToolbar({ - - - + + + + {template.summary_fields?.user_capabilities?.start && ( - - {({ handleLaunch }) => ( - - - - )} - + + + {({ handleLaunch }) => ( + + + + )} + + )} {!readOnly && ( <> - +