Commit c6423754 authored by Robert Knight's avatar Robert Knight

Begin to centralize UI state in annotationUI

This adds the initial infrastructure using Redux for centralizing the UI
state as an immutable object accessible via `annotationUI.getState()`
which is updated as a result of actions from the UI, network etc.

For background on why we want to do this, see the design overview at
https://github.com/hypothesis/h/pull/3176

Additionally this commit removes a couple of tests that checked for
non-mutation of the selected/focused annotation maps and uses
seamless-immutable instead which provides a better guarantee of this,
but only in debug builds.
parent 25d7a493
...@@ -5,25 +5,19 @@ var events = require('./events'); ...@@ -5,25 +5,19 @@ var events = require('./events');
/** Watch the UI state and update scope properties. */ /** Watch the UI state and update scope properties. */
// @ngInject // @ngInject
function AnnotationUIController($rootScope, $scope, annotationUI) { function AnnotationUIController($rootScope, $scope, annotationUI) {
$rootScope.$watch(function () { annotationUI.subscribe(function () {
return annotationUI.selectedAnnotationMap; var state = annotationUI.getState();
}, function (map) {
map = map || {};
var count = Object.keys(map).length;
$scope.selectedAnnotationsCount = count;
if (count) { $scope.selectedAnnotations = state.selectedAnnotationMap;
$scope.selectedAnnotations = map;
if (state.selectedAnnotationMap) {
$scope.selectedAnnotationsCount =
Object.keys(state.selectedAnnotationMap).length;
} else { } else {
$scope.selectedAnnotations = null; $scope.selectedAnnotationsCount = 0;
} }
});
$rootScope.$watch(function () { $scope.focusedAnnotations = state.focusedAnnotationMap;
return annotationUI.focusedAnnotationMap;
}, function (map) {
map = map || {};
$scope.focusedAnnotations = map;
}); });
$rootScope.$on(events.ANNOTATION_DELETED, function (event, annotation) { $rootScope.$on(events.ANNOTATION_DELETED, function (event, annotation) {
......
...@@ -42,8 +42,8 @@ function AnnotationUISync($rootScope, $window, bridge, annotationSync, ...@@ -42,8 +42,8 @@ function AnnotationUISync($rootScope, $window, bridge, annotationSync,
if (typeof state !== 'boolean') { if (typeof state !== 'boolean') {
state = true; state = true;
} }
if (annotationUI.visibleHighlights !== state) { if (annotationUI.getState().visibleHighlights !== state) {
annotationUI.visibleHighlights = state; annotationUI.setShowHighlights(state);
bridge.call('setVisibleHighlights', state); bridge.call('setVisibleHighlights', state);
} }
} }
...@@ -72,7 +72,7 @@ function AnnotationUISync($rootScope, $window, bridge, annotationSync, ...@@ -72,7 +72,7 @@ function AnnotationUISync($rootScope, $window, bridge, annotationSync,
return; return;
} else { } else {
// Synchronize the state of guests // Synchronize the state of guests
channel.call('setVisibleHighlights', annotationUI.visibleHighlights); channel.call('setVisibleHighlights', annotationUI.getState().visibleHighlights);
} }
}; };
......
'use strict'; 'use strict';
var immutable = require('seamless-immutable');
var redux = require('redux');
function value(selection) { function value(selection) {
if (Object.keys(selection).length) { if (Object.keys(selection).length) {
return Object.freeze(selection); return immutable(selection);
} else { } else {
return null; return null;
} }
...@@ -16,33 +19,81 @@ function initialSelection(settings) { ...@@ -16,33 +19,81 @@ function initialSelection(settings) {
return value(selection); return value(selection);
} }
function initialState(settings) {
return Object.freeze({
visibleHighlights: false,
// Contains a map of annotation tag:true pairs.
focusedAnnotationMap: null,
// Contains a map of annotation id:true pairs.
selectedAnnotationMap: initialSelection(settings),
});
}
var types = {
SELECT_ANNOTATIONS: 'SELECT_ANNOTATIONS',
FOCUS_ANNOTATIONS: 'FOCUS_ANNOTATIONS',
SET_HIGHLIGHTS_VISIBLE: 'SET_HIGHLIGHTS_VISIBLE',
};
function reducer(state, action) {
switch (action.type) {
case types.SELECT_ANNOTATIONS:
return Object.assign({}, state, {selectedAnnotationMap: action.selection});
case types.FOCUS_ANNOTATIONS:
return Object.assign({}, state, {focusedAnnotationMap: action.focused});
case types.SET_HIGHLIGHTS_VISIBLE:
return Object.assign({}, state, {visibleHighlights: action.visible});
default:
return state;
}
}
/** /**
* Stores the UI state of the annotator in connected clients. * Stores the UI state of the annotator in connected clients.
* *
* This includes: * This includes:
* - The set of annotations that are currently selected * - The IDs of annotations that are currently selected or focused
* - The annotation(s) that are currently hovered/focused
* - The state of the bucket bar * - The state of the bucket bar
* *
*/ */
// @ngInject // @ngInject
module.exports = function (settings) { module.exports = function (settings) {
var store = redux.createStore(reducer, initialState(settings));
function select(annotations) {
store.dispatch({
type: types.SELECT_ANNOTATIONS,
selection: value(annotations),
});
}
return { return {
visibleHighlights: false, /**
* Return the current UI state of the sidebar. This should not be modified
* directly but only though the helper methods below.
*/
getState: store.getState,
// Contains a map of annotation tag:true pairs. /** Listen for changes to the UI state of the sidebar. */
focusedAnnotationMap: null, subscribe: store.subscribe,
// Contains a map of annotation id:true pairs. /**
selectedAnnotationMap: initialSelection(settings), * Sets whether annotation highlights in connected documents are shown
* or not.
*/
setShowHighlights: function (show) {
store.dispatch({
type: types.SET_HIGHLIGHTS_VISIBLE,
visible: show,
});
},
/** /**
* @ngdoc method * Sets which annotations are currently focused.
* @name annotationUI.focusedAnnotations *
* @returns nothing * @param {Array<Annotation>} annotations
* @description Takes an array of annotations and uses them to set
* the focusedAnnotationMap.
*/ */
focusAnnotations: function (annotations) { focusAnnotations: function (annotations) {
var selection = {}; var selection = {};
...@@ -50,25 +101,24 @@ module.exports = function (settings) { ...@@ -50,25 +101,24 @@ module.exports = function (settings) {
annotation = annotations[i]; annotation = annotations[i];
selection[annotation.$$tag] = true; selection[annotation.$$tag] = true;
} }
this.focusedAnnotationMap = value(selection); store.dispatch({
type: types.FOCUS_ANNOTATIONS,
focused: value(selection),
});
}, },
/** /**
* @ngdoc method * Return true if any annotations are currently selected.
* @name annotationUI.hasSelectedAnnotations
* @returns true if there are any selected annotations.
*/ */
hasSelectedAnnotations: function () { hasSelectedAnnotations: function () {
return !!this.selectedAnnotationMap; return !!store.getState().selectedAnnotationMap;
}, },
/** /**
* @ngdoc method * Returns true if the annotation with the given `id` is selected.
* @name annotationUI.isAnnotationSelected
* @returns true if the provided annotation is selected.
*/ */
isAnnotationSelected: function (id) { isAnnotationSelected: function (id) {
return (this.selectedAnnotationMap || {}).hasOwnProperty(id); return (store.getState().selectedAnnotationMap || {}).hasOwnProperty(id);
}, },
/** /**
...@@ -86,18 +136,12 @@ module.exports = function (settings) { ...@@ -86,18 +136,12 @@ module.exports = function (settings) {
selection[annotations[i].id] = true; selection[annotations[i].id] = true;
} }
} }
this.selectedAnnotationMap = value(selection); select(selection);
}, },
/** /** Toggle whether annotations are selected or not. */
* @ngdoc method
* @name annotationUI.xorSelectedAnnotations()
* @returns nothing
* @description takes an array of annotations and adds them to the
* selectedAnnotationMap if not present otherwise removes them.
*/
xorSelectedAnnotations: function (annotations) { xorSelectedAnnotations: function (annotations) {
var selection = Object.assign({}, this.selectedAnnotationMap); var selection = Object.assign({}, store.getState().selectedAnnotationMap);
for (var i = 0, annotation; i < annotations.length; i++) { for (var i = 0, annotation; i < annotations.length; i++) {
annotation = annotations[i]; annotation = annotations[i];
var id = annotation.id; var id = annotation.id;
...@@ -107,31 +151,21 @@ module.exports = function (settings) { ...@@ -107,31 +151,21 @@ module.exports = function (settings) {
selection[id] = true; selection[id] = true;
} }
} }
this.selectedAnnotationMap = value(selection); select(selection);
}, },
/** /** De-select an annotation. */
* @ngdoc method
* @name annotationUI.removeSelectedAnnotation()
* @returns nothing
* @description removes an annotation from the current selection.
*/
removeSelectedAnnotation: function (annotation) { removeSelectedAnnotation: function (annotation) {
var selection = Object.assign({}, this.selectedAnnotationMap); var selection = Object.assign({}, store.getState().selectedAnnotationMap);
if (selection) { if (selection) {
delete selection[annotation.id]; delete selection[annotation.id];
this.selectedAnnotationMap = value(selection); select(selection);
} }
}, },
/** /** De-select all annotations. */
* @ngdoc method
* @name annotationUI.clearSelectedAnnotations()
* @returns nothing
* @description removes all annotations from the current selection.
*/
clearSelectedAnnotations: function () { clearSelectedAnnotations: function () {
this.selectedAnnotationMap = null; select({});
} },
}; };
}; };
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
var angular = require('angular'); var angular = require('angular');
var createFakeStore = require('./create-fake-store');
describe('AnnotationUIController', function () { describe('AnnotationUIController', function () {
var $scope; var $scope;
var $rootScope; var $rootScope;
...@@ -22,11 +24,15 @@ describe('AnnotationUIController', function () { ...@@ -22,11 +24,15 @@ describe('AnnotationUIController', function () {
$scope = $rootScope.$new(); $scope = $rootScope.$new();
$scope.search = {}; $scope.search = {};
annotationUI = { var store = createFakeStore({
tool: 'comment',
selectedAnnotationMap: null, selectedAnnotationMap: null,
focusedAnnotationsMap: null, focusedAnnotationsMap: null,
removeSelectedAnnotation: sandbox.stub() });
annotationUI = {
removeSelectedAnnotation: sandbox.stub(),
setState: store.setState,
getState: store.getState,
subscribe: store.subscribe,
}; };
$controller('AnnotationUIController', { $controller('AnnotationUIController', {
...@@ -40,27 +46,23 @@ describe('AnnotationUIController', function () { ...@@ -40,27 +46,23 @@ describe('AnnotationUIController', function () {
}); });
it('updates the view when the selection changes', function () { it('updates the view when the selection changes', function () {
annotationUI.selectedAnnotationMap = { 1: true, 2: true }; annotationUI.setState({selectedAnnotationMap: { 1: true, 2: true }});
$rootScope.$digest();
assert.deepEqual($scope.selectedAnnotations, { 1: true, 2: true }); assert.deepEqual($scope.selectedAnnotations, { 1: true, 2: true });
}); });
it('updates the selection counter when the selection changes', function () { it('updates the selection counter when the selection changes', function () {
annotationUI.selectedAnnotationMap = { 1: true, 2: true }; annotationUI.setState({selectedAnnotationMap: { 1: true, 2: true }});
$rootScope.$digest();
assert.deepEqual($scope.selectedAnnotationsCount, 2); assert.deepEqual($scope.selectedAnnotationsCount, 2);
}); });
it('clears the selection when no annotations are selected', function () { it('clears the selection when no annotations are selected', function () {
annotationUI.selectedAnnotationMap = {}; annotationUI.setState({selectedAnnotationMap: null});
$rootScope.$digest();
assert.deepEqual($scope.selectedAnnotations, null); assert.deepEqual($scope.selectedAnnotations, null);
assert.deepEqual($scope.selectedAnnotationsCount, 0); assert.deepEqual($scope.selectedAnnotationsCount, 0);
}); });
it('updates the focused annotations when the focus map changes', function () { it('updates the focused annotations when the focus map changes', function () {
annotationUI.focusedAnnotationMap = { 1: true, 2: true }; annotationUI.setState({focusedAnnotationMap: { 1: true, 2: true }});
$rootScope.$digest();
assert.deepEqual($scope.focusedAnnotations, { 1: true, 2: true }); assert.deepEqual($scope.focusedAnnotations, { 1: true, 2: true });
}); });
......
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
var angular = require('angular'); var angular = require('angular');
var createFakeStore = require('./create-fake-store');
describe('AnnotationUISync', function () { describe('AnnotationUISync', function () {
var sandbox = sinon.sandbox.create(); var sandbox = sinon.sandbox.create();
var $digest; var $digest;
...@@ -47,11 +49,13 @@ describe('AnnotationUISync', function () { ...@@ -47,11 +49,13 @@ describe('AnnotationUISync', function () {
} }
}; };
var store = createFakeStore({visibleHighlights: false});
fakeAnnotationUI = { fakeAnnotationUI = {
focusAnnotations: sandbox.stub(), focusAnnotations: sandbox.stub(),
selectAnnotations: sandbox.stub(), selectAnnotations: sandbox.stub(),
xorSelectedAnnotations: sandbox.stub(), xorSelectedAnnotations: sandbox.stub(),
visibleHighlights: false, setShowHighlights: sandbox.stub(),
getState: store.getState,
}; };
createAnnotationUISync = function () { createAnnotationUISync = function () {
...@@ -141,10 +145,10 @@ describe('AnnotationUISync', function () { ...@@ -141,10 +145,10 @@ describe('AnnotationUISync', function () {
}); });
describe('on "setVisibleHighlights" event', function () { describe('on "setVisibleHighlights" event', function () {
it('updates the annotationUI with the new value', function () { it('updates the annotationUI state', function () {
createAnnotationUISync(); createAnnotationUISync();
publish('setVisibleHighlights', true); publish('setVisibleHighlights', true);
assert.equal(fakeAnnotationUI.visibleHighlights, true); assert.calledWith(fakeAnnotationUI.setShowHighlights, true);
}); });
it('notifies the other frames of the change', function () { it('notifies the other frames of the change', function () {
......
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
var annotationUIFactory = require('../annotation-ui'); var annotationUIFactory = require('../annotation-ui');
var unroll = require('./util').unroll;
describe('annotationUI', function () { describe('annotationUI', function () {
var annotationUI; var annotationUI;
...@@ -16,154 +18,147 @@ describe('annotationUI', function () { ...@@ -16,154 +18,147 @@ describe('annotationUI', function () {
it('sets the selection when settings.annotations is set', function () { it('sets the selection when settings.annotations is set', function () {
annotationUI = annotationUIFactory({annotations: 'testid'}); annotationUI = annotationUIFactory({annotations: 'testid'});
assert.deepEqual(annotationUI.selectedAnnotationMap, { assert.deepEqual(annotationUI.getState().selectedAnnotationMap, {
testid: true, testid: true,
}); });
}); });
}); });
describe('.focusAnnotations()', function () { describe('#setShowHighlights()', function () {
unroll('sets the visibleHighlights state flag to #state', function (testCase) {
annotationUI.setShowHighlights(testCase.state);
assert.equal(annotationUI.getState().visibleHighlights, testCase.state);
}, [
{state: true},
{state: false},
]);
});
describe('#subscribe()', function () {
it('notifies subscribers when the UI state changes', function () {
var listener = sinon.stub();
annotationUI.subscribe(listener);
annotationUI.focusAnnotations([{ $$tag: 1}]);
assert.called(listener);
});
});
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 }]);
assert.deepEqual(annotationUI.focusedAnnotationMap, { assert.deepEqual(annotationUI.getState().focusedAnnotationMap, {
1: true, 2: true, 3: true 1: true, 2: true, 3: true
}); });
}); });
it('replaces any annotations originally in the map', function () { it('replaces any annotations originally in the map', function () {
annotationUI.focusedAnnotationMap = { 1: true }; annotationUI.focusAnnotations([{ $$tag: 1 }]);
annotationUI.focusAnnotations([{ $$tag: 2 }, { $$tag: 3 }]); annotationUI.focusAnnotations([{ $$tag: 2 }, { $$tag: 3 }]);
assert.deepEqual(annotationUI.focusedAnnotationMap, { assert.deepEqual(annotationUI.getState().focusedAnnotationMap, {
2: true, 3: true 2: true, 3: true
}); });
}); });
it('does not modify the original map object', function () {
var orig = annotationUI.focusedAnnotationMap = { 1: true };
annotationUI.focusAnnotations([{ $$tag: 2 }, { $$tag: 3 }]);
assert.notEqual(annotationUI.focusedAnnotationMap, orig);
});
it('nulls the map if no annotations are focused', function () { it('nulls the map if no annotations are focused', function () {
annotationUI.focusedAnnotationMap = { $$tag: true }; annotationUI.focusAnnotations([{$$tag: 1}]);
annotationUI.focusAnnotations([]); annotationUI.focusAnnotations([]);
assert.isNull(annotationUI.focusedAnnotationMap); assert.isNull(annotationUI.getState().focusedAnnotationMap);
}); });
}); });
describe('.hasSelectedAnnotations', function () { describe('#hasSelectedAnnotations', function () {
it('returns true if there are any selected annotations', function () { it('returns true if there are any selected annotations', function () {
annotationUI.selectedAnnotationMap = { 1: true }; annotationUI.selectAnnotations([{id: 1}]);
assert.isTrue(annotationUI.hasSelectedAnnotations()); assert.isTrue(annotationUI.hasSelectedAnnotations());
}); });
it('returns false if there are no selected annotations', function () { it('returns false if there are no selected annotations', function () {
annotationUI.selectedAnnotationMap = null;
assert.isFalse(annotationUI.hasSelectedAnnotations()); assert.isFalse(annotationUI.hasSelectedAnnotations());
}); });
}); });
describe('.isAnnotationSelected', function () { describe('#isAnnotationSelected', function () {
it('returns true if the id provided is selected', function () { it('returns true if the id provided is selected', function () {
annotationUI.selectedAnnotationMap = { 1: true }; annotationUI.selectAnnotations([{id: 1}]);
assert.isTrue(annotationUI.isAnnotationSelected(1)); assert.isTrue(annotationUI.isAnnotationSelected(1));
}); });
it('returns false if the id provided is not selected', function () { it('returns false if the id provided is not selected', function () {
annotationUI.selectedAnnotationMap = { 1: true }; annotationUI.selectAnnotations([{id: 1}]);
assert.isFalse(annotationUI.isAnnotationSelected(2)); assert.isFalse(annotationUI.isAnnotationSelected(2));
}); });
it('returns false if there are no selected annotations', function () { it('returns false if there are no selected annotations', function () {
annotationUI.selectedAnnotationMap = null;
assert.isFalse(annotationUI.isAnnotationSelected(1)); assert.isFalse(annotationUI.isAnnotationSelected(1));
}); });
}); });
describe('.selectAnnotations()', function () { describe('#selectAnnotations()', function () {
it('adds the passed annotations to the selectedAnnotationMap', function () { it('adds the passed annotations to the selectedAnnotationMap', function () {
annotationUI.selectAnnotations([{ id: 1 }, { id: 2 }, { id: 3 }]); annotationUI.selectAnnotations([{ id: 1 }, { id: 2 }, { id: 3 }]);
assert.deepEqual(annotationUI.selectedAnnotationMap, { assert.deepEqual(annotationUI.getState().selectedAnnotationMap, {
1: true, 2: true, 3: true 1: true, 2: true, 3: true
}); });
}); });
it('replaces any annotations originally in the map', function () { it('replaces any annotations originally in the map', function () {
annotationUI.selectedAnnotationMap = { 1: true }; annotationUI.selectAnnotations([{ id:1 }]);
annotationUI.selectAnnotations([{ id: 2 }, { id: 3 }]); annotationUI.selectAnnotations([{ id: 2 }, { id: 3 }]);
assert.deepEqual(annotationUI.selectedAnnotationMap, { assert.deepEqual(annotationUI.getState().selectedAnnotationMap, {
2: true, 3: true 2: true, 3: true
}); });
}); });
it('does not modify the original map object', function () {
var orig = annotationUI.selectedAnnotationMap = { 1: true };
annotationUI.selectAnnotations([{ id: 2 }, { id: 3 }]);
assert.notEqual(annotationUI.selectedAnnotationMap, orig);
});
it('nulls the map if no annotations are selected', function () { it('nulls the map if no annotations are selected', function () {
annotationUI.selectedAnnotationMap = { 1: true }; annotationUI.selectAnnotations([{id:1}]);
annotationUI.selectAnnotations([]); annotationUI.selectAnnotations([]);
assert.isNull(annotationUI.selectedAnnotationMap); assert.isNull(annotationUI.getState().selectedAnnotationMap);
}); });
}); });
describe('.xorSelectedAnnotations()', function () { describe('#xorSelectedAnnotations()', function () {
it('adds annotations missing from the selectedAnnotationMap', function () { it('adds annotations missing from the selectedAnnotationMap', function () {
annotationUI.selectedAnnotationMap = { 1: true, 2: true }; annotationUI.selectAnnotations([{ id: 1 }, { id: 2}]);
annotationUI.xorSelectedAnnotations([{ id: 3 }, { id: 4 }]); annotationUI.xorSelectedAnnotations([{ id: 3 }, { id: 4 }]);
assert.deepEqual(annotationUI.selectedAnnotationMap, { assert.deepEqual(annotationUI.getState().selectedAnnotationMap, {
1: true, 2: true, 3: true, 4: true 1: true, 2: true, 3: true, 4: true
}); });
}); });
it('removes annotations already in the selectedAnnotationMap', function () { it('removes annotations already in the selectedAnnotationMap', function () {
annotationUI.selectedAnnotationMap = { 1: true, 3: true }; annotationUI.selectAnnotations([{id: 1}, {id: 3}]);
annotationUI.xorSelectedAnnotations([{ id: 1 }, { id: 2 }]); annotationUI.xorSelectedAnnotations([{ id: 1 }, { id: 2 }]);
assert.deepEqual(annotationUI.selectedAnnotationMap, { 2: true, 3: true }); assert.deepEqual(annotationUI.getState().selectedAnnotationMap, { 2: true, 3: true });
});
it('does not modify the original map object', function () {
var orig = annotationUI.selectedAnnotationMap = { 1: true };
annotationUI.xorSelectedAnnotations([{ id: 2 }, { id: 3 }]);
assert.notEqual(annotationUI.selectedAnnotationMap, orig);
}); });
it('nulls the map if no annotations are selected', function () { it('nulls the map if no annotations are selected', function () {
annotationUI.selectedAnnotationMap = { 1: true }; annotationUI.selectAnnotations([{id: 1}]);
annotationUI.xorSelectedAnnotations([{ id: 1 }]); annotationUI.xorSelectedAnnotations([{ id: 1 }]);
assert.isNull(annotationUI.selectedAnnotationMap); assert.isNull(annotationUI.getState().selectedAnnotationMap);
}); });
}); });
describe('.removeSelectedAnnotation', function () { describe('#removeSelectedAnnotation()', function () {
it('removes an annotation from the selectedAnnotationMap', function () { it('removes an annotation from the selectedAnnotationMap', function () {
annotationUI.selectedAnnotationMap = { 1: true, 2: true, 3: true }; annotationUI.selectAnnotations([{id: 1}, {id: 2}, {id: 3}]);
annotationUI.removeSelectedAnnotation({ id: 2 }); annotationUI.removeSelectedAnnotation({ id: 2 });
assert.deepEqual(annotationUI.selectedAnnotationMap, { assert.deepEqual(annotationUI.getState().selectedAnnotationMap, {
1: true, 3: true 1: true, 3: true
}); });
}); });
it('does not modify the original map object', function () {
var orig = annotationUI.selectedAnnotationMap = { 1: true };
annotationUI.removeSelectedAnnotation({ id: 1 });
assert.notEqual(annotationUI.selectedAnnotationMap, orig);
});
it('nulls the map if no annotations are selected', function () { it('nulls the map if no annotations are selected', function () {
annotationUI.selectedAnnotationMap = { 1: true }; annotationUI.selectAnnotations([{id: 1}]);
annotationUI.removeSelectedAnnotation({ id: 1 }); annotationUI.removeSelectedAnnotation({ id: 1 });
assert.isNull(annotationUI.selectedAnnotationMap); assert.isNull(annotationUI.getState().selectedAnnotationMap);
}); });
}); });
describe('.clearSelectedAnnotations', function () { describe('#clearSelectedAnnotations()', function () {
it('removes all annotations from the selection', function () { it('removes all annotations from the selection', function () {
annotationUI.selectedAnnotationMap = { 1: true, 2: true, 3: true }; annotationUI.selectAnnotations([{id: 1}]);
annotationUI.clearSelectedAnnotations(); annotationUI.clearSelectedAnnotations();
assert.isNull(annotationUI.selectedAnnotationMap); assert.isNull(annotationUI.getState().selectedAnnotationMap);
}); });
}); });
}); });
'use strict';
var redux = require('redux');
function reducer(state, action) {
if (action.type === 'STATE_UPDATE') {
return Object.assign({}, state, action.update);
} else {
return state;
}
}
/**
* Creates a fake Redux store for use in tests.
*
* Unlike a normal Redux store where the user provides a function that
* transforms the state in response to actions and calls dispatch() to update
* the state when actions occur, this store has a setState() method for
* replacing state fields directly.
*/
function createFakeStore(initialState) {
var store = redux.createStore(reducer, initialState);
store.setState = function (update) {
store.dispatch({
type: 'STATE_UPDATE',
update: update,
});
};
return store;
}
module.exports = createFakeStore;
...@@ -5,6 +5,7 @@ var inherits = require('inherits'); ...@@ -5,6 +5,7 @@ var inherits = require('inherits');
var proxyquire = require('proxyquire'); var proxyquire = require('proxyquire');
var EventEmitter = require('tiny-emitter'); var EventEmitter = require('tiny-emitter');
var createFakeStore = require('./create-fake-store');
var events = require('../events'); var events = require('../events');
var noCallThru = require('./util').noCallThru; var noCallThru = require('./util').noCallThru;
...@@ -62,12 +63,26 @@ describe('WidgetController', function () { ...@@ -62,12 +63,26 @@ describe('WidgetController', function () {
unloadAnnotations: sandbox.spy() unloadAnnotations: sandbox.spy()
}; };
var store = createFakeStore({selectedAnnotationMap: {}});
fakeAnnotationUI = { fakeAnnotationUI = {
clearSelectedAnnotations: sandbox.spy(), clearSelectedAnnotations: sandbox.spy(),
selectedAnnotationMap: {},
hasSelectedAnnotations: function () { hasSelectedAnnotations: function () {
return !!this.selectedAnnotationMap && return !!this.getState().selectedAnnotationMap &&
Object.keys(this.selectedAnnotationMap).length > 0; Object.keys(this.getState().selectedAnnotationMap).length > 0;
},
getState: store.getState,
subscribe: store.subscribe,
_select: function (ids) {
if (!ids.length) {
store.setState({selectedAnnotationMap: null});
return;
}
store.setState({
selectedAnnotationMap: ids.reduce(function (map, id) {
map[id] = true;
return map;
}, {}),
});
}, },
}; };
fakeCrossFrame = { fakeCrossFrame = {
...@@ -172,7 +187,7 @@ describe('WidgetController', function () { ...@@ -172,7 +187,7 @@ describe('WidgetController', function () {
beforeEach(function () { beforeEach(function () {
fakeCrossFrame.frames = [{uri: uri}]; fakeCrossFrame.frames = [{uri: uri}];
fakeAnnotationUI.selectedAnnotationMap[id] = true; fakeAnnotationUI._select([id]);
$scope.$digest(); $scope.$digest();
}); });
...@@ -217,7 +232,7 @@ describe('WidgetController', function () { ...@@ -217,7 +232,7 @@ describe('WidgetController', function () {
beforeEach(function () { beforeEach(function () {
fakeCrossFrame.frames = [{uri: uri}]; fakeCrossFrame.frames = [{uri: uri}];
fakeAnnotationUI.selectedAnnotationMap[id] = true; fakeAnnotationUI._select([id]);
fakeGroups.focused = function () { return { id: 'private-group' }; }; fakeGroups.focused = function () { return { id: 'private-group' }; };
$scope.$digest(); $scope.$digest();
}); });
...@@ -233,7 +248,7 @@ describe('WidgetController', function () { ...@@ -233,7 +248,7 @@ describe('WidgetController', function () {
describe('when an annotation is anchored', function () { describe('when an annotation is anchored', function () {
it('focuses and scrolls to the annotation if already selected', function () { it('focuses and scrolls to the annotation if already selected', function () {
var uri = 'http://example.com'; var uri = 'http://example.com';
fakeAnnotationUI.selectedAnnotationMap = {'123': true}; fakeAnnotationUI._select(['123']);
fakeCrossFrame.frames.push({uri: uri}); fakeCrossFrame.frames.push({uri: uri});
var annot = { var annot = {
$$tag: 'atag', $$tag: 'atag',
...@@ -296,21 +311,21 @@ describe('WidgetController', function () { ...@@ -296,21 +311,21 @@ 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 () {
fakeAnnotationUI.selectedAnnotationMap = {'missing': true}; fakeAnnotationUI._select(['missing']);
fakeThreading.idTable = {'123': {}}; 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 () {
fakeAnnotationUI.selectedAnnotationMap = {'123': true}; fakeAnnotationUI._select(['123']);
fakeThreading.idTable = {'123': {}}; fakeThreading.idTable = {'123': {}};
$scope.$digest(); $scope.$digest();
assert.isFalse($scope.selectedAnnotationUnavailable()); assert.isFalse($scope.selectedAnnotationUnavailable());
}); });
it('does not a show a message if there is no selection', function () { it('does not a show a message if there is no selection', function () {
fakeAnnotationUI.selectedAnnotationMap = null; fakeAnnotationUI._select([]);
$scope.$digest(); $scope.$digest();
assert.isFalse($scope.selectedAnnotationUnavailable()); assert.isFalse($scope.selectedAnnotationUnavailable());
}); });
...@@ -319,7 +334,7 @@ describe('WidgetController', function () { ...@@ -319,7 +334,7 @@ describe('WidgetController', function () {
$scope.auth = { $scope.auth = {
status: 'signed-out' status: 'signed-out'
}; };
fakeAnnotationUI.selectedAnnotationMap = {'123': true}; fakeAnnotationUI._select(['123']);
fakeThreading.idTable = {'123': {}}; fakeThreading.idTable = {'123': {}};
$scope.$digest(); $scope.$digest();
assert.isTrue($scope.shouldShowLoggedOutMessage()); assert.isTrue($scope.shouldShowLoggedOutMessage());
...@@ -329,7 +344,7 @@ describe('WidgetController', function () { ...@@ -329,7 +344,7 @@ describe('WidgetController', function () {
$scope.auth = { $scope.auth = {
status: 'signed-out' status: 'signed-out'
}; };
fakeAnnotationUI.selectedAnnotationMap = {'missing': true}; fakeAnnotationUI._select(['missing']);
fakeThreading.idTable = {'123': {}}; fakeThreading.idTable = {'123': {}};
$scope.$digest(); $scope.$digest();
assert.isFalse($scope.shouldShowLoggedOutMessage()); assert.isFalse($scope.shouldShowLoggedOutMessage());
...@@ -339,7 +354,7 @@ describe('WidgetController', function () { ...@@ -339,7 +354,7 @@ describe('WidgetController', function () {
$scope.auth = { $scope.auth = {
status: 'signed-out' status: 'signed-out'
}; };
fakeAnnotationUI.selectedAnnotationMap = null; fakeAnnotationUI._select([]);
$scope.$digest(); $scope.$digest();
assert.isFalse($scope.shouldShowLoggedOutMessage()); assert.isFalse($scope.shouldShowLoggedOutMessage());
}); });
...@@ -348,7 +363,7 @@ describe('WidgetController', function () { ...@@ -348,7 +363,7 @@ describe('WidgetController', function () {
$scope.auth = { $scope.auth = {
status: 'signed-in' status: 'signed-in'
}; };
fakeAnnotationUI.selectedAnnotationMap = {'123': true}; fakeAnnotationUI._select(['123']);
fakeThreading.idTable = {'123': {}}; fakeThreading.idTable = {'123': {}};
$scope.$digest(); $scope.$digest();
assert.isFalse($scope.shouldShowLoggedOutMessage()); assert.isFalse($scope.shouldShowLoggedOutMessage());
...@@ -359,7 +374,7 @@ describe('WidgetController', function () { ...@@ -359,7 +374,7 @@ describe('WidgetController', function () {
status: 'signed-out' status: 'signed-out'
}; };
delete fakeSettings.annotations; delete fakeSettings.annotations;
fakeAnnotationUI.selectedAnnotationMap = {'123': true}; fakeAnnotationUI._select(['123']);
fakeThreading.idTable = {'123': {}}; fakeThreading.idTable = {'123': {}};
$scope.$digest(); $scope.$digest();
assert.isFalse($scope.shouldShowLoggedOutMessage()); assert.isFalse($scope.shouldShowLoggedOutMessage());
......
...@@ -58,8 +58,8 @@ module.exports = function WidgetController( ...@@ -58,8 +58,8 @@ module.exports = function WidgetController(
* not the order in which they appear in the document. * not the order in which they appear in the document.
*/ */
function firstSelectedAnnotation() { function firstSelectedAnnotation() {
if (annotationUI.selectedAnnotationMap) { if (annotationUI.getState().selectedAnnotationMap) {
var id = Object.keys(annotationUI.selectedAnnotationMap)[0]; var id = Object.keys(annotationUI.getState().selectedAnnotationMap)[0];
return threading.idTable[id] && threading.idTable[id].message; return threading.idTable[id] && threading.idTable[id].message;
} else { } else {
return null; return null;
...@@ -88,8 +88,8 @@ module.exports = function WidgetController( ...@@ -88,8 +88,8 @@ module.exports = function WidgetController(
if (annotationUI.hasSelectedAnnotations()) { if (annotationUI.hasSelectedAnnotations()) {
// Focus the group containing the selected annotation and filter // Focus the group containing the selected annotation and filter
// annotations to those from this group // annotations to those from this group
var groupID = groupIDFromSelection(annotationUI.selectedAnnotationMap, var groupID = groupIDFromSelection(
results); annotationUI.getState().selectedAnnotationMap, results);
if (!groupID) { if (!groupID) {
// If the selected annotation is not available, fall back to // If the selected annotation is not available, fall back to
// loading annotations for the currently focused group // loading annotations for the currently focused group
...@@ -200,16 +200,20 @@ module.exports = function WidgetController( ...@@ -200,16 +200,20 @@ module.exports = function WidgetController(
$scope.scrollTo = scrollToAnnotation; $scope.scrollTo = scrollToAnnotation;
$scope.hasFocus = function (annotation) { $scope.hasFocus = function (annotation) {
if (!annotation || !$scope.focusedAnnotations) { if (!annotation || !annotationUI.getState().focusedAnnotationMap) {
return false; return false;
} }
return annotation.$$tag in $scope.focusedAnnotations; return annotation.$$tag in annotationUI.getState().focusedAnnotationMap;
}; };
function selectedID() {
return firstKey(annotationUI.getState().selectedAnnotationMap);
}
$scope.selectedAnnotationUnavailable = function () { $scope.selectedAnnotationUnavailable = function () {
return !isLoading() && return !isLoading() &&
annotationUI.hasSelectedAnnotations() && !!selectedID() &&
!threading.idTable[firstKey(annotationUI.selectedAnnotationMap)]; !threading.idTable[selectedID()];
}; };
$scope.shouldShowLoggedOutMessage = function () { $scope.shouldShowLoggedOutMessage = function () {
...@@ -228,8 +232,8 @@ module.exports = function WidgetController( ...@@ -228,8 +232,8 @@ module.exports = function WidgetController(
// 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.
return !isLoading() && return !isLoading() &&
annotationUI.hasSelectedAnnotations() && !!selectedID() &&
!!threading.idTable[firstKey(annotationUI.selectedAnnotationMap)]; !!threading.idTable[selectedID()];
}; };
$scope.isLoading = isLoading; $scope.isLoading = isLoading;
......
...@@ -55,8 +55,10 @@ ...@@ -55,8 +55,10 @@
"query-string": "^3.0.1", "query-string": "^3.0.1",
"raf": "^3.1.0", "raf": "^3.1.0",
"raven-js": "^2.0.2", "raven-js": "^2.0.2",
"redux": "^3.5.2",
"retry": "^0.8.0", "retry": "^0.8.0",
"scroll-into-view": "^1.3.1", "scroll-into-view": "^1.3.1",
"seamless-immutable": "^6.0.1",
"showdown": "^1.2.1", "showdown": "^1.2.1",
"stringify": "^5.1.0", "stringify": "^5.1.0",
"through2": "^2.0.1", "through2": "^2.0.1",
......
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