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 { 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 <ContentError error={contentError} />;
@ -36,13 +27,6 @@ function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) {
return <ContentLoading />;
}
// 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 (
<Formik initialValues={initialValues} onSubmit={submit} validate={validate}>
{({ errors, values, touched, validateForm, handleSubmit }) => (
{({ validateForm, handleSubmit }) => (
<Wizard
isOpen
onClose={onCancel}
onSave={handleSubmit}
onNext={async (nextStep, prevStep) => {
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}

View File

@ -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 && <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';
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 {

View File

@ -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: <StepName hasErrors={hasErrors}>{i18n._(t`Inventory`)}</StepName>,
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 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: <StepName hasErrors={hasErrors}>{i18n._(t`Other Prompts`)}</StepName>,
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 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: <StepName hasErrors={hasErrors}>{i18n._(t`Survey`)}</StepName>,
component: <SurveyStep survey={survey} i18n={i18n} />,
};
}
@ -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;
};
}

View File

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