Commit 1832a97c authored by Robert Knight's avatar Robert Knight Committed by Nick Stenning

Preserve local tag and anchoring status flags when annotations are updated (#87)

When receiving annotation updates via the WebSocket, merge the updated
annotation with the existing local annotation, preserving the local tag
and anchoring status flags.

This fixes a problem where Annotations would be shown as Orphans after
an update was received via the WebSocket

 * When an annotation update is received, merge the current/new
   versions rather than removing the current version and replacing
   it with the new one.

 * Remove mutation of existing annotation in `loadAnnotations()`,
   since the reducer function is now responsible for merging changes
   and triggering UI updates
parent 8ef954c8
...@@ -20,8 +20,7 @@ function annotationMapper($rootScope, annotationUI, store) { ...@@ -20,8 +20,7 @@ function annotationMapper($rootScope, annotationUI, store) {
annotations.forEach(function (annotation) { annotations.forEach(function (annotation) {
var existing = getExistingAnnotation(annotationUI, annotation.id); var existing = getExistingAnnotation(annotationUI, annotation.id);
if (existing) { if (existing) {
angular.copy(annotation, existing); $rootScope.$broadcast(events.ANNOTATION_UPDATED, annotation);
$rootScope.$broadcast(events.ANNOTATION_UPDATED, existing);
return; return;
} }
loaded.push(annotation); loaded.push(annotation);
......
...@@ -129,11 +129,60 @@ function excludeAnnotations(current, annotations) { ...@@ -129,11 +129,60 @@ function excludeAnnotations(current, annotations) {
}); });
} }
function findByID(annotations, id) {
return annotations.find(function (annot) {
return annot.id === id;
});
}
function findByTag(annotations, tag) {
return annotations.find(function (annot) {
return annot.$$tag === tag;
});
}
function annotationsReducer(state, action) { function annotationsReducer(state, action) {
switch (action.type) { switch (action.type) {
case types.ADD_ANNOTATIONS: case types.ADD_ANNOTATIONS:
return Object.assign({}, state, {
{annotations: state.annotations.concat(action.annotations)}); var updatedIDs = {};
var updatedTags = {};
var added = [];
var unchanged = [];
var updated = [];
action.annotations.forEach(function (annot) {
var existing = findByID(state.annotations, annot.id);
if (!existing && annot.$$tag) {
existing = findByTag(state.annotations, annot.$$tag);
}
if (existing) {
// Merge the updated annotation with the private fields from the local
// annotation
updated.push(Object.assign({}, existing, annot));
if (annot.id) {
updatedIDs[annot.id] = true;
}
if (existing.$$tag) {
updatedTags[existing.$$tag] = true;
}
} else {
added.push(annot);
}
});
state.annotations.forEach(function (annot) {
if (!updatedIDs[annot.id] && !updatedTags[annot.$$tag]) {
unchanged.push(annot);
}
});
return Object.assign({}, state, {
annotations: added.concat(updated).concat(unchanged),
});
}
case types.REMOVE_ANNOTATIONS: case types.REMOVE_ANNOTATIONS:
{ {
var annots = excludeAnnotations(state.annotations, action.annotations); var annots = excludeAnnotations(state.annotations, action.annotations);
...@@ -375,8 +424,12 @@ module.exports = function ($rootScope, settings) { ...@@ -375,8 +424,12 @@ module.exports = function ($rootScope, settings) {
/** Add annotations to the currently displayed set. */ /** Add annotations to the currently displayed set. */
addAnnotations: function (annotations) { addAnnotations: function (annotations) {
var added = annotations.filter(function (annot) {
return !findByID(annot.id);
});
store.dispatch({ store.dispatch({
type: 'ADD_ANNOTATIONS', type: types.ADD_ANNOTATIONS,
annotations: annotations, annotations: annotations,
}); });
...@@ -389,7 +442,7 @@ module.exports = function ($rootScope, settings) { ...@@ -389,7 +442,7 @@ module.exports = function ($rootScope, settings) {
// successfully anchor then the status will be updated. // successfully anchor then the status will be updated.
var ANCHORING_TIMEOUT = 500; var ANCHORING_TIMEOUT = 500;
var anchoringAnnots = annotations.filter(metadata.isWaitingToAnchor); var anchoringAnnots = added.filter(metadata.isWaitingToAnchor);
if (anchoringAnnots.length) { if (anchoringAnnots.length) {
setTimeout(function () { setTimeout(function () {
arrayUtil arrayUtil
......
...@@ -119,10 +119,7 @@ function RootThread($rootScope, annotationUI, features, searchFilter, viewFilter ...@@ -119,10 +119,7 @@ function RootThread($rootScope, annotationUI, features, searchFilter, viewFilter
$rootScope.$on(event, function (event, annotation) { $rootScope.$on(event, function (event, annotation) {
var annotations = [].concat(annotation); var annotations = [].concat(annotation);
// Remove any annotations which are already loaded // Add or update annotations
annotationUI.removeAnnotations(annotations);
// Add the new annotations
annotationUI.addAnnotations(annotations); annotationUI.addAnnotations(annotations);
// Ensure that newly created annotations are always visible // Ensure that newly created annotations are always visible
......
'use strict'; 'use strict';
var angular = require('angular'); var angular = require('angular');
var immutable = require('seamless-immutable');
var events = require('../events'); var events = require('../events');
...@@ -57,7 +58,7 @@ describe('annotationMapper', function() { ...@@ -57,7 +58,7 @@ describe('annotationMapper', function() {
it('triggers the annotationUpdated event for each loaded annotation', function () { it('triggers the annotationUpdated event for each loaded annotation', function () {
sandbox.stub($rootScope, '$broadcast'); sandbox.stub($rootScope, '$broadcast');
var annotations = [{id: 1}, {id: 2}, {id: 3}]; var annotations = immutable([{id: 1}, {id: 2}, {id: 3}]);
annotationUI.addAnnotations(angular.copy(annotations)); annotationUI.addAnnotations(angular.copy(annotations));
annotationMapper.loadAnnotations(annotations); annotationMapper.loadAnnotations(annotations);
......
...@@ -59,12 +59,47 @@ describe('annotationUI', function () { ...@@ -59,12 +59,47 @@ describe('annotationUI', function () {
clock.restore(); clock.restore();
}); });
it('adds annotations to the current state', function () { it('adds annotations not in the store', function () {
var annot = defaultAnnotation(); var annot = defaultAnnotation();
annotationUI.addAnnotations([annot]); annotationUI.addAnnotations([annot]);
assert.deepEqual(annotationUI.getState().annotations, [annot]); assert.deepEqual(annotationUI.getState().annotations, [annot]);
}); });
it('updates annotations with matching IDs in the store', function () {
var annot = defaultAnnotation();
annotationUI.addAnnotations([annot]);
var update = Object.assign({}, defaultAnnotation(), {text: 'update'});
annotationUI.addAnnotations([update]);
var updatedAnnot = annotationUI.getState().annotations[0];
assert.equal(updatedAnnot.text, 'update');
});
it('updates annotations with matching tags in the store', function () {
var annot = annotationFixtures.newAnnotation();
annot.$$tag = 'local-tag';
annotationUI.addAnnotations([annot]);
var saved = Object.assign({}, annot, {id: 'server-id'});
annotationUI.addAnnotations([saved]);
var annots = annotationUI.getState().annotations;
assert.equal(annots.length, 1);
assert.equal(annots[0].id, 'server-id');
});
it('preserves anchoring status of updated annotations', function () {
var annot = defaultAnnotation();
annotationUI.addAnnotations([annot]);
annotationUI.updateAnchorStatus(annot.id, null, false /* not an orphan */);
var update = Object.assign({}, defaultAnnotation(), {text: 'update'});
annotationUI.addAnnotations([update]);
var updatedAnnot = annotationUI.getState().annotations[0];
assert.isFalse(updatedAnnot.$orphan);
});
it('marks annotations as orphans if they fail to anchor within a time limit', function () { it('marks annotations as orphans if they fail to anchor within a time limit', function () {
var isOrphan = function () { var isOrphan = function () {
return !!metadata.isOrphan(annotationUI.getState().annotations[0]); return !!metadata.isOrphan(annotationUI.getState().annotations[0]);
......
...@@ -303,10 +303,10 @@ describe('rootThread', function () { ...@@ -303,10 +303,10 @@ describe('rootThread', function () {
context('when annotation events occur', function () { context('when annotation events occur', function () {
var annot = annotationFixtures.defaultAnnotation(); var annot = annotationFixtures.defaultAnnotation();
unroll('removes and reloads annotations when #event event occurs', function (testCase) { unroll('adds or updates annotations when #event event occurs', function (testCase) {
$rootScope.$broadcast(testCase.event, testCase.annotations); $rootScope.$broadcast(testCase.event, testCase.annotations);
var annotations = [].concat(testCase.annotations); var annotations = [].concat(testCase.annotations);
assert.calledWith(fakeAnnotationUI.removeAnnotations, sinon.match(annotations)); assert.notCalled(fakeAnnotationUI.removeAnnotations);
assert.calledWith(fakeAnnotationUI.addAnnotations, sinon.match(annotations)); assert.calledWith(fakeAnnotationUI.addAnnotations, sinon.match(annotations));
}, [ }, [
{event: events.BEFORE_ANNOTATION_CREATED, annotations: annot}, {event: events.BEFORE_ANNOTATION_CREATED, annotations: 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