Commit 41904b1b authored by Sean Hammond's avatar Sean Hammond Committed by GitHub

Merge pull request #8 from hypothesis/simplify-annot-cmp

Simplify annotation component by removing duplicated state
parents 9d6f56bc 8db47b83
/* jshint node: true */
'use strict';
var angular = require('angular');
var annotationMetadata = require('../annotation-metadata');
var events = require('../events');
var memoize = require('../util/memoize');
var persona = require('../filter/persona');
var isNew = annotationMetadata.isNew;
......@@ -31,95 +30,22 @@ function errorMessage(reason) {
return message;
}
/** Restore unsaved changes to this annotation from the drafts service.
*
* If there are no draft changes to this annotation, does nothing.
*
*/
function restoreFromDrafts(drafts, domainModel, vm) {
var draft = drafts.get(domainModel);
if (draft) {
vm.isPrivate = draft.isPrivate;
vm.form.tags = draft.tags;
vm.form.text = draft.text;
}
}
/**
* Save the given annotation to the drafts service.
*
* Any existing drafts for this annotation will be overwritten.
*
* @param {object} drafts - The drafts service
* @param {object} domainModel - The full domainModel object of the
* annotation to be saved. This full domainModel model is not retrieved
* again from drafts, it's only used to identify the annotation's draft in
* order to retrieve the fields below.
* @param {object} vm - The view model object containing the user's unsaved
* changes to the annotation.
*
*/
function saveToDrafts(drafts, domainModel, vm) {
drafts.update(
domainModel,
{
isPrivate: vm.isPrivate,
tags: vm.form.tags,
text: vm.form.text,
});
}
/** Update domainModel from vm.
*
* Copy any properties from vm that might have been modified by the user into
* domainModel, overwriting any existing properties in domainModel.
*
* @param {object} domainModel The object to copy properties to
* @param {object} vm The object to copy properties from
*
* Return a copy of `annotation` with changes made in the editor applied.
*/
function updateDomainModel(domainModel, vm, permissions) {
domainModel.text = vm.form.text;
domainModel.tags = vm.form.tags;
if (vm.isPrivate) {
domainModel.permissions = permissions.private();
} else {
domainModel.permissions = permissions.shared(domainModel.group);
}
}
/** Update the view model from the domain model changes. */
function updateViewModel($scope, domainModel,
vm, permissions) {
vm.form = {
text: domainModel.text,
tags: domainModel.tags,
};
if (domainModel.links) {
vm.linkInContext = domainModel.links.incontext ||
domainModel.links.html ||
'';
vm.linkHTML = domainModel.links.html || '';
} else {
vm.linkInContext = '';
vm.linkHTML = '';
}
vm.isPrivate = permissions.isPrivate(
domainModel.permissions, domainModel.user);
vm.documentMeta = annotationMetadata.domainAndTitle(domainModel);
function updateModel(annotation, changes, permissions) {
return Object.assign({}, annotation, {
// Explicitly copy across the non-enumerable local tag for the annotation
$$tag: annotation.$$tag,
// Apply changes from the draft
tags: changes.tags,
text: changes.text,
permissions: changes.isPrivate ?
permissions.private() : permissions.shared(annotation.group),
});
}
/**
* @ngdoc type
* @name annotation.AnnotationController
*
*/
// @ngInject
function AnnotationController(
$document, $q, $rootScope, $scope, $timeout, $window, annotationUI,
......@@ -127,7 +53,6 @@ function AnnotationController(
settings, store) {
var vm = this;
var domainModel;
var newlyCreatedByHighlightButton;
/** Save an annotation to the server. */
......@@ -164,13 +89,6 @@ function AnnotationController(
* can call the methods.
*/
function init() {
/** The currently active action - 'view', 'create' or 'edit'. */
vm.action = 'view';
/** vm.form is the read-write part of vm for the templates: it contains
* the variables that the templates will write changes to via ng-model. */
vm.form = {};
// The remaining properties on vm are read-only properties for the
// templates.
......@@ -180,9 +98,6 @@ function AnnotationController(
/** Give the template access to the feature flags. */
vm.feature = features.flagEnabled;
/** Whether or not this annotation is private. */
vm.isPrivate = false;
/** Determines whether controls to expand/collapse the annotation body
* are displayed adjacent to the tags field.
*/
......@@ -197,13 +112,6 @@ function AnnotationController(
/** True if the 'Share' dialog for this annotation is currently open. */
vm.showShareDialog = false;
/** The domain model, contains the currently saved version of the
* 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
* they are saved).
*/
domainModel = vm.annotation;
/**
* `true` if this AnnotationController instance was created as a result of
* the highlight button being clicked.
......@@ -212,35 +120,34 @@ function AnnotationController(
* or annotation that was fetched from the server (as opposed to created
* new client-side).
*/
newlyCreatedByHighlightButton = domainModel.$highlight || false;
// Call `onAnnotationUpdated()` whenever the "annotationUpdated" event is
// emitted. This event is emitted after changes to the annotation are
// successfully saved to the server, and also when changes to the
// annotation made by another client are received by this client from the
// server.
$rootScope.$on(events.ANNOTATION_UPDATED, onAnnotationUpdated);
newlyCreatedByHighlightButton = vm.annotation.$highlight || false;
// When a new annotation is created, remove any existing annotations that
// are empty
$rootScope.$on(events.BEFORE_ANNOTATION_CREATED, deleteIfNewAndEmpty);
// are empty.
//
// This event is currently emitted with $emit rather than $broadcast so
// we have to listen for it on the $rootScope and manually de-register
// on destruction.
var removeNewAnnotListener =
$rootScope.$on(events.BEFORE_ANNOTATION_CREATED, deleteIfNewAndEmpty);
// Call `onDestroy()` when the component is destroyed.
$scope.$on('$destroy', onDestroy);
vm.$onDestroy = function () {
removeNewAnnotListener();
};
// Call `onGroupFocused()` whenever the currently-focused group changes.
$scope.$on(events.GROUP_FOCUSED, onGroupFocused);
// New annotations (just created locally by the client, rather then
// received from the server) have some fields missing. Add them.
domainModel.user = domainModel.user || session.state.userid;
domainModel.group = domainModel.group || groups.focused().id;
if (!domainModel.permissions) {
domainModel.permissions = permissions['default'](domainModel.group);
vm.annotation.user = vm.annotation.user || session.state.userid;
vm.annotation.group = vm.annotation.group || groups.focused().id;
if (!vm.annotation.permissions) {
vm.annotation.permissions = permissions['default'](vm.annotation.group);
}
domainModel.text = domainModel.text || '';
if (!Array.isArray(domainModel.tags)) {
domainModel.tags = [];
vm.annotation.text = vm.annotation.text || '';
if (!Array.isArray(vm.annotation.tags)) {
vm.annotation.tags = [];
}
// Automatically save new highlights to the server when they're created.
......@@ -250,51 +157,26 @@ function AnnotationController(
// log in.
saveNewHighlight();
updateView(domainModel);
// If this annotation is not a highlight and if it's new (has just been
// 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(domainModel) || drafts.get(domainModel)) {
if (isNew(vm.annotation) || drafts.get(vm.annotation)) {
vm.edit();
}
}
}
function updateView(domainModel) {
updateViewModel($scope, domainModel, vm, permissions);
}
function onAnnotationUpdated(event, updatedDomainModel) {
if (updatedDomainModel.id === domainModel.id) {
domainModel = updatedDomainModel;
updateView(updatedDomainModel);
}
}
function deleteIfNewAndEmpty() {
if (isNew(domainModel) && !vm.form.text && vm.form.tags.length === 0) {
if (isNew(vm.annotation) && !vm.state().text && vm.state().tags.length === 0) {
vm.revert();
}
}
function onDestroy() {
// If the annotation component is destroyed whilst the annotation is being
// edited, persist temporary state so that we can restore it if the
// annotation editor is later recreated.
//
// The annotation component may be destroyed when switching accounts,
// when switching groups or when the component is scrolled off-screen.
if (vm.editing()) {
saveToDrafts(drafts, domainModel, vm);
}
}
function onGroupFocused() {
// New annotations move to the new group, when a new group is focused.
if (isNew(domainModel)) {
domainModel.group = groups.focused().id;
if (isNew(vm.annotation)) {
vm.annotation.group = groups.focused().id;
}
}
......@@ -308,7 +190,7 @@ function AnnotationController(
*
*/
function saveNewHighlight() {
if (!isNew(domainModel)) {
if (!isNew(vm.annotation)) {
// Already saved.
return;
}
......@@ -318,29 +200,20 @@ function AnnotationController(
return;
}
if (domainModel.user) {
if (vm.annotation.user) {
// User is logged in, save to server.
// Highlights are always private.
domainModel.permissions = permissions.private();
save(domainModel).then(function(model) {
domainModel = model;
vm.annotation.permissions = permissions.private();
save(vm.annotation).then(function(model) {
model.$$tag = vm.annotation.$$tag;
$rootScope.$emit(events.ANNOTATION_CREATED, model);
updateView(domainModel);
});
} else {
// User isn't logged in, save to drafts.
saveToDrafts(drafts, domainModel, vm);
drafts.update(vm.annotation, vm.state());
}
}
/** Switches the view to a viewer, closing the editor controls if they're
* open.
* @name annotation.AnnotationController#view
*/
function view() {
vm.action = 'view';
}
/**
* @ngdoc method
* @name annotation.AnnotationController#authorize
......@@ -355,7 +228,7 @@ function AnnotationController(
// performance bottleneck and we would need to get the id token into the
// session, which we should probably do anyway (and move to opaque bearer
// tokens for the access token).
return permissions.permits(action, domainModel, session.state.userid);
return permissions.permits(action, vm.annotation, session.state.userid);
};
/**
......@@ -372,7 +245,7 @@ function AnnotationController(
errorMessage(reason), 'Deleting annotation failed');
};
$scope.$apply(function() {
annotationMapper.deleteAnnotation(domainModel).then(
annotationMapper.deleteAnnotation(vm.annotation).then(
null, onRejected);
});
}
......@@ -385,8 +258,9 @@ function AnnotationController(
* @description Switches the view to an editor.
*/
vm.edit = function() {
restoreFromDrafts(drafts, domainModel, vm);
vm.action = isNew(domainModel) ? 'create' : 'edit';
if (!drafts.get(vm.annotation)) {
drafts.update(vm.annotation, vm.state());
}
};
/**
......@@ -396,11 +270,7 @@ function AnnotationController(
* (i.e. the annotation editor form should be open), `false` otherwise.
*/
vm.editing = function() {
if (vm.action === 'create' || vm.action === 'edit') {
return true;
} else {
return false;
}
return drafts.get(vm.annotation) && !vm.isSaving;
};
/**
......@@ -409,7 +279,7 @@ function AnnotationController(
* @returns {Object} The full group object associated with the annotation.
*/
vm.group = function() {
return groups.get(domainModel.group);
return groups.get(vm.annotation.group);
};
/**
......@@ -419,14 +289,14 @@ function AnnotationController(
* otherwise.
*/
vm.hasContent = function() {
return vm.form.text.length > 0 || vm.form.tags.length > 0;
return vm.state().text.length > 0 || vm.state().tags.length > 0;
};
/**
* @returns {boolean} True if this annotation has quotes
*/
vm.hasQuotes = function() {
return domainModel.target.some(function(target) {
return vm.annotation.target.some(function(target) {
return target.selector && target.selector.some(function(selector) {
return selector.type === 'TextQuoteSelector';
});
......@@ -434,7 +304,7 @@ function AnnotationController(
};
vm.id = function() {
return domainModel.id;
return vm.annotation.id;
};
/**
......@@ -445,16 +315,16 @@ function AnnotationController(
vm.isHighlight = function() {
if (newlyCreatedByHighlightButton) {
return true;
} else if (isNew(domainModel)) {
} else if (isNew(vm.annotation)) {
return false;
} else {
// Once an annotation has been saved to the server there's no longer a
// simple property that says whether it's a highlight or not. For
// example there's no domainModel.highlight: true. Instead a highlight is
// example there's no vm.annotation.highlight: true. Instead a highlight is
// defined as an annotation that isn't a page note or a reply and that
// has no text or tags.
var isPageNote = (domainModel.target || []).length === 0;
return (!isPageNote && !isReply(domainModel) && !vm.hasContent());
var isPageNote = (vm.annotation.target || []).length === 0;
return (!isPageNote && !isReply(vm.annotation) && !vm.hasContent());
}
};
......@@ -465,7 +335,7 @@ function AnnotationController(
* current group or with everyone).
*/
vm.isShared = function() {
return !vm.isPrivate;
return !vm.state().isPrivate;
};
// Save on Meta + Enter or Ctrl + Enter.
......@@ -488,15 +358,15 @@ function AnnotationController(
* Creates a new message in reply to this annotation.
*/
vm.reply = function() {
var references = (domainModel.references || []).concat(domainModel.id);
var references = (vm.annotation.references || []).concat(vm.annotation.id);
var reply = annotationMapper.createAnnotation({
references: references,
uri: domainModel.uri
uri: vm.annotation.uri
});
reply.group = domainModel.group;
reply.group = vm.annotation.group;
if (session.state.userid) {
if (vm.isPrivate) {
if (vm.state().isPrivate) {
reply.permissions = permissions.private();
} else {
reply.permissions = permissions.shared(reply.group);
......@@ -510,56 +380,38 @@ function AnnotationController(
* @description Reverts an edit in progress and returns to the viewer.
*/
vm.revert = function() {
drafts.remove(domainModel);
if (vm.action === 'create') {
$rootScope.$emit(events.ANNOTATION_DELETED, domainModel);
} else {
updateView(domainModel);
view();
drafts.remove(vm.annotation);
if (isNew(vm.annotation)) {
$rootScope.$emit(events.ANNOTATION_DELETED, vm.annotation);
}
};
/**
* @ngdoc method
* @name annotation.AnnotationController#save
* @description Saves any edits and returns to the viewer.
*/
vm.save = function() {
if (!domainModel.user) {
if (!vm.annotation.user) {
flash.info('Please sign in to save your annotations.');
return Promise.resolve();
}
if ((vm.action === 'create' || vm.action === 'edit') &&
!vm.hasContent() && vm.isShared()) {
if (!vm.hasContent() && vm.isShared()) {
flash.info('Please add text or a tag before publishing.');
return Promise.resolve();
}
var updatedModel = angular.copy(domainModel);
// Copy across the non-enumerable local tag for the annotation
updatedModel.$$tag = domainModel.$$tag;
var updatedModel = updateModel(vm.annotation, vm.state(), permissions);
updateDomainModel(updatedModel, vm, permissions);
var saved = save(updatedModel).then(function (model) {
var isNew = !domainModel.id;
drafts.remove(domainModel);
domainModel = model;
if (isNew) {
$rootScope.$emit(events.ANNOTATION_CREATED, domainModel);
} else {
$rootScope.$emit(events.ANNOTATION_UPDATED, domainModel);
}
updateView(domainModel);
});
// optimistically switch back to view mode and display the saving
// Optimistically switch back to view mode and display the saving
// indicator
vm.isSaving = true;
view();
return saved.then(function () {
return save(updatedModel).then(function (model) {
Object.assign(updatedModel, model);
vm.isSaving = false;
var event = isNew(vm.annotation) ?
events.ANNOTATION_CREATED : events.ANNOTATION_UPDATED;
drafts.remove(vm.annotation);
$rootScope.$emit(event, updatedModel);
}).catch(function (reason) {
vm.isSaving = false;
vm.edit();
......@@ -584,10 +436,14 @@ function AnnotationController(
// creating or editing, we cache that and use the same privacy level the
// next time they create an annotation.
// But _don't_ cache it when they change the privacy level of a reply.
if (!isReply(domainModel)) {
if (!isReply(vm.annotation)) {
permissions.setDefault(privacy);
}
vm.isPrivate = (privacy === 'private');
drafts.update(vm.annotation, {
tags: vm.state().tags,
text: vm.state().text,
isPrivate: privacy === 'private'
});
};
vm.tagStreamURL = function(tag) {
......@@ -595,23 +451,34 @@ function AnnotationController(
};
vm.target = function() {
return domainModel.target;
return vm.annotation.target;
};
vm.updated = function() {
return domainModel.updated;
return vm.annotation.updated;
};
vm.user = function() {
return domainModel.user;
return vm.annotation.user;
};
vm.username = function() {
return persona.username(domainModel.user);
return persona.username(vm.annotation.user);
};
vm.isReply = function () {
return isReply(domainModel);
return isReply(vm.annotation);
};
vm.links = function () {
if (vm.annotation.links) {
return {incontext: vm.annotation.links.incontext ||
vm.annotation.links.html ||
'',
html: vm.annotation.links.html};
} else {
return {incontext: '', html: ''};
}
};
/**
......@@ -630,11 +497,37 @@ function AnnotationController(
};
vm.setText = function (text) {
vm.form.text = text;
drafts.update(vm.annotation, {
isPrivate: vm.state().isPrivate,
tags: vm.state().tags,
text: text,
});
};
vm.setTags = function (tags) {
vm.form.tags = tags;
drafts.update(vm.annotation, {
isPrivate: vm.state().isPrivate,
tags: tags,
text: vm.state().text,
});
};
vm.state = function () {
var draft = drafts.get(vm.annotation);
if (draft) {
return draft;
}
return {
tags: vm.annotation.tags,
text: vm.annotation.text,
isPrivate: permissions.isPrivate(vm.annotation.permissions,
vm.annotation.user),
};
};
var documentMeta = memoize(annotationMetadata.domainAndTitle);
vm.documentMeta = function () {
return documentMeta(vm.annotation);
};
init();
......@@ -664,7 +557,7 @@ module.exports = {
// to be unit tested.
// FIXME: The code should be refactored to enable unit testing without having
// to do this.
updateDomainModel: updateDomainModel,
updateModel: updateModel,
// These are meant to be the public API of this module.
directive: annotation,
......
......@@ -11,6 +11,12 @@ var util = require('./util');
var inject = angular.mock.inject;
var fakeDocumentMeta = {
domain: 'docs.io',
titleLink: 'http://docs.io/doc.html',
titleText: 'Dummy title',
};
/**
* Returns the annotation directive with helpers stubbed out.
*/
......@@ -19,10 +25,13 @@ function annotationDirective() {
var annotation = proxyquire('../annotation', {
angular: testUtil.noCallThru(angular),
'../filter/document-domain': noop,
'../filter/document-title': noop,
'../filter/persona': {
username: noop,
},
'../annotation-metadata': {
domainAndTitle: function (annot) {
return fakeDocumentMeta;
},
}
});
......@@ -30,8 +39,8 @@ function annotationDirective() {
}
describe('annotation', function() {
describe('updateDomainModel()', function() {
var updateDomainModel = require('../annotation').updateDomainModel;
describe('updateModel()', function() {
var updateModel = require('../annotation').updateModel;
function fakePermissions() {
return {
......@@ -40,89 +49,30 @@ describe('annotation', function() {
};
}
function fakeGroups() {
return {
focused: function() {return {};},
};
}
it('copies text from viewModel into domainModel', function() {
var domainModel = {};
var viewModel = {form: {text: 'bar', tags: []}};
updateDomainModel(domainModel, viewModel, fakePermissions(),
fakeGroups());
assert.equal(domainModel.text, viewModel.form.text);
it('copies tags and text into the new model', function() {
var changes = {text: 'bar', tags: ['foo', 'bar']};
var newModel = updateModel(fixtures.defaultAnnotation(), changes,
fakePermissions());
assert.deepEqual(newModel.tags, changes.tags);
assert.equal(newModel.text, changes.text);
});
it('overwrites text in domainModel', function() {
var domainModel = {text: 'foo'};
var viewModel = {form: {text: 'bar', tags: []}};
updateDomainModel(domainModel, viewModel, fakePermissions(),
fakeGroups());
assert.equal(domainModel.text, viewModel.form.text);
});
it('doesn\'t touch other properties in domainModel', function() {
var domainModel = {foo: 'foo', bar: 'bar'};
var viewModel = {form: {foo: 'FOO', tags: []}};
updateDomainModel(domainModel, viewModel, fakePermissions(),
fakeGroups());
assert.equal(
domainModel.bar, 'bar',
'updateDomainModel() should not touch properties of domainModel' +
'that don\'t exist in viewModel');
});
it('copies tag texts from viewModel into domainModel', function() {
var domainModel = {};
var viewModel = {
form: {
tags: ['foo', 'bar'],
}
};
updateDomainModel(domainModel, viewModel, fakePermissions(),
fakeGroups());
assert.deepEqual(domainModel.tags, ['foo', 'bar']);
});
it('sets domainModel.permissions to private if vm.isPrivate', function() {
var domainModel = {};
var viewModel = {
isPrivate: true,
form: {
text: 'foo',
},
};
it('sets permissions to private if the draft is private', function() {
var changes = {isPrivate: true, text: 'bar', tags: ['foo', 'bar']};
var annot = fixtures.defaultAnnotation();
var permissions = fakePermissions();
permissions.private = sinon.stub().returns('private permissions');
updateDomainModel(domainModel, viewModel, permissions, fakeGroups());
assert.equal(domainModel.permissions, 'private permissions');
var newModel = updateModel(annot, changes, permissions);
assert.equal(newModel.permissions, 'private permissions');
});
it('sets domainModel.permissions to shared if !vm.isPrivate', function() {
var domainModel = {};
var viewModel = {
isPrivate: false,
form: {
text: 'foo',
},
};
it('sets permissions to shared if the draft is shared', function() {
var changes = {isPrivate: false, text: 'bar', tags: ['foo', 'bar']};
var annot = fixtures.defaultAnnotation();
var permissions = fakePermissions();
permissions.shared = sinon.stub().returns('shared permissions');
updateDomainModel(domainModel, viewModel, permissions, fakeGroups());
assert.equal(domainModel.permissions, 'shared permissions');
var newModel = updateModel(annot, changes, permissions);
assert.equal(newModel.permissions, 'shared permissions');
});
});
......@@ -160,8 +110,6 @@ describe('annotation', function() {
};
}
before(function() {
angular.module('h', [])
.directive('annotation', annotationDirective());
......@@ -248,11 +196,11 @@ describe('annotation', function() {
$provide.value('drafts', fakeDrafts);
$provide.value('features', fakeFeatures);
$provide.value('flash', fakeFlash);
$provide.value('groups', fakeGroups);
$provide.value('permissions', fakePermissions);
$provide.value('session', fakeSession);
$provide.value('settings', fakeSettings);
$provide.value('store', fakeStore);
$provide.value('groups', fakeGroups);
}));
beforeEach(
......@@ -380,12 +328,22 @@ describe('annotation', function() {
assert.notCalled(fakeStore.annotation.create);
});
it('edits new annotations on initialization', function() {
it('creates drafts for new annotations on initialization', function() {
var annotation = fixtures.newAnnotation();
createDirective(annotation);
assert.calledWith(fakeDrafts.update, annotation, {
isPrivate: false,
tags: annotation.tags,
text: annotation.text,
});
});
it('does not create drafts for new highlights on initialization', function() {
var annotation = fixtures.newHighlight();
var controller = createDirective(annotation).controller;
assert.isTrue(controller.editing());
assert.notOk(controller.editing());
assert.notCalled(fakeDrafts.update);
});
it('edits annotations with drafts on initialization', function() {
......@@ -397,51 +355,29 @@ describe('annotation', function() {
assert.isTrue(controller.editing());
});
it('does not edit new highlights on initialization', function() {
var annotation = fixtures.newHighlight();
var controller = createDirective(annotation).controller;
assert.isFalse(controller.editing());
});
it('edits highlights with drafts on initialization', function() {
var annotation = fixtures.oldHighlight();
// You can edit a highlight, enter some text or tags, and save it (the
// highlight then becomes an annotation). You can also edit a highlight
// and then change focus to another group and back without saving the
// highlight, in which case the highlight will have draft edits.
// This highlight has draft edits.
fakeDrafts.get.returns({text: '', tags: []});
var controller = createDirective(annotation).controller;
assert.isTrue(controller.editing());
});
});
describe('.editing()', function() {
it('returns true if action is "create"', function() {
describe('#editing()', function() {
it('returns false if the annotation does not have a draft', function () {
var controller = createDirective().controller;
controller.action = 'create';
assert.equal(controller.editing(), true);
assert.notOk(controller.editing());
});
it('returns true if action is "edit"', function() {
it('returns true if the annotation has a draft', function () {
var controller = createDirective().controller;
controller.action = 'edit';
assert.equal(controller.editing(), true);
fakeDrafts.get.returns({tags: [], text: '', isPrivate: false});
assert.isTrue(controller.editing());
});
it('returns false if action is "view"', function() {
it('returns false if the annotation has a draft but is being saved', function () {
var controller = createDirective().controller;
controller.action = 'view';
assert.equal(controller.editing(), false);
fakeDrafts.get.returns({tags: [], text: '', isPrivate: false});
controller.isSaving = true;
assert.isFalse(controller.editing());
});
});
describe('.isHighlight()', function() {
describe('#isHighlight()', function() {
it('returns true for new highlights', function() {
var annotation = fixtures.newHighlight();
......@@ -582,7 +518,7 @@ describe('annotation', function() {
'does not add the world readable principal if the parent is private',
function() {
var controller = createDirective(annotation).controller;
controller.isPrivate = true;
fakePermissions.isPrivate.returns(true);
var reply = {};
fakeAnnotationMapper.createAnnotation.returns(reply);
controller.reply();
......@@ -606,78 +542,49 @@ describe('annotation', function() {
describe('#setPrivacy', function() {
it('makes the annotation private when level is "private"', function() {
var parts = createDirective();
// Make this annotation shared.
parts.controller.isPrivate = false;
fakePermissions.isPrivate.returns(false);
// Edit the annotation and make it private.
parts.controller.edit();
parts.controller.setPrivacy('private');
fakePermissions.isPrivate.returns(true);
return parts.controller.save().then(function() {
// Verify that the permissions are updated once the annotation
// is saved.
assert.equal(parts.controller.isPrivate, true);
});
assert.calledWith(fakeDrafts.update, parts.controller.annotation, sinon.match({
isPrivate: true,
}));
});
it('makes the annotation shared when level is "shared"', function() {
var parts = createDirective();
parts.controller.isPrivate = true;
parts.controller.edit();
parts.controller.form.text = 'test';
parts.controller.setPrivacy('shared');
return parts.controller.save().then(function() {
assert.equal(parts.controller.isPrivate, false);
});
assert.calledWith(fakeDrafts.update, parts.controller.annotation, sinon.match({
isPrivate: false,
}));
});
it('saves the "shared" visibility level to localStorage', function() {
it('sets the default visibility level if "shared"', function() {
var parts = createDirective();
parts.controller.edit();
parts.controller.setPrivacy('shared');
parts.controller.form.text = 'test';
return parts.controller.save().then(function() {
assert.calledWith(fakePermissions.setDefault, 'shared');
});
assert.calledWith(fakePermissions.setDefault, 'shared');
});
it('saves the "private" visibility level to localStorage', function() {
it('sets the default visibility if "private"', function() {
var parts = createDirective();
parts.controller.edit();
parts.controller.setPrivacy('private');
return parts.controller.save().then(function() {
assert.calledWith(fakePermissions.setDefault, 'private');
});
assert.calledWith(fakePermissions.setDefault, 'private');
});
it('doesn\'t save the visibility if the annotation is a reply', function() {
var parts = createDirective();
parts.annotation.references = ['parent id'];
parts.controller.edit();
var parts = createDirective(fixtures.oldReply());
parts.controller.setPrivacy('private');
return parts.controller.save().then(function() {
assert.notCalled(fakePermissions.setDefault);
});
assert.notCalled(fakePermissions.setDefault);
});
});
describe('#hasContent', function() {
it('returns false if the annotation has no tags or text', function() {
var controller = createDirective().controller;
controller.form.text = '';
controller.form.tags = [];
var controller = createDirective(fixtures.oldHighlight()).controller;
assert.ok(!controller.hasContent());
});
it('returns true if the annotation has tags or text', function() {
var controller = createDirective().controller;
controller.form.text = 'bar';
assert.ok(controller.hasContent());
controller.form.text = '';
controller.form.tags = ['foo'];
var controller = createDirective(fixtures.oldAnnotation()).controller;
assert.ok(controller.hasContent());
});
});
......@@ -786,6 +693,14 @@ describe('annotation', function() {
});
});
describe('#documentMeta()', function () {
it('returns the domain, title link and text for the annotation', function () {
var annot = fixtures.defaultAnnotation();
var controller = createDirective(annot).controller;
assert.deepEqual(controller.documentMeta(), fakeDocumentMeta);
});
});
describe('saving a new annotation', function() {
var annotation;
......@@ -793,26 +708,27 @@ describe('annotation', function() {
annotation = fixtures.newAnnotation();
});
function controllerWithActionCreate() {
var controller = createDirective(annotation).controller;
controller.action = 'create';
controller.form.text = 'new annotation';
return controller;
function createController() {
return createDirective(annotation).controller;
}
it(
'emits annotationCreated when saving an annotation succeeds',
function() {
var controller = controllerWithActionCreate();
sandbox.spy($rootScope, '$emit');
return controller.save().then(function() {
assert.calledWith($rootScope.$emit, events.ANNOTATION_CREATED);
});
}
);
it('removes the draft when saving an annotation succeeds', function () {
var controller = createController();
return controller.save().then(function () {
assert.calledWith(fakeDrafts.remove, annotation);
});
});
it('emits annotationCreated when saving an annotation succeeds', function () {
var controller = createController();
sandbox.spy($rootScope, '$emit');
return controller.save().then(function() {
assert.calledWith($rootScope.$emit, events.ANNOTATION_CREATED);
});
});
it('flashes a generic error if the server can\'t be reached', function() {
var controller = controllerWithActionCreate();
var controller = createController();
fakeStore.annotation.create = sinon.stub().returns(Promise.reject({
status: 0
}));
......@@ -823,7 +739,7 @@ describe('annotation', function() {
});
it('flashes an error if saving the annotation fails on the server', function() {
var controller = controllerWithActionCreate();
var controller = createController();
fakeStore.annotation.create = sinon.stub().returns(Promise.reject({
status: 500,
statusText: 'Server Error',
......@@ -836,59 +752,41 @@ describe('annotation', function() {
});
it('doesn\'t flash an error when saving an annotation succeeds', function() {
var controller = controllerWithActionCreate();
controller.save();
assert.notCalled(fakeFlash.error);
var controller = createController();
return controller.save().then(function () {
assert.notCalled(fakeFlash.error);
});
});
it('shows a saving indicator when saving an annotation', function() {
var controller = controllerWithActionCreate();
var controller = createController();
var create;
fakeStore.annotation.create = sinon.stub().returns(new Promise(function (resolve) {
create = resolve;
}));
var saved = controller.save();
assert.equal(controller.isSaving, true);
assert.equal(controller.action, 'view');
create();
create(Object.assign({}, controller.annotation, {id: 'new-id'}));
return saved.then(function () {
assert.equal(controller.isSaving, false);
});
});
it('reverts to edit mode if saving fails', function () {
var controller = controllerWithActionCreate();
var failCreation;
fakeStore.annotation.create = sinon.stub().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());
it('does not remove the draft if saving fails', function () {
var controller = createController();
fakeStore.annotation.create = sinon.stub().returns(Promise.reject({status: -1}));
return controller.save().then(function () {
assert.notCalled(fakeDrafts.remove);
});
});
it(
'Passes group:<id> to the server when saving a new annotation',
function() {
fakeGroups.focused = function () {
return { id: 'test-id' };
};
var annotation = {
user: 'acct:fred@hypothes.is',
text: 'foo',
};
var controller = createDirective(annotation).controller;
controller.action = 'create';
return controller.save().then(function() {
assert.calledWith(fakeStore.annotation.create, sinon.match({}),
sinon.match({group: 'test-id'}));
});
}
);
it('sets the annotation\'s group to the focused group', function() {
fakeGroups.focused = function () {
return { id: 'test-id' };
};
var controller = createDirective(fixtures.newAnnotation()).controller;
assert.equal(controller.annotation.group, 'test-id');
});
});
describe('saving an edited an annotation', function() {
......@@ -896,54 +794,43 @@ describe('annotation', function() {
beforeEach(function() {
annotation = fixtures.defaultAnnotation();
fakeDrafts.get.returns({text: 'unsaved change'});
});
function controllerWithActionEdit() {
var controller = createDirective(annotation).controller;
controller.action = 'edit';
controller.form.text = 'updated text';
return controller;
function createController() {
return createDirective(annotation).controller;
}
it(
'flashes a generic error if the server cannot be reached',
function() {
var controller = controllerWithActionEdit();
fakeStore.annotation.update = sinon.stub().returns(Promise.reject({
status: -1
}));
return controller.save().then(function() {
assert.calledWith(fakeFlash.error,
'Service unreachable.', 'Saving annotation failed');
});
}
);
it('flashes a generic error if the server cannot be reached', function () {
var controller = createController();
fakeStore.annotation.update = sinon.stub().returns(Promise.reject({
status: -1
}));
return controller.save().then(function() {
assert.calledWith(fakeFlash.error,
'Service unreachable.', 'Saving annotation failed');
});
});
it(
'flashes an error if saving the annotation fails on the server',
function() {
var controller = controllerWithActionEdit();
fakeStore.annotation.update = sinon.stub().returns(Promise.reject({
status: 500,
statusText: 'Server Error',
data: {}
}));
return controller.save().then(function() {
assert.calledWith(fakeFlash.error,
'500 Server Error', 'Saving annotation failed');
});
}
);
it('flashes an error if saving the annotation fails on the server', function () {
var controller = createController();
fakeStore.annotation.update = sinon.stub().returns(Promise.reject({
status: 500,
statusText: 'Server Error',
data: {}
}));
return controller.save().then(function() {
assert.calledWith(fakeFlash.error,
'500 Server Error', 'Saving annotation failed');
});
});
it(
'doesn\'t flash an error if saving the annotation succeeds',
function() {
var controller = controllerWithActionEdit();
controller.form.text = 'updated text';
controller.save();
it('doesn\'t flash an error if saving the annotation succeeds', function () {
var controller = createController();
return controller.save().then(function () {
assert.notCalled(fakeFlash.error);
}
);
});
});
});
describe('drafts', function() {
......@@ -962,8 +849,8 @@ describe('annotation', function() {
text: 'unsaved-text'
});
var controller = createDirective().controller;
assert.deepEqual(controller.form.tags, ['unsaved-tag']);
assert.equal(controller.form.text, 'unsaved-text');
assert.deepEqual(controller.state().tags, ['unsaved-tag']);
assert.equal(controller.state().text, 'unsaved-text');
});
it('removes the draft when changes are discarded', function() {
......@@ -976,41 +863,13 @@ describe('annotation', function() {
it('removes the draft when changes are saved', function() {
var annotation = fixtures.defaultAnnotation();
var controller = createDirective(annotation).controller;
controller.edit();
controller.form.text = 'test annotation';
fakeDrafts.get.returns({text: 'unsaved changes'});
return controller.save().then(function() {
assert.calledWith(fakeDrafts.remove, annotation);
});
});
});
describe('onAnnotationUpdated()', function() {
it('updates vm.form.text', function() {
var parts = createDirective();
var updatedModel = {
id: parts.annotation.id,
text: 'new text',
};
$rootScope.$emit(events.ANNOTATION_UPDATED, updatedModel);
assert.equal(parts.controller.form.text, 'new text');
});
it('doesn\'t update if a different annotation was updated', function() {
var parts = createDirective();
parts.controller.form.text = 'original text';
var updatedModel = {
id: 'different annotation id',
text: 'new text',
};
$rootScope.$emit(events.ANNOTATION_UPDATED, updatedModel);
assert.equal(parts.controller.form.text, 'original text');
});
});
describe('when another new annotation is created', function () {
it('removes the current annotation if empty', function () {
var annotation = fixtures.newEmptyAnnotation();
......@@ -1021,65 +880,43 @@ describe('annotation', function() {
});
it('does not remove the current annotation if is is not new', function () {
var parts = createDirective(fixtures.defaultAnnotation());
parts.controller.form.text = '';
parts.controller.form.tags = [];
createDirective(fixtures.defaultAnnotation());
fakeDrafts.get.returns({text: '', tags: []});
$rootScope.$emit(events.BEFORE_ANNOTATION_CREATED,
fixtures.newAnnotation());
assert.notCalled(fakeDrafts.remove);
});
it('does not remove the current annotation if it has text', function () {
var annotation = fixtures.newAnnotation();
it('does not remove the current annotation if the scope was destroyed', function () {
var annotation = fixtures.newEmptyAnnotation();
var parts = createDirective(annotation);
parts.controller.form.text = 'An incomplete thought';
parts.scope.$destroy();
$rootScope.$emit(events.BEFORE_ANNOTATION_CREATED,
fixtures.newAnnotation());
assert.notCalled(fakeDrafts.remove);
});
it('does not remove the current annotation if it has tags', function () {
it('does not remove the current annotation if it has text', function () {
var annotation = fixtures.newAnnotation();
var parts = createDirective(annotation);
parts.controller.form.tags = ['a-tag'];
createDirective(annotation);
fakeDrafts.get.returns({text: 'An incomplete thought'});
$rootScope.$emit(events.BEFORE_ANNOTATION_CREATED,
fixtures.newAnnotation());
assert.notCalled(fakeDrafts.remove);
});
});
describe('when component is destroyed', function () {
it('if the annotation is being edited it updates drafts', function() {
var parts = createDirective();
parts.controller.isPrivate = true;
parts.controller.edit();
parts.controller.form.text = 'unsaved-text';
parts.controller.form.tags = [];
fakeDrafts.get = sinon.stub().returns({
text: 'old-draft'
});
fakeDrafts.update = sinon.stub();
parts.scope.$broadcast('$destroy');
assert.calledWith(
fakeDrafts.update,
parts.annotation, {isPrivate:true, tags:[], text:'unsaved-text'});
it('does not remove the current annotation if it has tags', function () {
var annotation = fixtures.newAnnotation();
createDirective(annotation);
fakeDrafts.get.returns({tags: ['a-tag']});
$rootScope.$emit(events.BEFORE_ANNOTATION_CREATED,
fixtures.newAnnotation());
assert.notCalled(fakeDrafts.remove);
});
it('if the annotation isn\'t being edited it doesn\'t update drafts', function() {
var parts = createDirective();
parts.controller.isPrivate = true;
fakeDrafts.update = sinon.stub();
parts.scope.$broadcast('$destroy');
assert.notCalled(fakeDrafts.update);
});
});
describe('onGroupFocused()', function() {
it('updates domainModel.group if the annotation is new', function () {
describe('when the focused group changes', function() {
it('moves new annotations to the focused group', function () {
var annotation = fixtures.newAnnotation();
annotation.group = 'old-group-id';
createDirective(annotation);
......@@ -1090,7 +927,7 @@ describe('annotation', function() {
assert.equal(annotation.group, 'new-group-id');
});
it('does not update domainModel.group if the annotation is not new',
it('does not modify the group of saved annotations',
function () {
var annotation = fixtures.oldAnnotation();
annotation.group = 'old-group-id';
......@@ -1104,80 +941,24 @@ describe('annotation', function() {
);
});
describe('reverting edits', function () {
// Simulate what happens when the user edits an annotation,
// clicks Save, gets an error because the server fails to save the
// annotation, then clicks Cancel - in the frontend the annotation should
// be restored to its original value, the edits lost.
it('restores the original text', function() {
var controller = createDirective({
id: 'test-annotation-id',
user: 'acct:bill@localhost',
text: 'Initial annotation body text',
}).controller;
fakeStore.annotation.update = function () {
return Promise.reject({
status: 500,
statusText: 'Server Error',
data: {}
});
};
var originalText = controller.form.text;
// Simulate the user clicking the Edit button on the annotation.
it('removes the current draft', function() {
var controller = createDirective(fixtures.defaultAnnotation()).controller;
controller.edit();
// Simulate the user typing some text into the annotation editor textarea.
controller.form.text = 'changed by test code';
// Simulate the user hitting the Save button and wait for the
// (unsuccessful) response from the server.
controller.save();
// At this point the annotation editor controls are still open, and the
// annotation's text is still the modified (unsaved) text.
assert.equal(controller.form.text, 'changed by test code');
// Simulate the user clicking the Cancel button.
controller.revert();
assert.equal(controller.form.text, originalText);
assert.calledWith(fakeDrafts.remove, controller.annotation);
});
// Test that editing reverting changes to an annotation with
// no text resets the text to be empty.
it('clears the text if the text was originally empty', function() {
var controller = createDirective({
id: 'test-annotation-id',
user: 'acct:bill@localhost',
}).controller;
controller.edit();
assert.equal(controller.action, 'edit');
controller.form.text = 'this should be reverted';
it('deletes the annotation if it was new', function () {
var controller = createDirective(fixtures.newAnnotation()).controller;
sandbox.spy($rootScope, '$emit');
controller.revert();
assert.equal(controller.form.text, '');
});
it('reverts to the most recently saved version', function () {
fakeStore.annotation.update = function (params, ann) {
return Promise.resolve(Object.assign({}, ann));
};
var controller = createDirective({
id: 'new-annot',
user: 'acct:bill@localhost',
}).controller;
controller.edit();
controller.form.text = 'New annotation text';
return controller.save().then(function () {
controller.edit();
controller.form.text = 'Updated annotation text';
return controller.save();
}).then(function () {
controller.edit();
controller.revert();
assert.equal(controller.form.text, 'Updated annotation text');
});
assert.calledWith($rootScope.$emit, events.ANNOTATION_DELETED);
});
});
describe('tag display', function () {
it('displays annotation tags', function () {
it('displays links to tags on the stream', function () {
var directive = createDirective({
id: '1234',
tags: ['atag']
......@@ -1193,7 +974,7 @@ describe('annotation', function() {
});
describe('annotation links', function () {
it('linkInContext uses the in-context links when available', function () {
it('uses the in-context links when available', function () {
var annotation = Object.assign({}, fixtures.defaultAnnotation(), {
links: {
html: 'https://test.hypothes.is/a/deadbeef',
......@@ -1201,22 +982,20 @@ describe('annotation', function() {
},
});
var controller = createDirective(annotation).controller;
assert.equal(controller.linkInContext, annotation.links.incontext);
assert.equal(controller.links().incontext, annotation.links.incontext);
});
it('linkInContext falls back to the HTML link when in-context links are missing', function () {
it('falls back to the HTML link when in-context links are missing', function () {
var annotation = Object.assign({}, fixtures.defaultAnnotation(), {
links: {
html: 'https://test.hypothes.is/a/deadbeef',
},
});
var controller = createDirective(annotation).controller;
assert.equal(controller.linkInContext, annotation.links.html);
assert.equal(controller.links().html, annotation.links.html);
});
it('linkHTML uses the HTML link when available', function () {
it('uses the HTML link when available', function () {
var annotation = Object.assign({}, fixtures.defaultAnnotation(), {
links: {
html: 'https://test.hypothes.is/a/deadbeef',
......@@ -1224,22 +1003,19 @@ describe('annotation', function() {
},
});
var controller = createDirective(annotation).controller;
assert.equal(controller.linkHTML, annotation.links.html);
assert.equal(controller.links().html, annotation.links.html);
});
it('linkInContext is blank when unknown', function () {
it('in-context link is blank when unknown', function () {
var annotation = fixtures.defaultAnnotation();
var controller = createDirective(annotation).controller;
assert.equal(controller.linkInContext, '');
assert.equal(controller.links().incontext, '');
});
it('linkHTML is blank when unknown', function () {
it('HTML is blank when unknown', function () {
var annotation = fixtures.defaultAnnotation();
var controller = createDirective(annotation).controller;
assert.equal(controller.linkHTML, '');
assert.equal(controller.links().html, '');
});
});
});
......
......@@ -22,20 +22,20 @@
target="_blank" ng-if="vm.group() && vm.group().url" href="{{vm.group().url}}">
<i class="h-icon-group"></i><span class="annotation-header__group-name">{{vm.group().name}}</span>
</a>
<span ng-show="vm.isPrivate"
<span ng-show="vm.state().isPrivate"
title="This annotation is visible only to you.">
<i class="h-icon-lock"></i><span class="annotation-header__group-name" ng-show="!vm.group().url">Only me</span>
</span>
<i class="h-icon-border-color" ng-show="vm.isHighlight() && !vm.editing()" title="This is a highlight. Click 'edit' to add a note or tag."></i>
<span ng-if="::vm.showDocumentInfo">
<span class="annotation-citation" ng-if="vm.documentMeta.titleLink">
on "<a ng-href="{{vm.documentMeta.titleLink}}">{{vm.documentMeta.titleText}}</a>"
<span class="annotation-citation" ng-if="vm.documentMeta().titleLink">
on "<a ng-href="{{vm.documentMeta().titleLink}}">{{vm.documentMeta().titleText}}</a>"
</span>
<span class="annotation-citation" ng-if="!vm.documentMeta.titleLink">
on "{{vm.documentMeta.titleText}}"
<span class="annotation-citation" ng-if="!vm.documentMeta().titleLink">
on "{{vm.documentMeta().titleText}}"
</span>
<span class="annotation-citation-domain"
ng-if="vm.documentMeta.domain">({{vm.documentMeta.domain}})</span>
ng-if="vm.documentMeta().domain">({{vm.documentMeta().domain}})</span>
</span>
</span>
</span>
......@@ -45,7 +45,7 @@
<timestamp
class-name="'annotation-header__timestamp'"
timestamp="vm.updated()"
href="vm.linkHTML"
href="vm.links().html"
ng-if="!vm.editing() && vm.updated()"></timestamp>
</header>
......@@ -75,8 +75,8 @@
collapse="vm.collapseBody"
collapsed-height="400"
overflow-hysteresis="20"
content-data="vm.form.text">
<markdown text="vm.form.text"
content-data="vm.state().text">
<markdown text="vm.state().text"
on-edit-text="vm.setText(text)"
read-only="!vm.editing()">
</markdown>
......@@ -86,14 +86,14 @@
<!-- Tags -->
<div class="annotation-body form-field" ng-if="vm.editing()">
<tag-editor tags="vm.form.tags"
<tag-editor tags="vm.state().tags"
on-edit-tags="vm.setTags(tags)"></tag-editor>
</div>
<div class="annotation-body u-layout-row tags tags-read-only"
ng-if="(vm.canCollapseBody || vm.form.tags.length) && !vm.editing()">
ng-if="(vm.canCollapseBody || vm.state().tags.length) && !vm.editing()">
<ul class="tag-list">
<li class="tag-item" ng-repeat="tag in vm.form.tags">
<li class="tag-item" ng-repeat="tag in vm.state().tags">
<a ng-href="{{vm.tagStreamURL(tag)}}" target="_blank">{{tag}}</a>
</li>
</ul>
......@@ -170,8 +170,8 @@
</button>
<annotation-share-dialog
group="vm.group()"
uri="vm.linkInContext"
is-private="vm.isPrivate"
uri="vm.links().incontext"
is-private="vm.state().isPrivate"
is-open="vm.showShareDialog"
on-close="vm.showShareDialog = false">
</annotation-share-dialog>
......
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