diff --git a/__tests__/pages/Organizations/components/OrganizationForm.test.jsx b/__tests__/pages/Organizations/components/OrganizationForm.test.jsx index e1a4e84048..640845ed20 100644 --- a/__tests__/pages/Organizations/components/OrganizationForm.test.jsx +++ b/__tests__/pages/Organizations/components/OrganizationForm.test.jsx @@ -8,11 +8,16 @@ jest.mock('../../../../src/api'); describe('', () => { const network = {}; - + const meConfig = { + me: { + is_superuser: false + } + }; const mockData = { id: 1, name: 'Foo', description: 'Bar', + max_hosts: 1, custom_virtualenv: 'Fizz', related: { instance_groups: '/api/v2/organizations/1/instance_groups' @@ -30,6 +35,7 @@ describe('', () => { organization={mockData} handleSubmit={jest.fn()} handleCancel={jest.fn()} + me={meConfig.me} /> ), { context: { network }, @@ -55,6 +61,7 @@ describe('', () => { organization={mockData} handleSubmit={jest.fn()} handleCancel={jest.fn()} + me={meConfig.me} /> ), { context: { network }, @@ -72,6 +79,7 @@ describe('', () => { organization={mockData} handleSubmit={jest.fn()} handleCancel={jest.fn()} + me={meConfig.me} /> ); @@ -98,6 +106,7 @@ describe('', () => { organization={mockData} handleSubmit={jest.fn()} handleCancel={jest.fn()} + me={meConfig.me} /> ); @@ -110,6 +119,10 @@ describe('', () => { target: { value: 'new bar', name: 'description' } }); expect(form.state('values').description).toEqual('new bar'); + wrapper.find('input#org-max_hosts').simulate('change', { + target: { value: '134', name: 'max_hosts' } + }); + expect(form.state('values').max_hosts).toEqual('134'); }); test('AnsibleSelect component renders if there are virtual environments', () => { @@ -122,6 +135,7 @@ describe('', () => { organization={mockData} handleSubmit={jest.fn()} handleCancel={jest.fn()} + me={meConfig.me} /> ), { context: { config }, @@ -138,6 +152,7 @@ describe('', () => { organization={mockData} handleSubmit={handleSubmit} handleCancel={jest.fn()} + me={meConfig.me} /> ); expect(handleSubmit).not.toHaveBeenCalled(); @@ -146,6 +161,7 @@ describe('', () => { expect(handleSubmit).toHaveBeenCalledWith({ name: 'Foo', description: 'Bar', + max_hosts: 1, custom_virtualenv: 'Fizz', }, [], []); }); @@ -163,6 +179,7 @@ describe('', () => { const mockDataForm = { name: 'Foo', description: 'Bar', + max_hosts: 1, custom_virtualenv: 'Fizz', }; const handleSubmit = jest.fn(); @@ -175,14 +192,13 @@ describe('', () => { organization={mockData} handleSubmit={handleSubmit} handleCancel={jest.fn()} + me={meConfig.me} /> ), { - context: { network }, + context: { network } } ); - await sleep(0); - wrapper.find('InstanceGroupsLookup').prop('onChange')([ { name: 'One', id: 1 }, { name: 'Three', id: 3 } @@ -193,13 +209,95 @@ describe('', () => { expect(handleSubmit).toHaveBeenCalledWith(mockDataForm, [3], [2]); }); + test('handleSubmit is called with max_hosts value if it is in range', async () => { + const handleSubmit = jest.fn(); + + // normal mount + const wrapper = mountWithContexts( + + ); + wrapper.find('button[aria-label="Save"]').simulate('click'); + await sleep(0); + expect(handleSubmit).toHaveBeenCalledWith({ + name: 'Foo', + description: 'Bar', + max_hosts: 1, + custom_virtualenv: 'Fizz', + }, [], []); + }); + + test('handleSubmit does not get called if max_hosts value is out of range', async () => { + const handleSubmit = jest.fn(); + + // not mount with Negative value + const mockDataNegative = JSON.parse(JSON.stringify(mockData)); + mockDataNegative.max_hosts = -5; + const wrapper1 = mountWithContexts( + + ); + wrapper1.find('button[aria-label="Save"]').simulate('click'); + await sleep(0); + expect(handleSubmit).not.toHaveBeenCalled(); + + // not mount with Out of Range value + const mockDataOoR = JSON.parse(JSON.stringify(mockData)); + mockDataOoR.max_hosts = 999999999999; + const wrapper2 = mountWithContexts( + + ); + wrapper2.find('button[aria-label="Save"]').simulate('click'); + await sleep(0); + expect(handleSubmit).not.toHaveBeenCalled(); + }); + + test('handleSubmit is called and max_hosts value defaults to 0 if input is not a number', async () => { + const handleSubmit = jest.fn(); + + // mount with String value (default to zero) + const mockDataString = JSON.parse(JSON.stringify(mockData)); + mockDataString.max_hosts = 'Bee'; + const wrapper = mountWithContexts( + + ); + wrapper.find('button[aria-label="Save"]').simulate('click'); + await sleep(0); + expect(handleSubmit).toHaveBeenCalledWith({ + name: 'Foo', + description: 'Bar', + max_hosts: 0, + custom_virtualenv: 'Fizz', + }, [], []); + }); + test('calls "handleCancel" when Cancel button is clicked', () => { const handleCancel = jest.fn(); + const wrapper = mountWithContexts( ); expect(handleCancel).not.toHaveBeenCalled(); diff --git a/__tests__/pages/Organizations/screens/Organization/OrganizationDetail.test.jsx b/__tests__/pages/Organizations/screens/Organization/OrganizationDetail.test.jsx index a4f70f11da..ca7676db42 100644 --- a/__tests__/pages/Organizations/screens/Organization/OrganizationDetail.test.jsx +++ b/__tests__/pages/Organizations/screens/Organization/OrganizationDetail.test.jsx @@ -10,6 +10,7 @@ describe('', () => { name: 'Foo', description: 'Bar', custom_virtualenv: 'Fizz', + max_hosts: '0', created: 'Bat', modified: 'Boo', summary_fields: { @@ -71,11 +72,12 @@ describe('', () => { ); const detailWrapper = wrapper.find('Detail'); - expect(detailWrapper.length).toBe(5); + expect(detailWrapper.length).toBe(6); const nameDetail = detailWrapper.findWhere(node => node.props().label === 'Name'); const descriptionDetail = detailWrapper.findWhere(node => node.props().label === 'Description'); const custom_virtualenvDetail = detailWrapper.findWhere(node => node.props().label === 'Ansible Environment'); + const max_hostsDetail = detailWrapper.findWhere(node => node.props().label === 'Max Hosts'); const createdDetail = detailWrapper.findWhere(node => node.props().label === 'Created'); const modifiedDetail = detailWrapper.findWhere(node => node.props().label === 'Last Modified'); expect(nameDetail.find('dt').text()).toBe('Name'); @@ -92,6 +94,9 @@ describe('', () => { expect(modifiedDetail.find('dt').text()).toBe('Last Modified'); expect(modifiedDetail.find('dd').text()).toBe('Boo'); + + expect(max_hostsDetail.find('dt').text()).toBe('Max Hosts'); + expect(max_hostsDetail.find('dd').text()).toBe('0'); }); test('should show edit button for users with edit permission', () => { diff --git a/src/pages/Organizations/components/OrganizationForm.jsx b/src/pages/Organizations/components/OrganizationForm.jsx index c848b34e61..5e99e7d484 100644 --- a/src/pages/Organizations/components/OrganizationForm.jsx +++ b/src/pages/Organizations/components/OrganizationForm.jsx @@ -1,10 +1,14 @@ -import React, { Component } from 'react'; +import React, { Component, Fragment } from 'react'; import PropTypes from 'prop-types'; +import { QuestionCircleIcon } from '@patternfly/react-icons'; + import { withRouter } from 'react-router-dom'; import { Formik, Field } from 'formik'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; + import { + Tooltip, Form, FormGroup, } from '@patternfly/react-core'; @@ -16,8 +20,8 @@ import FormField from '../../../components/FormField'; import FormActionGroup from '../../../components/FormActionGroup/FormActionGroup'; import AnsibleSelect from '../../../components/AnsibleSelect'; import InstanceGroupsLookup from './InstanceGroupsLookup'; -import { required } from '../../../util/validators'; import { OrganizationsAPI } from '../../../api'; +import { required, minMaxValue } from '../../../util/validators'; class OrganizationForm extends Component { constructor (props) { @@ -79,11 +83,15 @@ class OrganizationForm extends Component { const groupsToDisassociate = [...initialIds] .filter(x => !updatedIds.includes(x)); + if (typeof values.max_hosts !== 'number' || values.max_hosts === 'undefined') { + values.max_hosts = 0; + } + handleSubmit(values, groupsToAssociate, groupsToDisassociate); } render () { - const { organization, handleCancel, i18n } = this.props; + const { organization, handleCancel, i18n, me } = this.props; const { instanceGroups, formIsValid, error } = this.state; const defaultVenv = '/venv/ansible/'; @@ -93,6 +101,7 @@ class OrganizationForm extends Component { name: organization.name, description: organization.description, custom_virtualenv: organization.custom_virtualenv || '', + max_hosts: organization.max_hosts || '0', }} onSubmit={this.handleSubmit} render={formik => ( @@ -112,6 +121,30 @@ class OrganizationForm extends Component { type="text" label={i18n._(t`Description`)} /> + + {i18n._(t`Max Hosts`)} + {' '} + {( + + + + )} + + ) + } + validate={minMaxValue(0, 2147483647, i18n)} + me={me || {}} + isDisabled={!me.is_superuser} + /> {({ custom_virtualenvs }) => ( custom_virtualenvs && custom_virtualenvs.length > 1 && ( @@ -153,6 +186,10 @@ class OrganizationForm extends Component { } } +FormField.propTypes = { + label: PropTypes.oneOfType([PropTypes.object, PropTypes.string]).isRequired +}; + OrganizationForm.propTypes = { organization: PropTypes.shape(), handleSubmit: PropTypes.func.isRequired, @@ -163,8 +200,9 @@ OrganizationForm.defaultProps = { organization: { name: '', description: '', + max_hosts: '0', custom_virtualenv: '', - } + }, }; OrganizationForm.contextTypes = { diff --git a/src/pages/Organizations/screens/Organization/OrganizationDetail.jsx b/src/pages/Organizations/screens/Organization/OrganizationDetail.jsx index a0c7b21ba1..6aafd74b45 100644 --- a/src/pages/Organizations/screens/Organization/OrganizationDetail.jsx +++ b/src/pages/Organizations/screens/Organization/OrganizationDetail.jsx @@ -56,6 +56,7 @@ class OrganizationDetail extends Component { name, description, custom_virtualenv, + max_hosts, created, modified, summary_fields @@ -75,6 +76,10 @@ class OrganizationDetail extends Component { label={i18n._(t`Description`)} value={description} /> + - + + {({ me }) => ( + + )} + {error ?
error
: null} ); diff --git a/src/pages/Organizations/screens/OrganizationAdd.jsx b/src/pages/Organizations/screens/OrganizationAdd.jsx index 1b5fd2d355..d5a537a633 100644 --- a/src/pages/Organizations/screens/OrganizationAdd.jsx +++ b/src/pages/Organizations/screens/OrganizationAdd.jsx @@ -11,6 +11,7 @@ import { Tooltip, } from '@patternfly/react-core'; +import { Config } from '../../../contexts/Config'; import { withNetwork } from '../../../contexts/Network'; import CardCloseButton from '../../../components/CardCloseButton'; import OrganizationForm from '../components/OrganizationForm'; @@ -71,10 +72,15 @@ class OrganizationAdd extends React.Component { - + + {({ me }) => ( + + )} + {error ?
error
: ''}
diff --git a/src/util/validators.jsx b/src/util/validators.jsx index 7de0a0a423..c3da8d4dbe 100644 --- a/src/util/validators.jsx +++ b/src/util/validators.jsx @@ -18,3 +18,12 @@ export function maxLength (max, i18n) { return undefined; }; } + +export function minMaxValue (min, max, i18n) { + return value => { + if (value < min || value > max) { + return i18n._(t`This field must be a number and have a value between ${min} and ${max}`); + } + return undefined; + }; +}