From 07f81f81a6cc50499d1cf0c74aea469093475e8b Mon Sep 17 00:00:00 2001 From: Juan Tejada Date: Fri, 4 Dec 2015 17:13:04 -0800 Subject: [PATCH] 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 --- build/config/eslint.json | 3 +- spec/components/editable-list-spec.jsx | 136 +++++++++++++++++++++ src/components/editable-list.jsx | 160 +++++++++++++++++++++++++ src/global/nylas-component-kit.coffee | 1 + static/components/editable-list.less | 50 ++++++++ static/index.less | 1 + 6 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 spec/components/editable-list-spec.jsx create mode 100644 src/components/editable-list.jsx create mode 100644 static/components/editable-list.less diff --git a/build/config/eslint.json b/build/config/eslint.json index d7888d971..7a9076b2c 100644 --- a/build/config/eslint.json +++ b/build/config/eslint.json @@ -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}] } } diff --git a/spec/components/editable-list-spec.jsx b/spec/components/editable-list-spec.jsx new file mode 100644 index 000000000..1c7490923 --- /dev/null +++ b/spec/components/editable-list-spec.jsx @@ -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({items}); +}; + +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(
, 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); + }); + }); +}); diff --git a/src/components/editable-list.jsx b/src/components/editable-list.jsx new file mode 100644 index 000000000..86e1f417d --- /dev/null +++ b/src/components/editable-list.jsx @@ -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 = ( + + ); + } + + return ( +
+ {itemToRender} +
+ ); + } + + render() { + return ( +
+
+ {this._items.map((item, idx)=> this._renderItem(item, idx))} +
+
+ + +
+
+ ); + } + +} + +export default EditableList; diff --git a/src/global/nylas-component-kit.coffee b/src/global/nylas-component-kit.coffee index f3ef5404b..3422407eb 100644 --- a/src/global/nylas-component-kit.coffee +++ b/src/global/nylas-component-kit.coffee @@ -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' diff --git a/static/components/editable-list.less b/static/components/editable-list.less new file mode 100644 index 000000000..0d83d0225 --- /dev/null +++ b/static/components/editable-list.less @@ -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; + } + } + } +} diff --git a/static/index.less b/static/index.less index d8cdf6b38..ab4365f2e 100644 --- a/static/index.less +++ b/static/index.less @@ -28,3 +28,4 @@ @import "components/unsafe"; @import "components/key-commands-region"; @import "components/contenteditable"; +@import "components/editable-list";