Commit 8b5eb489 authored by Robert Knight's avatar Robert Knight Committed by Nick Stenning

Add rootThread module for building conversation thread from loaded annotations (#3283)

This adds a service that listens for changes in the UI state and
the set of loaded annotations and generates a thread structure in
response, which can then be visualized by a view.

This commit also adds a first integration test which wires together
the non-visual parts of the new threading implementation.

 * Add 'annotations' to the UI state in annotationUI and update
   this when annotations are loaded or unloaded.

 * Add rootThread which listens for changes in the UI state and
   generates a new conversation thread structure in response.

 * Add an integration test which tests the wiring of the non-visual
   parts of the new threading implementation.
parent b22395fe
...@@ -29,6 +29,9 @@ function initialSelection(settings) { ...@@ -29,6 +29,9 @@ function initialSelection(settings) {
function initialState(settings) { function initialState(settings) {
return Object.freeze({ return Object.freeze({
// List of all loaded annotations
annotations: [],
visibleHighlights: false, visibleHighlights: false,
// Contains a map of annotation tag:true pairs. // Contains a map of annotation tag:true pairs.
...@@ -36,6 +39,16 @@ function initialState(settings) { ...@@ -36,6 +39,16 @@ function initialState(settings) {
// Contains a map of annotation id:true pairs. // Contains a map of annotation id:true pairs.
selectedAnnotationMap: initialSelection(settings), selectedAnnotationMap: initialSelection(settings),
// Map of annotation IDs to expanded/collapsed state. For annotations not
// present in the map, the default state is used which depends on whether
// the annotation is a top-level annotation or a reply, whether it is
// selected and whether it matches the current filter.
expanded: {},
// Set of IDs of annotations that have been explicitly shown
// by the user even if they do not match the current search filter
forceVisible: {},
}); });
} }
...@@ -43,9 +56,43 @@ var types = { ...@@ -43,9 +56,43 @@ var types = {
SELECT_ANNOTATIONS: 'SELECT_ANNOTATIONS', SELECT_ANNOTATIONS: 'SELECT_ANNOTATIONS',
FOCUS_ANNOTATIONS: 'FOCUS_ANNOTATIONS', FOCUS_ANNOTATIONS: 'FOCUS_ANNOTATIONS',
SET_HIGHLIGHTS_VISIBLE: 'SET_HIGHLIGHTS_VISIBLE', SET_HIGHLIGHTS_VISIBLE: 'SET_HIGHLIGHTS_VISIBLE',
SET_FORCE_VISIBLE: 'SET_FORCE_VISIBLE',
SET_EXPANDED: 'SET_EXPANDED',
ADD_ANNOTATIONS: 'ADD_ANNOTATIONS',
REMOVE_ANNOTATIONS: 'REMOVE_ANNOTATIONS',
CLEAR_ANNOTATIONS: 'CLEAR_ANNOTATIONS',
}; };
function excludeAnnotations(current, annotations) {
var idsAndTags = annotations.reduce(function (map, annot) {
var id = annot.id || annot.$$tag;
map[id] = true;
return map;
}, {});
return current.filter(function (annot) {
var id = annot.id || annot.$$tag;
return !idsAndTags[id];
});
}
function annotationsReducer(state, action) {
switch (action.type) {
case types.ADD_ANNOTATIONS:
return Object.assign({}, state,
{annotations: state.annotations.concat(action.annotations)});
case types.REMOVE_ANNOTATIONS:
return Object.assign({}, state,
{annotations: excludeAnnotations(state.annotations, action.annotations)});
case types.CLEAR_ANNOTATIONS:
return Object.assign({}, state, {annotations: []});
default:
return state;
}
}
function reducer(state, action) { function reducer(state, action) {
state = annotationsReducer(state, action);
switch (action.type) { switch (action.type) {
case types.SELECT_ANNOTATIONS: case types.SELECT_ANNOTATIONS:
return Object.assign({}, state, {selectedAnnotationMap: action.selection}); return Object.assign({}, state, {selectedAnnotationMap: action.selection});
...@@ -53,6 +100,10 @@ function reducer(state, action) { ...@@ -53,6 +100,10 @@ function reducer(state, action) {
return Object.assign({}, state, {focusedAnnotationMap: action.focused}); return Object.assign({}, state, {focusedAnnotationMap: action.focused});
case types.SET_HIGHLIGHTS_VISIBLE: case types.SET_HIGHLIGHTS_VISIBLE:
return Object.assign({}, state, {visibleHighlights: action.visible}); return Object.assign({}, state, {visibleHighlights: action.visible});
case types.SET_FORCE_VISIBLE:
return Object.assign({}, state, {forceVisible: action.forceVisible});
case types.SET_EXPANDED:
return Object.assign({}, state, {expanded: action.expanded});
default: default:
return state; return state;
} }
...@@ -122,6 +173,48 @@ module.exports = function (settings) { ...@@ -122,6 +173,48 @@ module.exports = function (settings) {
return !!store.getState().selectedAnnotationMap; return !!store.getState().selectedAnnotationMap;
}, },
/**
* Sets whether replies to the annotation with ID `id` are collapsed.
*
* @param {string} id - Annotation ID
* @param {boolean} collapsed
*/
setCollapsed: function (id, collapsed) {
var expanded = Object.assign({}, store.getState().expanded);
expanded[id] = !collapsed;
store.dispatch({
type: types.SET_EXPANDED,
expanded: expanded,
});
},
/**
* Sets whether a given annotation should be visible, even if it does not
* match the current search query.
*
* @param {string} id - Annotation ID
* @param {boolean} visible
*/
setForceVisible: function (id, visible) {
var forceVisible = Object.assign({}, store.getState().forceVisible);
forceVisible[id] = visible;
store.dispatch({
type: types.SET_FORCE_VISIBLE,
forceVisible: forceVisible,
});
},
/**
* 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.
*/ */
...@@ -175,5 +268,26 @@ module.exports = function (settings) { ...@@ -175,5 +268,26 @@ module.exports = function (settings) {
clearSelectedAnnotations: function () { clearSelectedAnnotations: function () {
select({}); select({});
}, },
/** Add annotations to the currently displayed set. */
addAnnotations: function (annotations) {
store.dispatch({
type: 'ADD_ANNOTATIONS',
annotations: annotations,
});
},
/** Remove annotations from the currently displayed set. */
removeAnnotations: function (annotations) {
store.dispatch({
type: types.REMOVE_ANNOTATIONS,
annotations: annotations,
});
},
/** Set the currently displayed annotations to the empty set. */
clearAnnotations: function () {
store.dispatch({type: types.CLEAR_ANNOTATIONS});
},
}; };
}; };
...@@ -23,16 +23,16 @@ module.exports = function(config) { ...@@ -23,16 +23,16 @@ module.exports = function(config) {
// Test setup // Test setup
'./test/bootstrap.js', './test/bootstrap.js',
// Angular directive templates
'../../templates/client/*.html',
// Tests
//
// Karma watching is disabled for these files because they are // Karma watching is disabled for these files because they are
// bundled with karma-browserify which handles watching itself via // bundled with karma-browserify which handles watching itself via
// watchify // watchify
// Unit tests
{ pattern: '**/*-test.coffee', watched: false, included: true, served: true }, { pattern: '**/*-test.coffee', watched: false, included: true, served: true },
{ pattern: '**/*-test.js', watched: false, included: true, served: true } { pattern: '**/test/*-test.js', watched: false, included: true, served: true },
// Integration tests
{ pattern: '**/integration/*-test.js', watched: false, included: true, served: true }
], ],
// list of files to exclude // list of files to exclude
......
'use strict';
var buildThread = require('./build-thread');
var events = require('./events');
var metadata = require('./annotation-metadata');
function truthyKeys(map) {
return Object.keys(map).filter(function (k) {
return !!map[k];
});
}
// Mapping from sort order name to a less-than predicate
// function for comparing annotations to determine their sort order.
var sortFns = {
'Newest': function (a, b) {
return a.updated > b.updated;
},
'Oldest': function (a, b) {
return a.updated < b.updated;
},
'Location': function (a, b) {
return metadata.location(a) < metadata.location(b);
},
};
/**
* Root conversation thread for the sidebar and stream.
*
* Listens for annotations being loaded, created and unloaded and
* builds a conversation thread.
*
* The thread is sorted and filtered according to
* current sort and filter settings.
*
* The root thread is then displayed by viewer.html
*/
// @ngInject
module.exports = function ($rootScope, annotationUI, searchFilter, viewFilter) {
var thread;
var sortFn = sortFns.Location;
var searchQuery;
/**
* Rebuild the root conversation thread. This should be called
* whenever the set of annotations to render or the sort/search/filter
* settings change.
*/
function rebuildRootThread() {
var filters;
if (searchQuery) {
// TODO - Only regenerate the filter function when the search
// query changes
filters = searchFilter.generateFacetedFilter(searchQuery);
}
var filterFn;
if (searchQuery) {
filterFn = function (annot) {
return viewFilter.filter([annot], filters).length > 0;
};
}
// Get the currently loaded annotations and the set of inputs which
// determines what is visible and build the visible thread structure
var state = annotationUI.getState();
thread = buildThread(state.annotations, {
forceVisible: truthyKeys(state.forceVisible),
expanded: state.expanded,
selected: truthyKeys(state.selectedAnnotationMap || {}),
sortCompareFn: sortFn,
filterFn: filterFn,
});
}
rebuildRootThread();
annotationUI.subscribe(rebuildRootThread);
// Listen for annotations being created or loaded
// and show them in the UI
var loadEvents = [events.BEFORE_ANNOTATION_CREATED,
events.ANNOTATION_CREATED,
events.ANNOTATIONS_LOADED];
loadEvents.forEach(function (event) {
$rootScope.$on(event, function (event, annotation) {
var annotations = [].concat(annotation);
// Remove any annotations which are already loaded
annotationUI.removeAnnotations(annotations);
// Add the new annotations
annotationUI.addAnnotations(annotations);
// Ensure that newly created annotations are always visible
if (event.name === events.BEFORE_ANNOTATION_CREATED) {
(annotation.references || []).forEach(function (parent) {
annotationUI.setCollapsed(parent, false);
});
}
});
});
// Remove any annotations that are deleted or unloaded
$rootScope.$on(events.ANNOTATION_DELETED, function (event, annotation) {
annotationUI.removeAnnotations([annotation]);
annotationUI.removeSelectedAnnotation(annotation);
});
$rootScope.$on(events.ANNOTATIONS_UNLOADED, function (event, annotations) {
annotationUI.removeAnnotations(annotations);
});
return {
/**
* Rebuild the conversation thread based on the currently loaded annotations
* and search/sort/filter settings.
*/
rebuild: rebuildRootThread,
/**
* Returns the current root conversation thread.
* @return {Thread}
*/
thread: function () {
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();
},
};
};
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
var annotationUIFactory = require('../annotation-ui'); var annotationUIFactory = require('../annotation-ui');
var annotationFixtures = require('./annotation-fixtures');
var unroll = require('./util').unroll; var unroll = require('./util').unroll;
describe('annotationUI', function () { describe('annotationUI', function () {
...@@ -24,6 +26,32 @@ describe('annotationUI', function () { ...@@ -24,6 +26,32 @@ describe('annotationUI', function () {
}); });
}); });
describe('#addAnnotations()', function () {
it('adds annotations to the current state', function () {
var annot = annotationFixtures.defaultAnnotation();
annotationUI.addAnnotations([annot]);
assert.deepEqual(annotationUI.getState().annotations, [annot]);
});
});
describe('#removeAnnotations()', function () {
it('removes annotations from the current state', function () {
var annot = annotationFixtures.defaultAnnotation();
annotationUI.addAnnotations([annot]);
annotationUI.removeAnnotations([annot]);
assert.deepEqual(annotationUI.getState().annotations, []);
});
});
describe('#clearAnnotations()', function () {
it('removes all annotations', function () {
var annot = annotationFixtures.defaultAnnotation();
annotationUI.addAnnotations([annot]);
annotationUI.clearAnnotations();
assert.deepEqual(annotationUI.getState().annotations, []);
});
});
describe('#setShowHighlights()', function () { describe('#setShowHighlights()', function () {
unroll('sets the visibleHighlights state flag to #state', function (testCase) { unroll('sets the visibleHighlights state flag to #state', function (testCase) {
annotationUI.setShowHighlights(testCase.state); annotationUI.setShowHighlights(testCase.state);
...@@ -38,11 +66,33 @@ describe('annotationUI', function () { ...@@ -38,11 +66,33 @@ describe('annotationUI', function () {
it('notifies subscribers when the UI state changes', function () { it('notifies subscribers when the UI state changes', function () {
var listener = sinon.stub(); var listener = sinon.stub();
annotationUI.subscribe(listener); annotationUI.subscribe(listener);
annotationUI.focusAnnotations([{ $$tag: 1}]); annotationUI.addAnnotations(annotationFixtures.defaultAnnotation());
assert.called(listener); assert.called(listener);
}); });
}); });
describe('#setForceVisible()', function () {
it('sets the visibility of the annotation', function () {
annotationUI.setForceVisible('id1', true);
assert.deepEqual(annotationUI.getState().forceVisible, {id1:true});
});
});
describe('#clearForceVisible()', function () {
it('clears the forceVisible set', function () {
annotationUI.setForceVisible('id1', true);
annotationUI.clearForceVisible();
assert.deepEqual(annotationUI.getState().forceVisible, {});
});
});
describe('#setCollapsed()', function () {
it('sets the expanded state of the annotation', function () {
annotationUI.setCollapsed('parent_id', false);
assert.deepEqual(annotationUI.getState().expanded, {'parent_id': true});
});
});
describe('#focusAnnotations()', function () { describe('#focusAnnotations()', function () {
it('adds the passed annotations to the focusedAnnotationMap', function () { it('adds the passed annotations to the focusedAnnotationMap', function () {
annotationUI.focusAnnotations([{ $$tag: 1 }, { $$tag: 2 }, { $$tag: 3 }]); annotationUI.focusAnnotations([{ $$tag: 1 }, { $$tag: 2 }, { $$tag: 3 }]);
......
'use strict';
var angular = require('angular');
var immutable = require('seamless-immutable');
var unroll = require('../util').unroll;
var fixtures = immutable({
annotations: [{
id: '1',
references: [],
text: 'first annotation',
updated: 50,
},{
id: '2',
references: [],
text: 'second annotation',
updated: 200,
},{
id: '3',
references: ['2'],
text: 'reply to first annotation',
updated: 100,
}],
});
describe('annotation threading', function () {
var annotationUI;
var rootThread;
beforeEach(function () {
var fakeUnicode = {
normalize: function (s) { return s; },
fold: function (s) { return s; },
};
angular.module('app', [])
.service('annotationUI', require('../../annotation-ui'))
.service('rootThread', require('../../root-thread'))
.service('searchFilter', require('../../search-filter'))
.service('viewFilter', require('../../view-filter'))
.value('settings', {})
.value('unicode', fakeUnicode);
angular.mock.module('app');
angular.mock.inject(function (_annotationUI_, _rootThread_) {
annotationUI = _annotationUI_;
rootThread = _rootThread_;
});
});
it('should display newly loaded annotations', function () {
annotationUI.addAnnotations(fixtures.annotations);
assert.equal(rootThread.thread().children.length, 2);
});
it('should not display unloaded annotations', function () {
annotationUI.addAnnotations(fixtures.annotations);
annotationUI.removeAnnotations(fixtures.annotations);
assert.equal(rootThread.thread().children.length, 0);
});
it('should filter annotations when a search is set', function () {
annotationUI.addAnnotations(fixtures.annotations);
rootThread.setSearchQuery('second');
assert.equal(rootThread.thread().children.length, 1);
assert.equal(rootThread.thread().children[0].id, '2');
});
unroll('should sort annotations by #mode', function (testCase) {
annotationUI.addAnnotations(fixtures.annotations);
rootThread.sortBy(testCase.mode);
var actualOrder = rootThread.thread().children.map(function (thread) {
return thread.annotation.id;
});
assert.deepEqual(actualOrder, testCase.expectedOrder);
}, [{
mode: 'Oldest',
expectedOrder: ['1','2'],
},{
mode: 'Newest',
expectedOrder: ['2','1'],
}]);
});
'use strict';
var angular = require('angular');
var proxyquire = require('proxyquire');
var immutable = require('seamless-immutable');
var annotationFixtures = require('./annotation-fixtures');
var events = require('../events');
var util = require('./util');
var unroll = util.unroll;
var fixtures = immutable({
emptyThread: {
annotation: undefined,
children: [],
},
});
describe('rootThread', function () {
var fakeAnnotationUI;
var fakeBuildThread;
var fakeSearchFilter;
var fakeViewFilter;
var $rootScope;
var rootThread;
beforeEach(function () {
fakeAnnotationUI = {
state: {
annotations: [],
visibleHighlights: false,
focusedAnnotationMap: null,
selectedAnnotationMap: null,
expanded: {},
forceVisible: {},
},
getState: function () {
return this.state;
},
subscribe: sinon.stub(),
removeAnnotations: sinon.stub(),
removeSelectedAnnotation: sinon.stub(),
addAnnotations: sinon.stub(),
setCollapsed: sinon.stub(),
clearForceVisible: sinon.stub(),
};
fakeBuildThread = sinon.stub().returns(fixtures.emptyThread);
fakeSearchFilter = {
generateFacetedFilter: sinon.stub(),
};
fakeViewFilter = {
filter: sinon.stub(),
};
angular.module('app', [])
.value('annotationUI', fakeAnnotationUI)
.value('searchFilter', fakeSearchFilter)
.value('viewFilter', fakeViewFilter)
.service('rootThread', proxyquire('../root-thread', {
'./build-thread': util.noCallThru(fakeBuildThread),
}));
angular.mock.module('app');
angular.mock.inject(function (_$rootScope_, _rootThread_) {
$rootScope = _$rootScope_;
rootThread = _rootThread_;
});
});
describe('initialization', function () {
it('builds a thread from the current set of annotations', function () {
assert.equal(rootThread.thread(), fixtures.emptyThread);
});
});
function assertRebuildsThread(fn) {
fakeBuildThread.reset();
var thread = Object.assign({}, fixtures.emptyThread);
fakeBuildThread.returns(thread);
fn();
assert.called(fakeBuildThread);
assert.equal(rootThread.thread(), thread);
}
describe('#rebuild', function () {
it('rebuilds the thread', function () {
assertRebuildsThread(function () {
rootThread.rebuild();
});
});
it('passes loaded annotations to buildThread()', function () {
var annotation = annotationFixtures.defaultAnnotation();
fakeAnnotationUI.state = Object.assign({}, fakeAnnotationUI.state, {
annotations: [annotation],
});
rootThread.rebuild();
assert.calledWith(fakeBuildThread, sinon.match([annotation]));
});
it('passes the current selection to buildThread()', function () {
fakeAnnotationUI.state = Object.assign({}, fakeAnnotationUI.state, {
selectedAnnotationMap: {id1: true, id2: true},
});
rootThread.rebuild();
assert.calledWith(fakeBuildThread, [], sinon.match({
selected: ['id1', 'id2'],
}));
});
it('passes the current expanded set to buildThread()', function () {
fakeAnnotationUI.state = Object.assign({}, fakeAnnotationUI.state, {
expanded: {id1: true, id2: true},
});
rootThread.rebuild();
assert.calledWith(fakeBuildThread, [], sinon.match({
expanded: {id1: true, id2: true},
}));
});
it('passes the current force-visible set to buildThread()', function () {
fakeAnnotationUI.state = Object.assign({}, fakeAnnotationUI.state, {
forceVisible: {id1: true, id2: true},
});
rootThread.rebuild();
assert.calledWith(fakeBuildThread, [], sinon.match({
forceVisible: ['id1', 'id2'],
}));
});
});
context('when the annotationUI state changes', function () {
it('rebuilds the root thread', function () {
assertRebuildsThread(function () {
var subscriber = fakeAnnotationUI.subscribe.args[0][0];
subscriber();
});
});
});
describe('#sortBy', function () {
it('rebuilds the thread when the sort order changes', function () {
assertRebuildsThread(function () {
rootThread.sortBy('Newest');
});
});
function sortBy(annotations, sortCompareFn) {
return annotations.slice().sort(function (a,b) {
return sortCompareFn(a,b) ? -1 : sortCompareFn(b,a) ? 1 : 0;
});
}
function targetWithPos(pos) {
return [{
selector: [{type: 'TextPositionSelector', start: pos}]
}];
}
unroll('sort order is correct when sorting by #order', function (testCase) {
var annotations = [{
target: targetWithPos(1),
updated: 20,
},{
target: targetWithPos(100),
updated: 100,
},{
target: targetWithPos(50),
updated: 50,
},{
target: targetWithPos(20),
updated: 10,
}];
fakeBuildThread.reset();
rootThread.sortBy(testCase.order);
var sortCompareFn = fakeBuildThread.args[0][1].sortCompareFn;
var actualOrder = sortBy(annotations, sortCompareFn).map(function (annot) {
return annotations.indexOf(annot);
});
assert.deepEqual(actualOrder, testCase.expectedOrder);
}, [
{order: 'Location', expectedOrder: [0,3,2,1]},
{order: 'Oldest', expectedOrder: [3,0,2,1]},
{order: 'Newest', expectedOrder: [1,2,0,3]},
]);
});
describe('#setSearchQuery', function () {
it('rebuilds the thread when the search query changes', function () {
assertRebuildsThread(function () {
rootThread.setSearchQuery('new query');
});
});
it('generates a thread filter function from the search query', function () {
fakeBuildThread.reset();
var filters = [{any: {terms: ['queryterm']}}];
var annotation = annotationFixtures.defaultAnnotation();
fakeSearchFilter.generateFacetedFilter.returns(filters);
rootThread.setSearchQuery('queryterm');
var filterFn = fakeBuildThread.args[0][1].filterFn;
fakeViewFilter.filter.returns([annotation]);
assert.equal(filterFn(annotation), true);
assert.calledWith(fakeViewFilter.filter, sinon.match([annotation]),
filters);
});
it('clears the set of explicitly shown conversations', function () {
rootThread.setSearchQuery('new query');
assert.called(fakeAnnotationUI.clearForceVisible);
});
});
context('when annotation events occur', function () {
var annot = annotationFixtures.defaultAnnotation();
unroll('removes and reloads annotations when #event event occurs', function (testCase) {
$rootScope.$broadcast(testCase.event, testCase.annotations);
var annotations = [].concat(testCase.annotations);
assert.calledWith(fakeAnnotationUI.removeAnnotations, sinon.match(annotations));
assert.calledWith(fakeAnnotationUI.addAnnotations, sinon.match(annotations));
}, [
{event: events.BEFORE_ANNOTATION_CREATED, annotations: annot},
{event: events.ANNOTATION_CREATED, annotations: annot},
{event: events.ANNOTATIONS_LOADED, annotations: [annot]},
]);
it('expands the parents of new annotations', function () {
var reply = annotationFixtures.oldReply();
$rootScope.$broadcast(events.BEFORE_ANNOTATION_CREATED, reply);
assert.calledWith(fakeAnnotationUI.setCollapsed, reply.references[0], false);
});
unroll('removes annotations when #event event occurs', function (testCase) {
$rootScope.$broadcast(testCase.event, testCase.annotations);
var annotations = [].concat(testCase.annotations);
assert.calledWith(fakeAnnotationUI.removeAnnotations, sinon.match(annotations));
}, [
{event: events.ANNOTATION_DELETED, annotations: annot},
{event: events.ANNOTATIONS_UNLOADED, annotations: [annot]},
]);
it('deselects deleted annotations', function () {
$rootScope.$broadcast(events.ANNOTATION_DELETED, annot);
assert.calledWith(fakeAnnotationUI.removeSelectedAnnotation, annot);
});
});
});
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