Commit 343237bb authored by Robert Knight's avatar Robert Knight

Move real-time update state to store

Move state related to received-but-not-yet-applied annotation updates
("pending updates") and deletions from the `streamer` service to a new
`real-time-updates` module in the store.

This will make it possible for React-based UI to reflect this state and
update when it changes. It also removes some usage of Angular-specific
logic (`$rootScope`) from the streamer service.

Applying pending updates still requires a call to the
`applyPendingUpdates` method of the streamer because that currently
needs to trigger side effects and dispatches Angular events.

 - Move `pendingUpdates` and `pendingDeletions` local variables in
   streamer to `real-time-updates` store module

 - Replace calls to `store.{countPendingUpdates, hasPendingDeletion}`
   with calls to the store instead

 - Call store/streamer methods related to real-time updates directly
   from `<top-bar>` instead of passing it down from `<hypothesis-app>`.

   The `<top-bar>` component is not likely to be used outside the app,
   so there is no benefit to the indirection
parent cbb01a67
......@@ -60,8 +60,7 @@ function AnnotationController(
permissions,
serviceUrl,
session,
settings,
streamer
settings
) {
const self = this;
let newlyCreatedByHighlightButton;
......@@ -526,7 +525,7 @@ function AnnotationController(
};
this.isDeleted = function() {
return streamer.hasPendingDeletion(self.annotation.id);
return store.hasPendingDeletion(self.annotation.id);
};
this.isHiddenByModerator = function() {
......
......@@ -51,8 +51,7 @@ function HypothesisAppController(
groups,
serviceUrl,
session,
settings,
streamer
settings
) {
const self = this;
......@@ -193,9 +192,6 @@ function HypothesisAppController(
store.setFilterQuery(query);
},
};
this.countPendingUpdates = streamer.countPendingUpdates;
this.applyPendingUpdates = streamer.applyPendingUpdates;
}
module.exports = {
......
......@@ -116,7 +116,6 @@ describe('annotation', function() {
let fakeSettings;
let fakeApi;
let fakeBridge;
let fakeStreamer;
let sandbox;
beforeEach(() => {
......@@ -189,6 +188,7 @@ describe('annotation', function() {
};
fakeStore = {
hasPendingDeletion: sinon.stub(),
updateFlagStatus: sandbox.stub().returns(true),
};
......@@ -253,10 +253,6 @@ describe('annotation', function() {
call: sinon.stub(),
};
fakeStreamer = {
hasPendingDeletion: sinon.stub(),
};
$provide.value('analytics', fakeAnalytics);
$provide.value('annotationMapper', fakeAnnotationMapper);
$provide.value('store', fakeStore);
......@@ -269,7 +265,6 @@ describe('annotation', function() {
$provide.value('session', fakeSession);
$provide.value('serviceUrl', fakeServiceUrl);
$provide.value('settings', fakeSettings);
$provide.value('streamer', fakeStreamer);
})
);
......@@ -861,13 +856,13 @@ describe('annotation', function() {
describe('#isDeleted', function() {
it('returns true if the annotation has been marked as deleted', function() {
const controller = createDirective().controller;
fakeStreamer.hasPendingDeletion.returns(true);
fakeStore.hasPendingDeletion.returns(true);
assert.equal(controller.isDeleted(), true);
});
it('returns false if the annotation has not been marked as deleted', function() {
const controller = createDirective().controller;
fakeStreamer.hasPendingDeletion.returns(false);
fakeStore.hasPendingDeletion.returns(false);
assert.equal(controller.isDeleted(), false);
});
});
......
......@@ -27,7 +27,6 @@ describe('sidebar.components.hypothesis-app', function() {
let fakeRoute = null;
let fakeServiceUrl = null;
let fakeSettings = null;
let fakeStreamer = null;
let fakeWindow = null;
let sandbox = null;
......@@ -122,10 +121,6 @@ describe('sidebar.components.hypothesis-app', function() {
fakeServiceUrl = sinon.stub();
fakeSettings = {};
fakeStreamer = {
countPendingUpdates: sinon.stub(),
applyPendingUpdates: sinon.stub(),
};
fakeBridge = {
call: sandbox.stub(),
};
......@@ -141,7 +136,6 @@ describe('sidebar.components.hypothesis-app', function() {
$provide.value('session', fakeSession);
$provide.value('settings', fakeSettings);
$provide.value('bridge', fakeBridge);
$provide.value('streamer', fakeStreamer);
$provide.value('groups', fakeGroups);
$provide.value('$route', fakeRoute);
$provide.value('$location', fakeLocation);
......
......@@ -7,6 +7,8 @@ const util = require('../../directive/test/util');
describe('topBar', function() {
const fakeSettings = {};
let fakeStore;
let fakeStreamer;
let fakeIsThirdPartyService;
before(function() {
......@@ -26,8 +28,18 @@ describe('topBar', function() {
});
beforeEach(function() {
fakeStore = {
pendingUpdateCount: sinon.stub().returns(0),
};
fakeStreamer = {
applyPendingUpdates: sinon.stub(),
};
angular.mock.module('app', {
settings: fakeSettings,
store: fakeStore,
streamer: fakeStreamer,
});
fakeIsThirdPartyService = sinon.stub().returns(false);
......@@ -61,30 +73,25 @@ describe('topBar', function() {
}
it('shows the pending update count', function() {
const el = createTopBar({
pendingUpdateCount: 1,
});
fakeStore.pendingUpdateCount.returns(1);
const el = createTopBar();
const applyBtn = applyUpdateBtn(el[0]);
assert.ok(applyBtn);
});
it('does not show the pending update count when there are no updates', function() {
const el = createTopBar({
pendingUpdateCount: 0,
});
fakeStore.pendingUpdateCount.returns(0);
const el = createTopBar();
const applyBtn = applyUpdateBtn(el[0]);
assert.notOk(applyBtn);
});
it('applies updates when clicked', function() {
const onApplyPendingUpdates = sinon.stub();
const el = createTopBar({
pendingUpdateCount: 1,
onApplyPendingUpdates: onApplyPendingUpdates,
});
fakeStore.pendingUpdateCount.returns(1);
const el = createTopBar();
const applyBtn = applyUpdateBtn(el[0]);
applyBtn.click();
assert.called(onApplyPendingUpdates);
assert.called(fakeStreamer.applyPendingUpdates);
});
it('shows help when help icon clicked', function() {
......
......@@ -5,13 +5,17 @@ const isThirdPartyService = require('../util/is-third-party-service');
module.exports = {
controllerAs: 'vm',
//@ngInject
controller: function(settings) {
controller: function(settings, store, streamer) {
if (settings.theme && settings.theme === 'clean') {
this.isThemeClean = true;
} else {
this.isThemeClean = false;
}
this.applyPendingUpdates = streamer.applyPendingUpdates;
this.pendingUpdateCount = store.pendingUpdateCount;
this.showSharePageButton = function() {
return !isThirdPartyService(settings);
};
......@@ -25,8 +29,6 @@ module.exports = {
onSharePage: '&',
onSignUp: '&',
searchController: '<',
pendingUpdateCount: '<',
onApplyPendingUpdates: '&',
},
template: require('../templates/top-bar.html'),
};
......@@ -5,7 +5,6 @@ const uuid = require('node-uuid');
const warnOnce = require('../../shared/warn-once');
const events = require('../events');
const Socket = require('../websocket');
/**
......@@ -41,23 +40,6 @@ function Streamer(
// established.
const configMessages = {};
// The streamer maintains a set of pending updates and deletions which have
// been received via the WebSocket but not yet applied to the contents of the
// app.
//
// This state should be managed as part of the global app state in
// store, but that is currently difficult because applying updates
// requires filtering annotations against the focused group (information not
// currently stored in the app state) and triggering events in order to update
// the annotations displayed in the page.
// Map of ID -> updated annotation for updates that have been received over
// the WS but not yet applied
let pendingUpdates = {};
// Set of IDs of annotations which have been deleted but for which the
// deletion has not yet been applied
let pendingDeletions = {};
function handleAnnotationNotification(message) {
const action = message.options.action;
const annotations = message.payload;
......@@ -66,31 +48,10 @@ function Streamer(
case 'create':
case 'update':
case 'past':
annotations.forEach(function(ann) {
// In the sidebar, only save pending updates for annotations in the
// focused group, since we only display annotations from the focused
// group and reload all annotations and discard pending updates
// when switching groups.
if (ann.group === groups.focused().id || !store.isSidebar()) {
pendingUpdates[ann.id] = ann;
}
});
store.receiveRealTimeUpdates({ updatedAnnotations: annotations });
break;
case 'delete':
annotations.forEach(function(ann) {
// Discard any pending but not-yet-applied updates for this annotation
delete pendingUpdates[ann.id];
// If we already have this annotation loaded, then record a pending
// deletion. We do not check the group of the annotation here because a)
// that information is not included with deletion notifications and b)
// even if the annotation is from the current group, it might be for a
// new annotation (saved in pendingUpdates and removed above), that has
// not yet been loaded.
if (store.annotationExists(ann.id)) {
pendingDeletions[ann.id] = true;
}
});
store.receiveRealTimeUpdates({ deletedAnnotations: annotations });
break;
}
......@@ -257,61 +218,20 @@ function Streamer(
}
function applyPendingUpdates() {
const updates = Object.values(pendingUpdates);
const deletions = Object.keys(pendingDeletions).map(function(id) {
return { id: id };
});
const updates = Object.values(store.pendingUpdates());
if (updates.length) {
annotationMapper.loadAnnotations(updates);
}
const deletions = Object.keys(store.pendingDeletions()).map(id => ({ id }));
if (deletions.length) {
annotationMapper.unloadAnnotations(deletions);
}
pendingUpdates = {};
pendingDeletions = {};
store.clearPendingUpdates();
}
function countPendingUpdates() {
return (
Object.keys(pendingUpdates).length + Object.keys(pendingDeletions).length
);
}
function hasPendingDeletion(id) {
return pendingDeletions.hasOwnProperty(id);
}
function removePendingUpdates(event, anns) {
if (!Array.isArray(anns)) {
anns = [anns];
}
anns.forEach(function(ann) {
delete pendingUpdates[ann.id];
delete pendingDeletions[ann.id];
});
}
function clearPendingUpdates() {
pendingUpdates = {};
pendingDeletions = {};
}
const updateEvents = [
events.ANNOTATION_DELETED,
events.ANNOTATION_UPDATED,
events.ANNOTATIONS_UNLOADED,
];
updateEvents.forEach(function(event) {
$rootScope.$on(event, removePendingUpdates);
});
$rootScope.$on(events.GROUP_FOCUSED, clearPendingUpdates);
this.applyPendingUpdates = applyPendingUpdates;
this.countPendingUpdates = countPendingUpdates;
this.hasPendingDeletion = hasPendingDeletion;
this.clientId = clientId;
this.configMessages = configMessages;
this.connect = connect;
......
......@@ -2,7 +2,6 @@
const EventEmitter = require('tiny-emitter');
const events = require('../../events');
const unroll = require('../../../shared/test/util').unroll;
const Streamer = require('../streamer');
......@@ -122,12 +121,18 @@ describe('Streamer', function() {
fakeStore = {
annotationExists: sinon.stub().returns(false),
isSidebar: sinon.stub().returns(true),
clearPendingUpdates: sinon.stub(),
getState: sinon.stub().returns({
session: {
userid: 'jim@hypothes.is',
},
}),
hasPendingDeletion: sinon.stub().returns(false),
isSidebar: sinon.stub().returns(true),
pendingUpdateCount: sinon.stub().returns(0),
pendingUpdates: sinon.stub().returns({}),
pendingDeletions: sinon.stub().returns({}),
receiveRealTimeUpdates: sinon.stub(),
};
fakeGroups = {
......@@ -278,20 +283,17 @@ describe('Streamer', function() {
fakeStore.isSidebar.returns(false);
});
it('does not defer updates', function() {
fakeWebSocket.notify(fixtures.createNotification);
assert.calledWith(
fakeAnnotationMapper.loadAnnotations,
fixtures.createNotification.payload
);
it('applies updates immediately', function() {
const [ann] = fixtures.createNotification.payload;
fakeStore.pendingUpdates.returns({
[ann.id]: ann,
});
it('applies updates from all groups', function() {
fakeGroups.focused.returns({ id: 'private' });
fakeWebSocket.notify(fixtures.createNotification);
assert.calledWith(fakeStore.receiveRealTimeUpdates, {
updatedAnnotations: [ann],
});
assert.calledWith(
fakeAnnotationMapper.loadAnnotations,
fixtures.createNotification.payload
......@@ -302,56 +304,37 @@ describe('Streamer', function() {
context('when the app is the sidebar', function() {
it('saves pending updates', function() {
fakeWebSocket.notify(fixtures.createNotification);
assert.equal(activeStreamer.countPendingUpdates(), 1);
assert.calledWith(fakeStore.receiveRealTimeUpdates, {
updatedAnnotations: fixtures.createNotification.payload,
});
it('does not save pending updates for annotations in unfocused groups', function() {
fakeGroups.focused.returns({ id: 'private' });
fakeWebSocket.notify(fixtures.createNotification);
assert.equal(activeStreamer.countPendingUpdates(), 0);
});
it('saves pending deletions if the annotation is loaded', function() {
const id = fixtures.deleteNotification.payload[0].id;
fakeStore.annotationExists.returns(true);
it('saves pending deletions', function() {
fakeWebSocket.notify(fixtures.deleteNotification);
assert.isTrue(activeStreamer.hasPendingDeletion(id));
assert.equal(activeStreamer.countPendingUpdates(), 1);
assert.calledWith(fakeStore.receiveRealTimeUpdates, {
deletedAnnotations: fixtures.deleteNotification.payload,
});
it('discards pending deletions if the annotation is not loaded', function() {
const id = fixtures.deleteNotification.payload[0].id;
fakeStore.annotationExists.returns(false);
fakeWebSocket.notify(fixtures.deleteNotification);
assert.isFalse(activeStreamer.hasPendingDeletion(id));
});
it('saves one pending update per annotation', function() {
fakeWebSocket.notify(fixtures.createNotification);
fakeWebSocket.notify(fixtures.updateNotification);
assert.equal(activeStreamer.countPendingUpdates(), 1);
it('does not apply updates immediately', function() {
const ann = fixtures.createNotification.payload;
fakeStore.pendingUpdates.returns({
[ann.id]: ann,
});
it('discards pending updates if an unloaded annotation is deleted', function() {
fakeStore.annotationExists.returns(false);
fakeWebSocket.notify(fixtures.createNotification);
fakeWebSocket.notify(fixtures.deleteNotification);
assert.equal(activeStreamer.countPendingUpdates(), 0);
});
it('does not apply updates immediately', function() {
fakeWebSocket.notify(fixtures.createNotification);
assert.notCalled(fakeAnnotationMapper.loadAnnotations);
});
it('does not apply deletions immediately', function() {
const ann = fixtures.deleteNotification.payload;
fakeStore.pendingDeletions.returns({
[ann.id]: true,
});
fakeWebSocket.notify(fixtures.deleteNotification);
assert.notCalled(fakeAnnotationMapper.unloadAnnotations);
});
});
......@@ -364,18 +347,16 @@ describe('Streamer', function() {
});
it('applies pending updates', function() {
fakeWebSocket.notify(fixtures.createNotification);
fakeStore.pendingUpdates.returns({ 'an-id': { id: 'an-id' } });
activeStreamer.applyPendingUpdates();
assert.calledWith(
fakeAnnotationMapper.loadAnnotations,
fixtures.createNotification.payload
);
assert.calledWith(fakeAnnotationMapper.loadAnnotations, [
{ id: 'an-id' },
]);
});
it('applies pending deletions', function() {
fakeStore.annotationExists.returns(true);
fakeStore.pendingDeletions.returns({ 'an-id': true });
fakeWebSocket.notify(fixtures.deleteNotification);
activeStreamer.applyPendingUpdates();
assert.calledWithMatch(
......@@ -387,56 +368,7 @@ describe('Streamer', function() {
it('clears the set of pending updates', function() {
fakeWebSocket.notify(fixtures.createNotification);
activeStreamer.applyPendingUpdates();
assert.equal(activeStreamer.countPendingUpdates(), 0);
});
});
describe('when annotations are unloaded, updated or deleted', function() {
const changeEvents = [
{ event: events.ANNOTATION_DELETED },
{ event: events.ANNOTATION_UPDATED },
{ event: events.ANNOTATIONS_UNLOADED },
];
beforeEach(function() {
createDefaultStreamer();
return activeStreamer.connect();
});
unroll(
'discards pending updates when #event occurs',
function(testCase) {
fakeWebSocket.notify(fixtures.createNotification);
assert.equal(activeStreamer.countPendingUpdates(), 1);
fakeRootScope.$broadcast(testCase.event, { id: 'an-id' });
assert.equal(activeStreamer.countPendingUpdates(), 0);
},
changeEvents
);
unroll(
'discards pending deletions when #event occurs',
function(testCase) {
fakeStore.annotationExists.returns(true);
fakeWebSocket.notify(fixtures.deleteNotification);
fakeRootScope.$broadcast(testCase.event, { id: 'an-id' });
assert.isFalse(activeStreamer.hasPendingDeletion('an-id'));
},
changeEvents
);
});
describe('when the focused group changes', function() {
it('clears pending updates and deletions', function() {
createDefaultStreamer();
return activeStreamer.connect().then(function() {
fakeWebSocket.notify(fixtures.createNotification);
fakeRootScope.$broadcast(events.GROUP_FOCUSED);
assert.equal(activeStreamer.countPendingUpdates(), 0);
});
assert.calledWith(fakeStore.clearPendingUpdates);
});
});
......
......@@ -40,6 +40,7 @@ const directLinked = require('./modules/direct-linked');
const frames = require('./modules/frames');
const links = require('./modules/links');
const groups = require('./modules/groups');
const realTimeUpdates = require('./modules/real-time-updates');
const selection = require('./modules/selection');
const session = require('./modules/session');
const viewer = require('./modules/viewer');
......@@ -91,6 +92,7 @@ function store($rootScope, settings) {
frames,
links,
groups,
realTimeUpdates,
selection,
session,
viewer,
......
'use strict';
/**
* This module contains state related to real-time updates received via the
* WebSocket connection to h's real-time API.
*/
const { createSelector } = require('reselect');
const { actionTypes } = require('../util');
const { selectors: annotationSelectors } = require('./annotations');
const { selectors: groupSelectors } = require('./groups');
const { selectors: viewerSelectors } = require('./viewer');
function init() {
return {
// Map of ID -> updated annotation for updates that have been received over
// the WebSocket but not yet applied
pendingUpdates: {},
// Set of IDs of annotations which have been deleted but for which the
// deletion has not yet been applied
pendingDeletions: {},
};
}
const update = {
RECEIVE_REAL_TIME_UPDATES(
state,
{ updatedAnnotations = [], deletedAnnotations = [] }
) {
const pendingUpdates = { ...state.pendingUpdates };
const pendingDeletions = { ...state.pendingDeletions };
updatedAnnotations.forEach(ann => {
// In the sidebar, only save pending updates for annotations in the
// focused group, since we only display annotations from the focused
// group and reload all annotations and discard pending updates
// when switching groups.
if (
ann.group === groupSelectors.focusedGroupId(state) ||
!viewerSelectors.isSidebar(state)
) {
pendingUpdates[ann.id] = ann;
}
});
deletedAnnotations.forEach(ann => {
// Discard any pending but not-yet-applied updates for this annotation
delete pendingUpdates[ann.id];
// If we already have this annotation loaded, then record a pending
// deletion. We do not check the group of the annotation here because a)
// that information is not included with deletion notifications and b)
// even if the annotation is from the current group, it might be for a
// new annotation (saved in pendingUpdates and removed above), that has
// not yet been loaded.
if (annotationSelectors.annotationExists(state, ann.id)) {
pendingDeletions[ann.id] = true;
}
});
return { pendingUpdates, pendingDeletions };
},
CLEAR_PENDING_UPDATES() {
return { pendingUpdates: {}, pendingDeletions: {} };
},
ADD_ANNOTATIONS(state, { annotations }) {
// Discard any pending updates which conflict with an annotation added
// locally or fetched via an API call.
//
// If there is a conflicting local update/remote delete then we keep
// the pending delete. The UI should prevent the user from editing an
// annotation that has been deleted on the server.
const pendingUpdates = { ...state.pendingUpdates };
annotations.forEach(ann => delete pendingUpdates[ann.id]);
return { pendingUpdates };
},
REMOVE_ANNOTATIONS(state, { annotations }) {
// Discard any pending updates which conflict with an annotation removed
// locally.
const pendingUpdates = { ...state.pendingUpdates };
const pendingDeletions = { ...state.pendingDeletions };
annotations.forEach(ann => {
delete pendingUpdates[ann.id];
delete pendingDeletions[ann.id];
});
return { pendingUpdates, pendingDeletions };
},
FOCUS_GROUP() {
// When switching groups we clear and re-fetch all annotations, so discard
// any pending updates.
return { pendingUpdates: {}, pendingDeletions: {} };
},
};
const actions = actionTypes(update);
/**
* Record pending updates representing changes on the server that the client
* has been notified about but has not yet applied.
*
* @param {Object} args
* @param {Annotation[]} args.updatedAnnotations
* @param {Annotation[]} args.deletedAnnotations
*/
function receiveRealTimeUpdates({ updatedAnnotations, deletedAnnotations }) {
return {
type: actions.RECEIVE_REAL_TIME_UPDATES,
updatedAnnotations,
deletedAnnotations,
};
}
/**
* Clear the queue of real-time updates which have been received but not applied.
*/
function clearPendingUpdates() {
return {
type: actions.CLEAR_PENDING_UPDATES,
};
}
/**
* Return added or updated annotations received via the WebSocket
* which have not been applied to the local state.
*
* @return {{[id: string]: Annotation}}
*/
function pendingUpdates(state) {
return state.pendingUpdates;
}
/**
* Return IDs of annotations which have been deleted on the server but not
* yet removed from the local state.
*
* @return {{[id: string]: boolean}}
*/
function pendingDeletions(state) {
return state.pendingDeletions;
}
/**
* Return a total count of pending updates and deletions.
*/
const pendingUpdateCount = createSelector(
state => [state.pendingUpdates, state.pendingDeletions],
([pendingUpdates, pendingDeletions]) =>
Object.keys(pendingUpdates).length + Object.keys(pendingDeletions).length
);
/**
* Return true if an annotation has been deleted on the server but the deletion
* has not yet been applied.
*/
function hasPendingDeletion(state, id) {
return state.pendingDeletions.hasOwnProperty(id);
}
module.exports = {
init,
update,
actions: {
receiveRealTimeUpdates,
clearPendingUpdates,
},
selectors: {
hasPendingDeletion,
pendingDeletions,
pendingUpdates,
pendingUpdateCount,
},
};
'use strict';
const createStore = require('../../create-store');
const annotations = require('../annotations');
const groups = require('../groups');
const realTimeUpdates = require('../real-time-updates');
const { removeAnnotations } = annotations.actions;
const { focusGroup } = groups.actions;
describe('sidebar/store/modules/real-time-updates', () => {
let fakeAnnotationExists;
let fakeFocusedGroupId;
let fakeIsSidebar;
let store;
beforeEach(() => {
fakeAnnotationExists = sinon.stub().returns(true);
fakeFocusedGroupId = sinon.stub().returns('group-1');
fakeIsSidebar = sinon.stub().returns(true);
store = createStore([realTimeUpdates]);
realTimeUpdates.$imports.$mock({
'./annotations': {
selectors: { annotationExists: fakeAnnotationExists },
},
'./groups': {
selectors: { focusedGroupId: fakeFocusedGroupId },
},
'./viewer': {
selectors: { isSidebar: fakeIsSidebar },
},
});
});
afterEach(() => {
realTimeUpdates.$imports.$restore();
});
function addPendingUpdates(store) {
const updates = [
{ id: 'updated-ann', group: 'group-1' },
{ id: 'created-ann', group: 'group-1' },
];
store.receiveRealTimeUpdates({
updatedAnnotations: updates,
});
return updates;
}
function addPendingDeletions(store) {
const deletions = [{ id: 'deleted-ann' }];
store.receiveRealTimeUpdates({
deletedAnnotations: deletions,
});
return deletions;
}
describe('receiveRealTimeUpdates', () => {
it("adds pending updates where the focused group matches the annotation's group", () => {
addPendingUpdates(store);
assert.deepEqual(store.pendingUpdates(), {
'updated-ann': { id: 'updated-ann', group: 'group-1' },
'created-ann': { id: 'created-ann', group: 'group-1' },
});
});
it("does not add pending updates if the focused group does not match the annotation's group", () => {
fakeFocusedGroupId.returns('other-group');
assert.deepEqual(store.pendingUpdates(), {});
});
it('always adds pending updates in the stream where there is no focused group', () => {
fakeFocusedGroupId.returns(null);
fakeIsSidebar.returns(false);
addPendingUpdates(store);
assert.deepEqual(store.pendingUpdates(), {
'updated-ann': { id: 'updated-ann', group: 'group-1' },
'created-ann': { id: 'created-ann', group: 'group-1' },
});
});
it('adds pending deletions if the annotation exists locally', () => {
fakeAnnotationExists.returns(true);
addPendingDeletions(store);
assert.deepEqual(store.pendingDeletions(), {
'deleted-ann': true,
});
});
it('does not add pending deletions if the annotation does not exist locally', () => {
fakeAnnotationExists.returns(false);
addPendingDeletions(store);
assert.deepEqual(store.pendingDeletions(), {});
});
});
describe('clearPendingUpdates', () => {
it('clears pending updates', () => {
addPendingUpdates(store);
store.clearPendingUpdates();
assert.deepEqual(store.pendingUpdates(), {});
});
it('clears pending deletions', () => {
addPendingDeletions(store);
store.clearPendingUpdates();
assert.deepEqual(store.pendingDeletions(), {});
});
});
describe('pendingUpdateCount', () => {
it('returns the total number of pending updates', () => {
const updates = addPendingUpdates(store);
const deletes = addPendingDeletions(store);
assert.deepEqual(
store.pendingUpdateCount(),
updates.length + deletes.length
);
});
});
it('clears pending updates when annotations are added/updated', () => {
const updates = addPendingUpdates(store);
// Dispatch `ADD_ANNOTATIONS` directly here rather than using
// the `addAnnotations` action creator because that has side effects.
store.dispatch({ type: 'ADD_ANNOTATIONS', annotations: updates });
assert.deepEqual(store.pendingUpdateCount(), 0);
});
it('clears pending updates when annotations are removed', () => {
const updates = addPendingUpdates(store);
store.dispatch(removeAnnotations(updates));
assert.deepEqual(store.pendingUpdateCount(), 0);
});
it('clears pending updates when focused group changes', () => {
addPendingUpdates(store);
addPendingDeletions(store);
store.dispatch(focusGroup('123'));
assert.deepEqual(store.pendingUpdateCount(), 0);
});
describe('hasPendingDeletion', () => {
it('returns false if there are no pending deletions', () => {
assert.equal(store.hasPendingDeletion('deleted-ann'), false);
});
it('returns true if there are pending deletions', () => {
addPendingDeletions(store);
assert.equal(store.hasPendingDeletion('deleted-ann'), true);
});
});
});
......@@ -7,8 +7,6 @@
on-share-page="vm.share()"
on-show-help-panel="vm.showHelpPanel()"
is-sidebar="::vm.isSidebar"
pending-update-count="vm.countPendingUpdates()"
on-apply-pending-updates="vm.applyPendingUpdates()"
search-controller="vm.search">
</top-bar>
......
......@@ -34,11 +34,11 @@
<group-list class="group-list" auth="vm.auth"></group-list>
<div class="top-bar__expander"></div>
<a class="top-bar__apply-update-btn"
ng-if="vm.pendingUpdateCount > 0"
ng-click="vm.onApplyPendingUpdates()"
ng-if="vm.pendingUpdateCount() > 0"
ng-click="vm.applyPendingUpdates()"
h-tooltip
tooltip-direction="up"
aria-label="Show {{vm.pendingUpdateCount}} new/updated annotation(s)">
aria-label="Show {{vm.pendingUpdateCount()}} new/updated annotation(s)">
<svg-icon class="top-bar__apply-icon" name="'refresh'"></svg-icon>
</a>
<search-input
......
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