Merge pull request #8235 from AlexSCorey/5913-RefactorJTPOL

Restructures Job Template POL and renames useSteps

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot] 2020-10-06 18:47:12 +00:00 committed by GitHub
commit 37b3cc72b2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 303 additions and 260 deletions

View File

@ -2,32 +2,27 @@ 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 { Formik, useFormikContext } from 'formik';
import ContentError from '../ContentError';
import ContentLoading from '../ContentLoading';
import { useDismissableError } from '../../util/useRequest';
import mergeExtraVars from './mergeExtraVars';
import useSteps from './useSteps';
import useLaunchSteps from './useLaunchSteps';
import AlertModal from '../AlertModal';
import getSurveyValues from './getSurveyValues';
function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) {
function PromptModalForm({ onSubmit, onCancel, i18n, config, resource }) {
const { values, setTouched, validateForm } = useFormikContext();
const {
steps,
initialValues,
isReady,
validate,
visitStep,
visitAllSteps,
contentError,
} = useSteps(config, resource, i18n);
} = useLaunchSteps(config, resource, i18n);
if (contentError) {
return <ContentError error={contentError} />;
}
if (!isReady) {
return <ContentLoading />;
}
const submit = values => {
const handleSave = () => {
const postValues = {};
const setValue = (key, value) => {
if (typeof value !== 'undefined' && value !== null) {
@ -49,39 +44,89 @@ function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) {
: resource.extra_vars;
setValue('extra_vars', mergeExtraVars(extraVars, surveyValues));
setValue('scm_branch', values.scm_branch);
onLaunch(postValues);
onSubmit(postValues);
};
const { error, dismissError } = useDismissableError(contentError);
if (error) {
return (
<AlertModal
isOpen={error}
variant="error"
title={i18n._(t`Error!`)}
onClose={() => {
dismissError();
}}
>
<ContentError error={error} />
</AlertModal>
);
}
return (
<Formik initialValues={initialValues} onSubmit={submit} validate={validate}>
{({ validateForm, setTouched, handleSubmit }) => (
<Wizard
isOpen
onClose={onCancel}
onSave={handleSubmit}
onNext={async (nextStep, prevStep) => {
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}
backButtonText={i18n._(t`Back`)}
cancelButtonText={i18n._(t`Cancel`)}
nextButtonText={i18n._(t`Next`)}
/>
)}
<Wizard
isOpen
onClose={onCancel}
onSave={handleSave}
onNext={async (nextStep, prevStep) => {
if (nextStep.id === 'preview') {
visitAllSteps(setTouched);
} else {
visitStep(prevStep.prevId);
}
await validateForm();
}}
onGoToStep={async (nextStep, prevStep) => {
if (nextStep.id === 'preview') {
visitAllSteps(setTouched);
} else {
visitStep(prevStep.prevId);
}
await validateForm();
}}
title={i18n._(t`Prompts`)}
steps={
isReady
? steps
: [
{
name: i18n._(t`Content Loading`),
component: <ContentLoading />,
},
]
}
backButtonText={i18n._(t`Back`)}
cancelButtonText={i18n._(t`Cancel`)}
nextButtonText={i18n._(t`Next`)}
/>
);
}
function LaunchPrompt({ config, resource = {}, onLaunch, onCancel, i18n }) {
return (
<Formik
initialValues={{
verbosity: resource.verbosity || 0,
inventory: resource.summary_fields?.inventory || null,
credentials: resource.summary_fields?.credentials || null,
diff_mode: resource.diff_mode || false,
extra_vars: resource.extra_vars || '---',
job_type: resource.job_type || '',
job_tags: resource.job_tags || '',
skip_tags: resource.skip_tags || '',
scm_branch: resource.scm_branch || '',
limit: resource.limit || '',
}}
onSubmit={values => onLaunch(values)}
>
<PromptModalForm
onSubmit={values => onLaunch(values)}
onCancel={onCancel}
i18n={i18n}
config={config}
resource={resource}
/>
</Formik>
);
}

View File

@ -95,7 +95,7 @@ describe('LaunchPrompt', () => {
expect(steps).toHaveLength(5);
expect(steps[0].name.props.children).toEqual('Inventory');
expect(steps[1].name).toEqual('Credentials');
expect(steps[2].name.props.children).toEqual('Other Prompts');
expect(steps[2].name).toEqual('Other Prompts');
expect(steps[3].name.props.children).toEqual('Survey');
expect(steps[4].name).toEqual('Preview');
});
@ -167,7 +167,7 @@ describe('LaunchPrompt', () => {
const steps = wizard.prop('steps');
expect(steps).toHaveLength(2);
expect(steps[0].name.props.children).toEqual('Other Prompts');
expect(steps[0].name).toEqual('Other Prompts');
expect(isElementOfType(steps[0].component, OtherPromptsStep)).toEqual(true);
expect(isElementOfType(steps[1].component, PreviewStep)).toEqual(true);
});

View File

@ -1,7 +1,10 @@
export default function getSurveyValues(values) {
const surveyValues = {};
Object.keys(values).forEach(key => {
if (key.startsWith('survey_')) {
if (key.startsWith('survey_') && values[key] !== []) {
if (Array.isArray(values[key]) && values[key].length === 0) {
return;
}
surveyValues[key.substr(7)] = values[key];
}
});

View File

@ -51,6 +51,7 @@ function OtherPromptsStep({ config, i18n }) {
id="prompt-job-tags"
name="job_tags"
label={i18n._(t`Job Tags`)}
aria-label={i18n._(t`Job Tags`)}
tooltip={i18n._(t`Tags are useful when you have a large
playbook, and you want to run a specific part of a play or task.
Use commas to separate multiple tags. Refer to Ansible Tower
@ -62,6 +63,7 @@ function OtherPromptsStep({ config, i18n }) {
id="prompt-skip-tags"
name="skip_tags"
label={i18n._(t`Skip Tags`)}
aria-label={i18n._(t`Skip Tags`)}
tooltip={i18n._(t`Skip tags are useful when you have a large
playbook, and you want to skip specific parts of a play or task.
Use commas to separate multiple tags. Refer to Ansible Tower
@ -108,6 +110,7 @@ function JobTypeField({ i18n }) {
and report problems without executing the playbook.`)}
/>
}
isRequired
validated={isValid ? 'default' : 'error'}
>
<AnsibleSelect
@ -129,6 +132,7 @@ function VerbosityField({ i18n }) {
{ value: '3', key: '3', label: i18n._(t`3 (Debug)`) },
{ value: '4', key: '4', label: i18n._(t`4 (Connection Debug)`) },
];
const isValid = !(meta.touched && meta.error);
return (
@ -171,6 +175,7 @@ function ShowChangesToggle({ i18n }) {
</label>
</FieldHeader>
<Switch
aria-label={field.value ? i18n._(t`On`) : i18n._(t`Off`)}
id="prompt-show-changes"
label={i18n._(t`On`)}
labelOff={i18n._(t`Off`)}

View File

@ -48,7 +48,7 @@ function PreviewStep({ resource, config, survey, formErrors, i18n }) {
return (
<Fragment>
{formErrors.length > 0 && (
{formErrors && (
<ErrorMessageWrapper>
{i18n._(t`Some of the previous step(s) have errors`)}
<Tooltip

View File

@ -104,4 +104,31 @@ describe('PreviewStep', () => {
extra_vars: 'one: 1',
});
});
test('should remove survey with empty array value', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik
initialValues={{ extra_vars: 'one: 1' }}
values={{ extra_vars: 'one: 1', survey_foo: [] }}
>
<PreviewStep
resource={resource}
config={{
ask_variables_on_launch: true,
}}
formErrors={formErrors}
/>
</Formik>
);
});
const detail = wrapper.find('PromptDetail');
expect(detail).toHaveLength(1);
expect(detail.prop('resource')).toEqual(resource);
expect(detail.prop('overrides')).toEqual({
extra_vars: 'one: 1',
});
});
});

View File

@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { useField } from 'formik';
import {
Form,
@ -114,15 +115,22 @@ function MultipleChoiceField({ question }) {
);
}
function MultiSelectField({ question }) {
function MultiSelectField({ question, i18n }) {
const [isOpen, setIsOpen] = useState(false);
const [field, meta, helpers] = useField(`survey_${question.variable}`);
const [field, meta, helpers] = useField({
name: `survey_${question.variable}`,
validate: question.isrequired ? required(null, i18n) : null,
});
const id = `survey-question-${question.variable}`;
const isValid = !(meta.touched && meta.error);
const hasActualValue = !question.required || meta.value.length > 0;
const isValid = !meta.touched || (!meta.error && hasActualValue);
return (
<FormGroup
fieldId={id}
helperTextInvalid={meta.error}
helperTextInvalid={
meta.error || i18n._(t`Must select a value for this field.`)
}
isRequired={question.required}
validated={isValid ? 'default' : 'error'}
label={question.question_name}
@ -133,14 +141,19 @@ function MultiSelectField({ question }) {
id={id}
onToggle={setIsOpen}
onSelect={(event, option) => {
if (field.value.includes(option)) {
if (field?.value?.includes(option)) {
helpers.setValue(field.value.filter(o => o !== option));
} else {
helpers.setValue(field.value.concat(option));
}
helpers.setTouched(true);
}}
isOpen={isOpen}
selections={field.value}
onClear={() => {
helpers.setTouched(true);
helpers.setValue([]);
}}
>
{question.choices.split('\n').map(opt => (
<SelectOption key={opt} value={opt} />

View File

@ -4,20 +4,9 @@ import CredentialsStep from './CredentialsStep';
const STEP_ID = 'credentials';
export default function useCredentialsStep(
config,
resource,
visitedSteps,
i18n
) {
const validate = () => {
return {};
};
export default function useCredentialsStep(config, i18n) {
return {
step: getStep(config, i18n),
initialValues: getInitialValues(config, resource),
validate,
isReady: true,
contentError: null,
formError: null,
@ -39,12 +28,3 @@ function getStep(config, i18n) {
component: <CredentialsStep i18n={i18n} />,
};
}
function getInitialValues(config, resource) {
if (!config.ask_credential_on_launch) {
return {};
}
return {
credentials: resource?.summary_fields?.credentials || [],
};
}

View File

@ -1,38 +1,19 @@
import React, { useState } from 'react';
import React from 'react';
import { t } from '@lingui/macro';
import { useField } from 'formik';
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 => {
if (
!config.ask_inventory_on_launch ||
(['workflow_job', 'workflow_job_template'].includes(resource.type) &&
!resource.inventory)
) {
return {};
}
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;
export default function useInventoryStep(config, visitedSteps, i18n) {
const [, meta] = useField('inventory');
return {
step: getStep(config, hasErrors, i18n),
initialValues: getInitialValues(config, resource),
validate,
step: getStep(config, meta, i18n, visitedSteps),
isReady: true,
contentError: null,
formError: stepErrors,
formError: !meta.value,
setTouched: setFieldsTouched => {
setFieldsTouched({
inventory: true,
@ -40,23 +21,24 @@ export default function useInventoryStep(config, resource, visitedSteps, i18n) {
},
};
}
function getStep(config, hasErrors, i18n) {
function getStep(config, meta, i18n, visitedSteps) {
if (!config.ask_inventory_on_launch) {
return null;
}
return {
id: STEP_ID,
name: <StepName hasErrors={hasErrors}>{i18n._(t`Inventory`)}</StepName>,
key: 3,
name: (
<StepName
hasErrors={
Object.keys(visitedSteps).includes(STEP_ID) &&
(!meta.value || meta.error)
}
>
{i18n._(t`Inventory`)}
</StepName>
),
component: <InventoryStep i18n={i18n} />,
};
}
function getInitialValues(config, resource) {
if (!config.ask_inventory_on_launch) {
return {};
}
return {
inventory: resource?.summary_fields?.inventory || null,
enableNext: true,
};
}

View File

@ -1,31 +1,15 @@
import React, { useState } from 'react';
import React 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;
export default function useOtherPrompt(config, i18n) {
return {
step: getStep(config, hasErrors, i18n),
initialValues: getInitialValues(config, resource),
validate,
step: getStep(config, i18n),
isReady: true,
contentError: null,
formError: stepErrors,
formError: null,
setTouched: setFieldsTouched => {
setFieldsTouched({
job_type: true,
@ -40,13 +24,13 @@ export default function useOtherPrompt(config, resource, visitedSteps, i18n) {
};
}
function getStep(config, hasErrors, i18n) {
function getStep(config, i18n) {
if (!shouldShowPrompt(config)) {
return null;
}
return {
id: STEP_ID,
name: <StepName hasErrors={hasErrors}>{i18n._(t`Other Prompts`)}</StepName>,
name: i18n._(t`Other Prompts`),
component: <OtherPromptsStep config={config} i18n={i18n} />,
};
}
@ -63,32 +47,3 @@ function shouldShowPrompt(config) {
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;
}

View File

@ -1,4 +1,5 @@
import React from 'react';
import { useFormikContext } from 'formik';
import { t } from '@lingui/macro';
import PreviewStep from './PreviewStep';
@ -8,9 +9,29 @@ export default function usePreviewStep(
config,
resource,
survey,
formErrors,
hasErrors,
i18n
) {
const { values: formikValues, errors } = useFormikContext();
const formErrorsContent = [];
if (config.ask_inventory_on_launch && !formikValues.inventory) {
formErrorsContent.push({
inventory: true,
});
}
const hasSurveyError = Object.keys(errors).find(e => e.includes('survey'));
if (
config.survey_enabled &&
(config.variables_needed_to_start ||
config.variables_needed_to_start.length === 0) &&
hasSurveyError
) {
formErrorsContent.push({
survey: true,
});
}
return {
step: {
id: STEP_ID,
@ -20,14 +41,13 @@ export default function usePreviewStep(
config={config}
resource={resource}
survey={survey}
formErrors={formErrors}
formErrors={hasErrors}
/>
),
enableNext: Object.keys(formErrors).length === 0,
enableNext: !hasErrors,
nextButtonText: i18n._(t`Launch`),
},
initialValues: {},
validate: () => ({}),
isReady: true,
error: null,
setTouched: () => {},

View File

@ -1,5 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useEffect, useCallback } from 'react';
import { t } from '@lingui/macro';
import { useFormikContext } from 'formik';
import useRequest from '../../../util/useRequest';
import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '../../../api';
import SurveyStep from './SurveyStep';
@ -7,27 +8,27 @@ import StepName from './StepName';
const STEP_ID = 'survey';
export default function useSurveyStep(config, resource, visitedSteps, i18n) {
const [stepErrors, setStepErrors] = useState({});
export default function useSurveyStep(config, visitedSteps, i18n) {
const { values } = useFormikContext();
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);
const { data } = config?.workflow_job_template_data
? await WorkflowJobTemplatesAPI.readSurvey(
config?.workflow_job_template_data?.id
)
: await JobTemplatesAPI.readSurvey(config?.job_template_data?.id);
return data;
}, [config.survey_enabled, resource])
}, [config])
);
useEffect(() => {
fetchSurvey();
}, [fetchSurvey]);
const validate = values => {
const validate = () => {
if (!config.survey_enabled || !survey || !survey.spec) {
return {};
}
@ -42,20 +43,16 @@ export default function useSurveyStep(config, resource, visitedSteps, i18n) {
errors[`survey_${question.variable}`] = errMessage;
}
});
setStepErrors(errors);
return errors;
};
const hasErrors = visitedSteps[STEP_ID] && Object.keys(stepErrors).length > 0;
const formError = Object.keys(validate()).length > 0;
return {
step: getStep(config, survey, hasErrors, i18n),
step: getStep(config, survey, formError, i18n, visitedSteps),
formError,
initialValues: getInitialValues(config, survey),
validate,
survey,
isReady: !isLoading && !!survey,
contentError: error,
formError: stepErrors,
setTouched: setFieldsTouched => {
if (!survey || !survey.spec) {
return;
@ -87,34 +84,49 @@ function validateField(question, value, i18n) {
);
}
}
if (question.required && !value && value !== 0) {
if (
question.required &&
((!value && value !== 0) || (Array.isArray(value) && value.length === 0))
) {
return i18n._(t`This field must not be blank`);
}
return null;
}
function getStep(config, survey, hasErrors, i18n) {
function getStep(config, survey, hasErrors, i18n, visitedSteps) {
if (!config.survey_enabled) {
return null;
}
return {
id: STEP_ID,
name: <StepName hasErrors={hasErrors}>{i18n._(t`Survey`)}</StepName>,
key: 6,
name: (
<StepName
hasErrors={Object.keys(visitedSteps).includes(STEP_ID) && hasErrors}
>
{i18n._(t`Survey`)}
</StepName>
),
component: <SurveyStep survey={survey} i18n={i18n} />,
enableNext: true,
};
}
function getInitialValues(config, survey) {
if (!config.survey_enabled || !survey) {
return {};
}
const values = {};
const surveyValues = {};
survey.spec.forEach(question => {
if (question.type === 'multiselect') {
values[`survey_${question.variable}`] = question.default.split('\n');
if (question.default === '') {
surveyValues[`survey_${question.variable}`] = [];
} else {
surveyValues[`survey_${question.variable}`] = question.default.split(
'\n'
);
}
} else {
values[`survey_${question.variable}`] = question.default;
surveyValues[`survey_${question.variable}`] = question.default;
}
});
return values;
return surveyValues;
}

View File

@ -0,0 +1,69 @@
import { useState, useEffect } from 'react';
import { useFormikContext } from 'formik';
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 useLaunchSteps(config, resource, i18n) {
const [visited, setVisited] = useState({});
const steps = [
useInventoryStep(config, visited, i18n),
useCredentialsStep(config, i18n),
useOtherPromptsStep(config, i18n),
useSurveyStep(config, visited, i18n),
];
const { resetForm, values: formikValues } = useFormikContext();
const hasErrors = steps.some(step => step.formError);
const surveyStepIndex = steps.findIndex(step => step.survey);
steps.push(
usePreviewStep(
config,
resource,
steps[surveyStepIndex]?.survey,
hasErrors,
i18n
)
);
const pfSteps = steps.map(s => s.step).filter(s => s != null);
const isReady = !steps.some(s => !s.isReady);
useEffect(() => {
if (surveyStepIndex > -1 && isReady) {
resetForm({
values: {
...formikValues,
...steps[surveyStepIndex].initialValues,
},
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isReady]);
const stepWithError = steps.find(s => s.contentError);
const contentError = stepWithError ? stepWithError.contentError : null;
return {
steps: pfSteps,
isReady,
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,
};
}

View File

@ -1,68 +0,0 @@
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),
];
const formErrorsContent = steps
.filter(s => s?.formError && Object.keys(s.formError).length > 0)
.map(({ formError }) => formError);
steps.push(
usePreviewStep(config, resource, steps[3].survey, formErrorsContent, 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.contentError);
const contentError = stepWithError ? stepWithError.contentError : 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,
};
}