diff --git a/awx/ui_next/package-lock.json b/awx/ui_next/package-lock.json index 31d60bcd10..4f294b6abd 100644 --- a/awx/ui_next/package-lock.json +++ b/awx/ui_next/package-lock.json @@ -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 } } }, diff --git a/awx/ui_next/src/components/FormField/FormField.jsx b/awx/ui_next/src/components/FormField/FormField.jsx index 19b1b8a0af..49d77cfae3 100644 --- a/awx/ui_next/src/components/FormField/FormField.jsx +++ b/awx/ui_next/src/components/FormField/FormField.jsx @@ -84,6 +84,7 @@ function FormField(props) { isValid={isValid} {...rest} {...field} + type={type} onChange={(value, event) => { field.onChange(event); }} diff --git a/awx/ui_next/src/components/RoutedTabs/RoutedTabs.jsx b/awx/ui_next/src/components/RoutedTabs/RoutedTabs.jsx index 27a0bf2940..a41ac3ea0a 100644 --- a/awx/ui_next/src/components/RoutedTabs/RoutedTabs.jsx +++ b/awx/ui_next/src/components/RoutedTabs/RoutedTabs.jsx @@ -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; }; diff --git a/awx/ui_next/src/screens/Host/HostAdd/HostAdd.test.jsx b/awx/ui_next/src/screens/Host/HostAdd/HostAdd.test.jsx index 1096b83ca6..f6a9362344 100644 --- a/awx/ui_next/src/screens/Host/HostAdd/HostAdd.test.jsx +++ b/awx/ui_next/src/screens/Host/HostAdd/HostAdd.test.jsx @@ -26,7 +26,10 @@ describe('', () => { let history; beforeEach(async () => { - history = createMemoryHistory(); + history = createMemoryHistory({ + initialEntries: ['/templates/job_templates/1/survey/edit/foo'], + state: { some: 'state' }, + }); await act(async () => { wrapper = mountWithContexts(, { context: { router: { history } }, diff --git a/awx/ui_next/src/screens/Template/shared/SurveyList.jsx b/awx/ui_next/src/screens/Template/Survey/SurveyList.jsx similarity index 79% rename from awx/ui_next/src/screens/Template/shared/SurveyList.jsx rename to awx/ui_next/src/screens/Template/Survey/SurveyList.jsx index e04f99d288..5c8a53f0bd 100644 --- a/awx/ui_next/src/screens/Template/shared/SurveyList.jsx +++ b/awx/ui_next/src/screens/Template/Survey/SurveyList.jsx @@ -95,7 +95,50 @@ function SurveyList({ ); } - + if (isDeleteModalOpen) { + return ( + { + setIsDeleteModalOpen(false); + setSelected([]); + }} + actions={[ + + {i18n._(t`Delete`)} + , + { + setIsDeleteModalOpen(false); + setSelected([]); + }} + > + {i18n._(t`Cancel`)} + , + ]} + > + {i18n._(t`This action will delete the following:`)} + {selected.map(question => ( + + {question.question_name} + + + ))} + + ); + } return ( <> - {question.question_name} - , - {question.type}, - - {question.default} + + + {question.question_name} + , + {question.type}, + {question.default}, ]} /> diff --git a/awx/ui_next/src/screens/Template/shared/SurveyListItem.test.jsx b/awx/ui_next/src/screens/Template/Survey/SurveyListItem.test.jsx similarity index 100% rename from awx/ui_next/src/screens/Template/shared/SurveyListItem.test.jsx rename to awx/ui_next/src/screens/Template/Survey/SurveyListItem.test.jsx diff --git a/awx/ui_next/src/screens/Template/Survey/SurveyQuestionAdd.jsx b/awx/ui_next/src/screens/Template/Survey/SurveyQuestionAdd.jsx new file mode 100644 index 0000000000..009c295bf7 --- /dev/null +++ b/awx/ui_next/src/screens/Template/Survey/SurveyQuestionAdd.jsx @@ -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 ( + + + + ); +} diff --git a/awx/ui_next/src/screens/Template/Survey/SurveyQuestionAdd.test.jsx b/awx/ui_next/src/screens/Template/Survey/SurveyQuestionAdd.test.jsx new file mode 100644 index 0000000000..0d0e3bed5e --- /dev/null +++ b/awx/ui_next/src/screens/Template/Survey/SurveyQuestionAdd.test.jsx @@ -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('', () => { + let updateSurvey; + + beforeEach(() => { + updateSurvey = jest.fn(); + }); + + test('should render form', () => { + let wrapper; + act(() => { + wrapper = mountWithContexts( + + ); + }); + + expect(wrapper.find('SurveyQuestionForm')).toHaveLength(1); + }); + + test('should call updateSurvey', () => { + let wrapper; + act(() => { + wrapper = mountWithContexts( + + ); + }); + + 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( + + ); + }); + + 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( + + ); + }); + + 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; + }); +}); diff --git a/awx/ui_next/src/screens/Template/Survey/SurveyQuestionEdit.jsx b/awx/ui_next/src/screens/Template/Survey/SurveyQuestionEdit.jsx new file mode 100644 index 0000000000..9c6ac6193a --- /dev/null +++ b/awx/ui_next/src/screens/Template/Survey/SurveyQuestionEdit.jsx @@ -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 ; + } + + 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 ( + + + + ); +} diff --git a/awx/ui_next/src/screens/Template/Survey/SurveyQuestionEdit.test.jsx b/awx/ui_next/src/screens/Template/Survey/SurveyQuestionEdit.test.jsx new file mode 100644 index 0000000000..25665b339d --- /dev/null +++ b/awx/ui_next/src/screens/Template/Survey/SurveyQuestionEdit.test.jsx @@ -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('', () => { + let updateSurvey; + let history; + let wrapper; + + beforeEach(() => { + history = createMemoryHistory({ + initialEntries: ['/templates/job_templates/1/survey/edit/foo'], + }); + updateSurvey = jest.fn(); + act(() => { + wrapper = mountWithContexts( + + + + + , + { + 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; + }); +}); diff --git a/awx/ui_next/src/screens/Template/Survey/SurveyQuestionForm.jsx b/awx/ui_next/src/screens/Template/Survey/SurveyQuestionForm.jsx new file mode 100644 index 0000000000..5f3fdb8258 --- /dev/null +++ b/awx/ui_next/src/screens/Template/Survey/SurveyQuestionForm.jsx @@ -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 ( + + + + + ); +} + +function SurveyQuestionForm({ + question, + handleSubmit, + handleCancel, + submitError, + i18n, +}) { + return ( + + {formik => ( + + + + + + + + + + {['text', 'textarea', 'password'].includes(formik.values.type) && ( + <> + + + > + )} + {['integer', 'float'].includes(formik.values.type) && ( + <> + + + > + )} + {['text', 'integer', 'float'].includes(formik.values.type) && ( + + )} + {formik.values.type === 'textarea' && ( + + )} + {formik.values.type === 'password' && ( + + )} + {['multiplechoice', 'multiselect'].includes(formik.values.type) && ( + <> + + + > + )} + + + + + )} + + ); +} + +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); diff --git a/awx/ui_next/src/screens/Template/Survey/SurveyQuestionForm.test.jsx b/awx/ui_next/src/screens/Template/Survey/SurveyQuestionForm.test.jsx new file mode 100644 index 0000000000..fb7111415f --- /dev/null +++ b/awx/ui_next/src/screens/Template/Survey/SurveyQuestionForm.test.jsx @@ -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('', () => { + test('should render form', () => { + let wrapper; + + act(() => { + wrapper = mountWithContexts( + + ); + }); + + 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( + + ); + }); + + 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( + + ); + }); + 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( + + ); + }); + 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( + + ); + }); + 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( + + ); + }); + 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( + + ); + }); + 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( + + ); + }); + 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'); + }); +}); diff --git a/awx/ui_next/src/screens/Template/shared/SurveyToolbar.jsx b/awx/ui_next/src/screens/Template/Survey/SurveyToolbar.jsx similarity index 92% rename from awx/ui_next/src/screens/Template/shared/SurveyToolbar.jsx rename to awx/ui_next/src/screens/Template/Survey/SurveyToolbar.jsx index f3d880cefd..fa2e171615 100644 --- a/awx/ui_next/src/screens/Template/shared/SurveyToolbar.jsx +++ b/awx/ui_next/src/screens/Template/Survey/SurveyToolbar.jsx @@ -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 ( @@ -45,7 +47,7 @@ function SurveyToolbar({ - + { + 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 ( <> - + + + + + + + updateSurvey({ ...survey, spec })} + updateSurvey={updateSurveySpec} deleteSurvey={deleteSurvey} /> diff --git a/awx/ui_next/src/screens/Template/Templates.jsx b/awx/ui_next/src/screens/Template/Templates.jsx index b8c5252aca..7995975194 100644 --- a/awx/ui_next/src/screens/Template/Templates.jsx +++ b/awx/ui_next/src/screens/Template/Templates.jsx @@ -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` ), diff --git a/awx/ui_next/src/util/validators.jsx b/awx/ui_next/src/util/validators.jsx index fe4627a935..d21347f3d1 100644 --- a/awx/ui_next/src/util/validators.jsx +++ b/awx/ui_next/src/util/validators.jsx @@ -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; + }; +} diff --git a/awx/ui_next/src/util/validators.test.js b/awx/ui_next/src/util/validators.test.js index ceb5eecc0f..067f753263 100644 --- a/awx/ui_next/src/util/validators.test.js +++ b/awx/ui_next/src/util/validators.test.js @@ -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(); + }); });