diff options
Diffstat (limited to 'src/web/js')
-rw-r--r-- | src/web/js/actions/MenuActions.js | 36 | ||||
-rw-r--r-- | src/web/js/actions/MiddlePanelActions.js | 54 | ||||
-rw-r--r-- | src/web/js/app.js | 17 | ||||
-rw-r--r-- | src/web/js/components/MainApp.react.js | 18 | ||||
-rw-r--r-- | src/web/js/components/Menu.react.js | 119 | ||||
-rw-r--r-- | src/web/js/components/MiddlePanel.react.js | 103 | ||||
-rw-r--r-- | src/web/js/constants/JarrConstants.js | 28 | ||||
-rw-r--r-- | src/web/js/dispatcher/JarrDispatcher.js | 16 | ||||
-rw-r--r-- | src/web/js/dispatcher/__tests__/AppDispatcher-test.js | 72 | ||||
-rw-r--r-- | src/web/js/stores/MenuStore.js | 56 | ||||
-rw-r--r-- | src/web/js/stores/MiddlePanelStore.js | 68 | ||||
-rw-r--r-- | src/web/js/stores/__tests__/TodoStore-test.js | 90 |
12 files changed, 677 insertions, 0 deletions
diff --git a/src/web/js/actions/MenuActions.js b/src/web/js/actions/MenuActions.js new file mode 100644 index 00000000..ce3a1030 --- /dev/null +++ b/src/web/js/actions/MenuActions.js @@ -0,0 +1,36 @@ +var JarrDispatcher = require('../dispatcher/JarrDispatcher'); +var MenuActionTypes = require('../constants/JarrConstants').MenuActionTypes; + + +var MenuActions = { + // PARENT FILTERS + reload: function() { + $.getJSON('/menu', function(payload) { + JarrDispatcher.dispatch({ + type: MenuActionTypes.RELOAD_MENU, + categories: payload.categories, + all_unread_count: payload.all_unread_count, + }); + }); + }, + setFilterMenuAll: function() { + JarrDispatcher.dispatch({ + component: 'menu', + type: MenuActionTypes.MENU_FILTER_ALL, + }); + }, + setFilterMenuUnread: function() { + JarrDispatcher.dispatch({ + component: 'menu', + type: MenuActionTypes.MENU_FILTER_UNREAD, + }); + }, + setFilterMenuError: function() { + JarrDispatcher.dispatch({ + type: MenuActionTypes.MENU_FILTER_ERROR, + }); + }, + +}; + +module.exports = MenuActions; diff --git a/src/web/js/actions/MiddlePanelActions.js b/src/web/js/actions/MiddlePanelActions.js new file mode 100644 index 00000000..9877d0d5 --- /dev/null +++ b/src/web/js/actions/MiddlePanelActions.js @@ -0,0 +1,54 @@ +var JarrDispatcher = require('../dispatcher/JarrDispatcher'); +var MiddlePanelActionTypes = require('../constants/JarrConstants').MiddlePanelActionTypes; + + +var MiddlePanelActions = { + reload: function() { + $.getJSON('/middle_panel', function(payload) { + JarrDispatcher.dispatch({ + type: MiddlePanelActionTypes.RELOAD_MIDDLE_PANEL, + articles: payload.articles, + }); + }); + }, + removeParentFilter: function(parent_type, parent_id) { + JarrDispatcher.dispatch({ + type: MiddlePanelActionTypes.MIDDLE_PANEL_PARENT_FILTER, + parent_type: null, + parent_id: null, + }); + }, + setCategoryFilter: function(category_id) { + JarrDispatcher.dispatch({ + type: MiddlePanelActionTypes.MIDDLE_PANEL_PARENT_FILTER, + parent_type: 'category', + parent_id: category_id, + }); + }, + setFeedFilter: function(feed_id) { + JarrDispatcher.dispatch({ + type: MiddlePanelActionTypes.MIDDLE_PANEL_PARENT_FILTER, + parent_type: 'feed', + parent_id: feed_id, + }); + }, + setFilterMiddlePanelAll: function() { + JarrDispatcher.dispatch({ + component: 'middle_panel', + type: MiddlePanelActionTypes.MIDDLE_PANEL_FILTER_ALL, + }); + }, + setFilterMiddlePanelUnread: function() { + JarrDispatcher.dispatch({ + component: 'middle_panel', + type: MiddlePanelActionTypes.MIDDLE_PANEL_FILTER_UNREAD, + }); + }, + setFilterMiddlePanelUnread: function() { + JarrDispatcher.dispatch({ + type: MiddlePanelActionTypes.MIDDLE_PANEL_FILTER_LIKED, + }); + }, +}; + +module.exports = MiddlePanelActions; diff --git a/src/web/js/app.js b/src/web/js/app.js new file mode 100644 index 00000000..603172b3 --- /dev/null +++ b/src/web/js/app.js @@ -0,0 +1,17 @@ +/** + * Copyright (c) 2014, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +var React = require('react'); + +var MainApp = require('./components/MainApp.react'); + +React.render( + <MainApp />, + document.getElementById('jarrapp') +); diff --git a/src/web/js/components/MainApp.react.js b/src/web/js/components/MainApp.react.js new file mode 100644 index 00000000..743c9510 --- /dev/null +++ b/src/web/js/components/MainApp.react.js @@ -0,0 +1,18 @@ +var Menu = require('./Menu.react'); +var MiddlePanel = require('./MiddlePanel.react'); +var React = require('react'); + + +var MainApp = React.createClass({ + render: function() { + return (<div className="container-fluid"> + <div className="row row-offcanvas row-offcanvas-left"> + <Menu /> + <MiddlePanel /> + </div> + </div> + ); + }, +}); + +module.exports = MainApp; diff --git a/src/web/js/components/Menu.react.js b/src/web/js/components/Menu.react.js new file mode 100644 index 00000000..caf8c3a8 --- /dev/null +++ b/src/web/js/components/Menu.react.js @@ -0,0 +1,119 @@ +var React = require('react'); +var MenuStore = require('../stores/MenuStore'); +var MenuActions = require('../actions/MenuActions'); +var MiddlePanelActions = require('../actions/MiddlePanelActions'); + +var FeedItem = React.createClass({ + propTypes: {feed_id: React.PropTypes.number.isRequired, + title: React.PropTypes.string.isRequired, + unread: React.PropTypes.number.isRequired, + icon_url: React.PropTypes.string, + }, + getInitialState: function() { + return {feed_id: this.props.feed_id, + title: this.props.title, + unread: this.props.unread, + icon_url: this.props.icon_url, + }; + }, + render: function() { + var unread = undefined; + var icon = undefined; + if(this.state.icon_url){ + icon = (<img width="16px" src={this.state.icon_url} />); + } else { + icon = (<span className="glyphicon glyphicon-ban-circle" />); + } + if(this.state.unread){ + unread = ( + <span className="badge pull-right"> + {this.state.unread} + </span> + ); + } + return (<li onMouseDown={this.handleClick}> + {icon} {this.state.title} {unread} + </li> + ); + }, + handleClick: function() { + MiddlePanelActions.setFeedFilter(this.state.feed_id); + }, +}); + +var Category = React.createClass({ + propTypes: {category_id: React.PropTypes.number.isRequired, + name: React.PropTypes.string.isRequired, + feeds: React.PropTypes.array.isRequired, + unread: React.PropTypes.number.isRequired, + }, + getInitialState: function() { + return {category_id: this.props.category_id, + name: this.props.name, + feeds: this.props.feeds, + unread: this.props.unread, + }; + }, + render: function() { + unread = undefined; + if(this.state.unread){ + unread = ( + <span className="badge pull-right"> + {this.state.unread} + </span> + ); + } + return (<div> + <h3 onMouseDown={this.handleClick}> + {this.state.name} {unread} + </h3> + <ul className="nav nav-sidebar"> + {this.state.feeds.map(function(feed){ + return <FeedItem key={"feed" + feed.id} + feed_id={feed.id} + title={feed.title} + unread={feed.unread} + icon_url={feed.icon_url} />;})} + </ul> + </div> + ); + }, + handleClick: function() { + MiddlePanelActions.setCategoryFilter(this.state.category_id); + }, +}); + +var Menu = React.createClass({ + getInitialState: function() { + return {categories: [], all_unread_count: 0}; + }, + render: function() { + return (<div id="sidebar" data-spy="affix" role="navigation" + className="col-md-2 sidebar sidebar-offcanvas pre-scrollable hidden-sm hidden-xs affix"> + {this.state.categories.map(function(category){ + return (<Category key={"cat" + category.id} + category_id={category.id} + feeds={category.feeds} + name={category.name} + unread={category.unread} />); + })} + + </div> + ); + }, + + componentDidMount: function() { + MenuActions.reload(); + MenuStore.addChangeListener(this._onChange); + }, + componentWillUnmount: function() { + MenuStore.removeChangeListener(this._onChange); + }, + _onChange: function() { + var datas = MenuStore.getAll(); + this.setState({categories: datas.categories, + all_unread_count: datas.all_unread_count}); + }, +}); + +module.exports = Menu; diff --git a/src/web/js/components/MiddlePanel.react.js b/src/web/js/components/MiddlePanel.react.js new file mode 100644 index 00000000..51d582c0 --- /dev/null +++ b/src/web/js/components/MiddlePanel.react.js @@ -0,0 +1,103 @@ +var React = require('react'); +var MiddlePanelStore = require('../stores/MiddlePanelStore'); +var MiddlePanelActions = require('../actions/MiddlePanelActions'); + +var TableLine = React.createClass({ + propTypes: {article_id: React.PropTypes.number.isRequired, + feed_title: React.PropTypes.string.isRequired, + icon_url: React.PropTypes.string, + title: React.PropTypes.string.isRequired, + date: React.PropTypes.string.isRequired, + read: React.PropTypes.bool.isRequired, + liked: React.PropTypes.bool.isRequired, + }, + getInitialState: function() { + return {article_id: this.props.article_id, + title: this.props.title, + icon_url: this.props.icon_url, + feed_title: this.props.feed_title, + date: this.props.date, + read: this.props.read, + liked: this.props.liked, + }; + }, + render: function() { + var read = this.state.read ? 'r' : ''; + var liked = this.state.liked ? 'l' : ''; + var icon = undefined; + if(this.state.icon_url){ + icon = (<img width="16px" src={this.state.icon_url} />); + } else { + icon = (<span className="glyphicon glyphicon-ban-circle" />); + } + return ( + <tr> + <td>{icon}{liked}</td> + <td> + <a href={'/redirect/' + this.state.article_id}> + {this.state.feed_title} + </a> + </td> + <td> + <a href={'/article/' + this.state.article_id}> + {this.state.title} + </a> + </td> + <td>{this.state.date}</td> + </tr> + ); + }, +}); + +var TableBody = React.createClass({ + propTypes: {articles: React.PropTypes.array.isRequired, + }, + getInitialState: function() { + return {articles: this.props.articles, + }; + }, + render: function() { + return (<div className="table-responsive"> + <table className="table table-striped strict-table"> + <tbody> + {this.state.articles.map(function(article){ + return (<TableLine key={"article" + article.article_id} + title={article.title} + icon_url={article.icon_url} + read={article.read} + liked={article.liked} + date={article.date} + article_id={article.article_id} + feed_title={article.feed_title} />);})} + </tbody> + </table> + </div> + ); + } +}); + +var MiddlePanel = React.createClass({ + getInitialState: function() { + return {articles: []}; + }, + render: function() { + var body = null; + if(this.state.articles.length) { + body = (<TableBody articles={this.state.articles} />); + } + return (<div className="col-md-offset-2 col-md-10 main">{body}</div>); + }, + componentDidMount: function() { + MiddlePanelActions.reload(); + MiddlePanelStore.addChangeListener(this._onChange); + }, + componentWillUnmount: function() { + MiddlePanelStore.removeChangeListener(this._onChange); + }, + _onChange: function() { + var datas = MiddlePanelStore.getAll(); + this.setState({articles: datas.articles}); + }, +}); + +module.exports = MiddlePanel; diff --git a/src/web/js/constants/JarrConstants.js b/src/web/js/constants/JarrConstants.js new file mode 100644 index 00000000..a0850283 --- /dev/null +++ b/src/web/js/constants/JarrConstants.js @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2014-2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * TodoConstants + */ + +var keyMirror = require('keymirror'); + +module.exports = { + MenuActionTypes: keyMirror({ + RELOAD_MENU: null, + MENU_FILTER_ALL: null, + MENU_FILTER_UNREAD: null, + MENU_FILTER_ERROR: null, + }), + MiddlePanelActionTypes: keyMirror({ + RELOAD_MIDDLE_PANEL: null, + MIDDLE_PANEL_PARENT_FILTER: null, + MIDDLE_PANEL_FILTER_ALL: null, + MIDDLE_PANEL_FILTER_UNREAD: null, + MIDDLE_PANEL_FILTER_LIKED: null, + }), +}; diff --git a/src/web/js/dispatcher/JarrDispatcher.js b/src/web/js/dispatcher/JarrDispatcher.js new file mode 100644 index 00000000..56da186f --- /dev/null +++ b/src/web/js/dispatcher/JarrDispatcher.js @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2014-2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * AppDispatcher + * + * A singleton that operates as the central hub for application updates. + */ + +var Dispatcher = require('flux').Dispatcher; + +module.exports = new Dispatcher(); diff --git a/src/web/js/dispatcher/__tests__/AppDispatcher-test.js b/src/web/js/dispatcher/__tests__/AppDispatcher-test.js new file mode 100644 index 00000000..d3a35fc5 --- /dev/null +++ b/src/web/js/dispatcher/__tests__/AppDispatcher-test.js @@ -0,0 +1,72 @@ +"use strict"; + +jest.autoMockOff(); + +describe('AppDispatcher', function() { + var AppDispatcher; + + beforeEach(function() { + AppDispatcher = require('../AppDispatcher.js'); + }); + + it('sends actions to subscribers', function() { + var listener = jest.genMockFunction(); + AppDispatcher.register(listener); + + var payload = {}; + AppDispatcher.dispatch(payload); + expect(listener.mock.calls.length).toBe(1); + expect(listener.mock.calls[0][0]).toBe(payload); + }); + + it('waits with chained dependencies properly', function() { + var payload = {}; + + var listener1Done = false; + var listener1 = function(pl) { + AppDispatcher.waitFor([index2, index4]); + // Second, third, and fourth listeners should have now been called + expect(listener2Done).toBe(true); + expect(listener3Done).toBe(true); + expect(listener4Done).toBe(true); + listener1Done = true; + }; + var index1 = AppDispatcher.register(listener1); + + var listener2Done = false; + var listener2 = function(pl) { + AppDispatcher.waitFor([index3]); + expect(listener3Done).toBe(true); + listener2Done = true; + }; + var index2 = AppDispatcher.register(listener2); + + var listener3Done = false; + var listener3 = function(pl) { + listener3Done = true; + }; + var index3 = AppDispatcher.register(listener3); + + var listener4Done = false; + var listener4 = function(pl) { + AppDispatcher.waitFor([index3]); + expect(listener3Done).toBe(true); + listener4Done = true; + }; + var index4 = AppDispatcher.register(listener4); + + runs(function() { + AppDispatcher.dispatch(payload); + }); + + waitsFor(function() { + return listener1Done; + }, "Not all subscribers were properly called", 500); + + runs(function() { + expect(listener1Done).toBe(true); + expect(listener2Done).toBe(true); + expect(listener3Done).toBe(true); + }); + }); +}); diff --git a/src/web/js/stores/MenuStore.js b/src/web/js/stores/MenuStore.js new file mode 100644 index 00000000..d7478091 --- /dev/null +++ b/src/web/js/stores/MenuStore.js @@ -0,0 +1,56 @@ +var JarrDispatcher = require('../dispatcher/JarrDispatcher'); +var MenuActionTypes = require('../constants/JarrConstants').MenuActionTypes; +var EventEmitter = require('events').EventEmitter; +var CHANGE_EVENT = 'change_menu'; +var assign = require('object-assign'); + + +var MenuStore = assign({}, EventEmitter.prototype, { + _datas: {filter: 'all', categories: [], all_unread_count: 0}, + getAll: function() { + return this._datas; + }, + setFilter: function(value) { + if(this._datas.filter != value) { + this._datas.filter = value; + this.emitChange(); + } + }, + readFeedArticle: function(feed_id) { + // TODO + }, + emitChange: function() { + this.emit(CHANGE_EVENT); + }, + addChangeListener: function(callback) { + this.on(CHANGE_EVENT, callback); + }, + removeChangeListener: function(callback) { + this.removeListener(CHANGE_EVENT, callback); + }, +}); + + +MenuStore.dispatchToken = JarrDispatcher.register(function(action) { + switch(action.type) { + case MenuActionTypes.RELOAD_MENU: + MenuStore._datas['categories'] = action.categories; + MenuStore._datas['all_unread_count'] = action.all_unread_count; + MenuStore.emitChange(); + break; + case MenuActionTypes.MENU_FILTER_ALL: + MenuStore.setFilter('all'); + break; + case MenuActionTypes.MENU_FILTER_UNREAD: + MenuStore.setFilter('unread'); + break; + case MenuActionTypes.MENU_FILTER_ERROR: + MenuStore.setFilter('error'); + break; + + default: + // do nothing + } +}); + +module.exports = MenuStore; diff --git a/src/web/js/stores/MiddlePanelStore.js b/src/web/js/stores/MiddlePanelStore.js new file mode 100644 index 00000000..d5744e20 --- /dev/null +++ b/src/web/js/stores/MiddlePanelStore.js @@ -0,0 +1,68 @@ +var JarrDispatcher = require('../dispatcher/JarrDispatcher'); +var MiddlePanelActionTypes = require('../constants/JarrConstants').MiddlePanelActionTypes; +var EventEmitter = require('events').EventEmitter; +var CHANGE_EVENT = 'change_middle_panel'; +var assign = require('object-assign'); + + +var MiddlePanelStore = assign({}, EventEmitter.prototype, { + _datas: {filter: 'unread', articles: [], + parent_filter_type: null, parent_filter_id: null}, + getAll: function() { + return this._datas; + }, + setFilter: function(value) { + if(this._datas.filter != value) { + this._datas.filter = value; + this.emitChange(); + } + }, + setParentFilter: function(type, value) { + if(this._datas['parent_filter_id'] != value + || this._datas['parent_filter_type'] != type) { + this._datas['parent_filter_type'] = type; + this._datas['parent_filter_id'] = value; + this.emitChange(); + } + }, + emitChange: function() { + this.emit(CHANGE_EVENT); + }, + addChangeListener: function(callback) { + this.on(CHANGE_EVENT, callback); + }, + removeChangeListener: function(callback) { + this.removeListener(CHANGE_EVENT, callback); + }, +}); + + +MiddlePanelStore.dispatchToken = JarrDispatcher.register(function(action) { + switch(action.type) { + case MiddlePanelActionTypes.RELOAD_MIDDLE_PANEL: + MiddlePanelStore._datas['articles'] = action.articles; + MiddlePanelStore.emitChange(); + break; + // PARENT FILTER + case MiddlePanelActionTypes.MIDDLE_PANEL_PARENT_FILTER: + MiddlePanelStore.setParentFilter(action.parent_type, + action.filter_id); + break; + // FILTER + case MiddlePanelActionTypes.MIDDLE_PANEL_FILTER_ALL: + MiddlePanelStore.setFilter('all'); + break; + case MiddlePanelActionTypes.MIDDLE_PANEL_FILTER_UNREAD: + MiddlePanelStore.setFilter('unread'); + break; + case MiddlePanelActionTypes.MIDDLE_PANEL_FILTER_LIKED: + MiddlePanelStore.setFilter('liked'); + break; + + + default: + // do nothing + } +}); + +module.exports = MiddlePanelStore; diff --git a/src/web/js/stores/__tests__/TodoStore-test.js b/src/web/js/stores/__tests__/TodoStore-test.js new file mode 100644 index 00000000..6da6cd3c --- /dev/null +++ b/src/web/js/stores/__tests__/TodoStore-test.js @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2014-2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * TodoStore-test + */ + +jest.dontMock('../../constants/TodoConstants'); +jest.dontMock('../TodoStore'); +jest.dontMock('object-assign'); + +describe('TodoStore', function() { + + var TodoConstants = require('../../constants/TodoConstants'); + var AppDispatcher; + var TodoStore; + var callback; + + // mock actions + var actionTodoCreate = { + actionType: TodoConstants.TODO_CREATE, + text: 'foo' + }; + var actionTodoDestroy = { + actionType: TodoConstants.TODO_DESTROY, + id: 'replace me in test' + }; + + beforeEach(function() { + AppDispatcher = require('../../dispatcher/AppDispatcher'); + TodoStore = require('../TodoStore'); + callback = AppDispatcher.register.mock.calls[0][0]; + }); + + it('registers a callback with the dispatcher', function() { + expect(AppDispatcher.register.mock.calls.length).toBe(1); + }); + + it('should initialize with no to-do items', function() { + var all = TodoStore.getAll(); + expect(all).toEqual({}); + }); + + it('creates a to-do item', function() { + callback(actionTodoCreate); + var all = TodoStore.getAll(); + var keys = Object.keys(all); + expect(keys.length).toBe(1); + expect(all[keys[0]].text).toEqual('foo'); + }); + + it('destroys a to-do item', function() { + callback(actionTodoCreate); + var all = TodoStore.getAll(); + var keys = Object.keys(all); + expect(keys.length).toBe(1); + actionTodoDestroy.id = keys[0]; + callback(actionTodoDestroy); + expect(all[keys[0]]).toBeUndefined(); + }); + + it('can determine whether all to-do items are complete', function() { + var i = 0; + for (; i < 3; i++) { + callback(actionTodoCreate); + } + expect(Object.keys(TodoStore.getAll()).length).toBe(3); + expect(TodoStore.areAllComplete()).toBe(false); + + var all = TodoStore.getAll(); + for (key in all) { + callback({ + actionType: TodoConstants.TODO_COMPLETE, + id: key + }); + } + expect(TodoStore.areAllComplete()).toBe(true); + + callback({ + actionType: TodoConstants.TODO_UNDO_COMPLETE, + id: key + }); + expect(TodoStore.areAllComplete()).toBe(false); + }); + +}); |