mirror of
https://github.com/ansible/awx.git
synced 2026-01-22 23:18:03 -03:30
Merge pull request #6768 from keithjgrant/5909-jt-launch-3b
JT Launch Prompting (phase 3) [rebuilt branch] Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
commit
28e27c5196
@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { oneOf, bool, number, string, func } from 'prop-types';
|
||||
import { Controlled as ReactCodeMirror } from 'react-codemirror2';
|
||||
import styled from 'styled-components';
|
||||
@ -67,6 +67,20 @@ function CodeMirrorInput({
|
||||
fullHeight,
|
||||
className,
|
||||
}) {
|
||||
// Workaround for CodeMirror bug: If CodeMirror renders in a modal on the
|
||||
// modal's initial render, it appears as an empty box due to mis-calculated
|
||||
// element height. Forcing an initial render before mounting <CodeMirror>
|
||||
// fixes this.
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!isInitialized) {
|
||||
setIsInitialized(true);
|
||||
}
|
||||
}, [isInitialized]);
|
||||
if (!isInitialized) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
return (
|
||||
<CodeMirror
|
||||
className={`pf-c-form-control ${className}`}
|
||||
|
||||
@ -8,19 +8,26 @@ const QuestionCircleIcon = styled(PFQuestionCircleIcon)`
|
||||
margin-left: 10px;
|
||||
`;
|
||||
|
||||
function FieldTooltip({ content }) {
|
||||
function FieldTooltip({ content, ...rest }) {
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Tooltip
|
||||
position="right"
|
||||
content={content}
|
||||
trigger="click mouseenter focus"
|
||||
{...rest}
|
||||
>
|
||||
<QuestionCircleIcon />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
FieldTooltip.propTypes = {
|
||||
content: node.isRequired,
|
||||
content: node,
|
||||
};
|
||||
FieldTooltip.defaultProps = {
|
||||
content: null,
|
||||
};
|
||||
|
||||
export default FieldTooltip;
|
||||
|
||||
@ -1,18 +1,8 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useField } from 'formik';
|
||||
import {
|
||||
FormGroup,
|
||||
TextInput,
|
||||
TextArea,
|
||||
Tooltip,
|
||||
} from '@patternfly/react-core';
|
||||
import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const QuestionCircleIcon = styled(PFQuestionCircleIcon)`
|
||||
margin-left: 10px;
|
||||
`;
|
||||
import { FormGroup, TextInput, TextArea } from '@patternfly/react-core';
|
||||
import FieldTooltip from './FieldTooltip';
|
||||
|
||||
function FormField(props) {
|
||||
const {
|
||||
@ -40,15 +30,7 @@ function FormField(props) {
|
||||
isValid={isValid}
|
||||
label={label}
|
||||
>
|
||||
{tooltip && (
|
||||
<Tooltip
|
||||
content={tooltip}
|
||||
maxWidth={tooltipMaxWidth}
|
||||
position="right"
|
||||
>
|
||||
<QuestionCircleIcon />
|
||||
</Tooltip>
|
||||
)}
|
||||
<FieldTooltip content={tooltip} maxWidth={tooltipMaxWidth} />
|
||||
<TextArea
|
||||
id={id}
|
||||
isRequired={isRequired}
|
||||
@ -69,15 +51,7 @@ function FormField(props) {
|
||||
isValid={isValid}
|
||||
label={label}
|
||||
>
|
||||
{tooltip && (
|
||||
<Tooltip
|
||||
content={tooltip}
|
||||
maxWidth={tooltipMaxWidth}
|
||||
position="right"
|
||||
>
|
||||
<QuestionCircleIcon />
|
||||
</Tooltip>
|
||||
)}
|
||||
<FieldTooltip content={tooltip} maxWidth={tooltipMaxWidth} />
|
||||
<TextInput
|
||||
id={id}
|
||||
isRequired={isRequired}
|
||||
|
||||
@ -8,6 +8,7 @@ import CredentialsStep from './CredentialsStep';
|
||||
import OtherPromptsStep from './OtherPromptsStep';
|
||||
import SurveyStep from './SurveyStep';
|
||||
import PreviewStep from './PreviewStep';
|
||||
import mergeExtraVars from './mergeExtraVars';
|
||||
|
||||
function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) {
|
||||
const steps = [];
|
||||
@ -69,9 +70,10 @@ function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) {
|
||||
});
|
||||
}
|
||||
if (config.survey_enabled) {
|
||||
initialValues.survey = {};
|
||||
steps.push({
|
||||
name: i18n._(t`Survey`),
|
||||
component: <SurveyStep />,
|
||||
component: <SurveyStep template={resource} />,
|
||||
});
|
||||
}
|
||||
steps.push({
|
||||
@ -93,7 +95,7 @@ function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) {
|
||||
setValue('limit', values.limit);
|
||||
setValue('job_tags', values.job_tags);
|
||||
setValue('skip_tags', values.skip_tags);
|
||||
setValue('extra_vars', values.extra_vars);
|
||||
setValue('extra_vars', mergeExtraVars(values.extra_vars, values.survey));
|
||||
onLaunch(postValues);
|
||||
};
|
||||
|
||||
|
||||
@ -1,7 +1,204 @@
|
||||
import React from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { Formik, useField } from 'formik';
|
||||
import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '@api';
|
||||
import {
|
||||
Form,
|
||||
FormGroup,
|
||||
Select,
|
||||
SelectOption,
|
||||
SelectVariant,
|
||||
} 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,
|
||||
maxLength,
|
||||
minLength,
|
||||
integer,
|
||||
combine,
|
||||
} from '@util/validators';
|
||||
|
||||
function InventoryStep() {
|
||||
return <div />;
|
||||
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} />
|
||||
);
|
||||
}
|
||||
|
||||
export default InventoryStep;
|
||||
// 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 */
|
||||
}, []);
|
||||
|
||||
const fieldTypes = {
|
||||
text: TextField,
|
||||
textarea: TextField,
|
||||
password: TextField,
|
||||
multiplechoice: MultipleChoiceField,
|
||||
multiselect: MultiSelectField,
|
||||
integer: NumberField,
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function TextField({ question, i18n }) {
|
||||
const validators = [
|
||||
question.required ? required(null, i18n) : null,
|
||||
question.min ? minLength(question.min, i18n) : null,
|
||||
question.max ? maxLength(question.max, i18n) : null,
|
||||
];
|
||||
return (
|
||||
<FormField
|
||||
id={`survey-question-${question.variable}`}
|
||||
name={question.variable}
|
||||
label={question.question_name}
|
||||
tooltip={question.question_description}
|
||||
isRequired={question.required}
|
||||
validate={combine(validators)}
|
||||
type={question.type}
|
||||
minLength={question.min}
|
||||
maxLength={question.max}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberField({ question, i18n }) {
|
||||
const validators = [
|
||||
question.required ? required(null, i18n) : null,
|
||||
minMaxValue(question.min, question.max, i18n),
|
||||
question.type === 'integer' ? integer(i18n) : null,
|
||||
];
|
||||
return (
|
||||
<FormField
|
||||
id={`survey-question-${question.variable}`}
|
||||
name={question.variable}
|
||||
label={question.question_name}
|
||||
tooltip={question.question_description}
|
||||
isRequired={question.required}
|
||||
validate={combine(validators)}
|
||||
type="number"
|
||||
min={question.min}
|
||||
max={question.max}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MultipleChoiceField({ question }) {
|
||||
const [field, meta] = useField(question.variable);
|
||||
const id = `survey-question-${question.variable}`;
|
||||
const isValid = !(meta.touched && meta.error);
|
||||
return (
|
||||
<FormGroup
|
||||
fieldId={id}
|
||||
helperTextInvalid={meta.error}
|
||||
isRequired={question.required}
|
||||
isValid={isValid}
|
||||
label={question.question_name}
|
||||
>
|
||||
<FieldTooltip content={question.question_description} />
|
||||
<AnsibleSelect
|
||||
id={id}
|
||||
isValid={isValid}
|
||||
{...field}
|
||||
data={question.choices.split('\n').map(opt => ({
|
||||
key: opt,
|
||||
value: opt,
|
||||
label: opt,
|
||||
}))}
|
||||
/>
|
||||
</FormGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiSelectField({ question }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [field, meta, helpers] = useField(question.variable);
|
||||
const id = `survey-question-${question.variable}`;
|
||||
const isValid = !(meta.touched && meta.error);
|
||||
return (
|
||||
<FormGroup
|
||||
fieldId={id}
|
||||
helperTextInvalid={meta.error}
|
||||
isRequired={question.required}
|
||||
isValid={isValid}
|
||||
label={question.question_name}
|
||||
>
|
||||
<FieldTooltip content={question.question_description} />
|
||||
<Select
|
||||
variant={SelectVariant.typeaheadMulti}
|
||||
id={id}
|
||||
onToggle={setIsOpen}
|
||||
onSelect={(event, option) => {
|
||||
if (field.value.includes(option)) {
|
||||
helpers.setValue(field.value.filter(o => o !== option));
|
||||
} else {
|
||||
helpers.setValue(field.value.concat(option));
|
||||
}
|
||||
}}
|
||||
isExpanded={isOpen}
|
||||
selections={field.value}
|
||||
>
|
||||
{question.choices.split('\n').map(opt => (
|
||||
<SelectOption key={opt} value={opt} />
|
||||
))}
|
||||
</Select>
|
||||
</FormGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n()(SurveyStep);
|
||||
|
||||
11
awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.js
Normal file
11
awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.js
Normal file
@ -0,0 +1,11 @@
|
||||
import yaml from 'js-yaml';
|
||||
|
||||
export default function mergeExtraVars(extraVars, survey = {}) {
|
||||
const vars = yaml.safeLoad(extraVars) || {};
|
||||
return {
|
||||
...vars,
|
||||
...survey,
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: "safe" version that obscures passwords for preview step
|
||||
@ -0,0 +1,34 @@
|
||||
import mergeExtraVars from './mergeExtraVars';
|
||||
|
||||
describe('mergeExtraVars', () => {
|
||||
test('should handle yaml string', () => {
|
||||
const yaml = '---\none: 1\ntwo: 2';
|
||||
expect(mergeExtraVars(yaml)).toEqual({
|
||||
one: 1,
|
||||
two: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle json string', () => {
|
||||
const jsonString = '{"one": 1, "two": 2}';
|
||||
expect(mergeExtraVars(jsonString)).toEqual({
|
||||
one: 1,
|
||||
two: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle empty string', () => {
|
||||
expect(mergeExtraVars('')).toEqual({});
|
||||
});
|
||||
|
||||
test('should merge survey results into extra vars object', () => {
|
||||
const yaml = '---\none: 1\ntwo: 2';
|
||||
const survey = { foo: 'bar', bar: 'baz' };
|
||||
expect(mergeExtraVars(yaml, survey)).toEqual({
|
||||
one: 1,
|
||||
two: 2,
|
||||
foo: 'bar',
|
||||
bar: 'baz',
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -27,7 +27,6 @@ describe('StatusIcon', () => {
|
||||
});
|
||||
test('renders a successful status when host status is "ok"', () => {
|
||||
const wrapper = mount(<StatusIcon status="ok" />);
|
||||
wrapper.debug();
|
||||
expect(wrapper).toHaveLength(1);
|
||||
expect(wrapper.find('StatusIcon__SuccessfulTop')).toHaveLength(1);
|
||||
expect(wrapper.find('StatusIcon__SuccessfulBottom')).toHaveLength(1);
|
||||
|
||||
@ -199,6 +199,7 @@ function SurveyQuestionForm({
|
||||
t`Each answer choice must be on a separate line.`
|
||||
)}
|
||||
isRequired
|
||||
rows="10"
|
||||
/>
|
||||
<FormField
|
||||
id="question-default"
|
||||
|
||||
@ -25,6 +25,15 @@ export function maxLength(max, i18n) {
|
||||
};
|
||||
}
|
||||
|
||||
export function minLength(min, i18n) {
|
||||
return value => {
|
||||
if (value.trim().length < min) {
|
||||
return i18n._(t`This field must be at least ${min} characters`);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
}
|
||||
|
||||
export function minMaxValue(min, max, i18n) {
|
||||
return value => {
|
||||
if (value < min || value > max) {
|
||||
@ -57,10 +66,21 @@ export function noWhiteSpace(i18n) {
|
||||
};
|
||||
}
|
||||
|
||||
export function integer(i18n) {
|
||||
return value => {
|
||||
const str = String(value);
|
||||
if (/[^0-9]/.test(str)) {
|
||||
return i18n._(t`This field must be an integer`);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
}
|
||||
|
||||
export function combine(validators) {
|
||||
return value => {
|
||||
for (let i = 0; i < validators.length; i++) {
|
||||
const error = validators[i](value);
|
||||
const validate = validators[i];
|
||||
const error = validate ? validate(value) : null;
|
||||
if (error) {
|
||||
return error;
|
||||
}
|
||||
|
||||
@ -1,4 +1,11 @@
|
||||
import { required, maxLength, noWhiteSpace, combine } from './validators';
|
||||
import {
|
||||
required,
|
||||
minLength,
|
||||
maxLength,
|
||||
noWhiteSpace,
|
||||
integer,
|
||||
combine,
|
||||
} from './validators';
|
||||
|
||||
const i18n = { _: val => val };
|
||||
|
||||
@ -52,6 +59,21 @@ describe('validators', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('minLength accepts value above min', () => {
|
||||
expect(minLength(3, i18n)('snazzy')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('minLength accepts value equal to min', () => {
|
||||
expect(minLength(10, i18n)('abracadbra')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('minLength rejects value below min', () => {
|
||||
expect(minLength(12, i18n)('abracadbra')).toEqual({
|
||||
id: 'This field must be at least {min} characters',
|
||||
values: { min: 12 },
|
||||
});
|
||||
});
|
||||
|
||||
test('noWhiteSpace returns error', () => {
|
||||
expect(noWhiteSpace(i18n)('this has spaces')).toEqual({
|
||||
id: 'This field must not contain spaces',
|
||||
@ -68,6 +90,26 @@ describe('validators', () => {
|
||||
expect(noWhiteSpace(i18n)('this_has_no_whitespace')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('integer should accept integer (number)', () => {
|
||||
expect(integer(i18n)(13)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('integer should accept integer (string)', () => {
|
||||
expect(integer(i18n)('13')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('integer should reject decimal/float', () => {
|
||||
expect(integer(i18n)(13.1)).toEqual({
|
||||
id: 'This field must be an integer',
|
||||
});
|
||||
});
|
||||
|
||||
test('integer should reject string containing alphanum', () => {
|
||||
expect(integer(i18n)('15a')).toEqual({
|
||||
id: 'This field must be an integer',
|
||||
});
|
||||
});
|
||||
|
||||
test('combine should run all validators', () => {
|
||||
const validators = [required(null, i18n), noWhiteSpace(i18n)];
|
||||
expect(combine(validators)('')).toEqual({
|
||||
@ -78,4 +120,12 @@ describe('validators', () => {
|
||||
});
|
||||
expect(combine(validators)('ok')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('combine should skip null validators', () => {
|
||||
const validators = [required(null, i18n), null];
|
||||
expect(combine(validators)('')).toEqual({
|
||||
id: 'This field must not be blank',
|
||||
});
|
||||
expect(combine(validators)('ok')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user