Commit 8701c515 authored by Robert Knight's avatar Robert Knight

Merge branch 'master' into move-pending-update-state-to-store

parents d6c00fd0 64cb045a
...@@ -9,7 +9,10 @@ ...@@ -9,7 +9,10 @@
"indent": "off", "indent": "off",
"react/self-closing-comp": "error", "react/self-closing-comp": "error",
"react-hooks/rules-of-hooks": "error", "react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "error" "react-hooks/exhaustive-deps": "error",
// Suppressed to make ESLint v6 migration easier.
"no-prototype-builtins": "off"
}, },
"parserOptions": { "parserOptions": {
"ecmaVersion": 2018, "ecmaVersion": 2018,
......
...@@ -47,7 +47,7 @@ ...@@ -47,7 +47,7 @@
"enzyme-adapter-preact-pure": "^2.0.0", "enzyme-adapter-preact-pure": "^2.0.0",
"escape-html": "^1.0.3", "escape-html": "^1.0.3",
"escape-string-regexp": "^1.0.5", "escape-string-regexp": "^1.0.5",
"eslint": "^5.12.1", "eslint": "^6.0.1",
"eslint-config-hypothesis": "^1.0.0", "eslint-config-hypothesis": "^1.0.0",
"eslint-plugin-mocha": "^5.2.1", "eslint-plugin-mocha": "^5.2.1",
"eslint-plugin-react": "^7.12.4", "eslint-plugin-react": "^7.12.4",
...@@ -88,7 +88,7 @@ ...@@ -88,7 +88,7 @@
"npm-packlist": "^1.1.12", "npm-packlist": "^1.1.12",
"postcss": "^7.0.13", "postcss": "^7.0.13",
"postcss-url": "^8.0.0", "postcss-url": "^8.0.0",
"preact": "10.0.0-beta.3", "preact": "10.0.0-rc.0",
"prettier": "1.18.2", "prettier": "1.18.2",
"query-string": "^3.0.1", "query-string": "^3.0.1",
"raf": "^3.1.0", "raf": "^3.1.0",
......
...@@ -54,7 +54,6 @@ function AnnotationController( ...@@ -54,7 +54,6 @@ function AnnotationController(
annotationMapper, annotationMapper,
api, api,
bridge, bridge,
drafts,
flash, flash,
groups, groups,
permissions, permissions,
...@@ -176,7 +175,7 @@ function AnnotationController( ...@@ -176,7 +175,7 @@ function AnnotationController(
// created by the annotate button) or it has edits not yet saved to the // created by the annotate button) or it has edits not yet saved to the
// server - then open the editor on AnnotationController instantiation. // server - then open the editor on AnnotationController instantiation.
if (!newlyCreatedByHighlightButton) { if (!newlyCreatedByHighlightButton) {
if (isNew(self.annotation) || drafts.get(self.annotation)) { if (isNew(self.annotation) || store.getDraft(self.annotation)) {
self.edit(); self.edit();
} }
} }
...@@ -217,7 +216,7 @@ function AnnotationController( ...@@ -217,7 +216,7 @@ function AnnotationController(
}); });
} else { } else {
// User isn't logged in, save to drafts. // User isn't logged in, save to drafts.
drafts.update(self.annotation, self.state()); store.createDraft(self.annotation, self.state());
} }
} }
...@@ -292,8 +291,8 @@ function AnnotationController( ...@@ -292,8 +291,8 @@ function AnnotationController(
* @description Switches the view to an editor. * @description Switches the view to an editor.
*/ */
this.edit = function() { this.edit = function() {
if (!drafts.get(self.annotation)) { if (!store.getDraft(self.annotation)) {
drafts.update(self.annotation, self.state()); store.createDraft(self.annotation, self.state());
} }
}; };
...@@ -304,7 +303,7 @@ function AnnotationController( ...@@ -304,7 +303,7 @@ function AnnotationController(
* (i.e. the annotation editor form should be open), `false` otherwise. * (i.e. the annotation editor form should be open), `false` otherwise.
*/ */
this.editing = function() { this.editing = function() {
return drafts.get(self.annotation) && !self.isSaving; return store.getDraft(self.annotation) && !self.isSaving;
}; };
/** /**
...@@ -430,7 +429,7 @@ function AnnotationController( ...@@ -430,7 +429,7 @@ function AnnotationController(
* @description Reverts an edit in progress and returns to the viewer. * @description Reverts an edit in progress and returns to the viewer.
*/ */
this.revert = function() { this.revert = function() {
drafts.remove(self.annotation); store.removeDraft(self.annotation);
if (isNew(self.annotation)) { if (isNew(self.annotation)) {
$rootScope.$broadcast(events.ANNOTATION_DELETED, self.annotation); $rootScope.$broadcast(events.ANNOTATION_DELETED, self.annotation);
} }
...@@ -465,7 +464,7 @@ function AnnotationController( ...@@ -465,7 +464,7 @@ function AnnotationController(
const event = isNew(self.annotation) const event = isNew(self.annotation)
? events.ANNOTATION_CREATED ? events.ANNOTATION_CREATED
: events.ANNOTATION_UPDATED; : events.ANNOTATION_UPDATED;
drafts.remove(self.annotation); store.removeDraft(self.annotation);
$rootScope.$broadcast(event, updatedModel); $rootScope.$broadcast(event, updatedModel);
}) })
...@@ -495,7 +494,7 @@ function AnnotationController( ...@@ -495,7 +494,7 @@ function AnnotationController(
if (!isReply(self.annotation)) { if (!isReply(self.annotation)) {
permissions.setDefault(privacy); permissions.setDefault(privacy);
} }
drafts.update(self.annotation, { store.createDraft(self.annotation, {
tags: self.state().tags, tags: self.state().tags,
text: self.state().text, text: self.state().text,
isPrivate: privacy === 'private', isPrivate: privacy === 'private',
...@@ -570,7 +569,7 @@ function AnnotationController( ...@@ -570,7 +569,7 @@ function AnnotationController(
}; };
this.setText = function(text) { this.setText = function(text) {
drafts.update(self.annotation, { store.createDraft(self.annotation, {
isPrivate: self.state().isPrivate, isPrivate: self.state().isPrivate,
tags: self.state().tags, tags: self.state().tags,
text: text, text: text,
...@@ -578,7 +577,7 @@ function AnnotationController( ...@@ -578,7 +577,7 @@ function AnnotationController(
}; };
this.setTags = function(tags) { this.setTags = function(tags) {
drafts.update(self.annotation, { store.createDraft(self.annotation, {
isPrivate: self.state().isPrivate, isPrivate: self.state().isPrivate,
tags: tags, tags: tags,
text: self.state().text, text: self.state().text,
...@@ -586,7 +585,7 @@ function AnnotationController( ...@@ -586,7 +585,7 @@ function AnnotationController(
}; };
this.state = function() { this.state = function() {
const draft = drafts.get(self.annotation); const draft = store.getDraft(self.annotation);
if (draft) { if (draft) {
return draft; return draft;
} }
......
...@@ -43,7 +43,6 @@ function HypothesisAppController( ...@@ -43,7 +43,6 @@ function HypothesisAppController(
store, store,
auth, auth,
bridge, bridge,
drafts,
features, features,
flash, flash,
frameSync, frameSync,
...@@ -149,18 +148,19 @@ function HypothesisAppController( ...@@ -149,18 +148,19 @@ function HypothesisAppController(
const promptToLogout = function() { const promptToLogout = function() {
// TODO - Replace this with a UI which doesn't look terrible. // TODO - Replace this with a UI which doesn't look terrible.
let text = ''; let text = '';
if (drafts.count() === 1) { const drafts = store.countDrafts();
if (drafts === 1) {
text = text =
'You have an unsaved annotation.\n' + 'You have an unsaved annotation.\n' +
'Do you really want to discard this draft?'; 'Do you really want to discard this draft?';
} else if (drafts.count() > 1) { } else if (drafts > 1) {
text = text =
'You have ' + 'You have ' +
drafts.count() + drafts +
' unsaved annotations.\n' + ' unsaved annotations.\n' +
'Do you really want to discard these drafts?'; 'Do you really want to discard these drafts?';
} }
return drafts.count() === 0 || $window.confirm(text); return drafts === 0 || $window.confirm(text);
}; };
// Log the user out. // Log the user out.
...@@ -168,11 +168,12 @@ function HypothesisAppController( ...@@ -168,11 +168,12 @@ function HypothesisAppController(
if (!promptToLogout()) { if (!promptToLogout()) {
return; return;
} }
store.clearGroups(); store.clearGroups();
drafts.unsaved().forEach(function(draft) { store.unsavedAnnotations().forEach(function(annotation) {
$rootScope.$emit(events.ANNOTATION_DELETED, draft); $rootScope.$emit(events.ANNOTATION_DELETED, annotation);
}); });
drafts.discard(); store.discardAllDrafts();
if (serviceConfig(settings)) { if (serviceConfig(settings)) {
// Let the host page handle the signup request // Let the host page handle the signup request
......
'use strict';
const propTypes = require('prop-types');
const { createElement } = require('preact');
const { withServices } = require('../util/service-context');
const SvgIcon = require('./svg-icon');
/**
* Render a call-to-action to log in or sign up. This message is intended to be
* displayed to non-auth'd users when viewing a single annotation in a
* direct-linked context (i.e. URL with syntax `/#annotations:<annotation_id>`)
*/
function LoggedOutMessage({ onLogin, serviceUrl }) {
return (
<div className="logged-out-message">
<span>
This is a public annotation created with Hypothesis. <br />
To reply or make your own annotations on this document,{' '}
<a
className="logged-out-message__link"
href={serviceUrl('signup')}
target="_blank"
rel="noopener noreferrer"
>
create a free account
</a>{' '}
or{' '}
<a className="logged-out-message__link" href="" onClick={onLogin}>
log in
</a>
.
</span>
<div className="logged-out-message__logo">
<a href="https://hypothes.is">
<SvgIcon name="logo" className="logged-out-message__logo-icon" />
</a>
</div>
</div>
);
}
LoggedOutMessage.propTypes = {
onLogin: propTypes.func.isRequired,
serviceUrl: propTypes.func.isRequired,
};
LoggedOutMessage.injectedProps = ['serviceUrl'];
module.exports = withServices(LoggedOutMessage);
'use strict';
module.exports = {
controllerAs: 'vm',
//@ngInject
controller: function(serviceUrl) {
this.serviceUrl = serviceUrl;
},
bindings: {
/**
* Called when the user clicks on the "Log in" text.
*/
onLogin: '&',
},
template: require('../templates/loggedout-message.html'),
};
'use strict'; 'use strict';
const SearchClient = require('../search-client');
const events = require('../events'); const events = require('../events');
const isThirdPartyService = require('../util/is-third-party-service'); const isThirdPartyService = require('../util/is-third-party-service');
const tabs = require('../tabs'); const tabs = require('../tabs');
/**
* Returns the group ID of the first annotation in `results` whose
* ID is `annId`.
*/
function getGroupID(annId, results) {
const annot = results.find(function(annot) {
return annot.id === annId;
});
if (!annot) {
return null;
}
return annot.group;
}
// @ngInject // @ngInject
function SidebarContentController( function SidebarContentController(
$scope, $scope,
analytics, analytics,
annotations,
store, store,
annotationMapper,
api,
drafts,
features,
frameSync, frameSync,
groups,
rootThread, rootThread,
settings, settings,
streamer, streamer
streamFilter
) { ) {
const self = this; const self = this;
...@@ -91,61 +71,7 @@ function SidebarContentController( ...@@ -91,61 +71,7 @@ function SidebarContentController(
} }
} }
const searchClients = []; this.isLoading = () => {
function _resetAnnotations() {
annotationMapper.unloadAnnotations(store.savedAnnotations());
}
function _loadAnnotationsFor(uris, group) {
const searchClient = new SearchClient(api.search, {
// If no group is specified, we are fetching annotations from
// all groups in order to find out which group contains the selected
// annotation, therefore we need to load all chunks before processing
// the results
incremental: !!group,
});
searchClients.push(searchClient);
searchClient.on('results', function(results) {
if (store.hasSelectedAnnotations()) {
// Focus the group containing the selected annotation and filter
// annotations to those from this group
let groupID = getGroupID(store.getFirstSelectedAnnotationId(), results);
if (!groupID) {
// If the selected annotation is not available, fall back to
// loading annotations for the currently focused group
groupID = groups.focused().id;
}
results = results.filter(function(result) {
return result.group === groupID;
});
groups.focus(groupID);
}
if (results.length) {
annotationMapper.loadAnnotations(results);
}
});
searchClient.on('end', function() {
// Remove client from list of active search clients.
//
// $evalAsync is required here because search results are emitted
// asynchronously. A better solution would be that the loading state is
// tracked as part of the app state.
$scope.$evalAsync(function() {
searchClients.splice(searchClients.indexOf(searchClient), 1);
});
store.frames().forEach(function(frame) {
if (0 <= uris.indexOf(frame.uri)) {
store.updateFrameAnnotationFetchStatus(frame.uri, true);
}
});
});
searchClient.get({ uri: uris, group: group });
}
this.isLoading = function() {
if ( if (
!store.frames().some(function(frame) { !store.frames().some(function(frame) {
return frame.uri; return frame.uri;
...@@ -154,47 +80,9 @@ function SidebarContentController( ...@@ -154,47 +80,9 @@ function SidebarContentController(
// The document's URL isn't known so the document must still be loading. // The document's URL isn't known so the document must still be loading.
return true; return true;
} }
return store.isFetchingAnnotations();
if (searchClients.length > 0) {
// We're still waiting for annotation search results from the API.
return true;
}
return false;
}; };
/**
* Load annotations for all URLs associated with `frames`.
*/
function loadAnnotations() {
_resetAnnotations();
searchClients.forEach(function(client) {
client.cancel();
});
// If there is no selection, load annotations only for the focused group.
//
// If there is a selection, we load annotations for all groups, find out
// which group the first selected annotation is in and then filter the
// results on the client by that group.
//
// In the common case where the total number of annotations on
// a page that are visible to the user is not greater than
// the batch size, this saves an extra roundtrip to the server
// to fetch the selected annotation in order to determine which group
// it is in before fetching the remaining annotations.
const group = store.hasSelectedAnnotations() ? null : groups.focused().id;
const searchUris = store.searchUris();
if (searchUris.length > 0) {
_loadAnnotationsFor(searchUris, group);
streamFilter.resetFilter().addClause('/uri', 'one_of', searchUris);
streamer.setConfig('filter', { filter: streamFilter.getFilter() });
}
}
$scope.$on('sidebarOpened', function() { $scope.$on('sidebarOpened', function() {
analytics.track(analytics.events.SIDEBAR_OPENED); analytics.track(analytics.events.SIDEBAR_OPENED);
...@@ -234,27 +122,25 @@ function SidebarContentController( ...@@ -234,27 +122,25 @@ function SidebarContentController(
// Re-fetch annotations when focused group, logged-in user or connected frames // Re-fetch annotations when focused group, logged-in user or connected frames
// change. // change.
$scope.$watch( $scope.$watch(
() => [groups.focused(), store.profile().userid, ...store.searchUris()], () => [
([currentGroup], [prevGroup]) => { store.focusedGroupId(),
if (!currentGroup) { store.profile().userid,
// When switching accounts, groups are cleared and so the focused group ...store.searchUris(),
],
([currentGroupId], [prevGroupId]) => {
if (!currentGroupId) {
// When switching accounts, groups are cleared and so the focused group id
// will be null for a brief period of time. // will be null for a brief period of time.
store.clearSelectedAnnotations(); store.clearSelectedAnnotations();
return; return;
} }
if (!prevGroup || currentGroup.id !== prevGroup.id) { if (!prevGroupId || currentGroupId !== prevGroupId) {
// The focused group may be changed during loading annotations as a result
// of switching to the group containing a direct-linked annotation.
//
// In that case, we don't want to trigger reloading annotations again.
if (this.isLoading()) {
return;
}
store.clearSelectedAnnotations(); store.clearSelectedAnnotations();
} }
loadAnnotations(); const searchUris = store.searchUris();
annotations.load(searchUris, currentGroupId);
}, },
true true
); );
...@@ -278,7 +164,7 @@ function SidebarContentController( ...@@ -278,7 +164,7 @@ function SidebarContentController(
this.scrollTo = scrollToAnnotation; this.scrollTo = scrollToAnnotation;
this.selectedGroupUnavailable = function() { this.selectedGroupUnavailable = function() {
return !this.isLoading() && store.getState().directLinkedGroupFetchFailed; return store.getState().directLinkedGroupFetchFailed;
}; };
this.selectedAnnotationUnavailable = function() { this.selectedAnnotationUnavailable = function() {
...@@ -311,7 +197,9 @@ function SidebarContentController( ...@@ -311,7 +197,9 @@ function SidebarContentController(
// selection is available to the user, show the CTA. // selection is available to the user, show the CTA.
const selectedID = store.getFirstSelectedAnnotationId(); const selectedID = store.getFirstSelectedAnnotationId();
return ( return (
!this.isLoading() && !!selectedID && store.annotationExists(selectedID) !store.isFetchingAnnotations() &&
!!selectedID &&
store.annotationExists(selectedID)
); );
}; };
} }
......
...@@ -16,6 +16,7 @@ const icons = { ...@@ -16,6 +16,7 @@ const icons = {
help: require('../../images/icons/help.svg'), help: require('../../images/icons/help.svg'),
leave: require('../../images/icons/leave.svg'), leave: require('../../images/icons/leave.svg'),
lock: require('../../images/icons/lock.svg'), lock: require('../../images/icons/lock.svg'),
logo: require('../../images/icons/logo.svg'),
public: require('../../images/icons/public.svg'), public: require('../../images/icons/public.svg'),
refresh: require('../../images/icons/refresh.svg'), refresh: require('../../images/icons/refresh.svg'),
share: require('../../images/icons/share.svg'), share: require('../../images/icons/share.svg'),
......
...@@ -107,7 +107,6 @@ describe('annotation', function() { ...@@ -107,7 +107,6 @@ describe('annotation', function() {
let fakeAnalytics; let fakeAnalytics;
let fakeAnnotationMapper; let fakeAnnotationMapper;
let fakeStore; let fakeStore;
let fakeDrafts;
let fakeFlash; let fakeFlash;
let fakeGroups; let fakeGroups;
let fakePermissions; let fakePermissions;
...@@ -136,7 +135,7 @@ describe('annotation', function() { ...@@ -136,7 +135,7 @@ describe('annotation', function() {
// A new annotation won't have any saved drafts yet. // A new annotation won't have any saved drafts yet.
if (!annotation.id) { if (!annotation.id) {
fakeDrafts.get.returns(null); fakeStore.getDraft.returns(null);
} }
return { return {
...@@ -190,12 +189,13 @@ describe('annotation', function() { ...@@ -190,12 +189,13 @@ describe('annotation', function() {
fakeStore = { fakeStore = {
hasPendingDeletion: sinon.stub(), hasPendingDeletion: sinon.stub(),
updateFlagStatus: sandbox.stub().returns(true), updateFlagStatus: sandbox.stub().returns(true),
}; // draft store
countDrafts: sandbox.stub().returns(0),
fakeDrafts = { createDraft: sandbox.stub(),
update: sandbox.stub(), discardAllDrafts: sandbox.stub(),
remove: sandbox.stub(), getDraft: sandbox.stub().returns(null),
get: sandbox.stub().returns(null), getDraftIfNotEmpty: sandbox.stub().returns(null),
removeDraft: sandbox.stub(),
}; };
fakeFlash = { fakeFlash = {
...@@ -258,7 +258,6 @@ describe('annotation', function() { ...@@ -258,7 +258,6 @@ describe('annotation', function() {
$provide.value('store', fakeStore); $provide.value('store', fakeStore);
$provide.value('api', fakeApi); $provide.value('api', fakeApi);
$provide.value('bridge', fakeBridge); $provide.value('bridge', fakeBridge);
$provide.value('drafts', fakeDrafts);
$provide.value('flash', fakeFlash); $provide.value('flash', fakeFlash);
$provide.value('groups', fakeGroups); $provide.value('groups', fakeGroups);
$provide.value('permissions', fakePermissions); $provide.value('permissions', fakePermissions);
...@@ -371,7 +370,7 @@ describe('annotation', function() { ...@@ -371,7 +370,7 @@ describe('annotation', function() {
createDirective(annotation); createDirective(annotation);
assert.notCalled(fakeApi.annotation.create); assert.notCalled(fakeApi.annotation.create);
assert.called(fakeDrafts.update); assert.called(fakeStore.createDraft);
}); });
it('opens the sidebar when trying to save highlights while logged out', () => { it('opens the sidebar when trying to save highlights while logged out', () => {
...@@ -413,7 +412,7 @@ describe('annotation', function() { ...@@ -413,7 +412,7 @@ describe('annotation', function() {
it('creates drafts for new annotations on initialization', function() { it('creates drafts for new annotations on initialization', function() {
const annotation = fixtures.newAnnotation(); const annotation = fixtures.newAnnotation();
createDirective(annotation); createDirective(annotation);
assert.calledWith(fakeDrafts.update, annotation, { assert.calledWith(fakeStore.createDraft, annotation, {
isPrivate: false, isPrivate: false,
tags: annotation.tags, tags: annotation.tags,
text: annotation.text, text: annotation.text,
...@@ -425,13 +424,13 @@ describe('annotation', function() { ...@@ -425,13 +424,13 @@ describe('annotation', function() {
const controller = createDirective(annotation).controller; const controller = createDirective(annotation).controller;
assert.notOk(controller.editing()); assert.notOk(controller.editing());
assert.notCalled(fakeDrafts.update); assert.notCalled(fakeStore.createDraft);
}); });
it('edits annotations with drafts on initialization', function() { it('edits annotations with drafts on initialization', function() {
const annotation = fixtures.oldAnnotation(); const annotation = fixtures.oldAnnotation();
// The drafts service has some draft changes for this annotation. // The drafts store has some draft changes for this annotation.
fakeDrafts.get.returns({ text: 'foo', tags: [] }); fakeStore.getDraft.returns({ text: 'foo', tags: [] });
const controller = createDirective(annotation).controller; const controller = createDirective(annotation).controller;
...@@ -447,13 +446,13 @@ describe('annotation', function() { ...@@ -447,13 +446,13 @@ describe('annotation', function() {
it('returns true if the annotation has a draft', function() { it('returns true if the annotation has a draft', function() {
const controller = createDirective().controller; const controller = createDirective().controller;
fakeDrafts.get.returns({ tags: [], text: '', isPrivate: false }); fakeStore.getDraft.returns({ tags: [], text: '', isPrivate: false });
assert.isTrue(controller.editing()); assert.isTrue(controller.editing());
}); });
it('returns false if the annotation has a draft but is being saved', function() { it('returns false if the annotation has a draft but is being saved', function() {
const controller = createDirective().controller; const controller = createDirective().controller;
fakeDrafts.get.returns({ tags: [], text: '', isPrivate: false }); fakeStore.getDraft.returns({ tags: [], text: '', isPrivate: false });
controller.isSaving = true; controller.isSaving = true;
assert.isFalse(controller.editing()); assert.isFalse(controller.editing());
}); });
...@@ -620,7 +619,7 @@ describe('annotation', function() { ...@@ -620,7 +619,7 @@ describe('annotation', function() {
const parts = createDirective(); const parts = createDirective();
parts.controller.setPrivacy('private'); parts.controller.setPrivacy('private');
assert.calledWith( assert.calledWith(
fakeDrafts.update, fakeStore.createDraft,
parts.controller.annotation, parts.controller.annotation,
sinon.match({ sinon.match({
isPrivate: true, isPrivate: true,
...@@ -632,7 +631,7 @@ describe('annotation', function() { ...@@ -632,7 +631,7 @@ describe('annotation', function() {
const parts = createDirective(); const parts = createDirective();
parts.controller.setPrivacy('shared'); parts.controller.setPrivacy('shared');
assert.calledWith( assert.calledWith(
fakeDrafts.update, fakeStore.createDraft,
parts.controller.annotation, parts.controller.annotation,
sinon.match({ sinon.match({
isPrivate: false, isPrivate: false,
...@@ -914,7 +913,7 @@ describe('annotation', function() { ...@@ -914,7 +913,7 @@ describe('annotation', function() {
function(testCase) { function(testCase) {
const ann = fixtures.publicAnnotation(); const ann = fixtures.publicAnnotation();
ann.group = testCase.group.id; ann.group = testCase.group.id;
fakeDrafts.get.returns(testCase.draft); fakeStore.getDraft.returns(testCase.draft);
fakeGroups.get.returns(testCase.group); fakeGroups.get.returns(testCase.group);
const controller = createDirective(ann).controller; const controller = createDirective(ann).controller;
...@@ -992,7 +991,7 @@ describe('annotation', function() { ...@@ -992,7 +991,7 @@ describe('annotation', function() {
it('removes the draft when saving an annotation succeeds', function() { it('removes the draft when saving an annotation succeeds', function() {
const controller = createController(); const controller = createController();
return controller.save().then(function() { return controller.save().then(function() {
assert.calledWith(fakeDrafts.remove, annotation); assert.calledWith(fakeStore.removeDraft, annotation);
}); });
}); });
...@@ -1046,7 +1045,7 @@ describe('annotation', function() { ...@@ -1046,7 +1045,7 @@ describe('annotation', function() {
.stub() .stub()
.returns(Promise.reject({ status: -1 })); .returns(Promise.reject({ status: -1 }));
return controller.save().then(function() { return controller.save().then(function() {
assert.notCalled(fakeDrafts.remove); assert.notCalled(fakeStore.removeDraft);
}); });
}); });
...@@ -1064,7 +1063,7 @@ describe('annotation', function() { ...@@ -1064,7 +1063,7 @@ describe('annotation', function() {
beforeEach(function() { beforeEach(function() {
annotation = fixtures.defaultAnnotation(); annotation = fixtures.defaultAnnotation();
fakeDrafts.get.returns({ text: 'unsaved change' }); fakeStore.getDraft.returns({ text: 'unsaved change' });
}); });
function createController() { function createController() {
...@@ -1094,7 +1093,7 @@ describe('annotation', function() { ...@@ -1094,7 +1093,7 @@ describe('annotation', function() {
describe('drafts', function() { describe('drafts', function() {
it('starts editing immediately if there is a draft', function() { it('starts editing immediately if there is a draft', function() {
fakeDrafts.get.returns({ fakeStore.getDraft.returns({
tags: ['unsaved'], tags: ['unsaved'],
text: 'unsaved-text', text: 'unsaved-text',
}); });
...@@ -1103,7 +1102,7 @@ describe('annotation', function() { ...@@ -1103,7 +1102,7 @@ describe('annotation', function() {
}); });
it('uses the text and tags from the draft if present', function() { it('uses the text and tags from the draft if present', function() {
fakeDrafts.get.returns({ fakeStore.getDraft.returns({
tags: ['unsaved-tag'], tags: ['unsaved-tag'],
text: 'unsaved-text', text: 'unsaved-text',
}); });
...@@ -1116,15 +1115,15 @@ describe('annotation', function() { ...@@ -1116,15 +1115,15 @@ describe('annotation', function() {
const parts = createDirective(); const parts = createDirective();
parts.controller.edit(); parts.controller.edit();
parts.controller.revert(); parts.controller.revert();
assert.calledWith(fakeDrafts.remove, parts.annotation); assert.calledWith(fakeStore.removeDraft, parts.annotation);
}); });
it('removes the draft when changes are saved', function() { it('removes the draft when changes are saved', function() {
const annotation = fixtures.defaultAnnotation(); const annotation = fixtures.defaultAnnotation();
const controller = createDirective(annotation).controller; const controller = createDirective(annotation).controller;
fakeDrafts.get.returns({ text: 'unsaved changes' }); fakeStore.getDraft.returns({ text: 'unsaved changes' });
return controller.save().then(function() { return controller.save().then(function() {
assert.calledWith(fakeDrafts.remove, annotation); assert.calledWith(fakeStore.removeDraft, annotation);
}); });
}); });
}); });
...@@ -1135,7 +1134,7 @@ describe('annotation', function() { ...@@ -1135,7 +1134,7 @@ describe('annotation', function() {
.controller; .controller;
controller.edit(); controller.edit();
controller.revert(); controller.revert();
assert.calledWith(fakeDrafts.remove, controller.annotation); assert.calledWith(fakeStore.removeDraft, controller.annotation);
}); });
it('deletes the annotation if it was new', function() { it('deletes the annotation if it was new', function() {
......
...@@ -15,7 +15,6 @@ describe('sidebar.components.hypothesis-app', function() { ...@@ -15,7 +15,6 @@ describe('sidebar.components.hypothesis-app', function() {
let fakeAnalytics = null; let fakeAnalytics = null;
let fakeAuth = null; let fakeAuth = null;
let fakeBridge = null; let fakeBridge = null;
let fakeDrafts = null;
let fakeFeatures = null; let fakeFeatures = null;
let fakeFlash = null; let fakeFlash = null;
let fakeFrameSync = null; let fakeFrameSync = null;
...@@ -63,6 +62,10 @@ describe('sidebar.components.hypothesis-app', function() { ...@@ -63,6 +62,10 @@ describe('sidebar.components.hypothesis-app', function() {
clearSelectedAnnotations: sandbox.spy(), clearSelectedAnnotations: sandbox.spy(),
getState: sinon.stub(), getState: sinon.stub(),
clearGroups: sinon.stub(), clearGroups: sinon.stub(),
// draft store
countDrafts: sandbox.stub().returns(0),
discardAllDrafts: sandbox.stub(),
unsavedAnnotations: sandbox.stub().returns([]),
}; };
fakeAnalytics = { fakeAnalytics = {
...@@ -72,15 +75,6 @@ describe('sidebar.components.hypothesis-app', function() { ...@@ -72,15 +75,6 @@ describe('sidebar.components.hypothesis-app', function() {
fakeAuth = {}; fakeAuth = {};
fakeDrafts = {
contains: sandbox.stub(),
remove: sandbox.spy(),
all: sandbox.stub().returns([]),
discard: sandbox.spy(),
count: sandbox.stub().returns(0),
unsaved: sandbox.stub().returns([]),
};
fakeFeatures = { fakeFeatures = {
fetch: sandbox.spy(), fetch: sandbox.spy(),
flagEnabled: sandbox.stub().returns(false), flagEnabled: sandbox.stub().returns(false),
...@@ -123,7 +117,6 @@ describe('sidebar.components.hypothesis-app', function() { ...@@ -123,7 +117,6 @@ describe('sidebar.components.hypothesis-app', function() {
$provide.value('store', fakeStore); $provide.value('store', fakeStore);
$provide.value('auth', fakeAuth); $provide.value('auth', fakeAuth);
$provide.value('analytics', fakeAnalytics); $provide.value('analytics', fakeAnalytics);
$provide.value('drafts', fakeDrafts);
$provide.value('features', fakeFeatures); $provide.value('features', fakeFeatures);
$provide.value('flash', fakeFlash); $provide.value('flash', fakeFlash);
$provide.value('frameSync', fakeFrameSync); $provide.value('frameSync', fakeFrameSync);
...@@ -428,7 +421,7 @@ describe('sidebar.components.hypothesis-app', function() { ...@@ -428,7 +421,7 @@ describe('sidebar.components.hypothesis-app', function() {
// Tests shared by both of the contexts below. // Tests shared by both of the contexts below.
function doSharedTests() { function doSharedTests() {
it('prompts the user if there are drafts', function() { it('prompts the user if there are drafts', function() {
fakeDrafts.count.returns(1); fakeStore.countDrafts.returns(1);
const ctrl = createController(); const ctrl = createController();
ctrl.logout(); ctrl.logout();
...@@ -445,7 +438,7 @@ describe('sidebar.components.hypothesis-app', function() { ...@@ -445,7 +438,7 @@ describe('sidebar.components.hypothesis-app', function() {
}); });
it('emits "annotationDeleted" for each unsaved draft annotation', function() { it('emits "annotationDeleted" for each unsaved draft annotation', function() {
fakeDrafts.unsaved = sandbox fakeStore.unsavedAnnotations = sandbox
.stub() .stub()
.returns(['draftOne', 'draftTwo', 'draftThree']); .returns(['draftOne', 'draftTwo', 'draftThree']);
const ctrl = createController(); const ctrl = createController();
...@@ -473,12 +466,12 @@ describe('sidebar.components.hypothesis-app', function() { ...@@ -473,12 +466,12 @@ describe('sidebar.components.hypothesis-app', function() {
ctrl.logout(); ctrl.logout();
assert(fakeDrafts.discard.calledOnce); assert(fakeStore.discardAllDrafts.calledOnce);
}); });
it('does not emit "annotationDeleted" if the user cancels the prompt', function() { it('does not emit "annotationDeleted" if the user cancels the prompt', function() {
const ctrl = createController(); const ctrl = createController();
fakeDrafts.count.returns(1); fakeStore.countDrafts.returns(1);
$rootScope.$emit = sandbox.stub(); $rootScope.$emit = sandbox.stub();
fakeWindow.confirm.returns(false); fakeWindow.confirm.returns(false);
...@@ -489,17 +482,17 @@ describe('sidebar.components.hypothesis-app', function() { ...@@ -489,17 +482,17 @@ describe('sidebar.components.hypothesis-app', function() {
it('does not discard drafts if the user cancels the prompt', function() { it('does not discard drafts if the user cancels the prompt', function() {
const ctrl = createController(); const ctrl = createController();
fakeDrafts.count.returns(1); fakeStore.countDrafts.returns(1);
fakeWindow.confirm.returns(false); fakeWindow.confirm.returns(false);
ctrl.logout(); ctrl.logout();
assert(fakeDrafts.discard.notCalled); assert(fakeStore.discardAllDrafts.notCalled);
}); });
it('does not prompt if there are no drafts', function() { it('does not prompt if there are no drafts', function() {
const ctrl = createController(); const ctrl = createController();
fakeDrafts.count.returns(0); fakeStore.countDrafts.returns(0);
ctrl.logout(); ctrl.logout();
...@@ -535,7 +528,7 @@ describe('sidebar.components.hypothesis-app', function() { ...@@ -535,7 +528,7 @@ describe('sidebar.components.hypothesis-app', function() {
}); });
it('does not send LOGOUT_REQUESTED if the user cancels the prompt', function() { it('does not send LOGOUT_REQUESTED if the user cancels the prompt', function() {
fakeDrafts.count.returns(1); fakeStore.countDrafts.returns(1);
fakeWindow.confirm.returns(false); fakeWindow.confirm.returns(false);
createController().logout(); createController().logout();
......
'use strict';
const { createElement } = require('preact');
const { shallow } = require('enzyme');
const LoggedOutMessage = require('../logged-out-message');
describe('LoggedOutMessage', () => {
const createLoggedOutMessage = props => {
return shallow(
<LoggedOutMessage
onLogin={sinon.stub()}
serviceUrl={sinon.stub()}
{...props}
/>
).dive(); // Dive needed because this component uses `withServices`
};
it('should link to signup', () => {
const fakeServiceUrl = sinon.stub().returns('signup_link');
const wrapper = createLoggedOutMessage({ serviceUrl: fakeServiceUrl });
const signupLink = wrapper.find('.logged-out-message__link').at(0);
assert.calledWith(fakeServiceUrl, 'signup');
assert.equal(signupLink.prop('href'), 'signup_link');
});
it('should have a login click handler', () => {
const fakeOnLogin = sinon.stub();
const wrapper = createLoggedOutMessage({ onLogin: fakeOnLogin });
const loginLink = wrapper.find('.logged-out-message__link').at(1);
assert.equal(loginLink.prop('onClick'), fakeOnLogin);
});
});
...@@ -171,7 +171,10 @@ function startAngularApp(config) { ...@@ -171,7 +171,10 @@ function startAngularApp(config) {
wrapReactComponent(require('./components/help-link')) wrapReactComponent(require('./components/help-link'))
) )
.component('helpPanel', require('./components/help-panel')) .component('helpPanel', require('./components/help-panel'))
.component('loggedoutMessage', require('./components/loggedout-message')) .component(
'loggedOutMessage',
wrapReactComponent(require('./components/logged-out-message'))
)
.component('markdown', require('./components/markdown')) .component('markdown', require('./components/markdown'))
.component( .component(
'moderationBanner', 'moderationBanner',
...@@ -204,11 +207,11 @@ function startAngularApp(config) { ...@@ -204,11 +207,11 @@ function startAngularApp(config) {
.service('analytics', require('./services/analytics')) .service('analytics', require('./services/analytics'))
.service('annotationMapper', require('./services/annotation-mapper')) .service('annotationMapper', require('./services/annotation-mapper'))
.service('annotations', require('./services/annotations'))
.service('api', require('./services/api')) .service('api', require('./services/api'))
.service('apiRoutes', require('./services/api-routes')) .service('apiRoutes', require('./services/api-routes'))
.service('auth', require('./services/oauth-auth')) .service('auth', require('./services/oauth-auth'))
.service('bridge', require('../shared/bridge')) .service('bridge', require('../shared/bridge'))
.service('drafts', require('./services/drafts'))
.service('features', require('./services/features')) .service('features', require('./services/features'))
.service('flash', require('./services/flash')) .service('flash', require('./services/flash'))
.service('frameSync', require('./services/frame-sync').default) .service('frameSync', require('./services/frame-sync').default)
......
'use strict';
const SearchClient = require('../search-client');
// @ngInject
function annotations(annotationMapper, api, store, streamer, streamFilter) {
let searchClient = null;
/**
* Load annotations for all URIs and groupId.
*
* @param {string[]} uris
* @param {string} groupId
*/
function load(uris, groupId) {
annotationMapper.unloadAnnotations(store.savedAnnotations());
// Cancel previously running search client.
if (searchClient) {
searchClient.cancel();
}
if (uris.length > 0) {
searchAndLoad(uris, groupId);
streamFilter.resetFilter().addClause('/uri', 'one_of', uris);
streamer.setConfig('filter', { filter: streamFilter.getFilter() });
}
}
function searchAndLoad(uris, groupId) {
searchClient = new SearchClient(api.search, {
incremental: true,
});
searchClient.on('results', results => {
if (results.length) {
annotationMapper.loadAnnotations(results);
}
});
searchClient.on('error', error => {
console.error(error);
});
searchClient.on('end', () => {
// Remove client as it's no longer active.
searchClient = null;
store.frames().forEach(function(frame) {
if (0 <= uris.indexOf(frame.uri)) {
store.updateFrameAnnotationFetchStatus(frame.uri, true);
}
});
store.annotationFetchFinished();
});
store.annotationFetchStarted();
searchClient.get({ uri: uris, group: groupId });
}
return {
load,
};
}
module.exports = annotations;
'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();
};
...@@ -286,7 +286,10 @@ function auth( ...@@ -286,7 +286,10 @@ function auth(
oauthClient(), oauthClient(),
]); ]);
await client.revokeToken(token.accessToken); await client.revokeToken(token.accessToken);
// eslint-disable-next-line require-atomic-updates
tokenInfoPromise = Promise.resolve(null); tokenInfoPromise = Promise.resolve(null);
localStorage.removeItem(storageKey()); localStorage.removeItem(storageKey());
} }
......
...@@ -40,7 +40,7 @@ const sortFns = { ...@@ -40,7 +40,7 @@ const sortFns = {
* The root thread is then displayed by viewer.html * The root thread is then displayed by viewer.html
*/ */
// @ngInject // @ngInject
function RootThread($rootScope, store, drafts, searchFilter, viewFilter) { function RootThread($rootScope, store, searchFilter, viewFilter) {
/** /**
* Build the root conversation thread from the given UI state. * Build the root conversation thread from the given UI state.
* *
...@@ -86,10 +86,10 @@ function RootThread($rootScope, store, drafts, searchFilter, viewFilter) { ...@@ -86,10 +86,10 @@ function RootThread($rootScope, store, drafts, searchFilter, viewFilter) {
store store
.getState() .getState()
.annotations.filter(function(ann) { .annotations.filter(function(ann) {
return metadata.isNew(ann) && !drafts.getIfNotEmpty(ann); return metadata.isNew(ann) && !store.getDraftIfNotEmpty(ann);
}) })
.forEach(function(ann) { .forEach(function(ann) {
drafts.remove(ann); store.removeDraft(ann);
$rootScope.$broadcast(events.ANNOTATION_DELETED, ann); $rootScope.$broadcast(events.ANNOTATION_DELETED, ann);
}); });
} }
......
'use strict';
const annotations = require('../annotations');
const EventEmitter = require('tiny-emitter');
let searchClients;
let longRunningSearchClient = false;
class FakeSearchClient extends EventEmitter {
constructor(searchFn, opts) {
super();
assert.ok(searchFn);
searchClients.push(this);
this.cancel = sinon.stub();
this.incremental = !!opts.incremental;
this.get = sinon.spy(query => {
assert.ok(query.uri);
for (let i = 0; i < query.uri.length; i++) {
const uri = query.uri[i];
this.emit('results', [{ id: uri + '123', group: '__world__' }]);
this.emit('results', [{ id: uri + '456', group: 'private-group' }]);
}
if (!longRunningSearchClient) {
this.emit('end');
}
});
}
}
describe('annotations', () => {
let fakeStore;
let fakeApi;
let fakeAnnotationMapper;
let fakeStreamer;
let fakeStreamFilter;
let fakeUris;
let fakeGroupId;
beforeEach(() => {
sinon.stub(console, 'error');
searchClients = [];
longRunningSearchClient = false;
fakeAnnotationMapper = {
loadAnnotations: sinon.stub(),
unloadAnnotations: sinon.stub(),
};
fakeApi = {
search: sinon.stub(),
};
fakeStore = {
getState: sinon.stub(),
frames: sinon.stub(),
searchUris: sinon.stub(),
savedAnnotations: sinon.stub(),
hasSelectedAnnotations: sinon.stub(),
updateFrameAnnotationFetchStatus: sinon.stub(),
annotationFetchStarted: sinon.stub(),
annotationFetchFinished: sinon.stub(),
};
fakeStreamer = {
setConfig: sinon.stub(),
connect: sinon.stub(),
reconnect: sinon.stub(),
};
fakeStreamFilter = {
resetFilter: sinon.stub().returns({
addClause: sinon.stub(),
}),
getFilter: sinon.stub().returns({}),
};
fakeUris = ['http://example.com'];
fakeGroupId = 'group-id';
annotations.$imports.$mock({
'../search-client': FakeSearchClient,
});
});
afterEach(() => {
console.error.restore();
annotations.$imports.$restore();
});
function service() {
fakeStore.frames.returns(
fakeUris.map(uri => {
return { uri: uri };
})
);
return annotations(
fakeAnnotationMapper,
fakeApi,
fakeStore,
fakeStreamer,
fakeStreamFilter
);
}
describe('load', () => {
it('unloads any existing annotations', () => {
// When new clients connect, all existing annotations should be unloaded
// before reloading annotations for each currently-connected client.
fakeStore.savedAnnotations.returns([
{ id: fakeUris[0] + '123' },
{ id: fakeUris[0] + '456' },
]);
const svc = service();
svc.load(fakeUris, fakeGroupId);
assert.calledWith(fakeAnnotationMapper.unloadAnnotations, [
sinon.match({ id: fakeUris[0] + '123' }),
sinon.match({ id: fakeUris[0] + '456' }),
]);
});
it('loads all annotations for a URI', () => {
const svc = service();
svc.load(fakeUris, fakeGroupId);
assert.calledWith(fakeAnnotationMapper.loadAnnotations, [
sinon.match({ id: fakeUris[0] + '123' }),
]);
assert.calledWith(fakeAnnotationMapper.loadAnnotations, [
sinon.match({ id: fakeUris[0] + '456' }),
]);
});
it('loads all annotations for a frame with multiple URIs', () => {
const uri = 'http://example.com/test.pdf';
const fingerprint = 'urn:x-pdf:fingerprint';
fakeUris = [uri, fingerprint];
const svc = service();
// Override the default frames set by the service call above.
fakeStore.frames.returns([
{
uri: uri,
metadata: {
documentFingerprint: 'fingerprint',
link: [
{
href: fingerprint,
},
{
href: uri,
},
],
},
},
]);
svc.load(fakeUris, fakeGroupId);
assert.calledWith(fakeAnnotationMapper.loadAnnotations, [
sinon.match({ id: uri + '123' }),
]);
assert.calledWith(fakeAnnotationMapper.loadAnnotations, [
sinon.match({ id: fingerprint + '123' }),
]);
assert.calledWith(fakeAnnotationMapper.loadAnnotations, [
sinon.match({ id: uri + '456' }),
]);
assert.calledWith(fakeAnnotationMapper.loadAnnotations, [
sinon.match({ id: fingerprint + '456' }),
]);
});
it('loads all annotations for all URIs', () => {
fakeUris = ['http://example.com', 'http://foobar.com'];
const svc = service();
svc.load(fakeUris, fakeGroupId);
[
fakeUris[0] + '123',
fakeUris[0] + '456',
fakeUris[1] + '123',
fakeUris[1] + '456',
].forEach(uri => {
assert.calledWith(fakeAnnotationMapper.loadAnnotations, [
sinon.match({ id: uri }),
]);
});
});
it('updates annotation fetch status for all frames', () => {
fakeUris = ['http://example.com', 'http://foobar.com'];
const svc = service();
svc.load(fakeUris, fakeGroupId);
assert.calledWith(
fakeStore.updateFrameAnnotationFetchStatus,
fakeUris[0],
true
);
assert.calledWith(
fakeStore.updateFrameAnnotationFetchStatus,
fakeUris[1],
true
);
});
it('fetches annotations for the specified group', () => {
const svc = service();
svc.load(fakeUris, fakeGroupId);
assert.calledWith(searchClients[0].get, {
uri: fakeUris,
group: fakeGroupId,
});
});
it('loads annotations in batches', () => {
const svc = service();
svc.load(fakeUris, fakeGroupId);
assert.ok(searchClients[0].incremental);
});
it("cancels previously search client if it's still running", () => {
const svc = service();
// Issue a long running load annotations request.
longRunningSearchClient = true;
svc.load(fakeUris, fakeGroupId);
// Issue another load annotations request while the
// previous annotation load is still running.
svc.load(fakeUris, fakeGroupId);
assert.calledOnce(searchClients[0].cancel);
});
it('does not load annotations if URIs list is empty', () => {
fakeUris = [];
const svc = service();
svc.load(fakeUris, fakeGroupId);
assert.notCalled(fakeAnnotationMapper.loadAnnotations);
});
it('calls annotationFetchStarted when it starts searching for annotations', () => {
const svc = service();
svc.load(fakeUris, fakeGroupId);
assert.calledOnce(fakeStore.annotationFetchStarted);
});
it('calls annotationFetchFinished when all annotations have been found', () => {
const svc = service();
svc.load(fakeUris, fakeGroupId);
assert.calledOnce(fakeStore.annotationFetchFinished);
});
it('logs an error to the console if the search client runs into an error', () => {
const svc = service();
const error = new Error('search for annotations failed');
svc.load(fakeUris, fakeGroupId);
searchClients[0].emit('error', error);
assert.calledWith(console.error, error);
});
});
});
'use strict';
const draftsService = require('../drafts');
const fixtures = {
draftWithText: {
isPrivate: false,
text: 'some text',
tags: [],
},
draftWithTags: {
isPrivate: false,
text: '',
tags: ['atag'],
},
emptyDraft: {
isPrivate: false,
text: '',
tags: [],
},
};
describe('drafts', function() {
let drafts;
beforeEach(function() {
drafts = draftsService();
});
describe('#getIfNotEmpty', function() {
it('returns the draft if it has tags', function() {
const model = { id: 'foo' };
drafts.update(model, fixtures.draftWithTags);
assert.deepEqual(drafts.getIfNotEmpty(model), fixtures.draftWithTags);
});
it('returns the draft if it has text', function() {
const model = { id: 'foo' };
drafts.update(model, fixtures.draftWithText);
assert.deepEqual(drafts.getIfNotEmpty(model), fixtures.draftWithText);
});
it('returns null if the text and tags are empty', function() {
const model = { id: 'foo' };
drafts.update(model, fixtures.emptyDraft);
assert.isNull(drafts.getIfNotEmpty(model));
});
});
describe('#update', function() {
it('should save changes', function() {
const model = { id: 'foo' };
assert.notOk(drafts.get(model));
drafts.update(model, { isPrivate: true, tags: ['foo'], text: 'edit' });
assert.deepEqual(drafts.get(model), {
isPrivate: true,
tags: ['foo'],
text: 'edit',
});
});
it('should replace existing drafts', function() {
const model = { id: 'foo' };
drafts.update(model, { isPrivate: true, tags: ['foo'], text: 'foo' });
drafts.update(model, { isPrivate: true, tags: ['foo'], text: 'bar' });
assert.equal(drafts.get(model).text, 'bar');
});
it('should replace existing drafts with the same ID', function() {
const modelA = { id: 'foo' };
const modelB = { id: 'foo' };
drafts.update(modelA, { isPrivate: true, tags: ['foo'], text: 'foo' });
drafts.update(modelB, { isPrivate: true, tags: ['foo'], text: 'bar' });
assert.equal(drafts.get(modelA).text, 'bar');
});
it('should replace drafts with the same tag', function() {
const modelA = { $tag: 'foo' };
const modelB = { $tag: 'foo' };
drafts.update(modelA, { isPrivate: true, tags: ['foo'], text: 'foo' });
drafts.update(modelB, { isPrivate: true, tags: ['foo'], text: 'bar' });
assert.equal(drafts.get(modelA).text, 'bar');
});
});
describe('#remove', function() {
it('should remove drafts', function() {
const model = { id: 'foo' };
drafts.update(model, { text: 'bar' });
drafts.remove(model);
assert.notOk(drafts.get(model));
});
});
describe('#unsaved', function() {
it('should return drafts for unsaved annotations', function() {
const model = { $tag: 'local-tag', id: undefined };
drafts.update(model, { text: 'bar' });
assert.deepEqual(drafts.unsaved(), [model]);
});
it('should not return drafts for saved annotations', function() {
const model = { id: 'foo' };
drafts.update(model, { text: 'baz' });
assert.deepEqual(drafts.unsaved(), []);
});
});
});
...@@ -27,7 +27,6 @@ const fixtures = immutable({ ...@@ -27,7 +27,6 @@ const fixtures = immutable({
describe('rootThread', function() { describe('rootThread', function() {
let fakeStore; let fakeStore;
let fakeBuildThread; let fakeBuildThread;
let fakeDrafts;
let fakeSearchFilter; let fakeSearchFilter;
let fakeViewFilter; let fakeViewFilter;
...@@ -62,15 +61,12 @@ describe('rootThread', function() { ...@@ -62,15 +61,12 @@ describe('rootThread', function() {
addAnnotations: sinon.stub(), addAnnotations: sinon.stub(),
setCollapsed: sinon.stub(), setCollapsed: sinon.stub(),
selectTab: sinon.stub(), selectTab: sinon.stub(),
getDraftIfNotEmpty: sinon.stub().returns(null),
removeDraft: sinon.stub(),
}; };
fakeBuildThread = sinon.stub().returns(fixtures.emptyThread); fakeBuildThread = sinon.stub().returns(fixtures.emptyThread);
fakeDrafts = {
getIfNotEmpty: sinon.stub().returns(null),
remove: sinon.stub(),
};
fakeSearchFilter = { fakeSearchFilter = {
generateFacetedFilter: sinon.stub(), generateFacetedFilter: sinon.stub(),
}; };
...@@ -82,7 +78,6 @@ describe('rootThread', function() { ...@@ -82,7 +78,6 @@ describe('rootThread', function() {
angular angular
.module('app', []) .module('app', [])
.value('store', fakeStore) .value('store', fakeStore)
.value('drafts', fakeDrafts)
.value('searchFilter', fakeSearchFilter) .value('searchFilter', fakeSearchFilter)
.value('viewFilter', fakeViewFilter) .value('viewFilter', fakeViewFilter)
.service('rootThread', rootThreadFactory); .service('rootThread', rootThreadFactory);
...@@ -401,16 +396,16 @@ describe('rootThread', function() { ...@@ -401,16 +396,16 @@ describe('rootThread', function() {
}); });
it('removes drafts for new and empty annotations', function() { it('removes drafts for new and empty annotations', function() {
fakeDrafts.getIfNotEmpty.returns(null); fakeStore.getDraftIfNotEmpty.returns(null);
const annotation = annotationFixtures.newEmptyAnnotation(); const annotation = annotationFixtures.newEmptyAnnotation();
$rootScope.$broadcast(events.BEFORE_ANNOTATION_CREATED, annotation); $rootScope.$broadcast(events.BEFORE_ANNOTATION_CREATED, annotation);
assert.calledWith(fakeDrafts.remove, existingNewAnnot); assert.calledWith(fakeStore.removeDraft, existingNewAnnot);
}); });
it('deletes new and empty annotations', function() { it('deletes new and empty annotations', function() {
fakeDrafts.getIfNotEmpty.returns(null); fakeStore.getDraftIfNotEmpty.returns(null);
const annotation = annotationFixtures.newEmptyAnnotation(); const annotation = annotationFixtures.newEmptyAnnotation();
$rootScope.$broadcast(events.BEFORE_ANNOTATION_CREATED, annotation); $rootScope.$broadcast(events.BEFORE_ANNOTATION_CREATED, annotation);
...@@ -419,14 +414,14 @@ describe('rootThread', function() { ...@@ -419,14 +414,14 @@ describe('rootThread', function() {
}); });
it('does not remove annotations that have non-empty drafts', function() { it('does not remove annotations that have non-empty drafts', function() {
fakeDrafts.getIfNotEmpty.returns(fixtures.nonEmptyDraft); fakeStore.getDraftIfNotEmpty.returns(fixtures.nonEmptyDraft);
$rootScope.$broadcast( $rootScope.$broadcast(
events.BEFORE_ANNOTATION_CREATED, events.BEFORE_ANNOTATION_CREATED,
annotationFixtures.newAnnotation() annotationFixtures.newAnnotation()
); );
assert.notCalled(fakeDrafts.remove); assert.notCalled(fakeStore.removeDraft);
assert.notCalled(onDelete); assert.notCalled(onDelete);
}); });
...@@ -439,7 +434,7 @@ describe('rootThread', function() { ...@@ -439,7 +434,7 @@ describe('rootThread', function() {
annotationFixtures.newAnnotation() annotationFixtures.newAnnotation()
); );
assert.notCalled(fakeDrafts.remove); assert.notCalled(fakeStore.removeDraft);
assert.notCalled(onDelete); assert.notCalled(onDelete);
}); });
}); });
......
...@@ -4,6 +4,11 @@ ...@@ -4,6 +4,11 @@
* Unicode combining characters * Unicode combining characters
* from http://xregexp.com/addons/unicode/unicode-categories.js line:30 * from http://xregexp.com/addons/unicode/unicode-categories.js line:30
*/ */
// Lint warning suppressed to faciliate ESLint upgrade. The warning may still
// be valid.
//
// eslint-disable-next-line no-misleading-character-class
const COMBINING_MARKS = /[\u0300-\u036F\u0483-\u0489\u0591-\u05BD\u05BF\u05C1\u05C2\u05C4\u05C5\u05C7\u0610-\u061A\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06E4\u06E7\u06E8\u06EA-\u06ED\u0711\u0730-\u074A\u07A6-\u07B0\u07EB-\u07F3\u0816-\u0819\u081B-\u0823\u0825-\u0827\u0829-\u082D\u0859-\u085B\u08E4-\u08FE\u0900-\u0903\u093A-\u093C\u093E-\u094F\u0951-\u0957\u0962\u0963\u0981-\u0983\u09BC\u09BE-\u09C4\u09C7\u09C8\u09CB-\u09CD\u09D7\u09E2\u09E3\u0A01-\u0A03\u0A3C\u0A3E-\u0A42\u0A47\u0A48\u0A4B-\u0A4D\u0A51\u0A70\u0A71\u0A75\u0A81-\u0A83\u0ABC\u0ABE-\u0AC5\u0AC7-\u0AC9\u0ACB-\u0ACD\u0AE2\u0AE3\u0B01-\u0B03\u0B3C\u0B3E-\u0B44\u0B47\u0B48\u0B4B-\u0B4D\u0B56\u0B57\u0B62\u0B63\u0B82\u0BBE-\u0BC2\u0BC6-\u0BC8\u0BCA-\u0BCD\u0BD7\u0C01-\u0C03\u0C3E-\u0C44\u0C46-\u0C48\u0C4A-\u0C4D\u0C55\u0C56\u0C62\u0C63\u0C82\u0C83\u0CBC\u0CBE-\u0CC4\u0CC6-\u0CC8\u0CCA-\u0CCD\u0CD5\u0CD6\u0CE2\u0CE3\u0D02\u0D03\u0D3E-\u0D44\u0D46-\u0D48\u0D4A-\u0D4D\u0D57\u0D62\u0D63\u0D82\u0D83\u0DCA\u0DCF-\u0DD4\u0DD6\u0DD8-\u0DDF\u0DF2\u0DF3\u0E31\u0E34-\u0E3A\u0E47-\u0E4E\u0EB1\u0EB4-\u0EB9\u0EBB\u0EBC\u0EC8-\u0ECD\u0F18\u0F19\u0F35\u0F37\u0F39\u0F3E\u0F3F\u0F71-\u0F84\u0F86\u0F87\u0F8D-\u0F97\u0F99-\u0FBC\u0FC6\u102B-\u103E\u1056-\u1059\u105E-\u1060\u1062-\u1064\u1067-\u106D\u1071-\u1074\u1082-\u108D\u108F\u109A-\u109D\u135D-\u135F\u1712-\u1714\u1732-\u1734\u1752\u1753\u1772\u1773\u17B4-\u17D3\u17DD\u180B-\u180D\u18A9\u1920-\u192B\u1930-\u193B\u19B0-\u19C0\u19C8\u19C9\u1A17-\u1A1B\u1A55-\u1A5E\u1A60-\u1A7C\u1A7F\u1B00-\u1B04\u1B34-\u1B44\u1B6B-\u1B73\u1B80-\u1B82\u1BA1-\u1BAD\u1BE6-\u1BF3\u1C24-\u1C37\u1CD0-\u1CD2\u1CD4-\u1CE8\u1CED\u1CF2-\u1CF4\u1DC0-\u1DE6\u1DFC-\u1DFF\u20D0-\u20F0\u2CEF-\u2CF1\u2D7F\u2DE0-\u2DFF\u302A-\u302F\u3099\u309A\uA66F-\uA672\uA674-\uA67D\uA69F\uA6F0\uA6F1\uA802\uA806\uA80B\uA823-\uA827\uA880\uA881\uA8B4-\uA8C4\uA8E0-\uA8F1\uA926-\uA92D\uA947-\uA953\uA980-\uA983\uA9B3-\uA9C0\uAA29-\uAA36\uAA43\uAA4C\uAA4D\uAA7B\uAAB0\uAAB2-\uAAB4\uAAB7\uAAB8\uAABE\uAABF\uAAC1\uAAEB-\uAAEF\uAAF5\uAAF6\uABE3-\uABEA\uABEC\uABED\uFB1E\uFE00-\uFE0F\uFE20-\uFE26]/g; const COMBINING_MARKS = /[\u0300-\u036F\u0483-\u0489\u0591-\u05BD\u05BF\u05C1\u05C2\u05C4\u05C5\u05C7\u0610-\u061A\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06E4\u06E7\u06E8\u06EA-\u06ED\u0711\u0730-\u074A\u07A6-\u07B0\u07EB-\u07F3\u0816-\u0819\u081B-\u0823\u0825-\u0827\u0829-\u082D\u0859-\u085B\u08E4-\u08FE\u0900-\u0903\u093A-\u093C\u093E-\u094F\u0951-\u0957\u0962\u0963\u0981-\u0983\u09BC\u09BE-\u09C4\u09C7\u09C8\u09CB-\u09CD\u09D7\u09E2\u09E3\u0A01-\u0A03\u0A3C\u0A3E-\u0A42\u0A47\u0A48\u0A4B-\u0A4D\u0A51\u0A70\u0A71\u0A75\u0A81-\u0A83\u0ABC\u0ABE-\u0AC5\u0AC7-\u0AC9\u0ACB-\u0ACD\u0AE2\u0AE3\u0B01-\u0B03\u0B3C\u0B3E-\u0B44\u0B47\u0B48\u0B4B-\u0B4D\u0B56\u0B57\u0B62\u0B63\u0B82\u0BBE-\u0BC2\u0BC6-\u0BC8\u0BCA-\u0BCD\u0BD7\u0C01-\u0C03\u0C3E-\u0C44\u0C46-\u0C48\u0C4A-\u0C4D\u0C55\u0C56\u0C62\u0C63\u0C82\u0C83\u0CBC\u0CBE-\u0CC4\u0CC6-\u0CC8\u0CCA-\u0CCD\u0CD5\u0CD6\u0CE2\u0CE3\u0D02\u0D03\u0D3E-\u0D44\u0D46-\u0D48\u0D4A-\u0D4D\u0D57\u0D62\u0D63\u0D82\u0D83\u0DCA\u0DCF-\u0DD4\u0DD6\u0DD8-\u0DDF\u0DF2\u0DF3\u0E31\u0E34-\u0E3A\u0E47-\u0E4E\u0EB1\u0EB4-\u0EB9\u0EBB\u0EBC\u0EC8-\u0ECD\u0F18\u0F19\u0F35\u0F37\u0F39\u0F3E\u0F3F\u0F71-\u0F84\u0F86\u0F87\u0F8D-\u0F97\u0F99-\u0FBC\u0FC6\u102B-\u103E\u1056-\u1059\u105E-\u1060\u1062-\u1064\u1067-\u106D\u1071-\u1074\u1082-\u108D\u108F\u109A-\u109D\u135D-\u135F\u1712-\u1714\u1732-\u1734\u1752\u1753\u1772\u1773\u17B4-\u17D3\u17DD\u180B-\u180D\u18A9\u1920-\u192B\u1930-\u193B\u19B0-\u19C0\u19C8\u19C9\u1A17-\u1A1B\u1A55-\u1A5E\u1A60-\u1A7C\u1A7F\u1B00-\u1B04\u1B34-\u1B44\u1B6B-\u1B73\u1B80-\u1B82\u1BA1-\u1BAD\u1BE6-\u1BF3\u1C24-\u1C37\u1CD0-\u1CD2\u1CD4-\u1CE8\u1CED\u1CF2-\u1CF4\u1DC0-\u1DE6\u1DFC-\u1DFF\u20D0-\u20F0\u2CEF-\u2CF1\u2D7F\u2DE0-\u2DFF\u302A-\u302F\u3099\u309A\uA66F-\uA672\uA674-\uA67D\uA69F\uA6F0\uA6F1\uA802\uA806\uA80B\uA823-\uA827\uA880\uA881\uA8B4-\uA8C4\uA8E0-\uA8F1\uA926-\uA92D\uA947-\uA953\uA980-\uA983\uA9B3-\uA9C0\uAA29-\uAA36\uAA43\uAA4C\uAA4D\uAA7B\uAAB0\uAAB2-\uAAB4\uAAB7\uAAB8\uAABE\uAABF\uAAC1\uAAEB-\uAAEF\uAAF5\uAAF6\uABE3-\uABEA\uABEC\uABED\uFB1E\uFE00-\uFE0F\uFE20-\uFE26]/g;
// @ngInject // @ngInject
......
...@@ -37,6 +37,7 @@ const debugMiddleware = require('./debug-middleware'); ...@@ -37,6 +37,7 @@ const debugMiddleware = require('./debug-middleware');
const activity = require('./modules/activity'); const activity = require('./modules/activity');
const annotations = require('./modules/annotations'); const annotations = require('./modules/annotations');
const directLinked = require('./modules/direct-linked'); const directLinked = require('./modules/direct-linked');
const drafts = require('./modules/drafts');
const frames = require('./modules/frames'); const frames = require('./modules/frames');
const links = require('./modules/links'); const links = require('./modules/links');
const groups = require('./modules/groups'); const groups = require('./modules/groups');
...@@ -89,6 +90,7 @@ function store($rootScope, settings) { ...@@ -89,6 +90,7 @@ function store($rootScope, settings) {
activity, activity,
annotations, annotations,
directLinked, directLinked,
drafts,
frames, frames,
links, links,
groups, groups,
......
'use strict';
const util = require('../util');
/**
* The drafts store provides temporary storage for unsaved edits to new or
* existing annotations.
*/
function init() {
return {
drafts: [],
};
}
/**
* Helper class to encapsulate the draft properties and a few simple methods.
*
* A draft consists of:
*
* 1. `annotation` which is the original annotation object which the
* draft is associated with. If this is just a draft, then this may
* not have an id yet and instead, $tag is used.
*
* 2. `isPrivate` (boolean), `tags` (array of objects) and `text` (string)
* which are the user's draft changes to the annotation. These are returned
* from the drafts store selector by `drafts.getDraft()`.
*/
class Draft {
constructor(annotation, changes) {
this.annotation = { id: annotation.id, $tag: annotation.$tag };
this.isPrivate = changes.isPrivate;
this.tags = changes.tags;
this.text = changes.text;
}
/**
* Returns true if this draft matches a given annotation.
*
* Annotations are matched by ID or local tag.
*/
match(annotation) {
return (
(this.annotation.$tag && annotation.$tag === this.annotation.$tag) ||
(this.annotation.id && annotation.id === this.annotation.id)
);
}
/**
* Return true if this draft is empty and can be discarded without losing
* any user input.
*/
isEmpty() {
return !this.text && this.tags.length === 0;
}
}
/* Reducer */
const update = {
DISCARD_ALL_DRAFTS: function() {
return {
drafts: [],
};
},
REMOVE_DRAFT: function(state, action) {
const drafts = state.drafts.filter(draft => {
return !draft.match(action.annotation);
});
return {
drafts,
};
},
UPDATE_DRAFT: function(state, action) {
// removes a matching existing draft, then adds
const drafts = state.drafts.filter(draft => {
return !draft.match(action.draft.annotation);
});
drafts.push(action.draft); // push ok since its a copy
return {
drafts,
};
},
};
const actions = util.actionTypes(update);
/* Actions */
/**
* Create or update the draft version for a given annotation by
* replacing any existing draft or simply creating a new one.
*/
function createDraft(annotation, changes) {
return {
type: actions.UPDATE_DRAFT,
draft: new Draft(annotation, changes),
};
}
/** Remove all drafts. */
function discardAllDrafts() {
return {
type: actions.DISCARD_ALL_DRAFTS,
};
}
/** Remove the draft version of an annotation. */
function removeDraft(annotation) {
return {
type: actions.REMOVE_DRAFT,
annotation,
};
}
/* Selectors */
/**
* Returns the number of drafts - both unsaved new annotations, and unsaved
* edits to saved annotations - currently stored.
*
* @return {number}
*/
function countDrafts(state) {
return state.drafts.length;
}
/**
* Retrieve the draft changes for an annotation.
*
* @return {Draft|null}
*/
function getDraft(state, annotation) {
for (let i = 0; i < state.drafts.length; i++) {
const draft = state.drafts[i];
if (draft.match(annotation)) {
return draft;
}
}
return null;
}
/**
* Returns the draft changes for an annotation, or null if no draft exists
* or the draft is empty.
*
* @return {Draft|null}
*/
function getDraftIfNotEmpty(state, annotation) {
const draft = getDraft(state, annotation);
if (!draft) {
return null;
}
return draft.isEmpty() ? null : draft;
}
/**
* Returns a list of draft annotations which have no id.
*
* @return {Object[]}
*/
function unsavedAnnotations(state) {
return state.drafts
.filter(draft => !draft.annotation.id)
.map(draft => draft.annotation);
}
module.exports = {
init,
update,
actions: {
createDraft,
discardAllDrafts,
removeDraft,
},
selectors: {
countDrafts,
getDraft,
getDraftIfNotEmpty,
unsavedAnnotations,
},
Draft,
};
'use strict';
const immutable = require('seamless-immutable');
const drafts = require('../drafts');
const { Draft } = require('../drafts');
const createStore = require('../../create-store');
const fixtures = immutable({
draftWithText: {
isPrivate: false,
text: 'some text',
tags: [],
},
draftWithTags: {
isPrivate: false,
text: '',
tags: ['atag'],
},
emptyDraft: {
isPrivate: false,
text: '',
tags: [],
},
annotation: {
id: 'my_annotation',
$tag: 'my_annotation_tag',
},
});
describe('Drafts Store', () => {
let store;
beforeEach(() => {
store = createStore([drafts]);
});
describe('Draft', () => {
it('constructor', () => {
const draft = new Draft(fixtures.annotation, fixtures.draftWithText);
assert.deepEqual(draft, {
annotation: {
...fixtures.annotation,
},
...fixtures.draftWithText,
});
});
describe('#isEmpty', () => {
it('returns false if draft has tags or text', () => {
const draft = new Draft(fixtures.annotation, fixtures.draftWithText);
assert.isFalse(draft.isEmpty());
});
it('returns true if draft has no tags or text', () => {
const draft = new Draft(fixtures.annotation, fixtures.emptyDraft);
assert.isTrue(draft.isEmpty());
});
});
describe('#match', () => {
it('matches an annotation with the same tag or id', () => {
const draft = new Draft(fixtures.annotation, fixtures.draftWithText);
assert.isTrue(
draft.match({
id: fixtures.annotation.id,
})
);
assert.isTrue(
draft.match({
$tag: fixtures.annotation.$tag,
})
);
});
it('does not match an annotation with a different tag or id', () => {
const draft = new Draft(fixtures.annotation, fixtures.draftWithText);
assert.isFalse(
draft.match({
id: 'fake',
})
);
assert.isFalse(
draft.match({
$tag: 'fake',
})
);
});
});
});
describe('#getDraftIfNotEmpty', () => {
it('returns the draft if it has tags', () => {
store.createDraft(fixtures.annotation, fixtures.draftWithTags);
assert.deepEqual(
store.getDraftIfNotEmpty(fixtures.annotation).annotation,
fixtures.annotation
);
});
it('returns the draft if it has text', () => {
store.createDraft(fixtures.annotation, fixtures.draftWithText);
assert.deepEqual(
store.getDraftIfNotEmpty(fixtures.annotation).annotation,
fixtures.annotation
);
});
it('returns null if the text and tags are empty', () => {
store.createDraft(fixtures.annotation, fixtures.emptyDraft);
assert.isNull(store.getDraftIfNotEmpty(fixtures.annotation));
});
it('returns null if there is no matching draft', () => {
assert.isNull(store.getDraftIfNotEmpty('fake'));
});
});
describe('#createDraft', () => {
it('should save changes', () => {
assert.notOk(store.getDraft(fixtures.annotation));
store.createDraft(fixtures.annotation, fixtures.draftWithText);
assert.deepEqual(
store.getDraft(fixtures.annotation),
new Draft(fixtures.annotation, fixtures.draftWithText)
);
});
it('should replace existing drafts with the same ID', () => {
const fakeAnnotation = {
id: 'my_annotation',
};
const fakeDraft = {
isPrivate: true,
tags: ['foo'],
text: '',
};
store.createDraft(fakeAnnotation, {
...fakeDraft,
text: 'foo',
});
assert.equal(store.getDraft(fakeAnnotation).text, 'foo');
// now replace the draft
store.createDraft(fakeAnnotation, {
...fakeDraft,
text: 'bar',
});
assert.equal(store.getDraft(fakeAnnotation).text, 'bar');
});
it('should replace existing drafts with the same tag', () => {
const fakeAnnotation = {
$tag: 'my_annotation_tag',
};
const fakeDraft = {
isPrivate: true,
tags: ['foo'],
text: '',
};
store.createDraft(fakeAnnotation, {
...fakeDraft,
text: 'foo',
});
assert.equal(store.getDraft(fakeAnnotation).text, 'foo');
// now replace the draft
store.createDraft(fakeAnnotation, {
...fakeDraft,
text: 'bar',
});
assert.equal(store.getDraft(fakeAnnotation).text, 'bar');
});
});
describe('#countDrafts', () => {
it('should count drafts', () => {
assert.equal(store.countDrafts(), 0);
store.createDraft({ id: '1' }, fixtures.draftWithText);
assert.equal(store.countDrafts(), 1);
// since same id, this performs a replace, should still be 1 count
store.createDraft({ id: '1' }, fixtures.draftWithText);
assert.equal(store.countDrafts(), 1);
store.createDraft({ id: '2' }, fixtures.draftWithText);
assert.equal(store.countDrafts(), 2);
});
});
describe('#discardAllDrafts', () => {
it('should remove all drafts', () => {
store.createDraft({ id: '1' }, fixtures.draftWithText);
store.createDraft({ id: '2' }, fixtures.draftWithText);
store.discardAllDrafts(fixtures.annotation);
assert.equal(store.countDrafts(), 0);
});
});
describe('#removeDraft', () => {
it('should remove drafts', () => {
store.createDraft(fixtures.annotation, fixtures.draftWithText);
assert.isOk(store.getDraft(fixtures.annotation));
store.removeDraft(fixtures.annotation);
assert.isNotOk(store.getDraft(fixtures.annotation));
});
});
describe('#unsavedAnnotations', () => {
it('should return unsaved annotations which have drafts', () => {
const fakeAnnotation1 = {
$tag: 'local-tag1',
id: undefined,
};
const fakeAnnotation2 = {
$tag: 'local-tag2',
id: undefined,
};
store.createDraft(fakeAnnotation1, fixtures.draftWithText);
store.createDraft(fakeAnnotation2, fixtures.draftWithText);
assert.deepEqual(store.unsavedAnnotations(), [
fakeAnnotation1,
fakeAnnotation2,
]);
});
it('should not return saved annotations which have drafts', () => {
store.createDraft(fixtures.annotation, fixtures.draftWithText);
assert.deepEqual(store.unsavedAnnotations(), []);
});
});
});
<!-- message to display to loggedout users when they visit direct linked annotations -->
<li class="loggedout-message">
<span>
This is a public annotation created with Hypothesis.
<br>
To reply or make your own annotations on this document,
<a class="loggedout-message__link" href="{{vm.serviceUrl('signup')}}" target="_blank">create a free account</a>
or
<a class="loggedout-message__link" href="" ng-click="vm.onLogin()">log in</a>.
</span>
<span class="loggedout-message-logo">
<a href="https://hypothes.is">
<i class="h-icon-hypothesis-logo loggedout-message-logo__icon"></i>
</a>
</span>
</li>
...@@ -46,5 +46,5 @@ ...@@ -46,5 +46,5 @@
thread="vm.rootThread"> thread="vm.rootThread">
</thread-list> </thread-list>
<loggedout-message ng-if="vm.shouldShowLoggedOutMessage()" on-login="vm.onLogin()"> <logged-out-message ng-if="vm.shouldShowLoggedOutMessage()" on-login="vm.onLogin()">
</loggedout-message> </logged-out-message>
...@@ -54,7 +54,6 @@ describe('annotation threading', function() { ...@@ -54,7 +54,6 @@ describe('annotation threading', function() {
angular angular
.module('app', []) .module('app', [])
.service('store', require('../../store')) .service('store', require('../../store'))
.service('drafts', require('../../services/drafts'))
.service('rootThread', require('../../services/root-thread')) .service('rootThread', require('../../services/root-thread'))
.service('searchFilter', require('../../services/search-filter')) .service('searchFilter', require('../../services/search-filter'))
.service('viewFilter', require('../../services/view-filter')) .service('viewFilter', require('../../services/view-filter'))
......
.loggedout-message { .logged-out-message {
margin: 25px auto; margin: 25px auto;
width: 70%; width: 70%;
text-align: center; text-align: center;
...@@ -7,7 +7,7 @@ ...@@ -7,7 +7,7 @@
flex-direction: column; flex-direction: column;
} }
.loggedout-message__link { .logged-out-message__link {
text-decoration: underline; text-decoration: underline;
color: $grey-5; color: $grey-5;
...@@ -16,11 +16,13 @@ ...@@ -16,11 +16,13 @@
} }
} }
.loggedout-message-logo { .logged-out-message__logo {
margin-top: 25px; margin-top: 25px;
display: flex;
justify-content: center;
} }
.loggedout-message-logo__icon { .logged-out-message__logo-icon {
font-size: 30px; font-size: 30px;
color: $grey-4; color: $grey-4;
......
...@@ -30,7 +30,7 @@ $base-line-height: 20px; ...@@ -30,7 +30,7 @@ $base-line-height: 20px;
@import './components/group-list'; @import './components/group-list';
@import './components/group-list-item'; @import './components/group-list-item';
@import './components/help-panel'; @import './components/help-panel';
@import './components/loggedout-message'; @import './components/logged-out-message';
@import './components/markdown'; @import './components/markdown';
@import './components/menu'; @import './components/menu';
@import './components/menu-item'; @import './components/menu-item';
......
This diff is collapsed.
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