1
0
mirror of https://github.com/ansible/awx.git synced 2024-11-01 08:21:15 +03:00

update TagMultiSelect to use PF <Select>

This commit is contained in:
Keith Grant 2020-01-13 10:26:48 -08:00
parent b18ca5ac1f
commit 1289ca9103
5 changed files with 26 additions and 376 deletions

View File

@ -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 => (
<Fragment key={option.id}>
{option.name.includes(input) ? (
<DropdownItem
component="button"
isDisabled={value.some(item => item.id === option.id)}
value={option.name}
onClick={e => {
this.handleClick(e, option);
}}
>
{option.name}
</DropdownItem>
) : null}
</Fragment>
));
return (
<Fragment>
<InputGroup>
<div
ref={node => {
this.node = node;
}}
>
<TextInput
type="text"
aria-label="labels"
value={input}
onFocus={() => this.setState({ isExpanded: true })}
onChange={this.handleInputChange}
onKeyDown={this.handleKeyDown}
/>
<Dropdown
type="button"
isPlain
value={value}
toggle={<DropdownToggle isPlain>Labels</DropdownToggle>}
// Above is not visible but is a required prop from Patternfly
isOpen={isExpanded}
dropdownItems={dropdownOptions}
/>
</div>
<div css="margin: 10px">
<ChipGroup defaultIsOpen numChips={5}>
{value.map(item => (
<Chip
key={item.id}
onClick={() => {
this.removeItem(item);
}}
>
{item.name}
</Chip>
))}
</ChipGroup>
</div>
</InputGroup>
</Fragment>
);
}
}
export default MultiSelect;

View File

@ -1,104 +0,0 @@
import React from 'react';
import { mount, shallow } from 'enzyme';
import MultiSelect from './MultiSelect';
describe('<MultiSelect />', () => {
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(
<MultiSelect
onAddNewItem={jest.fn()}
onRemoveItem={jest.fn()}
value={value}
options={options}
/>
);
expect(wrapper.find('Chip')).toHaveLength(2);
});
test('should add item when typed', async () => {
const onChange = jest.fn();
const onAdd = jest.fn();
const wrapper = mount(
<MultiSelect
onAddNewItem={onAdd}
onRemoveItem={jest.fn()}
onChange={onChange}
value={value}
options={options}
/>
);
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(
<MultiSelect
onAddNewItem={onAddNewItem}
onRemoveItem={jest.fn()}
onChange={onChange}
value={value}
options={options}
/>
);
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(
<MultiSelect
onAddNewItem={jest.fn()}
onRemoveItem={onRemoveItem}
onChange={onChange}
value={value}
options={options}
/>
);
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]]);
});
});

View File

@ -1,41 +1,38 @@
import React, { useState } from 'react';
import { func, string } from 'prop-types';
import { Select, SelectOption, SelectVariant } from '@patternfly/react-core';
import usePFSelect from '@components/MultiSelect/usePFSelect';
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 { selections, onSelect, options, setOptions } = usePFSelect(
value, // TODO: convert with stringToArray without triggering re-render loop
val => onChange(arrayToString(val))
);
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 => (
<SelectOption key={option.id} value={option}>
{option.name}
<SelectOption key={option} value={option}>
{option}
</SelectOption>
));
};
@ -45,18 +42,19 @@ function TagMultiSelect({ onChange, value }) {
variant={SelectVariant.typeaheadMulti}
onToggle={toggleExpanded}
onSelect={onSelect}
onClear={() => onChange([])}
onClear={() => onChange('')}
onFilter={event => {
const str = event.target.value.toLowerCase();
const matches = options.filter(o => o.name.toLowerCase().includes(str));
const matches = options.filter(o => o.toLowerCase().includes(str));
return renderOptions(matches);
}}
isCreatable
onCreateOption={name => {
// TODO check for duplicate in options
const newItem = { id: name, name };
setOptions(options.concat(newItem));
return newItem;
name = name.trim();
if (!options.includes(name)) {
setOptions(options.concat(name));
}
return name;
}}
selections={selections}
isExpanded={isExpanded}
@ -65,22 +63,6 @@ function TagMultiSelect({ onChange, value }) {
{renderOptions(options)}
</Select>
);
//
// return (
// <MultiSelect
// onChange={val => {
// onChange(arrayToString(val));
// }}
// onAddNewItem={newItem => {
// if (!options.find(o => o.name === newItem.name)) {
// setOptions(options.concat(newItem));
// }
// }}
// value={stringToArray(value)}
// options={options}
// createNewItem={name => ({ id: name, name })}
// />
// );
}
TagMultiSelect.propTypes = {

View File

@ -1,3 +1,2 @@
export { default } from './MultiSelect';
export { default as TagMultiSelect } from './TagMultiSelect';
export { default as usePFSelect } from './usePFSelect';

View File

@ -1,7 +1,8 @@
import { useState, useEffect } from 'react';
/*
Hook for using PatternFly's <Select> component. Guarantees object equality
Hook for using PatternFly's <Select> component when a pre-existing value
is loaded from somewhere other than the options. Guarantees object equality
between objects in `value` and the corresponding objects loaded as
`options` (based on matched id value).
*/
@ -11,7 +12,6 @@ export default function usePFSelect(value, onChange) {
useEffect(() => {
if (value !== selections && options.length) {
console.log(value, typeof value);
const syncedValue = value.map(item =>
options.find(i => i.id === item.id)
);
@ -48,5 +48,5 @@ function addToStringToObjects(items = []) {
}
function toString() {
return this.id;
return String(this.id);
}