From 83b6a91623e546c354e4e914c32051987a0e7c19 Mon Sep 17 00:00:00 2001 From: "Keith J. Grant" Date: Fri, 7 May 2021 14:48:32 -0700 Subject: [PATCH] validate variables field in launch prompt --- .../components/CodeEditor/VariablesField.jsx | 28 +++++- .../components/LaunchPrompt/LaunchPrompt.jsx | 3 - .../LaunchPrompt/steps/OtherPromptsStep.jsx | 4 +- .../steps/OtherPromptsStep.test.jsx | 25 +++++ .../LaunchPrompt/steps/PreviewStep.jsx | 32 ++++--- .../LaunchPrompt/steps/useInventoryStep.jsx | 7 +- .../steps/useOtherPromptsStep.jsx | 73 ++++++++++---- .../components/LaunchPrompt/useLaunchSteps.js | 94 +++++++++---------- 8 files changed, 174 insertions(+), 92 deletions(-) diff --git a/awx/ui_next/src/components/CodeEditor/VariablesField.jsx b/awx/ui_next/src/components/CodeEditor/VariablesField.jsx index ef9a551561..0e7633e470 100644 --- a/awx/ui_next/src/components/CodeEditor/VariablesField.jsx +++ b/awx/ui_next/src/components/CodeEditor/VariablesField.jsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback } from 'react'; -import { string, bool } from 'prop-types'; +import { string, bool, func, oneOf } from 'prop-types'; import { t } from '@lingui/macro'; import { useField } from 'formik'; @@ -24,11 +24,20 @@ const StyledCheckboxField = styled(CheckboxField)` margin-left: auto; `; -function VariablesField({ id, name, label, readOnly, promptId, tooltip }) { +function VariablesField({ + id, + name, + label, + readOnly, + promptId, + tooltip, + initialMode, + onModeChange, +}) { // track focus manually, because the Code Editor library doesn't wire // into Formik completely const [shouldValidate, setShouldValidate] = useState(false); - const [mode, setMode] = useState(YAML_MODE); + const [mode, setMode] = useState(initialMode || YAML_MODE); const validate = useCallback( value => { if (!shouldValidate) { @@ -54,6 +63,7 @@ function VariablesField({ id, name, label, readOnly, promptId, tooltip }) { // mode's useState above couldn't be initialized to JSON_MODE because // the field value had to be defined below it setMode(JSON_MODE); + onModeChange(JSON_MODE); helpers.setValue(JSON.stringify(JSON.parse(field.value), null, 2)); } }, []); // eslint-disable-line react-hooks/exhaustive-deps @@ -76,6 +86,7 @@ function VariablesField({ id, name, label, readOnly, promptId, tooltip }) { if (newMode === YAML_MODE && !isJsonEdited && lastYamlValue !== null) { helpers.setValue(lastYamlValue, false); setMode(newMode); + onModeChange(newMode); return; } @@ -86,6 +97,7 @@ function VariablesField({ id, name, label, readOnly, promptId, tooltip }) { : yamlToJson(field.value); helpers.setValue(newVal, false); setMode(newMode); + onModeChange(newMode); } catch (err) { helpers.setError(err.message); } @@ -163,10 +175,14 @@ VariablesField.propTypes = { label: string.isRequired, readOnly: bool, promptId: string, + initialMode: oneOf([YAML_MODE, JSON_MODE]), + onModeChange: func, }; VariablesField.defaultProps = { readOnly: false, promptId: null, + initialMode: YAML_MODE, + onModeChange: () => {}, }; function VariablesFieldInternals({ @@ -189,7 +205,11 @@ function VariablesFieldInternals({ if (mode === YAML_MODE) { return; } - helpers.setValue(JSON.stringify(JSON.parse(field.value), null, 2)); + try { + helpers.setValue(JSON.stringify(JSON.parse(field.value), null, 2)); + } catch (e) { + helpers.setError(e.message); + } }, []); // eslint-disable-line react-hooks/exhaustive-deps return ( diff --git a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx index 614fde426a..ddb0a1e7cd 100644 --- a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx @@ -13,7 +13,6 @@ import AlertModal from '../AlertModal'; function PromptModalForm({ launchConfig, - onCancel, onSubmit, resource, @@ -33,7 +32,6 @@ function PromptModalForm({ launchConfig, surveyConfig, resource, - resourceDefaultCredentials ); @@ -124,7 +122,6 @@ function PromptModalForm({ function LaunchPrompt({ launchConfig, - onCancel, onLaunch, resource = {}, diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/OtherPromptsStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/OtherPromptsStep.jsx index e938cb541d..f521c6e92d 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/OtherPromptsStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/OtherPromptsStep.jsx @@ -20,7 +20,7 @@ const FieldHeader = styled.div` } `; -function OtherPromptsStep({ launchConfig }) { +function OtherPromptsStep({ launchConfig, variablesMode, onVarModeChange }) { return (
{ @@ -78,6 +78,8 @@ function OtherPromptsStep({ launchConfig }) { id="prompt-variables" name="extra_vars" label={t`Variables`} + initialMode={variablesMode} + onModeChange={onVarModeChange} /> )}
diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/OtherPromptsStep.test.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/OtherPromptsStep.test.jsx index 852cf97927..ca962d87c1 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/OtherPromptsStep.test.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/OtherPromptsStep.test.jsx @@ -107,4 +107,29 @@ describe('OtherPromptsStep', () => { true ); }); + + test('should pass mode and onModeChange to VariablesField', async () => { + let wrapper; + const onModeChange = jest.fn(); + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + + expect(wrapper.find('VariablesField').prop('initialMode')).toEqual( + 'javascript' + ); + expect(wrapper.find('VariablesField').prop('onModeChange')).toEqual( + onModeChange + ); + }); }); diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.jsx index e5e9e90bdd..e4bc717ec4 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.jsx @@ -34,20 +34,24 @@ function PreviewStep({ resource, launchConfig, surveyConfig, formErrors }) { }; 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); - overrides.extra_vars = yaml.safeDump( - mergeExtraVars(initialExtraVars, masked) - ); - } else { - overrides.extra_vars = yaml.safeDump( - mergeExtraVars(initialExtraVars, {}) - ); + try { + 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); + overrides.extra_vars = yaml.safeDump( + mergeExtraVars(initialExtraVars, masked) + ); + } else { + overrides.extra_vars = yaml.safeDump( + mergeExtraVars(initialExtraVars, {}) + ); + } + } catch (e) { + // } } diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx index 0e69e0877c..c19bdf1ccc 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx @@ -12,12 +12,7 @@ const InventoryAlert = styled(Alert)` const STEP_ID = 'inventory'; -export default function useInventoryStep( - launchConfig, - resource, - - visitedSteps -) { +export default function useInventoryStep(launchConfig, resource, visitedSteps) { const [, meta, helpers] = useField('inventory'); const formError = !resource || resource?.type === 'workflow_job_template' diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx index f1581812e2..4b0fc00742 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx @@ -1,44 +1,77 @@ -import React from 'react'; +import React, { useState } from 'react'; import { t } from '@lingui/macro'; -import { jsonToYaml, parseVariableField } from '../../../util/yaml'; +import { useField } from 'formik'; +import { jsonToYaml, yamlToJson } from '../../../util/yaml'; import OtherPromptsStep from './OtherPromptsStep'; import StepName from './StepName'; const STEP_ID = 'other'; +export const YAML_MODE = 'yaml'; +export const JSON_MODE = 'javascript'; 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 resource.extra_vars; } return '---'; }; +const FIELD_NAMES = [ + 'job_type', + 'limit', + 'verbosity', + 'diff_mode', + 'job_tags', + 'skip_tags', + 'extra_vars', +]; + export default function useOtherPromptsStep(launchConfig, resource) { + const [variablesField] = useField('extra_vars'); + const [variablesMode, setVariablesMode] = useState(null); + const [isTouched, setIsTouched] = useState(false); + + const handleModeChange = mode => { + setVariablesMode(mode); + }; + + const validateVariables = () => { + if (!isTouched) { + return false; + } + try { + if (variablesMode === JSON_MODE) { + JSON.parse(variablesField.value); + } else { + yamlToJson(variablesField.value); + } + } catch (error) { + return true; + } + return false; + }; + const hasError = launchConfig.ask_variables_on_launch + ? validateVariables() + : false; + return { - step: getStep(launchConfig), + step: getStep(launchConfig, hasError, variablesMode, handleModeChange), initialValues: getInitialValues(launchConfig, resource), isReady: true, contentError: null, - hasError: false, + hasError, setTouched: setFieldTouched => { - [ - 'job_type', - 'limit', - 'verbosity', - 'diff_mode', - 'job_tags', - 'skip_tags', - 'extra_vars', - ].forEach(field => setFieldTouched(field, true, false)); + setIsTouched(true); + FIELD_NAMES.forEach(fieldName => setFieldTouched(fieldName, true, false)); }, validate: () => {}, }; } -function getStep(launchConfig) { +function getStep(launchConfig, hasError, variablesMode, handleModeChange) { if (!shouldShowPrompt(launchConfig)) { return null; } @@ -46,11 +79,17 @@ function getStep(launchConfig) { id: STEP_ID, key: 5, name: ( - + {t`Other prompts`} ), - component: , + component: ( + + ), enableNext: true, }; } diff --git a/awx/ui_next/src/components/LaunchPrompt/useLaunchSteps.js b/awx/ui_next/src/components/LaunchPrompt/useLaunchSteps.js index 74b962c0e1..e8b0b82c5f 100644 --- a/awx/ui_next/src/components/LaunchPrompt/useLaunchSteps.js +++ b/awx/ui_next/src/components/LaunchPrompt/useLaunchSteps.js @@ -54,12 +54,10 @@ export default function useLaunchSteps( launchConfig, resource, resourceDefaultCredentials, - true ), useCredentialPasswordsStep( launchConfig, - showCredentialPasswordsStep(formikValues.credentials, launchConfig), visited ), @@ -77,52 +75,54 @@ export default function useLaunchSteps( const stepsAreReady = !steps.some(s => !s.isReady); useEffect(() => { - if (stepsAreReady) { - const initialValues = steps.reduce((acc, cur) => { - return { - ...acc, - ...cur.initialValues, - }; - }, {}); - - const newFormValues = { ...initialValues }; - - Object.keys(formikValues).forEach(formikValueKey => { - if ( - formikValueKey === 'credential_passwords' && - Object.prototype.hasOwnProperty.call( - newFormValues, - 'credential_passwords' - ) - ) { - const formikCredentialPasswords = formikValues.credential_passwords; - Object.keys(formikCredentialPasswords).forEach( - credentialPasswordValueKey => { - if ( - Object.prototype.hasOwnProperty.call( - newFormValues.credential_passwords, - credentialPasswordValueKey - ) - ) { - newFormValues.credential_passwords[credentialPasswordValueKey] = - formikCredentialPasswords[credentialPasswordValueKey]; - } - } - ); - } else if ( - Object.prototype.hasOwnProperty.call(newFormValues, formikValueKey) - ) { - newFormValues[formikValueKey] = formikValues[formikValueKey]; - } - }); - - resetForm({ - values: newFormValues, - touched, - }); - - setIsReady(true); + if (!stepsAreReady) { + return; } + + const initialValues = steps.reduce((acc, cur) => { + return { + ...acc, + ...cur.initialValues, + }; + }, {}); + + const newFormValues = { ...initialValues }; + + Object.keys(formikValues).forEach(formikValueKey => { + if ( + formikValueKey === 'credential_passwords' && + Object.prototype.hasOwnProperty.call( + newFormValues, + 'credential_passwords' + ) + ) { + const formikCredentialPasswords = formikValues.credential_passwords; + Object.keys(formikCredentialPasswords).forEach( + credentialPasswordValueKey => { + if ( + Object.prototype.hasOwnProperty.call( + newFormValues.credential_passwords, + credentialPasswordValueKey + ) + ) { + newFormValues.credential_passwords[credentialPasswordValueKey] = + formikCredentialPasswords[credentialPasswordValueKey]; + } + } + ); + } else if ( + Object.prototype.hasOwnProperty.call(newFormValues, formikValueKey) + ) { + newFormValues[formikValueKey] = formikValues[formikValueKey]; + } + }); + + resetForm({ + values: newFormValues, + touched, + }); + + setIsReady(true); // eslint-disable-next-line react-hooks/exhaustive-deps }, [formikValues.credentials, stepsAreReady]);