working on prompts validation

This commit is contained in:
Keith Grant
2020-04-30 11:38:12 -07:00
parent 9b3b20c96b
commit 5c2eebf692
10 changed files with 225 additions and 32 deletions

View File

@@ -12,15 +12,19 @@ import CredentialChip from '@components/CredentialChip';
import ContentError from '@components/ContentError'; import ContentError from '@components/ContentError';
import { getQSConfig, parseQueryString } from '@util/qs'; import { getQSConfig, parseQueryString } from '@util/qs';
import useRequest from '@util/useRequest'; import useRequest from '@util/useRequest';
import { required } from '@util/validators';
const QS_CONFIG = getQSConfig('inventory', { const QS_CONFIG = getQSConfig('credential', {
page: 1, page: 1,
page_size: 5, page_size: 5,
order_by: 'name', order_by: 'name',
}); });
function CredentialsStep({ i18n }) { function CredentialsStep({ i18n }) {
const [field, , helpers] = useField('credentials'); const [field, , helpers] = useField({
name: 'credentials',
validate: required(null, i18n),
});
const [selectedType, setSelectedType] = useState(null); const [selectedType, setSelectedType] = useState(null);
const history = useHistory(); const history = useHistory();

View File

@@ -9,6 +9,7 @@ import useRequest from '@util/useRequest';
import OptionsList from '@components/OptionsList'; import OptionsList from '@components/OptionsList';
import ContentLoading from '@components/ContentLoading'; import ContentLoading from '@components/ContentLoading';
import ContentError from '@components/ContentError'; import ContentError from '@components/ContentError';
import { required } from '@util/validators';
const QS_CONFIG = getQSConfig('inventory', { const QS_CONFIG = getQSConfig('inventory', {
page: 1, page: 1,
@@ -17,7 +18,10 @@ const QS_CONFIG = getQSConfig('inventory', {
}); });
function InventoryStep({ i18n }) { function InventoryStep({ i18n }) {
const [field, , helpers] = useField('inventory'); const [field, , helpers] = useField({
name: 'inventory',
validate: required(null, i18n),
});
const history = useHistory(); const history = useHistory();
const { const {

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useEffect } from 'react'; import React, { useState, useCallback, useEffect } from 'react';
import { Wizard } from '@patternfly/react-core'; import { Wizard } from '@patternfly/react-core';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
@@ -6,14 +6,61 @@ import { Formik } from 'formik';
import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '@api'; import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '@api';
import useRequest from '@util/useRequest'; import useRequest from '@util/useRequest';
import ContentError from '@components/ContentError'; import ContentError from '@components/ContentError';
import ContentLoading from '@components/ContentLoading';
import { required } from '@util/validators';
import InventoryStep from './InventoryStep'; import InventoryStep from './InventoryStep';
import CredentialsStep from './CredentialsStep'; import CredentialsStep from './CredentialsStep';
import OtherPromptsStep from './OtherPromptsStep'; import OtherPromptsStep from './OtherPromptsStep';
import SurveyStep from './SurveyStep'; import SurveyStep from './SurveyStep';
import PreviewStep from './PreviewStep'; import PreviewStep from './PreviewStep';
import PromptFooter from './PromptFooter';
import mergeExtraVars from './mergeExtraVars'; 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 }) { function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) {
const [formErrors, setFormErrors] = useState({});
const [visitedSteps, setVisitedSteps] = useState(
getInitialVisitedSteps(config)
);
const { const {
result: survey, result: survey,
request: fetchSurvey, request: fetchSurvey,
@@ -37,12 +84,16 @@ function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) {
if (surveyError) { if (surveyError) {
return <ContentError error={surveyError} />; return <ContentError error={surveyError} />;
} }
if (config.survey_enabled && !survey) {
return <ContentLoading />;
}
const steps = []; const steps = [];
const initialValues = {}; const initialValues = {};
if (config.ask_inventory_on_launch) { if (config.ask_inventory_on_launch) {
initialValues.inventory = resource?.summary_fields?.inventory || null; initialValues.inventory = resource?.summary_fields?.inventory || null;
steps.push({ steps.push({
id: STEPS.INVENTORY,
name: i18n._(t`Inventory`), name: i18n._(t`Inventory`),
component: <InventoryStep />, component: <InventoryStep />,
}); });
@@ -50,6 +101,7 @@ function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) {
if (config.ask_credential_on_launch) { if (config.ask_credential_on_launch) {
initialValues.credentials = resource?.summary_fields?.credentials || []; initialValues.credentials = resource?.summary_fields?.credentials || [];
steps.push({ steps.push({
id: STEPS.CREDENTIALS,
name: i18n._(t`Credentials`), name: i18n._(t`Credentials`),
component: <CredentialsStep />, component: <CredentialsStep />,
}); });
@@ -81,36 +133,44 @@ function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) {
if (config.ask_diff_mode_on_launch) { if (config.ask_diff_mode_on_launch) {
initialValues.diff_mode = resource.diff_mode || false; initialValues.diff_mode = resource.diff_mode || false;
} }
if ( if (showOtherPrompts(config)) {
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({ steps.push({
id: STEPS.OTHER_PROMPTS,
name: i18n._(t`Other Prompts`), name: i18n._(t`Other Prompts`),
component: <OtherPromptsStep config={config} />, component: <OtherPromptsStep config={config} />,
}); });
} }
if (config.survey_enabled) { if (config.survey_enabled) {
initialValues.survey = {}; initialValues.survey = {};
// survey.spec.forEach(question => {
// initialValues[`survey_${question.variable}`] = question.default;
// })
steps.push({ steps.push({
id: STEPS.SURVEY,
name: i18n._(t`Survey`), name: i18n._(t`Survey`),
component: <SurveyStep survey={survey} />, component: <SurveyStep survey={survey} />,
}); });
} }
steps.push({ steps.push({
id: STEPS.PREVIEW,
name: i18n._(t`Preview`), name: i18n._(t`Preview`),
component: ( component: (
<PreviewStep resource={resource} config={config} survey={survey} /> <PreviewStep
resource={resource}
config={config}
survey={survey}
formErrors={formErrors}
/>
), ),
enableNext: Object.keys(formErrors).length === 0,
nextButtonText: i18n._(t`Launch`), nextButtonText: i18n._(t`Launch`),
}); });
const validate = values => {
// return {};
return { limit: ['required field'] };
};
const submit = values => { const submit = values => {
const postValues = {}; const postValues = {};
const setValue = (key, value) => { const setValue = (key, value) => {
@@ -127,16 +187,32 @@ function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) {
setValue('extra_vars', mergeExtraVars(values.extra_vars, values.survey)); setValue('extra_vars', mergeExtraVars(values.extra_vars, values.survey));
onLaunch(postValues); onLaunch(postValues);
}; };
console.log('formErrors:', formErrors);
return ( return (
<Formik initialValues={initialValues} onSubmit={submit}> <Formik initialValues={initialValues} onSubmit={submit} validate={validate}>
{({ handleSubmit }) => ( {({ errors, values, touched, validateForm, handleSubmit }) => (
<Wizard <Wizard
isOpen isOpen
onClose={onCancel} onClose={onCancel}
onSave={handleSubmit} onSave={handleSubmit}
onNext={async (nextStep, prevStep) => {
// 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`)} title={i18n._(t`Prompts`)}
steps={steps} steps={steps}
// footer={<PromptFooter firstStep={steps[0].id} />}
/> />
)} )}
</Formik> </Formik>

View File

@@ -8,6 +8,7 @@ import { TagMultiSelect } from '@components/MultiSelect';
import AnsibleSelect from '@components/AnsibleSelect'; import AnsibleSelect from '@components/AnsibleSelect';
import { VariablesField } from '@components/CodeMirrorInput'; import { VariablesField } from '@components/CodeMirrorInput';
import styled from 'styled-components'; import styled from 'styled-components';
import { required } from '@util/validators';
const FieldHeader = styled.div` const FieldHeader = styled.div`
display: flex; display: flex;
@@ -32,6 +33,9 @@ function OtherPromptsStep({ config, i18n }) {
of hosts that will be managed or affected by the playbook. Multiple of hosts that will be managed or affected by the playbook. Multiple
patterns are allowed. Refer to Ansible documentation for more patterns are allowed. Refer to Ansible documentation for more
information and examples on patterns.`)} information and examples on patterns.`)}
// TODO: remove this validator (for testing only)
isRequired
validate={required(null, i18n)}
/> />
)} )}
{config.ask_verbosity_on_launch && <VerbosityField i18n={i18n} />} {config.ask_verbosity_on_launch && <VerbosityField i18n={i18n} />}

View File

@@ -4,21 +4,30 @@ import yaml from 'js-yaml';
import PromptDetail from '@components/PromptDetail'; import PromptDetail from '@components/PromptDetail';
import mergeExtraVars, { maskPasswords } from './mergeExtraVars'; import mergeExtraVars, { maskPasswords } from './mergeExtraVars';
function PreviewStep({ resource, config, survey }) { function PreviewStep({ resource, config, survey, formErrors }) {
const { values } = useFormikContext(); const { values } = useFormikContext();
const passwordFields = survey.spec const passwordFields = survey.spec
.filter(q => q.type === 'password') .filter(q => q.type === 'password')
.map(q => q.variable); .map(q => q.variable);
const masked = maskPasswords(values.survey, passwordFields); const masked = maskPasswords(values.survey, passwordFields);
return ( return (
<PromptDetail <>
resource={resource} <PromptDetail
launchConfig={config} resource={resource}
overrides={{ launchConfig={config}
...values, overrides={{
extra_vars: yaml.safeDump(mergeExtraVars(values.extra_vars, masked)), ...values,
}} extra_vars: yaml.safeDump(mergeExtraVars(values.extra_vars, masked)),
/> }}
/>
{formErrors && (
<ul css="color: red">
{Object.keys(formErrors).map(
field => `${field}: ${formErrors[field]}`
)}
</ul>
)}
</>
); );
} }

View File

@@ -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 (
<WizardFooter>
<WizardContextConsumer>
{({
activeStep,
goToStepByName,
goToStepById,
onNext,
onBack,
onClose,
}) => {
if (activeStep.name !== STEPS.PREVIEW) {
return (
<>
<Button variant="primary" type="submit" onClick={onNext}>
{activeStep.nextButtonText || i18n._(t`Next`)}
</Button>
<Button
variant="secondary"
onClick={onBack}
className={activeStep.id === firstStep ? 'pf-m-disabled' : ''}
>
{i18n._(t`Back`)}
</Button>
<Button variant="link" onClick={onClose}>
{i18n._(t`Cancel`)}
</Button>
</>
);
}
return (
<>
<Button onClick={() => this.validateLastStep(onNext)}>
{activeStep.nextButtonText || i18n._(t`Launch`)}
</Button>
<Button onClick={() => goToStepByName('Step 1')}>
Go to Beginning
</Button>
</>
);
}}
</WizardContextConsumer>
</WizardFooter>
);
}
export { PromptFooter as _PromptFooter };
export default withI18n()(PromptFooter);

View File

@@ -10,7 +10,6 @@ import {
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import FormField, { FieldTooltip } from '@components/FormField'; import FormField, { FieldTooltip } from '@components/FormField';
import AnsibleSelect from '@components/AnsibleSelect'; import AnsibleSelect from '@components/AnsibleSelect';
import ContentLoading from '@components/ContentLoading';
import { import {
required, required,
minMaxValue, minMaxValue,
@@ -19,12 +18,9 @@ import {
integer, integer,
combine, combine,
} from '@util/validators'; } from '@util/validators';
import { Survey } from '@types';
function SurveyStep({ survey, i18n }) { function SurveyStep({ survey, i18n }) {
if (!survey) {
return <ContentLoading />;
}
const initialValues = {}; const initialValues = {};
survey.spec.forEach(question => { survey.spec.forEach(question => {
if (question.type === 'multiselect') { if (question.type === 'multiselect') {
@@ -38,6 +34,9 @@ function SurveyStep({ survey, i18n }) {
<SurveySubForm survey={survey} initialValues={initialValues} i18n={i18n} /> <SurveySubForm survey={survey} initialValues={initialValues} i18n={i18n} />
); );
} }
SurveyStep.propTypes = {
survey: Survey.isRequired,
};
// This is a nested Formik form to perform validation on individual // This is a nested Formik form to perform validation on individual
// survey questions. When changes to the inner form occur (onBlur), the // survey questions. When changes to the inner form occur (onBlur), the

View File

@@ -11,7 +11,7 @@ export default function mergeExtraVars(extraVars, survey = {}) {
export function maskPasswords(vars, passwordKeys) { export function maskPasswords(vars, passwordKeys) {
const updated = { ...vars }; const updated = { ...vars };
passwordKeys.forEach(key => { passwordKeys.forEach(key => {
if (updated[key]) { if (typeof updated[key] !== 'undefined') {
updated[key] = '········'; updated[key] = '········';
} }
}); });

View File

@@ -46,5 +46,17 @@ describe('mergeExtraVars', () => {
three: '········', three: '········',
}); });
}); });
test('should mask empty strings', () => {
const vars = {
one: '',
two: 'bravo',
};
expect(maskPasswords(vars, ['one', 'three'])).toEqual({
one: '········',
two: 'bravo',
});
});
}); });
}); });

View File

@@ -306,3 +306,21 @@ export const Schedule = shape({
timezone: string, timezone: string,
until: 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),
});