Commit 2810d866 authored by Nick Stenning's avatar Nick Stenning

Merge pull request #3284 from hypothesis/316-annotation-thread-view

New threading 3/N - Render annotation threads using new threading infrastructure
parents 4219c4f3 ad776c90
...@@ -4,37 +4,26 @@ var angular = require('angular'); ...@@ -4,37 +4,26 @@ var angular = require('angular');
var events = require('./events'); var events = require('./events');
// Fetch the container object for the passed annotation from the threading function getExistingAnnotation(annotationUI, id) {
// service, but only return it if it has an associated message. return annotationUI.getState().annotations.find(function (annot) {
function getContainer(threading, annotation) { return annot.id === id;
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;
} }
// Wraps the annotation store to trigger events for the CRUD actions // Wraps the annotation store to trigger events for the CRUD actions
// @ngInject // @ngInject
function annotationMapper($rootScope, threading, store) { function annotationMapper($rootScope, annotationUI, store) {
function loadAnnotations(annotations, replies) { function loadAnnotations(annotations, replies) {
annotations = annotations.concat(replies || []); annotations = annotations.concat(replies || []);
var loaded = []; var loaded = [];
annotations.forEach(function (annotation) { annotations.forEach(function (annotation) {
var container = getContainer(threading, annotation); var existing = getExistingAnnotation(annotationUI, annotation.id);
if (container !== null) { if (existing) {
angular.copy(annotation, container.message); angular.copy(annotation, existing);
$rootScope.$emit(events.ANNOTATION_UPDATED, container.message); $rootScope.$emit(events.ANNOTATION_UPDATED, existing);
return; return;
} }
loaded.push(new store.AnnotationResource(annotation)); loaded.push(new store.AnnotationResource(annotation));
}); });
...@@ -43,9 +32,9 @@ function annotationMapper($rootScope, threading, store) { ...@@ -43,9 +32,9 @@ function annotationMapper($rootScope, threading, store) {
function unloadAnnotations(annotations) { function unloadAnnotations(annotations) {
var unloaded = annotations.map(function (annotation) { var unloaded = annotations.map(function (annotation) {
var container = getContainer(threading, annotation); var existing = getExistingAnnotation(annotationUI, annotation.id);
if (container !== null && annotation !== container.message) { if (existing && annotation !== existing) {
annotation = angular.copy(annotation, container.message); annotation = angular.copy(annotation, existing);
} }
return annotation; return annotation;
}); });
......
...@@ -49,6 +49,10 @@ function initialState(settings) { ...@@ -49,6 +49,10 @@ function initialState(settings) {
// Set of IDs of annotations that have been explicitly shown // Set of IDs of annotations that have been explicitly shown
// by the user even if they do not match the current search filter // by the user even if they do not match the current search filter
forceVisible: {}, forceVisible: {},
filterQuery: null,
sortMode: 'Location',
}); });
} }
...@@ -61,6 +65,8 @@ var types = { ...@@ -61,6 +65,8 @@ var types = {
ADD_ANNOTATIONS: 'ADD_ANNOTATIONS', ADD_ANNOTATIONS: 'ADD_ANNOTATIONS',
REMOVE_ANNOTATIONS: 'REMOVE_ANNOTATIONS', REMOVE_ANNOTATIONS: 'REMOVE_ANNOTATIONS',
CLEAR_ANNOTATIONS: 'CLEAR_ANNOTATIONS', CLEAR_ANNOTATIONS: 'CLEAR_ANNOTATIONS',
SET_FILTER_QUERY: 'SET_FILTER_QUERY',
SORT_BY: 'SORT_BY',
}; };
function excludeAnnotations(current, annotations) { function excludeAnnotations(current, annotations) {
...@@ -104,6 +110,14 @@ function reducer(state, action) { ...@@ -104,6 +110,14 @@ function reducer(state, action) {
return Object.assign({}, state, {forceVisible: action.forceVisible}); return Object.assign({}, state, {forceVisible: action.forceVisible});
case types.SET_EXPANDED: case types.SET_EXPANDED:
return Object.assign({}, state, {expanded: action.expanded}); return Object.assign({}, state, {expanded: action.expanded});
case types.SET_FILTER_QUERY:
return Object.assign({}, state, {
filterQuery: action.query,
forceVisible: {},
expanded: {},
});
case types.SORT_BY:
return Object.assign({}, state, {sortMode: action.mode});
default: default:
return state; return state;
} }
...@@ -204,17 +218,6 @@ module.exports = function (settings) { ...@@ -204,17 +218,6 @@ module.exports = function (settings) {
}); });
}, },
/**
* Clear the set of annotations which have been explicitly shown by
* setForceVisible()
*/
clearForceVisible: function () {
store.dispatch({
type: types.SET_FORCE_VISIBLE,
forceVisible: {},
});
},
/** /**
* Returns true if the annotation with the given `id` is selected. * Returns true if the annotation with the given `id` is selected.
*/ */
...@@ -289,5 +292,21 @@ module.exports = function (settings) { ...@@ -289,5 +292,21 @@ module.exports = function (settings) {
clearAnnotations: function () { clearAnnotations: function () {
store.dispatch({type: types.CLEAR_ANNOTATIONS}); store.dispatch({type: types.CLEAR_ANNOTATIONS});
}, },
/** Set the query used to filter displayed annotations. */
setFilterQuery: function (query) {
store.dispatch({
type: types.SET_FILTER_QUERY,
query: query,
});
},
/** Sets the sort mode for the annotation list. */
sortBy: function (mode) {
store.dispatch({
type: types.SORT_BY,
mode: mode,
});
},
}; };
}; };
...@@ -5,7 +5,7 @@ var angular = require('angular'); ...@@ -5,7 +5,7 @@ var angular = require('angular');
// @ngInject // @ngInject
function AnnotationViewerController ( function AnnotationViewerController (
$location, $routeParams, $scope, $location, $routeParams, $scope,
streamer, store, streamFilter, annotationMapper, threading annotationUI, rootThread, streamer, store, streamFilter, annotationMapper
) { ) {
var id = $routeParams.id; var id = $routeParams.id;
...@@ -17,9 +17,16 @@ function AnnotationViewerController ( ...@@ -17,9 +17,16 @@ function AnnotationViewerController (
$location.path('/stream').search('q', query); $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) { store.AnnotationResource.get({ id: id }, function (annotation) {
annotationMapper.loadAnnotations([annotation]); annotationMapper.loadAnnotations([annotation]);
$scope.threadRoot = { children: [threading.idTable[id]] };
}); });
store.SearchResource.get({ references: id }, function (data) { store.SearchResource.get({ references: id }, function (data) {
......
...@@ -25,7 +25,6 @@ if (settings.raven) { ...@@ -25,7 +25,6 @@ if (settings.raven) {
angular.module('ngRaven', []); angular.module('ngRaven', []);
} }
var mail = require('./vendor/jwz');
var streamer = require('./streamer'); var streamer = require('./streamer');
// Fetch external state that the app needs before it can run. This includes the // Fetch external state that the app needs before it can run. This includes the
...@@ -40,20 +39,6 @@ var resolve = { ...@@ -40,20 +39,6 @@ var resolve = {
return store.$promise; return store.$promise;
}, },
streamer: streamer.connect, 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 // @ngInject
...@@ -117,15 +102,6 @@ function processAppOpts() { ...@@ -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', [ module.exports = angular.module('h', [
// Angular addons which export the Angular module name // Angular addons which export the Angular module name
// via module.exports // via module.exports
...@@ -155,7 +131,7 @@ module.exports = angular.module('h', [ ...@@ -155,7 +131,7 @@ module.exports = angular.module('h', [
.directive('aboutThisVersionDialog', require('./directive/about-this-version-dialog')) .directive('aboutThisVersionDialog', require('./directive/about-this-version-dialog'))
.directive('annotation', require('./directive/annotation').directive) .directive('annotation', require('./directive/annotation').directive)
.directive('annotationShareDialog', require('./directive/annotation-share-dialog')) .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('dropdownMenuBtn', require('./directive/dropdown-menu-btn'))
.directive('excerpt', require('./directive/excerpt').directive) .directive('excerpt', require('./directive/excerpt').directive)
.directive('feedbackLink', require('./directive/feedback-link')) .directive('feedbackLink', require('./directive/feedback-link'))
...@@ -176,8 +152,6 @@ module.exports = angular.module('h', [ ...@@ -176,8 +152,6 @@ module.exports = angular.module('h', [
.directive('sortDropdown', require('./directive/sort-dropdown')) .directive('sortDropdown', require('./directive/sort-dropdown'))
.directive('spinner', require('./directive/spinner')) .directive('spinner', require('./directive/spinner'))
.directive('statusButton', require('./directive/status-button')) .directive('statusButton', require('./directive/status-button'))
.directive('thread', require('./directive/thread'))
.directive('threadFilter', require('./directive/thread-filter'))
.directive('topBar', require('./directive/top-bar')) .directive('topBar', require('./directive/top-bar'))
.directive('windowScroll', require('./directive/window-scroll')) .directive('windowScroll', require('./directive/window-scroll'))
...@@ -195,12 +169,11 @@ module.exports = angular.module('h', [ ...@@ -195,12 +169,11 @@ module.exports = angular.module('h', [
.service('localStorage', require('./local-storage')) .service('localStorage', require('./local-storage'))
.service('permissions', require('./permissions')) .service('permissions', require('./permissions'))
.service('queryParser', require('./query-parser')) .service('queryParser', require('./query-parser'))
.service('render', require('./render')) .service('rootThread', require('./root-thread'))
.service('searchFilter', require('./search-filter')) .service('searchFilter', require('./search-filter'))
.service('session', require('./session')) .service('session', require('./session'))
.service('streamFilter', require('./stream-filter')) .service('streamFilter', require('./stream-filter'))
.service('tags', require('./tags')) .service('tags', require('./tags'))
.service('threading', require('./threading'))
.service('unicode', require('./unicode')) .service('unicode', require('./unicode'))
.service('viewFilter', require('./view-filter')) .service('viewFilter', require('./view-filter'))
...@@ -219,7 +192,6 @@ module.exports = angular.module('h', [ ...@@ -219,7 +192,6 @@ module.exports = angular.module('h', [
.config(configureRoutes) .config(configureRoutes)
.run(setupCrossFrame) .run(setupCrossFrame)
.run(setupHttp) .run(setupHttp);
.run(setupTemplateCache);
processAppOpts(); processAppOpts();
...@@ -210,14 +210,6 @@ function hasVisibleChildren(thread) { ...@@ -210,14 +210,6 @@ function hasVisibleChildren(thread) {
}); });
} }
function hasSelectedChildren(thread, selected) {
return thread.children.some(function (child) {
return selected.indexOf(child.id) !== -1 ||
hasSelectedChildren(child, selected);
});
}
/** /**
* Default options for buildThread() * Default options for buildThread()
*/ */
...@@ -279,16 +271,22 @@ function buildThread(annotations, opts) { ...@@ -279,16 +271,22 @@ function buildThread(annotations, opts) {
if (opts.forceVisible && opts.forceVisible.indexOf(id(annotation)) !== -1) { if (opts.forceVisible && opts.forceVisible.indexOf(id(annotation)) !== -1) {
return true; return true;
} }
if (opts.selected.length > 0 &&
opts.selected.indexOf(id(annotation)) === -1) {
return false;
}
if (opts.filterFn && !opts.filterFn(annotation)) { if (opts.filterFn && !opts.filterFn(annotation)) {
return false; return false;
} }
return true; return true;
}; };
// When there is a selection, only include top-level threads (annotations)
// that are selected
if (opts.selected.length > 0) {
thread = Object.assign({}, thread, {
children: thread.children.filter(function (child) {
return opts.selected.indexOf(child.id) !== -1;
}),
});
}
// Set the visibility of threads based on whether they match // Set the visibility of threads based on whether they match
// the current search filter // the current search filter
thread = mapThread(thread, function (thread) { thread = mapThread(thread, function (thread) {
...@@ -301,8 +299,7 @@ function buildThread(annotations, opts) { ...@@ -301,8 +299,7 @@ function buildThread(annotations, opts) {
// Expand any threads which: // Expand any threads which:
// 1) Have been explicitly expanded OR // 1) Have been explicitly expanded OR
// 2) Have children matching the filter OR // 2) Have children matching the filter
// 3) Contain children which have been selected
thread = mapThread(thread, function (thread) { thread = mapThread(thread, function (thread) {
var id = thread.id; var id = thread.id;
...@@ -315,8 +312,7 @@ function buildThread(annotations, opts) { ...@@ -315,8 +312,7 @@ function buildThread(annotations, opts) {
return Object.assign({}, thread, { return Object.assign({}, thread, {
collapsed: thread.collapsed && collapsed: thread.collapsed &&
!hasUnfilteredChildren && !hasUnfilteredChildren,
!hasSelectedChildren(thread, opts.selected)
}); });
}); });
......
'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 visibleCount(thread) {
var isVisible = thread.annotation && thread.visible;
return thread.children.reduce(function (count, reply) {
return count + visibleCount(reply);
}, isVisible ? 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);
};
this.shouldShowReply = function (child) {
return visibleCount(child) > 0;
};
}
/**
* 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( ...@@ -721,51 +721,7 @@ function AnnotationController(
function link(scope, elem, attrs, controllers) { function link(scope, elem, attrs, controllers) {
var ctrl = controllers[0]; var ctrl = controllers[0];
var thread = controllers[1];
var threadFilter = controllers[2];
var counter = controllers[3];
elem.on('keydown', ctrl.onKeydown); 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() { ...@@ -785,12 +741,12 @@ function annotation() {
controller: AnnotationController, controller: AnnotationController,
controllerAs: 'vm', controllerAs: 'vm',
link: link, link: link,
require: ['annotation', '?^thread', '?^threadFilter', '?^deepCount'], require: ['annotation'],
scope: { scope: {
annotation: '<', annotation: '<',
// Indicates whether this is the last reply in a thread. // Indicates whether this is the last reply in a thread.
isLastReply: '<', isLastReply: '<',
isSidebar: '<', showDocumentInfo: '<',
onReplyCountClick: '&', onReplyCountClick: '&',
replyCount: '<', replyCount: '<',
isCollapsed: '<' isCollapsed: '<'
......
...@@ -182,104 +182,6 @@ describe('annotation', function() { ...@@ -182,104 +182,6 @@ describe('annotation', function() {
'keydown', mockAnnotationController.onKeydown) '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() { 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.visibleReplies = function () {
return Array.from(element[0].querySelectorAll('.thread:not(.ng-hide)'));
};
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,
}],
visible: true,
},
});
var pageObject = new PageObject(element);
assert.equal(pageObject.annotations().length, 2);
assert.equal(pageObject.visibleReplies().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.isFalse(pageObject.isHidden(pageObject.replyList()));
});
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()));
});
it('only shows replies that match the search filter', function () {
var element = util.createDirective(document, 'annotationThread', {
thread: {
id: '1',
annotation: {id: '1'},
visible: true,
children: [{
id: '2',
annotation: {id: '2'},
children: [],
visible: false,
},{
id: '3',
annotation: {id: '3'},
children: [],
visible: true,
}],
collapsed: false,
}
});
var pageObject = new PageObject(element);
assert.equal(pageObject.visibleReplies().length, 1);
});
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]);
});
});
});
...@@ -46,6 +46,7 @@ module.exports = function(config) { ...@@ -46,6 +46,7 @@ module.exports = function(config) {
'./test/bootstrap.js': ['browserify'], './test/bootstrap.js': ['browserify'],
'**/*-test.js': ['browserify'], '**/*-test.js': ['browserify'],
'**/*-test.coffee': ['browserify'], '**/*-test.coffee': ['browserify'],
'**/*-it.js': ['browserify'],
}, },
browserify: { browserify: {
......
...@@ -27,11 +27,12 @@ var sortFns = { ...@@ -27,11 +27,12 @@ var sortFns = {
/** /**
* Root conversation thread for the sidebar and stream. * Root conversation thread for the sidebar and stream.
* *
* Listens for annotations being loaded, created and unloaded and * This performs two functions:
* builds a conversation thread.
* *
* The thread is sorted and filtered according to * 1. It listens for annotations being loaded, created and unloaded and
* current sort and filter settings. * dispatches annotationUI.{addAnnotations|removeAnnotations} actions.
* 2. Listens for changes in the UI state and rebuilds the root conversation
* thread.
* *
* The root thread is then displayed by viewer.html * The root thread is then displayed by viewer.html
*/ */
...@@ -39,24 +40,23 @@ var sortFns = { ...@@ -39,24 +40,23 @@ var sortFns = {
module.exports = function ($rootScope, annotationUI, searchFilter, viewFilter) { module.exports = function ($rootScope, annotationUI, searchFilter, viewFilter) {
var thread; var thread;
var sortFn = sortFns.Location;
var searchQuery;
/** /**
* Rebuild the root conversation thread. This should be called * Rebuild the root conversation thread. This should be called
* whenever the set of annotations to render or the sort/search/filter * whenever the set of annotations to render or the sort/search/filter
* settings change. * settings change.
*/ */
function rebuildRootThread() { function rebuildRootThread() {
var sortFn = sortFns[annotationUI.getState().sortMode];
var filters; var filters;
if (searchQuery) { var filterQuery = annotationUI.getState().filterQuery;
// TODO - Only regenerate the filter function when the search
// query changes if (filterQuery) {
filters = searchFilter.generateFacetedFilter(searchQuery); filters = searchFilter.generateFacetedFilter(filterQuery);
} }
var filterFn; var filterFn;
if (searchQuery) { if (filterQuery) {
filterFn = function (annot) { filterFn = function (annot) {
return viewFilter.filter([annot], filters).length > 0; return viewFilter.filter([annot], filters).length > 0;
}; };
...@@ -77,7 +77,10 @@ module.exports = function ($rootScope, annotationUI, searchFilter, viewFilter) { ...@@ -77,7 +77,10 @@ module.exports = function ($rootScope, annotationUI, searchFilter, viewFilter) {
annotationUI.subscribe(rebuildRootThread); annotationUI.subscribe(rebuildRootThread);
// Listen for annotations being created or loaded // Listen for annotations being created or loaded
// and show them in the UI // and show them in the UI.
//
// Note: These events could all be converted into actions that are handled by
// the Redux store in annotationUI.
var loadEvents = [events.BEFORE_ANNOTATION_CREATED, var loadEvents = [events.BEFORE_ANNOTATION_CREATED,
events.ANNOTATION_CREATED, events.ANNOTATION_CREATED,
events.ANNOTATIONS_LOADED]; events.ANNOTATIONS_LOADED];
...@@ -123,27 +126,5 @@ module.exports = function ($rootScope, annotationUI, searchFilter, viewFilter) { ...@@ -123,27 +126,5 @@ module.exports = function ($rootScope, annotationUI, searchFilter, viewFilter) {
thread: function () { thread: function () {
return thread; return thread;
}, },
/**
* Set the sort order for annotations.
* @param {'Location'|'Newest'|'Oldest'} mode
*/
sortBy: function (mode) {
if (!sortFns[mode]) {
throw new Error('Unknown sort mode: ' + mode);
}
sortFn = sortFns[mode];
rebuildRootThread();
},
/**
* Set the query to use when filtering annotations.
* @param {string} query - The filter query
*/
setSearchQuery: function (query) {
searchQuery = query;
annotationUI.clearForceVisible();
rebuildRootThread();
},
}; };
}; };
angular = require('angular') angular = require('angular')
mail = require('./vendor/jwz')
module.exports = class StreamController module.exports = class StreamController
this.$inject = [ this.$inject = [
'$scope', '$route', '$rootScope', '$routeParams', '$scope', '$route', '$rootScope', '$routeParams',
'queryParser', 'searchFilter', 'store', 'annotationUI',
'streamer', 'streamFilter', 'threading', 'annotationMapper' 'queryParser', 'rootThread', 'searchFilter', 'store',
'streamer', 'streamFilter', 'annotationMapper'
] ]
constructor: ( constructor: (
$scope, $route, $rootScope, $routeParams $scope, $route, $rootScope, $routeParams
queryParser, searchFilter, store, annotationUI,
streamer, streamFilter, threading, annotationMapper queryParser, rootThread, searchFilter, store,
streamer, streamFilter, annotationMapper
) -> ) ->
offset = 0 offset = 0
...@@ -26,16 +26,11 @@ module.exports = class StreamController ...@@ -26,16 +26,11 @@ module.exports = class StreamController
offset += rows.length offset += rows.length
annotationMapper.loadAnnotations(rows, replies) 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) # Reload on query change (ignore hash change)
lastQuery = $routeParams.q lastQuery = $routeParams.q
$scope.$on '$routeUpdate', -> $scope.$on '$routeUpdate', ->
if $routeParams.q isnt lastQuery if $routeParams.q isnt lastQuery
annotationUI.clearAnnotations()
$route.reload() $route.reload()
# Initialize the base filter # Initialize the base filter
...@@ -51,8 +46,19 @@ module.exports = class StreamController ...@@ -51,8 +46,19 @@ module.exports = class StreamController
# Perform the initial search # Perform the initial search
fetch(20) fetch(20)
$scope.$watch('sort.name', (name) ->
annotationUI.sortBy(name)
)
$scope.setCollapsed = (id, collapsed) ->
annotationUI.setCollapsed(id, collapsed)
$scope.forceVisible = (id) ->
annotationUI.setForceVisible(id, true)
$scope.isStream = true $scope.isStream = true
$scope.sortOptions = ['Newest', 'Oldest'] $scope.sortOptions = ['Newest', 'Oldest']
$scope.sort.name = 'Newest' $scope.sort.name = 'Newest'
$scope.threadRoot = threading.root $scope.rootThread = ->
return rootThread.thread()
$scope.loadMore = fetch $scope.loadMore = fetch
...@@ -2,37 +2,32 @@ ...@@ -2,37 +2,32 @@
var angular = require('angular'); var angular = require('angular');
var annotationUIFactory = require('../annotation-ui');
var events = require('../events'); var events = require('../events');
describe('annotationMapper', function() { describe('annotationMapper', function() {
var sandbox = sinon.sandbox.create(); var sandbox = sinon.sandbox.create();
var $rootScope; var $rootScope;
var annotationUI;
var fakeStore; var fakeStore;
var fakeThreading;
var annotationMapper; var annotationMapper;
before(function () { beforeEach(function () {
angular.module('h', [])
.service('annotationMapper', require('../annotation-mapper'));
});
beforeEach(angular.mock.module('h'));
beforeEach(angular.mock.module(function ($provide) {
fakeStore = { fakeStore = {
AnnotationResource: sandbox.stub().returns({}) AnnotationResource: sandbox.stub().returns({}),
}; };
fakeThreading = { annotationUI = annotationUIFactory({});
idTable: {} angular.module('app', [])
}; .service('annotationMapper', require('../annotation-mapper'))
.value('annotationUI', annotationUI)
$provide.value('store', fakeStore); .value('store', fakeStore);
$provide.value('threading', fakeThreading); angular.mock.module('app');
}));
angular.mock.inject(function (_$rootScope_, _annotationMapper_) {
beforeEach(angular.mock.inject(function (_annotationMapper_, _$rootScope_) { $rootScope = _$rootScope_;
$rootScope = _$rootScope_; annotationMapper = _annotationMapper_;
annotationMapper = _annotationMapper_; });
})); });
afterEach(function () { afterEach(function () {
sandbox.restore(); sandbox.restore();
...@@ -58,24 +53,22 @@ describe('annotationMapper', function() { ...@@ -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'); sandbox.stub($rootScope, '$emit');
var annotations = [{id: 1}, {id: 2}, {id: 3}]; var annotations = [{id: 1}, {id: 2}, {id: 3}];
var cached = {message: {id: 1, $$tag: 'tag1'}}; annotationUI.addAnnotations(angular.copy(annotations));
fakeThreading.idTable[1] = cached;
annotationMapper.loadAnnotations(annotations); annotationMapper.loadAnnotations(annotations);
assert.called($rootScope.$emit); assert.called($rootScope.$emit);
assert.calledWith($rootScope.$emit, events.ANNOTATION_UPDATED, assert.calledWith($rootScope.$emit, events.ANNOTATION_UPDATED,
cached.message); annotations[0]);
}); });
it('also triggers annotationUpdated for cached replies', function () { it('also triggers annotationUpdated for cached replies', function () {
sandbox.stub($rootScope, '$emit'); sandbox.stub($rootScope, '$emit');
var annotations = [{id: 1}]; var annotations = [{id: 1}];
var replies = [{id: 2}, {id: 3}, {id: 4}]; var replies = [{id: 2}, {id: 3}, {id: 4}];
var cached = {message: {id: 3, $$tag: 'tag3'}}; annotationUI.addAnnotations([{id:3}]);
fakeThreading.idTable[3] = cached;
annotationMapper.loadAnnotations(annotations, replies); annotationMapper.loadAnnotations(annotations, replies);
assert($rootScope.$emit.calledWith(events.ANNOTATION_UPDATED, assert($rootScope.$emit.calledWith(events.ANNOTATION_UPDATED,
...@@ -85,8 +78,7 @@ describe('annotationMapper', function() { ...@@ -85,8 +78,7 @@ describe('annotationMapper', function() {
it('replaces the properties on the cached annotation with those from the loaded one', function () { it('replaces the properties on the cached annotation with those from the loaded one', function () {
sandbox.stub($rootScope, '$emit'); sandbox.stub($rootScope, '$emit');
var annotations = [{id: 1, url: 'http://example.com'}]; var annotations = [{id: 1, url: 'http://example.com'}];
var cached = {message: {id: 1, $$tag: 'tag1'}}; annotationUI.addAnnotations([{id:1, $$tag: 'tag1'}]);
fakeThreading.idTable[1] = cached;
annotationMapper.loadAnnotations(annotations); annotationMapper.loadAnnotations(annotations);
assert.called($rootScope.$emit); assert.called($rootScope.$emit);
...@@ -99,8 +91,7 @@ describe('annotationMapper', function() { ...@@ -99,8 +91,7 @@ describe('annotationMapper', function() {
it('excludes cached annotations from the annotationLoaded event', function () { it('excludes cached annotations from the annotationLoaded event', function () {
sandbox.stub($rootScope, '$emit'); sandbox.stub($rootScope, '$emit');
var annotations = [{id: 1, url: 'http://example.com'}]; var annotations = [{id: 1, url: 'http://example.com'}];
var cached = {message: {id: 1, $$tag: 'tag1'}}; annotationUI.addAnnotations([{id: 1, $$tag: 'tag1'}]);
fakeThreading.idTable[1] = cached;
annotationMapper.loadAnnotations(annotations); annotationMapper.loadAnnotations(annotations);
assert.called($rootScope.$emit); assert.called($rootScope.$emit);
...@@ -120,8 +111,7 @@ describe('annotationMapper', function() { ...@@ -120,8 +111,7 @@ describe('annotationMapper', function() {
it('replaces the properties on the cached annotation with those from the deleted one', function () { it('replaces the properties on the cached annotation with those from the deleted one', function () {
sandbox.stub($rootScope, '$emit'); sandbox.stub($rootScope, '$emit');
var annotations = [{id: 1, url: 'http://example.com'}]; var annotations = [{id: 1, url: 'http://example.com'}];
var cached = {message: {id: 1, $$tag: 'tag1'}}; annotationUI.addAnnotations([{id: 1, $$tag: 'tag1'}]);
fakeThreading.idTable[1] = cached;
annotationMapper.unloadAnnotations(annotations); annotationMapper.unloadAnnotations(annotations);
assert.calledWith($rootScope.$emit, events.ANNOTATIONS_UNLOADED, [{ assert.calledWith($rootScope.$emit, events.ANNOTATIONS_UNLOADED, [{
......
...@@ -78,14 +78,6 @@ describe('annotationUI', function () { ...@@ -78,14 +78,6 @@ describe('annotationUI', function () {
}); });
}); });
describe('#clearForceVisible()', function () {
it('clears the forceVisible set', function () {
annotationUI.setForceVisible('id1', true);
annotationUI.clearForceVisible();
assert.deepEqual(annotationUI.getState().forceVisible, {});
});
});
describe('#setCollapsed()', function () { describe('#setCollapsed()', function () {
it('sets the expanded state of the annotation', function () { it('sets the expanded state of the annotation', function () {
annotationUI.setCollapsed('parent_id', false); annotationUI.setCollapsed('parent_id', false);
...@@ -211,4 +203,19 @@ describe('annotationUI', function () { ...@@ -211,4 +203,19 @@ describe('annotationUI', function () {
assert.isNull(annotationUI.getState().selectedAnnotationMap); assert.isNull(annotationUI.getState().selectedAnnotationMap);
}); });
}); });
describe('#setFilterQuery()', function () {
it('sets the filter query', function () {
annotationUI.setFilterQuery('a-query');
assert.equal(annotationUI.getState().filterQuery, 'a-query');
});
it('resets the force-visible and expanded sets', function () {
annotationUI.setForceVisible('123', true);
annotationUI.setCollapsed('456', false);
annotationUI.setFilterQuery('some-query');
assert.deepEqual(annotationUI.getState().forceVisible, {});
assert.deepEqual(annotationUI.getState().expanded, {});
});
});
}); });
...@@ -28,9 +28,10 @@ describe('AnnotationViewerController', function () { ...@@ -28,9 +28,10 @@ describe('AnnotationViewerController', function () {
$routeParams: opts.$routeParams || { id: 'test_annotation_id' }, $routeParams: opts.$routeParams || { id: 'test_annotation_id' },
$scope: opts.$scope || { $scope: opts.$scope || {
search: {}, search: {},
threading: { },
getContainer: function () {} annotationUI: {},
} rootThread: {
thread: sinon.stub(),
}, },
streamer: opts.streamer || { setConfig: function () {} }, streamer: opts.streamer || { setConfig: function () {} },
store: opts.store || { store: opts.store || {
...@@ -50,7 +51,6 @@ describe('AnnotationViewerController', function () { ...@@ -50,7 +51,6 @@ describe('AnnotationViewerController', function () {
getFilter: function () {} getFilter: function () {}
}, },
annotationMapper: opts.annotationMapper || { loadAnnotations: sinon.spy() }, annotationMapper: opts.annotationMapper || { loadAnnotations: sinon.spy() },
threading: opts.threading || { idTable: {} }
}; };
locals.ctrl = getControllerService()( locals.ctrl = getControllerService()(
'AnnotationViewerController', locals); 'AnnotationViewerController', locals);
......
...@@ -201,11 +201,6 @@ describe('build-thread', function () { ...@@ -201,11 +201,6 @@ describe('build-thread', function () {
assert.isTrue(thread.children[0].children[0].collapsed); assert.isTrue(thread.children[0].children[0].collapsed);
}); });
it('expands threads with selected children', function () {
var thread = buildThread(SIMPLE_FIXTURE, {selected: ['3']});
assert.isFalse(thread.children[0].collapsed);
});
it('expands threads with visible children', function () { it('expands threads with visible children', function () {
// Simulate performing a search which only matches the top-level // Simulate performing a search which only matches the top-level
// annotation, not its reply, and then clicking // annotation, not its reply, and then clicking
......
...@@ -63,14 +63,14 @@ describe('annotation threading', function () { ...@@ -63,14 +63,14 @@ describe('annotation threading', function () {
it('should filter annotations when a search is set', function () { it('should filter annotations when a search is set', function () {
annotationUI.addAnnotations(fixtures.annotations); annotationUI.addAnnotations(fixtures.annotations);
rootThread.setSearchQuery('second'); annotationUI.setFilterQuery('second');
assert.equal(rootThread.thread().children.length, 1); assert.equal(rootThread.thread().children.length, 1);
assert.equal(rootThread.thread().children[0].id, '2'); assert.equal(rootThread.thread().children[0].id, '2');
}); });
unroll('should sort annotations by #mode', function (testCase) { unroll('should sort annotations by #mode', function (testCase) {
annotationUI.addAnnotations(fixtures.annotations); annotationUI.addAnnotations(fixtures.annotations);
rootThread.sortBy(testCase.mode); annotationUI.sortBy(testCase.mode);
var actualOrder = rootThread.thread().children.map(function (thread) { var actualOrder = rootThread.thread().children.map(function (thread) {
return thread.annotation.id; return thread.annotation.id;
}); });
......
...@@ -36,6 +36,8 @@ describe('rootThread', function () { ...@@ -36,6 +36,8 @@ describe('rootThread', function () {
selectedAnnotationMap: null, selectedAnnotationMap: null,
expanded: {}, expanded: {},
forceVisible: {}, forceVisible: {},
filterQuery: null,
sortMode: 'Location',
}, },
getState: function () { getState: function () {
...@@ -46,7 +48,6 @@ describe('rootThread', function () { ...@@ -46,7 +48,6 @@ describe('rootThread', function () {
removeSelectedAnnotation: sinon.stub(), removeSelectedAnnotation: sinon.stub(),
addAnnotations: sinon.stub(), addAnnotations: sinon.stub(),
setCollapsed: sinon.stub(), setCollapsed: sinon.stub(),
clearForceVisible: sinon.stub(),
}; };
fakeBuildThread = sinon.stub().returns(fixtures.emptyThread); fakeBuildThread = sinon.stub().returns(fixtures.emptyThread);
...@@ -146,13 +147,7 @@ describe('rootThread', function () { ...@@ -146,13 +147,7 @@ describe('rootThread', function () {
}); });
}); });
describe('#sortBy', function () { describe('when the sort order changes', function () {
it('rebuilds the thread when the sort order changes', function () {
assertRebuildsThread(function () {
rootThread.sortBy('Newest');
});
});
function sortBy(annotations, sortCompareFn) { function sortBy(annotations, sortCompareFn) {
return annotations.slice().sort(function (a,b) { return annotations.slice().sort(function (a,b) {
return sortCompareFn(a,b) ? -1 : sortCompareFn(b,a) ? 1 : 0; return sortCompareFn(a,b) ? -1 : sortCompareFn(b,a) ? 1 : 0;
...@@ -181,7 +176,10 @@ describe('rootThread', function () { ...@@ -181,7 +176,10 @@ describe('rootThread', function () {
}]; }];
fakeBuildThread.reset(); fakeBuildThread.reset();
rootThread.sortBy(testCase.order); fakeAnnotationUI.state = Object.assign({}, fakeAnnotationUI.state, {
sortMode: testCase.order,
});
rootThread.rebuild();
var sortCompareFn = fakeBuildThread.args[0][1].sortCompareFn; var sortCompareFn = fakeBuildThread.args[0][1].sortCompareFn;
var actualOrder = sortBy(annotations, sortCompareFn).map(function (annot) { var actualOrder = sortBy(annotations, sortCompareFn).map(function (annot) {
return annotations.indexOf(annot); return annotations.indexOf(annot);
...@@ -194,19 +192,15 @@ describe('rootThread', function () { ...@@ -194,19 +192,15 @@ describe('rootThread', function () {
]); ]);
}); });
describe('#setSearchQuery', function () { describe('when the filter query changes', function () {
it('rebuilds the thread when the search query changes', function () { it('generates a thread filter function from the query', function () {
assertRebuildsThread(function () {
rootThread.setSearchQuery('new query');
});
});
it('generates a thread filter function from the search query', function () {
fakeBuildThread.reset(); fakeBuildThread.reset();
var filters = [{any: {terms: ['queryterm']}}]; var filters = [{any: {terms: ['queryterm']}}];
var annotation = annotationFixtures.defaultAnnotation(); var annotation = annotationFixtures.defaultAnnotation();
fakeSearchFilter.generateFacetedFilter.returns(filters); fakeSearchFilter.generateFacetedFilter.returns(filters);
rootThread.setSearchQuery('queryterm'); fakeAnnotationUI.state = Object.assign({}, fakeAnnotationUI.state,
{filterQuery: 'queryterm'});
rootThread.rebuild();
var filterFn = fakeBuildThread.args[0][1].filterFn; var filterFn = fakeBuildThread.args[0][1].filterFn;
fakeViewFilter.filter.returns([annotation]); fakeViewFilter.filter.returns([annotation]);
...@@ -214,11 +208,6 @@ describe('rootThread', function () { ...@@ -214,11 +208,6 @@ describe('rootThread', function () {
assert.calledWith(fakeViewFilter.filter, sinon.match([annotation]), assert.calledWith(fakeViewFilter.filter, sinon.match([annotation]),
filters); filters);
}); });
it('clears the set of explicitly shown conversations', function () {
rootThread.setSearchQuery('new query');
assert.called(fakeAnnotationUI.clearForceVisible);
});
}); });
context('when annotation events occur', function () { context('when annotation events occur', function () {
......
...@@ -4,6 +4,7 @@ describe 'StreamController', -> ...@@ -4,6 +4,7 @@ describe 'StreamController', ->
$controller = null $controller = null
$scope = null $scope = null
fakeAnnotationMapper = null fakeAnnotationMapper = null
fakeAnnotationUI = null
fakeParams = null fakeParams = null
fakeQueryParser = null fakeQueryParser = null
fakeRoute = null fakeRoute = null
...@@ -30,6 +31,12 @@ describe 'StreamController', -> ...@@ -30,6 +31,12 @@ describe 'StreamController', ->
loadAnnotations: sandbox.spy() loadAnnotations: sandbox.spy()
} }
fakeAnnotationUI = {
clearAnnotations: sandbox.spy()
setCollapsed: sandbox.spy()
setForceVisible: sandbox.spy()
}
fakeParams = {id: 'test'} fakeParams = {id: 'test'}
fakeQueryParser = { fakeQueryParser = {
...@@ -63,19 +70,20 @@ describe 'StreamController', -> ...@@ -63,19 +70,20 @@ describe 'StreamController', ->
getFilter: sandbox.stub() getFilter: sandbox.stub()
} }
fakeThreading = { fakeRootThread = {
root: {} thread: sandbox.stub()
} }
$provide.value 'annotationMapper', fakeAnnotationMapper $provide.value 'annotationMapper', fakeAnnotationMapper
$provide.value 'annotationUI', fakeAnnotationUI
$provide.value '$route', fakeRoute $provide.value '$route', fakeRoute
$provide.value '$routeParams', fakeParams $provide.value '$routeParams', fakeParams
$provide.value 'queryParser', fakeQueryParser $provide.value 'queryParser', fakeQueryParser
$provide.value 'rootThread', fakeRootThread
$provide.value 'searchFilter', fakeSearchFilter $provide.value 'searchFilter', fakeSearchFilter
$provide.value 'store', fakeStore $provide.value 'store', fakeStore
$provide.value 'streamer', fakeStreamer $provide.value 'streamer', fakeStreamer
$provide.value 'streamFilter', fakeStreamFilter $provide.value 'streamFilter', fakeStreamFilter
$provide.value 'threading', fakeThreading
return return
beforeEach inject (_$controller_, $rootScope) -> beforeEach inject (_$controller_, $rootScope) ->
...@@ -106,17 +114,6 @@ describe 'StreamController', -> ...@@ -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', -> describe 'on $routeUpdate', ->
it 'reloads the route when the query changes', -> it 'reloads the route when the query changes', ->
...@@ -124,6 +121,7 @@ describe 'StreamController', -> ...@@ -124,6 +121,7 @@ describe 'StreamController', ->
createController() createController()
fakeParams.q = 'new query' fakeParams.q = 'new query'
$scope.$broadcast('$routeUpdate') $scope.$broadcast('$routeUpdate')
assert.called(fakeAnnotationUI.clearAnnotations)
assert.calledOnce(fakeRoute.reload) assert.calledOnce(fakeRoute.reload)
it 'does not reload the route when the query is the same', -> it 'does not reload the route when the query is the same', ->
......
...@@ -27,20 +27,20 @@ function FakeSearchClient(resource, opts) { ...@@ -27,20 +27,20 @@ function FakeSearchClient(resource, opts) {
inherits(FakeSearchClient, EventEmitter); inherits(FakeSearchClient, EventEmitter);
describe('WidgetController', function () { describe('WidgetController', function () {
var $scope = null; var $rootScope;
var $rootScope = null; var $scope;
var annotationUI = null; var annotationUI;
var fakeAnnotationMapper = null; var fakeAnnotationMapper;
var fakeCrossFrame = null; var fakeCrossFrame;
var fakeDrafts = null; var fakeDrafts;
var fakeStore = null; var fakeGroups;
var fakeStreamer = null; var fakeRootThread;
var fakeStreamFilter = null; var fakeSettings;
var fakeThreading = null; var fakeStore;
var fakeGroups = null; var fakeStreamer;
var sandbox = null; var fakeStreamFilter;
var viewer = null; var sandbox;
var fakeSettings = null; var viewer;
before(function () { before(function () {
angular.module('h', []) angular.module('h', [])
...@@ -64,12 +64,13 @@ describe('WidgetController', function () { ...@@ -64,12 +64,13 @@ describe('WidgetController', function () {
}; };
annotationUI = annotationUIFactory({}); annotationUI = annotationUIFactory({});
fakeCrossFrame = { fakeCrossFrame = {
call: sinon.stub(), call: sinon.stub(),
frames: [], frames: [],
}; };
fakeDrafts = { fakeDrafts = {
unsaved: sandbox.stub() unsaved: sandbox.stub().returns([]),
}; };
fakeStreamer = { fakeStreamer = {
...@@ -82,19 +83,19 @@ describe('WidgetController', function () { ...@@ -82,19 +83,19 @@ describe('WidgetController', function () {
getFilter: sandbox.stub().returns({}) getFilter: sandbox.stub().returns({})
}; };
fakeThreading = {
root: {},
thread: sandbox.stub(),
annotationList: function () {
return [{id: '123'}];
},
};
fakeGroups = { fakeGroups = {
focused: function () { return {id: 'foo'}; }, focused: function () { return {id: 'foo'}; },
focus: sinon.stub(), focus: sinon.stub(),
}; };
fakeRootThread = {
thread: sinon.stub().returns({
totalChildren: 0,
}),
setSearchQuery: sinon.stub(),
sortBy: sinon.stub(),
};
fakeSettings = { fakeSettings = {
annotations: 'test', annotations: 'test',
}; };
...@@ -107,10 +108,10 @@ describe('WidgetController', function () { ...@@ -107,10 +108,10 @@ describe('WidgetController', function () {
$provide.value('annotationUI', annotationUI); $provide.value('annotationUI', annotationUI);
$provide.value('crossframe', fakeCrossFrame); $provide.value('crossframe', fakeCrossFrame);
$provide.value('drafts', fakeDrafts); $provide.value('drafts', fakeDrafts);
$provide.value('rootThread', fakeRootThread);
$provide.value('store', fakeStore); $provide.value('store', fakeStore);
$provide.value('streamer', fakeStreamer); $provide.value('streamer', fakeStreamer);
$provide.value('streamFilter', fakeStreamFilter); $provide.value('streamFilter', fakeStreamFilter);
$provide.value('threading', fakeThreading);
$provide.value('groups', fakeGroups); $provide.value('groups', fakeGroups);
$provide.value('settings', fakeSettings); $provide.value('settings', fakeSettings);
})); }));
...@@ -129,13 +130,14 @@ describe('WidgetController', function () { ...@@ -129,13 +130,14 @@ describe('WidgetController', function () {
it('unloads any existing annotations', function () { it('unloads any existing annotations', function () {
// When new clients connect, all existing annotations should be unloaded // When new clients connect, all existing annotations should be unloaded
// before reloading annotations for each currently-connected client // before reloading annotations for each currently-connected client
annotationUI.addAnnotations([{id: '123'}]);
fakeCrossFrame.frames.push({uri: 'http://example.com/page-a'}); fakeCrossFrame.frames.push({uri: 'http://example.com/page-a'});
$scope.$digest(); $scope.$digest();
fakeAnnotationMapper.unloadAnnotations = sandbox.spy(); fakeAnnotationMapper.unloadAnnotations = sandbox.spy();
fakeCrossFrame.frames.push({uri: 'http://example.com/page-b'}); fakeCrossFrame.frames.push({uri: 'http://example.com/page-b'});
$scope.$digest(); $scope.$digest();
assert.calledWith(fakeAnnotationMapper.unloadAnnotations, assert.calledWith(fakeAnnotationMapper.unloadAnnotations,
fakeThreading.annotationList()); annotationUI.getState().annotations);
}); });
it('loads all annotations for a frame', function () { it('loads all annotations for a frame', function () {
...@@ -170,6 +172,10 @@ describe('WidgetController', function () { ...@@ -170,6 +172,10 @@ describe('WidgetController', function () {
$scope.$digest(); $scope.$digest();
}); });
it('selectedAnnotationCount is > 0', function () {
assert.equal($scope.selectedAnnotationCount(), 1);
});
it('switches to the selected annotation\'s group', function () { it('switches to the selected annotation\'s group', function () {
assert.calledWith(fakeGroups.focus, '__world__'); assert.calledWith(fakeGroups.focus, '__world__');
assert.calledOnce(fakeAnnotationMapper.loadAnnotations); assert.calledOnce(fakeAnnotationMapper.loadAnnotations);
...@@ -196,6 +202,10 @@ describe('WidgetController', function () { ...@@ -196,6 +202,10 @@ describe('WidgetController', function () {
$scope.$digest(); $scope.$digest();
}); });
it('selectedAnnotationCount is 0', function () {
assert.equal($scope.selectedAnnotationCount(), 0);
});
it('fetches annotations for the current group', function () { it('fetches annotations for the current group', function () {
assert.calledWith(searchClients[0].get, {uri: uri, group: 'a-group'}); assert.calledWith(searchClients[0].get, {uri: uri, group: 'a-group'});
}); });
...@@ -233,11 +243,7 @@ describe('WidgetController', function () { ...@@ -233,11 +243,7 @@ describe('WidgetController', function () {
$$tag: 'atag', $$tag: 'atag',
id: '123', id: '123',
}; };
fakeThreading.idTable = { annotationUI.addAnnotations([annot]);
'123': {
message: annot,
},
};
$scope.$digest(); $scope.$digest();
$rootScope.$broadcast(events.ANNOTATIONS_SYNCED, [{tag: 'atag'}]); $rootScope.$broadcast(events.ANNOTATIONS_SYNCED, [{tag: 'atag'}]);
assert.calledWith(fakeCrossFrame.call, 'focusAnnotations', ['atag']); assert.calledWith(fakeCrossFrame.call, 'focusAnnotations', ['atag']);
...@@ -248,12 +254,18 @@ describe('WidgetController', function () { ...@@ -248,12 +254,18 @@ describe('WidgetController', function () {
describe('when the focused group changes', function () { describe('when the focused group changes', function () {
it('should load annotations for the new group', function () { it('should load annotations for the new group', function () {
var uri = 'http://example.com'; 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}); fakeCrossFrame.frames.push({uri: uri});
var loadSpy = fakeAnnotationMapper.loadAnnotations; var loadSpy = fakeAnnotationMapper.loadAnnotations;
$scope.$broadcast(events.GROUP_FOCUSED); $scope.$broadcast(events.GROUP_FOCUSED);
assert.calledWith(fakeAnnotationMapper.unloadAnnotations, [{id: '123'}]); assert.calledWith(fakeAnnotationMapper.unloadAnnotations, [{id: '123'}]);
assert.calledWith(fakeThreading.thread, fakeDrafts.unsaved()); assert.calledWith(annotationUI.addAnnotations, fakeDrafts.unsaved());
$scope.$digest(); $scope.$digest();
assert.calledWith(loadSpy, [sinon.match({id: uri + '123'})]); assert.calledWith(loadSpy, [sinon.match({id: uri + '123'})]);
assert.calledWith(loadSpy, [sinon.match({id: uri + '456'})]); assert.calledWith(loadSpy, [sinon.match({id: uri + '456'})]);
...@@ -291,14 +303,13 @@ describe('WidgetController', function () { ...@@ -291,14 +303,13 @@ describe('WidgetController', function () {
describe('direct linking messages', function () { describe('direct linking messages', function () {
it('displays a message if the selection is unavailable', function () { it('displays a message if the selection is unavailable', function () {
annotationUI.selectAnnotations(['missing']); annotationUI.selectAnnotations(['missing']);
fakeThreading.idTable = {'123': {}};
$scope.$digest(); $scope.$digest();
assert.isTrue($scope.selectedAnnotationUnavailable()); assert.isTrue($scope.selectedAnnotationUnavailable());
}); });
it('does not show a message if the selection is available', function () { it('does not show a message if the selection is available', function () {
annotationUI.addAnnotations([{id: '123'}]);
annotationUI.selectAnnotations(['123']); annotationUI.selectAnnotations(['123']);
fakeThreading.idTable = {'123': {}};
$scope.$digest(); $scope.$digest();
assert.isFalse($scope.selectedAnnotationUnavailable()); assert.isFalse($scope.selectedAnnotationUnavailable());
}); });
...@@ -313,8 +324,8 @@ describe('WidgetController', function () { ...@@ -313,8 +324,8 @@ describe('WidgetController', function () {
$scope.auth = { $scope.auth = {
status: 'signed-out' status: 'signed-out'
}; };
annotationUI.addAnnotations([{id: '123'}]);
annotationUI.selectAnnotations(['123']); annotationUI.selectAnnotations(['123']);
fakeThreading.idTable = {'123': {}};
$scope.$digest(); $scope.$digest();
assert.isTrue($scope.shouldShowLoggedOutMessage()); assert.isTrue($scope.shouldShowLoggedOutMessage());
}); });
...@@ -324,7 +335,6 @@ describe('WidgetController', function () { ...@@ -324,7 +335,6 @@ describe('WidgetController', function () {
status: 'signed-out' status: 'signed-out'
}; };
annotationUI.selectAnnotations(['missing']); annotationUI.selectAnnotations(['missing']);
fakeThreading.idTable = {'123': {}};
$scope.$digest(); $scope.$digest();
assert.isFalse($scope.shouldShowLoggedOutMessage()); assert.isFalse($scope.shouldShowLoggedOutMessage());
}); });
...@@ -342,8 +352,8 @@ describe('WidgetController', function () { ...@@ -342,8 +352,8 @@ describe('WidgetController', function () {
$scope.auth = { $scope.auth = {
status: 'signed-in' status: 'signed-in'
}; };
annotationUI.addAnnotations([{id: '123'}]);
annotationUI.selectAnnotations(['123']); annotationUI.selectAnnotations(['123']);
fakeThreading.idTable = {'123': {}};
$scope.$digest(); $scope.$digest();
assert.isFalse($scope.shouldShowLoggedOutMessage()); assert.isFalse($scope.shouldShowLoggedOutMessage());
}); });
...@@ -353,10 +363,46 @@ describe('WidgetController', function () { ...@@ -353,10 +363,46 @@ describe('WidgetController', function () {
status: 'signed-out' status: 'signed-out'
}; };
delete fakeSettings.annotations; delete fakeSettings.annotations;
annotationUI.addAnnotations([{id: '123'}]);
annotationUI.selectAnnotations(['123']); annotationUI.selectAnnotations(['123']);
fakeThreading.idTable = {'123': {}};
$scope.$digest(); $scope.$digest();
assert.isFalse($scope.shouldShowLoggedOutMessage()); 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);
});
});
describe('#visibleCount', function () {
it('returns the total number of visible annotations or replies', function () {
fakeRootThread.thread.returns({
children: [{
id: '1',
visible: true,
children: [{ id: '3', visible: true, children: [] }],
},{
id: '2',
visible: false,
children: [],
}],
});
$scope.$digest();
assert.equal($scope.visibleCount(), 2);
});
});
}); });
'use strict';
/**
* A simple memoization function which caches the last result of
* a single-argument function.
*
* The argument to the input function may be of any type and is compared
* using reference equality.
*/
function memoize(fn) {
if (fn.length !== 1) {
throw new Error('Memoize input must be a function of one argument');
}
var lastArg;
var lastResult;
return function (arg) {
if (arg === lastArg) {
return lastResult;
}
lastArg = arg;
lastResult = fn(arg);
return lastResult;
};
}
module.exports = memoize;
'use strict';
var memoize = require('../memoize');
describe('memoize', function () {
var count = 0;
var memoized;
function square(arg) {
++count;
return arg * arg;
}
beforeEach(function () {
count = 0;
memoized = memoize(square);
});
it('computes the result of the function', function () {
assert.equal(memoized(12), 144);
});
it('does not recompute if the input is unchanged', function () {
memoized(42);
memoized(42);
assert.equal(count, 1);
});
it('recomputes if the input changes', function () {
memoized(42);
memoized(39);
assert.equal(count, 2);
});
});
'use strict'; 'use strict';
var events = require('./events'); var events = require('./events');
var memoize = require('./util/memoize');
var SearchClient = require('./search-client'); var SearchClient = require('./search-client');
function firstKey(object) { function firstKey(object) {
...@@ -31,11 +32,17 @@ function groupIDFromSelection(selection, results) { ...@@ -31,11 +32,17 @@ function groupIDFromSelection(selection, results) {
// @ngInject // @ngInject
module.exports = function WidgetController( module.exports = function WidgetController(
$scope, $rootScope, annotationUI, crossframe, annotationMapper, $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']; $scope.sortOptions = ['Newest', 'Oldest', 'Location'];
function annotationExists(id) {
return annotationUI.getState().annotations.some(function (annot) {
return annot.id === id;
});
}
function focusAnnotation(annotation) { function focusAnnotation(annotation) {
var highlights = []; var highlights = [];
if (annotation) { if (annotation) {
...@@ -60,21 +67,23 @@ module.exports = function WidgetController( ...@@ -60,21 +67,23 @@ module.exports = function WidgetController(
function firstSelectedAnnotation() { function firstSelectedAnnotation() {
if (annotationUI.getState().selectedAnnotationMap) { if (annotationUI.getState().selectedAnnotationMap) {
var id = Object.keys(annotationUI.getState().selectedAnnotationMap)[0]; 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 { } else {
return null; return null;
} }
} }
var searchClients = [];
function _resetAnnotations() { function _resetAnnotations() {
// Unload all the annotations // Unload all the annotations
annotationMapper.unloadAnnotations(threading.annotationList()); annotationMapper.unloadAnnotations(annotationUI.getState().annotations);
// Reload all the drafts // Reload all the drafts
threading.thread(drafts.unsaved()); annotationUI.addAnnotations(drafts.unsaved());
} }
var searchClients = [];
function _loadAnnotationsFor(uri, group) { function _loadAnnotationsFor(uri, group) {
var searchClient = new SearchClient(store.SearchResource, { var searchClient = new SearchClient(store.SearchResource, {
// If no group is specified, we are fetching annotations from // If no group is specified, we are fetching annotations from
...@@ -196,6 +205,30 @@ module.exports = function WidgetController( ...@@ -196,6 +205,30 @@ module.exports = function WidgetController(
return crossframe.frames; return crossframe.frames;
}, loadAnnotations); }, 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) {
annotationUI.sortBy(mode);
});
$scope.$watch('search.query', function (query) {
annotationUI.setFilterQuery(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.focus = focusAnnotation;
$scope.scrollTo = scrollToAnnotation; $scope.scrollTo = scrollToAnnotation;
...@@ -206,14 +239,19 @@ module.exports = function WidgetController( ...@@ -206,14 +239,19 @@ module.exports = function WidgetController(
return annotation.$$tag in annotationUI.getState().focusedAnnotationMap; return annotation.$$tag in annotationUI.getState().focusedAnnotationMap;
}; };
function selectedID() { $scope.selectedAnnotationCount = function () {
return firstKey(annotationUI.getState().selectedAnnotationMap); var selection = annotationUI.getState().selectedAnnotationMap;
} if (!selection) {
return 0;
}
return Object.keys(selection).length;
};
$scope.selectedAnnotationUnavailable = function () { $scope.selectedAnnotationUnavailable = function () {
var selectedID = firstKey(annotationUI.getState().selectedAnnotationMap);
return !isLoading() && return !isLoading() &&
!!selectedID() && !!selectedID &&
!threading.idTable[selectedID()]; !annotationExists(selectedID);
}; };
$scope.shouldShowLoggedOutMessage = function () { $scope.shouldShowLoggedOutMessage = function () {
...@@ -231,21 +269,32 @@ module.exports = function WidgetController( ...@@ -231,21 +269,32 @@ module.exports = function WidgetController(
// The user is logged out and has landed on a direct linked // The user is logged out and has landed on a direct linked
// annotation. If there is an annotation selection and that // annotation. If there is an annotation selection and that
// selection is available to the user, show the CTA. // selection is available to the user, show the CTA.
var selectedID = firstKey(annotationUI.getState().selectedAnnotationMap);
return !isLoading() && return !isLoading() &&
!!selectedID() && !!selectedID &&
!!threading.idTable[selectedID()]; annotationExists(selectedID);
}; };
$scope.isLoading = isLoading; $scope.isLoading = isLoading;
var visibleCount = memoize(function (thread) {
return thread.children.reduce(function (count, child) {
return count + visibleCount(child);
}, thread.visible ? 1 : 0);
});
$scope.visibleCount = function () {
return visibleCount(rootThread.thread());
};
$scope.topLevelThreadCount = function () { $scope.topLevelThreadCount = function () {
return threading.root.children.length; return rootThread.thread().totalChildren;
}; };
$rootScope.$on(events.BEFORE_ANNOTATION_CREATED, 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;
} }
return $scope.clearSelection(); $scope.clearSelection();
}); });
}; };
...@@ -29,11 +29,11 @@ ...@@ -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> <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" <span class="annotation-citation"
ng-bind-html="vm.documentTitle" ng-bind-html="vm.documentTitle"
ng-if="::!vm.isSidebar"> ng-if="::vm.showDocumentInfo">
</span> </span>
<span class="annotation-citation-domain" <span class="annotation-citation-domain"
ng-bind-html="vm.documentDomain" ng-bind-html="vm.documentDomain"
ng-if="::!vm.isSidebar"> ng-if="::vm.showDocumentInfo">
</span> </span>
</span> </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"
ng-show="vm.shouldShowReply(child)">
<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 @@ ...@@ -4,16 +4,15 @@
--> -->
<ul class="stream-list ng-hide" <ul class="stream-list ng-hide"
ng-show="true" ng-show="true"
deep-count="count"
thread-filter="search.query"
window-scroll="loadMore(20)"> window-scroll="loadMore(20)">
<search-status-bar <search-status-bar
ng-show="!isLoading()" ng-show="!isLoading()"
filter-active="threadFilter.active()" ng-if="!isStream"
filter-match-count="count('match')" filter-active="search.query"
filter-match-count="visibleCount()"
on-clear-selection="clearSelection()" on-clear-selection="clearSelection()"
search-query="search ? search.query : ''" search-query="search ? search.query : ''"
selection-count="selectedAnnotationsCount" selection-count="selectedAnnotationCount()"
total-count="topLevelThreadCount()" total-count="topLevelThreadCount()"
> >
</search-status-bar> </search-status-bar>
...@@ -33,15 +32,17 @@ ...@@ -33,15 +32,17 @@
</li> </li>
<li id="{{vm.id}}" <li id="{{vm.id}}"
class="annotation-card thread" class="annotation-card thread"
ng-class="{'js-hover': hasFocus(child.message)}" ng-class="{'js-hover': hasFocus(child.annotation)}"
deep-count="count" ng-mouseenter="focus(child.annotation)"
thread="child" thread-filter ng-click="scrollTo(child.annotation)"
ng-include="'thread.html'"
ng-mouseenter="focus(child.message)"
ng-click="scrollTo(child.message)"
ng-mouseleave="focus()" ng-mouseleave="focus()"
ng-repeat="child in threadRoot.children | orderBy : sort.predicate" ng-repeat="child in rootThread().children track by child.id">
ng-show="vm.shouldShow()"> <annotation-thread
thread="child"
show-document-info="::!isSidebar"
on-change-collapsed="setCollapsed(id, collapsed)"
on-force-visible="forceVisible(thread)">
</annotation-thread>
</li> </li>
<loggedout-message ng-if="isSidebar && shouldShowLoggedOutMessage()" <loggedout-message ng-if="isSidebar && shouldShowLoggedOutMessage()"
on-login="login()" ng-cloak> 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