diff --git a/__tests__/api.test.js b/__tests__/api.test.js index e9276e8c8a..54e7db395b 100644 --- a/__tests__/api.test.js +++ b/__tests__/api.test.js @@ -126,4 +126,16 @@ describe('APIClient (api.js)', () => { done(); }); + + test('getInstanceGroups calls expected http method', async (done) => { + const createPromise = () => Promise.resolve(); + const mockHttp = ({ get: jest.fn(createPromise) }); + + const api = new APIClient(mockHttp); + await api.getInstanceGroups(); + + expect(mockHttp.get).toHaveBeenCalledTimes(1); + + done(); + }); }); diff --git a/__tests__/components/Lookup.test.jsx b/__tests__/components/Lookup.test.jsx new file mode 100644 index 0000000000..8175b619e4 --- /dev/null +++ b/__tests__/components/Lookup.test.jsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import Lookup from '../../src/components/Lookup'; +import { I18nProvider } from '@lingui/react'; + +const mockData = [{ name: 'foo', id: 0, isChecked: false }]; +describe('', () => { + test('initially renders succesfully', () => { + mount( + { }} + data={mockData} + /> + ); + }); + test('calls "onLookup" when search icon is clicked', () => { + const spy = jest.spyOn(Lookup.prototype, 'onLookup'); + const wrapper = mount( + + { }} + data={mockData} + /> + + ); + expect(spy).not.toHaveBeenCalled(); + wrapper.find('#search').simulate('click'); + expect(spy).toHaveBeenCalled(); + }); + test('calls "onChecked" when a user changes a checkbox', () => { + const spy = jest.spyOn(Lookup.prototype, 'onChecked'); + const wrapper = mount( + + { }} + data={mockData} + /> + + ); + wrapper.find('#search').simulate('click'); + wrapper.find('input[type="checkbox"]').simulate('change'); + expect(spy).toHaveBeenCalled(); + }); + test('calls "onRemove" when remove icon is clicked', () => { + const spy = jest.spyOn(Lookup.prototype, 'onRemove'); + const mockData = [{ name: 'foo', id: 0, isChecked: false }, { name: 'bar', id: 1, isChecked: true }]; + const wrapper = mount( + + { }} + data={mockData} + /> + + ); + wrapper.find('.awx-c-icon--remove').simulate('click'); + expect(spy).toHaveBeenCalled(); + }); + test('"wrapTags" method properly handles data', () => { + const spy = jest.spyOn(Lookup.prototype, 'wrapTags'); + const mockData = [{ name: 'foo', id: 0, isChecked: false }, { name: 'bar', id: 1, isChecked: false }]; + const wrapper = mount( + + { }} + data={mockData} + /> + + ); + expect(spy).toHaveBeenCalled(); + const pill = wrapper.find('span.awx-c-tag--pill'); + expect(pill).toHaveLength(0); + }); +}); diff --git a/__tests__/pages/Organizations/screens/OrganizationAdd.test.jsx b/__tests__/pages/Organizations/screens/OrganizationAdd.test.jsx index 577ce56441..ad47241399 100644 --- a/__tests__/pages/Organizations/screens/OrganizationAdd.test.jsx +++ b/__tests__/pages/Organizations/screens/OrganizationAdd.test.jsx @@ -1,28 +1,7 @@ import React from 'react'; import { mount } from 'enzyme'; import { MemoryRouter } from 'react-router-dom'; - -let OrganizationAdd; -const getAppWithConfigContext = (context = { - custom_virtualenvs: ['foo', 'bar'] -}) => { - - // Mock the ConfigContext module being used in our OrganizationAdd component - jest.doMock('../../../../src/context', () => { - return { - ConfigContext: { - Consumer: (props) => props.children(context) - } - } - }); - - // Return the updated OrganizationAdd module with mocked context - return require('../../../../src/pages/Organizations/screens/OrganizationAdd').default; -}; - -beforeEach(() => { - OrganizationAdd = getAppWithConfigContext(); -}) +import OrganizationAdd from '../../../../src/pages/Organizations/screens/OrganizationAdd' describe('', () => { test('initially renders succesfully', () => { @@ -78,4 +57,48 @@ describe('', () => { wrapper.find('button.at-C-CancelButton').prop('onClick')(); expect(spy).toBeCalled(); }); + test('API response data is formatted properly', () => { + const mockData = { data: { results: [{ name: 'test instance', id: 1 }] } }; + const promise = Promise.resolve(mockData); + + return promise.then(({ data }) => { + const expected = [{ id: 1, name: 'test instance', isChecked: false }]; + const results = OrganizationAdd.WrappedComponent.prototype.format(data); + expect(results).toEqual(expected); + }); + }); + test('API response is formatted properly', (done) => { + const spy = jest.spyOn(OrganizationAdd.WrappedComponent.prototype, 'format'); + const mockedResp = {data: {id: 1, name: 'foo bar'} }; + const api = { getInstanceGroups: jest.fn().mockResolvedValue(mockedResp) }; + mount( + + + + ); + + setImmediate(() => { + expect(spy).toHaveBeenCalled(); + done(); + }); + }); + + test('Successful form submission triggers redirect', (done) => { + const onSuccess = jest.spyOn(OrganizationAdd.WrappedComponent.prototype, 'onSuccess'); + const resetForm = jest.spyOn(OrganizationAdd.WrappedComponent.prototype, 'resetForm'); + const mockedResp = {data: {id: 1, related: {instance_groups: '/bar'}}}; + const api = { createOrganization: jest.fn().mockResolvedValue(mockedResp), createInstanceGroups: jest.fn().mockResolvedValue('done') }; + const wrapper = mount( + + + + ); + wrapper.find('input#add-org-form-name').simulate('change', { target: { value: 'foo' } }); + wrapper.find('button.at-C-SubmitButton').prop('onClick')(); + setImmediate(() => { + expect(onSuccess).toHaveBeenCalled(); + expect(resetForm).toHaveBeenCalled(); + done(); + }); + }); }); diff --git a/src/api.js b/src/api.js index 902eee6a3c..ce915bd136 100644 --- a/src/api.js +++ b/src/api.js @@ -4,6 +4,7 @@ const API_LOGOUT = `${API_ROOT}logout/`; const API_V2 = `${API_ROOT}v2/`; const API_CONFIG = `${API_V2}config/`; const API_ORGANIZATIONS = `${API_V2}organizations/`; +const API_INSTANCE_GROUPS = `${API_V2}instance_groups/`; const LOGIN_CONTENT_TYPE = 'application/x-www-form-urlencoded'; @@ -68,6 +69,14 @@ class APIClient { return this.http.get(endpoint); } + + getInstanceGroups () { + return this.http.get(API_INSTANCE_GROUPS); + } + + createInstanceGroups (url, id) { + return this.http.post(url, { id }); + } } export default APIClient; diff --git a/src/app.scss b/src/app.scss index 7f60ca575a..5d2f72d701 100644 --- a/src/app.scss +++ b/src/app.scss @@ -119,7 +119,30 @@ --pf-c-about-modal-box--MaxWidth: 63rem; } +.pf-c-list { + li { + list-style-type: none; + margin: 0; + padding: 0; + } +} +.awx-lookup { + min-height: 36px; +} +.pf-c-input-group__text { + &:hover { + cursor: pointer; + } +} +.awx-c-tag--pill { + color: white; + background-color: rgb(0, 123, 186); + border-radius: 3px; + margin: 1px 2px; + padding: 0 10px; + display: inline-block; +} // // layout styles // @@ -127,4 +150,41 @@ display: flex; flex-direction: row; justify-content: flex-end; -} \ No newline at end of file +} +// +// list styles +// +.awx-c-list { + border-top: 1px solid #d7d7d7; + border-bottom: 1px solid #d7d7d7; +} +// +// pf modal overrides +// +.awx-c-modal { + width: 550px; + margin: 0; +} + +.awx-c-icon--remove { + padding-left: 10px; + &:hover { + cursor: pointer; + } +} +.pf-c-modal-box__footer { + --pf-c-modal-box__footer--PaddingTop: 0; + --pf-c-modal-box__footer--PaddingBottom: 0; +} + +.pf-c-modal-box__header { + --pf-c-modal-box__header--PaddingTop: 10px; + --pf-c-modal-box__header--PaddingRight: 0; + --pf-c-modal-box__header--PaddingBottom: 0; + --pf-c-modal-box__header--PaddingLeft: 20px; +} + +.pf-c-modal-box__body { + --pf-c-modal-box__body--PaddingLeft: 20px; + --pf-c-modal-box__body--PaddingRight: 20px; +} diff --git a/src/components/About.jsx b/src/components/About.jsx index 22c1157b30..0e8a07f09e 100644 --- a/src/components/About.jsx +++ b/src/components/About.jsx @@ -1,4 +1,4 @@ -import React, { Component } from 'react'; +import React from 'react'; import { I18n } from '@lingui/react'; import { Trans, t } from '@lingui/macro'; import { @@ -12,7 +12,7 @@ import heroImg from '@patternfly/patternfly-next/assets/images/pfbg_992.jpg'; import brandImg from '../../images/tower-logo-white.svg'; import logoImg from '../../images/tower-logo-login.svg'; -class About extends Component { +class About extends React.Component { static createSpeechBubble (version) { let text = `Tower ${version}`; let top = ''; diff --git a/src/components/ListItem/CheckboxListItem.jsx b/src/components/ListItem/CheckboxListItem.jsx new file mode 100644 index 0000000000..d27722af19 --- /dev/null +++ b/src/components/ListItem/CheckboxListItem.jsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { I18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { + Checkbox, +} from '@patternfly/react-core'; + +export default ({ + itemId, + name, + isSelected, + onSelect, +}) => ( +
  • +
    + + {({ i18n }) => ( + + )} + +
    +
    + +
    +
  • + ); diff --git a/src/components/ListItem/index.js b/src/components/ListItem/index.js new file mode 100644 index 0000000000..cfc19060dd --- /dev/null +++ b/src/components/ListItem/index.js @@ -0,0 +1,3 @@ +import CheckboxListItem from './CheckboxListItem'; + +export default CheckboxListItem; diff --git a/src/components/Lookup/Lookup.jsx b/src/components/Lookup/Lookup.jsx new file mode 100644 index 0000000000..1f81d632f3 --- /dev/null +++ b/src/components/Lookup/Lookup.jsx @@ -0,0 +1,91 @@ +import React from 'react'; + +import { SearchIcon } from '@patternfly/react-icons'; +import { + Modal, + Button, + ActionGroup, + Toolbar, + ToolbarGroup, +} from '@patternfly/react-core'; + +import CheckboxListItem from '../ListItem' + +class Lookup extends React.Component { + constructor(props) { + super(props); + this.state = { + isModalOpen: false, + } + this.handleModalToggle = this.handleModalToggle.bind(this); + this.onLookup = this.onLookup.bind(this); + this.onChecked = this.onChecked.bind(this); + this.wrapTags = this.wrapTags.bind(this); + this.onRemove = this.onRemove.bind(this); + } + + handleModalToggle() { + this.setState((prevState, _) => ({ + isModalOpen: !prevState.isModalOpen, + })); + }; + + onLookup() { + this.handleModalToggle(); + } + + onChecked(_, evt) { + this.props.lookupChange(evt.target.value); + }; + + onRemove(evt) { + this.props.lookupChange(evt.target.id); + } + wrapTags(tags) { + return tags.filter(tag => tag.isChecked).map((tag, index) => { + return ( + {tag.name}x + ) + }) + } + + render() { + const { isModalOpen } = this.state; + const { data } = this.props; + return ( +
    + +
    {this.wrapTags(this.props.data)}
    + +
      + {data.map(i => + + )} +
    + + + + + + + + + + +
    +
    + ) + } +} +export default Lookup; diff --git a/src/components/Lookup/index.js b/src/components/Lookup/index.js new file mode 100644 index 0000000000..9fe24f31ed --- /dev/null +++ b/src/components/Lookup/index.js @@ -0,0 +1,3 @@ +import Lookup from './Lookup'; + +export default Lookup; diff --git a/src/pages/Organizations/screens/OrganizationAdd.jsx b/src/pages/Organizations/screens/OrganizationAdd.jsx index e42072ff18..2d447d4b77 100644 --- a/src/pages/Organizations/screens/OrganizationAdd.jsx +++ b/src/pages/Organizations/screens/OrganizationAdd.jsx @@ -19,38 +19,53 @@ import { } from '@patternfly/react-core'; import { ConfigContext } from '../../../context'; +import Lookup from '../../../components/Lookup'; import AnsibleSelect from '../../../components/AnsibleSelect' const { light } = PageSectionVariants; - class OrganizationAdd extends React.Component { constructor(props) { super(props); this.handleChange = this.handleChange.bind(this); this.onSelectChange = this.onSelectChange.bind(this); + this.onLookupChange = this.onLookupChange.bind(this); this.onSubmit = this.onSubmit.bind(this); this.resetForm = this.resetForm.bind(this); + this.onSuccess = this.onSuccess.bind(this); this.onCancel = this.onCancel.bind(this); + this.format = this.format.bind(this); } state = { name: '', description: '', - instanceGroups: '', + results: [], + instance_groups: [], custom_virtualenv: '', - error:'', + error: '', }; onSelectChange(value, _) { this.setState({ custom_virtualenv: value }); }; + onLookupChange(id, _) { + let selected = { ...this.state.results } + const index = id - 1; + selected[index].isChecked = !selected[index].isChecked; + this.setState({ selected }) + } + resetForm() { this.setState({ - ...this.state, name: '', - description: '' + description: '', + }); + let reset = []; + this.state.results.map((result) => { + reset.push({ id: result.id, name: result.name, isChecked: false }); }) + this.setState({ results: reset }); } handleChange(_, evt) { @@ -60,16 +75,57 @@ class OrganizationAdd extends React.Component { async onSubmit() { const { api } = this.props; const data = Object.assign({}, { ...this.state }); - await api.createOrganization(data); - this.resetForm(); + try { + const { data: response } = await api.createOrganization(data); + const url = response.related.instance_groups; + const selected = this.state.results.filter(group => group.isChecked); + try { + if (selected.length > 0) { + selected.forEach( async (select) => { + await api.createInstanceGroups(url, select.id); + }); + } + } catch (err) { + this.setState({ createInstanceGroupsError: err }) + } finally { + this.resetForm(); + this.onSuccess(response.id); + } + } + catch (err) { + this.setState({ onSubmitError: err }) + } } onCancel() { this.props.history.push('/organizations'); } + onSuccess(id) { + this.props.history.push(`/organizations/${id}`); + } + + format(data) { + let results = []; + data.results.map((result) => { + results.push({ id: result.id, name: result.name, isChecked: false }); + }); + return results; + }; + + async componentDidMount() { + const { api } = this.props; + try { + const { data } = await api.getInstanceGroups(); + const results = this.format(data); + this.setState({ results }); + } catch (error) { + this.setState({ getInstanceGroupsError: error }) + } + } + render() { - const { name } = this.state; + const { name, results } = this.state; const enabled = name.length > 0; // TODO: add better form validation return ( @@ -106,13 +162,11 @@ class OrganizationAdd extends React.Component { onChange={this.handleChange} /> - {/* LOOKUP MODAL PLACEHOLDER */} -