diff --git a/awx/ui_next/src/components/LaunchPrompt/CredentialsStep.jsx b/awx/ui_next/src/components/LaunchPrompt/CredentialsStep.jsx index a389db0cff..5288e5f8cc 100644 --- a/awx/ui_next/src/components/LaunchPrompt/CredentialsStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/CredentialsStep.jsx @@ -12,15 +12,19 @@ import CredentialChip from '@components/CredentialChip'; import ContentError from '@components/ContentError'; import { getQSConfig, parseQueryString } from '@util/qs'; import useRequest from '@util/useRequest'; +import { required } from '@util/validators'; -const QS_CONFIG = getQSConfig('inventory', { +const QS_CONFIG = getQSConfig('credential', { page: 1, page_size: 5, order_by: 'name', }); function CredentialsStep({ i18n }) { - const [field, , helpers] = useField('credentials'); + const [field, , helpers] = useField({ + name: 'credentials', + validate: required(null, i18n), + }); const [selectedType, setSelectedType] = useState(null); const history = useHistory(); diff --git a/awx/ui_next/src/components/LaunchPrompt/InventoryStep.jsx b/awx/ui_next/src/components/LaunchPrompt/InventoryStep.jsx index e34dc40664..d892e0c91b 100644 --- a/awx/ui_next/src/components/LaunchPrompt/InventoryStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/InventoryStep.jsx @@ -9,6 +9,7 @@ import useRequest from '@util/useRequest'; import OptionsList from '@components/OptionsList'; import ContentLoading from '@components/ContentLoading'; import ContentError from '@components/ContentError'; +import { required } from '@util/validators'; const QS_CONFIG = getQSConfig('inventory', { page: 1, @@ -17,7 +18,10 @@ const QS_CONFIG = getQSConfig('inventory', { }); function InventoryStep({ i18n }) { - const [field, , helpers] = useField('inventory'); + const [field, , helpers] = useField({ + name: 'inventory', + validate: required(null, i18n), + }); const history = useHistory(); const { diff --git a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx index 2db38bd918..8666c838ea 100644 --- a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect } from 'react'; +import React, { useState, useCallback, useEffect } from 'react'; import { Wizard } from '@patternfly/react-core'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; @@ -6,14 +6,61 @@ import { Formik } from 'formik'; import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '@api'; import useRequest from '@util/useRequest'; import ContentError from '@components/ContentError'; +import ContentLoading from '@components/ContentLoading'; +import { required } from '@util/validators'; import InventoryStep from './InventoryStep'; import CredentialsStep from './CredentialsStep'; import OtherPromptsStep from './OtherPromptsStep'; import SurveyStep from './SurveyStep'; import PreviewStep from './PreviewStep'; +import PromptFooter from './PromptFooter'; import mergeExtraVars from './mergeExtraVars'; +const STEPS = { + INVENTORY: 'inventory', + CREDENTIALS: 'credentials', + PASSWORDS: 'passwords', + OTHER_PROMPTS: 'other', + SURVEY: 'survey', + PREVIEW: 'preview', +}; + +function showOtherPrompts(config) { + 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 + ); +} + +function getInitialVisitedSteps(config) { + const visited = {}; + if (config.ask_inventory_on_launch) { + visited[STEPS.INVENTORY] = false; + } + if (config.ask_credential_on_launch) { + visited[STEPS.CREDENTIALS] = false; + } + if (showOtherPrompts(config)) { + visited[STEPS.OTHER_PROMPTS] = false; + } + if (config.survey_enabled) { + visited[STEPS.SURVEY] = false; + } + return visited; +} + function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) { + const [formErrors, setFormErrors] = useState({}); + const [visitedSteps, setVisitedSteps] = useState( + getInitialVisitedSteps(config) + ); + const { result: survey, request: fetchSurvey, @@ -37,12 +84,16 @@ function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) { if (surveyError) { return ; } + if (config.survey_enabled && !survey) { + return ; + } const steps = []; const initialValues = {}; if (config.ask_inventory_on_launch) { initialValues.inventory = resource?.summary_fields?.inventory || null; steps.push({ + id: STEPS.INVENTORY, name: i18n._(t`Inventory`), component: , }); @@ -50,6 +101,7 @@ function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) { if (config.ask_credential_on_launch) { initialValues.credentials = resource?.summary_fields?.credentials || []; steps.push({ + id: STEPS.CREDENTIALS, name: i18n._(t`Credentials`), component: , }); @@ -81,36 +133,44 @@ function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) { if (config.ask_diff_mode_on_launch) { initialValues.diff_mode = resource.diff_mode || false; } - if ( - 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 - ) { + if (showOtherPrompts(config)) { steps.push({ + id: STEPS.OTHER_PROMPTS, name: i18n._(t`Other Prompts`), component: , }); } if (config.survey_enabled) { initialValues.survey = {}; + // survey.spec.forEach(question => { + // initialValues[`survey_${question.variable}`] = question.default; + // }) steps.push({ + id: STEPS.SURVEY, name: i18n._(t`Survey`), component: , }); } steps.push({ + id: STEPS.PREVIEW, name: i18n._(t`Preview`), component: ( - + ), + enableNext: Object.keys(formErrors).length === 0, nextButtonText: i18n._(t`Launch`), }); + const validate = values => { + // return {}; + return { limit: ['required field'] }; + }; + const submit = values => { const postValues = {}; const setValue = (key, value) => { @@ -127,16 +187,32 @@ function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) { setValue('extra_vars', mergeExtraVars(values.extra_vars, values.survey)); onLaunch(postValues); }; + console.log('formErrors:', formErrors); return ( - - {({ handleSubmit }) => ( + + {({ errors, values, touched, validateForm, handleSubmit }) => ( { + // console.log(`${prevStep.prevName} -> ${nextStep.name}`); + // console.log('errors', errors); + // console.log('values', values); + const newErrors = await validateForm(); + setFormErrors(newErrors); + // console.log('new errors:', newErrors); + }} + onGoToStep={async (newStep, prevStep) => { + // console.log('errors', errors); + // console.log('values', values); + const newErrors = await validateForm(); + setFormErrors(newErrors); + }} title={i18n._(t`Prompts`)} steps={steps} + // footer={} /> )} diff --git a/awx/ui_next/src/components/LaunchPrompt/OtherPromptsStep.jsx b/awx/ui_next/src/components/LaunchPrompt/OtherPromptsStep.jsx index 0989368652..7b552b8b23 100644 --- a/awx/ui_next/src/components/LaunchPrompt/OtherPromptsStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/OtherPromptsStep.jsx @@ -8,6 +8,7 @@ import { TagMultiSelect } from '@components/MultiSelect'; import AnsibleSelect from '@components/AnsibleSelect'; import { VariablesField } from '@components/CodeMirrorInput'; import styled from 'styled-components'; +import { required } from '@util/validators'; const FieldHeader = styled.div` display: flex; @@ -32,6 +33,9 @@ function OtherPromptsStep({ config, i18n }) { of hosts that will be managed or affected by the playbook. Multiple patterns are allowed. Refer to Ansible documentation for more information and examples on patterns.`)} + // TODO: remove this validator (for testing only) + isRequired + validate={required(null, i18n)} /> )} {config.ask_verbosity_on_launch && } diff --git a/awx/ui_next/src/components/LaunchPrompt/PreviewStep.jsx b/awx/ui_next/src/components/LaunchPrompt/PreviewStep.jsx index 981c05e076..26d0572457 100644 --- a/awx/ui_next/src/components/LaunchPrompt/PreviewStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/PreviewStep.jsx @@ -4,21 +4,30 @@ import yaml from 'js-yaml'; import PromptDetail from '@components/PromptDetail'; import mergeExtraVars, { maskPasswords } from './mergeExtraVars'; -function PreviewStep({ resource, config, survey }) { +function PreviewStep({ resource, config, survey, formErrors }) { const { values } = useFormikContext(); const passwordFields = survey.spec .filter(q => q.type === 'password') .map(q => q.variable); const masked = maskPasswords(values.survey, passwordFields); return ( - + <> + + {formErrors && ( +
    + {Object.keys(formErrors).map( + field => `${field}: ${formErrors[field]}` + )} +
+ )} + ); } diff --git a/awx/ui_next/src/components/LaunchPrompt/PromptFooter.jsx b/awx/ui_next/src/components/LaunchPrompt/PromptFooter.jsx new file mode 100644 index 0000000000..1c56c0b66a --- /dev/null +++ b/awx/ui_next/src/components/LaunchPrompt/PromptFooter.jsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { + WizardFooter, + WizardContextConsumer, + Button, +} from '@patternfly/react-core'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; + +const STEPS = { + INVENTORY: 'inventory', + CREDENTIALS: 'credentials', + PASSWORDS: 'passwords', + OTHER_PROMPTS: 'other', + SURVEY: 'survey', + PREVIEW: 'preview', +}; + +export function PromptFooter({ firstStep, i18n }) { + return ( + + + {({ + activeStep, + goToStepByName, + goToStepById, + onNext, + onBack, + onClose, + }) => { + if (activeStep.name !== STEPS.PREVIEW) { + return ( + <> + + + + + ); + } + return ( + <> + + + + ); + }} + + + ); +} + +export { PromptFooter as _PromptFooter }; +export default withI18n()(PromptFooter); diff --git a/awx/ui_next/src/components/LaunchPrompt/SurveyStep.jsx b/awx/ui_next/src/components/LaunchPrompt/SurveyStep.jsx index 451836ba8d..d9120d95d1 100644 --- a/awx/ui_next/src/components/LaunchPrompt/SurveyStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/SurveyStep.jsx @@ -10,7 +10,6 @@ import { } from '@patternfly/react-core'; import FormField, { FieldTooltip } from '@components/FormField'; import AnsibleSelect from '@components/AnsibleSelect'; -import ContentLoading from '@components/ContentLoading'; import { required, minMaxValue, @@ -19,12 +18,9 @@ import { integer, combine, } from '@util/validators'; +import { Survey } from '@types'; function SurveyStep({ survey, i18n }) { - if (!survey) { - return ; - } - const initialValues = {}; survey.spec.forEach(question => { if (question.type === 'multiselect') { @@ -38,6 +34,9 @@ function SurveyStep({ survey, i18n }) { ); } +SurveyStep.propTypes = { + survey: Survey.isRequired, +}; // This is a nested Formik form to perform validation on individual // survey questions. When changes to the inner form occur (onBlur), the diff --git a/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.js b/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.js index b5e8fe1442..261c02a875 100644 --- a/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.js +++ b/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.js @@ -11,7 +11,7 @@ export default function mergeExtraVars(extraVars, survey = {}) { export function maskPasswords(vars, passwordKeys) { const updated = { ...vars }; passwordKeys.forEach(key => { - if (updated[key]) { + if (typeof updated[key] !== 'undefined') { updated[key] = '········'; } }); diff --git a/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.test.js b/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.test.js index ef1420fb06..bd696ab9e5 100644 --- a/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.test.js +++ b/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.test.js @@ -46,5 +46,17 @@ describe('mergeExtraVars', () => { three: '········', }); }); + + test('should mask empty strings', () => { + const vars = { + one: '', + two: 'bravo', + }; + + expect(maskPasswords(vars, ['one', 'three'])).toEqual({ + one: '········', + two: 'bravo', + }); + }); }); }); diff --git a/awx/ui_next/src/types.js b/awx/ui_next/src/types.js index e46021a5b6..52e07d53cb 100644 --- a/awx/ui_next/src/types.js +++ b/awx/ui_next/src/types.js @@ -306,3 +306,21 @@ export const Schedule = shape({ timezone: string, until: string, }); + +export const SurveyQuestion = shape({ + question_name: string, + question_description: string, + required: bool, + type: string, + variable: string, + min: number, + max: number, + default: string, + choices: string, +}); + +export const Survey = shape({ + name: string, + description: string, + spec: arrayOf(SurveyQuestion), +});