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..246c54fb91 100644 --- a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx @@ -3,84 +3,29 @@ import { Wizard } from '@patternfly/react-core'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Formik } from 'formik'; -import InventoryStep from './InventoryStep'; -import CredentialsStep from './CredentialsStep'; -import OtherPromptsStep from './OtherPromptsStep'; -import SurveyStep from './SurveyStep'; -import PreviewStep from './PreviewStep'; +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 steps = []; - const initialValues = {}; - if (config.ask_inventory_on_launch) { - initialValues.inventory = resource?.summary_fields?.inventory || null; - steps.push({ - name: i18n._(t`Inventory`), - component: , - }); - } - if (config.ask_credential_on_launch) { - initialValues.credentials = resource?.summary_fields?.credentials || []; - steps.push({ - name: i18n._(t`Credentials`), - component: , - }); - } + const { + steps, + initialValues, + isReady, + validate, + visitStep, + visitAllSteps, + contentError, + } = useSteps(config, resource, i18n); - // TODO: Add Credential Passwords step - - if (config.ask_job_type_on_launch) { - initialValues.job_type = resource.job_type || ''; + if (contentError) { + return ; } - if (config.ask_limit_on_launch) { - initialValues.limit = resource.limit || ''; + if (!isReady) { + return ; } - 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 ( - 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 - ) { - steps.push({ - name: i18n._(t`Other Prompts`), - component: , - }); - } - if (config.survey_enabled) { - initialValues.survey = {}; - steps.push({ - name: i18n._(t`Survey`), - component: , - }); - } - steps.push({ - name: i18n._(t`Preview`), - component: , - nextButtonText: i18n._(t`Launch`), - }); const submit = values => { const postValues = {}; @@ -89,23 +34,40 @@ 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); }; return ( - - {({ handleSubmit }) => ( + + {({ validateForm, setTouched, handleSubmit }) => ( { + if (nextStep.id === 'preview') { + visitAllSteps(setTouched); + } else { + visitStep(prevStep.prevId); + } + await validateForm(); + }} + onGoToStep={async (newStep, prevStep) => { + if (newStep.id === 'preview') { + visitAllSteps(setTouched); + } else { + visitStep(prevStep.prevId); + } + await validateForm(); + }} title={i18n._(t`Prompts`)} steps={steps} /> 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/LaunchPrompt/PreviewStep.jsx b/awx/ui_next/src/components/LaunchPrompt/PreviewStep.jsx deleted file mode 100644 index b681a402bf..0000000000 --- a/awx/ui_next/src/components/LaunchPrompt/PreviewStep.jsx +++ /dev/null @@ -1,7 +0,0 @@ -import React from 'react'; - -function PreviewStep() { - return
Preview of selected values will appear here
; -} - -export default PreviewStep; 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/mergeExtraVars.js b/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.js index f324f23f6b..261c02a875 100644 --- a/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.js +++ b/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.js @@ -8,4 +8,12 @@ export default function mergeExtraVars(extraVars, survey = {}) { }; } -// TODO: "safe" version that obscures passwords for preview step +export function maskPasswords(vars, passwordKeys) { + const updated = { ...vars }; + passwordKeys.forEach(key => { + if (typeof updated[key] !== 'undefined') { + 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..bd696ab9e5 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,32 @@ 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: '········', + }); + }); + + 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/components/LaunchPrompt/CredentialsStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/CredentialsStep.jsx similarity index 96% rename from awx/ui_next/src/components/LaunchPrompt/CredentialsStep.jsx rename to awx/ui_next/src/components/LaunchPrompt/steps/CredentialsStep.jsx index a389db0cff..5288e5f8cc 100644 --- a/awx/ui_next/src/components/LaunchPrompt/CredentialsStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/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/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 93% rename from awx/ui_next/src/components/LaunchPrompt/InventoryStep.jsx rename to awx/ui_next/src/components/LaunchPrompt/steps/InventoryStep.jsx index e34dc40664..d892e0c91b 100644 --- a/awx/ui_next/src/components/LaunchPrompt/InventoryStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/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/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/steps/PreviewStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.jsx new file mode 100644 index 0000000000..e8df1ea8ff --- /dev/null +++ b/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.jsx @@ -0,0 +1,36 @@ +import React from 'react'; +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(surveyValues, passwordFields); + return ( + <> + + {formErrors && ( +
    + {Object.keys(formErrors).map( + field => `${field}: ${formErrors[field]}` + )} +
