Commit b005ed0d authored by Robert Knight's avatar Robert Knight Committed by Nick Stenning

Render annotation thread using new threading infrastructure

This replaces the rendering of conversation threads using
the new threading implementation.

 * Add <annotation-thread> component to render Thread objects
   generated by rootThread.

 * Expose the Thread generated by the `rootThread` service on
   the scope so that <annotation-thread> can render it.

 * Remove logic and tests in <annotation> for updating reply
   counts and thread states as these are no longer needed.
parent de829f34
......@@ -4,37 +4,26 @@ var angular = require('angular');
var events = require('./events');
// Fetch the container object for the passed annotation from the threading
// service, but only return it if it has an associated message.
function getContainer(threading, annotation) {
var container = threading.idTable[annotation.id];
if (container === null || typeof container === 'undefined') {
return null;
}
// Also return null if the container has no message
if (!container.message) {
return null;
}
return container;
function getExistingAnnotation(annotationUI, id) {
return annotationUI.getState().annotations.find(function (annot) {
return annot.id === id;
});
}
// Wraps the annotation store to trigger events for the CRUD actions
// @ngInject
function annotationMapper($rootScope, threading, store) {
function annotationMapper($rootScope, annotationUI, store) {
function loadAnnotations(annotations, replies) {
annotations = annotations.concat(replies || []);
var loaded = [];
annotations.forEach(function (annotation) {
var container = getContainer(threading, annotation);
if (container !== null) {
angular.copy(annotation, container.message);
$rootScope.$emit(events.ANNOTATION_UPDATED, container.message);
var existing = getExistingAnnotation(annotationUI, annotation.id);
if (existing) {
angular.copy(annotation, existing);
$rootScope.$emit(events.ANNOTATION_UPDATED, existing);
return;
}
loaded.push(new store.AnnotationResource(annotation));
});
......@@ -43,9 +32,9 @@ function annotationMapper($rootScope, threading, store) {
function unloadAnnotations(annotations) {
var unloaded = annotations.map(function (annotation) {
var container = getContainer(threading, annotation);
if (container !== null && annotation !== container.message) {
annotation = angular.copy(annotation, container.message);
var existing = getExistingAnnotation(annotationUI, annotation.id);
if (existing && annotation !== existing) {
annotation = angular.copy(annotation, existing);
}
return annotation;
});
......
......@@ -5,7 +5,7 @@ var angular = require('angular');
// @ngInject
function AnnotationViewerController (
$location, $routeParams, $scope,
streamer, store, streamFilter, annotationMapper, threading
annotationUI, rootThread, streamer, store, streamFilter, annotationMapper
) {
var id = $routeParams.id;
......@@ -17,9 +17,16 @@ function AnnotationViewerController (
$location.path('/stream').search('q', query);
};
$scope.rootThread = function () {
return rootThread.thread();
};
$scope.setCollapsed = function (id, collapsed) {
annotationUI.setCollapsed(id, collapsed);
};
store.AnnotationResource.get({ id: id }, function (annotation) {
annotationMapper.loadAnnotations([annotation]);
$scope.threadRoot = { children: [threading.idTable[id]] };
});
store.SearchResource.get({ references: id }, function (data) {
......
......@@ -25,7 +25,6 @@ if (settings.raven) {
angular.module('ngRaven', []);
}
var mail = require('./vendor/jwz');
var streamer = require('./streamer');
// Fetch external state that the app needs before it can run. This includes the
......@@ -40,20 +39,6 @@ var resolve = {
return store.$promise;
},
streamer: streamer.connect,
// @ngInject
threading: function (annotationMapper, drafts, threading) {
// Unload all the annotations
annotationMapper.unloadAnnotations(threading.annotationList());
// Reset the threading root
threading.createIdTable([]);
threading.root = mail.messageContainer();
// Reload all new, unsaved annotations
threading.thread(drafts.unsaved());
return threading;
},
};
// @ngInject
......@@ -117,15 +102,6 @@ function processAppOpts() {
}
}
// @ngInject
function setupTemplateCache($templateCache) {
// 'thread.html' is used via ng-include so it needs to be added
// to the template cache. Other components just require() templates
// directly as strings.
$templateCache.put('thread.html',
require('../../templates/client/thread.html'));
}
module.exports = angular.module('h', [
// Angular addons which export the Angular module name
// via module.exports
......@@ -155,7 +131,7 @@ module.exports = angular.module('h', [
.directive('aboutThisVersionDialog', require('./directive/about-this-version-dialog'))
.directive('annotation', require('./directive/annotation').directive)
.directive('annotationShareDialog', require('./directive/annotation-share-dialog'))
.directive('deepCount', require('./directive/deep-count'))
.directive('annotationThread', require('./directive/annotation-thread'))
.directive('dropdownMenuBtn', require('./directive/dropdown-menu-btn'))
.directive('excerpt', require('./directive/excerpt').directive)
.directive('feedbackLink', require('./directive/feedback-link'))
......@@ -176,8 +152,6 @@ module.exports = angular.module('h', [
.directive('sortDropdown', require('./directive/sort-dropdown'))
.directive('spinner', require('./directive/spinner'))
.directive('statusButton', require('./directive/status-button'))
.directive('thread', require('./directive/thread'))
.directive('threadFilter', require('./directive/thread-filter'))
.directive('topBar', require('./directive/top-bar'))
.directive('windowScroll', require('./directive/window-scroll'))
......@@ -195,12 +169,11 @@ module.exports = angular.module('h', [
.service('localStorage', require('./local-storage'))
.service('permissions', require('./permissions'))
.service('queryParser', require('./query-parser'))
.service('render', require('./render'))
.service('rootThread', require('./root-thread'))
.service('searchFilter', require('./search-filter'))
.service('session', require('./session'))
.service('streamFilter', require('./stream-filter'))
.service('tags', require('./tags'))
.service('threading', require('./threading'))
.service('unicode', require('./unicode'))
.service('viewFilter', require('./view-filter'))
......@@ -219,7 +192,6 @@ module.exports = angular.module('h', [
.config(configureRoutes)
.run(setupCrossFrame)
.run(setupHttp)
.run(setupTemplateCache);
.run(setupHttp);
processAppOpts();
'use strict';
function hiddenCount(thread) {
var isHidden = thread.annotation && !thread.visible;
return thread.children.reduce(function (count, reply) {
return count + hiddenCount(reply);
}, isHidden ? 1 : 0);
}
function showAllChildren(thread, showFn) {
thread.children.forEach(function (child) {
showFn({thread: child});
showAllChildren(child, showFn);
});
}
function showAllParents(thread, showFn) {
while (thread.parent && thread.parent.annotation) {
showFn({thread: thread.parent});
thread = thread.parent;
}
}
// @ngInject
function AnnotationThreadController() {
this.toggleCollapsed = function () {
this.onChangeCollapsed({
id: this.thread.id,
collapsed: !this.thread.collapsed,
});
};
/**
* Show this thread and any of its children
*/
this.showThreadAndReplies = function () {
showAllParents(this.thread, this.onForceVisible);
this.onForceVisible({thread: this.thread});
showAllChildren(this.thread, this.onForceVisible);
};
/**
* Return the total number of annotations in the current
* thread which have been hidden because they do not match the current
* search filter.
*/
this.hiddenCount = function () {
return hiddenCount(this.thread);
};
}
/**
* Renders a thread of annotations.
*/
module.exports = function () {
return {
restrict: 'E',
bindToController: true,
controllerAs: 'vm',
controller: AnnotationThreadController,
scope: {
/** The annotation thread to render. */
thread: '<',
/**
* Specify whether document information should be shown
* on annotation cards.
*/
showDocumentInfo: '<',
/** Called when the user clicks on the expand/collapse replies toggle. */
onChangeCollapsed: '&',
/**
* Called when the user clicks the button to show this thread or
* one of its replies.
*/
onForceVisible: '&',
},
template: require('../../../templates/client/annotation_thread.html'),
};
};
......@@ -721,51 +721,7 @@ function AnnotationController(
function link(scope, elem, attrs, controllers) {
var ctrl = controllers[0];
var thread = controllers[1];
var threadFilter = controllers[2];
var counter = controllers[3];
elem.on('keydown', ctrl.onKeydown);
// FIXME: Replace this counting code with something more sane.
if (counter !== null) {
scope.$watch((function() {return ctrl.editing();}), function(editing, old) {
if (editing) { // The user has just started editing this annotation.
// Keep track of edits going on in the thread.
// This 'edit' count is for example to uncollapse a thread if one of
// the replies in the thread is currently being edited when the
// annotations are first rendered (this can happen when switching
// focus to a different group then back again, for example).
counter.count('edit', 1);
// Always show an annotation if it is being edited, even if there's an
// active search filter that does not match the annotation.
if ((thread !== null) && (threadFilter !== null)) {
threadFilter.active(false);
threadFilter.freeze(true);
}
} else if (old) { // The user has just finished editing this annotation.
counter.count('edit', -1);
if (threadFilter) {
threadFilter.freeze(false);
}
// Ensure that the just-edited annotation remains visible.
if (thread.parent) {
thread.parent.toggleCollapsed(false);
}
}
});
scope.$on('$destroy', function() {
if (ctrl.editing() && counter) {
counter.count('edit', -1);
}
});
}
}
/**
......@@ -785,12 +741,12 @@ function annotation() {
controller: AnnotationController,
controllerAs: 'vm',
link: link,
require: ['annotation', '?^thread', '?^threadFilter', '?^deepCount'],
require: ['annotation'],
scope: {
annotation: '<',
// Indicates whether this is the last reply in a thread.
isLastReply: '<',
isSidebar: '<',
showDocumentInfo: '<',
onReplyCountClick: '&',
replyCount: '<',
isCollapsed: '<'
......
......@@ -182,104 +182,6 @@ describe('annotation', function() {
'keydown', mockAnnotationController.onKeydown)
);
});
it('increments the "edit" count when editing() becomes true', function () {
link(scope, mockElement, mockAttributes, mockControllers);
mockAnnotationController.editing.returns(true);
scope.$digest();
assert.equal(true, mockDeepCountController.count.calledOnce);
assert.equal(
true, mockDeepCountController.count.calledWithExactly('edit', 1)
);
});
it('decrements the "edit" count when editing() turns false', function () {
mockAnnotationController.editing.returns(true);
link(scope, mockElement, mockAttributes, mockControllers);
scope.$digest();
mockAnnotationController.editing.returns(false);
scope.$digest();
assert.equal(
true,
mockDeepCountController.count.lastCall.calledWithExactly('edit', -1)
);
});
it('decrements the edit count when destroyed while editing', function () {
mockAnnotationController.editing.returns(true);
link(scope, mockElement, mockAttributes, mockControllers);
scope.$destroy();
assert.equal(1, mockDeepCountController.count.callCount);
assert.equal(
true, mockDeepCountController.count.calledWithExactly('edit', -1)
);
});
it('does not decrement the edit count when destroyed while not editing',
function () {
mockAnnotationController.editing.returns(false);
link(scope, mockElement, mockAttributes, mockControllers);
scope.$destroy();
assert.equal(0, mockDeepCountController.count.callCount);
}
);
it('deactivates the thread filter when editing() turns true', function () {
mockAnnotationController.editing.returns(false);
link(scope, mockElement, mockAttributes, mockControllers);
mockAnnotationController.editing.returns(true);
scope.$digest();
assert.equal(1, mockThreadFilterController.active.callCount);
assert.equal(
true, mockThreadFilterController.active.calledWithExactly(false));
});
it('freezes the thread filter when editing', function () {
mockAnnotationController.editing.returns(false);
link(scope, mockElement, mockAttributes, mockControllers);
mockAnnotationController.editing.returns(true);
scope.$digest();
assert.equal(1, mockThreadFilterController.freeze.callCount);
assert.equal(
true, mockThreadFilterController.freeze.calledWithExactly(true));
});
it('unfreezes the thread filter when editing becomes false', function () {
mockAnnotationController.editing.returns(true);
link(scope, mockElement, mockAttributes, mockControllers);
scope.$digest();
mockAnnotationController.editing.returns(false);
scope.$digest();
assert.equal(
true,
mockThreadFilterController.freeze.lastCall.calledWithExactly(false));
});
it('does not collapse parent thread after edit', function() {
mockAnnotationController.editing.returns(true);
link(scope, mockElement, mockAttributes, mockControllers);
scope.$digest();
mockAnnotationController.editing.returns(false);
scope.$digest();
assert.calledWith(mockThreadController.parent.toggleCollapsed, false);
});
});
describe('AnnotationController', function() {
......
'use strict';
var angular = require('angular');
var annotationThread = require('../annotation-thread');
var util = require('./util');
function PageObject(element) {
this.annotations = function () {
return Array.from(element[0].querySelectorAll('annotation'));
};
this.replies = function () {
return Array.from(element[0].querySelectorAll('annotation-thread'));
};
this.replyList = function () {
return element[0].querySelector('.thread-replies');
};
this.isHidden = function (element) {
return element.classList.contains('ng-hide');
};
}
describe('annotationThread', function () {
before(function () {
angular.module('app', [])
.directive('annotationThread', annotationThread);
});
beforeEach(function () {
angular.mock.module('app');
});
it('renders the tree structure of parent and child annotations', function () {
var element = util.createDirective(document, 'annotationThread', {
thread: {
id: '1',
annotation: {id: '1', text: 'text'},
children: [{
id: '2',
annotation: {id: '2', text: 'areply'},
children: [],
}],
visible: true,
},
});
var pageObject = new PageObject(element);
assert.equal(pageObject.annotations().length, 2);
assert.equal(pageObject.replies().length, 1);
});
it('does not render hidden threads', function () {
var element = util.createDirective(document, 'annotationThread', {
thread: {
id: '1',
annotation: {id: '1'},
visible: false,
children: []
}
});
var pageObject = new PageObject(element);
assert.equal(pageObject.annotations().length, 1);
assert.isTrue(pageObject.isHidden(pageObject.annotations()[0]));
});
it('shows replies if not collapsed', function () {
var element = util.createDirective(document, 'annotationThread', {
thread: {
id: '1',
annotation: {id: '1'},
visible: true,
children: [{
id: '2',
annotation: {id: '2'},
children: [],
visible: true,
}],
collapsed: false,
}
});
var pageObject = new PageObject(element);
assert.equal(pageObject.replies().length, 1);
});
it('does not show replies if collapsed', function () {
var element = util.createDirective(document, 'annotationThread', {
thread: {
id: '1',
annotation: {id: '1'},
visible: true,
children: [{
id: '2',
annotation: {id: '2'},
children: [],
visible: true,
}],
collapsed: true,
}
});
var pageObject = new PageObject(element);
assert.isTrue(pageObject.isHidden(pageObject.replyList()));
});
describe('#toggleCollapsed', function () {
it('toggles replies', function () {
var onChangeCollapsed = sinon.stub();
var element = util.createDirective(document, 'annotationThread', {
thread: {
id: '123',
annotation: {id: '123'},
children: [],
collapsed: true,
},
onChangeCollapsed: {
args: ['id', 'collapsed'],
callback: onChangeCollapsed,
}
});
element.ctrl.toggleCollapsed();
assert.calledWith(onChangeCollapsed, '123', false);
});
});
describe('#showThreadAndReplies', function () {
it('reveals all parents and replies', function () {
var onForceVisible = sinon.stub();
var thread = {
id: '123',
annotation: {id: '123'},
children: [{
id: 'child-id',
annotation: {id: 'child-id'},
children: [],
}],
parent: {
id: 'parent-id',
annotation: {id: 'parent-id'},
},
};
var element = util.createDirective(document, 'annotationThread', {
thread: thread,
onForceVisible: {
args: ['thread'],
callback: onForceVisible,
},
});
element.ctrl.showThreadAndReplies();
assert.calledWith(onForceVisible, thread.parent);
assert.calledWith(onForceVisible, thread);
assert.calledWith(onForceVisible, thread.children[0]);
});
});
});
angular = require('angular')
mail = require('./vendor/jwz')
module.exports = class StreamController
this.$inject = [
'$scope', '$route', '$rootScope', '$routeParams',
'queryParser', 'searchFilter', 'store',
'streamer', 'streamFilter', 'threading', 'annotationMapper'
'annotationUI',
'queryParser', 'rootThread', 'searchFilter', 'store',
'streamer', 'streamFilter', 'annotationMapper'
]
constructor: (
$scope, $route, $rootScope, $routeParams
queryParser, searchFilter, store,
streamer, streamFilter, threading, annotationMapper
annotationUI,
queryParser, rootThread, searchFilter, store,
streamer, streamFilter, annotationMapper
) ->
offset = 0
......@@ -26,16 +26,11 @@ module.exports = class StreamController
offset += rows.length
annotationMapper.loadAnnotations(rows, replies)
# Disable the thread filter (client-side search)
$scope.$on '$routeChangeSuccess', ->
if $scope.threadFilter?
$scope.threadFilter.active(false)
$scope.threadFilter.freeze(true)
# 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
......@@ -51,8 +46,19 @@ module.exports = class StreamController
# Perform the initial search
fetch(20)
$scope.$watch('sort.name', (name) ->
rootThread.sortBy(name)
)
$scope.setCollapsed = (id, collapsed) ->
annotationUI.setCollapsed(id, collapsed)
$scope.forceVisible = (id) ->
annotationUI.setForceVisible(id, true)
$scope.isStream = true
$scope.sortOptions = ['Newest', 'Oldest']
$scope.sort.name = 'Newest'
$scope.threadRoot = threading.root
$scope.rootThread = ->
return rootThread.thread()
$scope.loadMore = fetch
......@@ -2,37 +2,32 @@
var angular = require('angular');
var annotationUIFactory = require('../annotation-ui');
var events = require('../events');
describe('annotationMapper', function() {
var sandbox = sinon.sandbox.create();
var $rootScope;
var annotationUI;
var fakeStore;
var fakeThreading;
var annotationMapper;
before(function () {
angular.module('h', [])
.service('annotationMapper', require('../annotation-mapper'));
});
beforeEach(angular.mock.module('h'));
beforeEach(angular.mock.module(function ($provide) {
beforeEach(function () {
fakeStore = {
AnnotationResource: sandbox.stub().returns({})
AnnotationResource: sandbox.stub().returns({}),
};
fakeThreading = {
idTable: {}
};
$provide.value('store', fakeStore);
$provide.value('threading', fakeThreading);
}));
beforeEach(angular.mock.inject(function (_annotationMapper_, _$rootScope_) {
annotationUI = annotationUIFactory({});
angular.module('app', [])
.service('annotationMapper', require('../annotation-mapper'))
.value('annotationUI', annotationUI)
.value('store', fakeStore);
angular.mock.module('app');
angular.mock.inject(function (_$rootScope_, _annotationMapper_) {
$rootScope = _$rootScope_;
annotationMapper = _annotationMapper_;
}));
});
});
afterEach(function () {
sandbox.restore();
......@@ -58,24 +53,22 @@ describe('annotationMapper', function() {
[{}, {}, {}]);
});
it('triggers the annotationUpdated event for each annotation in the threading cache', function () {
it('triggers the annotationUpdated event for each loaded annotation', function () {
sandbox.stub($rootScope, '$emit');
var annotations = [{id: 1}, {id: 2}, {id: 3}];
var cached = {message: {id: 1, $$tag: 'tag1'}};
fakeThreading.idTable[1] = cached;
annotationUI.addAnnotations(angular.copy(annotations));
annotationMapper.loadAnnotations(annotations);
assert.called($rootScope.$emit);
assert.calledWith($rootScope.$emit, events.ANNOTATION_UPDATED,
cached.message);
annotations[0]);
});
it('also triggers annotationUpdated for cached replies', function () {
sandbox.stub($rootScope, '$emit');
var annotations = [{id: 1}];
var replies = [{id: 2}, {id: 3}, {id: 4}];
var cached = {message: {id: 3, $$tag: 'tag3'}};
fakeThreading.idTable[3] = cached;
annotationUI.addAnnotations([{id:3}]);
annotationMapper.loadAnnotations(annotations, replies);
assert($rootScope.$emit.calledWith(events.ANNOTATION_UPDATED,
......@@ -85,8 +78,7 @@ describe('annotationMapper', function() {
it('replaces the properties on the cached annotation with those from the loaded one', function () {
sandbox.stub($rootScope, '$emit');
var annotations = [{id: 1, url: 'http://example.com'}];
var cached = {message: {id: 1, $$tag: 'tag1'}};
fakeThreading.idTable[1] = cached;
annotationUI.addAnnotations([{id:1, $$tag: 'tag1'}]);
annotationMapper.loadAnnotations(annotations);
assert.called($rootScope.$emit);
......@@ -99,8 +91,7 @@ describe('annotationMapper', function() {
it('excludes cached annotations from the annotationLoaded event', function () {
sandbox.stub($rootScope, '$emit');
var annotations = [{id: 1, url: 'http://example.com'}];
var cached = {message: {id: 1, $$tag: 'tag1'}};
fakeThreading.idTable[1] = cached;
annotationUI.addAnnotations([{id: 1, $$tag: 'tag1'}]);
annotationMapper.loadAnnotations(annotations);
assert.called($rootScope.$emit);
......@@ -120,8 +111,7 @@ describe('annotationMapper', function() {
it('replaces the properties on the cached annotation with those from the deleted one', function () {
sandbox.stub($rootScope, '$emit');
var annotations = [{id: 1, url: 'http://example.com'}];
var cached = {message: {id: 1, $$tag: 'tag1'}};
fakeThreading.idTable[1] = cached;
annotationUI.addAnnotations([{id: 1, $$tag: 'tag1'}]);
annotationMapper.unloadAnnotations(annotations);
assert.calledWith($rootScope.$emit, events.ANNOTATIONS_UNLOADED, [{
......
......@@ -28,9 +28,10 @@ describe('AnnotationViewerController', function () {
$routeParams: opts.$routeParams || { id: 'test_annotation_id' },
$scope: opts.$scope || {
search: {},
threading: {
getContainer: function () {}
}
},
annotationUI: {},
rootThread: {
thread: sinon.stub(),
},
streamer: opts.streamer || { setConfig: function () {} },
store: opts.store || {
......@@ -50,7 +51,6 @@ describe('AnnotationViewerController', function () {
getFilter: function () {}
},
annotationMapper: opts.annotationMapper || { loadAnnotations: sinon.spy() },
threading: opts.threading || { idTable: {} }
};
locals.ctrl = getControllerService()(
'AnnotationViewerController', locals);
......
......@@ -4,6 +4,7 @@ describe 'StreamController', ->
$controller = null
$scope = null
fakeAnnotationMapper = null
fakeAnnotationUI = null
fakeParams = null
fakeQueryParser = null
fakeRoute = null
......@@ -30,6 +31,12 @@ describe 'StreamController', ->
loadAnnotations: sandbox.spy()
}
fakeAnnotationUI = {
clearAnnotations: sandbox.spy()
setCollapsed: sandbox.spy()
setForceVisible: sandbox.spy()
}
fakeParams = {id: 'test'}
fakeQueryParser = {
......@@ -63,19 +70,20 @@ describe 'StreamController', ->
getFilter: sandbox.stub()
}
fakeThreading = {
root: {}
fakeRootThread = {
thread: sandbox.stub()
}
$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
$provide.value 'threading', fakeThreading
return
beforeEach inject (_$controller_, $rootScope) ->
......@@ -106,17 +114,6 @@ describe 'StreamController', ->
)
describe 'on $routeChangeSuccess', ->
it 'disables page search', ->
createController()
$scope.threadFilter = {active: sinon.stub(), freeze: sinon.stub()}
$scope.$broadcast('$routeChangeSuccess')
assert.calledOnce($scope.threadFilter.active)
assert.calledWith($scope.threadFilter.active, false)
assert.calledOnce($scope.threadFilter.freeze)
assert.calledWith($scope.threadFilter.freeze, true)
describe 'on $routeUpdate', ->
it 'reloads the route when the query changes', ->
......@@ -124,6 +121,7 @@ describe 'StreamController', ->
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', ->
......
......@@ -27,20 +27,20 @@ function FakeSearchClient(resource, opts) {
inherits(FakeSearchClient, EventEmitter);
describe('WidgetController', function () {
var $scope = null;
var $rootScope = null;
var annotationUI = null;
var fakeAnnotationMapper = null;
var fakeCrossFrame = null;
var fakeDrafts = null;
var fakeStore = null;
var fakeStreamer = null;
var fakeStreamFilter = null;
var fakeThreading = null;
var fakeGroups = null;
var sandbox = null;
var viewer = null;
var fakeSettings = null;
var $rootScope;
var $scope;
var annotationUI;
var fakeAnnotationMapper;
var fakeCrossFrame;
var fakeDrafts;
var fakeGroups;
var fakeRootThread;
var fakeSettings;
var fakeStore;
var fakeStreamer;
var fakeStreamFilter;
var sandbox;
var viewer;
before(function () {
angular.module('h', [])
......@@ -64,12 +64,13 @@ describe('WidgetController', function () {
};
annotationUI = annotationUIFactory({});
fakeCrossFrame = {
call: sinon.stub(),
frames: [],
};
fakeDrafts = {
unsaved: sandbox.stub()
unsaved: sandbox.stub().returns([]),
};
fakeStreamer = {
......@@ -82,19 +83,19 @@ describe('WidgetController', function () {
getFilter: sandbox.stub().returns({})
};
fakeThreading = {
root: {},
thread: sandbox.stub(),
annotationList: function () {
return [{id: '123'}];
},
};
fakeGroups = {
focused: function () { return {id: 'foo'}; },
focus: sinon.stub(),
};
fakeRootThread = {
thread: sinon.stub().returns({
totalChildren: 0,
}),
setSearchQuery: sinon.stub(),
sortBy: sinon.stub(),
};
fakeSettings = {
annotations: 'test',
};
......@@ -107,10 +108,10 @@ describe('WidgetController', function () {
$provide.value('annotationUI', annotationUI);
$provide.value('crossframe', fakeCrossFrame);
$provide.value('drafts', fakeDrafts);
$provide.value('rootThread', fakeRootThread);
$provide.value('store', fakeStore);
$provide.value('streamer', fakeStreamer);
$provide.value('streamFilter', fakeStreamFilter);
$provide.value('threading', fakeThreading);
$provide.value('groups', fakeGroups);
$provide.value('settings', fakeSettings);
}));
......@@ -129,13 +130,14 @@ describe('WidgetController', 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
annotationUI.addAnnotations([{id: '123'}]);
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());
annotationUI.getState().annotations);
});
it('loads all annotations for a frame', function () {
......@@ -170,6 +172,10 @@ describe('WidgetController', function () {
$scope.$digest();
});
it('selectedAnnotationCount is > 0', function () {
assert.equal($scope.selectedAnnotationCount(), 1);
});
it('switches to the selected annotation\'s group', function () {
assert.calledWith(fakeGroups.focus, '__world__');
assert.calledOnce(fakeAnnotationMapper.loadAnnotations);
......@@ -196,6 +202,10 @@ describe('WidgetController', function () {
$scope.$digest();
});
it('selectedAnnotationCount is 0', function () {
assert.equal($scope.selectedAnnotationCount(), 0);
});
it('fetches annotations for the current group', function () {
assert.calledWith(searchClients[0].get, {uri: uri, group: 'a-group'});
});
......@@ -233,11 +243,7 @@ describe('WidgetController', function () {
$$tag: 'atag',
id: '123',
};
fakeThreading.idTable = {
'123': {
message: annot,
},
};
annotationUI.addAnnotations([annot]);
$scope.$digest();
$rootScope.$broadcast(events.ANNOTATIONS_SYNCED, [{tag: 'atag'}]);
assert.calledWith(fakeCrossFrame.call, 'focusAnnotations', ['atag']);
......@@ -248,12 +254,18 @@ describe('WidgetController', function () {
describe('when the focused group changes', function () {
it('should load annotations for the new group', function () {
var uri = 'http://example.com';
annotationUI.addAnnotations([{id: '123'}]);
annotationUI.addAnnotations = sinon.stub();
fakeDrafts.unsaved.returns([{id: uri + '123'}, {id: uri + '456'}]);
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(annotationUI.addAnnotations, fakeDrafts.unsaved());
$scope.$digest();
assert.calledWith(loadSpy, [sinon.match({id: uri + '123'})]);
assert.calledWith(loadSpy, [sinon.match({id: uri + '456'})]);
......@@ -291,14 +303,13 @@ describe('WidgetController', function () {
describe('direct linking messages', function () {
it('displays a message if the selection is unavailable', function () {
annotationUI.selectAnnotations(['missing']);
fakeThreading.idTable = {'123': {}};
$scope.$digest();
assert.isTrue($scope.selectedAnnotationUnavailable());
});
it('does not show a message if the selection is available', function () {
annotationUI.addAnnotations([{id: '123'}]);
annotationUI.selectAnnotations(['123']);
fakeThreading.idTable = {'123': {}};
$scope.$digest();
assert.isFalse($scope.selectedAnnotationUnavailable());
});
......@@ -313,8 +324,8 @@ describe('WidgetController', function () {
$scope.auth = {
status: 'signed-out'
};
annotationUI.addAnnotations([{id: '123'}]);
annotationUI.selectAnnotations(['123']);
fakeThreading.idTable = {'123': {}};
$scope.$digest();
assert.isTrue($scope.shouldShowLoggedOutMessage());
});
......@@ -324,7 +335,6 @@ describe('WidgetController', function () {
status: 'signed-out'
};
annotationUI.selectAnnotations(['missing']);
fakeThreading.idTable = {'123': {}};
$scope.$digest();
assert.isFalse($scope.shouldShowLoggedOutMessage());
});
......@@ -342,8 +352,8 @@ describe('WidgetController', function () {
$scope.auth = {
status: 'signed-in'
};
annotationUI.addAnnotations([{id: '123'}]);
annotationUI.selectAnnotations(['123']);
fakeThreading.idTable = {'123': {}};
$scope.$digest();
assert.isFalse($scope.shouldShowLoggedOutMessage());
});
......@@ -353,10 +363,28 @@ describe('WidgetController', function () {
status: 'signed-out'
};
delete fakeSettings.annotations;
annotationUI.addAnnotations([{id: '123'}]);
annotationUI.selectAnnotations(['123']);
fakeThreading.idTable = {'123': {}};
$scope.$digest();
assert.isFalse($scope.shouldShowLoggedOutMessage());
});
});
describe('#forceVisible', function () {
it('shows the thread', function () {
var thread = {id: '1'};
$scope.forceVisible(thread);
assert.deepEqual(annotationUI.getState().forceVisible, {1: true});
});
it('uncollapses the parent', function () {
var thread = {
id: '2',
parent: {id: '3'},
};
assert.equal(annotationUI.getState().expanded[thread.parent.id], undefined);
$scope.forceVisible(thread);
assert.equal(annotationUI.getState().expanded[thread.parent.id], true);
});
});
});
......@@ -31,11 +31,17 @@ function groupIDFromSelection(selection, results) {
// @ngInject
module.exports = function WidgetController(
$scope, $rootScope, annotationUI, crossframe, annotationMapper,
drafts, groups, settings, streamer, streamFilter, store, threading
drafts, groups, rootThread, settings, streamer, streamFilter, store
) {
$scope.threadRoot = threading.root;
$scope.sortOptions = ['Newest', 'Oldest', 'Location'];
function annotationExists(id) {
return annotationUI.getState().annotations.some(function (annot) {
return annot.id === id;
});
}
function focusAnnotation(annotation) {
var highlights = [];
if (annotation) {
......@@ -60,21 +66,23 @@ module.exports = function WidgetController(
function firstSelectedAnnotation() {
if (annotationUI.getState().selectedAnnotationMap) {
var id = Object.keys(annotationUI.getState().selectedAnnotationMap)[0];
return threading.idTable[id] && threading.idTable[id].message;
return annotationUI.getState().annotations.find(function (annot) {
return annot.id === id;
});
} else {
return null;
}
}
var searchClients = [];
function _resetAnnotations() {
// Unload all the annotations
annotationMapper.unloadAnnotations(threading.annotationList());
annotationMapper.unloadAnnotations(annotationUI.getState().annotations);
// Reload all the drafts
threading.thread(drafts.unsaved());
annotationUI.addAnnotations(drafts.unsaved());
}
var searchClients = [];
function _loadAnnotationsFor(uri, group) {
var searchClient = new SearchClient(store.SearchResource, {
// If no group is specified, we are fetching annotations from
......@@ -196,6 +204,30 @@ module.exports = function WidgetController(
return crossframe.frames;
}, loadAnnotations);
// Watch the inputs that determine which annotations are currently
// visible and how they are sorted and rebuild the thread when they change
$scope.$watch('sort.name', function (mode) {
rootThread.sortBy(mode);
});
$scope.$watch('search.query', function (query) {
rootThread.setSearchQuery(query);
});
$scope.rootThread = function () {
return rootThread.thread();
};
$scope.setCollapsed = function (id, collapsed) {
annotationUI.setCollapsed(id, collapsed);
};
$scope.forceVisible = function (thread) {
annotationUI.setForceVisible(thread.id, true);
if (thread.parent) {
annotationUI.setCollapsed(thread.parent.id, false);
}
};
$scope.focus = focusAnnotation;
$scope.scrollTo = scrollToAnnotation;
......@@ -206,14 +238,19 @@ module.exports = function WidgetController(
return annotation.$$tag in annotationUI.getState().focusedAnnotationMap;
};
function selectedID() {
return firstKey(annotationUI.getState().selectedAnnotationMap);
$scope.selectedAnnotationCount = function () {
var selection = annotationUI.getState().selectedAnnotationMap;
if (!selection) {
return 0;
}
return Object.keys(selection).length;
};
$scope.selectedAnnotationUnavailable = function () {
var selectedID = firstKey(annotationUI.getState().selectedAnnotationMap);
return !isLoading() &&
!!selectedID() &&
!threading.idTable[selectedID()];
!!selectedID &&
!annotationExists(selectedID);
};
$scope.shouldShowLoggedOutMessage = function () {
......@@ -231,21 +268,22 @@ module.exports = function WidgetController(
// The user is logged out and has landed on a direct linked
// annotation. If there is an annotation selection and that
// selection is available to the user, show the CTA.
var selectedID = firstKey(annotationUI.getState().selectedAnnotationMap);
return !isLoading() &&
!!selectedID() &&
!!threading.idTable[selectedID()];
!!selectedID &&
annotationExists(selectedID);
};
$scope.isLoading = isLoading;
$scope.topLevelThreadCount = function () {
return threading.root.children.length;
return rootThread.thread().totalChildren;
};
$rootScope.$on(events.BEFORE_ANNOTATION_CREATED, function (event, data) {
if (data.$highlight || (data.references && data.references.length > 0)) {
return;
}
return $scope.clearSelection();
$scope.clearSelection();
});
};
......@@ -29,11 +29,11 @@
<i class="h-icon-border-color" ng-show="vm.isHighlight() && !vm.editing()" title="This is a highlight. Click 'edit' to add a note or tag."></i>
<span class="annotation-citation"
ng-bind-html="vm.documentTitle"
ng-if="::!vm.isSidebar">
ng-if="::vm.showDocumentInfo">
</span>
<span class="annotation-citation-domain"
ng-bind-html="vm.documentDomain"
ng-if="::!vm.isSidebar">
ng-if="::vm.showDocumentInfo">
</span>
</span>
</span>
......
<a href=""
class="threadexp"
title="{{vm.thread.collapsed && 'Expand' || 'Collapse'}}"
ng-click="vm.toggleCollapsed()"
ng-if="vm.thread.parent">
<span ng-class="{'h-icon-arrow-right': vm.thread.collapsed,
'h-icon-arrow-drop-down': !vm.thread.collapsed}"></span>
</a>
<!-- Annotation -->
<annotation class="annotation thread-message {{vm.thread.collapsed && 'collapsed'}}"
annotation="vm.thread.annotation"
is-collapsed="vm.thread.collapsed"
is-last-reply="$last"
is-sidebar="::vm.isSidebar"
name="annotation"
ng-if="vm.thread.annotation"
ng-show="vm.thread.visible"
show-document-info="vm.showDocumentInfo"
on-reply-count-click="vm.toggleCollapsed()"
reply-count="vm.thread.replyCount">
</annotation>
<div ng-if="!vm.thread.annotation" class="thread-deleted">
<p><em>Message not available.</em></p>
</div>
<div class="thread-load-more" ng-if="vm.hiddenCount() > 0">
<a class="load-more small"
href=""
ng-click="vm.showThreadAndReplies()"
ng-pluralize
count="vm.hiddenCount()"
when="{'0': '',
one: 'View one more in conversation',
other: 'View {} more in conversation'}"
></a>
</div>
<!-- Replies -->
<ul class="thread-replies" ng-show="!vm.thread.collapsed">
<li class="thread" ng-repeat="child in vm.thread.children track by child.id">
<annotation-thread
show-document-info="false"
thread="child"
on-change-collapsed="vm.onChangeCollapsed({id:id, collapsed:collapsed})"
on-force-visible="vm.onForceVisible({thread:thread})">
</annotation-thread>
</li>
</ul>
......@@ -4,16 +4,15 @@
-->
<ul class="stream-list ng-hide"
ng-show="true"
deep-count="count"
thread-filter="search.query"
window-scroll="loadMore(20)">
<search-status-bar
ng-show="!isLoading()"
filter-active="threadFilter.active()"
filter-match-count="count('match')"
ng-if="!isStream"
filter-active="search.query"
filter-match-count="rootThread().children.length"
on-clear-selection="clearSelection()"
search-query="search ? search.query : ''"
selection-count="selectedAnnotationsCount"
selection-count="selectedAnnotationCount()"
total-count="topLevelThreadCount()"
>
</search-status-bar>
......@@ -33,15 +32,17 @@
</li>
<li id="{{vm.id}}"
class="annotation-card thread"
ng-class="{'js-hover': hasFocus(child.message)}"
deep-count="count"
thread="child" thread-filter
ng-include="'thread.html'"
ng-mouseenter="focus(child.message)"
ng-click="scrollTo(child.message)"
ng-class="{'js-hover': hasFocus(child.annotation)}"
ng-mouseenter="focus(child.annotation)"
ng-click="scrollTo(child.annotation)"
ng-mouseleave="focus()"
ng-repeat="child in threadRoot.children | orderBy : sort.predicate"
ng-show="vm.shouldShow()">
ng-repeat="child in rootThread().children track by child.id">
<annotation-thread
thread="child"
show-document-info="::!isSidebar"
on-change-collapsed="setCollapsed(id, collapsed)"
on-force-visible="forceVisible(thread)">
</annotation-thread>
</li>
<loggedout-message ng-if="isSidebar && shouldShowLoggedOutMessage()"
on-login="login()" ng-cloak>
......
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