diff --git a/awx/ui_next/src/components/MultiSelect/MultiSelect.jsx b/awx/ui_next/src/components/MultiSelect/MultiSelect.jsx index 62352b0036..930664334d 100644 --- a/awx/ui_next/src/components/MultiSelect/MultiSelect.jsx +++ b/awx/ui_next/src/components/MultiSelect/MultiSelect.jsx @@ -1,7 +1,5 @@ import React, { Component, Fragment } from 'react'; -import PropTypes from 'prop-types'; -import { withI18n } from '@lingui/react'; -import { withRouter } from 'react-router-dom'; +import { shape, number, string, func, arrayOf, oneOfType } from 'prop-types'; import { Chip, ChipGroup } from '@components/Chip'; import { Dropdown as PFDropdown, @@ -15,11 +13,13 @@ 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 { @@ -38,23 +38,28 @@ const Dropdown = styled(PFDropdown)` } `; +const Item = shape({ + id: oneOfType([number, string]).isRequired, + name: string.isRequired, +}); + class MultiSelect extends Component { static propTypes = { - associatedItems: PropTypes.arrayOf( - PropTypes.shape({ - name: PropTypes.string.isRequired, - }) - ).isRequired, - onAddNewItem: PropTypes.func, - onRemoveItem: PropTypes.func, - onChange: PropTypes.func, + associatedItems: 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); @@ -68,6 +73,7 @@ class MultiSelect extends Component { this.handleSelection = this.handleSelection.bind(this); this.removeChip = this.removeChip.bind(this); this.handleClick = this.handleClick.bind(this); + this.createNewItem = this.createNewItem.bind(this); } componentDidMount() { @@ -80,11 +86,7 @@ class MultiSelect extends Component { getInitialChipItems() { const { associatedItems } = this.props; - return associatedItems.map(item => ({ - name: item.name, - id: item.id, - organization: item.organization, - })); + return associatedItems.map(item => ({ ...item })); } handleClick(e, option) { @@ -111,9 +113,21 @@ class MultiSelect extends Component { onChange(items); } + createNewItem(name) { + const { createNewItem } = this.props; + if (createNewItem) { + return createNewItem(name); + } + return { + id: Math.random(), + name, + }; + } + handleAddItem(event) { const { input, chipItems } = this.state; - const { onAddNewItem, onChange } = this.props; + const { options, onAddNewItem, onChange } = this.props; + const match = options.find(item => item.name === input); const isIncluded = chipItems.some(chipItem => chipItem.name === input); if (!input) { @@ -127,23 +141,25 @@ class MultiSelect extends Component { this.setState({ input: '', isExpanded: false }); return; } - if (event.key === 'Enter') { + const isNewItem = !match || !chipItems.find(item => item.id === match.id); + if (event.key === 'Enter' && isNewItem) { event.preventDefault(); const items = chipItems.concat({ name: input, id: input }); + const newItem = match || this.createNewItem(input); this.setState({ chipItems: items, isExpanded: false, input: '', }); - onAddNewItem(input); + onAddNewItem(newItem); onChange(items); - } else if (event.key === 'Tab') { - this.setState({ input: '' }); + } else if (!isNewItem || event.key === 'Tab') { + this.setState({ isExpanded: false, input: '' }); } } - handleInputChange(e) { - this.setState({ input: e, isExpanded: true }); + handleInputChange(value) { + this.setState({ input: value, isExpanded: true }); } removeChip(e, item) { @@ -226,5 +242,4 @@ class MultiSelect extends Component { ); } } -export { MultiSelect as _MultiSelect }; -export default withI18n()(withRouter(MultiSelect)); +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 index 3832cea6fe..e52e56b3c1 100644 --- a/awx/ui_next/src/components/MultiSelect/MultiSelect.test.jsx +++ b/awx/ui_next/src/components/MultiSelect/MultiSelect.test.jsx @@ -1,7 +1,7 @@ import React from 'react'; +import { mount } from 'enzyme'; import { sleep } from '@testUtils/testUtils'; -import MultiSelect, { _MultiSelect } from './MultiSelect'; -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import MultiSelect from './MultiSelect'; describe('', () => { const associatedItems = [ @@ -11,11 +11,7 @@ describe('', () => { const options = [{ name: 'Angry', id: 3 }, { name: 'Potato', id: 4 }]; test('Initially render successfully', () => { - const getInitialChipItems = jest.spyOn( - _MultiSelect.prototype, - 'getInitialChipItems' - ); - const wrapper = mountWithContexts( + const wrapper = mount( ', () => { ); const component = wrapper.find('MultiSelect'); - expect(getInitialChipItems).toBeCalled(); expect(component.state().chipItems.length).toBe(2); }); test('handleSelection add item to chipItems', async () => { - const wrapper = mountWithContexts( + const wrapper = mount( ', () => { test('handleAddItem adds a chip only when Tab is pressed', () => { const onAddNewItem = jest.fn(); const onChange = jest.fn(); - const wrapper = mountWithContexts( + const wrapper = mount( ', () => { const onRemoveItem = jest.fn(); const onChange = jest.fn(); - const wrapper = mountWithContexts( + const wrapper = mount( v.name).join(','); +} + +function stringToArray(value) { + return value.split(',').map(v => ({ + id: v, + name: v, + })); +} + +function TagMultiSelect ({ onChange, value }) { + const [options, setOptions] = useState(stringToArray(value)); + + return ( + { onChange(arrayToString(val)) }} + onAddNewItem={(newItem) => { + if (!options.find(o => o.name === newItem.name)) { + setOptions(options.concat(newItem)); + } + }} + associatedItems={stringToArray(value)} + options={options} + createNewItem={(name) => ({ id: name, name })} + /> + ) +} + +TagMultiSelect.propTypes = { + onChange: func.isRequired, + value: string.isRequired, +} + +export default TagMultiSelect; diff --git a/awx/ui_next/src/components/MultiSelect/index.js b/awx/ui_next/src/components/MultiSelect/index.js index 8cda42c7cb..d983765272 100644 --- a/awx/ui_next/src/components/MultiSelect/index.js +++ b/awx/ui_next/src/components/MultiSelect/index.js @@ -1 +1,2 @@ export { default } from './MultiSelect'; +export { default as TagMultiSelect } from './TagMultiSelect'; diff --git a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx index 518f0ebc72..bd686bf3cf 100644 --- a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx +++ b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx @@ -9,7 +9,7 @@ import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-ic import ContentError from '@components/ContentError'; import ContentLoading from '@components/ContentLoading'; import AnsibleSelect from '@components/AnsibleSelect'; -import MultiSelect from '@components/MultiSelect'; +import MultiSelect, { TagMultiSelect } from '@components/MultiSelect'; import FormActionGroup from '@components/FormActionGroup'; import FormField from '@components/FormField'; import FormRow from '@components/FormRow'; @@ -552,24 +552,32 @@ class JobTemplateForm extends Component { t`Select the Instance Groups for this Organization to run on.` )} /> - - - - - - + { + return ( + + + + + form.setFieldValue(field.name, value)} + value={field.value} + /> + + ); + }} + /> bag.props.handleSubmit(values),