diff options
Diffstat (limited to 'src/web/js')
-rw-r--r-- | src/web/js/actions/MenuActions.js | 37 | ||||
-rw-r--r-- | src/web/js/actions/MiddlePanelActions.js | 155 | ||||
-rw-r--r-- | src/web/js/actions/RightPanelActions.js | 38 | ||||
-rw-r--r-- | src/web/js/app.js | 18 | ||||
-rw-r--r-- | src/web/js/components/MainApp.react.js | 29 | ||||
-rw-r--r-- | src/web/js/components/Menu.react.js | 286 | ||||
-rw-r--r-- | src/web/js/components/MiddlePanel.react.js | 261 | ||||
-rw-r--r-- | src/web/js/components/Navbar.react.js | 138 | ||||
-rw-r--r-- | src/web/js/components/RightPanel.react.js | 452 | ||||
-rw-r--r-- | src/web/js/components/time.react.js | 15 | ||||
-rw-r--r-- | src/web/js/constants/JarrConstants.js | 23 | ||||
-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 | 118 | ||||
-rw-r--r-- | src/web/js/stores/MiddlePanelStore.js | 132 | ||||
-rw-r--r-- | src/web/js/stores/RightPanelStore.js | 77 | ||||
-rw-r--r-- | src/web/js/stores/__tests__/TodoStore-test.js | 90 |
17 files changed, 1957 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..b9154581 --- /dev/null +++ b/src/web/js/actions/MenuActions.js @@ -0,0 +1,37 @@ +var JarrDispatcher = require('../dispatcher/JarrDispatcher'); +var ActionTypes = require('../constants/JarrConstants'); +var jquery = require('jquery'); + + +var MenuActions = { + // PARENT FILTERS + reload: function() { + jquery.getJSON('/menu', function(payload) { + JarrDispatcher.dispatch({ + type: ActionTypes.RELOAD_MENU, + feeds: payload.feeds, + categories: payload.categories, + categories_order: payload.categories_order, + is_admin: payload.is_admin, + max_error: payload.max_error, + error_threshold: payload.error_threshold, + crawling_method: payload.crawling_method, + all_unread_count: payload.all_unread_count, + }); + }); + }, + setFilter: function(filter) { + JarrDispatcher.dispatch({ + type: ActionTypes.MENU_FILTER, + filter: filter, + }); + }, + toggleAllFolding: function(all_folded) { + JarrDispatcher.dispatch({ + type: ActionTypes.TOGGLE_MENU_FOLD, + all_folded: all_folded, + }); + }, +}; + +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..f805b7b1 --- /dev/null +++ b/src/web/js/actions/MiddlePanelActions.js @@ -0,0 +1,155 @@ +var JarrDispatcher = require('../dispatcher/JarrDispatcher'); +var ActionTypes = require('../constants/JarrConstants'); +var jquery = require('jquery'); +var MiddlePanelStore = require('../stores/MiddlePanelStore'); + +var _last_fetched_with = {}; +var shouldFetch = function(filters) { + return true; // FIXME disabling intelligent fetch for now, no caching better that bad one +// if(filters.filter != null // undefined means unchanged +// && (_last_fetched_with.filter != 'all' +// || _last_fetched_with.filter != filters.filter)) { +// return true; +// } +// if(_last_fetched_with.filter_type != null) { +// if(_last_fetched_with.filter_type != filters.filter_type) { +// return true; +// } +// if(_last_fetched_with.filter_id != filters.filter_id) { +// return true; +// } +// } +// return false; +} +var reloadIfNecessaryAndDispatch = function(dispath_payload) { + if(shouldFetch(dispath_payload)) { + var filters = MiddlePanelStore.getRequestFilter( + dispath_payload.display_search); + MiddlePanelStore.filter_whitelist.map(function(key) { + if(key in dispath_payload) { + filters[key] = dispath_payload[key]; + } + if(filters[key] == null) { + delete filters[key]; + } + }); + if('display_search' in filters) { + delete filters['display_search']; + } + jquery.getJSON('/middle_panel', filters, + function(payload) { + dispath_payload.articles = payload.articles; + dispath_payload.filters = filters; + JarrDispatcher.dispatch(dispath_payload); + _last_fetched_with = MiddlePanelStore.getRequestFilter(); + }); + } else { + JarrDispatcher.dispatch(dispath_payload); + } +} + + +var MiddlePanelActions = { + reload: function() { + reloadIfNecessaryAndDispatch({ + type: ActionTypes.RELOAD_MIDDLE_PANEL, + }); + }, + search: function(search) { + reloadIfNecessaryAndDispatch({ + type: ActionTypes.RELOAD_MIDDLE_PANEL, + display_search: true, + query: search.query, + search_title: search.title, + search_content: search.content, + }); + }, + search_off: function() { + reloadIfNecessaryAndDispatch({ + type: ActionTypes.RELOAD_MIDDLE_PANEL, + display_search: false, + }); + }, + removeParentFilter: function() { + reloadIfNecessaryAndDispatch({ + type: ActionTypes.PARENT_FILTER, + filter_type: null, + filter_id: null, + }); + }, + setCategoryFilter: function(category_id) { + reloadIfNecessaryAndDispatch({ + type: ActionTypes.PARENT_FILTER, + filter_type: 'category_id', + filter_id: category_id, + }); + }, + setFeedFilter: function(feed_id) { + reloadIfNecessaryAndDispatch({ + type: ActionTypes.PARENT_FILTER, + filter_type: 'feed_id', + filter_id: feed_id, + }); + }, + setFilter: function(filter) { + reloadIfNecessaryAndDispatch({ + type: ActionTypes.MIDDLE_PANEL_FILTER, + filter: filter, + }); + }, + changeRead: function(category_id, feed_id, article_id, new_value){ + jquery.ajax({type: 'PUT', + contentType: 'application/json', + data: JSON.stringify({readed: new_value}), + url: "api/v2.0/article/" + article_id, + success: function () { + JarrDispatcher.dispatch({ + type: ActionTypes.CHANGE_ATTR, + attribute: 'read', + value_bool: new_value, + value_num: new_value ? -1 : 1, + articles: [{article_id: article_id, + category_id: category_id, + feed_id: feed_id}], + }); + }, + }); + }, + changeLike: function(category_id, feed_id, article_id, new_value){ + jquery.ajax({type: 'PUT', + contentType: 'application/json', + data: JSON.stringify({like: new_value}), + url: "api/v2.0/article/" + article_id, + success: function () { + JarrDispatcher.dispatch({ + type: ActionTypes.CHANGE_ATTR, + attribute: 'liked', + value_bool: new_value, + value_num: new_value ? -1 : 1, + articles: [{article_id: article_id, + category_id: category_id, + feed_id: feed_id}], + }); + }, + }); + }, + markAllAsRead: function() { + var filters = MiddlePanelStore.getRequestFilter(); + jquery.ajax({type: 'PUT', + contentType: 'application/json', + data: JSON.stringify(filters), + url: "/mark_all_as_read", + success: function (payload) { + JarrDispatcher.dispatch({ + type: ActionTypes.CHANGE_ATTR, + attribute: 'read', + value_num: -1, + value_bool: true, + articles: payload.articles, + }); + }, + }); + }, +}; + +module.exports = MiddlePanelActions; diff --git a/src/web/js/actions/RightPanelActions.js b/src/web/js/actions/RightPanelActions.js new file mode 100644 index 00000000..47adad79 --- /dev/null +++ b/src/web/js/actions/RightPanelActions.js @@ -0,0 +1,38 @@ +var jquery = require('jquery'); +var JarrDispatcher = require('../dispatcher/JarrDispatcher'); +var ActionTypes = require('../constants/JarrConstants'); +var MenuActions = require('../actions/MenuActions'); + +var RightPanelActions = { + loadArticle: function(article_id, was_read_before) { + jquery.getJSON('/getart/' + article_id, + function(payload) { + JarrDispatcher.dispatch({ + type: ActionTypes.LOAD_ARTICLE, + article: payload, + was_read_before: was_read_before, + }); + } + ); + }, + _apiReq: function(meth, id, obj_type, data, success_callback) { + var args = {type: meth, contentType: 'application/json', + url: "api/v2.0/" + obj_type + "/" + id} + if(data) {args.data = JSON.stringify(data);} + if(success_callback) {args.success = success_callback;} + jquery.ajax(args); + }, + putObj: function(id, obj_type, fields) { + this._apiReq('PUT', id, obj_type, fields, MenuActions.reload); + }, + delObj: function(id, obj_type, fields) { + this._apiReq('DELETE', id, obj_type, null, MenuActions.reload); + }, + resetErrors: function(feed_id) { + this._apiReq('PUT', feed_id, 'feed', {error_count: 0, last_error: ''}, + MenuActions.reload); + + }, +}; + +module.exports = RightPanelActions; diff --git a/src/web/js/app.js b/src/web/js/app.js new file mode 100644 index 00000000..00156670 --- /dev/null +++ b/src/web/js/app.js @@ -0,0 +1,18 @@ +/** + * 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 ReactDOM = require('react-dom'); + +var MainApp = require('./components/MainApp.react'); + +ReactDOM.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..cbdc5833 --- /dev/null +++ b/src/web/js/components/MainApp.react.js @@ -0,0 +1,29 @@ +var React = require('react'); +var Col = require('react-bootstrap/lib/Col'); +var Grid = require('react-bootstrap/lib/Grid'); + +var JarrNavBar = require('./Navbar.react'); +var Menu = require('./Menu.react'); +var MiddlePanel = require('./MiddlePanel.react'); +var RightPanel = require('./RightPanel.react'); + + +var MainApp = React.createClass({ + render: function() { + return (<div> + <JarrNavBar /> + <Grid fluid id="jarr-container"> + <Menu /> + <Col id="middle-panel" mdOffset={3} lgOffset={2} + xs={4} sm={4} md={4} lg={4}> + <MiddlePanel.MiddlePanelFilter /> + <MiddlePanel.MiddlePanel /> + </Col> + <RightPanel /> + </Grid> + </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..60578f8a --- /dev/null +++ b/src/web/js/components/Menu.react.js @@ -0,0 +1,286 @@ +var React = require('react'); +var Col = require('react-bootstrap/lib/Col'); +var Badge = require('react-bootstrap/lib/Badge'); +var Button = require('react-bootstrap/lib/Button'); +var ButtonGroup = require('react-bootstrap/lib/ButtonGroup'); +var Glyphicon = require('react-bootstrap/lib/Glyphicon'); + +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, + error_count: React.PropTypes.number.isRequired, + icon_url: React.PropTypes.string, + active: React.PropTypes.bool.isRequired, + }, + render: function() { + var icon = null; + var badge_unread = null; + if(this.props.icon_url){ + icon = (<img width="16px" src={this.props.icon_url} />); + } else { + icon = <Glyphicon glyph="ban-circle" />; + } + if(this.props.unread){ + badge_unread = <Badge pullRight>{this.props.unread}</Badge>; + } + var classes = "nav-feed"; + if(this.props.active) { + classes += " bg-primary"; + } + if(this.props.error_count >= MenuStore._datas.max_error) { + classes += " bg-danger"; + } else if(this.props.error_count > MenuStore._datas.error_threshold) { + classes += " bg-warning"; + } + var title = <span className="title">{this.props.title}</span>; + return (<li className={classes} onClick={this.handleClick}> + {icon}{title}{badge_unread} + </li> + ); + }, + handleClick: function() { + MiddlePanelActions.setFeedFilter(this.props.feed_id); + }, +}); + +var Category = React.createClass({ + propTypes: {category_id: React.PropTypes.number, + active_type: React.PropTypes.string, + active_id: React.PropTypes.number}, + render: function() { + var classes = "nav-cat"; + if((this.props.active_type == 'category_id' + || this.props.category_id == null) + && this.props.active_id == this.props.category_id) { + classes += " bg-primary"; + } + return (<li className={classes} onClick={this.handleClick}> + {this.props.children} + </li> + ); + }, + handleClick: function(evnt) { + // hack to avoid selection when clicking on folding icon + if(!evnt.target.classList.contains('glyphicon')) { + if(this.props.category_id != null) { + MiddlePanelActions.setCategoryFilter(this.props.category_id); + } else { + MiddlePanelActions.removeParentFilter(); + } + } + }, +}); + +var CategoryGroup = React.createClass({ + propTypes: {cat_id: React.PropTypes.number.isRequired, + filter: React.PropTypes.string.isRequired, + active_type: React.PropTypes.string, + active_id: React.PropTypes.number, + name: React.PropTypes.string.isRequired, + feeds: React.PropTypes.array.isRequired, + unread: React.PropTypes.number.isRequired, + folded: React.PropTypes.bool.isRequired, + }, + getInitialState: function() { + return {folded: this.props.folded}; + }, + componentWillReceiveProps: function(nextProps) { + this.setState({folded: nextProps.folded}); + }, + render: function() { + // hidden the no category if empty + if(!this.props.cat_id && !this.props.feeds.length) { + return <ul className="hidden" />; + } + var filter = this.props.filter; + var a_type = this.props.active_type; + var a_id = this.props.active_id; + if(!this.state.folded) { + // filtering according to this.props.filter + var feeds = this.props.feeds.filter(function(feed) { + if (filter == 'unread' && feed.unread <= 0) { + return false; + } else if (filter == 'error' && feed.error_count <= MenuStore._datas.error_threshold) { + return false; + } + return true; + }).sort(function(feed_a, feed_b){ + return feed_b.unread - feed_a.unread; + }).map(function(feed) { + return (<FeedItem key={"f" + feed.id} feed_id={feed.id} + title={feed.title} unread={feed.unread} + error_count={feed.error_count} + active={a_type == 'feed_id' && a_id == feed.id} + icon_url={feed.icon_url} /> + ); + }); + } else { + var feeds = []; + } + var unread = null; + if(this.props.unread) { + unread = <Badge pullRight>{this.props.unread}</Badge>; + } + var ctrl = (<Glyphicon onClick={this.toggleFolding} pullLeft + glyph={this.state.folded?"menu-right":"menu-down"} /> + ); + + return (<ul className="nav nav-sidebar"> + <Category category_id={this.props.cat_id} + active_id={this.props.active_id} + active_type={this.props.active_type}> + {ctrl}<h4>{this.props.name}</h4>{unread} + </Category> + {feeds} + </ul> + ); + }, + toggleFolding: function(evnt) { + this.setState({folded: !this.state.folded}); + evnt.stopPropagation(); + }, +}); + +var MenuFilter = React.createClass({ + propTypes: {feed_in_error: React.PropTypes.bool, + filter: React.PropTypes.string.isRequired}, + getInitialState: function() { + return {allFolded: false}; + }, + render: function() { + var error_button = null; + if (this.props.feed_in_error) { + error_button = ( + <Button active={this.props.filter == "error"} + title="Some of your feeds are in error, click here to list them" + onClick={this.setErrorFilter} + bsSize="small" bsStyle="warning"> + <Glyphicon glyph="exclamation-sign" /> + </Button> + ); + } + if(this.state.allFolded) { + var foldBtnTitle = "Unfold all categories"; + var foldBtnGlyph = "option-horizontal"; + } else { + var foldBtnTitle = "Fold all categories"; + var foldBtnGlyph = "option-vertical"; + } + return (<div> + <ButtonGroup className="nav nav-sidebar"> + <Button active={this.props.filter == "all"} + title="Display all feeds" + onClick={this.setAllFilter} bsSize="small"> + <Glyphicon glyph="menu-hamburger" /> + </Button> + <Button active={this.props.filter == "unread"} + title="Display only feed with unread article" + onClick={this.setUnreadFilter} + bsSize="small"> + <Glyphicon glyph="unchecked" /> + </Button> + {error_button} + </ButtonGroup> + <ButtonGroup className="nav nav-sidebar"> + <Button onClick={MenuActions.reload} + title="Refresh all feeds" bsSize="small"> + <Glyphicon glyph="refresh" /> + </Button> + </ButtonGroup> + <ButtonGroup className="nav nav-sidebar"> + <Button title={foldBtnTitle} bsSize="small" + onClick={this.toggleFold}> + <Glyphicon glyph={foldBtnGlyph} /> + </Button> + </ButtonGroup> + </div> + ); + }, + setAllFilter: function() { + MenuActions.setFilter("all"); + }, + setUnreadFilter: function() { + MenuActions.setFilter("unread"); + }, + setErrorFilter: function() { + MenuActions.setFilter("error"); + }, + toggleFold: function() { + this.setState({allFolded: !this.state.allFolded}, function() { + MenuActions.toggleAllFolding(this.state.allFolded); + }.bind(this)); + }, +}); + +var Menu = React.createClass({ + getInitialState: function() { + return {filter: 'all', categories: {}, feeds: {}, + all_folded: false, active_type: null, active_id: null}; + }, + render: function() { + var feed_in_error = false; + var rmPrntFilt = ( + <ul className="nav nav-sidebar"> + <Category category_id={null} + active_id={this.state.active_id} + active_type={this.state.active_type}> + <h4>All</h4> + </Category> + </ul> + ); + var categories = []; + for(var index in this.state.categories_order) { + var cat_id = this.state.categories_order[index]; + var feeds = []; + var unread = 0; + this.state.categories[cat_id].feeds.map(function(feed_id) { + if(this.state.feeds[feed_id].error_count > MenuStore._datas.error_threshold) { + feed_in_error = true; + } + unread += this.state.feeds[feed_id].unread; + feeds.push(this.state.feeds[feed_id]); + }.bind(this)); + categories.push(<CategoryGroup key={"c" + cat_id} feeds={feeds} + filter={this.state.filter} + active_type={this.state.active_type} + active_id={this.state.active_id} + name={this.state.categories[cat_id].name} + cat_id={this.state.categories[cat_id].id} + folded={this.state.all_folded} + unread={unread} />); + } + + return (<Col xsHidden smHidden md={3} lg={2} + id="menu" className="sidebar"> + <MenuFilter filter={this.state.filter} + feed_in_error={feed_in_error} /> + {rmPrntFilt} + {categories} + </Col> + ); + }, + componentDidMount: function() { + MenuActions.reload(); + MenuStore.addChangeListener(this._onChange); + }, + componentWillUnmount: function() { + MenuStore.removeChangeListener(this._onChange); + }, + _onChange: function() { + var datas = MenuStore.getAll(); + this.setState({filter: datas.filter, + feeds: datas.feeds, + categories: datas.categories, + categories_order: datas.categories_order, + active_type: datas.active_type, + active_id: datas.active_id, + all_folded: datas.all_folded}); + }, +}); + +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..e251447e --- /dev/null +++ b/src/web/js/components/MiddlePanel.react.js @@ -0,0 +1,261 @@ +var React = require('react'); + +var Row = require('react-bootstrap/lib/Row'); +var Button = require('react-bootstrap/lib/Button'); +var ButtonGroup = require('react-bootstrap/lib/ButtonGroup'); +var Glyphicon = require('react-bootstrap/lib/Glyphicon'); + +var MiddlePanelStore = require('../stores/MiddlePanelStore'); +var MiddlePanelActions = require('../actions/MiddlePanelActions'); +var RightPanelActions = require('../actions/RightPanelActions'); + +var JarrTime = require('./time.react'); + +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, + timestamp: React.PropTypes.number.isRequired, + date: React.PropTypes.string.isRequired, + read: React.PropTypes.bool.isRequired, + selected: React.PropTypes.bool.isRequired, + liked: React.PropTypes.bool.isRequired, + }, + getInitialState: function() { + return {read: this.props.read, liked: this.props.liked, + selected: false}; + }, + render: function() { + var liked = this.state.liked ? 'l' : ''; + var icon = null; + if(this.props.icon_url){ + icon = (<img width="16px" src={this.props.icon_url} />); + } else { + icon = <Glyphicon glyph="ban-circle" />; + } + var title = (<a href={'/article/redirect/' + this.props.article_id} + onClick={this.openRedirectLink}> + {icon} {this.props.feed_title} + </a>); + var read = (<Glyphicon glyph={this.state.read?"check":"unchecked"} + onClick={this.toogleRead} />); + var liked = (<Glyphicon glyph={this.state.liked?"star":"star-empty"} + onClick={this.toogleLike} />); + var clsses = "list-group-item"; + if(this.props.selected) { + clsses += " active"; + } + // FIXME https://github.com/yahoo/react-intl/issues/189 + // use FormattedRelative when fixed, will have to upgrade to ReactIntlv2 + return (<div className={clsses} onClick={this.loadArticle}> + <h5><strong>{title}</strong></h5> + <JarrTime text={this.props.date} + stamp={this.props.timestamp} /> + <div>{read} {liked} {this.props.title}</div> + </div> + ); + }, + openRedirectLink: function(evnt) { + if(!this.state.read) { + this.toogleRead(evnt); + } + }, + toogleRead: function(evnt) { + this.setState({read: !this.state.read}, function() { + MiddlePanelActions.changeRead(this.props.category_id, + this.props.feed_id, this.props.article_id, this.state.read); + }.bind(this)); + evnt.stopPropagation(); + }, + toogleLike: function(evnt) { + this.setState({liked: !this.state.liked}, function() { + MiddlePanelActions.changeLike(this.props.category_id, + this.props.feed_id, this.props.article_id, this.state.liked); + }.bind(this)); + evnt.stopPropagation(); + }, + loadArticle: function() { + this.setState({active: true, read: true}, function() { + RightPanelActions.loadArticle( + this.props.article_id, this.props.read); + }.bind(this)); + }, + stopPropagation: function(evnt) { + evnt.stopPropagation(); + }, +}); + +var MiddlePanelSearchRow = React.createClass({ + getInitialState: function() { + return {query: MiddlePanelStore._datas.query, + search_title: MiddlePanelStore._datas.search_title, + search_content: MiddlePanelStore._datas.search_content, + }; + }, + render: function() { + return (<Row> + <form onSubmit={this.launchSearch}> + <div className="input-group input-group-sm"> + <span className="input-group-addon"> + <span onClick={this.toogleSTitle}>Title</span> + <input id="search-title" type="checkbox" + onChange={this.toogleSTitle} + checked={this.state.search_title} + aria-label="Search title" /> + </span> + <span className="input-group-addon"> + <span onClick={this.toogleSContent}>Content</span> + <input id="search-content" type="checkbox" + onChange={this.toogleSContent} + checked={this.state.search_content} + aria-label="Search content" /> + </span> + <input type="text" className="form-control" + onChange={this.setQuery} + placeholder="Search text" /> + </div> + </form> + </Row> + ); + }, + setQuery: function(evnt) { + this.setState({query: evnt.target.value}); + }, + toogleSTitle: function() { + this.setState({search_title: !this.state.search_title}, + this.launchSearch); + }, + toogleSContent: function() { + this.setState({search_content: !this.state.search_content}, + this.launchSearch); + }, + launchSearch: function(evnt) { + if(this.state.query && (this.state.search_title + || this.state.search_content)) { + MiddlePanelActions.search({query: this.state.query, + title: this.state.search_title, + content: this.state.search_content}); + } + if(evnt) { + evnt.preventDefault(); + } + }, +}); + +var MiddlePanelFilter = React.createClass({ + getInitialState: function() { + return {filter: MiddlePanelStore._datas.filter, + display_search: MiddlePanelStore._datas.display_search}; + }, + render: function() { + var search_row = null; + if(this.state.display_search) { + search_row = <MiddlePanelSearchRow /> + } + return (<div> + <Row className="show-grid"> + <ButtonGroup> + <Button active={this.state.filter == "all"} + title="Display all articles" + onClick={this.setAllFilter} bsSize="small"> + <Glyphicon glyph="menu-hamburger" /> + </Button> + <Button active={this.state.filter == "unread"} + title="Display only unread article" + onClick={this.setUnreadFilter} + bsSize="small"> + <Glyphicon glyph="unchecked" /> + </Button> + <Button active={this.state.filter == "liked"} + title="Filter only liked articles" + onClick={this.setLikedFilter} + bsSize="small"> + <Glyphicon glyph="star" /> + </Button> + </ButtonGroup> + <ButtonGroup> + <Button onClick={this.toogleSearch} + title="Search through displayed articles" + bsSize="small"> + <Glyphicon glyph="search" /> + </Button> + </ButtonGroup> + <ButtonGroup> + <Button onClick={MiddlePanelActions.markAllAsRead} + title="Mark all displayed article as read" + bsSize="small"> + <Glyphicon glyph="trash" /> + </Button> + </ButtonGroup> + </Row> + {search_row} + </div> + ); + }, + setAllFilter: function() { + this.setState({filter: 'all'}, function() { + MiddlePanelActions.setFilter('all'); + }.bind(this)); + }, + setUnreadFilter: function() { + this.setState({filter: 'unread'}, function() { + MiddlePanelActions.setFilter('unread'); + }.bind(this)); + }, + setLikedFilter: function() { + this.setState({filter: 'liked'}, function() { + MiddlePanelActions.setFilter('liked'); + }.bind(this)); + }, + toogleSearch: function() { + this.setState({display_search: !this.state.display_search}, + function() { + if(!this.state.display_search) { + MiddlePanelActions.search_off(); + } + }.bind(this) + ); + }, +}); + +var MiddlePanel = React.createClass({ + getInitialState: function() { + return {filter: MiddlePanelStore._datas.filter, articles: []}; + }, + render: function() { + return (<Row className="show-grid"> + <div className="list-group"> + {this.state.articles.map(function(article){ + return (<TableLine key={"a" + article.article_id} + title={article.title} + icon_url={article.icon_url} + read={article.read} + liked={article.liked} + timestamp={article.timestamp} + date={article.date} + selected={article.selected} + article_id={article.article_id} + feed_id={article.feed_id} + locales={['en']} + category_id={article.category_id} + feed_title={article.feed_title} />);})} + </div> + </Row> + ); + }, + componentDidMount: function() { + MiddlePanelActions.reload(); + MiddlePanelStore.addChangeListener(this._onChange); + }, + componentWillUnmount: function() { + MiddlePanelStore.removeChangeListener(this._onChange); + }, + _onChange: function() { + this.setState({filter: MiddlePanelStore._datas.filter, + articles: MiddlePanelStore.getArticles()}); + }, +}); + +module.exports = {MiddlePanel: MiddlePanel, + MiddlePanelFilter: MiddlePanelFilter}; diff --git a/src/web/js/components/Navbar.react.js b/src/web/js/components/Navbar.react.js new file mode 100644 index 00000000..57c38900 --- /dev/null +++ b/src/web/js/components/Navbar.react.js @@ -0,0 +1,138 @@ +var React = require('react'); +var Glyphicon = require('react-bootstrap/lib/Glyphicon'); +var Nav = require('react-bootstrap/lib/Nav'); +var NavItem = require('react-bootstrap/lib/NavItem'); +var Navbar = require('react-bootstrap/lib/Navbar'); +var NavDropdown = require('react-bootstrap/lib/NavDropdown'); +var MenuItem = require('react-bootstrap/lib/MenuItem'); +var Modal = require('react-bootstrap/lib/Modal'); +var Button = require('react-bootstrap/lib/Button'); +var Input = require('react-bootstrap/lib/Input'); + +var MenuStore = require('../stores/MenuStore'); + +JarrNavBar = React.createClass({ + getInitialState: function() { + return {is_admin: MenuStore._datas.is_admin, + crawling_method: MenuStore._datas.crawling_method, + showModal: false, modalType: null}; + }, + buttonFetch: function() { + if(this.state.is_admin && this.state.crawling_method != 'http') { + return (<NavItem eventKey={2} href="/fetch"> + <Glyphicon glyph="import" />Fetch + </NavItem>); + } + }, + buttonAdmin: function() { + if(this.state.is_admin) { + return (<NavDropdown title={<Glyphicon glyph='cog' />} + id='admin-dropdown'> + <MenuItem href="/admin/dashboard"> + <Glyphicon glyph="dashboard" />Dashboard + </MenuItem> + </NavDropdown>); + } + }, + getModal: function() { + var heading = null; + var action = null; + var body = null; + if(this.state.modalType == 'addFeed') { + heading = 'Add a new feed'; + action = '/feed/bookmarklet'; + placeholder = "Site or feed url, we'll sort it out later ;)"; + body = <Input name="url" type="text" placeholder={placeholder} />; + } else { + heading = 'Add a new category'; + action = '/category/create'; + body = <Input name="name" type="text" + placeholder="Name, there isn't much more to it" />; + } + return (<Modal show={this.state.showModal} onHide={this.close}> + <form action={action} method="POST"> + <Modal.Header closeButton> + <Modal.Title>{heading}</Modal.Title> + </Modal.Header> + <Modal.Body> + {body} + </Modal.Body> + <Modal.Footer> + <Button type="submit">Add</Button> + </Modal.Footer> + </form> + </Modal>); + }, + close: function() { + this.setState({showModal: false, modalType: null}); + }, + openAddFeed: function() { + this.setState({showModal: true, modalType: 'addFeed'}); + }, + openAddCategory: function() { + this.setState({showModal: true, modalType: 'addCategory'}); + }, + render: function() { + return (<Navbar fixedTop inverse id="jarrnav"> + {this.getModal()} + <Navbar.Header> + <Navbar.Brand> + <a href="/">JARR</a> + </Navbar.Brand> + <Navbar.Toggle /> + </Navbar.Header> + <Nav pullRight> + {this.buttonFetch()} + <NavItem className="jarrnavitem" + onClick={this.openAddFeed} href="#"> + <Glyphicon glyph="plus-sign" />Add a new feed + </NavItem> + <NavItem className="jarrnavitem" + onClick={this.openAddCategory} href="#"> + <Glyphicon glyph="plus-sign" />Add a new category + </NavItem> + <NavDropdown title="Feed" id="feed-dropdown"> + <MenuItem href="/feeds/inactives"> + Inactives + </MenuItem> + <MenuItem href="/articles/history"> + History + </MenuItem> + <MenuItem href="/feeds/"> + All + </MenuItem> + </NavDropdown> + {this.buttonAdmin()} + <NavDropdown title={<Glyphicon glyph='user' />} + id="user-dropdown"> + <MenuItem href="/user/profile"> + <Glyphicon glyph="user" />Profile + </MenuItem> + <MenuItem href="/user/management"> + <Glyphicon glyph="cog" />Your data + </MenuItem> + <MenuItem href="/about"> + <Glyphicon glyph="question-sign" />About + </MenuItem> + <MenuItem href="/logout"> + <Glyphicon glyph="log-out" />Logout + </MenuItem> + </NavDropdown> + </Nav> + </Navbar> + ); + }, + componentDidMount: function() { + MenuStore.addChangeListener(this._onChange); + }, + componentWillUnmount: function() { + MenuStore.removeChangeListener(this._onChange); + }, + _onChange: function() { + var datas = MenuStore.getAll(); + this.setState({is_admin: datas.is_admin, + crawling_method: datas.crawling_method}); + }, +}); + +module.exports = JarrNavBar; diff --git a/src/web/js/components/RightPanel.react.js b/src/web/js/components/RightPanel.react.js new file mode 100644 index 00000000..7af0aa75 --- /dev/null +++ b/src/web/js/components/RightPanel.react.js @@ -0,0 +1,452 @@ +var React = require('react'); +var Col = require('react-bootstrap/lib/Col'); +var Glyphicon = require('react-bootstrap/lib/Glyphicon'); +var Button = require('react-bootstrap/lib/Button'); +var ButtonGroup = require('react-bootstrap/lib/ButtonGroup'); +var Modal = require('react-bootstrap/lib/Modal'); + +var RightPanelActions = require('../actions/RightPanelActions'); +var RightPanelStore = require('../stores/RightPanelStore'); +var MenuStore = require('../stores/MenuStore'); +var JarrTime = require('./time.react'); + +var PanelMixin = { + propTypes: {obj: React.PropTypes.object.isRequired}, + getInitialState: function() { + return {edit_mode: false, obj: this.props.obj, showModal: false}; + }, + getHeader: function() { + var icon = null; + if(this.props.obj.icon_url){ + icon = (<img width="16px" src={this.props.obj.icon_url} />); + } + var btn_grp = null; + if(this.isEditable() || this.isRemovable()) { + var edit_button = null; + if(this.isEditable()) { + edit_button = (<Button onClick={this.onClickEdit}> + <Glyphicon glyph="pencil" /> + </Button>); + } + var rem_button = null; + if(this.isRemovable()) { + rem_button = (<Button onClick={this.onClickRemove}> + <Glyphicon glyph="remove-sign" /> + </Button>); + } + btn_grp = (<ButtonGroup bsSize="small"> + {edit_button} + {rem_button} + </ButtonGroup>); + } + return (<div id="right-panel-heading" className="panel-heading"> + <Modal show={this.state.showModal} onHide={this.closeModal}> + <Modal.Header closeButton> + <Modal.Title>Are you sure ?</Modal.Title> + </Modal.Header> + <Modal.Footer> + <Button onClick={this.confirmDelete}> + Confirm + </Button> + </Modal.Footer> + </Modal> + + <h4>{icon}{this.getTitle()}</h4> + {btn_grp} + </div>); + }, + getKey: function(prefix, suffix) { + return ((this.state.edit_mode?'edit':'fix') + prefix + + '-' + this.props.obj.id + '-' + suffix); + }, + getCore: function() { + var items = []; + var key; + if(!this.state.edit_mode) { + this.fields.filter(function(field) { + return field.type != 'ignore'; + }).map(function(field) { + key = this.getKey('dt', field.key); + items.push(<dt key={key}>{field.title}</dt>); + key = this.getKey('dd', field.key); + if(field.type == 'string') { + items.push(<dd key={key}>{this.props.obj[field.key]}</dd>); + } else if(field.type == 'bool') { + if(this.props.obj[field.key]) { + items.push(<dd key={key}><Glyphicon glyph="ok" /></dd>); + } else { + items.push(<dd key={key}><Glyphicon glyph="pause" /></dd>); + } + } else if (field.type == 'link') { + items.push(<dd key={key}> + <a href={this.props.obj[field.key]}> + {this.props.obj[field.key]} + </a> + </dd>); + } + }.bind(this)); + } else { + this.fields.filter(function(field) { + return field.type != 'ignore'; + }).map(function(field) { + key = this.getKey('dd', field.key); + items.push(<dt key={key}>{field.title}</dt>); + key = this.getKey('dt', field.key); + var input = null; + if(field.type == 'string' || field.type == 'link') { + input = (<input type="text" name={field.key} + onChange={this.saveField} + defaultValue={this.props.obj[field.key]} />); + } else if (field.type == 'bool') { + input = (<input type="checkbox" name={field.key} + onChange={this.saveField} + defaultChecked={this.props.obj[field.key]} />); + } + items.push(<dd key={key}>{input}</dd>); + }.bind(this)); + } + return (<dl className="dl-horizontal">{items}</dl>); + }, + getSubmit: function() { + return (<dd key={this.getKey('dd', 'submit')}> + <button className="btn btn-default" onClick={this.saveObj}> + Submit + </button> + </dd>); + }, + render: function() { + return (<div className="panel panel-default"> + {this.getHeader()} + {this.getBody()} + </div> + ); + }, + onClickEdit: function() { + this.setState({edit_mode: !this.state.edit_mode}); + }, + onClickRemove: function() { + this.setState({showModal: true}); + }, + closeModal: function() { + this.setState({showModal: false}); + }, + confirmDelete: function() { + this.setState({showModal: false}, function() { + RightPanelActions.delObj(this.props.obj.id, this.obj_type); + }.bind(this)); + }, + saveField: function(evnt) { + var obj = this.state.obj; + for(var i in this.fields) { + if(evnt.target.name == this.fields[i].key) { + if(this.fields[i].type == 'bool') { + obj[evnt.target.name] = evnt.target.checked; + } else { + obj[evnt.target.name] = evnt.target.value; + } + break; + } + } + this.setState({obj: obj}); + }, + saveObj: function() { + var to_push = {}; + this.fields.map(function(field) { + to_push[field.key] = this.state.obj[field.key]; + }.bind(this)); + this.setState({edit_mode: false}, function() { + RightPanelActions.putObj(this.props.obj.id, this.obj_type, to_push); + }.bind(this)); + }, +}; + +var Article = React.createClass({ + mixins: [PanelMixin], + isEditable: function() {return false;}, + isRemovable: function() {return true;}, + fields: [{'title': 'Date', 'type': 'string', 'key': 'date'}, + {'title': 'Original link', 'type': 'link', 'key': 'link'}, + ], + obj_type: 'article', + getTitle: function() {return this.props.obj.title;}, + getBody: function() { + return (<div className="panel-body"> + {this.getCore()} + <div dangerouslySetInnerHTML={ + {__html: this.props.obj.content}} /> + </div>); + }, +}); + +var Feed = React.createClass({ + mixins: [PanelMixin], + isEditable: function() {return true;}, + isRemovable: function() {return true;}, + obj_type: 'feed', + fields: [{'title': 'Feed title', 'type': 'string', 'key': 'title'}, + {'title': 'Description', 'type': 'string', 'key': 'description'}, + {'title': 'Feed link', 'type': 'link', 'key': 'link'}, + {'title': 'Site link', 'type': 'link', 'key': 'site_link'}, + {'title': 'Enabled', 'type': 'bool', 'key': 'enabled'}, + {'title': 'Filters', 'type': 'ignore', 'key': 'filters'}, + {'title': 'Category', 'type': 'ignore', 'key': 'category_id'}, + ], + getTitle: function() {return this.props.obj.title;}, + getFilterRow: function(i, filter) { + return (<dd key={'d' + i + '-' + this.props.obj.id} + className="input-group filter-row"> + <span className="input-group-btn"> + <button className="btn btn-default" type="button" + data-index={i} onClick={this.removeFilterRow}> + <Glyphicon glyph='minus' /> + </button> + </span> + <select name="action on" className="form-control" + data-index={i} onChange={this.saveFilterChange} + defaultValue={filter['action on']}> + <option value="match">match</option> + <option value="no match">no match</option> + </select> + <input name="pattern" type="text" className="form-control" + data-index={i} onChange={this.saveFilterChange} + defaultValue={filter.pattern} /> + <select name="type" className="form-control" + data-index={i} onChange={this.saveFilterChange} + defaultValue={filter.type}> + <option value='simple match'>simple match</option> + <option value='regex'>regex</option> + </select> + <select name="action" className="form-control" + data-index={i} onChange={this.saveFilterChange} + defaultValue={filter.action}> + <option value="mark as read">mark as read</option> + <option value="mark as favorite">mark as favorite</option> + </select> + </dd>); + }, + getFilterRows: function() { + var rows = []; + if(this.state.edit_mode) { + for(var i in this.state.obj.filters) { + rows.push(this.getFilterRow(i, this.state.obj.filters[i])); + } + return (<dl className="dl-horizontal"> + <dt>Filters</dt> + <dd> + <button className="btn btn-default" + type="button" onClick={this.addFilterRow}> + <Glyphicon glyph='plus' /> + </button> + </dd> + {rows} + </dl>); + } + rows = []; + rows.push(<dt key={'d-title'}>Filters</dt>); + for(var i in this.state.obj.filters) { + rows.push(<dd key={'d' + i}> + When {this.state.obj.filters[i]['action on']} on "{this.state.obj.filters[i].pattern}" ({this.state.obj.filters[i].type}) => {this.state.obj.filters[i].action} + </dd>); + } + return <dl className="dl-horizontal">{rows}</dl>; + }, + getCategorySelect: function() { + var content = null; + if(this.state.edit_mode) { + var categ_options = []; + for(var index in MenuStore._datas.categories_order) { + var cat_id = MenuStore._datas.categories_order[index]; + categ_options.push( + <option value={cat_id} + key={MenuStore._datas.categories[cat_id].id}> + {MenuStore._datas.categories[cat_id].name} + </option>); + } + content = (<select name="category_id" className="form-control" + onChange={this.saveField} + defaultValue={this.props.obj.category_id}> + {categ_options} + </select>); + } else { + content = MenuStore._datas.categories[this.props.obj.category_id].name; + } + return (<dl className="dl-horizontal"> + <dt>Category</dt><dd>{content}</dd> + </dl>); + }, + getErrorFields: function() { + if(this.props.obj.error_count < MenuStore._datas.error_threshold) { + return; + } + if(this.props.obj.error_count < MenuStore._datas.max_error) { + return (<dl className="dl-horizontal"> + <dt>State</dt> + <dd>The download of this feed has encountered some problems. However its error counter will be reinitialized at the next successful retrieving.</dd> + <dt>Last error</dt> + <dd>{this.props.obj.last_error}</dd> + </dl>); + } + return (<dl className="dl-horizontal"> + <dt>State</dt> + <dd>That feed has encountered too much consecutive errors and won't be retrieved anymore.</dd> + + <dt>Last error</dt> + <dd>{this.props.obj.last_error}</dd> + <dd> + <Button onClick={this.resetErrors}>Reset error count + </Button> + </dd> + </dl>); + + }, + resetErrors: function() { + var obj = this.state.obj; + obj.error_count = 0; + this.setState({obj: obj}, function() { + RightPanelActions.resetErrors(this.props.obj.id); + }.bind(this)); + }, + getBody: function() { + return (<div className="panel-body"> + <dl className="dl-horizontal"> + <dt>Created on</dt> + <dd><JarrTime stamp={this.props.obj.created_stamp} + text={this.props.obj.created_date} /> + </dd> + <dt>Last fetched</dt> + <dd><JarrTime stamp={this.props.obj.last_stamp} + text={this.props.obj.last_retrieved} /> + </dd> + </dl> + {this.getErrorFields()} + {this.getCategorySelect()} + {this.getCore()} + {this.getFilterRows()} + {this.state.edit_mode?this.getSubmit():null} + </div> + ); + }, + addFilterRow: function() { + var obj = this.state.obj; + obj.filters.push({action: "mark as read", 'action on': "match", + type: "simple match", pattern: ""}); + this.setState({obj: obj}); + }, + removeFilterRow: function(evnt) { + var obj = this.state.obj; + delete obj.filters[evnt.target.getAttribute('data-index')]; + this.setState({obj: obj}); + }, + saveFilterChange: function(evnt) { + var index = evnt.target.getAttribute('data-index'); + var obj = this.state.obj; + obj.filters[index][evnt.target.name] = evnt.target.value; + this.setState({obj: obj}); + }, +}); + +var Category = React.createClass({ + mixins: [PanelMixin], + isEditable: function() { + if(this.props.obj.id != 0) {return true;} + else {return false;} + }, + isRemovable: function() {return this.isEditable();}, + obj_type: 'category', + fields: [{'title': 'Category name', 'type': 'string', 'key': 'name'}], + getTitle: function() {return this.props.obj.name;}, + getBody: function() { + return (<div className="panel-body"> + {this.getCore()} + {this.state.edit_mode?this.getSubmit():null} + </div>); + }, +}); + +var RightPanel = React.createClass({ + getInitialState: function() { + return {category: null, feed: null, article: null, current: null}; + }, + getCategoryCrum: function() { + return (<li><a onClick={this.selectCategory} href="#"> + {this.state.category.name} + </a></li>); + }, + getFeedCrum: function() { + return (<li><a onClick={this.selectFeed} href="#"> + {this.state.feed.title} + </a></li>); + }, + getArticleCrum: function() { + return <li>{this.state.article.title}</li>; + }, + render: function() { + window.scrollTo(0, 0); + var brd_category = null; + var brd_feed = null; + var brd_article = null; + var breadcrum = null; + if(this.state.category) { + brd_category = (<li className="rp-crum"> + <a onClick={this.selectCategory} href="#"> + {this.state.category.name} + </a> + </li>); + } + if(this.state.feed) { + brd_feed = (<li className="rp-crum"> + <a onClick={this.selectFeed} href="#"> + {this.state.feed.title} + </a> + </li>); + } + if(this.state.article) { + brd_article = <li className="rp-crum">{this.state.article.title}</li>; + } + if(brd_category || brd_feed || brd_article) { + breadcrum = (<ol className="breadcrumb" id="rp-breadcrum"> + {brd_category} + {brd_feed} + {brd_article} + </ol>); + } + if(this.state.current == 'article') { + var cntnt = (<Article type='article' obj={this.state.article} + key={this.state.article.id} />); + } else if(this.state.current == 'feed') { + var cntnt = (<Feed type='feed' obj={this.state.feed} + key={this.state.feed.id} />); + } else if(this.state.current == 'category') { + var cntnt = (<Category type='category' obj={this.state.category} + key={this.state.category.id} />); + } + + return (<Col id="right-panel" xsOffset={4} smOffset={4} + mdOffset={7} lgOffset={6} + xs={8} sm={8} md={5} lg={6}> + {breadcrum} + {cntnt} + </Col> + ); + }, + selectCategory: function() { + this.setState({current: 'category'}); + }, + selectFeed: function() { + this.setState({current: 'feed'}); + }, + selectArticle: function() { + this.setState({current: 'article'}); + }, + componentDidMount: function() { + RightPanelStore.addChangeListener(this._onChange); + }, + componentWillUnmount: function() { + RightPanelStore.removeChangeListener(this._onChange); + }, + _onChange: function() { + this.setState(RightPanelStore.getAll()); + }, +}); + +module.exports = RightPanel; diff --git a/src/web/js/components/time.react.js b/src/web/js/components/time.react.js new file mode 100644 index 00000000..8b4d47d9 --- /dev/null +++ b/src/web/js/components/time.react.js @@ -0,0 +1,15 @@ +var React = require('react'); +var ReactIntl = require('react-intl'); + +var JarrTime = React.createClass({ + mixins: [ReactIntl.IntlMixin], + propTypes: {stamp: React.PropTypes.number.isRequired, + text: React.PropTypes.string.isRequired}, + render: function() { + return (<time dateTime={this.props.text} title={this.props.text}> + {this.formatRelative(this.props.stamp)} + </time>); + }, +}); + +module.exports = JarrTime; diff --git a/src/web/js/constants/JarrConstants.js b/src/web/js/constants/JarrConstants.js new file mode 100644 index 00000000..0ea42aad --- /dev/null +++ b/src/web/js/constants/JarrConstants.js @@ -0,0 +1,23 @@ +/* + * 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 = keyMirror({ + TOGGLE_MENU_FOLD: null, + RELOAD_MENU: null, + PARENT_FILTER: null, + MENU_FILTER: null, + CHANGE_ATTR: null, + RELOAD_MIDDLE_PANEL: null, + MIDDLE_PANEL_FILTER: null, + LOAD_ARTICLE: 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..49c61bc1 --- /dev/null +++ b/src/web/js/stores/MenuStore.js @@ -0,0 +1,118 @@ +var JarrDispatcher = require('../dispatcher/JarrDispatcher'); +var ActionTypes = require('../constants/JarrConstants'); +var EventEmitter = require('events').EventEmitter; +var CHANGE_EVENT = 'change_menu'; +var assign = require('object-assign'); + + +var MenuStore = assign({}, EventEmitter.prototype, { + _datas: {filter: 'all', feeds: {}, categories: {}, categories_order: [], + active_type: null, active_id: null, + is_admin: false, crawling_method: 'classic', + all_unread_count: 0, max_error: 0, error_threshold: 0, + all_folded: false}, + getAll: function() { + return this._datas; + }, + setFilter: function(value) { + if(this._datas.filter != value) { + this._datas.filter = value; + this.emitChange(); + } + }, + setActive: function(type, value) { + if(this._datas.active_id != value || this._datas.active_type != type) { + this._datas.active_type = type; + this._datas.active_id = 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 ActionTypes.RELOAD_MENU: + MenuStore._datas['feeds'] = action.feeds; + MenuStore._datas['categories'] = action.categories; + MenuStore._datas['categories_order'] = action.categories_order; + MenuStore._datas['is_admin'] = action.is_admin; + MenuStore._datas['max_error'] = action.max_error; + MenuStore._datas['error_threshold'] = action.error_threshold; + MenuStore._datas['crawling_method'] = action.crawling_method; + MenuStore._datas['all_unread_count'] = action.all_unread_count; + MenuStore.emitChange(); + break; + case ActionTypes.PARENT_FILTER: + MenuStore.setActive(action.filter_type, action.filter_id); + if(action.filters && action.articles && !action.filters.query + && action.filters.filter == 'unread') { + var new_unread = {}; + action.articles.map(function(article) { + if(!(article.feed_id in new_unread)) { + new_unread[article.feed_id] = 0; + } + if(!article.read) { + new_unread[article.feed_id] += 1; + } + }); + var changed = false; + for(var feed_id in new_unread) { + var old_unread = MenuStore._datas.feeds[feed_id].unread; + if(old_unread == new_unread[feed_id]) { + continue; + } + changed = true; + MenuStore._datas.feeds[feed_id].unread = new_unread[feed_id]; + var cat_id = MenuStore._datas.feeds[feed_id].category_id; + MenuStore._datas.categories[cat_id].unread -= old_unread; + MenuStore._datas.categories[cat_id].unread += new_unread[feed_id]; + } + if(changed) { + MenuStore.emitChange(); + } + } + + break; + case ActionTypes.MENU_FILTER: + MenuStore.setFilter(action.filter); + break; + case ActionTypes.CHANGE_ATTR: + if(action.attribute != 'read') { + return; + } + var val = action.value_num; + action.articles.map(function(article) { + MenuStore._datas.categories[article.category_id].unread += val; + MenuStore._datas.feeds[article.feed_id].unread += val; + }); + MenuStore.emitChange(); + break; + case ActionTypes.LOAD_ARTICLE: + if(!action.was_read_before) { + MenuStore._datas.categories[action.article.category_id].unread -= 1; + MenuStore._datas.feeds[action.article.feed_id].unread -= 1; + MenuStore.emitChange(); + } + break; + case ActionTypes.TOGGLE_MENU_FOLD: + MenuStore._datas.all_folded = action.all_folded; + MenuStore.emitChange(); + 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..4a5efd00 --- /dev/null +++ b/src/web/js/stores/MiddlePanelStore.js @@ -0,0 +1,132 @@ +var JarrDispatcher = require('../dispatcher/JarrDispatcher'); +var ActionTypes = require('../constants/JarrConstants'); +var EventEmitter = require('events').EventEmitter; +var CHANGE_EVENT = 'change_middle_panel'; +var assign = require('object-assign'); + + +var MiddlePanelStore = assign({}, EventEmitter.prototype, { + filter_whitelist: ['filter', 'filter_id', 'filter_type', 'display_search', + 'query', 'search_title', 'search_content'], + _datas: {articles: [], selected_article: null, + filter: 'unread', filter_type: null, filter_id: null, + display_search: false, query: null, + search_title: true, search_content: false}, + getAll: function() { + return this._datas; + }, + getRequestFilter: function(display_search) { + var filters = {'filter': this._datas.filter, + 'filter_type': this._datas.filter_type, + 'filter_id': this._datas.filter_id, + }; + if(display_search || (display_search == undefined && this._datas.display_search)) { + filters.query = this._datas.query; + filters.search_title = this._datas.search_title; + filters.search_content = this._datas.search_content; + }; + return filters; + }, + getArticles: function() { + var key = null; + var id = null; + if (this._datas.filter_type) { + key = this._datas.filter_type; + id = this._datas.filter_id; + } + return this._datas.articles + .map(function(article) { + if(article.article_id == this._datas.selected_article) { + article.selected = true; + } else if(article.selected) { + article.selected = false; + } + return article; + }.bind(this)) + .filter(function(article) { + return (article.selected || ((!key || article[key] == id) + && (this._datas.filter == 'all' + || (this._datas.filter == 'unread' && !article.read) + || (this._datas.filter == 'liked' && article.liked)))); + }.bind(this)); + + }, + setArticles: function(articles) { + if(articles || articles == []) { + this._datas.articles = articles; + return true; + } + return false; + }, + registerFilter: function(action) { + var changed = false; + this.filter_whitelist.map(function(key) { + if(key in action && this._datas[key] != action[key]) { + changed = true; + this._datas[key] = action[key]; + } + }.bind(this)); + return changed; + }, + 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) { + var changed = false; + switch(action.type) { + case ActionTypes.RELOAD_MIDDLE_PANEL: + changed = MiddlePanelStore.registerFilter(action); + changed = MiddlePanelStore.setArticles(action.articles) || changed; + break; + case ActionTypes.PARENT_FILTER: + changed = MiddlePanelStore.registerFilter(action); + changed = MiddlePanelStore.setArticles(action.articles) || changed; + break; + case ActionTypes.MIDDLE_PANEL_FILTER: + changed = MiddlePanelStore.registerFilter(action); + changed = MiddlePanelStore.setArticles(action.articles) || changed; + break; + case ActionTypes.CHANGE_ATTR: + var attr = action.attribute; + var val = action.value_bool; + action.articles.map(function(article) { + for (var i in MiddlePanelStore._datas.articles) { + if(MiddlePanelStore._datas.articles[i].article_id == article.article_id) { + if (MiddlePanelStore._datas.articles[i][attr] != val) { + MiddlePanelStore._datas.articles[i][attr] = val; + // avoiding redraw if not filter, display won't change anyway + if(MiddlePanelStore._datas.filter != 'all') { + changed = true; + } + } + break; + } + } + }); + break; + case ActionTypes.LOAD_ARTICLE: + changed = true; + MiddlePanelStore._datas.selected_article = action.article.id; + for (var i in MiddlePanelStore._datas.articles) { + if(MiddlePanelStore._datas.articles[i].article_id == action.article.id) { + MiddlePanelStore._datas.articles[i].read = true; + break; + } + } + break; + default: + // pass + } + if(changed) {MiddlePanelStore.emitChange();} +}); + +module.exports = MiddlePanelStore; diff --git a/src/web/js/stores/RightPanelStore.js b/src/web/js/stores/RightPanelStore.js new file mode 100644 index 00000000..6c268dfd --- /dev/null +++ b/src/web/js/stores/RightPanelStore.js @@ -0,0 +1,77 @@ +var JarrDispatcher = require('../dispatcher/JarrDispatcher'); +var ActionTypes = require('../constants/JarrConstants'); +var EventEmitter = require('events').EventEmitter; +var CHANGE_EVENT = 'change_middle_panel'; +var assign = require('object-assign'); +var MenuStore = require('../stores/MenuStore'); + + +var RightPanelStore = assign({}, EventEmitter.prototype, { + category: null, + feed: null, + article: null, + current: null, + getAll: function() { + return {category: this.category, feed: this.feed, + article: this.article, current: this.current}; + }, + emitChange: function() { + this.emit(CHANGE_EVENT); + }, + addChangeListener: function(callback) { + this.on(CHANGE_EVENT, callback); + }, + removeChangeListener: function(callback) { + this.removeListener(CHANGE_EVENT, callback); + }, +}); + + +RightPanelStore.dispatchToken = JarrDispatcher.register(function(action) { + switch(action.type) { + case ActionTypes.PARENT_FILTER: + RightPanelStore.article = null; + if(action.filter_id == null) { + RightPanelStore.category = null; + RightPanelStore.feed = null; + RightPanelStore.current = null; + } else if(action.filter_type == 'category_id') { + RightPanelStore.category = MenuStore._datas.categories[action.filter_id]; + RightPanelStore.feed = null; + RightPanelStore.current = 'category'; + RightPanelStore.emitChange(); + } else { + + RightPanelStore.feed = MenuStore._datas.feeds[action.filter_id]; + RightPanelStore.category = MenuStore._datas.categories[RightPanelStore.feed.category_id]; + RightPanelStore.current = 'feed'; + RightPanelStore.emitChange(); + } + break; + case ActionTypes.LOAD_ARTICLE: + RightPanelStore.feed = MenuStore._datas.feeds[action.article.feed_id]; + RightPanelStore.category = MenuStore._datas.categories[action.article.category_id]; + RightPanelStore.article = action.article; + RightPanelStore.current = 'article'; + RightPanelStore.emitChange(); + break; + case ActionTypes.RELOAD_MENU: + RightPanelStore.article = null; + if(RightPanelStore.category && !(RightPanelStore.category.id.toString() in action.categories)) { + RightPanelStore.category = null; + RightPanelStore.current = null; + } + if(RightPanelStore.feed && !(RightPanelStore.feed.id.toString() in action.feeds)) { + RightPanelStore.feed = null; + RightPanelStore.current = null; + } + if(RightPanelStore.current == 'article') { + RightPanelStore.current = null; + } + RightPanelStore.emitChange(); + default: + // pass + } +}); + +module.exports = RightPanelStore; 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); + }); + +}); |