1
0
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:
softwarefactory-project-zuul[bot] 2020-03-20 18:56:47 +00:00 committed by GitHub
commit 5573e1c7ce
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 979 additions and 25 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

@ -84,6 +84,7 @@ function FormField(props) {
isValid={isValid}
{...rest}
{...field}
type={type}
onChange={(value, event) => {
field.onChange(event);
}}

View File

@ -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;
};

View File

@ -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 } },

View File

@ -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

View File

@ -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');

View File

@ -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}>
{question.question_name}
</DataListCell>,
<DataListCell key={question.type}>{question.type}</DataListCell>,
<DataListCell key={question.default}>
{question.default}
<DataListCell key="name">
<Link to={`survey/edit/${question.variable}`}>
{question.question_name}
</Link>
</DataListCell>,
<DataListCell key="type">{question.type}</DataListCell>,
<DataListCell key="default">{question.default}</DataListCell>,
]}
/>
</DataListItemRow>

View File

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

View File

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

View File

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

View File

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

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

View File

@ -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');
});
});

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

@ -0,0 +1,3 @@
export { default as SurveyList } from './SurveyList';
export { default as SurveyQuestionAdd } from './SurveyQuestionAdd';
export { default as SurveyQuestionEdit } from './SurveyQuestionEdit';

View File

@ -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>

View File

@ -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`
),

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