From 91d4948564b45d564ed05b03e29a46be2724ccfc Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Fri, 24 Apr 2020 10:05:51 -0700 Subject: [PATCH 01/12] use PromptDetail in launch prompt preview --- .../components/ErrorDetail/getErrorMessage.js | 2 +- .../components/LaunchPrompt/LaunchPrompt.jsx | 35 +++++++++++++++++-- .../components/LaunchPrompt/PreviewStep.jsx | 17 +++++++-- .../components/LaunchPrompt/SurveyStep.jsx | 20 ++--------- .../components/LaunchPrompt/mergeExtraVars.js | 5 +++ .../components/PromptDetail/PromptDetail.jsx | 17 +++++++-- 6 files changed, 69 insertions(+), 27 deletions(-) diff --git a/awx/ui_next/src/components/ErrorDetail/getErrorMessage.js b/awx/ui_next/src/components/ErrorDetail/getErrorMessage.js index ca6fbb23be..1c2450a64d 100644 --- a/awx/ui_next/src/components/ErrorDetail/getErrorMessage.js +++ b/awx/ui_next/src/components/ErrorDetail/getErrorMessage.js @@ -1,5 +1,5 @@ export default function getErrorMessage(response) { - if (!response.data) { + if (!response?.data) { return null; } if (typeof response.data === 'string') { diff --git a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx index 521be1001b..2db38bd918 100644 --- a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx @@ -1,8 +1,11 @@ -import React from 'react'; +import React, { 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 InventoryStep from './InventoryStep'; import CredentialsStep from './CredentialsStep'; import OtherPromptsStep from './OtherPromptsStep'; @@ -11,6 +14,30 @@ import PreviewStep from './PreviewStep'; import mergeExtraVars from './mergeExtraVars'; function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) { + 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 ; + } + const steps = []; const initialValues = {}; if (config.ask_inventory_on_launch) { @@ -73,12 +100,14 @@ function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) { initialValues.survey = {}; steps.push({ name: i18n._(t`Survey`), - component: , + component: , }); } steps.push({ name: i18n._(t`Preview`), - component: , + component: ( + + ), nextButtonText: i18n._(t`Launch`), }); diff --git a/awx/ui_next/src/components/LaunchPrompt/PreviewStep.jsx b/awx/ui_next/src/components/LaunchPrompt/PreviewStep.jsx index b681a402bf..711a52ee02 100644 --- a/awx/ui_next/src/components/LaunchPrompt/PreviewStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/PreviewStep.jsx @@ -1,7 +1,20 @@ import React from 'react'; +import { useFormikContext } from 'formik'; +import PromptDetail from '@components/PromptDetail'; +import { encodeExtraVars } from './mergeExtraVars'; -function PreviewStep() { - return
Preview of selected values will appear here
; +function PreviewStep({ resource, config, survey }) { + const { values } = useFormikContext(); + return ( + + ); } export default PreviewStep; diff --git a/awx/ui_next/src/components/LaunchPrompt/SurveyStep.jsx b/awx/ui_next/src/components/LaunchPrompt/SurveyStep.jsx index c7b7d9da64..5cd158ad59 100644 --- a/awx/ui_next/src/components/LaunchPrompt/SurveyStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/SurveyStep.jsx @@ -23,24 +23,8 @@ import { combine, } from '@util/validators'; -function SurveyStep({ template, i18n }) { - const { result: survey, request: fetchSurvey, isLoading, error } = useRequest( - useCallback(async () => { - const { data } = - template.type === 'workflow_job_template' - ? await WorkflowJobTemplatesAPI.readSurvey(template.id) - : await JobTemplatesAPI.readSurvey(template.id); - return data; - }, [template]) - ); - useEffect(() => { - fetchSurvey(); - }, [fetchSurvey]); - - if (error) { - return ; - } - if (isLoading || !survey) { +function SurveyStep({ survey, i18n }) { + if (!survey) { return ; } diff --git a/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.js b/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.js index f324f23f6b..681dc8a059 100644 --- a/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.js +++ b/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.js @@ -9,3 +9,8 @@ export default function mergeExtraVars(extraVars, survey = {}) { } // TODO: "safe" version that obscures passwords for preview step + +export function encodeExtraVars(extraVars, survey = {}) { + const vars = mergeExtraVars(extraVars, survey); + return yaml.safeDump(vars); +} diff --git a/awx/ui_next/src/components/PromptDetail/PromptDetail.jsx b/awx/ui_next/src/components/PromptDetail/PromptDetail.jsx index 05ff230175..b9d0011f3f 100644 --- a/awx/ui_next/src/components/PromptDetail/PromptDetail.jsx +++ b/awx/ui_next/src/components/PromptDetail/PromptDetail.jsx @@ -78,7 +78,7 @@ function omitOverrides(resource, overrides) { // TODO: When prompting is hooked up, update function // to filter based on prompt overrides -function partitionPromptDetails(resource, launchConfig) { +function partitionPromptDetails(resource, userResponses, launchConfig) { const { defaults = {} } = launchConfig; const overrides = {}; @@ -150,7 +150,12 @@ function partitionPromptDetails(resource, launchConfig) { return [withoutOverrides, overrides]; } -function PromptDetail({ i18n, resource, launchConfig = {} }) { +function PromptDetail({ + i18n, + resource, + launchConfig = {}, + promptResponses = {}, +}) { const VERBOSITY = { 0: i18n._(t`0 (Normal)`), 1: i18n._(t`1 (Verbose)`), @@ -159,7 +164,13 @@ function PromptDetail({ i18n, resource, launchConfig = {} }) { 4: i18n._(t`4 (Connection Debug)`), }; - const [details, overrides] = partitionPromptDetails(resource, launchConfig); + // const [details, overrides] = partitionPromptDetails( + // resource, + // promptResponses, + // launchConfig + // ); + const overrides = promptResponses; + const details = omitOverrides(resource, overrides); const hasOverrides = Object.keys(overrides).length > 0; return ( From 9b3b20c96be5ee1aad21708820b1d1b87388c241 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Fri, 24 Apr 2020 15:21:04 -0700 Subject: [PATCH 02/12] mask passwords in launch preview step --- .../components/LaunchPrompt/PreviewStep.jsx | 11 ++- .../components/LaunchPrompt/SurveyStep.jsx | 5 +- .../components/LaunchPrompt/mergeExtraVars.js | 13 +-- .../LaunchPrompt/mergeExtraVars.test.js | 18 +++- .../components/PromptDetail/PromptDetail.jsx | 87 +------------------ 5 files changed, 35 insertions(+), 99 deletions(-) diff --git a/awx/ui_next/src/components/LaunchPrompt/PreviewStep.jsx b/awx/ui_next/src/components/LaunchPrompt/PreviewStep.jsx index 711a52ee02..981c05e076 100644 --- a/awx/ui_next/src/components/LaunchPrompt/PreviewStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/PreviewStep.jsx @@ -1,17 +1,22 @@ import React from 'react'; import { useFormikContext } from 'formik'; +import yaml from 'js-yaml'; import PromptDetail from '@components/PromptDetail'; -import { encodeExtraVars } from './mergeExtraVars'; +import mergeExtraVars, { maskPasswords } from './mergeExtraVars'; function PreviewStep({ resource, config, survey }) { const { values } = useFormikContext(); + const passwordFields = survey.spec + .filter(q => q.type === 'password') + .map(q => q.variable); + const masked = maskPasswords(values.survey, passwordFields); return ( ); diff --git a/awx/ui_next/src/components/LaunchPrompt/SurveyStep.jsx b/awx/ui_next/src/components/LaunchPrompt/SurveyStep.jsx index 5cd158ad59..451836ba8d 100644 --- a/awx/ui_next/src/components/LaunchPrompt/SurveyStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/SurveyStep.jsx @@ -1,7 +1,6 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { withI18n } from '@lingui/react'; import { Formik, useField } from 'formik'; -import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '@api'; import { Form, FormGroup, @@ -12,8 +11,6 @@ import { import FormField, { FieldTooltip } from '@components/FormField'; import AnsibleSelect from '@components/AnsibleSelect'; import ContentLoading from '@components/ContentLoading'; -import ContentError from '@components/ContentError'; -import useRequest from '@util/useRequest'; import { required, minMaxValue, diff --git a/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.js b/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.js index 681dc8a059..b5e8fe1442 100644 --- a/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.js +++ b/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.js @@ -8,9 +8,12 @@ export default function mergeExtraVars(extraVars, survey = {}) { }; } -// TODO: "safe" version that obscures passwords for preview step - -export function encodeExtraVars(extraVars, survey = {}) { - const vars = mergeExtraVars(extraVars, survey); - return yaml.safeDump(vars); +export function maskPasswords(vars, passwordKeys) { + const updated = { ...vars }; + passwordKeys.forEach(key => { + if (updated[key]) { + updated[key] = '········'; + } + }); + return updated; } diff --git a/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.test.js b/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.test.js index 55f37088bb..ef1420fb06 100644 --- a/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.test.js +++ b/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.test.js @@ -1,4 +1,4 @@ -import mergeExtraVars from './mergeExtraVars'; +import mergeExtraVars, { maskPasswords } from './mergeExtraVars'; describe('mergeExtraVars', () => { test('should handle yaml string', () => { @@ -31,4 +31,20 @@ describe('mergeExtraVars', () => { bar: 'baz', }); }); + + describe('maskPasswords', () => { + test('should mask password fields', () => { + const vars = { + one: 'alpha', + two: 'bravo', + three: 'charlie', + }; + + expect(maskPasswords(vars, ['one', 'three'])).toEqual({ + one: '········', + two: 'bravo', + three: '········', + }); + }); + }); }); diff --git a/awx/ui_next/src/components/PromptDetail/PromptDetail.jsx b/awx/ui_next/src/components/PromptDetail/PromptDetail.jsx index b9d0011f3f..d874ffecbd 100644 --- a/awx/ui_next/src/components/PromptDetail/PromptDetail.jsx +++ b/awx/ui_next/src/components/PromptDetail/PromptDetail.jsx @@ -76,86 +76,7 @@ function omitOverrides(resource, overrides) { return clonedResource; } -// TODO: When prompting is hooked up, update function -// to filter based on prompt overrides -function partitionPromptDetails(resource, userResponses, launchConfig) { - const { defaults = {} } = launchConfig; - const overrides = {}; - - if (launchConfig.ask_credential_on_launch) { - let isEqual; - const defaultCreds = defaults.credentials; - const currentCreds = resource?.summary_fields?.credentials; - - if (defaultCreds?.length === currentCreds?.length) { - isEqual = currentCreds.every(cred => { - return defaultCreds.some(item => item.id === cred.id); - }); - } else { - isEqual = false; - } - - if (!isEqual) { - overrides.credentials = resource?.summary_fields?.credentials; - } - } - if (launchConfig.ask_diff_mode_on_launch) { - if (defaults.diff_mode !== resource.diff_mode) { - overrides.diff_mode = resource.diff_mode; - } - } - if (launchConfig.ask_inventory_on_launch) { - if (defaults.inventory.id !== resource.inventory) { - overrides.inventory = resource?.summary_fields?.inventory; - } - } - if (launchConfig.ask_job_type_on_launch) { - if (defaults.job_type !== resource.job_type) { - overrides.job_type = resource.job_type; - } - } - if (launchConfig.ask_limit_on_launch) { - if (defaults.limit !== resource.limit) { - overrides.limit = resource.limit; - } - } - if (launchConfig.ask_scm_branch_on_launch) { - if (defaults.scm_branch !== resource.scm_branch) { - overrides.scm_branch = resource.scm_branch; - } - } - if (launchConfig.ask_skip_tags_on_launch) { - if (defaults.skip_tags !== resource.skip_tags) { - overrides.skip_tags = resource.skip_tags; - } - } - if (launchConfig.ask_tags_on_launch) { - if (defaults.job_tags !== resource.job_tags) { - overrides.job_tags = resource.job_tags; - } - } - if (launchConfig.ask_variables_on_launch) { - if (defaults.extra_vars !== resource.extra_vars) { - overrides.extra_vars = resource.extra_vars; - } - } - if (launchConfig.ask_verbosity_on_launch) { - if (defaults.verbosity !== resource.verbosity) { - overrides.verbosity = resource.verbosity; - } - } - - const withoutOverrides = omitOverrides(resource, overrides); - - return [withoutOverrides, overrides]; -} - -function PromptDetail({ - i18n, - resource, - launchConfig = {}, - promptResponses = {}, -}) { +function PromptDetail({ i18n, resource, launchConfig = {}, overrides = {} }) { const VERBOSITY = { 0: i18n._(t`0 (Normal)`), 1: i18n._(t`1 (Verbose)`), @@ -164,12 +85,6 @@ function PromptDetail({ 4: i18n._(t`4 (Connection Debug)`), }; - // const [details, overrides] = partitionPromptDetails( - // resource, - // promptResponses, - // launchConfig - // ); - const overrides = promptResponses; const details = omitOverrides(resource, overrides); const hasOverrides = Object.keys(overrides).length > 0; From 5c2eebf692c26bfa7a9e28862a6d3415cfe8241e Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Thu, 30 Apr 2020 11:38:12 -0700 Subject: [PATCH 03/12] working on prompts validation --- .../LaunchPrompt/CredentialsStep.jsx | 8 +- .../components/LaunchPrompt/InventoryStep.jsx | 6 +- .../components/LaunchPrompt/LaunchPrompt.jsx | 104 +++++++++++++++--- .../LaunchPrompt/OtherPromptsStep.jsx | 4 + .../components/LaunchPrompt/PreviewStep.jsx | 27 +++-- .../components/LaunchPrompt/PromptFooter.jsx | 67 +++++++++++ .../components/LaunchPrompt/SurveyStep.jsx | 9 +- .../components/LaunchPrompt/mergeExtraVars.js | 2 +- .../LaunchPrompt/mergeExtraVars.test.js | 12 ++ awx/ui_next/src/types.js | 18 +++ 10 files changed, 225 insertions(+), 32 deletions(-) create mode 100644 awx/ui_next/src/components/LaunchPrompt/PromptFooter.jsx 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), +}); From 11752e123dab9d314448edf96c0310931852deb9 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Fri, 1 May 2020 15:30:48 -0700 Subject: [PATCH 04/12] converting prompt steps to hook-based approach --- .../LaunchPrompt/LaunchPrompt.ORIGINAL.jsx | 223 ++++++++++++++++++ .../components/LaunchPrompt/LaunchPrompt.jsx | 188 ++------------- .../src/components/LaunchPrompt/hooks.js | 70 ++++++ .../{ => steps}/CredentialsStep.jsx | 0 .../{ => steps}/CredentialsStep.test.jsx | 0 .../{ => steps}/InventoryStep.jsx | 0 .../{ => steps}/InventoryStep.test.jsx | 0 .../{ => steps}/OtherPromptsStep.jsx | 0 .../{ => steps}/OtherPromptsStep.test.jsx | 0 .../LaunchPrompt/{ => steps}/PreviewStep.jsx | 2 +- .../LaunchPrompt/{ => steps}/SurveyStep.jsx | 0 .../LaunchPrompt/steps/useCredentialsStep.jsx | 34 +++ .../LaunchPrompt/steps/useInventoryStep.jsx | 34 +++ .../steps/useOtherPromptsStep.jsx | 67 ++++++ .../LaunchPrompt/steps/usePreviewStep.jsx | 33 +++ .../LaunchPrompt/steps/useSurveyStep.jsx | 60 +++++ 16 files changed, 548 insertions(+), 163 deletions(-) create mode 100644 awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.ORIGINAL.jsx create mode 100644 awx/ui_next/src/components/LaunchPrompt/hooks.js rename awx/ui_next/src/components/LaunchPrompt/{ => steps}/CredentialsStep.jsx (100%) rename awx/ui_next/src/components/LaunchPrompt/{ => steps}/CredentialsStep.test.jsx (100%) rename awx/ui_next/src/components/LaunchPrompt/{ => steps}/InventoryStep.jsx (100%) rename awx/ui_next/src/components/LaunchPrompt/{ => steps}/InventoryStep.test.jsx (100%) rename awx/ui_next/src/components/LaunchPrompt/{ => steps}/OtherPromptsStep.jsx (100%) rename awx/ui_next/src/components/LaunchPrompt/{ => steps}/OtherPromptsStep.test.jsx (100%) rename awx/ui_next/src/components/LaunchPrompt/{ => steps}/PreviewStep.jsx (92%) rename awx/ui_next/src/components/LaunchPrompt/{ => steps}/SurveyStep.jsx (100%) create mode 100644 awx/ui_next/src/components/LaunchPrompt/steps/useCredentialsStep.jsx create mode 100644 awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx create mode 100644 awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx create mode 100644 awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx create mode 100644 awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx diff --git a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.ORIGINAL.jsx b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.ORIGINAL.jsx new file mode 100644 index 0000000000..8666c838ea --- /dev/null +++ b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.ORIGINAL.jsx @@ -0,0 +1,223 @@ +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 8666c838ea..bc77a30555 100644 --- a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx @@ -3,174 +3,42 @@ 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 { 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 { 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; -} +import { useSteps, useVisitedSteps } from './hooks'; function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) { - const [formErrors, setFormErrors] = useState({}); - const [visitedSteps, setVisitedSteps] = useState( - getInitialVisitedSteps(config) + const { steps, initialValues, isReady, contentError } = useSteps( + config, + resource, + i18n ); + const [visitedSteps, visitStep] = useVisitedSteps(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 (contentError) { + return ; } - if (config.survey_enabled && !survey) { + if (!isReady) { 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`), - }); - + // 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) => { @@ -187,7 +55,6 @@ function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) { setValue('extra_vars', mergeExtraVars(values.extra_vars, values.survey)); onLaunch(postValues); }; - console.log('formErrors:', formErrors); return ( @@ -197,22 +64,19 @@ function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) { onClose={onCancel} onSave={handleSubmit} onNext={async (nextStep, prevStep) => { - // console.log(`${prevStep.prevName} -> ${nextStep.name}`); - // console.log('errors', errors); - // console.log('values', values); + console.log(prevStep); + visitStep(prevStep.id); const newErrors = await validateForm(); - setFormErrors(newErrors); - // console.log('new errors:', newErrors); + // updatePromptErrors(prevStep.prevName, newErrors); }} onGoToStep={async (newStep, prevStep) => { - // console.log('errors', errors); - // console.log('values', values); + console.log(prevStep); + visitStep(prevStep.id); const newErrors = await validateForm(); - setFormErrors(newErrors); + // updatePromptErrors(prevStep.prevName, newErrors); }} title={i18n._(t`Prompts`)} steps={steps} - // footer={} /> )} diff --git a/awx/ui_next/src/components/LaunchPrompt/hooks.js b/awx/ui_next/src/components/LaunchPrompt/hooks.js new file mode 100644 index 0000000000..5248c588bc --- /dev/null +++ b/awx/ui_next/src/components/LaunchPrompt/hooks.js @@ -0,0 +1,70 @@ +import { useState } from 'react'; +import useInventoryStep from './steps/useInventoryStep'; +import useCredentialsStep from './steps/useCredentialsStep'; +import useOtherPromptsStep from './steps/useOtherPromptsStep'; +import useSurveyStep from './steps/useSurveyStep'; +import usePreviewStep from './steps/usePreviewStep'; + +// const INVENTORY = 'inventory'; +// const CREDENTIALS = 'credentials'; +// const PASSWORDS = 'passwords'; +// const OTHER_PROMPTS = 'other'; +// const SURVEY = 'survey'; +// const PREVIEW = 'preview'; + +export function useSteps(config, resource, i18n) { + // TODO pass in form errors? + const formErrors = {}; + const inventory = useInventoryStep(config, resource, i18n); + const credentials = useCredentialsStep(config, resource, i18n); + const otherPrompts = useOtherPromptsStep(config, resource, i18n); + const survey = useSurveyStep(config, resource, i18n); + const preview = usePreviewStep( + config, + resource, + survey.survey, + formErrors, + i18n + ); + + // TODO useState for steps to track dynamic steps (credentialPasswords)? + const steps = [ + inventory.step, + credentials.step, + otherPrompts.step, + survey.step, + preview.step, + ].filter(step => step !== null); + const initialValues = { + ...inventory.initialValues, + ...credentials.initialValues, + ...otherPrompts.initialValues, + ...survey.initialValues, + }; + const isReady = + inventory.isReady && + credentials.isReady && + otherPrompts.isReady && + survey.isReady && + preview.isReady; + const contentError = + inventory.error || + credentials.error || + otherPrompts.error || + survey.error || + preview.error; + + return { steps, initialValues, isReady, 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 [[], () => {}]; +} diff --git a/awx/ui_next/src/components/LaunchPrompt/CredentialsStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/CredentialsStep.jsx similarity index 100% rename from awx/ui_next/src/components/LaunchPrompt/CredentialsStep.jsx rename to awx/ui_next/src/components/LaunchPrompt/steps/CredentialsStep.jsx diff --git a/awx/ui_next/src/components/LaunchPrompt/CredentialsStep.test.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/CredentialsStep.test.jsx similarity index 100% rename from awx/ui_next/src/components/LaunchPrompt/CredentialsStep.test.jsx rename to awx/ui_next/src/components/LaunchPrompt/steps/CredentialsStep.test.jsx diff --git a/awx/ui_next/src/components/LaunchPrompt/InventoryStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/InventoryStep.jsx similarity index 100% rename from awx/ui_next/src/components/LaunchPrompt/InventoryStep.jsx rename to awx/ui_next/src/components/LaunchPrompt/steps/InventoryStep.jsx diff --git a/awx/ui_next/src/components/LaunchPrompt/InventoryStep.test.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/InventoryStep.test.jsx similarity index 100% rename from awx/ui_next/src/components/LaunchPrompt/InventoryStep.test.jsx rename to awx/ui_next/src/components/LaunchPrompt/steps/InventoryStep.test.jsx diff --git a/awx/ui_next/src/components/LaunchPrompt/OtherPromptsStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/OtherPromptsStep.jsx similarity index 100% rename from awx/ui_next/src/components/LaunchPrompt/OtherPromptsStep.jsx rename to awx/ui_next/src/components/LaunchPrompt/steps/OtherPromptsStep.jsx diff --git a/awx/ui_next/src/components/LaunchPrompt/OtherPromptsStep.test.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/OtherPromptsStep.test.jsx similarity index 100% rename from awx/ui_next/src/components/LaunchPrompt/OtherPromptsStep.test.jsx rename to awx/ui_next/src/components/LaunchPrompt/steps/OtherPromptsStep.test.jsx diff --git a/awx/ui_next/src/components/LaunchPrompt/PreviewStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.jsx similarity index 92% rename from awx/ui_next/src/components/LaunchPrompt/PreviewStep.jsx rename to awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.jsx index 26d0572457..95e73509fb 100644 --- a/awx/ui_next/src/components/LaunchPrompt/PreviewStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.jsx @@ -2,7 +2,7 @@ import React from 'react'; import { useFormikContext } from 'formik'; import yaml from 'js-yaml'; import PromptDetail from '@components/PromptDetail'; -import mergeExtraVars, { maskPasswords } from './mergeExtraVars'; +import mergeExtraVars, { maskPasswords } from '../mergeExtraVars'; function PreviewStep({ resource, config, survey, formErrors }) { const { values } = useFormikContext(); diff --git a/awx/ui_next/src/components/LaunchPrompt/SurveyStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/SurveyStep.jsx similarity index 100% rename from awx/ui_next/src/components/LaunchPrompt/SurveyStep.jsx rename to awx/ui_next/src/components/LaunchPrompt/steps/SurveyStep.jsx diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialsStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialsStep.jsx new file mode 100644 index 0000000000..9fb9d88ecd --- /dev/null +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialsStep.jsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { t } from '@lingui/macro'; +import CredentialsStep from './CredentialsStep'; + +const STEP_ID = 'credentials'; + +export default function useCredentialsStep(config, resource, i18n) { + return { + step: getStep(config, i18n), + initialValues: getInitialValues(config, resource), + isReady: true, + error: null, + }; +} + +function getStep(config, i18n) { + if (!config.ask_credential_on_launch) { + return null; + } + return { + id: STEP_ID, + name: i18n._(t`Credentials`), + component: , + }; +} + +function getInitialValues(config, resource) { + if (!config.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 new file mode 100644 index 0000000000..7aaed7d8e3 --- /dev/null +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { t } from '@lingui/macro'; +import InventoryStep from './InventoryStep'; + +const STEP_ID = 'inventory'; + +export default function useInventoryStep(config, resource, i18n) { + return { + step: getStep(config, i18n), + initialValues: getInitialValues(config, resource), + isReady: true, + error: null, + }; +} + +function getStep(config, i18n) { + if (!config.ask_inventory_on_launch) { + return null; + } + return { + id: STEP_ID, + name: i18n._(t`Inventory`), + component: , + }; +} + +function getInitialValues(config, resource) { + if (!config.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 new file mode 100644 index 0000000000..7b5f5d2cce --- /dev/null +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { t } from '@lingui/macro'; +import OtherPromptsStep from './OtherPromptsStep'; + +const STEP_ID = 'other'; + +export default function useOtherPrompt(config, resource, i18n) { + return { + step: getStep(config, i18n), + initialValues: getInitialValues(config, resource), + isReady: true, + error: null, + }; +} + +function getStep(config, i18n) { + if (!shouldShowPrompt(config)) { + return null; + } + return { + id: STEP_ID, + name: i18n._(t`Other Prompts`), + component: , + }; +} + +function shouldShowPrompt(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 getInitialValues(config, resource) { + const initialValues = {}; + 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; + } + return initialValues; +} diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx new file mode 100644 index 0000000000..ec34d8e7d5 --- /dev/null +++ b/awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { t } from '@lingui/macro'; +import PreviewStep from './PreviewStep'; + +const STEP_ID = 'preview'; + +export default function usePreviewStep( + config, + resource, + survey, + formErrors, + i18n +) { + return { + step: { + id: STEP_ID, + name: i18n._(t`Preview`), + component: ( + + ), + enableNext: Object.keys(formErrors).length === 0, + nextButtonText: i18n._(t`Launch`), + }, + initialValues: {}, + isReady: true, + error: null, + }; +} diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx new file mode 100644 index 0000000000..5383e629ba --- /dev/null +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx @@ -0,0 +1,60 @@ +import React, { useEffect, useCallback } from 'react'; +import { t } from '@lingui/macro'; +import useRequest from '@util/useRequest'; +import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '@api'; +import SurveyStep from './SurveyStep'; + +const STEP_ID = 'survey'; + +export default function useSurveyStep(config, resource, i18n) { + const { result: survey, request: fetchSurvey, isLoading, error } = 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]); + + return { + step: getStep(config, survey, i18n), + initialValues: getInitialValues(config, survey), + survey, + isReady: !isLoading && !!survey, + error, + }; +} + +function getStep(config, survey, i18n) { + if (!config.survey_enabled) { + return null; + } + return { + id: STEP_ID, + name: i18n._(t`Survey`), + component: , + }; +} + +function getInitialValues(config, survey) { + if (!config.survey_enabled || !survey) { + return {}; + } + const values = {}; + survey.spec.forEach(question => { + if (question.type === 'multiselect') { + values[`survey_${question.variable}`] = question.default.split('\n'); + } else { + values[`survey_${question.variable}`] = question.default; + } + }); + return values; +} From 1ac92b0493409db78a0552f5480ade0319f791ab Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Mon, 4 May 2020 15:57:32 -0700 Subject: [PATCH 05/12] add rough jt launch prompt validation --- .../components/LaunchPrompt/LaunchPrompt.jsx | 22 +++++++++------- .../src/components/LaunchPrompt/hooks.js | 26 ++++++++++++------- .../LaunchPrompt/steps/useCredentialsStep.jsx | 9 +++++++ .../LaunchPrompt/steps/useInventoryStep.jsx | 9 +++++++ .../steps/useOtherPromptsStep.jsx | 9 +++++++ .../LaunchPrompt/steps/useSurveyStep.jsx | 20 ++++++++++++++ 6 files changed, 76 insertions(+), 19 deletions(-) diff --git a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx index bc77a30555..1134d20ff0 100644 --- a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx @@ -18,11 +18,15 @@ import mergeExtraVars from './mergeExtraVars'; import { useSteps, useVisitedSteps } from './hooks'; function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) { - const { steps, initialValues, isReady, contentError } = useSteps( - config, - resource, - i18n - ); + // const [formErrors, setFormErrors] = useState({}); + const { + steps, + initialValues, + isReady, + validate, + formErrors, + contentError, + } = useSteps(config, resource, i18n); const [visitedSteps, visitStep] = useVisitedSteps(config); if (contentError) { @@ -33,10 +37,10 @@ function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) { } // TODO move into hook? - const validate = values => { - // return {}; - return { limit: ['required field'] }; - }; + // const validate = values => { + // // return {}; + // return { limit: ['required field'] }; + // }; // TODO move into hook? const submit = values => { diff --git a/awx/ui_next/src/components/LaunchPrompt/hooks.js b/awx/ui_next/src/components/LaunchPrompt/hooks.js index 5248c588bc..872133f782 100644 --- a/awx/ui_next/src/components/LaunchPrompt/hooks.js +++ b/awx/ui_next/src/components/LaunchPrompt/hooks.js @@ -5,16 +5,8 @@ import useOtherPromptsStep from './steps/useOtherPromptsStep'; import useSurveyStep from './steps/useSurveyStep'; import usePreviewStep from './steps/usePreviewStep'; -// const INVENTORY = 'inventory'; -// const CREDENTIALS = 'credentials'; -// const PASSWORDS = 'passwords'; -// const OTHER_PROMPTS = 'other'; -// const SURVEY = 'survey'; -// const PREVIEW = 'preview'; - export function useSteps(config, resource, i18n) { - // TODO pass in form errors? - const formErrors = {}; + const [formErrors, setFormErrors] = useState({}); const inventory = useInventoryStep(config, resource, i18n); const credentials = useCredentialsStep(config, resource, i18n); const otherPrompts = useOtherPromptsStep(config, resource, i18n); @@ -54,7 +46,21 @@ export function useSteps(config, resource, i18n) { survey.error || preview.error; - return { steps, initialValues, isReady, contentError }; + const validate = values => { + const errors = { + ...inventory.validate(values), + ...credentials.validate(values), + ...otherPrompts.validate(values), + ...survey.validate(values), + }; + setFormErrors(errors); + if (Object.keys(errors).length) { + return errors; + } + return false; + }; + + return { steps, initialValues, isReady, validate, formErrors, contentError }; } export function usePromptErrors(config) { diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialsStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialsStep.jsx index 9fb9d88ecd..a8d5d0053b 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialsStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialsStep.jsx @@ -5,9 +5,18 @@ 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; + }; + return { step: getStep(config, i18n), initialValues: getInitialValues(config, resource), + validate, isReady: true, error: null, }; diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx index 7aaed7d8e3..91988f83d0 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx @@ -5,9 +5,18 @@ import InventoryStep from './InventoryStep'; const STEP_ID = 'inventory'; export default function useInventoryStep(config, resource, i18n) { + const validate = values => { + const errors = {}; + if (!values.inventory) { + errors.inventory = i18n._(t`An inventory must be selected`); + } + return errors; + }; + return { step: getStep(config, i18n), initialValues: getInitialValues(config, resource), + validate, isReady: true, error: null, }; diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx index 7b5f5d2cce..1977070e28 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx @@ -5,9 +5,18 @@ import OtherPromptsStep from './OtherPromptsStep'; const STEP_ID = 'other'; export default function useOtherPrompt(config, resource, i18n) { + 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`); + } + return errors; + }; + return { step: getStep(config, i18n), initialValues: getInitialValues(config, resource), + validate, isReady: true, error: null, }; diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx index 5383e629ba..6e52748ee9 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx @@ -27,6 +27,7 @@ export default function useSurveyStep(config, resource, i18n) { return { step: getStep(config, survey, i18n), initialValues: getInitialValues(config, survey), + validate: getValidate(config, survey, i18n), survey, isReady: !isLoading && !!survey, error, @@ -58,3 +59,22 @@ 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; + }; +} From da8f486c5d3ae440a42cef13c52550c34383996e Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Wed, 6 May 2020 14:50:44 -0700 Subject: [PATCH 06/12] flush out prompt validation errors --- .../LaunchPrompt/LaunchPrompt.ORIGINAL.jsx | 223 ------------------ .../components/LaunchPrompt/LaunchPrompt.jsx | 48 ++-- .../LaunchPrompt/steps/OtherPromptsStep.jsx | 3 - .../LaunchPrompt/steps/StepName.jsx | 37 +++ .../LaunchPrompt/steps/useCredentialsStep.jsx | 15 +- .../LaunchPrompt/steps/useInventoryStep.jsx | 16 +- .../steps/useOtherPromptsStep.jsx | 16 +- .../LaunchPrompt/steps/useSurveyStep.jsx | 79 +++++-- .../LaunchPrompt/{hooks.js => useSteps.js} | 50 ++-- 9 files changed, 168 insertions(+), 319 deletions(-) delete mode 100644 awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.ORIGINAL.jsx create mode 100644 awx/ui_next/src/components/LaunchPrompt/steps/StepName.jsx rename awx/ui_next/src/components/LaunchPrompt/{hooks.js => useSteps.js} (57%) 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, + }; } From 59e3306a3c5bb9dc809d5581b33898bbad1dbf51 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Wed, 6 May 2020 16:38:08 -0700 Subject: [PATCH 07/12] merge survey fields into rest of jt promt form --- .../components/LaunchPrompt/LaunchPrompt.jsx | 1 - .../LaunchPrompt/steps/OtherPromptsStep.jsx | 1 - .../LaunchPrompt/steps/SurveyStep.jsx | 65 +++++-------------- .../LaunchPrompt/steps/useSurveyStep.jsx | 2 +- 4 files changed, 18 insertions(+), 51 deletions(-) diff --git a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx index 6727262cb8..8e89cf87b9 100644 --- a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx @@ -16,7 +16,6 @@ function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) { validate, visitStep, visitAllSteps, - // formErrors, contentError, } = useSteps(config, resource, i18n); diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/OtherPromptsStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/OtherPromptsStep.jsx index 1196e5998e..0989368652 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/OtherPromptsStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/OtherPromptsStep.jsx @@ -32,7 +32,6 @@ 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.`)} - isRequired /> )} {config.ask_verbosity_on_launch && } diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/SurveyStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/SurveyStep.jsx index d9120d95d1..ba33b8ec11 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/SurveyStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/SurveyStep.jsx @@ -1,6 +1,6 @@ -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; import { withI18n } from '@lingui/react'; -import { Formik, useField } from 'formik'; +import { useField } from 'formik'; import { Form, FormGroup, @@ -21,35 +21,6 @@ import { import { Survey } from '@types'; function SurveyStep({ survey, i18n }) { - const initialValues = {}; - survey.spec.forEach(question => { - if (question.type === 'multiselect') { - initialValues[question.variable] = question.default.split('\n'); - } else { - initialValues[question.variable] = question.default; - } - }); - - return ( - - ); -} -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 -// values for all questions are added to the outer form's `survey` field -// as a single object. -function SurveySubForm({ survey, initialValues, i18n }) { - const [, , surveyFieldHelpers] = useField('survey'); - useEffect(() => { - // set survey initial values to parent form - surveyFieldHelpers.setValue(initialValues); - /* eslint-disable-next-line react-hooks/exhaustive-deps */ - }, []); - const fieldTypes = { text: TextField, textarea: TextField, @@ -60,21 +31,19 @@ function SurveySubForm({ survey, initialValues, i18n }) { float: NumberField, }; return ( - - {({ values }) => ( -
surveyFieldHelpers.setValue(values)}> - {' '} - {survey.spec.map(question => { - const Field = fieldTypes[question.type]; - return ( - - ); - })} - - )} -
+
+ {survey.spec.map(question => { + const Field = fieldTypes[question.type]; + return ( + + ); + })} + ); } +SurveyStep.propTypes = { + survey: Survey.isRequired, +}; function TextField({ question, i18n }) { const validators = [ @@ -85,7 +54,7 @@ function TextField({ question, i18n }) { return ( { const errMessage = validateField( question, - values[question.variable], + values[`survey_${question.variable}`], i18n ); if (errMessage) { From 0b207e02abd5d1bbabc2c96cc867ef2c0889b724 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Thu, 7 May 2020 16:36:34 -0700 Subject: [PATCH 08/12] set fields touched when steps marked as visited --- .../components/LaunchPrompt/LaunchPrompt.jsx | 6 +- .../LaunchPrompt/steps/useCredentialsStep.jsx | 5 ++ .../LaunchPrompt/steps/useInventoryStep.jsx | 5 ++ .../steps/useOtherPromptsStep.jsx | 11 +++ .../LaunchPrompt/steps/usePreviewStep.jsx | 2 + .../LaunchPrompt/steps/useSurveyStep.jsx | 10 +++ .../src/components/LaunchPrompt/useSteps.js | 84 ++++++++----------- 7 files changed, 70 insertions(+), 53 deletions(-) diff --git a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx index 8e89cf87b9..d568dc1810 100644 --- a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx @@ -45,14 +45,14 @@ function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) { return ( - {({ validateForm, handleSubmit }) => ( + {({ validateForm, setTouched, handleSubmit }) => ( { if (nextStep.id === 'preview') { - visitAllSteps(); + visitAllSteps(setTouched); } else { visitStep(prevStep.prevId); } @@ -60,7 +60,7 @@ function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) { }} onGoToStep={async (newStep, prevStep) => { if (newStep.id === 'preview') { - visitAllSteps(); + visitAllSteps(setTouched); } else { visitStep(prevStep.prevId); } diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialsStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialsStep.jsx index e17a9861a1..f63d85599b 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialsStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialsStep.jsx @@ -20,6 +20,11 @@ export default function useCredentialsStep( validate, isReady: true, error: null, + setTouched: setFieldsTouched => { + setFieldsTouched({ + credentials: true, + }); + }, }; } diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx index 3098f2b0f6..e0d0ea009b 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx @@ -25,6 +25,11 @@ export default function useInventoryStep(config, resource, visitedSteps, i18n) { validate, isReady: true, error: null, + setTouched: setFieldsTouched => { + setFieldsTouched({ + inventory: true, + }); + }, }; } diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx index b92f1e2eb9..516238ca7a 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx @@ -25,6 +25,17 @@ export default function useOtherPrompt(config, resource, visitedSteps, i18n) { validate, isReady: true, error: null, + setTouched: setFieldsTouched => { + setFieldsTouched({ + job_type: true, + limit: true, + verbosity: true, + diff_mode: true, + job_tags: true, + skip_tags: true, + extra_vars: true, + }); + }, }; } diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx index ec34d8e7d5..a7ae2c61d1 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx @@ -27,7 +27,9 @@ export default function usePreviewStep( nextButtonText: i18n._(t`Launch`), }, 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 dd824616f8..8b4014b251 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx @@ -55,6 +55,16 @@ export default function useSurveyStep(config, resource, visitedSteps, i18n) { survey, isReady: !isLoading && !!survey, error, + setTouched: setFieldsTouched => { + if (!survey) { + return; + } + const fields = {}; + survey.spec.forEach(question => { + fields[`survey_${question.variable}`] = true; + }); + setFieldsTouched(fields); + }, }; } diff --git a/awx/ui_next/src/components/LaunchPrompt/useSteps.js b/awx/ui_next/src/components/LaunchPrompt/useSteps.js index 871ce8c5a1..ed61a01804 100644 --- a/awx/ui_next/src/components/LaunchPrompt/useSteps.js +++ b/awx/ui_next/src/components/LaunchPrompt/useSteps.js @@ -7,70 +7,53 @@ import usePreviewStep from './steps/usePreviewStep'; 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, - {}, // TODO: formErrors ? - i18n + const steps = [ + useInventoryStep(config, resource, visited, i18n), + useCredentialsStep(config, resource, visited, i18n), + useOtherPromptsStep(config, resource, visited, i18n), + useSurveyStep(config, resource, visited, i18n), + ]; + steps.push( + usePreviewStep( + config, + resource, + steps[3].survey, + {}, // TODO: formErrors ? + i18n + ) ); - // TODO useState for steps to track dynamic steps (credentialPasswords)? - const steps = [ - inventory.step, - credentials.step, - otherPrompts.step, - survey.step, - preview.step, - ].filter(step => step !== null); - const initialValues = { - ...inventory.initialValues, - ...credentials.initialValues, - ...otherPrompts.initialValues, - ...survey.initialValues, - }; - const isReady = - inventory.isReady && - credentials.isReady && - otherPrompts.isReady && - survey.isReady && - preview.isReady; - const contentError = - inventory.error || - credentials.error || - otherPrompts.error || - 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), - ...credentials.validate(values), - ...otherPrompts.validate(values), - ...survey.validate(values), + const pfSteps = steps.map(s => s.step).filter(s => s != null); + const initialValues = steps.reduce((acc, cur) => { + return { + ...acc, + ...cur.initialValues, }; - // setFormErrors(errors); + }, {}); + const isReady = !steps.some(s => !s.isReady); + const stepWithError = steps.find(s => s.error); + const contentError = stepWithError ? stepWithError.error : null; + + const validate = values => { + const errors = steps.reduce((acc, cur) => { + return { + ...acc, + ...cur.validate(values), + }; + }, {}); if (Object.keys(errors).length) { return errors; } return false; }; - // TODO move visited flags into each step hook return { - steps, + steps: pfSteps, initialValues, isReady, validate, visitStep: stepId => setVisited({ ...visited, [stepId]: true }), - visitAllSteps: () => { + visitAllSteps: setFieldsTouched => { setVisited({ inventory: true, credentials: true, @@ -78,6 +61,7 @@ export default function useSteps(config, resource, i18n) { survey: true, preview: true, }); + steps.forEach(s => s.setTouched(setFieldsTouched)); }, contentError, }; From dfecd4ad9d087eb68225bc061a764b1a80fd68d0 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Mon, 11 May 2020 11:16:29 -0700 Subject: [PATCH 09/12] fix tests --- .../LaunchPrompt/LaunchPrompt.test.jsx | 47 +++++++++---- .../PromptDetail/PromptDetail.test.jsx | 69 ++++++++++++++++++- 2 files changed, 100 insertions(+), 16 deletions(-) diff --git a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.test.jsx b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.test.jsx index 78e8dc5504..650e2cc640 100644 --- a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.test.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.test.jsx @@ -1,16 +1,22 @@ import React from 'react'; import { act, isElementOfType } from 'react-dom/test-utils'; -import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; import LaunchPrompt from './LaunchPrompt'; -import InventoryStep from './InventoryStep'; -import CredentialsStep from './CredentialsStep'; -import OtherPromptsStep from './OtherPromptsStep'; -import PreviewStep from './PreviewStep'; -import { InventoriesAPI, CredentialsAPI, CredentialTypesAPI } from '@api'; +import InventoryStep from './steps/InventoryStep'; +import CredentialsStep from './steps/CredentialsStep'; +import OtherPromptsStep from './steps/OtherPromptsStep'; +import PreviewStep from './steps/PreviewStep'; +import { + InventoriesAPI, + CredentialsAPI, + CredentialTypesAPI, + JobTemplatesAPI, +} from '@api'; jest.mock('@api/models/Inventories'); jest.mock('@api/models/CredentialTypes'); jest.mock('@api/models/Credentials'); +jest.mock('@api/models/JobTemplates'); let config; const resource = { @@ -31,6 +37,13 @@ describe('LaunchPrompt', () => { data: { results: [{ id: 1 }], count: 1 }, }); CredentialTypesAPI.loadAllTypes({ data: { results: [{ type: 'ssh' }] } }); + JobTemplatesAPI.readSurvey.mockResolvedValue({ + data: { + name: '', + description: '', + spec: [{ type: 'text', variable: 'foo' }], + }, + }); config = { can_start_without_user_input: false, @@ -73,13 +86,14 @@ describe('LaunchPrompt', () => { /> ); }); - const steps = wrapper.find('Wizard').prop('steps'); + const wizard = await waitForElement(wrapper, 'Wizard'); + const steps = wizard.prop('steps'); expect(steps).toHaveLength(5); - expect(steps[0].name).toEqual('Inventory'); + expect(steps[0].name.props.children).toEqual('Inventory'); expect(steps[1].name).toEqual('Credentials'); - expect(steps[2].name).toEqual('Other Prompts'); - expect(steps[3].name).toEqual('Survey'); + expect(steps[2].name.props.children).toEqual('Other Prompts'); + expect(steps[3].name.props.children).toEqual('Survey'); expect(steps[4].name).toEqual('Preview'); }); @@ -98,10 +112,11 @@ describe('LaunchPrompt', () => { /> ); }); - const steps = wrapper.find('Wizard').prop('steps'); + const wizard = await waitForElement(wrapper, 'Wizard'); + const steps = wizard.prop('steps'); expect(steps).toHaveLength(2); - expect(steps[0].name).toEqual('Inventory'); + expect(steps[0].name.props.children).toEqual('Inventory'); expect(isElementOfType(steps[0].component, InventoryStep)).toEqual(true); expect(isElementOfType(steps[1].component, PreviewStep)).toEqual(true); }); @@ -121,7 +136,8 @@ describe('LaunchPrompt', () => { /> ); }); - const steps = wrapper.find('Wizard').prop('steps'); + const wizard = await waitForElement(wrapper, 'Wizard'); + const steps = wizard.prop('steps'); expect(steps).toHaveLength(2); expect(steps[0].name).toEqual('Credentials'); @@ -144,10 +160,11 @@ describe('LaunchPrompt', () => { /> ); }); - const steps = wrapper.find('Wizard').prop('steps'); + const wizard = await waitForElement(wrapper, 'Wizard'); + 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/PromptDetail/PromptDetail.test.jsx b/awx/ui_next/src/components/PromptDetail/PromptDetail.test.jsx index 17ecfd4e30..0ee3a43a1c 100644 --- a/awx/ui_next/src/components/PromptDetail/PromptDetail.test.jsx +++ b/awx/ui_next/src/components/PromptDetail/PromptDetail.test.jsx @@ -67,7 +67,7 @@ describe('PromptDetail', () => { expect(wrapper.find(`Detail[label="${label}"] dd`).text()).toBe(value); } - expect(wrapper.find('PromptDetail h2').text()).toBe('Prompted Values'); + expect(wrapper.find('PromptDetail h2')).toHaveLength(0); assertDetail('Name', 'Mock JT'); assertDetail('Description', 'Mock JT Description'); assertDetail('Type', 'Job Template'); @@ -143,4 +143,71 @@ describe('PromptDetail', () => { expect(overrideDetails.find('VariablesDetail').length).toBe(0); }); }); + + describe('with overrides', () => { + let wrapper; + const overrides = { + extra_vars: '---one: two\nbar: baz', + inventory: { + name: 'Override inventory', + }, + }; + + beforeAll(() => { + wrapper = mountWithContexts( + + ); + }); + + afterAll(() => { + wrapper.unmount(); + }); + + test('should render overridden details', () => { + function assertDetail(label, value) { + expect(wrapper.find(`Detail[label="${label}"] dt`).text()).toBe(label); + expect(wrapper.find(`Detail[label="${label}"] dd`).text()).toBe(value); + } + + expect(wrapper.find('PromptDetail h2').text()).toBe('Prompted Values'); + assertDetail('Name', 'Mock JT'); + assertDetail('Description', 'Mock JT Description'); + assertDetail('Type', 'Job Template'); + assertDetail('Job Type', 'Run'); + assertDetail('Inventory', 'Override inventory'); + assertDetail('Source Control Branch', 'Foo branch'); + assertDetail('Limit', 'alpha:beta'); + assertDetail('Verbosity', '3 (Debug)'); + assertDetail('Show Changes', 'Off'); + expect(wrapper.find('VariablesDetail').prop('value')).toEqual( + '---one: two\nbar: baz' + ); + expect( + wrapper + .find('Detail[label="Credentials"]') + .containsAllMatchingElements([ + + SSH:Credential 1 + , + + Awx:Credential 2 + , + ]) + ).toEqual(true); + expect( + wrapper + .find('Detail[label="Job Tags"]') + .containsAnyMatchingElements([T_100, T_200]) + ).toEqual(true); + expect( + wrapper + .find('Detail[label="Skip Tags"]') + .containsAllMatchingElements([S_100, S_200]) + ).toEqual(true); + }); + }); }); From e3a3a472299329ce59ab6820f72ca9e19e3e433c Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Mon, 11 May 2020 15:51:35 -0700 Subject: [PATCH 10/12] delete unused file --- .../components/LaunchPrompt/PromptFooter.jsx | 67 ------------------- 1 file changed, 67 deletions(-) delete mode 100644 awx/ui_next/src/components/LaunchPrompt/PromptFooter.jsx diff --git a/awx/ui_next/src/components/LaunchPrompt/PromptFooter.jsx b/awx/ui_next/src/components/LaunchPrompt/PromptFooter.jsx deleted file mode 100644 index 1c56c0b66a..0000000000 --- a/awx/ui_next/src/components/LaunchPrompt/PromptFooter.jsx +++ /dev/null @@ -1,67 +0,0 @@ -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); From 70a9a72c2538b2c25ecfcd613ac9f76522ccc2ef Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Tue, 12 May 2020 10:14:46 -0700 Subject: [PATCH 11/12] fix promptdetail test --- .../src/components/PromptDetail/PromptDetail.test.jsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/awx/ui_next/src/components/PromptDetail/PromptDetail.test.jsx b/awx/ui_next/src/components/PromptDetail/PromptDetail.test.jsx index 0ee3a43a1c..ece4fe211d 100644 --- a/awx/ui_next/src/components/PromptDetail/PromptDetail.test.jsx +++ b/awx/ui_next/src/components/PromptDetail/PromptDetail.test.jsx @@ -157,7 +157,10 @@ describe('PromptDetail', () => { wrapper = mountWithContexts( ); From 90f6d4ed0532cf64085d1638b206a5bb20deec5d Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Tue, 12 May 2020 10:23:25 -0700 Subject: [PATCH 12/12] fix merging of survey values into extra_vars --- awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx | 4 +++- .../src/components/LaunchPrompt/getSurveyValues.js | 9 +++++++++ .../src/components/LaunchPrompt/steps/PreviewStep.jsx | 4 +++- 3 files changed, 15 insertions(+), 2 deletions(-) create mode 100644 awx/ui_next/src/components/LaunchPrompt/getSurveyValues.js diff --git a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx index d568dc1810..246c54fb91 100644 --- a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx @@ -7,6 +7,7 @@ import ContentError from '@components/ContentError'; import ContentLoading from '@components/ContentLoading'; import mergeExtraVars from './mergeExtraVars'; import useSteps from './useSteps'; +import getSurveyValues from './getSurveyValues'; function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) { const { @@ -33,13 +34,14 @@ function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) { postValues[key] = value; } }; + const surveyValues = getSurveyValues(values); 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)); + setValue('extra_vars', mergeExtraVars(values.extra_vars, surveyValues)); onLaunch(postValues); }; diff --git a/awx/ui_next/src/components/LaunchPrompt/getSurveyValues.js b/awx/ui_next/src/components/LaunchPrompt/getSurveyValues.js new file mode 100644 index 0000000000..0559eefc1f --- /dev/null +++ b/awx/ui_next/src/components/LaunchPrompt/getSurveyValues.js @@ -0,0 +1,9 @@ +export default function getSurveyValues(values) { + const surveyValues = {}; + Object.keys(values).forEach(key => { + if (key.startsWith('survey_')) { + surveyValues[key.substr(7)] = values[key]; + } + }); + return surveyValues; +} diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.jsx index 95e73509fb..e8df1ea8ff 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.jsx @@ -3,13 +3,15 @@ import { useFormikContext } from 'formik'; import yaml from 'js-yaml'; import PromptDetail from '@components/PromptDetail'; import mergeExtraVars, { maskPasswords } from '../mergeExtraVars'; +import getSurveyValues from '../getSurveyValues'; function PreviewStep({ resource, config, survey, formErrors }) { const { values } = useFormikContext(); + const surveyValues = getSurveyValues(values); const passwordFields = survey.spec .filter(q => q.type === 'password') .map(q => q.variable); - const masked = maskPasswords(values.survey, passwordFields); + const masked = maskPasswords(surveyValues, passwordFields); return ( <>