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 @@
"indent": "off",
"react/self-closing-comp": "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": {
"ecmaVersion": 2018,
......
......@@ -47,7 +47,7 @@
"enzyme-adapter-preact-pure": "^2.0.0",
"escape-html": "^1.0.3",
"escape-string-regexp": "^1.0.5",
"eslint": "^5.12.1",
"eslint": "^6.0.1",
"eslint-config-hypothesis": "^1.0.0",
"eslint-plugin-mocha": "^5.2.1",
"eslint-plugin-react": "^7.12.4",
......@@ -88,7 +88,7 @@
"npm-packlist": "^1.1.12",
"postcss": "^7.0.13",
"postcss-url": "^8.0.0",
"preact": "10.0.0-beta.3",
"preact": "10.0.0-rc.0",
"prettier": "1.18.2",
"query-string": "^3.0.1",
"raf": "^3.1.0",
......
......@@ -54,7 +54,6 @@ function AnnotationController(
annotationMapper,
api,
bridge,
drafts,
flash,
groups,
permissions,
......@@ -176,7 +175,7 @@ function AnnotationController(
// created by the annotate button) or it has edits not yet saved to the
// server - then open the editor on AnnotationController instantiation.
if (!newlyCreatedByHighlightButton) {
if (isNew(self.annotation) || drafts.get(self.annotation)) {
if (isNew(self.annotation) || store.getDraft(self.annotation)) {
self.edit();
}
}
......@@ -217,7 +216,7 @@ function AnnotationController(
});
} else {
// 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(
* @description Switches the view to an editor.
*/
this.edit = function() {
if (!drafts.get(self.annotation)) {
drafts.update(self.annotation, self.state());
if (!store.getDraft(self.annotation)) {
store.createDraft(self.annotation, self.state());
}
};
......@@ -304,7 +303,7 @@ function AnnotationController(
* (i.e. the annotation editor form should be open), `false` otherwise.
*/
this.editing = function() {
return drafts.get(self.annotation) && !self.isSaving;
return store.getDraft(self.annotation) && !self.isSaving;
};
/**
......@@ -430,7 +429,7 @@ function AnnotationController(
* @description Reverts an edit in progress and returns to the viewer.
*/
this.revert = function() {
drafts.remove(self.annotation);
store.removeDraft(self.annotation);
if (isNew(self.annotation)) {
$rootScope.$broadcast(events.ANNOTATION_DELETED, self.annotation);
}
......@@ -465,7 +464,7 @@ function AnnotationController(
const event = isNew(self.annotation)
? events.ANNOTATION_CREATED
: events.ANNOTATION_UPDATED;
drafts.remove(self.annotation);
store.removeDraft(self.annotation);
$rootScope.$broadcast(event, updatedModel);
})
......@@ -495,7 +494,7 @@ function AnnotationController(
if (!isReply(self.annotation)) {
permissions.setDefault(privacy);
}
drafts.update(self.annotation, {
store.createDraft(self.annotation, {
tags: self.state().tags,
text: self.state().text,
isPrivate: privacy === 'private',
......@@ -570,7 +569,7 @@ function AnnotationController(
};
this.setText = function(text) {
drafts.update(self.annotation, {
store.createDraft(self.annotation, {
isPrivate: self.state().isPrivate,
tags: self.state().tags,
text: text,
......@@ -578,7 +577,7 @@ function AnnotationController(
};
this.setTags = function(tags) {
drafts.update(self.annotation, {
store.createDraft(self.annotation, {
isPrivate: self.state().isPrivate,
tags: tags,
text: self.state().text,
......@@ -586,7 +585,7 @@ function AnnotationController(
};
this.state = function() {
const draft = drafts.get(self.annotation);
const draft = store.getDraft(self.annotation);
if (draft) {
return draft;
}
......
......@@ -43,7 +43,6 @@ function HypothesisAppController(
store,
auth,
bridge,
drafts,
features,
flash,
frameSync,
......@@ -149,18 +148,19 @@ function HypothesisAppController(
const promptToLogout = function() {
// TODO - Replace this with a UI which doesn't look terrible.
let text = '';
if (drafts.count() === 1) {
const drafts = store.countDrafts();
if (drafts === 1) {
text =
'You have an unsaved annotation.\n' +
'Do you really want to discard this draft?';
} else if (drafts.count() > 1) {
} else if (drafts > 1) {
text =
'You have ' +
drafts.count() +
drafts +
' unsaved annotations.\n' +
'Do you really want to discard these drafts?';
}
return drafts.count() === 0 || $window.confirm(text);
return drafts === 0 || $window.confirm(text);
};
// Log the user out.
......@@ -168,11 +168,12 @@ function HypothesisAppController(
if (!promptToLogout()) {
return;
}
store.clearGroups();
drafts.unsaved().forEach(function(draft) {
$rootScope.$emit(events.ANNOTATION_DELETED, draft);
store.unsavedAnnotations().forEach(function(annotation) {
$rootScope.$emit(events.ANNOTATION_DELETED, annotation);
});
drafts.discard();
store.discardAllDrafts();
if (serviceConfig(settings)) {
// Let the host page handle the signup request
......
'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';
const SearchClient = require('../search-client');
const events = require('../events');
const isThirdPartyService = require('../util/is-third-party-service');
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
function SidebarContentController(
$scope,
analytics,
annotations,
store,
annotationMapper,
api,
drafts,
features,
frameSync,
groups,
rootThread,
settings,
streamer,
streamFilter
streamer
) {
const self = this;
......@@ -91,61 +71,7 @@ function SidebarContentController(
}
}
const searchClients = [];
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() {
this.isLoading = () => {
if (
!store.frames().some(function(frame) {
return frame.uri;
......@@ -154,47 +80,9 @@ function SidebarContentController(
// The document's URL isn't known so the document must still be loading.
return true;
}
if (searchClients.length > 0) {
// We're still waiting for annotation search results from the API.
return true;
}
return false;
return store.isFetchingAnnotations();
};
/**
* 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() {
analytics.track(analytics.events.SIDEBAR_OPENED);
......@@ -234,27 +122,25 @@ function SidebarContentController(
// Re-fetch annotations when focused group, logged-in user or connected frames
// change.
$scope.$watch(
() => [groups.focused(), store.profile().userid, ...store.searchUris()],
([currentGroup], [prevGroup]) => {
if (!currentGroup) {
// When switching accounts, groups are cleared and so the focused group
() => [
store.focusedGroupId(),
store.profile().userid,
...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.
store.clearSelectedAnnotations();
return;
}
if (!prevGroup || currentGroup.id !== prevGroup.id) {
// 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;
}
if (!prevGroupId || currentGroupId !== prevGroupId) {
store.clearSelectedAnnotations();
}
loadAnnotations();
const searchUris = store.searchUris();
annotations.load(searchUris, currentGroupId);
},
true
);
......@@ -278,7 +164,7 @@ function SidebarContentController(
this.scrollTo = scrollToAnnotation;
this.selectedGroupUnavailable = function() {
return !this.isLoading() && store.getState().directLinkedGroupFetchFailed;
return store.getState().directLinkedGroupFetchFailed;
};
this.selectedAnnotationUnavailable = function() {
......@@ -311,7 +197,9 @@ function SidebarContentController(
// selection is available to the user, show the CTA.
const selectedID = store.getFirstSelectedAnnotationId();
return (
!this.isLoading() && !!selectedID && store.annotationExists(selectedID)
!store.isFetchingAnnotations() &&
!!selectedID &&
store.annotationExists(selectedID)
);
};
}
......
......@@ -16,6 +16,7 @@ const icons = {
help: require('../../images/icons/help.svg'),
leave: require('../../images/icons/leave.svg'),
lock: require('../../images/icons/lock.svg'),
logo: require('../../images/icons/logo.svg'),
public: require('../../images/icons/public.svg'),
refresh: require('../../images/icons/refresh.svg'),
share: require('../../images/icons/share.svg'),
......
......@@ -107,7 +107,6 @@ describe('annotation', function() {
let fakeAnalytics;
let fakeAnnotationMapper;
let fakeStore;
let fakeDrafts;
let fakeFlash;
let fakeGroups;
let fakePermissions;
......@@ -136,7 +135,7 @@ describe('annotation', function() {
// A new annotation won't have any saved drafts yet.
if (!annotation.id) {
fakeDrafts.get.returns(null);
fakeStore.getDraft.returns(null);
}
return {
......@@ -190,12 +189,13 @@ describe('annotation', function() {
fakeStore = {
hasPendingDeletion: sinon.stub(),
updateFlagStatus: sandbox.stub().returns(true),
};
fakeDrafts = {
update: sandbox.stub(),
remove: sandbox.stub(),
get: sandbox.stub().returns(null),
// draft store
countDrafts: sandbox.stub().returns(0),
createDraft: sandbox.stub(),
discardAllDrafts: sandbox.stub(),
getDraft: sandbox.stub().returns(null),
getDraftIfNotEmpty: sandbox.stub().returns(null),
removeDraft: sandbox.stub(),
};
fakeFlash = {
......@@ -258,7 +258,6 @@ describe('annotation', function() {
$provide.value('store', fakeStore);
$provide.value('api', fakeApi);
$provide.value('bridge', fakeBridge);
$provide.value('drafts', fakeDrafts);
$provide.value('flash', fakeFlash);
$provide.value('groups', fakeGroups);
$provide.value('permissions', fakePermissions);
......@@ -371,7 +370,7 @@ describe('annotation', function() {
createDirective(annotation);
assert.notCalled(fakeApi.annotation.create);
assert.called(fakeDrafts.update);
assert.called(fakeStore.createDraft);
});
it('opens the sidebar when trying to save highlights while logged out', () => {
......@@ -413,7 +412,7 @@ describe('annotation', function() {
it('creates drafts for new annotations on initialization', function() {
const annotation = fixtures.newAnnotation();
createDirective(annotation);
assert.calledWith(fakeDrafts.update, annotation, {
assert.calledWith(fakeStore.createDraft, annotation, {
isPrivate: false,
tags: annotation.tags,
text: annotation.text,
......@@ -425,13 +424,13 @@ describe('annotation', function() {
const controller = createDirective(annotation).controller;
assert.notOk(controller.editing());
assert.notCalled(fakeDrafts.update);
assert.notCalled(fakeStore.createDraft);
});
it('edits annotations with drafts on initialization', function() {
const annotation = fixtures.oldAnnotation();
// The drafts service has some draft changes for this annotation.
fakeDrafts.get.returns({ text: 'foo', tags: [] });
// The drafts store has some draft changes for this annotation.
fakeStore.getDraft.returns({ text: 'foo', tags: [] });
const controller = createDirective(annotation).controller;
......@@ -447,13 +446,13 @@ describe('annotation', function() {
it('returns true if the annotation has a draft', function() {
const controller = createDirective().controller;
fakeDrafts.get.returns({ tags: [], text: '', isPrivate: false });
fakeStore.getDraft.returns({ tags: [], text: '', isPrivate: false });
assert.isTrue(controller.editing());
});
it('returns false if the annotation has a draft but is being saved', function() {
const controller = createDirective().controller;
fakeDrafts.get.returns({ tags: [], text: '', isPrivate: false });
fakeStore.getDraft.returns({ tags: [], text: '', isPrivate: false });
controller.isSaving = true;
assert.isFalse(controller.editing());
});
......@@ -620,7 +619,7 @@ describe('annotation', function() {
const parts = createDirective();
parts.controller.setPrivacy('private');
assert.calledWith(
fakeDrafts.update,
fakeStore.createDraft,
parts.controller.annotation,
sinon.match({
isPrivate: true,
......@@ -632,7 +631,7 @@ describe('annotation', function() {
const parts = createDirective();
parts.controller.setPrivacy('shared');
assert.calledWith(
fakeDrafts.update,
fakeStore.createDraft,
parts.controller.annotation,
sinon.match({
isPrivate: false,
......@@ -914,7 +913,7 @@ describe('annotation', function() {
function(testCase) {
const ann = fixtures.publicAnnotation();
ann.group = testCase.group.id;
fakeDrafts.get.returns(testCase.draft);
fakeStore.getDraft.returns(testCase.draft);
fakeGroups.get.returns(testCase.group);
const controller = createDirective(ann).controller;
......@@ -992,7 +991,7 @@ describe('annotation', function() {
it('removes the draft when saving an annotation succeeds', function() {
const controller = createController();
return controller.save().then(function() {
assert.calledWith(fakeDrafts.remove, annotation);
assert.calledWith(fakeStore.removeDraft, annotation);
});
});
......@@ -1046,7 +1045,7 @@ describe('annotation', function() {
.stub()
.returns(Promise.reject({ status: -1 }));
return controller.save().then(function() {
assert.notCalled(fakeDrafts.remove);
assert.notCalled(fakeStore.removeDraft);
});
});
......@@ -1064,7 +1063,7 @@ describe('annotation', function() {
beforeEach(function() {
annotation = fixtures.defaultAnnotation();
fakeDrafts.get.returns({ text: 'unsaved change' });
fakeStore.getDraft.returns({ text: 'unsaved change' });
});
function createController() {
......@@ -1094,7 +1093,7 @@ describe('annotation', function() {
describe('drafts', function() {
it('starts editing immediately if there is a draft', function() {
fakeDrafts.get.returns({
fakeStore.getDraft.returns({
tags: ['unsaved'],
text: 'unsaved-text',
});
......@@ -1103,7 +1102,7 @@ describe('annotation', function() {
});
it('uses the text and tags from the draft if present', function() {
fakeDrafts.get.returns({
fakeStore.getDraft.returns({
tags: ['unsaved-tag'],
text: 'unsaved-text',
});
......@@ -1116,15 +1115,15 @@ describe('annotation', function() {
const parts = createDirective();
parts.controller.edit();
parts.controller.revert();
assert.calledWith(fakeDrafts.remove, parts.annotation);
assert.calledWith(fakeStore.removeDraft, parts.annotation);
});
it('removes the draft when changes are saved', function() {
const annotation = fixtures.defaultAnnotation();
const controller = createDirective(annotation).controller;
fakeDrafts.get.returns({ text: 'unsaved changes' });
fakeStore.getDraft.returns({ text: 'unsaved changes' });
return controller.save().then(function() {
assert.calledWith(fakeDrafts.remove, annotation);
assert.calledWith(fakeStore.removeDraft, annotation);
});
});
});
......@@ -1135,7 +1134,7 @@ describe('annotation', function() {
.controller;
controller.edit();
controller.revert();
assert.calledWith(fakeDrafts.remove, controller.annotation);
assert.calledWith(fakeStore.removeDraft, controller.annotation);
});
it('deletes the annotation if it was new', function() {
......
......@@ -15,7 +15,6 @@ describe('sidebar.components.hypothesis-app', function() {
let fakeAnalytics = null;
let fakeAuth = null;
let fakeBridge = null;
let fakeDrafts = null;
let fakeFeatures = null;
let fakeFlash = null;
let fakeFrameSync = null;
......@@ -63,6 +62,10 @@ describe('sidebar.components.hypothesis-app', function() {
clearSelectedAnnotations: sandbox.spy(),
getState: sinon.stub(),
clearGroups: sinon.stub(),
// draft store
countDrafts: sandbox.stub().returns(0),
discardAllDrafts: sandbox.stub(),
unsavedAnnotations: sandbox.stub().returns([]),
};
fakeAnalytics = {
......@@ -72,15 +75,6 @@ describe('sidebar.components.hypothesis-app', function() {
fakeAuth = {};
fakeDrafts = {
contains: sandbox.stub(),
remove: sandbox.spy(),
all: sandbox.stub().returns([]),
discard: sandbox.spy(),
count: sandbox.stub().returns(0),
unsaved: sandbox.stub().returns([]),
};
fakeFeatures = {
fetch: sandbox.spy(),
flagEnabled: sandbox.stub().returns(false),
......@@ -123,7 +117,6 @@ describe('sidebar.components.hypothesis-app', function() {
$provide.value('store', fakeStore);
$provide.value('auth', fakeAuth);
$provide.value('analytics', fakeAnalytics);
$provide.value('drafts', fakeDrafts);
$provide.value('features', fakeFeatures);
$provide.value('flash', fakeFlash);
$provide.value('frameSync', fakeFrameSync);
......@@ -428,7 +421,7 @@ describe('sidebar.components.hypothesis-app', function() {
// Tests shared by both of the contexts below.
function doSharedTests() {
it('prompts the user if there are drafts', function() {
fakeDrafts.count.returns(1);
fakeStore.countDrafts.returns(1);
const ctrl = createController();
ctrl.logout();
......@@ -445,7 +438,7 @@ describe('sidebar.components.hypothesis-app', function() {
});
it('emits "annotationDeleted" for each unsaved draft annotation', function() {
fakeDrafts.unsaved = sandbox
fakeStore.unsavedAnnotations = sandbox
.stub()
.returns(['draftOne', 'draftTwo', 'draftThree']);
const ctrl = createController();
......@@ -473,12 +466,12 @@ describe('sidebar.components.hypothesis-app', function() {
ctrl.logout();
assert(fakeDrafts.discard.calledOnce);
assert(fakeStore.discardAllDrafts.calledOnce);
});
it('does not emit "annotationDeleted" if the user cancels the prompt', function() {
const ctrl = createController();
fakeDrafts.count.returns(1);
fakeStore.countDrafts.returns(1);
$rootScope.$emit = sandbox.stub();
fakeWindow.confirm.returns(false);
......@@ -489,17 +482,17 @@ describe('sidebar.components.hypothesis-app', function() {
it('does not discard drafts if the user cancels the prompt', function() {
const ctrl = createController();
fakeDrafts.count.returns(1);
fakeStore.countDrafts.returns(1);
fakeWindow.confirm.returns(false);
ctrl.logout();
assert(fakeDrafts.discard.notCalled);
assert(fakeStore.discardAllDrafts.notCalled);
});
it('does not prompt if there are no drafts', function() {
const ctrl = createController();
fakeDrafts.count.returns(0);
fakeStore.countDrafts.returns(0);
ctrl.logout();
......@@ -535,7 +528,7 @@ describe('sidebar.components.hypothesis-app', function() {
});
it('does not send LOGOUT_REQUESTED if the user cancels the prompt', function() {
fakeDrafts.count.returns(1);
fakeStore.countDrafts.returns(1);
fakeWindow.confirm.returns(false);
createController().logout();
......
'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) {
wrapReactComponent(require('./components/help-link'))
)
.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(
'moderationBanner',
......@@ -204,11 +207,11 @@ function startAngularApp(config) {
.service('analytics', require('./services/analytics'))
.service('annotationMapper', require('./services/annotation-mapper'))
.service('annotations', require('./services/annotations'))
.service('api', require('./services/api'))
.service('apiRoutes', require('./services/api-routes'))
.service('auth', require('./services/oauth-auth'))
.service('bridge', require('../shared/bridge'))
.service('drafts', require('./services/drafts'))
.service('features', require('./services/features'))
.service('flash', require('./services/flash'))
.service('frameSync', require('./services/frame-sync').default)
......
'use strict';
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(
oauthClient(),
]);
await client.revokeToken(token.accessToken);
// eslint-disable-next-line require-atomic-updates
tokenInfoPromise = Promise.resolve(null);
localStorage.removeItem(storageKey());
}
......
......@@ -40,7 +40,7 @@ const sortFns = {
* The root thread is then displayed by viewer.html
*/
// @ngInject
function RootThread($rootScope, store, drafts, searchFilter, viewFilter) {
function RootThread($rootScope, store, searchFilter, viewFilter) {
/**
* Build the root conversation thread from the given UI state.
*
......@@ -86,10 +86,10 @@ function RootThread($rootScope, store, drafts, searchFilter, viewFilter) {
store
.getState()
.annotations.filter(function(ann) {
return metadata.isNew(ann) && !drafts.getIfNotEmpty(ann);
return metadata.isNew(ann) && !store.getDraftIfNotEmpty(ann);
})
.forEach(function(ann) {
drafts.remove(ann);
store.removeDraft(ann);
$rootScope.$broadcast(events.ANNOTATION_DELETED, ann);
});
}
......
'use strict';
const 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({
describe('rootThread', function() {
let fakeStore;
let fakeBuildThread;
let fakeDrafts;
let fakeSearchFilter;
let fakeViewFilter;
......@@ -62,15 +61,12 @@ describe('rootThread', function() {
addAnnotations: sinon.stub(),
setCollapsed: sinon.stub(),
selectTab: sinon.stub(),
getDraftIfNotEmpty: sinon.stub().returns(null),
removeDraft: sinon.stub(),
};
fakeBuildThread = sinon.stub().returns(fixtures.emptyThread);
fakeDrafts = {
getIfNotEmpty: sinon.stub().returns(null),
remove: sinon.stub(),
};
fakeSearchFilter = {
generateFacetedFilter: sinon.stub(),
};
......@@ -82,7 +78,6 @@ describe('rootThread', function() {
angular
.module('app', [])
.value('store', fakeStore)
.value('drafts', fakeDrafts)
.value('searchFilter', fakeSearchFilter)
.value('viewFilter', fakeViewFilter)
.service('rootThread', rootThreadFactory);
......@@ -401,16 +396,16 @@ describe('rootThread', function() {
});
it('removes drafts for new and empty annotations', function() {
fakeDrafts.getIfNotEmpty.returns(null);
fakeStore.getDraftIfNotEmpty.returns(null);
const annotation = annotationFixtures.newEmptyAnnotation();
$rootScope.$broadcast(events.BEFORE_ANNOTATION_CREATED, annotation);
assert.calledWith(fakeDrafts.remove, existingNewAnnot);
assert.calledWith(fakeStore.removeDraft, existingNewAnnot);
});
it('deletes new and empty annotations', function() {
fakeDrafts.getIfNotEmpty.returns(null);
fakeStore.getDraftIfNotEmpty.returns(null);
const annotation = annotationFixtures.newEmptyAnnotation();
$rootScope.$broadcast(events.BEFORE_ANNOTATION_CREATED, annotation);
......@@ -419,14 +414,14 @@ describe('rootThread', function() {
});
it('does not remove annotations that have non-empty drafts', function() {
fakeDrafts.getIfNotEmpty.returns(fixtures.nonEmptyDraft);
fakeStore.getDraftIfNotEmpty.returns(fixtures.nonEmptyDraft);
$rootScope.$broadcast(
events.BEFORE_ANNOTATION_CREATED,
annotationFixtures.newAnnotation()
);
assert.notCalled(fakeDrafts.remove);
assert.notCalled(fakeStore.removeDraft);
assert.notCalled(onDelete);
});
......@@ -439,7 +434,7 @@ describe('rootThread', function() {
annotationFixtures.newAnnotation()
);
assert.notCalled(fakeDrafts.remove);
assert.notCalled(fakeStore.removeDraft);
assert.notCalled(onDelete);
});
});
......
......@@ -4,6 +4,11 @@
* Unicode combining characters
* 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;
// @ngInject
......
......@@ -37,6 +37,7 @@ const debugMiddleware = require('./debug-middleware');
const activity = require('./modules/activity');
const annotations = require('./modules/annotations');
const directLinked = require('./modules/direct-linked');
const drafts = require('./modules/drafts');
const frames = require('./modules/frames');
const links = require('./modules/links');
const groups = require('./modules/groups');
......@@ -89,6 +90,7 @@ function store($rootScope, settings) {
activity,
annotations,
directLinked,
drafts,
frames,
links,
groups,
......
'use strict';
const util = require('../util');
/**
* The drafts store provides temporary storage for unsaved edits to new or
* existing annotations.
*/
function init() {
return {
drafts: [],
};
}
/**
* Helper class to encapsulate the draft properties and a few simple methods.
*
* A draft consists of:
*
* 1. `annotation` which is the original annotation object which the
* draft is associated with. If this is just a draft, then this may
* not have an id yet and instead, $tag is used.
*
* 2. `isPrivate` (boolean), `tags` (array of objects) and `text` (string)
* which are the user's draft changes to the annotation. These are returned
* from the drafts store selector by `drafts.getDraft()`.
*/
class Draft {
constructor(annotation, changes) {
this.annotation = { id: annotation.id, $tag: annotation.$tag };
this.isPrivate = changes.isPrivate;
this.tags = changes.tags;
this.text = changes.text;
}
/**
* Returns true if this draft matches a given annotation.
*
* Annotations are matched by ID or local tag.
*/
match(annotation) {
return (
(this.annotation.$tag && annotation.$tag === this.annotation.$tag) ||
(this.annotation.id && annotation.id === this.annotation.id)
);
}
/**
* Return true if this draft is empty and can be discarded without losing
* any user input.
*/
isEmpty() {
return !this.text && this.tags.length === 0;
}
}
/* Reducer */
const update = {
DISCARD_ALL_DRAFTS: function() {
return {
drafts: [],
};
},
REMOVE_DRAFT: function(state, action) {
const drafts = state.drafts.filter(draft => {
return !draft.match(action.annotation);
});
return {
drafts,
};
},
UPDATE_DRAFT: function(state, action) {
// removes a matching existing draft, then adds
const drafts = state.drafts.filter(draft => {
return !draft.match(action.draft.annotation);
});
drafts.push(action.draft); // push ok since its a copy
return {
drafts,
};
},
};
const actions = util.actionTypes(update);
/* Actions */
/**
* Create or update the draft version for a given annotation by
* replacing any existing draft or simply creating a new one.
*/
function createDraft(annotation, changes) {
return {
type: actions.UPDATE_DRAFT,
draft: new Draft(annotation, changes),
};
}
/** Remove all drafts. */
function discardAllDrafts() {
return {
type: actions.DISCARD_ALL_DRAFTS,
};
}
/** Remove the draft version of an annotation. */
function removeDraft(annotation) {
return {
type: actions.REMOVE_DRAFT,
annotation,
};
}
/* Selectors */
/**
* Returns the number of drafts - both unsaved new annotations, and unsaved
* edits to saved annotations - currently stored.
*
* @return {number}
*/
function countDrafts(state) {
return state.drafts.length;
}
/**
* Retrieve the draft changes for an annotation.
*
* @return {Draft|null}
*/
function getDraft(state, annotation) {
for (let i = 0; i < state.drafts.length; i++) {
const draft = state.drafts[i];
if (draft.match(annotation)) {
return draft;
}
}
return null;
}
/**
* Returns the draft changes for an annotation, or null if no draft exists
* or the draft is empty.
*
* @return {Draft|null}
*/
function getDraftIfNotEmpty(state, annotation) {
const draft = getDraft(state, annotation);
if (!draft) {
return null;
}
return draft.isEmpty() ? null : draft;
}
/**
* Returns a list of draft annotations which have no id.
*
* @return {Object[]}
*/
function unsavedAnnotations(state) {
return state.drafts
.filter(draft => !draft.annotation.id)
.map(draft => draft.annotation);
}
module.exports = {
init,
update,
actions: {
createDraft,
discardAllDrafts,
removeDraft,
},
selectors: {
countDrafts,
getDraft,
getDraftIfNotEmpty,
unsavedAnnotations,
},
Draft,
};
'use strict';
const immutable = require('seamless-immutable');
const drafts = require('../drafts');
const { Draft } = require('../drafts');
const createStore = require('../../create-store');
const fixtures = immutable({
draftWithText: {
isPrivate: false,
text: 'some text',
tags: [],
},
draftWithTags: {
isPrivate: false,
text: '',
tags: ['atag'],
},
emptyDraft: {
isPrivate: false,
text: '',
tags: [],
},
annotation: {
id: 'my_annotation',
$tag: 'my_annotation_tag',
},
});
describe('Drafts Store', () => {
let store;
beforeEach(() => {
store = createStore([drafts]);
});
describe('Draft', () => {
it('constructor', () => {
const draft = new Draft(fixtures.annotation, fixtures.draftWithText);
assert.deepEqual(draft, {
annotation: {
...fixtures.annotation,
},
...fixtures.draftWithText,
});
});
describe('#isEmpty', () => {
it('returns false if draft has tags or text', () => {
const draft = new Draft(fixtures.annotation, fixtures.draftWithText);
assert.isFalse(draft.isEmpty());
});
it('returns true if draft has no tags or text', () => {
const draft = new Draft(fixtures.annotation, fixtures.emptyDraft);
assert.isTrue(draft.isEmpty());
});
});
describe('#match', () => {
it('matches an annotation with the same tag or id', () => {
const draft = new Draft(fixtures.annotation, fixtures.draftWithText);
assert.isTrue(
draft.match({
id: fixtures.annotation.id,
})
);
assert.isTrue(
draft.match({
$tag: fixtures.annotation.$tag,
})
);
});
it('does not match an annotation with a different tag or id', () => {
const draft = new Draft(fixtures.annotation, fixtures.draftWithText);
assert.isFalse(
draft.match({
id: 'fake',
})
);
assert.isFalse(
draft.match({
$tag: 'fake',
})
);
});
});
});
describe('#getDraftIfNotEmpty', () => {
it('returns the draft if it has tags', () => {
store.createDraft(fixtures.annotation, fixtures.draftWithTags);
assert.deepEqual(
store.getDraftIfNotEmpty(fixtures.annotation).annotation,
fixtures.annotation
);
});
it('returns the draft if it has text', () => {
store.createDraft(fixtures.annotation, fixtures.draftWithText);
assert.deepEqual(
store.getDraftIfNotEmpty(fixtures.annotation).annotation,
fixtures.annotation
);
});
it('returns null if the text and tags are empty', () => {
store.createDraft(fixtures.annotation, fixtures.emptyDraft);
assert.isNull(store.getDraftIfNotEmpty(fixtures.annotation));
});
it('returns null if there is no matching draft', () => {
assert.isNull(store.getDraftIfNotEmpty('fake'));
});
});
describe('#createDraft', () => {
it('should save changes', () => {
assert.notOk(store.getDraft(fixtures.annotation));
store.createDraft(fixtures.annotation, fixtures.draftWithText);
assert.deepEqual(
store.getDraft(fixtures.annotation),
new Draft(fixtures.annotation, fixtures.draftWithText)
);
});
it('should replace existing drafts with the same ID', () => {
const fakeAnnotation = {
id: 'my_annotation',
};
const fakeDraft = {
isPrivate: true,
tags: ['foo'],
text: '',
};
store.createDraft(fakeAnnotation, {
...fakeDraft,
text: 'foo',
});
assert.equal(store.getDraft(fakeAnnotation).text, 'foo');
// now replace the draft
store.createDraft(fakeAnnotation, {
...fakeDraft,
text: 'bar',
});
assert.equal(store.getDraft(fakeAnnotation).text, 'bar');
});
it('should replace existing drafts with the same tag', () => {
const fakeAnnotation = {
$tag: 'my_annotation_tag',
};
const fakeDraft = {
isPrivate: true,
tags: ['foo'],
text: '',
};
store.createDraft(fakeAnnotation, {
...fakeDraft,
text: 'foo',
});
assert.equal(store.getDraft(fakeAnnotation).text, 'foo');
// now replace the draft
store.createDraft(fakeAnnotation, {
...fakeDraft,
text: 'bar',
});
assert.equal(store.getDraft(fakeAnnotation).text, 'bar');
});
});
describe('#countDrafts', () => {
it('should count drafts', () => {
assert.equal(store.countDrafts(), 0);
store.createDraft({ id: '1' }, fixtures.draftWithText);
assert.equal(store.countDrafts(), 1);
// since same id, this performs a replace, should still be 1 count
store.createDraft({ id: '1' }, fixtures.draftWithText);
assert.equal(store.countDrafts(), 1);
store.createDraft({ id: '2' }, fixtures.draftWithText);
assert.equal(store.countDrafts(), 2);
});
});
describe('#discardAllDrafts', () => {
it('should remove all drafts', () => {
store.createDraft({ id: '1' }, fixtures.draftWithText);
store.createDraft({ id: '2' }, fixtures.draftWithText);
store.discardAllDrafts(fixtures.annotation);
assert.equal(store.countDrafts(), 0);
});
});
describe('#removeDraft', () => {
it('should remove drafts', () => {
store.createDraft(fixtures.annotation, fixtures.draftWithText);
assert.isOk(store.getDraft(fixtures.annotation));
store.removeDraft(fixtures.annotation);
assert.isNotOk(store.getDraft(fixtures.annotation));
});
});
describe('#unsavedAnnotations', () => {
it('should return unsaved annotations which have drafts', () => {
const fakeAnnotation1 = {
$tag: 'local-tag1',
id: undefined,
};
const fakeAnnotation2 = {
$tag: 'local-tag2',
id: undefined,
};
store.createDraft(fakeAnnotation1, fixtures.draftWithText);
store.createDraft(fakeAnnotation2, fixtures.draftWithText);
assert.deepEqual(store.unsavedAnnotations(), [
fakeAnnotation1,
fakeAnnotation2,
]);
});
it('should not return saved annotations which have drafts', () => {
store.createDraft(fixtures.annotation, fixtures.draftWithText);
assert.deepEqual(store.unsavedAnnotations(), []);
});
});
});
<!-- 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 @@
thread="vm.rootThread">
</thread-list>
<loggedout-message ng-if="vm.shouldShowLoggedOutMessage()" on-login="vm.onLogin()">
</loggedout-message>
<logged-out-message ng-if="vm.shouldShowLoggedOutMessage()" on-login="vm.onLogin()">
</logged-out-message>
......@@ -54,7 +54,6 @@ describe('annotation threading', function() {
angular
.module('app', [])
.service('store', require('../../store'))
.service('drafts', require('../../services/drafts'))
.service('rootThread', require('../../services/root-thread'))
.service('searchFilter', require('../../services/search-filter'))
.service('viewFilter', require('../../services/view-filter'))
......
.loggedout-message {
.logged-out-message {
margin: 25px auto;
width: 70%;
text-align: center;
......@@ -7,7 +7,7 @@
flex-direction: column;
}
.loggedout-message__link {
.logged-out-message__link {
text-decoration: underline;
color: $grey-5;
......@@ -16,11 +16,13 @@
}
}
.loggedout-message-logo {
.logged-out-message__logo {
margin-top: 25px;
display: flex;
justify-content: center;
}
.loggedout-message-logo__icon {
.logged-out-message__logo-icon {
font-size: 30px;
color: $grey-4;
......
......@@ -30,7 +30,7 @@ $base-line-height: 20px;
@import './components/group-list';
@import './components/group-list-item';
@import './components/help-panel';
@import './components/loggedout-message';
@import './components/logged-out-message';
@import './components/markdown';
@import './components/menu';
@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