Merge pull request #6955 from keithjgrant/5909-jt-launch-prompt-4

JT Launch prompt preview & validation

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot] 2020-05-12 18:45:48 +00:00 committed by GitHub
commit deadf197a3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 729 additions and 250 deletions

View File

@ -1,5 +1,5 @@
export default function getErrorMessage(response) {
if (!response.data) {
if (!response?.data) {
return null;
}
if (typeof response.data === 'string') {

View File

@ -3,84 +3,29 @@ import { Wizard } from '@patternfly/react-core';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Formik } from 'formik';
import InventoryStep from './InventoryStep';
import CredentialsStep from './CredentialsStep';
import OtherPromptsStep from './OtherPromptsStep';
import SurveyStep from './SurveyStep';
import PreviewStep from './PreviewStep';
import ContentError from '@components/ContentError';
import ContentLoading from '@components/ContentLoading';
import mergeExtraVars from './mergeExtraVars';
import useSteps from './useSteps';
import getSurveyValues from './getSurveyValues';
function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) {
const steps = [];
const initialValues = {};
if (config.ask_inventory_on_launch) {
initialValues.inventory = resource?.summary_fields?.inventory || null;
steps.push({
name: i18n._(t`Inventory`),
component: <InventoryStep />,
});
}
if (config.ask_credential_on_launch) {
initialValues.credentials = resource?.summary_fields?.credentials || [];
steps.push({
name: i18n._(t`Credentials`),
component: <CredentialsStep />,
});
}
const {
steps,
initialValues,
isReady,
validate,
visitStep,
visitAllSteps,
contentError,
} = useSteps(config, resource, i18n);
// TODO: Add Credential Passwords step
if (config.ask_job_type_on_launch) {
initialValues.job_type = resource.job_type || '';
if (contentError) {
return <ContentError error={contentError} />;
}
if (config.ask_limit_on_launch) {
initialValues.limit = resource.limit || '';
if (!isReady) {
return <ContentLoading />;
}
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 (
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({
name: i18n._(t`Other Prompts`),
component: <OtherPromptsStep config={config} />,
});
}
if (config.survey_enabled) {
initialValues.survey = {};
steps.push({
name: i18n._(t`Survey`),
component: <SurveyStep template={resource} />,
});
}
steps.push({
name: i18n._(t`Preview`),
component: <PreviewStep />,
nextButtonText: i18n._(t`Launch`),
});
const submit = values => {
const postValues = {};
@ -89,23 +34,40 @@ function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) {
postValues[key] = value;
}
};
const surveyValues = getSurveyValues(values);
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));
setValue('extra_vars', mergeExtraVars(values.extra_vars, surveyValues));
onLaunch(postValues);
};
return (
<Formik initialValues={initialValues} onSubmit={submit}>
{({ handleSubmit }) => (
<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}
/>

View File

@ -1,16 +1,22 @@
import React from 'react';
import { act, isElementOfType } from 'react-dom/test-utils';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import LaunchPrompt from './LaunchPrompt';
import InventoryStep from './InventoryStep';
import CredentialsStep from './CredentialsStep';
import OtherPromptsStep from './OtherPromptsStep';
import PreviewStep from './PreviewStep';
import { InventoriesAPI, CredentialsAPI, CredentialTypesAPI } from '@api';
import InventoryStep from './steps/InventoryStep';
import CredentialsStep from './steps/CredentialsStep';
import OtherPromptsStep from './steps/OtherPromptsStep';
import PreviewStep from './steps/PreviewStep';
import {
InventoriesAPI,
CredentialsAPI,
CredentialTypesAPI,
JobTemplatesAPI,
} from '@api';
jest.mock('@api/models/Inventories');
jest.mock('@api/models/CredentialTypes');
jest.mock('@api/models/Credentials');
jest.mock('@api/models/JobTemplates');
let config;
const resource = {
@ -31,6 +37,13 @@ describe('LaunchPrompt', () => {
data: { results: [{ id: 1 }], count: 1 },
});
CredentialTypesAPI.loadAllTypes({ data: { results: [{ type: 'ssh' }] } });
JobTemplatesAPI.readSurvey.mockResolvedValue({
data: {
name: '',
description: '',
spec: [{ type: 'text', variable: 'foo' }],
},
});
config = {
can_start_without_user_input: false,
@ -73,13 +86,14 @@ describe('LaunchPrompt', () => {
/>
);
});
const steps = wrapper.find('Wizard').prop('steps');
const wizard = await waitForElement(wrapper, 'Wizard');
const steps = wizard.prop('steps');
expect(steps).toHaveLength(5);
expect(steps[0].name).toEqual('Inventory');
expect(steps[0].name.props.children).toEqual('Inventory');
expect(steps[1].name).toEqual('Credentials');
expect(steps[2].name).toEqual('Other Prompts');
expect(steps[3].name).toEqual('Survey');
expect(steps[2].name.props.children).toEqual('Other Prompts');
expect(steps[3].name.props.children).toEqual('Survey');
expect(steps[4].name).toEqual('Preview');
});
@ -98,10 +112,11 @@ describe('LaunchPrompt', () => {
/>
);
});
const steps = wrapper.find('Wizard').prop('steps');
const wizard = await waitForElement(wrapper, 'Wizard');
const steps = wizard.prop('steps');
expect(steps).toHaveLength(2);
expect(steps[0].name).toEqual('Inventory');
expect(steps[0].name.props.children).toEqual('Inventory');
expect(isElementOfType(steps[0].component, InventoryStep)).toEqual(true);
expect(isElementOfType(steps[1].component, PreviewStep)).toEqual(true);
});
@ -121,7 +136,8 @@ describe('LaunchPrompt', () => {
/>
);
});
const steps = wrapper.find('Wizard').prop('steps');
const wizard = await waitForElement(wrapper, 'Wizard');
const steps = wizard.prop('steps');
expect(steps).toHaveLength(2);
expect(steps[0].name).toEqual('Credentials');
@ -144,10 +160,11 @@ describe('LaunchPrompt', () => {
/>
);
});
const steps = wrapper.find('Wizard').prop('steps');
const wizard = await waitForElement(wrapper, 'Wizard');
const steps = wizard.prop('steps');
expect(steps).toHaveLength(2);
expect(steps[0].name).toEqual('Other Prompts');
expect(steps[0].name.props.children).toEqual('Other Prompts');
expect(isElementOfType(steps[0].component, OtherPromptsStep)).toEqual(true);
expect(isElementOfType(steps[1].component, PreviewStep)).toEqual(true);
});

