Unverified Commit b1895465 authored by Robert Knight's avatar Robert Knight Committed by GitHub

Merge pull request #1993 from hypothesis/remove-annotation-mapper

Remove annotation-mapper service and associated Angular events
parents b3936370 eb5c1c5d
...@@ -16,7 +16,7 @@ import Button from './button'; ...@@ -16,7 +16,7 @@ import Button from './button';
*/ */
function AnnotationActionBar({ function AnnotationActionBar({
annotation, annotation,
annotationMapper, annotationsService,
onReply, onReply,
settings, settings,
toastMessenger, toastMessenger,
...@@ -40,15 +40,10 @@ function AnnotationActionBar({ ...@@ -40,15 +40,10 @@ function AnnotationActionBar({
const showShareAction = isShareable(annotation, settings); const showShareAction = isShareable(annotation, settings);
const createDraft = useStore(store => store.createDraft); const createDraft = useStore(store => store.createDraft);
const updateFlagFn = useStore(store => store.updateFlagStatus);
const updateFlag = () => {
updateFlagFn(annotation.id, true);
};
const onDelete = () => { const onDelete = () => {
if (window.confirm('Are you sure you want to delete this annotation?')) { if (window.confirm('Are you sure you want to delete this annotation?')) {
annotationMapper.deleteAnnotation(annotation).catch(err => { annotationsService.delete(annotation).catch(err => {
toastMessenger.error(err.message, 'Deleting annotation failed'); toastMessenger.error(err.message, 'Deleting annotation failed');
}); });
} }
...@@ -67,9 +62,8 @@ function AnnotationActionBar({ ...@@ -67,9 +62,8 @@ function AnnotationActionBar({
toastMessenger.error('You must be logged in to report an annotation'); toastMessenger.error('You must be logged in to report an annotation');
return; return;
} }
annotationMapper annotationsService
.flagAnnotation(annotation) // Flag annotation on service .flag(annotation)
.then(updateFlag) // Update app state with flag
.catch(() => toastMessenger.error('Flagging annotation failed')); .catch(() => toastMessenger.error('Flagging annotation failed'));
}; };
...@@ -119,13 +113,13 @@ AnnotationActionBar.propTypes = { ...@@ -119,13 +113,13 @@ AnnotationActionBar.propTypes = {
onReply: propTypes.func.isRequired, onReply: propTypes.func.isRequired,
// Injected services // Injected services
annotationMapper: propTypes.object.isRequired, annotationsService: propTypes.object.isRequired,
settings: propTypes.object.isRequired, settings: propTypes.object.isRequired,
toastMessenger: propTypes.object.isRequired, toastMessenger: propTypes.object.isRequired,
}; };
AnnotationActionBar.injectedProps = [ AnnotationActionBar.injectedProps = [
'annotationMapper', 'annotationsService',
'settings', 'settings',
'toastMessenger', 'toastMessenger',
]; ];
......
...@@ -30,8 +30,7 @@ function AnnotationViewerContentController( ...@@ -30,8 +30,7 @@ function AnnotationViewerContentController(
api, api,
rootThread, rootThread,
streamer, streamer,
streamFilter, streamFilter
annotationMapper
) { ) {
store.clearAnnotations(); store.clearAnnotations();
...@@ -44,7 +43,7 @@ function AnnotationViewerContentController( ...@@ -44,7 +43,7 @@ function AnnotationViewerContentController(
}; };
this.ready = fetchThread(api, annotationId).then(function (annots) { this.ready = fetchThread(api, annotationId).then(function (annots) {
annotationMapper.loadAnnotations(annots); store.addAnnotations(annots);
const topLevelAnnot = annots.filter(function (annot) { const topLevelAnnot = annots.filter(function (annot) {
return (annot.references || []).length === 0; return (annot.references || []).length === 0;
......
...@@ -154,9 +154,7 @@ function HypothesisAppController( ...@@ -154,9 +154,7 @@ function HypothesisAppController(
} }
store.clearGroups(); store.clearGroups();
store.unsavedAnnotations().forEach(function (annotation) { store.removeAnnotations(store.unsavedAnnotations());
$rootScope.$emit(events.ANNOTATION_DELETED, annotation);
});
store.discardAllDrafts(); store.discardAllDrafts();
if (serviceConfig(settings)) { if (serviceConfig(settings)) {
......
// @ngInject // @ngInject
function StreamContentController( function StreamContentController($scope, store, api, rootThread, searchFilter) {
$scope,
annotationMapper,
store,
api,
rootThread,
searchFilter
) {
/** `offset` parameter for the next search API call. */ /** `offset` parameter for the next search API call. */
let offset = 0; let offset = 0;
/** Load annotations fetched from the API into the app. */ /** Load annotations fetched from the API into the app. */
const load = function (result) { const load = function (result) {
offset += result.rows.length; offset += result.rows.length;
annotationMapper.loadAnnotations(result.rows, result.replies); const annots = [...result.rows, ...result.replies];
store.addAnnotations(annots);
}; };
const currentQuery = () => store.routeParams().q; const currentQuery = () => store.routeParams().q;
......
...@@ -18,7 +18,7 @@ describe('AnnotationActionBar', () => { ...@@ -18,7 +18,7 @@ describe('AnnotationActionBar', () => {
let fakeUserProfile; let fakeUserProfile;
// Fake services // Fake services
let fakeAnnotationMapper; let fakeAnnotationsService;
let fakeToastMessenger; let fakeToastMessenger;
let fakePermits; let fakePermits;
let fakeSettings; let fakeSettings;
...@@ -30,7 +30,7 @@ describe('AnnotationActionBar', () => { ...@@ -30,7 +30,7 @@ describe('AnnotationActionBar', () => {
return mount( return mount(
<AnnotationActionBar <AnnotationActionBar
annotation={fakeAnnotation} annotation={fakeAnnotation}
annotationMapper={fakeAnnotationMapper} annotationsService={fakeAnnotationsService}
toastMessenger={fakeToastMessenger} toastMessenger={fakeToastMessenger}
onReply={fakeOnReply} onReply={fakeOnReply}
settings={fakeSettings} settings={fakeSettings}
...@@ -62,9 +62,9 @@ describe('AnnotationActionBar', () => { ...@@ -62,9 +62,9 @@ describe('AnnotationActionBar', () => {
userid: 'account:foo@bar.com', userid: 'account:foo@bar.com',
}; };
fakeAnnotationMapper = { fakeAnnotationsService = {
deleteAnnotation: sinon.stub().resolves(), delete: sinon.stub().resolves(),
flagAnnotation: sinon.stub().resolves(), flag: sinon.stub().resolves(),
}; };
fakeToastMessenger = { fakeToastMessenger = {
...@@ -84,7 +84,6 @@ describe('AnnotationActionBar', () => { ...@@ -84,7 +84,6 @@ describe('AnnotationActionBar', () => {
isLoggedIn: sinon.stub(), isLoggedIn: sinon.stub(),
openSidebarPanel: sinon.stub(), openSidebarPanel: sinon.stub(),
profile: sinon.stub().returns(fakeUserProfile), profile: sinon.stub().returns(fakeUserProfile),
updateFlagStatus: sinon.stub(),
}; };
$imports.$mock(mockImportedComponents()); $imports.$mock(mockImportedComponents());
...@@ -154,7 +153,7 @@ describe('AnnotationActionBar', () => { ...@@ -154,7 +153,7 @@ describe('AnnotationActionBar', () => {
}); });
assert.calledOnce(confirm); assert.calledOnce(confirm);
assert.notCalled(fakeAnnotationMapper.deleteAnnotation); assert.notCalled(fakeAnnotationsService.delete);
}); });
it('invokes delete on service when confirmed', () => { it('invokes delete on service when confirmed', () => {
...@@ -166,13 +165,13 @@ describe('AnnotationActionBar', () => { ...@@ -166,13 +165,13 @@ describe('AnnotationActionBar', () => {
button.props().onClick(); button.props().onClick();
}); });
assert.calledWith(fakeAnnotationMapper.deleteAnnotation, fakeAnnotation); assert.calledWith(fakeAnnotationsService.delete, fakeAnnotation);
}); });
it('sets a flash message if there is an error with deletion', async () => { it('sets a flash message if there is an error with deletion', async () => {
allowOnly('delete'); allowOnly('delete');
window.confirm.returns(true); window.confirm.returns(true);
fakeAnnotationMapper.deleteAnnotation.rejects(); fakeAnnotationsService.delete.rejects();
const button = getButton(createComponent(), 'trash'); const button = getButton(createComponent(), 'trash');
act(() => { act(() => {
...@@ -264,7 +263,7 @@ describe('AnnotationActionBar', () => { ...@@ -264,7 +263,7 @@ describe('AnnotationActionBar', () => {
}); });
assert.calledOnce(fakeToastMessenger.error); assert.calledOnce(fakeToastMessenger.error);
assert.notCalled(fakeAnnotationMapper.flagAnnotation); assert.notCalled(fakeAnnotationsService.flag);
}); });
it('invokes flag on service when clicked', () => { it('invokes flag on service when clicked', () => {
...@@ -276,26 +275,12 @@ describe('AnnotationActionBar', () => { ...@@ -276,26 +275,12 @@ describe('AnnotationActionBar', () => {
button.props().onClick(); button.props().onClick();
}); });
assert.calledWith(fakeAnnotationMapper.flagAnnotation, fakeAnnotation); assert.calledWith(fakeAnnotationsService.flag, fakeAnnotation);
});
it('updates flag state in store after flagging on service is successful', async () => {
window.confirm.returns(true);
fakeAnnotationMapper.flagAnnotation.resolves(fakeAnnotation);
const button = getButton(createComponent(), 'flag');
act(() => {
button.props().onClick();
});
await fakeAnnotation;
assert.calledWith(fakeStore.updateFlagStatus, fakeAnnotation.id, true);
}); });
it('sets flash error message if flagging fails on service', async () => { it('sets flash error message if flagging fails on service', async () => {
window.confirm.returns(true); window.confirm.returns(true);
fakeAnnotationMapper.flagAnnotation.rejects(); fakeAnnotationsService.flag.rejects();
const button = getButton(createComponent(), 'flag'); const button = getButton(createComponent(), 'flag');
...@@ -304,7 +289,6 @@ describe('AnnotationActionBar', () => { ...@@ -304,7 +289,6 @@ describe('AnnotationActionBar', () => {
}); });
await waitFor(() => fakeToastMessenger.error.called); await waitFor(() => fakeToastMessenger.error.called);
assert.notCalled(fakeStore.updateFlagStatus);
}); });
it('does not show flag action button if user is author', () => { it('does not show flag action button if user is author', () => {
......
...@@ -42,6 +42,7 @@ describe('annotationViewerContent', function () { ...@@ -42,6 +42,7 @@ describe('annotationViewerContent', function () {
function createController(opts) { function createController(opts) {
const locals = { const locals = {
store: { store: {
addAnnotations: sinon.stub(),
clearAnnotations: sinon.stub(), clearAnnotations: sinon.stub(),
setCollapsed: sinon.stub(), setCollapsed: sinon.stub(),
highlightAnnotations: sinon.stub(), highlightAnnotations: sinon.stub(),
...@@ -62,9 +63,6 @@ describe('annotationViewerContent', function () { ...@@ -62,9 +63,6 @@ describe('annotationViewerContent', function () {
}, },
getFilter: function () {}, getFilter: function () {},
}, },
annotationMapper: {
loadAnnotations: sinon.spy(),
},
}; };
let $componentController; let $componentController;
...@@ -85,9 +83,9 @@ describe('annotationViewerContent', function () { ...@@ -85,9 +83,9 @@ describe('annotationViewerContent', function () {
]); ]);
const controller = createController({ api: fakeApi }); const controller = createController({ api: fakeApi });
return controller.ctrl.ready.then(function () { return controller.ctrl.ready.then(function () {
assert.calledOnce(controller.annotationMapper.loadAnnotations); assert.calledOnce(controller.store.addAnnotations);
assert.calledWith( assert.calledWith(
controller.annotationMapper.loadAnnotations, controller.store.addAnnotations,
sinon.match(fakeApi.annots) sinon.match(fakeApi.annots)
); );
}); });
...@@ -114,7 +112,7 @@ describe('annotationViewerContent', function () { ...@@ -114,7 +112,7 @@ describe('annotationViewerContent', function () {
const controller = createController({ api: fakeApi }); const controller = createController({ api: fakeApi });
return controller.ctrl.ready.then(function () { return controller.ctrl.ready.then(function () {
assert.calledWith( assert.calledWith(
controller.annotationMapper.loadAnnotations, controller.store.addAnnotations,
sinon.match(fakeApi.annots) sinon.match(fakeApi.annots)
); );
}); });
......
...@@ -79,6 +79,7 @@ describe('sidebar.components.hypothesis-app', function () { ...@@ -79,6 +79,7 @@ describe('sidebar.components.hypothesis-app', function () {
countDrafts: sandbox.stub().returns(0), countDrafts: sandbox.stub().returns(0),
discardAllDrafts: sandbox.stub(), discardAllDrafts: sandbox.stub(),
unsavedAnnotations: sandbox.stub().returns([]), unsavedAnnotations: sandbox.stub().returns([]),
removeAnnotations: sandbox.stub(),
}; };
fakeAnalytics = { fakeAnalytics = {
...@@ -382,31 +383,22 @@ describe('sidebar.components.hypothesis-app', function () { ...@@ -382,31 +383,22 @@ describe('sidebar.components.hypothesis-app', function () {
assert.called(fakeStore.clearGroups); assert.called(fakeStore.clearGroups);
}); });
it('emits "annotationDeleted" for each unsaved draft annotation', function () { it('removes unsaved annotations', function () {
fakeStore.unsavedAnnotations = sandbox fakeStore.unsavedAnnotations = sandbox
.stub() .stub()
.returns(['draftOne', 'draftTwo', 'draftThree']); .returns(['draftOne', 'draftTwo', 'draftThree']);
const ctrl = createController(); const ctrl = createController();
$rootScope.$emit = sandbox.stub();
ctrl.logout(); ctrl.logout();
assert($rootScope.$emit.calledThrice); assert.calledWith(fakeStore.removeAnnotations, [
assert.deepEqual($rootScope.$emit.firstCall.args, [
'annotationDeleted',
'draftOne', 'draftOne',
]);
assert.deepEqual($rootScope.$emit.secondCall.args, [
'annotationDeleted',
'draftTwo', 'draftTwo',
]);
assert.deepEqual($rootScope.$emit.thirdCall.args, [
'annotationDeleted',
'draftThree', 'draftThree',
]); ]);
}); });
it('discards draft annotations', function () { it('discards drafts', function () {
const ctrl = createController(); const ctrl = createController();
ctrl.logout(); ctrl.logout();
...@@ -414,7 +406,7 @@ describe('sidebar.components.hypothesis-app', function () { ...@@ -414,7 +406,7 @@ describe('sidebar.components.hypothesis-app', function () {
assert(fakeStore.discardAllDrafts.calledOnce); assert(fakeStore.discardAllDrafts.calledOnce);
}); });
it('does not emit "annotationDeleted" if the user cancels the prompt', function () { it('does not remove unsaved annotations if the user cancels the prompt', function () {
const ctrl = createController(); const ctrl = createController();
fakeStore.countDrafts.returns(1); fakeStore.countDrafts.returns(1);
$rootScope.$emit = sandbox.stub(); $rootScope.$emit = sandbox.stub();
...@@ -422,7 +414,7 @@ describe('sidebar.components.hypothesis-app', function () { ...@@ -422,7 +414,7 @@ describe('sidebar.components.hypothesis-app', function () {
ctrl.logout(); ctrl.logout();
assert($rootScope.$emit.notCalled); assert.notCalled(fakeStore.removeAnnotations);
}); });
it('does not discard drafts if the user cancels the prompt', function () { it('does not discard drafts if the user cancels the prompt', function () {
......
...@@ -12,7 +12,6 @@ class FakeRootThread extends EventEmitter { ...@@ -12,7 +12,6 @@ class FakeRootThread extends EventEmitter {
describe('StreamContentController', function () { describe('StreamContentController', function () {
let $componentController; let $componentController;
let fakeAnnotationMapper;
let fakeStore; let fakeStore;
let fakeRootThread; let fakeRootThread;
let fakeSearchFilter; let fakeSearchFilter;
...@@ -25,11 +24,8 @@ describe('StreamContentController', function () { ...@@ -25,11 +24,8 @@ describe('StreamContentController', function () {
}); });
beforeEach(function () { beforeEach(function () {
fakeAnnotationMapper = {
loadAnnotations: sinon.spy(),
};
fakeStore = { fakeStore = {
addAnnotations: sinon.stub(),
clearAnnotations: sinon.spy(), clearAnnotations: sinon.spy(),
routeParams: sinon.stub().returns({ id: 'test' }), routeParams: sinon.stub().returns({ id: 'test' }),
setCollapsed: sinon.spy(), setCollapsed: sinon.spy(),
...@@ -44,9 +40,7 @@ describe('StreamContentController', function () { ...@@ -44,9 +40,7 @@ describe('StreamContentController', function () {
}; };
fakeApi = { fakeApi = {
search: sinon.spy(function () { search: sinon.stub().resolves({ rows: [], replies: [], total: 0 }),
return Promise.resolve({ rows: [], total: 0 });
}),
}; };
fakeStreamer = { fakeStreamer = {
...@@ -65,7 +59,6 @@ describe('StreamContentController', function () { ...@@ -65,7 +59,6 @@ describe('StreamContentController', function () {
fakeRootThread = new FakeRootThread(); fakeRootThread = new FakeRootThread();
angular.mock.module('h', { angular.mock.module('h', {
annotationMapper: fakeAnnotationMapper,
store: fakeStore, store: fakeStore,
api: fakeApi, api: fakeApi,
rootThread: fakeRootThread, rootThread: fakeRootThread,
...@@ -104,12 +97,14 @@ describe('StreamContentController', function () { ...@@ -104,12 +97,14 @@ describe('StreamContentController', function () {
createController(); createController();
return Promise.resolve().then(function () { return Promise.resolve().then(function () {
assert.calledOnce(fakeAnnotationMapper.loadAnnotations); assert.calledOnce(fakeStore.addAnnotations);
assert.calledWith( assert.calledWith(fakeStore.addAnnotations, [
fakeAnnotationMapper.loadAnnotations, 'annotation_1',
['annotation_1', 'annotation_2'], 'annotation_2',
['reply_1', 'reply_2', 'reply_3'] 'reply_1',
); 'reply_2',
'reply_3',
]);
}); });
}); });
......
...@@ -25,19 +25,4 @@ export default { ...@@ -25,19 +25,4 @@ export default {
/** Annotations were anchored in a connected document. */ /** Annotations were anchored in a connected document. */
ANNOTATIONS_SYNCED: 'sync', ANNOTATIONS_SYNCED: 'sync',
/** An annotation was created on the server and assigned an ID. */
ANNOTATION_CREATED: 'annotationCreated',
/** An annotation was either deleted or unloaded. */
ANNOTATION_DELETED: 'annotationDeleted',
/** An annotation was flagged. */
ANNOTATION_FLAGGED: 'annotationFlagged',
/** An annotation has been updated. */
ANNOTATION_UPDATED: 'annotationUpdated',
/** A set of annotations were loaded from the server. */
ANNOTATIONS_LOADED: 'annotationsLoaded',
}; };
...@@ -142,7 +142,6 @@ import threadList from './components/thread-list'; ...@@ -142,7 +142,6 @@ import threadList from './components/thread-list';
import bridgeService from '../shared/bridge'; import bridgeService from '../shared/bridge';
import analyticsService from './services/analytics'; import analyticsService from './services/analytics';
import annotationMapperService from './services/annotation-mapper';
import annotationsService from './services/annotations'; import annotationsService from './services/annotations';
import apiService from './services/api'; import apiService from './services/api';
import apiRoutesService from './services/api-routes'; import apiRoutesService from './services/api-routes';
...@@ -186,7 +185,6 @@ function startAngularApp(config) { ...@@ -186,7 +185,6 @@ function startAngularApp(config) {
// Register services. // Register services.
container container
.register('analytics', analyticsService) .register('analytics', analyticsService)
.register('annotationMapper', annotationMapperService)
.register('annotationsService', annotationsService) .register('annotationsService', annotationsService)
.register('api', apiService) .register('api', apiService)
.register('apiRoutes', apiRoutesService) .register('apiRoutes', apiRoutesService)
...@@ -264,7 +262,6 @@ function startAngularApp(config) { ...@@ -264,7 +262,6 @@ function startAngularApp(config) {
// Register services, the store and utilities with Angular, so that // Register services, the store and utilities with Angular, so that
// Angular components can use them. // Angular components can use them.
.service('analytics', () => container.get('analytics')) .service('analytics', () => container.get('analytics'))
.service('annotationMapper', () => container.get('annotationMapper'))
.service('annotationsService', () => container.get('annotationsService')) .service('annotationsService', () => container.get('annotationsService'))
.service('api', () => container.get('api')) .service('api', () => container.get('api'))
.service('auth', () => container.get('auth')) .service('auth', () => container.get('auth'))
......
import events from '../events';
function getExistingAnnotation(store, id) {
return store.getState().annotations.annotations.find(function (annot) {
return annot.id === id;
});
}
// Wraps the annotation store to trigger events for the CRUD actions
// @ngInject
export default function annotationMapper($rootScope, store, api) {
function loadAnnotations(annotations, replies) {
annotations = annotations.concat(replies || []);
const loaded = [];
annotations.forEach(function (annotation) {
const existing = getExistingAnnotation(store, annotation.id);
if (existing) {
$rootScope.$broadcast(events.ANNOTATION_UPDATED, annotation);
return;
}
loaded.push(annotation);
});
$rootScope.$broadcast(events.ANNOTATIONS_LOADED, loaded);
}
function createAnnotation(annotation) {
$rootScope.$broadcast(events.BEFORE_ANNOTATION_CREATED, annotation);
return annotation;
}
function deleteAnnotation(annotation) {
return api.annotation
.delete({
id: annotation.id,
})
.then(function () {
$rootScope.$broadcast(events.ANNOTATION_DELETED, annotation);
return annotation;
});
}
function flagAnnotation(annot) {
return api.annotation
.flag({
id: annot.id,
})
.then(function () {
$rootScope.$broadcast(events.ANNOTATION_FLAGGED, annot);
return annot;
});
}
return {
loadAnnotations: loadAnnotations,
createAnnotation: createAnnotation,
deleteAnnotation: deleteAnnotation,
flagAnnotation: flagAnnotation,
};
}
...@@ -116,6 +116,22 @@ export default function annotationsService(api, store) { ...@@ -116,6 +116,22 @@ export default function annotationsService(api, store) {
}); });
} }
/**
* Delete an annotation via the API and update the store.
*/
async function delete_(annotation) {
await api.annotation.delete({ id: annotation.id });
store.removeAnnotations([annotation]);
}
/**
* Flag an annotation for review by a moderator.
*/
async function flag(annotation) {
await api.annotation.flag({ id: annotation.id });
store.updateFlagStatus(annotation.id, true);
}
/** /**
* Create a reply to `annotation` by the user `userid` and add to the store. * Create a reply to `annotation` by the user `userid` and add to the store.
* *
...@@ -178,6 +194,8 @@ export default function annotationsService(api, store) { ...@@ -178,6 +194,8 @@ export default function annotationsService(api, store) {
return { return {
create, create,
delete: delete_,
flag,
reply, reply,
save, save,
}; };
......
...@@ -5,7 +5,6 @@ import SearchClient from '../search-client'; ...@@ -5,7 +5,6 @@ import SearchClient from '../search-client';
// @ngInject // @ngInject
export default function loadAnnotationsService( export default function loadAnnotationsService(
annotationMapper,
api, api,
store, store,
streamer, streamer,
...@@ -41,7 +40,7 @@ export default function loadAnnotationsService( ...@@ -41,7 +40,7 @@ export default function loadAnnotationsService(
}); });
searchClient.on('results', results => { searchClient.on('results', results => {
if (results.length) { if (results.length) {
annotationMapper.loadAnnotations(results); store.addAnnotations(results);
} }
}); });
searchClient.on('error', error => { searchClient.on('error', error => {
......
...@@ -98,31 +98,10 @@ export default function RootThread( ...@@ -98,31 +98,10 @@ export default function RootThread(
}); });
} }
// Listen for annotations being created or loaded
// and show them in the UI.
//
// Note: These events could all be converted into actions that are handled by
// the Redux store in store.
const loadEvents = [
events.ANNOTATION_CREATED,
events.ANNOTATION_UPDATED,
events.ANNOTATIONS_LOADED,
];
loadEvents.forEach(function (event) {
$rootScope.$on(event, function (event, annotation) {
store.addAnnotations([].concat(annotation));
});
});
$rootScope.$on(events.BEFORE_ANNOTATION_CREATED, function (event, ann) { $rootScope.$on(events.BEFORE_ANNOTATION_CREATED, function (event, ann) {
annotationsService.create(ann); annotationsService.create(ann);
}); });
// Remove any annotations that are deleted or unloaded
$rootScope.$on(events.ANNOTATION_DELETED, function (event, annotation) {
store.removeAnnotations([annotation]);
});
/** /**
* Build the root conversation thread from the given UI state. * Build the root conversation thread from the given UI state.
* @return {Thread} * @return {Thread}
......
...@@ -8,25 +8,9 @@ import Socket from '../websocket'; ...@@ -8,25 +8,9 @@ import Socket from '../websocket';
* Open a new WebSocket connection to the Hypothesis push notification service. * Open a new WebSocket connection to the Hypothesis push notification service.
* Only one websocket connection may exist at a time, any existing socket is * Only one websocket connection may exist at a time, any existing socket is
* closed. * closed.
*
* @param $rootScope - Scope used to $apply() app state changes
* resulting from WebSocket messages, in order to update
* appropriate watchers.
* @param annotationMapper - The local annotation store
* @param groups - The local groups store
* @param session - Provides access to read and update the session state
* @param settings - Application settings
*/ */
// @ngInject // @ngInject
export default function Streamer( export default function Streamer(store, auth, groups, session, settings) {
$rootScope,
annotationMapper,
store,
auth,
groups,
session,
settings
) {
// The randomly generated session ID // The randomly generated session ID
const clientId = generateHexString(32); const clientId = generateHexString(32);
...@@ -81,32 +65,27 @@ export default function Streamer( ...@@ -81,32 +65,27 @@ export default function Streamer(
} }
function handleSocketOnMessage(event) { function handleSocketOnMessage(event) {
// Wrap message dispatches in $rootScope.$apply() so that const message = JSON.parse(event.data);
// scope watches on app state affected by the received message if (!message) {
// are updated return;
$rootScope.$apply(function () { }
const message = JSON.parse(event.data);
if (!message) {
return;
}
if (message.type === 'annotation-notification') { if (message.type === 'annotation-notification') {
handleAnnotationNotification(message); handleAnnotationNotification(message);
} else if (message.type === 'session-change') { } else if (message.type === 'session-change') {
handleSessionChangeNotification(message); handleSessionChangeNotification(message);
} else if (message.type === 'whoyouare') { } else if (message.type === 'whoyouare') {
const userid = store.getState().session.userid; const userid = store.getState().session.userid;
if (message.userid !== userid) { if (message.userid !== userid) {
console.warn( console.warn(
'WebSocket user ID "%s" does not match logged-in ID "%s"', 'WebSocket user ID "%s" does not match logged-in ID "%s"',
message.userid, message.userid,
userid userid
); );
}
} else {
warnOnce('received unsupported notification', message.type);
} }
}); } else {
warnOnce('received unsupported notification', message.type);
}
} }
function sendClientConfig() { function sendClientConfig() {
...@@ -217,7 +196,7 @@ export default function Streamer( ...@@ -217,7 +196,7 @@ export default function Streamer(
function applyPendingUpdates() { function applyPendingUpdates() {
const updates = Object.values(store.pendingUpdates()); const updates = Object.values(store.pendingUpdates());
if (updates.length) { if (updates.length) {
annotationMapper.loadAnnotations(updates); store.addAnnotations(updates);
} }
const deletions = Object.keys(store.pendingDeletions()).map(id => ({ id })); const deletions = Object.keys(store.pendingDeletions()).map(id => ({ id }));
......
import { Injector } from '../../../shared/injector';
import events from '../../events';
import storeFactory from '../../store';
import annotationMapperFactory from '../annotation-mapper';
import immutable from '../../util/immutable';
describe('annotationMapper', function () {
let $rootScope;
let store;
let fakeApi;
let annotationMapper;
beforeEach(function () {
fakeApi = {
annotation: {
delete: sinon.stub().returns(Promise.resolve({})),
flag: sinon.stub().returns(Promise.resolve({})),
},
};
$rootScope = {
// nb. `$applyAsync` is needed because this test uses the real `store`
// service.
$applyAsync: sinon.stub().yields(),
$broadcast: sinon.stub(),
};
const injector = new Injector()
.register('$rootScope', { value: $rootScope })
.register('api', { value: fakeApi })
.register('settings', { value: {} })
.register('store', storeFactory)
.register('annotationMapper', annotationMapperFactory);
store = injector.get('store');
annotationMapper = injector.get('annotationMapper');
});
describe('#loadAnnotations()', function () {
it('triggers the annotationLoaded event', function () {
const annotations = [{ id: 1 }, { id: 2 }, { id: 3 }];
annotationMapper.loadAnnotations(annotations);
assert.called($rootScope.$broadcast);
assert.calledWith($rootScope.$broadcast, events.ANNOTATIONS_LOADED, [
{ id: 1 },
{ id: 2 },
{ id: 3 },
]);
});
it('also includes replies in the annotationLoaded event', function () {
const annotations = [{ id: 1 }];
const replies = [{ id: 2 }, { id: 3 }];
annotationMapper.loadAnnotations(annotations, replies);
assert.called($rootScope.$broadcast);
assert.calledWith($rootScope.$broadcast, events.ANNOTATIONS_LOADED, [
{ id: 1 },
{ id: 2 },
{ id: 3 },
]);
});
it('triggers the annotationUpdated event for each loaded annotation', function () {
const annotations = immutable([{ id: 1 }, { id: 2 }, { id: 3 }]);
store.addAnnotations(annotations);
annotationMapper.loadAnnotations(annotations);
assert.called($rootScope.$broadcast);
assert.calledWith(
$rootScope.$broadcast,
events.ANNOTATION_UPDATED,
annotations[0]
);
});
it('also triggers annotationUpdated for cached replies', function () {
const annotations = [{ id: 1 }];
const replies = [{ id: 2 }, { id: 3 }, { id: 4 }];
store.addAnnotations([{ id: 3 }]);
annotationMapper.loadAnnotations(annotations, replies);
assert(
$rootScope.$broadcast.calledWith(events.ANNOTATION_UPDATED, { id: 3 })
);
});
it('replaces the properties on the cached annotation with those from the loaded one', function () {
const annotations = [{ id: 1, url: 'http://example.com' }];
store.addAnnotations([{ id: 1, $tag: 'tag1' }]);
annotationMapper.loadAnnotations(annotations);
assert.called($rootScope.$broadcast);
assert.calledWith($rootScope.$broadcast, events.ANNOTATION_UPDATED, {
id: 1,
url: 'http://example.com',
});
});
it('excludes cached annotations from the annotationLoaded event', function () {
const annotations = [{ id: 1, url: 'http://example.com' }];
store.addAnnotations([{ id: 1, $tag: 'tag1' }]);
annotationMapper.loadAnnotations(annotations);
assert.called($rootScope.$broadcast);
assert.calledWith($rootScope.$broadcast, events.ANNOTATIONS_LOADED, []);
});
});
describe('#flagAnnotation()', function () {
it('flags an annotation', function () {
const ann = { id: 'test-id' };
annotationMapper.flagAnnotation(ann);
assert.calledOnce(fakeApi.annotation.flag);
assert.calledWith(fakeApi.annotation.flag, { id: ann.id });
});
it('emits the "annotationFlagged" event', function (done) {
const ann = { id: 'test-id' };
annotationMapper
.flagAnnotation(ann)
.then(function () {
assert.calledWith(
$rootScope.$broadcast,
events.ANNOTATION_FLAGGED,
ann
);
})
.then(done, done);
});
});
describe('#createAnnotation()', function () {
it('creates a new annotation resource', function () {
const ann = {};
const ret = annotationMapper.createAnnotation(ann);
assert.equal(ret, ann);
});
it('emits the "beforeAnnotationCreated" event', function () {
const ann = {};
annotationMapper.createAnnotation(ann);
assert.calledWith(
$rootScope.$broadcast,
events.BEFORE_ANNOTATION_CREATED,
ann
);
});
});
describe('#deleteAnnotation()', function () {
it('deletes the annotation on the server', function () {
const ann = { id: 'test-id' };
annotationMapper.deleteAnnotation(ann);
assert.calledWith(fakeApi.annotation.delete, { id: 'test-id' });
});
it('triggers the "annotationDeleted" event on success', function (done) {
const ann = {};
annotationMapper
.deleteAnnotation(ann)
.then(function () {
assert.calledWith(
$rootScope.$broadcast,
events.ANNOTATION_DELETED,
ann
);
})
.then(done, done);
});
it('does not emit an event on error', function (done) {
fakeApi.annotation.delete.returns(Promise.reject());
const ann = { id: 'test-id' };
annotationMapper
.deleteAnnotation(ann)
.catch(function () {
assert.notCalled($rootScope.$broadcast);
})
.then(done, done);
});
});
});
...@@ -18,6 +18,8 @@ describe('annotationsService', () => { ...@@ -18,6 +18,8 @@ describe('annotationsService', () => {
fakeApi = { fakeApi = {
annotation: { annotation: {
create: sinon.stub().resolves(fixtures.defaultAnnotation()), create: sinon.stub().resolves(fixtures.defaultAnnotation()),
delete: sinon.stub().resolves(),
flag: sinon.stub().resolves(),
update: sinon.stub().resolves(fixtures.defaultAnnotation()), update: sinon.stub().resolves(fixtures.defaultAnnotation()),
}, },
}; };
...@@ -44,9 +46,11 @@ describe('annotationsService', () => { ...@@ -44,9 +46,11 @@ describe('annotationsService', () => {
getDefault: sinon.stub(), getDefault: sinon.stub(),
getDraft: sinon.stub().returns(null), getDraft: sinon.stub().returns(null),
profile: sinon.stub().returns({}), profile: sinon.stub().returns({}),
removeAnnotations: sinon.stub(),
removeDraft: sinon.stub(), removeDraft: sinon.stub(),
selectTab: sinon.stub(), selectTab: sinon.stub(),
setCollapsed: sinon.stub(), setCollapsed: sinon.stub(),
updateFlagStatus: sinon.stub(),
}; };
$imports.$mock({ $imports.$mock({
...@@ -223,6 +227,60 @@ describe('annotationsService', () => { ...@@ -223,6 +227,60 @@ describe('annotationsService', () => {
}); });
}); });
describe('delete', () => {
it('removes the annotation via the API', async () => {
const annot = fixtures.defaultAnnotation();
await svc.delete(annot);
assert.calledWith(fakeApi.annotation.delete, { id: annot.id });
});
it('removes the annotation from the store', async () => {
const annot = fixtures.defaultAnnotation();
await svc.delete(annot);
assert.calledWith(fakeStore.removeAnnotations, [annot]);
});
it('does not remove the annotation from the store if the API call fails', async () => {
fakeApi.annotation.delete.rejects(new Error('Annotation does not exist'));
const annot = fixtures.defaultAnnotation();
try {
await svc.delete(annot);
} catch (e) {
/* Ignored */
}
assert.notCalled(fakeStore.removeAnnotations);
});
});
describe('flag', () => {
it('flags the annotation via the API', async () => {
const annot = fixtures.defaultAnnotation();
await svc.flag(annot);
assert.calledWith(fakeApi.annotation.flag, { id: annot.id });
});
it('updates the flag status in the store', async () => {
const annot = fixtures.defaultAnnotation();
await svc.flag(annot);
assert.calledWith(fakeStore.updateFlagStatus, annot.id, true);
});
it('does not update the flag status if the API call fails', async () => {
fakeApi.annotation.flag.rejects(new Error('Annotation does not exist'));
const annot = fixtures.defaultAnnotation();
try {
await svc.flag(annot);
} catch (e) {
/* Ignored */
}
assert.notCalled(fakeStore.updateFlagStatus);
});
});
describe('reply', () => { describe('reply', () => {
const filledAnnotation = () => { const filledAnnotation = () => {
const annot = fixtures.defaultAnnotation(); const annot = fixtures.defaultAnnotation();
......
...@@ -29,7 +29,6 @@ class FakeSearchClient extends EventEmitter { ...@@ -29,7 +29,6 @@ class FakeSearchClient extends EventEmitter {
} }
describe('loadAnnotationsService', () => { describe('loadAnnotationsService', () => {
let fakeAnnotationMapper;
let fakeApi; let fakeApi;
let fakeStore; let fakeStore;
let fakeStreamer; let fakeStreamer;
...@@ -43,15 +42,12 @@ describe('loadAnnotationsService', () => { ...@@ -43,15 +42,12 @@ describe('loadAnnotationsService', () => {
searchClients = []; searchClients = [];
longRunningSearchClient = false; longRunningSearchClient = false;
fakeAnnotationMapper = {
loadAnnotations: sinon.stub(),
};
fakeApi = { fakeApi = {
search: sinon.stub(), search: sinon.stub(),
}; };
fakeStore = { fakeStore = {
addAnnotations: sinon.stub(),
annotationFetchFinished: sinon.stub(), annotationFetchFinished: sinon.stub(),
annotationFetchStarted: sinon.stub(), annotationFetchStarted: sinon.stub(),
frames: sinon.stub(), frames: sinon.stub(),
...@@ -90,7 +86,6 @@ describe('loadAnnotationsService', () => { ...@@ -90,7 +86,6 @@ describe('loadAnnotationsService', () => {
}) })
); );
return loadAnnotationsService( return loadAnnotationsService(
fakeAnnotationMapper,
fakeApi, fakeApi,
fakeStore, fakeStore,
fakeStreamer, fakeStreamer,
...@@ -119,10 +114,10 @@ describe('loadAnnotationsService', () => { ...@@ -119,10 +114,10 @@ describe('loadAnnotationsService', () => {
const svc = createService(); const svc = createService();
svc.load(fakeUris, fakeGroupId); svc.load(fakeUris, fakeGroupId);
assert.calledWith(fakeAnnotationMapper.loadAnnotations, [ assert.calledWith(fakeStore.addAnnotations, [
sinon.match({ id: fakeUris[0] + '123' }), sinon.match({ id: fakeUris[0] + '123' }),
]); ]);
assert.calledWith(fakeAnnotationMapper.loadAnnotations, [ assert.calledWith(fakeStore.addAnnotations, [
sinon.match({ id: fakeUris[0] + '456' }), sinon.match({ id: fakeUris[0] + '456' }),
]); ]);
}); });
...@@ -151,16 +146,16 @@ describe('loadAnnotationsService', () => { ...@@ -151,16 +146,16 @@ describe('loadAnnotationsService', () => {
]); ]);
svc.load(fakeUris, fakeGroupId); svc.load(fakeUris, fakeGroupId);
assert.calledWith(fakeAnnotationMapper.loadAnnotations, [ assert.calledWith(fakeStore.addAnnotations, [
sinon.match({ id: uri + '123' }), sinon.match({ id: uri + '123' }),
]); ]);
assert.calledWith(fakeAnnotationMapper.loadAnnotations, [ assert.calledWith(fakeStore.addAnnotations, [
sinon.match({ id: fingerprint + '123' }), sinon.match({ id: fingerprint + '123' }),
]); ]);
assert.calledWith(fakeAnnotationMapper.loadAnnotations, [ assert.calledWith(fakeStore.addAnnotations, [
sinon.match({ id: uri + '456' }), sinon.match({ id: uri + '456' }),
]); ]);
assert.calledWith(fakeAnnotationMapper.loadAnnotations, [ assert.calledWith(fakeStore.addAnnotations, [
sinon.match({ id: fingerprint + '456' }), sinon.match({ id: fingerprint + '456' }),
]); ]);
}); });
...@@ -177,9 +172,7 @@ describe('loadAnnotationsService', () => { ...@@ -177,9 +172,7 @@ describe('loadAnnotationsService', () => {
fakeUris[1] + '123', fakeUris[1] + '123',
fakeUris[1] + '456', fakeUris[1] + '456',
].forEach(uri => { ].forEach(uri => {
assert.calledWith(fakeAnnotationMapper.loadAnnotations, [ assert.calledWith(fakeStore.addAnnotations, [sinon.match({ id: uri })]);
sinon.match({ id: uri }),
]);
}); });
}); });
...@@ -235,7 +228,7 @@ describe('loadAnnotationsService', () => { ...@@ -235,7 +228,7 @@ describe('loadAnnotationsService', () => {
const svc = createService(); const svc = createService();
svc.load(fakeUris, fakeGroupId); svc.load(fakeUris, fakeGroupId);
assert.notCalled(fakeAnnotationMapper.loadAnnotations); assert.notCalled(fakeStore.addAnnotations);
}); });
it('calls annotationFetchStarted when it starts searching for annotations', () => { it('calls annotationFetchStarted when it starts searching for annotations', () => {
......
...@@ -366,31 +366,9 @@ describe('rootThread', function () { ...@@ -366,31 +366,9 @@ describe('rootThread', function () {
assert.calledWith(fakeAnnotationsService.create, sinon.match(annot)); assert.calledWith(fakeAnnotationsService.create, sinon.match(annot));
}); });
[
{ event: events.ANNOTATION_CREATED, annotations: annot },
{ event: events.ANNOTATION_UPDATED, annotations: annot },
{ event: events.ANNOTATIONS_LOADED, annotations: [annot] },
].forEach(testCase => {
it(`adds or updates annotations when ${testCase.event} event occurs`, () => {
$rootScope.$broadcast(testCase.event, testCase.annotations);
const annotations = [].concat(testCase.annotations);
assert.notCalled(fakeStore.removeAnnotations);
assert.calledWith(fakeStore.addAnnotations, sinon.match(annotations));
});
});
it('removes annotations when ANNOTATION_DELETED event occurs', function () {
$rootScope.$broadcast(events.ANNOTATION_DELETED, annot);
assert.calledWith(fakeStore.removeAnnotations, sinon.match([annot]));
});
describe('when a new annotation is created', function () { describe('when a new annotation is created', function () {
let existingNewAnnot; let existingNewAnnot;
let onDelete;
beforeEach(function () { beforeEach(function () {
onDelete = sinon.stub();
$rootScope.$on(events.ANNOTATION_DELETED, onDelete);
existingNewAnnot = { $tag: 'a-new-tag' }; existingNewAnnot = { $tag: 'a-new-tag' };
fakeStore.state.annotations.annotations.push(existingNewAnnot); fakeStore.state.annotations.annotations.push(existingNewAnnot);
}); });
...@@ -403,7 +381,6 @@ describe('rootThread', function () { ...@@ -403,7 +381,6 @@ describe('rootThread', function () {
); );
assert.notCalled(fakeStore.removeDraft); assert.notCalled(fakeStore.removeDraft);
assert.notCalled(onDelete);
}); });
it('does not remove saved annotations', function () { it('does not remove saved annotations', function () {
...@@ -416,7 +393,6 @@ describe('rootThread', function () { ...@@ -416,7 +393,6 @@ describe('rootThread', function () {
); );
assert.notCalled(fakeStore.removeDraft); assert.notCalled(fakeStore.removeDraft);
assert.notCalled(onDelete);
}); });
}); });
}); });
......
...@@ -71,19 +71,15 @@ class FakeSocket extends EventEmitter { ...@@ -71,19 +71,15 @@ class FakeSocket extends EventEmitter {
} }
describe('Streamer', function () { describe('Streamer', function () {
let fakeAnnotationMapper;
let fakeStore; let fakeStore;
let fakeAuth; let fakeAuth;
let fakeGroups; let fakeGroups;
let fakeRootScope;
let fakeSession; let fakeSession;
let fakeSettings; let fakeSettings;
let activeStreamer; let activeStreamer;
function createDefaultStreamer() { function createDefaultStreamer() {
activeStreamer = new Streamer( activeStreamer = new Streamer(
fakeRootScope,
fakeAnnotationMapper,
fakeStore, fakeStore,
fakeAuth, fakeAuth,
fakeGroups, fakeGroups,
...@@ -93,29 +89,14 @@ describe('Streamer', function () { ...@@ -93,29 +89,14 @@ describe('Streamer', function () {
} }
beforeEach(function () { beforeEach(function () {
const emitter = new EventEmitter();
fakeAuth = { fakeAuth = {
tokenGetter: function () { tokenGetter: function () {
return Promise.resolve('dummy-access-token'); return Promise.resolve('dummy-access-token');
}, },
}; };
fakeRootScope = {
$apply: function (callback) {
callback();
},
$on: emitter.on.bind(emitter),
$broadcast: function (event, data) {
emitter.emit(event, { event: event }, data);
},
};
fakeAnnotationMapper = {
loadAnnotations: sinon.stub(),
};
fakeStore = { fakeStore = {
addAnnotations: sinon.stub(),
annotationExists: sinon.stub().returns(false), annotationExists: sinon.stub().returns(false),
clearPendingUpdates: sinon.stub(), clearPendingUpdates: sinon.stub(),
getState: sinon.stub().returns({ getState: sinon.stub().returns({
...@@ -290,7 +271,7 @@ describe('Streamer', function () { ...@@ -290,7 +271,7 @@ describe('Streamer', function () {
updatedAnnotations: [ann], updatedAnnotations: [ann],
}); });
assert.calledWith( assert.calledWith(
fakeAnnotationMapper.loadAnnotations, fakeStore.addAnnotations,
fixtures.createNotification.payload fixtures.createNotification.payload
); );
}); });
...@@ -319,7 +300,7 @@ describe('Streamer', function () { ...@@ -319,7 +300,7 @@ describe('Streamer', function () {
fakeWebSocket.notify(fixtures.createNotification); fakeWebSocket.notify(fixtures.createNotification);
assert.notCalled(fakeAnnotationMapper.loadAnnotations); assert.notCalled(fakeStore.addAnnotations);
}); });
it('does not apply deletions immediately', function () { it('does not apply deletions immediately', function () {
...@@ -344,9 +325,7 @@ describe('Streamer', function () { ...@@ -344,9 +325,7 @@ describe('Streamer', function () {
it('applies pending updates', function () { it('applies pending updates', function () {
fakeStore.pendingUpdates.returns({ 'an-id': { id: 'an-id' } }); fakeStore.pendingUpdates.returns({ 'an-id': { id: 'an-id' } });
activeStreamer.applyPendingUpdates(); activeStreamer.applyPendingUpdates();
assert.calledWith(fakeAnnotationMapper.loadAnnotations, [ assert.calledWith(fakeStore.addAnnotations, [{ id: 'an-id' }]);
{ id: 'an-id' },
]);
}); });
it('applies pending deletions', function () { it('applies pending deletions', function () {
......
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