feat(components): Add EditableList component to component-kit

Summary:
- Generic list component wich supports adding, editing and removing
string-like items or components
- Needs some css love

Test Plan: - Unit tests.

Reviewers: evan, bengotow

Reviewed By: bengotow

Differential Revision: https://phab.nylas.com/D2322
This commit is contained in:
Juan Tejada 2015-12-04 17:13:04 -08:00
parent ef7266d362
commit 07f81f81a6
6 changed files with 350 additions and 1 deletions

View file

@ -12,6 +12,7 @@
"react/prop-types": [2, {"ignore": ["children"]}],
"eqeqeq": [2, "smart"],
"id-length": [0],
"no-loop-func": [0]
"no-loop-func": [0],
"new-cap": [2, {"capIsNew": false}]
}
}

View file

@ -0,0 +1,136 @@
import React, {addons} from 'react/addons';
import EditableList from '../../src/components/editable-list';
const {findDOMNode} = React;
const {TestUtils: {
renderIntoDocument,
findRenderedDOMComponentWithTag,
findRenderedDOMComponentWithClass,
scryRenderedDOMComponentsWithClass,
Simulate,
}} = addons;
const makeList = (items = [], props = {})=> {
return renderIntoDocument(<EditableList {...props}>{items}</EditableList>);
};
describe('EditableList', ()=> {
describe('_onItemClick', ()=> {
it('calls onItemSelected', ()=> {
const onItemSelected = jasmine.createSpy('onItemSelected');
const list = makeList(['1', '2'], {onItemSelected});
const item = scryRenderedDOMComponentsWithClass(list, 'editable-item')[0];
Simulate.click(item);
expect(onItemSelected).toHaveBeenCalledWith('1', 0);
});
});
describe('_onListKeyDown', ()=> {
it('calls onItemSelected', ()=> {
const onItemSelected = jasmine.createSpy('onItemSelected');
const list = makeList(['1', '2'], {initialState: {selected: 0}, onItemSelected});
const innerList = findRenderedDOMComponentWithClass(list, 'items-wrapper');
Simulate.keyDown(innerList, {key: 'ArrowDown'});
expect(onItemSelected).toHaveBeenCalledWith('2', 1);
});
});
describe('_renderItem', ()=> {
const makeItem = (item, idx, state = {}, handlers = {})=> {
const list = makeList();
return renderIntoDocument(
list._renderItem(item, idx, state, handlers)
);
};
it('binds correct click callbacks', ()=> {
const onClick = jasmine.createSpy('onClick');
const onDoubleClick = jasmine.createSpy('onDoubleClick');
const item = makeItem('item 1', 0, {}, {onClick, onDoubleClick});
Simulate.click(item);
expect(onClick.calls[0].args[1]).toEqual('item 1');
expect(onClick.calls[0].args[2]).toEqual(0);
Simulate.doubleClick(item);
expect(onDoubleClick.calls[0].args[1]).toEqual('item 1');
expect(onDoubleClick.calls[0].args[2]).toEqual(0);
});
it('renders correctly when item is selected', ()=> {
const item = findDOMNode(makeItem('item 1', 0, {selected: 0}));
expect(item.className.indexOf('selected')).not.toEqual(-1);
});
it('renders correctly when item is string', ()=> {
const item = findDOMNode(makeItem('item 1', 0));
expect(item.className.indexOf('selected')).toEqual(-1);
expect(item.className.indexOf('editable-item')).not.toEqual(-1);
expect(item.className.indexOf('component-item')).toEqual(-1);
expect(item.childNodes[0].textContent).toEqual('item 1');
});
it('renders correctly when item is component', ()=> {
const item = findDOMNode(makeItem(<div></div>, 0));
expect(item.className.indexOf('selected')).toEqual(-1);
expect(item.className.indexOf('editable-item')).toEqual(-1);
expect(item.className.indexOf('component-item')).not.toEqual(-1);
expect(item.childNodes[0].tagName).toEqual('DIV');
});
it('renders correctly when item is in editing state', ()=> {
const onInputBlur = jasmine.createSpy('onInputBlur');
const onInputFocus = jasmine.createSpy('onInputFocus');
const onInputKeyDown = jasmine.createSpy('onInputKeyDown');
const item = makeItem('item 1', 0, {editing: 0}, {onInputBlur, onInputFocus, onInputKeyDown});
const input = findRenderedDOMComponentWithTag(item, 'input');
Simulate.focus(input);
Simulate.keyDown(input);
Simulate.blur(input);
expect(onInputFocus).toHaveBeenCalled();
expect(onInputBlur).toHaveBeenCalled();
expect(onInputKeyDown.calls[0].args[1]).toEqual('item 1');
expect(onInputKeyDown.calls[0].args[2]).toEqual(0);
expect(findDOMNode(input).tagName).toEqual('INPUT');
});
});
describe('render', ()=> {
it('renders list of items', ()=> {
const items = ['1', '2', '3'];
const list = makeList(items);
const innerList = findDOMNode(
findRenderedDOMComponentWithClass(list, 'items-wrapper')
);
expect(innerList.childNodes.length).toEqual(3);
items.forEach((item, idx)=> expect(innerList.childNodes[idx].textContent).toEqual(item));
});
it('renders add button', ()=> {
const onCreateItem = jasmine.createSpy('onCreateItem');
const list = makeList([], {onCreateItem});
const button = scryRenderedDOMComponentsWithClass(list, 'btn-editable-list')[0];
Simulate.click(button);
expect(onCreateItem).toHaveBeenCalled();
});
it('renders delete button', ()=> {
const onDeleteItem = jasmine.createSpy('onDeleteItem');
const list = makeList(['1', '2'], {initialState: {selected: 1}, onDeleteItem});
const button = scryRenderedDOMComponentsWithClass(list, 'btn-editable-list')[1];
Simulate.click(button);
expect(onDeleteItem).toHaveBeenCalledWith('2', 1);
});
});
});

