mirror of
https://github.com/ansible/awx.git
synced 2026-01-11 18:09:57 -03:30
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:
commit
deadf197a3
@ -1,5 +1,5 @@
|
||||
export default function getErrorMessage(response) {
|
||||
if (!response.data) {
|
||||
if (!response?.data) {
|
||||
return null;
|
||||
}
|
||||
if (typeof response.data === 'string') {
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -1,7 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
function PreviewStep() {
|
||||
return <div>Preview of selected values will appear here</div>;
|
||||
}
|
||||
|
||||
export default PreviewStep;
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 {
|
||||
@ -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;
|
||||
37
awx/ui_next/src/components/LaunchPrompt/steps/StepName.jsx
Normal file
37
awx/ui_next/src/components/LaunchPrompt/steps/StepName.jsx
Normal 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);
|
||||
@ -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 (
|
||||
@ -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 || [],
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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: () => {},
|
||||
};
|
||||
}
|
||||
119
awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx
Normal file
119
awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx
Normal 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;
|
||||
}
|
||||
68
awx/ui_next/src/components/LaunchPrompt/useSteps.js
Normal file
68
awx/ui_next/src/components/LaunchPrompt/useSteps.js
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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 (
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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),
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user