mirror of
https://github.com/ansible/awx.git
synced 2024-10-30 13:55:31 +03:00
Merge pull request #6356 from keithjgrant/5899-survey-add-form
Survey add/edit forms Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
commit
5573e1c7ce
41
awx/ui_next/package-lock.json
generated
41
awx/ui_next/package-lock.json
generated
@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -84,6 +84,7 @@ function FormField(props) {
|
||||
isValid={isValid}
|
||||
{...rest}
|
||||
{...field}
|
||||
type={type}
|
||||
onChange={(value, event) => {
|
||||
field.onChange(event);
|
||||
}}
|
||||
|
@ -12,6 +12,12 @@ function RoutedTabs(props) {
|
||||
if (match) {
|
||||
return match.id;
|
||||
}
|
||||
const subpathMatch = tabsArray.find(tab =>
|
||||
history.location.pathname.startsWith(tab.link)
|
||||
);
|
||||
if (subpathMatch) {
|
||||
return subpathMatch.id;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
|
@ -26,7 +26,10 @@ describe('<HostAdd />', () => {
|
||||
let history;
|
||||
|
||||
beforeEach(async () => {
|
||||
history = createMemoryHistory();
|
||||
history = createMemoryHistory({
|
||||
initialEntries: ['/templates/job_templates/1/survey/edit/foo'],
|
||||
state: { some: 'state' },
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<HostAdd />, {
|
||||
context: { router: { history } },
|
||||
|
@ -95,7 +95,50 @@ function SurveyList({
|
||||
</DataList>
|
||||
);
|
||||
}
|
||||
|
||||
if (isDeleteModalOpen) {
|
||||
return (
|
||||
<AlertModal
|
||||
variant="danger"
|
||||
title={
|
||||
isAllSelected ? i18n._(t`Delete Survey`) : i18n._(t`Delete Questions`)
|
||||
}
|
||||
isOpen={isDeleteModalOpen}
|
||||
onClose={() => {
|
||||
setIsDeleteModalOpen(false);
|
||||
setSelected([]);
|
||||
}}
|
||||
actions={[
|
||||
<Button
|
||||
key="delete"
|
||||
variant="danger"
|
||||
aria-label={i18n._(t`confirm delete`)}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{i18n._(t`Delete`)}
|
||||
</Button>,
|
||||
<Button
|
||||
key="cancel"
|
||||
variant="secondary"
|
||||
aria-label={i18n._(t`cancel delete`)}
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(false);
|
||||
setSelected([]);
|
||||
}}
|
||||
>
|
||||
{i18n._(t`Cancel`)}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<div>{i18n._(t`This action will delete the following:`)}</div>
|
||||
{selected.map(question => (
|
||||
<span key={question.variable}>
|
||||
<strong>{question.question_name}</strong>
|
||||
<br />
|
||||
</span>
|
||||
))}
|
||||
</AlertModal>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<SurveyToolbar
|
@ -3,7 +3,7 @@ import { act } from 'react-dom/test-utils';
|
||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||
import SurveyList from './SurveyList';
|
||||
import { JobTemplatesAPI } from '@api';
|
||||
import mockJobTemplateData from './data.job_template.json';
|
||||
import mockJobTemplateData from '../shared/data.job_template.json';
|
||||
|
||||
jest.mock('@api/models/JobTemplates');
|
||||
|
@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Button as _Button,
|
||||
DataListAction as _DataListAction,
|
||||
@ -75,13 +76,13 @@ function SurveyListItem({
|
||||
/>
|
||||
<DataListItemCells
|
||||
dataListCells={[
|
||||
<DataListCell key={question.question_name}>
|
||||
<DataListCell key="name">
|
||||
<Link to={`survey/edit/${question.variable}`}>
|
||||
{question.question_name}
|
||||
</Link>
|
||||
</DataListCell>,
|
||||
<DataListCell key={question.type}>{question.type}</DataListCell>,
|
||||
<DataListCell key={question.default}>
|
||||
{question.default}
|
||||
</DataListCell>,
|
||||
<DataListCell key="type">{question.type}</DataListCell>,
|
||||
<DataListCell key="default">{question.default}</DataListCell>,
|
||||
]}
|
||||
/>
|
||||
</DataListItemRow>
|
@ -0,0 +1,42 @@
|
||||
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({ survey, updateSurvey }) {
|
||||
const [formError, setFormError] = useState(null);
|
||||
const history = useHistory();
|
||||
const match = useRouteMatch();
|
||||
|
||||
const handleSubmit = async question => {
|
||||
try {
|
||||
if (survey.spec?.some(q => q.variable === question.variable)) {
|
||||
setFormError(
|
||||
new Error(
|
||||
`Survey already contains a question with variable named “${question.variable}”`
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
const newSpec = survey.spec ? survey.spec.concat(question) : [question];
|
||||
await updateSurvey(newSpec);
|
||||
history.push(match.url.replace('/add', ''));
|
||||
} catch (err) {
|
||||
setFormError(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
history.push(match.url.replace('/add', ''));
|
||||
};
|
||||
|
||||
return (
|
||||
<CardBody>
|
||||
<SurveyQuestionForm
|
||||
handleSubmit={handleSubmit}
|
||||
handleCancel={handleCancel}
|
||||
submitError={formError}
|
||||
/>
|
||||
</CardBody>
|
||||
);
|
||||
}
|
@ -0,0 +1,126 @@
|
||||
import React from 'react';
|
||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import SurveyQuestionAdd from './SurveyQuestionAdd';
|
||||
|
||||
const survey = {
|
||||
spec: [
|
||||
{
|
||||
question_name: 'What is the foo?',
|
||||
question_description: 'more about the foo',
|
||||
variable: 'foo',
|
||||
required: true,
|
||||
type: 'text',
|
||||
min: 0,
|
||||
max: 1024,
|
||||
},
|
||||
{
|
||||
question_name: 'Who shot the sheriff?',
|
||||
question_description: 'they did not shoot the deputy',
|
||||
variable: 'bar',
|
||||
required: true,
|
||||
type: 'textarea',
|
||||
min: 0,
|
||||
max: 1024,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('<SurveyQuestionAdd />', () => {
|
||||
let updateSurvey;
|
||||
|
||||
beforeEach(() => {
|
||||
updateSurvey = jest.fn();
|
||||
});
|
||||
|
||||
test('should render form', () => {
|
||||
let wrapper;
|
||||
act(() => {
|
||||
wrapper = mountWithContexts(
|
||||
<SurveyQuestionAdd survey={survey} updateSurvey={updateSurvey} />
|
||||
);
|
||||
});
|
||||
|
||||
expect(wrapper.find('SurveyQuestionForm')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('should call updateSurvey', () => {
|
||||
let wrapper;
|
||||
act(() => {
|
||||
wrapper = mountWithContexts(
|
||||
<SurveyQuestionAdd survey={survey} updateSurvey={updateSurvey} />
|
||||
);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
wrapper.find('SurveyQuestionForm').invoke('handleSubmit')({
|
||||
question_name: 'new question',
|
||||
variable: 'question',
|
||||
type: 'text',
|
||||
});
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
expect(updateSurvey).toHaveBeenCalledWith([
|
||||
...survey.spec,
|
||||
{
|
||||
question_name: 'new question',
|
||||
variable: 'question',
|
||||
type: 'text',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('should set formError', async () => {
|
||||
const realConsoleError = global.console.error;
|
||||
global.console.error = jest.fn();
|
||||
const err = new Error('oops');
|
||||
updateSurvey.mockImplementation(() => {
|
||||
throw err;
|
||||
});
|
||||
let wrapper;
|
||||
act(() => {
|
||||
wrapper = mountWithContexts(
|
||||
<SurveyQuestionAdd survey={survey} updateSurvey={updateSurvey} />
|
||||
);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
wrapper.find('SurveyQuestionForm').invoke('handleSubmit')({
|
||||
question_name: 'new question',
|
||||
variable: 'question',
|
||||
type: 'text',
|
||||
});
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.find('SurveyQuestionForm').prop('submitError')).toEqual(err);
|
||||
global.console.error = realConsoleError;
|
||||
});
|
||||
|
||||
test('should generate error for duplicate variable names', async () => {
|
||||
const realConsoleError = global.console.error;
|
||||
global.console.error = jest.fn();
|
||||
let wrapper;
|
||||
act(() => {
|
||||
wrapper = mountWithContexts(
|
||||
<SurveyQuestionAdd survey={survey} updateSurvey={updateSurvey} />
|
||||
);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
wrapper.find('SurveyQuestionForm').invoke('handleSubmit')({
|
||||
question_name: 'new question',
|
||||
variable: 'foo',
|
||||
type: 'text',
|
||||
});
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
const err = wrapper.find('SurveyQuestionForm').prop('submitError');
|
||||
expect(err.message).toEqual(
|
||||
'Survey already contains a question with variable named “foo”'
|
||||
);
|
||||
global.console.error = realConsoleError;
|
||||
});
|
||||
});
|
@ -0,0 +1,63 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useHistory, useRouteMatch } from 'react-router-dom';
|
||||
import ContentLoading from '@components/ContentLoading';
|
||||
import { CardBody } from '@components/Card';
|
||||
import SurveyQuestionForm from './SurveyQuestionForm';
|
||||
|
||||
export default function SurveyQuestionEdit({ survey, updateSurvey }) {
|
||||
const [formError, setFormError] = useState(null);
|
||||
const history = useHistory();
|
||||
const match = useRouteMatch();
|
||||
|
||||
if (!survey) {
|
||||
return <ContentLoading />;
|
||||
}
|
||||
|
||||
const question = survey.spec.find(q => q.variable === match.params.variable);
|
||||
|
||||
const navigateToList = () => {
|
||||
const index = match.url.indexOf('/edit');
|
||||
history.push(match.url.substr(0, index));
|
||||
};
|
||||
|
||||
const handleSubmit = async formData => {
|
||||
try {
|
||||
if (
|
||||
formData.variable !== question.variable &&
|
||||
survey.spec.find(q => q.variable === formData.variable)
|
||||
) {
|
||||
setFormError(
|
||||
new Error(
|
||||
`Survey already contains a question with variable named “${formData.variable}”`
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
const questionIndex = survey.spec.findIndex(
|
||||
q => q.variable === match.params.variable
|
||||
);
|
||||
if (questionIndex === -1) {
|
||||
throw new Error('Question not found in spec');
|
||||
}
|
||||
await updateSurvey([
|
||||
...survey.spec.slice(0, questionIndex),
|
||||
formData,
|
||||
...survey.spec.slice(questionIndex + 1),
|
||||
]);
|
||||
navigateToList();
|
||||
} catch (err) {
|
||||
setFormError(err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CardBody>
|
||||
<SurveyQuestionForm
|
||||
question={question}
|
||||
handleSubmit={handleSubmit}
|
||||
handleCancel={navigateToList}
|
||||
submitError={formError}
|
||||
/>
|
||||
</CardBody>
|
||||
);
|
||||
}
|
@ -0,0 +1,119 @@
|
||||
import React from 'react';
|
||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||
import { Switch, Route } from 'react-router-dom';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import SurveyQuestionEdit from './SurveyQuestionEdit';
|
||||
|
||||
const survey = {
|
||||
spec: [
|
||||
{
|
||||
question_name: 'What is the foo?',
|
||||
question_description: 'more about the foo',
|
||||
variable: 'foo',
|
||||
required: true,
|
||||
type: 'text',
|
||||
min: 0,
|
||||
max: 1024,
|
||||
},
|
||||
{
|
||||
question_name: 'Who shot the sheriff?',
|
||||
question_description: 'they did not shoot the deputy',
|
||||
variable: 'bar',
|
||||
required: true,
|
||||
type: 'textarea',
|
||||
min: 0,
|
||||
max: 1024,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('<SurveyQuestionEdit />', () => {
|
||||
let updateSurvey;
|
||||
let history;
|
||||
let wrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
history = createMemoryHistory({
|
||||
initialEntries: ['/templates/job_templates/1/survey/edit/foo'],
|
||||
});
|
||||
updateSurvey = jest.fn();
|
||||
act(() => {
|
||||
wrapper = mountWithContexts(
|
||||
<Switch>
|
||||
<Route path="/templates/:templateType/:id/survey/edit/:variable">
|
||||
<SurveyQuestionEdit survey={survey} updateSurvey={updateSurvey} />
|
||||
</Route>
|
||||
</Switch>,
|
||||
{
|
||||
context: { router: { history } },
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('should render form', () => {
|
||||
expect(wrapper.find('SurveyQuestionForm')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('should call updateSurvey', () => {
|
||||
act(() => {
|
||||
wrapper.find('SurveyQuestionForm').invoke('handleSubmit')({
|
||||
question_name: 'new question',
|
||||
variable: 'question',
|
||||
type: 'text',
|
||||
});
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
expect(updateSurvey).toHaveBeenCalledWith([
|
||||
{
|
||||
question_name: 'new question',
|
||||
variable: 'question',
|
||||
type: 'text',
|
||||
},
|
||||
survey.spec[1],
|
||||
]);
|
||||
});
|
||||
|
||||
test('should set formError', async () => {
|
||||
const realConsoleError = global.console.error;
|
||||
global.console.error = jest.fn();
|
||||
const err = new Error('oops');
|
||||
updateSurvey.mockImplementation(() => {
|
||||
throw err;
|
||||
});
|
||||
|
||||
act(() => {
|
||||
wrapper.find('SurveyQuestionForm').invoke('handleSubmit')({
|
||||
question_name: 'new question',
|
||||
variable: 'question',
|
||||
type: 'text',
|
||||
});
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.find('SurveyQuestionForm').prop('submitError')).toEqual(err);
|
||||
global.console.error = realConsoleError;
|
||||
});
|
||||
|
||||
test('should generate error for duplicate variable names', async () => {
|
||||
const realConsoleError = global.console.error;
|
||||
global.console.error = jest.fn();
|
||||
|
||||
act(() => {
|
||||
wrapper.find('SurveyQuestionForm').invoke('handleSubmit')({
|
||||
question_name: 'new question',
|
||||
variable: 'bar',
|
||||
type: 'text',
|
||||
});
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
const err = wrapper.find('SurveyQuestionForm').prop('submitError');
|
||||
expect(err.message).toEqual(
|
||||
'Survey already contains a question with variable named “bar”'
|
||||
);
|
||||
global.console.error = realConsoleError;
|
||||
});
|
||||
});
|
231
awx/ui_next/src/screens/Template/Survey/SurveyQuestionForm.jsx
Normal file
231
awx/ui_next/src/screens/Template/Survey/SurveyQuestionForm.jsx
Normal 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 AnswerTypeField({ 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.`
|
||||
)}
|
||||
/>
|
||||
<AnswerTypeField 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={formik.values.type === 'text' ? 'text' : 'number'}
|
||||
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);
|
@ -0,0 +1,228 @@
|
||||
import React from 'react';
|
||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import SurveyQuestionForm from './SurveyQuestionForm';
|
||||
|
||||
const question = {
|
||||
question_name: 'What is the foo?',
|
||||
question_description: 'more about the foo',
|
||||
variable: 'foo',
|
||||
required: true,
|
||||
type: 'text',
|
||||
min: 0,
|
||||
max: 1024,
|
||||
};
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
async function selectType(wrapper, type) {
|
||||
await act(async () => {
|
||||
wrapper.find('AnsibleSelect#question-type').invoke('onChange')({
|
||||
target: {
|
||||
name: 'type',
|
||||
value: type,
|
||||
},
|
||||
});
|
||||
});
|
||||
wrapper.update();
|
||||
}
|
||||
|
||||
describe('<SurveyQuestionForm />', () => {
|
||||
test('should render form', () => {
|
||||
let wrapper;
|
||||
|
||||
act(() => {
|
||||
wrapper = mountWithContexts(
|
||||
<SurveyQuestionForm
|
||||
question={question}
|
||||
handleSubmit={noop}
|
||||
handleCancel={noop}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
expect(wrapper.find('FormField#question-name input').prop('value')).toEqual(
|
||||
question.question_name
|
||||
);
|
||||
expect(
|
||||
wrapper.find('FormField#question-description input').prop('value')
|
||||
).toEqual(question.question_description);
|
||||
expect(
|
||||
wrapper.find('FormField#question-variable input').prop('value')
|
||||
).toEqual(question.variable);
|
||||
expect(
|
||||
wrapper.find('CheckboxField#question-required input').prop('checked')
|
||||
).toEqual(true);
|
||||
expect(wrapper.find('AnsibleSelect#question-type').prop('value')).toEqual(
|
||||
question.type
|
||||
);
|
||||
});
|
||||
|
||||
test('should provide fields for text question', () => {
|
||||
let wrapper;
|
||||
|
||||
act(() => {
|
||||
wrapper = mountWithContexts(
|
||||
<SurveyQuestionForm
|
||||
question={question}
|
||||
handleSubmit={noop}
|
||||
handleCancel={noop}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
expect(wrapper.find('FormField#question-min').prop('type')).toEqual(
|
||||
'number'
|
||||
);
|
||||
expect(wrapper.find('FormField#question-max').prop('type')).toEqual(
|
||||
'number'
|
||||
);
|
||||
expect(wrapper.find('FormField#question-default').prop('type')).toEqual(
|
||||
'text'
|
||||
);
|
||||
});
|
||||
|
||||
test('should provide fields for textarea question', async () => {
|
||||
let wrapper;
|
||||
|
||||
act(() => {
|
||||
wrapper = mountWithContexts(
|
||||
<SurveyQuestionForm
|
||||
question={question}
|
||||
handleSubmit={noop}
|
||||
handleCancel={noop}
|
||||
/>
|
||||
);
|
||||
});
|
||||
await selectType(wrapper, 'textarea');
|
||||
|
||||
expect(wrapper.find('FormField#question-min').prop('type')).toEqual(
|
||||
'number'
|
||||
);
|
||||
expect(wrapper.find('FormField#question-max').prop('type')).toEqual(
|
||||
'number'
|
||||
);
|
||||
expect(wrapper.find('FormField#question-default').prop('type')).toEqual(
|
||||
'textarea'
|
||||
);
|
||||
});
|
||||
|
||||
test('should provide fields for password question', async () => {
|
||||
let wrapper;
|
||||
|
||||
act(() => {
|
||||
wrapper = mountWithContexts(
|
||||
<SurveyQuestionForm
|
||||
question={question}
|
||||
handleSubmit={noop}
|
||||
handleCancel={noop}
|
||||
/>
|
||||
);
|
||||
});
|
||||
await selectType(wrapper, 'password');
|
||||
|
||||
expect(wrapper.find('FormField#question-min').prop('type')).toEqual(
|
||||
'number'
|
||||
);
|
||||
expect(wrapper.find('FormField#question-max').prop('type')).toEqual(
|
||||
'number'
|
||||
);
|
||||
expect(
|
||||
wrapper.find('PasswordField#question-default input').prop('type')
|
||||
).toEqual('password');
|
||||
});
|
||||
|
||||
test('should provide fields for multiple choice question', async () => {
|
||||
let wrapper;
|
||||
|
||||
act(() => {
|
||||
wrapper = mountWithContexts(
|
||||
<SurveyQuestionForm
|
||||
question={question}
|
||||
handleSubmit={noop}
|
||||
handleCancel={noop}
|
||||
/>
|
||||
);
|
||||
});
|
||||
await selectType(wrapper, 'multiplechoice');
|
||||
|
||||
expect(wrapper.find('FormField#question-options').prop('type')).toEqual(
|
||||
'textarea'
|
||||
);
|
||||
expect(wrapper.find('FormField#question-default').prop('type')).toEqual(
|
||||
'text'
|
||||
);
|
||||
});
|
||||
|
||||
test('should provide fields for multi-select question', async () => {
|
||||
let wrapper;
|
||||
|
||||
act(() => {
|
||||
wrapper = mountWithContexts(
|
||||
<SurveyQuestionForm
|
||||
question={question}
|
||||
handleSubmit={noop}
|
||||
handleCancel={noop}
|
||||
/>
|
||||
);
|
||||
});
|
||||
await selectType(wrapper, 'multiselect');
|
||||
|
||||
expect(wrapper.find('FormField#question-options').prop('type')).toEqual(
|
||||
'textarea'
|
||||
);
|
||||
expect(wrapper.find('FormField#question-default').prop('type')).toEqual(
|
||||
'textarea'
|
||||
);
|
||||
});
|
||||
|
||||
test('should provide fields for integer question', async () => {
|
||||
let wrapper;
|
||||
|
||||
act(() => {
|
||||
wrapper = mountWithContexts(
|
||||
<SurveyQuestionForm
|
||||
question={question}
|
||||
handleSubmit={noop}
|
||||
handleCancel={noop}
|
||||
/>
|
||||
);
|
||||
});
|
||||
await selectType(wrapper, 'integer');
|
||||
|
||||
expect(wrapper.find('FormField#question-min').prop('type')).toEqual(
|
||||
'number'
|
||||
);
|
||||
expect(wrapper.find('FormField#question-max').prop('type')).toEqual(
|
||||
'number'
|
||||
);
|
||||
expect(
|
||||
wrapper.find('FormField#question-default input').prop('type')
|
||||
).toEqual('number');
|
||||
});
|
||||
|
||||
test('should provide fields for float question', async () => {
|
||||
let wrapper;
|
||||
|
||||
act(() => {
|
||||
wrapper = mountWithContexts(
|
||||
<SurveyQuestionForm
|
||||
question={question}
|
||||
handleSubmit={noop}
|
||||
handleCancel={noop}
|
||||
/>
|
||||
);
|
||||
});
|
||||
await selectType(wrapper, 'float');
|
||||
|
||||
expect(wrapper.find('FormField#question-min').prop('type')).toEqual(
|
||||
'number'
|
||||
);
|
||||
expect(wrapper.find('FormField#question-max').prop('type')).toEqual(
|
||||
'number'
|
||||
);
|
||||
expect(
|
||||
wrapper.find('FormField#question-default input').prop('type')
|
||||
).toEqual('number');
|
||||
});
|
||||
});
|
@ -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
|
3
awx/ui_next/src/screens/Template/Survey/index.js
Normal file
3
awx/ui_next/src/screens/Template/Survey/index.js
Normal file
@ -0,0 +1,3 @@
|
||||
export { default as SurveyList } from './SurveyList';
|
||||
export { default as SurveyQuestionAdd } from './SurveyQuestionAdd';
|
||||
export { default as SurveyQuestionEdit } from './SurveyQuestionEdit';
|
@ -7,7 +7,7 @@ import ContentError from '@components/ContentError';
|
||||
import AlertModal from '@components/AlertModal';
|
||||
import ErrorDetail from '@components/ErrorDetail';
|
||||
import useRequest, { useDismissableError } from '@util/useRequest';
|
||||
import SurveyList from './shared/SurveyList';
|
||||
import { SurveyList, SurveyQuestionAdd, SurveyQuestionEdit } from './Survey';
|
||||
|
||||
function TemplateSurvey({ template, i18n }) {
|
||||
const [surveyEnabled, setSurveyEnabled] = useState(template.survey_enabled);
|
||||
@ -37,6 +37,13 @@ function TemplateSurvey({ template, i18n }) {
|
||||
[template.id, setSurvey]
|
||||
)
|
||||
);
|
||||
const updateSurveySpec = spec => {
|
||||
updateSurvey({
|
||||
name: survey.name || '',
|
||||
description: survey.description || '',
|
||||
spec,
|
||||
});
|
||||
};
|
||||
|
||||
const { request: deleteSurvey, error: deleteError } = useRequest(
|
||||
useCallback(async () => {
|
||||
@ -64,13 +71,19 @@ 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/edit/:variable">
|
||||
<SurveyQuestionEdit 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>
|
||||
|
@ -53,6 +53,12 @@ class Templates extends Component {
|
||||
t`Completed Jobs`
|
||||
),
|
||||
[`/templates/${template.type}/${template.id}/survey`]: i18n._(t`Survey`),
|
||||
[`/templates/${template.type}/${template.id}/survey/add`]: i18n._(
|
||||
t`Add Question`
|
||||
),
|
||||
[`/templates/${template.type}/${template.id}/survey/edit`]: i18n._(
|
||||
t`Edit Question`
|
||||
),
|
||||
[`/templates/${template.type}/${template.id}/schedules`]: i18n._(
|
||||
t`Schedules`
|
||||
),
|
||||
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user