diff --git a/awx/ui_next/src/api/models/WorkflowJobTemplateNodes.js b/awx/ui_next/src/api/models/WorkflowJobTemplateNodes.js index 512316a1ab..b628312d03 100644 --- a/awx/ui_next/src/api/models/WorkflowJobTemplateNodes.js +++ b/awx/ui_next/src/api/models/WorkflowJobTemplateNodes.js @@ -55,6 +55,19 @@ class WorkflowJobTemplateNodes extends Base { readCredentials(id) { return this.http.get(`${this.baseUrl}${id}/credentials/`); } + + associateCredentials(id, credentialId) { + return this.http.post(`${this.baseUrl}${id}/credentials/`, { + id: credentialId, + }); + } + + disassociateCredentials(id, credentialId) { + return this.http.post(`${this.baseUrl}${id}/credentials/`, { + id: credentialId, + disassociate: true, + }); + } } export default WorkflowJobTemplateNodes; diff --git a/awx/ui_next/src/components/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 e1fc3bbdad..00074a3e87 100644 --- a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx @@ -6,12 +6,19 @@ import { Formik, useFormikContext } from 'formik'; import ContentError from '../ContentError'; import ContentLoading from '../ContentLoading'; import { useDismissableError } from '../../util/useRequest'; -import mergeExtraVars from './mergeExtraVars'; +import mergeExtraVars from '../../util/prompt/mergeExtraVars'; +import getSurveyValues from '../../util/prompt/getSurveyValues'; import useLaunchSteps from './useLaunchSteps'; import AlertModal from '../AlertModal'; -import getSurveyValues from './getSurveyValues'; -function PromptModalForm({ 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,28 +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..c320aac30b 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', + }, + ], + }} /> ); }); @@ -94,10 +112,10 @@ describe('LaunchPrompt', () => { expect(steps).toHaveLength(5); expect(steps[0].name.props.children).toEqual('Inventory'); - expect(steps[1].name).toEqual('Credentials'); - expect(steps[2].name).toEqual('Other Prompts'); + expect(steps[1].name.props.children).toEqual('Credentials'); + expect(steps[2].name.props.children).toEqual('Other prompts'); expect(steps[3].name.props.children).toEqual('Survey'); - expect(steps[4].name).toEqual('Preview'); + expect(steps[4].name.props.children).toEqual('Preview'); }); test('should add inventory step', async () => { @@ -105,7 +123,7 @@ describe('LaunchPrompt', () => { await act(async () => { wrapper = mountWithContexts( { await act(async () => { wrapper = mountWithContexts( { const steps = wizard.prop('steps'); expect(steps).toHaveLength(2); - expect(steps[0].name).toEqual('Credentials'); + expect(steps[0].name.props.children).toEqual('Credentials'); expect(isElementOfType(steps[0].component, CredentialsStep)).toEqual(true); expect(isElementOfType(steps[1].component, PreviewStep)).toEqual(true); }); @@ -153,7 +171,7 @@ describe('LaunchPrompt', () => { await act(async () => { wrapper = mountWithContexts( { const steps = wizard.prop('steps'); expect(steps).toHaveLength(2); - expect(steps[0].name).toEqual('Other Prompts'); + expect(steps[0].name.props.children).toEqual('Other prompts'); expect(isElementOfType(steps[0].component, OtherPromptsStep)).toEqual(true); expect(isElementOfType(steps[1].component, PreviewStep)).toEqual(true); }); diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/OtherPromptsStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/OtherPromptsStep.jsx index 710562c7b9..a2ba566950 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/OtherPromptsStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/OtherPromptsStep.jsx @@ -20,11 +20,11 @@ const FieldHeader = styled.div` } `; -function OtherPromptsStep({ config, i18n }) { +function OtherPromptsStep({ launchConfig, i18n }) { return (
- {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 bc94fea258..c2242b44fa 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.jsx @@ -6,8 +6,10 @@ import { t } from '@lingui/macro'; import { useFormikContext } from 'formik'; import { withI18n } from '@lingui/react'; import yaml from 'js-yaml'; -import mergeExtraVars, { maskPasswords } from '../mergeExtraVars'; -import getSurveyValues from '../getSurveyValues'; +import mergeExtraVars, { + maskPasswords, +} from '../../../util/prompt/mergeExtraVars'; +import getSurveyValues from '../../../util/prompt/getSurveyValues'; import PromptDetail from '../../PromptDetail'; const ExclamationCircleIcon = styled(PFExclamationCircleIcon)` @@ -23,18 +25,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 }; + const overrides = { + ...values, + }; - if (config.ask_variables_on_launch || config.survey_enabled) { - const initialExtraVars = config.ask_variables_on_launch - ? values.extra_vars || '---' - : resource.extra_vars; - if (survey && survey.spec) { - const passwordFields = survey.spec + if (launchConfig.ask_variables_on_launch || launchConfig.survey_enabled) { + const initialExtraVars = + 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); @@ -42,7 +51,9 @@ function PreviewStep({ resource, config, survey, formErrors, i18n }) { mergeExtraVars(initialExtraVars, masked) ); } else { - overrides.extra_vars = initialExtraVars; + overrides.extra_vars = yaml.safeDump( + mergeExtraVars(initialExtraVars, {}) + ); } } @@ -62,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 cd780e1519..322deb8bd6 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', () => { { expect(detail).toHaveLength(1); expect(detail.prop('resource')).toEqual(resource); expect(detail.prop('overrides')).toEqual({ - extra_vars: 'one: 1', + extra_vars: 'one: 1\n', }); }); - test('should remove survey with empty array value', async () => { let wrapper; await act(async () => { @@ -115,7 +139,7 @@ describe('PreviewStep', () => { > { expect(detail).toHaveLength(1); expect(detail.prop('resource')).toEqual(resource); expect(detail.prop('overrides')).toEqual({ - extra_vars: 'one: 1', + extra_vars: 'one: 1\n', }); }); }); diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/StepName.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/StepName.jsx index 28bf5f0414..d7d37989cd 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/StepName.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/StepName.jsx @@ -14,13 +14,13 @@ const ExclamationCircleIcon = styled(PFExclamationCircleIcon)` margin-left: 10px; `; -function StepName({ hasErrors, children, i18n }) { +function StepName({ hasErrors, children, i18n, id }) { if (!hasErrors) { - return children; + return
{children}
; } return ( <> - + {children} - {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 }) { @@ -130,7 +130,8 @@ function MultiSelectField({ question, i18n }) { ({}), isReady: true, contentError: null, formError: null, @@ -18,13 +21,29 @@ export default function useCredentialsStep(config, i18n) { }; } -function getStep(config, i18n) { - if (!config.ask_credential_on_launch) { +function getStep(launchConfig, i18n) { + if (!launchConfig.ask_credential_on_launch) { return null; } return { id: STEP_ID, - name: i18n._(t`Credentials`), + key: 4, + name: ( + + {i18n._(t`Credentials`)} + + ), component: , + enableNext: true, + }; +} + +function getInitialValues(launchConfig, resource) { + if (!launchConfig.ask_credential_on_launch) { + return {}; + } + + return { + credentials: resource?.summary_fields?.credentials || [], }; } diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx index 4724be21e0..0d00a3b747 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx @@ -6,14 +6,22 @@ import StepName from './StepName'; const STEP_ID = 'inventory'; -export default function useInventoryStep(config, visitedSteps, i18n) { +export default function useInventoryStep( + launchConfig, + resource, + i18n, + visitedSteps +) { const [, meta] = useField('inventory'); + const formError = + Object.keys(visitedSteps).includes(STEP_ID) && (!meta.value || meta.error); return { - step: getStep(config, meta, i18n, visitedSteps), + step: getStep(launchConfig, i18n, formError), + initialValues: getInitialValues(launchConfig, resource), isReady: true, contentError: null, - formError: !meta.value, + formError: launchConfig.ask_inventory_on_launch && formError, setTouched: setFieldsTouched => { setFieldsTouched({ inventory: true, @@ -21,20 +29,14 @@ export default function useInventoryStep(config, visitedSteps, i18n) { }, }; } -function getStep(config, meta, i18n, visitedSteps) { - 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`)} ), @@ -42,3 +44,13 @@ function getStep(config, meta, i18n, visitedSteps) { enableNext: true, }; } + +function getInitialValues(launchConfig, resource) { + if (!launchConfig.ask_inventory_on_launch) { + return {}; + } + + return { + 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 c03565b358..1f63397c17 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx @@ -1,12 +1,25 @@ import React from 'react'; import { t } from '@lingui/macro'; +import { jsonToYaml, parseVariableField } from '../../../util/yaml'; import OtherPromptsStep from './OtherPromptsStep'; +import StepName from './StepName'; const STEP_ID = 'other'; -export default function useOtherPrompt(config, i18n) { +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), + step: getStep(launchConfig, i18n), + initialValues: getInitialValues(launchConfig, resource), isReady: true, contentError: null, formError: null, @@ -24,26 +37,66 @@ export default function useOtherPrompt(config, i18n) { }; } -function getStep(config, i18n) { - if (!shouldShowPrompt(config)) { +function getStep(launchConfig, i18n) { + if (!shouldShowPrompt(launchConfig)) { return null; } return { id: STEP_ID, - name: i18n._(t`Other Prompts`), - component: , + key: 5, + name: ( + + {i18n._(t`Other prompts`)} + + ), + 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(launchConfig, resource) { + const initialValues = {}; + + if (!launchConfig) { + return initialValues; + } + + if (launchConfig.ask_job_type_on_launch) { + initialValues.job_type = resource?.job_type || ''; + } + if (launchConfig.ask_limit_on_launch) { + initialValues.limit = resource?.limit || ''; + } + if (launchConfig.ask_verbosity_on_launch) { + initialValues.verbosity = resource?.verbosity || 0; + } + if (launchConfig.ask_tags_on_launch) { + initialValues.job_tags = resource?.job_tags || ''; + } + if (launchConfig.ask_skip_tags_on_launch) { + initialValues.skip_tags = resource?.skip_tags || ''; + } + if (launchConfig.ask_variables_on_launch) { + initialValues.extra_vars = getVariablesData(resource); + } + if (launchConfig.ask_scm_branch_on_launch) { + initialValues.scm_branch = resource?.scm_branch || ''; + } + 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 e033f7eb93..77570fab0b 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx @@ -1,53 +1,41 @@ import React from 'react'; -import { useFormikContext } from 'formik'; import { t } from '@lingui/macro'; import PreviewStep from './PreviewStep'; +import StepName from './StepName'; const STEP_ID = 'preview'; export default function usePreviewStep( - config, + launchConfig, + i18n, resource, - survey, + surveyConfig, hasErrors, - i18n + showStep ) { - const { values: formikValues, errors } = useFormikContext(); - - const formErrorsContent = []; - if (config.ask_inventory_on_launch && !formikValues.inventory) { - formErrorsContent.push({ - inventory: true, - }); - } - const hasSurveyError = Object.keys(errors).find(e => e.includes('survey')); - if ( - config.survey_enabled && - (config.variables_needed_to_start || - config.variables_needed_to_start.length === 0) && - hasSurveyError - ) { - formErrorsContent.push({ - survey: true, - }); - } - return { - step: { - id: STEP_ID, - name: i18n._(t`Preview`), - component: ( - - ), - enableNext: !hasErrors, - nextButtonText: i18n._(t`Launch`), - }, + step: showStep + ? { + id: STEP_ID, + name: ( + + {i18n._(t`Preview`)} + + ), + component: ( + + ), + enableNext: !hasErrors, + nextButtonText: i18n._(t`Launch`), + } + : null, initialValues: {}, + validate: () => ({}), isReady: true, error: null, setTouched: () => {}, diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx index 7137536f09..37e5454d13 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx @@ -1,39 +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, visitedSteps, i18n) { +export default function useSurveyStep( + launchConfig, + surveyConfig, + resource, + 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 {}; } - const errors = {}; - survey.spec.forEach(question => { + surveyConfig.spec.forEach(question => { const errMessage = validateField( question, values[`survey_${question.variable}`], @@ -47,18 +33,19 @@ export default function useSurveyStep(config, visitedSteps, i18n) { }; const formError = Object.keys(validate()).length > 0; return { - step: getStep(config, survey, formError, i18n, visitedSteps), + step: getStep(launchConfig, surveyConfig, validate, i18n, visitedSteps), + initialValues: getInitialValues(launchConfig, surveyConfig, resource), + validate, + surveyConfig, + isReady: true, + contentError: null, formError, - initialValues: getInitialValues(config, survey), - survey, - isReady: !isLoading && !!survey, - contentError: error, 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); @@ -84,49 +71,65 @@ function validateField(question, value, i18n) { ); } } - if ( - question.required && - ((!value && value !== 0) || (Array.isArray(value) && value.length === 0)) - ) { + if (question.required && !value && value !== 0) { return i18n._(t`This field must not be blank`); } return null; } -function getStep(config, survey, hasErrors, i18n, visitedSteps) { - if (!config.survey_enabled) { +function getStep(launchConfig, surveyConfig, validate, i18n, visitedSteps) { + if (!launchConfig.survey_enabled) { return null; } + return { id: STEP_ID, - key: 6, name: ( {i18n._(t`Survey`)} ), - component: , + component: , enableNext: true, }; } -function getInitialValues(config, survey) { - if (!config.survey_enabled || !survey) { + +function getInitialValues(launchConfig, surveyConfig, resource) { + if (!launchConfig.survey_enabled || !surveyConfig) { return {}; } - const surveyValues = {}; - survey.spec.forEach(question => { - if (question.type === 'multiselect') { - if (question.default === '') { - surveyValues[`survey_${question.variable}`] = []; + + const values = {}; + if (surveyConfig?.spec) { + surveyConfig.spec.forEach(question => { + if (question.type === 'multiselect') { + values[`survey_${question.variable}`] = question.default + ? question.default.split('\n') + : []; + } else if (question.type === 'multiplechoice') { + values[`survey_${question.variable}`] = + question.default || question.choices.split('\n')[0]; } else { - surveyValues[`survey_${question.variable}`] = question.default.split( - '\n' - ); + values[`survey_${question.variable}`] = question.default || ''; } - } else { - surveyValues[`survey_${question.variable}`] = question.default; - } - }); - return surveyValues; + if (resource?.extra_data) { + Object.entries(resource.extra_data).forEach(([key, value]) => { + if (key === question.variable) { + if (question.type === 'multiselect') { + values[`survey_${question.variable}`] = value; + } else { + values[`survey_${question.variable}`] = value; + } + } + }); + } + }); + } + + return values; } diff --git a/awx/ui_next/src/components/LaunchPrompt/useLaunchSteps.js b/awx/ui_next/src/components/LaunchPrompt/useLaunchSteps.js index caa32fe8ba..3d6958a0ae 100644 --- a/awx/ui_next/src/components/LaunchPrompt/useLaunchSteps.js +++ b/awx/ui_next/src/components/LaunchPrompt/useLaunchSteps.js @@ -6,42 +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, visited, i18n), - useCredentialsStep(config, i18n), - useOtherPromptsStep(config, i18n), - useSurveyStep(config, visited, 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 { resetForm } = useFormikContext(); const hasErrors = steps.some(step => step.formError); - const surveyStepIndex = steps.findIndex(step => step.survey); steps.push( - usePreviewStep( - config, - resource, - steps[surveyStepIndex]?.survey, - hasErrors, - i18n - ) + 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 8d02bd2cf3..0939b9a4c8 100644 --- a/awx/ui_next/src/components/PromptDetail/PromptDetail.jsx +++ b/awx/ui_next/src/components/PromptDetail/PromptDetail.jsx @@ -53,6 +53,7 @@ function buildResourceLink(resource) { function hasPromptData(launchData) { return ( + launchData.survey_enabled || launchData.ask_credential_on_launch || launchData.ask_diff_mode_on_launch || launchData.ask_inventory_on_launch || @@ -66,14 +67,15 @@ function hasPromptData(launchData) { ); } -function omitOverrides(resource, overrides) { +function omitOverrides(resource, overrides, defaultConfig) { const clonedResource = { ...resource, summary_fields: { ...resource.summary_fields }, + ...defaultConfig, }; Object.keys(overrides).forEach(keyToOmit => { delete clonedResource[keyToOmit]; - delete clonedResource.summary_fields[keyToOmit]; + delete clonedResource?.summary_fields[keyToOmit]; }); return clonedResource; } @@ -87,7 +89,8 @@ function PromptDetail({ i18n, resource, launchConfig = {}, overrides = {} }) { 4: i18n._(t`4 (Connection Debug)`), }; - const details = omitOverrides(resource, overrides); + const details = omitOverrides(resource, overrides, launchConfig.defaults); + details.type = overrides?.nodeType || details.type; const hasOverrides = Object.keys(overrides).length > 0; return ( @@ -136,13 +139,13 @@ function PromptDetail({ i18n, resource, launchConfig = {}, overrides = {} }) { {i18n._(t`Prompted Values`)} - {overrides?.job_type && ( + {launchConfig.ask_job_type_on_launch && ( )} - {overrides?.credentials && ( + {launchConfig.ask_credential_on_launch && ( )} - {overrides?.inventory && ( + {launchConfig.ask_inventory_on_launch && ( )} - {overrides?.scm_branch && ( + {launchConfig.ask_scm_branch_on_launch && ( )} - {overrides?.limit && ( + {launchConfig.ask_limit_on_launch && ( )} - {overrides?.verbosity && ( + {Object.prototype.hasOwnProperty.call(overrides, 'verbosity') && + launchConfig.ask_verbosity_on_launch ? ( - )} - {overrides?.job_tags && ( + ) : 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} + + ))} } /> )} - {overrides?.skip_tags && ( + {launchConfig.ask_skip_tags_on_launch && ( - {overrides.skip_tags.split(',').map(skipTag => ( - - {skipTag} - - ))} + {overrides.skip_tags.length > 0 && + overrides.skip_tags.split(',').map(skipTag => ( + + {skipTag} + + ))} } /> )} - {overrides?.diff_mode && ( + {launchConfig.ask_diff_mode_on_launch && ( )} - {overrides?.extra_vars && ( + {(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 5a2a9399e8..373643e4e8 100644 --- a/awx/ui_next/src/components/PromptDetail/PromptJobTemplateDetail.jsx +++ b/awx/ui_next/src/components/PromptDetail/PromptJobTemplateDetail.jsx @@ -72,7 +72,7 @@ function PromptJobTemplateDetail({ i18n, resource }) { ? 'smart_inventory' : 'inventory'; - const recentJobs = summary_fields.recent_jobs.map(job => ({ + const recentJobs = summary_fields?.recent_jobs?.map(job => ({ ...job, type: 'job', })); @@ -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 && ( ({ + const recentJobs = summary_fields?.recent_jobs?.map(job => ({ ...job, type: 'job', })); return ( <> - {summary_fields.recent_jobs?.length > 0 && ( + {summary_fields?.recent_jobs?.length > 0 && ( } label={i18n._(t`Activity`)} @@ -84,7 +84,7 @@ function PromptWFJobTemplateDetail({ i18n, resource }) { value={toTitleCase(webhook_service)} /> - {related.webhook_receiver && ( + {related?.webhook_receiver && ( <_Link {...props} />)` const Wrapper = styled.div` display: inline-flex; + flex-wrap: wrap; `; /* eslint-enable react/jsx-pascal-case */ diff --git a/awx/ui_next/src/components/Workflow/WorkflowNodeHelp.jsx b/awx/ui_next/src/components/Workflow/WorkflowNodeHelp.jsx index 1abf65c3a7..909a7c3d6e 100644 --- a/awx/ui_next/src/components/Workflow/WorkflowNodeHelp.jsx +++ b/awx/ui_next/src/components/Workflow/WorkflowNodeHelp.jsx @@ -34,9 +34,12 @@ const StyledExclamationTriangleIcon = styled(ExclamationTriangleIcon)` function WorkflowNodeHelp({ node, i18n }) { let nodeType; const job = node?.originalNodeObject?.summary_fields?.job; - if (node.unifiedJobTemplate || job) { - const type = node.unifiedJobTemplate - ? node.unifiedJobTemplate.unified_job_type || node.unifiedJobTemplate.type + const unifiedJobTemplate = + node?.fullUnifiedJobTemplate || + node?.originalNodeObject?.summary_fields?.unified_job_template; + if (unifiedJobTemplate || job) { + const type = unifiedJobTemplate + ? unifiedJobTemplate.unified_job_type || unifiedJobTemplate.type : job.type; switch (type) { case 'job_template': @@ -113,7 +116,7 @@ function WorkflowNodeHelp({ node, i18n }) { return ( <> - {!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 36171811e4..6d5e5bf635 100644 --- a/awx/ui_next/src/components/Workflow/workflowReducer.js +++ b/awx/ui_next/src/components/Workflow/workflowReducer.js @@ -55,27 +55,60 @@ export default function visualizerReducer(state, action) { case 'SELECT_SOURCE_FOR_LINKING': return selectSourceForLinking(state, action.node); case 'SET_ADD_LINK_TARGET_NODE': - return { ...state, addLinkTargetNode: action.value }; + return { + ...state, + addLinkTargetNode: action.value, + }; case 'SET_CONTENT_ERROR': - return { ...state, contentError: action.value }; + return { + ...state, + contentError: action.value, + }; case 'SET_IS_LOADING': - return { ...state, isLoading: action.value }; + return { + ...state, + isLoading: action.value, + }; case 'SET_LINK_TO_DELETE': - return { ...state, linkToDelete: action.value }; + return { + ...state, + linkToDelete: action.value, + }; case 'SET_LINK_TO_EDIT': - return { ...state, linkToEdit: action.value }; + return { + ...state, + linkToEdit: action.value, + }; case 'SET_NODES': - return { ...state, nodes: action.value }; + return { + ...state, + nodes: action.value, + }; case 'SET_NODE_POSITIONS': - return { ...state, nodePositions: action.value }; + return { + ...state, + nodePositions: action.value, + }; case 'SET_NODE_TO_DELETE': - return { ...state, nodeToDelete: action.value }; + return { + ...state, + nodeToDelete: action.value, + }; case 'SET_NODE_TO_EDIT': - return { ...state, nodeToEdit: action.value }; + return { + ...state, + nodeToEdit: action.value, + }; case 'SET_NODE_TO_VIEW': - return { ...state, nodeToView: action.value }; + return { + ...state, + nodeToView: action.value, + }; case 'SET_SHOW_DELETE_ALL_NODES_MODAL': - return { ...state, showDeleteAllNodesModal: action.value }; + return { + ...state, + showDeleteAllNodesModal: action.value, + }; case 'START_ADD_NODE': return { ...state, @@ -113,8 +146,12 @@ function createLink(state, linkType) { }); newLinks.push({ - source: { id: addLinkSourceNode.id }, - target: { id: addLinkTargetNode.id }, + source: { + id: addLinkSourceNode.id, + }, + target: { + id: addLinkTargetNode.id, + }, linkType, }); @@ -143,8 +180,9 @@ function createNode(state, node) { newNodes.push({ id: nextNodeId, - unifiedJobTemplate: node.nodeResource, + fullUnifiedJobTemplate: node.nodeResource, isInvalidLinkTarget: false, + promptValues: node.promptValues, }); // Ensures that root nodes appear to always run @@ -154,8 +192,12 @@ function createNode(state, node) { } newLinks.push({ - source: { id: addNodeSource }, - target: { id: nextNodeId }, + source: { + id: addNodeSource, + }, + target: { + id: nextNodeId, + }, linkType: node.linkType, }); @@ -165,7 +207,9 @@ function createNode(state, node) { linkToCompare.source.id === addNodeSource && linkToCompare.target.id === addNodeTarget ) { - linkToCompare.source = { id: nextNodeId }; + linkToCompare.source = { + id: nextNodeId, + }; } }); } @@ -268,15 +312,23 @@ function addLinksFromParentsToChildren( // doesn't have any other parents if (linkParentMapping[child.id].length === 1) { newLinks.push({ - source: { id: parentId }, - target: { id: child.id }, + source: { + id: parentId, + }, + target: { + id: child.id, + }, linkType: 'always', }); } } else if (!linkParentMapping[child.id].includes(parentId)) { newLinks.push({ - source: { id: parentId }, - target: { id: child.id }, + source: { + id: parentId, + }, + target: { + id: child.id, + }, linkType: child.linkType, }); } @@ -302,7 +354,10 @@ function removeLinksFromDeletedNode( if (link.source.id === nodeId || link.target.id === nodeId) { if (link.source.id === nodeId) { - children.push({ id: link.target.id, linkType: link.linkType }); + children.push({ + id: link.target.id, + linkType: link.linkType, + }); } else if (link.target.id === nodeId) { parents.push(link.source.id); } @@ -352,7 +407,7 @@ function generateNodes(workflowNodes, i18n) { const arrayOfNodesForChart = [ { id: 1, - unifiedJobTemplate: { + fullUnifiedJobTemplate: { name: i18n._(t`START`), }, }, @@ -365,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); @@ -596,11 +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.launchConfig = launchConfig; + + if (promptValues) { + matchingNode.promptValues = promptValues; + } else { + delete matchingNode.promptValues; + } return { ...state, @@ -615,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/Survey/SurveyPreviewModal.jsx b/awx/ui_next/src/screens/Template/Survey/SurveyPreviewModal.jsx index d542c1b56b..1b1086e6b2 100644 --- a/awx/ui_next/src/screens/Template/Survey/SurveyPreviewModal.jsx +++ b/awx/ui_next/src/screens/Template/Survey/SurveyPreviewModal.jsx @@ -104,7 +104,9 @@ function SurveyPreviewModal({ isReadOnly variant={SelectVariant.typeaheadMulti} isOpen={false} - selections={q.default.length > 0 && q.default.split('\n')} + selections={ + q.default.length > 0 ? q.default.split('\n') : [] + } onToggle={() => {}} aria-label={i18n._(t`Multi-Select`)} id={`survey-preview-multiSelect-${q.variable}`} diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeAddModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeAddModal.jsx index 18ee5fcdc9..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 @@ -6,18 +6,56 @@ import { WorkflowStateContext, } from '../../../../../contexts/Workflow'; import NodeModal from './NodeModal'; +import { getAddedAndRemoved } from '../../../../../util/lists'; function NodeAddModal({ i18n }) { const dispatch = useContext(WorkflowDispatchContext); const { addNodeSource } = useContext(WorkflowStateContext); - const addNode = (resource, linkType) => { + const addNode = (values, config) => { + const { + approvalName, + approvalDescription, + timeoutMinutes, + timeoutSeconds, + linkType, + } = values; + + if (values) { + const { added, removed } = getAddedAndRemoved( + config?.defaults?.credentials, + values?.credentials + ); + + values.addedCredentials = added; + values.removedCredentials = removed; + } + + 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.nodeResource = values.nodeResource; + if ( + values?.nodeType === 'job_template' || + values?.nodeType === 'workflow_job_template' + ) { + node.promptValues = values; + } + } dispatch({ type: 'CREATE_NODE', - node: { - linkType, - nodeResource: resource, - }, + node, }); }; diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeAddModal.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeAddModal.test.jsx index 723de11c73..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 @@ -1,6 +1,9 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; -import { mountWithContexts } from '../../../../../../testUtils/enzymeHelpers'; +import { + mountWithContexts, + waitForElement, +} from '../../../../../../testUtils/enzymeHelpers'; import { WorkflowDispatchContext, WorkflowStateContext, @@ -13,6 +16,9 @@ const nodeResource = { id: 448, type: 'job_template', name: 'Test JT', + summary_fields: { + credentials: [], + }, }; const workflowContext = { @@ -20,23 +26,37 @@ const workflowContext = { }; describe('NodeAddModal', () => { - test('Node modal confirmation dispatches as expected', () => { + test('Node modal confirmation dispatches as expected', async () => { const wrapper = mountWithContexts( - + {}} askLinkType title="Add Node" /> ); - act(() => { - wrapper.find('NodeModal').prop('onSave')(nodeResource, 'success'); + waitForElement( + wrapper, + 'WizardNavItem[content="ContentLoading"]', + el => el.length === 0 + ); + await act(async () => { + wrapper.find('NodeModal').prop('onSave')( + { linkType: 'success', nodeResource }, + {} + ); }); + expect(dispatch).toHaveBeenCalledWith({ - type: 'CREATE_NODE', node: { linkType: 'success', - nodeResource, + nodeResource: { + id: 448, + name: 'Test JT', + summary_fields: { credentials: [] }, + type: 'job_template', + }, }, + type: 'CREATE_NODE', }); }); }); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeEditModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeEditModal.jsx index db002942a0..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 @@ -7,12 +7,45 @@ import NodeModal from './NodeModal'; function NodeEditModal({ i18n }) { const dispatch = useContext(WorkflowDispatchContext); - const updateNode = resource => { + 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: { - nodeResource: resource, - }, + node, }); }; diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeEditModal.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeEditModal.test.jsx index e0f5ff1b72..08b046d27b 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeEditModal.test.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeEditModal.test.jsx @@ -1,6 +1,9 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; -import { mountWithContexts } from '../../../../../../testUtils/enzymeHelpers'; +import { + mountWithContexts, + waitForElement, +} from '../../../../../../testUtils/enzymeHelpers'; import { WorkflowDispatchContext, WorkflowStateContext, @@ -9,10 +12,17 @@ import NodeEditModal from './NodeEditModal'; const dispatch = jest.fn(); -const nodeResource = { - id: 448, - type: 'job_template', - name: 'Test JT', +jest.mock('../../../../../api/models/InventorySources'); +jest.mock('../../../../../api/models/JobTemplates'); +jest.mock('../../../../../api/models/Projects'); +jest.mock('../../../../../api/models/WorkflowJobTemplates'); +const values = { + inventory: undefined, + nodeResource: { + id: 448, + name: 'Test JT', + type: 'job_template', + }, }; const workflowContext = { @@ -22,27 +32,40 @@ const workflowContext = { id: 30, name: 'Foo JT', type: 'job_template', + unified_job_type: 'job', + }, + originalNodeObject: { + summary_fields: { unified_job_template: { id: 1, name: 'Job Template' } }, }, }, }; describe('NodeEditModal', () => { - test('Node modal confirmation dispatches as expected', () => { + test('Node modal confirmation dispatches as expected', async () => { const wrapper = mountWithContexts( - + {}} + askLinkType={false} + title="Edit Node" + /> ); - act(() => { - wrapper.find('NodeModal').prop('onSave')(nodeResource); + waitForElement( + wrapper, + 'WizardNavItem[content="ContentLoading"]', + el => el.length === 0 + ); + await act(async () => { + wrapper.find('NodeModal').prop('onSave')(values, {}); }); expect(dispatch).toHaveBeenCalledWith({ - type: 'UPDATE_NODE', node: { - nodeResource, + nodeResource: { id: 448, name: 'Test JT', type: 'job_template' }, }, + type: 'UPDATE_NODE', }); }); }); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeModal.jsx index 489b40042b..2b57effb35 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeModal.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeModal.jsx @@ -1,86 +1,51 @@ import 'styled-components/macro'; -import React, { useContext, useState } from 'react'; +import React, { useContext, useState, useEffect, useCallback } from 'react'; import { useHistory } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; +import { Formik, useFormikContext } from 'formik'; +import yaml from 'js-yaml'; import { bool, node, func } from 'prop-types'; import { Button, WizardContextConsumer, WizardFooter, + Form, } from '@patternfly/react-core'; +import ContentError from '../../../../../components/ContentError'; +import ContentLoading from '../../../../../components/ContentLoading'; + +import useRequest, { + useDismissableError, +} from '../../../../../util/useRequest'; +import mergeExtraVars from '../../../../../util/prompt/mergeExtraVars'; +import getSurveyValues from '../../../../../util/prompt/getSurveyValues'; +import { parseVariableField } from '../../../../../util/yaml'; import { WorkflowDispatchContext, WorkflowStateContext, } from '../../../../../contexts/Workflow'; +import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '../../../../../api'; import Wizard from '../../../../../components/Wizard'; -import { NodeTypeStep } from './NodeTypeStep'; -import RunStep from './RunStep'; +import useWorkflowNodeSteps from './useWorkflowNodeSteps'; +import AlertModal from '../../../../../components/AlertModal'; + import NodeNextButton from './NodeNextButton'; -function NodeModal({ askLinkType, i18n, onSave, title }) { +function 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(); - let defaultApprovalDescription = ''; - let defaultApprovalName = ''; - let defaultApprovalTimeout = 0; - let defaultNodeResource = null; - let defaultNodeType = 'job_template'; - if (nodeToEdit && nodeToEdit.unifiedJobTemplate) { - if ( - nodeToEdit && - nodeToEdit.unifiedJobTemplate && - (nodeToEdit.unifiedJobTemplate.type || - nodeToEdit.unifiedJobTemplate.unified_job_type) - ) { - const ujtType = - nodeToEdit.unifiedJobTemplate.type || - nodeToEdit.unifiedJobTemplate.unified_job_type; - switch (ujtType) { - case 'job_template': - case 'job': - defaultNodeType = 'job_template'; - defaultNodeResource = nodeToEdit.unifiedJobTemplate; - break; - case 'project': - case 'project_update': - defaultNodeType = 'project_sync'; - defaultNodeResource = nodeToEdit.unifiedJobTemplate; - break; - case 'inventory_source': - case 'inventory_update': - defaultNodeType = 'inventory_source_sync'; - defaultNodeResource = nodeToEdit.unifiedJobTemplate; - break; - case 'workflow_job_template': - case 'workflow_job': - defaultNodeType = 'workflow_job_template'; - defaultNodeResource = nodeToEdit.unifiedJobTemplate; - break; - case 'workflow_approval_template': - case 'workflow_approval': - defaultNodeType = 'approval'; - defaultApprovalName = nodeToEdit.unifiedJobTemplate.name; - defaultApprovalDescription = - nodeToEdit.unifiedJobTemplate.description; - defaultApprovalTimeout = nodeToEdit.unifiedJobTemplate.timeout; - break; - default: - } - } - } - const [approvalDescription, setApprovalDescription] = useState( - defaultApprovalDescription - ); - const [approvalName, setApprovalName] = useState(defaultApprovalName); - const [approvalTimeout, setApprovalTimeout] = useState( - defaultApprovalTimeout - ); - const [linkType, setLinkType] = useState('success'); - const [nodeResource, setNodeResource] = useState(defaultNodeResource); - const [nodeType, setNodeType] = useState(defaultNodeType); const [triggerNext, setTriggerNext] = useState(0); const clearQueryParams = () => { @@ -93,20 +58,48 @@ function NodeModal({ askLinkType, i18n, onSave, title }) { history.replace(`${history.location.pathname}?${otherParts.join('&')}`); }; + const { + steps: promptSteps, + visitStep, + visitAllSteps, + contentError, + } = useWorkflowNodeSteps( + launchConfig, + surveyConfig, + i18n, + values.nodeResource, + askLinkType + ); + const handleSaveNode = () => { clearQueryParams(); + if (values.nodeType !== 'workflow_approval_template') { + delete values.approvalName; + delete values.approvalDescription; + delete values.timeoutMinutes; + delete values.timeoutSeconds; + } - const resource = - nodeType === 'approval' - ? { - description: approvalDescription, - name: approvalName, - timeout: approvalTimeout, - type: 'workflow_approval_template', - } - : nodeResource; + if ( + ['job_template', 'workflow_job_template'].includes(values.nodeType) && + (launchConfig.ask_variables_on_launch || launchConfig.survey_enabled) + ) { + let extraVars; + const surveyValues = getSurveyValues(values); + const initialExtraVars = + launchConfig.ask_variables_on_launch && (values.extra_vars || '---'); + if (surveyConfig?.spec) { + extraVars = yaml.safeDump( + mergeExtraVars(initialExtraVars, surveyValues) + ); + } else { + extraVars = yaml.safeDump(mergeExtraVars(initialExtraVars, {})); + } + values.extra_data = extraVars && parseVariableField(extraVars); + delete values.extra_vars; + } - onSave(resource, askLinkType ? linkType : null); + onSave(values, launchConfig); }; const handleCancel = () => { @@ -114,53 +107,15 @@ function NodeModal({ askLinkType, i18n, onSave, title }) { dispatch({ type: 'CANCEL_NODE_MODAL' }); }; - const handleNodeTypeChange = newNodeType => { - setNodeType(newNodeType); - setNodeResource(null); - setApprovalName(''); - setApprovalDescription(''); - setApprovalTimeout(0); - }; + const { error, dismissError } = useDismissableError( + contentError || credentialError + ); - const steps = [ - ...(askLinkType - ? [ - { - name: i18n._(t`Run Type`), - key: 'run_type', - component: ( - - ), - enableNext: linkType !== null, - }, - ] - : []), - { - name: i18n._(t`Node Type`), - key: 'node_resource', - enableNext: - (nodeType !== 'approval' && nodeResource !== null) || - (nodeType === 'approval' && approvalName !== ''), - component: ( - - ), - }, - ]; - - steps.forEach((step, n) => { - step.id = n + 1; - }); + const nextButtonText = activeStep => + activeStep.id === promptSteps[promptSteps?.length - 1]?.id || + activeStep.name === 'Preview' + ? i18n._(t`Save`) + : i18n._(t`Next`); const CustomFooter = ( @@ -168,24 +123,28 @@ function NodeModal({ askLinkType, i18n, onSave, title }) { {({ activeStep, onNext, onBack }) => ( <> setTriggerNext(triggerNext + 1)} - buttonText={ - activeStep.key === 'node_resource' - ? i18n._(t`Save`) - : i18n._(t`Next`) - } + buttonText={nextButtonText(activeStep)} /> - {activeStep && activeStep.id !== 1 && ( - )} @@ -34,7 +35,7 @@ NodeNextButton.propTypes = { buttonText: string.isRequired, onClick: func.isRequired, onNext: func.isRequired, - triggerNext: number.isRequired, + triggerNext: oneOfType([string, number]).isRequired, }; export default NodeNextButton; diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/NodeTypeStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/NodeTypeStep.jsx index e9ee7928bb..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 @@ -2,16 +2,18 @@ import 'styled-components/macro'; import React from 'react'; import { withI18n } from '@lingui/react'; import { t, Trans } from '@lingui/macro'; -import { func, number, shape, string } from 'prop-types'; import styled from 'styled-components'; -import { Formik, Field } from 'formik'; +import { useField } from 'formik'; import { Form, FormGroup, TextInput } from '@patternfly/react-core'; +import { required } from '../../../../../../util/validators'; + import { FormFullWidthLayout } from '../../../../../../components/FormLayout'; import AnsibleSelect from '../../../../../../components/AnsibleSelect'; import InventorySourcesList from './InventorySourcesList'; import JobTemplatesList from './JobTemplatesList'; import ProjectsList from './ProjectsList'; import WorkflowJobTemplatesList from './WorkflowJobTemplatesList'; +import FormField from '../../../../../../components/FormField'; const TimeoutInput = styled(TextInput)` width: 200px; @@ -25,19 +27,19 @@ const TimeoutLabel = styled.p` margin-left: 10px; `; -function NodeTypeStep({ - description, - i18n, - name, - nodeResource, - nodeType, - timeout, - onUpdateDescription, - onUpdateName, - onUpdateNodeResource, - onUpdateNodeType, - onUpdateTimeout, -}) { +function NodeTypeStep({ i18n }) { + const [nodeTypeField, , nodeTypeHelpers] = useField('nodeType'); + const [nodeResourceField, , nodeResourceHelpers] = useField('nodeResource'); + const [, approvalNameMeta, approvalNameHelpers] = useField('approvalName'); + const [, , approvalDescriptionHelpers] = useField('approvalDescription'); + const [timeoutMinutesField, , timeoutMinutesHelpers] = useField( + 'timeoutMinutes' + ); + const [timeoutSecondsField, , timeoutSecondsHelpers] = useField( + 'timeoutSeconds' + ); + + const isValid = !approvalNameMeta.touched || !approvalNameMeta.error; return ( <>
@@ -48,14 +50,14 @@ function NodeTypeStep({ 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, }, @@ -66,8 +68,8 @@ function NodeTypeStep({ isDisabled: false, }, { - key: 'project_sync', - value: 'project_sync', + key: 'project', + value: 'project', label: i18n._(t`Project Sync`), isDisabled: false, }, @@ -78,189 +80,98 @@ function NodeTypeStep({ isDisabled: false, }, ]} - value={nodeType} + value={nodeTypeField.value} onChange={(e, val) => { - onUpdateNodeType(val); + nodeTypeHelpers.setValue(val); + nodeResourceHelpers.setValue(null); + approvalNameHelpers.setValue(''); + approvalDescriptionHelpers.setValue(''); + timeoutMinutesHelpers.setValue(0); + timeoutSecondsHelpers.setValue(0); }} />
- {nodeType === 'job_template' && ( + {nodeTypeField.value === 'job_template' && ( )} - {nodeType === 'project_sync' && ( + {nodeTypeField.value === 'project' && ( )} - {nodeType === 'inventory_source_sync' && ( + {nodeTypeField.value === 'inventory_source' && ( )} - {nodeType === 'workflow_job_template' && ( + {nodeTypeField.value === 'workflow_job_template' && ( )} - {nodeType === 'approval' && ( - - {() => ( -
- - - {({ field, form }) => { - const isValid = - form && - (!form.touched[field.name] || !form.errors[field.name]); - - return ( - - { - onUpdateName(evt.target.value); - field.onChange(evt); - }} - /> - - ); + {nodeTypeField.value === 'workflow_approval_template' && ( + + + + + +
+ { + timeoutMinutesField.onChange(event); }} - - - {({ field }) => ( - - { - onUpdateDescription(evt.target.value); - field.onChange(evt); - }} - /> - - )} - - -
- - {({ field, form }) => ( - <> - { - if ( - !evt.target.value || - evt.target.value === '' - ) { - evt.target.value = 0; - } - onUpdateTimeout( - Number(evt.target.value) * 60 + - Number(form.values.timeoutSeconds) - ); - field.onChange(evt); - }} - /> - - min - - - )} - - - {({ field, form }) => ( - <> - { - if ( - !evt.target.value || - evt.target.value === '' - ) { - evt.target.value = 0; - } - onUpdateTimeout( - Number(evt.target.value) + - Number(form.values.timeoutMinutes) * 60 - ); - field.onChange(evt); - }} - /> - - sec - - - )} - -
-
- - - )} - + step="1" + type="number" + /> + + min + + { + timeoutSecondsField.onChange(event); + }} + step="1" + type="number" + /> + + sec + +
+
+
+ )} ); } - -NodeTypeStep.propTypes = { - description: string, - name: string, - nodeResource: shape(), - nodeType: string, - timeout: number, - onUpdateDescription: func.isRequired, - onUpdateName: func.isRequired, - onUpdateNodeResource: func.isRequired, - onUpdateNodeType: func.isRequired, - onUpdateTimeout: func.isRequired, -}; - -NodeTypeStep.defaultProps = { - description: '', - name: '', - nodeResource: null, - nodeType: 'job_template', - timeout: 0, -}; - export default withI18n()(NodeTypeStep); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/NodeTypeStep.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/NodeTypeStep.test.jsx index b7b3bfe0d5..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 @@ -1,5 +1,6 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; +import { Formik } from 'formik'; import { mountWithContexts } from '../../../../../../../testUtils/enzymeHelpers'; import { InventorySourcesAPI, @@ -7,6 +8,7 @@ import { ProjectsAPI, WorkflowJobTemplatesAPI, } from '../../../../../../api'; + import NodeTypeStep from './NodeTypeStep'; jest.mock('../../../../../../api/models/InventorySources'); @@ -14,12 +16,6 @@ jest.mock('../../../../../../api/models/JobTemplates'); jest.mock('../../../../../../api/models/Projects'); jest.mock('../../../../../../api/models/WorkflowJobTemplates'); -const onUpdateDescription = jest.fn(); -const onUpdateName = jest.fn(); -const onUpdateNodeResource = jest.fn(); -const onUpdateNodeType = jest.fn(); -const onUpdateTimeout = jest.fn(); - describe('NodeTypeStep', () => { beforeAll(() => { JobTemplatesAPI.read.mockResolvedValue({ @@ -118,90 +114,50 @@ describe('NodeTypeStep', () => { let wrapper; await act(async () => { wrapper = mountWithContexts( - + + + ); }); wrapper.update(); expect(wrapper.find('AnsibleSelect').prop('value')).toBe('job_template'); expect(wrapper.find('JobTemplatesList').length).toBe(1); - wrapper.find('Radio').simulate('click'); - expect(onUpdateNodeResource).toHaveBeenCalledWith({ - id: 1, - name: 'Test Job Template', - type: 'job_template', - url: '/api/v2/job_templates/1', - }); }); - test('It shows the project list when node type is project sync', async () => { + 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); - wrapper.find('Radio').simulate('click'); - expect(onUpdateNodeResource).toHaveBeenCalledWith({ - id: 1, - name: 'Test Project', - type: 'project', - url: '/api/v2/projects/1', - }); }); - test('It shows the inventory source list when node type is inventory source sync', async () => { + 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); - wrapper.find('Radio').simulate('click'); - expect(onUpdateNodeResource).toHaveBeenCalledWith({ - id: 1, - name: 'Test Inventory Source', - type: 'inventory_source', - url: '/api/v2/inventory_sources/1', - }); }); test('It shows the workflow job template list when node type is workflow job template', async () => { let wrapper; await act(async () => { wrapper = mountWithContexts( - + + + ); }); wrapper.update(); @@ -209,67 +165,60 @@ describe('NodeTypeStep', () => { 'workflow_job_template' ); expect(wrapper.find('WorkflowJobTemplatesList').length).toBe(1); - wrapper.find('Radio').simulate('click'); - expect(onUpdateNodeResource).toHaveBeenCalledWith({ - id: 1, - name: 'Test Workflow Job Template', - type: 'workflow_job_template', - url: '/api/v2/workflow_job_templates/1', - }); }); test('It shows the approval form fields when node type is approval', async () => { let wrapper; await act(async () => { wrapper = mountWithContexts( - + + + ); }); wrapper.update(); - expect(wrapper.find('AnsibleSelect').prop('value')).toBe('approval'); - expect(wrapper.find('input#approval-name').length).toBe(1); - expect(wrapper.find('input#approval-description').length).toBe(1); - expect(wrapper.find('input#approval-timeout-minutes').length).toBe(1); - expect(wrapper.find('input#approval-timeout-seconds').length).toBe(1); + expect(wrapper.find('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); + expect(wrapper.find('input[name="timeoutSeconds"]').length).toBe(1); await act(async () => { wrapper.find('input#approval-name').simulate('change', { - target: { value: 'Test Approval', name: 'name' }, + target: { value: 'Test Approval', name: 'approvalName' }, }); - }); - - expect(onUpdateName).toHaveBeenCalledWith('Test Approval'); - - await act(async () => { wrapper.find('input#approval-description').simulate('change', { - target: { value: 'Test Approval Description', name: 'description' }, + target: { + value: 'Test Approval Description', + name: 'approvalDescription', + }, }); - }); - - expect(onUpdateDescription).toHaveBeenCalledWith( - 'Test Approval Description' - ); - - await act(async () => { - wrapper.find('input#approval-timeout-minutes').simulate('change', { + wrapper.find('input[name="timeoutMinutes"]').simulate('change', { target: { value: 5, name: 'timeoutMinutes' }, }); - }); - - expect(onUpdateTimeout).toHaveBeenCalledWith(300); - - await act(async () => { - wrapper.find('input#approval-timeout-seconds').simulate('change', { + wrapper.find('input[name="timeoutSeconds"]').simulate('change', { target: { value: 30, name: 'timeoutSeconds' }, }); }); - expect(onUpdateTimeout).toHaveBeenCalledWith(330); + wrapper.update(); + + expect(wrapper.find('input#approval-name').prop('value')).toBe( + 'Test Approval' + ); + expect(wrapper.find('input#approval-description').prop('value')).toBe( + 'Test Approval Description' + ); + expect(wrapper.find('input[name="timeoutMinutes"]').prop('value')).toBe(5); + expect(wrapper.find('input[name="timeoutSeconds"]').prop('value')).toBe(30); }); }); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/useNodeTypeStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/useNodeTypeStep.jsx new file mode 100644 index 0000000000..2b0dcd888d --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/useNodeTypeStep.jsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { t } from '@lingui/macro'; +import { useField } from 'formik'; +import NodeTypeStep from './NodeTypeStep'; +import StepName from '../../../../../../components/LaunchPrompt/steps/StepName'; + +const STEP_ID = 'nodeType'; + +export default function useNodeTypeStep(i18n) { + const [, meta] = useField('nodeType'); + const [approvalNameField] = useField('approvalName'); + const [nodeTypeField, ,] = useField('nodeType'); + const [nodeResourceField] = useField('nodeResource'); + + return { + step: getStep(i18n, nodeTypeField, approvalNameField, nodeResourceField), + initialValues: getInitialValues(), + isReady: true, + contentError: null, + formError: meta.error, + setTouched: setFieldsTouched => { + setFieldsTouched({ + inventory: true, + }); + }, + }; +} +function getStep(i18n, nodeTypeField, approvalNameField, nodeResourceField) { + const isEnabled = () => { + if ( + (nodeTypeField.value !== 'workflow_approval_template' && + nodeResourceField.value === null) || + (nodeTypeField.value === 'workflow_approval_template' && + approvalNameField.value === undefined) + ) { + return false; + } + return true; + }; + return { + id: STEP_ID, + name: ( + + {i18n._(t`Node type`)} + + ), + component: , + enableNext: isEnabled(), + }; +} + +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/RunStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/RunStep.jsx index f7316ee8d6..da08b9ad71 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/RunStep.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/RunStep.jsx @@ -1,8 +1,8 @@ import React from 'react'; import { withI18n } from '@lingui/react'; +import { useField } from 'formik'; import { t } from '@lingui/macro'; import styled from 'styled-components'; -import { func, string } from 'prop-types'; import { Title } from '@patternfly/react-core'; import SelectableCard from '../../../../../components/SelectableCard'; @@ -16,7 +16,8 @@ const Grid = styled.div` width: 100%; `; -function RunStep({ i18n, linkType, onUpdateLinkType }) { +function RunStep({ i18n }) { + const [field, , helpers] = useField('linkType'); return ( <> @@ -30,39 +31,33 @@ function RunStep({ i18n, linkType, onUpdateLinkType }) { <Grid> <SelectableCard id="link-type-success" - isSelected={linkType === 'success'} + isSelected={field.value === 'success'} label={i18n._(t`On Success`)} description={i18n._( t`Execute when the parent node results in a successful state.` )} - onClick={() => onUpdateLinkType('success')} + onClick={() => helpers.setValue('success')} /> <SelectableCard id="link-type-failure" - isSelected={linkType === 'failure'} + isSelected={field.value === 'failure'} label={i18n._(t`On Failure`)} description={i18n._( t`Execute when the parent node results in a failure state.` )} - onClick={() => onUpdateLinkType('failure')} + onClick={() => helpers.setValue('failure')} /> <SelectableCard id="link-type-always" - isSelected={linkType === 'always'} + isSelected={field.value === 'always'} label={i18n._(t`Always`)} description={i18n._( t`Execute regardless of the parent node's final state.` )} - onClick={() => onUpdateLinkType('always')} + onClick={() => helpers.setValue('always')} /> </Grid> </> ); } - -RunStep.propTypes = { - linkType: string.isRequired, - onUpdateLinkType: func.isRequired, -}; - export default withI18n()(RunStep); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/RunStep.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/RunStep.test.jsx index b822601482..e8ff15780c 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/RunStep.test.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/RunStep.test.jsx @@ -1,15 +1,18 @@ import React from 'react'; +import { Formik } from 'formik'; +import { act } from 'react-dom/test-utils'; + import { mountWithContexts } from '../../../../../../testUtils/enzymeHelpers'; import RunStep from './RunStep'; let wrapper; -const linkType = 'always'; -const onUpdateLinkType = jest.fn(); describe('RunStep', () => { beforeAll(() => { wrapper = mountWithContexts( - <RunStep linkType={linkType} onUpdateLinkType={onUpdateLinkType} /> + <Formik initialValues={{ linkType: 'success' }}> + <RunStep /> + </Formik> ); }); @@ -18,23 +21,20 @@ describe('RunStep', () => { }); test('Default selected card matches default link type when present', () => { - expect(wrapper.find('#link-type-success').props().isSelected).toBe(false); + expect(wrapper.find('#link-type-success').props().isSelected).toBe(true); expect(wrapper.find('#link-type-failure').props().isSelected).toBe(false); + expect(wrapper.find('#link-type-always').props().isSelected).toBe(false); + }); + + test('Clicking success card makes expected callback', async () => { + await act(async () => wrapper.find('#link-type-always').simulate('click')); + wrapper.update(); expect(wrapper.find('#link-type-always').props().isSelected).toBe(true); }); - test('Clicking success card makes expected callback', () => { - wrapper.find('#link-type-success').simulate('click'); - expect(onUpdateLinkType).toHaveBeenCalledWith('success'); - }); - - test('Clicking failure card makes expected callback', () => { - wrapper.find('#link-type-failure').simulate('click'); - expect(onUpdateLinkType).toHaveBeenCalledWith('failure'); - }); - - test('Clicking always card makes expected callback', () => { - wrapper.find('#link-type-always').simulate('click'); - expect(onUpdateLinkType).toHaveBeenCalledWith('always'); + test('Clicking failure card makes expected callback', async () => { + await act(async () => wrapper.find('#link-type-failure').simulate('click')); + wrapper.update(); + expect(wrapper.find('#link-type-failure').props().isSelected).toBe(true); }); }); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/useRunTypeStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/useRunTypeStep.jsx new file mode 100644 index 0000000000..2e117da77e --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/useRunTypeStep.jsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { t } from '@lingui/macro'; +import { useField } from 'formik'; +import RunStep from './RunStep'; +import StepName from '../../../../../components/LaunchPrompt/steps/StepName'; + +const STEP_ID = 'runType'; + +export default function useRunTypeStep(i18n, askLinkType) { + const [, meta] = useField('linkType'); + + return { + step: getStep(askLinkType, meta, i18n), + initialValues: askLinkType ? { linkType: 'success' } : {}, + isReady: true, + contentError: null, + formError: meta.error, + setTouched: setFieldsTouched => { + setFieldsTouched({ + inventory: true, + }); + }, + }; +} +function getStep(askLinkType, meta, i18n) { + if (!askLinkType) { + return null; + } + return { + id: STEP_ID, + name: ( + <StepName hasErrors={false} id="run-type-step"> + {i18n._(t`Run type`)} + </StepName> + ), + component: <RunStep />, + enableNext: meta.value !== '', + }; +} diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/useWorkflowNodeSteps.js b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/useWorkflowNodeSteps.js new file mode 100644 index 0000000000..bd8a8c74e4 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/useWorkflowNodeSteps.js @@ -0,0 +1,270 @@ +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) { + const newExtraData = { ...sourceOfValues.extra_data }; + if (launchConfig.survey_enabled && surveyConfig.spec) { + surveyConfig.spec.forEach(question => { + if ( + Object.prototype.hasOwnProperty.call(newExtraData, question.variable) + ) { + delete newExtraData[question.variable]; + } + }); + } + initialValues.extra_vars = jsonToYaml(JSON.stringify(newExtraData)); + } + + if (surveyConfig?.spec) { + 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( + launchConfig, + surveyConfig, + i18n, + resource, + askLinkType +) { + const { nodeToEdit } = useContext(WorkflowStateContext); + const { resetForm, values: formikValues } = useFormikContext(); + const [visited, setVisited] = useState({}); + + const steps = [ + useRunTypeStep(i18n, askLinkType), + useNodeTypeStep(i18n), + useInventoryStep(launchConfig, resource, i18n, visited), + useCredentialsStep(launchConfig, resource, i18n), + useOtherPromptsStep(launchConfig, resource, i18n), + useSurveyStep(launchConfig, surveyConfig, resource, i18n, visited), + ]; + + const hasErrors = steps.some(step => step.formError); + steps.push( + usePreviewStep( + launchConfig, + i18n, + resource, + surveyConfig, + hasErrors, + showPreviewStep(formikValues.nodeType, launchConfig) + ) + ); + + const pfSteps = steps.map(s => s.step).filter(s => s != null); + const isReady = !steps.some(s => !s.isReady); + + useEffect(() => { + 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, + nodeType: formikValues.nodeType, + linkType: formikValues.linkType, + verbosity: initialValues?.verbosity?.toString(), + }, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [launchConfig, surveyConfig, isReady]); + + const stepWithError = steps.find(s => s.contentError); + const contentError = stepWithError ? stepWithError.contentError : null; + + return { + steps: pfSteps, + visitStep: stepId => + setVisited({ + ...visited, + [stepId]: true, + }), + visitAllSteps: setFieldsTouched => { + setVisited({ + inventory: true, + credentials: true, + other: true, + survey: true, + preview: true, + }); + steps.forEach(s => s.setTouched(setFieldsTouched)); + }, + contentError, + }; +} diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx index 311d95cec9..1deb449890 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx @@ -1,16 +1,21 @@ -import React, { useEffect, useReducer } from 'react'; +import React, { useCallback, useEffect, useReducer } from 'react'; import { useHistory } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import styled from 'styled-components'; import { shape } from 'prop-types'; +import { t } from '@lingui/macro'; import { WorkflowDispatchContext, WorkflowStateContext, } from '../../../contexts/Workflow'; +import { getAddedAndRemoved } from '../../../util/lists'; +import AlertModal from '../../../components/AlertModal'; +import ErrorDetail from '../../../components/ErrorDetail'; import { layoutGraph } from '../../../components/Workflow/WorkflowUtils'; import ContentError from '../../../components/ContentError'; import ContentLoading from '../../../components/ContentLoading'; import workflowReducer from '../../../components/Workflow/workflowReducer'; +import useRequest, { useDismissableError } from '../../../util/useRequest'; import { DeleteAllNodesModal, UnsavedChangesModal } from './Modals'; import { LinkAddModal, @@ -46,6 +51,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, @@ -206,175 +250,6 @@ function Visualizer({ template, i18n }) { return disassociateNodeRequests; }; - const generateLinkMapAndNewLinks = originalLinkMap => { - const linkMap = {}; - const newLinks = []; - - links.forEach(link => { - if (link.source.id !== 1) { - const realLinkSourceId = originalLinkMap[link.source.id].id; - const realLinkTargetId = originalLinkMap[link.target.id].id; - if (!linkMap[realLinkSourceId]) { - linkMap[realLinkSourceId] = {}; - } - linkMap[realLinkSourceId][realLinkTargetId] = link.linkType; - switch (link.linkType) { - case 'success': - if ( - !originalLinkMap[link.source.id].success_nodes.includes( - originalLinkMap[link.target.id].id - ) - ) { - newLinks.push(link); - } - break; - case 'failure': - if ( - !originalLinkMap[link.source.id].failure_nodes.includes( - originalLinkMap[link.target.id].id - ) - ) { - newLinks.push(link); - } - break; - case 'always': - if ( - !originalLinkMap[link.source.id].always_nodes.includes( - originalLinkMap[link.target.id].id - ) - ) { - newLinks.push(link); - } - break; - default: - } - } - }); - - return [linkMap, newLinks]; - }; - - const handleVisualizerSave = async () => { - const nodeRequests = []; - const approvalTemplateRequests = []; - const originalLinkMap = {}; - const deletedNodeIds = []; - nodes.forEach(node => { - // node with id=1 is the artificial start node - if (node.id === 1) { - return; - } - if (node.originalNodeObject && !node.isDeleted) { - const { - id, - success_nodes, - failure_nodes, - always_nodes, - } = node.originalNodeObject; - originalLinkMap[node.id] = { - id, - success_nodes, - failure_nodes, - always_nodes, - }; - } - if (node.isDeleted && node.originalNodeObject) { - deletedNodeIds.push(node.originalNodeObject.id); - nodeRequests.push( - WorkflowJobTemplateNodesAPI.destroy(node.originalNodeObject.id) - ); - } else if (!node.isDeleted && !node.originalNodeObject) { - if (node.unifiedJobTemplate.type === 'workflow_approval_template') { - nodeRequests.push( - WorkflowJobTemplatesAPI.createNode(template.id, {}).then( - ({ data }) => { - node.originalNodeObject = data; - originalLinkMap[node.id] = { - id: data.id, - success_nodes: [], - failure_nodes: [], - always_nodes: [], - }; - approvalTemplateRequests.push( - WorkflowJobTemplateNodesAPI.createApprovalTemplate(data.id, { - name: node.unifiedJobTemplate.name, - description: node.unifiedJobTemplate.description, - timeout: node.unifiedJobTemplate.timeout, - }) - ); - } - ) - ); - } else { - nodeRequests.push( - WorkflowJobTemplatesAPI.createNode(template.id, { - unified_job_template: node.unifiedJobTemplate.id, - }).then(({ data }) => { - node.originalNodeObject = data; - originalLinkMap[node.id] = { - id: data.id, - success_nodes: [], - failure_nodes: [], - always_nodes: [], - }; - }) - ); - } - } else if (node.isEdited) { - if ( - node.unifiedJobTemplate && - (node.unifiedJobTemplate.unified_job_type === 'workflow_approval' || - node.unifiedJobTemplate.type === 'workflow_approval_template') - ) { - if ( - node.originalNodeObject.summary_fields.unified_job_template - .unified_job_type === 'workflow_approval' - ) { - approvalTemplateRequests.push( - WorkflowApprovalTemplatesAPI.update( - node.originalNodeObject.summary_fields.unified_job_template.id, - { - name: node.unifiedJobTemplate.name, - description: node.unifiedJobTemplate.description, - timeout: node.unifiedJobTemplate.timeout, - } - ) - ); - } else { - approvalTemplateRequests.push( - WorkflowJobTemplateNodesAPI.createApprovalTemplate( - node.originalNodeObject.id, - { - name: node.unifiedJobTemplate.name, - description: node.unifiedJobTemplate.description, - timeout: node.unifiedJobTemplate.timeout, - } - ) - ); - } - } else { - nodeRequests.push( - WorkflowJobTemplateNodesAPI.update(node.originalNodeObject.id, { - unified_job_template: node.unifiedJobTemplate.id, - }) - ); - } - } - }); - - await Promise.all(nodeRequests); - // Creating approval templates needs to happen after the node has been created - // since we reference the node in the approval template request. - await Promise.all(approvalTemplateRequests); - const [linkMap, newLinks] = generateLinkMapAndNewLinks(originalLinkMap); - await Promise.all( - disassociateNodes(originalLinkMap, deletedNodeIds, linkMap) - ); - await Promise.all(associateNodes(newLinks, originalLinkMap)); - - history.push(`/templates/workflow_job_template/${template.id}/details`); - }; - useEffect(() => { async function fetchData() { try { @@ -408,6 +283,249 @@ function Visualizer({ template, i18n }) { } }, [links, nodes]); + const { error: saveVisualizerError, request: saveVisualizer } = useRequest( + useCallback(async () => { + const nodeRequests = []; + const approvalTemplateRequests = []; + const originalLinkMap = {}; + const deletedNodeIds = []; + const associateCredentialRequests = []; + const disassociateCredentialRequests = []; + + const generateLinkMapAndNewLinks = () => { + const linkMap = {}; + const newLinks = []; + + links.forEach(link => { + if (link.source.id !== 1) { + const realLinkSourceId = originalLinkMap[link.source.id].id; + const realLinkTargetId = originalLinkMap[link.target.id].id; + if (!linkMap[realLinkSourceId]) { + linkMap[realLinkSourceId] = {}; + } + linkMap[realLinkSourceId][realLinkTargetId] = link.linkType; + switch (link.linkType) { + case 'success': + if ( + !originalLinkMap[link.source.id].success_nodes.includes( + originalLinkMap[link.target.id].id + ) + ) { + newLinks.push(link); + } + break; + case 'failure': + if ( + !originalLinkMap[link.source.id].failure_nodes.includes( + originalLinkMap[link.target.id].id + ) + ) { + newLinks.push(link); + } + break; + case 'always': + if ( + !originalLinkMap[link.source.id].always_nodes.includes( + originalLinkMap[link.target.id].id + ) + ) { + newLinks.push(link); + } + break; + default: + } + } + }); + + return [linkMap, newLinks]; + }; + + nodes.forEach(node => { + // node with id=1 is the artificial start node + if (node.id === 1) { + return; + } + if (node.originalNodeObject && !node.isDeleted) { + const { + id, + success_nodes, + failure_nodes, + always_nodes, + } = node.originalNodeObject; + originalLinkMap[node.id] = { + id, + success_nodes, + failure_nodes, + always_nodes, + }; + } + if (node.isDeleted && node.originalNodeObject) { + deletedNodeIds.push(node.originalNodeObject.id); + nodeRequests.push( + WorkflowJobTemplateNodesAPI.destroy(node.originalNodeObject.id) + ); + } else if (!node.isDeleted && !node.originalNodeObject) { + if ( + node.fullUnifiedJobTemplate.type === 'workflow_approval_template' + ) { + nodeRequests.push( + WorkflowJobTemplatesAPI.createNode(template.id, {}).then( + ({ data }) => { + node.originalNodeObject = data; + originalLinkMap[node.id] = { + id: data.id, + success_nodes: [], + failure_nodes: [], + always_nodes: [], + }; + approvalTemplateRequests.push( + WorkflowJobTemplateNodesAPI.createApprovalTemplate( + data.id, + { + name: node.fullUnifiedJobTemplate.name, + description: node.fullUnifiedJobTemplate.description, + timeout: node.fullUnifiedJobTemplate.timeout, + } + ) + ); + } + ) + ); + } else { + nodeRequests.push( + WorkflowJobTemplatesAPI.createNode(template.id, { + ...node.promptValues, + inventory: node.promptValues?.inventory?.id || null, + unified_job_template: node.fullUnifiedJobTemplate.id, + }).then(({ data }) => { + node.originalNodeObject = data; + originalLinkMap[node.id] = { + id: data.id, + success_nodes: [], + failure_nodes: [], + always_nodes: [], + }; + if (node.promptValues?.removedCredentials?.length > 0) { + node.promptValues.removedCredentials.forEach(cred => { + disassociateCredentialRequests.push( + WorkflowJobTemplateNodesAPI.disassociateCredentials( + data.id, + cred.id + ) + ); + }); + } + if (node.promptValues?.addedCredentials?.length > 0) { + node.promptValues.addedCredentials.forEach(cred => { + associateCredentialRequests.push( + WorkflowJobTemplateNodesAPI.associateCredentials( + data.id, + cred.id + ) + ); + }); + } + }) + ); + } + } else if (node.isEdited) { + if ( + node.fullUnifiedJobTemplate.type === 'workflow_approval_template' + ) { + if ( + node.originalNodeObject.summary_fields.unified_job_template + .unified_job_type === 'workflow_approval' + ) { + approvalTemplateRequests.push( + WorkflowApprovalTemplatesAPI.update( + node.originalNodeObject.summary_fields.unified_job_template + .id, + { + name: node.fullUnifiedJobTemplate.name, + description: node.fullUnifiedJobTemplate.description, + timeout: node.fullUnifiedJobTemplate.timeout, + } + ) + ); + } else { + approvalTemplateRequests.push( + WorkflowJobTemplateNodesAPI.createApprovalTemplate( + node.originalNodeObject.id, + { + name: node.fullUnifiedJobTemplate.name, + description: node.fullUnifiedJobTemplate.description, + timeout: node.fullUnifiedJobTemplate.timeout, + } + ) + ); + } + } else { + nodeRequests.push( + WorkflowJobTemplateNodesAPI.replace(node.originalNodeObject.id, { + ...node.promptValues, + inventory: node.promptValues?.inventory?.id || null, + unified_job_template: node.fullUnifiedJobTemplate.id, + }).then(() => { + const { + added: addedCredentials, + removed: removedCredentials, + } = getAddedAndRemoved( + getAggregatedCredentials( + node?.originalNodeCredentials, + node.launchConfig?.defaults?.credentials + ), + node.promptValues?.credentials + ); + + if (addedCredentials.length > 0) { + addedCredentials.forEach(cred => { + associateCredentialRequests.push( + WorkflowJobTemplateNodesAPI.associateCredentials( + node.originalNodeObject.id, + cred.id + ) + ); + }); + } + if (removedCredentials?.length > 0) { + removedCredentials.forEach(cred => + disassociateCredentialRequests.push( + WorkflowJobTemplateNodesAPI.disassociateCredentials( + node.originalNodeObject.id, + cred.id + ) + ) + ); + } + }) + ); + } + } + }); + + await Promise.all(nodeRequests); + // Creating approval templates needs to happen after the node has been created + // since we reference the node in the approval template request. + await Promise.all(approvalTemplateRequests); + const [linkMap, newLinks] = generateLinkMapAndNewLinks(originalLinkMap); + await Promise.all( + disassociateNodes(originalLinkMap, deletedNodeIds, linkMap) + ); + await Promise.all(associateNodes(newLinks, originalLinkMap)); + + await Promise.all(disassociateCredentialRequests); + await Promise.all(associateCredentialRequests); + + history.push(`/templates/workflow_job_template/${template.id}/details`); + }, [links, nodes, history, template.id]), + {} + ); + + const { + error: nodeRequestError, + dismissError: dismissNodeRequestError, + } = useDismissableError(saveVisualizerError); + if (isLoading) { return ( <CenteredContent> @@ -432,7 +550,7 @@ function Visualizer({ template, i18n }) { <Wrapper> <VisualizerToolbar onClose={handleVisualizerClose} - onSave={handleVisualizerSave} + onSave={() => saveVisualizer(nodes)} hasUnsavedChanges={unsavedChanges} template={template} readOnly={readOnly} @@ -456,11 +574,22 @@ function Visualizer({ template, i18n }) { `/templates/workflow_job_template/${template.id}/details` ) } - onSaveAndExit={() => handleVisualizerSave()} + onSaveAndExit={() => saveVisualizer(nodes)} /> )} {showDeleteAllNodesModal && <DeleteAllNodesModal />} {nodeToView && <NodeViewModal readOnly={readOnly} />} + {nodeRequestError && ( + <AlertModal + isOpen + variant="error" + title={i18n._(t`Error!`)} + onClose={dismissNodeRequestError} + > + {i18n._(t`There was an error saving the workflow.`)} + <ErrorDetail error={nodeRequestError} /> + </AlertModal> + )} </WorkflowDispatchContext.Provider> </WorkflowStateContext.Provider> ); 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({ <WorkflowActionTooltipItem id="node-details" key="details" - onClick={() => { - 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({ <WorkflowActionTooltipItem id="node-edit" key="edit" - onClick={() => { - 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 ( - <NodeG - id={`node-${node.id}`} - job={node.job} - noPointerEvents={isAddLinkSourceNode} - onMouseEnter={handleNodeMouseEnter} - onMouseLeave={handleNodeMouseLeave} - ref={ref} - transform={`translate(${nodePositions[node.id].x},${nodePositions[node.id] - .y - nodePositions[1].y})`} - > - <rect - fill="#FFFFFF" - height={wfConstants.nodeH} - rx="2" - ry="2" - stroke={ - hovering && addingLink && !node.isInvalidLinkTarget - ? '#007ABC' - : '#93969A' - } - strokeWidth="2px" - width={wfConstants.nodeW} - /> - <foreignObject - height="58" - {...(!addingLink && { - onMouseEnter: () => updateNodeHelp(node), - onMouseLeave: () => updateNodeHelp(null), - })} - onClick={() => handleNodeClick()} - width="178" - x="1" - y="1" + <> + <NodeG + id={`node-${node.id}`} + job={node.job} + noPointerEvents={isAddLinkSourceNode} + onMouseEnter={handleNodeMouseEnter} + onMouseLeave={handleNodeMouseLeave} + ref={ref} + transform={`translate(${nodePositions[node.id].x},${nodePositions[ + node.id + ].y - nodePositions[1].y})`} > - <NodeContents isInvalidLinkTarget={node.isInvalidLinkTarget}> - <NodeResourceName id={`node-${node.id}-name`}> - {node.unifiedJobTemplate - ? node.unifiedJobTemplate.name - : i18n._(t`DELETED`)} - </NodeResourceName> - </NodeContents> - </foreignObject> - {node.unifiedJobTemplate && <WorkflowNodeTypeLetter node={node} />} - {hovering && !addingLink && ( - <WorkflowActionTooltip - pointX={wfConstants.nodeW} - pointY={wfConstants.nodeH / 2} - actions={tooltipActions} + <rect + fill="#FFFFFF" + height={wfConstants.nodeH} + rx="2" + ry="2" + stroke={ + hovering && addingLink && !node.isInvalidLinkTarget + ? '#007ABC' + : '#93969A' + } + strokeWidth="2px" + width={wfConstants.nodeW} /> + <foreignObject + height="58" + {...(!addingLink && { + onMouseEnter: () => updateNodeHelp(node), + onMouseLeave: () => updateNodeHelp(null), + })} + onClick={() => handleNodeClick()} + width="178" + x="1" + y="1" + > + <NodeContents isInvalidLinkTarget={node.isInvalidLinkTarget}> + <NodeResourceName id={`node-${node.id}-name`}> + {node?.fullUnifiedJobTemplate?.name || + node?.originalNodeObject?.summary_fields?.unified_job_template + ?.name || + i18n._(t`DELETED`)} + </NodeResourceName> + </NodeContents> + </foreignObject> + <WorkflowNodeTypeLetter node={node} /> + {hovering && !addingLink && ( + <WorkflowActionTooltip + pointX={wfConstants.nodeW} + pointY={wfConstants.nodeH / 2} + actions={tooltipActions} + /> + )} + </NodeG> + {detailError && ( + <AlertModal + isOpen={detailError} + variant="error" + title={i18n._(t`Error!`)} + onClose={handleDetailErrorClose} + > + {i18n._(t`Failed to retrieve full node resource object.`)} + <ErrorDetail error={detailError} /> + </AlertModal> )} - </NodeG> + {credentialsError && ( + <AlertModal + isOpen={credentialsError} + variant="error" + title={i18n._(t`Error!`)} + onClose={handleCredentialsErrorClose} + > + {i18n._(t`Failed to retrieve node credentials.`)} + <ErrorDetail error={credentialsError} /> + </AlertModal> + )} + </> ); } 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', () => { <WorkflowStateContext.Provider value={mockedContext}> <svg> <VisualizerNode - mouseEnter={() => {}} - mouseLeave={() => {}} node={nodeWithJT} readOnly={false} updateHelpText={updateHelpText} @@ -59,6 +70,9 @@ describe('VisualizerNode', () => { </WorkflowDispatchContext.Provider> ); }); + 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', () => { <svg> <WorkflowStateContext.Provider value={mockedContext}> <VisualizerNode - mouseEnter={() => {}} - 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( + <WorkflowDispatchContext.Provider value={dispatch}> + <WorkflowStateContext.Provider value={mockedContext}> + <svg> + <VisualizerNode + node={{ + id: 2, + originalNodeObject: { + all_parents_must_converge: false, + always_nodes: [], + created: '2020-11-19T21:47:55.278081Z', + diff_mode: null, + extra_data: {}, + failure_nodes: [], + id: 49, + identifier: 'f03b62c5-40f8-49e4-97c3-5bb20c91ec91', + inventory: null, + job_tags: null, + job_type: null, + limit: null, + modified: '2020-11-19T21:47:55.278156Z', + related: { + credentials: + '/api/v2/workflow_job_template_nodes/49/credentials/', + }, + scm_branch: null, + skip_tags: null, + success_nodes: [], + summary_fields: { + workflow_job_template: { id: 15 }, + unified_job_template: { + id: 7, + description: '', + name: 'Example', + unified_job_type: 'job', + }, + }, + type: 'workflow_job_template_node', + unified_job_template: 7, + url: '/api/v2/workflow_job_template_nodes/49/', + verbosity: null, + workflowMakerNodeId: 2, + workflow_job_template: 15, + }, + }} + readOnly={false} + updateHelpText={updateHelpText} + updateNodeHelp={updateNodeHelp} + /> + </svg> + </WorkflowStateContext.Provider> + </WorkflowDispatchContext.Provider> + ); + }); + 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/VisualizerToolbar.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerToolbar.jsx index 26c80f338f..1170858f83 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerToolbar.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerToolbar.jsx @@ -79,8 +79,9 @@ function VisualizerToolbar({ <Badge id="visualizer-total-nodes-badge" isRead> {totalNodes} </Badge> - <Tooltip content={i18n._(t`Toggle Legend`)} position="bottom"> + <Tooltip content={i18n._(t`Toggle legend`)} position="bottom"> <ActionButton + aria-label={i18n._(t`Toggle legend`)} id="visualizer-toggle-legend" isActive={totalNodes > 0 && showLegend} isDisabled={totalNodes === 0} @@ -90,8 +91,9 @@ function VisualizerToolbar({ <CompassIcon /> </ActionButton> </Tooltip> - <Tooltip content={i18n._(t`Toggle Tools`)} position="bottom"> + <Tooltip content={i18n._(t`Toggle tools`)} position="bottom"> <ActionButton + aria-label={i18n._(t`Toggle tools`)} id="visualizer-toggle-tools" isActive={totalNodes > 0 && showTools} isDisabled={totalNodes === 0} @@ -101,33 +103,43 @@ function VisualizerToolbar({ <WrenchIcon /> </ActionButton> </Tooltip> - <ActionButton - aria-label={i18n._(t`Workflow Documentation`)} - id="visualizer-documentation" - variant="plain" - component="a" - target="_blank" - href={DOCLINK} + <Tooltip + content={i18n._(t`Workflow documentation`)} + position="bottom" > - <BookIcon /> - </ActionButton> + <ActionButton + aria-label={i18n._(t`Workflow documentation`)} + id="visualizer-documentation" + variant="plain" + component="a" + target="_blank" + href={DOCLINK} + > + <BookIcon /> + </ActionButton> + </Tooltip> {template.summary_fields?.user_capabilities?.start && ( - <LaunchButton resource={template} aria-label={i18n._(t`Launch`)}> - {({ handleLaunch }) => ( - <ActionButton - id="visualizer-launch" - variant="plain" - isDisabled={hasUnsavedChanges || totalNodes === 0} - onClick={handleLaunch} - > - <RocketIcon /> - </ActionButton> - )} - </LaunchButton> + <Tooltip content={i18n._(t`Launch workflow`)} position="bottom"> + <LaunchButton + resource={template} + aria-label={i18n._(t`Launch workflow`)} + > + {({ handleLaunch }) => ( + <ActionButton + id="visualizer-launch" + variant="plain" + isDisabled={hasUnsavedChanges || totalNodes === 0} + onClick={handleLaunch} + > + <RocketIcon /> + </ActionButton> + )} + </LaunchButton> + </Tooltip> )} {!readOnly && ( <> - <Tooltip content={i18n._(t`Delete All Nodes`)} position="bottom"> + <Tooltip content={i18n._(t`Delete all nodes`)} position="bottom"> <ActionButton id="visualizer-delete-all" aria-label={i18n._(t`Delete all nodes`)} 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]; + } +} diff --git a/awx/ui_next/src/components/LaunchPrompt/getSurveyValues.js b/awx/ui_next/src/util/prompt/getSurveyValues.js similarity index 100% rename from awx/ui_next/src/components/LaunchPrompt/getSurveyValues.js rename to awx/ui_next/src/util/prompt/getSurveyValues.js diff --git a/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.js b/awx/ui_next/src/util/prompt/mergeExtraVars.js similarity index 100% rename from awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.js rename to awx/ui_next/src/util/prompt/mergeExtraVars.js diff --git a/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.test.js b/awx/ui_next/src/util/prompt/mergeExtraVars.test.js similarity index 100% rename from awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.test.js rename to awx/ui_next/src/util/prompt/mergeExtraVars.test.js