View File

@ -1,7 +0,0 @@
import React from 'react';
function PreviewStep() {
return <div>Preview of selected values will appear here</div>;
}
export default PreviewStep;

View File

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

View File

@ -8,4 +8,12 @@ export default function mergeExtraVars(extraVars, survey = {}) {
};
}
// TODO: "safe" version that obscures passwords for preview step
export function maskPasswords(vars, passwordKeys) {
const updated = { ...vars };
passwordKeys.forEach(key => {
if (typeof updated[key] !== 'undefined') {
updated[key] = '········';
}
});
return updated;
}

View File

@ -1,4 +1,4 @@
import mergeExtraVars from './mergeExtraVars';
import mergeExtraVars, { maskPasswords } from './mergeExtraVars';
describe('mergeExtraVars', () => {
test('should handle yaml string', () => {
@ -31,4 +31,32 @@ describe('mergeExtraVars', () => {
bar: 'baz',
});
});
describe('maskPasswords', () => {
test('should mask password fields', () => {
const vars = {
one: 'alpha',
two: 'bravo',
three: 'charlie',
};
expect(maskPasswords(vars, ['one', 'three'])).toEqual({
one: '········',
two: 'bravo',
three: '········',
});
});
test('should mask empty strings', () => {
const vars = {
one: '',
two: 'bravo',
};
expect(maskPasswords(vars, ['one', 'three'])).toEqual({
one: '········',
two: 'bravo',
});
});
});
});

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

@ -0,0 +1,36 @@
import React from 'react';
import { useFormikContext } from 'formik';
import yaml from 'js-yaml';
import PromptDetail from '@components/PromptDetail';
import mergeExtraVars, { maskPasswords } from '../mergeExtraVars';
import getSurveyValues from '../getSurveyValues';
function PreviewStep({ resource, config, survey, formErrors }) {
const { values } = useFormikContext();
const surveyValues = getSurveyValues(values);
const passwordFields = survey.spec
.filter(q => q.type === 'password')
.map(q => q.variable);
const masked = maskPasswords(surveyValues, passwordFields);
return (
<>
<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>
)}
</>
);
}
export default PreviewStep;

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