+ )} + + ); +} + +export default PreviewStep; 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/SurveyStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/SurveyStep.jsx similarity index 59% rename from awx/ui_next/src/components/LaunchPrompt/SurveyStep.jsx rename to awx/ui_next/src/components/LaunchPrompt/steps/SurveyStep.jsx index c7b7d9da64..ba33b8ec11 100644 --- a/awx/ui_next/src/components/LaunchPrompt/SurveyStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/SurveyStep.jsx @@ -1,7 +1,6 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useState } from 'react'; import { withI18n } from '@lingui/react'; -import { Formik, useField } from 'formik'; -import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '@api'; +import { useField } from 'formik'; import { Form, FormGroup, @@ -11,9 +10,6 @@ import { } from '@patternfly/react-core'; 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, @@ -22,54 +18,9 @@ import { integer, combine, } from '@util/validators'; +import { Survey } from '@types'; -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) { - return ; - } - - const initialValues = {}; - survey.spec.forEach(question => { - if (question.type === 'multiselect') { - initialValues[question.variable] = question.default.split('\n'); - } else { - initialValues[question.variable] = question.default; - } - }); - - return ( - - ); -} - -// 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 */ - }, []); - +function SurveyStep({ survey, i18n }) { const fieldTypes = { text: TextField, textarea: TextField, @@ -80,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 = [ @@ -105,7 +54,7 @@ function TextField({ question, i18n }) { return ( { + return {}; + }; + + return { + step: getStep(config, i18n), + initialValues: getInitialValues(config, resource), + validate, + isReady: true, + error: null, + setTouched: setFieldsTouched => { + setFieldsTouched({ + credentials: true, + }); + }, + }; +} + +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..e0d0ea009b --- /dev/null +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx @@ -0,0 +1,54 @@ +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, 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, hasErrors, i18n), + initialValues: getInitialValues(config, resource), + validate, + isReady: true, + error: null, + setTouched: setFieldsTouched => { + setFieldsTouched({ + inventory: true, + }); + }, + }; +} + +function getStep(config, hasErrors, 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..516238ca7a --- /dev/null +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx @@ -0,0 +1,93 @@ +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, 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, hasErrors, i18n), + initialValues: getInitialValues(config, resource), + 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, + }); + }, + }; +} + +function getStep(config, hasErrors, 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..a7ae2c61d1 --- /dev/null +++ b/awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx @@ -0,0 +1,35 @@ +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: {}, + 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 new file mode 100644 index 0000000000..8b4014b251 --- /dev/null +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx @@ -0,0 +1,119 @@ +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, visitedSteps, i18n) { + const [stepErrors, setStepErrors] = useState({}); + + 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]); + + const validate = values => { + if (!config.survey_enabled || !survey || !survey.spec) { + return {}; + } + const errors = {}; + survey.spec.forEach(question => { + const errMessage = validateField( + question, + values[`survey_${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, hasErrors, i18n), + initialValues: getInitialValues(config, survey), + validate, + survey, + isReady: !isLoading && !!survey, + error, + setTouched: setFieldsTouched => { + if (!survey) { + return; + } + const fields = {}; + survey.spec.forEach(question => { + fields[`survey_${question.variable}`] = true; + }); + setFieldsTouched(fields); + }, + }; +} + +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`)}, + 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; +} diff --git a/awx/ui_next/src/components/LaunchPrompt/useSteps.js b/awx/ui_next/src/components/LaunchPrompt/useSteps.js new file mode 100644 index 0000000000..ed61a01804 --- /dev/null +++ b/awx/ui_next/src/components/LaunchPrompt/useSteps.js @@ -0,0 +1,68 @@ +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'; + +export default function useSteps(config, resource, i18n) { + const [visited, setVisited] = useState({}); + 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 + ) + ); + + const pfSteps = steps.map(s => s.step).filter(s => s != null); + const initialValues = steps.reduce((acc, cur) => { + return { + ...acc, + ...cur.initialValues, + }; + }, {}); + 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; + }; + + return { + steps: pfSteps, + initialValues, + isReady, + validate, + visitStep: stepId => setVisited({ ...visited, [stepId]: true }), + visitAllSteps: setFieldsTouched => { + setVisited({ + inventory: true, + credentials: true, + other: true, + survey: true, + preview: true, + }); + steps.forEach(s => s.setTouched(setFieldsTouched)); + }, + contentError, + }; +} diff --git a/awx/ui_next/src/components/PromptDetail/PromptDetail.jsx b/awx/ui_next/src/components/PromptDetail/PromptDetail.jsx index 53e0dc092f..9e32e60ba2 100644 --- a/awx/ui_next/src/components/PromptDetail/PromptDetail.jsx +++ b/awx/ui_next/src/components/PromptDetail/PromptDetail.jsx @@ -77,81 +77,7 @@ function omitOverrides(resource, overrides) { return clonedResource; } -// TODO: When prompting is hooked up, update function -// to filter based on prompt overrides -function partitionPromptDetails(resource, 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 = {} }) { +function PromptDetail({ i18n, resource, launchConfig = {}, overrides = {} }) { const VERBOSITY = { 0: i18n._(t`0 (Normal)`), 1: i18n._(t`1 (Verbose)`), @@ -160,7 +86,7 @@ function PromptDetail({ i18n, resource, launchConfig = {} }) { 4: i18n._(t`4 (Connection Debug)`), }; - const [details, overrides] = partitionPromptDetails(resource, launchConfig); + const details = omitOverrides(resource, overrides); const hasOverrides = Object.keys(overrides).length > 0; return ( diff --git a/awx/ui_next/src/components/PromptDetail/PromptDetail.test.jsx b/awx/ui_next/src/components/PromptDetail/PromptDetail.test.jsx index 17ecfd4e30..ece4fe211d 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,74 @@ 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); + }); + }); }); 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), +});