flush out prompt validation errors

This commit is contained in:
Keith Grant
2020-05-06 14:50:44 -07:00
parent 1ac92b0493
commit da8f486c5d
9 changed files with 168 additions and 319 deletions

View File

@@ -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 <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 />,
});
}
if (config.ask_credential_on_launch) {
initialValues.credentials = resource?.summary_fields?.credentials || [];
steps.push({
id: STEPS.CREDENTIALS,
name: i18n._(t`Credentials`),
component: <CredentialsStep />,
});
}
// 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: <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}
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) => {
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 (
<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>
);
}
export { LaunchPrompt as _LaunchPrompt };
export default withI18n()(LaunchPrompt);

View File

@@ -1,33 +1,24 @@
import React, { useState, useCallback, useEffect } from 'react'; import React 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';
import { Formik } from 'formik'; import { Formik } from 'formik';
// import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '@api';
// import useRequest from '@util/useRequest';
import ContentError from '@components/ContentError'; import ContentError from '@components/ContentError';
import ContentLoading from '@components/ContentLoading'; 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 mergeExtraVars from './mergeExtraVars';
import { useSteps, useVisitedSteps } from './hooks'; import useSteps from './useSteps';
function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) { function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) {
// const [formErrors, setFormErrors] = useState({});
const { const {
steps, steps,
initialValues, initialValues,
isReady, isReady,
validate, validate,
formErrors, visitStep,
visitAllSteps,
// formErrors,
contentError, contentError,
} = useSteps(config, resource, i18n); } = useSteps(config, resource, i18n);
const [visitedSteps, visitStep] = useVisitedSteps(config);
if (contentError) { if (contentError) {
return <ContentError error={contentError} />; return <ContentError error={contentError} />;
@@ -36,13 +27,6 @@ function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) {
return <ContentLoading />; return <ContentLoading />;
} }
// TODO move into hook?
// const validate = values => {
// // return {};
// return { limit: ['required field'] };
// };
// TODO move into hook?
const submit = values => { const submit = values => {
const postValues = {}; const postValues = {};
const setValue = (key, value) => { const setValue = (key, value) => {
@@ -62,22 +46,26 @@ function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) {
return ( return (
<Formik initialValues={initialValues} onSubmit={submit} validate={validate}> <Formik initialValues={initialValues} onSubmit={submit} validate={validate}>
{({ errors, values, touched, validateForm, handleSubmit }) => ( {({ validateForm, handleSubmit }) => (
<Wizard <Wizard
isOpen isOpen
onClose={onCancel} onClose={onCancel}
onSave={handleSubmit} onSave={handleSubmit}
onNext={async (nextStep, prevStep) => { onNext={async (nextStep, prevStep) => {
console.log(prevStep); if (nextStep.id === 'preview') {
visitStep(prevStep.id); visitAllSteps();
const newErrors = await validateForm(); } else {
// updatePromptErrors(prevStep.prevName, newErrors); visitStep(prevStep.prevId);
}
await validateForm();
}} }}
onGoToStep={async (newStep, prevStep) => { onGoToStep={async (newStep, prevStep) => {
console.log(prevStep); if (newStep.id === 'preview') {
visitStep(prevStep.id); visitAllSteps();
const newErrors = await validateForm(); } else {
// updatePromptErrors(prevStep.prevName, newErrors); visitStep(prevStep.prevId);
}
await validateForm();
}} }}
title={i18n._(t`Prompts`)} title={i18n._(t`Prompts`)}
steps={steps} steps={steps}

View File

@@ -8,7 +8,6 @@ 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;
@@ -33,9 +32,7 @@ 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 isRequired
validate={required(null, i18n)}
/> />
)} )}
{config.ask_verbosity_on_launch && <VerbosityField i18n={i18n} />} {config.ask_verbosity_on_launch && <VerbosityField i18n={i18n} />}

View File

@@ -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 (
<>
<AlertText>
{children}
<Tooltip
position="right"
content={i18n._(t`This step contains errors`)}
trigger="click mouseenter focus"
>
<ExclamationCircleIcon css="color: var(--pf-global--danger-color--100)" />
</Tooltip>
</AlertText>
</>
);
}
export default withI18n()(StepName);

View File

@@ -4,13 +4,14 @@ import CredentialsStep from './CredentialsStep';
const STEP_ID = 'credentials'; const STEP_ID = 'credentials';
export default function useCredentialsStep(config, resource, i18n) { export default function useCredentialsStep(
const validate = values => { config,
const errors = {}; resource,
if (!values.credentials || !values.credentials.length) { visitedSteps,
errors.credentials = i18n._(t`Credentials must be selected`); i18n
} ) {
return errors; const validate = () => {
return {};
}; };
return { return {

View File

@@ -1,20 +1,26 @@
import React from 'react'; import React, { useState } from 'react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import InventoryStep from './InventoryStep'; import InventoryStep from './InventoryStep';
import StepName from './StepName';
const STEP_ID = 'inventory'; 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 validate = values => {
const errors = {}; const errors = {};
if (!values.inventory) { if (!values.inventory) {
errors.inventory = i18n._(t`An inventory must be selected`); errors.inventory = i18n._(t`An inventory must be selected`);
} }
setStepErrors(errors);
return errors; return errors;
}; };
const hasErrors = visitedSteps[STEP_ID] && Object.keys(stepErrors).length > 0;
return { return {
step: getStep(config, i18n), step: getStep(config, hasErrors, i18n),
initialValues: getInitialValues(config, resource), initialValues: getInitialValues(config, resource),
validate, validate,
isReady: true, 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) { if (!config.ask_inventory_on_launch) {
return null; return null;
} }
return { return {
id: STEP_ID, id: STEP_ID,
name: i18n._(t`Inventory`), name: <StepName hasErrors={hasErrors}>{i18n._(t`Inventory`)}</StepName>,
component: <InventoryStep i18n={i18n} />, component: <InventoryStep i18n={i18n} />,
}; };
} }

View File

@@ -1,20 +1,26 @@
import React from 'react'; import React, { useState } from 'react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import OtherPromptsStep from './OtherPromptsStep'; import OtherPromptsStep from './OtherPromptsStep';
import StepName from './StepName';
const STEP_ID = 'other'; 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 validate = values => {
const errors = {}; const errors = {};
if (config.ask_job_type_on_launch && !values.job_type) { if (config.ask_job_type_on_launch && !values.job_type) {
errors.job_type = i18n._(t`This field must not be blank`); errors.job_type = i18n._(t`This field must not be blank`);
} }
setStepErrors(errors);
return errors; return errors;
}; };
const hasErrors = visitedSteps[STEP_ID] && Object.keys(stepErrors).length > 0;
return { return {
step: getStep(config, i18n), step: getStep(config, hasErrors, i18n),
initialValues: getInitialValues(config, resource), initialValues: getInitialValues(config, resource),
validate, validate,
isReady: true, 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)) { if (!shouldShowPrompt(config)) {
return null; return null;
} }
return { return {
id: STEP_ID, id: STEP_ID,
name: i18n._(t`Other Prompts`), name: <StepName hasErrors={hasErrors}>{i18n._(t`Other Prompts`)}</StepName>,
component: <OtherPromptsStep config={config} i18n={i18n} />, component: <OtherPromptsStep config={config} i18n={i18n} />,
}; };
} }

View File

@@ -1,12 +1,15 @@
import React, { useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import useRequest from '@util/useRequest'; import useRequest from '@util/useRequest';
import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '@api'; import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '@api';
import SurveyStep from './SurveyStep'; import SurveyStep from './SurveyStep';
import StepName from './StepName';
const STEP_ID = 'survey'; 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( const { result: survey, request: fetchSurvey, isLoading, error } = useRequest(
useCallback(async () => { useCallback(async () => {
if (!config.survey_enabled) { if (!config.survey_enabled) {
@@ -24,23 +27,68 @@ export default function useSurveyStep(config, resource, i18n) {
fetchSurvey(); fetchSurvey();
}, [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 { return {
step: getStep(config, survey, i18n), step: getStep(config, survey, hasErrors, i18n),
initialValues: getInitialValues(config, survey), initialValues: getInitialValues(config, survey),
validate: getValidate(config, survey, i18n), validate,
survey, survey,
isReady: !isLoading && !!survey, isReady: !isLoading && !!survey,
error, 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) { if (!config.survey_enabled) {
return null; return null;
} }
return { return {
id: STEP_ID, id: STEP_ID,
name: i18n._(t`Survey`), name: <StepName hasErrors={hasErrors}>{i18n._(t`Survey`)}</StepName>,
component: <SurveyStep survey={survey} i18n={i18n} />, component: <SurveyStep survey={survey} i18n={i18n} />,
}; };
} }
@@ -59,22 +107,3 @@ function getInitialValues(config, survey) {
}); });
return values; 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;
};
}

View File

@@ -5,17 +5,17 @@ import useOtherPromptsStep from './steps/useOtherPromptsStep';
import useSurveyStep from './steps/useSurveyStep'; import useSurveyStep from './steps/useSurveyStep';
import usePreviewStep from './steps/usePreviewStep'; import usePreviewStep from './steps/usePreviewStep';
export function useSteps(config, resource, i18n) { export default function useSteps(config, resource, i18n) {
const [formErrors, setFormErrors] = useState({}); const [visited, setVisited] = useState({});
const inventory = useInventoryStep(config, resource, i18n); const inventory = useInventoryStep(config, resource, visited, i18n);
const credentials = useCredentialsStep(config, resource, i18n); const credentials = useCredentialsStep(config, resource, visited, i18n);
const otherPrompts = useOtherPromptsStep(config, resource, i18n); const otherPrompts = useOtherPromptsStep(config, resource, visited, i18n);
const survey = useSurveyStep(config, resource, i18n); const survey = useSurveyStep(config, resource, visited, i18n);
const preview = usePreviewStep( const preview = usePreviewStep(
config, config,
resource, resource,
survey.survey, survey.survey,
formErrors, {}, // TODO: formErrors ?
i18n i18n
); );
@@ -46,6 +46,9 @@ export function useSteps(config, resource, i18n) {
survey.error || survey.error ||
preview.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 validate = values => {
const errors = { const errors = {
...inventory.validate(values), ...inventory.validate(values),
@@ -53,24 +56,29 @@ export function useSteps(config, resource, i18n) {
...otherPrompts.validate(values), ...otherPrompts.validate(values),
...survey.validate(values), ...survey.validate(values),
}; };
setFormErrors(errors); // setFormErrors(errors);
if (Object.keys(errors).length) { if (Object.keys(errors).length) {
return errors; return errors;
} }
return false; return false;
}; };
return { steps, initialValues, isReady, validate, formErrors, contentError }; // TODO move visited flags into each step hook
} return {
steps,
export function usePromptErrors(config) { initialValues,
const [promptErrors, setPromptErrors] = useState({}); isReady,
const updatePromptErrors = () => {}; validate,
return [promptErrors, updatePromptErrors]; visitStep: stepId => setVisited({ ...visited, [stepId]: true }),
} visitAllSteps: () => {
setVisited({
// TODO this interrelates with usePromptErrors inventory: true,
// merge? or pass result from one into the other? credentials: true,
export function useVisitedSteps(config) { other: true,
return [[], () => {}]; survey: true,
preview: true,
});
},
contentError,
};
} }