@ -1,7 +1,6 @@
import React, { useCallback, useEffect, useState } from 'react';
import React, { useState } from 'react';
import { withI18n } from '@lingui/react';
import { Formik, useField } from 'formik';
import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '@api';
import { useField } from 'formik';
import {
Form,
FormGroup,
@ -11,9 +10,6 @@ import {
} from '@patternfly/react-core';
import FormField, { FieldTooltip } from '@components/FormField';
import AnsibleSelect from '@components/AnsibleSelect';
import ContentLoading from '@components/ContentLoading';
import ContentError from '@components/ContentError';
import useRequest from '@util/useRequest';
import {
required,
minMaxValue,
@ -22,54 +18,9 @@ import {
integer,
combine,
} from '@util/validators';
import { Survey } from '@types';
function SurveyStep({ template, i18n }) {
const { result: survey, request: fetchSurvey, isLoading, error } = useRequest(
useCallback(async () => {
const { data } =
template.type === 'workflow_job_template'
? await WorkflowJobTemplatesAPI.readSurvey(template.id)
: await JobTemplatesAPI.readSurvey(template.id);
return data;
}, [template])
);
useEffect(() => {
fetchSurvey();
}, [fetchSurvey]);
if (error) {
return <ContentError error={error} />;
}
if (isLoading || !survey) {
return <ContentLoading />;
}
const initialValues = {};
survey.spec.forEach(question => {
if (question.type === 'multiselect') {
initialValues[question.variable] = question.default.split('\n');
} else {
initialValues[question.variable] = question.default;
}
});
return (
<SurveySubForm survey={survey} initialValues={initialValues} i18n={i18n} />
);
}
// This is a nested Formik form to perform validation on individual
// survey questions. When changes to the inner form occur (onBlur), the
// values for all questions are added to the outer form's `survey` field
// as a single object.
function SurveySubForm({ survey, initialValues, i18n }) {
const [, , surveyFieldHelpers] = useField('survey');
useEffect(() => {
// set survey initial values to parent form
surveyFieldHelpers.setValue(initialValues);
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, []);
function SurveyStep({ survey, i18n }) {
const fieldTypes = {
text: TextField,
textarea: TextField,
@ -80,21 +31,19 @@ function SurveySubForm({ survey, initialValues, i18n }) {
float: NumberField,
};
return (
<Formik initialValues={initialValues}>
{({ values }) => (
<Form onBlur={() => surveyFieldHelpers.setValue(values)}>
{' '}
{survey.spec.map(question => {
const Field = fieldTypes[question.type];
return (
<Field key={question.variable} question={question} i18n={i18n} />
);
})}
</Form>
)}
</Formik>
<Form>
{survey.spec.map(question => {
const Field = fieldTypes[question.type];
return (
<Field key={question.variable} question={question} i18n={i18n} />
);
})}
</Form>
);
}
SurveyStep.propTypes = {
survey: Survey.isRequired,
};
function TextField({ question, i18n }) {
const validators = [
@ -105,7 +54,7 @@ function TextField({ question, i18n }) {
return (
<FormField
id={`survey-question-${question.variable}`}
name={question.variable}
name={`survey_${question.variable}`}
label={question.question_name}
tooltip={question.question_description}
isRequired={question.required}
@ -126,7 +75,7 @@ function NumberField({ question, i18n }) {
return (
<FormField
id={`survey-question-${question.variable}`}
name={question.variable}
name={`survey_${question.variable}`}
label={question.question_name}
tooltip={question.question_description}
isRequired={question.required}
@ -139,7 +88,7 @@ function NumberField({ question, i18n }) {
}
function MultipleChoiceField({ question }) {
const [field, meta] = useField(question.variable);
const [field, meta] = useField(`survey_${question.variable}`);
const id = `survey-question-${question.variable}`;
const isValid = !(meta.touched && meta.error);
return (
@ -167,7 +116,7 @@ function MultipleChoiceField({ question }) {
function MultiSelectField({ question }) {
const [isOpen, setIsOpen] = useState(false);
const [field, meta, helpers] = useField(question.variable);
const [field, meta, helpers] = useField(`survey_${question.variable}`);
const id = `survey-question-${question.variable}`;
const isValid = !(meta.touched && meta.error);
return (

View File

@ -0,0 +1,49 @@
import React from 'react';
import { t } from '@lingui/macro';
import CredentialsStep from './CredentialsStep';
const STEP_ID = 'credentials';
export default function useCredentialsStep(
config,
resource,
visitedSteps,
i18n
) {
const validate = () => {
return {};
};
return {
step: getStep(config, i18n),
initialValues: getInitialValues(config, resource),
validate,
isReady: true,
error: null,
setTouched: setFieldsTouched => {
setFieldsTouched({
credentials: true,
});
},
};
}
function getStep(config, i18n) {
if (!config.ask_credential_on_launch) {
return null;
}
return {
id: STEP_ID,
name: i18n._(t`Credentials`),
component: <CredentialsStep i18n={i18n} />,
};
}
function getInitialValues(config, resource) {
if (!config.ask_credential_on_launch) {
return {};
}
return {
credentials: resource?.summary_fields?.credentials || [],
};
}

View File

@ -0,0 +1,54 @@
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, 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, hasErrors, i18n),
initialValues: getInitialValues(config, resource),
validate,
isReady: true,
error: null,
setTouched: setFieldsTouched => {
setFieldsTouched({
inventory: true,
});
},
};
}
function getStep(config, hasErrors, i18n) {
if (!config.ask_inventory_on_launch) {
return null;
}
return {
id: STEP_ID,
name: <StepName hasErrors={hasErrors}>{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,
};
}

View File

@ -0,0 +1,93 @@
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, 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, hasErrors, i18n),
initialValues: getInitialValues(config, resource),
validate,
isReady: true,
error: null,
setTouched: setFieldsTouched => {
setFieldsTouched({
job_type: true,
limit: true,
verbosity: true,
diff_mode: true,
job_tags: true,
skip_tags: true,
extra_vars: true,
});
},
};
}
function getStep(config, hasErrors, i18n) {
if (!shouldShowPrompt(config)) {
return null;
}
return {
id: STEP_ID,
name: <StepName hasErrors={hasErrors}>{i18n._(t`Other Prompts`)}</StepName>,
component: <OtherPromptsStep config={config} i18n={i18n} />,
};
}
function shouldShowPrompt(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 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

@ -0,0 +1,35 @@
import React from 'react';
import { t } from '@lingui/macro';
import PreviewStep from './PreviewStep';
const STEP_ID = 'preview';
export default function usePreviewStep(
config,
resource,
survey,
formErrors,
i18n
) {
return {
step: {
id: STEP_ID,
name: i18n._(t`Preview`),
component: (
<PreviewStep
config={config}
resource={resource}
survey={survey}
formErrors={formErrors}
/>
),
enableNext: Object.keys(formErrors).length === 0,
nextButtonText: i18n._(t`Launch`),
},
initialValues: {},
validate: () => ({}),
isReady: true,
error: null,
setTouched: () => {},
};
}

View File

@ -0,0 +1,119 @@
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, visitedSteps, i18n) {
const [stepErrors, setStepErrors] = useState({});
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);
return data;
}, [config.survey_enabled, resource])
);
useEffect(() => {
fetchSurvey();
}, [fetchSurvey]);
const validate = values => {
if (!config.survey_enabled || !survey || !survey.spec) {
return {};
}
const errors = {};
survey.spec.forEach(question => {
const errMessage = validateField(
question,
values[`survey_${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, hasErrors, i18n),
initialValues: getInitialValues(config, survey),
validate,
survey,
isReady: !isLoading && !!survey,
error,
setTouched: setFieldsTouched => {
if (!survey) {
return;
}
const fields = {};
survey.spec.forEach(question => {
fields[`survey_${question.variable}`] = true;
});
setFieldsTouched(fields);
},
};
}
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: <StepName hasErrors={hasErrors}>{i18n._(t`Survey`)}</StepName>,
component: <SurveyStep survey={survey} i18n={i18n} />,
};
}
function getInitialValues(config, survey) {
if (!config.survey_enabled || !survey) {
return {};
}
const values = {};
survey.spec.forEach(question => {
if (question.type === 'multiselect') {
values[`survey_${question.variable}`] = question.default.split('\n');
} else {
values[`survey_${question.variable}`] = question.default;
}
});
return values;
}

View File

@ -0,0 +1,68 @@
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),
];
steps.push(
usePreviewStep(
config,
resource,
steps[3].survey,
{}, // TODO: formErrors ?
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.error);
const contentError = stepWithError ? stepWithError.error : 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,
};
}

View File

@ -77,81 +77,7 @@ function omitOverrides(resource, overrides) {
return clonedResource;
}
// TODO: When prompting is hooked up, update function
// to filter based on prompt overrides
function partitionPromptDetails(resource, launchConfig) {
const { defaults = {} } = launchConfig;
const overrides = {};
if (launchConfig.ask_credential_on_launch) {
let isEqual;
const defaultCreds = defaults.credentials;
const currentCreds = resource?.summary_fields?.credentials;
if (defaultCreds?.length === currentCreds?.length) {
isEqual = currentCreds.every(cred => {
return defaultCreds.some(item => item.id === cred.id);
});
} else {
isEqual = false;
}
if (!isEqual) {
overrides.credentials = resource?.summary_fields?.credentials;
}
}
if (launchConfig.ask_diff_mode_on_launch) {
if (defaults.diff_mode !== resource.diff_mode) {
overrides.diff_mode = resource.diff_mode;
}
}
if (launchConfig.ask_inventory_on_launch) {
if (defaults.inventory.id !== resource.inventory) {
overrides.inventory = resource?.summary_fields?.inventory;
}
}
if (launchConfig.ask_job_type_on_launch) {
if (defaults.job_type !== resource.job_type) {
overrides.job_type = resource.job_type;
}
}
if (launchConfig.ask_limit_on_launch) {
if (defaults.limit !== resource.limit) {
overrides.limit = resource.limit;
}
}
if (launchConfig.ask_scm_branch_on_launch) {
if (defaults.scm_branch !== resource.scm_branch) {
overrides.scm_branch = resource.scm_branch;
}
}
if (launchConfig.ask_skip_tags_on_launch) {
if (defaults.skip_tags !== resource.skip_tags) {
overrides.skip_tags = resource.skip_tags;
}
}
if (launchConfig.ask_tags_on_launch) {
if (defaults.job_tags !== resource.job_tags) {
overrides.job_tags = resource.job_tags;
}
}
if (launchConfig.ask_variables_on_launch) {
if (defaults.extra_vars !== resource.extra_vars) {
overrides.extra_vars = resource.extra_vars;
}
}
if (launchConfig.ask_verbosity_on_launch) {
if (defaults.verbosity !== resource.verbosity) {
overrides.verbosity = resource.verbosity;
}
}
const withoutOverrides = omitOverrides(resource, overrides);
return [withoutOverrides, overrides];
}
function PromptDetail({ i18n, resource, launchConfig = {} }) {
function PromptDetail({ i18n, resource, launchConfig = {}, overrides = {} }) {
const VERBOSITY = {
0: i18n._(t`0 (Normal)`),
1: i18n._(t`1 (Verbose)`),
@ -160,7 +86,7 @@ function PromptDetail({ i18n, resource, launchConfig = {} }) {
4: i18n._(t`4 (Connection Debug)`),
};
const [details, overrides] = partitionPromptDetails(resource, launchConfig);
const details = omitOverrides(resource, overrides);
const hasOverrides = Object.keys(overrides).length > 0;
return (

View File

@ -67,7 +67,7 @@ describe('PromptDetail', () => {
expect(wrapper.find(`Detail[label="${label}"] dd`).text()).toBe(value);
}
expect(wrapper.find('PromptDetail h2').text()).toBe('Prompted Values');
expect(wrapper.find('PromptDetail h2')).toHaveLength(0);
assertDetail('Name', 'Mock JT');
assertDetail('Description', 'Mock JT Description');
assertDetail('Type', 'Job Template');
@ -143,4 +143,74 @@ describe('PromptDetail', () => {
expect(overrideDetails.find('VariablesDetail').length).toBe(0);
});
});
describe('with overrides', () => {
let wrapper;
const overrides = {
extra_vars: '---one: two\nbar: baz',
inventory: {
name: 'Override inventory',
},
};
beforeAll(() => {
wrapper = mountWithContexts(
<PromptDetail
launchConfig={mockPromptLaunch}
resource={{
...mockTemplate,
ask_inventory_on_launch: true,
}}
overrides={overrides}
/>
);
});
afterAll(() => {
wrapper.unmount();
});
test('should render overridden details', () => {
function assertDetail(label, value) {
expect(wrapper.find(`Detail[label="${label}"] dt`).text()).toBe(label);
expect(wrapper.find(`Detail[label="${label}"] dd`).text()).toBe(value);
}
expect(wrapper.find('PromptDetail h2').text()).toBe('Prompted Values');
assertDetail('Name', 'Mock JT');
assertDetail('Description', 'Mock JT Description');
assertDetail('Type', 'Job Template');
assertDetail('Job Type', 'Run');
assertDetail('Inventory', 'Override inventory');
assertDetail('Source Control Branch', 'Foo branch');
assertDetail('Limit', 'alpha:beta');
assertDetail('Verbosity', '3 (Debug)');
assertDetail('Show Changes', 'Off');
expect(wrapper.find('VariablesDetail').prop('value')).toEqual(
'---one: two\nbar: baz'
);
expect(
wrapper
.find('Detail[label="Credentials"]')
.containsAllMatchingElements([
<span>
<strong>SSH:</strong>Credential 1
</span>,
<span>
<strong>Awx:</strong>Credential 2
</span>,
])
).toEqual(true);
expect(
wrapper
.find('Detail[label="Job Tags"]')
.containsAnyMatchingElements([<span>T_100</span>, <span>T_200</span>])
).toEqual(true);
expect(
wrapper
.find('Detail[label="Skip Tags"]')
.containsAllMatchingElements([<span>S_100</span>, <span>S_200</span>])
).toEqual(true);
});
});
});

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