diff --git a/__tests__/components/Lookup.test.jsx b/__tests__/components/Lookup.test.jsx index 9081fd0dfd..d5623aad55 100644 --- a/__tests__/components/Lookup.test.jsx +++ b/__tests__/components/Lookup.test.jsx @@ -3,41 +3,51 @@ import { mount } from 'enzyme'; import { I18nProvider } from '@lingui/react'; import Lookup from '../../src/components/Lookup'; -let mockData = [{ name: 'foo', id: 0, isChecked: false }]; +let mockData = [{ name: 'foo', id: 1, isChecked: false }]; describe('', () => { test('initially renders succesfully', () => { mount( { }} + onLookupSave={() => { }} data={mockData} + selected={[]} /> ); }); - test('calls "onLookup" when search icon is clicked', () => { - const spy = jest.spyOn(Lookup.prototype, 'onLookup'); + test('Opens modal when search icon is clicked', () => { + const spy = jest.spyOn(Lookup.prototype, 'handleModalToggle'); + const mockSelected = [{ name: 'foo', id: 1 }]; const wrapper = mount( { }} + onLookupSave={() => { }} data={mockData} + selected={mockSelected} /> - ); + ).find('Lookup'); expect(spy).not.toHaveBeenCalled(); + expect(wrapper.state('lookupSelectedItems')).toEqual([]); const searchItem = wrapper.find('.pf-c-input-group__text#search'); searchItem.first().simulate('click'); expect(spy).toHaveBeenCalled(); + expect(wrapper.state('lookupSelectedItems')).toEqual([{ + id: 1, + name: 'foo' + }]); + expect(wrapper.state('isModalOpen')).toEqual(true); }); - test('calls "onChecked" when a user changes a checkbox', () => { - const spy = jest.spyOn(Lookup.prototype, 'onChecked'); + test('calls "toggleSelected" when a user changes a checkbox', () => { + const spy = jest.spyOn(Lookup.prototype, 'toggleSelected'); const wrapper = mount( { }} + onLookupSave={() => { }} data={mockData} + selected={[]} /> ); @@ -46,15 +56,17 @@ describe('', () => { 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'); - mockData = [{ name: 'foo', id: 0, isChecked: false }, { name: 'bar', id: 1, isChecked: true }]; + test('calls "toggleSelected" when remove icon is clicked', () => { + const spy = jest.spyOn(Lookup.prototype, 'toggleSelected'); + mockData = [{ name: 'foo', id: 1, isChecked: false }, { name: 'bar', id: 2, isChecked: true }]; + const mockSelected = [{ name: 'foo', id: 1 }, { name: 'bar', id: 2 }]; const wrapper = mount( { }} + onLookupSave={() => { }} data={mockData} + selected={mockSelected} /> ); @@ -69,8 +81,9 @@ describe('', () => { { }} + onLookupSave={() => { }} data={mockData} + selected={[]} /> ); @@ -78,4 +91,57 @@ describe('', () => { const pill = wrapper.find('span.awx-c-tag--pill'); expect(pill).toHaveLength(0); }); + test('toggleSelected successfully adds/removes row from lookupSelectedItems state', () => { + mockData = [{ name: 'foo', id: 1 }]; + const wrapper = mount( + + { }} + data={mockData} + selected={[]} + /> + + ).find('Lookup'); + wrapper.instance().toggleSelected({ + id: 1, + name: 'foo' + }); + expect(wrapper.state('lookupSelectedItems')).toEqual([{ + id: 1, + name: 'foo' + }]); + wrapper.instance().toggleSelected({ + id: 1, + name: 'foo' + }); + expect(wrapper.state('lookupSelectedItems')).toEqual([]); + }); + test('saveModal calls callback with selected items', () => { + mockData = [{ name: 'foo', id: 1 }]; + const onLookupSaveFn = jest.fn(); + const wrapper = mount( + + + + ).find('Lookup'); + wrapper.instance().toggleSelected({ + id: 1, + name: 'foo' + }); + expect(wrapper.state('lookupSelectedItems')).toEqual([{ + id: 1, + name: 'foo' + }]); + wrapper.instance().saveModal(); + expect(onLookupSaveFn).toHaveBeenCalledWith([{ + id: 1, + name: 'foo' + }]); + }); }); diff --git a/__tests__/components/SelectedList.test.jsx b/__tests__/components/SelectedList.test.jsx new file mode 100644 index 0000000000..4eaa52bf61 --- /dev/null +++ b/__tests__/components/SelectedList.test.jsx @@ -0,0 +1,123 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import SelectedList from '../../src/components/SelectedList'; + +describe('', () => { + test('initially renders succesfully', () => { + const mockSelected = [ + { + id: 1, + name: 'foo' + }, { + id: 2, + name: 'bar' + } + ]; + mount( + {}} + /> + ); + }); + test('showOverflow should set showOverflow state to true', () => { + const wrapper = mount( + {}} + /> + ); + expect(wrapper.state('showOverflow')).toBe(false); + wrapper.instance().showOverflow(); + expect(wrapper.state('showOverflow')).toBe(true); + }); + test('Overflow chip should be shown when more selected.length exceeds showOverflowAfter', () => { + const mockSelected = [ + { + id: 1, + name: 'foo' + }, { + id: 2, + name: 'bar' + }, { + id: 3, + name: 'foobar' + }, { + id: 4, + name: 'baz' + }, { + id: 5, + name: 'foobaz' + } + ]; + const wrapper = mount( + {}} + /> + ); + expect(wrapper.find('Chip').length).toBe(4); + expect(wrapper.find('[isOverflowChip=true]').length).toBe(1); + }); + test('Clicking overflow chip should show all chips', () => { + const mockSelected = [ + { + id: 1, + name: 'foo' + }, { + id: 2, + name: 'bar' + }, { + id: 3, + name: 'foobar' + }, { + id: 4, + name: 'baz' + }, { + id: 5, + name: 'foobaz' + } + ]; + const wrapper = mount( + {}} + /> + ); + expect(wrapper.find('Chip').length).toBe(4); + expect(wrapper.find('[isOverflowChip=true]').length).toBe(1); + wrapper.find('[isOverflowChip=true] button').simulate('click'); + expect(wrapper.find('Chip').length).toBe(5); + expect(wrapper.find('[isOverflowChip=true]').length).toBe(0); + }); + test('Clicking remove on chip calls onRemove callback prop with correct params', () => { + const onRemove = jest.fn(); + const mockSelected = [ + { + id: 1, + name: 'foo' + } + ]; + const wrapper = mount( + + ); + wrapper.find('.pf-c-chip button').first().simulate('click'); + expect(onRemove).toBeCalledWith({ + id: 1, + name: 'foo' + }); + }); +}); diff --git a/__tests__/pages/Organizations/screens/OrganizationAdd.test.jsx b/__tests__/pages/Organizations/screens/OrganizationAdd.test.jsx index ad0ef6fc8f..27e0b7b07b 100644 --- a/__tests__/pages/Organizations/screens/OrganizationAdd.test.jsx +++ b/__tests__/pages/Organizations/screens/OrganizationAdd.test.jsx @@ -92,4 +92,70 @@ describe('', () => { done(); }); }); + + test('updateSelectedInstanceGroups successfully sets selectedInstanceGroups state', () => { + const wrapper = mount( + + + + ).find('OrganizationAdd'); + wrapper.instance().updateSelectedInstanceGroups([ + { + id: 1, + name: 'foo' + } + ]); + expect(wrapper.state('selectedInstanceGroups')).toEqual([ + { + id: 1, + name: 'foo' + } + ]); + }); + + test('onSelectChange successfully sets custom_virtualenv state', () => { + const wrapper = mount( + + + + ).find('OrganizationAdd'); + wrapper.instance().onSelectChange('foobar'); + expect(wrapper.state('custom_virtualenv')).toBe('foobar'); + }); + + test('onSubmit posts instance groups from selectedInstanceGroups', async () => { + const createOrganizationFn = jest.fn().mockResolvedValue({ + data: { + id: 1, + name: 'mock org', + related: { + instance_groups: '/api/v2/organizations/1/instance_groups' + } + } + }); + const createInstanceGroupsFn = jest.fn().mockResolvedValue('done'); + const api = { + createOrganization: createOrganizationFn, + createInstanceGroups: createInstanceGroupsFn + }; + const wrapper = mount( + + + + ).find('OrganizationAdd'); + wrapper.setState({ + name: 'mock org', + selectedInstanceGroups: [{ + id: 1, + name: 'foo' + }] + }); + await wrapper.instance().onSubmit(); + expect(createOrganizationFn).toHaveBeenCalledWith({ + custom_virtualenv: '', + description: '', + name: 'mock org' + }); + expect(createInstanceGroupsFn).toHaveBeenCalledWith('/api/v2/organizations/1/instance_groups', 1); + }); }); diff --git a/package-lock.json b/package-lock.json index 90242670df..517dacec0f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1311,22 +1311,27 @@ "integrity": "sha512-sIRfo/tk4NSnaRwHIHLUf4XoqzNNa4MMa8ZWivzzSfdZ5pCbgvZtyEUqKnQAEH6zuaCM9S8HyhxcNXnm/xaYaQ==" }, "@patternfly/react-core": { - "version": "1.43.5", - "resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-1.43.5.tgz", - "integrity": "sha512-xO37/q5BJEdGvAoPllm/gbcwCtW7t0Ae7mKm5UU7d4i5bycEjd0UwJacYxCA6GFTwxN5kzn61XEpAUPY49U3pA==", + "version": "1.49.5", + "resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-1.49.5.tgz", + "integrity": "sha512-bb62fkL8nB6F1cUd/szfpLOIAjaa5HBzAoOa4Vc1AjdagwZ6w4MsU7xBPtC0Sp937CpGckRGiVOf0XHWEiSL2g==", "requires": { - "@patternfly/react-icons": "^2.9.5", + "@patternfly/react-icons": "^2.10.1", "@patternfly/react-styles": "^2.3.0", - "@patternfly/react-tokens": "^1.0.0", + "@patternfly/react-tokens": "^1.10.0", "@tippy.js/react": "^1.1.1", "exenv": "^1.2.2", "focus-trap-react": "^4.0.1" }, "dependencies": { "@patternfly/react-icons": { - "version": "2.9.5", - "resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-2.9.5.tgz", - "integrity": "sha512-5e/BD2ER5jifUjUgbIilApOfhVldlAjhQdh7EwH/M3M+qzIb+2qKxV/xQ6hWD3AA71lcYIxvPMMHgdWIAl5oPQ==" + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-2.10.1.tgz", + "integrity": "sha512-d3uWfQQeCgCLel2DVlF1SSlyOI0Z12tT1YjSLDE091E2uCB582DUQQ4HfmuV51nH5aTXg+en35QG7JP5jzYlvA==" + }, + "@patternfly/react-tokens": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-1.10.0.tgz", + "integrity": "sha512-jslQPSRgwbSXAGszA22prGSVye6ri3sRFkaF3BUdWBa8fO6Z2MDFB59x4d6BGK9iW7S+3U/Qkden6myP1CgXdA==" } } }, @@ -13808,9 +13813,9 @@ "integrity": "sha1-rifbOPZgp64uHDt9G8KQgZuFGeY=" }, "tabbable": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-3.1.1.tgz", - "integrity": "sha512-583MHIOwictf7+zbxqO/L5fBqMN6Li4SJ1XTKQA9WzHRA7c2BB+D+Ny7Y6kGqU2u+rHK59+oRzrBvMU53aZz+A==" + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-3.1.2.tgz", + "integrity": "sha512-wjB6puVXTYO0BSFtCmWQubA/KIn7Xvajw0x0l6eJUudMG/EAiJvIUnyNX6xO4NpGrJ16lbD0eUseB9WxW0vlpQ==" }, "table": { "version": "5.1.0", @@ -13927,9 +13932,9 @@ } }, "tippy.js": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-3.3.0.tgz", - "integrity": "sha512-2gIQg57EFSCBqE97NZbakSkGBJF0GzdOhx/lneGQGMzJiJyvbpyKgNy4l4qofq0nEbXACl7C/jW/ErsdQa21aQ==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-3.4.1.tgz", + "integrity": "sha512-ZiyGP9WZyCCcjxKM4G88cm4U1r1ytjeMDGa5FSKPaPzwc/3yZJVZsb1ffcmqUMCpryRp5LNxRNGKLzbs11sb/Q==", "requires": { "popper.js": "^1.14.6" } diff --git a/package.json b/package.json index 31eb8bd2da..a95bcb1f79 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "dependencies": { "@lingui/react": "^2.7.2", "@patternfly/patternfly-next": "^1.0.84", - "@patternfly/react-core": "^1.43.5", + "@patternfly/react-core": "^1.49.5", "@patternfly/react-icons": "^2.9.1", "@patternfly/react-styles": "^2.3.0", "@patternfly/react-tokens": "^1.9.0", diff --git a/src/components/Lookup/Lookup.jsx b/src/components/Lookup/Lookup.jsx index 691c798350..c4ffe97030 100644 --- a/src/components/Lookup/Lookup.jsx +++ b/src/components/Lookup/Lookup.jsx @@ -8,47 +8,66 @@ import { Toolbar, ToolbarGroup, } from '@patternfly/react-core'; +import { I18n } from '@lingui/react'; +import { t, Trans } from '@lingui/macro'; import CheckboxListItem from '../ListItem'; +import SelectedList from '../SelectedList'; + class Lookup extends React.Component { constructor (props) { super(props); this.state = { isModalOpen: false, + lookupSelectedItems: [] }; 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); + this.toggleSelected = this.toggleSelected.bind(this); + this.saveModal = this.saveModal.bind(this); } - onLookup () { - this.handleModalToggle(); - } - - onChecked (_, evt) { - const { lookupChange } = this.props; - lookupChange(evt.target.value); - } - - onRemove (evt) { - const { lookupChange } = this.props; - lookupChange(evt.target.id); + toggleSelected (row) { + const { lookupSelectedItems } = this.state; + const selectedIndex = lookupSelectedItems + .findIndex(selectedRow => selectedRow.id === row.id); + if (selectedIndex > -1) { + lookupSelectedItems.splice(selectedIndex, 1); + this.setState({ lookupSelectedItems }); + } else { + this.setState(prevState => ({ + lookupSelectedItems: [...prevState.lookupSelectedItems, row] + })); + } } handleModalToggle () { + const { isModalOpen } = this.state; + const { selected } = this.props; + // Resets the selected items from parent state whenever modal is opened + // This handles the case where the user closes/cancels the modal and + // opens it again + if (!isModalOpen) { + this.setState({ lookupSelectedItems: [...selected] }); + } this.setState((prevState) => ({ isModalOpen: !prevState.isModalOpen, })); } + saveModal () { + const { onLookupSave } = this.props; + const { lookupSelectedItems } = this.state; + onLookupSave(lookupSelectedItems); + this.handleModalToggle(); + } + wrapTags (tags) { - return tags.filter(tag => tag.isChecked).map((tag) => ( + return tags.map(tag => ( {tag.name} - @@ -56,14 +75,14 @@ class Lookup extends React.Component { } render () { - const { isModalOpen } = this.state; - const { data, lookupHeader } = this.props; + const { isModalOpen, lookupSelectedItems } = this.state; + const { data, lookupHeader, selected } = this.props; return (
- -
{this.wrapTags(data)}
+
{this.wrapTags(selected)}
item.id === i.id)} + onSelect={() => this.toggleSelected(i)} /> ))} + {lookupSelectedItems.length > 0 && ( + + {({ i18n }) => ( + + )} + + )} - + - + diff --git a/src/components/SelectedList/SelectedList.jsx b/src/components/SelectedList/SelectedList.jsx new file mode 100644 index 0000000000..3e11549024 --- /dev/null +++ b/src/components/SelectedList/SelectedList.jsx @@ -0,0 +1,61 @@ +import React, { Component } from 'react'; +import { + Chip +} from '@patternfly/react-core'; + +class SelectedList extends Component { + constructor (props) { + super(props); + + this.state = { + showOverflow: false + }; + + this.showOverflow = this.showOverflow.bind(this); + } + + showOverflow = () => { + this.setState({ showOverflow: true }); + }; + + render () { + const { label, selected, showOverflowAfter, onRemove } = this.props; + const { showOverflow } = this.state; + return ( +
+
+
+ {label} +
+
+
+ {selected + .slice(0, showOverflow ? selected.length : showOverflowAfter) + .map(selectedItem => ( + onRemove(selectedItem)} + > + {selectedItem.name} + + ))} + {( + !showOverflow + && selected.length > showOverflowAfter + ) && ( + this.showOverflow()} + > + {`${(selected.length - showOverflowAfter).toString()} more`} + + )} +
+
+
+
+ ); + } +} + +export default SelectedList; diff --git a/src/components/SelectedList/index.js b/src/components/SelectedList/index.js new file mode 100644 index 0000000000..00a1ae10be --- /dev/null +++ b/src/components/SelectedList/index.js @@ -0,0 +1,3 @@ +import SelectedList from './SelectedList'; + +export default SelectedList; diff --git a/src/components/SelectedList/styles.scss b/src/components/SelectedList/styles.scss new file mode 100644 index 0000000000..dbf385d251 --- /dev/null +++ b/src/components/SelectedList/styles.scss @@ -0,0 +1,31 @@ +.awx-selectedList { + --awx-selectedList--BackgroundColor: var(--pf-global--BackgroundColor--light-100); + --awx-selectedList--BorderColor: #d7d7d7; + --awx-selectedList--BorderWidth: var(--pf-global--BorderWidth--sm); + --awx-selectedList--FontSize: var(--pf-c-chip__text--FontSize); + + + .pf-l-split { + padding-top: 20px; + padding-bottom: 10px; + border-bottom: var(--awx-selectedList--BorderWidth) solid var(--awx-selectedList--BorderColor); + } + .pf-l-split__item:first-child { + display: flex; + white-space: nowrap; + height: 30px; + } + .pf-l-split__item:not(:last-child):after { + content: ""; + background-color: var(--awx-selectedList--BorderColor); + width: 1px; + height: 30px; + display: block; + margin-left: 20px; + margin-right: 20px; + } + .pf-c-chip { + margin-right: 10px; + margin-bottom: 10px; + } +} \ No newline at end of file diff --git a/src/index.jsx b/src/index.jsx index 0a5ff2742a..c8958c7fe5 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -18,6 +18,7 @@ import '@patternfly/patternfly-next/patternfly.css'; import './app.scss'; import './components/Pagination/styles.scss'; import './components/DataListToolbar/styles.scss'; +import './components/SelectedList/styles.scss'; import APIClient from './api'; diff --git a/src/pages/Organizations/screens/OrganizationAdd.jsx b/src/pages/Organizations/screens/OrganizationAdd.jsx index ab116eece0..cadd5d7c9e 100644 --- a/src/pages/Organizations/screens/OrganizationAdd.jsx +++ b/src/pages/Organizations/screens/OrganizationAdd.jsx @@ -34,20 +34,20 @@ class OrganizationAdd extends React.Component { 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.updateSelectedInstanceGroups = this.updateSelectedInstanceGroups.bind(this); } state = { name: '', description: '', results: [], - instance_groups: [], custom_virtualenv: '', error: '', + selectedInstanceGroups: [] }; async componentDidMount () { @@ -57,7 +57,7 @@ class OrganizationAdd extends React.Component { const results = format(data); this.setState({ results }); } catch (error) { - this.setState({ getInstanceGroupsError: error }); + this.setState({ error }); } } @@ -65,36 +65,32 @@ class OrganizationAdd extends React.Component { this.setState({ custom_virtualenv: value }); } - onLookupChange (id) { - const { results } = this.state; - const selected = { ...results }; - const index = id - 1; - selected[index].isChecked = !selected[index].isChecked; - this.setState({ selected }); - } - async onSubmit () { const { api } = this.props; - const data = Object.assign({}, { ...this.state }); - const { results } = this.state; + const { name, description, custom_virtualenv } = this.state; + const data = { + name, + description, + custom_virtualenv + }; + const { selectedInstanceGroups } = this.state; try { const { data: response } = await api.createOrganization(data); const url = response.related.instance_groups; - const selected = results.filter(group => group.isChecked); try { - if (selected.length > 0) { - selected.forEach(async (select) => { + if (selectedInstanceGroups.length > 0) { + selectedInstanceGroups.forEach(async (select) => { await api.createInstanceGroups(url, select.id); }); } } catch (err) { - this.setState({ createInstanceGroupsError: err }); + this.setState({ error: err }); } finally { this.resetForm(); this.onSuccess(response.id); } } catch (err) { - this.setState({ onSubmitError: err }); + this.setState({ error: err }); } } @@ -108,6 +104,10 @@ class OrganizationAdd extends React.Component { history.push(`/organizations/${id}`); } + updateSelectedInstanceGroups (selectedInstanceGroups) { + this.setState({ selectedInstanceGroups }); + } + handleChange (_, evt) { this.setState({ [evt.target.name]: evt.target.value }); } @@ -123,7 +123,14 @@ class OrganizationAdd extends React.Component { } render () { - const { name, results, description, custom_virtualenv } = this.state; + const { + name, + results, + description, + custom_virtualenv, + selectedInstanceGroups, + error + } = this.state; const enabled = name.length > 0; // TODO: add better form validation return ( @@ -157,8 +164,9 @@ class OrganizationAdd extends React.Component { @@ -182,6 +190,7 @@ class OrganizationAdd extends React.Component { + { error ?
error
: '' }