Commit fd162d2e authored by Lyza Danger Gardner's avatar Lyza Danger Gardner

Add `save` method to `AnnotationsService`

Also add polyfill for `Promise.prototype.finally`
parent f705f6be
...@@ -72,6 +72,7 @@ function bootHypothesisClient(doc, config) { ...@@ -72,6 +72,7 @@ function bootHypothesisClient(doc, config) {
'es2015', 'es2015',
'es2016', 'es2016',
'es2017', 'es2017',
'es2018',
'url', 'url',
]); ]);
......
import 'core-js/es/promise/finally';
...@@ -57,6 +57,10 @@ const needsPolyfill = { ...@@ -57,6 +57,10 @@ const needsPolyfill = {
return !hasMethods(Object, 'entries', 'values'); return !hasMethods(Object, 'entries', 'values');
}, },
es2018: () => {
return !hasMethods(Promise.prototype, 'finally');
},
// Test for a fully-working URL constructor. // Test for a fully-working URL constructor.
url: () => { url: () => {
try { try {
......
...@@ -36,6 +36,10 @@ describe('shared/polyfills/index', () => { ...@@ -36,6 +36,10 @@ describe('shared/polyfills/index', () => {
set: 'es2017', set: 'es2017',
providesMethod: [Object, 'entries'], providesMethod: [Object, 'entries'],
}, },
{
set: 'es2018',
providesMethod: [Promise.prototype, 'finally'],
},
{ {
set: 'string.prototype.normalize', set: 'string.prototype.normalize',
providesMethod: [String.prototype, 'normalize'], providesMethod: [String.prototype, 'normalize'],
......
import SearchClient from '../search-client'; import SearchClient from '../search-client';
import { isNew } from '../util/annotation-metadata';
import { privatePermissions, sharedPermissions } from '../util/permissions';
// @ngInject // @ngInject
export default function annotationsService( export default function annotationsService(
...@@ -59,7 +61,63 @@ export default function annotationsService( ...@@ -59,7 +61,63 @@ export default function annotationsService(
searchClient.get({ uri: uris, group: groupId }); searchClient.get({ uri: uris, group: groupId });
} }
/**
* Apply changes for the given `annotation` from its draft in the store (if
* any) and return a new object with those changes integrated.
*/
function applyDraftChanges(annotation) {
const changes = {};
const draft = store.getDraft(annotation);
if (draft) {
changes.tags = draft.tags;
changes.text = draft.text;
changes.permissions = draft.isPrivate
? privatePermissions(annotation.user)
: sharedPermissions(annotation.user, annotation.group);
}
// Integrate changes from draft into object to be persisted
return { ...annotation, ...changes };
}
/**
* Save new (or update existing) annotation. On success,
* the annotation's `Draft` will be removed and the annotation added
* to the store.
*/
async function save(annotation) {
let saved;
const annotationWithChanges = applyDraftChanges(annotation);
if (isNew(annotation)) {
saved = api.annotation.create({}, annotationWithChanges);
} else {
saved = api.annotation.update(
{ id: annotation.id },
annotationWithChanges
);
}
const savedAnnotation = await saved;
Object.keys(annotation).forEach(key => {
if (key[0] === '$') {
savedAnnotation[key] = annotation[key];
}
});
// Clear out any pending changes (draft)
store.removeDraft(annotation);
// Add (or, in effect, update) the annotation to the store's collection
store.addAnnotations([savedAnnotation]);
return savedAnnotation;
}
return { return {
load, load,
save,
}; };
} }
import EventEmitter from 'tiny-emitter'; import EventEmitter from 'tiny-emitter';
import * as fixtures from '../../test/annotation-fixtures';
import annotationsService from '../annotations'; import annotationsService from '../annotations';
import { $imports } from '../annotations'; import { $imports } from '../annotations';
...@@ -33,6 +35,7 @@ describe('annotationService', () => { ...@@ -33,6 +35,7 @@ describe('annotationService', () => {
let fakeStore; let fakeStore;
let fakeApi; let fakeApi;
let fakeAnnotationMapper; let fakeAnnotationMapper;
let fakeIsNew;
let fakeStreamer; let fakeStreamer;
let fakeStreamFilter; let fakeStreamFilter;
...@@ -48,17 +51,25 @@ describe('annotationService', () => { ...@@ -48,17 +51,25 @@ describe('annotationService', () => {
unloadAnnotations: sinon.stub(), unloadAnnotations: sinon.stub(),
}; };
fakeApi = { fakeApi = {
annotation: {
create: sinon.stub().resolves(fixtures.defaultAnnotation()),
update: sinon.stub().resolves(fixtures.defaultAnnotation()),
},
search: sinon.stub(), search: sinon.stub(),
}; };
fakeIsNew = sinon.stub().returns(true);
fakeStore = { fakeStore = {
getState: sinon.stub(), addAnnotations: sinon.stub(),
annotationFetchFinished: sinon.stub(),
annotationFetchStarted: sinon.stub(),
frames: sinon.stub(), frames: sinon.stub(),
getDraft: sinon.stub().returns(null),
getState: sinon.stub(),
hasSelectedAnnotations: sinon.stub(),
removeDraft: sinon.stub(),
searchUris: sinon.stub(), searchUris: sinon.stub(),
savedAnnotations: sinon.stub(), savedAnnotations: sinon.stub(),
hasSelectedAnnotations: sinon.stub(),
updateFrameAnnotationFetchStatus: sinon.stub(), updateFrameAnnotationFetchStatus: sinon.stub(),
annotationFetchStarted: sinon.stub(),
annotationFetchFinished: sinon.stub(),
}; };
fakeStreamer = { fakeStreamer = {
setConfig: sinon.stub(), setConfig: sinon.stub(),
...@@ -76,6 +87,7 @@ describe('annotationService', () => { ...@@ -76,6 +87,7 @@ describe('annotationService', () => {
$imports.$mock({ $imports.$mock({
'../search-client': FakeSearchClient, '../search-client': FakeSearchClient,
'../util/annotation-metadata': { isNew: fakeIsNew },
}); });
}); });
...@@ -265,4 +277,119 @@ describe('annotationService', () => { ...@@ -265,4 +277,119 @@ describe('annotationService', () => {
assert.calledWith(console.error, error); assert.calledWith(console.error, error);
}); });
}); });
describe('save', () => {
let svc;
beforeEach(() => {
svc = service();
});
it('calls the `create` API service for new annotations', () => {
fakeIsNew.returns(true);
// Using the new-annotation fixture has no bearing on which API method
// will get called because `isNew` is mocked, but it has representative
// properties
const annotation = fixtures.newAnnotation();
return svc.save(annotation).then(() => {
assert.calledOnce(fakeApi.annotation.create);
assert.notCalled(fakeApi.annotation.update);
});
});
it('calls the `update` API service for pre-existing annotations', () => {
fakeIsNew.returns(false);
const annotation = fixtures.defaultAnnotation();
return svc.save(annotation).then(() => {
assert.calledOnce(fakeApi.annotation.update);
assert.notCalled(fakeApi.annotation.create);
});
});
it('calls the relevant API service with an object that has any draft changes integrated', () => {
const annotation = fixtures.defaultAnnotation();
annotation.text = 'not this';
annotation.tags = ['nope'];
fakeStore.getDraft.returns({
tags: ['one', 'two'],
text: 'my text',
isPrivate: true,
annotation: fixtures.defaultAnnotation(),
});
return svc.save(fixtures.defaultAnnotation()).then(() => {
const annotationWithChanges = fakeApi.annotation.create.getCall(0)
.args[1];
assert.equal(annotationWithChanges.text, 'my text');
assert.sameMembers(annotationWithChanges.tags, ['one', 'two']);
// Permissions converted to "private"
assert.include(
annotationWithChanges.permissions.read,
fixtures.defaultAnnotation().user
);
assert.notInclude(annotationWithChanges.permissions.read, [
'group:__world__',
]);
});
});
context('successful save', () => {
it('copies over internal app-specific keys to the annotation object', () => {
fakeIsNew.returns(false);
const annotation = fixtures.defaultAnnotation();
annotation.$tag = 'mytag';
annotation.$foo = 'bar';
// The fixture here has no `$`-prefixed props
fakeApi.annotation.update.resolves(fixtures.defaultAnnotation());
return svc.save(annotation).then(() => {
const savedAnnotation = fakeStore.addAnnotations.getCall(0)
.args[0][0];
assert.equal(savedAnnotation.$tag, 'mytag');
assert.equal(savedAnnotation.$foo, 'bar');
});
});
it('removes the annotation draft', () => {
const annotation = fixtures.defaultAnnotation();
return svc.save(annotation).then(() => {
assert.calledWith(fakeStore.removeDraft, annotation);
});
});
it('adds the updated annotation to the store', () => {
const annotation = fixtures.defaultAnnotation();
fakeIsNew.returns(false);
fakeApi.annotation.update.resolves(annotation);
return svc.save(annotation).then(() => {
assert.calledWith(fakeStore.addAnnotations, [annotation]);
});
});
});
context('error on save', () => {
it('does not remove the annotation draft', () => {
fakeApi.annotation.update.rejects();
fakeIsNew.returns(false);
return svc.save(fixtures.defaultAnnotation()).catch(() => {
assert.notCalled(fakeStore.removeDraft);
});
});
it('does not add the annotation to the store', () => {
fakeApi.annotation.update.rejects();
fakeIsNew.returns(false);
return svc.save(fixtures.defaultAnnotation()).catch(() => {
assert.notCalled(fakeStore.addAnnotations);
});
});
});
});
}); });
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