Commit f407141a authored by Robert Knight's avatar Robert Knight

Prevent double-posting of new annotations

When the user clicks the 'Post' button to create an annotation,
optimistically switch the card back to View mode but display
a 'Saving...' indicator in place of the Reply/Edit/Delete links.

This makes the UI appear more responsive when the user clicks
the Post button and also prevents an issue where the user could
click 'Post' multiple times during the save and create multiple
annotations.

 * Fix a possible inconsistency between the 'Post' button's enabled
   state and whether or not the save() function can succeed.

   The hasContent() and isShared() methods also already have tests,
   so this lets us remove several redundant tests.

 * Fix inconsistency in the return type of the save() function -
   always return a promise.

 * Treat negative status values as network errors as well as 0.
   If the server is unreachable, the real status value may be -1.

Fixes #2864
parent e1009e6b
...@@ -27,11 +27,11 @@ function domainModelTagsFromViewModelTags(viewModelTags) { ...@@ -27,11 +27,11 @@ function domainModelTagsFromViewModelTags(viewModelTags) {
*/ */
function errorMessage(reason) { function errorMessage(reason) {
var message; var message;
if (reason.status === 0) { if (reason.status <= 0) {
message = 'Service unreachable.'; message = 'Service unreachable.';
} else { } else {
message = reason.status + ' ' + reason.statusText; message = reason.status + ' ' + reason.statusText;
if (reason.data.reason) { if (reason.data && reason.data.reason) {
message = message + ': ' + reason.data.reason; message = message + ': ' + reason.data.reason;
} }
} }
...@@ -193,43 +193,6 @@ function updateViewModel($scope, time, domainModel, vm, permissions) { ...@@ -193,43 +193,6 @@ function updateViewModel($scope, time, domainModel, vm, permissions) {
} }
} }
/** Return truthy if the given annotation is valid, falsy otherwise.
*
* A public annotation has to have some text and/or some tags to be valid,
* public annotations with no text or tags (i.e. public highlights) are not
* valid.
*
* Non-public annotations just need to have a target to be valid, non-public
* highlights are valid.
*
* @param {object} annotation The annotation to be validated
*
*/
function validate(domainModel) {
if (!angular.isObject(domainModel)) {
return;
}
var permissions = domainModel.permissions || {};
var readPermissions = permissions.read || [];
var targets = domainModel.target || [];
if (domainModel.tags && domainModel.tags.length) {
return domainModel.tags.length;
}
if (domainModel.text && domainModel.text.length) {
return domainModel.text.length;
}
var worldReadable = false;
if (readPermissions.indexOf('group:__world__') !== -1) {
worldReadable = true;
}
return (targets.length && !worldReadable);
}
/** Return a vm tags array from the given domainModel tags array. /** Return a vm tags array from the given domainModel tags array.
* *
* domainModel.tags and vm.form.tags use different formats. This * domainModel.tags and vm.form.tags use different formats. This
...@@ -318,6 +281,9 @@ function AnnotationController( ...@@ -318,6 +281,9 @@ function AnnotationController(
/** Determines whether the annotation body should be collapsed. */ /** Determines whether the annotation body should be collapsed. */
vm.collapseBody = true; vm.collapseBody = true;
/** True if the annotation is currently being saved. */
vm.isSaving = false;
/** The domain model, contains the currently saved version of the /** The domain model, contains the currently saved version of the
* annotation from the server (or in the case of new annotations that * annotation from the server (or in the case of new annotations that
* haven't been saved yet - the data that will be saved to the server when * haven't been saved yet - the data that will be saved to the server when
...@@ -661,7 +627,13 @@ function AnnotationController( ...@@ -661,7 +627,13 @@ function AnnotationController(
*/ */
vm.save = function() { vm.save = function() {
if (!domainModel.user) { if (!domainModel.user) {
return flash.info('Please sign in to save your annotations.'); flash.info('Please sign in to save your annotations.');
return Promise.resolve();
}
if ((vm.action === 'create' || vm.action === 'edit') &&
!vm.hasContent() && vm.isShared()) {
flash.info('Please add text or a tag before publishing.');
return Promise.resolve();
} }
// Update stored tags with the new tags of this annotation. // Update stored tags with the new tags of this annotation.
...@@ -671,47 +643,45 @@ function AnnotationController( ...@@ -671,47 +643,45 @@ function AnnotationController(
}); });
tags.store(newTags); tags.store(newTags);
var saved;
switch (vm.action) { switch (vm.action) {
case 'create': case 'create':
updateDomainModel(domainModel, vm, permissions, groups); updateDomainModel(domainModel, vm, permissions, groups);
saved = domainModel.$create().then(function () {
if (!validate(domainModel)) {
return flash.info('Please add text or a tag before publishing.');
}
var onFulfilled = function() {
$rootScope.$emit('annotationCreated', domainModel); $rootScope.$emit('annotationCreated', domainModel);
updateView(domainModel); updateView(domainModel);
view();
drafts.remove(domainModel); drafts.remove(domainModel);
}; });
var onRejected = function(reason) { break;
flash.error(
errorMessage(reason), 'Saving annotation failed');
};
return domainModel.$create().then(onFulfilled, onRejected);
case 'edit': case 'edit':
var updatedModel = angular.copy(domainModel); var updatedModel = angular.copy(domainModel);
updateDomainModel(updatedModel, vm, permissions, groups); updateDomainModel(updatedModel, vm, permissions, groups);
saved = updatedModel.$update({
id: updatedModel.id
}).then(function () {
drafts.remove(domainModel);
$rootScope.$emit('annotationUpdated', updatedModel);
});
break;
if (!validate(updatedModel)) { default:
return flash.info('Please add text or a tag before publishing.'); throw new Error('Tried to save an annotation that is not being edited');
} }
onFulfilled = function() { // optimistically switch back to view mode and display the saving
drafts.remove(domainModel); // indicator
$rootScope.$emit('annotationUpdated', updatedModel); vm.isSaving = true;
view(); view();
};
onRejected = function(reason) { return saved.then(function () {
vm.isSaving = false;
}).catch(function (reason) {
vm.isSaving = false;
vm.edit();
flash.error( flash.error(
errorMessage(reason), 'Saving annotation failed'); errorMessage(reason), 'Saving annotation failed');
}; });
return updatedModel.$update({
id: updatedModel.id
}).then(onFulfilled, onRejected);
}
}; };
/** /**
...@@ -863,7 +833,6 @@ module.exports = { ...@@ -863,7 +833,6 @@ module.exports = {
extractDocumentMetadata: extractDocumentMetadata, extractDocumentMetadata: extractDocumentMetadata,
link: link, link: link,
updateDomainModel: updateDomainModel, updateDomainModel: updateDomainModel,
validate: validate,
// These are meant to be the public API of this module. // These are meant to be the public API of this module.
directive: annotation, directive: annotation,
......
...@@ -262,99 +262,6 @@ describe('annotation', function() { ...@@ -262,99 +262,6 @@ describe('annotation', function() {
}); });
}); });
describe('validate()', function() {
var validate = require('../annotation').validate;
it('returns undefined if value is not an object', function() {
var i;
var values = [2, 'foo', true, null];
for (i = 0; i < values.length; i++) {
assert.equal(validate(values[i]));
}
});
it(
'returns the length if the value contains a non-empty tags array',
function() {
assert.equal(
validate({
tags: ['foo', 'bar'],
permissions: {
read: ['group:__world__']
},
target: [1, 2, 3]
}),
2);
}
);
it(
'returns the length if the value contains a non-empty text string',
function() {
assert.equal(
validate({
text: 'foobar',
permissions: {
read: ['group:__world__']
},
target: [1, 2, 3]
}),
6);
}
);
it('returns true for private highlights', function() {
assert.equal(
validate({
permissions: {
read: ['acct:seanh@hypothes.is']
},
target: [1, 2, 3]
}),
true);
});
it('returns true for group highlights', function() {
assert.equal(
validate({
permissions: {
read: ['group:foo']
},
target: [1, 2, 3]
}),
true);
});
it('returns false for public highlights', function() {
assert.equal(
validate(
{text: undefined, tags: undefined, target: [1, 2, 3],
permissions: {read: ['group:__world__']}}
),
false);
});
it('handles values with no permissions', function() {
assert.equal(
validate({
permissions: void 0,
target: [1, 2, 3]
}),
true);
});
it('handles permissions objects with no read', function() {
assert.equal(
validate({
permissions: {
read: void 0
},
target: [1, 2, 3]
}),
true);
});
});
describe('link', function () { describe('link', function () {
var link = require('../annotation').link; var link = require('../annotation').link;
...@@ -1120,6 +1027,7 @@ describe('annotation', function() { ...@@ -1120,6 +1027,7 @@ describe('annotation', function() {
parts.controller.isPrivate = true; parts.controller.isPrivate = true;
parts.annotation.$update = sinon.stub().returns(Promise.resolve()); parts.annotation.$update = sinon.stub().returns(Promise.resolve());
parts.controller.edit(); parts.controller.edit();
parts.controller.form.text = 'test';
parts.controller.setPrivacy('shared'); parts.controller.setPrivacy('shared');
return parts.controller.save().then(function() { return parts.controller.save().then(function() {
assert.equal(parts.controller.isPrivate, false); assert.equal(parts.controller.isPrivate, false);
...@@ -1131,6 +1039,7 @@ describe('annotation', function() { ...@@ -1131,6 +1039,7 @@ describe('annotation', function() {
parts.annotation.$update = sinon.stub().returns(Promise.resolve()); parts.annotation.$update = sinon.stub().returns(Promise.resolve());
parts.controller.edit(); parts.controller.edit();
parts.controller.setPrivacy('shared'); parts.controller.setPrivacy('shared');
parts.controller.form.text = 'test';
return parts.controller.save().then(function() { return parts.controller.save().then(function() {
assert(fakePermissions.setDefault.calledWithExactly('shared')); assert(fakePermissions.setDefault.calledWithExactly('shared'));
}); });
...@@ -1245,6 +1154,7 @@ describe('annotation', function() { ...@@ -1245,6 +1154,7 @@ describe('annotation', function() {
}; };
var controller = createDirective(annotation).controller; var controller = createDirective(annotation).controller;
controller.action = 'create'; controller.action = 'create';
controller.form.text = 'test';
return controller.save().then(function () { return controller.save().then(function () {
assert.equal(controller.relativeTimestamp, 'a while ago'); assert.equal(controller.relativeTimestamp, 'a while ago');
}); });
...@@ -1270,6 +1180,7 @@ describe('annotation', function() { ...@@ -1270,6 +1180,7 @@ describe('annotation', function() {
var controller = createDirective(annotation).controller; var controller = createDirective(annotation).controller;
assert.equal(controller.relativeTimestamp, 'ages ago'); assert.equal(controller.relativeTimestamp, 'ages ago');
controller.edit(); controller.edit();
controller.form.text = 'test';
clock.restore(); clock.restore();
return controller.save().then(function () { return controller.save().then(function () {
assert.equal(controller.relativeTimestamp, 'just now'); assert.equal(controller.relativeTimestamp, 'just now');
...@@ -1414,6 +1325,7 @@ describe('annotation', function() { ...@@ -1414,6 +1325,7 @@ describe('annotation', function() {
function controllerWithActionCreate() { function controllerWithActionCreate() {
var controller = createDirective(annotation).controller; var controller = createDirective(annotation).controller;
controller.action = 'create'; controller.action = 'create';
controller.form.text = 'new annotation';
return controller; return controller;
} }
...@@ -1471,6 +1383,36 @@ describe('annotation', function() { ...@@ -1471,6 +1383,36 @@ describe('annotation', function() {
assert(fakeFlash.error.notCalled); assert(fakeFlash.error.notCalled);
} }
); );
it('shows a saving indicator when saving an annotation', function() {
var controller = controllerWithActionCreate();
var create;
annotation.$create.returns(new Promise(function (resolve) {
create = resolve;
}));
var saved = controller.save();
assert.equal(controller.isSaving, true);
assert.equal(controller.action, 'view');
create();
return saved.then(function () {
assert.equal(controller.isSaving, false);
});
});
it('reverts to edit mode if saving fails', function () {
var controller = controllerWithActionCreate();
var failCreation;
annotation.$create.returns(new Promise(function (resolve, reject) {
failCreation = reject;
}));
var saved = controller.save();
assert.equal(controller.isSaving, true);
failCreation({status: -1});
return saved.then(function () {
assert.equal(controller.isSaving, false);
assert.ok(controller.editing());
});
});
}); });
describe('saving an edited an annotation', function() { describe('saving an edited an annotation', function() {
...@@ -1485,37 +1427,36 @@ describe('annotation', function() { ...@@ -1485,37 +1427,36 @@ describe('annotation', function() {
function controllerWithActionEdit() { function controllerWithActionEdit() {
var controller = createDirective(annotation).controller; var controller = createDirective(annotation).controller;
controller.action = 'edit'; controller.action = 'edit';
controller.form.text = 'updated text';
return controller; return controller;
} }
it( it(
'flashes a generic error if the server cannot be reached', 'flashes a generic error if the server cannot be reached',
function(done) { function() {
var controller = controllerWithActionEdit(); var controller = controllerWithActionEdit();
annotation.$update.returns(Promise.reject({ annotation.$update.returns(Promise.reject({
status: 0 status: -1
})); }));
controller.save().then(function() { return controller.save().then(function() {
assert(fakeFlash.error.calledWith( assert(fakeFlash.error.calledWith(
'Service unreachable.', 'Saving annotation failed')); 'Service unreachable.', 'Saving annotation failed'));
done();
}); });
} }
); );
it( it(
'flashes an error if saving the annotation fails on the server', 'flashes an error if saving the annotation fails on the server',
function(done) { function() {
var controller = controllerWithActionEdit(); var controller = controllerWithActionEdit();
annotation.$update.returns(Promise.reject({ annotation.$update.returns(Promise.reject({
status: 500, status: 500,
statusText: 'Server Error', statusText: 'Server Error',
data: {} data: {}
})); }));
controller.save().then(function() { return controller.save().then(function() {
assert(fakeFlash.error.calledWith( assert(fakeFlash.error.calledWith(
'500 Server Error', 'Saving annotation failed')); '500 Server Error', 'Saving annotation failed'));
done();
}); });
} }
); );
...@@ -1525,6 +1466,7 @@ describe('annotation', function() { ...@@ -1525,6 +1466,7 @@ describe('annotation', function() {
function() { function() {
var controller = controllerWithActionEdit(); var controller = controllerWithActionEdit();
annotation.$update.returns(Promise.resolve()); annotation.$update.returns(Promise.resolve());
controller.form.text = 'updated text';
controller.save(); controller.save();
assert(fakeFlash.error.notCalled); assert(fakeFlash.error.notCalled);
} }
...@@ -1571,6 +1513,7 @@ describe('annotation', function() { ...@@ -1571,6 +1513,7 @@ describe('annotation', function() {
annotation.$update = sandbox.stub().returns(Promise.resolve()); annotation.$update = sandbox.stub().returns(Promise.resolve());
var controller = createDirective(annotation).controller; var controller = createDirective(annotation).controller;
controller.edit(); controller.edit();
controller.form.text = 'test annotation';
return controller.save().then(function() { return controller.save().then(function() {
assert.calledWith(fakeDrafts.remove, annotation); assert.calledWith(fakeDrafts.remove, annotation);
}); });
......
...@@ -149,7 +149,12 @@ ...@@ -149,7 +149,12 @@
when="{'0': '', 'one': '1 reply', 'other': '{} replies'}"></a> when="{'0': '', 'one': '1 reply', 'other': '{} replies'}"></a>
</div> </div>
<div class="annotation-actions" ng-if="!vm.editing() && vm.id()"> <div class="annotation-actions" ng-if="vm.isSaving">
Saving...
</div>
<div class="annotation-actions" ng-if="!vm.isSaving && !vm.editing() && vm.id()">
<div ng-show="vm.isSaving">Saving…</div>
<button class="small btn btn-clean" <button class="small btn btn-clean"
ng-click="vm.reply()" ng-click="vm.reply()"
><i class="h-icon-reply btn-icon"></i> Reply</button> ><i class="h-icon-reply btn-icon"></i> Reply</button>
...@@ -166,7 +171,7 @@ ...@@ -166,7 +171,7 @@
</span> </span>
</span> </span>
<button class="small btn btn-clean" <button class="small btn btn-clean"
ng-show="vm.authorize('update')" ng-show="vm.authorize('update') && !vm.isSaving"
ng-click="vm.edit()" ng-click="vm.edit()"
><i class="h-icon-edit btn-icon"></i> Edit</button> ><i class="h-icon-edit btn-icon"></i> Edit</button>
<button class="small btn btn-clean" <button class="small btn btn-clean"
......
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