Commit 18198d1f authored by Nick Stenning's avatar Nick Stenning

Merge pull request #3108 from hypothesis/256-focus-selected-annot-on-load

Switch to appropriate group and scroll to direct linked annotation when sidebar loads
parents cf1eabf9 abd2c79b
...@@ -4,19 +4,7 @@ var angular = require('angular'); ...@@ -4,19 +4,7 @@ var angular = require('angular');
var proxyquire = require('proxyquire'); var proxyquire = require('proxyquire');
var util = require('./util'); var util = require('./util');
var noCallThru = require('../../test/util').noCallThru;
/**
* Disable calling through to the original module for a stub.
*
* By default proxyquire will call through to the original module
* for any methods not provided by a stub. This function disables
* this behavior for a stub and returns the input stub.
*
* This prevents unintended usage of the original dependency.
*/
function noCallThru(stub) {
return Object.assign(stub, {'@noCallThru':true});
}
describe('markdown', function () { describe('markdown', function () {
function isHidden(element) { function isHidden(element) {
...@@ -49,8 +37,8 @@ describe('markdown', function () { ...@@ -49,8 +37,8 @@ describe('markdown', function () {
before(function () { before(function () {
angular.module('app', ['ngSanitize']) angular.module('app', ['ngSanitize'])
.directive('markdown', proxyquire('../markdown', { .directive('markdown', proxyquire('../markdown', noCallThru({
angular: noCallThru(angular), angular: angular,
katex: { katex: {
renderToString: function (input) { renderToString: function (input) {
return 'math:' + input.replace(/$$/g, ''); return 'math:' + input.replace(/$$/g, '');
...@@ -62,8 +50,7 @@ describe('markdown', function () { ...@@ -62,8 +50,7 @@ describe('markdown', function () {
toggleSpanStyle: mockFormattingCommand, toggleSpanStyle: mockFormattingCommand,
LinkType: require('../../markdown-commands').LinkType, LinkType: require('../../markdown-commands').LinkType,
}, },
'@noCallThru': true, })))
}))
.filter('converter', function () { .filter('converter', function () {
return function (input) { return function (input) {
return 'rendered:' + input; return 'rendered:' + input;
......
'use strict';
/** /**
* This module defines the set of global events that are dispatched * This module defines the set of global events that are dispatched
* on $rootScope * on $rootScope
*/ */
module.exports = { module.exports = {
/** Broadcast when the currently selected group changes */ // Session state changes
GROUP_FOCUSED: 'groupFocused',
/** Broadcast when the list of groups changes */ /** The list of groups changed */
GROUPS_CHANGED: 'groupsChanged', GROUPS_CHANGED: 'groupsChanged',
/** Broadcast when the signed-in user changes */ /** The signed-in user changed */
USER_CHANGED: 'userChanged', USER_CHANGED: 'userChanged',
/** Broadcast when the session state is updated. /**
* This event is NOT broadcast after the initial session load. * The session state was updated.
*/ */
SESSION_CHANGED: 'sessionChanged', SESSION_CHANGED: 'sessionChanged',
// UI state changes
/** The currently selected group changed */
GROUP_FOCUSED: 'groupFocused',
// Annotation events
/** A new annotation has been created locally. */
BEFORE_ANNOTATION_CREATED: 'beforeAnnotationCreated',
/** Annotations were anchored in a connected document. */
ANNOTATIONS_SYNCED: 'sync',
/** An annotation was created on the server and assigned an ID. */
ANNOTATION_CREATED: 'annotationCreated',
/** An annotation was either deleted or unloaded. */
ANNOTATION_DELETED: 'annotationDeleted',
/** A set of annotations were loaded from the server. */
ANNOTATIONS_LOADED: 'annotationsLoaded',
}; };
...@@ -25,7 +25,7 @@ function groups(localStorage, session, settings, $rootScope, $http) { ...@@ -25,7 +25,7 @@ function groups(localStorage, session, settings, $rootScope, $http) {
function all() { function all() {
return session.state.groups || []; return session.state.groups || [];
}; }
// Return the full object for the group with the given id. // Return the full object for the group with the given id.
function get(id) { function get(id) {
...@@ -35,7 +35,7 @@ function groups(localStorage, session, settings, $rootScope, $http) { ...@@ -35,7 +35,7 @@ function groups(localStorage, session, settings, $rootScope, $http) {
return gs[i]; return gs[i];
} }
} }
}; }
/** Leave the group with the given ID. /** Leave the group with the given ID.
* Returns a promise which resolves when the action completes. * Returns a promise which resolves when the action completes.
...@@ -51,7 +51,7 @@ function groups(localStorage, session, settings, $rootScope, $http) { ...@@ -51,7 +51,7 @@ function groups(localStorage, session, settings, $rootScope, $http) {
// by optimistically updating the session state // by optimistically updating the session state
return response; return response;
}; }
/** Return the currently focused group. If no group is explicitly focused we /** Return the currently focused group. If no group is explicitly focused we
...@@ -72,13 +72,16 @@ function groups(localStorage, session, settings, $rootScope, $http) { ...@@ -72,13 +72,16 @@ function groups(localStorage, session, settings, $rootScope, $http) {
/** Set the group with the passed id as the currently focused group. */ /** Set the group with the passed id as the currently focused group. */
function focus(id) { function focus(id) {
var prevFocused = focused();
var g = get(id); var g = get(id);
if (g) { if (g) {
focusedGroup = g; focusedGroup = g;
localStorage.setItem(STORAGE_KEY, g.id); localStorage.setItem(STORAGE_KEY, g.id);
if (prevFocused.id !== g.id) {
$rootScope.$broadcast(events.GROUP_FOCUSED, g.id); $rootScope.$broadcast(events.GROUP_FOCUSED, g.id);
} }
} }
}
// reset the focused group if the user leaves it // reset the focused group if the user leaves it
$rootScope.$on(events.GROUPS_CHANGED, function () { $rootScope.$on(events.GROUPS_CHANGED, function () {
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
// ES2015 polyfills // ES2015 polyfills
require('core-js/es6/promise'); require('core-js/es6/promise');
require('core-js/fn/array/find');
require('core-js/fn/object/assign'); require('core-js/fn/object/assign');
// URL constructor, required by IE 10/11, // URL constructor, required by IE 10/11,
......
'use strict';
var EventEmitter = require('tiny-emitter');
var inherits = require('inherits');
/**
* Client for the Hypothesis search API.
*
* SearchClient handles paging through results, canceling search etc.
*
* @param {Object} resource - ngResource class instance for the /search API
* @param {Object} opts - Search options
* @constructor
*/
function SearchClient(resource, opts) {
opts = opts || {};
var DEFAULT_CHUNK_SIZE = 200;
this._resource = resource;
this._chunkSize = opts.chunkSize || DEFAULT_CHUNK_SIZE;
if (typeof opts.incremental !== 'undefined') {
this._incremental = opts.incremental;
} else {
this._incremental = true;
}
this._canceled = false;
}
inherits(SearchClient, EventEmitter);
SearchClient.prototype._getBatch = function (query, offset) {
var searchQuery = Object.assign({
limit: this._chunkSize,
offset: offset,
sort: 'created',
order: 'asc',
_separate_replies: true,
}, query);
var self = this;
this._resource.get(searchQuery).$promise.then(function (results) {
if (self._canceled) {
return;
}
var chunk = results.rows.concat(results.replies || []);
if (self._incremental) {
self.emit('results', chunk);
} else {
self._results = self._results.concat(chunk);
}
var nextOffset = offset + results.rows.length;
if (results.total > nextOffset) {
self._getBatch(query, nextOffset);
} else {
if (!self._incremental) {
self.emit('results', self._results);
}
self.emit('end');
}
}).catch(function (err) {
if (self._canceled) {
return;
}
self.emit('error', err);
}).then(function () {
if (self._canceled) {
return;
}
self.emit('end');
});
};
/**
* Perform a search against the Hypothesis API.
*
* Emits a 'results' event with an array of annotations as they become
* available (in incremental mode) or when all annotations are available
* (in non-incremental mode).
*
* Emits an 'error' event if the search fails.
* Emits an 'end' event once the search completes.
*/
SearchClient.prototype.get = function (query) {
this._results = [];
this._getBatch(query, 0);
};
/**
* Cancel the current search and emit the 'end' event.
* No further events will be emitted after this.
*/
SearchClient.prototype.cancel = function () {
this._canceled = true;
this.emit('end');
};
module.exports = SearchClient;
...@@ -45,7 +45,7 @@ describe('groups', function() { ...@@ -45,7 +45,7 @@ describe('groups', function() {
} }
} }
}; };
fakeHttp = sandbox.stub() fakeHttp = sandbox.stub();
}); });
afterEach(function () { afterEach(function () {
...@@ -134,7 +134,7 @@ describe('groups', function() { ...@@ -134,7 +134,7 @@ describe('groups', function() {
}); });
}); });
describe('.focus() method', function() { describe('.focus()', function() {
it('sets the focused group to the named group', function() { it('sets the focused group to the named group', function() {
var s = service(); var s = service();
s.focus('id2'); s.focus('id2');
...@@ -155,6 +155,20 @@ describe('groups', function() { ...@@ -155,6 +155,20 @@ describe('groups', function() {
assert.calledWithMatch(fakeLocalStorage.setItem, sinon.match.any, 'id3'); assert.calledWithMatch(fakeLocalStorage.setItem, sinon.match.any, 'id3');
}); });
it('emits the GROUP_FOCUSED event if the focused group changed', function () {
var s = service();
s.focus('id3');
assert.calledWith(fakeRootScope.$broadcast, events.GROUP_FOCUSED, 'id3');
});
it('does not emit GROUP_FOCUSED if the focused group did not change', function () {
var s = service();
s.focus('id3');
fakeRootScope.$broadcast = sinon.stub();
s.focus('id3');
assert.notCalled(fakeRootScope.$broadcast);
});
}); });
describe('.leave()', function () { describe('.leave()', function () {
......
'use strict'; 'use strict';
var proxyquire = require('proxyquire'); var proxyquire = require('proxyquire');
var noCallThru = require('./util').noCallThru;
function noCallThru(stub) {
return Object.assign(stub, {'@noCallThru':true});
}
function fakeExceptionData(scriptURL) { function fakeExceptionData(scriptURL) {
return { return {
...@@ -51,10 +48,10 @@ describe('raven', function () { ...@@ -51,10 +48,10 @@ describe('raven', function () {
Raven.setDataCallback(fakeAngularTransformer); Raven.setDataCallback(fakeAngularTransformer);
}); });
raven = proxyquire('../raven', { raven = proxyquire('../raven', noCallThru({
'raven-js': noCallThru(fakeRavenJS), 'raven-js': fakeRavenJS,
'raven-js/plugins/angular': noCallThru(fakeAngularPlugin), 'raven-js/plugins/angular': fakeAngularPlugin,
}); }));
}); });
describe('.install()', function () { describe('.install()', function () {
......
'use strict';
var SearchClient = require('../search-client');
function await(emitter, event) {
return new Promise(function (resolve) {
emitter.on(event, resolve);
});
}
describe('SearchClient', function () {
var RESULTS = [
{id: 'one'},
{id: 'two'},
{id: 'three'},
{id: 'four'},
];
var fakeResource;
beforeEach(function () {
fakeResource = {
get: sinon.spy(function (params) {
return {
$promise: Promise.resolve({
rows: RESULTS.slice(params.offset,
params.offset + params.limit),
total: RESULTS.length,
}),
};
}),
};
});
it('emits "results"', function () {
var client = new SearchClient(fakeResource);
var onResults = sinon.stub();
client.on('results', onResults);
client.get({uri: 'http://example.com'});
return await(client, 'end').then(function () {
assert.calledWith(onResults, RESULTS);
});
});
it('emits "results" with chunks in incremental mode', function () {
var client = new SearchClient(fakeResource, {chunkSize: 2});
var onResults = sinon.stub();
client.on('results', onResults);
client.get({uri: 'http://example.com'});
return await(client, 'end').then(function () {
assert.calledWith(onResults, RESULTS.slice(0,2));
assert.calledWith(onResults, RESULTS.slice(2,4));
});
});
it('emits "results" once in non-incremental mode', function () {
var client = new SearchClient(fakeResource,
{chunkSize: 2, incremental: false});
var onResults = sinon.stub();
client.on('results', onResults);
client.get({uri: 'http://example.com'});
return await(client, 'end').then(function () {
assert.calledOnce(onResults);
assert.calledWith(onResults, RESULTS);
});
});
it('does not emit "results" if canceled', function () {
var client = new SearchClient(fakeResource);
var onResults = sinon.stub();
var onEnd = sinon.stub();
client.on('results', onResults);
client.on('end', onEnd);
client.get({uri: 'http://example.com'});
client.cancel();
return Promise.resolve().then(function () {
assert.notCalled(onResults);
assert.called(onEnd);
});
});
it('emits "error" event if search fails', function () {
var err = new Error('search failed');
fakeResource.get = function () {
return {
$promise: Promise.reject(err),
};
};
var client = new SearchClient(fakeResource);
var onError = sinon.stub();
client.on('error', onError);
client.get({uri: 'http://example.com'});
return await(client, 'end').then(function () {
assert.calledWith(onError, err);
});
});
});
'use strict';
/**
* Utility function for use with 'proxyquire' that prevents calls to
* stubs 'calling through' to the _original_ dependency if a particular
* function or property is not set on a stub, which is proxyquire's default
* but usually undesired behavior.
*
* See https://github.com/thlorenz/proxyquireify#nocallthru
*
* Usage:
* var moduleUnderTest = proxyquire('./module-under-test', noCallThru({
* './dependency-foo': fakeFoo,
* }));
*
* @param {Object} stubs - A map of dependency paths to stubs, or a single
* stub.
*/
function noCallThru(stubs) {
// This function is trivial but serves as documentation for why
// '@noCallThru' is used.
return Object.assign(stubs, {'@noCallThru':true});
}
module.exports = {
noCallThru: noCallThru,
};
'use strict'; 'use strict';
var angular = require('angular'); var angular = require('angular');
var inherits = require('inherits');
var proxyquire = require('proxyquire');
var EventEmitter = require('tiny-emitter');
var events = require('../events'); var events = require('../events');
var noCallThru = require('./util').noCallThru;
var searchClients;
function FakeSearchClient(resource, opts) {
assert.ok(resource);
searchClients.push(this);
this.cancel = sinon.stub();
this.incremental = !!opts.incremental;
this.get = sinon.spy(function (query) {
assert.ok(query.uri);
this.emit('results', [{id: query.uri + '123', group: '__world__'}]);
this.emit('results', [{id: query.uri + '456', group: 'private-group'}]);
this.emit('end');
});
}
inherits(FakeSearchClient, EventEmitter);
describe('WidgetController', function () { describe('WidgetController', function () {
var $scope = null; var $scope = null;
var $rootScope = null; var $rootScope = null;
var fakeAnnotationMapper = null; var fakeAnnotationMapper = null;
var fakeAnnotationUI = null; var fakeAnnotationUI = null;
var fakeAuth = null;
var fakeCrossFrame = null; var fakeCrossFrame = null;
var fakeDrafts = null; var fakeDrafts = null;
var fakeStore = null; var fakeStore = null;
...@@ -22,12 +42,18 @@ describe('WidgetController', function () { ...@@ -22,12 +42,18 @@ describe('WidgetController', function () {
before(function () { before(function () {
angular.module('h', []) angular.module('h', [])
.controller('WidgetController', require('../widget-controller')); .controller('WidgetController', proxyquire('../widget-controller',
noCallThru({
angular: angular,
'./search-client': FakeSearchClient,
})
));
}); });
beforeEach(angular.mock.module('h')); beforeEach(angular.mock.module('h'));
beforeEach(angular.mock.module(function ($provide) { beforeEach(angular.mock.module(function ($provide) {
searchClients = [];
sandbox = sinon.sandbox.create(); sandbox = sinon.sandbox.create();
fakeAnnotationMapper = { fakeAnnotationMapper = {
...@@ -36,45 +62,20 @@ describe('WidgetController', function () { ...@@ -36,45 +62,20 @@ describe('WidgetController', function () {
}; };
fakeAnnotationUI = { fakeAnnotationUI = {
tool: 'comment', clearSelectedAnnotations: sandbox.spy(),
clearSelectedAnnotations: sandbox.spy() selectedAnnotationMap: {},
hasSelectedAnnotations: function () {
return Object.keys(this.selectedAnnotationMap).length > 0;
},
};
fakeCrossFrame = {
call: sinon.stub(),
frames: [],
}; };
fakeAuth = {user: null};
fakeCrossFrame = {frames: []};
fakeDrafts = { fakeDrafts = {
unsaved: sandbox.stub() unsaved: sandbox.stub()
}; };
fakeStore = {
SearchResource: {
get: function (query, callback) {
var offset = query.offset || 0;
var limit = query.limit || 20;
var result =
{
total: 100,
rows: ((function () {
var result1 = [];
var end = offset + limit - 1;
var i = offset;
if (offset <= end) {
while (i <= end) {
result1.push(i++);
}
} else {
while (i >= end) {
result1.push(i--);
}
}
return result1;
})()),
replies: []
};
return callback(result);
}
},
};
fakeStreamer = { fakeStreamer = {
setConfig: sandbox.spy() setConfig: sandbox.spy()
}; };
...@@ -87,11 +88,19 @@ describe('WidgetController', function () { ...@@ -87,11 +88,19 @@ describe('WidgetController', function () {
fakeThreading = { fakeThreading = {
root: {}, root: {},
thread: sandbox.stub() thread: sandbox.stub(),
annotationList: function () {
return [{id: '123'}];
},
}; };
fakeGroups = { fakeGroups = {
focused: function () { return {id: 'foo'}; } focused: function () { return {id: 'foo'}; },
focus: sinon.stub(),
};
fakeStore = {
SearchResource: {},
}; };
$provide.value('annotationMapper', fakeAnnotationMapper); $provide.value('annotationMapper', fakeAnnotationMapper);
...@@ -103,7 +112,6 @@ describe('WidgetController', function () { ...@@ -103,7 +112,6 @@ describe('WidgetController', function () {
$provide.value('streamFilter', fakeStreamFilter); $provide.value('streamFilter', fakeStreamFilter);
$provide.value('threading', fakeThreading); $provide.value('threading', fakeThreading);
$provide.value('groups', fakeGroups); $provide.value('groups', fakeGroups);
return;
})); }));
beforeEach(angular.mock.inject(function ($controller, _$rootScope_) { beforeEach(angular.mock.inject(function ($controller, _$rootScope_) {
...@@ -117,61 +125,137 @@ describe('WidgetController', function () { ...@@ -117,61 +125,137 @@ describe('WidgetController', function () {
}); });
describe('loadAnnotations', function () { describe('loadAnnotations', function () {
it('unloads any existing annotations', function () {
// When new clients connect, all existing annotations should be unloaded
// before reloading annotations for each currently-connected client
fakeCrossFrame.frames.push({uri: 'http://example.com/page-a'});
$scope.$digest();
fakeAnnotationMapper.unloadAnnotations = sandbox.spy();
fakeCrossFrame.frames.push({uri: 'http://example.com/page-b'});
$scope.$digest();
assert.calledWith(fakeAnnotationMapper.unloadAnnotations,
fakeThreading.annotationList());
});
it('loads all annotations for a frame', function () { it('loads all annotations for a frame', function () {
$scope.chunkSize = 20; var uri = 'http://example.com';
fakeCrossFrame.frames.push({uri: 'http://example.com'}); fakeCrossFrame.frames.push({uri: uri});
$scope.$digest();
var loadSpy = fakeAnnotationMapper.loadAnnotations;
assert.calledWith(loadSpy, [sinon.match({id: uri + '123'})]);
assert.calledWith(loadSpy, [sinon.match({id: uri + '456'})]);
});
it('loads all annotations for all frames', function () {
var uris = ['http://example.com', 'http://foobar.com'];
fakeCrossFrame.frames = uris.map(function (uri) {
return {uri: uri};
});
$scope.$digest(); $scope.$digest();
var loadSpy = fakeAnnotationMapper.loadAnnotations; var loadSpy = fakeAnnotationMapper.loadAnnotations;
assert.callCount(loadSpy, 5); assert.calledWith(loadSpy, [sinon.match({id: uris[0] + '123'})]);
assert.calledWith(loadSpy, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]); assert.calledWith(loadSpy, [sinon.match({id: uris[0] + '456'})]);
assert.calledWith(loadSpy, [20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39]); assert.calledWith(loadSpy, [sinon.match({id: uris[1] + '123'})]);
assert.calledWith(loadSpy, [40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59]); assert.calledWith(loadSpy, [sinon.match({id: uris[1] + '456'})]);
assert.calledWith(loadSpy, [60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79]);
assert.calledWith(loadSpy, [80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99]);
}); });
it('passes _separate_replies: true to the search API', function () { context('when there is a selection', function () {
fakeStore.SearchResource.get = sandbox.stub(); var uri = 'http://example.com';
fakeCrossFrame.frames.push({uri: 'http://example.com'}); var id = uri + '123';
beforeEach(function () {
fakeCrossFrame.frames = [{uri: uri}];
fakeAnnotationUI.selectedAnnotationMap[id] = true;
$scope.$digest(); $scope.$digest();
});
assert.equal( it('switches to the selected annotation\'s group', function () {
fakeStore.SearchResource.get.firstCall.args[0]._separate_replies, true); assert.calledWith(fakeGroups.focus, '__world__');
assert.calledOnce(fakeAnnotationMapper.loadAnnotations);
assert.calledWith(fakeAnnotationMapper.loadAnnotations, [
{id: uri + '123', group: '__world__'},
]);
}); });
return it('passes annotations and replies from search to loadAnnotations()', function () { it('fetches annotations for all groups', function () {
fakeStore.SearchResource.get = function (query, callback) { assert.calledWith(searchClients[0].get, {uri: uri, group: null});
return callback({
rows: ['annotation_1', 'annotation_2'],
replies: ['reply_1', 'reply_2', 'reply_3']
}); });
};
fakeCrossFrame.frames.push({uri: 'http://example.com'}); it('loads annotations in one batch', function () {
assert.notOk(searchClients[0].incremental);
});
});
context('when there is no selection', function () {
var uri = 'http://example.com';
beforeEach(function () {
fakeCrossFrame.frames = [{uri: uri}];
fakeGroups.focused = function () { return { id: 'a-group' }; };
$scope.$digest(); $scope.$digest();
});
assert(fakeAnnotationMapper.loadAnnotations.calledOnce); it('fetches annotations for the current group', function () {
assert(fakeAnnotationMapper.loadAnnotations.calledWith( assert.calledWith(searchClients[0].get, {uri: uri, group: 'a-group'});
['annotation_1', 'annotation_2'], ['reply_1', 'reply_2', 'reply_3'] });
));
it('loads annotations in batches', function () {
assert.ok(searchClients[0].incremental);
}); });
}); });
describe('when the focused group changes', function () { context('when the selected annotation is not available', function () {
return it('should load annotations for the new group', function () { var uri = 'http://example.com';
fakeThreading.annotationList = sandbox.stub().returns([{id: '1'}]); var id = uri + 'does-not-exist';
fakeCrossFrame.frames.push({uri: 'http://example.com'});
var searchResult = {total: 10, rows: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10], replies: []};
fakeStore.SearchResource.get = function (query, callback) {
return callback(searchResult);
};
$scope.$broadcast(events.GROUP_FOCUSED); beforeEach(function () {
fakeCrossFrame.frames = [{uri: uri}];
fakeAnnotationUI.selectedAnnotationMap[id] = true;
fakeGroups.focused = function () { return { id: 'private-group' }; };
$scope.$digest();
});
it('loads annotations from the focused group instead', function () {
assert.calledWith(fakeGroups.focus, 'private-group');
assert.calledWith(fakeAnnotationMapper.loadAnnotations,
[{group: "private-group", id: "http://example.com456"}]);
});
});
});
assert.calledWith(fakeAnnotationMapper.unloadAnnotations, [{id: '1'}]); describe('when an annotation is anchored', function () {
it('focuses and scrolls to the annotation if already selected', function () {
var uri = 'http://example.com';
fakeAnnotationUI.selectedAnnotationMap = {'123': true};
fakeCrossFrame.frames.push({uri: uri});
var annot = {
$$tag: 'atag',
id: '123',
};
fakeThreading.idTable = {
'123': {
message: annot,
},
};
$scope.$digest(); $scope.$digest();
assert.calledWith(fakeAnnotationMapper.loadAnnotations, searchResult.rows); $rootScope.$broadcast(events.ANNOTATIONS_SYNCED, [{tag: 'atag'}]);
assert.calledWith(fakeCrossFrame.call, 'focusAnnotations', ['atag']);
assert.calledWith(fakeCrossFrame.call, 'scrollToAnnotation', 'atag');
});
});
describe('when the focused group changes', function () {
it('should load annotations for the new group', function () {
var uri = 'http://example.com';
fakeCrossFrame.frames.push({uri: uri});
var loadSpy = fakeAnnotationMapper.loadAnnotations;
$scope.$broadcast(events.GROUP_FOCUSED);
assert.calledWith(fakeAnnotationMapper.unloadAnnotations, [{id: '123'}]);
assert.calledWith(fakeThreading.thread, fakeDrafts.unsaved()); assert.calledWith(fakeThreading.thread, fakeDrafts.unsaved());
$scope.$digest();
assert.calledWith(loadSpy, [sinon.match({id: uri + '123'})]);
assert.calledWith(loadSpy, [sinon.match({id: uri + '456'})]);
}); });
}); });
......
angular = require('angular') angular = require('angular')
mail = require('./vendor/jwz') mail = require('./vendor/jwz')
events = require('./events')
# The threading service provides the model for the currently loaded # The threading service provides the model for the currently loaded
# set of annotations, structured as a tree of annotations and replies. # set of annotations, structured as a tree of annotations and replies.
...@@ -31,10 +32,10 @@ module.exports = class Threading ...@@ -31,10 +32,10 @@ module.exports = class Threading
# Create a root container. # Create a root container.
@root = mail.messageContainer() @root = mail.messageContainer()
$rootScope.$on('beforeAnnotationCreated', this.beforeAnnotationCreated) $rootScope.$on(events.BEFORE_ANNOTATION_CREATED, this.beforeAnnotationCreated)
$rootScope.$on('annotationCreated', this.annotationCreated) $rootScope.$on(events.ANNOTATION_CREATED, this.annotationCreated)
$rootScope.$on('annotationDeleted', this.annotationDeleted) $rootScope.$on(events.ANNOTATION_DELETED, this.annotationDeleted)
$rootScope.$on('annotationsLoaded', this.annotationsLoaded) $rootScope.$on(events.ANNOTATIONS_LOADED, this.annotationsLoaded)
# TODO: Refactor the jwz API for progressive updates. # TODO: Refactor the jwz API for progressive updates.
# Right now the idTable is wiped when `messageThread.thread()` is called and # Right now the idTable is wiped when `messageThread.thread()` is called and
......
'use strict'; 'use strict';
var angular = require('angular');
var events = require('./events'); var events = require('./events');
var SearchClient = require('./search-client');
/**
* Returns the group ID of the first annotation in `results` whose
* ID is a key in `selection`.
*/
function groupIDFromSelection(selection, results) {
var id = Object.keys(selection)[0];
var annot = results.find(function (annot) {
return annot.id === id;
});
if (!annot) {
return;
}
return annot.group;
}
// @ngInject // @ngInject
module.exports = function WidgetController( module.exports = function WidgetController(
...@@ -12,78 +26,165 @@ module.exports = function WidgetController( ...@@ -12,78 +26,165 @@ module.exports = function WidgetController(
$scope.threadRoot = threading.root; $scope.threadRoot = threading.root;
$scope.sortOptions = ['Newest', 'Oldest', 'Location']; $scope.sortOptions = ['Newest', 'Oldest', 'Location'];
var DEFAULT_CHUNK_SIZE = 200; function focusAnnotation(annotation) {
var loaded = []; var highlights = [];
if (annotation) {
highlights = [annotation.$$tag];
}
crossframe.call('focusAnnotations', highlights);
}
function scrollToAnnotation(annotation) {
if (!annotation) {
return;
}
crossframe.call('scrollToAnnotation', annotation.$$tag);
}
var _resetAnnotations = function () { /**
* Returns the Annotation object for the first annotation in the
* selected annotation set. Note that 'first' refers to the order
* of annotations passed to annotationUI when selecting annotations,
* not the order in which they appear in the document.
*/
function firstSelectedAnnotation() {
if (annotationUI.selectedAnnotationMap) {
var id = Object.keys(annotationUI.selectedAnnotationMap)[0];
return threading.idTable[id] && threading.idTable[id].message;
} else {
return null;
}
}
function _resetAnnotations() {
// Unload all the annotations // Unload all the annotations
annotationMapper.unloadAnnotations(threading.annotationList()); annotationMapper.unloadAnnotations(threading.annotationList());
// Reload all the drafts // Reload all the drafts
threading.thread(drafts.unsaved()); threading.thread(drafts.unsaved());
}; }
var _loadAnnotationsFrom = function (query, offset) { var searchClients = [];
var queryCore = {
limit: $scope.chunkSize || DEFAULT_CHUNK_SIZE,
offset: offset,
sort: 'created',
order: 'asc',
group: groups.focused().id
};
var q = angular.extend(queryCore, query);
q._separate_replies = true;
store.SearchResource.get(q, function (results) { function _loadAnnotationsFor(uri, group) {
var total = results.total; var searchClient = new SearchClient(store.SearchResource, {
offset += results.rows.length; // If no group is specified, we are fetching annotations from
if (offset < total) { // all groups in order to find out which group contains the selected
_loadAnnotationsFrom(query, offset); // annotation, therefore we need to load all chunks before processing
// the results
incremental: !!group,
});
searchClients.push(searchClient);
searchClient.on('results', function (results) {
if (annotationUI.hasSelectedAnnotations()) {
// Focus the group containing the selected annotation and filter
// annotations to those from this group
var groupID = groupIDFromSelection(annotationUI.selectedAnnotationMap,
results);
if (!groupID) {
// If the selected annotation is not available, fall back to
// loading annotations for the currently focused group
groupID = groups.focused().id;
}
results = results.filter(function (result) {
return result.group === groupID;
});
groups.focus(groupID);
} }
annotationMapper.loadAnnotations(results.rows, results.replies); if (results.length) {
annotationMapper.loadAnnotations(results);
}
});
searchClient.on('end', function () {
// Remove client from list of active search clients
searchClients.splice(searchClients.indexOf(searchClient), 1);
});
searchClient.get({uri: uri, group: group});
}
/**
* Load annotations for all URLs associated with @p frames.
*
* @param {Array<{uri:string}>} frames - Hypothesis client frames
* to load annotations for.
*/
function loadAnnotations(frames) {
_resetAnnotations();
searchClients.forEach(function (client) {
client.cancel();
}); });
};
var loadAnnotations = function (frames) { var urls = frames.reduce(function (urls, frame) {
for (var i = 0, f; i < frames.length; i++) { if (urls.indexOf(frame.uri) !== -1) {
f = frames[i]; return urls;
var ref; } else {
if (ref = f.uri, loaded.indexOf(ref) >= 0) { return urls.concat(frame.uri);
continue;
} }
loaded.push(f.uri); }, []);
_loadAnnotationsFrom({uri: f.uri}, 0);
// If there is no selection, load annotations only for the focused group.
//
// If there is a selection, we load annotations for all groups, find out
// which group the first selected annotation is in and then filter the
// results on the client by that group.
//
// In the common case where the total number of annotations on
// a page that are visible to the user is not greater than
// the batch size, this saves an extra roundtrip to the server
// to fetch the selected annotation in order to determine which group
// it is in before fetching the remaining annotations.
var group = annotationUI.hasSelectedAnnotations() ?
null : groups.focused().id;
for (var i=0; i < urls.length; i++) {
_loadAnnotationsFor(urls[i], group);
} }
if (loaded.length > 0) { if (urls.length > 0) {
streamFilter.resetFilter().addClause('/uri', 'one_of', loaded); streamFilter.resetFilter().addClause('/uri', 'one_of', urls);
streamer.setConfig('filter', {filter: streamFilter.getFilter()}); streamer.setConfig('filter', {filter: streamFilter.getFilter()});
} }
}; }
// When a direct-linked annotation is successfully anchored in the page,
// focus and scroll to it
$rootScope.$on(events.ANNOTATIONS_SYNCED, function (event, tags) {
var selectedAnnot = firstSelectedAnnotation();
if (!selectedAnnot) {
return;
}
var matchesSelection = tags.some(function (tag) {
return tag.tag === selectedAnnot.$$tag;
});
if (!matchesSelection) {
return;
}
focusAnnotation(selectedAnnot);
scrollToAnnotation(selectedAnnot);
});
$scope.$on(events.GROUP_FOCUSED, function () { $scope.$on(events.GROUP_FOCUSED, function () {
_resetAnnotations(annotationMapper, drafts, threading); // The focused group may be changed during loading annotations (in which
loaded = []; // case, searchClients.length > 0), as a result of switching to the group
// containing the selected annotation.
//
// In that case, we don't want to trigger reloading annotations again.
if (searchClients.length) {
return;
}
annotationUI.clearSelectedAnnotations();
return loadAnnotations(crossframe.frames); return loadAnnotations(crossframe.frames);
}); });
// Watch anything that may require us to reload annotations.
$scope.$watchCollection(function () { $scope.$watchCollection(function () {
return crossframe.frames; return crossframe.frames;
}, loadAnnotations); }, loadAnnotations);
$scope.focus = function (annotation) { $scope.focus = focusAnnotation;
var highlights = []; $scope.scrollTo = scrollToAnnotation;
if (angular.isObject(annotation)) {
highlights = [annotation.$$tag];
}
return crossframe.call('focusAnnotations', highlights);
};
$scope.scrollTo = function (annotation) {
if (angular.isObject(annotation)) {
return crossframe.call('scrollToAnnotation', annotation.$$tag);
}
};
$scope.hasFocus = function (annotation) { $scope.hasFocus = function (annotation) {
if (!annotation || !$scope.focusedAnnotations) { if (!annotation || !$scope.focusedAnnotations) {
...@@ -92,7 +193,7 @@ module.exports = function WidgetController( ...@@ -92,7 +193,7 @@ module.exports = function WidgetController(
return annotation.$$tag in $scope.focusedAnnotations; return annotation.$$tag in $scope.focusedAnnotations;
}; };
$rootScope.$on('beforeAnnotationCreated', function (event, data) { $rootScope.$on(events.BEFORE_ANNOTATION_CREATED, function (event, data) {
if (data.$highlight || (data.references && data.references.length > 0)) { if (data.$highlight || (data.references && data.references.length > 0)) {
return; return;
} }
......
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