diff --git a/awx/ui_next/src/components/MultiSelect/MultiSelect.jsx b/awx/ui_next/src/components/MultiSelect/MultiSelect.jsx deleted file mode 100644 index b8afb15855..0000000000 --- a/awx/ui_next/src/components/MultiSelect/MultiSelect.jsx +++ /dev/null @@ -1,227 +0,0 @@ -import React, { Component, Fragment } from 'react'; -import { shape, number, string, func, arrayOf, oneOfType } from 'prop-types'; -import { Chip, ChipGroup } from '@components/Chip'; -import { - Dropdown as PFDropdown, - DropdownItem, - TextInput as PFTextInput, - DropdownToggle, -} from '@patternfly/react-core'; -import styled from 'styled-components'; - -const InputGroup = styled.div` - border: 1px solid black; - margin-top: 2px; -`; - -const TextInput = styled(PFTextInput)` - border: none; - width: 100%; - padding-left: 8px; -`; - -const Dropdown = styled(PFDropdown)` - width: 100%; - .pf-c-dropdown__toggle.pf-m-plain { - display: none; - } - display: block; - .pf-c-dropdown__menu { - max-height: 200px; - overflow: scroll; - } - && button[disabled] { - color: var(--pf-c-button--m-plain--Color); - pointer-events: initial; - cursor: not-allowed; - color: var(--pf-global--disabled-color--200); - } -`; - -const Item = shape({ - id: oneOfType([number, string]).isRequired, - name: string.isRequired, -}); - -class MultiSelect extends Component { - static propTypes = { - value: arrayOf(Item).isRequired, - options: arrayOf(Item), - onAddNewItem: func, - onRemoveItem: func, - onChange: func, - createNewItem: func, - }; - - static defaultProps = { - onAddNewItem: () => {}, - onRemoveItem: () => {}, - onChange: () => {}, - options: [], - createNewItem: null, - }; - - constructor(props) { - super(props); - this.state = { - input: '', - isExpanded: false, - }; - this.handleKeyDown = this.handleKeyDown.bind(this); - this.handleInputChange = this.handleInputChange.bind(this); - this.removeItem = this.removeItem.bind(this); - this.handleClick = this.handleClick.bind(this); - this.createNewItem = this.createNewItem.bind(this); - } - - componentDidMount() { - document.addEventListener('mousedown', this.handleClick, false); - } - - componentWillUnmount() { - document.removeEventListener('mousedown', this.handleClick, false); - } - - handleClick(e, option) { - if (this.node && this.node.contains(e.target)) { - if (option) { - e.preventDefault(); - this.addItem(option); - } - } else { - this.setState({ input: '', isExpanded: false }); - } - } - - addItem(item) { - const { value, onAddNewItem, onChange } = this.props; - const items = value.concat(item); - onAddNewItem(item); - onChange(items); - this.close(); - } - - // TODO: UpArrow & DownArrow for menu navigation - handleKeyDown(event) { - const { value, options } = this.props; - const { input } = this.state; - if (event.key === 'Tab') { - this.close(); - return; - } - if (!input || event.key !== 'Enter') { - return; - } - - const isAlreadySelected = value.some(i => i.name === input); - if (isAlreadySelected) { - event.preventDefault(); - this.close(); - return; - } - - const match = options.find(item => item.name === input); - const isNewItem = !match || !value.find(item => item.id === match.id); - if (isNewItem) { - event.preventDefault(); - this.addItem(match || this.createNewItem(input)); - } - } - - close() { - this.setState({ - isExpanded: false, - input: '', - }); - } - - createNewItem(name) { - const { createNewItem } = this.props; - if (createNewItem) { - return createNewItem(name); - } - return { - id: Math.random(), - name, - }; - } - - handleInputChange(value) { - this.setState({ input: value, isExpanded: true }); - } - - removeItem(item) { - const { value, onRemoveItem, onChange } = this.props; - const remainingItems = value.filter(chip => chip.id !== item.id); - - onRemoveItem(item); - onChange(remainingItems); - } - - render() { - const { value, options } = this.props; - const { input, isExpanded } = this.state; - - const dropdownOptions = options.map(option => ( - - {option.name.includes(input) ? ( - item.id === option.id)} - value={option.name} - onClick={e => { - this.handleClick(e, option); - }} - > - {option.name} - - ) : null} - - )); - - return ( - - -
{ - this.node = node; - }} - > - this.setState({ isExpanded: true })} - onChange={this.handleInputChange} - onKeyDown={this.handleKeyDown} - /> - Labels} - // Above is not visible but is a required prop from Patternfly - isOpen={isExpanded} - dropdownItems={dropdownOptions} - /> -
-
- - {value.map(item => ( - { - this.removeItem(item); - }} - > - {item.name} - - ))} - -
-
-
- ); - } -} -export default MultiSelect; diff --git a/awx/ui_next/src/components/MultiSelect/MultiSelect.test.jsx b/awx/ui_next/src/components/MultiSelect/MultiSelect.test.jsx deleted file mode 100644 index 82627b4949..0000000000 --- a/awx/ui_next/src/components/MultiSelect/MultiSelect.test.jsx +++ /dev/null @@ -1,104 +0,0 @@ -import React from 'react'; -import { mount, shallow } from 'enzyme'; -import MultiSelect from './MultiSelect'; - -describe('', () => { - const value = [ - { name: 'Foo', id: 1, organization: 1 }, - { name: 'Bar', id: 2, organization: 1 }, - ]; - const options = [{ name: 'Angry', id: 3 }, { name: 'Potato', id: 4 }]; - - test('should render successfully', () => { - const wrapper = shallow( - - ); - - expect(wrapper.find('Chip')).toHaveLength(2); - }); - - test('should add item when typed', async () => { - const onChange = jest.fn(); - const onAdd = jest.fn(); - const wrapper = mount( - - ); - const component = wrapper.find('MultiSelect'); - const input = component.find('TextInputBase'); - input.invoke('onChange')('Flabadoo'); - input.simulate('keydown', { key: 'Enter' }); - - expect(onAdd.mock.calls[0][0].name).toEqual('Flabadoo'); - const newVal = onChange.mock.calls[0][0]; - expect(newVal).toHaveLength(3); - expect(newVal[2].name).toEqual('Flabadoo'); - }); - - test('should add item when clicked from menu', () => { - const onAddNewItem = jest.fn(); - const onChange = jest.fn(); - const wrapper = mount( - - ); - - const input = wrapper.find('TextInputBase'); - input.simulate('focus'); - wrapper.update(); - const event = { - preventDefault: () => {}, - target: wrapper - .find('DropdownItem') - .at(1) - .getDOMNode(), - }; - wrapper - .find('DropdownItem') - .at(1) - .invoke('onClick')(event); - - expect(onAddNewItem).toHaveBeenCalledWith(options[1]); - const newVal = onChange.mock.calls[0][0]; - expect(newVal).toHaveLength(3); - expect(newVal[2]).toEqual(options[1]); - }); - - test('should remove item', () => { - const onRemoveItem = jest.fn(); - const onChange = jest.fn(); - const wrapper = mount( - - ); - - const chips = wrapper.find('PFChip'); - expect(chips).toHaveLength(2); - chips.at(1).invoke('onClick')(); - - expect(onRemoveItem).toHaveBeenCalledWith(value[1]); - const newVal = onChange.mock.calls[0][0]; - expect(newVal).toHaveLength(1); - expect(newVal).toEqual([value[0]]); - }); -}); diff --git a/awx/ui_next/src/components/MultiSelect/TagMultiSelect.jsx b/awx/ui_next/src/components/MultiSelect/TagMultiSelect.jsx index a398bc0311..c52de1032f 100644 --- a/awx/ui_next/src/components/MultiSelect/TagMultiSelect.jsx +++ b/awx/ui_next/src/components/MultiSelect/TagMultiSelect.jsx @@ -1,42 +1,67 @@ import React, { useState } from 'react'; import { func, string } from 'prop-types'; -import MultiSelect from './MultiSelect'; +import { Select, SelectOption, SelectVariant } from '@patternfly/react-core'; function arrayToString(tags) { - return tags.map(v => v.name).join(','); + return tags.join(','); } function stringToArray(value) { - return value - .split(',') - .filter(val => !!val) - .map(val => ({ - id: val, - name: val, - })); + return value.split(',').filter(val => !!val); } -/* - * Adapter providing a simplified API to a MultiSelect. The value - * is a comma-separated string. - */ function TagMultiSelect({ onChange, value }) { - const [options, setOptions] = useState(stringToArray(value)); + const selections = stringToArray(value); + const [options, setOptions] = useState(selections); + const [isExpanded, setIsExpanded] = useState(false); + + const onSelect = (event, item) => { + let newValue; + if (selections.includes(item)) { + newValue = selections.filter(i => i !== item); + } else { + newValue = selections.concat(item); + } + onChange(arrayToString(newValue)); + }; + + const toggleExpanded = () => { + setIsExpanded(!isExpanded); + }; + + const renderOptions = opts => { + return opts.map(option => ( + + {option} + + )); + }; return ( - { - onChange(arrayToString(val)); + ); } diff --git a/awx/ui_next/src/components/MultiSelect/TagMultiSelect.test.jsx b/awx/ui_next/src/components/MultiSelect/TagMultiSelect.test.jsx index 41f4aa5540..0e19d31c5d 100644 --- a/awx/ui_next/src/components/MultiSelect/TagMultiSelect.test.jsx +++ b/awx/ui_next/src/components/MultiSelect/TagMultiSelect.test.jsx @@ -3,19 +3,22 @@ import { mount } from 'enzyme'; import TagMultiSelect from './TagMultiSelect'; describe('', () => { - it('should render MultiSelect', () => { + it('should render Select', () => { const wrapper = mount( ); - expect(wrapper.find('MultiSelect').prop('options')).toEqual([ - { id: 'foo', name: 'foo' }, - { id: 'bar', name: 'bar' }, - ]); + wrapper.find('input').simulate('focus'); + const options = wrapper.find('SelectOption'); + expect(options).toHaveLength(2); + expect(options.at(0).prop('value')).toEqual('foo'); + expect(options.at(1).prop('value')).toEqual('bar'); }); it('should not treat empty string as an option', () => { const wrapper = mount(); - expect(wrapper.find('MultiSelect').prop('options')).toEqual([]); + wrapper.find('input').simulate('focus'); + expect(wrapper.find('Select').prop('isExpanded')).toEqual(true); + expect(wrapper.find('SelectOption')).toHaveLength(0); }); it('should trigger onChange', () => { @@ -23,13 +26,9 @@ describe('', () => { const wrapper = mount( ); + wrapper.find('input').simulate('focus'); - const select = wrapper.find('MultiSelect'); - select.invoke('onChange')([ - { name: 'foo' }, - { name: 'bar' }, - { name: 'baz' }, - ]); + wrapper.find('Select').invoke('onSelect')(null, 'baz'); expect(onChange).toHaveBeenCalledWith('foo,bar,baz'); }); }); diff --git a/awx/ui_next/src/components/MultiSelect/index.js b/awx/ui_next/src/components/MultiSelect/index.js index d983765272..cbe4fe49e2 100644 --- a/awx/ui_next/src/components/MultiSelect/index.js +++ b/awx/ui_next/src/components/MultiSelect/index.js @@ -1,2 +1,2 @@ -export { default } from './MultiSelect'; export { default as TagMultiSelect } from './TagMultiSelect'; +export { default as useSyncedSelectValue } from './useSyncedSelectValue'; diff --git a/awx/ui_next/src/components/MultiSelect/useSyncedSelectValue.js b/awx/ui_next/src/components/MultiSelect/useSyncedSelectValue.js new file mode 100644 index 0000000000..2512ebe265 --- /dev/null +++ b/awx/ui_next/src/components/MultiSelect/useSyncedSelectValue.js @@ -0,0 +1,52 @@ +import { useState, useEffect } from 'react'; + +/* + Hook for using PatternFly's onChange([])} + onFilter={event => { + const str = event.target.value.toLowerCase(); + const matches = options.filter(o => o.name.toLowerCase().includes(str)); + return renderOptions(matches); + }} + selections={selections} + isExpanded={isExpanded} + ariaLabelledBy="label-select" + placeholderText={placeholder} + > + {renderOptions(options)} + ); } LabelSelect.propTypes = { @@ -55,7 +80,12 @@ LabelSelect.propTypes = { name: string.isRequired, }) ).isRequired, + placeholder: string, + onChange: func.isRequired, onError: func.isRequired, }; +LabelSelect.defaultProps = { + placeholder: '', +}; export default LabelSelect; diff --git a/awx/ui_next/src/screens/Template/shared/LabelSelect.test.jsx b/awx/ui_next/src/screens/Template/shared/LabelSelect.test.jsx index c4cc7ef8a1..88a76ae570 100644 --- a/awx/ui_next/src/screens/Template/shared/LabelSelect.test.jsx +++ b/awx/ui_next/src/screens/Template/shared/LabelSelect.test.jsx @@ -19,12 +19,16 @@ describe('', () => { }); let wrapper; await act(async () => { - wrapper = mount( {}} />); + wrapper = mount( + {}} onChange={() => {}} /> + ); }); - wrapper.update(); - expect(LabelsAPI.read).toHaveBeenCalledTimes(1); - expect(wrapper.find('MultiSelect').prop('options')).toEqual(options); + wrapper.find('input').simulate('focus'); + const selectOptions = wrapper.find('SelectOption'); + expect(selectOptions).toHaveLength(2); + expect(selectOptions.at(0).prop('value')).toEqual(options[0]); + expect(selectOptions.at(1).prop('value')).toEqual(options[1]); }); test('should fetch two pages labels if present', async () => { @@ -36,19 +40,20 @@ describe('', () => { }); LabelsAPI.read.mockReturnValueOnce({ data: { - results: options, + results: [{ id: 3, name: 'three' }, { id: 4, name: 'four' }], }, }); let wrapper; await act(async () => { - wrapper = mount( {}} />); + wrapper = mount( + {}} onChange={() => {}} /> + ); }); wrapper.update(); expect(LabelsAPI.read).toHaveBeenCalledTimes(2); - expect(wrapper.find('MultiSelect').prop('options')).toEqual([ - ...options, - ...options, - ]); + wrapper.find('input').simulate('focus'); + const selectOptions = wrapper.find('SelectOption'); + expect(selectOptions).toHaveLength(4); }); });