1
0
mirror of https://github.com/ansible/awx.git synced 2024-10-30 13:55:31 +03:00

add survey form

This commit is contained in:
Keith Grant 2020-03-06 15:53:37 -08:00
parent e92acce4eb
commit 1412bf6232
7 changed files with 352 additions and 15 deletions

View File

@ -10625,7 +10625,8 @@
"ansi-regex": {
"version": "2.1.1",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"aproba": {
"version": "1.2.0",
@ -10646,12 +10647,14 @@
"balanced-match": {
"version": "1.0.0",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"brace-expansion": {
"version": "1.1.11",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@ -10666,17 +10669,20 @@
"code-point-at": {
"version": "1.1.0",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"concat-map": {
"version": "0.0.1",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"console-control-strings": {
"version": "1.1.0",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"core-util-is": {
"version": "1.0.2",
@ -10793,7 +10799,8 @@
"inherits": {
"version": "2.0.3",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"ini": {
"version": "1.3.5",
@ -10805,6 +10812,7 @@
"version": "1.0.0",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"number-is-nan": "^1.0.0"
}
@ -10819,6 +10827,7 @@
"version": "3.0.4",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"brace-expansion": "^1.1.7"
}
@ -10826,12 +10835,14 @@
"minimist": {
"version": "0.0.8",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"minipass": {
"version": "2.3.5",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"safe-buffer": "^5.1.2",
"yallist": "^3.0.0"
@ -10850,6 +10861,7 @@
"version": "0.5.1",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"minimist": "0.0.8"
}
@ -10930,7 +10942,8 @@
"number-is-nan": {
"version": "1.0.1",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"object-assign": {
"version": "4.1.1",
@ -10942,6 +10955,7 @@
"version": "1.4.0",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"wrappy": "1"
}
@ -11027,7 +11041,8 @@
"safe-buffer": {
"version": "5.1.2",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"safer-buffer": {
"version": "2.1.2",
@ -11063,6 +11078,7 @@
"version": "1.0.2",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"code-point-at": "^1.0.0",
"is-fullwidth-code-point": "^1.0.0",
@ -11082,6 +11098,7 @@
"version": "3.0.1",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"ansi-regex": "^2.0.0"
}
@ -11125,12 +11142,14 @@
"wrappy": {
"version": "1.0.2",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"yallist": {
"version": "3.0.3",
"bundled": true,
"dev": true
"dev": true,
"optional": true
}
}
},

View File

@ -8,6 +8,7 @@ import AlertModal from '@components/AlertModal';
import ErrorDetail from '@components/ErrorDetail';
import useRequest, { useDismissableError } from '@util/useRequest';
import SurveyList from './shared/SurveyList';
import SurveyQuestionAdd from './shared/SurveyQuestionAdd';
function TemplateSurvey({ template, i18n }) {
const [surveyEnabled, setSurveyEnabled] = useState(template.survey_enabled);
@ -37,6 +38,12 @@ function TemplateSurvey({ template, i18n }) {
[template.id, setSurvey]
)
);
const updateSurveySpec = spec => {
updateSurvey({
...survey,
spec,
});
};
const { request: deleteSurvey, error: deleteError } = useRequest(
useCallback(async () => {
@ -64,13 +71,16 @@ function TemplateSurvey({ template, i18n }) {
return (
<>
<Switch>
<Route path="/templates/:templateType/:id/survey">
<Route path="/templates/:templateType/:id/survey/add">
<SurveyQuestionAdd survey={survey} updateSurvey={updateSurveySpec} />
</Route>
<Route path="/templates/:templateType/:id/survey" exact>
<SurveyList
isLoading={isLoading}
survey={survey}
surveyEnabled={surveyEnabled}
toggleSurvey={toggleSurvey}
updateSurvey={spec => updateSurvey({ ...survey, spec })}
updateSurvey={updateSurveySpec}
deleteSurvey={deleteSurvey}
/>
</Route>

View File

@ -0,0 +1,27 @@
import React, { useState } from 'react';
import { useHistory, useRouteMatch } from 'react-router-dom';
import { CardBody } from '@components/Card';
import SurveyQuestionForm from './SurveyQuestionForm';
export default function SurveyQuestionAdd({ template }) {
const [formError, setFormError] = useState(null);
const history = useHistory();
const match = useRouteMatch();
const handleSubmit = async formData => {
//
};
const handleCancel = () => {
history.push(match.url.replace('/add', ''));
};
return (
<CardBody>
<SurveyQuestionForm
handleSubmit={handleSubmit}
handleCancel={handleCancel}
/>
</CardBody>
);
}

View File

@ -0,0 +1,231 @@
import React from 'react';
import { func, string, bool, number, shape } from 'prop-types';
import { Formik, useField } from 'formik';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Form, FormGroup } from '@patternfly/react-core';
import { FormColumnLayout } from '@components/FormLayout';
import FormActionGroup from '@components/FormActionGroup/FormActionGroup';
import FormField, {
CheckboxField,
PasswordField,
FormSubmitError,
FieldTooltip,
} from '@components/FormField';
import AnsibleSelect from '@components/AnsibleSelect';
import { required, noWhiteSpace, combine } from '@util/validators';
function AnswerType({ i18n }) {
const [field] = useField({
name: 'type',
validate: required(i18n._(t`Select a value for this field`), i18n),
});
return (
<FormGroup
label={i18n._(t`Answer Type`)}
isRequired
fieldId="question-answer-type"
>
<FieldTooltip
content={i18n._(
t`Choose an answer type or format you want as the prompt for the user.
Refer to the Ansible Tower Documentation for more additional
information about each option.`
)}
/>
<AnsibleSelect
id="question-type"
{...field}
data={[
{ key: 'text', value: 'text', label: i18n._(t`Text`) },
{ key: 'textarea', value: 'textarea', label: i18n._(t`Textarea`) },
{ key: 'password', value: 'password', label: i18n._(t`Password`) },
{
key: 'multiplechoice',
value: 'multiplechoice',
label: i18n._(t`Multiple Choice (single select)`),
},
{
key: 'multiselect',
value: 'multiselect',
label: i18n._(t`Multiple Choice (multiple select)`),
},
{ key: 'integer', value: 'integer', label: i18n._(t`Integer`) },
{ key: 'float', value: 'float', label: i18n._(t`Float`) },
]}
/>
</FormGroup>
);
}
function SurveyQuestionForm({
question,
handleSubmit,
handleCancel,
submitError,
i18n,
}) {
return (
<Formik
initialValues={{
question_name: question?.question_name || '',
question_description: question?.question_description || '',
required: question ? question?.required : true,
type: question?.type || 'text',
variable: question?.variable || '',
min: question?.min || 0,
max: question?.max || 1024,
default: question?.default,
choices: question?.choices,
new_question: !question,
}}
onSubmit={handleSubmit}
>
{formik => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormColumnLayout>
<FormField
id="question-name"
name="question_name"
type="text"
label={i18n._(t`Question`)}
validate={required(null, i18n)}
isRequired
/>
<FormField
id="question-description"
name="question_description"
type="text"
label={i18n._(t`Description`)}
/>
<FormField
id="question-variable"
name="variable"
type="text"
label={i18n._(t`Answer Variable Name`)}
validate={combine([noWhiteSpace(i18n), required(null, i18n)])}
isRequired
tooltip={i18n._(
t`The suggested format for variable names is lowercase and
underscore-separated (for example, foo_bar, user_id, host_name,
etc.). Variable names with spaces are not allowed.`
)}
/>
<AnswerType i18n={i18n} />
<CheckboxField
id="question-required"
name="required"
label="Required"
/>
</FormColumnLayout>
<FormColumnLayout>
{['text', 'textarea', 'password'].includes(formik.values.type) && (
<>
<FormField
id="question-min"
name="min"
type="number"
label={i18n._(t`Minimum length`)}
/>
<FormField
id="question-max"
name="max"
type="number"
label={i18n._(t`Maximum length`)}
/>
</>
)}
{['integer', 'float'].includes(formik.values.type) && (
<>
<FormField
id="question-min"
name="min"
type="number"
label={i18n._(t`Minimum`)}
/>
<FormField
id="question-max"
name="max"
type="number"
label={i18n._(t`Maximum`)}
/>
</>
)}
{['text', 'integer', 'float'].includes(formik.values.type) && (
<FormField
id="question-default"
name="default"
type="text"
label={i18n._(t`Default answer`)}
/>
)}
{formik.values.type === 'textarea' && (
<FormField
id="question-default"
name="default"
type="textarea"
label={i18n._(t`Default answer`)}
/>
)}
{formik.values.type === 'password' && (
<PasswordField
id="question-default"
name="default"
label={i18n._(t`Default answer`)}
/>
)}
{['multiplechoice', 'multiselect'].includes(formik.values.type) && (
<>
<FormField
id="question-options"
name="choices"
type="textarea"
label={i18n._(t`Multiple Choice Options`)}
validate={required(null, i18n)}
isRequired
/>
<FormField
id="question-default"
name="default"
type={
formik.values.type === 'multiplechoice'
? 'text'
: 'textarea'
}
label={i18n._(t`Default answer`)}
/>
</>
)}
</FormColumnLayout>
<FormSubmitError error={submitError} />
<FormActionGroup
onCancel={handleCancel}
onSubmit={formik.handleSubmit}
/>
</Form>
)}
</Formik>
);
}
SurveyQuestionForm.propTypes = {
question: shape({
question_name: string.isRequired,
question_description: string.isRequired,
required: bool,
type: string.isRequired,
min: number,
max: number,
}),
handleSubmit: func.isRequired,
handleCancel: func.isRequired,
submitError: shape({}),
};
SurveyQuestionForm.defaultProps = {
question: null,
submitError: null,
};
export default withI18n()(SurveyQuestionForm);

View File

@ -1,4 +1,5 @@
import React from 'react';
import { useRouteMatch } from 'react-router-dom';
import { t } from '@lingui/macro';
import { withI18n } from '@lingui/react';
@ -20,6 +21,7 @@ function SurveyToolbar({
isDeleteDisabled,
onToggleDeleteModal,
}) {
const match = useRouteMatch();
return (
<DataToolbar id="survey-toolbar">
<DataToolbarContent>
@ -45,7 +47,7 @@ function SurveyToolbar({
</DataToolbarItem>
<DataToolbarGroup>
<DataToolbarItem>
<ToolbarAddButton linkTo="/" />
<ToolbarAddButton linkTo={`${match.url}/add`} />
</DataToolbarItem>
<DataToolbarItem>
<Button

View File

@ -47,3 +47,24 @@ export function requiredEmail(i18n) {
return undefined;
};
}
export function noWhiteSpace(i18n) {
return value => {
if (/\s/.test(value)) {
return i18n._(t`This field must not contain spaces`);
}
return undefined;
};
}
export function combine(validators) {
return value => {
for (let i = 0; i < validators.length; i++) {
const error = validators[i](value);
if (error) {
return error;
}
}
return undefined;
};
}

View File

@ -1,4 +1,4 @@
import { required, maxLength } from './validators';
import { required, maxLength, noWhiteSpace, combine } from './validators';
const i18n = { _: val => val };
@ -51,4 +51,31 @@ describe('validators', () => {
values: { max: 8 },
});
});
test('noWhiteSpace returns error', () => {
expect(noWhiteSpace(i18n)('this has spaces')).toEqual({
id: 'This field must not contain spaces',
});
expect(noWhiteSpace(i18n)('this has\twhitespace')).toEqual({
id: 'This field must not contain spaces',
});
expect(noWhiteSpace(i18n)('this\nhas\nnewlines')).toEqual({
id: 'This field must not contain spaces',
});
});
test('noWhiteSpace should accept valid string', () => {
expect(noWhiteSpace(i18n)('this_has_no_whitespace')).toBeUndefined();
});
test('combine should run all validators', () => {
const validators = [required(null, i18n), noWhiteSpace(i18n)];
expect(combine(validators)('')).toEqual({
id: 'This field must not be blank',
});
expect(combine(validators)('one two')).toEqual({
id: 'This field must not contain spaces',
});
expect(combine(validators)('ok')).toBeUndefined();
});
});