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(
annotationMapper,
api,
bridge,
drafts,
flash,
groups,
permissions,
......@@ -177,7 +176,7 @@ function AnnotationController(
// created by the annotate button) or it has edits not yet saved to the
// server - then open the editor on AnnotationController instantiation.
if (!newlyCreatedByHighlightButton) {
if (isNew(self.annotation) || drafts.get(self.annotation)) {
if (isNew(self.annotation) || store.getDraft(self.annotation)) {
self.edit();
}
}
......@@ -218,7 +217,7 @@ function AnnotationController(
});
} else {
// 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(
* @description Switches the view to an editor.
*/
this.edit = function() {
if (!drafts.get(self.annotation)) {
drafts.update(self.annotation, self.state());
if (!store.getDraft(self.annotation)) {
store.createDraft(self.annotation, self.state());
}
};
......@@ -305,7 +304,7 @@ function AnnotationController(
* (i.e. the annotation editor form should be open), `false` otherwise.
*/
this.editing = function() {
return drafts.get(self.annotation) && !self.isSaving;
return store.getDraft(self.annotation) && !self.isSaving;
};
/**
......@@ -431,7 +430,7 @@ function AnnotationController(
* @description Reverts an edit in progress and returns to the viewer.
*/
this.revert = function() {
drafts.remove(self.annotation);
store.removeDraft(self.annotation);
if (isNew(self.annotation)) {
$rootScope.$broadcast(events.ANNOTATION_DELETED, self.annotation);
}
......@@ -466,7 +465,7 @@ function AnnotationController(
const event = isNew(self.annotation)
? events.ANNOTATION_CREATED
: events.ANNOTATION_UPDATED;
drafts.remove(self.annotation);
store.removeDraft(self.annotation);
$rootScope.$broadcast(event, updatedModel);
})
......@@ -496,7 +495,7 @@ function AnnotationController(
if (!isReply(self.annotation)) {
permissions.setDefault(privacy);
}
drafts.update(self.annotation, {
store.createDraft(self.annotation, {
tags: self.state().tags,
text: self.state().text,
isPrivate: privacy === 'private',
......@@ -571,7 +570,7 @@ function AnnotationController(
};
this.setText = function(text) {
drafts.update(self.annotation, {
store.createDraft(self.annotation, {
isPrivate: self.state().isPrivate,
tags: self.state().tags,
text: text,
......@@ -579,7 +578,7 @@ function AnnotationController(
};
this.setTags = function(tags) {
drafts.update(self.annotation, {
store.createDraft(self.annotation, {
isPrivate: self.state().isPrivate,
tags: tags,
text: self.state().text,
......@@ -587,7 +586,7 @@ function AnnotationController(
};
this.state = function() {
const draft = drafts.get(self.annotation);
const draft = store.getDraft(self.annotation);
if (draft) {
return draft;
}
......
......@@ -43,7 +43,6 @@ function HypothesisAppController(
store,
auth,
bridge,
drafts,
features,
flash,
frameSync,
......@@ -150,18 +149,19 @@ function HypothesisAppController(
const promptToLogout = function() {
// TODO - Replace this with a UI which doesn't look terrible.
let text = '';
if (drafts.count() === 1) {
const drafts = store.countDrafts();
if (drafts === 1) {
text =
'You have an unsaved annotation.\n' +
'Do you really want to discard this draft?';
} else if (drafts.count() > 1) {
} else if (drafts > 1) {
text =
'You have ' +
drafts.count() +
drafts +
' unsaved annotations.\n' +
'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.
......@@ -169,11 +169,12 @@ function HypothesisAppController(
if (!promptToLogout()) {
return;
}
store.clearGroups();
drafts.unsaved().forEach(function(draft) {
$rootScope.$emit(events.ANNOTATION_DELETED, draft);
store.unsavedAnnotations().forEach(function(annotation) {
$rootScope.$emit(events.ANNOTATION_DELETED, annotation);
});
drafts.discard();
store.discardAllDrafts();
if (serviceConfig(settings)) {
// Let the host page handle the signup request
......
......@@ -26,7 +26,6 @@ function SidebarContentController(
store,
annotationMapper,
api,
drafts,
features,
frameSync,
groups,
......
......@@ -107,7 +107,6 @@ describe('annotation', function() {
let fakeAnalytics;
let fakeAnnotationMapper;
let fakeStore;
let fakeDrafts;
let fakeFlash;
let fakeGroups;
let fakePermissions;
......@@ -137,7 +136,7 @@ describe('annotation', function() {
// A new annotation won't have any saved drafts yet.
if (!annotation.id) {
fakeDrafts.get.returns(null);
fakeStore.getDraft.returns(null);
}
return {
......@@ -190,12 +189,13 @@ describe('annotation', function() {
fakeStore = {
updateFlagStatus: sandbox.stub().returns(true),
};
fakeDrafts = {
update: sandbox.stub(),
remove: sandbox.stub(),
get: sandbox.stub().returns(null),
// draft store
countDrafts: sandbox.stub().returns(0),
createDraft: sandbox.stub(),
discardAllDrafts: sandbox.stub(),
getDraft: sandbox.stub().returns(null),
getDraftIfNotEmpty: sandbox.stub().returns(null),
removeDraft: sandbox.stub(),
};
fakeFlash = {
......@@ -262,7 +262,6 @@ describe('annotation', function() {
$provide.value('store', fakeStore);
$provide.value('api', fakeApi);
$provide.value('bridge', fakeBridge);
$provide.value('drafts', fakeDrafts);
$provide.value('flash', fakeFlash);
$provide.value('groups', fakeGroups);
$provide.value('permissions', fakePermissions);
......@@ -376,7 +375,7 @@ describe('annotation', function() {
createDirective(annotation);
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', () => {
......@@ -418,7 +417,7 @@ describe('annotation', function() {
it('creates drafts for new annotations on initialization', function() {
const annotation = fixtures.newAnnotation();
createDirective(annotation);
assert.calledWith(fakeDrafts.update, annotation, {
assert.calledWith(fakeStore.createDraft, annotation, {
isPrivate: false,
tags: annotation.tags,
text: annotation.text,
......@@ -430,13 +429,13 @@ describe('annotation', function() {
const controller = createDirective(annotation).controller;
assert.notOk(controller.editing());
assert.notCalled(fakeDrafts.update);
assert.notCalled(fakeStore.createDraft);
});
it('edits annotations with drafts on initialization', function() {
const annotation = fixtures.oldAnnotation();
// The drafts service has some draft changes for this annotation.
fakeDrafts.get.returns({ text: 'foo', tags: [] });
// The drafts store has some draft changes for this annotation.
fakeStore.getDraft.returns({ text: 'foo', tags: [] });
const controller = createDirective(annotation).controller;
......@@ -452,13 +451,13 @@ describe('annotation', function() {
it('returns true if the annotation has a draft', function() {
const controller = createDirective().controller;
fakeDrafts.get.returns({ tags: [], text: '', isPrivate: false });
fakeStore.getDraft.returns({ tags: [], text: '', isPrivate: false });
assert.isTrue(controller.editing());
});
it('returns false if the annotation has a draft but is being saved', function() {
const controller = createDirective().controller;
fakeDrafts.get.returns({ tags: [], text: '', isPrivate: false });
fakeStore.getDraft.returns({ tags: [], text: '', isPrivate: false });
controller.isSaving = true;
assert.isFalse(controller.editing());
});
......@@ -625,7 +624,7 @@ describe('annotation', function() {
const parts = createDirective();
parts.controller.setPrivacy('private');
assert.calledWith(
fakeDrafts.update,
fakeStore.createDraft,
parts.controller.annotation,
sinon.match({
isPrivate: true,
......@@ -637,7 +636,7 @@ describe('annotation', function() {
const parts = createDirective();
parts.controller.setPrivacy('shared');
assert.calledWith(
fakeDrafts.update,
fakeStore.createDraft,
parts.controller.annotation,
sinon.match({
isPrivate: false,
......@@ -919,7 +918,7 @@ describe('annotation', function() {
function(testCase) {
const ann = fixtures.publicAnnotation();
ann.group = testCase.group.id;
fakeDrafts.get.returns(testCase.draft);
fakeStore.getDraft.returns(testCase.draft);
fakeGroups.get.returns(testCase.group);
const controller = createDirective(ann).controller;
......@@ -997,7 +996,7 @@ describe('annotation', function() {
it('removes the draft when saving an annotation succeeds', function() {
const controller = createController();
return controller.save().then(function() {
assert.calledWith(fakeDrafts.remove, annotation);
assert.calledWith(fakeStore.removeDraft, annotation);
});
});
......@@ -1051,7 +1050,7 @@ describe('annotation', function() {
.stub()
.returns(Promise.reject({ status: -1 }));
return controller.save().then(function() {
assert.notCalled(fakeDrafts.remove);
assert.notCalled(fakeStore.removeDraft);
});
});
......@@ -1069,7 +1068,7 @@ describe('annotation', function() {
beforeEach(function() {
annotation = fixtures.defaultAnnotation();
fakeDrafts.get.returns({ text: 'unsaved change' });
fakeStore.getDraft.returns({ text: 'unsaved change' });
});
function createController() {
......@@ -1099,7 +1098,7 @@ describe('annotation', function() {
describe('drafts', function() {
it('starts editing immediately if there is a draft', function() {
fakeDrafts.get.returns({
fakeStore.getDraft.returns({
tags: ['unsaved'],
text: 'unsaved-text',
});
......@@ -1108,7 +1107,7 @@ describe('annotation', function() {
});
it('uses the text and tags from the draft if present', function() {
fakeDrafts.get.returns({
fakeStore.getDraft.returns({
tags: ['unsaved-tag'],
text: 'unsaved-text',
});
......@@ -1121,15 +1120,15 @@ describe('annotation', function() {
const parts = createDirective();
parts.controller.edit();
parts.controller.revert();
assert.calledWith(fakeDrafts.remove, parts.annotation);
assert.calledWith(fakeStore.removeDraft, parts.annotation);
});
it('removes the draft when changes are saved', function() {
const annotation = fixtures.defaultAnnotation();
const controller = createDirective(annotation).controller;
fakeDrafts.get.returns({ text: 'unsaved changes' });
fakeStore.getDraft.returns({ text: 'unsaved changes' });
return controller.save().then(function() {
assert.calledWith(fakeDrafts.remove, annotation);
assert.calledWith(fakeStore.removeDraft, annotation);
});
});
});
......@@ -1140,7 +1139,7 @@ describe('annotation', function() {
.controller;
controller.edit();
controller.revert();
assert.calledWith(fakeDrafts.remove, controller.annotation);
assert.calledWith(fakeStore.removeDraft, controller.annotation);
});
it('deletes the annotation if it was new', function() {
......
......@@ -15,7 +15,6 @@ describe('sidebar.components.hypothesis-app', function() {
let fakeAnalytics = null;
let fakeAuth = null;
let fakeBridge = null;
let fakeDrafts = null;
let fakeFeatures = null;
let fakeFlash = null;
let fakeFrameSync = null;
......@@ -64,6 +63,10 @@ describe('sidebar.components.hypothesis-app', function() {
clearSelectedAnnotations: sandbox.spy(),
getState: sinon.stub(),
clearGroups: sinon.stub(),
// draft store
countDrafts: sandbox.stub().returns(0),
discardAllDrafts: sandbox.stub(),
unsavedAnnotations: sandbox.stub().returns([]),
};
fakeAnalytics = {
......@@ -73,15 +76,6 @@ describe('sidebar.components.hypothesis-app', function() {
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 = {
fetch: sandbox.spy(),
flagEnabled: sandbox.stub().returns(false),
......@@ -128,7 +122,6 @@ describe('sidebar.components.hypothesis-app', function() {
$provide.value('store', fakeStore);
$provide.value('auth', fakeAuth);
$provide.value('analytics', fakeAnalytics);
$provide.value('drafts', fakeDrafts);
$provide.value('features', fakeFeatures);
$provide.value('flash', fakeFlash);
$provide.value('frameSync', fakeFrameSync);
......@@ -434,7 +427,7 @@ describe('sidebar.components.hypothesis-app', function() {
// Tests shared by both of the contexts below.
function doSharedTests() {
it('prompts the user if there are drafts', function() {
fakeDrafts.count.returns(1);
fakeStore.countDrafts.returns(1);
const ctrl = createController();
ctrl.logout();
......@@ -451,7 +444,7 @@ describe('sidebar.components.hypothesis-app', function() {
});
it('emits "annotationDeleted" for each unsaved draft annotation', function() {
fakeDrafts.unsaved = sandbox
fakeStore.unsavedAnnotations = sandbox
.stub()
.returns(['draftOne', 'draftTwo', 'draftThree']);
const ctrl = createController();
......@@ -479,12 +472,12 @@ describe('sidebar.components.hypothesis-app', function() {
ctrl.logout();
assert(fakeDrafts.discard.calledOnce);
assert(fakeStore.discardAllDrafts.calledOnce);
});
it('does not emit "annotationDeleted" if the user cancels the prompt', function() {
const ctrl = createController();
fakeDrafts.count.returns(1);
fakeStore.countDrafts.returns(1);
$rootScope.$emit = sandbox.stub();
fakeWindow.confirm.returns(false);
......@@ -495,17 +488,17 @@ describe('sidebar.components.hypothesis-app', function() {
it('does not discard drafts if the user cancels the prompt', function() {
const ctrl = createController();
fakeDrafts.count.returns(1);
fakeStore.countDrafts.returns(1);
fakeWindow.confirm.returns(false);
ctrl.logout();
assert(fakeDrafts.discard.notCalled);
assert(fakeStore.discardAllDrafts.notCalled);
});
it('does not prompt if there are no drafts', function() {
const ctrl = createController();
fakeDrafts.count.returns(0);
fakeStore.countDrafts.returns(0);
ctrl.logout();
......@@ -541,7 +534,7 @@ describe('sidebar.components.hypothesis-app', 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);
createController().logout();
......
......@@ -211,7 +211,6 @@ function startAngularApp(config) {
.service('apiRoutes', require('./services/api-routes'))
.service('auth', require('./services/oauth-auth'))
.service('bridge', require('../shared/bridge'))
.service('drafts', require('./services/drafts'))
.service('features', require('./services/features'))
.service('flash', require('./services/flash'))
.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 = {
* The root thread is then displayed by viewer.html
*/
// @ngInject
function RootThread($rootScope, store, drafts, searchFilter, viewFilter) {
function RootThread($rootScope, store, searchFilter, viewFilter) {
/**
* Build the root conversation thread from the given UI state.
*
......@@ -86,10 +86,10 @@ function RootThread($rootScope, store, drafts, searchFilter, viewFilter) {
store
.getState()
.annotations.filter(function(ann) {
return metadata.isNew(ann) && !drafts.getIfNotEmpty(ann);
return metadata.isNew(ann) && !store.getDraftIfNotEmpty(ann);
})
.forEach(function(ann) {
drafts.remove(ann);
store.removeDraft(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({
describe('rootThread', function() {
let fakeStore;
let fakeBuildThread;
let fakeDrafts;
let fakeSearchFilter;
let fakeViewFilter;
......@@ -62,15 +61,12 @@ describe('rootThread', function() {
addAnnotations: sinon.stub(),
setCollapsed: sinon.stub(),
selectTab: sinon.stub(),
getDraftIfNotEmpty: sinon.stub().returns(null),
removeDraft: sinon.stub(),
};
fakeBuildThread = sinon.stub().returns(fixtures.emptyThread);
fakeDrafts = {
getIfNotEmpty: sinon.stub().returns(null),
remove: sinon.stub(),
};
fakeSearchFilter = {
generateFacetedFilter: sinon.stub(),
};
......@@ -82,7 +78,6 @@ describe('rootThread', function() {
angular
.module('app', [])
.value('store', fakeStore)
.value('drafts', fakeDrafts)
.value('searchFilter', fakeSearchFilter)
.value('viewFilter', fakeViewFilter)
.service('rootThread', rootThreadFactory);
......@@ -401,16 +396,16 @@ describe('rootThread', function() {
});
it('removes drafts for new and empty annotations', function() {
fakeDrafts.getIfNotEmpty.returns(null);
fakeStore.getDraftIfNotEmpty.returns(null);
const annotation = annotationFixtures.newEmptyAnnotation();
$rootScope.$broadcast(events.BEFORE_ANNOTATION_CREATED, annotation);
assert.calledWith(fakeDrafts.remove, existingNewAnnot);
assert.calledWith(fakeStore.removeDraft, existingNewAnnot);
});
it('deletes new and empty annotations', function() {
fakeDrafts.getIfNotEmpty.returns(null);
fakeStore.getDraftIfNotEmpty.returns(null);
const annotation = annotationFixtures.newEmptyAnnotation();
$rootScope.$broadcast(events.BEFORE_ANNOTATION_CREATED, annotation);
......@@ -419,14 +414,14 @@ describe('rootThread', function() {
});
it('does not remove annotations that have non-empty drafts', function() {
fakeDrafts.getIfNotEmpty.returns(fixtures.nonEmptyDraft);
fakeStore.getDraftIfNotEmpty.returns(fixtures.nonEmptyDraft);
$rootScope.$broadcast(
events.BEFORE_ANNOTATION_CREATED,
annotationFixtures.newAnnotation()
);
assert.notCalled(fakeDrafts.remove);
assert.notCalled(fakeStore.removeDraft);
assert.notCalled(onDelete);
});
......@@ -439,7 +434,7 @@ describe('rootThread', function() {
annotationFixtures.newAnnotation()
);
assert.notCalled(fakeDrafts.remove);
assert.notCalled(fakeStore.removeDraft);
assert.notCalled(onDelete);
});
});
......
......@@ -37,6 +37,7 @@ const debugMiddleware = require('./debug-middleware');
const activity = require('./modules/activity');
const annotations = require('./modules/annotations');
const directLinked = require('./modules/direct-linked');
const drafts = require('./modules/drafts');
const frames = require('./modules/frames');
const links = require('./modules/links');
const groups = require('./modules/groups');
......@@ -88,6 +89,7 @@ function store($rootScope, settings) {
activity,
annotations,
directLinked,
drafts,
frames,
links,
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() {
angular
.module('app', [])
.service('store', require('../../store'))
.service('drafts', require('../../services/drafts'))
.service('rootThread', require('../../services/root-thread'))
.service('searchFilter', require('../../services/search-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