diff --git a/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx b/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx index e2484abb5a..d38cc7f2c2 100644 --- a/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx +++ b/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx @@ -44,6 +44,7 @@ class LaunchButton extends React.Component { showLaunchPrompt: false, launchConfig: null, launchError: false, + surveyConfig: null, }; this.handleLaunch = this.handleLaunch.bind(this); @@ -67,15 +68,28 @@ class LaunchButton extends React.Component { resource.type === 'workflow_job_template' ? WorkflowJobTemplatesAPI.readLaunch(resource.id) : JobTemplatesAPI.readLaunch(resource.id); + const readSurvey = + resource.type === 'workflow_job_template' + ? WorkflowJobTemplatesAPI.readSurvey(resource.id) + : JobTemplatesAPI.readSurvey(resource.id); try { const { data: launchConfig } = await readLaunch; + let surveyConfig = null; + + if (launchConfig.survey_enabled) { + const { data } = await readSurvey; + + surveyConfig = data; + } + if (canLaunchWithoutPrompt(launchConfig)) { this.launchWithParams({}); } else { this.setState({ showLaunchPrompt: true, launchConfig, + surveyConfig, }); } } catch (err) { @@ -151,7 +165,12 @@ class LaunchButton extends React.Component { } render() { - const { launchError, showLaunchPrompt, launchConfig } = this.state; + const { + launchError, + showLaunchPrompt, + launchConfig, + surveyConfig, + } = this.state; const { resource, i18n, children } = this.props; return ( @@ -172,7 +191,8 @@ class LaunchButton extends React.Component { )} {showLaunchPrompt && ( this.setState({ showLaunchPrompt: false })} diff --git a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx index 5e1eecb052..991d527c81 100644 --- a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx @@ -11,7 +11,14 @@ import useLaunchSteps from './useLaunchSteps'; import AlertModal from '../AlertModal'; import getSurveyValues from './getSurveyValues'; -function PromptModalForm({ onSubmit, onCancel, i18n, config, resource }) { +function PromptModalForm({ + launchConfig, + i18n, + onCancel, + onSubmit, + resource, + surveyConfig, +}) { const { values, setTouched, validateForm } = useFormikContext(); const { @@ -20,7 +27,7 @@ function PromptModalForm({ onSubmit, onCancel, i18n, config, resource }) { visitStep, visitAllSteps, contentError, - } = useLaunchSteps(config, resource, i18n); + } = useLaunchSteps(launchConfig, surveyConfig, resource, i18n); const handleSave = () => { const postValues = {}; @@ -39,7 +46,7 @@ function PromptModalForm({ onSubmit, onCancel, i18n, config, resource }) { setValue('limit', values.limit); setValue('job_tags', values.job_tags); setValue('skip_tags', values.skip_tags); - const extraVars = config.ask_variables_on_launch + const extraVars = launchConfig.ask_variables_on_launch ? values.extra_vars || '---' : resource.extra_vars; setValue('extra_vars', mergeExtraVars(extraVars, surveyValues)); @@ -103,33 +110,22 @@ function PromptModalForm({ onSubmit, onCancel, i18n, config, resource }) { ); } -function LaunchPrompt({ config, resource = {}, onLaunch, onCancel, i18n }) { +function LaunchPrompt({ + launchConfig, + i18n, + onCancel, + onLaunch, + resource = {}, + surveyConfig, +}) { return ( - onLaunch(values)} - > + onLaunch(values)}> onLaunch(values)} onCancel={onCancel} i18n={i18n} - config={config} + launchConfig={launchConfig} + surveyConfig={surveyConfig} resource={resource} /> diff --git a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.test.jsx b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.test.jsx index 37abb8c830..707e0f5401 100644 --- a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.test.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.test.jsx @@ -76,7 +76,7 @@ describe('LaunchPrompt', () => { await act(async () => { wrapper = mountWithContexts( { resource={resource} onLaunch={noop} onCancel={noop} + surveyConfig={{ + name: '', + description: '', + spec: [ + { + choices: '', + default: '', + max: 1024, + min: 0, + new_question: false, + question_description: '', + question_name: 'foo', + required: true, + type: 'text', + variable: 'foo', + }, + ], + }} /> ); }); @@ -105,7 +123,7 @@ describe('LaunchPrompt', () => { await act(async () => { wrapper = mountWithContexts( { await act(async () => { wrapper = mountWithContexts( { await act(async () => { wrapper = mountWithContexts( - {config.ask_job_type_on_launch && } - {config.ask_limit_on_launch && ( + {launchConfig.ask_job_type_on_launch && } + {launchConfig.ask_limit_on_launch && ( )} - {config.ask_scm_branch_on_launch && ( + {launchConfig.ask_scm_branch_on_launch && ( )} - {config.ask_verbosity_on_launch && } - {config.ask_diff_mode_on_launch && } - {config.ask_tags_on_launch && ( + {launchConfig.ask_verbosity_on_launch && } + {launchConfig.ask_diff_mode_on_launch && ( + + )} + {launchConfig.ask_tags_on_launch && ( )} - {config.ask_skip_tags_on_launch && ( + {launchConfig.ask_skip_tags_on_launch && ( )} - {config.ask_variables_on_launch && ( + {launchConfig.ask_variables_on_launch && ( { wrapper = mountWithContexts( @@ -34,7 +34,7 @@ describe('OtherPromptsStep', () => { wrapper = mountWithContexts( @@ -54,7 +54,7 @@ describe('OtherPromptsStep', () => { wrapper = mountWithContexts( @@ -74,7 +74,7 @@ describe('OtherPromptsStep', () => { wrapper = mountWithContexts( @@ -94,7 +94,7 @@ describe('OtherPromptsStep', () => { wrapper = mountWithContexts( diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.jsx index c20f96d0e8..672956027d 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.jsx @@ -24,18 +24,25 @@ const ErrorMessageWrapper = styled.div` margin-bottom: 10px; `; -function PreviewStep({ resource, config, survey, formErrors, i18n }) { +function PreviewStep({ + resource, + launchConfig, + surveyConfig, + formErrors, + i18n, +}) { const { values } = useFormikContext(); const surveyValues = getSurveyValues(values); const overrides = { ...values, }; - if (config.ask_variables_on_launch || config.survey_enabled) { + + if (launchConfig.ask_variables_on_launch || launchConfig.survey_enabled) { const initialExtraVars = - config.ask_variables_on_launch && (overrides.extra_vars || '---'); - if (survey && survey.spec) { - const passwordFields = survey.spec + launchConfig.ask_variables_on_launch && (overrides.extra_vars || '---'); + if (surveyConfig?.spec) { + const passwordFields = surveyConfig.spec .filter(q => q.type === 'password') .map(q => q.variable); const masked = maskPasswords(surveyValues, passwordFields); @@ -46,11 +53,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 && parseVariableField(overrides?.extra_vars); + return ( {formErrors && ( @@ -67,7 +73,7 @@ function PreviewStep({ resource, config, survey, formErrors, i18n }) { )} 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 753be606c8..8e5d833bce 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.test.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.test.jsx @@ -36,11 +36,11 @@ describe('PreviewStep', () => { @@ -64,7 +64,7 @@ describe('PreviewStep', () => { { limit: '4', }); }); + test('should handle extra vars with survey', 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\nfoo: abc\n', + survey_foo: 'abc', + }); + }); test('should handle extra vars without survey', async () => { let wrapper; await act(async () => { @@ -88,7 +113,7 @@ 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', - }); + 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/SurveyStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/SurveyStep.jsx index 941f14b4ed..cfdae4e957 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/SurveyStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/SurveyStep.jsx @@ -22,7 +22,7 @@ import { } from '../../../util/validators'; import { Survey } from '../../../types'; -function SurveyStep({ survey, i18n }) { +function SurveyStep({ surveyConfig, i18n }) { const fieldTypes = { text: TextField, textarea: TextField, @@ -34,7 +34,7 @@ function SurveyStep({ survey, i18n }) { }; return (
- {survey.spec.map(question => { + {surveyConfig.spec.map(question => { const Field = fieldTypes[question.type]; return ( @@ -44,7 +44,7 @@ function SurveyStep({ survey, i18n }) { ); } SurveyStep.propTypes = { - survey: Survey.isRequired, + surveyConfig: Survey.isRequired, }; function TextField({ question, i18n }) { diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialsStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialsStep.jsx index 8373d0efca..8cc32714c6 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialsStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialsStep.jsx @@ -1,64 +1,17 @@ -import React, { useCallback, useEffect } from 'react'; +import React 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, - 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 {}; - }; - +export default function useCredentialsStep(launchConfig, resource, i18n) { return { - step: getStep(config, i18n), - initialValues: getInitialValues(config, resource, result), - validate, - isReady: !isLoading && !!result, - contentError: error, + step: getStep(launchConfig, i18n), + initialValues: getInitialValues(launchConfig, resource), + validate: () => ({}), + isReady: true, + contentError: null, formError: null, setTouched: setFieldsTouched => { setFieldsTouched({ @@ -68,8 +21,8 @@ export default function useCredentialsStep( }; } -function getStep(config, i18n) { - if (!config.ask_credential_on_launch) { +function getStep(launchConfig, i18n) { + if (!launchConfig.ask_credential_on_launch) { return null; } return { @@ -81,11 +34,12 @@ function getStep(config, i18n) { }; } -function getInitialValues(config, resource, result) { - if (!config.ask_credential_on_launch) { +function getInitialValues(launchConfig, resource) { + if (!launchConfig.ask_credential_on_launch) { return {}; } + return { - credentials: resource?.summary_fields?.credentials || result || [], + 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 30e997875a..dfdf645275 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx @@ -7,23 +7,21 @@ import StepName from './StepName'; const STEP_ID = 'inventory'; export default function useInventoryStep( - config, + launchConfig, + resource, i18n, - visitedSteps, - selectedResource, - nodeToEdit + visitedSteps ) { const [, meta] = useField('inventory'); - const resource = nodeToEdit?.originalNodeObject || nodeToEdit?.promptValues || selectedResource; const formError = Object.keys(visitedSteps).includes(STEP_ID) && (!meta.value || meta.error); return { - step: getStep(config, i18n, formError), - initialValues: getInitialValues(config, resource), + step: getStep(launchConfig, i18n, formError), + initialValues: getInitialValues(launchConfig, resource), isReady: true, contentError: null, - formError: config.ask_inventory_on_launch && formError, + formError: launchConfig.ask_inventory_on_launch && formError, setTouched: setFieldsTouched => { setFieldsTouched({ inventory: true, @@ -31,25 +29,24 @@ export default function useInventoryStep( }, }; } -function getStep(config, i18n, formError) { - if (!config.ask_inventory_on_launch) { +function getStep(launchConfig, i18n, formError) { + if (!launchConfig.ask_inventory_on_launch) { return null; } return { id: STEP_ID, - key: 3, name: {i18n._(t`Inventory`)}, component: , enableNext: true, }; } -function getInitialValues(config, resource) { - if (!config.ask_inventory_on_launch) { +function getInitialValues(launchConfig, resource) { + if (!launchConfig.ask_inventory_on_launch) { return {}; } return { - inventory: resource?.summary_fields?.inventory || resource?.inventory || null, + inventory: resource?.summary_fields?.inventory || null, }; } diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx index d3bf095c23..9af74046fd 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx @@ -5,16 +5,20 @@ import OtherPromptsStep from './OtherPromptsStep'; const STEP_ID = 'other'; -export default function useOtherPrompt( - config, - i18n, - selectedResource, - nodeToEdit -) { - const resource = nodeToEdit || selectedResource; +const getVariablesData = resource => { + if (resource?.extra_data) { + return jsonToYaml(JSON.stringify(resource.extra_data)); + } + if (resource?.extra_vars && resource?.extra_vars !== '---') { + return jsonToYaml(JSON.stringify(parseVariableField(resource.extra_vars))); + } + return '---'; +}; + +export default function useOtherPromptsStep(launchConfig, resource, i18n) { return { - step: getStep(config, i18n), - initialValues: getInitialValues(config, resource), + step: getStep(launchConfig, i18n), + initialValues: getInitialValues(launchConfig, resource), isReady: true, contentError: null, formError: null, @@ -32,73 +36,61 @@ export default function useOtherPrompt( }; } -function getStep(config, i18n) { - if (!shouldShowPrompt(config)) { +function getStep(launchConfig, i18n) { + if (!shouldShowPrompt(launchConfig)) { return null; } return { id: STEP_ID, key: 5, name: i18n._(t`Other Prompts`), - component: , + component: , enableNext: true, }; } -function shouldShowPrompt(config) { +function shouldShowPrompt(launchConfig) { return ( - config.ask_job_type_on_launch || - config.ask_limit_on_launch || - config.ask_verbosity_on_launch || - config.ask_tags_on_launch || - config.ask_skip_tags_on_launch || - config.ask_variables_on_launch || - config.ask_scm_branch_on_launch || - config.ask_diff_mode_on_launch + launchConfig.ask_job_type_on_launch || + launchConfig.ask_limit_on_launch || + launchConfig.ask_verbosity_on_launch || + launchConfig.ask_tags_on_launch || + launchConfig.ask_skip_tags_on_launch || + launchConfig.ask_variables_on_launch || + launchConfig.ask_scm_branch_on_launch || + launchConfig.ask_diff_mode_on_launch ); } -function getInitialValues(config, resource) { - if (!config) { - return {}; +function getInitialValues(launchConfig, resource) { + const initialValues = {}; + + if (!launchConfig) { + return initialValues; } - 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) { + if (launchConfig.ask_job_type_on_launch) { initialValues.job_type = resource?.job_type || ''; } - if (config.ask_limit_on_launch) { + if (launchConfig.ask_limit_on_launch) { initialValues.limit = resource?.limit || ''; } - if (config.ask_verbosity_on_launch) { + if (launchConfig.ask_verbosity_on_launch) { initialValues.verbosity = resource?.verbosity || 0; } - if (config.ask_tags_on_launch) { + if (launchConfig.ask_tags_on_launch) { initialValues.job_tags = resource?.job_tags || ''; } - if (config.ask_skip_tags_on_launch) { + if (launchConfig.ask_skip_tags_on_launch) { initialValues.skip_tags = resource?.skip_tags || ''; } - if (config.ask_variables_on_launch) { - initialValues.extra_vars = getVariablesData(); + if (launchConfig.ask_variables_on_launch) { + initialValues.extra_vars = getVariablesData(resource); } - if (config.ask_scm_branch_on_launch) { + if (launchConfig.ask_scm_branch_on_launch) { initialValues.scm_branch = resource?.scm_branch || ''; } - if (config.ask_diff_mode_on_launch) { + if (launchConfig.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 de60246b64..fae5fd91f3 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx @@ -5,28 +5,23 @@ import PreviewStep from './PreviewStep'; const STEP_ID = 'preview'; export default function usePreviewStep( - config, + launchConfig, i18n, resource, - survey, + surveyConfig, hasErrors, - needsPreviewStep, - nodeToEdit + showStep ) { - const showStep = - needsPreviewStep && resource && Object.keys(config).length > 0; - const promptResource = nodeToEdit || resource return { step: showStep ? { id: STEP_ID, - key: 7, name: i18n._(t`Preview`), component: ( ), diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx index 988bf5da2a..4a92d1b970 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx @@ -1,45 +1,25 @@ -import React, { useEffect, useCallback } from 'react'; +import React from 'react'; import { t } from '@lingui/macro'; import { useFormikContext } from 'formik'; -import useRequest from '../../../util/useRequest'; -import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '../../../api'; import SurveyStep from './SurveyStep'; import StepName from './StepName'; const STEP_ID = 'survey'; export default function useSurveyStep( - config, - i18n, - visitedSteps, + launchConfig, + surveyConfig, resource, - nodeToEdit + i18n, + visitedSteps ) { const { values } = useFormikContext(); - const { result: survey, request: fetchSurvey, isLoading, error } = useRequest( - useCallback(async () => { - if (!config.survey_enabled) { - return {}; - } - const { data } = config?.workflow_job_template_data - ? await WorkflowJobTemplatesAPI.readSurvey( - config?.workflow_job_template_data?.id - ) - : await JobTemplatesAPI.readSurvey(config?.job_template_data?.id); - return data; - }, [config]) - ); - - useEffect(() => { - fetchSurvey(); - }, [fetchSurvey]); - const errors = {}; const validate = () => { - if (!config.survey_enabled || !survey || !survey.spec) { + if (!launchConfig.survey_enabled || !surveyConfig?.spec) { return {}; } - survey.spec.forEach(question => { + surveyConfig.spec.forEach(question => { const errMessage = validateField( question, values[`survey_${question.variable}`], @@ -53,19 +33,19 @@ export default function useSurveyStep( }; const formError = Object.keys(validate()).length > 0; return { - step: getStep(config, survey, validate, i18n, visitedSteps), - initialValues: getInitialValues(config, survey, nodeToEdit), + step: getStep(launchConfig, surveyConfig, validate, i18n, visitedSteps), + initialValues: getInitialValues(launchConfig, surveyConfig, resource), validate, - survey, - isReady: !isLoading && !!survey, - contentError: error, + surveyConfig, + isReady: true, + contentError: null, formError, setTouched: setFieldsTouched => { - if (!survey || !survey.spec) { + if (!surveyConfig?.spec) { return; } const fields = {}; - survey.spec.forEach(question => { + surveyConfig.spec.forEach(question => { fields[`survey_${question.variable}`] = true; }); setFieldsTouched(fields); @@ -96,14 +76,13 @@ function validateField(question, value, i18n) { } return null; } -function getStep(config, survey, validate, i18n, visitedSteps) { - if (!config.survey_enabled) { +function getStep(launchConfig, surveyConfig, validate, i18n, visitedSteps) { + if (!launchConfig.survey_enabled) { return null; } return { id: STEP_ID, - key: 6, name: ( ), - component: , + component: , enableNext: true, }; } -function getInitialValues(config, survey, nodeToEdit) { - if (!config.survey_enabled || !survey) { +function getInitialValues(launchConfig, surveyConfig, resource) { + if (!launchConfig.survey_enabled || !surveyConfig) { return {}; } const values = {}; - if (survey && survey.spec) { - survey.spec.forEach(question => { + if (surveyConfig?.spec) { + surveyConfig.spec.forEach(question => { if (question.type === 'multiselect') { values[`survey_${question.variable}`] = question.default.split('\n'); } else { values[`survey_${question.variable}`] = question.default; } - if (nodeToEdit?.extra_data) { - Object.entries(nodeToEdit?.extra_data).forEach(([key, value]) => { + 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; diff --git a/awx/ui_next/src/components/LaunchPrompt/useLaunchSteps.js b/awx/ui_next/src/components/LaunchPrompt/useLaunchSteps.js index 501cb91eea..3d6958a0ae 100644 --- a/awx/ui_next/src/components/LaunchPrompt/useLaunchSteps.js +++ b/awx/ui_next/src/components/LaunchPrompt/useLaunchSteps.js @@ -6,43 +6,48 @@ import useOtherPromptsStep from './steps/useOtherPromptsStep'; import useSurveyStep from './steps/useSurveyStep'; import usePreviewStep from './steps/usePreviewStep'; -export default function useLaunchSteps(config, resource, i18n) { +export default function useLaunchSteps( + launchConfig, + surveyConfig, + resource, + i18n +) { const [visited, setVisited] = useState({}); + const [isReady, setIsReady] = useState(false); const steps = [ - useInventoryStep(config, i18n, visited), - useCredentialsStep(config, i18n), - useOtherPromptsStep(config, i18n), - useSurveyStep(config, i18n, visited), + 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 { resetForm } = 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, - true - ) + usePreviewStep(launchConfig, i18n, resource, surveyConfig, hasErrors, true) ); const pfSteps = steps.map(s => s.step).filter(s => s != null); - const isReady = !steps.some(s => !s.isReady); + const stepsAreReady = !steps.some(s => !s.isReady); useEffect(() => { - if (surveyStepIndex > -1 && isReady) { + if (stepsAreReady) { + const initialValues = steps.reduce((acc, cur) => { + return { + ...acc, + ...cur.initialValues, + }; + }, {}); resetForm({ values: { - ...formikValues, - ...steps[surveyStepIndex].initialValues, + ...initialValues, }, }); + + setIsReady(true); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isReady]); + }, [stepsAreReady]); const stepWithError = steps.find(s => s.contentError); const contentError = stepWithError ? stepWithError.contentError : null; diff --git a/awx/ui_next/src/components/PromptDetail/PromptDetail.jsx b/awx/ui_next/src/components/PromptDetail/PromptDetail.jsx index 2e8eb9510b..0939b9a4c8 100644 --- a/awx/ui_next/src/components/PromptDetail/PromptDetail.jsx +++ b/awx/ui_next/src/components/PromptDetail/PromptDetail.jsx @@ -71,11 +71,11 @@ function omitOverrides(resource, overrides, defaultConfig) { const clonedResource = { ...resource, summary_fields: { ...resource.summary_fields }, - ...defaultConfig + ...defaultConfig, }; Object.keys(overrides).forEach(keyToOmit => { delete clonedResource[keyToOmit]; - delete clonedResource.summary_fields[keyToOmit]; + delete clonedResource?.summary_fields[keyToOmit]; }); return clonedResource; } @@ -90,7 +90,7 @@ function PromptDetail({ i18n, resource, launchConfig = {}, overrides = {} }) { }; const details = omitOverrides(resource, overrides, launchConfig.defaults); - details.type = overrides?.nodeType || details.type + details.type = overrides?.nodeType || details.type; const hasOverrides = Object.keys(overrides).length > 0; return ( @@ -139,7 +139,7 @@ function PromptDetail({ i18n, resource, launchConfig = {}, overrides = {} }) { {i18n._(t`Prompted Values`)} - {launchConfig.ask_job_type_on_launch && ( + {launchConfig.ask_job_type_on_launch && ( )} - {overrides?.verbosity && launchConfig.ask_verbosity_on_launch && ( + {Object.prototype.hasOwnProperty.call(overrides, 'verbosity') && + launchConfig.ask_verbosity_on_launch ? ( - )} + ) : null} {launchConfig.ask_tags_on_launch && ( - {overrides.job_tags.split(',').map(jobTag => ( - - {jobTag} - - ))} + {overrides.job_tags.length > 0 && + overrides.job_tags.split(',').map(jobTag => ( + + {jobTag} + + ))} } /> @@ -212,13 +218,18 @@ function PromptDetail({ i18n, resource, launchConfig = {}, overrides = {} }) { value={ - {overrides.skip_tags.split(',').map(skipTag => ( - - {skipTag} - - ))} + {overrides.skip_tags.length > 0 && + overrides.skip_tags.split(',').map(skipTag => ( + + {skipTag} + + ))} } /> @@ -231,7 +242,8 @@ function PromptDetail({ i18n, resource, launchConfig = {}, overrides = {} }) { } /> )} - {launchConfig.ask_variables_on_launch && ( + {(launchConfig.survey_enabled || + launchConfig.ask_variables_on_launch) && ( { assertDetail('Job Type', 'Run'); assertDetail('Inventory', 'Demo Inventory'); assertDetail('Source Control Branch', 'Foo branch'); - assertDetail('Limit', 'alpha:beta'); + assertDetail('Limit', 'localhost'); assertDetail('Verbosity', '3 (Debug)'); assertDetail('Show Changes', 'Off'); expect(wrapper.find('VariablesDetail').prop('value')).toEqual( @@ -151,6 +151,14 @@ describe('PromptDetail', () => { inventory: { name: 'Override inventory', }, + credentials: mockPromptLaunch.defaults.credentials, + job_tags: 'foo,bar', + skip_tags: 'baz,boo', + limit: 'otherlimit', + verbosity: 0, + job_type: 'check', + scm_branch: 'Bar branch', + diff_mode: true, }; beforeAll(() => { @@ -180,12 +188,12 @@ describe('PromptDetail', () => { assertDetail('Name', 'Mock JT'); assertDetail('Description', 'Mock JT Description'); assertDetail('Type', 'Job Template'); - assertDetail('Job Type', 'Run'); + assertDetail('Job Type', 'Check'); assertDetail('Inventory', 'Override inventory'); - assertDetail('Source Control Branch', 'Foo branch'); - assertDetail('Limit', 'alpha:beta'); - assertDetail('Verbosity', '3 (Debug)'); - assertDetail('Show Changes', 'Off'); + assertDetail('Source Control Branch', 'Bar branch'); + assertDetail('Limit', 'otherlimit'); + assertDetail('Verbosity', '0 (Normal)'); + assertDetail('Show Changes', 'On'); expect(wrapper.find('VariablesDetail').prop('value')).toEqual( '---one: two\nbar: baz' ); @@ -204,12 +212,12 @@ describe('PromptDetail', () => { expect( wrapper .find('Detail[label="Job Tags"]') - .containsAnyMatchingElements([T_100, T_200]) + .containsAnyMatchingElements([foo, bar]) ).toEqual(true); expect( wrapper .find('Detail[label="Skip Tags"]') - .containsAllMatchingElements([S_100, S_200]) + .containsAllMatchingElements([baz, boo]) ).toEqual(true); }); }); diff --git a/awx/ui_next/src/components/PromptDetail/PromptJobTemplateDetail.jsx b/awx/ui_next/src/components/PromptDetail/PromptJobTemplateDetail.jsx index 2f6a13c6ac..373643e4e8 100644 --- a/awx/ui_next/src/components/PromptDetail/PromptJobTemplateDetail.jsx +++ b/awx/ui_next/src/components/PromptDetail/PromptJobTemplateDetail.jsx @@ -133,10 +133,12 @@ function PromptJobTemplateDetail({ i18n, resource }) { - + {typeof diff_mode === 'boolean' && ( + + )} {related?.callback && ( @@ -149,7 +151,7 @@ function PromptJobTemplateDetail({ i18n, resource }) { label={i18n._(t`Webhook Service`)} value={toTitleCase(webhook_service)} /> - {related.webhook_receiver && ( + {related?.webhook_receiver && ( - {!node.unifiedJobTemplate && (!job || job.type !== 'workflow_approval') && ( + {!unifiedJobTemplate && (!job || job.type !== 'workflow_approval') && ( <> @@ -149,12 +152,12 @@ function WorkflowNodeHelp({ node, i18n }) { )} )} - {node.unifiedJobTemplate && !job && ( + {unifiedJobTemplate && !job && (
{i18n._(t`Name`)}
-
{node.unifiedJobTemplate.name}
+
{unifiedJobTemplate.name}
{i18n._(t`Type`)}
diff --git a/awx/ui_next/src/components/Workflow/WorkflowNodeTypeLetter.jsx b/awx/ui_next/src/components/Workflow/WorkflowNodeTypeLetter.jsx index bd3f65dcac..e0eb145528 100644 --- a/awx/ui_next/src/components/Workflow/WorkflowNodeTypeLetter.jsx +++ b/awx/ui_next/src/components/Workflow/WorkflowNodeTypeLetter.jsx @@ -19,16 +19,27 @@ const CenteredPauseIcon = styled(PauseIcon)` `; function WorkflowNodeTypeLetter({ node }) { + if ( + !node?.fullUnifiedJobTemplate && + !node?.originalNodeObject?.summary_fields?.unified_job_template + ) { + return null; + } + + const unifiedJobTemplate = + node?.fullUnifiedJobTemplate || + node?.originalNodeObject?.summary_fields?.unified_job_template; + let nodeTypeLetter; if ( - (node.unifiedJobTemplate && - (node.unifiedJobTemplate.type || - node.unifiedJobTemplate.unified_job_type)) || - (node.job && node.job.type) + unifiedJobTemplate.type || + unifiedJobTemplate.unified_job_type || + node?.job?.type ) { - const ujtType = node.unifiedJobTemplate - ? node.unifiedJobTemplate.type || node.unifiedJobTemplate.unified_job_type - : node.job.type; + const ujtType = + unifiedJobTemplate.type || + unifiedJobTemplate.unified_job_type || + node.job.type; switch (ujtType) { case 'job_template': case 'job': diff --git a/awx/ui_next/src/components/Workflow/WorkflowNodeTypeLetter.test.jsx b/awx/ui_next/src/components/Workflow/WorkflowNodeTypeLetter.test.jsx index 24313f1f54..ce67fe6dd7 100644 --- a/awx/ui_next/src/components/Workflow/WorkflowNodeTypeLetter.test.jsx +++ b/awx/ui_next/src/components/Workflow/WorkflowNodeTypeLetter.test.jsx @@ -8,7 +8,7 @@ describe('WorkflowNodeTypeLetter', () => { const wrapper = mount( ); @@ -19,7 +19,7 @@ describe('WorkflowNodeTypeLetter', () => { const wrapper = mount( ); @@ -30,7 +30,7 @@ describe('WorkflowNodeTypeLetter', () => { const wrapper = mount( ); @@ -41,7 +41,9 @@ describe('WorkflowNodeTypeLetter', () => { const wrapper = mount( ); @@ -52,7 +54,7 @@ describe('WorkflowNodeTypeLetter', () => { const wrapper = mount( ); @@ -64,7 +66,7 @@ describe('WorkflowNodeTypeLetter', () => { @@ -76,7 +78,7 @@ describe('WorkflowNodeTypeLetter', () => { const wrapper = mount( ); @@ -87,7 +89,9 @@ describe('WorkflowNodeTypeLetter', () => { const wrapper = mount( ); @@ -98,7 +102,9 @@ describe('WorkflowNodeTypeLetter', () => { const wrapper = mount( ); @@ -110,7 +116,7 @@ describe('WorkflowNodeTypeLetter', () => { diff --git a/awx/ui_next/src/components/Workflow/workflowReducer.js b/awx/ui_next/src/components/Workflow/workflowReducer.js index 296b9188aa..6d5e5bf635 100644 --- a/awx/ui_next/src/components/Workflow/workflowReducer.js +++ b/awx/ui_next/src/components/Workflow/workflowReducer.js @@ -180,7 +180,7 @@ function createNode(state, node) { newNodes.push({ id: nextNodeId, - unifiedJobTemplate: node.nodeResource, + fullUnifiedJobTemplate: node.nodeResource, isInvalidLinkTarget: false, promptValues: node.promptValues, }); @@ -407,7 +407,7 @@ function generateNodes(workflowNodes, i18n) { const arrayOfNodesForChart = [ { id: 1, - unifiedJobTemplate: { + fullUnifiedJobTemplate: { name: i18n._(t`START`), }, }, @@ -420,8 +420,14 @@ function generateNodes(workflowNodes, i18n) { originalNodeObject: node, }; - if (node.summary_fields.unified_job_template) { - nodeObj.unifiedJobTemplate = node.summary_fields.unified_job_template; + if ( + node.summary_fields?.unified_job_template?.unified_job_type === + 'workflow_approval' + ) { + nodeObj.fullUnifiedJobTemplate = { + ...node.summary_fields.unified_job_template, + type: 'workflow_approval_template', + }; } arrayOfNodesForChart.push(nodeObj); @@ -651,12 +657,19 @@ function updateLink(state, linkType) { function updateNode(state, editedNode) { const { nodeToEdit, nodes } = state; + const { nodeResource, launchConfig, promptValues } = editedNode; const newNodes = [...nodes]; const matchingNode = newNodes.find(node => node.id === nodeToEdit.id); - matchingNode.unifiedJobTemplate = editedNode.nodeResource; + matchingNode.fullUnifiedJobTemplate = nodeResource; matchingNode.isEdited = true; - matchingNode.promptValues = editedNode.promptValues; + matchingNode.launchConfig = launchConfig; + + if (promptValues) { + matchingNode.promptValues = promptValues; + } else { + delete matchingNode.promptValues; + } return { ...state, @@ -671,7 +684,15 @@ function refreshNode(state, refreshedNode) { const newNodes = [...nodes]; const matchingNode = newNodes.find(node => node.id === nodeToView.id); - matchingNode.unifiedJobTemplate = refreshedNode.nodeResource; + + if (refreshedNode.fullUnifiedJobTemplate) { + matchingNode.fullUnifiedJobTemplate = refreshedNode.fullUnifiedJobTemplate; + } + + if (refreshedNode.originalNodeCredentials) { + matchingNode.originalNodeCredentials = + refreshedNode.originalNodeCredentials; + } return { ...state, diff --git a/awx/ui_next/src/components/Workflow/workflowReducer.test.js b/awx/ui_next/src/components/Workflow/workflowReducer.test.js index cab793ee5c..60013abe85 100644 --- a/awx/ui_next/src/components/Workflow/workflowReducer.test.js +++ b/awx/ui_next/src/components/Workflow/workflowReducer.test.js @@ -197,7 +197,7 @@ describe('Workflow reducer', () => { { id: 2, isInvalidLinkTarget: false, - unifiedJobTemplate: { + fullUnifiedJobTemplate: { id: 7000, name: 'Foo JT', }, @@ -281,7 +281,7 @@ describe('Workflow reducer', () => { { id: 3, isInvalidLinkTarget: false, - unifiedJobTemplate: { + fullUnifiedJobTemplate: { id: 7000, name: 'Foo JT', }, @@ -869,10 +869,6 @@ describe('Workflow reducer', () => { }, workflowMakerNodeId: 2, }, - unifiedJobTemplate: { - id: 1, - name: 'JT 1', - }, }, target: { id: 4, @@ -889,10 +885,6 @@ describe('Workflow reducer', () => { }, workflowMakerNodeId: 4, }, - unifiedJobTemplate: { - id: 3, - name: 'JT 3', - }, }, }, { @@ -912,10 +904,6 @@ describe('Workflow reducer', () => { }, workflowMakerNodeId: 2, }, - unifiedJobTemplate: { - id: 1, - name: 'JT 1', - }, }, target: { id: 3, @@ -932,10 +920,6 @@ describe('Workflow reducer', () => { }, workflowMakerNodeId: 3, }, - unifiedJobTemplate: { - id: 2, - name: 'JT 2', - }, }, }, { @@ -955,10 +939,6 @@ describe('Workflow reducer', () => { }, workflowMakerNodeId: 5, }, - unifiedJobTemplate: { - id: 4, - name: 'JT 4', - }, }, target: { id: 3, @@ -975,17 +955,13 @@ describe('Workflow reducer', () => { }, workflowMakerNodeId: 3, }, - unifiedJobTemplate: { - id: 2, - name: 'JT 2', - }, }, }, { linkType: 'always', source: { id: 1, - unifiedJobTemplate: { + fullUnifiedJobTemplate: { name: undefined, }, }, @@ -1004,17 +980,13 @@ describe('Workflow reducer', () => { }, workflowMakerNodeId: 2, }, - unifiedJobTemplate: { - id: 1, - name: 'JT 1', - }, }, }, { linkType: 'always', source: { id: 1, - unifiedJobTemplate: { + fullUnifiedJobTemplate: { name: undefined, }, }, @@ -1033,10 +1005,6 @@ describe('Workflow reducer', () => { }, workflowMakerNodeId: 5, }, - unifiedJobTemplate: { - id: 4, - name: 'JT 4', - }, }, }, ], @@ -1044,7 +1012,7 @@ describe('Workflow reducer', () => { nodes: [ { id: 1, - unifiedJobTemplate: { + fullUnifiedJobTemplate: { name: undefined, }, }, @@ -1063,10 +1031,6 @@ describe('Workflow reducer', () => { }, workflowMakerNodeId: 2, }, - unifiedJobTemplate: { - id: 1, - name: 'JT 1', - }, }, { id: 3, @@ -1083,10 +1047,6 @@ describe('Workflow reducer', () => { }, workflowMakerNodeId: 3, }, - unifiedJobTemplate: { - id: 2, - name: 'JT 2', - }, }, { id: 4, @@ -1103,10 +1063,6 @@ describe('Workflow reducer', () => { }, workflowMakerNodeId: 4, }, - unifiedJobTemplate: { - id: 3, - name: 'JT 3', - }, }, { id: 5, @@ -1123,10 +1079,6 @@ describe('Workflow reducer', () => { }, workflowMakerNodeId: 5, }, - unifiedJobTemplate: { - id: 4, - name: 'JT 4', - }, }, ], }); @@ -1718,7 +1670,7 @@ describe('Workflow reducer', () => { id: 2, isEdited: false, isInvalidLinkTarget: false, - unifiedJobTemplate: { + fullUnifiedJobTemplate: { id: 703, name: 'Test JT', type: 'job_template', @@ -1729,7 +1681,7 @@ describe('Workflow reducer', () => { id: 2, isEdited: false, isInvalidLinkTarget: false, - unifiedJobTemplate: { + fullUnifiedJobTemplate: { id: 703, name: 'Test JT', type: 'job_template', @@ -1757,7 +1709,7 @@ describe('Workflow reducer', () => { id: 2, isEdited: true, isInvalidLinkTarget: false, - unifiedJobTemplate: { + fullUnifiedJobTemplate: { id: 704, name: 'Other JT', type: 'job_template', diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.jsx index b875e9c768..7ae47b6c72 100644 --- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.jsx +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.jsx @@ -65,6 +65,10 @@ function WorkflowOutputNode({ i18n, mouseEnter, mouseLeave, node }) { const history = useHistory(); const { nodePositions } = useContext(WorkflowStateContext); const job = node?.originalNodeObject?.summary_fields?.job; + const jobName = + node?.originalNodeObject?.summary_fields?.unified_job_template?.name || + node?.unifiedJobTemplate?.name; + let borderColor = '#93969A'; if (job) { @@ -110,19 +114,15 @@ function WorkflowOutputNode({ i18n, mouseEnter, mouseLeave, node }) { {job ? ( <> - {job.status && } -

{job.name || node.unifiedJobTemplate.name}

+ {job.status !== 'pending' && } +

{jobName}

{!!job?.elapsed && ( {secondsToHHMMSS(job.elapsed)} )} ) : ( - - {node.unifiedJobTemplate - ? node.unifiedJobTemplate.name - : i18n._(t`DELETED`)} - + {jobName || i18n._(t`DELETED`)} )} diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.test.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.test.jsx index a8709ccf4e..8309c0bcc2 100644 --- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.test.jsx +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.test.jsx @@ -14,6 +14,9 @@ const nodeWithJT = { status: 'successful', type: 'job', }, + unified_job_template: { + name: 'Automation JT', + }, }, unifiedJobTemplate: { id: 77, @@ -34,6 +37,9 @@ const nodeWithoutJT = { status: 'successful', type: 'job', }, + unified_job_template: { + name: 'Automation JT 2', + }, }, }, }; @@ -80,7 +86,7 @@ describe('WorkflowOutputNode', () => { ); - expect(wrapper.contains(

Automation JT

)).toEqual(true); + expect(wrapper.text('p')).toContain('Automation JT'); expect(wrapper.find('WorkflowOutputNode Elapsed').text()).toBe('00:00:07'); }); test('node contents displayed correctly when Job Template deleted', () => { @@ -95,7 +101,7 @@ describe('WorkflowOutputNode', () => { ); - expect(wrapper.contains(

Automation JT 2

)).toEqual(true); + expect(wrapper.contains(

Automation JT 2

)).toBe(true); expect(wrapper.find('WorkflowOutputNode Elapsed').text()).toBe('00:00:07'); }); test('node contents displayed correctly when Job deleted', () => { 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 1900d53d2a..12c1deb2f8 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 @@ -12,8 +12,15 @@ function NodeAddModal({ i18n }) { const dispatch = useContext(WorkflowDispatchContext); const { addNodeSource } = useContext(WorkflowStateContext); - const addNode = (values, linkType, config) => { - const { approvalName, approvalDescription, approvalTimeout } = values; + const addNode = (values, config) => { + const { + approvalName, + approvalDescription, + timeoutMinutes, + timeoutSeconds, + linkType, + } = values; + if (values) { const { added, removed } = getAddedAndRemoved( config?.defaults?.credentials, @@ -24,21 +31,21 @@ function NodeAddModal({ i18n }) { values.removedCredentials = removed; } - let node; - if (values.nodeType === 'approval') { - node = { - nodeResource: { - description: approvalDescription, - name: approvalName, - timeout: approvalTimeout, - type: 'workflow_approval_template', - }, + const node = { + linkType, + }; + + delete values.linkType; + + if (values.nodeType === 'workflow_approval_template') { + node.nodeResource = { + description: approvalDescription, + name: approvalName, + timeout: Number(timeoutMinutes) * 60 + Number(timeoutSeconds), + type: 'workflow_approval_template', }; } else { - node = { - linkType, - nodeResource: values.nodeResource, - }; + node.nodeResource = values.nodeResource; if ( values?.nodeType === 'job_template' || values?.nodeType === 'workflow_job_template' 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 17dd6433c6..036d7b7bb8 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 @@ -40,7 +40,10 @@ describe('NodeAddModal', () => { el => el.length === 0 ); await act(async () => { - wrapper.find('NodeModal').prop('onSave')({ nodeResource }, 'success', {}); + wrapper.find('NodeModal').prop('onSave')( + { linkType: 'success', nodeResource }, + {} + ); }); expect(dispatch).toHaveBeenCalledWith({ diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeEditModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeEditModal.jsx index f16fffa868..428986e2fe 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeEditModal.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeEditModal.jsx @@ -2,34 +2,47 @@ import React, { useContext } from 'react'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { WorkflowDispatchContext } from '../../../../../contexts/Workflow'; -import { getAddedAndRemoved } from '../../../../../util/lists'; import NodeModal from './NodeModal'; function NodeEditModal({ i18n }) { const dispatch = useContext(WorkflowDispatchContext); - 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; + const updateNode = (values, config) => { + const { + approvalName, + approvalDescription, + credentials, + linkType, + nodeResource, + nodeType, + timeoutMinutes, + timeoutSeconds, + ...rest + } = values; + let node; + if (values.nodeType === 'workflow_approval_template') { + node = { + nodeResource: { + description: approvalDescription, + name: approvalName, + timeout: Number(timeoutMinutes) * 60 + Number(timeoutSeconds), + type: 'workflow_approval_template', + }, + }; + } else { + node = { + nodeResource, + }; + if (nodeType === 'job_template' || nodeType === 'workflow_job_template') { + node.promptValues = { + ...rest, + credentials, + }; + + node.launchConfig = config; + } } + dispatch({ type: 'UPDATE_NODE', 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 72bdb710e5..9ae4539cb5 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 @@ -29,26 +29,18 @@ import AlertModal from '../../../../../components/AlertModal'; import NodeNextButton from './NodeNextButton'; -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 }) { +function NodeModalForm({ + askLinkType, + i18n, + onSave, + title, + credentialError, + launchConfig, + surveyConfig, + isLaunchLoading, +}) { const history = useHistory(); const dispatch = useContext(WorkflowDispatchContext); - const { nodeToEdit } = useContext(WorkflowStateContext); const { values, setTouched, validateForm } = useFormikContext(); const [triggerNext, setTriggerNext] = useState(0); @@ -63,67 +55,28 @@ function NodeModalForm({ askLinkType, i18n, onSave, title, credentialError }) { history.replace(`${history.location.pathname}?${otherParts.join('&')}`); }; - 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, - isReady, visitStep, visitAllSteps, contentError, } = useWorkflowNodeSteps( launchConfig, + surveyConfig, i18n, values.nodeResource, - askLinkType, - !canLaunchWithoutPrompt(values.nodeType, launchConfig), - nodeToEdit + askLinkType ); const handleSaveNode = () => { clearQueryParams(); - onSave(values, askLinkType ? values.linkType : null, launchConfig); + if (values.nodeType !== 'workflow_approval_template') { + delete values.approvalName; + delete values.approvalDescription; + delete values.timeoutMinutes; + delete values.timeoutSeconds; + } + onSave(values, launchConfig); }; const handleCancel = () => { @@ -132,30 +85,22 @@ function NodeModalForm({ askLinkType, i18n, onSave, title, credentialError }) { }; const { error, dismissError } = useDismissableError( - launchConfigError || contentError || credentialError + contentError || credentialError ); - const steps = [ - ...(isReady - ? [...promptSteps] - : [ - { - name: i18n._(t`Content Loading`), - component: , - }, - ]), - ]; const nextButtonText = activeStep => - activeStep.id === steps[steps?.length - 1]?.id || + activeStep.id === promptSteps[promptSteps?.length - 1]?.id || activeStep.name === 'Preview' ? i18n._(t`Save`) : i18n._(t`Next`); + const CustomFooter = ( {({ activeStep, onNext, onBack }) => ( <> setTriggerNext(triggerNext + 1)} buttonText={nextButtonText(activeStep)} /> - {activeStep && activeStep.id !== steps[0]?.id && ( + {activeStep && activeStep.id !== promptSteps[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]; + } +}