View file

@ -0,0 +1,160 @@
import _ from 'underscore';
import classNames from 'classNames';
import React, {Component, PropTypes} from 'react';
class EditableList extends Component {
static displayName = 'EditableList'
static propTypes = {
children: PropTypes.arrayOf(PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
PropTypes.element,
])),
className: PropTypes.string,
onCreateItem: PropTypes.func,
onDeleteItem: PropTypes.func,
onItemEdited: PropTypes.func,
onItemSelected: PropTypes.func,
initialState: PropTypes.object,
}
static defaultProps = {
children: [],
onCreateItem: ()=> {},
onDeleteItem: ()=> {},
onItemEdited: ()=> {},
onItemSelected: ()=> {},
}
constructor(props) {
super(props);
this._items = this.props.children;
this._doubleClickedItem = false;
this.state = props.initialState || {
editing: null,
selected: null,
};
}
_onInputBlur = ()=> {
this.setState({editing: null});
}
_onInputFocus = ()=> {
this._doubleClickedItem = false;
}
_onInputKeyDown = (event, item, idx)=> {
if (_.includes(['Enter', 'Return'], event.key)) {
this.setState({editing: null});
this.props.onItemEdited(event.target.value, item, idx);
} else if (event.key === 'Escape') {
this.setState({editing: null});
}
}
_onItemClick = (event, item, idx)=> {
this._selectItem(item, idx);
}
_onItemDoubleClick = (event, item, idx)=> {
if (!React.isValidElement(item)) {
this._doubleClickedItem = true;
this.setState({editing: idx});
}
}
_onListBlur = ()=> {
if (!this._doubleClickedItem) {
this.setState({selected: null});
}
}
_onListKeyDown = (event)=> {
const len = this._items.size;
const handle = {
'ArrowUp': (sel)=> sel === 0 ? sel : sel - 1,
'ArrowDown': (sel)=> sel === len - 1 ? sel : sel + 1,
'Escape': ()=> null,
};
const selected = (handle[event.key] || ((sel)=> sel))(this.state.selected);
this._selectItem(this._items[selected], selected);
}
_onCreateItem = ()=> {
this.props.onCreateItem();
}
_onDeleteItem = ()=> {
const idx = this.state.selected;
const item = this._items[idx];
if (item) {
this.props.onDeleteItem(item, idx);
}
}
_selectItem = (item, idx)=> {
this.setState({selected: idx});
this.props.onItemSelected(item, idx);
}
_renderItem = (item, idx, {editing, selected} = this.state, handlers = {})=> {
const onClick = handlers.onClick || this._onItemClick;
const onDoubleClick = handlers.onDoubleClick || this._onItemDoubleClick;
const onInputBlur = handlers.onInputBlur || this._onInputBlur;
const onInputFocus = handlers.onInputFocus || this._onInputFocus;
const onInputKeyDown = handlers.onInputKeyDown || this._onInputKeyDown;
const classes = classNames({
'component-item': React.isValidElement(item),
'editable-item': !React.isValidElement(item),
'selected': selected === idx,
});
let itemToRender = item;
if (React.isValidElement(item)) {
itemToRender = item;
} else if (editing === idx) {
itemToRender = (
<input
autoFocus
type="text"
placeholder={item}
onBlur={onInputBlur}
onFocus={onInputFocus}
onKeyDown={_.partial(onInputKeyDown, _, item, idx)} />
);
}
return (
<div
className={classes}
key={idx}
onClick={_.partial(onClick, _, item, idx)}
onDoubleClick={_.partial(onDoubleClick, _, item, idx)}>
{itemToRender}
</div>
);
}
render() {
return (
<div className={`nylas-editable-list ${this.props.className}`}>
<div
className="items-wrapper"
tabIndex="1"
onBlur={this._onListBlur}
onKeyDown={this._onListKeyDown}>
{this._items.map((item, idx)=> this._renderItem(item, idx))}
</div>
<div className="buttons-wrapper">
<button className="btn btn-small btn-editable-list" onClick={this._onCreateItem}>+</button>
<button className="btn btn-small btn-editable-list" onClick={this._onDeleteItem}></button>
</div>
</div>
);
}
}
export default EditableList;

