diff --git a/__tests__/api.test.js b/__tests__/api.test.js index 31b69913b5..a6e12ecdba 100644 --- a/__tests__/api.test.js +++ b/__tests__/api.test.js @@ -138,4 +138,36 @@ describe('APIClient (api.js)', () => { done(); }); + + test('associateInstanceGroup calls expected http method with expected data', async (done) => { + const createPromise = () => Promise.resolve(); + const mockHttp = ({ post: jest.fn(createPromise) }); + + const api = new APIClient(mockHttp); + const url = 'foo/bar/'; + const id = 1; + await api.associateInstanceGroup(url, id); + + expect(mockHttp.post).toHaveBeenCalledTimes(1); + expect(mockHttp.post.mock.calls[0][0]).toEqual(url); + expect(mockHttp.post.mock.calls[0][1]).toEqual({ id }); + + done(); + }); + + test('disassociateInstanceGroup calls expected http method with expected data', async (done) => { + const createPromise = () => Promise.resolve(); + const mockHttp = ({ post: jest.fn(createPromise) }); + + const api = new APIClient(mockHttp); + const url = 'foo/bar/'; + const id = 1; + await api.disassociateInstanceGroup(url, id); + + expect(mockHttp.post).toHaveBeenCalledTimes(1); + expect(mockHttp.post.mock.calls[0][0]).toEqual(url); + expect(mockHttp.post.mock.calls[0][1]).toEqual({ id, disassociate: true }); + + done(); + }); }); diff --git a/__tests__/components/Lookup.test.jsx b/__tests__/components/Lookup.test.jsx index 7ac2c9575b..2e990ec1b9 100644 --- a/__tests__/components/Lookup.test.jsx +++ b/__tests__/components/Lookup.test.jsx @@ -60,7 +60,7 @@ describe('', () => { ).find('Lookup'); expect(spy).not.toHaveBeenCalled(); - expect(wrapper.state('lookupSelectedItems')).toEqual([]); + expect(wrapper.state('lookupSelectedItems')).toEqual(mockSelected); const searchItem = wrapper.find('.pf-c-input-group__text#search'); searchItem.first().simulate('click'); expect(spy).toHaveBeenCalled(); @@ -110,12 +110,11 @@ describe('', () => { /> ); - const removeIcon = wrapper.find('.awx-c-icon--remove').first(); + const removeIcon = wrapper.find('button[aria-label="close"]').first(); removeIcon.simulate('click'); expect(spy).toHaveBeenCalled(); }); - test('"wrapTags" method properly handles data', () => { - const spy = jest.spyOn(Lookup.prototype, 'wrapTags'); + test('renders chips from prop value', () => { mockData = [{ name: 'foo', id: 0 }, { name: 'bar', id: 1 }]; const wrapper = mount( @@ -129,20 +128,18 @@ describe('', () => { sortedColumnKey="name" /> - ); - expect(spy).toHaveBeenCalled(); - const pill = wrapper.find('span.awx-c-tag--pill'); - expect(pill).toHaveLength(2); + ).find('Lookup'); + const chip = wrapper.find('li.pf-c-chip'); + expect(chip).toHaveLength(2); }); test('toggleSelected successfully adds/removes row from lookupSelectedItems state', () => { - mockData = [{ name: 'foo', id: 1 }]; + mockData = []; const wrapper = mount( { }} value={mockData} - selected={[]} getItems={() => { }} columns={mockColumns} sortedColumnKey="name" @@ -164,7 +161,7 @@ describe('', () => { expect(wrapper.state('lookupSelectedItems')).toEqual([]); }); test('saveModal calls callback with selected items', () => { - mockData = [{ name: 'foo', id: 1 }]; + mockData = []; const onLookupSaveFn = jest.fn(); const wrapper = mount( diff --git a/__tests__/pages/Organizations/screens/Organization/OrganizationEdit.test.jsx b/__tests__/pages/Organizations/screens/Organization/OrganizationEdit.test.jsx index 6bcb5c6b37..39c638bba6 100644 --- a/__tests__/pages/Organizations/screens/Organization/OrganizationEdit.test.jsx +++ b/__tests__/pages/Organizations/screens/Organization/OrganizationEdit.test.jsx @@ -1,16 +1,244 @@ import React from 'react'; import { mount } from 'enzyme'; import { MemoryRouter } from 'react-router-dom'; +import { I18nProvider } from '@lingui/react'; +import { ConfigContext } from '../../../../../src/context'; +import APIClient from '../../../../../src/api'; import OrganizationEdit from '../../../../../src/pages/Organizations/screens/Organization/OrganizationEdit'; describe('', () => { - test('initially renders succesfully', () => { + const mockData = { + name: 'Foo', + description: 'Bar', + custom_virtualenv: 'Fizz', + id: 1, + related: { + instance_groups: '/api/v2/organizations/1/instance_groups' + } + }; + + test('should request related instance groups from api', () => { + const mockInstanceGroups = [ + { name: 'One', id: 1 }, + { name: 'Two', id: 2 } + ]; + const getOrganizationInstanceGroups = jest.fn(() => ( + Promise.resolve({ data: { results: mockInstanceGroups } }) + )); mount( - - + + + + + + ).find('OrganizationEdit'); + + expect(getOrganizationInstanceGroups).toHaveBeenCalledTimes(1); + }); + + test('componentDidMount should set instanceGroups to state', async () => { + const mockInstanceGroups = [ + { name: 'One', id: 1 }, + { name: 'Two', id: 2 } + ]; + const getOrganizationInstanceGroups = jest.fn(() => ( + Promise.resolve({ data: { results: mockInstanceGroups } }) + )); + const wrapper = mount( + + + + + + ).find('OrganizationEdit'); + + await wrapper.instance().componentDidMount(); + expect(wrapper.state().form.instanceGroups.value).toEqual(mockInstanceGroups); + }); + test('onLookupSave successfully sets instanceGroups state', () => { + const api = jest.fn(); + const wrapper = mount( + + + + + + ).find('OrganizationEdit'); + + wrapper.instance().onLookupSave([ + { + id: 1, + name: 'foo' + } + ], 'instanceGroups'); + expect(wrapper.state().form.instanceGroups.value).toEqual([ + { + id: 1, + name: 'foo' + } + ]); + }); + test('calls "onFieldChange" when input values change', () => { + const api = new APIClient(); + const spy = jest.spyOn(OrganizationEdit.WrappedComponent.prototype, 'onFieldChange'); + const wrapper = mount( + + + + + + ).find('OrganizationEdit'); + + expect(spy).not.toHaveBeenCalled(); + wrapper.instance().onFieldChange('foo', { target: { name: 'name' } }); + wrapper.instance().onFieldChange('bar', { target: { name: 'description' } }); + expect(spy).toHaveBeenCalledTimes(2); + }); + test('AnsibleSelect component renders if there are virtual environments', () => { + const api = jest.fn(); + const config = { + custom_virtualenvs: ['foo', 'bar'], + }; + const wrapper = mount( + + + + + + ); + expect(wrapper.find('FormSelect')).toHaveLength(1); + expect(wrapper.find('FormSelectOption')).toHaveLength(2); + }); + test('calls onSubmit when Save button is clicked', () => { + const api = jest.fn(); + const spy = jest.spyOn(OrganizationEdit.WrappedComponent.prototype, 'onSubmit'); + const wrapper = mount( + + + + + + ); + expect(spy).not.toHaveBeenCalled(); + wrapper.find('button[aria-label="Save"]').prop('onClick')(); + expect(spy).toBeCalled(); + }); + test('onSubmit associates and disassociates instance groups', async () => { + const mockInstanceGroups = [ + { name: 'One', id: 1 }, + { name: 'Two', id: 2 } + ]; + const getOrganizationInstanceGroupsFn = jest.fn(() => ( + Promise.resolve({ data: { results: mockInstanceGroups } }) + )); + const mockDataForm = { + name: 'Foo', + description: 'Bar', + custom_virtualenv: 'Fizz', + }; + const updateOrganizationDetailsFn = jest.fn().mockResolvedValue(1, mockDataForm); + const associateInstanceGroupFn = jest.fn().mockResolvedValue('done'); + const disassociateInstanceGroupFn = jest.fn().mockResolvedValue('done'); + const api = { + getOrganizationInstanceGroups: getOrganizationInstanceGroupsFn, + updateOrganizationDetails: updateOrganizationDetailsFn, + associateInstanceGroup: associateInstanceGroupFn, + disassociateInstanceGroup: disassociateInstanceGroupFn + }; + const wrapper = mount( + + + + + + ).find('OrganizationEdit'); + + await wrapper.instance().componentDidMount(); + + wrapper.instance().onLookupSave([ + { name: 'One', id: 1 }, + { name: 'Three', id: 3 } + ], 'instanceGroups'); + + await wrapper.instance().onSubmit(); + expect(updateOrganizationDetailsFn).toHaveBeenCalledWith(1, mockDataForm); + expect(associateInstanceGroupFn).toHaveBeenCalledWith('/api/v2/organizations/1/instance_groups', 3); + expect(associateInstanceGroupFn).not.toHaveBeenCalledWith('/api/v2/organizations/1/instance_groups', 1); + expect(associateInstanceGroupFn).not.toHaveBeenCalledWith('/api/v2/organizations/1/instance_groups', 2); + + expect(disassociateInstanceGroupFn).toHaveBeenCalledWith('/api/v2/organizations/1/instance_groups', 2); + expect(disassociateInstanceGroupFn).not.toHaveBeenCalledWith('/api/v2/organizations/1/instance_groups', 1); + expect(disassociateInstanceGroupFn).not.toHaveBeenCalledWith('/api/v2/organizations/1/instance_groups', 3); + }); + + test('calls "onCancel" when Cancel button is clicked', () => { + const api = jest.fn(); + const spy = jest.spyOn(OrganizationEdit.WrappedComponent.prototype, 'onCancel'); + const wrapper = mount( + + + + + + ); + expect(spy).not.toHaveBeenCalled(); + wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); + expect(spy).toBeCalled(); }); }); diff --git a/__tests__/pages/Organizations/screens/OrganizationAdd.test.jsx b/__tests__/pages/Organizations/screens/OrganizationAdd.test.jsx index 1ce8ae0a6e..9d5079ce70 100644 --- a/__tests__/pages/Organizations/screens/OrganizationAdd.test.jsx +++ b/__tests__/pages/Organizations/screens/OrganizationAdd.test.jsx @@ -71,7 +71,7 @@ describe('', () => { test('Successful form submission triggers redirect', (done) => { const onSuccess = jest.spyOn(OrganizationAdd.WrappedComponent.prototype, 'onSuccess'); const mockedResp = { data: { id: 1, related: { instance_groups: '/bar' } } }; - const api = { createOrganization: jest.fn().mockResolvedValue(mockedResp), createInstanceGroups: jest.fn().mockResolvedValue('done') }; + const api = { createOrganization: jest.fn().mockResolvedValue(mockedResp), associateInstanceGroup: jest.fn().mockResolvedValue('done') }; const wrapper = mount( @@ -131,10 +131,10 @@ describe('', () => { } } }); - const createInstanceGroupsFn = jest.fn().mockResolvedValue('done'); + const associateInstanceGroupFn = jest.fn().mockResolvedValue('done'); const api = { createOrganization: createOrganizationFn, - createInstanceGroups: createInstanceGroupsFn + associateInstanceGroup: associateInstanceGroupFn }; const wrapper = mount( @@ -156,7 +156,7 @@ describe('', () => { description: '', name: 'mock org' }); - expect(createInstanceGroupsFn).toHaveBeenCalledWith('/api/v2/organizations/1/instance_groups', 1); + expect(associateInstanceGroupFn).toHaveBeenCalledWith('/api/v2/organizations/1/instance_groups', 1); }); test('AnsibleSelect component renders if there are virtual environments', () => { diff --git a/src/api.js b/src/api.js index 60106abe6b..54fe242f5e 100644 --- a/src/api.js +++ b/src/api.js @@ -77,6 +77,12 @@ class APIClient { return this.http.get(endpoint); } + updateOrganizationDetails (id, data) { + const endpoint = `${API_ORGANIZATIONS}${id}/`; + + return this.http.patch(endpoint, data); + } + getOrganizationInstanceGroups (id, params = {}) { const endpoint = `${API_ORGANIZATIONS}${id}/instance_groups/`; @@ -117,10 +123,14 @@ class APIClient { return this.http.get(API_INSTANCE_GROUPS, { params }); } - createInstanceGroups (url, id) { + associateInstanceGroup (url, id) { return this.http.post(url, { id }); } + disassociateInstanceGroup (url, id) { + return this.http.post(url, { id, disassociate: true }); + } + getUserRoles (id) { const endpoint = `${API_USERS}${id}/roles/`; diff --git a/src/app.scss b/src/app.scss index 0a1e430ded..934240376c 100644 --- a/src/app.scss +++ b/src/app.scss @@ -228,26 +228,10 @@ } } -.awx-c-icon--remove { - padding-left: 10px; - &:hover { - cursor: pointer; - } -} - .awx-c-list { border-bottom: 1px solid #d7d7d7; } -.awx-c-tag--pill { - color: var(--pf-global--BackgroundColor--light-100); - background-color: rgb(0, 123, 186); - border-radius: 3px; - margin: 1px 2px; - padding: 0 10px; - display: inline-block; -} - .at-c-listCardBody { --pf-c-card__footer--PaddingX: 0; --pf-c-card__footer--PaddingY: 0; diff --git a/src/components/Lookup/Lookup.jsx b/src/components/Lookup/Lookup.jsx index 54fb3dbebf..54be10b2d9 100644 --- a/src/components/Lookup/Lookup.jsx +++ b/src/components/Lookup/Lookup.jsx @@ -2,6 +2,7 @@ import React, { Fragment } from 'react'; import PropTypes from 'prop-types'; import { SearchIcon, CubesIcon } from '@patternfly/react-icons'; import { + Chip, Modal, Button, EmptyState, @@ -28,9 +29,10 @@ const paginationStyling = { class Lookup extends React.Component { constructor (props) { super(props); + this.state = { isModalOpen: false, - lookupSelectedItems: [], + lookupSelectedItems: [...props.value] || [], results: [], count: 0, page: 1, @@ -41,7 +43,6 @@ class Lookup extends React.Component { }; this.onSetPage = this.onSetPage.bind(this); this.handleModalToggle = this.handleModalToggle.bind(this); - this.wrapTags = this.wrapTags.bind(this); this.toggleSelected = this.toggleSelected.bind(this); this.saveModal = this.saveModal.bind(this); this.getData = this.getData.bind(this); @@ -100,17 +101,27 @@ class Lookup extends React.Component { }; toggleSelected (row) { - const { lookupSelectedItems } = this.state; - const selectedIndex = lookupSelectedItems + const { name, onLookupSave } = this.props; + const { lookupSelectedItems: updatedSelectedItems, isModalOpen } = this.state; + + const selectedIndex = updatedSelectedItems .findIndex(selectedRow => selectedRow.id === row.id); + if (selectedIndex > -1) { - lookupSelectedItems.splice(selectedIndex, 1); - this.setState({ lookupSelectedItems }); + updatedSelectedItems.splice(selectedIndex, 1); + this.setState({ lookupSelectedItems: updatedSelectedItems }); } else { this.setState(prevState => ({ lookupSelectedItems: [...prevState.lookupSelectedItems, row] })); } + + // Updates the selected items from parent state + // This handles the case where the user removes chips from the lookup input + // while the modal is closed + if (!isModalOpen) { + onLookupSave(updatedSelectedItems, name); + } } handleModalToggle () { @@ -134,17 +145,6 @@ class Lookup extends React.Component { this.handleModalToggle(); } - wrapTags (tags = []) { - return tags.map(tag => ( - - {tag.name} - - - )); - } - render () { const { isModalOpen, @@ -159,6 +159,16 @@ class Lookup extends React.Component { } = this.state; const { lookupHeader = 'items', value, columns } = this.props; + const chips = value ? ( +
+ {value.map(chip => ( + this.toggleSelected(chip)}> + {chip.name} + + ))} +
+ ) : null; + return ( {({ i18n }) => ( @@ -166,7 +176,7 @@ class Lookup extends React.Component { -
{this.wrapTags(value)}
+
{chips}
- ( - - )} - /> + {organization && ( + ( + + )} + /> + )} {organization && ( ( - -

edit view

- - save/cancel and go back to view - -
-); +import { ConfigContext } from '../../../../context'; +import Lookup from '../../../../components/Lookup'; +import FormActionGroup from '../../../../components/FormActionGroup'; +import AnsibleSelect from '../../../../components/AnsibleSelect'; -export default OrganizationEdit; +class OrganizationEdit extends Component { + constructor (props) { + super(props); + + this.getInstanceGroups = this.getInstanceGroups.bind(this); + this.getRelatedInstanceGroups = this.getRelatedInstanceGroups.bind(this); + this.checkValidity = this.checkValidity.bind(this); + this.onFieldChange = this.onFieldChange.bind(this); + this.onLookupSave = this.onLookupSave.bind(this); + this.onSubmit = this.onSubmit.bind(this); + this.postInstanceGroups = this.postInstanceGroups.bind(this); + this.onCancel = this.onCancel.bind(this); + this.onSuccess = this.onSuccess.bind(this); + + this.state = { + form: { + name: { + value: '', + isValid: true, + validation: { + required: true + }, + helperTextInvalid: i18nMark('This field must not be blank') + }, + description: { + value: '' + }, + instanceGroups: { + value: [], + initialValue: [] + }, + custom_virtualenv: { + value: '', + defaultValue: '/venv/ansible/' + } + }, + error: '', + formIsValid: true + }; + } + + async componentDidMount () { + const { organization } = this.props; + const { form: formData } = this.state; + + formData.name.value = organization.name; + formData.description.value = organization.description; + formData.custom_virtualenv.value = organization.custom_virtualenv; + + try { + formData.instanceGroups.value = await this.getRelatedInstanceGroups(); + formData.instanceGroups.initialValue = [...formData.instanceGroups.value]; + } catch (err) { + this.setState({ error: err }); + } + + this.setState({ form: formData }); + } + + onFieldChange (val, evt) { + const targetName = evt.target.name; + const value = val; + + const { form: updatedForm } = this.state; + const updatedFormEl = { ...updatedForm[targetName] }; + + updatedFormEl.value = value; + updatedForm[targetName] = updatedFormEl; + + updatedFormEl.isValid = (updatedFormEl.validation) + ? this.checkValidity(updatedFormEl.value, updatedFormEl.validation) : true; + + const formIsValid = (updatedFormEl.validation) ? updatedFormEl.isValid : true; + + this.setState({ form: updatedForm, formIsValid }); + } + + onLookupSave (val, targetName) { + const { form: updatedForm } = this.state; + updatedForm[targetName].value = val; + + this.setState({ form: updatedForm }); + } + + async onSubmit () { + const { api, organization } = this.props; + const { form: { name, description, custom_virtualenv } } = this.state; + const formData = { name, description, custom_virtualenv }; + + const updatedData = {}; + Object.keys(formData) + .forEach(formId => { + updatedData[formId] = formData[formId].value; + }); + + try { + await api.updateOrganizationDetails(organization.id, updatedData); + await this.postInstanceGroups(); + } catch (err) { + this.setState({ error: err }); + } finally { + this.onSuccess(); + } + } + + onCancel () { + const { organization: { id }, history } = this.props; + history.push(`/organizations/${id}`); + } + + onSuccess () { + const { organization: { id }, history } = this.props; + history.push(`/organizations/${id}`); + } + + async getInstanceGroups (params) { + const { api } = this.props; + const data = await api.getInstanceGroups(params); + return data; + } + + async getRelatedInstanceGroups () { + const { + api, + organization: { id } + } = this.props; + const { data } = await api.getOrganizationInstanceGroups(id); + const { results } = data; + return results; + } + + checkValidity = (value, validation) => { + const isValid = (validation.required) + ? (value.trim() !== '') : true; + + return isValid; + } + + async postInstanceGroups () { + const { api, organization } = this.props; + const { form: { instanceGroups } } = this.state; + const url = organization.related.instance_groups; + + const initialInstanceGroups = instanceGroups.initialValue.map(ig => ig.id); + const updatedInstanceGroups = instanceGroups.value.map(ig => ig.id); + + const groupsToAssociate = [...updatedInstanceGroups] + .filter(x => !initialInstanceGroups.includes(x)); + const groupsToDisassociate = [...initialInstanceGroups] + .filter(x => !updatedInstanceGroups.includes(x)); + + try { + await Promise.all(groupsToAssociate.map(async id => { + await api.associateInstanceGroup(url, id); + })); + await Promise.all(groupsToDisassociate.map(async id => { + await api.disassociateInstanceGroup(url, id); + })); + } catch (err) { + this.setState({ error: err }); + } + } + + render () { + const { + form: { + name, + description, + instanceGroups, + custom_virtualenv + }, + formIsValid, + error + } = this.state; + + const instanceGroupsLookupColumns = [ + { name: i18nMark('Name'), key: 'name', isSortable: true }, + { name: i18nMark('Modified'), key: 'modified', isSortable: false, isNumeric: true }, + { name: i18nMark('Created'), key: 'created', isSortable: false, isNumeric: true } + ]; + + return ( + + + {({ i18n }) => ( +
+
+ + + + + + + + {({ custom_virtualenvs }) => ( + custom_virtualenvs && custom_virtualenvs.length > 1 && ( + + + + ) + )} + +
+ + + + + { error ?
error
: '' } + + )} +
+
+ ); + } +} + +OrganizationEdit.contextTypes = { + custom_virtualenvs: PropTypes.arrayOf(PropTypes.string) +}; + +export default withRouter(OrganizationEdit); diff --git a/src/pages/Organizations/screens/OrganizationAdd.jsx b/src/pages/Organizations/screens/OrganizationAdd.jsx index 6dbd224781..5f38a237f8 100644 --- a/src/pages/Organizations/screens/OrganizationAdd.jsx +++ b/src/pages/Organizations/screens/OrganizationAdd.jsx @@ -61,7 +61,7 @@ class OrganizationAdd extends React.Component { try { if (instanceGroups.length > 0) { instanceGroups.forEach(async (select) => { - await api.createInstanceGroups(instanceGroupsUrl, select.id); + await api.associateInstanceGroup(instanceGroupsUrl, select.id); }); } } catch (err) {