From df8f6b5fee643c5b2af8e8d33a7865e898518485 Mon Sep 17 00:00:00 2001 From: Bernhard Posselt Date: Sat, 14 Sep 2013 02:22:36 +0200 Subject: implement pull to refresh, fix #44 --- CHANGELOG | 1 + appinfo/routes.php | 6 ++ controller/itemcontroller.php | 29 ++++++++ css/items.css | 21 ++++++ js/app/controllers/itemcontroller.coffee | 5 ++ js/app/directives/pulltorefresh.coffee | 30 ++++++++ .../businesslayer/itembusinesslayer.coffee | 8 +- js/app/services/models/itemmodel.coffee | 14 +++- js/app/services/persistence.coffee | 13 ++++ js/public/app.js | 82 ++++++++++++++++++++- js/tests/controllers/itemcontrollerSpec.coffee | 15 ++++ .../businesslayer/itembusinesslayerSpec.coffee | 16 ++++ js/tests/services/models/itemmodelSpec.coffee | 11 ++- js/tests/services/persistenceSpec.coffee | 16 ++++ templates/main.php | 1 + templates/part.items.php | 2 + tests/unit/controller/ItemControllerTest.php | 86 ++++++++++++++++++++++ 17 files changed, 349 insertions(+), 7 deletions(-) create mode 100644 js/app/directives/pulltorefresh.coffee diff --git a/CHANGELOG b/CHANGELOG index c6dbb068b..27bd2e597 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -4,6 +4,7 @@ owncloud-news (1.601) * Autopurge limit is now added to the number of articles each feed gets when it updates * Fix CORS headers for OPTIONS request deeper than one level * Use before and after update cleanup hooks to make sure that read items are not turned unread again after an update. This breaks custom updaters. The updater script has been adjusted accordingly +* Implement pull to refresh owncloud-news (1.404) * Fix bug on postgres databases that would not delete old articles diff --git a/appinfo/routes.php b/appinfo/routes.php index de8f5a47a..bc1b7ad7d 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -157,6 +157,12 @@ $this->create('news_items', '/items')->get()->action( } ); +$this->create('news_items_new', '/items/new')->get()->action( + function($params){ + App::main('ItemController', 'newItems', $params, new DIContainer()); + } +); + $this->create('news_items_read', '/items/{itemId}/read')->post()->action( function($params){ App::main('ItemController', 'read', $params, new DIContainer()); diff --git a/controller/itemcontroller.php b/controller/itemcontroller.php index a35d27476..a3f632e39 100644 --- a/controller/itemcontroller.php +++ b/controller/itemcontroller.php @@ -89,6 +89,35 @@ class ItemController extends Controller { } + /** + * @IsAdminExemption + * @IsSubAdminExemption + * @Ajax + */ + public function newItems() { + $userId = $this->api->getUserId(); + $showAll = $this->api->getUserValue('showAll') === '1'; + + $type = (int) $this->params('type'); + $id = (int) $this->params('id'); + $lastModified = (int) $this->params('lastModified', 0); + + $params = array(); + + try { + $params['newestItemId'] = $this->itemBusinessLayer->getNewestItemId($userId); + $params['feeds'] = $this->feedBusinessLayer->findAll($userId); + $params['starred'] = $this->itemBusinessLayer->starredCount($userId); + $params['items'] = $this->itemBusinessLayer->findAllNew($id, $type, + $lastModified, $showAll, $userId); + // this gets thrown if there are no items + // in that case just return an empty array + } catch(BusinessLayerException $ex) {} + + return $this->renderJSON($params); + } + + private function setStarred($isStarred){ $userId = $this->api->getUserId(); $feedId = (int) $this->params('feedId'); diff --git a/css/items.css b/css/items.css index 60173ed82..9c5ca9ab2 100644 --- a/css/items.css +++ b/css/items.css @@ -30,6 +30,7 @@ background-image: url('%webroot%/core/img/loading.gif'); background-position: center; background-repeat: no-repeat; + background-size: 18px; display: block; height: 100%; } @@ -45,6 +46,26 @@ } +.pull-refresh { + -webkit-transition: height 0.5s; + -moz-transition: height 0.5s; + transition: height 0.5s; + background-image: url('%webroot%/core/img/loading.gif'), linear-gradient(top, rgb(235,235,235) 0%, rgb(248,248,248) 100%); + background-image: url('%webroot%/core/img/loading.gif'), -o-linear-gradient(top, rgb(235,235,235) 0%, rgb(248,248,248) 100%); + background-image: url('%webroot%/core/img/loading.gif'), -moz-linear-gradient(top, rgb(235,235,235) 0%, rgb(248,248,248) 100%); + background-image: url('%webroot%/core/img/loading.gif'), -webkit-linear-gradient(top, rgb(235,235,235) 0%, rgb(248,248,248) 100%); + background-image: url('%webroot%/core/img/loading.gif'), -ms-linear-gradient(top, rgb(235,235,235) 0%, rgb(248,248,248) 100%); + background-position: center 45px, center; + background-repeat: no-repeat; + background-size: 18px, 100%; + height: 0; + width: 100%; +} + +.refresh { + height: 80px; +} + /** * Rules for a single feed item */ diff --git a/js/app/controllers/itemcontroller.coffee b/js/app/controllers/itemcontroller.coffee index eac508e18..c9a97285f 100644 --- a/js/app/controllers/itemcontroller.coffee +++ b/js/app/controllers/itemcontroller.coffee @@ -58,6 +58,11 @@ Language, AutoPageLoading) -> else return '' + @_$scope.loadNew = => + @_$scope.refresh = true + @_itemBusinessLayer.loadNew => + @_$scope.refresh = false + @_$scope.$on 'readItem', (scope, data) => @_itemBusinessLayer.setRead(data) diff --git a/js/app/directives/pulltorefresh.coffee b/js/app/directives/pulltorefresh.coffee new file mode 100644 index 000000000..436280793 --- /dev/null +++ b/js/app/directives/pulltorefresh.coffee @@ -0,0 +1,30 @@ +### + +ownCloud - News + +@author Bernhard Posselt +@copyright 2012 Bernhard Posselt dev@bernhard-posselt.com + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE +License as published by the Free Software Foundation; either +version 3 of the License, or any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU AFFERO GENERAL PUBLIC LICENSE for more details. + +You should have received a copy of the GNU Affero General Public +License along with this library. If not, see . + +### + +angular.module('News').directive 'newsPullToRefresh', -> + directive = + restrict: 'A' + link: (scope, elm, attrs) -> + scrollTop = 0 + elm.scroll -> + if @scrollTop == 0 + scope.$apply attrs.newsPullToRefresh \ No newline at end of file diff --git a/js/app/services/businesslayer/itembusinesslayer.coffee b/js/app/services/businesslayer/itembusinesslayer.coffee index 0563ff096..1382df190 100644 --- a/js/app/services/businesslayer/itembusinesslayer.coffee +++ b/js/app/services/businesslayer/itembusinesslayer.coffee @@ -116,8 +116,12 @@ StarredBusinessLayer, NewestItem) -> callback() - - loadNew: -> + loadNew: (onSuccess) -> + lastModified = @_itemModel.getLastModified() + @_persistence.getNewItems(@_activeFeed.getType(), + @_activeFeed.getId(), + lastModified, + onSuccess) diff --git a/js/app/services/models/itemmodel.coffee b/js/app/services/models/itemmodel.coffee index 349147ca5..7da924be3 100644 --- a/js/app/services/models/itemmodel.coffee +++ b/js/app/services/models/itemmodel.coffee @@ -21,8 +21,8 @@ License along with this library. If not, see . ### angular.module('News').factory 'ItemModel', -['_Model', '_MinimumQuery', 'StatusFlag', -(_Model, _MinimumQuery, StatusFlag) -> +['_Model', '_MinimumQuery', '_MaximumQuery', 'StatusFlag', +(_Model, _MinimumQuery, _MaximumQuery, StatusFlag) -> class ItemModel extends _Model @@ -109,5 +109,15 @@ angular.module('News').factory 'ItemModel', return 0 + getLastModified: -> + query = new _MaximumQuery('lastModified') + lastModified = @get(query) + + if angular.isDefined(lastModified) + return lastModified.lastModified + else + return 0 + + return new ItemModel() ] \ No newline at end of file diff --git a/js/app/services/persistence.coffee b/js/app/services/persistence.coffee index 559601f69..d51c7acb1 100644 --- a/js/app/services/persistence.coffee +++ b/js/app/services/persistence.coffee @@ -86,6 +86,19 @@ $rootScope, $q) -> @_request.get 'news_items', params + getNewItems: (type, id, lastModified, onSuccess) -> + onSuccess or= -> + params = + data: + type: type + id: id + lastModified: lastModified + onSuccess: onSuccess + onFailure: onSuccess + + @_request.get 'news_items_new', params + + starItem: (feedId, guidHash) -> ### Stars an item diff --git a/js/public/app.js b/js/public/app.js index cd822b6a4..ab237c645 100644 --- a/js/public/app.js +++ b/js/public/app.js @@ -451,6 +451,48 @@ License along with this library. If not, see . */ +(function() { + angular.module('News').directive('newsPullToRefresh', function() { + var directive; + return directive = { + restrict: 'A', + link: function(scope, elm, attrs) { + var scrollTop; + scrollTop = 0; + return elm.scroll(function() { + if (this.scrollTop === 0) { + return scope.$apply(attrs.newsPullToRefresh); + } + }); + } + }; + }); + +}).call(this); + +// Generated by CoffeeScript 1.6.3 +/* + +ownCloud - News + +@author Bernhard Posselt +@copyright 2012 Bernhard Posselt dev@bernhard-posselt.com + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE +License as published by the Free Software Foundation; either +version 3 of the License, or any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU AFFERO GENERAL PUBLIC LICENSE for more details. + +You should have received a copy of the GNU Affero General Public +License along with this library. If not, see . +*/ + + (function() { angular.module('News').directive('undoNotification', [ '$rootScope', '$timeout', 'Config', function($rootScope, $timeout, Config) { @@ -735,6 +777,12 @@ License along with this library. If not, see . return ''; } }; + this._$scope.loadNew = function() { + _this._$scope.refresh = true; + return _this._itemBusinessLayer.loadNew(function() { + return _this._$scope.refresh = false; + }); + }; this._$scope.$on('readItem', function(scope, data) { return _this._itemBusinessLayer.setRead(data); }); @@ -1525,7 +1573,11 @@ License along with this library. If not, see . } }; - ItemBusinessLayer.prototype.loadNew = function() {}; + ItemBusinessLayer.prototype.loadNew = function(onSuccess) { + var lastModified; + lastModified = this._itemModel.getLastModified(); + return this._persistence.getNewItems(this._activeFeed.getType(), this._activeFeed.getId(), lastModified, onSuccess); + }; return ItemBusinessLayer; @@ -2212,7 +2264,7 @@ License along with this library. If not, see . __extends = function(child, parent) { for (var key in parent) { if (__hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }; angular.module('News').factory('ItemModel', [ - '_Model', '_MinimumQuery', 'StatusFlag', function(_Model, _MinimumQuery, StatusFlag) { + '_Model', '_MinimumQuery', '_MaximumQuery', 'StatusFlag', function(_Model, _MinimumQuery, _MaximumQuery, StatusFlag) { var ItemModel; ItemModel = (function(_super) { __extends(ItemModel, _super); @@ -2309,6 +2361,17 @@ License along with this library. If not, see . } }; + ItemModel.prototype.getLastModified = function() { + var lastModified, query; + query = new _MaximumQuery('lastModified'); + lastModified = this.get(query); + if (angular.isDefined(lastModified)) { + return lastModified.lastModified; + } else { + return 0; + } + }; + return ItemModel; })(_Model); @@ -2574,6 +2637,21 @@ License along with this library. If not, see . return this._request.get('news_items', params); }; + Persistence.prototype.getNewItems = function(type, id, lastModified, onSuccess) { + var params; + onSuccess || (onSuccess = function() {}); + params = { + data: { + type: type, + id: id, + lastModified: lastModified + }, + onSuccess: onSuccess, + onFailure: onSuccess + }; + return this._request.get('news_items_new', params); + }; + Persistence.prototype.starItem = function(feedId, guidHash) { /* Stars an item diff --git a/js/tests/controllers/itemcontrollerSpec.coffee b/js/tests/controllers/itemcontrollerSpec.coffee index b06e16a9c..27fc82f71 100644 --- a/js/tests/controllers/itemcontrollerSpec.coffee +++ b/js/tests/controllers/itemcontrollerSpec.coffee @@ -169,3 +169,18 @@ describe 'ItemController', -> expect(@persistence.getItems.callCount).toBe(2) + + it 'should set refresh to true when pull to refresh is activated', => + @ItemBusinessLayer.loadNew = -> + + @scope.loadNew() + expect(@scope.refresh).toBe(true) + + + it 'should set refresh to false after load next was successful', => + @ItemBusinessLayer.loadNew = jasmine.createSpy('loadNew') + @ItemBusinessLayer.loadNew.andCallFake (callback) -> + callback() + + @scope.loadNew() + expect(@scope.refresh).toBe(false) \ No newline at end of file diff --git a/js/tests/services/businesslayer/itembusinesslayerSpec.coffee b/js/tests/services/businesslayer/itembusinesslayerSpec.coffee index e14ba54b3..75804323d 100644 --- a/js/tests/services/businesslayer/itembusinesslayerSpec.coffee +++ b/js/tests/services/businesslayer/itembusinesslayerSpec.coffee @@ -248,3 +248,19 @@ describe 'ItemBusinessLayer', -> expect(@persistence.getItems).toHaveBeenCalledWith( @FeedType.Feed, 3, 1, jasmine.any(Function)) + + + it 'should load the next items', => + @NewestItem.handle(13) + @persistence.getNewItems = jasmine.createSpy('loadnew') + callback = -> + + @ItemModel.add({id: 2, guidHash: 'abc', feedId: 2, lastModified: 2}) + @ItemModel.add({id: 3, guidHash: 'abcd', feedId: 2, lastModified: 4}) + @ItemModel.add({id: 1, guidHash: 'abce', feedId: 2, lastModified: 3}) + @ItemModel.add({id: 6, guidHash: 'abcf', feedId: 2, lastModified: 1}) + + @ItemBusinessLayer.loadNew(callback) + + expect(@persistence.getNewItems).toHaveBeenCalledWith( + @FeedType.Feed, 3, 4, callback) diff --git a/js/tests/services/models/itemmodelSpec.coffee b/js/tests/services/models/itemmodelSpec.coffee index 4decc7918..d2fed6630 100644 --- a/js/tests/services/models/itemmodelSpec.coffee +++ b/js/tests/services/models/itemmodelSpec.coffee @@ -110,4 +110,13 @@ describe 'ItemModel', -> @ItemModel.add({id: 1, guidHash: 'abce', feedId: 2, status: 16}) @ItemModel.add({id: 6, guidHash: 'abcf', feedId: 2, status: 16}) - expect(@ItemModel.getLowestId()).toBe(1) \ No newline at end of file + expect(@ItemModel.getLowestId()).toBe(1) + + + it 'should return the highest lastModified', => + @ItemModel.add({id: 2, guidHash: 'abc', feedId: 2, lastModified: 3}) + @ItemModel.add({id: 3, guidHash: 'abcd', feedId: 2, lastModified: 13}) + @ItemModel.add({id: 1, guidHash: 'abce', feedId: 2, lastModified: 15}) + @ItemModel.add({id: 6, guidHash: 'abcf', feedId: 2, lastModified: 11}) + + expect(@ItemModel.getLastModified()).toBe(15) \ No newline at end of file diff --git a/js/tests/services/persistenceSpec.coffee b/js/tests/services/persistenceSpec.coffee index e9f12f669..38e02705a 100644 --- a/js/tests/services/persistenceSpec.coffee +++ b/js/tests/services/persistenceSpec.coffee @@ -74,6 +74,22 @@ describe 'Persistence', -> expect(@req.get).toHaveBeenCalledWith('news_items', expected) + it 'should send a load new items request', => + success = -> + params = + data: + type: 2 + id: 5 + lastModified: 3 + onSuccess: success + onFailure: success + + @Persistence.getNewItems(params.data.type, params.data.id, + params.data.lastModified, success) + + expect(@req.get).toHaveBeenCalledWith('news_items_new', params) + + it 'send a correct star item request', => params = routeParams: diff --git a/templates/main.php b/templates/main.php index e0722a91d..80cf5eb86 100644 --- a/templates/main.php +++ b/templates/main.php @@ -61,6 +61,7 @@ if($version[0] > 5 || ($version[0] >= 5 && $version[1] >= 80)) { ng-show="initialized && !feedBusinessLayer.noFeeds()" news-item-scroll="true" item-shortcuts + news-pull-to-refresh="loadNew()" tabindex="-1"> inc("part.items")); ?> diff --git a/templates/part.items.php b/templates/part.items.php index 9a067cf6e..a0c0c3cc4 100644 --- a/templates/part.items.php +++ b/templates/part.items.php @@ -1,3 +1,5 @@ +
+
  • assertAnnotations($this->controller, $methodName, $annotations); } + public function testItemsAnnotations(){ $this->assertItemControllerAnnotations('items'); } + public function testNewItemsAnnotations(){ + $this->assertItemControllerAnnotations('newItems'); + } + public function testStarAnnotations(){ $this->assertItemControllerAnnotations('star'); } @@ -446,5 +451,86 @@ class ItemControllerTest extends ControllerTestUtility { } + public function testNewItems(){ + $feeds = array(new Feed()); + $result = array( + 'items' => array(new Item()), + 'feeds' => $feeds, + 'newestItemId' => $this->newestItemId, + 'starred' => 3111 + ); + $post = array( + 'lastModified' => 3, + 'type' => FeedType::FEED, + 'id' => 2 + ); + $this->controller = $this->getPostController($post); + + $this->api->expects($this->once()) + ->method('getUserValue') + ->with($this->equalTo('showAll')) + ->will($this->returnValue('1')); + $this->api->expects($this->once()) + ->method('getUserId') + ->will($this->returnValue($this->user)); + + $this->feedBusinessLayer->expects($this->once()) + ->method('findAll') + ->with($this->equalTo($this->user)) + ->will($this->returnValue($feeds)); + + $this->itemBusinessLayer->expects($this->once()) + ->method('getNewestItemId') + ->with($this->equalTo($this->user)) + ->will($this->returnValue($this->newestItemId)); + + $this->itemBusinessLayer->expects($this->once()) + ->method('starredCount') + ->with($this->equalTo($this->user)) + ->will($this->returnValue(3111)); + + $this->itemBusinessLayer->expects($this->once()) + ->method('findAllNew') + ->with( + $this->equalTo($post['id']), + $this->equalTo($post['type']), + $this->equalTo($post['lastModified']), + $this->equalTo(true), + $this->equalTo($this->user)) + ->will($this->returnValue($result['items'])); + + $response = $this->controller->newItems(); + $this->assertEquals($result, $response->getParams()); + $this->assertTrue($response instanceof JSONResponse); + } + + + public function testGetNewItemsNoNewestItemsId(){ + $result = array(); + $post = array( + 'lastModified' => 3, + 'type' => FeedType::FEED, + 'id' => 2 + ); + $this->controller = $this->getPostController($post); + + $this->api->expects($this->once()) + ->method('getUserValue') + ->with($this->equalTo('showAll')) + ->will($this->returnValue('1')); + $this->api->expects($this->once()) + ->method('getUserId') + ->will($this->returnValue($this->user)); + + $this->itemBusinessLayer->expects($this->once()) + ->method('getNewestItemId') + ->with($this->equalTo($this->user)) + ->will($this->throwException(new BusinessLayerException(''))); + + $response = $this->controller->newItems(); + $this->assertEquals($result, $response->getParams()); + $this->assertTrue($response instanceof JSONResponse); + } + } \ No newline at end of file -- cgit v1.2.3