diff --git a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.ORIGINAL.jsx b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.ORIGINAL.jsx deleted file mode 100644 index 8666c838ea..0000000000 --- a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.ORIGINAL.jsx +++ /dev/null @@ -1,223 +0,0 @@ -import React, { useState, useCallback, useEffect } from 'react'; -import { Wizard } from '@patternfly/react-core'; -import { withI18n } from '@lingui/react'; -import { t } from '@lingui/macro'; -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, - error: surveyError, - } = useRequest( - useCallback(async () => { - if (!config.survey_enabled) { - return {}; - } - const { data } = - resource.type === 'workflow_job_template' - ? await WorkflowJobTemplatesAPI.readSurvey(resource.id) - : await JobTemplatesAPI.readSurvey(resource.id); - return data; - }, [config.survey_enabled, resource]) - ); - useEffect(() => { - fetchSurvey(); - }, [fetchSurvey]); - - 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: , - }); - } - if (config.ask_credential_on_launch) { - initialValues.credentials = resource?.summary_fields?.credentials || []; - steps.push({ - id: STEPS.CREDENTIALS, - name: i18n._(t`Credentials`), - component: , - }); - } - - // TODO: Add Credential Passwords step - - if (config.ask_job_type_on_launch) { - initialValues.job_type = resource.job_type || ''; - } - if (config.ask_limit_on_launch) { - initialValues.limit = resource.limit || ''; - } - if (config.ask_verbosity_on_launch) { - initialValues.verbosity = resource.verbosity || 0; - } - if (config.ask_tags_on_launch) { - initialValues.job_tags = resource.job_tags || ''; - } - if (config.ask_skip_tags_on_launch) { - initialValues.skip_tags = resource.skip_tags || ''; - } - if (config.ask_variables_on_launch) { - initialValues.extra_vars = resource.extra_vars || '---'; - } - if (config.ask_scm_branch_on_launch) { - initialValues.scm_branch = resource.scm_branch || ''; - } - if (config.ask_diff_mode_on_launch) { - initialValues.diff_mode = resource.diff_mode || false; - } - 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) => { - if (typeof value !== 'undefined' && value !== null) { - postValues[key] = value; - } - }; - setValue('inventory_id', values.inventory?.id); - setValue('credentials', values.credentials?.map(c => c.id)); - setValue('job_type', values.job_type); - setValue('limit', values.limit); - setValue('job_tags', values.job_tags); - setValue('skip_tags', values.skip_tags); - setValue('extra_vars', mergeExtraVars(values.extra_vars, values.survey)); - onLaunch(postValues); - }; - console.log('formErrors:', formErrors); - - return ( - - {({ 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={} - /> - )} - - ); -} - -export { LaunchPrompt as _LaunchPrompt }; -export default withI18n()(LaunchPrompt); diff --git a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx index 1134d20ff0..6727262cb8 100644 --- a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx @@ -1,33 +1,24 @@ -import React, { useState, useCallback, useEffect } from 'react'; +import React from 'react'; import { Wizard } from '@patternfly/react-core'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; 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'; -import { useSteps, useVisitedSteps } from './hooks'; +import useSteps from './useSteps'; function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) { - // const [formErrors, setFormErrors] = useState({}); const { steps, initialValues, isReady, validate, - formErrors, + visitStep, + visitAllSteps, + // formErrors, contentError, } = useSteps(config, resource, i18n); - const [visitedSteps, visitStep] = useVisitedSteps(config); if (contentError) { return ; @@ -36,13 +27,6 @@ function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) { return ; } - // TODO move into hook? - // const validate = values => { - // // return {}; - // return { limit: ['required field'] }; - // }; - - // TODO move into hook? const submit = values => { const postValues = {}; const setValue = (key, value) => { @@ -62,22 +46,26 @@ function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) { return ( - {({ errors, values, touched, validateForm, handleSubmit }) => ( + {({ validateForm, handleSubmit }) => ( { - console.log(prevStep); - visitStep(prevStep.id); - const newErrors = await validateForm(); - // updatePromptErrors(prevStep.prevName, newErrors); + if (nextStep.id === 'preview') { + visitAllSteps(); + } else { + visitStep(prevStep.prevId); + } + await validateForm(); }} onGoToStep={async (newStep, prevStep) => { - console.log(prevStep); - visitStep(prevStep.id); - const newErrors = await validateForm(); - // updatePromptErrors(prevStep.prevName, newErrors); + if (newStep.id === 'preview') { + visitAllSteps(); + } else { + visitStep(prevStep.prevId); + } + await validateForm(); }} title={i18n._(t`Prompts`)} steps={steps} diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/OtherPromptsStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/OtherPromptsStep.jsx index 7b552b8b23..1196e5998e 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/OtherPromptsStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/OtherPromptsStep.jsx @@ -8,7 +8,6 @@ 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; @@ -33,9 +32,7 @@ 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/steps/StepName.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/StepName.jsx new file mode 100644 index 0000000000..28bf5f0414 --- /dev/null +++ b/awx/ui_next/src/components/LaunchPrompt/steps/StepName.jsx @@ -0,0 +1,37 @@ +import React from 'react'; +import styled from 'styled-components'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Tooltip } from '@patternfly/react-core'; +import { ExclamationCircleIcon as PFExclamationCircleIcon } from '@patternfly/react-icons'; + +const AlertText = styled.div` + color: var(--pf-global--danger-color--200); + font-weight: var(--pf-global--FontWeight--bold); +`; + +const ExclamationCircleIcon = styled(PFExclamationCircleIcon)` + margin-left: 10px; +`; + +function StepName({ hasErrors, children, i18n }) { + if (!hasErrors) { + return children; + } + return ( + <> + + {children} + + + + + + ); +} + +export default withI18n()(StepName); diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialsStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialsStep.jsx index a8d5d0053b..e17a9861a1 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialsStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialsStep.jsx @@ -4,13 +4,14 @@ import CredentialsStep from './CredentialsStep'; const STEP_ID = 'credentials'; -export default function useCredentialsStep(config, resource, i18n) { - const validate = values => { - const errors = {}; - if (!values.credentials || !values.credentials.length) { - errors.credentials = i18n._(t`Credentials must be selected`); - } - return errors; +export default function useCredentialsStep( + config, + resource, + visitedSteps, + i18n +) { + const validate = () => { + return {}; }; return { diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx index 91988f83d0..3098f2b0f6 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx @@ -1,20 +1,26 @@ -import React from 'react'; +import React, { useState } from 'react'; import { t } from '@lingui/macro'; import InventoryStep from './InventoryStep'; +import StepName from './StepName'; const STEP_ID = 'inventory'; -export default function useInventoryStep(config, resource, i18n) { +export default function useInventoryStep(config, resource, visitedSteps, i18n) { + const [stepErrors, setStepErrors] = useState({}); + const validate = values => { const errors = {}; if (!values.inventory) { errors.inventory = i18n._(t`An inventory must be selected`); } + setStepErrors(errors); return errors; }; + const hasErrors = visitedSteps[STEP_ID] && Object.keys(stepErrors).length > 0; + return { - step: getStep(config, i18n), + step: getStep(config, hasErrors, i18n), initialValues: getInitialValues(config, resource), validate, isReady: true, @@ -22,13 +28,13 @@ export default function useInventoryStep(config, resource, i18n) { }; } -function getStep(config, i18n) { +function getStep(config, hasErrors, i18n) { if (!config.ask_inventory_on_launch) { return null; } return { id: STEP_ID, - name: i18n._(t`Inventory`), + name: {i18n._(t`Inventory`)}, component: , }; } diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx index 1977070e28..b92f1e2eb9 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx @@ -1,20 +1,26 @@ -import React from 'react'; +import React, { useState } from 'react'; import { t } from '@lingui/macro'; import OtherPromptsStep from './OtherPromptsStep'; +import StepName from './StepName'; const STEP_ID = 'other'; -export default function useOtherPrompt(config, resource, i18n) { +export default function useOtherPrompt(config, resource, visitedSteps, i18n) { + const [stepErrors, setStepErrors] = useState({}); + const validate = values => { const errors = {}; if (config.ask_job_type_on_launch && !values.job_type) { errors.job_type = i18n._(t`This field must not be blank`); } + setStepErrors(errors); return errors; }; + const hasErrors = visitedSteps[STEP_ID] && Object.keys(stepErrors).length > 0; + return { - step: getStep(config, i18n), + step: getStep(config, hasErrors, i18n), initialValues: getInitialValues(config, resource), validate, isReady: true, @@ -22,13 +28,13 @@ export default function useOtherPrompt(config, resource, i18n) { }; } -function getStep(config, i18n) { +function getStep(config, hasErrors, i18n) { if (!shouldShowPrompt(config)) { return null; } return { id: STEP_ID, - name: i18n._(t`Other Prompts`), + name: {i18n._(t`Other Prompts`)}, component: , }; } diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx index 6e52748ee9..9ef75b6029 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx @@ -1,12 +1,15 @@ -import React, { useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { t } from '@lingui/macro'; 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, resource, i18n) { +export default function useSurveyStep(config, resource, visitedSteps, i18n) { + const [stepErrors, setStepErrors] = useState({}); + const { result: survey, request: fetchSurvey, isLoading, error } = useRequest( useCallback(async () => { if (!config.survey_enabled) { @@ -24,23 +27,68 @@ export default function useSurveyStep(config, resource, i18n) { fetchSurvey(); }, [fetchSurvey]); + const validate = values => { + if (!config.survey_enabled || !survey || !survey.spec) { + return {}; + } + const errors = {}; + survey.spec.forEach(question => { + const errMessage = validateField( + question, + values[question.variable], + i18n + ); + if (errMessage) { + errors[`survey_${question.variable}`] = errMessage; + } + }); + setStepErrors(errors); + return errors; + }; + + const hasErrors = visitedSteps[STEP_ID] && Object.keys(stepErrors).length > 0; + return { - step: getStep(config, survey, i18n), + step: getStep(config, survey, hasErrors, i18n), initialValues: getInitialValues(config, survey), - validate: getValidate(config, survey, i18n), + validate, survey, isReady: !isLoading && !!survey, error, }; } -function getStep(config, survey, i18n) { +function validateField(question, value, i18n) { + const isTextField = ['text', 'textarea'].includes(question.type); + const isNumeric = ['integer', 'float'].includes(question.type); + if (isTextField && (value || value === 0)) { + if (question.min && value.length < question.min) { + return i18n._(t`This field must be at least ${question.min} characters`); + } + if (question.max && value.length > question.max) { + return i18n._(t`This field must not exceed ${question.max} characters`); + } + } + if (isNumeric && (value || value === 0)) { + if (value < question.min || value > question.max) { + return i18n._( + t`This field must be a number and have a value between ${question.min} and ${question.max}` + ); + } + } + if (question.required && !value && value !== 0) { + return i18n._(t`This field must not be blank`); + } + return null; +} + +function getStep(config, survey, hasErrors, i18n) { if (!config.survey_enabled) { return null; } return { id: STEP_ID, - name: i18n._(t`Survey`), + name: {i18n._(t`Survey`)}, component: , }; } @@ -59,22 +107,3 @@ function getInitialValues(config, survey) { }); return values; } - -function getValidate(config, survey, i18n) { - return values => { - if (!config.survey_enabled || !survey || !survey.spec) { - return {}; - } - const errors = {}; - survey.spec.forEach(question => { - // TODO validate min/max - // TODO allow 0 - if (question.required && !values[question.variable]) { - errors[`survey_${question.variable}`] = i18n._( - t`This field must not be blank` - ); - } - }); - return errors; - }; -} diff --git a/awx/ui_next/src/components/LaunchPrompt/hooks.js b/awx/ui_next/src/components/LaunchPrompt/useSteps.js similarity index 57% rename from awx/ui_next/src/components/LaunchPrompt/hooks.js rename to awx/ui_next/src/components/LaunchPrompt/useSteps.js index 872133f782..871ce8c5a1 100644 --- a/awx/ui_next/src/components/LaunchPrompt/hooks.js +++ b/awx/ui_next/src/components/LaunchPrompt/useSteps.js @@ -5,17 +5,17 @@ import useOtherPromptsStep from './steps/useOtherPromptsStep'; import useSurveyStep from './steps/useSurveyStep'; import usePreviewStep from './steps/usePreviewStep'; -export function useSteps(config, resource, i18n) { - const [formErrors, setFormErrors] = useState({}); - const inventory = useInventoryStep(config, resource, i18n); - const credentials = useCredentialsStep(config, resource, i18n); - const otherPrompts = useOtherPromptsStep(config, resource, i18n); - const survey = useSurveyStep(config, resource, i18n); +export default function useSteps(config, resource, i18n) { + const [visited, setVisited] = useState({}); + const inventory = useInventoryStep(config, resource, visited, i18n); + const credentials = useCredentialsStep(config, resource, visited, i18n); + const otherPrompts = useOtherPromptsStep(config, resource, visited, i18n); + const survey = useSurveyStep(config, resource, visited, i18n); const preview = usePreviewStep( config, resource, survey.survey, - formErrors, + {}, // TODO: formErrors ? i18n ); @@ -46,6 +46,9 @@ export function useSteps(config, resource, i18n) { survey.error || preview.error; + // TODO: store error state in each step's hook. + // but continue to return values here (async?) so form errors can be returned + // out and set into Formik const validate = values => { const errors = { ...inventory.validate(values), @@ -53,24 +56,29 @@ export function useSteps(config, resource, i18n) { ...otherPrompts.validate(values), ...survey.validate(values), }; - setFormErrors(errors); + // setFormErrors(errors); if (Object.keys(errors).length) { return errors; } return false; }; - return { steps, initialValues, isReady, validate, formErrors, contentError }; -} - -export function usePromptErrors(config) { - const [promptErrors, setPromptErrors] = useState({}); - const updatePromptErrors = () => {}; - return [promptErrors, updatePromptErrors]; -} - -// TODO this interrelates with usePromptErrors -// merge? or pass result from one into the other? -export function useVisitedSteps(config) { - return [[], () => {}]; + // TODO move visited flags into each step hook + return { + steps, + initialValues, + isReady, + validate, + visitStep: stepId => setVisited({ ...visited, [stepId]: true }), + visitAllSteps: () => { + setVisited({ + inventory: true, + credentials: true, + other: true, + survey: true, + preview: true, + }); + }, + contentError, + }; }