rework MultiSelect into controlled input; refactoring

This commit is contained in:
Keith Grant 2019-09-27 15:04:09 -07:00
parent 6e9804b713
commit da149d931c
7 changed files with 139 additions and 212 deletions

View File

@ -1,6 +1,7 @@
import { Chip } from '@patternfly/react-core';
import styled from 'styled-components';
Chip.displayName = 'PFChip';
export default styled(Chip)`
--pf-c-chip--m-read-only--PaddingTop: 3px;
--pf-c-chip--m-read-only--PaddingRight: 8px;

View File

@ -45,7 +45,7 @@ const Item = shape({
class MultiSelect extends Component {
static propTypes = {
associatedItems: arrayOf(Item).isRequired,
value: arrayOf(Item).isRequired,
options: arrayOf(Item),
onAddNewItem: func,
onRemoveItem: func,
@ -65,13 +65,11 @@ class MultiSelect extends Component {
super(props);
this.state = {
input: '',
chipItems: this.getInitialChipItems(),
isExpanded: false,
};
this.handleAddItem = this.handleAddItem.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.handleInputChange = this.handleInputChange.bind(this);
this.handleSelection = this.handleSelection.bind(this);
this.removeChip = this.removeChip.bind(this);
this.removeItem = this.removeItem.bind(this);
this.handleClick = this.handleClick.bind(this);
this.createNewItem = this.createNewItem.bind(this);
}
@ -84,33 +82,57 @@ class MultiSelect extends Component {
document.removeEventListener('mousedown', this.handleClick, false);
}
getInitialChipItems() {
const { associatedItems } = this.props;
return associatedItems.map(item => ({ ...item }));
}
handleClick(e, option) {
if (this.node && this.node.contains(e.target)) {
if (option) {
this.handleSelection(e, option);
e.preventDefault();
this.addItem(option);
}
} else {
this.setState({ input: '', isExpanded: false });
}
}
handleSelection(e, item) {
const { chipItems } = this.state;
const { onAddNewItem, onChange } = this.props;
e.preventDefault();
const items = chipItems.concat({ name: item.name, id: item.id });
this.setState({
chipItems: items,
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) {
@ -124,66 +146,28 @@ class MultiSelect extends Component {
};
}
handleAddItem(event) {
const { input, chipItems } = this.state;
const { options, onAddNewItem, onChange } = this.props;
const match = options.find(item => item.name === input);
const isIncluded = chipItems.some(chipItem => chipItem.name === input);
if (!input) {
return;
}
if (isIncluded) {
// This event.preventDefault prevents the form from submitting
// if the user tries to create 2 chips of the same name
event.preventDefault();
this.setState({ input: '', isExpanded: false });
return;
}
const isNewItem = !match || !chipItems.find(item => item.id === match.id);
if (event.key === 'Enter' && isNewItem) {
event.preventDefault();
const newItem = match || this.createNewItem(input);
const items = chipItems.concat(newItem);
this.setState({
chipItems: items,
isExpanded: false,
input: '',
});
onAddNewItem(newItem);
onChange(items);
} else if (!isNewItem || event.key === 'Tab') {
this.setState({ isExpanded: false, input: '' });
}
}
handleInputChange(value) {
this.setState({ input: value, isExpanded: true });
}
removeChip(e, item) {
const { onRemoveItem, onChange } = this.props;
const { chipItems } = this.state;
const chips = chipItems.filter(chip => chip.id !== item.id);
removeItem(item) {
const { value, onRemoveItem, onChange } = this.props;
const remainingItems = value.filter(chip => chip.id !== item.id);
this.setState({ chipItems: chips });
onRemoveItem(item);
onChange(chips);
e.preventDefault();
onChange(remainingItems);
}
render() {
const { options } = this.props;
const { chipItems, input, isExpanded } = this.state;
const { value, options } = this.props;
const { input, isExpanded } = this.state;
const list = options.map(option => (
const dropdownOptions = options.map(option => (
<Fragment key={option.id}>
{option.name.includes(input) ? (
<DropdownItem
component="button"
isDisabled={chipItems.some(item => item.id === option.id)}
isDisabled={value.some(item => item.id === option.id)}
value={option.name}
onClick={e => {
this.handleClick(e, option);
@ -195,21 +179,6 @@ class MultiSelect extends Component {
</Fragment>
));
const chips = (
<ChipGroup>
{chipItems &&
chipItems.map(item => (
<Chip
key={item.id}
onClick={e => {
this.removeChip(e, item);
}}
>
{item.name}
</Chip>
))}
</ChipGroup>
);
return (
<Fragment>
<InputGroup>
@ -222,21 +191,32 @@ class MultiSelect extends Component {
type="text"
aria-label="labels"
value={input}
onClick={() => this.setState({ isExpanded: true })}
onFocus={() => this.setState({ isExpanded: true })}
onChange={this.handleInputChange}
onKeyDown={this.handleAddItem}
onKeyDown={this.handleKeyDown}
/>
<Dropdown
type="button"
isPlain
value={chipItems}
value={value}
toggle={<DropdownToggle isPlain>Labels</DropdownToggle>}
// Above is not rendered but is a required prop from Patternfly
// Above is not visible but is a required prop from Patternfly
isOpen={isExpanded}
dropdownItems={list}
dropdownItems={dropdownOptions}
/>
</div>
<div css="margin: 10px">{chips}</div>
<div css="margin: 10px">
<ChipGroup>
{value.map(item => (
<Chip
key={item.id}
onClick={() => { this.removeItem(item); }}
>
{item.name}
</Chip>
))}
</ChipGroup>
</div>
</InputGroup>
</Fragment>
);

View File

@ -1,48 +1,51 @@
import React from 'react';
import { mount } from 'enzyme';
import { sleep } from '@testUtils/testUtils';
import { mount, shallow } from 'enzyme';
import MultiSelect from './MultiSelect';
describe('<MultiSelect />', () => {
const associatedItems = [
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('Initially render successfully', () => {
const wrapper = mount(
test('should render successfully', () => {
const wrapper = shallow(
<MultiSelect
onAddNewItem={jest.fn()}
onRemoveItem={jest.fn()}
associatedItems={associatedItems}
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('TextInput');
input.invoke('onChange')('Flabadoo');
input.simulate('keydown', { key: 'Enter' });
expect(component.state().chipItems.length).toBe(2);
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('handleSelection add item to chipItems', async () => {
const wrapper = mount(
<MultiSelect
onAddNewItem={jest.fn()}
onRemoveItem={jest.fn()}
associatedItems={associatedItems}
options={options}
/>
);
const component = wrapper.find('MultiSelect');
component
.find('input[aria-label="labels"]')
.simulate('keydown', { key: 'Enter' });
component.update();
await sleep(1);
expect(component.state().chipItems.length).toBe(2);
});
test('handleAddItem adds a chip only when Tab is pressed', () => {
test('should add item when clicked from menu', () => {
const onAddNewItem = jest.fn();
const onChange = jest.fn();
const wrapper = mount(
@ -50,48 +53,44 @@ describe('<MultiSelect />', () => {
onAddNewItem={onAddNewItem}
onRemoveItem={jest.fn()}
onChange={onChange}
associatedItems={associatedItems}
value={value}
options={options}
/>
);
const input = wrapper.find('TextInput');
input.simulate('focus');
wrapper.update();
const event = {
preventDefault: () => {},
key: 'Enter',
target: wrapper.find('DropdownItem').at(1).getDOMNode(),
};
const component = wrapper.find('MultiSelect');
wrapper.find('DropdownItem').at(1).invoke('onClick')(event);
component.setState({ input: 'newLabel' });
component.update();
component.instance().handleAddItem(event);
expect(component.state().chipItems.length).toBe(3);
expect(component.state().input.length).toBe(0);
expect(component.state().isExpanded).toBe(false);
expect(onAddNewItem).toBeCalled();
expect(onChange).toBeCalled();
expect(onAddNewItem).toHaveBeenCalledWith(options[1]);
const newVal = onChange.mock.calls[0][0];
expect(newVal).toHaveLength(3);
expect(newVal[2]).toEqual(options[1]);
});
test('removeChip removes chip properly', () => {
test('should remove item', () => {
const onRemoveItem = jest.fn();
const onChange = jest.fn();
const wrapper = mount(
<MultiSelect
onAddNewItem={jest.fn()}
onRemoveItem={onRemoveItem}
onChange={onChange}
associatedItems={associatedItems}
value={value}
options={options}
/>
);
const event = {
preventDefault: () => {},
};
const component = wrapper.find('MultiSelect');
component
.instance()
.removeChip(event, { name: 'Foo', id: 1, organization: 1 });
expect(component.state().chipItems.length).toBe(1);
expect(onRemoveItem).toBeCalled();
expect(onChange).toBeCalled();
wrapper.find('Chip').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

@ -33,7 +33,7 @@ function TagMultiSelect({ onChange, value }) {
setOptions(options.concat(newItem));
}
}}
associatedItems={stringToArray(value)}
value={stringToArray(value)}
options={options}
createNewItem={name => ({ id: name, name })}
/>

View File

@ -326,18 +326,23 @@ class JobTemplateForm extends Component {
/>
</FormRow>
<FormRow>
<FormGroup label={i18n._(t`Labels`)} fieldId="template-labels">
<FieldTooltip
content={i18n._(t`Optional labels that describe this job template,
such as 'dev' or 'test'. Labels can be used to group and filter
job templates and completed jobs.`)}
/>
<LabelSelect
initialValues={template.summary_fields.labels.results}
onChange={labels => setFieldValue('labels', labels)}
onError={err => this.setState({ contentError: err })}
/>
</FormGroup>
<Field
name="labels"
render={({ field }) => (
<FormGroup label={i18n._(t`Labels`)} fieldId="template-labels">
<FieldTooltip
content={i18n._(t`Optional labels that describe this job template,
such as 'dev' or 'test'. Labels can be used to group and filter
job templates and completed jobs.`)}
/>
<LabelSelect
value={field.value}
onChange={labels => setFieldValue('labels', labels)}
onError={err => this.setState({ contentError: err })}
/>
</FormGroup>
)}
/>
</FormRow>
<AdvancedFieldsWrapper label="Advanced">
<FormRow>

View File

@ -158,57 +158,4 @@ describe('<JobTemplateForm />', () => {
wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
expect(handleCancel).toBeCalled();
});
// TODO Move this test to <LabelSelect> tests
test.skip('handleNewLabel should arrange new labels properly', async () => {
const event = { key: 'Enter', preventDefault: () => {} };
const wrapper = mountWithContexts(
<JobTemplateForm
template={mockData}
handleSubmit={jest.fn()}
handleCancel={jest.fn()}
/>
);
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
const multiSelect = wrapper.find(
'FormGroup[fieldId="template-labels"] MultiSelect'
);
const component = wrapper.find('JobTemplateForm');
wrapper.setState({ newLabels: [], loadedLabels: [], removedLabels: [] });
multiSelect.setState({ input: 'Foo' });
component
.find('FormGroup[fieldId="template-labels"] input[aria-label="labels"]')
.prop('onKeyDown')(event);
component.instance().handleNewLabel({ name: 'Bar', id: 2 });
const newLabels = component.state('newLabels');
expect(newLabels).toHaveLength(2);
expect(newLabels[0].name).toEqual('Foo');
expect(newLabels[0].organization).toEqual(1);
});
// TODO Move this test to <LabelSelect> tests
test.skip('disassociateLabel should arrange new labels properly', async () => {
const wrapper = mountWithContexts(
<JobTemplateForm
template={mockData}
handleSubmit={jest.fn()}
handleCancel={jest.fn()}
/>
);
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
const component = wrapper.find('JobTemplateForm');
// This asserts that the user generated a label or clicked
// on a label option, and then changed their mind and
// removed the label.
component.instance().removeLabel({ name: 'Alex', id: 17 });
expect(component.state().newLabels.length).toBe(0);
expect(component.state().removedLabels.length).toBe(0);
// This asserts that the user removed a label that was associated
// with the template when the template loaded.
component.instance().removeLabel({ name: 'Sushi', id: 1 });
expect(component.state().newLabels.length).toBe(0);
expect(component.state().removedLabels.length).toBe(1);
});
});

View File

@ -29,13 +29,8 @@ async function loadLabelOptions(setLabels, onError) {
}
}
function LabelSelect({
initialValues, // todo: change to value, controlled ?
onChange,
onError,
}) {
function LabelSelect({ value, onChange, onError }) {
const [options, setOptions] = useState([]);
// TODO: move newLabels into a prop?
useEffect(() => {
loadLabelOptions(setOptions, onError);
}, []);
@ -43,7 +38,7 @@ function LabelSelect({
return (
<MultiSelect
onChange={onChange}
associatedItems={initialValues}
value={value}
options={options}
createNewItem={name => ({
id: name,
@ -54,7 +49,7 @@ function LabelSelect({
);
}
LabelSelect.propTypes = {
initialValues: arrayOf(
value: arrayOf(
shape({
id: number.isRequired,
name: string.isRequired,