View file

@ -31,6 +31,7 @@ class NylasComponentKit
@load "TimeoutTransitionGroup", 'timeout-transition-group'
@load "ConfigPropContainer", "config-prop-container"
@load "DisclosureTriangle", "disclosure-triangle"
@load "EditableList", "editable-list"
@load "ScrollRegion", 'scroll-region'
@load "ResizableRegion", 'resizable-region'

View file

@ -0,0 +1,50 @@
@import "ui-variables";
.nylas-editable-list {
.items-wrapper {
display: flex;
flex-direction: column;
border: 1px solid @border-secondary-bg;
background-color: @background-secondary;
font-size: 0.9em;
.editable-item {
padding: @padding-small-vertical @padding-small-horizontal;
cursor: default;
&.selected {
background-color: @background-selected;
color: @text-color-selected;
}
&+.editable-item {
border-top: 1px solid @border-color-divider;
}
input {
border: none;
padding: 0;
font-size: inherit;
background-color: @background-selected;
color: @text-color-selected;
}
::-webkit-input-placeholder {
color: @text-color-inverse-very-subtle;
}
}
}
.buttons-wrapper {
margin-top: @padding-xs-horizontal;
.btn-editable-list {
height: 20px;
width: 30px;
text-align: center;
padding: 0 0 2px 0;
&+.btn-editable-list {
margin-left: 3px;
}
}
}
}

View file

@ -28,3 +28,4 @@
@import "components/unsafe";
@import "components/key-commands-region";
@import "components/contenteditable";
@import "components/editable-list";