Unverified Commit 11ab71ea authored by Kyle Keating's avatar Kyle Keating Committed by GitHub

Merge pull request #1238 from hypothesis/draft-service-refactor

Convert draft service to store
parents b5d16912 f98a4f51
...@@ -54,7 +54,6 @@ function AnnotationController( ...@@ -54,7 +54,6 @@ function AnnotationController(
annotationMapper, annotationMapper,
api, api,
bridge, bridge,
drafts,
flash, flash,
groups, groups,
permissions, permissions,
...@@ -177,7 +176,7 @@ function AnnotationController( ...@@ -177,7 +176,7 @@ function AnnotationController(
// created by the annotate button) or it has edits not yet saved to the // created by the annotate button) or it has edits not yet saved to the
// server - then open the editor on AnnotationController instantiation. // server - then open the editor on AnnotationController instantiation.
if (!newlyCreatedByHighlightButton) { if (!newlyCreatedByHighlightButton) {
if (isNew(self.annotation) || drafts.get(self.annotation)) { if (isNew(self.annotation) || store.getDraft(self.annotation)) {
self.edit(); self.edit();
} }
} }
...@@ -218,7 +217,7 @@ function AnnotationController( ...@@ -218,7 +217,7 @@ function AnnotationController(
}); });
} else { } else {
// User isn't logged in, save to drafts. // User isn't logged in, save to drafts.
drafts.update(self.annotation, self.state()); store.createDraft(self.annotation, self.state());
} }
} }
...@@ -293,8 +292,8 @@ function AnnotationController( ...@@ -293,8 +292,8 @@ function AnnotationController(
* @description Switches the view to an editor. * @description Switches the view to an editor.
*/ */
this.edit = function() { this.edit = function() {
if (!drafts.get(self.annotation)) { if (!store.getDraft(self.annotation)) {
drafts.update(self.annotation, self.state()); store.createDraft(self.annotation, self.state());
} }
}; };
...@@ -305,7 +304,7 @@ function AnnotationController( ...@@ -305,7 +304,7 @@ function AnnotationController(
* (i.e. the annotation editor form should be open), `false` otherwise. * (i.e. the annotation editor form should be open), `false` otherwise.
*/ */
this.editing = function() { this.editing = function() {
return drafts.get(self.annotation) && !self.isSaving; return store.getDraft(self.annotation) && !self.isSaving;
}; };
/** /**
...@@ -431,7 +430,7 @@ function AnnotationController( ...@@ -431,7 +430,7 @@ function AnnotationController(
* @description Reverts an edit in progress and returns to the viewer. * @description Reverts an edit in progress and returns to the viewer.
*/ */
this.revert = function() { this.revert = function() {
drafts.remove(self.annotation); store.removeDraft(self.annotation);
if (isNew(self.annotation)) { if (isNew(self.annotation)) {
$rootScope.$broadcast(events.ANNOTATION_DELETED, self.annotation); $rootScope.$broadcast(events.ANNOTATION_DELETED, self.annotation);
} }
...@@ -466,7 +465,7 @@ function AnnotationController( ...@@ -466,7 +465,7 @@ function AnnotationController(
const event = isNew(self.annotation) const event = isNew(self.annotation)
? events.ANNOTATION_CREATED ? events.ANNOTATION_CREATED
: events.ANNOTATION_UPDATED; : events.ANNOTATION_UPDATED;
drafts.remove(self.annotation); store.removeDraft(self.annotation);
$rootScope.$broadcast(event, updatedModel); $rootScope.$broadcast(event, updatedModel);
}) })
...@@ -496,7 +495,7 @@ function AnnotationController( ...@@ -496,7 +495,7 @@ function AnnotationController(
if (!isReply(self.annotation)) { if (!isReply(self.annotation)) {
permissions.setDefault(privacy); permissions.setDefault(privacy);
} }
drafts.update(self.annotation, { store.createDraft(self.annotation, {
tags: self.state().tags, tags: self.state().tags,
text: self.state().text, text: self.state().text,
isPrivate: privacy === 'private', isPrivate: privacy === 'private',
...@@ -571,7 +570,7 @@ function AnnotationController( ...@@ -571,7 +570,7 @@ function AnnotationController(
}; };
this.setText = function(text) { this.setText = function(text) {
drafts.update(self.annotation, { store.createDraft(self.annotation, {
isPrivate: self.state().isPrivate, isPrivate: self.state().isPrivate,
tags: self.state().tags, tags: self.state().tags,
text: text, text: text,
...@@ -579,7 +578,7 @@ function AnnotationController( ...@@ -579,7 +578,7 @@ function AnnotationController(
}; };
this.setTags = function(tags) { this.setTags = function(tags) {
drafts.update(self.annotation, { store.createDraft(self.annotation, {
isPrivate: self.state().isPrivate, isPrivate: self.state().isPrivate,
tags: tags, tags: tags,
text: self.state().text, text: self.state().text,
...@@ -587,7 +586,7 @@ function AnnotationController( ...@@ -587,7 +586,7 @@ function AnnotationController(
}; };
this.state = function() { this.state = function() {
const draft = drafts.get(self.annotation); const draft = store.getDraft(self.annotation);
if (draft) { if (draft) {
return draft; return draft;
} }
......
...@@ -43,7 +43,6 @@ function HypothesisAppController( ...@@ -43,7 +43,6 @@ function HypothesisAppController(
store, store,
auth, auth,
bridge, bridge,
drafts,
features, features,
flash, flash,
frameSync, frameSync,
...@@ -150,18 +149,19 @@ function HypothesisAppController( ...@@ -150,18 +149,19 @@ function HypothesisAppController(
const promptToLogout = function() { const promptToLogout = function() {
// TODO - Replace this with a UI which doesn't look terrible. // TODO - Replace this with a UI which doesn't look terrible.
let text = ''; let text = '';
if (drafts.count() === 1) { const drafts = store.countDrafts();
if (drafts === 1) {
text = text =
'You have an unsaved annotation.\n' + 'You have an unsaved annotation.\n' +
'Do you really want to discard this draft?'; 'Do you really want to discard this draft?';
} else if (drafts.count() > 1) { } else if (drafts > 1) {
text = text =
'You have ' + 'You have ' +
drafts.count() + drafts +
' unsaved annotations.\n' + ' unsaved annotations.\n' +
'Do you really want to discard these drafts?'; 'Do you really want to discard these drafts?';
} }
return drafts.count() === 0 || $window.confirm(text); return drafts === 0 || $window.confirm(text);
}; };
// Log the user out. // Log the user out.
...@@ -169,11 +169,12 @@ function HypothesisAppController( ...@@ -169,11 +169,12 @@ function HypothesisAppController(
if (!promptToLogout()) { if (!promptToLogout()) {
return; return;
} }
store.clearGroups(); store.clearGroups();
drafts.unsaved().forEach(function(draft) { store.unsavedAnnotations().forEach(function(annotation) {
$rootScope.$emit(events.ANNOTATION_DELETED, draft); $rootScope.$emit(events.ANNOTATION_DELETED, annotation);
}); });
drafts.discard(); store.discardAllDrafts();
if (serviceConfig(settings)) { if (serviceConfig(settings)) {
// Let the host page handle the signup request // Let the host page handle the signup request
......
...@@ -26,7 +26,6 @@ function SidebarContentController( ...@@ -26,7 +26,6 @@ function SidebarContentController(
store, store,
annotationMapper, annotationMapper,
api, api,
drafts,
features, features,
frameSync, frameSync,
groups, groups,
......
...@@ -107,7 +107,6 @@ describe('annotation', function() { ...@@ -107,7 +107,6 @@ describe('annotation', function() {
let fakeAnalytics; let fakeAnalytics;
let fakeAnnotationMapper; let fakeAnnotationMapper;
let fakeStore; let fakeStore;
let fakeDrafts;
let fakeFlash; let fakeFlash;
let fakeGroups; let fakeGroups;
let fakePermissions; let fakePermissions;
...@@ -137,7 +136,7 @@ describe('annotation', function() { ...@@ -137,7 +136,7 @@ describe('annotation', function() {
// A new annotation won't have any saved drafts yet. // A new annotation won't have any saved drafts yet.
if (!annotation.id) { if (!annotation.id) {
fakeDrafts.get.returns(null); fakeStore.getDraft.returns(null);
} }
return { return {
...@@ -190,12 +189,13 @@ describe('annotation', function() { ...@@ -190,12 +189,13 @@ describe('annotation', function() {
fakeStore = { fakeStore = {
updateFlagStatus: sandbox.stub().returns(true), updateFlagStatus: sandbox.stub().returns(true),
}; // draft store
countDrafts: sandbox.stub().returns(0),
fakeDrafts = { createDraft: sandbox.stub(),
update: sandbox.stub(), discardAllDrafts: sandbox.stub(),
remove: sandbox.stub(), getDraft: sandbox.stub().returns(null),
get: sandbox.stub().returns(null), getDraftIfNotEmpty: sandbox.stub().returns(null),
removeDraft: sandbox.stub(),
}; };
fakeFlash = { fakeFlash = {
...@@ -262,7 +262,6 @@ describe('annotation', function() { ...@@ -262,7 +262,6 @@ describe('annotation', function() {
$provide.value('store', fakeStore); $provide.value('store', fakeStore);
$provide.value('api', fakeApi); $provide.value('api', fakeApi);
$provide.value('bridge', fakeBridge); $provide.value('bridge', fakeBridge);
$provide.value('drafts', fakeDrafts);
$provide.value('flash', fakeFlash); $provide.value('flash', fakeFlash);
$provide.value('groups', fakeGroups); $provide.value('groups', fakeGroups);
$provide.value('permissions', fakePermissions); $provide.value('permissions', fakePermissions);
...@@ -376,7 +375,7 @@ describe('annotation', function() { ...@@ -376,7 +375,7 @@ describe('annotation', function() {
createDirective(annotation); createDirective(annotation);
assert.notCalled(fakeApi.annotation.create); assert.notCalled(fakeApi.annotation.create);
assert.called(fakeDrafts.update); assert.called(fakeStore.createDraft);
}); });
it('opens the sidebar when trying to save highlights while logged out', () => { it('opens the sidebar when trying to save highlights while logged out', () => {
...@@ -418,7 +417,7 @@ describe('annotation', function() { ...@@ -418,7 +417,7 @@ describe('annotation', function() {
it('creates drafts for new annotations on initialization', function() { it('creates drafts for new annotations on initialization', function() {
const annotation = fixtures.newAnnotation(); const annotation = fixtures.newAnnotation();
createDirective(annotation); createDirective(annotation);
assert.calledWith(fakeDrafts.update, annotation, { assert.calledWith(fakeStore.createDraft, annotation, {
isPrivate: false, isPrivate: false,
tags: annotation.tags, tags: annotation.tags,
text: annotation.text, text: annotation.text,
...@@ -430,13 +429,13 @@ describe('annotation', function() { ...@@ -430,13 +429,13 @@ describe('annotation', function() {
const controller = createDirective(annotation).controller; const controller = createDirective(annotation).controller;
assert.notOk(controller.editing()); assert.notOk(controller.editing());
assert.notCalled(fakeDrafts.update); assert.notCalled(fakeStore.createDraft);
}); });
it('edits annotations with drafts on initialization', function() { it('edits annotations with drafts on initialization', function() {
const annotation = fixtures.oldAnnotation(); const annotation = fixtures.oldAnnotation();
// The drafts service has some draft changes for this annotation. // The drafts store has some draft changes for this annotation.
fakeDrafts.get.returns({ text: 'foo', tags: [] }); fakeStore.getDraft.returns({ text: 'foo', tags: [] });
const controller = createDirective(annotation).controller; const controller = createDirective(annotation).controller;
...@@ -452,13 +451,13 @@ describe('annotation', function() { ...@@ -452,13 +451,13 @@ describe('annotation', function() {
it('returns true if the annotation has a draft', function() { it('returns true if the annotation has a draft', function() {
const controller = createDirective().controller; const controller = createDirective().controller;
fakeDrafts.get.returns({ tags: [], text: '', isPrivate: false }); fakeStore.getDraft.returns({ tags: [], text: '', isPrivate: false });
assert.isTrue(controller.editing()); assert.isTrue(controller.editing());
}); });
it('returns false if the annotation has a draft but is being saved', function() { it('returns false if the annotation has a draft but is being saved', function() {
const controller = createDirective().controller; const controller = createDirective().controller;
fakeDrafts.get.returns({ tags: [], text: '', isPrivate: false }); fakeStore.getDraft.returns({ tags: [], text: '', isPrivate: false });
controller.isSaving = true; controller.isSaving = true;
assert.isFalse(controller.editing()); assert.isFalse(controller.editing());
}); });
...@@ -625,7 +624,7 @@ describe('annotation', function() { ...@@ -625,7 +624,7 @@ describe('annotation', function() {
const parts = createDirective(); const parts = createDirective();
parts.controller.setPrivacy('private'); parts.controller.setPrivacy('private');
assert.calledWith( assert.calledWith(
fakeDrafts.update, fakeStore.createDraft,
parts.controller.annotation, parts.controller.annotation,
sinon.match({ sinon.match({
isPrivate: true, isPrivate: true,
...@@ -637,7 +636,7 @@ describe('annotation', function() { ...@@ -637,7 +636,7 @@ describe('annotation', function() {
const parts = createDirective(); const parts = createDirective();
parts.controller.setPrivacy('shared'); parts.controller.setPrivacy('shared');
assert.calledWith( assert.calledWith(
fakeDrafts.update, fakeStore.createDraft,
parts.controller.annotation, parts.controller.annotation,
sinon.match({ sinon.match({
isPrivate: false, isPrivate: false,
...@@ -919,7 +918,7 @@ describe('annotation', function() { ...@@ -919,7 +918,7 @@ describe('annotation', function() {
function(testCase) { function(testCase) {
const ann = fixtures.publicAnnotation(); const ann = fixtures.publicAnnotation();
ann.group = testCase.group.id; ann.group = testCase.group.id;
fakeDrafts.get.returns(testCase.draft); fakeStore.getDraft.returns(testCase.draft);
fakeGroups.get.returns(testCase.group); fakeGroups.get.returns(testCase.group);
const controller = createDirective(ann).controller; const controller = createDirective(ann).controller;
...@@ -997,7 +996,7 @@ describe('annotation', function() { ...@@ -997,7 +996,7 @@ describe('annotation', function() {
it('removes the draft when saving an annotation succeeds', function() { it('removes the draft when saving an annotation succeeds', function() {
const controller = createController(); const controller = createController();
return controller.save().then(function() { return controller.save().then(function() {
assert.calledWith(fakeDrafts.remove, annotation); assert.calledWith(fakeStore.removeDraft, annotation);
}); });
}); });
...@@ -1051,7 +1050,7 @@ describe('annotation', function() { ...@@ -1051,7 +1050,7 @@ describe('annotation', function() {
.stub() .stub()
.returns(Promise.reject({ status: -1 })); .returns(Promise.reject({ status: -1 }));
return controller.save().then(function() { return controller.save().then(function() {
assert.notCalled(fakeDrafts.remove); assert.notCalled(fakeStore.removeDraft);
}); });
}); });
...@@ -1069,7 +1068,7 @@ describe('annotation', function() { ...@@ -1069,7 +1068,7 @@ describe('annotation', function() {
beforeEach(function() { beforeEach(function() {
annotation = fixtures.defaultAnnotation(); annotation = fixtures.defaultAnnotation();
fakeDrafts.get.returns({ text: 'unsaved change' }); fakeStore.getDraft.returns({ text: 'unsaved change' });
}); });
function createController() { function createController() {
...@@ -1099,7 +1098,7 @@ describe('annotation', function() { ...@@ -1099,7 +1098,7 @@ describe('annotation', function() {
describe('drafts', function() { describe('drafts', function() {
it('starts editing immediately if there is a draft', function() { it('starts editing immediately if there is a draft', function() {
fakeDrafts.get.returns({ fakeStore.getDraft.returns({
tags: ['unsaved'], tags: ['unsaved'],
text: 'unsaved-text', text: 'unsaved-text',
}); });
...@@ -1108,7 +1107,7 @@ describe('annotation', function() { ...@@ -1108,7 +1107,7 @@ describe('annotation', function() {
}); });
it('uses the text and tags from the draft if present', function() { it('uses the text and tags from the draft if present', function() {
fakeDrafts.get.returns({ fakeStore.getDraft.returns({
tags: ['unsaved-tag'], tags: ['unsaved-tag'],
text: 'unsaved-text', text: 'unsaved-text',
}); });
...@@ -1121,15 +1120,15 @@ describe('annotation', function() { ...@@ -1121,15 +1120,15 @@ describe('annotation', function() {
const parts = createDirective(); const parts = createDirective();
parts.controller.edit(); parts.controller.edit();
parts.controller.revert(); parts.controller.revert();
assert.calledWith(fakeDrafts.remove, parts.annotation); assert.calledWith(fakeStore.removeDraft, parts.annotation);
}); });
it('removes the draft when changes are saved', function() { it('removes the draft when changes are saved', function() {
const annotation = fixtures.defaultAnnotation(); const annotation = fixtures.defaultAnnotation();
const controller = createDirective(annotation).controller; const controller = createDirective(annotation).controller;
fakeDrafts.get.returns({ text: 'unsaved changes' }); fakeStore.getDraft.returns({ text: 'unsaved changes' });
return controller.save().then(function() { return controller.save().then(function() {
assert.calledWith(fakeDrafts.remove, annotation); assert.calledWith(fakeStore.removeDraft, annotation);
}); });
}); });
}); });
...@@ -1140,7 +1139,7 @@ describe('annotation', function() { ...@@ -1140,7 +1139,7 @@ describe('annotation', function() {
.controller; .controller;
controller.edit(); controller.edit();
controller.revert(); controller.revert();
assert.calledWith(fakeDrafts.remove, controller.annotation); assert.calledWith(fakeStore.removeDraft, controller.annotation);
}); });
it('deletes the annotation if it was new', function() { it('deletes the annotation if it was new', function() {
......
...@@ -15,7 +15,6 @@ describe('sidebar.components.hypothesis-app', function() { ...@@ -15,7 +15,6 @@ describe('sidebar.components.hypothesis-app', function() {
let fakeAnalytics = null; let fakeAnalytics = null;
let fakeAuth = null; let fakeAuth = null;
let fakeBridge = null; let fakeBridge = null;
let fakeDrafts = null;
let fakeFeatures = null; let fakeFeatures = null;
let fakeFlash = null; let fakeFlash = null;
let fakeFrameSync = null; let fakeFrameSync = null;
...@@ -64,6 +63,10 @@ describe('sidebar.components.hypothesis-app', function() { ...@@ -64,6 +63,10 @@ describe('sidebar.components.hypothesis-app', function() {
clearSelectedAnnotations: sandbox.spy(), clearSelectedAnnotations: sandbox.spy(),
getState: sinon.stub(), getState: sinon.stub(),
clearGroups: sinon.stub(), clearGroups: sinon.stub(),
// draft store
countDrafts: sandbox.stub().returns(0),
discardAllDrafts: sandbox.stub(),
unsavedAnnotations: sandbox.stub().returns([]),
}; };
fakeAnalytics = { fakeAnalytics = {
...@@ -73,15 +76,6 @@ describe('sidebar.components.hypothesis-app', function() { ...@@ -73,15 +76,6 @@ describe('sidebar.components.hypothesis-app', function() {
fakeAuth = {}; fakeAuth = {};
fakeDrafts = {
contains: sandbox.stub(),
remove: sandbox.spy(),
all: sandbox.stub().returns([]),
discard: sandbox.spy(),
count: sandbox.stub().returns(0),
unsaved: sandbox.stub().returns([]),
};
fakeFeatures = { fakeFeatures = {
fetch: sandbox.spy(), fetch: sandbox.spy(),
flagEnabled: sandbox.stub().returns(false), flagEnabled: sandbox.stub().returns(false),
...@@ -128,7 +122,6 @@ describe('sidebar.components.hypothesis-app', function() { ...@@ -128,7 +122,6 @@ describe('sidebar.components.hypothesis-app', function() {
$provide.value('store', fakeStore); $provide.value('store', fakeStore);
$provide.value('auth', fakeAuth); $provide.value('auth', fakeAuth);
$provide.value('analytics', fakeAnalytics); $provide.value('analytics', fakeAnalytics);
$provide.value('drafts', fakeDrafts);
$provide.value('features', fakeFeatures); $provide.value('features', fakeFeatures);
$provide.value('flash', fakeFlash); $provide.value('flash', fakeFlash);
$provide.value('frameSync', fakeFrameSync); $provide.value('frameSync', fakeFrameSync);
...@@ -434,7 +427,7 @@ describe('sidebar.components.hypothesis-app', function() { ...@@ -434,7 +427,7 @@ describe('sidebar.components.hypothesis-app', function() {
// Tests shared by both of the contexts below. // Tests shared by both of the contexts below.
function doSharedTests() { function doSharedTests() {
it('prompts the user if there are drafts', function() { it('prompts the user if there are drafts', function() {
fakeDrafts.count.returns(1); fakeStore.countDrafts.returns(1);
const ctrl = createController(); const ctrl = createController();
ctrl.logout(); ctrl.logout();
...@@ -451,7 +444,7 @@ describe('sidebar.components.hypothesis-app', function() { ...@@ -451,7 +444,7 @@ describe('sidebar.components.hypothesis-app', function() {
}); });
it('emits "annotationDeleted" for each unsaved draft annotation', function() { it('emits "annotationDeleted" for each unsaved draft annotation', function() {
fakeDrafts.unsaved = sandbox fakeStore.unsavedAnnotations = sandbox
.stub() .stub()
.returns(['draftOne', 'draftTwo', 'draftThree']); .returns(['draftOne', 'draftTwo', 'draftThree']);
const ctrl = createController(); const ctrl = createController();
...@@ -479,12 +472,12 @@ describe('sidebar.components.hypothesis-app', function() { ...@@ -479,12 +472,12 @@ describe('sidebar.components.hypothesis-app', function() {
ctrl.logout(); ctrl.logout();
assert(fakeDrafts.discard.calledOnce); assert(fakeStore.discardAllDrafts.calledOnce);
}); });
it('does not emit "annotationDeleted" if the user cancels the prompt', function() { it('does not emit "annotationDeleted" if the user cancels the prompt', function() {
const ctrl = createController(); const ctrl = createController();
fakeDrafts.count.returns(1); fakeStore.countDrafts.returns(1);
$rootScope.$emit = sandbox.stub(); $rootScope.$emit = sandbox.stub();
fakeWindow.confirm.returns(false); fakeWindow.confirm.returns(false);
...@@ -495,17 +488,17 @@ describe('sidebar.components.hypothesis-app', function() { ...@@ -495,17 +488,17 @@ describe('sidebar.components.hypothesis-app', function() {
it('does not discard drafts if the user cancels the prompt', function() { it('does not discard drafts if the user cancels the prompt', function() {
const ctrl = createController(); const ctrl = createController();
fakeDrafts.count.returns(1); fakeStore.countDrafts.returns(1);
fakeWindow.confirm.returns(false); fakeWindow.confirm.returns(false);
ctrl.logout(); ctrl.logout();
assert(fakeDrafts.discard.notCalled); assert(fakeStore.discardAllDrafts.notCalled);
}); });
it('does not prompt if there are no drafts', function() { it('does not prompt if there are no drafts', function() {
const ctrl = createController(); const ctrl = createController();
fakeDrafts.count.returns(0); fakeStore.countDrafts.returns(0);
ctrl.logout(); ctrl.logout();
...@@ -541,7 +534,7 @@ describe('sidebar.components.hypothesis-app', function() { ...@@ -541,7 +534,7 @@ describe('sidebar.components.hypothesis-app', function() {
}); });
it('does not send LOGOUT_REQUESTED if the user cancels the prompt', function() { it('does not send LOGOUT_REQUESTED if the user cancels the prompt', function() {
fakeDrafts.count.returns(1); fakeStore.countDrafts.returns(1);
fakeWindow.confirm.returns(false); fakeWindow.confirm.returns(false);
createController().logout(); createController().logout();
......
...@@ -211,7 +211,6 @@ function startAngularApp(config) { ...@@ -211,7 +211,6 @@ function startAngularApp(config) {
.service('apiRoutes', require('./services/api-routes')) .service('apiRoutes', require('./services/api-routes'))
.service('auth', require('./services/oauth-auth')) .service('auth', require('./services/oauth-auth'))
.service('bridge', require('../shared/bridge')) .service('bridge', require('../shared/bridge'))
.service('drafts', require('./services/drafts'))
.service('features', require('./services/features')) .service('features', require('./services/features'))
.service('flash', require('./services/flash')) .service('flash', require('./services/flash'))
.service('frameSync', require('./services/frame-sync').default) .service('frameSync', require('./services/frame-sync').default)
......
'use strict';
/**
* Return true if a given `draft` is empty and can be discarded without losing
* any user input
*/
function isEmpty(draft) {
if (!draft) {
return true;
}
return !draft.text && draft.tags.length === 0;
}
/**
* The drafts service provides temporary storage for unsaved edits to new or
* existing annotations.
*
* A draft consists of:
*
* 1. `model` which is the original annotation domain model object which the
* draft is associated with. Domain model objects are never returned from
* the drafts service, they're only used to identify the correct draft to
* return.
*
* 2. `isPrivate` (boolean), `tags` (array of objects) and `text` (string)
* which are the user's draft changes to the annotation. These are returned
* from the drafts service by `drafts.get()`.
*
*/
function DraftStore() {
this._drafts = [];
/**
* Returns true if 'draft' is a draft for a given
* annotation.
*
* Annotations are matched by ID or local tag.
*/
function match(draft, model) {
return (
(draft.model.$tag && model.$tag === draft.model.$tag) ||
(draft.model.id && model.id === draft.model.id)
);
}
/**
* Returns the number of drafts - both unsaved new annotations, and unsaved
* edits to saved annotations - currently stored.
*/
this.count = function count() {
return this._drafts.length;
};
/**
* Returns a list of local tags of new annotations for which unsaved drafts
* exist.
*
* @return {Array<{$tag: string}>}
*/
this.unsaved = function unsaved() {
return this._drafts
.filter(function(draft) {
return !draft.model.id;
})
.map(function(draft) {
return draft.model;
});
};
/** Retrieve the draft changes for an annotation. */
this.get = function get(model) {
for (let i = 0; i < this._drafts.length; i++) {
const draft = this._drafts[i];
if (match(draft, model)) {
return {
isPrivate: draft.isPrivate,
tags: draft.tags,
text: draft.text,
};
}
}
return null;
};
/**
* Returns the draft changes for an annotation, or null if no draft exists
* or the draft is empty.
*/
this.getIfNotEmpty = function(model) {
const draft = this.get(model);
return isEmpty(draft) ? null : draft;
};
/**
* Update the draft version for a given annotation, replacing any
* existing draft.
*/
this.update = function update(model, changes) {
const newDraft = {
model: { id: model.id, $tag: model.$tag },
isPrivate: changes.isPrivate,
tags: changes.tags,
text: changes.text,
};
this.remove(model);
this._drafts.push(newDraft);
};
/** Remove the draft version of an annotation. */
this.remove = function remove(model) {
this._drafts = this._drafts.filter(function(draft) {
return !match(draft, model);
});
};
/** Remove all drafts. */
this.discard = function discard() {
this._drafts = [];
};
}
module.exports = function() {
return new DraftStore();
};
...@@ -40,7 +40,7 @@ const sortFns = { ...@@ -40,7 +40,7 @@ const sortFns = {
* The root thread is then displayed by viewer.html * The root thread is then displayed by viewer.html
*/ */
// @ngInject // @ngInject
function RootThread($rootScope, store, drafts, searchFilter, viewFilter) { function RootThread($rootScope, store, searchFilter, viewFilter) {
/** /**
* Build the root conversation thread from the given UI state. * Build the root conversation thread from the given UI state.
* *
...@@ -86,10 +86,10 @@ function RootThread($rootScope, store, drafts, searchFilter, viewFilter) { ...@@ -86,10 +86,10 @@ function RootThread($rootScope, store, drafts, searchFilter, viewFilter) {
store store
.getState() .getState()
.annotations.filter(function(ann) { .annotations.filter(function(ann) {
return metadata.isNew(ann) && !drafts.getIfNotEmpty(ann); return metadata.isNew(ann) && !store.getDraftIfNotEmpty(ann);
}) })
.forEach(function(ann) { .forEach(function(ann) {
drafts.remove(ann); store.removeDraft(ann);
$rootScope.$broadcast(events.ANNOTATION_DELETED, ann); $rootScope.$broadcast(events.ANNOTATION_DELETED, ann);
}); });
} }
......
'use strict';
const draftsService = require('../drafts');
const fixtures = {
draftWithText: {
isPrivate: false,
text: 'some text',
tags: [],
},
draftWithTags: {
isPrivate: false,
text: '',
tags: ['atag'],
},
emptyDraft: {
isPrivate: false,
text: '',
tags: [],
},
};
describe('drafts', function() {
let drafts;
beforeEach(function() {
drafts = draftsService();
});
describe('#getIfNotEmpty', function() {
it('returns the draft if it has tags', function() {
const model = { id: 'foo' };
drafts.update(model, fixtures.draftWithTags);
assert.deepEqual(drafts.getIfNotEmpty(model), fixtures.draftWithTags);
});
it('returns the draft if it has text', function() {
const model = { id: 'foo' };
drafts.update(model, fixtures.draftWithText);
assert.deepEqual(drafts.getIfNotEmpty(model), fixtures.draftWithText);
});
it('returns null if the text and tags are empty', function() {
const model = { id: 'foo' };
drafts.update(model, fixtures.emptyDraft);
assert.isNull(drafts.getIfNotEmpty(model));
});
});
describe('#update', function() {
it('should save changes', function() {
const model = { id: 'foo' };
assert.notOk(drafts.get(model));
drafts.update(model, { isPrivate: true, tags: ['foo'], text: 'edit' });
assert.deepEqual(drafts.get(model), {
isPrivate: true,
tags: ['foo'],
text: 'edit',
});
});
it('should replace existing drafts', function() {
const model = { id: 'foo' };
drafts.update(model, { isPrivate: true, tags: ['foo'], text: 'foo' });
drafts.update(model, { isPrivate: true, tags: ['foo'], text: 'bar' });
assert.equal(drafts.get(model).text, 'bar');
});
it('should replace existing drafts with the same ID', function() {
const modelA = { id: 'foo' };
const modelB = { id: 'foo' };
drafts.update(modelA, { isPrivate: true, tags: ['foo'], text: 'foo' });
drafts.update(modelB, { isPrivate: true, tags: ['foo'], text: 'bar' });
assert.equal(drafts.get(modelA).text, 'bar');
});
it('should replace drafts with the same tag', function() {
const modelA = { $tag: 'foo' };
const modelB = { $tag: 'foo' };
drafts.update(modelA, { isPrivate: true, tags: ['foo'], text: 'foo' });
drafts.update(modelB, { isPrivate: true, tags: ['foo'], text: 'bar' });
assert.equal(drafts.get(modelA).text, 'bar');
});
});
describe('#remove', function() {
it('should remove drafts', function() {
const model = { id: 'foo' };
drafts.update(model, { text: 'bar' });
drafts.remove(model);
assert.notOk(drafts.get(model));
});
});
describe('#unsaved', function() {
it('should return drafts for unsaved annotations', function() {
const model = { $tag: 'local-tag', id: undefined };
drafts.update(model, { text: 'bar' });
assert.deepEqual(drafts.unsaved(), [model]);
});
it('should not return drafts for saved annotations', function() {
const model = { id: 'foo' };
drafts.update(model, { text: 'baz' });
assert.deepEqual(drafts.unsaved(), []);
});
});
});
...@@ -27,7 +27,6 @@ const fixtures = immutable({ ...@@ -27,7 +27,6 @@ const fixtures = immutable({
describe('rootThread', function() { describe('rootThread', function() {
let fakeStore; let fakeStore;
let fakeBuildThread; let fakeBuildThread;
let fakeDrafts;
let fakeSearchFilter; let fakeSearchFilter;
let fakeViewFilter; let fakeViewFilter;
...@@ -62,15 +61,12 @@ describe('rootThread', function() { ...@@ -62,15 +61,12 @@ describe('rootThread', function() {
addAnnotations: sinon.stub(), addAnnotations: sinon.stub(),
setCollapsed: sinon.stub(), setCollapsed: sinon.stub(),
selectTab: sinon.stub(), selectTab: sinon.stub(),
getDraftIfNotEmpty: sinon.stub().returns(null),
removeDraft: sinon.stub(),
}; };
fakeBuildThread = sinon.stub().returns(fixtures.emptyThread); fakeBuildThread = sinon.stub().returns(fixtures.emptyThread);
fakeDrafts = {
getIfNotEmpty: sinon.stub().returns(null),
remove: sinon.stub(),
};
fakeSearchFilter = { fakeSearchFilter = {
generateFacetedFilter: sinon.stub(), generateFacetedFilter: sinon.stub(),
}; };
...@@ -82,7 +78,6 @@ describe('rootThread', function() { ...@@ -82,7 +78,6 @@ describe('rootThread', function() {
angular angular
.module('app', []) .module('app', [])
.value('store', fakeStore) .value('store', fakeStore)
.value('drafts', fakeDrafts)
.value('searchFilter', fakeSearchFilter) .value('searchFilter', fakeSearchFilter)
.value('viewFilter', fakeViewFilter) .value('viewFilter', fakeViewFilter)
.service('rootThread', rootThreadFactory); .service('rootThread', rootThreadFactory);
...@@ -401,16 +396,16 @@ describe('rootThread', function() { ...@@ -401,16 +396,16 @@ describe('rootThread', function() {
}); });
it('removes drafts for new and empty annotations', function() { it('removes drafts for new and empty annotations', function() {
fakeDrafts.getIfNotEmpty.returns(null); fakeStore.getDraftIfNotEmpty.returns(null);
const annotation = annotationFixtures.newEmptyAnnotation(); const annotation = annotationFixtures.newEmptyAnnotation();
$rootScope.$broadcast(events.BEFORE_ANNOTATION_CREATED, annotation); $rootScope.$broadcast(events.BEFORE_ANNOTATION_CREATED, annotation);
assert.calledWith(fakeDrafts.remove, existingNewAnnot); assert.calledWith(fakeStore.removeDraft, existingNewAnnot);
}); });
it('deletes new and empty annotations', function() { it('deletes new and empty annotations', function() {
fakeDrafts.getIfNotEmpty.returns(null); fakeStore.getDraftIfNotEmpty.returns(null);
const annotation = annotationFixtures.newEmptyAnnotation(); const annotation = annotationFixtures.newEmptyAnnotation();
$rootScope.$broadcast(events.BEFORE_ANNOTATION_CREATED, annotation); $rootScope.$broadcast(events.BEFORE_ANNOTATION_CREATED, annotation);
...@@ -419,14 +414,14 @@ describe('rootThread', function() { ...@@ -419,14 +414,14 @@ describe('rootThread', function() {
}); });
it('does not remove annotations that have non-empty drafts', function() { it('does not remove annotations that have non-empty drafts', function() {
fakeDrafts.getIfNotEmpty.returns(fixtures.nonEmptyDraft); fakeStore.getDraftIfNotEmpty.returns(fixtures.nonEmptyDraft);
$rootScope.$broadcast( $rootScope.$broadcast(
events.BEFORE_ANNOTATION_CREATED, events.BEFORE_ANNOTATION_CREATED,
annotationFixtures.newAnnotation() annotationFixtures.newAnnotation()
); );
assert.notCalled(fakeDrafts.remove); assert.notCalled(fakeStore.removeDraft);
assert.notCalled(onDelete); assert.notCalled(onDelete);
}); });
...@@ -439,7 +434,7 @@ describe('rootThread', function() { ...@@ -439,7 +434,7 @@ describe('rootThread', function() {
annotationFixtures.newAnnotation() annotationFixtures.newAnnotation()
); );
assert.notCalled(fakeDrafts.remove); assert.notCalled(fakeStore.removeDraft);
assert.notCalled(onDelete); assert.notCalled(onDelete);
}); });
}); });
......
...@@ -37,6 +37,7 @@ const debugMiddleware = require('./debug-middleware'); ...@@ -37,6 +37,7 @@ const debugMiddleware = require('./debug-middleware');
const activity = require('./modules/activity'); const activity = require('./modules/activity');
const annotations = require('./modules/annotations'); const annotations = require('./modules/annotations');
const directLinked = require('./modules/direct-linked'); const directLinked = require('./modules/direct-linked');
const drafts = require('./modules/drafts');
const frames = require('./modules/frames'); const frames = require('./modules/frames');
const links = require('./modules/links'); const links = require('./modules/links');
const groups = require('./modules/groups'); const groups = require('./modules/groups');
...@@ -88,6 +89,7 @@ function store($rootScope, settings) { ...@@ -88,6 +89,7 @@ function store($rootScope, settings) {
activity, activity,
annotations, annotations,
directLinked, directLinked,
drafts,
frames, frames,
links, links,
groups, groups,
......
'use strict';
const util = require('../util');
/**
* The drafts store provides temporary storage for unsaved edits to new or
* existing annotations.
*/
function init() {
return {
drafts: [],
};
}
/**
* Helper class to encapsulate the draft properties and a few simple methods.
*
* A draft consists of:
*
* 1. `annotation` which is the original annotation object which the
* draft is associated with. If this is just a draft, then this may
* not have an id yet and instead, $tag is used.
*
* 2. `isPrivate` (boolean), `tags` (array of objects) and `text` (string)
* which are the user's draft changes to the annotation. These are returned
* from the drafts store selector by `drafts.getDraft()`.
*/
class Draft {
constructor(annotation, changes) {
this.annotation = { id: annotation.id, $tag: annotation.$tag };
this.isPrivate = changes.isPrivate;
this.tags = changes.tags;
this.text = changes.text;
}
/**
* Returns true if this draft matches a given annotation.
*
* Annotations are matched by ID or local tag.
*/
match(annotation) {
return (
(this.annotation.$tag && annotation.$tag === this.annotation.$tag) ||
(this.annotation.id && annotation.id === this.annotation.id)
);
}
/**
* Return true if this draft is empty and can be discarded without losing
* any user input.
*/
isEmpty() {
return !this.text && this.tags.length === 0;
}
}
/* Reducer */
const update = {
DISCARD_ALL_DRAFTS: function() {
return {
drafts: [],
};
},
REMOVE_DRAFT: function(state, action) {
const drafts = state.drafts.filter(draft => {
return !draft.match(action.annotation);
});
return {
drafts,
};
},
UPDATE_DRAFT: function(state, action) {
// removes a matching existing draft, then adds
const drafts = state.drafts.filter(draft => {
return !draft.match(action.draft.annotation);
});
drafts.push(action.draft); // push ok since its a copy
return {
drafts,
};
},
};
const actions = util.actionTypes(update);
/* Actions */
/**
* Create or update the draft version for a given annotation by
* replacing any existing draft or simply creating a new one.
*/
function createDraft(annotation, changes) {
return {
type: actions.UPDATE_DRAFT,
draft: new Draft(annotation, changes),
};
}
/** Remove all drafts. */
function discardAllDrafts() {
return {
type: actions.DISCARD_ALL_DRAFTS,
};
}
/** Remove the draft version of an annotation. */
function removeDraft(annotation) {
return {
type: actions.REMOVE_DRAFT,
annotation,
};
}
/* Selectors */
/**
* Returns the number of drafts - both unsaved new annotations, and unsaved
* edits to saved annotations - currently stored.
*
* @return {number}
*/
function countDrafts(state) {
return state.drafts.length;
}
/**
* Retrieve the draft changes for an annotation.
*
* @return {Draft|null}
*/
function getDraft(state, annotation) {
for (let i = 0; i < state.drafts.length; i++) {
const draft = state.drafts[i];
if (draft.match(annotation)) {
return draft;
}
}
return null;
}
/**
* Returns the draft changes for an annotation, or null if no draft exists
* or the draft is empty.
*
* @return {Draft|null}
*/
function getDraftIfNotEmpty(state, annotation) {
const draft = getDraft(state, annotation);
if (!draft) {
return null;
}
return draft.isEmpty() ? null : draft;
}
/**
* Returns a list of draft annotations which have no id.
*
* @return {Object[]}
*/
function unsavedAnnotations(state) {
return state.drafts
.filter(draft => !draft.annotation.id)
.map(draft => draft.annotation);
}
module.exports = {
init,
update,
actions: {
createDraft,
discardAllDrafts,
removeDraft,
},
selectors: {
countDrafts,
getDraft,
getDraftIfNotEmpty,
unsavedAnnotations,
},
Draft,
};
'use strict';
const immutable = require('seamless-immutable');
const drafts = require('../drafts');
const { Draft } = require('../drafts');
const createStore = require('../../create-store');
const fixtures = immutable({
draftWithText: {
isPrivate: false,
text: 'some text',
tags: [],
},
draftWithTags: {
isPrivate: false,
text: '',
tags: ['atag'],
},
emptyDraft: {
isPrivate: false,
text: '',
tags: [],
},
annotation: {
id: 'my_annotation',
$tag: 'my_annotation_tag',
},
});
describe('Drafts Store', () => {
let store;
beforeEach(() => {
store = createStore([drafts]);
});
describe('Draft', () => {
it('constructor', () => {
const draft = new Draft(fixtures.annotation, fixtures.draftWithText);
assert.deepEqual(draft, {
annotation: {
...fixtures.annotation,
},
...fixtures.draftWithText,
});
});
describe('#isEmpty', () => {
it('returns false if draft has tags or text', () => {
const draft = new Draft(fixtures.annotation, fixtures.draftWithText);
assert.isFalse(draft.isEmpty());
});
it('returns true if draft has no tags or text', () => {
const draft = new Draft(fixtures.annotation, fixtures.emptyDraft);
assert.isTrue(draft.isEmpty());
});
});
describe('#match', () => {
it('matches an annotation with the same tag or id', () => {
const draft = new Draft(fixtures.annotation, fixtures.draftWithText);
assert.isTrue(
draft.match({
id: fixtures.annotation.id,
})
);
assert.isTrue(
draft.match({
$tag: fixtures.annotation.$tag,
})
);
});
it('does not match an annotation with a different tag or id', () => {
const draft = new Draft(fixtures.annotation, fixtures.draftWithText);
assert.isFalse(
draft.match({
id: 'fake',
})
);
assert.isFalse(
draft.match({
$tag: 'fake',
})
);
});
});
});
describe('#getDraftIfNotEmpty', () => {
it('returns the draft if it has tags', () => {
store.createDraft(fixtures.annotation, fixtures.draftWithTags);
assert.deepEqual(
store.getDraftIfNotEmpty(fixtures.annotation).annotation,
fixtures.annotation
);
});
it('returns the draft if it has text', () => {
store.createDraft(fixtures.annotation, fixtures.draftWithText);
assert.deepEqual(
store.getDraftIfNotEmpty(fixtures.annotation).annotation,
fixtures.annotation
);
});
it('returns null if the text and tags are empty', () => {
store.createDraft(fixtures.annotation, fixtures.emptyDraft);
assert.isNull(store.getDraftIfNotEmpty(fixtures.annotation));
});
it('returns null if there is no matching draft', () => {
assert.isNull(store.getDraftIfNotEmpty('fake'));
});
});
describe('#createDraft', () => {
it('should save changes', () => {
assert.notOk(store.getDraft(fixtures.annotation));
store.createDraft(fixtures.annotation, fixtures.draftWithText);
assert.deepEqual(
store.getDraft(fixtures.annotation),
new Draft(fixtures.annotation, fixtures.draftWithText)
);
});
it('should replace existing drafts with the same ID', () => {
const fakeAnnotation = {
id: 'my_annotation',
};
const fakeDraft = {
isPrivate: true,
tags: ['foo'],
text: '',
};
store.createDraft(fakeAnnotation, {
...fakeDraft,
text: 'foo',
});
assert.equal(store.getDraft(fakeAnnotation).text, 'foo');
// now replace the draft
store.createDraft(fakeAnnotation, {
...fakeDraft,
text: 'bar',
});
assert.equal(store.getDraft(fakeAnnotation).text, 'bar');
});
it('should replace existing drafts with the same tag', () => {
const fakeAnnotation = {
$tag: 'my_annotation_tag',
};
const fakeDraft = {
isPrivate: true,
tags: ['foo'],
text: '',
};
store.createDraft(fakeAnnotation, {
...fakeDraft,
text: 'foo',
});
assert.equal(store.getDraft(fakeAnnotation).text, 'foo');
// now replace the draft
store.createDraft(fakeAnnotation, {
...fakeDraft,
text: 'bar',
});
assert.equal(store.getDraft(fakeAnnotation).text, 'bar');
});
});
describe('#countDrafts', () => {
it('should count drafts', () => {
assert.equal(store.countDrafts(), 0);
store.createDraft({ id: '1' }, fixtures.draftWithText);
assert.equal(store.countDrafts(), 1);
// since same id, this performs a replace, should still be 1 count
store.createDraft({ id: '1' }, fixtures.draftWithText);
assert.equal(store.countDrafts(), 1);
store.createDraft({ id: '2' }, fixtures.draftWithText);
assert.equal(store.countDrafts(), 2);
});
});
describe('#discardAllDrafts', () => {
it('should remove all drafts', () => {
store.createDraft({ id: '1' }, fixtures.draftWithText);
store.createDraft({ id: '2' }, fixtures.draftWithText);
store.discardAllDrafts(fixtures.annotation);
assert.equal(store.countDrafts(), 0);
});
});
describe('#removeDraft', () => {
it('should remove drafts', () => {
store.createDraft(fixtures.annotation, fixtures.draftWithText);
assert.isOk(store.getDraft(fixtures.annotation));
store.removeDraft(fixtures.annotation);
assert.isNotOk(store.getDraft(fixtures.annotation));
});
});
describe('#unsavedAnnotations', () => {
it('should return unsaved annotations which have drafts', () => {
const fakeAnnotation1 = {
$tag: 'local-tag1',
id: undefined,
};
const fakeAnnotation2 = {
$tag: 'local-tag2',
id: undefined,
};
store.createDraft(fakeAnnotation1, fixtures.draftWithText);
store.createDraft(fakeAnnotation2, fixtures.draftWithText);
assert.deepEqual(store.unsavedAnnotations(), [
fakeAnnotation1,
fakeAnnotation2,
]);
});
it('should not return saved annotations which have drafts', () => {
store.createDraft(fixtures.annotation, fixtures.draftWithText);
assert.deepEqual(store.unsavedAnnotations(), []);
});
});
});
...@@ -54,7 +54,6 @@ describe('annotation threading', function() { ...@@ -54,7 +54,6 @@ describe('annotation threading', function() {
angular angular
.module('app', []) .module('app', [])
.service('store', require('../../store')) .service('store', require('../../store'))
.service('drafts', require('../../services/drafts'))
.service('rootThread', require('../../services/root-thread')) .service('rootThread', require('../../services/root-thread'))
.service('searchFilter', require('../../services/search-filter')) .service('searchFilter', require('../../services/search-filter'))
.service('viewFilter', require('../../services/view-filter')) .service('viewFilter', require('../../services/view-filter'))
......
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