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 { 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();

View File

@ -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 {

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 { 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 <ContentError error={surveyError} />;
}
if (config.survey_enabled && !survey) {
return <ContentLoading />;
}
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: <InventoryStep />,
});
@ -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: <CredentialsStep />,
});
@ -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: <OtherPromptsStep config={config} />,
});
}
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: <SurveyStep survey={survey} />,
});
}
steps.push({
id: STEPS.PREVIEW,
name: i18n._(t`Preview`),
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`),
});
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 (
<Formik initialValues={initialValues} onSubmit={submit}>
{({ handleSubmit }) => (
<Formik initialValues={initialValues} onSubmit={submit} validate={validate}>
{({ errors, values, touched, validateForm, handleSubmit }) => (
<Wizard
isOpen
onClose={onCancel}
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`)}
steps={steps}
// footer={<PromptFooter firstStep={steps[0].id} />}
/>
)}
</Formik>

View File

@ -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 && <VerbosityField i18n={i18n} />}

View File

@ -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 (
<PromptDetail
resource={resource}
launchConfig={config}
overrides={{
...values,
extra_vars: yaml.safeDump(mergeExtraVars(values.extra_vars, masked)),
}}
/>
<>
<PromptDetail
resource={resource}
launchConfig={config}
overrides={{
...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';
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 <ContentLoading />;
}
const initialValues = {};
survey.spec.forEach(question => {
if (question.type === 'multiselect') {
@ -38,6 +34,9 @@ function SurveyStep({ survey, i18n }) {
<SurveySubForm survey={survey} initialValues={initialValues} i18n={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

View File

@ -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] = '········';
}
});

View File

@ -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',
});
});
});
});

View File

@ -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),
});