Commit 5b36b58c authored by Sean Hammond's avatar Sean Hammond Committed by GitHub

Merge pull request #314 from hypothesis/stream-content-component

Convert StreamController to a component
parents f4b00536 ba2b95f4
......@@ -69,8 +69,7 @@ function configureRoutes($routeProvider) {
});
$routeProvider.when('/stream',
{
controller: 'StreamController',
template: require('./templates/stream_content.html'),
template: '<stream-content search="search"></stream-content>',
reloadOnSearch: false,
resolve: resolve,
});
......@@ -130,7 +129,6 @@ module.exports = angular.module('h', [
])
.controller('AnnotationViewerController', require('./annotation-viewer-controller'))
.controller('StreamController', require('./stream-controller'))
// The root component for the application
.directive('hypothesisApp', require('./directive/app'))
......@@ -156,6 +154,7 @@ module.exports = angular.module('h', [
.component('sidebarTutorial', require('./components/sidebar-tutorial').component)
.component('shareDialog', require('./components/share-dialog'))
.component('sortDropdown', require('./components/sort-dropdown'))
.component('streamContent', require('./components/stream-content'))
.component('svgIcon', require('./components/svg-icon'))
.component('tagEditor', require('./components/tag-editor'))
.component('threadList', require('./components/thread-list'))
......
'use strict';
// @ngInject
function StreamContentController(
$scope, $location, $route, $routeParams, annotationMapper, annotationUI,
queryParser, rootThread, searchFilter, store, streamFilter, streamer
) {
var self = this;
annotationUI.setAppIsSidebar(false);
/** `offset` parameter for the next search API call. */
var offset = 0;
/** Load annotations fetched from the API into the app. */
var load = function (result) {
offset += result.rows.length;
annotationMapper.loadAnnotations(result.rows, result.replies);
};
/**
* Fetch the next `limit` annotations starting from `offset` from the API.
*/
var fetch = function (limit) {
var query = Object.assign({
_separate_replies: true,
offset: offset,
limit: limit,
}, searchFilter.toObject($routeParams.q));
store.search(query)
.then(load)
.catch(function (err) {
console.error(err);
});
};
// Re-do search when query changes
var lastQuery = $routeParams.q;
$scope.$on('$routeUpdate', function () {
if ($routeParams.q !== lastQuery) {
annotationUI.clearAnnotations();
$route.reload();
}
});
// Set up updates from real-time API.
streamFilter
.resetFilter()
.setMatchPolicyIncludeAll();
var terms = searchFilter.generateFacetedFilter($routeParams.q);
queryParser.populateFilter(streamFilter, terms);
streamer.setConfig('filter', {filter: streamFilter.getFilter()});
streamer.connect();
// Perform the initial search
fetch(20);
this.setCollapsed = annotationUI.setCollapsed;
this.forceVisible = function (id) {
annotationUI.setForceVisible(id, true);
};
Object.assign(this.search, {
query: function () {
return $routeParams.q || '';
},
update: function (q) {
$location.search({q: q});
},
});
annotationUI.subscribe(function () {
self.rootThread = rootThread.thread(annotationUI.getState());
});
// Sort the stream so that the newest annotations are at the top
annotationUI.setSortKey('Newest');
this.loadMore = fetch;
}
module.exports = {
controller: StreamContentController,
controllerAs: 'vm',
bindings: {
search: '<',
},
template: require('../templates/stream_content.html'),
};
'use strict';
var angular = require('angular');
var inherits = require('inherits');
var EventEmitter = require('tiny-emitter');
function FakeRootThread() {
this.thread = sinon.stub();
}
inherits(FakeRootThread, EventEmitter);
describe('StreamContentController', function () {
var $componentController;
var $rootScope;
var fakeRoute;
var fakeRouteParams;
var fakeAnnotationMapper;
var fakeAnnotationUI;
var fakeQueryParser;
var fakeRootThread;
var fakeSearchFilter;
var fakeStore;
var fakeStreamer;
var fakeStreamFilter;
before(function () {
angular.module('h', [])
.component('streamContent', require('../stream-content'));
});
beforeEach(function () {
fakeAnnotationMapper = {
loadAnnotations: sinon.spy(),
};
fakeAnnotationUI = {
clearAnnotations: sinon.spy(),
setAppIsSidebar: sinon.spy(),
setCollapsed: sinon.spy(),
setForceVisible: sinon.spy(),
setSortKey: sinon.spy(),
subscribe: sinon.spy(),
};
fakeRouteParams = {id: 'test'};
fakeQueryParser = {
populateFilter: sinon.spy(),
};
fakeRoute = {
reload: sinon.spy(),
};
fakeSearchFilter = {
generateFacetedFilter: sinon.stub(),
toObject: sinon.stub().returns({}),
};
fakeStore = {
search: sinon.spy(function () {
return Promise.resolve({rows: [], total: 0});
}),
};
fakeStreamer = {
open: sinon.spy(),
close: sinon.spy(),
setConfig: sinon.spy(),
connect: sinon.spy(),
};
fakeStreamFilter = {
resetFilter: sinon.stub().returnsThis(),
setMatchPolicyIncludeAll: sinon.stub().returnsThis(),
getFilter: sinon.stub(),
};
fakeRootThread = new FakeRootThread();
angular.mock.module('h', {
$route: fakeRoute,
$routeParams: fakeRouteParams,
annotationMapper: fakeAnnotationMapper,
annotationUI: fakeAnnotationUI,
queryParser: fakeQueryParser,
rootThread: fakeRootThread,
searchFilter: fakeSearchFilter,
store: fakeStore,
streamFilter: fakeStreamFilter,
streamer: fakeStreamer,
});
angular.mock.inject(function (_$componentController_, _$rootScope_) {
$componentController = _$componentController_;
$rootScope = _$rootScope_;
});
});
function createController() {
return $componentController('streamContent', {}, {
search: {
query: sinon.stub(),
update: sinon.stub(),
},
});
}
it('calls the search API with `_separate_replies: true`', function () {
createController();
assert.equal(fakeStore.search.firstCall.args[0]._separate_replies, true);
});
it('passes the annotations and replies from search to loadAnnotations()', function () {
fakeStore.search = function () {
return Promise.resolve({
'rows': ['annotation_1', 'annotation_2'],
'replies': ['reply_1', 'reply_2', 'reply_3'],
});
};
createController();
Promise.resolve().then(function () {
assert.calledOnce(fakeAnnotationMapper.loadAnnotations);
assert.calledWith(fakeAnnotationMapper.loadAnnotations,
['annotation_1', 'annotation_2'], ['reply_1', 'reply_2', 'reply_3']);
});
});
context('when a $routeUpdate event occurs', function () {
it('reloads the route if the query changed', function () {
fakeRouteParams.q = 'test query';
createController();
fakeRouteParams.q = 'new query';
$rootScope.$broadcast('$routeUpdate');
assert.called(fakeAnnotationUI.clearAnnotations);
assert.calledOnce(fakeRoute.reload);
});
it('does not reload the route if the query did not change', function () {
fakeRouteParams.q = 'test query';
createController();
$rootScope.$broadcast('$routeUpdate');
assert.notCalled(fakeAnnotationUI.clearAnnotations);
assert.notCalled(fakeRoute.reload);
});
});
});
angular = require('angular')
module.exports = class StreamController
this.$inject = [
'$scope', '$location', '$route', '$rootScope', '$routeParams',
'annotationUI',
'queryParser', 'rootThread', 'searchFilter', 'store',
'streamer', 'streamFilter', 'annotationMapper'
]
constructor: (
$scope, $location, $route, $rootScope, $routeParams
annotationUI,
queryParser, rootThread, searchFilter, store,
streamer, streamFilter, annotationMapper
) ->
annotationUI.setAppIsSidebar(false)
offset = 0
fetch = (limit) ->
options = {offset, limit}
searchParams = searchFilter.toObject($routeParams.q)
query = angular.extend(options, searchParams)
query._separate_replies = true
store.search(query)
.then(load)
.catch((err) -> console.error err)
load = ({rows, replies}) ->
offset += rows.length
annotationMapper.loadAnnotations(rows, replies)
# Reload on query change (ignore hash change)
lastQuery = $routeParams.q
$scope.$on '$routeUpdate', ->
if $routeParams.q isnt lastQuery
annotationUI.clearAnnotations()
$route.reload()
# Initialize the base filter
streamFilter
.resetFilter()
.setMatchPolicyIncludeAll()
# Apply query clauses
terms = searchFilter.generateFacetedFilter $routeParams.q
queryParser.populateFilter streamFilter, terms
streamer.setConfig('filter', {filter: streamFilter.getFilter()})
streamer.connect()
# Perform the initial search
fetch(20)
$scope.setCollapsed = (id, collapsed) ->
annotationUI.setCollapsed(id, collapsed)
$scope.forceVisible = (id) ->
annotationUI.setForceVisible(id, true)
Object.assign $scope.search, {
query: -> $routeParams.q || ''
update: (q) -> $location.search({q: q})
}
annotationUI.subscribe( ->
$scope.rootThread = rootThread.thread(annotationUI.getState())
);
# Sort the stream so that the newest annotations are at the top
annotationUI.setSortKey('Newest')
$scope.isStream = true
$scope.loadMore = fetch
<span window-scroll="loadMore(20)">
<span window-scroll="vm.loadMore(20)">
<thread-list
on-change-collapsed="setCollapsed(id, collapsed)"
on-force-visible="forceVisible(thread)"
on-change-collapsed="vm.setCollapsed(id, collapsed)"
on-force-visible="vm.forceVisible(thread)"
show-document-info="true"
thread="rootThread">
thread="vm.rootThread">
</thread-list>
</span>
EventEmitter = require('tiny-emitter')
inherits = require('inherits')
{module, inject} = angular.mock
class FakeRootThread extends EventEmitter
constructor: () ->
this.thread = sinon.stub()
describe 'StreamController', ->
$controller = null
$scope = null
fakeAnnotationMapper = null
fakeAnnotationUI = null
fakeParams = null
fakeQueryParser = null
fakeRoute = null
fakeStore = null
fakeStreamer = null
fakeStreamFilter = null
fakeThreading = null
sandbox = null
createController = ->
$controller('StreamController', {$scope: $scope})
before ->
angular.module('h', [])
.controller('StreamController', require('../stream-controller'))
beforeEach module('h')
beforeEach module ($provide) ->
sandbox = sinon.sandbox.create()
fakeAnnotationMapper = {
loadAnnotations: sandbox.spy()
}
fakeAnnotationUI = {
clearAnnotations: sandbox.spy()
setAppIsSidebar: sandbox.spy()
setCollapsed: sandbox.spy()
setForceVisible: sandbox.spy()
setSortKey: sandbox.spy()
subscribe: sandbox.spy()
}
fakeParams = {id: 'test'}
fakeQueryParser = {
populateFilter: sandbox.spy()
}
fakeRoute = {
reload: sandbox.spy()
}
fakeSearchFilter = {
generateFacetedFilter: sandbox.stub()
toObject: sandbox.stub().returns({})
}
fakeStore = {
search: sandbox.spy(-> Promise.resolve({rows: [], total: 0}))
}
fakeStreamer = {
open: sandbox.spy()
close: sandbox.spy()
setConfig: sandbox.spy()
connect: sandbox.spy()
}
fakeStreamFilter = {
resetFilter: sandbox.stub().returnsThis()
setMatchPolicyIncludeAll: sandbox.stub().returnsThis()
getFilter: sandbox.stub()
}
fakeRootThread = new FakeRootThread()
$provide.value 'annotationMapper', fakeAnnotationMapper
$provide.value 'annotationUI', fakeAnnotationUI
$provide.value '$route', fakeRoute
$provide.value '$routeParams', fakeParams
$provide.value 'queryParser', fakeQueryParser
$provide.value 'rootThread', fakeRootThread
$provide.value 'searchFilter', fakeSearchFilter
$provide.value 'store', fakeStore
$provide.value 'streamer', fakeStreamer
$provide.value 'streamFilter', fakeStreamFilter
return
beforeEach inject (_$controller_, $rootScope) ->
$controller = _$controller_
$scope = $rootScope.$new()
$scope.search = {}
afterEach ->
sandbox.restore()
it 'calls the search API with _separate_replies: true', ->
createController()
assert.equal(fakeStore.search.firstCall.args[0]._separate_replies, true)
it 'passes the annotations and replies from search to loadAnnotations()', ->
fakeStore.search = (query) ->
Promise.resolve({
'rows': ['annotation_1', 'annotation_2']
'replies': ['reply_1', 'reply_2', 'reply_3']
})
createController()
Promise.resolve().then ->
assert.calledOnce fakeAnnotationMapper.loadAnnotations
assert.calledWith fakeAnnotationMapper.loadAnnotations,
['annotation_1', 'annotation_2'], ['reply_1', 'reply_2', 'reply_3']
describe 'on $routeUpdate', ->
it 'reloads the route when the query changes', ->
fakeParams.q = 'test query'
createController()
fakeParams.q = 'new query'
$scope.$broadcast('$routeUpdate')
assert.called(fakeAnnotationUI.clearAnnotations)
assert.calledOnce(fakeRoute.reload)
it 'does not reload the route when the query is the same', ->
fakeParams.q = 'test query'
createController()
$scope.$broadcast('$routeUpdate')
assert.notCalled(fakeRoute.reload)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment