Commit 17e990bb authored by Lyza Danger Gardner's avatar Lyza Danger Gardner Committed by Lyza Gardner

Add AnnotationActivityService

Add new service to notify ancestor frame on annotation activity, if
so configured.
parent 14a1cb73
...@@ -113,6 +113,7 @@ import { ServiceContext } from './service-context'; ...@@ -113,6 +113,7 @@ import { ServiceContext } from './service-context';
// Services. // Services.
import { AnnotationsService } from './services/annotations'; import { AnnotationsService } from './services/annotations';
import { AnnotationActivityService } from './services/annotation-activity';
import { APIService } from './services/api'; import { APIService } from './services/api';
import { APIRoutesService } from './services/api-routes'; import { APIRoutesService } from './services/api-routes';
import { AuthService } from './services/auth'; import { AuthService } from './services/auth';
...@@ -150,6 +151,7 @@ function startApp(settings, appEl) { ...@@ -150,6 +151,7 @@ function startApp(settings, appEl) {
// Register services. // Register services.
container container
.register('annotationsService', AnnotationsService) .register('annotationsService', AnnotationsService)
.register('annotationActivity', AnnotationActivityService)
.register('api', APIService) .register('api', APIService)
.register('apiRoutes', APIRoutesService) .register('apiRoutes', APIRoutesService)
.register('auth', AuthService) .register('auth', AuthService)
......
import * as postMessageJsonRpc from '../util/postmessage-json-rpc';
/**
* @typedef {import('../../types/api').Annotation} Annotation
* @typedef {import('../../types/config').SidebarSettings} SidebarSettings
* @typedef {import('../../types/config').AnnotationEventType} AnnotationEventType
*/
/**
* Send messages to configured ancestor frame on annotation activity
*/
// @inject
export class AnnotationActivityService {
/**
* @param {SidebarSettings} settings
*/
constructor(settings) {
this._rpc = settings.rpc;
this._reportConfig = settings.reportActivity;
}
/**
* @param {AnnotationEventType} eventType
* @param {Annotation} annotation
*/
reportActivity(eventType, annotation) {
if (!this._rpc || !this._reportConfig) {
return;
}
// Determine the appropriate ISO-8601 timestamp for this "activity"
let activityDate;
switch (eventType) {
case 'create':
activityDate = new Date(annotation.created).toISOString();
break;
case 'update':
activityDate = new Date(annotation.updated).toISOString();
break;
default:
activityDate = new Date().toISOString();
}
const data = {
date: activityDate,
annotation: {
id: annotation.id,
},
};
if (this._reportConfig.events.includes(eventType)) {
postMessageJsonRpc.call(
this._rpc.targetFrame,
this._rpc.origin,
this._reportConfig.method,
[eventType, data],
3000
);
}
}
}
...@@ -20,9 +20,11 @@ import { ...@@ -20,9 +20,11 @@ import {
export class AnnotationsService { export class AnnotationsService {
/** /**
* @param {import('./api').APIService} api * @param {import('./api').APIService} api
* @param {import('./annotation-activity').AnnotationActivityService} annotationActivity
* @param {import('../store').SidebarStore} store * @param {import('../store').SidebarStore} store
*/ */
constructor(api, store) { constructor(annotationActivity, api, store) {
this._activity = annotationActivity;
this._api = api; this._api = api;
this._store = store; this._store = store;
} }
...@@ -167,6 +169,7 @@ export class AnnotationsService { ...@@ -167,6 +169,7 @@ export class AnnotationsService {
*/ */
async delete(annotation) { async delete(annotation) {
await this._api.annotation.delete({ id: annotation.id }); await this._api.annotation.delete({ id: annotation.id });
this._activity.reportActivity('delete', annotation);
this._store.removeAnnotations([annotation]); this._store.removeAnnotations([annotation]);
} }
...@@ -177,6 +180,7 @@ export class AnnotationsService { ...@@ -177,6 +180,7 @@ export class AnnotationsService {
*/ */
async flag(annotation) { async flag(annotation) {
await this._api.annotation.flag({ id: annotation.id }); await this._api.annotation.flag({ id: annotation.id });
this._activity.reportActivity('flag', annotation);
this._store.updateFlagStatus(annotation.id, true); this._store.updateFlagStatus(annotation.id, true);
} }
...@@ -208,22 +212,27 @@ export class AnnotationsService { ...@@ -208,22 +212,27 @@ export class AnnotationsService {
*/ */
async save(annotation) { async save(annotation) {
let saved; let saved;
/** @type {import('../../types/config').AnnotationEventType} */
let eventType;
const annotationWithChanges = this._applyDraftChanges(annotation); const annotationWithChanges = this._applyDraftChanges(annotation);
if (!metadata.isSaved(annotation)) { if (!metadata.isSaved(annotation)) {
saved = this._api.annotation.create({}, annotationWithChanges); saved = this._api.annotation.create({}, annotationWithChanges);
eventType = 'create';
} else { } else {
saved = this._api.annotation.update( saved = this._api.annotation.update(
{ id: annotation.id }, { id: annotation.id },
annotationWithChanges annotationWithChanges
); );
eventType = 'update';
} }
let savedAnnotation; let savedAnnotation;
this._store.annotationSaveStarted(annotation); this._store.annotationSaveStarted(annotation);
try { try {
savedAnnotation = await saved; savedAnnotation = await saved;
this._activity.reportActivity(eventType, savedAnnotation);
} finally { } finally {
this._store.annotationSaveFinished(annotation); this._store.annotationSaveFinished(annotation);
} }
......
import * as fixtures from '../../test/annotation-fixtures';
import { AnnotationActivityService, $imports } from '../annotation-activity';
describe('AnnotationActivityService', () => {
let fakePostMessageJsonRpc;
let fakeRpcSettings;
let fakeReportActivity;
let fakeSettings;
beforeEach(() => {
fakePostMessageJsonRpc = {
call: sinon.stub(),
};
fakeRpcSettings = {
targetFrame: window,
origin: 'https://www.example.com',
};
fakeReportActivity = {
method: 'remoteMethod',
events: ['create', 'update'],
};
fakeSettings = {
reportActivity: fakeReportActivity,
rpc: fakeRpcSettings,
};
$imports.$mock({
'../util/postmessage-json-rpc': fakePostMessageJsonRpc,
});
});
afterEach(() => {
$imports.$restore();
});
describe('#reportActivity', () => {
it('invokes remote activity method if configured for annotation event type', () => {
const svc = new AnnotationActivityService(fakeSettings);
const annotation = fixtures.defaultAnnotation();
svc.reportActivity('update', annotation);
assert.calledOnce(fakePostMessageJsonRpc.call);
assert.calledWith(
fakePostMessageJsonRpc.call,
window,
'https://www.example.com',
'remoteMethod'
);
});
it('invokes remote method with eventType and data arguments', () => {
const svc = new AnnotationActivityService(fakeSettings);
const annotation = fixtures.defaultAnnotation();
svc.reportActivity('update', annotation);
const eventType = fakePostMessageJsonRpc.call.getCall(0).args[3][0];
const data = fakePostMessageJsonRpc.call.getCall(0).args[3][1];
assert.equal(eventType, 'update');
assert.deepEqual(data, {
// Creating a new Date here is necessary to account for precision differences
// betwen server dates (microsecond precision) and JS (millisecond)
date: new Date(annotation.updated).toISOString(),
annotation: {
id: annotation.id,
},
});
});
it('does not invoke remote activity method if RPC configuration not present', () => {
const svc = new AnnotationActivityService({
reportActivity: fakeReportActivity,
});
const annotation = fixtures.defaultAnnotation();
svc.reportActivity('update', annotation);
assert.notCalled(fakePostMessageJsonRpc.call);
});
it('does not invoke remote activity method if reportActivity not configured', () => {
const svc = new AnnotationActivityService({ rpc: fakeRpcSettings });
const annotation = fixtures.defaultAnnotation();
svc.reportActivity('update', annotation);
assert.notCalled(fakePostMessageJsonRpc.call);
});
it('does not invoke remote activity method if annotation event type is not one of configured events', () => {
const svc = new AnnotationActivityService(fakeSettings);
const annotation = fixtures.defaultAnnotation();
svc.reportActivity('delete', annotation);
assert.notCalled(fakePostMessageJsonRpc.call);
});
it('uses annotation created date as `date` for `create` events', () => {
const svc = new AnnotationActivityService(fakeSettings);
const annotation = fixtures.defaultAnnotation();
svc.reportActivity('create', annotation);
const data = fakePostMessageJsonRpc.call.getCall(0).args[3][1];
assert.equal(data.date, new Date(annotation.created).toISOString());
});
it('uses annotation updated date as `date` for `update` events', () => {
const svc = new AnnotationActivityService(fakeSettings);
const annotation = fixtures.defaultAnnotation();
svc.reportActivity('update', annotation);
const data = fakePostMessageJsonRpc.call.getCall(0).args[3][1];
assert.equal(data.date, new Date(annotation.updated).toISOString());
});
describe('using current time for other event type dates', () => {
let clock;
let now;
before(() => {
now = new Date();
clock = sinon.useFakeTimers(now);
});
after(() => {
clock.restore();
});
it('uses current date as date for other event types', () => {
fakeReportActivity.events = ['delete'];
const svc = new AnnotationActivityService(fakeSettings);
const annotation = fixtures.defaultAnnotation();
svc.reportActivity('delete', annotation);
const data = fakePostMessageJsonRpc.call.getCall(0).args[3][1];
assert.equal(data.date, now.toISOString());
});
});
});
});
...@@ -3,6 +3,7 @@ import * as fixtures from '../../test/annotation-fixtures'; ...@@ -3,6 +3,7 @@ import * as fixtures from '../../test/annotation-fixtures';
import { AnnotationsService, $imports } from '../annotations'; import { AnnotationsService, $imports } from '../annotations';
describe('AnnotationsService', () => { describe('AnnotationsService', () => {
let fakeAnnotationActivity;
let fakeApi; let fakeApi;
let fakeMetadata; let fakeMetadata;
let fakeStore; let fakeStore;
...@@ -14,6 +15,9 @@ describe('AnnotationsService', () => { ...@@ -14,6 +15,9 @@ describe('AnnotationsService', () => {
let svc; let svc;
beforeEach(() => { beforeEach(() => {
fakeAnnotationActivity = {
reportActivity: sinon.stub(),
};
fakeApi = { fakeApi = {
annotation: { annotation: {
create: sinon.stub().resolves(fixtures.defaultAnnotation()), create: sinon.stub().resolves(fixtures.defaultAnnotation()),
...@@ -64,7 +68,7 @@ describe('AnnotationsService', () => { ...@@ -64,7 +68,7 @@ describe('AnnotationsService', () => {
}, },
}); });
svc = new AnnotationsService(fakeApi, fakeStore); svc = new AnnotationsService(fakeAnnotationActivity, fakeApi, fakeStore);
}); });
afterEach(() => { afterEach(() => {
...@@ -286,6 +290,13 @@ describe('AnnotationsService', () => { ...@@ -286,6 +290,13 @@ describe('AnnotationsService', () => {
await assert.rejects(svc.delete(annot), 'Annotation does not exist'); await assert.rejects(svc.delete(annot), 'Annotation does not exist');
assert.notCalled(fakeStore.removeAnnotations); assert.notCalled(fakeStore.removeAnnotations);
}); });
it('reports delete-annotation activity', async () => {
const annot = fixtures.defaultAnnotation();
await svc.delete(annot);
assert.calledOnce(fakeAnnotationActivity.reportActivity);
assert.calledWith(fakeAnnotationActivity.reportActivity, 'delete', annot);
});
}); });
describe('flag', () => { describe('flag', () => {
...@@ -308,6 +319,13 @@ describe('AnnotationsService', () => { ...@@ -308,6 +319,13 @@ describe('AnnotationsService', () => {
await assert.rejects(svc.flag(annot), 'Annotation does not exist'); await assert.rejects(svc.flag(annot), 'Annotation does not exist');
assert.notCalled(fakeStore.updateFlagStatus); assert.notCalled(fakeStore.updateFlagStatus);
}); });
it('reports flag-annotation activity', async () => {
const annot = fixtures.defaultAnnotation();
await svc.flag(annot);
assert.calledOnce(fakeAnnotationActivity.reportActivity);
assert.calledWith(fakeAnnotationActivity.reportActivity, 'flag', annot);
});
}); });
describe('reply', () => { describe('reply', () => {
...@@ -383,6 +401,19 @@ describe('AnnotationsService', () => { ...@@ -383,6 +401,19 @@ describe('AnnotationsService', () => {
}); });
}); });
it('reports create-annotation activity for new annotations', async () => {
fakeMetadata.isSaved.returns(false);
const annotation = fixtures.newAnnotation();
const savedAnnotation = await svc.save(annotation);
assert.calledOnce(fakeAnnotationActivity.reportActivity);
assert.calledWith(
fakeAnnotationActivity.reportActivity,
'create',
savedAnnotation
);
});
it('calls the `update` API service for pre-existing annotations', () => { it('calls the `update` API service for pre-existing annotations', () => {
fakeMetadata.isSaved.returns(true); fakeMetadata.isSaved.returns(true);
...@@ -395,6 +426,19 @@ describe('AnnotationsService', () => { ...@@ -395,6 +426,19 @@ describe('AnnotationsService', () => {
}); });
}); });
it('reports update-annotation activity for pre-existing annotations', async () => {
fakeMetadata.isSaved.returns(true);
const annotation = fixtures.defaultAnnotation();
const savedAnnotation = await svc.save(annotation);
assert.calledOnce(fakeAnnotationActivity.reportActivity);
assert.calledWith(
fakeAnnotationActivity.reportActivity,
'update',
savedAnnotation
);
});
it('calls the relevant API service with an object that has any draft changes integrated', () => { it('calls the relevant API service with an object that has any draft changes integrated', () => {
fakeMetadata.isSaved.returns(false); fakeMetadata.isSaved.returns(false);
fakePrivatePermissions.returns({ read: ['foo'] }); fakePrivatePermissions.returns({ read: ['foo'] });
......
...@@ -68,12 +68,12 @@ ...@@ -68,12 +68,12 @@
* An "embedder frame" may provide configuration to be notified (via JSON RPC) * An "embedder frame" may provide configuration to be notified (via JSON RPC)
* of qualifying annotation activity from the sidebar frame. * of qualifying annotation activity from the sidebar frame.
* *
* @typedef {'create'|'save'|'delete'} AnnotationActivityEvent * @typedef {'create'|'update'|'flag'|'delete'} AnnotationEventType
* *
* @typedef ReportAnnotationActivityConfig * @typedef ReportAnnotationActivityConfig
* @prop {string} method - Name of method to call in embedder frame on * @prop {string} method - Name of method to call in embedder frame on
* qualifying annotation activity * qualifying annotation activity
* @prop {AnnotationActivityEvent[]} events - Which events to notify about * @prop {AnnotationEventType[]} events - Which events to notify about
* *
*/ */
......
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